Eighth plugin out, and the second resurrection: like soulseek, hono.ts carried the router import and its mount commented out since 2026-08-13, with the permission reduced to api: [], so the server booted clean while every panel request 404'd. jellyfin_servers was also absent from the drizzle barrel, so bun db:push would have dropped it. Easier than soulseek in one specific way: mountPrefix derives /jellyfin from the appName, which is exactly what the router already hardcoded, so no frontend call site needed repointing. Soulseek needed 47 because its appName and its old prefix differed. The frontend also already routed through one helper, so there were five call sites rather than forty-seven. Self-contained, unlike transmission and soulseek: jellyfin_servers is a registry this plugin owns outright — label, URL, encrypted token, one active — because more than one Jellyfin is a thing someone has, and more than one Transmission is not. Carries its own .prettierignore for the vendored hls.mjs, rather than leaving a stale rule behind in the platform naming a path that no longer exists.
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 });
|
|
}
|