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"})
|
||||
Reference in New Issue
Block a user