merge: right-sized Plex poster/art images — v0.28.2
This commit is contained in:
commit
335e4d7c76
4 changed files with 35 additions and 6 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.28.1
|
0.28.2
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ metadata, image proxying, and (P5) optional watch-state write-back. Requests JSO
|
||||||
``Accept: application/json`` header (Plex serves XML by default).
|
``Accept: application/json`` header (Plex serves XML by default).
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
@ -112,11 +113,24 @@ class PlexClient:
|
||||||
items = mc.get("Metadata", []) or []
|
items = mc.get("Metadata", []) or []
|
||||||
return items[0] if items else None
|
return items[0] if items else None
|
||||||
|
|
||||||
def image_bytes(self, image_path: str) -> tuple[bytes, str]:
|
def image_bytes(
|
||||||
|
self, image_path: str, width: int | None = None, height: int | None = None
|
||||||
|
) -> tuple[bytes, str]:
|
||||||
"""Raw bytes + content-type of a Plex thumb/art image (proxied to the browser). The
|
"""Raw bytes + content-type of a Plex thumb/art image (proxied to the browser). The
|
||||||
image_path is a Plex path like ``/library/metadata/123/thumb/456``."""
|
image_path is a Plex path like ``/library/metadata/123/thumb/456``.
|
||||||
|
|
||||||
|
With width+height, Plex's photo transcoder resizes server-side so the browser fetches a small
|
||||||
|
image (~tens of KB) instead of the multi-MB full-res original — far faster grid scroll + a
|
||||||
|
much smaller disk cache. ``minSize=1`` fits within the box; ``upscale=0`` never enlarges."""
|
||||||
|
if width and height:
|
||||||
|
path = (
|
||||||
|
f"/photo/:/transcode?width={width}&height={height}"
|
||||||
|
f"&minSize=1&upscale=0&url={quote(image_path, safe='')}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
path = image_path
|
||||||
try:
|
try:
|
||||||
r = self._http.get(f"{self.base}{image_path}")
|
r = self._http.get(f"{self.base}{path}")
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
raise PlexError(str(e)) from e
|
raise PlexError(str(e)) from e
|
||||||
|
|
|
||||||
|
|
@ -534,6 +534,9 @@ def _image_key(db: Session, rating_key: str, variant: str) -> str | None:
|
||||||
_IMG_CACHE = Path(settings.download_root) / ".plex-img-cache"
|
_IMG_CACHE = Path(settings.download_root) / ".plex-img-cache"
|
||||||
_EXT_CT = [("jpg", "image/jpeg"), ("png", "image/png"), ("webp", "image/webp")]
|
_EXT_CT = [("jpg", "image/jpeg"), ("png", "image/png"), ("webp", "image/webp")]
|
||||||
_CT_EXT = {ct: ext for ext, ct in _EXT_CT}
|
_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.
|
||||||
|
_IMG_SIZES = {"thumb": (400, 600), "art": (1280, 720)}
|
||||||
|
|
||||||
|
|
||||||
def _img_cache_read(key: str) -> tuple[bytes, str] | None:
|
def _img_cache_read(key: str) -> tuple[bytes, str] | None:
|
||||||
|
|
@ -584,7 +587,11 @@ def image(rating_key: str, request: Request, variant: str = "thumb") -> Response
|
||||||
fetch. Auth is the signed-session check (no DB user-load)."""
|
fetch. Auth is the signed-session check (no DB user-load)."""
|
||||||
_require_session(request)
|
_require_session(request)
|
||||||
v = "art" if variant == "art" else "thumb"
|
v = "art" if variant == "art" else "thumb"
|
||||||
cache_key = f"item_{rating_key}_{v}"
|
w, h = _IMG_SIZES[v]
|
||||||
|
# Size is part of the cache key: Plex resizes server-side (see PlexClient.image_bytes), so we cache
|
||||||
|
# the small variant. The width tag also means changing a target size just re-caches, never serves a
|
||||||
|
# stale differently-sized file (old full-size files under the un-tagged key are simply orphaned).
|
||||||
|
cache_key = f"item_{rating_key}_{v}_{w}"
|
||||||
hit = _img_cache_read(cache_key)
|
hit = _img_cache_read(cache_key)
|
||||||
if hit:
|
if hit:
|
||||||
return _img_response(*hit)
|
return _img_response(*hit)
|
||||||
|
|
@ -599,7 +606,7 @@ def image(rating_key: str, request: Request, variant: str = "thumb") -> Response
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
try:
|
try:
|
||||||
with plex:
|
with plex:
|
||||||
data, ct = plex.image_bytes(key)
|
data, ct = plex.image_bytes(key, w, h)
|
||||||
except PlexError as e:
|
except PlexError as e:
|
||||||
raise HTTPException(status_code=502, detail=f"Plex image fetch failed: {e}")
|
raise HTTPException(status_code=502, detail=f"Plex image fetch failed: {e}")
|
||||||
_img_cache_write(cache_key, data, ct)
|
_img_cache_write(cache_key, data, ct)
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,14 @@ export interface ReleaseEntry {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RELEASE_NOTES: ReleaseEntry[] = [
|
export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||||
|
{
|
||||||
|
version: "0.28.2",
|
||||||
|
date: "2026-07-06",
|
||||||
|
summary: "Much faster, smoother Plex browsing — right-sized poster images.",
|
||||||
|
features: [
|
||||||
|
"Plex images: posters and art are now resized by Plex to the size actually shown (instead of shipping the full-resolution originals — some were several MB each), so the movie grid scrolls smoothly and images pop in almost instantly. Also shrinks the image cache dramatically.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "0.28.1",
|
version: "0.28.1",
|
||||||
date: "2026-07-06",
|
date: "2026-07-06",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue