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:
parent
50042607d7
commit
fc68a6e741
5 changed files with 109 additions and 37 deletions
|
|
@ -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}}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue