Devaholic commited on
Commit
bccb9df
·
1 Parent(s): 2addc01

feat: add TMDB integration and movie endpoints

Browse files
Files changed (5) hide show
  1. .gitignore +2 -0
  2. main.py +111 -1
  3. pyproject.toml +2 -0
  4. tmdb.py +75 -0
  5. uv.lock +4 -0
.gitignore CHANGED
@@ -12,3 +12,5 @@ wheels/
12
  results/
13
 
14
  __huggingface_repos__.json
 
 
 
12
  results/
13
 
14
  __huggingface_repos__.json
15
+
16
+ .env
main.py CHANGED
@@ -1,10 +1,17 @@
1
  import time
2
  from contextlib import asynccontextmanager
3
 
4
- from fastapi import FastAPI, HTTPException
 
 
 
5
  from pydantic import BaseModel, Field
6
  from transformers import pipeline
7
 
 
 
 
 
8
  MODEL_PATH = "./results/best_model"
9
 
10
  ml: dict = {}
@@ -32,6 +39,14 @@ app = FastAPI(
32
  lifespan=lifespan,
33
  )
34
 
 
 
 
 
 
 
 
 
35
 
36
  class PredictRequest(BaseModel):
37
  text: str = Field(
@@ -63,6 +78,35 @@ class BatchResponse(BaseModel):
63
  total_latency_ms: float
64
 
65
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  @app.get("/", tags=["health"])
67
  def health():
68
  return {"status": "ok", "model": MODEL_PATH}
@@ -105,3 +149,69 @@ def predict_batch(req: BatchRequest):
105
  ]
106
 
107
  return BatchResponse(results=results, total_latency_ms=round(total_latency, 2))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import time
2
  from contextlib import asynccontextmanager
3
 
4
+ import httpx
5
+ from dotenv import load_dotenv
6
+ from fastapi import FastAPI, HTTPException, Query
7
+ from fastapi.middleware.cors import CORSMiddleware
8
  from pydantic import BaseModel, Field
9
  from transformers import pipeline
10
 
11
+ from tmdb import fetch_reviews, search_movies
12
+
13
+ load_dotenv()
14
+
15
  MODEL_PATH = "./results/best_model"
16
 
17
  ml: dict = {}
 
39
  lifespan=lifespan,
40
  )
41
 
42
+ app.add_middleware(
43
+ CORSMiddleware,
44
+ allow_origins=["http://localhost:5173"],
45
+ allow_credentials=True,
46
+ allow_methods=["*"],
47
+ allow_headers=["*"],
48
+ )
49
+
50
 
51
  class PredictRequest(BaseModel):
52
  text: str = Field(
 
78
  total_latency_ms: float
79
 
80
 
81
+ class MovieResult(BaseModel):
82
+ id: int
83
+ title: str
84
+ release_year: int | None
85
+ poster_url: str | None
86
+ tmdb_rating: float | None
87
+
88
+
89
+ class MovieSearchResponse(BaseModel):
90
+ results: list[MovieResult]
91
+
92
+
93
+ class ReviewResult(BaseModel):
94
+ id: str
95
+ author: str
96
+ content: str
97
+ author_rating: float | None
98
+ created_at: str
99
+ sentiment_label: str
100
+ sentiment_score: float
101
+
102
+
103
+ class MovieReviewsResponse(BaseModel):
104
+ movie_id: int
105
+ title: str
106
+ review_count: int
107
+ reviews: list[ReviewResult]
108
+
109
+
110
  @app.get("/", tags=["health"])
111
  def health():
112
  return {"status": "ok", "model": MODEL_PATH}
 
149
  ]
150
 
151
  return BatchResponse(results=results, total_latency_ms=round(total_latency, 2))
