refactor(plex): extract img/subtitle/rollup helpers into app/plex services
R12 S1 step A. Move the pure, leaf-level helpers out of the 2227-line routes/plex.py into the app/plex service package with public names: - app/plex/imgcache.py — on-disk poster/art cache + proxy (was _img_cache_*, _image_key, _img_response, _IMG_CACHE/_IMG_SIZES). Owns the shared cache dir. - app/plex/subtitles.py — WebVTT convert/time-shift + subtitle cache (was _srt_to_vtt, _shift_vtt, _ffmpeg_to_vtt, _sub_cache_*, _BITMAP_SUBS). - app/plex/rollup.py — show watch-state aggregation (was _rollup, _show_status, _show_agg_subq, _show_state_map). Behaviour-equivalent move: bodies verbatim, only the public API renamed and call sites re-qualified. No endpoint or SQL change (db.query kept as-is; the 2.0 select() conversion is a later step). plex.py 2227 -> 2011 lines. Gate: ruff clean, pytest 193 passed (pure-logic + DB lane).
This commit is contained in:
@@ -0,0 +1,68 @@
|
|||||||
|
"""Thin on-disk cache + proxy helpers for Plex poster/art images.
|
||||||
|
|
||||||
|
Posters/art/cast-photos are proxied on demand; without a cache every scroll re-hits Plex over the
|
||||||
|
LAN (janky). We cache the fetched bytes on local disk keyed by a stable id, so repeat views (and
|
||||||
|
other users) serve instantly from disk. On-demand + bounded by the library size; no eviction (a
|
||||||
|
few hundred MB at most for a big library — acceptable, matching the download disk trade-offs).
|
||||||
|
|
||||||
|
`CACHE_DIR` is the shared Plex on-disk cache root; converted subtitle VTTs live here too
|
||||||
|
(see ``subtitles.py``).
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import Response
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.models import PlexCollection, PlexItem, PlexSeason, PlexShow
|
||||||
|
|
||||||
|
CACHE_DIR = Path(settings.download_root) / ".plex-img-cache"
|
||||||
|
_EXT_CT = [("jpg", "image/jpeg"), ("png", "image/png"), ("webp", "image/webp")]
|
||||||
|
_CT_EXT = {ct: ext for ext, ct in _EXT_CT}
|
||||||
|
# Target box per variant — Plex resizes to these server-side. Posters fill ≤176px cells (grid/info/
|
||||||
|
# strips), so 400px covers 2× DPR; the faint backdrop art only needs ~1280px. Keeps images ~tens of KB.
|
||||||
|
SIZES = {"thumb": (400, 600), "art": (1280, 720)}
|
||||||
|
|
||||||
|
|
||||||
|
def key_for(db: Session, rating_key: str, variant: str) -> str | None:
|
||||||
|
"""The stored Plex image path for a known rating_key (item/show/season). Only mirrored keys
|
||||||
|
are proxyable — this is not an open image proxy."""
|
||||||
|
it = db.query(PlexItem).filter_by(rating_key=rating_key).first()
|
||||||
|
if it is not None:
|
||||||
|
return it.art_key if variant == "art" else it.thumb_key
|
||||||
|
sh = db.query(PlexShow).filter_by(rating_key=rating_key).first()
|
||||||
|
if sh is not None:
|
||||||
|
return sh.art_key if variant == "art" else sh.thumb_key
|
||||||
|
se = db.query(PlexSeason).filter_by(rating_key=rating_key).first()
|
||||||
|
if se is not None:
|
||||||
|
return se.thumb_key
|
||||||
|
col = db.query(PlexCollection).filter_by(rating_key=rating_key).first()
|
||||||
|
if col is not None:
|
||||||
|
return col.thumb_key
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def read(key: str) -> tuple[bytes, str] | None:
|
||||||
|
for ext, ct in _EXT_CT:
|
||||||
|
p = CACHE_DIR / f"{key}.{ext}"
|
||||||
|
try:
|
||||||
|
if p.exists() and p.stat().st_size > 0:
|
||||||
|
return p.read_bytes(), ct
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def write(key: str, data: bytes, ct: str) -> None:
|
||||||
|
ext = _CT_EXT.get((ct or "").split(";")[0].strip(), "jpg")
|
||||||
|
try:
|
||||||
|
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = CACHE_DIR / f"{key}.{ext}.tmp"
|
||||||
|
tmp.write_bytes(data)
|
||||||
|
tmp.replace(CACHE_DIR / f"{key}.{ext}") # atomic publish
|
||||||
|
except OSError:
|
||||||
|
pass # cache is best-effort
|
||||||
|
|
||||||
|
|
||||||
|
def response(data: bytes, ct: str) -> Response:
|
||||||
|
return Response(content=data, media_type=ct, headers={"Cache-Control": "public, max-age=86400"})
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""Roll a show's episodes up into a single watch-state (new | in_progress | watched) + on-deck.
|
||||||
|
|
||||||
|
Drives the show/season grid badges and the Resume / Play-from-start / mark-all buttons.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import and_, case, func, or_
|
||||||
|
from sqlalchemy.orm import Session, aliased
|
||||||
|
|
||||||
|
from app.models import PlexItem, PlexState
|
||||||
|
|
||||||
|
|
||||||
|
def show_status(total: int, watched: int, inprog: int) -> str:
|
||||||
|
"""Roll a show's episodes up into a single watch state: fully watched, partially started, or new."""
|
||||||
|
if total and watched >= total:
|
||||||
|
return "watched"
|
||||||
|
if (watched or 0) > 0 or (inprog or 0) > 0:
|
||||||
|
return "in_progress"
|
||||||
|
return "new"
|
||||||
|
|
||||||
|
|
||||||
|
def rollup(cards: list[dict]) -> dict:
|
||||||
|
"""From ORDERED episode cards → the aggregate watch status, the "on deck" episode to Resume (the
|
||||||
|
last in-progress one, else the first unwatched), the first episode (for Play from the start), and
|
||||||
|
the episode count. Drives the show/season Resume + Play + mark-all buttons."""
|
||||||
|
total = len(cards)
|
||||||
|
watched = sum(1 for c in cards if c.get("status") == "watched")
|
||||||
|
inprog = [c for c in cards if (c.get("position_seconds") or 0) > 0 and c.get("status") != "watched"]
|
||||||
|
resume = None
|
||||||
|
if inprog:
|
||||||
|
resume = inprog[-1] # continue the latest-started episode
|
||||||
|
else:
|
||||||
|
resume = next((c for c in cards if c.get("status") != "watched"), None)
|
||||||
|
return {
|
||||||
|
"status": show_status(total, watched, len(inprog)),
|
||||||
|
"resume": resume,
|
||||||
|
"first": cards[0] if cards else None,
|
||||||
|
"episode_count": total,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def agg_subquery(db: Session, user_id: int):
|
||||||
|
"""Per-show episode counts for a user: total, watched, in-progress. Reused to (a) filter the show
|
||||||
|
grid by aggregate watch-state and (b) compute each card's badge."""
|
||||||
|
ps = aliased(PlexState)
|
||||||
|
return (
|
||||||
|
db.query(
|
||||||
|
PlexItem.show_id.label("show_id"),
|
||||||
|
func.count(PlexItem.id).label("total"),
|
||||||
|
func.count(case((ps.status == "watched", 1))).label("watched"),
|
||||||
|
func.count(
|
||||||
|
case(
|
||||||
|
(
|
||||||
|
and_(ps.position_seconds > 0, or_(ps.status.is_(None), ps.status != "watched")),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).label("inprog"),
|
||||||
|
)
|
||||||
|
.outerjoin(ps, and_(ps.item_id == PlexItem.id, ps.user_id == user_id))
|
||||||
|
.filter(PlexItem.kind == "episode", PlexItem.show_id.isnot(None))
|
||||||
|
.group_by(PlexItem.show_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def state_map(db: Session, user_id: int, show_ids: list[int]) -> dict[int, str]:
|
||||||
|
if not show_ids:
|
||||||
|
return {}
|
||||||
|
rows = agg_subquery(db, user_id).filter(PlexItem.show_id.in_(show_ids)).all()
|
||||||
|
return {r.show_id: show_status(r.total, r.watched, r.inprog) for r in rows}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"""WebVTT conversion, HLS-clock time-shift, and on-disk cache for Plex subtitle streams.
|
||||||
|
|
||||||
|
Text subtitles (embedded or external) are turned into WebVTT so the browser <track> can render them;
|
||||||
|
bitmap subtitles (Blu-ray/DVD) can't be (would need burn-in). Converted VTTs are cached on the shared
|
||||||
|
Plex on-disk cache root (``imgcache.CACHE_DIR``).
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from app.plex.client import PlexError
|
||||||
|
from app.plex.imgcache import CACHE_DIR
|
||||||
|
|
||||||
|
# Image-based subtitle codecs (Blu-ray/DVD) — can't be turned into text WebVTT (would need burn-in).
|
||||||
|
BITMAP_SUBS = {"pgs", "hdmv_pgs_subtitle", "dvd_subtitle", "dvdsub", "vobsub", "dvbsub", "dvb_subtitle"}
|
||||||
|
|
||||||
|
|
||||||
|
def srt_to_vtt(text: str) -> str:
|
||||||
|
"""Minimal SubRip→WebVTT: add the header and switch the ms separator (`,`→`.`). SRT cue-index
|
||||||
|
lines are valid in WebVTT, so they're left as-is."""
|
||||||
|
body = re.sub(r"(\d\d:\d\d:\d\d),(\d\d\d)", r"\1.\2", text.replace("\r\n", "\n").lstrip(""))
|
||||||
|
return "WEBVTT\n\n" + body.strip() + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _vtt_ts_to_s(ts: str) -> float:
|
||||||
|
parts = ts.split(":")
|
||||||
|
h = "0" if len(parts) == 2 else parts[0]
|
||||||
|
m, rest = parts[-2], parts[-1]
|
||||||
|
sec, _, ms = rest.partition(".")
|
||||||
|
return int(h) * 3600 + int(m) * 60 + int(sec) + int(ms or 0) / 1000.0
|
||||||
|
|
||||||
|
|
||||||
|
def _vtt_s_to_ts(x: float) -> str:
|
||||||
|
x = max(0.0, x)
|
||||||
|
h = int(x // 3600)
|
||||||
|
x -= h * 3600
|
||||||
|
m = int(x // 60)
|
||||||
|
x -= m * 60
|
||||||
|
s = int(x)
|
||||||
|
ms = int(round((x - s) * 1000))
|
||||||
|
if ms >= 1000:
|
||||||
|
s += 1
|
||||||
|
ms -= 1000
|
||||||
|
return f"{h:02d}:{m:02d}:{s:02d}.{ms:03d}"
|
||||||
|
|
||||||
|
|
||||||
|
def shift_vtt(vtt: str, offset: float) -> str:
|
||||||
|
"""Subtract `offset` seconds from every cue's start/end. WebVTT cues carry ABSOLUTE content times,
|
||||||
|
but an HLS remux session's clock is zero-based at the real keyframe (see stream.py media_start_s),
|
||||||
|
so a subtitle overlaid on that session must be shifted by that offset to line up with speech.
|
||||||
|
|
||||||
|
Processes cue BLOCKS so a cue that shifts fully into the past (end <= 0) can be DROPPED entirely.
|
||||||
|
(Collapsing them to 00:00:00.000-->00:00:00.000 made EVERY past cue count as active at currentTime 0
|
||||||
|
— where a resume/seek lands — piling all of them on screen until playback advanced past 0.) A cue
|
||||||
|
straddling 0 is clamped to start at 0."""
|
||||||
|
if not offset:
|
||||||
|
return vtt
|
||||||
|
out_blocks: list[str] = []
|
||||||
|
for block in re.split(r"\n[ \t]*\n", vtt):
|
||||||
|
if "-->" not in block:
|
||||||
|
out_blocks.append(block) # header / NOTE / STYLE — keep verbatim
|
||||||
|
continue
|
||||||
|
lines = block.split("\n")
|
||||||
|
keep = True
|
||||||
|
for i, ln in enumerate(lines):
|
||||||
|
if "-->" not in ln:
|
||||||
|
continue
|
||||||
|
left, _, right = ln.partition("-->")
|
||||||
|
rparts = right.strip().split(None, 1)
|
||||||
|
settings = (" " + rparts[1]) if len(rparts) > 1 else ""
|
||||||
|
try:
|
||||||
|
st = _vtt_ts_to_s(left.strip()) - offset
|
||||||
|
en = _vtt_ts_to_s(rparts[0]) - offset
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
break # unparseable timing → leave the block as-is
|
||||||
|
if en <= 0:
|
||||||
|
keep = False # whole cue is before the session start → drop the block
|
||||||
|
else:
|
||||||
|
lines[i] = f"{_vtt_s_to_ts(st)} --> {_vtt_s_to_ts(en)}{settings}" # _vtt_s_to_ts clamps st>=0
|
||||||
|
break
|
||||||
|
if keep:
|
||||||
|
out_blocks.append("\n".join(lines))
|
||||||
|
return "\n\n".join(out_blocks)
|
||||||
|
|
||||||
|
|
||||||
|
def ffmpeg_to_vtt(src: str, sub_ord: int | None) -> str:
|
||||||
|
"""Convert one subtitle stream of a file to WebVTT via ffmpeg (for embedded subs, and non-SRT
|
||||||
|
external subs written to a temp file). `sub_ord` = ordinal among the file's subtitle streams."""
|
||||||
|
cmd = ["ffmpeg", "-nostdin", "-loglevel", "error", "-i", src,
|
||||||
|
"-map", f"0:s:{sub_ord if sub_ord is not None else 0}", "-f", "webvtt", "pipe:1"]
|
||||||
|
r = subprocess.run(cmd, capture_output=True)
|
||||||
|
if r.returncode != 0 or not r.stdout:
|
||||||
|
raise PlexError((r.stderr or b"").decode("utf-8", "replace")[:200] or "ffmpeg subtitle convert failed")
|
||||||
|
return r.stdout.decode("utf-8", "replace")
|
||||||
|
|
||||||
|
|
||||||
|
def cache_read(key: str) -> str | None:
|
||||||
|
try:
|
||||||
|
p = CACHE_DIR / f"{key}.vtt"
|
||||||
|
return p.read_text(encoding="utf-8") if p.exists() else None
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def cache_write(key: str, vtt: str) -> None:
|
||||||
|
try:
|
||||||
|
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = CACHE_DIR / f"{key}.vtt.tmp"
|
||||||
|
tmp.write_text(vtt, encoding="utf-8")
|
||||||
|
tmp.replace(CACHE_DIR / f"{key}.vtt") # atomic publish
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
+25
-245
@@ -8,15 +8,12 @@ catalog sync.
|
|||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response
|
||||||
@@ -27,7 +24,6 @@ from sqlalchemy.orm import Session, aliased
|
|||||||
|
|
||||||
from app import sysconfig
|
from app import sysconfig
|
||||||
from app.auth import current_user, resolved_user_id
|
from app.auth import current_user, resolved_user_id
|
||||||
from app.config import settings
|
|
||||||
from app.db import SessionLocal, get_db
|
from app.db import SessionLocal, get_db
|
||||||
from app.models import (
|
from app.models import (
|
||||||
PlexCollection,
|
PlexCollection,
|
||||||
@@ -48,6 +44,7 @@ from app.plex import stream as plex_stream
|
|||||||
from app.plex import sync as plex_sync
|
from app.plex import sync as plex_sync
|
||||||
from app.plex import watch_sync as plex_watch
|
from app.plex import watch_sync as plex_watch
|
||||||
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
|
||||||
|
from app.plex import imgcache, rollup, subtitles
|
||||||
from app.routes._common import owned_or_404
|
from app.routes._common import owned_or_404
|
||||||
from app.routes.admin import admin_user
|
from app.routes.admin import admin_user
|
||||||
from app.routes.feed import _to_tsquery_str
|
from app.routes.feed import _to_tsquery_str
|
||||||
@@ -243,66 +240,6 @@ def _show_card(sh: PlexShow, status: str = "new") -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _show_status(total: int, watched: int, inprog: int) -> str:
|
|
||||||
"""Roll a show's episodes up into a single watch state: fully watched, partially started, or new."""
|
|
||||||
if total and watched >= total:
|
|
||||||
return "watched"
|
|
||||||
if (watched or 0) > 0 or (inprog or 0) > 0:
|
|
||||||
return "in_progress"
|
|
||||||
return "new"
|
|
||||||
|
|
||||||
|
|
||||||
def _rollup(cards: list[dict]) -> dict:
|
|
||||||
"""From ORDERED episode cards → the aggregate watch status, the "on deck" episode to Resume (the
|
|
||||||
last in-progress one, else the first unwatched), the first episode (for Play from the start), and
|
|
||||||
the episode count. Drives the show/season Resume + Play + mark-all buttons."""
|
|
||||||
total = len(cards)
|
|
||||||
watched = sum(1 for c in cards if c.get("status") == "watched")
|
|
||||||
inprog = [c for c in cards if (c.get("position_seconds") or 0) > 0 and c.get("status") != "watched"]
|
|
||||||
resume = None
|
|
||||||
if inprog:
|
|
||||||
resume = inprog[-1] # continue the latest-started episode
|
|
||||||
else:
|
|
||||||
resume = next((c for c in cards if c.get("status") != "watched"), None)
|
|
||||||
return {
|
|
||||||
"status": _show_status(total, watched, len(inprog)),
|
|
||||||
"resume": resume,
|
|
||||||
"first": cards[0] if cards else None,
|
|
||||||
"episode_count": total,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _show_agg_subq(db: Session, user_id: int):
|
|
||||||
"""Per-show episode counts for a user: total, watched, in-progress. Reused to (a) filter the show
|
|
||||||
grid by aggregate watch-state and (b) compute each card's badge."""
|
|
||||||
ps = aliased(PlexState)
|
|
||||||
return (
|
|
||||||
db.query(
|
|
||||||
PlexItem.show_id.label("show_id"),
|
|
||||||
func.count(PlexItem.id).label("total"),
|
|
||||||
func.count(case((ps.status == "watched", 1))).label("watched"),
|
|
||||||
func.count(
|
|
||||||
case(
|
|
||||||
(
|
|
||||||
and_(ps.position_seconds > 0, or_(ps.status.is_(None), ps.status != "watched")),
|
|
||||||
1,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).label("inprog"),
|
|
||||||
)
|
|
||||||
.outerjoin(ps, and_(ps.item_id == PlexItem.id, ps.user_id == user_id))
|
|
||||||
.filter(PlexItem.kind == "episode", PlexItem.show_id.isnot(None))
|
|
||||||
.group_by(PlexItem.show_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _show_state_map(db: Session, user_id: int, show_ids: list[int]) -> dict[int, str]:
|
|
||||||
if not show_ids:
|
|
||||||
return {}
|
|
||||||
rows = _show_agg_subq(db, user_id).filter(PlexItem.show_id.in_(show_ids)).all()
|
|
||||||
return {r.show_id: _show_status(r.total, r.watched, r.inprog) for r in rows}
|
|
||||||
|
|
||||||
|
|
||||||
def _lib_key(db: Session, library_id: int | None) -> str | None:
|
def _lib_key(db: Session, library_id: int | None) -> str | None:
|
||||||
"""The Plex section key for a library id — the info/show page passes it to the admin collection
|
"""The Plex section key for a library id — the info/show page passes it to the admin collection
|
||||||
editor (in the unified cross-library view there's no single library prop otherwise)."""
|
editor (in the unified cross-library view there's no single library prop otherwise)."""
|
||||||
@@ -498,7 +435,7 @@ def unified_library(
|
|||||||
mq = mq.filter(PlexItem.search_vector.op("@@")(tsq))
|
mq = mq.filter(PlexItem.search_vector.op("@@")(tsq))
|
||||||
selects.append(mq)
|
selects.append(mq)
|
||||||
if want_shows:
|
if want_shows:
|
||||||
agg = _show_agg_subq(db, user.id).subquery()
|
agg = rollup.agg_subquery(db, user.id).subquery()
|
||||||
tot = func.coalesce(agg.c.total, 0)
|
tot = func.coalesce(agg.c.total, 0)
|
||||||
wat = func.coalesce(agg.c.watched, 0)
|
wat = func.coalesce(agg.c.watched, 0)
|
||||||
inp = func.coalesce(agg.c.inprog, 0)
|
inp = func.coalesce(agg.c.inprog, 0)
|
||||||
@@ -650,7 +587,7 @@ def facets(
|
|||||||
query = query.filter(or_(st.status.is_(None), st.status != "hidden"))
|
query = query.filter(or_(st.status.is_(None), st.status != "hidden"))
|
||||||
else:
|
else:
|
||||||
if show in ("watched", "in_progress", "unwatched"):
|
if show in ("watched", "in_progress", "unwatched"):
|
||||||
agg = _show_agg_subq(db, uid).subquery()
|
agg = rollup.agg_subquery(db, uid).subquery()
|
||||||
query = query.outerjoin(agg, agg.c.show_id == model.id)
|
query = query.outerjoin(agg, agg.c.show_id == model.id)
|
||||||
tot = func.coalesce(agg.c.total, 0)
|
tot = func.coalesce(agg.c.total, 0)
|
||||||
wat = func.coalesce(agg.c.watched, 0)
|
wat = func.coalesce(agg.c.watched, 0)
|
||||||
@@ -1260,7 +1197,7 @@ def show_detail(
|
|||||||
card = _episode_card(e, states.get(e.id))
|
card = _episode_card(e, states.get(e.id))
|
||||||
by_season.setdefault(e.season_id, []).append(card)
|
by_season.setdefault(e.season_id, []).append(card)
|
||||||
all_cards.append(card)
|
all_cards.append(card)
|
||||||
show_roll = _rollup(all_cards)
|
show_roll = rollup.rollup(all_cards)
|
||||||
|
|
||||||
# Cast + IMDb (best-effort live metadata; same shape as the movie/episode info page). The two live
|
# Cast + IMDb (best-effort live metadata; same shape as the movie/episode info page). The two live
|
||||||
# Plex calls are cached per-show + fetched in parallel by _show_enrichment; the per-user shaping
|
# Plex calls are cached per-show + fetched in parallel by _show_enrichment; the per-user shaping
|
||||||
@@ -1323,7 +1260,7 @@ def show_detail(
|
|||||||
def _season_block(se: PlexSeason, cards: list[dict]) -> dict:
|
def _season_block(se: PlexSeason, cards: list[dict]) -> dict:
|
||||||
"""A season's card for the show page (with its aggregate rollup) plus its episodes (used by the
|
"""A season's card for the show page (with its aggregate rollup) plus its episodes (used by the
|
||||||
season subpage, read from the same cached show-detail payload)."""
|
season subpage, read from the same cached show-detail payload)."""
|
||||||
roll = _rollup(cards)
|
roll = rollup.rollup(cards)
|
||||||
return {
|
return {
|
||||||
"id": se.rating_key,
|
"id": se.rating_key,
|
||||||
"season_number": se.season_number,
|
"season_number": se.season_number,
|
||||||
@@ -1347,7 +1284,7 @@ def _related_show_cards(db: Session, user_id: int, related: list[dict], exclude:
|
|||||||
s.rating_key: s
|
s.rating_key: s
|
||||||
for s in db.query(PlexShow).filter(PlexShow.rating_key.in_(rks[:60]), PlexShow.id != exclude)
|
for s in db.query(PlexShow).filter(PlexShow.rating_key.in_(rks[:60]), PlexShow.id != exclude)
|
||||||
}
|
}
|
||||||
statuses = _show_state_map(db, user_id, [s.id for s in shows.values()])
|
statuses = rollup.state_map(db, user_id, [s.id for s in shows.values()])
|
||||||
out: list[dict] = []
|
out: list[dict] = []
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
for rk in rks:
|
for rk in rks:
|
||||||
@@ -1361,163 +1298,6 @@ def _related_show_cards(db: Session, user_id: int, related: list[dict], exclude:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _image_key(db: Session, rating_key: str, variant: str) -> str | None:
|
|
||||||
"""The stored Plex image path for a known rating_key (item/show/season). Only mirrored keys
|
|
||||||
are proxyable — this is not an open image proxy."""
|
|
||||||
it = db.query(PlexItem).filter_by(rating_key=rating_key).first()
|
|
||||||
if it is not None:
|
|
||||||
return it.art_key if variant == "art" else it.thumb_key
|
|
||||||
sh = db.query(PlexShow).filter_by(rating_key=rating_key).first()
|
|
||||||
if sh is not None:
|
|
||||||
return sh.art_key if variant == "art" else sh.thumb_key
|
|
||||||
se = db.query(PlexSeason).filter_by(rating_key=rating_key).first()
|
|
||||||
if se is not None:
|
|
||||||
return se.thumb_key
|
|
||||||
col = db.query(PlexCollection).filter_by(rating_key=rating_key).first()
|
|
||||||
if col is not None:
|
|
||||||
return col.thumb_key
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# --- Thin on-disk image cache -----------------------------------------------------------------
|
|
||||||
# Posters/art/cast-photos are proxied on demand; without a cache every scroll re-hits Plex over the
|
|
||||||
# LAN (janky). We cache the fetched bytes on local disk keyed by a stable id, so repeat views (and
|
|
||||||
# other users) serve instantly from disk. On-demand + bounded by the library size; no eviction (a
|
|
||||||
# few hundred MB at most for a big library — acceptable, matching the download disk trade-offs).
|
|
||||||
_IMG_CACHE = Path(settings.download_root) / ".plex-img-cache"
|
|
||||||
_EXT_CT = [("jpg", "image/jpeg"), ("png", "image/png"), ("webp", "image/webp")]
|
|
||||||
_CT_EXT = {ct: ext for ext, ct in _EXT_CT}
|
|
||||||
# Target box per variant — Plex resizes to these server-side. Posters fill ≤176px cells (grid/info/
|
|
||||||
# strips), so 400px covers 2× DPR; the faint backdrop art only needs ~1280px. Keeps images ~tens of KB.
|
|
||||||
_IMG_SIZES = {"thumb": (400, 600), "art": (1280, 720)}
|
|
||||||
|
|
||||||
# Image-based subtitle codecs (Blu-ray/DVD) — can't be turned into text WebVTT (would need burn-in).
|
|
||||||
_BITMAP_SUBS = {"pgs", "hdmv_pgs_subtitle", "dvd_subtitle", "dvdsub", "vobsub", "dvbsub", "dvb_subtitle"}
|
|
||||||
|
|
||||||
|
|
||||||
def _srt_to_vtt(text: str) -> str:
|
|
||||||
"""Minimal SubRip→WebVTT: add the header and switch the ms separator (`,`→`.`). SRT cue-index
|
|
||||||
lines are valid in WebVTT, so they're left as-is."""
|
|
||||||
body = re.sub(r"(\d\d:\d\d:\d\d),(\d\d\d)", r"\1.\2", text.replace("\r\n", "\n").lstrip(""))
|
|
||||||
return "WEBVTT\n\n" + body.strip() + "\n"
|
|
||||||
|
|
||||||
|
|
||||||
def _vtt_ts_to_s(ts: str) -> float:
|
|
||||||
parts = ts.split(":")
|
|
||||||
h = "0" if len(parts) == 2 else parts[0]
|
|
||||||
m, rest = parts[-2], parts[-1]
|
|
||||||
sec, _, ms = rest.partition(".")
|
|
||||||
return int(h) * 3600 + int(m) * 60 + int(sec) + int(ms or 0) / 1000.0
|
|
||||||
|
|
||||||
|
|
||||||
def _vtt_s_to_ts(x: float) -> str:
|
|
||||||
x = max(0.0, x)
|
|
||||||
h = int(x // 3600)
|
|
||||||
x -= h * 3600
|
|
||||||
m = int(x // 60)
|
|
||||||
x -= m * 60
|
|
||||||
s = int(x)
|
|
||||||
ms = int(round((x - s) * 1000))
|
|
||||||
if ms >= 1000:
|
|
||||||
s += 1
|
|
||||||
ms -= 1000
|
|
||||||
return f"{h:02d}:{m:02d}:{s:02d}.{ms:03d}"
|
|
||||||
|
|
||||||
|
|
||||||
def _shift_vtt(vtt: str, offset: float) -> str:
|
|
||||||
"""Subtract `offset` seconds from every cue's start/end. WebVTT cues carry ABSOLUTE content times,
|
|
||||||
but an HLS remux session's clock is zero-based at the real keyframe (see stream.py media_start_s),
|
|
||||||
so a subtitle overlaid on that session must be shifted by that offset to line up with speech.
|
|
||||||
|
|
||||||
Processes cue BLOCKS so a cue that shifts fully into the past (end <= 0) can be DROPPED entirely.
|
|
||||||
(Collapsing them to 00:00:00.000-->00:00:00.000 made EVERY past cue count as active at currentTime 0
|
|
||||||
— where a resume/seek lands — piling all of them on screen until playback advanced past 0.) A cue
|
|
||||||
straddling 0 is clamped to start at 0."""
|
|
||||||
if not offset:
|
|
||||||
return vtt
|
|
||||||
out_blocks: list[str] = []
|
|
||||||
for block in re.split(r"\n[ \t]*\n", vtt):
|
|
||||||
if "-->" not in block:
|
|
||||||
out_blocks.append(block) # header / NOTE / STYLE — keep verbatim
|
|
||||||
continue
|
|
||||||
lines = block.split("\n")
|
|
||||||
keep = True
|
|
||||||
for i, ln in enumerate(lines):
|
|
||||||
if "-->" not in ln:
|
|
||||||
continue
|
|
||||||
left, _, right = ln.partition("-->")
|
|
||||||
rparts = right.strip().split(None, 1)
|
|
||||||
settings = (" " + rparts[1]) if len(rparts) > 1 else ""
|
|
||||||
try:
|
|
||||||
st = _vtt_ts_to_s(left.strip()) - offset
|
|
||||||
en = _vtt_ts_to_s(rparts[0]) - offset
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
break # unparseable timing → leave the block as-is
|
|
||||||
if en <= 0:
|
|
||||||
keep = False # whole cue is before the session start → drop the block
|
|
||||||
else:
|
|
||||||
lines[i] = f"{_vtt_s_to_ts(st)} --> {_vtt_s_to_ts(en)}{settings}" # _vtt_s_to_ts clamps st>=0
|
|
||||||
break
|
|
||||||
if keep:
|
|
||||||
out_blocks.append("\n".join(lines))
|
|
||||||
return "\n\n".join(out_blocks)
|
|
||||||
|
|
||||||
|
|
||||||
def _ffmpeg_to_vtt(src: str, sub_ord: int | None) -> str:
|
|
||||||
"""Convert one subtitle stream of a file to WebVTT via ffmpeg (for embedded subs, and non-SRT
|
|
||||||
external subs written to a temp file). `sub_ord` = ordinal among the file's subtitle streams."""
|
|
||||||
cmd = ["ffmpeg", "-nostdin", "-loglevel", "error", "-i", src,
|
|
||||||
"-map", f"0:s:{sub_ord if sub_ord is not None else 0}", "-f", "webvtt", "pipe:1"]
|
|
||||||
r = subprocess.run(cmd, capture_output=True)
|
|
||||||
if r.returncode != 0 or not r.stdout:
|
|
||||||
raise PlexError((r.stderr or b"").decode("utf-8", "replace")[:200] or "ffmpeg subtitle convert failed")
|
|
||||||
return r.stdout.decode("utf-8", "replace")
|
|
||||||
|
|
||||||
|
|
||||||
def _sub_cache_read(key: str) -> str | None:
|
|
||||||
try:
|
|
||||||
p = _IMG_CACHE / f"{key}.vtt"
|
|
||||||
return p.read_text(encoding="utf-8") if p.exists() else None
|
|
||||||
except OSError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _sub_cache_write(key: str, vtt: str) -> None:
|
|
||||||
try:
|
|
||||||
_IMG_CACHE.mkdir(parents=True, exist_ok=True)
|
|
||||||
tmp = _IMG_CACHE / f"{key}.vtt.tmp"
|
|
||||||
tmp.write_text(vtt, encoding="utf-8")
|
|
||||||
tmp.replace(_IMG_CACHE / f"{key}.vtt") # atomic publish
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _img_cache_read(key: str) -> tuple[bytes, str] | None:
|
|
||||||
for ext, ct in _EXT_CT:
|
|
||||||
p = _IMG_CACHE / f"{key}.{ext}"
|
|
||||||
try:
|
|
||||||
if p.exists() and p.stat().st_size > 0:
|
|
||||||
return p.read_bytes(), ct
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _img_cache_write(key: str, data: bytes, ct: str) -> None:
|
|
||||||
ext = _CT_EXT.get((ct or "").split(";")[0].strip(), "jpg")
|
|
||||||
try:
|
|
||||||
_IMG_CACHE.mkdir(parents=True, exist_ok=True)
|
|
||||||
tmp = _IMG_CACHE / f"{key}.{ext}.tmp"
|
|
||||||
tmp.write_bytes(data)
|
|
||||||
tmp.replace(_IMG_CACHE / f"{key}.{ext}") # atomic publish
|
|
||||||
except OSError:
|
|
||||||
pass # cache is best-effort
|
|
||||||
|
|
||||||
|
|
||||||
def _img_response(data: bytes, ct: str) -> Response:
|
|
||||||
return Response(content=data, media_type=ct, headers={"Cache-Control": "public, max-age=86400"})
|
|
||||||
|
|
||||||
|
|
||||||
def _require_session(request: Request) -> int:
|
def _require_session(request: Request) -> int:
|
||||||
"""Lightweight auth for the high-fan-out image proxies: validate the signed session cookie WITHOUT
|
"""Lightweight auth for the high-fan-out image proxies: validate the signed session cookie WITHOUT
|
||||||
a DB user-load, so these endpoints never hold a pool connection for the auth. Poster images are
|
a DB user-load, so these endpoints never hold a pool connection for the auth. Poster images are
|
||||||
@@ -1540,18 +1320,18 @@ def image(rating_key: str, request: Request, variant: str = "thumb") -> Response
|
|||||||
fetch. Auth is the signed-session check (no DB user-load)."""
|
fetch. Auth is the signed-session check (no DB user-load)."""
|
||||||
_require_session(request)
|
_require_session(request)
|
||||||
v = "art" if variant == "art" else "thumb"
|
v = "art" if variant == "art" else "thumb"
|
||||||
w, h = _IMG_SIZES[v]
|
w, h = imgcache.SIZES[v]
|
||||||
# Size is part of the cache key: Plex resizes server-side (see PlexClient.image_bytes), so we cache
|
# Size is part of the cache key: Plex resizes server-side (see PlexClient.image_bytes), so we cache
|
||||||
# the small variant. The width tag also means changing a target size just re-caches, never serves a
|
# the small variant. The width tag also means changing a target size just re-caches, never serves a
|
||||||
# stale differently-sized file (old full-size files under the un-tagged key are simply orphaned).
|
# stale differently-sized file (old full-size files under the un-tagged key are simply orphaned).
|
||||||
cache_key = f"item_{rating_key}_{v}_{w}"
|
cache_key = f"item_{rating_key}_{v}_{w}"
|
||||||
hit = _img_cache_read(cache_key)
|
hit = imgcache.read(cache_key)
|
||||||
if hit:
|
if hit:
|
||||||
return _img_response(*hit)
|
return imgcache.response(*hit)
|
||||||
# Cold miss: short-lived session for the key + Plex client config, released before the slow fetch.
|
# Cold miss: short-lived session for the key + Plex client config, released before the slow fetch.
|
||||||
try:
|
try:
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
key = _image_key(db, str(rating_key), v)
|
key = imgcache.key_for(db, str(rating_key), v)
|
||||||
if not key:
|
if not key:
|
||||||
raise HTTPException(status_code=404, detail="No image")
|
raise HTTPException(status_code=404, detail="No image")
|
||||||
plex = PlexClient(db) # reads base URL + token now; the fetch below needs no DB
|
plex = PlexClient(db) # reads base URL + token now; the fetch below needs no DB
|
||||||
@@ -1562,8 +1342,8 @@ def image(rating_key: str, request: Request, variant: str = "thumb") -> Response
|
|||||||
data, ct = plex.image_bytes(key, w, h)
|
data, ct = plex.image_bytes(key, w, h)
|
||||||
except PlexError as e:
|
except PlexError as e:
|
||||||
raise HTTPException(status_code=502, detail=f"Plex image fetch failed: {e}")
|
raise HTTPException(status_code=502, detail=f"Plex image fetch failed: {e}")
|
||||||
_img_cache_write(cache_key, data, ct)
|
imgcache.write(cache_key, data, ct)
|
||||||
return _img_response(data, ct)
|
return imgcache.response(data, ct)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/subtitle/{rating_key}/{sub_ord}")
|
@router.get("/subtitle/{rating_key}/{sub_ord}")
|
||||||
@@ -1576,9 +1356,9 @@ def subtitle(rating_key: str, sub_ord: int, request: Request, offset: float = 0.
|
|||||||
# Cache the ABSOLUTE-time VTT once (per rk+ord); the per-session cue-shift is applied on the way
|
# Cache the ABSOLUTE-time VTT once (per rk+ord); the per-session cue-shift is applied on the way
|
||||||
# out, so a resume/seek to any offset reuses the same cached track (no cache-per-offset explosion).
|
# out, so a resume/seek to any offset reuses the same cached track (no cache-per-offset explosion).
|
||||||
cache_key = f"sub_{rating_key}_{sub_ord}"
|
cache_key = f"sub_{rating_key}_{sub_ord}"
|
||||||
hit = _sub_cache_read(cache_key)
|
hit = subtitles.cache_read(cache_key)
|
||||||
if hit is not None:
|
if hit is not None:
|
||||||
return Response(_shift_vtt(hit, offset), media_type="text/vtt")
|
return Response(subtitles.shift_vtt(hit, offset), media_type="text/vtt")
|
||||||
try:
|
try:
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
it = _item_or_404(db, rating_key)
|
it = _item_or_404(db, rating_key)
|
||||||
@@ -1589,18 +1369,18 @@ def subtitle(rating_key: str, sub_ord: int, request: Request, offset: float = 0.
|
|||||||
raise HTTPException(status_code=404, detail="No such subtitle")
|
raise HTTPException(status_code=404, detail="No such subtitle")
|
||||||
s = subs[sub_ord]
|
s = subs[sub_ord]
|
||||||
codec = (s.get("codec") or s.get("format") or "").lower()
|
codec = (s.get("codec") or s.get("format") or "").lower()
|
||||||
if codec in _BITMAP_SUBS:
|
if codec in subtitles.BITMAP_SUBS:
|
||||||
raise HTTPException(status_code=415, detail="Image-based subtitle can't be shown as text")
|
raise HTTPException(status_code=415, detail="Image-based subtitle can't be shown as text")
|
||||||
if s.get("key"): # external sidecar → fetch from Plex, convert to VTT
|
if s.get("key"): # external sidecar → fetch from Plex, convert to VTT
|
||||||
raw = plex.raw_get(s["key"])
|
raw = plex.raw_get(s["key"])
|
||||||
if codec in ("srt", "subrip", ""):
|
if codec in ("srt", "subrip", ""):
|
||||||
vtt = _srt_to_vtt(raw.decode("utf-8", "replace"))
|
vtt = subtitles.srt_to_vtt(raw.decode("utf-8", "replace"))
|
||||||
else:
|
else:
|
||||||
fd, tmp = tempfile.mkstemp(suffix="." + (codec or "sub"))
|
fd, tmp = tempfile.mkstemp(suffix="." + (codec or "sub"))
|
||||||
try:
|
try:
|
||||||
os.write(fd, raw)
|
os.write(fd, raw)
|
||||||
os.close(fd)
|
os.close(fd)
|
||||||
vtt = _ffmpeg_to_vtt(tmp, None)
|
vtt = subtitles.ffmpeg_to_vtt(tmp, None)
|
||||||
finally:
|
finally:
|
||||||
os.unlink(tmp)
|
os.unlink(tmp)
|
||||||
elif s.get("index") is not None: # embedded → extract from the local file
|
elif s.get("index") is not None: # embedded → extract from the local file
|
||||||
@@ -1608,15 +1388,15 @@ def subtitle(rating_key: str, sub_ord: int, request: Request, offset: float = 0.
|
|||||||
if src is None:
|
if src is None:
|
||||||
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
raise HTTPException(status_code=404, detail="Media file not found on disk")
|
||||||
emb_ord = sum(1 for x in subs[:sub_ord] if x.get("index") is not None)
|
emb_ord = sum(1 for x in subs[:sub_ord] if x.get("index") is not None)
|
||||||
vtt = _ffmpeg_to_vtt(str(src), emb_ord)
|
vtt = subtitles.ffmpeg_to_vtt(str(src), emb_ord)
|
||||||
else:
|
else:
|
||||||
raise HTTPException(status_code=404, detail="Subtitle not fetchable")
|
raise HTTPException(status_code=404, detail="Subtitle not fetchable")
|
||||||
except PlexNotConfigured as e:
|
except PlexNotConfigured as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
except PlexError as e:
|
except PlexError as e:
|
||||||
raise HTTPException(status_code=502, detail=f"Subtitle fetch failed: {e}")
|
raise HTTPException(status_code=502, detail=f"Subtitle fetch failed: {e}")
|
||||||
_sub_cache_write(cache_key, vtt)
|
subtitles.cache_write(cache_key, vtt)
|
||||||
return Response(_shift_vtt(vtt, offset), media_type="text/vtt")
|
return Response(subtitles.shift_vtt(vtt, offset), media_type="text/vtt")
|
||||||
|
|
||||||
|
|
||||||
# --- Playback (local file → direct 206 or on-the-fly HLS remux; P2) ----------------------------
|
# --- Playback (local file → direct 206 or on-the-fly HLS remux; P2) ----------------------------
|
||||||
@@ -1726,9 +1506,9 @@ def person_image(u: str, request: Request) -> Response:
|
|||||||
if not u.startswith(plex_people.PERSON_IMG_HOST):
|
if not u.startswith(plex_people.PERSON_IMG_HOST):
|
||||||
raise HTTPException(status_code=400, detail="Unsupported image host")
|
raise HTTPException(status_code=400, detail="Unsupported image host")
|
||||||
cache_key = "person_" + hashlib.sha1(u.encode()).hexdigest()
|
cache_key = "person_" + hashlib.sha1(u.encode()).hexdigest()
|
||||||
hit = _img_cache_read(cache_key)
|
hit = imgcache.read(cache_key)
|
||||||
if hit:
|
if hit:
|
||||||
return _img_response(*hit)
|
return imgcache.response(*hit)
|
||||||
try:
|
try:
|
||||||
with httpx.Client(timeout=10.0, follow_redirects=True) as c:
|
with httpx.Client(timeout=10.0, follow_redirects=True) as c:
|
||||||
r = c.get(u)
|
r = c.get(u)
|
||||||
@@ -1736,8 +1516,8 @@ def person_image(u: str, request: Request) -> Response:
|
|||||||
except httpx.HTTPError:
|
except httpx.HTTPError:
|
||||||
raise HTTPException(status_code=502, detail="Image fetch failed")
|
raise HTTPException(status_code=502, detail="Image fetch failed")
|
||||||
ct = r.headers.get("content-type", "image/jpeg")
|
ct = r.headers.get("content-type", "image/jpeg")
|
||||||
_img_cache_write(cache_key, r.content, ct)
|
imgcache.write(cache_key, r.content, ct)
|
||||||
return _img_response(r.content, ct)
|
return imgcache.response(r.content, ct)
|
||||||
|
|
||||||
|
|
||||||
# Known external list providers that seed Plex collections (via plextraktsync et al). A collection's
|
# Known external list providers that seed Plex collections (via plextraktsync et al). A collection's
|
||||||
@@ -1864,7 +1644,7 @@ def item_detail(
|
|||||||
"codec": codec,
|
"codec": codec,
|
||||||
# Text subs (srt/ass/…) can be served as WebVTT tracks; image subs (PGS/VobSub)
|
# Text subs (srt/ass/…) can be served as WebVTT tracks; image subs (PGS/VobSub)
|
||||||
# can't be turned into text, so the player hides them (burn-in is a later phase).
|
# can't be turned into text, so the player hides them (burn-in is a later phase).
|
||||||
"text": codec not in _BITMAP_SUBS,
|
"text": codec not in subtitles.BITMAP_SUBS,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
Reference in New Issue
Block a user