A person's TMDB bio was fetched only when their page was first opened, off the request path, so the first open showed no bio and the page never asked again (2 of 18,202 linked people were ever enriched). - people_enrich scheduler job (every 10 min, 300 per run): linked people's bios from TMDB and their portraits into the image cache, the most-credited people first. A failed TMDB lookup ends the batch. - Portraits are resized through Plex's photo transcoder to a 320 box (~15 KB instead of ~170 KB; the person-image proxy uses the same cache key, and a full-size original is removed as its small twin lands). - The person response says `enriching` while a bio is on its way (a person the job hasn't reached yet). - "Known for" is ranked by vote count, without guest visits as themselves (talk shows had crowded out the films: Tom Cruise had 14 of 15), with directed works, from a 40-deep pool; the page shows up to 15. Migration 0093 un-stamps enriched people so the job refetches them. - A phased job result (backfill, people_enrich) now counts as a change for the audit trail; before, an automatic backfill run that added videos was never audited. - HU: the watch reconcile job reads "egyeztetés" (a leftover from the S5 sweep).
100 lines
3.9 KiB
Python
100 lines
3.9 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``).
|
||
"""
|
||
|
||
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 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 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"})
|