"""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). The cache directory (``cache.CACHE_DIR``) is shared with the subtitle cache (see ``subtitles.py``). """ from fastapi import Response from sqlalchemy.orm import Session from app import logs from app.models import PlexCollection, PlexItem, PlexSeason, PlexShow from app.plex.cache import CACHE_DIR from app.plex.lookups import by_rating_key log = logs.get_logger(__name__) _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 = by_rating_key(db, PlexItem, rating_key) if it is not None: return it.art_key if variant == "art" else it.thumb_key sh = by_rating_key(db, PlexShow, rating_key) if sh is not None: return sh.art_key if variant == "art" else sh.thumb_key se = by_rating_key(db, PlexSeason, rating_key) if se is not None: return se.thumb_key col = by_rating_key(db, PlexCollection, rating_key) 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 # silent-ok: a miss. The caller fetches from Plex and writes the file again, and the write # path below is the one that reports a cache that cannot be used. 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 as exc: # Best-effort, and silently so: with an unwritable cache every poster on every grid goes # to Plex live, for as long as nobody notices. Debug, because a grid is ~100 images and # the point is the condition, not the count. log.debug( "the Plex image cache could not be written", extra=logs.event("plex.imgcache.write_failed", reason=type(exc).__name__), ) def response(data: bytes, ct: str) -> Response: return Response(content=data, media_type=ct, headers={"Cache-Control": "public, max-age=86400"})