From 4106bceb083599eb859eed5e71f89a1a39c9ee81 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 11 Aug 2026 21:16:44 +0200 Subject: [PATCH] refactor(plex): extract img/subtitle/rollup helpers into app/plex services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- backend/app/plex/imgcache.py | 68 +++++++++ backend/app/plex/rollup.py | 68 +++++++++ backend/app/plex/subtitles.py | 111 ++++++++++++++ backend/app/routes/plex.py | 270 ++++------------------------------ 4 files changed, 272 insertions(+), 245 deletions(-) create mode 100644 backend/app/plex/imgcache.py create mode 100644 backend/app/plex/rollup.py create mode 100644 backend/app/plex/subtitles.py diff --git a/backend/app/plex/imgcache.py b/backend/app/plex/imgcache.py new file mode 100644 index 00000000..10fcb4aa --- /dev/null +++ b/backend/app/plex/imgcache.py @@ -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"}) diff --git a/backend/app/plex/rollup.py b/backend/app/plex/rollup.py new file mode 100644 index 00000000..f71734d9 --- /dev/null +++ b/backend/app/plex/rollup.py @@ -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} diff --git a/backend/app/plex/subtitles.py b/backend/app/plex/subtitles.py new file mode 100644 index 00000000..b0f76289 --- /dev/null +++ b/backend/app/plex/subtitles.py @@ -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 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 diff --git a/backend/app/routes/plex.py b/backend/app/routes/plex.py index 8c67e0e5..63a46821 100644 --- a/backend/app/routes/plex.py +++ b/backend/app/routes/plex.py @@ -8,15 +8,12 @@ catalog sync. import hashlib import logging import os -import re -import subprocess import tempfile import threading import time from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass from datetime import datetime, timedelta, timezone -from pathlib import Path import httpx 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.auth import current_user, resolved_user_id -from app.config import settings from app.db import SessionLocal, get_db from app.models import ( 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 watch_sync as plex_watch 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.admin import admin_user 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: """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).""" @@ -498,7 +435,7 @@ def unified_library( mq = mq.filter(PlexItem.search_vector.op("@@")(tsq)) selects.append(mq) 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) wat = func.coalesce(agg.c.watched, 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")) else: 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) tot = func.coalesce(agg.c.total, 0) wat = func.coalesce(agg.c.watched, 0) @@ -1260,7 +1197,7 @@ def show_detail( card = _episode_card(e, states.get(e.id)) by_season.setdefault(e.season_id, []).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 # 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: """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).""" - roll = _rollup(cards) + roll = rollup.rollup(cards) return { "id": se.rating_key, "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 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] = [] seen: set[str] = set() for rk in rks: @@ -1361,163 +1298,6 @@ def _related_show_cards(db: Session, user_id: int, related: list[dict], exclude: 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: """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 @@ -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).""" _require_session(request) 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 # 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). cache_key = f"item_{rating_key}_{v}_{w}" - hit = _img_cache_read(cache_key) + hit = imgcache.read(cache_key) 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. try: with SessionLocal() as db: - key = _image_key(db, str(rating_key), v) + key = imgcache.key_for(db, str(rating_key), v) if not key: raise HTTPException(status_code=404, detail="No image") 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) except PlexError as e: raise HTTPException(status_code=502, detail=f"Plex image fetch failed: {e}") - _img_cache_write(cache_key, data, ct) - return _img_response(data, ct) + imgcache.write(cache_key, data, ct) + return imgcache.response(data, ct) @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 # 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}" - hit = _sub_cache_read(cache_key) + hit = subtitles.cache_read(cache_key) 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: with SessionLocal() as db: 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") s = subs[sub_ord] 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") if s.get("key"): # external sidecar → fetch from Plex, convert to VTT raw = plex.raw_get(s["key"]) 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: fd, tmp = tempfile.mkstemp(suffix="." + (codec or "sub")) try: os.write(fd, raw) os.close(fd) - vtt = _ffmpeg_to_vtt(tmp, None) + vtt = subtitles.ffmpeg_to_vtt(tmp, None) finally: os.unlink(tmp) 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: 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) - vtt = _ffmpeg_to_vtt(str(src), emb_ord) + vtt = subtitles.ffmpeg_to_vtt(str(src), emb_ord) else: raise HTTPException(status_code=404, detail="Subtitle not fetchable") except PlexNotConfigured as e: raise HTTPException(status_code=400, detail=str(e)) except PlexError as e: raise HTTPException(status_code=502, detail=f"Subtitle fetch failed: {e}") - _sub_cache_write(cache_key, vtt) - return Response(_shift_vtt(vtt, offset), media_type="text/vtt") + subtitles.cache_write(cache_key, vtt) + return Response(subtitles.shift_vtt(vtt, offset), media_type="text/vtt") # --- 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): raise HTTPException(status_code=400, detail="Unsupported image host") cache_key = "person_" + hashlib.sha1(u.encode()).hexdigest() - hit = _img_cache_read(cache_key) + hit = imgcache.read(cache_key) if hit: - return _img_response(*hit) + return imgcache.response(*hit) try: with httpx.Client(timeout=10.0, follow_redirects=True) as c: r = c.get(u) @@ -1736,8 +1516,8 @@ def person_image(u: str, request: Request) -> Response: except httpx.HTTPError: raise HTTPException(status_code=502, detail="Image fetch failed") ct = r.headers.get("content-type", "image/jpeg") - _img_cache_write(cache_key, r.content, ct) - return _img_response(r.content, ct) + imgcache.write(cache_key, r.content, ct) + return imgcache.response(r.content, ct) # Known external list providers that seed Plex collections (via plextraktsync et al). A collection's @@ -1864,7 +1644,7 @@ def item_detail( "codec": codec, # 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). - "text": codec not in _BITMAP_SUBS, + "text": codec not in subtitles.BITMAP_SUBS, } ) except Exception: