Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions dashboard/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? '',
Expand Down
27 changes: 27 additions & 0 deletions dashboard/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { config } from './config.js';
import { getHealth } from './sources/docker.js';
import { getMetrics } from './sources/prometheus.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';
Expand Down Expand Up @@ -56,6 +57,32 @@ app.get('/api/upcoming', async () => getUpcoming());
app.get('/api/activity', async () => getActivity());
app.get('/api/vpn', async () => getVpn());

/**
* 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 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 tile falls back to its monogram,
* which is the same thing it renders before an image loads.
*/
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' });

// 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')
.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.
Expand Down
46 changes: 39 additions & 7 deletions dashboard/server/src/sources/arr.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -31,6 +32,10 @@ export async function arrRequest<T>(

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;
Expand All @@ -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<QueueRecord[]> {
const page = await arrRequest<QueuePage>(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<QueuePage>(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 {
Expand All @@ -78,6 +106,8 @@ export async function history(arr: ArrId, pageSize = 20): Promise<HistoryRecord[

export interface CalendarEpisode {
seriesTitle: string;
/** Dashboard-relative poster URL for the series, or null if unavailable. */
poster: string | null;
code: string;
title: string;
network: string;
Expand All @@ -92,6 +122,7 @@ interface SonarrCalendarItem {
episodeNumber?: number;
airDateUtc?: string;
hasFile?: boolean;
seriesId?: number;
series?: { title?: string; network?: string };
}

Expand All @@ -114,6 +145,7 @@ export async function calendar(start: Date, end: Date): Promise<CalendarEpisode[
const now = new Date();
return items.map((item) => ({
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 ?? '',
Expand Down
150 changes: 150 additions & 0 deletions dashboard/server/src/sources/posters.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {}): Promise<PosterImage | null> {
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<PosterImage | null> {
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<PosterImage | null> {
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<PosterImage | null> {
return fetchImage(`${config.tmdbImageBase}/w342${ref}`);
}

export async function getPoster(source: string, ref: string): Promise<PosterImage | null> {
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 };
44 changes: 33 additions & 11 deletions dashboard/server/src/sources/seerr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */

Expand Down Expand Up @@ -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;
Expand All @@ -42,6 +45,8 @@ export interface RequestItem {
interface SeerrMediaDetails {
title?: string;
name?: string;
/** TMDb path, e.g. `/u4YZhMms48mgP756hniUcw6PQPU.jpg`. */
posterPath?: string;
}

export interface RequestsPayload {
Expand Down Expand Up @@ -77,24 +82,41 @@ function statusOf(request: SeerrRequest): RequestItem['status'] {
return STATUS[request.status ?? 0] ?? 'Pending';
}

async function titleFor(
request: SeerrRequest,
apiKey: string,
): Promise<string> {
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<Described> {
const tmdbId = request.media?.tmdbId;
if (!tmdbId) return 'Unknown title';
if (!tmdbId) return UNKNOWN;

const kind = request.type === 'tv' ? 'tv' : 'movie';
try {
const details = await getJson<SeerrMediaDetails>(
`${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;
}
}

Expand Down Expand Up @@ -139,7 +161,7 @@ async function load(): Promise<RequestsPayload> {

const requests = await Promise.all(
results.map(async (request): Promise<RequestItem> => ({
title: await titleFor(request, credential.apiKey!),
...(await describe(request, credential.apiKey!)),
kind: request.type === 'tv' ? 'Series' : 'Movie',
user:
request.requestedBy?.displayName ||
Expand Down Expand Up @@ -168,4 +190,4 @@ export const getRequests = memoize<Result<RequestsPayload>>(async () => {
return safely(load);
}, config.ttl.requests);

export const __test = { relative, statusOf, pendingCount };
export const __test = { relative, statusOf, pendingCount, describe };
Loading
Loading