fix(plex): every small Plex picture asks for its card-size cut

The playlist rows, the search box and the collection picker still
fetched the 400px poster for a 32-80px picture. A contract test pins the
set of bare Plex pictures, and the srcset widths to imgcache.SIZES.
This commit is contained in:
2026-10-02 23:42:32 +02:00
parent e16cdbd16f
commit a50055577a
5 changed files with 90 additions and 6 deletions
+2 -1
View File
@@ -27,7 +27,8 @@ _CT_EXT = {ct: ext for ext, ct in _EXT_CT}
# 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`).
# picks between a pair itself (`srcset`, built by the frontend's `lib/plexImage` cardImage, whose
# widths plexImage.contract.test holds to these).
SIZES = {
"thumb": (400, 600),
"thumb_sm": (240, 360),
@@ -5,6 +5,7 @@ import { qk } from "../lib/queryKeys";
import { api, type PlexCard } from "../lib/api";
import { useDebounced } from "../lib/useDebounced";
import { LoadingState } from "./ui/QueryState";
import { cardImage } from "../lib/plexImage";
// A debounced library search-and-pick list, shared by the "Add title" panel (PlexCollectionView) and
// the "New collection" first-title panel (plexBrowseLists) — both search the library the same way and
@@ -49,7 +50,7 @@ export default function PlexLibraryPicker({
{results.map((c) => (
<li key={c.id} className="flex items-center gap-3">
<img
src={c.thumb}
{...cardImage(c.thumb)}
alt=""
className="h-12 w-8 shrink-0 rounded object-cover"
loading="lazy"
+9 -3
View File
@@ -55,6 +55,7 @@ import { SURFACE } from "./ui/surfaces";
import { PageHeading } from "./ui/Headings";
import { MEDIA_BADGE } from "./ui/mediaCardStyles";
import { seasonTitle } from "../lib/plexDetail";
import { cardImage } from "../lib/plexImage";
// A playlist's items, grouped for readability: movies stay standalone, while a run of episodes from the
// same show collapses into a season/show group so a whole series doesn't sprawl into an endless flat
@@ -157,7 +158,7 @@ function Poster({ src, className }: { src: string; className: string }) {
<div
className={`relative shrink-0 overflow-hidden rounded-sm border border-border bg-surface ${className}`}
>
<img src={src} alt="" loading="lazy" className="h-full w-full object-cover" />
<img {...cardImage(src)} alt="" loading="lazy" className="h-full w-full object-cover" />
</div>
);
}
@@ -200,7 +201,12 @@ function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean
<div
className={`relative shrink-0 overflow-hidden rounded-sm border border-border bg-surface ${dense ? "aspect-video w-14" : "aspect-video w-20"}`}
>
<img src={ep.thumb} alt="" loading="lazy" className="h-full w-full object-cover" />
<img
{...cardImage(ep.thumb)}
alt=""
loading="lazy"
className="h-full w-full object-cover"
/>
{ep.status === "watched" && <span className={`${MEDIA_BADGE} right-0.5 top-0.5`}>✓</span>}
{inProgress && <div className="absolute inset-x-0 bottom-0 h-0.5 bg-accent" />}
<div className="absolute inset-0 grid place-items-center bg-black/40 opacity-0 transition group-hover/thumb:opacity-100">
@@ -319,7 +325,7 @@ function ShowGroup({ block, ctx }: { block: ShowBlock; ctx: Ctx }) {
{block.items.slice(0, 3).map((ep, i) => (
<img
key={ep.id}
src={ep.thumb}
{...cardImage(ep.thumb)}
alt=""
loading="lazy"
className="aspect-2/3 w-8 rounded-sm border-2 border-surface object-cover"
+2 -1
View File
@@ -12,6 +12,7 @@ import { availablePlexFields, healPlexField, plexFieldLabelKey } from "../lib/pl
import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces";
import { ROVING_HIGHLIGHT } from "./ui/AnchoredPopover";
import { cardImage } from "../lib/plexImage";
// The Plex header search box: the shared SearchBar enriched with the S2 search scope-pill (a
// GitHub-style field selector inside the box) and an instant typeahead of the top matches attached
@@ -160,7 +161,7 @@ export default function PlexSearchBox() {
className={`flex w-full items-center gap-3 rounded-lg p-1.5 transition ${ROVING_HIGHLIGHT}`}
>
<img
src={c.thumb}
{...cardImage(c.thumb)}
alt=""
loading="lazy"
className="h-12 w-8 shrink-0 rounded-md object-cover bg-border/40"
@@ -0,0 +1,75 @@
import { readFileSync } from "node:fs";
import { expect, it } from "vitest";
import { cardImage } from "./plexImage";
// A card's srcset names each Plex cut by its width, and the browser picks one by that width. The
// widths are the image proxy's (backend `app/plex/imgcache.py` SIZES), with no wire format between
// the two: a backend-only change would have the browser pick a cut by a width it does not have (E31
// S5). Read from source, as plexFinishMargin.contract does.
const source = readFileSync(
new URL("../../../backend/app/plex/imgcache.py", import.meta.url),
"utf8",
);
const backendWidth = (variant: string) => {
const m = new RegExp(`"${variant}": \\((\\d+), \\d+\\)`).exec(source);
// If this fails the variant was renamed or moved: re-point this test, the invariant stayed.
expect(m, `${variant} not in imgcache.SIZES`).not.toBeNull();
return Number(m![1]);
};
const srcsetWidths = (url: string) =>
Object.fromEntries(
cardImage(url)
.srcSet!.split(", ")
.map((c) => {
const [u, w] = c.split(" ");
return [
new URL(u!, "http://x").searchParams.get("variant") ?? "thumb",
Number(w!.slice(0, -1)),
];
}),
);
it("names each poster cut by the width the proxy cuts it at", () => {
expect(srcsetWidths("/api/plex/image/1")).toEqual({
thumb_sm: backendWidth("thumb_sm"),
thumb: backendWidth("thumb"),
});
});
it("names each backdrop cut by the width the proxy cuts it at", () => {
expect(srcsetWidths("/api/plex/image/1?variant=art")).toEqual({
art_sm: backendWidth("art_sm"),
art: backendWidth("art"),
});
});
// Every small Plex picture asks for its card-size cut through cardImage (E31 S4e, swept in S5: the
// playlist rows, the search box and the collection picker still fetched the 400px poster for a 32px
// picture). A bare `src={…thumb}` is allowed only where the full cut is the point or the URL is not
// the proxy's; the SET is pinned, so a new bare one fails here.
const plexFiles = import.meta.glob(
[
"../components/{Plex,plex}*.tsx",
"../components/{Home,PosterTile,UpNextCard}.tsx",
"!../components/**/*.test.tsx",
],
{ query: "?raw", import: "default", eager: true },
);
const ALLOWED = new Set([
"plexCards.tsx c.thumb", // cast photos: the person-image route, not the image proxy
"PlexInfo.tsx c.thumb", // the same cast photos on the detail page
"PlexInfo.tsx detail.thumb", // the detail page's own poster: the full cut is the point
"plexDetailUi.tsx thumb", // the show/season header poster: the same
"Home.tsx card.thumb", // the hero (full width) and the shelf's own tiles (YouTube channel art)
]);
it("leaves no small Plex picture on the full cut", () => {
expect(Object.keys(plexFiles).length).toBeGreaterThan(5);
const bare = new Set<string>();
for (const [path, src] of Object.entries(plexFiles))
for (const m of (src as string).matchAll(/<img\s+src=\{([^}]*thumb[^}]*)\}/g))
bare.add(`${path.split("/").pop()} ${m[1]!.trim()}`);
expect([...bare].filter((b) => !ALLOWED.has(b))).toEqual([]);
});