video enrichment: background TMDB details backfill (fills 'status' on pre-matched items)
The media server pre-matches shows/movies (tmdb_id set), so the enrichment matcher skips them and never fetches TMDB details — leaving details-only fields like `status` (airing vs ended) blank on almost the whole library. That's why the watchlist's airing-shows default only ever saw the handful of shows whose detail page had been opened (the one path that force-fetches details). Library here: 3,371 matched shows, only 18 with status. Fix: a one-time-per-item details backfill that runs in the enrichment worker's idle loop (after the episode-sync pass). New `details_synced` marker column on shows+movies; detail_backfill_next/mark_details_synced/pending_count; worker._detail_backfill_one() re-fetches an already-matched item's TMDB details and gap-fills (never clobbers server data), then marks it done so it's attempted once. No re-scan needed — it heals the existing library in place, and once status is populated the airing-watchlist reflects real TV. It's a background gap-fill on already-matched items (like episode coverage), so it doesn't block the worker's 'Complete' status. kettui: DB seam tests + worker tests (fills status / enrich-by-id / marks-done-when-absent). ruff + isolation guards green; 94 enrichment tests pass.
This commit is contained in:
parent
9a3ca6ba4e
commit
57f254acaf
3 changed files with 138 additions and 3 deletions
|
|
@ -118,10 +118,13 @@ class VideoEnrichmentWorker:
|
||||||
try:
|
try:
|
||||||
priority = self.db.get_setting("enrichment_priority") or None
|
priority = self.db.get_setting("enrichment_priority") or None
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.debug("enrichment priority lookup failed", exc_info=True)
|
||||||
item = self.db.enrichment_next(self.service, self.retry_days, priority=priority)
|
item = self.db.enrichment_next(self.service, self.retry_days, priority=priority)
|
||||||
if not item:
|
if not item:
|
||||||
return self._sync_episodes_once()
|
# No pending matches → use idle time for the full episode-list sync
|
||||||
|
# (which also fills details for shows it touches), then the details
|
||||||
|
# backfill for everything already episode-synced but missing `status`.
|
||||||
|
return self._sync_episodes_once() or self._detail_backfill_one()
|
||||||
self.current_item = {"type": item["kind"], "name": item["title"]}
|
self.current_item = {"type": item["kind"], "name": item["title"]}
|
||||||
try:
|
try:
|
||||||
# Prefer the provider id the server already gave us (enrich BY ID, no
|
# Prefer the provider id the server already gave us (enrich BY ID, no
|
||||||
|
|
@ -240,6 +243,33 @@ class VideoEnrichmentWorker:
|
||||||
self.db.mark_episodes_synced(show["id"]) # move on (never loop on one show)
|
self.db.mark_episodes_synced(show["id"]) # move on (never loop on one show)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def _detail_backfill_one(self) -> bool:
|
||||||
|
"""Background TMDB details backfill: re-fetch ONE already-matched show/movie
|
||||||
|
that's missing details-only fields (status, network, tagline, rating…) and
|
||||||
|
gap-fill them. The matcher skips server-pre-matched items, so without this
|
||||||
|
their `status` stays blank — and the watchlist's airing-default can't see
|
||||||
|
them. TMDB-only. Returns True if it did work (so the loop rate-limits)."""
|
||||||
|
if not hasattr(self.client, "match"):
|
||||||
|
return False
|
||||||
|
item = self.db.detail_backfill_next("show") or self.db.detail_backfill_next("movie")
|
||||||
|
if not item:
|
||||||
|
return False
|
||||||
|
self.current_item = {"type": item["kind"], "name": item["title"]}
|
||||||
|
try:
|
||||||
|
result = self.client.match(item["kind"], item["title"], item.get("year"),
|
||||||
|
known_id=item.get("tmdb_id"))
|
||||||
|
if result and result.get("id"):
|
||||||
|
# Gap-fill only (never clobbers server data); fills status et al.
|
||||||
|
self.db.enrichment_apply("tmdb", item["kind"], item["id"], matched=True,
|
||||||
|
external_id=result["id"], metadata=result.get("metadata"))
|
||||||
|
logger.info("Backfilled details for %s '%s'", item["kind"], item["title"])
|
||||||
|
self.db.mark_details_synced(item["kind"], item["id"]) # attempted once → don't re-pick
|
||||||
|
except Exception:
|
||||||
|
# Transient call failure — leave details_synced=0 so it retries later.
|
||||||
|
logger.exception("detail backfill failed for %s '%s'", item["kind"], item["title"])
|
||||||
|
self.stats["errors"] += 1
|
||||||
|
return True
|
||||||
|
|
||||||
def _cascade_episodes(self, show_id, tv_id, season_numbers=None) -> None:
|
def _cascade_episodes(self, show_id, tv_id, season_numbers=None) -> None:
|
||||||
"""Backfill a show's FULL episode list from the provider (one call per
|
"""Backfill a show's FULL episode list from the provider (one call per
|
||||||
season) — owned + missing. Best-effort: a season failure never aborts the
|
season) — owned + missing. Best-effort: a season failure never aborts the
|
||||||
|
|
@ -278,7 +308,10 @@ class VideoEnrichmentWorker:
|
||||||
try:
|
try:
|
||||||
pending += self.db.episode_sync_pending_count()
|
pending += self.db.episode_sync_pending_count()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
logger.debug("episode_sync_pending_count failed", exc_info=True)
|
||||||
|
# NB: the TMDB details backfill (status/network/…) is a background gap-fill on
|
||||||
|
# ALREADY-matched items — like episode coverage, it doesn't block "Complete".
|
||||||
|
# It still runs in the idle loop; it's just not counted as blocking pending.
|
||||||
cooling = self._cooldown_until > time.monotonic()
|
cooling = self._cooldown_until > time.monotonic()
|
||||||
running = self.running and not self.paused and self.enabled and not cooling
|
running = self.running and not self.paused and self.enabled and not cooling
|
||||||
idle = running and pending == 0 and self.current_item is None
|
idle = running and pending == 0 and self.current_item is None
|
||||||
|
|
|
||||||
|
|
@ -201,6 +201,12 @@ _COLUMN_MIGRATIONS = [
|
||||||
("youtube_video_stats", "dearrow_title", "TEXT"),
|
("youtube_video_stats", "dearrow_title", "TEXT"),
|
||||||
("youtube_video_stats", "dearrow_status", "TEXT"),
|
("youtube_video_stats", "dearrow_status", "TEXT"),
|
||||||
("youtube_video_stats", "dearrow_attempted", "TEXT"),
|
("youtube_video_stats", "dearrow_attempted", "TEXT"),
|
||||||
|
# TMDB details backfill: the server pre-matches shows/movies (so the matcher
|
||||||
|
# skips them) but never supplies details-only fields like `status` (airing vs
|
||||||
|
# ended) — which the watchlist's airing-default depends on. This marker drives a
|
||||||
|
# one-time per-item detail re-fetch that fills those gaps. Starts 0 = needs it.
|
||||||
|
("shows", "details_synced", "INTEGER NOT NULL DEFAULT 0"),
|
||||||
|
("movies", "details_synced", "INTEGER NOT NULL DEFAULT 0"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -865,6 +871,55 @@ class VideoDatabase:
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
# ── TMDB details backfill (status / network / tagline / rating …) ──────────
|
||||||
|
# The media server pre-matches items (tmdb_id set), so the matcher skips them and
|
||||||
|
# never fetches details-only fields. This one-time pass re-fetches details for a
|
||||||
|
# matched item and gap-fills, then marks it done so it isn't re-picked.
|
||||||
|
_DETAIL_TBL = {"show": "shows", "movie": "movies"}
|
||||||
|
|
||||||
|
def detail_backfill_next(self, kind: str) -> dict | None:
|
||||||
|
"""Next matched show/movie (has tmdb_id) whose details haven't been
|
||||||
|
backfilled yet. Returns {kind, id, title, year, tmdb_id} or None."""
|
||||||
|
tbl = self._DETAIL_TBL.get(kind)
|
||||||
|
if not tbl:
|
||||||
|
return None
|
||||||
|
conn = self._get_connection()
|
||||||
|
try:
|
||||||
|
row = conn.execute(
|
||||||
|
f"SELECT id, title, year, tmdb_id FROM {tbl} "
|
||||||
|
f"WHERE tmdb_id IS NOT NULL AND details_synced=0 ORDER BY id LIMIT 1").fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
d = dict(row)
|
||||||
|
d["kind"] = kind
|
||||||
|
return d
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def mark_details_synced(self, kind: str, item_id: int) -> None:
|
||||||
|
"""Flag that an item's TMDB details were backfilled (attempted once), so the
|
||||||
|
background pass doesn't re-pick it even if a field stayed empty."""
|
||||||
|
tbl = self._DETAIL_TBL.get(kind)
|
||||||
|
if not tbl:
|
||||||
|
return
|
||||||
|
conn = self._get_connection()
|
||||||
|
try:
|
||||||
|
conn.execute(f"UPDATE {tbl} SET details_synced=1 WHERE id=?", (item_id,))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def detail_backfill_pending_count(self) -> int:
|
||||||
|
conn = self._get_connection()
|
||||||
|
try:
|
||||||
|
n = 0
|
||||||
|
for tbl in ("shows", "movies"):
|
||||||
|
n += conn.execute(
|
||||||
|
f"SELECT COUNT(*) FROM {tbl} WHERE tmdb_id IS NOT NULL AND details_synced=0").fetchone()[0]
|
||||||
|
return n
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
def _ratings_breakdown(self) -> dict:
|
def _ratings_breakdown(self) -> dict:
|
||||||
"""OMDb 'coverage' breakdown: matched = ratings present, pending = has an
|
"""OMDb 'coverage' breakdown: matched = ratings present, pending = has an
|
||||||
imdb_id but not fetched, not_found = fetched but OMDb had no rating."""
|
imdb_id but not fetched, not_found = fetched but OMDb had no rating."""
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,53 @@ def test_worker_background_episode_sync_pulls_full_list(db):
|
||||||
assert db.episode_sync_pending_count() == 0
|
assert db.episode_sync_pending_count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_detail_backfill_next_only_matched_unsynced(db):
|
||||||
|
# Matched items (have tmdb_id) that haven't had details backfilled are queued;
|
||||||
|
# un-matched ones are skipped. Shows + movies are independent queues.
|
||||||
|
a = db.upsert_show_tree("plex", {"server_id": "s1", "title": "A", "tmdb_id": 1, "seasons": []})
|
||||||
|
db.upsert_show_tree("plex", {"server_id": "s2", "title": "B", "seasons": []}) # no tmdb_id → skip
|
||||||
|
nx = db.detail_backfill_next("show")
|
||||||
|
assert nx and nx["id"] == a and nx["kind"] == "show" and nx["tmdb_id"] == 1
|
||||||
|
db.mark_details_synced("show", a)
|
||||||
|
assert db.detail_backfill_next("show") is None
|
||||||
|
mid = db.upsert_movie("plex", {"server_id": "m1", "title": "M", "tmdb_id": 9})
|
||||||
|
assert db.detail_backfill_next("movie")["id"] == mid
|
||||||
|
db.mark_details_synced("movie", mid)
|
||||||
|
assert db.detail_backfill_next("movie") is None
|
||||||
|
assert db.detail_backfill_pending_count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_worker_detail_backfill_fills_status(db):
|
||||||
|
# The real bug: a show pre-matched by the server (tmdb_id set) + already
|
||||||
|
# episode-synced, but `status` never captured (server doesn't supply it, the
|
||||||
|
# matcher skips matched rows). With the match + episode queues clear, the worker
|
||||||
|
# re-fetches details and gap-fills `status` — what the airing-watchlist needs.
|
||||||
|
sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "tmdb_id": 1396, "seasons": []})
|
||||||
|
db.enrichment_apply("tmdb", "show", sid, matched=True, external_id=1396)
|
||||||
|
db.mark_episodes_synced(sid) # episode queue clear → detail backfill runs
|
||||||
|
assert db.detail_backfill_pending_count() == 1
|
||||||
|
client = FakeClient({"id": 1396, "metadata": {"status": "Returning Series", "network": "HBO"}})
|
||||||
|
w = VideoEnrichmentWorker(db, "tmdb", client)
|
||||||
|
assert w.process_one() is True # no match/episode work → detail backfill
|
||||||
|
assert client.calls == [("show", "S", None, 1396)] # enrich BY id, no re-search
|
||||||
|
with db.connect() as c:
|
||||||
|
row = c.execute("SELECT status, details_synced FROM shows WHERE id=?", (sid,)).fetchone()
|
||||||
|
assert row["status"] == "Returning Series"
|
||||||
|
assert row["details_synced"] == 1
|
||||||
|
assert db.detail_backfill_pending_count() == 0 # done → not re-picked
|
||||||
|
|
||||||
|
|
||||||
|
def test_detail_backfill_marks_done_even_when_status_absent(db):
|
||||||
|
# If TMDB returns no status, we still mark it attempted so it isn't re-fetched
|
||||||
|
# forever (bounded: one attempt per item).
|
||||||
|
sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "tmdb_id": 5, "seasons": []})
|
||||||
|
db.enrichment_apply("tmdb", "show", sid, matched=True, external_id=5)
|
||||||
|
db.mark_episodes_synced(sid)
|
||||||
|
w = VideoEnrichmentWorker(db, "tmdb", FakeClient({"id": 5, "metadata": {}}))
|
||||||
|
assert w.process_one() is True
|
||||||
|
assert db.detail_backfill_pending_count() == 0
|
||||||
|
|
||||||
|
|
||||||
def test_show_match_info(db):
|
def test_show_match_info(db):
|
||||||
sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "year": 2019,
|
sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "year": 2019,
|
||||||
"tmdb_id": 1396, "seasons": []})
|
"tmdb_id": 1396, "seasons": []})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue