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).
This commit is contained in:
BoulderBadgeDad 2026-06-23 00:25:10 -07:00
parent d019e3aca1
commit 3f344a8cd9
4 changed files with 100 additions and 1 deletions

View file

@ -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).

View file

@ -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:

View file

@ -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}]."""

View file

@ -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,