perf(plex): request 220x220 transcoded artwork for thumbnail contexts

Plex artwork URLs were going directly to `/library/metadata/<id>/thumb/<ts>`
which serves the **full resolution** image — typically 1000x1000 or
larger for album covers. The browser then had to decode the full JPEG
and downscale to the displayed 220x220, blowing through both decode
budget (full-res JPEG decode per visible card) and memory budget
(~4MB raw raster per card × N visible = hundreds of MB of image cache).

For a Plex-dominant library (the user's case), this was a hidden
cost factor on every thumbnail grid surface.

Fix: `buildPlexArtworkUrl` now takes an optional `size` parameter.
When provided, the path is wrapped in `/photo/:/transcode` so the
Plex server downscales server-side and the browser receives ~30KB
JPEGs at the right size instead of 1MB+ originals. When `size` is
omitted, the original full-res URL is returned — preserved for
`getFullArtworkUrl` (AlbumDetailView's big artwork display).

Updated callsites pass `220`:
  - LocalLibraryView `getArtworkUrl` (grid + list thumbs)
  - MixtapeCollectionDetailView resolve path
  - PlaylistDetailView track artwork

Per the 2026-05-12 user call: 220 over 440 — fluidity wins over
HiDPI sharpness on this surface.

cc
This commit is contained in:
vicrodh committed 2026-05-12 16:33:28 -06:00
1 parent 4bc1b0faea
commit 9f41799965
3 files changed
+36 -6

No files matched your search

@@ -3025,11 +3025,22 @@
return enabled && baseUrl.length > 0 && token.length > 0;
}
function buildPlexArtworkUrl(path: string): string {
/**
* Build a Plex artwork URL. When `size` is provided, wraps the path
* in `/photo/:/transcode` so the Plex server returns a resized image
* (server-side downscale) — critical for thumbnail contexts where
* the original artwork is typically 1000x1000+ and blowing through
* decode + memory budget otherwise. When `size` is omitted, returns
* the original full-resolution path (used by AlbumDetailView).
*/
function buildPlexArtworkUrl(path: string, size?: number): string {
const baseUrl = getUserItem('qbz-plex-poc-base-url') || '';
const token = getUserItem('qbz-plex-poc-token') || '';
if (!baseUrl || !token) return path;
const base = baseUrl.replace(/\/+$/, '');
if (size && size > 0) {
return `${base}/photo/:/transcode?url=${encodeURIComponent(path)}&width=${size}&height=${size}&minSize=1&X-Plex-Token=${encodeURIComponent(token)}`;
}
const separator = path.includes('?') ? '&' : '?';
return `${base}${path}${separator}X-Plex-Token=${encodeURIComponent(token)}`;
}
@@ -4608,7 +4619,7 @@
function getArtworkUrl(path?: string): string {
if (!path) return '';
if (/^https?:\/\//i.test(path)) return path;
if (path.startsWith('/library/')) return buildPlexArtworkUrl(path);
if (path.startsWith('/library/')) return buildPlexArtworkUrl(path, 220);
// For grid/list views, prefer thumbnails
const cachedThumb = thumbnailUrlCache.get(path);
@@ -996,11 +996,21 @@
let resolvedById = $state<Record<string, ResolvedItem>>({});
function buildPlexArtworkUrl(path: string): string | null {
/**
* Build a Plex artwork URL. When `size` is provided, wraps the path
* in `/photo/:/transcode` so the Plex server returns a resized image
* (server-side downscale). Without `size`, returns the original
* full-res path. Mixtape items render at thumbnail-sized rows so
* 220px is plenty.
*/
function buildPlexArtworkUrl(path: string, size?: number): string | null {
const baseUrl = (getUserItem('qbz-plex-poc-base-url') || '').trim();
const token = (getUserItem('qbz-plex-poc-token') || '').trim();
if (!baseUrl || !token) return null;
const base = baseUrl.replace(/\/+$/, '');
if (size && size > 0) {
return `${base}/photo/:/transcode?url=${encodeURIComponent(path)}&width=${size}&height=${size}&minSize=1&X-Plex-Token=${encodeURIComponent(token)}`;
}
const separator = path.includes('?') ? '&' : '?';
return `${base}${path}${separator}X-Plex-Token=${encodeURIComponent(token)}`;
}
@@ -1070,7 +1080,7 @@
next[key] = {
kind: 'plex',
artworkUrl: plexHit.artworkPath
? buildPlexArtworkUrl(plexHit.artworkPath)
? buildPlexArtworkUrl(plexHit.artworkPath, 220)
: null,
bitDepth: plexHit.bitDepth ?? null,
sampleRateKhz: plexHit.sampleRate ? plexHit.sampleRate / 1000 : null,
@@ -1498,12 +1498,21 @@
// is a library-relative URI like /library/metadata/123/thumb/…,
// which only resolves when combined with the configured base URL +
// X-Plex-Token. Matches LocalLibraryView's buildPlexArtworkUrl.
function buildPlexArtworkUrl(path: string): string {
/**
* Build a Plex artwork URL. When `size` is provided, wraps the path
* in `/photo/:/transcode` so Plex returns a server-side resized
* image (critical for thumbnail rows; the original is typically
* 1000x1000+). Without `size`, returns the original full-res URL.
*/
function buildPlexArtworkUrl(path: string, size?: number): string {
if (path.startsWith('http://') || path.startsWith('https://')) return path;
const baseUrl = getUserItem('qbz-plex-poc-base-url') || '';
const token = getUserItem('qbz-plex-poc-token') || '';
if (!baseUrl || !token) return path;
const base = baseUrl.replace(/\/+$/, '');
if (size && size > 0) {
return `${base}/photo/:/transcode?url=${encodeURIComponent(path)}&width=${size}&height=${size}&minSize=1&X-Plex-Token=${encodeURIComponent(token)}`;
}
const separator = path.includes('?') ? '&' : '?';
return `${base}${path}${separator}X-Plex-Token=${encodeURIComponent(token)}`;
}
@@ -1527,7 +1536,7 @@
title: track.title,
artist: track.artist,
album: track.album,
albumArt: track.artwork_path ? buildPlexArtworkUrl(track.artwork_path) : undefined,
albumArt: track.artwork_path ? buildPlexArtworkUrl(track.artwork_path, 220) : undefined,
duration: formatDuration(track.duration_secs),
durationSeconds: track.duration_secs,
hires: (track.bit_depth && track.bit_depth >= 24) || track.sample_rate > 48000,