From a50055577aa5a905cad0b3d651711aabd8640b3e Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 2 Oct 2026 23:42:32 +0200 Subject: [PATCH] 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. --- backend/app/plex/imgcache.py | 3 +- frontend/src/components/PlexLibraryPicker.tsx | 3 +- frontend/src/components/PlexPlaylistView.tsx | 12 ++- frontend/src/components/PlexSearchBox.tsx | 3 +- frontend/src/lib/plexImage.contract.test.ts | 75 +++++++++++++++++++ 5 files changed, 90 insertions(+), 6 deletions(-) create mode 100644 frontend/src/lib/plexImage.contract.test.ts diff --git a/backend/app/plex/imgcache.py b/backend/app/plex/imgcache.py index 6f7ba2e0..8eed4e1b 100644 --- a/backend/app/plex/imgcache.py +++ b/backend/app/plex/imgcache.py @@ -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), diff --git a/frontend/src/components/PlexLibraryPicker.tsx b/frontend/src/components/PlexLibraryPicker.tsx index aa480f8b..3e042133 100644 --- a/frontend/src/components/PlexLibraryPicker.tsx +++ b/frontend/src/components/PlexLibraryPicker.tsx @@ -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) => (
  • - + ); } @@ -200,7 +201,12 @@ function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean
    - + {ep.status === "watched" && ✓} {inProgress &&
    }
    @@ -319,7 +325,7 @@ function ShowGroup({ block, ctx }: { block: ShowBlock; ctx: Ctx }) { {block.items.slice(0, 3).map((ep, i) => ( { + 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(); + for (const [path, src] of Object.entries(plexFiles)) + for (const m of (src as string).matchAll(/ !ALLOWED.has(b))).toEqual([]); +});