diff --git a/backend/app/plex/imgcache.py b/backend/app/plex/imgcache.py index 130c241d..6f7ba2e0 100644 --- a/backend/app/plex/imgcache.py +++ b/backend/app/plex/imgcache.py @@ -23,9 +23,22 @@ 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)} +# 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: diff --git a/backend/app/routes/plex/playback.py b/backend/app/routes/plex/playback.py index 562973a4..f819f043 100644 --- a/backend/app/routes/plex/playback.py +++ b/backend/app/routes/plex/playback.py @@ -65,7 +65,7 @@ def image(rating_key: str, request: Request, variant: str = "thumb") -> Response brief session only to resolve the image key + read the Plex config, then RELEASES it before the fetch. Auth is the signed-session check (no DB user-load).""" _require_session(request) - v = "art" if variant == "art" else "thumb" + v = variant if variant in imgcache.SIZES else "thumb" w, h = imgcache.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 @@ -77,7 +77,7 @@ def image(rating_key: str, request: Request, variant: str = "thumb") -> Response # Cold miss: short-lived session for the key + Plex client config, released before the slow fetch. try: with SessionLocal() as db: - key = imgcache.key_for(db, str(rating_key), v) + key = imgcache.key_for(db, str(rating_key), imgcache.picture(v)) if not key: raise not_found("image") plex = PlexClient(db) # reads base URL + token now; the fetch below needs no DB diff --git a/backend/tests/test_plex_image_sizes.py b/backend/tests/test_plex_image_sizes.py new file mode 100644 index 00000000..6e3cdaad --- /dev/null +++ b/backend/tests/test_plex_image_sizes.py @@ -0,0 +1,77 @@ +"""A card asks the Plex image proxy for a small cut; a detail page keeps the full one (E31 S4e). + +Cards fetched the 400×600 poster for a ~144px picture and the 1280×720 backdrop for a ~212px shelf +picture. The `_sm` variants are cut by Plex at a card's size and cached apart from the full ones; an +unknown variant still gets the full poster. DB lane (the proxy opens its own short session). +""" + +from fastapi.testclient import TestClient + +import app.state as app_state +from app.main import app +from app.models import PlexItem, PlexLibrary +from app.plex import imgcache +from app.routes.plex import playback + + +class _FakePlex: + calls: list[tuple[str, int | None, int | None]] = [] + + def __init__(self, db) -> None: + pass + + def __enter__(self): + return self + + def __exit__(self, *exc) -> None: + return None + + def image_bytes(self, key, width=None, height=None): + _FakePlex.calls.append((key, width, height)) + return b"\xff\xd8jpeg", "image/jpeg" + + +def _setup(db, make_user, monkeypatch, tmp_path) -> TestClient: + user = make_user() + lib = PlexLibrary(plex_key="90", title="Movies", kind="movie", enabled=True) + db.add(lib) + db.flush() + db.add( + PlexItem( + rating_key="pm1", + library_id=lib.id, + kind="movie", + title="A Movie", + thumb_key="/library/metadata/pm1/thumb/1", + art_key="/library/metadata/pm1/art/1", + ) + ) + db.commit() + monkeypatch.setattr(app_state, "_configured_cache", True) + monkeypatch.setattr(playback, "resolved_user_id", lambda request: (user.id, True)) + monkeypatch.setattr(playback, "PlexClient", _FakePlex) + monkeypatch.setattr(imgcache, "CACHE_DIR", tmp_path) + _FakePlex.calls = [] + return TestClient(app) + + +def test_each_variant_is_cut_at_its_own_size(db, make_user, monkeypatch, tmp_path): + client = _setup(db, make_user, monkeypatch, tmp_path) + for variant in ("thumb", "thumb_sm", "art", "art_sm"): + assert client.get(f"/api/plex/image/pm1?variant={variant}").status_code == 200 + assert _FakePlex.calls == [ + ("/library/metadata/pm1/thumb/1", 400, 600), + ("/library/metadata/pm1/thumb/1", 240, 360), + ("/library/metadata/pm1/art/1", 1280, 720), + ("/library/metadata/pm1/art/1", 480, 270), + ] + # Each cut is cached under its own name: asking again reaches Plex no more. + for variant in ("thumb", "thumb_sm", "art", "art_sm"): + client.get(f"/api/plex/image/pm1?variant={variant}") + assert len(_FakePlex.calls) == 4 + + +def test_an_unknown_variant_gets_the_full_poster(db, make_user, monkeypatch, tmp_path): + client = _setup(db, make_user, monkeypatch, tmp_path) + assert client.get("/api/plex/image/pm1?variant=huge").status_code == 200 + assert _FakePlex.calls == [("/library/metadata/pm1/thumb/1", 400, 600)] diff --git a/frontend/src/components/PosterTile.tsx b/frontend/src/components/PosterTile.tsx index 7e99d63f..9acfb9f9 100644 --- a/frontend/src/components/PosterTile.tsx +++ b/frontend/src/components/PosterTile.tsx @@ -3,6 +3,7 @@ import { hueOf, hueTileGradient } from "../lib/hueTile"; import { navItemAttrs } from "../lib/listNav"; import { MEDIA_IMG } from "./ui/mediaCardStyles"; import { Pressable } from "./ui/Pressable"; +import { cardImage } from "../lib/plexImage"; // A browse tile split in two: the TOP half shows up to 2 montage posters (from /api/plex/image/{key}), // the BOTTOM half is a solid colour-hash band that carries the label — so the text is always readable @@ -50,7 +51,7 @@ export default function PosterTile({ posters[i] ? ( {next && ( diff --git a/frontend/src/components/plexCards.dom.test.tsx b/frontend/src/components/plexCards.dom.test.tsx index e27d1ab2..e155f584 100644 --- a/frontend/src/components/plexCards.dom.test.tsx +++ b/frontend/src/components/plexCards.dom.test.tsx @@ -363,3 +363,19 @@ describe("PlexPosterCard on a shelf", () => { } }); }); + +// E31 S4e: a card fetched the detail page's 400px poster for its ~144px picture. It offers the +// proxy's small cut too and lets the browser pick by its width. +describe("a Plex card's picture", () => { + it("offers the small cut beside the full one, lazily", () => { + const { container } = render( + , + ); + const img = container.querySelector("img")!; + expect(img.getAttribute("srcset")).toBe( + "/api/plex/image/1?variant=thumb_sm 240w, /api/plex/image/1 400w", + ); + expect(img.getAttribute("sizes")).toBe("auto, 200px"); + expect(img.getAttribute("loading")).toBe("lazy"); + }); +}); diff --git a/frontend/src/components/plexCards.tsx b/frontend/src/components/plexCards.tsx index 5b1728f4..0c791a9f 100644 --- a/frontend/src/components/plexCards.tsx +++ b/frontend/src/components/plexCards.tsx @@ -27,6 +27,7 @@ import { MediaCard, MediaProgress, MediaPlayGlyph } from "./ui/MediaCard"; import { MediaPreview } from "./ui/MediaPreview"; import { useMediaPreview } from "./ui/useMediaPreview"; import { SURFACE } from "./ui/surfaces"; +import { cardImage } from "../lib/plexImage"; // Presentational Plex cards + strips, split out of PlexBrowse (all purely prop-driven, no shared // state). Rendered by the browse grid, the person page, and the series show/season views. @@ -211,10 +212,19 @@ function PlexPicture({ // Not read off `pct`: that is rounded, so 20 s into a 3 h film was 0 % and "not started" here while // the menu offered "from the start" (E28 review D4). One rule, `plexStarted`, for both. const resumable = plays && started && !!onRestart; + const pic = cardImage(thumb); return (
- + {children}
{plays && !resumable && } diff --git a/frontend/src/lib/plexImage.test.ts b/frontend/src/lib/plexImage.test.ts new file mode 100644 index 00000000..df705fdb --- /dev/null +++ b/frontend/src/lib/plexImage.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; + +import { cardImage } from "./plexImage"; + +describe("cardImage", () => { + it("offers a poster's small cut beside the full one", () => { + expect(cardImage("/api/plex/image/123")).toEqual({ + src: "/api/plex/image/123", + srcSet: "/api/plex/image/123?variant=thumb_sm 240w, /api/plex/image/123 400w", + sizes: "auto, 200px", + }); + }); + + it("offers a backdrop's small cut beside the full one", () => { + expect(cardImage("/api/plex/image/123?variant=art").srcSet).toBe( + "/api/plex/image/123?variant=art_sm 480w, /api/plex/image/123?variant=art 1280w", + ); + }); + + it("leaves any other picture alone", () => { + expect(cardImage("https://image.tmdb.org/x.jpg")).toEqual({ + src: "https://image.tmdb.org/x.jpg", + }); + expect(cardImage("")).toEqual({ src: "" }); + }); +}); diff --git a/frontend/src/lib/plexImage.ts b/frontend/src/lib/plexImage.ts new file mode 100644 index 00000000..8da8264c --- /dev/null +++ b/frontend/src/lib/plexImage.ts @@ -0,0 +1,25 @@ +// A Plex picture at a card's size (E31 S4e). The image proxy cuts each picture twice (backend +// `app/plex/imgcache.py` SIZES): the full one a detail page shows and a card's `_sm` one. Cards fetched +// the full cut — a 400px poster for a ~144px picture, the 1280px backdrop for a ~212px shelf picture. +// A card now hands the browser both and lets it pick by the laid-out width: `sizes="auto"`, which the +// browser honours on a LAZY image only; the 200px after it is the guess for a browser without it. + +// The cut widths, as imgcache.SIZES has them: [small, full]. +const WIDTHS = { thumb: [240, 400], art: [480, 1280] } as const; +const PROXY = /^\/api\/plex\/image\/([^?]+)(?:\?variant=(art))?$/; + +export type CardImage = { src: string; srcSet?: string; sizes?: string }; + +/** `src` + `srcSet` + `sizes` for a lazy card picture; a URL that is not the Plex proxy's is left as it is. */ +export function cardImage(url: string): CardImage { + const m = PROXY.exec(url); + if (!m) return { src: url }; + const kind = m[2] === "art" ? "art" : "thumb"; + const [small, full] = WIDTHS[kind]; + // srcSet and sizes ahead of src: spread onto an , the attributes land in this order. + return { + srcSet: `/api/plex/image/${m[1]}?variant=${kind}_sm ${small}w, ${url} ${full}w`, + sizes: "auto, 200px", + src: url, + }; +}