From 6f4800671bbc8961f54b90c581efecf45480f9c9 Mon Sep 17 00:00:00 2001 From: Josh Cain Date: Wed, 29 Jul 2026 12:16:35 -0400 Subject: [PATCH 1/4] Show real poster artwork for active streams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now Playing rendered a monogram placeholder because no endpoint ever returned an image. Posters now come from Plex through Tautulli's pms_image_proxy, so artwork needs no outbound internet — matching the self-hosted assumption that keeps Inter and Phosphor vendored. The browser cannot call Tautulli directly: it may have no route to it, and the API key must not leave the server. A new /api/poster route proxies the bytes instead. The `img` value round-trips through the client, so it is re-validated against Plex's metadata path shape on the way back in rather than trusted because we emitted it — that check is the whole trust boundary, and it keeps the route from being usable as a general-purpose proxy. Every failure is a 404, not a 5xx: an item with no poster, a booting Tautulli, or an undiscovered key all fall back to the monogram tile the design already specifies, which is also what renders before the image loads. Episodes use the show's poster rather than the episode still. Co-Authored-By: Claude Opus 5 --- dashboard/server/src/index.ts | 26 ++++++- dashboard/server/src/sources/sources.test.ts | 47 ++++++++++++ dashboard/server/src/sources/tautulli.ts | 79 +++++++++++++++++++- dashboard/web/src/components/Poster.tsx | 65 ++++++++++++++++ dashboard/web/src/types.ts | 2 + dashboard/web/src/views/CommandCenter.tsx | 20 +---- 6 files changed, 219 insertions(+), 20 deletions(-) create mode 100644 dashboard/web/src/components/Poster.tsx diff --git a/dashboard/server/src/index.ts b/dashboard/server/src/index.ts index ce7c16b..5f903c2 100644 --- a/dashboard/server/src/index.ts +++ b/dashboard/server/src/index.ts @@ -8,7 +8,7 @@ import fastifyStatic from '@fastify/static'; import { config } from './config.js'; import { getHealth } from './sources/docker.js'; import { getMetrics } from './sources/prometheus.js'; -import { getStreams } from './sources/tautulli.js'; +import { getStreams, getPoster } from './sources/tautulli.js'; import { getDownloads, getVpn } from './sources/transmission.js'; import { getRequests } from './sources/seerr.js'; import { getUpcoming } from './sources/upcoming.js'; @@ -56,6 +56,30 @@ app.get('/api/upcoming', async () => getUpcoming()); app.get('/api/activity', async () => getActivity()); app.get('/api/vpn', async () => getVpn()); +/** + * Poster artwork, proxied from Plex via Tautulli. + * + * The browser can't call Tautulli itself — it may have no route to it, and the + * API key must not leave the server — so posters come through here. `img` is + * re-validated against Plex's metadata path shape inside `getPoster` even + * though this server produced it, since it round-trips through the client. + * + * Any failure is a 404 rather than a 5xx: the poster tile falls back to its + * monogram, which is the same thing it renders before the image loads. + */ +app.get<{ Querystring: { img?: string } }>('/api/poster', async (request, reply) => { + const img = request.query.img; + const image = img ? await getPoster(img) : null; + if (!image) return reply.code(404).send({ error: 'no poster' }); + + // The timestamp in a Plex image path changes whenever the artwork does, so + // any given URL is safe to cache hard. + return reply + .header('content-type', image.contentType) + .header('cache-control', 'public, max-age=86400') + .send(image.body); +}); + /** * Integration status for the Setup panel — what's live, what's still waiting on * a service's first boot, and the one concrete step for anything that's stuck. diff --git a/dashboard/server/src/sources/sources.test.ts b/dashboard/server/src/sources/sources.test.ts index ad27ce7..9bc3733 100644 --- a/dashboard/server/src/sources/sources.test.ts +++ b/dashboard/server/src/sources/sources.test.ts @@ -52,6 +52,53 @@ test('episode sessions render as SxxEyy, movies as a year', () => { assert.equal(movie.meta, '2024'); }); +test('only Plex metadata image paths are accepted', () => { + assert.ok(tautulli.isPlexImagePath('/library/metadata/130222/thumb/1785273308')); + assert.ok(tautulli.isPlexImagePath('/library/metadata/49126/art/1784188852')); + + // The value round-trips through the browser before coming back to the poster + // route, so anything that would widen it beyond one Plex image must be + // refused — traversal, absolute URLs, and query smuggling in particular. + for (const bad of [ + '/library/metadata/1/thumb/1/../../../etc/passwd', + 'http://evil.example/pwn.png', + '//evil.example/pwn.png', + '/library/metadata/1/thumb/1&cmd=get_settings', + '/library/sections/1/all', + '/library/metadata/abc/thumb/1', + '', + ]) { + assert.equal(tautulli.isPlexImagePath(bad), false, bad); + } +}); + +test('an episode posts the show poster; a movie its own', () => { + const episode = tautulli.toStream({ + media_type: 'episode', + grandparent_thumb: '/library/metadata/49126/thumb/1784188852', + thumb: '/library/metadata/130248/thumb/1785326051', + }); + // The episode's own thumb is the still frame; the design's tile wants the + // show's poster. + assert.equal(episode.poster, '/api/poster?img=%2Flibrary%2Fmetadata%2F49126%2Fthumb%2F1784188852'); + + const movie = tautulli.toStream({ + media_type: 'movie', + thumb: '/library/metadata/130222/thumb/1785273308', + }); + assert.equal(movie.poster, '/api/poster?img=%2Flibrary%2Fmetadata%2F130222%2Fthumb%2F1785273308'); +}); + +test('a missing or unusable thumb yields no poster rather than a broken URL', () => { + assert.equal(tautulli.toStream({ media_type: 'movie' }).poster, null); + assert.equal( + tautulli.toStream({ media_type: 'movie', thumb: 'http://evil.example/x.png' }).poster, + null, + ); + // An episode with no show poster falls back to the monogram, not to its still. + assert.equal(tautulli.toStream({ media_type: 'episode' }).poster, null); +}); + test('progress percent is clamped to 0-100', () => { assert.equal(tautulli.toStream({ progress_percent: '150' }).percent, 100); assert.equal(tautulli.toStream({ progress_percent: '-10' }).percent, 0); diff --git a/dashboard/server/src/sources/tautulli.ts b/dashboard/server/src/sources/tautulli.ts index 8d3813a..b9a35fd 100644 --- a/dashboard/server/src/sources/tautulli.ts +++ b/dashboard/server/src/sources/tautulli.ts @@ -23,6 +23,10 @@ interface TautulliSession { progress_percent?: string; view_offset?: string; duration?: string; + /** Plex image path for this item, e.g. `/library/metadata/130222/thumb/1785273308`. */ + thumb?: string; + /** The show's poster, which is what an episode should display. */ + grandparent_thumb?: string; } interface TautulliActivity { @@ -39,6 +43,13 @@ export interface Stream { title: string; meta: string; mono: string; + /** + * Dashboard-relative URL for the poster, or null when Plex didn't supply a + * usable path. Always points at this server's own `/api/poster` rather than + * at Tautulli: the browser may have no route to Tautulli, and the API key + * must not leave the server. `mono` stays the fallback either way. + */ + poster: string | null; user: string; quality: string; mode: StreamMode; @@ -89,6 +100,27 @@ function monogram(title: string): string { .toUpperCase(); } +/** + * The shape Plex uses for image paths. Anything else is refused. + * + * This is the whole of the trust boundary for `/api/poster`: the value arrives + * from Tautulli, round-trips through the browser, and is then handed back to + * Tautulli, so it has to be re-checked on the way in rather than trusted + * because we emitted it. Constraining it to a literal metadata path keeps the + * route from being usable as a general-purpose proxy. + */ +const PLEX_IMAGE_PATH = /^\/library\/metadata\/\d+\/(?:thumb|art|poster)\/\d+$/; + +export function isPlexImagePath(value: string): boolean { + return PLEX_IMAGE_PATH.test(value); +} + +/** Wraps a Plex image path as a URL this server will proxy, or null if unusable. */ +function posterUrl(path: string | undefined): string | null { + if (!path || !isPlexImagePath(path)) return null; + return `/api/poster?img=${encodeURIComponent(path)}`; +} + function toStream(session: TautulliSession): Stream { const isEpisode = session.media_type === 'episode'; const title = (isEpisode ? session.grandparent_title : session.title) ?? 'Unknown'; @@ -107,6 +139,9 @@ function toStream(session: TautulliSession): Stream { title, meta, mono: monogram(title), + // An episode's own thumb is the episode still; the show's poster is what + // the design's poster tile wants. + poster: posterUrl(isEpisode ? session.grandparent_thumb : session.thumb), user: session.friendly_name || session.user || 'Unknown', quality: session.video_full_resolution || session.quality_profile || '', mode: mode(session.transcode_decision), @@ -157,4 +192,46 @@ export const getStreams = memoize>(async () => { return safely(load); }, config.ttl.streams); -export const __test = { duration, mode, monogram, toStream }; +export interface PosterImage { + body: Buffer; + contentType: string; +} + +/** + * Fetches one poster through Tautulli's `pms_image_proxy`, which reads it from + * Plex — so artwork never requires outbound internet, matching the rest of the + * stack's self-hosted assumption. + * + * Returns null rather than throwing for every failure mode, because the caller + * turns that into a 404 and the UI falls back to the monogram tile. A missing + * poster must never be louder than that. + */ +export async function getPoster(path: string): Promise { + if (!isPlexImagePath(path)) return null; + + const credential = await credentialFor('tautulli'); + if (credential.state !== 'live' || !credential.apiKey) return null; + + const url = + `${config.upstream.tautulli}/api/v2` + + `?apikey=${encodeURIComponent(credential.apiKey)}` + + `&cmd=pms_image_proxy` + + `&img=${encodeURIComponent(path)}` + + `&width=300&height=450&fallback=poster`; + + try { + const response = await fetch(url, { signal: AbortSignal.timeout(config.upstreamTimeoutMs) }); + if (!response.ok) return null; + + const contentType = response.headers.get('content-type') ?? ''; + // Tautulli answers a bad key with a 200 JSON envelope, so the content type + // is the only thing that distinguishes an image from an error. + if (!contentType.startsWith('image/')) return null; + + return { body: Buffer.from(await response.arrayBuffer()), contentType }; + } catch { + return null; + } +} + +export const __test = { duration, mode, monogram, toStream, posterUrl, isPlexImagePath }; diff --git a/dashboard/web/src/components/Poster.tsx b/dashboard/web/src/components/Poster.tsx new file mode 100644 index 0000000..557576d --- /dev/null +++ b/dashboard/web/src/components/Poster.tsx @@ -0,0 +1,65 @@ +import { useState } from 'react'; + +/** + * A poster tile that degrades to the design's monogram placeholder. + * + * Artwork is proxied from Plex and can be missing for ordinary reasons — an + * item with no poster, Tautulli still booting, a key not discovered yet — so + * the placeholder is the default state rather than an error state. The image + * simply replaces it once it loads. + */ +export function Poster({ + src, + mono, + width, + height, +}: { + src: string | null; + mono: string; + width: number; + height: number; +}) { + // Tracking which src failed (rather than a boolean) means a new poster gets + // its own attempt without needing an effect to reset the flag. + const [failedSrc, setFailedSrc] = useState(null); + const showImage = src !== null && failedSrc !== src; + + const frame = { + width, + height, + flex: 'none' as const, + borderRadius: 'var(--radius-sm)', + overflow: 'hidden' as const, + }; + + if (!showImage) { + return ( + + ); + } + + return ( + setFailedSrc(src)} + style={{ ...frame, objectFit: 'cover', background: 'var(--color-neutral-800)' }} + /> + ); +} diff --git a/dashboard/web/src/types.ts b/dashboard/web/src/types.ts index 19b5f0b..5257270 100644 --- a/dashboard/web/src/types.ts +++ b/dashboard/web/src/types.ts @@ -85,6 +85,8 @@ export interface Stream { title: string; meta: string; mono: string; + /** Dashboard-relative poster URL, or null when there's no artwork to show. */ + poster: string | null; user: string; quality: string; mode: StreamMode; diff --git a/dashboard/web/src/views/CommandCenter.tsx b/dashboard/web/src/views/CommandCenter.tsx index df808e7..949c84a 100644 --- a/dashboard/web/src/views/CommandCenter.tsx +++ b/dashboard/web/src/views/CommandCenter.tsx @@ -11,6 +11,7 @@ import { } from '@phosphor-icons/react'; import { Panel, PanelBody } from '../components/Panel'; +import { Poster } from '../components/Poster'; import { usePolled } from '../hooks/usePolled'; import { MODE_HUE, @@ -206,24 +207,7 @@ function StreamRow({ stream }: { stream: Stream }) { background: 'var(--color-bg)', }} > - +
Date: Wed, 29 Jul 2026 15:23:38 -0400 Subject: [PATCH 2/4] Extend poster artwork to downloads, upcoming and requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now Playing was the only panel with real art. The rest kept their placeholders, which reads as broken rather than intentional once one panel has covers. Each panel's art comes from whichever service already holds it, so the route generalises from `?img=` to `?src=…&ref=…`, and posters.ts becomes the single place that knows how to turn a reference into bytes. Doing this now rather than as a follow-up keeps the URL shape from changing under anyone twice. plex Tautulli's pms_image_proxy (Now Playing) sonarr the *arr's own cached cover (Upcoming, downloads it grabbed) radarr likewise (downloads it grabbed) tmdb image.tmdb.org (Requests) The *arrs matter more than they look: their `images` arrays only carry `remoteUrl` pointing at thetvdb, but each keeps a local copy under `mediacover`, so Upcoming and most downloads stay offline-safe. TMDb is the one outbound dependency and is confined to Requests, where there is no alternative — Seerr holds no local copy and its /imageproxy/ is absent on some builds, and a pending request is in no *arr yet. It fails to the placeholder, so an install with no route out sees exactly what it saw before. Every `ref` still round-trips through the browser, so each source re-validates its own shape on the way in. Refs are not interchangeable: a Plex path offered as a TMDb one is refused, since it would otherwise reach a different host entirely. Download art rides on the existing queue match, which turned out to be capped at one page. Both queues here run past 300 records, so anything beyond the first 100 silently fell back to OTHER — indistinguishable from a torrent no *arr tracks. queue() now pages to the end, bounded so a queue that misreports its total can't spin it. Co-Authored-By: Claude Opus 5 --- dashboard/server/src/config.ts | 7 + dashboard/server/src/index.ts | 29 ++-- dashboard/server/src/sources/arr.ts | 46 +++++- dashboard/server/src/sources/posters.ts | 150 +++++++++++++++++++ dashboard/server/src/sources/seerr.ts | 42 ++++-- dashboard/server/src/sources/sources.test.ts | 103 ++++++++++--- dashboard/server/src/sources/tautulli.ts | 68 +-------- dashboard/server/src/sources/transmission.ts | 57 +++++-- dashboard/web/src/components/Poster.tsx | 5 +- dashboard/web/src/types.ts | 4 + dashboard/web/src/views/CommandCenter.tsx | 18 +-- 11 files changed, 380 insertions(+), 149 deletions(-) create mode 100644 dashboard/server/src/sources/posters.ts diff --git a/dashboard/server/src/config.ts b/dashboard/server/src/config.ts index 6cb6f8b..228601d 100644 --- a/dashboard/server/src/config.ts +++ b/dashboard/server/src/config.ts @@ -58,6 +58,13 @@ export const config = { transmission: process.env.TRANSMISSION_URL ?? 'http://transmission:9091', }, + /** + * TMDb's image CDN, used only for request posters no *arr holds yet. This is + * the single outbound dependency in the app; pointing it at an unreachable + * host simply falls those tiles back to their placeholder. + */ + tmdbImageBase: process.env.TMDB_IMAGE_BASE ?? 'https://image.tmdb.org/t/p', + /** Optional Transmission RPC auth, mirroring the stack's existing .env vars. */ transmissionAuth: { username: process.env.TRANSMISSION_RPC_USERNAME ?? '', diff --git a/dashboard/server/src/index.ts b/dashboard/server/src/index.ts index 5f903c2..af21dea 100644 --- a/dashboard/server/src/index.ts +++ b/dashboard/server/src/index.ts @@ -8,7 +8,8 @@ import fastifyStatic from '@fastify/static'; import { config } from './config.js'; import { getHealth } from './sources/docker.js'; import { getMetrics } from './sources/prometheus.js'; -import { getStreams, getPoster } from './sources/tautulli.js'; +import { getStreams } from './sources/tautulli.js'; +import { getPoster } from './sources/posters.js'; import { getDownloads, getVpn } from './sources/transmission.js'; import { getRequests } from './sources/seerr.js'; import { getUpcoming } from './sources/upcoming.js'; @@ -57,23 +58,25 @@ app.get('/api/activity', async () => getActivity()); app.get('/api/vpn', async () => getVpn()); /** - * Poster artwork, proxied from Plex via Tautulli. + * Poster artwork, proxied from whichever service holds it — Plex via Tautulli, + * a Servarr's own cached cover, or TMDb for requests nothing has picked up yet. * - * The browser can't call Tautulli itself — it may have no route to it, and the - * API key must not leave the server — so posters come through here. `img` is - * re-validated against Plex's metadata path shape inside `getPoster` even - * though this server produced it, since it round-trips through the client. + * The browser can't fetch any of these itself: three sit behind API keys that + * must not leave the server, and the fourth is off-box. `src` and `ref` are + * re-validated inside `getPoster` even though this server produced them, since + * they round-trip through the client. * - * Any failure is a 404 rather than a 5xx: the poster tile falls back to its - * monogram, which is the same thing it renders before the image loads. + * Any failure is a 404 rather than a 5xx: the tile falls back to its monogram, + * which is the same thing it renders before an image loads. */ -app.get<{ Querystring: { img?: string } }>('/api/poster', async (request, reply) => { - const img = request.query.img; - const image = img ? await getPoster(img) : null; +app.get<{ Querystring: { src?: string; ref?: string } }>('/api/poster', async (request, reply) => { + const { src, ref } = request.query; + const image = src && ref ? await getPoster(src, ref) : null; if (!image) return reply.code(404).send({ error: 'no poster' }); - // The timestamp in a Plex image path changes whenever the artwork does, so - // any given URL is safe to cache hard. + // Every ref identifies one immutable rendition — Plex and Servarr paths carry + // a version number, TMDb filenames are content-addressed — so any given URL + // is safe to cache hard. return reply .header('content-type', image.contentType) .header('cache-control', 'public, max-age=86400') diff --git a/dashboard/server/src/sources/arr.ts b/dashboard/server/src/sources/arr.ts index 2bdb58f..30ae071 100644 --- a/dashboard/server/src/sources/arr.ts +++ b/dashboard/server/src/sources/arr.ts @@ -1,6 +1,7 @@ import { config } from '../config.js'; import { credentialFor, type SourceId } from '../discovery.js'; import { getJson } from '../http.js'; +import { arrPoster } from './posters.js'; /** * Shared client for the Servarr v3 API. Sonarr and Radarr differ in their @@ -31,6 +32,10 @@ export async function arrRequest( export interface QueueRecord { title?: string; + /** Set on Sonarr records; the key to that series' cached cover. */ + seriesId?: number; + /** Set on Radarr records; the key to that movie's cached cover. */ + movieId?: number; status?: string; size?: number; sizeleft?: number; @@ -41,18 +46,41 @@ export interface QueueRecord { interface QueuePage { records?: QueueRecord[]; + totalRecords?: number; } /** - * Current queue. Asked for a generous page size because the dashboard matches - * these against Transmission's torrent list to label each download's source. + * Current queue, in full. + * + * Every record is matched against Transmission's torrent list to label each + * download and find its artwork, so a partial queue silently produces wrong + * answers rather than missing ones — an unmatched torrent is labelled OTHER, + * which is indistinguishable from one no *arr is tracking. A long-running + * install accumulates hundreds of stalled entries (this was found against a + * 636-record queue), so paging to the end is the only way the match can be + * trusted. */ export async function queue(arr: ArrId): Promise { - const page = await arrRequest(arr, 'queue', { - pageSize: '100', - includeUnknownMovieItems: 'false', - }); - return page.records ?? []; + const pageSize = 250; + const records: QueueRecord[] = []; + + // Bounded rather than `while (true)`: a queue that never reports a total, or + // reports a wrong one, must not spin this loop forever. + for (let page = 1; page <= 20; page += 1) { + const body = await arrRequest(arr, 'queue', { + page: String(page), + pageSize: String(pageSize), + includeUnknownMovieItems: 'false', + }); + + const batch = body.records ?? []; + records.push(...batch); + + if (batch.length < pageSize) break; + if (body.totalRecords !== undefined && records.length >= body.totalRecords) break; + } + + return records; } export interface HistoryRecord { @@ -78,6 +106,8 @@ export async function history(arr: ArrId, pageSize = 20): Promise ({ seriesTitle: item.series?.title ?? 'Unknown', + poster: arrPoster('sonarr', item.seriesId), code: `S${String(item.seasonNumber ?? 0).padStart(2, '0')}E${String(item.episodeNumber ?? 0).padStart(2, '0')}`, title: item.title ?? '', network: item.series?.network ?? '', diff --git a/dashboard/server/src/sources/posters.ts b/dashboard/server/src/sources/posters.ts new file mode 100644 index 0000000..1b66def --- /dev/null +++ b/dashboard/server/src/sources/posters.ts @@ -0,0 +1,150 @@ +import { config } from '../config.js'; +import { credentialFor } from '../discovery.js'; +import type { ArrId } from './arr.js'; + +/** + * Poster artwork, from whichever service already holds it. + * + * Four upstreams supply art and none of them can be reached from the browser: + * three sit behind API keys that must not leave the server, and the fourth is + * off-box. So every poster is addressed as `/api/poster?src=…&ref=…` and this + * module is the only place that knows how to turn that into bytes. + * + * `ref` is produced here, travels to the browser, and comes back — so each + * source re-validates it on the way in rather than trusting it because we + * emitted it. That check is the trust boundary; without it the route would be a + * general-purpose proxy wearing a dashboard's clothes. + */ + +export type PosterSource = 'plex' | 'sonarr' | 'radarr' | 'tmdb'; + +/** Plex addresses art by metadata path; the trailing number changes when art does. */ +const PLEX_IMAGE_PATH = /^\/library\/metadata\/\d+\/(?:thumb|art|poster)\/\d+$/; +/** TMDb paths are a single opaque filename — no directories, so no traversal. */ +const TMDB_IMAGE_PATH = /^\/[A-Za-z0-9_-]+\.(?:jpg|jpeg|png|webp)$/; +/** Servarr covers are addressed by the numeric id of the series or movie. */ +const ARR_ID = /^[1-9]\d{0,9}$/; + +/** + * Total by construction: an unrecognised source is refused rather than falling + * off the end. The type says that can't happen, but this is a security check + * reached from a query string, so it does not lean on the type to be safe. + */ +function isValidRef(source: PosterSource, ref: string): boolean { + switch (source) { + case 'plex': + return PLEX_IMAGE_PATH.test(ref); + case 'tmdb': + return TMDB_IMAGE_PATH.test(ref); + case 'sonarr': + case 'radarr': + return ARR_ID.test(ref); + default: + return false; + } +} + +function url(source: PosterSource, ref: string | undefined | null): string | null { + if (!ref || !isValidRef(source, ref)) return null; + return `/api/poster?src=${source}&ref=${encodeURIComponent(ref)}`; +} + +/** A Plex image path, as `get_activity` reports it. */ +export const plexPoster = (path: string | undefined): string | null => url('plex', path); + +/** A Sonarr series or Radarr movie, by its numeric id. */ +export const arrPoster = (arr: ArrId, id: number | undefined): string | null => + id === undefined ? null : url(arr, String(id)); + +/** A TMDb poster path, as Seerr reports it. */ +export const tmdbPoster = (path: string | undefined): string | null => url('tmdb', path); + +export interface PosterImage { + body: Buffer; + contentType: string; +} + +/** + * One fetch, shared by every source. Returns null rather than throwing for + * every failure mode — the caller turns that into a 404 and the tile falls back + * to its monogram, which is also what it shows before an image loads. + */ +async function fetchImage(target: string, headers: Record = {}): Promise { + try { + const response = await fetch(target, { + headers, + signal: AbortSignal.timeout(config.upstreamTimeoutMs), + }); + if (!response.ok) return null; + + const contentType = response.headers.get('content-type') ?? ''; + // Tautulli answers a bad key with a 200 JSON envelope, and the *arrs will + // hand back an HTML error page, so the content type is the only reliable + // way to tell an image from a polite refusal. + if (!contentType.startsWith('image/')) return null; + + return { body: Buffer.from(await response.arrayBuffer()), contentType }; + } catch { + return null; + } +} + +/** Plex art, read through Tautulli so it stays on the LAN. */ +async function fromPlex(ref: string): Promise { + const credential = await credentialFor('tautulli'); + if (credential.state !== 'live' || !credential.apiKey) return null; + + return fetchImage( + `${config.upstream.tautulli}/api/v2` + + `?apikey=${encodeURIComponent(credential.apiKey)}` + + `&cmd=pms_image_proxy&img=${encodeURIComponent(ref)}` + + `&width=300&height=450&fallback=poster`, + ); +} + +/** + * A Servarr's own cached cover. The `images` array on these APIs only carries + * `remoteUrl` pointing at thetvdb/tmdb, but each *arr also keeps a local copy + * and serves it from `mediacover` — which is what keeps this offline-safe. + */ +async function fromArr(arr: ArrId, ref: string): Promise { + const credential = await credentialFor(arr); + if (credential.state !== 'live' || !credential.apiKey) return null; + + const base = config.upstream[arr].replace(/\/$/, ''); + const urlBase = credential.urlBase.replace(/\/$/, ''); + return fetchImage(`${base}${urlBase}/api/v3/mediacover/${ref}/poster.jpg`, { + 'X-Api-Key': credential.apiKey, + }); +} + +/** + * TMDb, for requests that no *arr has picked up yet. + * + * This is the one source that needs outbound internet, which the rest of the + * stack deliberately avoids. It is confined to the Requests panel and fails to + * the placeholder, so an install with no route out sees exactly the behaviour + * it had before rather than an error. + */ +async function fromTmdb(ref: string): Promise { + return fetchImage(`${config.tmdbImageBase}/w342${ref}`); +} + +export async function getPoster(source: string, ref: string): Promise { + if (source !== 'plex' && source !== 'sonarr' && source !== 'radarr' && source !== 'tmdb') { + return null; + } + if (!isValidRef(source, ref)) return null; + + switch (source) { + case 'plex': + return fromPlex(ref); + case 'sonarr': + case 'radarr': + return fromArr(source, ref); + case 'tmdb': + return fromTmdb(ref); + } +} + +export const __test = { isValidRef, url }; diff --git a/dashboard/server/src/sources/seerr.ts b/dashboard/server/src/sources/seerr.ts index 8dfff74..9b5339e 100644 --- a/dashboard/server/src/sources/seerr.ts +++ b/dashboard/server/src/sources/seerr.ts @@ -2,6 +2,7 @@ import { config } from '../config.js'; import { memoize } from '../cache.js'; import { credentialFor } from '../discovery.js'; import { getJson, safely, unavailable, type Result } from '../http.js'; +import { tmdbPoster } from './posters.js'; /** Content requests, from Seerr. */ @@ -33,6 +34,8 @@ interface SeerrPage { export interface RequestItem { title: string; + /** Dashboard-relative poster URL, or null when there's nothing to show. */ + poster: string | null; kind: 'Movie' | 'Series'; user: string; when: string; @@ -42,6 +45,8 @@ export interface RequestItem { interface SeerrMediaDetails { title?: string; name?: string; + /** TMDb path, e.g. `/u4YZhMms48mgP756hniUcw6PQPU.jpg`. */ + posterPath?: string; } export interface RequestsPayload { @@ -77,12 +82,26 @@ function statusOf(request: SeerrRequest): RequestItem['status'] { return STATUS[request.status ?? 0] ?? 'Pending'; } -async function titleFor( - request: SeerrRequest, - apiKey: string, -): Promise { +interface Described { + title: string; + poster: string | null; +} + +const UNKNOWN: Described = { title: 'Unknown title', poster: null }; + +/** + * Title and artwork for a request, from the one detail call. + * + * The poster comes back on the same response as the title, so artwork costs no + * extra request. Unlike every other source here the path resolves against + * TMDb rather than something in the stack — Seerr keeps no local copy, and its + * `/imageproxy/` is not present on every build. A request no *arr has picked up + * yet has no other source of art, so this is the trade; it degrades to the + * placeholder when there's no route out. + */ +async function describe(request: SeerrRequest, apiKey: string): Promise { const tmdbId = request.media?.tmdbId; - if (!tmdbId) return 'Unknown title'; + if (!tmdbId) return UNKNOWN; const kind = request.type === 'tv' ? 'tv' : 'movie'; try { @@ -90,11 +109,14 @@ async function titleFor( `${config.upstream.seerr}/api/v1/${kind}/${tmdbId}`, { 'X-Api-Key': apiKey }, ); - return details.title || details.name || 'Unknown title'; + return { + title: details.title || details.name || 'Unknown title', + poster: tmdbPoster(details.posterPath), + }; } catch { - // Title lookup goes out to TMDb via Seerr and can fail independently of the - // request list; a missing title shouldn't drop the row. - return 'Unknown title'; + // Detail lookup goes out to TMDb via Seerr and can fail independently of + // the request list; a missing title shouldn't drop the row. + return UNKNOWN; } } @@ -139,7 +161,7 @@ async function load(): Promise { const requests = await Promise.all( results.map(async (request): Promise => ({ - title: await titleFor(request, credential.apiKey!), + ...(await describe(request, credential.apiKey!)), kind: request.type === 'tv' ? 'Series' : 'Movie', user: request.requestedBy?.displayName || diff --git a/dashboard/server/src/sources/sources.test.ts b/dashboard/server/src/sources/sources.test.ts index 9bc3733..071521e 100644 --- a/dashboard/server/src/sources/sources.test.ts +++ b/dashboard/server/src/sources/sources.test.ts @@ -7,6 +7,7 @@ import { __test as seerr } from './seerr.js'; import { __test as arr } from './arr.js'; import { __test as prometheus } from './prometheus.js'; import { __test as activity } from './activity.js'; +import { __test as posters } from './posters.js'; // ---- Tautulli -------------------------------------------------------------- @@ -52,26 +53,54 @@ test('episode sessions render as SxxEyy, movies as a year', () => { assert.equal(movie.meta, '2024'); }); -test('only Plex metadata image paths are accepted', () => { - assert.ok(tautulli.isPlexImagePath('/library/metadata/130222/thumb/1785273308')); - assert.ok(tautulli.isPlexImagePath('/library/metadata/49126/art/1784188852')); - - // The value round-trips through the browser before coming back to the poster - // route, so anything that would widen it beyond one Plex image must be - // refused — traversal, absolute URLs, and query smuggling in particular. - for (const bad of [ - '/library/metadata/1/thumb/1/../../../etc/passwd', - 'http://evil.example/pwn.png', - '//evil.example/pwn.png', - '/library/metadata/1/thumb/1&cmd=get_settings', - '/library/sections/1/all', - '/library/metadata/abc/thumb/1', - '', - ]) { - assert.equal(tautulli.isPlexImagePath(bad), false, bad); +test('each poster source accepts only its own reference shape', () => { + assert.ok(posters.isValidRef('plex', '/library/metadata/130222/thumb/1785273308')); + assert.ok(posters.isValidRef('plex', '/library/metadata/49126/art/1784188852')); + assert.ok(posters.isValidRef('sonarr', '1034')); + assert.ok(posters.isValidRef('radarr', '1')); + assert.ok(posters.isValidRef('tmdb', '/u4YZhMms48mgP756hniUcw6PQPU.jpg')); + + // A ref round-trips through the browser before coming back to the poster + // route, so anything that would widen it beyond one image must be refused — + // traversal, absolute URLs and query smuggling in particular. Refs must not + // be interchangeable between sources either: a Plex path accepted as a TMDb + // one would reach a different host entirely. + const bad: [string, string][] = [ + ['plex', '/library/metadata/1/thumb/1/../../../etc/passwd'], + ['plex', 'http://evil.example/pwn.png'], + ['plex', '//evil.example/pwn.png'], + ['plex', '/library/metadata/1/thumb/1&cmd=get_settings'], + ['plex', '/library/sections/1/all'], + ['plex', '/library/metadata/abc/thumb/1'], + ['plex', '/u4YZhMms48mgP756hniUcw6PQPU.jpg'], + ['tmdb', '/library/metadata/1/thumb/1'], + ['tmdb', '/../../etc/passwd.jpg'], + ['tmdb', '/nested/path/poster.jpg'], + ['tmdb', 'https://evil.example/x.jpg'], + ['sonarr', '0'], + ['sonarr', '-1'], + ['sonarr', '1;rm -rf /'], + ['sonarr', '../../etc/passwd'], + ['radarr', '1.5'], + ['nope', '1'], + ['plex', ''], + ]; + for (const [source, ref] of bad) { + assert.equal(posters.isValidRef(source as never, ref), false, `${source}: ${ref}`); } }); +test('a poster URL names its source and escapes its reference', () => { + assert.equal( + posters.url('plex', '/library/metadata/34279/thumb/1785309193'), + '/api/poster?src=plex&ref=%2Flibrary%2Fmetadata%2F34279%2Fthumb%2F1785309193', + ); + assert.equal(posters.url('sonarr', '1034'), '/api/poster?src=sonarr&ref=1034'); + // An invalid ref yields no URL at all rather than one that 404s on use. + assert.equal(posters.url('tmdb', '/nested/path.jpg'), null); + assert.equal(posters.url('plex', undefined), null); +}); + test('an episode posts the show poster; a movie its own', () => { const episode = tautulli.toStream({ media_type: 'episode', @@ -80,13 +109,19 @@ test('an episode posts the show poster; a movie its own', () => { }); // The episode's own thumb is the still frame; the design's tile wants the // show's poster. - assert.equal(episode.poster, '/api/poster?img=%2Flibrary%2Fmetadata%2F49126%2Fthumb%2F1784188852'); + assert.equal( + episode.poster, + '/api/poster?src=plex&ref=%2Flibrary%2Fmetadata%2F49126%2Fthumb%2F1784188852', + ); const movie = tautulli.toStream({ media_type: 'movie', thumb: '/library/metadata/130222/thumb/1785273308', }); - assert.equal(movie.poster, '/api/poster?img=%2Flibrary%2Fmetadata%2F130222%2Fthumb%2F1785273308'); + assert.equal( + movie.poster, + '/api/poster?src=plex&ref=%2Flibrary%2Fmetadata%2F130222%2Fthumb%2F1785273308', + ); }); test('a missing or unusable thumb yields no poster rather than a broken URL', () => { @@ -138,11 +173,31 @@ test('the failure hint matches the failure, not a generic one', () => { }); test('attribution falls back to OTHER rather than guessing', () => { - const sonarr = new Set(['Show.S01E01.1080p']); - const radarr = new Set(['Movie.2024.2160p']); - assert.equal(transmission.attribute('Show.S01E01.1080p', sonarr, radarr), 'SONARR'); - assert.equal(transmission.attribute('Movie.2024.2160p', sonarr, radarr), 'RADARR'); - assert.equal(transmission.attribute('Something.Else', sonarr, radarr), 'OTHER'); + const index = transmission.indexQueues( + [{ title: 'Show.S01E01.1080p', seriesId: 7 }], + [{ title: 'Movie.2024.2160p', movieId: 9 }], + ); + + const show = transmission.attribute('Show.S01E01.1080p', index); + assert.equal(show.source, 'SONARR'); + assert.equal(show.poster, '/api/poster?src=sonarr&ref=7'); + + const movie = transmission.attribute('Movie.2024.2160p', index); + assert.equal(movie.source, 'RADARR'); + assert.equal(movie.poster, '/api/poster?src=radarr&ref=9'); + + // A torrent no *arr tracks keeps its placeholder rather than borrowing art + // from a near-miss — a wrong poster is worse than none. + const unknown = transmission.attribute('Something.Else', index); + assert.equal(unknown.source, 'OTHER'); + assert.equal(unknown.poster, null); +}); + +test('a queue record without an id still attributes, just without art', () => { + const index = transmission.indexQueues([{ title: 'Show.S01E01.1080p' }], []); + const show = transmission.attribute('Show.S01E01.1080p', index); + assert.equal(show.source, 'SONARR'); + assert.equal(show.poster, null); }); // ---- Seerr ----------------------------------------------------------------- diff --git a/dashboard/server/src/sources/tautulli.ts b/dashboard/server/src/sources/tautulli.ts index b9a35fd..8500183 100644 --- a/dashboard/server/src/sources/tautulli.ts +++ b/dashboard/server/src/sources/tautulli.ts @@ -2,6 +2,7 @@ import { config } from '../config.js'; import { memoize } from '../cache.js'; import { credentialFor } from '../discovery.js'; import { getJson, safely, unavailable, type Result } from '../http.js'; +import { plexPoster } from './posters.js'; /** Now Playing, from Tautulli's `get_activity` command. */ @@ -100,27 +101,6 @@ function monogram(title: string): string { .toUpperCase(); } -/** - * The shape Plex uses for image paths. Anything else is refused. - * - * This is the whole of the trust boundary for `/api/poster`: the value arrives - * from Tautulli, round-trips through the browser, and is then handed back to - * Tautulli, so it has to be re-checked on the way in rather than trusted - * because we emitted it. Constraining it to a literal metadata path keeps the - * route from being usable as a general-purpose proxy. - */ -const PLEX_IMAGE_PATH = /^\/library\/metadata\/\d+\/(?:thumb|art|poster)\/\d+$/; - -export function isPlexImagePath(value: string): boolean { - return PLEX_IMAGE_PATH.test(value); -} - -/** Wraps a Plex image path as a URL this server will proxy, or null if unusable. */ -function posterUrl(path: string | undefined): string | null { - if (!path || !isPlexImagePath(path)) return null; - return `/api/poster?img=${encodeURIComponent(path)}`; -} - function toStream(session: TautulliSession): Stream { const isEpisode = session.media_type === 'episode'; const title = (isEpisode ? session.grandparent_title : session.title) ?? 'Unknown'; @@ -141,7 +121,7 @@ function toStream(session: TautulliSession): Stream { mono: monogram(title), // An episode's own thumb is the episode still; the show's poster is what // the design's poster tile wants. - poster: posterUrl(isEpisode ? session.grandparent_thumb : session.thumb), + poster: plexPoster(isEpisode ? session.grandparent_thumb : session.thumb), user: session.friendly_name || session.user || 'Unknown', quality: session.video_full_resolution || session.quality_profile || '', mode: mode(session.transcode_decision), @@ -192,46 +172,4 @@ export const getStreams = memoize>(async () => { return safely(load); }, config.ttl.streams); -export interface PosterImage { - body: Buffer; - contentType: string; -} - -/** - * Fetches one poster through Tautulli's `pms_image_proxy`, which reads it from - * Plex — so artwork never requires outbound internet, matching the rest of the - * stack's self-hosted assumption. - * - * Returns null rather than throwing for every failure mode, because the caller - * turns that into a 404 and the UI falls back to the monogram tile. A missing - * poster must never be louder than that. - */ -export async function getPoster(path: string): Promise { - if (!isPlexImagePath(path)) return null; - - const credential = await credentialFor('tautulli'); - if (credential.state !== 'live' || !credential.apiKey) return null; - - const url = - `${config.upstream.tautulli}/api/v2` + - `?apikey=${encodeURIComponent(credential.apiKey)}` + - `&cmd=pms_image_proxy` + - `&img=${encodeURIComponent(path)}` + - `&width=300&height=450&fallback=poster`; - - try { - const response = await fetch(url, { signal: AbortSignal.timeout(config.upstreamTimeoutMs) }); - if (!response.ok) return null; - - const contentType = response.headers.get('content-type') ?? ''; - // Tautulli answers a bad key with a 200 JSON envelope, so the content type - // is the only thing that distinguishes an image from an error. - if (!contentType.startsWith('image/')) return null; - - return { body: Buffer.from(await response.arrayBuffer()), contentType }; - } catch { - return null; - } -} - -export const __test = { duration, mode, monogram, toStream, posterUrl, isPlexImagePath }; +export const __test = { duration, mode, monogram, toStream }; diff --git a/dashboard/server/src/sources/transmission.ts b/dashboard/server/src/sources/transmission.ts index b448a76..bebe95d 100644 --- a/dashboard/server/src/sources/transmission.ts +++ b/dashboard/server/src/sources/transmission.ts @@ -2,6 +2,7 @@ import { config } from '../config.js'; import { memoize } from '../cache.js'; import { safely, type Result } from '../http.js'; import { queue, type QueueRecord } from './arr.js'; +import { arrPoster } from './posters.js'; /** * Active downloads, from Transmission's RPC endpoint. @@ -34,6 +35,8 @@ export interface Download { label: string; /** Which *arr grabbed it, when it can be matched. */ source: 'SONARR' | 'RADARR' | 'OTHER'; + /** That *arr's cached cover, or null when nothing matched. */ + poster: string | null; /** 0-100 */ percent: number; speed: string; @@ -103,25 +106,46 @@ function formatEta(seconds: number | undefined): string { return `${Math.floor(hours / 24)}d`; } +/** What a queue match tells us about a torrent: who grabbed it, and its art. */ +interface Attribution { + source: Download['source']; + poster: string | null; +} + +const UNATTRIBUTED: Attribution = { source: 'OTHER', poster: null }; + /** - * Labels a torrent with the *arr that grabbed it. + * Labels a torrent with the *arr that grabbed it, and its artwork. * * The queues carry the release title, which is what Transmission names the * torrent, so an exact match works for the common case. Falls back to OTHER - * rather than guessing — a wrong attribution is worse than none. + * rather than guessing — a wrong attribution is worse than none, and that + * applies to the poster just as much as the tag. + * + * A torrent no *arr is tracking — an old manual grab, or one whose queue entry + * has since been removed — matches nothing and keeps its placeholder. That is a + * correct answer, not a missing one. */ -function attribute( - name: string, - sonarrTitles: Set, - radarrTitles: Set, -): Download['source'] { - if (sonarrTitles.has(name)) return 'SONARR'; - if (radarrTitles.has(name)) return 'RADARR'; - return 'OTHER'; +function attribute(name: string, index: Map): Attribution { + return index.get(name) ?? UNATTRIBUTED; } -function titleSet(records: QueueRecord[]): Set { - return new Set(records.map((r) => r.title).filter((t): t is string => Boolean(t))); +/** + * Indexes both queues by release title. Sonarr is inserted first so that if the + * same title somehow appears in both, the series wins over the movie — the + * previous ordering, preserved. + */ +function indexQueues(sonarr: QueueRecord[], radarr: QueueRecord[]): Map { + const index = new Map(); + + for (const record of radarr) { + if (record.title) index.set(record.title, { source: 'RADARR', poster: arrPoster('radarr', record.movieId) }); + } + for (const record of sonarr) { + if (record.title) index.set(record.title, { source: 'SONARR', poster: arrPoster('sonarr', record.seriesId) }); + } + + return index; } async function load(): Promise { @@ -138,8 +162,7 @@ async function load(): Promise { queue('radarr').catch(() => [] as QueueRecord[]), ]); - const sonarrTitles = titleSet(sonarrQueue); - const radarrTitles = titleSet(radarrQueue); + const index = indexQueues(sonarrQueue, radarrQueue); const torrents = response.arguments?.torrents ?? []; // Status 4 is "downloading"; anything complete or seeding isn't interesting @@ -149,9 +172,11 @@ async function load(): Promise { const downloads: Download[] = active .map((torrent) => { const name = torrent.name ?? 'Unknown'; + const { source, poster } = attribute(name, index); return { label: name, - source: attribute(name, sonarrTitles, radarrTitles), + source, + poster, percent: Math.round((torrent.percentDone ?? 0) * 100), speed: formatSpeed(torrent.rateDownload ?? 0), eta: formatEta(torrent.eta), @@ -215,4 +240,4 @@ export const getVpn = memoize>( config.ttl.downloads, ); -export const __test = { formatSpeed, formatEta, attribute, hintFor }; +export const __test = { formatSpeed, formatEta, attribute, indexQueues, hintFor }; diff --git a/dashboard/web/src/components/Poster.tsx b/dashboard/web/src/components/Poster.tsx index 557576d..ec25e88 100644 --- a/dashboard/web/src/components/Poster.tsx +++ b/dashboard/web/src/components/Poster.tsx @@ -10,12 +10,13 @@ import { useState } from 'react'; */ export function Poster({ src, - mono, + mono = '', width, height, }: { src: string | null; - mono: string; + /** Shown on the placeholder. Omitted where the design has no monogram. */ + mono?: string; width: number; height: number; }) { diff --git a/dashboard/web/src/types.ts b/dashboard/web/src/types.ts index 5257270..f1bc412 100644 --- a/dashboard/web/src/types.ts +++ b/dashboard/web/src/types.ts @@ -101,6 +101,8 @@ export interface Stream { export interface Download { label: string; source: 'SONARR' | 'RADARR' | 'OTHER'; + /** Dashboard-relative poster URL, or null when there's no artwork to show. */ + poster: string | null; percent: number; speed: string; eta: string; @@ -108,6 +110,7 @@ export interface Download { export interface RequestItem { title: string; + poster: string | null; kind: 'Movie' | 'Series'; user: string; when: string; @@ -116,6 +119,7 @@ export interface RequestItem { export interface UpcomingItem { seriesTitle: string; + poster: string | null; code: string; title: string; network: string; diff --git a/dashboard/web/src/views/CommandCenter.tsx b/dashboard/web/src/views/CommandCenter.tsx index 949c84a..27ff179 100644 --- a/dashboard/web/src/views/CommandCenter.tsx +++ b/dashboard/web/src/views/CommandCenter.tsx @@ -259,7 +259,9 @@ function DownloadRow({ download }: { download: Download }) { // Near-complete downloads go green regardless of source, matching the design. const hue = download.percent > 90 ? 'var(--ap-green)' : SOURCE_HUE[download.source]; return ( -
+
+ +
{download.source} ETA {download.eta}
+
); } @@ -303,6 +306,7 @@ function UpcomingRow({ item }: { item: UpcomingItem }) {
+
-