video: detail-page data layer (show tree + movie) + backdrop proxy
Backend for the upcoming TV/movie detail pages, isolated to video.db:
- show_detail(id): show + seasons->episodes tree with owned/total roll-ups
(season 0 -> 'Specials', missing-season-row episodes still grouped).
- movie_detail(id): movie + owned flag + best media-file (resolution/quality).
- get_art_ref generalizes the poster ref to poster|backdrop; new
/api/video/backdrop/<kind>/<id> streams the hero art server-side (Jellyfin
Backdrop vs Primary handled).
- /api/video/detail/{show,movie}/<id> endpoints.
Seam tests for the tree roll-ups, owned/file, art ref, and both endpoints.
This commit is contained in:
parent
a483219746
commit
9b607b3d1b
6 changed files with 235 additions and 7 deletions
|
|
@ -43,11 +43,13 @@ def create_video_blueprint() -> Blueprint:
|
||||||
from .libraries import register_routes as reg_libraries
|
from .libraries import register_routes as reg_libraries
|
||||||
from .poster import register_routes as reg_poster
|
from .poster import register_routes as reg_poster
|
||||||
from .enrichment import register_routes as reg_enrichment
|
from .enrichment import register_routes as reg_enrichment
|
||||||
|
from .detail import register_routes as reg_detail
|
||||||
reg_dashboard(bp)
|
reg_dashboard(bp)
|
||||||
reg_scan(bp)
|
reg_scan(bp)
|
||||||
reg_library(bp)
|
reg_library(bp)
|
||||||
reg_libraries(bp)
|
reg_libraries(bp)
|
||||||
reg_poster(bp)
|
reg_poster(bp)
|
||||||
reg_enrichment(bp)
|
reg_enrichment(bp)
|
||||||
|
reg_detail(bp)
|
||||||
|
|
||||||
return bp
|
return bp
|
||||||
|
|
|
||||||
33
api/video/detail.py
Normal file
33
api/video/detail.py
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
"""Video detail payloads (drill-in pages).
|
||||||
|
|
||||||
|
GET /api/video/detail/show/<id> → show + seasons→episodes tree (owned roll-ups)
|
||||||
|
GET /api/video/detail/movie/<id> → movie + owned/file info
|
||||||
|
|
||||||
|
Reads only video.db; isolated from the music API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask import jsonify
|
||||||
|
|
||||||
|
from utils.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("video_api.detail")
|
||||||
|
|
||||||
|
|
||||||
|
def register_routes(bp):
|
||||||
|
@bp.route("/detail/show/<int:show_id>", methods=["GET"])
|
||||||
|
def video_show_detail(show_id):
|
||||||
|
from . import get_video_db
|
||||||
|
data = get_video_db().show_detail(show_id)
|
||||||
|
if not data:
|
||||||
|
return jsonify({"error": "not found"}), 404
|
||||||
|
return jsonify(data)
|
||||||
|
|
||||||
|
@bp.route("/detail/movie/<int:movie_id>", methods=["GET"])
|
||||||
|
def video_movie_detail(movie_id):
|
||||||
|
from . import get_video_db
|
||||||
|
data = get_video_db().movie_detail(movie_id)
|
||||||
|
if not data:
|
||||||
|
return jsonify({"error": "not found"}), 404
|
||||||
|
return jsonify(data)
|
||||||
|
|
@ -15,10 +15,9 @@ logger = get_logger("video_api.poster")
|
||||||
|
|
||||||
|
|
||||||
def register_routes(bp):
|
def register_routes(bp):
|
||||||
@bp.route("/poster/<kind>/<int:item_id>", methods=["GET"])
|
def _stream_art(kind, item_id, art):
|
||||||
def video_poster(kind, item_id):
|
|
||||||
from . import get_video_db
|
from . import get_video_db
|
||||||
ref = get_video_db().get_poster_ref(kind, item_id)
|
ref = get_video_db().get_art_ref(kind, item_id, art)
|
||||||
if not ref or not ref.get("poster_url"):
|
if not ref or not ref.get("poster_url"):
|
||||||
abort(404)
|
abort(404)
|
||||||
try:
|
try:
|
||||||
|
|
@ -37,7 +36,8 @@ def register_routes(bp):
|
||||||
base, key = cfg.get("base_url"), cfg.get("api_key")
|
base, key = cfg.get("base_url"), cfg.get("api_key")
|
||||||
if not base:
|
if not base:
|
||||||
abort(404)
|
abort(404)
|
||||||
url = base.rstrip("/") + f"/Items/{ref['server_id']}/Images/Primary"
|
image = "Backdrop" if art == "backdrop" else "Primary"
|
||||||
|
url = base.rstrip("/") + f"/Items/{ref['server_id']}/Images/{image}"
|
||||||
params = {"api_key": key} if key else {}
|
params = {"api_key": key} if key else {}
|
||||||
else:
|
else:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
@ -50,5 +50,13 @@ def register_routes(bp):
|
||||||
resp.headers["Cache-Control"] = "public, max-age=86400"
|
resp.headers["Cache-Control"] = "public, max-age=86400"
|
||||||
return resp
|
return resp
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("video poster proxy failed for %s/%s", kind, item_id)
|
logger.exception("video %s proxy failed for %s/%s", art, kind, item_id)
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
|
@bp.route("/poster/<kind>/<int:item_id>", methods=["GET"])
|
||||||
|
def video_poster(kind, item_id):
|
||||||
|
return _stream_art(kind, item_id, "poster")
|
||||||
|
|
||||||
|
@bp.route("/backdrop/<kind>/<int:item_id>", methods=["GET"])
|
||||||
|
def video_backdrop(kind, item_id):
|
||||||
|
return _stream_art(kind, item_id, "backdrop")
|
||||||
|
|
|
||||||
|
|
@ -600,18 +600,115 @@ class VideoDatabase:
|
||||||
|
|
||||||
def get_poster_ref(self, kind: str, item_id: int) -> dict | None:
|
def get_poster_ref(self, kind: str, item_id: int) -> dict | None:
|
||||||
"""Server source/id/poster path for one movie or show, for the poster proxy."""
|
"""Server source/id/poster path for one movie or show, for the poster proxy."""
|
||||||
|
return self.get_art_ref(kind, item_id, "poster")
|
||||||
|
|
||||||
|
def get_art_ref(self, kind: str, item_id: int, art: str = "poster") -> dict | None:
|
||||||
|
"""Server source/id + artwork path for one movie/show, for the image proxy.
|
||||||
|
``art`` is 'poster' or 'backdrop'. Returns the path under 'poster_url' so
|
||||||
|
the proxy is artwork-agnostic."""
|
||||||
table = {"movie": "movies", "show": "shows"}.get(kind)
|
table = {"movie": "movies", "show": "shows"}.get(kind)
|
||||||
if not table:
|
col = {"poster": "poster_url", "backdrop": "backdrop_url"}.get(art)
|
||||||
|
if not table or not col:
|
||||||
return None
|
return None
|
||||||
conn = self._get_connection()
|
conn = self._get_connection()
|
||||||
try:
|
try:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
f"SELECT server_source, server_id, poster_url FROM {table} WHERE id=?",
|
f"SELECT server_source, server_id, {col} AS poster_url FROM {table} WHERE id=?",
|
||||||
(item_id,)).fetchone()
|
(item_id,)).fetchone()
|
||||||
return dict(row) if row else None
|
return dict(row) if row else None
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
# ── detail payloads (drill-in pages) ──────────────────────────────────────
|
||||||
|
def show_detail(self, show_id: int) -> dict | None:
|
||||||
|
"""Full TV-show detail: the show + its seasons → episodes tree, with
|
||||||
|
owned/total roll-ups. Drives the (isolated) video show-detail page."""
|
||||||
|
conn = self._get_connection()
|
||||||
|
try:
|
||||||
|
show = conn.execute("SELECT * FROM shows WHERE id=?", (show_id,)).fetchone()
|
||||||
|
if not show:
|
||||||
|
return None
|
||||||
|
seasons = conn.execute(
|
||||||
|
"SELECT id, season_number, title, overview, "
|
||||||
|
"(poster_url IS NOT NULL AND poster_url<>'') AS has_poster "
|
||||||
|
"FROM seasons WHERE show_id=? ORDER BY season_number", (show_id,)).fetchall()
|
||||||
|
eps = conn.execute(
|
||||||
|
"SELECT id, season_number, episode_number, title, overview, air_date, "
|
||||||
|
"runtime_minutes, monitored, has_file FROM episodes WHERE show_id=? "
|
||||||
|
"ORDER BY season_number, episode_number", (show_id,)).fetchall()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
by_season: dict = {}
|
||||||
|
for e in eps:
|
||||||
|
by_season.setdefault(e["season_number"], []).append({
|
||||||
|
"id": e["id"], "episode_number": e["episode_number"],
|
||||||
|
"title": e["title"], "overview": e["overview"], "air_date": e["air_date"],
|
||||||
|
"runtime_minutes": e["runtime_minutes"],
|
||||||
|
"monitored": bool(e["monitored"]), "owned": bool(e["has_file"]),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Seasons declared in the seasons table, plus any season numbers that only
|
||||||
|
# exist via episodes (defensive — a show with episodes but no season row).
|
||||||
|
season_nums = [s["season_number"] for s in seasons]
|
||||||
|
season_meta = {s["season_number"]: s for s in seasons}
|
||||||
|
for num in by_season:
|
||||||
|
if num not in season_meta:
|
||||||
|
season_nums.append(num)
|
||||||
|
out_seasons = []
|
||||||
|
for num in sorted(set(season_nums)):
|
||||||
|
ep_list = by_season.get(num, [])
|
||||||
|
owned = sum(1 for e in ep_list if e["owned"])
|
||||||
|
meta = season_meta.get(num)
|
||||||
|
out_seasons.append({
|
||||||
|
"season_number": num,
|
||||||
|
"title": (meta["title"] if meta else None) or (
|
||||||
|
"Specials" if num == 0 else "Season %d" % num),
|
||||||
|
"overview": meta["overview"] if meta else None,
|
||||||
|
"has_poster": bool(meta["has_poster"]) if meta else False,
|
||||||
|
"episode_total": len(ep_list),
|
||||||
|
"episode_owned": owned,
|
||||||
|
"episodes": ep_list,
|
||||||
|
})
|
||||||
|
|
||||||
|
total = len(eps)
|
||||||
|
owned_total = sum(1 for e in eps if e["has_file"])
|
||||||
|
return {
|
||||||
|
"kind": "show", "id": show["id"], "title": show["title"], "year": show["year"],
|
||||||
|
"overview": show["overview"], "status": show["status"], "network": show["network"],
|
||||||
|
"content_rating": show["content_rating"], "runtime_minutes": show["runtime_minutes"],
|
||||||
|
"tmdb_id": show["tmdb_id"], "tvdb_id": show["tvdb_id"], "imdb_id": show["imdb_id"],
|
||||||
|
"has_poster": bool(show["poster_url"]), "has_backdrop": bool(show["backdrop_url"]),
|
||||||
|
"season_count": len(out_seasons),
|
||||||
|
"episode_total": total, "episode_owned": owned_total,
|
||||||
|
"seasons": out_seasons,
|
||||||
|
}
|
||||||
|
|
||||||
|
def movie_detail(self, movie_id: int) -> dict | None:
|
||||||
|
"""Full movie detail: the movie + owned/file info. Drives the (isolated)
|
||||||
|
video movie-detail page."""
|
||||||
|
conn = self._get_connection()
|
||||||
|
try:
|
||||||
|
m = conn.execute("SELECT * FROM movies WHERE id=?", (movie_id,)).fetchone()
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
f = conn.execute(
|
||||||
|
"SELECT resolution, quality, video_codec, audio_codec, size_bytes "
|
||||||
|
"FROM media_files WHERE movie_id=? ORDER BY size_bytes DESC LIMIT 1",
|
||||||
|
(movie_id,)).fetchone()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return {
|
||||||
|
"kind": "movie", "id": m["id"], "title": m["title"], "year": m["year"],
|
||||||
|
"overview": m["overview"], "status": m["status"], "studio": m["studio"],
|
||||||
|
"release_date": m["release_date"], "runtime_minutes": m["runtime_minutes"],
|
||||||
|
"content_rating": m["content_rating"],
|
||||||
|
"tmdb_id": m["tmdb_id"], "imdb_id": m["imdb_id"],
|
||||||
|
"has_poster": bool(m["poster_url"]), "has_backdrop": bool(m["backdrop_url"]),
|
||||||
|
"owned": bool(m["has_file"]), "monitored": bool(m["monitored"]),
|
||||||
|
"file": (dict(f) if f else None),
|
||||||
|
}
|
||||||
|
|
||||||
# ── paged/filtered/sorted library query (server-side, like music) ─────────
|
# ── paged/filtered/sorted library query (server-side, like music) ─────────
|
||||||
def query_library(self, kind: str, *, search=None, letter=None, sort="title",
|
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) -> dict:
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,39 @@ def test_blueprint_exposes_dashboard_route():
|
||||||
assert "/api/video/enrichment/<service>/unmatched" in rules
|
assert "/api/video/enrichment/<service>/unmatched" in rules
|
||||||
assert "/api/video/enrichment/config" in rules
|
assert "/api/video/enrichment/config" in rules
|
||||||
assert "/api/video/enrichment/<service>/test" in rules
|
assert "/api/video/enrichment/<service>/test" in rules
|
||||||
|
assert "/api/video/detail/show/<int:show_id>" in rules
|
||||||
|
assert "/api/video/detail/movie/<int:movie_id>" in rules
|
||||||
|
assert any(r.startswith("/api/video/backdrop/") for r in rules)
|
||||||
|
|
||||||
|
|
||||||
|
def test_show_detail_endpoint(tmp_path):
|
||||||
|
client, videoapi = _make_client(tmp_path)
|
||||||
|
try:
|
||||||
|
sid = videoapi._video_db.upsert_show_tree("plex", {
|
||||||
|
"server_id": "s1", "title": "Show", "seasons": [
|
||||||
|
{"season_number": 1, "episodes": [
|
||||||
|
{"episode_number": 1, "title": "Pilot",
|
||||||
|
"file": {"relative_path": "e1.mkv", "size_bytes": 5}}]}]})
|
||||||
|
resp = client.get("/api/video/detail/show/%d" % sid)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
d = resp.get_json()
|
||||||
|
assert d["kind"] == "show" and d["episode_total"] == 1 and d["episode_owned"] == 1
|
||||||
|
assert d["seasons"][0]["episodes"][0]["title"] == "Pilot"
|
||||||
|
assert client.get("/api/video/detail/show/999999").status_code == 404
|
||||||
|
finally:
|
||||||
|
videoapi._video_db = None
|
||||||
|
|
||||||
|
|
||||||
|
def test_movie_detail_endpoint(tmp_path):
|
||||||
|
client, videoapi = _make_client(tmp_path)
|
||||||
|
try:
|
||||||
|
mid = videoapi._video_db.upsert_movie("plex", {"server_id": "m1", "title": "Dune"})
|
||||||
|
resp = client.get("/api/video/detail/movie/%d" % mid)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["title"] == "Dune"
|
||||||
|
assert client.get("/api/video/detail/movie/999999").status_code == 404
|
||||||
|
finally:
|
||||||
|
videoapi._video_db = None
|
||||||
|
|
||||||
|
|
||||||
def test_dashboard_endpoint_returns_zeroed_json(tmp_path):
|
def test_dashboard_endpoint_returns_zeroed_json(tmp_path):
|
||||||
|
|
|
||||||
|
|
@ -241,6 +241,61 @@ def test_upsert_stores_provider_ids(db):
|
||||||
assert erow["tvdb_id"] == 349232
|
assert erow["tvdb_id"] == 349232
|
||||||
|
|
||||||
|
|
||||||
|
def test_show_detail_builds_season_episode_tree_with_rollups(db):
|
||||||
|
sid = db.upsert_show_tree("plex", {
|
||||||
|
"server_id": "s1", "title": "Show", "year": 2019, "overview": "A show",
|
||||||
|
"network": "HBO", "content_rating": "TV-MA", "status": "ended",
|
||||||
|
"poster_url": "/p.jpg", "seasons": [
|
||||||
|
{"season_number": 0, "episodes": [{"episode_number": 1, "title": "Special"}]},
|
||||||
|
{"season_number": 1, "title": "Season One", "episodes": [
|
||||||
|
{"episode_number": 1, "title": "Pilot", "air_date": "2019-01-01",
|
||||||
|
"file": {"relative_path": "e1.mkv", "size_bytes": 5}},
|
||||||
|
{"episode_number": 2, "title": "Two", "air_date": "2019-01-08"}]}]})
|
||||||
|
# backdrop_url is filled by TMDB enrichment, not the scan — simulate that.
|
||||||
|
with db.connect() as c:
|
||||||
|
c.execute("UPDATE shows SET backdrop_url='/b.jpg' WHERE id=?", (sid,))
|
||||||
|
c.commit()
|
||||||
|
d = db.show_detail(sid)
|
||||||
|
assert d["title"] == "Show" and d["network"] == "HBO" and d["status"] == "ended"
|
||||||
|
assert d["has_poster"] and d["has_backdrop"]
|
||||||
|
assert (d["episode_total"], d["episode_owned"], d["season_count"]) == (3, 1, 2)
|
||||||
|
# Season 0 renders as "Specials"; season 1 keeps its title; ordered by number.
|
||||||
|
assert [s["season_number"] for s in d["seasons"]] == [0, 1]
|
||||||
|
assert d["seasons"][0]["title"] == "Specials"
|
||||||
|
s1 = d["seasons"][1]
|
||||||
|
assert s1["title"] == "Season One"
|
||||||
|
assert (s1["episode_total"], s1["episode_owned"]) == (2, 1)
|
||||||
|
assert s1["episodes"][0]["owned"] is True and s1["episodes"][1]["owned"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_show_detail_returns_none_for_missing(db):
|
||||||
|
assert db.show_detail(999999) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_movie_detail_includes_owned_and_file(db):
|
||||||
|
mid = db.upsert_movie("plex", {
|
||||||
|
"server_id": "m1", "title": "Dune", "year": 2021, "overview": "Sand",
|
||||||
|
"tmdb_id": 438631, "poster_url": "/p.jpg",
|
||||||
|
"file": {"relative_path": "dune.mkv", "size_bytes": 99, "resolution": "2160p"}})
|
||||||
|
d = db.movie_detail(mid)
|
||||||
|
assert d["title"] == "Dune" and d["owned"] is True and d["tmdb_id"] == 438631
|
||||||
|
assert d["file"] and d["file"]["resolution"] == "2160p"
|
||||||
|
# A wishlist movie with no file reports owned False, file None.
|
||||||
|
mid2 = db.upsert_movie("plex", {"server_id": "m2", "title": "Wanted"})
|
||||||
|
d2 = db.movie_detail(mid2)
|
||||||
|
assert d2["owned"] is False and d2["file"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_art_ref_poster_vs_backdrop(db):
|
||||||
|
sid = db.upsert_show_tree("plex", {"server_id": "s1", "title": "S", "poster_url": "/p.jpg"})
|
||||||
|
with db.connect() as c: # backdrop is enrichment-filled, not scanned
|
||||||
|
c.execute("UPDATE shows SET backdrop_url='/b.jpg' WHERE id=?", (sid,))
|
||||||
|
c.commit()
|
||||||
|
assert db.get_art_ref("show", sid, "poster")["poster_url"] == "/p.jpg"
|
||||||
|
assert db.get_art_ref("show", sid, "backdrop")["poster_url"] == "/b.jpg"
|
||||||
|
assert db.get_art_ref("show", sid, "bogus") is None
|
||||||
|
|
||||||
|
|
||||||
def test_prune_missing_skips_when_over_half_would_be_removed(db):
|
def test_prune_missing_skips_when_over_half_would_be_removed(db):
|
||||||
# >100 movies; a scan that "sees" only a couple must NOT wipe the rest
|
# >100 movies; a scan that "sees" only a couple must NOT wipe the rest
|
||||||
# (mirrors music's deep-scan 50% safety against partial server failures).
|
# (mirrors music's deep-scan 50% safety against partial server failures).
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue