"""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``). """ import time from datetime import timedelta 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. The full poster (400px) covers a detail # page's ≤224px poster at 2× DPR, the full art (1280px) a hero backdrop. A card is much smaller: a grid # poster is ~144px and a shelf's landscape art ~212px, so the `_sm` pair serves those at 1.33× DPR # (cards used to fetch the 400 poster, 42 KB, and the 1280 art for a 212px picture). The browser # picks between a pair itself (`srcset`, built by the frontend's `lib/plexImage` cardImage, whose # widths plexImage.contract.test holds to these). SIZES = { "thumb": (400, 600), "thumb_sm": (240, 360), "art": (1280, 720), "art_sm": (480, 270), } def picture(variant: str) -> str: """Which stored picture a size variant is cut from: the poster (`thumb`) or the backdrop (`art`).""" return "art" if variant.startswith("art") else "thumb" 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 has(key: str) -> bool: """A cached, non-empty file exists for `key` (a prefetch's cheap check — no read).""" for ext, _ in _EXT_CT: try: if (CACHE_DIR / f"{key}.{ext}").stat().st_size > 0: return True # silent-ok: a miss, as in read(). except OSError: pass return False def drop(key: str) -> None: """Remove `key`'s file, whatever its extension (a superseded variant). Best-effort.""" for ext, _ in _EXT_CT: try: (CACHE_DIR / f"{key}.{ext}").unlink(missing_ok=True) # silent-ok: a leftover original only costs disk; the small twin is already written. except OSError: pass def mark_miss(key: str) -> None: """Remember that `key`'s source could not be fetched (an empty `.miss` file; its mtime is when), so a prefetch can leave a dead source alone for a while. Best-effort.""" try: CACHE_DIR.mkdir(parents=True, exist_ok=True) (CACHE_DIR / f"{key}.miss").touch() # silent-ok: without the mark the dead source is only asked for again on the next run. except OSError: pass def missed_within(key: str, age: timedelta) -> bool: """`key` was marked as a miss less than `age` ago.""" try: mtime = (CACHE_DIR / f"{key}.miss").stat().st_mtime # silent-ok: no mark (the usual case). except OSError: return False return time.time() - mtime < age.total_seconds() 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"})