video enrichment: background episode-sync pass (full lists for the whole library)

So library cards show real owned/total (e.g. 8/10) WITHOUT opening each show. When
the TMDB worker's match queue is clear, it pulls the full season/episode list for
one already-matched-but-unsynced show per loop (episode_sync_next), inserting
missing episodes + marking it synced. Over time every library show gets its full
list; the on-view lazy refresh still makes the one you open instant. TMDB-only;
counts toward the worker's pending so it shows busy (not 'Complete') while syncing,
and never loops on a single failing show.

Seam tests: episode_sync_next selection + pending count, worker syncs a pre-feature
matched show to the full list.
This commit is contained in:
BoulderBadgeDad 2026-06-14 22:39:55 -07:00
parent e2a8d3339a
commit aff4ecccc6
3 changed files with 86 additions and 1 deletions

View file

@ -105,7 +105,7 @@ class VideoEnrichmentWorker:
pass
item = self.db.enrichment_next(self.service, self.retry_days, priority=priority)
if not item:
return False
return self._sync_episodes_once()
self.current_item = {"type": item["kind"], "name": item["title"]}
try:
# Prefer the provider id the server already gave us (enrich BY ID, no
@ -140,6 +140,33 @@ class VideoEnrichmentWorker:
logger.info("No %s match for %s '%s'", self.display_name, item["kind"], item["title"])
return True
def _sync_episodes_once(self) -> bool:
"""Background episode-sync: pull the FULL season/episode list for one
already-matched show that hasn't been synced, so library cards show real
owned/total. TMDB-only (it owns season_episodes). Returns True if it did
work (so the loop rate-limits between shows)."""
if not hasattr(self.client, "season_episodes"):
return False
show = self.db.episode_sync_next()
if not show:
return False
self.current_item = {"type": "episodes", "name": show["title"]}
try:
result = self.client.match("show", show["title"], show.get("year"),
known_id=show.get("tmdb_id"))
if result and result.get("id"):
self.db.enrichment_apply("tmdb", "show", show["id"], matched=True,
external_id=result["id"], metadata=result.get("metadata"))
nums = [s["season_number"] for s in (result.get("metadata") or {}).get("seasons") or []]
self._cascade_episodes(show["id"], result["id"], nums) # marks synced
logger.info("Synced full episode list for show '%s'", show["title"])
else:
self.db.mark_episodes_synced(show["id"]) # no match → don't re-pick
except Exception:
logger.exception("episode sync failed for show '%s'", show["title"])
self.db.mark_episodes_synced(show["id"]) # move on (never loop on one show)
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
@ -172,6 +199,13 @@ class VideoEnrichmentWorker:
# coverage-only cascade (no queue), so it's excluded from idle/pending.
pending = sum(b["pending"] + b.get("errors", 0)
for b in breakdown.values() if not b.get("coverage_only"))
# Shows still needing their full episode list pulled count as outstanding
# work for the TMDB worker (so it isn't "Complete" while syncing).
if hasattr(self.client, "season_episodes"):
try:
pending += self.db.episode_sync_pending_count()
except Exception:
pass
running = self.running and not self.paused and self.enabled
idle = running and pending == 0 and self.current_item is None
progress = {}

View file

@ -338,6 +338,27 @@ class VideoDatabase:
finally:
conn.close()
def episode_sync_next(self) -> dict | None:
"""A matched show (has tmdb_id) whose FULL episode list hasn't been pulled
yet for the TMDB worker's background episode-sync pass, so library cards
show real owned/total without the user opening each one."""
conn = self._get_connection()
try:
row = conn.execute(
"SELECT id, title, year, tmdb_id FROM shows "
"WHERE tmdb_id IS NOT NULL AND episodes_synced=0 ORDER BY id LIMIT 1").fetchone()
return dict(row) if row else None
finally:
conn.close()
def episode_sync_pending_count(self) -> int:
conn = self._get_connection()
try:
return conn.execute(
"SELECT COUNT(*) FROM shows WHERE tmdb_id IS NOT NULL AND episodes_synced=0").fetchone()[0]
finally:
conn.close()
def show_season_numbers(self, show_id: int) -> list:
conn = self._get_connection()
try:

View file

@ -130,6 +130,36 @@ def test_refresh_show_art_needs_tmdb_configured(db):
assert res["ok"] is False and res["reason"] == "tmdb_not_configured"
def test_episode_sync_next_only_matched_unsynced(db):
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
assert db.episode_sync_next()["id"] == a # a is matched + unsynced; b skipped
db.mark_episodes_synced(a)
assert db.episode_sync_next() is None and db.episode_sync_pending_count() == 0
def test_worker_background_episode_sync_pulls_full_list(db):
# A show matched BEFORE the cascade feature: tmdb_id set, only owned episodes,
# episodes_synced still 0. With the match queue clear, the worker syncs it.
sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "tmdb_id": 1396, "seasons": [
{"season_number": 1, "episodes": [{"episode_number": 1, "file": {"relative_path": "e1.mkv"}}]}]})
db.enrichment_apply("tmdb", "show", sid, matched=True, external_id=1396)
assert db.episode_sync_pending_count() == 1
class C:
enabled = True
def match(self, kind, title, year, known_id=None):
return {"id": 1396, "metadata": {"seasons": [{"season_number": 1, "poster_url": None}]}}
def season_episodes(self, tv, sn):
return {"episodes": [{"episode_number": 1}, {"episode_number": 2}, {"episode_number": 3}]}
w = VideoEnrichmentWorker(db, "tmdb", C())
assert w.process_one() is True # no match pending → episode sync runs
s1 = db.show_detail(sid)["seasons"][0]
assert (s1["episode_total"], s1["episode_owned"]) == (3, 1) # full list now
assert db.episode_sync_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": []})