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:
parent
8e00670491
commit
3a8c803a54
6 changed files with 113 additions and 36 deletions
|
|
@ -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] = {}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 + "%")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue