Files
siftlode/backend/app/plex/imgcache.py
T
peter 533571f572 refactor(plex): the unit's tables move into plex/models
PlexLibrary, PlexShow, PlexSeason, PlexItem, PlexPerson, PlexCollection, PlexState, PlexLink,
PlexPlaylist and PlexPlaylistItem leave app/models.py for app/plex/models.py; app.tables registers
them. Every import is re-pointed; no schema change.
2026-10-19 21:42:07 +02:00

138 lines
5.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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.plex.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"})