152
+
153
+
154
+ @app.get("/movies/search", response_model=MovieSearchResponse, tags=["movies"])
155
+ async def movies_search(q: str = Query(..., min_length=1)):
156
+ try:
157
+ movies = await search_movies(q)
158
+ except httpx.HTTPStatusError as exc:
159
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
160
+ except httpx.TimeoutException as exc:
161
+ raise HTTPException(status_code=504, detail="TMDB request timed out") from exc
162
+ except ValueError as exc:
163
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
164
+
165
+ return MovieSearchResponse(results=[MovieResult(**m) for m in movies])
166
+
167
+
168
+ @app.get(
169
+ "/movies/{movie_id}/reviews", response_model=MovieReviewsResponse, tags=["movies"]
170
+ )
171
+ async def movies_reviews(movie_id: int):
172
+ if "pipe" not in ml:
173
+ raise HTTPException(status_code=503, detail="Model not loaded")
174
+
175
+ try:
176
+ title, reviews = await fetch_reviews(movie_id)
177
+ except httpx.HTTPStatusError as exc:
178
+ raise HTTPException(status_code=502, detail=str(exc)) from exc
179
+ except httpx.TimeoutException as exc:
180
+ raise HTTPException(status_code=504, detail="TMDB request timed out") from exc
181
+ except ValueError as exc:
182
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
183
+
184
+ if not reviews:
185
+ return MovieReviewsResponse(
186
+ movie_id=movie_id, title=title, review_count=0, reviews=[]
187
+ )
188
+
189
+ # Truncate to 512 chars for inference, keep full content for response
190
+ truncated = [r["content"][:512] for r in reviews]
191
+
192
+ # Run inference in chunks of 32
193
+ chunk_size = 32
194
+ predictions: list[dict] = []
195
+ for i in range(0, len(truncated), chunk_size):
196
+ chunk = truncated[i : i + chunk_size]
197
+ predictions.extend(ml["pipe"](chunk))
198
+
199
+ review_results = [
200
+ ReviewResult(
201
+ id=review["id"],
202
+ author=review["author"],
203
+ content=review["content"],
204
+ author_rating=review["author_rating"],
205
+ created_at=review["created_at"],
206
+ sentiment_label=pred["label"],
207
+ sentiment_score=round(pred["score"], 4),
208
+ )
209
+ for review, pred in zip(reviews, predictions)
210
+ ]
211
+
212
+ return MovieReviewsResponse(
213
+ movie_id=movie_id,
214
+ title=title,
215
+ review_count=len(review_results),
216
+ reviews=review_results,
217
+ )
pyproject.toml CHANGED
@@ -12,6 +12,8 @@ dependencies = [
12
  "scikit-learn>=1.4.0",
13
  "fastapi>=0.111.0",
14
  "uvicorn[standard]>=0.29.0",
 
 
15
  ]
16
 
17
  [dependency-groups]
 
12
  "scikit-learn>=1.4.0",
13
  "fastapi>=0.111.0",
14
  "uvicorn[standard]>=0.29.0",
15
+ "httpx>=0.27.0",
16
+ "python-dotenv>=1.0.0",
17
  ]
18
 
19
  [dependency-groups]
