diff --git a/api/video/calendar.py b/api/video/calendar.py index 76f7a1f0..7ca59b53 100644 --- a/api/video/calendar.py +++ b/api/video/calendar.py @@ -38,7 +38,9 @@ def register_routes(bp): except Exception: logger.exception("airs_time backfill queue failed") - eps = db.calendar_upcoming(start.isoformat(), end.isoformat()) + from core.video.sources import resolve_video_server + eps = db.calendar_upcoming(start.isoformat(), end.isoformat(), + server_source=resolve_video_server()) # Per-date counts drive the day-strip dots without a second query. counts: dict[str, int] = {} diff --git a/api/video/dashboard.py b/api/video/dashboard.py index 2e185039..5bc41305 100644 --- a/api/video/dashboard.py +++ b/api/video/dashboard.py @@ -17,13 +17,11 @@ def register_routes(bp): @bp.route("/dashboard", methods=["GET"]) def video_dashboard(): from . import get_video_db + from core.video.sources import resolve_video_server try: - stats = get_video_db().dashboard_stats() - try: - from core.video.sources import resolve_video_server - stats["server"] = resolve_video_server() # the VIDEO server, not music's active - except Exception: - stats["server"] = None + server = resolve_video_server() # the VIDEO server, not music's active + stats = get_video_db().dashboard_stats(server_source=server) + stats["server"] = server return jsonify(stats) except Exception: logger.exception("Failed to build video dashboard stats") diff --git a/api/video/library.py b/api/video/library.py index 6c57f97c..15a83ea3 100644 --- a/api/video/library.py +++ b/api/video/library.py @@ -17,6 +17,7 @@ def register_routes(bp): @bp.route("/library", methods=["GET"]) def video_library(): from . import get_video_db + from core.video.sources import resolve_video_server try: return jsonify(get_video_db().query_library( request.args.get("kind", "movies"), @@ -26,6 +27,7 @@ def register_routes(bp): status=request.args.get("status", "all"), page=request.args.get("page", 1), limit=request.args.get("limit", 75), + server_source=resolve_video_server(), )) except Exception: logger.exception("Failed to query video library") diff --git a/core/video/enrichment/engine.py b/core/video/enrichment/engine.py index 8a3a07df..362cd11e 100644 --- a/core/video/enrichment/engine.py +++ b/core/video/enrichment/engine.py @@ -279,9 +279,10 @@ 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"]) + r["library_id"] = self.db.library_id_for_tmdb(r["kind"], r["tmdb_id"], srv) return results def trending(self) -> list: @@ -298,9 +299,10 @@ class VideoEnrichmentEngine: 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"]) + r["library_id"] = self.db.library_id_for_tmdb(r["kind"], r["tmdb_id"], srv) return cached def tmdb_detail(self, kind, tmdb_id) -> dict | None: @@ -310,7 +312,7 @@ class VideoEnrichmentEngine: w = self.workers.get("tmdb") if not w or not w.enabled: return None - lib_id = self.db.library_id_for_tmdb(kind, tmdb_id) + lib_id = self.db.library_id_for_tmdb(kind, tmdb_id, self._server()) if lib_id: return {"redirect": {"source": "library", "kind": kind, "id": lib_id}} region = self._region() @@ -426,11 +428,21 @@ class VideoEnrichmentEngine: return None self._cache_put(("person", tmdb_id), p) # Re-annotate ownership fresh each call (cheap) so it tracks the library. + srv = self._server() for c in p.get("credits") or []: if c.get("tmdb_id"): - c["library_id"] = self.db.library_id_for_tmdb(c["kind"], c["tmdb_id"]) + c["library_id"] = self.db.library_id_for_tmdb(c["kind"], c["tmdb_id"], srv) return p + def _server(self): + """Active video server — scopes ownership lookups so an item owned only on + the inactive server doesn't read as owned (Plex/Jellyfin stay separate).""" + try: + from core.video.sources import resolve_video_server + return resolve_video_server(self.db) + except Exception: + return None + def worker(self, service): return self.workers.get(service) diff --git a/database/video_database.py b/database/video_database.py index 52e5b0d1..ec73f124 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -337,10 +337,11 @@ class VideoDatabase: finally: conn.close() - def library_id_for_tmdb(self, kind: str, tmdb_id) -> int | None: - """The library row id for a TMDB id if it's already owned, else None. Lets - the search → detail flow link owned titles to their real library detail - (instead of the live TMDB view).""" + def library_id_for_tmdb(self, kind: str, tmdb_id, server_source=None) -> int | None: + """The library row id for a TMDB id if it's owned on the active video + server (``server_source``), else None. Lets the search → detail flow link + owned titles to their real library detail — scoped per server so an item + owned only on the inactive server doesn't read as owned here.""" table = {"movie": "movies", "show": "shows"}.get(kind) if not table or tmdb_id is None: return None @@ -350,8 +351,13 @@ class VideoDatabase: return None conn = self._get_connection() try: - row = conn.execute( - f"SELECT id FROM {table} WHERE tmdb_id=? LIMIT 1", (tmdb_id,)).fetchone() + if server_source: + row = conn.execute( + f"SELECT id FROM {table} WHERE tmdb_id=? AND server_source=? LIMIT 1", + (tmdb_id, server_source)).fetchone() + else: + row = conn.execute( + f"SELECT id FROM {table} WHERE tmdb_id=? LIMIT 1", (tmdb_id,)).fetchone() return row["id"] if row else None except sqlite3.Error: return None @@ -687,23 +693,35 @@ class VideoDatabase: self.set_setting(server + ".tv_library", tv or "") # ── dashboard ───────────────────────────────────────────────────────────── - def dashboard_stats(self) -> dict: - """Live counts for the video dashboard, straight from video.db. + def dashboard_stats(self, server_source=None) -> dict: + """Live counts for the video dashboard, straight from video.db. Library + counts are scoped to the active video server (``server_source``) so Plex + and Jellyfin never commingle. Shape is stable so the frontend can map it directly; with an empty database every number is a real 0 (not a stub). """ + # server_source given → scope to that server; None → all servers. + mw = " WHERE server_source=?" if server_source else "" + sw = " WHERE s.server_source=?" if server_source else "" + sv = (server_source,) if server_source else () + size_sql = ("SELECT COALESCE(SUM(size_bytes), 0) FROM media_files mf " + "WHERE mf.movie_id IN (SELECT id FROM movies WHERE server_source=?) " + "OR mf.episode_id IN (SELECT e.id FROM episodes e JOIN shows s ON s.id=e.show_id " + "WHERE s.server_source=?)") if server_source else \ + "SELECT COALESCE(SUM(size_bytes), 0) FROM media_files" conn = self._get_connection() try: - def scalar(sql: str): - return conn.execute(sql).fetchone()[0] + def scalar(sql: str, params=()): + return conn.execute(sql, params).fetchone()[0] return { "library": { - "movies": scalar("SELECT COUNT(*) FROM movies"), - "shows": scalar("SELECT COUNT(*) FROM shows"), - "episodes": scalar("SELECT COUNT(*) FROM episodes"), - "size_bytes": scalar("SELECT COALESCE(SUM(size_bytes), 0) FROM media_files"), + "movies": scalar("SELECT COUNT(*) FROM movies" + mw, sv), + "shows": scalar("SELECT COUNT(*) FROM shows" + mw, sv), + "episodes": scalar( + "SELECT COUNT(*) FROM episodes e JOIN shows s ON s.id=e.show_id" + sw, sv), + "size_bytes": scalar(size_sql, (sv + sv) if server_source else ()), }, "downloads": { "active": scalar( @@ -1023,12 +1041,16 @@ class VideoDatabase: finally: conn.close() - def calendar_upcoming(self, start_date: str, end_date: str) -> list[dict]: - """Episodes airing in [start_date, end_date] (ISO) for OWNED shows — the - Calendar feed. Owned = the show is on a media server (server_source set), - so we surface upcoming episodes for things the user actually follows. Each - row carries owned/missing (has_file), a still flag, and show network/year - for the card. Ordered by air date then show then episode.""" + def calendar_upcoming(self, start_date: str, end_date: str, server_source=None) -> list[dict]: + """Episodes airing in [start_date, end_date] (ISO) for shows on the active + video server (``server_source``) — the Calendar feed. Scoped to one server + so Plex and Jellyfin never commingle. Each row carries owned/missing + (has_file), a still flag, and show network/airs_time/year for the card.""" + # server_source given → that server only; None → all owned shows. + if server_source: + srv_where, pre = "s.server_source = ?", [server_source] + else: + srv_where, pre = "s.server_source IS NOT NULL", [] conn = self._get_connection() try: rows = conn.execute( @@ -1039,11 +1061,11 @@ class VideoDatabase: "(s.poster_url IS NOT NULL AND s.poster_url<>'') AS show_has_poster, " "(s.backdrop_url IS NOT NULL AND s.backdrop_url<>'') AS show_has_backdrop " "FROM episodes e JOIN shows s ON s.id = e.show_id " - "WHERE s.server_source IS NOT NULL " + "WHERE " + srv_where + " " "AND e.air_date IS NOT NULL AND e.air_date >= ? AND e.air_date <= ? " "ORDER BY e.air_date, COALESCE(s.sort_title, s.title) COLLATE NOCASE, " "e.season_number, e.episode_number", - (start_date, end_date)).fetchall() + pre + [start_date, end_date]).fetchall() return [dict(r) for r in rows] finally: conn.close() @@ -1220,10 +1242,11 @@ class VideoDatabase: # ── paged/filtered/sorted library query (server-side, like music) ───────── def query_library(self, kind: str, *, search=None, letter=None, sort="title", - status="all", page=1, limit=75) -> dict: + status="all", page=1, limit=75, server_source=None) -> dict: """One page of movies/shows with search + A–Z + sort + owned/wanted - filtering done in SQL. Returns {items, pagination:{...}} mirroring the - music library's contract.""" + filtering done in SQL. Scoped to ``server_source`` (the active video + server) so Plex and Jellyfin libraries never commingle — mirrors how the + music side keeps servers separate. Returns {items, pagination:{...}}.""" try: page = max(1, int(page or 1)) limit = max(1, min(500, int(limit or 75))) @@ -1234,6 +1257,9 @@ class VideoDatabase: tbl = "shows" if is_shows else "movies" where, params = [], [] + if server_source: + where.append(f"{alias}.server_source = ?") + params.append(server_source) if search: where.append(f"{alias}.title LIKE ? COLLATE NOCASE") params.append("%" + search + "%") diff --git a/tests/test_video_database.py b/tests/test_video_database.py index 695ad360..37d82588 100644 --- a/tests/test_video_database.py +++ b/tests/test_video_database.py @@ -730,3 +730,40 @@ def test_video_db_uses_distinct_default_path_and_env(): assert "video_library.db" in src assert "VIDEO_DATABASE_PATH" in src # distinct env var from music's DATABASE_PATH assert "music_library.db" not in src + + +def test_servers_do_not_commingle_in_reads(db): + """Per-server isolation (mirrors the music side): the SAME movie/show on Plex + and Jellyfin are separate rows, and scoped reads only ever return the active + server's data — so a Jellyfin scan never shows up alongside Plex.""" + # Same title on both servers → two distinct rows. + db.upsert_movie("plex", {"server_id": "p1", "title": "Dune", "tmdb_id": 438631, "year": 2021}) + db.upsert_movie("jellyfin", {"server_id": "j1", "title": "Dune", "tmdb_id": 438631, "year": 2021}) + db.upsert_show_tree("plex", {"server_id": "ps", "title": "Severance", "tmdb_id": 95396, "seasons": []}) + db.upsert_show_tree("jellyfin", {"server_id": "js", "title": "Severance", "tmdb_id": 95396, "seasons": []}) + + # query_library is scoped per server (and unscoped == all). + assert db.query_library("movies", server_source="plex")["pagination"]["total_count"] == 1 + assert db.query_library("movies", server_source="jellyfin")["pagination"]["total_count"] == 1 + assert db.query_library("movies")["pagination"]["total_count"] == 2 + assert db.query_library("shows", server_source="plex")["pagination"]["total_count"] == 1 + + # dashboard counts are scoped; None counts everything. + assert db.dashboard_stats("plex")["library"]["movies"] == 1 + assert db.dashboard_stats("jellyfin")["library"]["movies"] == 1 + assert db.dashboard_stats()["library"]["movies"] == 2 + + # ownership lookup resolves to the row on the ACTIVE server, not the other. + plex_id = db.library_id_for_tmdb("movie", 438631, "plex") + jelly_id = db.library_id_for_tmdb("movie", 438631, "jellyfin") + assert plex_id is not None and jelly_id is not None and plex_id != jelly_id + + +def test_prune_is_scoped_to_one_server(db): + """A deep scan of one server must never prune the other server's rows.""" + db.upsert_movie("plex", {"server_id": "p1", "title": "Dune"}) + db.upsert_movie("jellyfin", {"server_id": "j1", "title": "Arrival"}) + # Deep-scan Plex sees nothing → prunes only Plex rows, leaves Jellyfin intact. + db.prune_missing("movies", "plex", seen_ids=[]) + assert db.query_library("movies", server_source="plex")["pagination"]["total_count"] == 0 + assert db.query_library("movies", server_source="jellyfin")["pagination"]["total_count"] == 1