diff --git a/api/video/discover.py b/api/video/discover.py index 747ae520..940b9554 100644 --- a/api/video/discover.py +++ b/api/video/discover.py @@ -34,6 +34,23 @@ def register_routes(bp): items = [] return jsonify({"items": items}) + @bp.route("/discover/taste", methods=["GET"]) + def video_discover_taste(): + """The user's most-owned genres (movies + shows) → personalized rails.""" + from . import get_video_db + try: + from core.video.sources import resolve_video_server + srv = resolve_video_server() + except Exception: + srv = None + db = get_video_db() + try: + return jsonify({"movie": db.top_owned_genres("movie", srv, 6), + "show": db.top_owned_genres("show", srv, 6)}) + except Exception: + logger.exception("discover taste failed") + return jsonify({"movie": [], "show": []}) + @bp.route("/discover/genres", methods=["GET"]) def video_discover_genres(): """Genre id→name maps for both kinds (powers the genre rails + filter).""" diff --git a/core/video/enrichment/engine.py b/core/video/enrichment/engine.py index c76a44e0..421fa309 100644 --- a/core/video/enrichment/engine.py +++ b/core/video/enrichment/engine.py @@ -279,11 +279,7 @@ class VideoEnrichmentEngine: except Exception: logger.exception("video search failed for %r", query) return [] - srv = self._server() - for r in results: - if r.get("kind") in ("movie", "show") and r.get("tmdb_id"): - r["library_id"] = self.db.library_id_for_tmdb(r["kind"], r["tmdb_id"], srv) - return results + return self._stamp_owned(results) def trending(self) -> list: """Trending titles for the idle search page, annotated owned/not.""" @@ -298,21 +294,26 @@ class VideoEnrichmentEngine: except Exception: logger.exception("video trending failed") return [] - # Re-annotate ownership fresh each call (cheap) so it tracks the library. - srv = self._server() - for r in cached: - if r.get("tmdb_id"): - r["library_id"] = self.db.library_id_for_tmdb(r["kind"], r["tmdb_id"], srv) - return cached + # Re-annotate ownership fresh each call (batched) so it tracks the library. + return self._stamp_owned(cached) # ── discover (browse TMDB lists; owned titles annotated) ────────────────── def _stamp_owned(self, items): """Annotate each movie/show with its library_id for the active server — - stamped fresh (not cached with ownership) so 'In Library' tracks scans.""" + stamped fresh (not cached with ownership) so 'In Library' tracks scans. + Batched: one query per kind for the whole list, not one per item.""" srv = self._server() + by_kind: dict = {} for r in items or []: if r.get("kind") in ("movie", "show") and r.get("tmdb_id"): - r["library_id"] = self.db.library_id_for_tmdb(r["kind"], r["tmdb_id"], srv) + by_kind.setdefault(r["kind"], []).append(r["tmdb_id"]) + maps = {k: self.db.library_ids_for_tmdb(k, ids, srv) for k, ids in by_kind.items()} + for r in items or []: + if r.get("kind") in ("movie", "show") and r.get("tmdb_id"): + try: + r["library_id"] = maps.get(r["kind"], {}).get(int(r["tmdb_id"])) + except (TypeError, ValueError): + r["library_id"] = None return items def discover_curated(self, key, page=1) -> list: diff --git a/database/video_database.py b/database/video_database.py index ae1b1519..96cf8a05 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -365,6 +365,66 @@ class VideoDatabase: finally: conn.close() + def library_ids_for_tmdb(self, kind: str, tmdb_ids, server_source=None) -> dict: + """{tmdb_id: library_row_id} for the owned subset of ``tmdb_ids`` on the + active server. Batched (chunked IN) so a whole Discover rail costs one + query per kind instead of one connection+query per item.""" + table = {"movie": "movies", "show": "shows"}.get(kind) + out: dict = {} + if not table: + return out + ids = [] + for x in (tmdb_ids or []): + try: + ids.append(int(x)) + except (TypeError, ValueError): + pass + if not ids: + return out + conn = self._get_connection() + try: + for i in range(0, len(ids), 400): # stay under SQLite's variable cap + chunk = ids[i:i + 400] + ph = ",".join("?" * len(chunk)) + sql = f"SELECT id, tmdb_id FROM {table} WHERE tmdb_id IN ({ph})" + args = list(chunk) + if server_source: + sql += " AND server_source=?" + args.append(server_source) + for row in conn.execute(sql, args): + out.setdefault(row["tmdb_id"], row["id"]) # first match wins + return out + except sqlite3.Error: + return out + finally: + conn.close() + + def top_owned_genres(self, kind: str, server_source=None, limit: int = 6) -> list: + """The user's most-owned genre names for movies/shows, busiest first — + drives Discover's personalized 'Because you like …' rails.""" + if kind == "movie": + link, owner, tbl, owned = "movie_genres", "movie_id", "movies", "t.has_file=1" + elif kind == "show": + link, owner, tbl, owned = "show_genres", "show_id", "shows", "1=1" # any library show counts + else: + return [] + sql = (f"SELECT g.name AS name, COUNT(*) AS c FROM {link} lt " + f"JOIN genres g ON g.id = lt.genre_id " + f"JOIN {tbl} t ON t.id = lt.{owner} WHERE {owned}") + args: list = [] + if server_source: + sql += " AND t.server_source=?" + args.append(server_source) + sql += " GROUP BY g.name ORDER BY c DESC, g.name LIMIT ?" + args.append(int(limit)) + conn = self._get_connection() + try: + return [r["name"] for r in conn.execute(sql, args)] + except sqlite3.Error: + return [] + finally: + conn.close() + def apply_ratings(self, kind: str, item_id: int, ratings: dict) -> None: """Store IMDb / RT / Metacritic scores (from OMDb) + mark ratings_synced. Ratings are dynamic, so these overwrite (unlike gap-only metadata).""" diff --git a/tests/test_video_enrichment.py b/tests/test_video_enrichment.py index 415e3bfc..6531ca77 100644 --- a/tests/test_video_enrichment.py +++ b/tests/test_video_enrichment.py @@ -1116,3 +1116,49 @@ def test_engine_discover_swallows_client_errors(db): assert eng.discover_curated("popular_movies") == [] assert eng.discover_filter("movie", genre=1) == [] assert eng.genre_list("movie") == [] + + +def test_library_ids_for_tmdb_batched(db): + m1 = db.upsert_movie("plex", {"server_id": "m1", "title": "A", "tmdb_id": 10}) + m2 = db.upsert_movie("plex", {"server_id": "m2", "title": "B", "tmdb_id": 20}) + sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "tmdb_id": 30, "seasons": []}) + assert db.library_ids_for_tmdb("movie", [10, 20, 99]) == {10: m1, 20: m2} # 99 not owned + assert db.library_ids_for_tmdb("show", [30, 31]) == {30: sid} + assert db.library_ids_for_tmdb("movie", []) == {} + assert db.library_ids_for_tmdb("bogus", [10]) == {} + + +def test_library_ids_for_tmdb_scopes_by_server(db): + db.upsert_movie("plex", {"server_id": "m1", "title": "A", "tmdb_id": 10}) + db.upsert_movie("jellyfin", {"server_id": "j1", "title": "A", "tmdb_id": 11}) + got = db.library_ids_for_tmdb("movie", [10, 11], server_source="plex") + assert 10 in got and 11 not in got # the other server's copy doesn't count + + +def test_stamp_owned_is_one_query_per_kind(db, monkeypatch): + # The whole rail must cost one ownership query per kind, not one per item. + db.upsert_movie("plex", {"server_id": "m1", "title": "Owned", "tmdb_id": 1}) + calls = {"n": 0} + real = db.library_ids_for_tmdb + def counted(kind, ids, server_source=None): + calls["n"] += 1 + return real(kind, ids, server_source) + monkeypatch.setattr(db, "library_ids_for_tmdb", counted) + eng = VideoEnrichmentEngine(db, {}) + items = [{"kind": "movie", "tmdb_id": i, "title": "T%d" % i} for i in range(1, 21)] + items.append({"kind": "show", "tmdb_id": 99, "title": "Show"}) + eng._stamp_owned(items) + assert calls["n"] == 2 # one movie query + one show query + assert items[0]["library_id"] is not None and items[1]["library_id"] is None + + +def test_top_owned_genres_orders_by_count(db): + db.upsert_movie("plex", {"server_id": "m1", "title": "A", "tmdb_id": 1, + "genres": ["Action", "Drama"], "file": {"relative_path": "a.mkv"}}) + db.upsert_movie("plex", {"server_id": "m2", "title": "B", "tmdb_id": 2, + "genres": ["Action"], "file": {"relative_path": "b.mkv"}}) + db.upsert_movie("plex", {"server_id": "m3", "title": "C", "tmdb_id": 3, + "genres": ["Comedy"]}) # no file → not owned → excluded + g = db.top_owned_genres("movie", server_source="plex", limit=5) + assert g[0] == "Action" # owned twice → first + assert "Drama" in g and "Comedy" not in g # Comedy movie isn't owned diff --git a/webui/index.html b/webui/index.html index 0b22b441..c6ac5b4a 100644 --- a/webui/index.html +++ b/webui/index.html @@ -868,7 +868,8 @@ a filter bar that flips into a paged grid. Built by video/video-discover.js, styled by .vdsc-* in video-side.css. -->