tmdb.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ import httpx
4
+
5
+ TMDB_BASE_URL = "https://api.themoviedb.org/3"
6
+ TMDB_IMAGE_BASE = "https://image.tmdb.org/t/p/w200"
7
+ MAX_REVIEW_PAGES = 5
8
+
9
+
10
+ def _get_auth_headers() -> dict:
11
+ token = os.environ.get("TMDB_API_KEY")
12
+ if not token:
13
+ raise ValueError("TMDB_API_KEY environment variable is not set")
14
+ return {"Authorization": f"Bearer {token}", "accept": "application/json"}
15
+
16
+
17
+ async def search_movies(query: str) -> list[dict]:
18
+ headers = _get_auth_headers()
19
+ async with httpx.AsyncClient(timeout=10, headers=headers) as client:
20
+ response = await client.get(
21
+ f"{TMDB_BASE_URL}/search/movie",
22
+ params={"query": query, "language": "en-US", "page": 1},
23
+ )
24
+ response.raise_for_status()
25
+ data = response.json()
26
+
27
+ results = []
28
+ for movie in data.get("results", []):
29
+ release_date = movie.get("release_date") or ""
30
+ release_year = int(release_date[:4]) if len(release_date) >= 4 else None
31
+ poster_path = movie.get("poster_path")
32
+ poster_url = f"{TMDB_IMAGE_BASE}{poster_path}" if poster_path else None
33
+ results.append(
34
+ {
35
+ "id": movie["id"],
36
+ "title": movie["title"],
37
+ "release_year": release_year,
38
+ "poster_url": poster_url,
39
+ "tmdb_rating": movie.get("vote_average"),
40
+ }
41
+ )
42
+ return results
43
+
44
+
45
+ async def fetch_reviews(movie_id: int) -> tuple[str, list[dict]]:
46
+ headers = _get_auth_headers()
47
+ async with httpx.AsyncClient(timeout=10, headers=headers) as client:
48
+ movie_resp = await client.get(f"{TMDB_BASE_URL}/movie/{movie_id}")
49
+ movie_resp.raise_for_status()
50
+ title = movie_resp.json().get("title", "")
51
+
52
+ reviews: list[dict] = []
53
+ for page in range(1, MAX_REVIEW_PAGES + 1):
54
+ rev_resp = await client.get(
55
+ f"{TMDB_BASE_URL}/movie/{movie_id}/reviews",
56
+ params={"page": page},
57
+ )
58
+ rev_resp.raise_for_status()
59
+ rev_data = rev_resp.json()
60
+ page_results = rev_data.get("results", [])
61
+ for r in page_results:
62
+ author_details = r.get("author_details") or {}
63
+ reviews.append(
64
+ {
65
+ "id": r["id"],
66
+ "author": r.get("author", ""),
67
+ "content": r.get("content", ""),
68
+ "author_rating": author_details.get("rating"),
69
+ "created_at": r.get("created_at", ""),
70
+ }
71
+ )
72
+ if len(page_results) == 0 or rev_data.get("page", 1) >= rev_data.get("total_pages", 1):
73
+ break
74
+
75
+ return title, reviews
uv.lock CHANGED
@@ -775,6 +775,8 @@ dependencies = [
775
  { name = "datasets" },
776
  { name = "evaluate" },
777
  { name = "fastapi" },
 
 
778
  { name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
779
  { name = "scikit-learn", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
780
  { name = "torch" },
@@ -793,6 +795,8 @@ requires-dist = [
793
  { name = "datasets", specifier = ">=2.19.0" },
794
  { name = "evaluate", specifier = ">=0.4.1" },
795
  { name = "fastapi", specifier = ">=0.111.0" },
 
 
796
  { name = "scikit-learn", specifier = ">=1.4.0" },
797
  { name = "torch", specifier = ">=2.2.0" },
798
  { name = "transformers", specifier = ">=4.40.0" },
 
775
  { name = "datasets" },
776
  { name = "evaluate" },
777
  { name = "fastapi" },
778
+ { name = "httpx" },
779
+ { name = "python-dotenv" },
780
  { name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
781
  { name = "scikit-learn", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
782
  { name = "torch" },
 
795
  { name = "datasets", specifier = ">=2.19.0" },
796
  { name = "evaluate", specifier = ">=0.4.1" },
797
  { name = "fastapi", specifier = ">=0.111.0" },
798
+ { name = "httpx", specifier = ">=0.27.0" },
799
+ { name = "python-dotenv", specifier = ">=1.0.0" },
800
  { name = "scikit-learn", specifier = ">=1.4.0" },
801
  { name = "torch", specifier = ">=2.2.0" },
802
  { name = "transformers", specifier = ">=4.40.0" },