diff --git a/core/video/enrichment/worker.py b/core/video/enrichment/worker.py index a0c7502e..e2f0adf3 100644 --- a/core/video/enrichment/worker.py +++ b/core/video/enrichment/worker.py @@ -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 = {} diff --git a/database/video_database.py b/database/video_database.py index 90654607..629f1d78 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -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: diff --git a/tests/test_video_enrichment.py b/tests/test_video_enrichment.py index daf4edf4..983516a5 100644 --- a/tests/test_video_enrichment.py +++ b/tests/test_video_enrichment.py @@ -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": []})