video enrichment: enrich by the server's provider id, not a title re-search

The deep scan stores tmdb_id/tvdb_id/imdb_id from Plex/Jellyfin, but the workers
only ever searched by title+year and ignored those ids — re-deriving matches the
server already had exact (wasteful, and a title search can mis-match).

enrichment_next now surfaces the row's known provider id; the worker forwards it
and the TMDB/TVDB clients fetch details BY ID (one call, no /search) when it's
present, falling back to title/year search only for items the server couldn't
identify. Still grabs the overview/backdrop the scan doesn't capture.
This commit is contained in:
BoulderBadgeDad 2026-06-14 15:03:26 -07:00
parent 50042607d7
commit fc68a6e741
5 changed files with 109 additions and 37 deletions

View file

@ -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}}

View file

@ -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

View file

@ -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()

View file

@ -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"))

View file

@ -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/<id> 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