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:
parent
e2a8d3339a
commit
aff4ecccc6
3 changed files with 86 additions and 1 deletions
|
|
@ -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 = {}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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": []})
|
||||
|
|
|
|||
Loading…
Reference in a new issue