From a68d9755a978c29ad7ab9f7ae6f151904891e17b Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Mon, 22 Jun 2026 23:48:45 -0700 Subject: [PATCH] video discover: lazy collection-id backfill so 'complete your collections' lights up Already-matched movies predate the tmdb_collection_id column, so the collection gap rails were empty (only newly-enriched movies got the id). Add a self-healing backfill: each /discover/gaps load fills the franchise id for up to 20 owned movies missing it (eng.movie_collection reuses the matcher's belongs_to_collection read), recording 0 for movies with no franchise so they're not re-checked. The 'no-franchise' 0 is excluded from the rails. Backfill is wrapped/isolated so it can never break the gap response. Over a few Discover visits the whole library fills in and 'Complete the ' rails populate. --- api/video/discover.py | 11 ++++++++++ core/video/enrichment/engine.py | 14 +++++++++++++ database/video_database.py | 36 ++++++++++++++++++++++++++++++++- 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/api/video/discover.py b/api/video/discover.py index 96575822..7ba6c450 100644 --- a/api/video/discover.py +++ b/api/video/discover.py @@ -100,6 +100,17 @@ def register_routes(bp): db = get_video_db() eng = get_video_enrichment_engine() try: + # Lazy collection-id backfill: movies matched before the collection column + # exists have no franchise id. Fill a small batch each load (self-healing); + # isolated so a backfill hiccup never breaks the gap rails. + try: + for mv in db.movies_missing_collection(srv, limit=20): + coll = eng.movie_collection(mv["tmdb_id"]) + if coll is not None: + db.set_movie_collection(mv["id"], coll.get("id"), coll.get("name")) + except Exception: + logger.exception("collection-id backfill batch failed") + owned = db.owned_movie_tmdb_ids(srv) rails = [] # Complete your collections — top franchises you've started, missing entries. diff --git a/core/video/enrichment/engine.py b/core/video/enrichment/engine.py index b8c9237c..fb29ed02 100644 --- a/core/video/enrichment/engine.py +++ b/core/video/enrichment/engine.py @@ -436,6 +436,20 @@ class VideoEnrichmentEngine: return [] return self._stamp_owned(items) + def movie_collection(self, tmdb_id) -> dict | None: + """A movie's TMDB franchise membership {id, name} (belongs_to_collection), or + {id: None} when it belongs to no collection. Used to backfill already-matched + movies that predate the collection column.""" + w = self.workers.get("tmdb") + if not w or not w.enabled or not tmdb_id: + return None + try: + meta = w.client.match("movie", None, None, known_id=tmdb_id) or {} + return {"id": meta.get("tmdb_collection_id"), "name": meta.get("tmdb_collection_name")} + except Exception: + logger.exception("movie_collection backfill failed for %s", tmdb_id) + return None + def trailer(self, kind, tmdb_id) -> dict | None: """Best YouTube trailer for a title (cached a day — trailers don't move).""" w = self.workers.get("tmdb") diff --git a/database/video_database.py b/database/video_database.py index de19ceba..d3b1435b 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -815,13 +815,47 @@ class VideoDatabase: finally: conn.close() + def movies_missing_collection(self, server_source=None, limit: int = 20) -> list: + """Owned, TMDB-matched movies whose franchise (tmdb_collection_id) hasn't been + backfilled yet — drives the lazy collection-id backfill. Returns [{id, tmdb_id}].""" + sql = ("SELECT id, tmdb_id FROM movies WHERE has_file=1 AND tmdb_id IS NOT NULL " + "AND tmdb_collection_id IS NULL") + args: list = [] + if server_source: + sql += " AND server_source=?" + args.append(server_source) + sql += " LIMIT ?" + args.append(int(limit)) + conn = self._get_connection() + try: + return [{"id": r["id"], "tmdb_id": r["tmdb_id"]} for r in conn.execute(sql, args)] + except sqlite3.Error: + return [] + finally: + conn.close() + + def set_movie_collection(self, movie_id: int, collection_id, name) -> None: + """Persist a movie's TMDB collection id/name (backfill). collection_id may be + None — recorded as 0 so the row isn't re-checked forever (a movie with no + franchise). 0 is excluded from the gap rails.""" + conn = self._get_connection() + try: + conn.execute("UPDATE movies SET tmdb_collection_id=?, tmdb_collection_name=? WHERE id=?", + (int(collection_id) if collection_id else 0, name, int(movie_id))) + conn.commit() + except sqlite3.Error: + pass + finally: + conn.close() + def owned_movie_collections(self, server_source=None, limit: int = 12) -> list: """Franchises the user has STARTED (owns >=1 movie in), most-invested first — drives the 'Complete your collections' gap rails. Returns [{collection_id, name, owned_count}].""" sql = ("SELECT tmdb_collection_id AS cid, " "MAX(tmdb_collection_name) AS name, COUNT(*) AS c " - "FROM movies WHERE has_file=1 AND tmdb_collection_id IS NOT NULL") + "FROM movies WHERE has_file=1 AND tmdb_collection_id IS NOT NULL " + "AND tmdb_collection_id != 0") args: list = [] if server_source: sql += " AND server_source=?"