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 SourceStream = { Type?: string; Index?: number; Codec?: string; Profile?: string; Level?: number; Width?: number; Height?: number; BitRate?: number; Channels?: number; Language?: string | null; DisplayTitle?: string | null; Title?: string | null; IsDefault?: boolean; IsForced?: boolean; IsExternal?: boolean; IsTextSubtitleStream?: boolean; /** Jellyfin's own verdict for THIS profile: External (a file we can fetch), Embed, Encode, or None. */ DeliveryMethod?: string | null; DeliveryUrl?: string | null; }; type MediaSource = { Id?: string; Container?: string; RunTimeTicks?: number; SupportsDirectPlay?: boolean; SupportsDirectStream?: boolean; DirectStreamUrl?: string; TranscodingUrl?: string; MediaStreams?: SourceStream[]; }; 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}` : ''}`; } /** * A ceiling high enough to never be the binding constraint on a stream copy. It matches the profile's * `MaxStreamingBitrate`: the request has to name a number, and for a copy the right number is "more than * whatever this file is", since Jellyfin refuses to copy a stream that exceeds the requested bitrate. */ const COPY_CEILING_BITRATE = 120_000_000; /** What a real CPU re-encode targets. 1080p at 12 Mbit is visually generous for h264 at the crf Jellyfin uses. */ const ENCODE_BITRATE = 12_000_000; const ENCODE_MAX_WIDTH = 1920; /** * The rungs the quality picker offers, widest first. A bitrate alone is not a quality: Jellyfin's own * `ResolutionNormalizer` would pair a low one with a resolution of its choosing, and the whole 416-pixel * incident was that mapping being applied to a number nobody meant. Naming both here is what makes * "720p" mean 720p. */ export const QUALITY_RUNGS = [ { label: '1080p', videoBitrate: 12_000_000, maxWidth: 1920 }, { label: '720p', videoBitrate: 6_000_000, maxWidth: 1280 }, { label: '480p', videoBitrate: 2_500_000, maxWidth: 854 }, { label: '360p', videoBitrate: 1_000_000, maxWidth: 640 }, ] as const; /** * Decide the bitrate and resolution cap to ask for, from what is actually in the file. * * With no `requested` ceiling — the "Auto" the player sends by default — the two cases pull in opposite * directions, which is why this is not one constant: * * video can be copied → any cap is a reason for Jellyfin to REFUSE the copy (it will not copy a stream * above the requested bitrate, or wider than a requested `maxWidth`), so ask for a * ceiling nothing hits and no width at all. The bytes are passed through untouched. * video is re-encoded → a cap is the only thing keeping ffmpeg tractable. There is no /dev/dri in the * Jellyfin container, so 4K HDR HEVC is being decoded, tonemapped and encoded on the * CPU; downscaling to 1080p is what makes that keep up with playback. * * The copy test mirrors the profile's own conditions rather than a new opinion: h264, not 10-bit, level ≤ 5.2. * * A `requested` ceiling overrides both, and deliberately defeats the copy: picking 480p on a file that would * have passed through untouched is asking for a re-encode, and that is the entire point of picking it. */ export type TranscodeQuality = { videoBitrate: number; maxWidth?: number }; function transcodeQuality(source: MediaSource, requested?: number): TranscodeQuality { if (requested && requested < COPY_CEILING_BITRATE) { const rung = QUALITY_RUNGS.find((r) => r.videoBitrate <= requested) ?? QUALITY_RUNGS[QUALITY_RUNGS.length - 1]!; return { videoBitrate: rung.videoBitrate, maxWidth: rung.maxWidth }; } const video = source.MediaStreams?.find((stream) => stream.Type === 'Video'); const copyable = video?.Codec?.toLowerCase() === 'h264' && video.Profile?.toLowerCase() !== 'high 10' && (video.Level ?? 0) <= 52; if (copyable) return { videoBitrate: COPY_CEILING_BITRATE }; return { videoBitrate: ENCODE_BITRATE, maxWidth: ENCODE_MAX_WIDTH }; } /** `DisplayTitle` is what Jellyfin shows in its own picker; the rest is a legible fallback when it is absent. */ function trackLabel(stream: SourceStream, fallback: string): string { const parts = [stream.DisplayTitle || stream.Title, stream.Language, stream.Codec?.toUpperCase()].filter(Boolean); return (parts[0] as string) || parts.join(' · ') || fallback; } export type AudioTrack = { index: number; label: string; language: string | null; codec: string | null; channels: number | null; isDefault: boolean; }; export type SubtitleTrack = { index: number; label: string; language: string | null; codec: string | null; isDefault: boolean; isForced: boolean; /** A `/_jf` path to a WebVTT file, or null when this track cannot be delivered as one. */ url: string | null; /** Why `url` is null, in words the picker can show. Null when the track is fine. */ unavailable: string | null; }; /** * Turn the source's streams into the two pickers the player offers. * * Subtitles are the interesting half. Jellyfin has already decided, for OUR profile, how each one could be * delivered, and `DeliveryMethod: 'External'` with a `DeliveryUrl` means "fetch this as a file" — which for * the profile's declared formats is WebVTT the `` element reads directly. That is the only method * taken. `Encode` means burning the subtitle into the picture, which forces a full video re-encode of a file * that may have needed nothing but a remux, so an image-based track (PGS, VOBSUB) is reported as unavailable * WITH ITS REASON rather than quietly dropped — "why is this subtitle missing" should be answerable from the * picker instead of from the server logs. */ function describeTracks(source: MediaSource): { audio: AudioTrack[]; subtitles: SubtitleTrack[] } { const streams = source.MediaStreams ?? []; const audio = streams .filter((stream) => stream.Type === 'Audio' && stream.Index !== undefined) .map((stream) => ({ index: stream.Index!, label: trackLabel(stream, `Track ${stream.Index}`), language: stream.Language ?? null, codec: stream.Codec ?? null, channels: stream.Channels ?? null, isDefault: stream.IsDefault === true, })); const subtitles = streams .filter((stream) => stream.Type === 'Subtitle' && stream.Index !== undefined) .map((stream) => { const external = stream.DeliveryMethod === 'External' && !!stream.DeliveryUrl; const graphical = stream.IsTextSubtitleStream === false; return { index: stream.Index!, label: trackLabel(stream, `Subtitle ${stream.Index}`), language: stream.Language ?? null, codec: stream.Codec ?? null, isDefault: stream.IsDefault === true, isForced: stream.IsForced === true, url: external ? `/_jf${sanitizeUpstreamPath(stream.DeliveryUrl!)}` : null, unavailable: external ? null : graphical ? 'image-based subtitles can only be burned into the video, which forces a full re-encode' : `the server offered no external file for this track (delivery: ${stream.DeliveryMethod ?? 'none'})`, }; }); return { audio: disambiguate(audio), subtitles: disambiguate(subtitles) }; } /** * Make every label in a list distinguishable. * * Two subtitle tracks on the same file genuinely do come back as "English - ASS" and "English - ASS" — one is * usually dialogue and the other signs, and Jellyfin's own `DisplayTitle` does not say which. A picker with * two identical rows is a coin flip, so colliding labels get their stream index, which at least makes the * choice repeatable. */ function disambiguate(tracks: T[]): T[] { const counts = new Map(); for (const track of tracks) counts.set(track.label, (counts.get(track.label) ?? 0) + 1); return tracks.map((track) => counts.get(track.label)! > 1 ? { ...track, label: `${track.label} #${track.index}` } : track, ); } /** * Fix up the HLS URL Jellyfin handed back so it carries the same quality decision as the progressive one. * * `TranscodingUrl` is built by the server from our `DeviceProfile`, which is why it is taken rather than * reinvented: it already carries the segment container, the audio codec list, the play session and the * transcode reasons, and every one of those is the server's business rather than ours. What it gets wrong for * this machine is exactly two values — it resolves `VideoBitrate` from `MaxStreamingBitrate` (~119 Mbit, the * ceiling that exists to let a stream COPY through) and never sets a width. For a file that can be copied that * is right. For a file that must be re-encoded it asks a CPU-only container to encode 4K, which cannot keep up * with playback. So those two are overwritten from `transcodeQuality`, and nothing else is touched. * * Verified against a real answer from this server: the URL comes back with `VideoBitrate=119616000` and no * width, on an item whose transcode reason was `ContainerNotSupported` alone. */ function hlsPath(source: MediaSource, quality: TranscodeQuality): string { const sanitized = sanitizeUpstreamPath(source.TranscodingUrl!); const [path, search = ''] = sanitized.split('?'); const params = new URLSearchParams(search); // Jellyfin's own casing here is PascalCase, and its query parsing is case-insensitive — but a duplicate key // under a different case would still be two values, so delete before setting rather than trusting that. for (const key of [...params.keys()]) { const lower = key.toLowerCase(); if (lower === 'videobitrate' || lower === 'maxwidth' || lower === 'maxheight') params.delete(key); } params.set('VideoBitrate', String(quality.videoBitrate)); if (quality.maxWidth) params.set('MaxWidth', String(quality.maxWidth)); return `${path}?${params.toString()}`; } /** * Build the PROGRESSIVE transcode URL — one continuous mp4 the `