Video: scope library reads to the active server (Plex/Jellyfin don't commingle)

Storage was already per-server (movies/shows UNIQUE(server_source, server_id),
episodes via per-server show_id, prune_missing scoped) — but reads returned
every server's rows, so a Jellyfin scan would show up alongside Plex.

Mirror the music standard: scope reads to the active video server
(resolve_video_server). query_library, calendar_upcoming, dashboard_stats and
library_id_for_tmdb take a server_source; the dashboard/library/calendar
endpoints pass it. server_source=None keeps "all servers" (enrichment processes
every server; tests unchanged). No schema change, no data migration — existing
Plex data is untouched and simply hidden while Jellyfin is the active server.

Regression tests: same title on both servers stays two rows; scoped reads only
return the active server's data; deep-scan prune never touches the other server.
This commit is contained in:
BoulderBadgeDad 2026-06-15 18:45:28 -07:00
parent 8e00670491
commit 3a8c803a54
6 changed files with 113 additions and 36 deletions

View file

@ -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] = {}

View file

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

View file

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

View file

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

View file

@ -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 + AZ + 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 + "%")

View file

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