soulsync/api/video/poster.py
BoulderBadgeDad 9b607b3d1b 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.
2026-06-14 15:52:22 -07:00

62 lines
2.4 KiB
Python

"""Video poster proxy.
GET /api/video/poster/<kind>/<id> streams a movie/show poster from the media
server, server-side (so the Plex token / Jellyfin key never reaches the
browser). Falls back to 404 so the frontend shows its placeholder.
"""
from __future__ import annotations
from flask import Response, abort
from utils.logging_config import get_logger
logger = get_logger("video_api.poster")
def register_routes(bp):
def _stream_art(kind, item_id, art):
from . import get_video_db
ref = get_video_db().get_art_ref(kind, item_id, art)
if not ref or not ref.get("poster_url"):
abort(404)
try:
import requests
from config.settings import config_manager
source = ref.get("server_source")
if source == "plex":
cfg = config_manager.get_plex_config() or {}
base, token = cfg.get("base_url"), cfg.get("token")
if not base or not token:
abort(404)
url = base.rstrip("/") + ref["poster_url"]
params = {"X-Plex-Token": token}
elif source == "jellyfin":
cfg = config_manager.get_jellyfin_config() or {}
base, key = cfg.get("base_url"), cfg.get("api_key")
if not base:
abort(404)
image = "Backdrop" if art == "backdrop" else "Primary"
url = base.rstrip("/") + f"/Items/{ref['server_id']}/Images/{image}"
params = {"api_key": key} if key else {}
else:
abort(404)
upstream = requests.get(url, params=params, timeout=15, stream=True)
if upstream.status_code != 200:
abort(404)
ctype = upstream.headers.get("Content-Type", "image/jpeg")
resp = Response(upstream.iter_content(8192), content_type=ctype)
resp.headers["Cache-Control"] = "public, max-age=86400"
return resp
except Exception:
logger.exception("video %s proxy failed for %s/%s", art, kind, item_id)
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")