Cards fetched the full cuts: a 400x600 poster (~38 KB) for a ~144px picture and the 1280x720 backdrop (~162 KB) for a ~212px shelf picture. The image proxy now also cuts thumb_sm (240x360) and art_sm (480x270), cached apart; card pictures (grid, shelves, collection tiles, Up next) carry both in srcset with sizes=auto, so the browser picks by laid-out width and DPR. Localdev: 17 KB a poster, 56 KB a backdrop. The detail page keeps the full cuts.
137 lines
5.2 KiB
Python
137 lines
5.2 KiB
Python
"""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
|
||
# (E31 S4e: cards fetched the 400 poster, 42 KB, and the 1280 art for a 212px picture). The browser
|
||
# picks between a pair itself (`srcset`, the frontend's `plexSrcSet`).
|
||
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"})
|