perf(plex): a card fetches its picture at a card's size, not the detail page's
Cards fetched the full cuts: a 400x600 poster (~38 KB) for a ~144px picture and the 1280x720 backdrop (~162 KB) for a ~212px shelf picture. The image proxy now also cuts thumb_sm (240x360) and art_sm (480x270), cached apart; card pictures (grid, shelves, collection tiles, Up next) carry both in srcset with sizes=auto, so the browser picks by laid-out width and DPR. Localdev: 17 KB a poster, 56 KB a backdrop. The detail page keeps the full cuts.
This commit is contained in:
@@ -23,9 +23,22 @@ log = logs.get_logger(__name__)
|
|||||||
|
|
||||||
_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/
|
# Target box per variant — Plex resizes to these server-side. The full poster (400px) covers a detail
|
||||||
# strips), so 400px covers 2× DPR; the faint backdrop art only needs ~1280px. Keeps images ~tens of KB.
|
# page's ≤224px poster at 2× DPR, the full art (1280px) a hero backdrop. A card is much smaller: a grid
|
||||||
SIZES = {"thumb": (400, 600), "art": (1280, 720)}
|
# 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:
|
def key_for(db: Session, rating_key: str, variant: str) -> str | None:
|
||||||
|
|||||||
@@ -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
|
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)."""
|
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 = variant if variant in imgcache.SIZES else "thumb"
|
||||||
w, h = imgcache.SIZES[v]
|
w, h = imgcache.SIZES[v]
|
||||||
# Size is part of the cache key: Plex resizes server-side (see PlexClient.image_bytes), so we cache
|
# 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
|
# 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.
|
# Cold miss: short-lived session for the key + Plex client config, released before the slow fetch.
|
||||||
try:
|
try:
|
||||||
with SessionLocal() as db:
|
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:
|
if not key:
|
||||||
raise not_found("image")
|
raise not_found("image")
|
||||||
plex = PlexClient(db) # reads base URL + token now; the fetch below needs no DB
|
plex = PlexClient(db) # reads base URL + token now; the fetch below needs no DB
|
||||||
|
|||||||
@@ -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)]
|
||||||
@@ -3,6 +3,7 @@ import { hueOf, hueTileGradient } from "../lib/hueTile";
|
|||||||
import { navItemAttrs } from "../lib/listNav";
|
import { navItemAttrs } from "../lib/listNav";
|
||||||
import { MEDIA_IMG } from "./ui/mediaCardStyles";
|
import { MEDIA_IMG } from "./ui/mediaCardStyles";
|
||||||
import { Pressable } from "./ui/Pressable";
|
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}),
|
// 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
|
// 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] ? (
|
posters[i] ? (
|
||||||
<img
|
<img
|
||||||
key={i}
|
key={i}
|
||||||
src={`/api/plex/image/${posters[i]}`}
|
{...cardImage(`/api/plex/image/${posters[i]}`)}
|
||||||
alt=""
|
alt=""
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
className={MEDIA_IMG}
|
className={MEDIA_IMG}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Play, X } from "lucide-react";
|
|||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
import { plexLang } from "../i18n";
|
import { plexLang } from "../i18n";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
|
import { cardImage } from "../lib/plexImage";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "./ui/Button";
|
||||||
import { IconButton } from "./ui/IconButton";
|
import { IconButton } from "./ui/IconButton";
|
||||||
|
|
||||||
@@ -72,8 +73,9 @@ export function UpNextCard({
|
|||||||
<div className="h-[6.5rem] w-[4.6rem] flex-none overflow-hidden rounded-lg bg-white/10">
|
<div className="h-[6.5rem] w-[4.6rem] flex-none overflow-hidden rounded-lg bg-white/10">
|
||||||
{next && (
|
{next && (
|
||||||
<img
|
<img
|
||||||
src={api.plexImageUrl(nextId, "thumb")}
|
{...cardImage(api.plexImageUrl(nextId, "thumb"))}
|
||||||
alt=""
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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(
|
||||||
|
<PlexPosterCard card={{ ...movie(), thumb: "/api/plex/image/1" }} onOpen={vi.fn()} />,
|
||||||
|
);
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { MediaCard, MediaProgress, MediaPlayGlyph } from "./ui/MediaCard";
|
|||||||
import { MediaPreview } from "./ui/MediaPreview";
|
import { MediaPreview } from "./ui/MediaPreview";
|
||||||
import { useMediaPreview } from "./ui/useMediaPreview";
|
import { useMediaPreview } from "./ui/useMediaPreview";
|
||||||
import { SURFACE } from "./ui/surfaces";
|
import { SURFACE } from "./ui/surfaces";
|
||||||
|
import { cardImage } from "../lib/plexImage";
|
||||||
|
|
||||||
// Presentational Plex cards + strips, split out of PlexBrowse (all purely prop-driven, no shared
|
// 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.
|
// 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
|
// 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.
|
// the menu offered "from the start" (E28 review D4). One rule, `plexStarted`, for both.
|
||||||
const resumable = plays && started && !!onRestart;
|
const resumable = plays && started && !!onRestart;
|
||||||
|
const pic = cardImage(thumb);
|
||||||
return (
|
return (
|
||||||
<div className={mediaPictureBox(aspect)}>
|
<div className={mediaPictureBox(aspect)}>
|
||||||
<Pressable onClick={onOpen} aria-label={label} data-nav-open="" className={MEDIA_PICTURE}>
|
<Pressable onClick={onOpen} aria-label={label} data-nav-open="" className={MEDIA_PICTURE}>
|
||||||
<img src={thumb} alt="" loading="lazy" decoding="async" className={MEDIA_IMG} />
|
<img
|
||||||
|
srcSet={pic.srcSet}
|
||||||
|
sizes={pic.sizes}
|
||||||
|
src={pic.src}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
className={MEDIA_IMG}
|
||||||
|
/>
|
||||||
{children}
|
{children}
|
||||||
<div className={MEDIA_HOVER_LAYER}>
|
<div className={MEDIA_HOVER_LAYER}>
|
||||||
{plays && !resumable && <MediaPlayGlyph small={small} />}
|
{plays && !resumable && <MediaPlayGlyph small={small} />}
|
||||||
|
|||||||
@@ -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: "" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 <img>, 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user