the sidecar now describes every audio and subtitle stream on the chosen source. a subtitle jellyfin can deliver as an external file gets a /_jf webvtt path with the embedded ApiKey stripped; one it cannot gets the reason instead, so the picker can show it disabled rather than pretend it does not exist. colliding labels get their stream index — two tracks called "English - ASS" are a coin flip otherwise. quality is a named rung rather than a number. a bitrate the owner picks maps onto the rung at or below it, which is what stops jellyfin's ResolutionNormalizer from inventing a resolution from an unrounded number. auto stays absent so a copyable source still copies. the control bar is ours in all three transports. progressive has no timeline the browser can render, and an audio-track or quality change is a re-negotiation with the server rather than something a <video> knows how to do — one bar for all three is what keeps those from being three different players. the menus are hand-rolled because a radix dropdown portals to document.body, which is outside the fullscreen element and would be invisible exactly when the player is most likely to be used. no scrubber thumbnails: they come from trickplay tiles and this server generates none. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
754 lines
32 KiB
TypeScript
754 lines
32 KiB
TypeScript
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=<the access token>` 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<Response> {
|
|
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<T>(cfg: UpstreamConfig, path: string, params: Record<string, string | undefined>): Promise<T> {
|
|
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, string | undefined>): 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<JellyfinItem[]> {
|
|
const data = await fetchJson<ItemsResponse>(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<Response> {
|
|
const views = await videoViews(cfg);
|
|
|
|
const [resume, nextUp, latest] = await Promise.all([
|
|
fetchJson<ItemsResponse>(cfg, '/UserItems/Resume', {
|
|
userId: cfg.jellyfinUserId,
|
|
limit: '12',
|
|
mediaTypes: 'Video',
|
|
fields: GRID_FIELDS,
|
|
enableTotalRecordCount: 'false',
|
|
}).catch(() => ({ Items: [] })),
|
|
fetchJson<ItemsResponse>(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<JellyfinItem[]>(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<string, string | undefined> {
|
|
const params: Record<string, string | undefined> = {};
|
|
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<Response> {
|
|
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<Response> {
|
|
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=<access token>&…`. 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 `<track>` 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<T extends { index: number; label: string }>(tracks: T[]): T[] {
|
|
const counts = new Map<string, number>();
|
|
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 `<video>` element can play with no library.
|
|
*
|
|
* This is the FALLBACK now, not the primary: the player prefers `hlsUrl`, whose playlist covers the whole
|
|
* runtime and therefore seeks properly. This path stays for the browser that cannot do MSE at all (iOS
|
|
* Safari's native HLS covers itself, but a stripped-down webview may have neither), and it is why the plan
|
|
* still carries a plain `url`.
|
|
*
|
|
* What it costs is honest and worth stating: a live transcode has no length and no byte ranges, so the
|
|
* browser cannot seek it. The player seeks by asking for a NEW stream at an offset — which is what
|
|
* `startTimeTicks` is for here, and why the UI re-requests playback on every scrub when it falls back here.
|
|
*
|
|
* `allowVideoStreamCopy` is what keeps this cheap for the common case. An mkv whose video is already h264
|
|
* gets REMUXED, not re-encoded: the container changes, the video bytes are copied. That matters on this
|
|
* machine, where the Jellyfin container has no /dev/dri and every real encode is on the CPU.
|
|
*
|
|
* `videoBitrate` IS NOT OPTIONAL, and omitting it is not "let the server choose". Jellyfin resolves a missing
|
|
* bitrate to 0 and then runs `ResolutionNormalizer` over it, which maps a bitrate to a resolution — 0 lands on
|
|
* the bottom rung. Verified on this machine: without it, a 3840x1600 source transcoded through
|
|
* `scale=…min(max(iw,ih*a),416)…` with `-maxrate 0`, i.e. 416 pixels wide. That is the whole of the "why does
|
|
* it look so bad" question, and it is a silent, plausible-looking picture rather than an error.
|
|
*/
|
|
type ProgressiveParams = {
|
|
id: string;
|
|
source: MediaSource;
|
|
playSessionId: string | null;
|
|
startSeconds: number;
|
|
quality: TranscodeQuality;
|
|
};
|
|
|
|
function progressivePath({ id, source, playSessionId, startSeconds, quality }: ProgressiveParams): string {
|
|
return `/Videos/${encodeURIComponent(id)}/stream.mp4${buildQuery({
|
|
static: 'false',
|
|
container: 'mp4',
|
|
mediaSourceId: source.Id,
|
|
playSessionId: playSessionId ?? undefined,
|
|
videoCodec: 'h264',
|
|
audioCodec: 'aac',
|
|
audioBitrate: '192000',
|
|
maxAudioChannels: '2',
|
|
allowVideoStreamCopy: 'true',
|
|
allowAudioStreamCopy: 'true',
|
|
videoBitrate: String(quality.videoBitrate),
|
|
maxWidth: quality.maxWidth ? String(quality.maxWidth) : undefined,
|
|
// No `subtitleMethod=Encode` here on purpose: burning subtitles in forces a full video re-encode, which
|
|
// throws away the stream copy above and pins the CPU on a file that needed nothing but a remux. Subtitles
|
|
// are a deliberate later feature, chosen per-item, not a default that quietly costs that much.
|
|
startTimeTicks: startSeconds > 0 ? String(Math.round(startSeconds * TICKS_PER_SECOND)) : undefined,
|
|
})}`;
|
|
}
|
|
|
|
/**
|
|
* 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. They are reported explicitly rather than inferred,
|
|
* because "why is my CPU pinned" is a question the UI should be able to answer.
|
|
*
|
|
* `hlsUrl` is returned alongside whenever Jellyfin offered one, and is what the player actually uses for a
|
|
* transcode — hls.js is vendored for it (`apps/Jellyfin/vendor/`). `url` remains the progressive fallback for
|
|
* a browser with no MSE, so both are carried and the player chooses.
|
|
*/
|
|
async function playbackInfo(cfg: UpstreamConfig, id: string, req: Request, url: URL): Promise<Response> {
|
|
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 quality = transcodeQuality(source, body.maxStreamingBitrate);
|
|
|
|
const staticPath = `/Videos/${encodeURIComponent(id)}/stream${buildQuery({
|
|
static: 'true',
|
|
mediaSourceId: source.Id,
|
|
playSessionId: playSessionId ?? undefined,
|
|
})}`;
|
|
|
|
const path = direct
|
|
? source.SupportsDirectPlay
|
|
? staticPath
|
|
: (source.DirectStreamUrl && sanitizeUpstreamPath(source.DirectStreamUrl)) || staticPath
|
|
: // A `TranscodingUrl` is Jellyfin agreeing to transcode. We ask for the same thing progressively
|
|
// instead of taking its HLS URL, but its absence still means "refused", so it stays the signal.
|
|
source.TranscodingUrl
|
|
? progressivePath({ id, source, playSessionId, startSeconds, quality })
|
|
: 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}`,
|
|
// A direct file is byte-range seekable; a live transcode is not, and the player has to restart the stream
|
|
// at an offset instead. This flag is the difference, and it is the one thing the player cannot guess.
|
|
seekable: direct,
|
|
// The seekable form of a transcode: the playlist spans the whole runtime, so a scrub is a segment request
|
|
// rather than a new ffmpeg. Preferred by the player whenever it is present and MSE is available.
|
|
hlsUrl: source.TranscodingUrl ? `/_jf${hlsPath(source, quality)}` : null,
|
|
playSessionId,
|
|
mediaSourceId: source.Id ?? null,
|
|
mediaSource: source,
|
|
startSeconds,
|
|
runtimeSeconds: source.RunTimeTicks ? source.RunTimeTicks / TICKS_PER_SECOND : null,
|
|
// The pickers. Audio needs a re-negotiation to change (it is muxed into the stream); subtitles do not,
|
|
// because an external VTT is a separate file the <track> element fetches on its own.
|
|
...describeTracks(source),
|
|
audioStreamIndex: body.audioStreamIndex ?? null,
|
|
/** Null means Auto — the copy-or-encode decision above, rather than a rung the owner picked. */
|
|
maxStreamingBitrate: body.maxStreamingBitrate ?? null,
|
|
qualityRungs: QUALITY_RUNGS,
|
|
});
|
|
}
|
|
|
|
/** 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<Response> {
|
|
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<Response | null> {
|
|
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 `<video>` element and the browser cache
|
|
* behave, and dropping them turns seeking into a re-download.
|
|
*/
|
|
export async function handleBytesRoute(cfg: UpstreamConfig, req: Request, url: URL): Promise<Response | null> {
|
|
if (req.method !== 'GET' && req.method !== 'HEAD') return null;
|
|
|
|
const subpath = url.pathname.slice('/_jf/'.length);
|
|
if (!subpath || subpath.includes('..')) return null;
|
|
if (!isAllowedBytesPath(subpath)) return null;
|
|
|
|
const upstream = await callUpstream(cfg, {
|
|
path: `/${subpath}`,
|
|
method: req.method,
|
|
query: url.search,
|
|
accept: '*/*',
|
|
range: req.headers.get('range'),
|
|
});
|
|
|
|
const headers = new Headers();
|
|
for (const header of [
|
|
'content-type',
|
|
'content-length',
|
|
'content-range',
|
|
'accept-ranges',
|
|
'etag',
|
|
'last-modified',
|
|
'cache-control',
|
|
] as const) {
|
|
const value = upstream.headers.get(header);
|
|
if (value) headers.set(header, value);
|
|
}
|
|
|
|
return new Response(upstream.body, { status: upstream.status, headers });
|
|
}
|