| """TMDB enrichment for the API service. |
| |
| Searches TMDB by (clean) title + year, returns poster/backdrop/overview/rating, |
| and caches the result in Redis under `tmdb:{movie_id}` so repeat lookups are free. |
| Degrades gracefully: if TMDB_API_KEY is unset or the HTTP client is unavailable, |
| items are returned with empty TMDB fields rather than raising. |
| """ |
| import asyncio |
| import json |
| import os |
| import re |
|
|
| TMDB_API_KEY = os.getenv("TMDB_API_KEY", "") |
| TMDB_SEARCH = "https://api.themoviedb.org/3/search/movie" |
| IMG_BASE = "https://image.tmdb.org/t/p/" |
| CACHE_TTL = 60 * 60 * 24 * 30 |
|
|
| YEAR_RE = re.compile(r"\((\d{4})\)\s*$") |
| ARTICLE_RE = re.compile( |
| r"^(.*),\s+(The|A|An|Les|La|Le|Los|Las|Un|Une|Der|Die|Das|Il|Lo)$", re.I |
| ) |
|
|
| EMPTY = {"poster": None, "backdrop": None, "overview": "", "tmdb_rating": 0.0, "tmdb_id": None} |
|
|
|
|
| def clean_title(raw: str | None) -> str: |
| """'Wizard of Oz, The (1939)' -> 'The Wizard of Oz' (year stripped).""" |
| stripped = YEAR_RE.sub("", raw or "").strip() |
| m = ARTICLE_RE.match(stripped) |
| return f"{m.group(2)} {m.group(1)}" if m else stripped |
|
|
|
|
| def extract_year(raw: str | None): |
| m = YEAR_RE.search(raw or "") |
| return int(m.group(1)) if m else None |
|
|
|
|
| async def _fetch_one(http, item: dict, retries: int = 3) -> dict: |
| title = clean_title(item.get("title")) |
| year = item.get("year") or extract_year(item.get("title")) |
| params = {"api_key": TMDB_API_KEY, "query": title} |
| if year: |
| params["year"] = year |
| for attempt in range(retries): |
| try: |
| r = await http.get(TMDB_SEARCH, params=params) |
| if r.status_code == 429: |
| await asyncio.sleep(2 ** attempt) |
| continue |
| results = r.json().get("results", []) if r.status_code == 200 else [] |
| if not results and year: |
| params.pop("year", None) |
| r = await http.get(TMDB_SEARCH, params=params) |
| results = r.json().get("results", []) if r.status_code == 200 else [] |
| best = results[0] if results else {} |
| return { |
| "tmdb_id": best.get("id"), |
| "poster": IMG_BASE + "w342" + best["poster_path"] if best.get("poster_path") else None, |
| "backdrop": IMG_BASE + "w780" + best["backdrop_path"] if best.get("backdrop_path") else None, |
| "overview": best.get("overview") or "", |
| "tmdb_rating": best.get("vote_average") or 0.0, |
| } |
| except Exception: |
| if attempt < retries - 1: |
| await asyncio.sleep(0.5 * (attempt + 1)) |
| else: |
| return dict(EMPTY) |
| return dict(EMPTY) |
|
|
|
|
| async def enrich(redis, http, items: list[dict]) -> list[dict]: |
| """Mutates `items` in place, attaching TMDB fields. Returns the same list.""" |
| if not items: |
| return items |
|
|
| for it in items: |
| if not it.get("year"): |
| it["year"] = extract_year(it.get("title")) |
|
|
| |
| missing: list[dict] = [] |
| if redis is not None: |
| pipe = redis.pipeline() |
| for it in items: |
| pipe.get(f"tmdb:{it['movie_id']}") |
| cached = await pipe.execute() |
| for it, raw in zip(items, cached): |
| if raw: |
| try: |
| it.update(json.loads(raw)) |
| continue |
| except Exception: |
| pass |
| missing.append(it) |
| else: |
| missing = list(items) |
|
|
| |
| if not missing or http is None or not TMDB_API_KEY: |
| for it in missing: |
| for k, v in EMPTY.items(): |
| it.setdefault(k, v) |
| return items |
|
|
| |
| fetched = await asyncio.gather(*[_fetch_one(http, it) for it in missing]) |
| if redis is not None: |
| pipe = redis.pipeline() |
| for it, data in zip(missing, fetched): |
| it.update(data) |
| |
| |
| if data.get("tmdb_id") is not None: |
| pipe.set(f"tmdb:{it['movie_id']}", json.dumps(data), ex=CACHE_TTL) |
| await pipe.execute() |
| else: |
| for it, data in zip(missing, fetched): |
| it.update(data) |
| return items |
|
|