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(
+