import type { UpstreamConfig } from './upstream'; import { DEVICE_PROFILE } from './profile'; import { callUpstream } from './upstream'; // The two halves of the Jellyfin surface Officer exposes. // // `/_officer/*` — a small hand-written façade. Every route here exists because the raw Jellyfin call needs // something the browser should not have to know: the user id in the path, a Fields list that decides whether // a grid has posters, or a PlaybackInfo negotiation. It is JSON in, JSON out. // // `/_jf/*` — a transparent, authenticated, GET-only pass-through for BYTES: images, video, HLS playlists and // segments, subtitles. It exists because HLS cannot be faked. A master playlist references its variant // playlists and every segment RELATIVELY, so the URLs a browser derives from it must resolve back onto the // same prefix; any façade that renamed those paths would have to rewrite the playlists on the way out, and // rewriting m3u8 bodies to keep a prettier URL scheme is a trade nobody should take. // // The pass-through is deny-by-default over a prefix allow-list. Jellyfin's API is the whole server — // `/System/*`, `/ScheduledTasks/*`, `/Plugins/*`, user administration, the setup wizard — and the token this // sidecar holds belongs to a real account, so an "everything under /_jf" proxy would put library scanning and // user creation one URL away from the browser. // // One thing here is a security fix rather than plumbing: `TranscodingUrl` comes back from Jellyfin with // `api_key=` embedded in its query string. That URL is handed to the video element, so it // would put the token in the DOM, in history and in any log that records URLs. It is stripped in // `sanitizeUpstreamPath` before anything is returned, and the pass-through re-adds the credential as a header // where it belongs. /** Ticks are 100-nanosecond units. Jellyfin speaks these everywhere; the UI speaks seconds. */ const TICKS_PER_SECOND = 10_000_000; const json = (data: unknown, status = 200) => Response.json(data, { status }); const bad = (error: string, status = 400) => json({ error }, status); /** Pass a Jellyfin JSON response through, keeping its status so a 404 upstream stays a 404 here. */ async function relayJson(res: Response): Promise { const text = await res.text(); return new Response(text, { status: res.status, headers: { 'Content-Type': res.headers.get('content-type') ?? 'application/json' }, }); } /** GET a Jellyfin path as parsed JSON, or throw with the upstream status attached. */ async function fetchJson(cfg: UpstreamConfig, path: string, params: Record): Promise { const res = await callUpstream(cfg, { path, query: buildQuery(params) }); if (!res.ok) throw new Error(`${path} → ${res.status}`); return (await res.json()) as T; } function buildQuery(params: Record): string { const search = new URLSearchParams(); for (const [key, value] of Object.entries(params)) { if (value != null && value !== '') search.set(key, value); } const qs = search.toString(); return qs ? `?${qs}` : ''; } // The Fields lists. A grid without ImageTags renders grey rectangles, which is the single most common way a // Jellyfin client looks broken, so the poster-bearing fields are not optional anywhere. const GRID_FIELDS = 'PrimaryImageAspectRatio,BasicSyncInfo,ProductionYear,Status,EndDate'; const DETAIL_FIELDS = 'Overview,Genres,Studios,People,Taglines,ProductionYear,OfficialRating,CommunityRating,MediaSources,MediaStreams,Chapters,ExternalUrls,RemoteTrailers,PrimaryImageAspectRatio,ParentId,SeriesStudio'; /** * The libraries worth showing. * * Music is filtered OUT on purpose and not as a stylistic choice: the owner's audio lives in Officer's own * /music player, and this machine runs separate Jellyfin instances for albums and DJ sets. A Jellyfin panel * that also listed those would be two music libraries competing for the same job. */ const VIDEO_COLLECTION_TYPES = new Set(['movies', 'tvshows', 'homevideos', 'musicvideos', 'boxsets', 'playlists']); type JellyfinItem = { Id?: string; Name?: string; CollectionType?: string; Type?: string }; type ItemsResponse = { Items?: JellyfinItem[]; TotalRecordCount?: number }; async function videoViews(cfg: UpstreamConfig): Promise { const data = await fetchJson(cfg, `/Users/${cfg.jellyfinUserId}/Views`, {}); return (data.Items ?? []).filter((view) => !view.CollectionType || VIDEO_COLLECTION_TYPES.has(view.CollectionType)); } /** * The home screen in ONE round trip: libraries, what is half-watched, what is next, and the newest thing in * each library. Jellyfin needs four different calls for that, and issuing them from the browser would mean * four proxy hops and a screen that assembles itself in visible stages. * * Latest-per-library is fetched concurrently and failures are swallowed per library: one library that is * mid-scan should cost its own shelf, not the whole page. */ async function home(cfg: UpstreamConfig): Promise { const views = await videoViews(cfg); const [resume, nextUp, latest] = await Promise.all([ fetchJson(cfg, '/UserItems/Resume', { userId: cfg.jellyfinUserId, limit: '12', mediaTypes: 'Video', fields: GRID_FIELDS, enableTotalRecordCount: 'false', }).catch(() => ({ Items: [] })), fetchJson(cfg, '/Shows/NextUp', { userId: cfg.jellyfinUserId, limit: '12', fields: GRID_FIELDS, enableTotalRecordCount: 'false', }).catch(() => ({ Items: [] })), Promise.all( views.map(async (view) => ({ viewId: view.Id ?? '', viewName: view.Name ?? '', collectionType: view.CollectionType ?? null, items: await fetchJson(cfg, `/Users/${cfg.jellyfinUserId}/Items/Latest`, { parentId: view.Id, limit: '12', fields: GRID_FIELDS, }).catch(() => []), })), ), ]); return json({ views, resume: resume.Items ?? [], nextUp: nextUp.Items ?? [], latest }); } // What a browse grid is allowed to ask for. Deny-by-default again — `/Items` accepts filters that reach // outside the library (`path`, `userId`) and this list is what keeps the query the UI's business only. const ITEM_QUERY_PARAMS = [ 'parentId', 'includeItemTypes', 'excludeItemTypes', 'recursive', 'sortBy', 'sortOrder', 'startIndex', 'limit', 'searchTerm', 'filters', 'genres', 'genreIds', 'years', 'officialRatings', 'tags', 'studioIds', 'personIds', 'isPlayed', 'isFavorite', 'nameStartsWith', 'imageTypeLimit', 'enableImageTypes', 'collapseBoxSetItems', ] as const; function passthroughItemQuery(url: URL): Record { const params: Record = {}; for (const key of ITEM_QUERY_PARAMS) { const value = url.searchParams.get(key); if (value != null) params[key] = value; } return params; } /** A browse grid. Defaults to recursive video items so a library id is enough to get something sensible. */ async function items(cfg: UpstreamConfig, url: URL): Promise { const params = passthroughItemQuery(url); const res = await callUpstream(cfg, { path: '/Items', query: buildQuery({ userId: cfg.jellyfinUserId, recursive: params.recursive ?? 'true', includeItemTypes: params.includeItemTypes ?? 'Movie,Series,Video', sortBy: params.sortBy ?? 'SortName', sortOrder: params.sortOrder ?? 'Ascending', limit: params.limit ?? '100', fields: GRID_FIELDS, imageTypeLimit: '1', enableImageTypes: 'Primary,Backdrop,Thumb,Logo', ...params, }), }); return relayJson(res); } /** One item, with everything the detail page draws — including MediaSources, which drive the codec line. */ async function item(cfg: UpstreamConfig, id: string): Promise { const res = await callUpstream(cfg, { path: `/Items/${encodeURIComponent(id)}`, query: buildQuery({ userId: cfg.jellyfinUserId, fields: DETAIL_FIELDS }), }); return relayJson(res); } type MediaSource = { Id?: string; SupportsDirectPlay?: boolean; SupportsDirectStream?: boolean; TranscodingUrl?: string; }; type PlaybackInfoResponse = { MediaSources?: MediaSource[]; PlaySessionId?: string; ErrorCode?: string | null }; /** * Strip the credential Jellyfin embeds in the URLs it hands back. * * `TranscodingUrl` arrives as `/videos/…/main.m3u8?…&api_key=&…`. Returning it untouched would * publish the token to the browser. The pass-through supplies the token as a header on every hop, so the * parameter is not merely unsafe, it is redundant. */ function sanitizeUpstreamPath(raw: string): string { const [path, search = ''] = raw.split('?'); const params = new URLSearchParams(search); params.delete('api_key'); params.delete('ApiKey'); params.delete('X-Emby-Token'); const qs = params.toString(); return `${path}${qs ? `?${qs}` : ''}`; } /** * Negotiate playback: ask the server what it can do with this file for this profile, and turn the answer into * one URL the player can use. * * The three outcomes are direct play (the original container is browser-playable), direct stream (remuxed on * the fly, still `/Videos/{id}/stream`) and transcode (HLS). They are reported explicitly rather than * inferred, because "why is my CPU pinned" is a question the UI should be able to answer. */ async function playbackInfo(cfg: UpstreamConfig, id: string, req: Request, url: URL): Promise { const startSeconds = Number(url.searchParams.get('startSeconds') ?? '0'); const body = (await req.json().catch(() => ({}))) as { mediaSourceId?: string; audioStreamIndex?: number; subtitleStreamIndex?: number; maxStreamingBitrate?: number; }; const res = await callUpstream(cfg, { path: `/Items/${encodeURIComponent(id)}/PlaybackInfo`, method: 'POST', query: buildQuery({ userId: cfg.jellyfinUserId }), contentType: 'application/json', body: JSON.stringify({ DeviceProfile: DEVICE_PROFILE, UserId: cfg.jellyfinUserId, MaxStreamingBitrate: body.maxStreamingBitrate ?? DEVICE_PROFILE.MaxStreamingBitrate, StartTimeTicks: Math.max(0, Math.round(startSeconds * TICKS_PER_SECOND)), MediaSourceId: body.mediaSourceId, AudioStreamIndex: body.audioStreamIndex, SubtitleStreamIndex: body.subtitleStreamIndex, EnableDirectPlay: true, EnableDirectStream: true, EnableTranscoding: true, AllowVideoStreamCopy: true, AllowAudioStreamCopy: true, // Without this the transcode is only prepared, not started, and the first segment request 404s. AutoOpenLiveStream: true, }), }); if (!res.ok) return relayJson(res); const info = (await res.json()) as PlaybackInfoResponse; const source = info.MediaSources?.find((s) => s.Id === body.mediaSourceId) ?? info.MediaSources?.[0]; if (!source) return bad('the server returned no playable source for this item', 502); const playSessionId = info.PlaySessionId ?? null; const direct = source.SupportsDirectPlay || source.SupportsDirectStream; const path = direct ? `/Videos/${encodeURIComponent(id)}/stream${buildQuery({ static: 'true', mediaSourceId: source.Id, playSessionId: playSessionId ?? undefined, })}` : source.TranscodingUrl ? sanitizeUpstreamPath(source.TranscodingUrl) : null; if (!path) { return bad(info.ErrorCode ? `playback refused: ${info.ErrorCode}` : 'the server offered no way to play this', 502); } return json({ playMethod: direct ? (source.SupportsDirectPlay ? 'DirectPlay' : 'DirectStream') : 'Transcode', // Relative to the panel's mount, so the browser prefixes /api/jellyfin and the pass-through does the rest. url: `/_jf${path}`, isHls: !direct, playSessionId, mediaSourceId: source.Id ?? null, mediaSource: source, startSeconds, }); } /** Playback reporting — start, heartbeat, stop. Bodies are the upstream's own shapes, forwarded as sent. */ async function reportPlaystate(cfg: UpstreamConfig, action: string, req: Request): Promise { const path = action === 'playing' ? '/Sessions/Playing' : action === 'progress' ? '/Sessions/Playing/Progress' : action === 'stopped' ? '/Sessions/Playing/Stopped' : null; if (!path) return bad('not found', 404); const body = await req.text(); const res = await callUpstream(cfg, { path, method: 'POST', contentType: 'application/json', body }); // 204 with no body is the normal answer; relaying it as JSON would invent content that is not there. return new Response(null, { status: res.status }); } /** Watched and favourite marks. POST sets, DELETE clears — the same shape Jellyfin's own routes use. */ async function userItemFlag(cfg: UpstreamConfig, kind: 'played' | 'favorite', id: string, method: string) { const base = kind === 'played' ? '/UserPlayedItems' : '/UserFavoriteItems'; const res = await callUpstream(cfg, { path: `${base}/${encodeURIComponent(id)}`, method: method === 'DELETE' ? 'DELETE' : 'POST', query: buildQuery({ userId: cfg.jellyfinUserId }), }); return relayJson(res); } /** The `/_officer/*` façade. Returns null when nothing matched, so index.ts can answer a single 404. */ export async function handleOfficerRoute(cfg: UpstreamConfig, req: Request, url: URL): Promise { const [head, a, b] = url.pathname.slice('/_officer/'.length).split('/'); const method = req.method; if (head === 'home' && method === 'GET') return home(cfg); if (head === 'views' && method === 'GET') return json({ views: await videoViews(cfg) }); if (head === 'items' && !a && method === 'GET') return items(cfg, url); if (head === 'items' && a) { if (!b && method === 'GET') return item(cfg, a); if (b === 'similar' && method === 'GET') { const res = await callUpstream(cfg, { path: `/Items/${encodeURIComponent(a)}/Similar`, query: buildQuery({ userId: cfg.jellyfinUserId, limit: '12', fields: GRID_FIELDS }), }); return relayJson(res); } if (b === 'playback' && method === 'POST') return playbackInfo(cfg, a, req, url); if ((b === 'played' || b === 'favorite') && (method === 'POST' || method === 'DELETE')) { return userItemFlag(cfg, b, a, method); } } if (head === 'shows' && a && method === 'GET') { if (b === 'seasons') { const res = await callUpstream(cfg, { path: `/Shows/${encodeURIComponent(a)}/Seasons`, query: buildQuery({ userId: cfg.jellyfinUserId, fields: GRID_FIELDS }), }); return relayJson(res); } if (b === 'episodes') { const res = await callUpstream(cfg, { path: `/Shows/${encodeURIComponent(a)}/Episodes`, query: buildQuery({ userId: cfg.jellyfinUserId, seasonId: url.searchParams.get('seasonId') ?? undefined, fields: `${GRID_FIELDS},Overview,MediaSources`, }), }); return relayJson(res); } } if (head === 'resume' && method === 'GET') { const res = await callUpstream(cfg, { path: '/UserItems/Resume', query: buildQuery({ userId: cfg.jellyfinUserId, limit: '24', mediaTypes: 'Video', fields: GRID_FIELDS }), }); return relayJson(res); } if (head === 'nextup' && method === 'GET') { const res = await callUpstream(cfg, { path: '/Shows/NextUp', query: buildQuery({ userId: cfg.jellyfinUserId, limit: '24', fields: GRID_FIELDS }), }); return relayJson(res); } if (head === 'search' && method === 'GET') { const term = url.searchParams.get('q')?.trim(); if (!term) return json({ Items: [], TotalRecordCount: 0 }); const res = await callUpstream(cfg, { path: '/Items', query: buildQuery({ userId: cfg.jellyfinUserId, searchTerm: term, recursive: 'true', includeItemTypes: 'Movie,Series,Episode,Video,BoxSet,Person', limit: '48', fields: GRID_FIELDS, }), }); return relayJson(res); } if (head === 'genres' && method === 'GET') { const res = await callUpstream(cfg, { path: '/Genres', query: buildQuery({ userId: cfg.jellyfinUserId, parentId: url.searchParams.get('parentId') ?? undefined }), }); return relayJson(res); } if (head === 'sessions' && a && method === 'POST') return reportPlaystate(cfg, a, req); return null; } // The pass-through allow-list. Anything not matching one of these is refused, including every administrative // route. Matched case-insensitively because Jellyfin's own HLS playlists reference `/videos/…` in lower case // while its OpenAPI document says `/Videos/…`, and a browser resolving a relative segment URL will send back // whatever the playlist said. const BYTES_PREFIXES = [ 'items/', // artwork: /Items/{id}/Images/{type} 'videos/', // direct stream, HLS playlists and segments, subtitles 'users/', // /Users/{id}/Images/Primary — the account avatar 'persons/', // cast portraits 'studios/', 'genres/', 'musicgenres/', ]; const isAllowedBytesPath = (path: string): boolean => { const lower = path.toLowerCase(); return BYTES_PREFIXES.some((prefix) => lower.startsWith(prefix)); }; /** * `/_jf/*` — bytes only, GET only, allow-listed. * * `Accept: * / *` matters: the default JSON Accept makes Jellyfin answer some image routes with a JSON error * instead of the picture. Range is forwarded so seeking works, and the response headers are relayed nearly * whole — content-range, etag and cache-control are what make the `