From 3f344a8cd98a41979b799d1a410b22a2cecffa17 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Tue, 23 Jun 2026 00:25:10 -0700 Subject: [PATCH] video discover phase 4 (backend): ignore list / 'Not interested' Movie/show-level ignore list (episodes can't be individually ignored): - video_ignored table (kind, tmdb_id, title/year/poster snapshot) + SCHEMA_VERSION 18->19. - add_ignored / remove_ignored / list_ignored / ignored_keys DB methods. - _stamp_owned (the choke point all discover results pass through) drops ignored titles, so every surface hides them uniformly + always-fresh (tiny indexed query). Person-gap rails (which bypass _stamp_owned) filtered in the gaps endpoint. - GET/POST /discover/ignore (list / add / remove). --- api/video/discover.py | 24 +++++++++++++++ core/video/enrichment/engine.py | 9 ++++++ database/video_database.py | 54 ++++++++++++++++++++++++++++++++- database/video_schema.sql | 14 +++++++++ 4 files changed, 100 insertions(+), 1 deletion(-) diff --git a/api/video/discover.py b/api/video/discover.py index 8fb5ef45..fab426a4 100644 --- a/api/video/discover.py +++ b/api/video/discover.py @@ -137,6 +137,7 @@ def register_routes(bp): logger.exception("collection-id backfill batch failed") owned = db.owned_movie_tmdb_ids(srv) + ignored = db.ignored_keys() rails = [] # Complete your collections — top franchises you've started, missing entries. for coll in db.owned_movie_collections(srv, limit=8): @@ -152,6 +153,9 @@ def register_routes(bp): continue missing = filmography_gaps(owned, p.get("credits") or [], kinds=("movie",), min_vote_count=50, limit=30) + if ignored: + missing = [m for m in missing + if f"{m.get('kind')}:{m.get('tmdb_id')}" not in ignored] if len(missing) >= 3: rails.append({"title": "More from " + person["name"], "kind": "person", "items": missing}) @@ -176,6 +180,26 @@ def register_routes(bp): tr = None return jsonify({"trailer": tr or None}) + @bp.route("/discover/ignore", methods=["GET", "POST"]) + def video_discover_ignore(): + """The Discover 'Not interested' list. GET -> {items}. POST + {action:'add'|'remove', kind, tmdb_id, title?, year?, poster?}.""" + from . import get_video_db + db = get_video_db() + try: + if request.method == "GET": + return jsonify({"items": db.list_ignored()}) + body = request.get_json(silent=True) or {} + kind, tmdb_id = body.get("kind"), body.get("tmdb_id") + if body.get("action") == "remove": + db.remove_ignored(kind, tmdb_id) + return jsonify({"success": True}) + ok = db.add_ignored(kind, tmdb_id, body.get("title"), body.get("year"), body.get("poster")) + return jsonify({"success": ok}) + except Exception: + logger.exception("discover ignore failed") + return jsonify({"success": False, "items": []}) + @bp.route("/discover/languages", methods=["GET", "POST"]) def video_discover_languages(): """Get/set the preferred original-languages for general rails (ISO-639-1 codes). diff --git a/core/video/enrichment/engine.py b/core/video/enrichment/engine.py index da69ecb9..3047f852 100644 --- a/core/video/enrichment/engine.py +++ b/core/video/enrichment/engine.py @@ -343,6 +343,15 @@ class VideoEnrichmentEngine: r["library_id"] = maps.get(r["kind"], {}).get(int(r["tmdb_id"])) except (TypeError, ValueError): r["library_id"] = None + # Drop titles the user marked 'Not interested' — one tiny indexed query, always + # fresh, so every discover surface (rails, recs, collection gaps) hides them uniformly. + try: + ignored = self.db.ignored_keys() + except Exception: + ignored = None + if ignored: + items = [r for r in (items or []) + if f"{r.get('kind')}:{r.get('tmdb_id')}" not in ignored] return items def discover_curated(self, key, page=1) -> list: diff --git a/database/video_database.py b/database/video_database.py index d3b1435b..e38c9c66 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -31,7 +31,7 @@ logger = get_logger("video_database") # Bump when video_schema.sql changes in a way worth recording. Stored in # PRAGMA user_version as a backstop indicator (nothing gates on it yet). -SCHEMA_VERSION = 18 +SCHEMA_VERSION = 19 _DEFAULT_DB_PATH = "database/video_library.db" _SCHEMA_FILE = Path(__file__).resolve().parent / "video_schema.sql" @@ -815,6 +815,58 @@ class VideoDatabase: finally: conn.close() + # ── Discover ignore list ("Not interested") ────────────────────────────── + def add_ignored(self, kind: str, tmdb_id, title=None, year=None, poster_url=None) -> bool: + """Hide a title from Discover (movie/show level). Idempotent.""" + if kind not in ("movie", "show") or tmdb_id is None: + return False + try: + with self._get_connection() as conn: + conn.execute( + "INSERT OR REPLACE INTO video_ignored (kind, tmdb_id, title, year, poster_url) " + "VALUES (?, ?, ?, ?, ?)", + (kind, int(tmdb_id), title, int(year) if year else None, poster_url), + ) + conn.commit() + return True + except (sqlite3.Error, TypeError, ValueError) as e: + logger.debug(f"add_ignored failed: {e}") + return False + + def remove_ignored(self, kind: str, tmdb_id) -> bool: + """Un-hide a title.""" + try: + with self._get_connection() as conn: + conn.execute("DELETE FROM video_ignored WHERE kind=? AND tmdb_id=?", + (kind, int(tmdb_id))) + conn.commit() + return True + except (sqlite3.Error, TypeError, ValueError) as e: + logger.debug(f"remove_ignored failed: {e}") + return False + + def list_ignored(self) -> list: + """All ignored titles, most-recently-added first — drives the manage modal.""" + try: + with self._get_connection() as conn: + rows = conn.execute( + "SELECT kind, tmdb_id, title, year, poster_url FROM video_ignored " + "ORDER BY added_at DESC" + ).fetchall() + return [{"kind": r["kind"], "tmdb_id": r["tmdb_id"], "title": r["title"], + "year": r["year"], "poster": r["poster_url"]} for r in rows] + except sqlite3.Error: + return [] + + def ignored_keys(self) -> set: + """Set of 'kind:tmdb_id' strings for fast Discover filtering.""" + try: + with self._get_connection() as conn: + return {f"{r['kind']}:{r['tmdb_id']}" + for r in conn.execute("SELECT kind, tmdb_id FROM video_ignored")} + except sqlite3.Error: + return set() + 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}].""" diff --git a/database/video_schema.sql b/database/video_schema.sql index d09a8a83..106f2664 100644 --- a/database/video_schema.sql +++ b/database/video_schema.sql @@ -231,6 +231,20 @@ CREATE INDEX IF NOT EXISTS idx_credits_movie ON credits(movie_id); CREATE INDEX IF NOT EXISTS idx_credits_show ON credits(show_id); CREATE INDEX IF NOT EXISTS idx_credits_person ON credits(person_id); +-- ── Discover: "Not interested" / ignore list ──────────────────────────────── +-- Titles the user has hidden from Discover (movie/show level only — episodes can't be +-- individually ignored). Keyed by (kind, tmdb_id); a denormalised title/poster snapshot so +-- the manage-modal renders without a TMDB round-trip. +CREATE TABLE IF NOT EXISTS video_ignored ( + kind TEXT NOT NULL, -- 'movie' | 'show' + tmdb_id INTEGER NOT NULL, + title TEXT, + year INTEGER, + poster_url TEXT, + added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (kind, tmdb_id) +); + -- ── Content: YouTube (channels → videos) ──────────────────────────────────── CREATE TABLE IF NOT EXISTS channels ( id INTEGER PRIMARY KEY,