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:
|
||||
priority = self.db.get_setting("enrichment_priority") or None
|
||||
except Exception:
|
||||
pass
|
||||
logger.debug("enrichment priority lookup failed", exc_info=True)
|
||||
item = self.db.enrichment_next(self.service, self.retry_days, priority=priority)
|
||||
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"]}
|
||||
try:
|
||||
# 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)
|
||||
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:
|
||||
"""Backfill a show's FULL episode list from the provider (one call per
|
||||
season) — owned + missing. Best-effort: a season failure never aborts the
|
||||
|
|
@ -278,7 +308,10 @@ class VideoEnrichmentWorker:
|
|||
try:
|
||||
pending += self.db.episode_sync_pending_count()
|
||||
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()
|
||||
running = self.running and not self.paused and self.enabled and not cooling
|
||||
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_status", "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:
|
||||
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:
|
||||
"""OMDb 'coverage' breakdown: matched = ratings present, pending = has an
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "year": 2019,
|
||||
"tmdb_id": 1396, "seasons": []})
|
||||
|
|
|
|||
Loading…
Reference in a new issue