diff --git a/core/video/enrichment/clients.py b/core/video/enrichment/clients.py index 19c9b99e..7738c9e9 100644 --- a/core/video/enrichment/clients.py +++ b/core/video/enrichment/clients.py @@ -46,27 +46,35 @@ class TMDBClient: logger.exception("TMDB test failed") return False, "Could not reach TMDB" - def match(self, kind, title, year): - if not self.api_key or not title: + def match(self, kind, title, year, known_id=None): + if not self.api_key: return None import requests - path = "/search/movie" if kind == "movie" else "/search/tv" - params = {"api_key": self.api_key, "query": title} - if year: - params["year" if kind == "movie" else "first_air_date_year"] = year - resp = requests.get(self.BASE + path, params=params, timeout=15) - results = (resp.json() or {}).get("results") or [] - if not results: - return None - top = results[0] - tmdb_id = top.get("id") - meta = {"overview": top.get("overview")} + # The server already knows the TMDB id → go straight to the details + # fetch (accurate, one call). Otherwise fall back to a title/year search. + tmdb_id = _int(known_id) + meta = {} + if tmdb_id is None: + if not title: + return None + path = "/search/movie" if kind == "movie" else "/search/tv" + params = {"api_key": self.api_key, "query": title} + if year: + params["year" if kind == "movie" else "first_air_date_year"] = year + resp = requests.get(self.BASE + path, params=params, timeout=15) + results = (resp.json() or {}).get("results") or [] + if not results: + return None + tmdb_id = results[0].get("id") + meta["overview"] = results[0].get("overview") + if tmdb_id is None: + return None try: detail_path = "/movie/" if kind == "movie" else "/tv/" dr = requests.get(self.BASE + detail_path + str(tmdb_id), params={"api_key": self.api_key, "append_to_response": "external_ids"}, timeout=15).json() or {} - meta["overview"] = dr.get("overview") or meta["overview"] + meta["overview"] = dr.get("overview") or meta.get("overview") if dr.get("backdrop_path"): meta["backdrop_url"] = self.IMG + dr["backdrop_path"] ext = dr.get("external_ids") or {} @@ -77,7 +85,7 @@ class TMDBClient: meta["status"] = dr.get("status") meta["tvdb_id"] = _int(ext.get("tvdb_id")) except Exception: - logger.exception("TMDB details fetch failed for %s", title) + logger.exception("TMDB details fetch failed for %s", title or tmdb_id) return {"id": tmdb_id, "metadata": {k: v for k, v in meta.items() if v}} @@ -112,21 +120,37 @@ class TVDBClient: self._token = (r.get("data") or {}).get("token") return self._token - def match(self, kind, title, year): - if kind != "show" or not self.api_key or not title: + def match(self, kind, title, year, known_id=None): + if kind != "show" or not self.api_key: return None import requests token = self._auth() if not token: return None - r = requests.get(self.BASE + "/search", headers={"Authorization": "Bearer " + token}, - params={"query": title, "type": "series"}, timeout=15).json() or {} - results = r.get("data") or [] - if not results: + headers = {"Authorization": "Bearer " + token} + tvdb_id = _int(known_id) + meta = {} + if tvdb_id is None: + if not title: + return None + r = requests.get(self.BASE + "/search", headers=headers, + params={"query": title, "type": "series"}, timeout=15).json() or {} + results = r.get("data") or [] + if not results: + return None + top = results[0] + tvdb_id = _int(top.get("tvdb_id") or top.get("id")) + meta["overview"] = top.get("overview") + else: + # Known id from the server → fetch the series directly for its overview. + try: + dr = requests.get(self.BASE + "/series/" + str(tvdb_id), + headers=headers, timeout=15).json() or {} + meta["overview"] = (dr.get("data") or {}).get("overview") + except Exception: + logger.exception("TVDB details fetch failed for %s", title or tvdb_id) + if tvdb_id is None: return None - top = results[0] - tvdb_id = _int(top.get("tvdb_id") or top.get("id")) - meta = {"overview": top.get("overview")} return {"id": tvdb_id, "metadata": {k: v for k, v in meta.items() if v}} diff --git a/core/video/enrichment/worker.py b/core/video/enrichment/worker.py index efae9328..750c11e9 100644 --- a/core/video/enrichment/worker.py +++ b/core/video/enrichment/worker.py @@ -103,7 +103,10 @@ class VideoEnrichmentWorker: return False self.current_item = {"type": item["kind"], "name": item["title"]} try: - result = self.client.match(item["kind"], item["title"], item.get("year")) + # Prefer the provider id the server already gave us (enrich BY ID, no + # re-search); the client falls back to a title/year search if it's None. + result = self.client.match(item["kind"], item["title"], item.get("year"), + known_id=item.get("known_id")) except Exception: logger.exception("video enrichment %s match failed for %s", self.service, item["title"]) self.stats["errors"] += 1 diff --git a/database/video_database.py b/database/video_database.py index 6b308f28..cba0ed6d 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -149,24 +149,31 @@ class VideoDatabase: def enrichment_next(self, service: str, retry_days: int = 30) -> dict | None: """Next item that needs enrichment for a service: pending (never tried) first, then a not_found item older than retry_days. Returns - {kind, id, title, year} or None.""" + {kind, id, title, year, known_id} or None. ``known_id`` is the provider + id the media server already supplied (e.g. tmdb_id/tvdb_id) so the worker + can enrich BY ID instead of re-searching by title.""" kinds = _ENRICH.get(service) if not kinds: return None cutoff = (datetime.now(timezone.utc) - timedelta(days=retry_days)).strftime("%Y-%m-%d %H:%M:%S") conn = self._get_connection() + + def _row(row, kind, idc): + return {"kind": kind, "id": row["id"], "title": row["title"], + "year": row["year"], "known_id": row[idc]} + try: - for kind, (tbl, _idc, sc, _ac) in kinds.items(): + for kind, (tbl, idc, sc, _ac) in kinds.items(): row = conn.execute( - f"SELECT id, title, year FROM {tbl} WHERE {sc} IS NULL ORDER BY id LIMIT 1").fetchone() + f"SELECT id, title, year, {idc} FROM {tbl} WHERE {sc} IS NULL ORDER BY id LIMIT 1").fetchone() if row: - return {"kind": kind, "id": row["id"], "title": row["title"], "year": row["year"]} - for kind, (tbl, _idc, sc, ac) in kinds.items(): + return _row(row, kind, idc) + for kind, (tbl, idc, sc, ac) in kinds.items(): row = conn.execute( - f"SELECT id, title, year FROM {tbl} WHERE {sc}='not_found' " + f"SELECT id, title, year, {idc} FROM {tbl} WHERE {sc}='not_found' " f"AND ({ac} IS NULL OR {ac} < ?) ORDER BY {ac} LIMIT 1", (cutoff,)).fetchone() if row: - return {"kind": kind, "id": row["id"], "title": row["title"], "year": row["year"]} + return _row(row, kind, idc) return None finally: conn.close() diff --git a/tests/test_video_api.py b/tests/test_video_api.py index 0912c5a1..c7967da5 100644 --- a/tests/test_video_api.py +++ b/tests/test_video_api.py @@ -100,7 +100,7 @@ def test_enrichment_endpoints(tmp_path): class FakeClient: enabled = True - def match(self, *a): return None + def match(self, *a, **k): return None def test(self): return (True, "ok") db = VideoDatabase(database_path=str(tmp_path / "video_library.db")) diff --git a/tests/test_video_enrichment.py b/tests/test_video_enrichment.py index 27998377..947bdb10 100644 --- a/tests/test_video_enrichment.py +++ b/tests/test_video_enrichment.py @@ -7,6 +7,8 @@ nothing from the music side. from __future__ import annotations +import sys +import types from pathlib import Path import pytest @@ -14,6 +16,7 @@ import pytest from database.video_database import VideoDatabase from core.video.enrichment.worker import VideoEnrichmentWorker from core.video.enrichment.engine import VideoEnrichmentEngine +from core.video.enrichment.clients import TMDBClient class FakeClient: @@ -23,8 +26,8 @@ class FakeClient: self._result = result self.calls = [] - def match(self, kind, title, year): - self.calls.append((kind, title, year)) + def match(self, kind, title, year, known_id=None): + self.calls.append((kind, title, year, known_id)) return self._result @@ -38,13 +41,48 @@ def test_worker_process_one_matches(db): client = FakeClient({"id": 438631, "metadata": {"overview": "O", "backdrop_url": "/b.jpg"}}) w = VideoEnrichmentWorker(db, "tmdb", client) assert w.process_one() is True - assert client.calls == [("movie", "Dune", 2021)] + assert client.calls == [("movie", "Dune", 2021, None)] # no server id → search with db.connect() as c: row = c.execute("SELECT tmdb_id, tmdb_match_status, overview FROM movies").fetchone() assert (row["tmdb_id"], row["tmdb_match_status"], row["overview"]) == (438631, "matched", "O") assert w.stats["matched"] == 1 +def test_worker_enriches_by_server_id_instead_of_searching(db): + # The server already gave us tmdb_id during the scan → the worker must pass + # it through (enrich BY ID, no title re-search). + db.upsert_movie("plex", {"server_id": "m1", "title": "Dune", "year": 2021, "tmdb_id": 438631}) + client = FakeClient({"id": 438631, "metadata": {"overview": "O"}}) + w = VideoEnrichmentWorker(db, "tmdb", client) + assert w.process_one() is True + assert client.calls == [("movie", "Dune", 2021, 438631)] # known_id forwarded + assert db.enrichment_next("tmdb") is None # nothing left pending + + +def test_enrichment_next_surfaces_known_id(db): + db.upsert_movie("plex", {"server_id": "m1", "title": "A", "tmdb_id": 99}) + nxt = db.enrichment_next("tmdb") + assert nxt["known_id"] == 99 and nxt["kind"] == "movie" + + +def test_tmdb_client_with_known_id_skips_the_search(monkeypatch): + # With a known id, the client must hit /movie/ directly and never the + # /search endpoint (no chance of a wrong title-search match). + urls = [] + + class _Resp: + def json(self): + return {"overview": "by-id overview", "backdrop_path": "/b.jpg"} + + fake = types.SimpleNamespace(get=lambda url, **kw: (urls.append(url), _Resp())[1]) + monkeypatch.setitem(sys.modules, "requests", fake) + + res = TMDBClient("KEY").match("movie", "Whatever", 2021, known_id=438631) + assert res["id"] == 438631 + assert any("/movie/438631" in u for u in urls) + assert not any("/search/" in u for u in urls) + + def test_worker_process_one_not_found(db): db.upsert_movie("plex", {"server_id": "m1", "title": "X"}) w = VideoEnrichmentWorker(db, "tmdb", FakeClient(None)) @@ -63,7 +101,7 @@ def test_worker_match_exception_marks_not_found_and_counts_error(db): class Boom: enabled = True - def match(self, *a): raise RuntimeError("api down") + def match(self, *a, **k): raise RuntimeError("api down") w = VideoEnrichmentWorker(db, "tmdb", Boom()) assert w.process_one() is True # doesn't crash the loop