Mechanical 1.x->2.0 conversion of the self-contained plex modules, behaviour-
equivalent (the characterization net + existing plex tests stay green, 39
passed): app/plex/{sync(15),watch_sync(9),people(1),imgcache(4)} and
app/routes/plex/{admin(4),playback(8),_shared(1)}. Query builders become
select(); .first()/.all()->execute().scalars(); a bulk .update() ->
execute(update()); a single-column .first() -> db.scalar().
browse.py (61) + rollup.agg_subquery (the atomic unit with unified_library)
remain for wave 2.
77 lines
2.9 KiB
Python
77 lines
2.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 import select
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.models import PlexCollection, PlexItem, PlexSeason, PlexShow
|
||
from app.plex.cache import CACHE_DIR
|
||
|
||
_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 = db.execute(
|
||
select(PlexItem).where(PlexItem.rating_key == rating_key)
|
||
).scalars().first()
|
||
if it is not None:
|
||
return it.art_key if variant == "art" else it.thumb_key
|
||
sh = db.execute(
|
||
select(PlexShow).where(PlexShow.rating_key == rating_key)
|
||
).scalars().first()
|
||
if sh is not None:
|
||
return sh.art_key if variant == "art" else sh.thumb_key
|
||
se = db.execute(
|
||
select(PlexSeason).where(PlexSeason.rating_key == rating_key)
|
||
).scalars().first()
|
||
if se is not None:
|
||
return se.thumb_key
|
||
col = db.execute(
|
||
select(PlexCollection).where(PlexCollection.rating_key == rating_key)
|
||
).scalars().first()
|
||
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
|
||
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:
|
||
pass # cache is best-effort
|
||
|
||
|
||
def response(data: bytes, ct: str) -> Response:
|
||
return Response(
|
||
content=data, media_type=ct, headers={"Cache-Control": "public, max-age=86400"}
|
||
)
|