jellyfin, extracted from the platform as a plugin

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.
This commit is contained in:
officer
2026-08-19 03:17:32 +00:00
commit a7f1a71785
34 changed files with 41581 additions and 0 deletions
+249
View File
@@ -0,0 +1,249 @@
import type { JellyfinServer } from '../db';
import {
createJellyfinServer,
deleteJellyfinServer,
getJellyfinCredentials,
listJellyfinServers,
recordJellyfinProbe,
setActiveJellyfinServer,
updateJellyfinServer,
} from '../db';
import { UpstreamError, authenticate, invalidateConfig, normalizeBase, probe, publicInfo } from './upstream';
// `/_config` — the Jellyfin server registry, driven from the app.
//
// The access token is WRITE-ONLY across this boundary, and it is never even written directly: the owner
// supplies a URL, a username and a password, and the sidecar trades them for a token through
// `POST /Users/AuthenticateByName`. The password lives for the duration of that one call. The list route
// reports label, URL, account name, server name and version — it has no field that could carry a token,
// masked or otherwise.
//
// A save is validated against the live instance before it is stored, in two steps that answer two different
// questions: `/System/Info/Public` says "this URL is a Jellyfin, and it is called X", and the sign-in says
// "these credentials work on it". Reporting them separately is what makes a typo'd port distinguishable from
// a wrong password in the setup form.
const bad = (error: string, status = 400) => Response.json({ error }, { status });
/** What the browser is allowed to know about the registry. Never includes a token. */
async function serverList(userId: number): Promise<Response> {
const servers = await listJellyfinServers(userId);
const active = servers.find((server) => server.isActive) ?? null;
return Response.json({ configured: !!active, activeId: active?.id ?? null, servers });
}
/** Record that the instance answered, so the UI can tell "never connected" from "was working, now isn't". */
export async function noteProbe(userId: number, id: number, version: string | null): Promise<void> {
await recordJellyfinProbe(userId, id, version).catch(() => {
/* a stale lastSeenAt is not worth failing a request over */
});
}
type ServerBody = { label?: unknown; url?: unknown; username?: unknown; password?: unknown };
const readBody = async (req: Request): Promise<ServerBody> =>
((await req.json().catch(() => null)) as ServerBody | null) ?? {};
const readLabel = (body: ServerBody): string => (typeof body.label === 'string' ? body.label.trim() : '');
const readUrl = (body: ServerBody): string => (typeof body.url === 'string' ? normalizeBase(body.url) : '');
const readUsername = (body: ServerBody): string => (typeof body.username === 'string' ? body.username.trim() : '');
const readPassword = (body: ServerBody): string => (typeof body.password === 'string' ? body.password : '');
const isHttpUrl = (url: string): boolean => /^https?:\/\//i.test(url);
/** Sign-in and reachability failures, in the words of what the owner actually did. */
function upstreamError(err: unknown): string {
if (err instanceof UpstreamError) return err.message;
return `could not reach that URL (${err instanceof Error ? err.message : String(err)})`;
}
const duplicateLabel = (err: unknown): boolean => String(err).includes('uq_jellyfin_servers_user_label');
/**
* Add a server: the URL is identified, the credentials are traded for a token, then the token is stored
* encrypted and the password is dropped.
*
* A fresh `DeviceId` is minted here and only here. It is what Jellyfin keys sessions and remembered devices
* by, so it belongs to the ROW, not to a request — see schema/jellyfin.ts.
*/
async function addServer(req: Request, userId: number): Promise<Response> {
const body = await readBody(req);
const url = readUrl(body);
const username = readUsername(body);
const password = readPassword(body);
let label = readLabel(body);
if (!url) return bad('url is required');
if (!isHttpUrl(url)) return bad('url must start with http:// or https://');
if (!username) return bad('username is required');
if (!password) return bad('password is required');
let info;
try {
info = await publicInfo(url);
} catch (err) {
return bad(upstreamError(err));
}
const deviceId = crypto.randomUUID();
let auth;
try {
auth = await authenticate(url, deviceId, username, password);
} catch (err) {
return bad(upstreamError(err));
}
// An unlabelled server takes the instance's own name, which is nearly always what the owner would type.
// Falling back to the host keeps the switcher readable when the server is unnamed.
if (!label) label = info.serverName ?? new URL(url).host;
// The first server wins the selection: a registry with rows but nothing selected reads as "not connected".
const existing = await listJellyfinServers(userId);
const activate = existing.length === 0;
try {
const server = await createJellyfinServer({
userId,
label,
url,
accessToken: auth.token,
jellyfinUserId: auth.userId,
jellyfinUsername: auth.username ?? username,
deviceId,
serverName: info.serverName,
version: info.version,
activate,
});
invalidateConfig(userId);
return Response.json({ server });
} catch (err) {
if (duplicateLabel(err)) return bad(`you already have a server called "${label}"`);
throw err;
}
}
/**
* Edit one server. No password means "keep the stored token", so a rename never needs one re-typed.
*
* Re-authenticating deliberately keeps the row's existing `deviceId`: the point of signing in again is
* usually that the token was revoked, and reusing the device id keeps the new session attached to the same
* entry in Jellyfin's device list instead of adding a second ghost.
*/
async function editServer(req: Request, userId: number, id: number): Promise<Response> {
const body = await readBody(req);
const label = readLabel(body);
const url = readUrl(body);
const username = readUsername(body);
const password = readPassword(body);
const current = await getJellyfinCredentials(userId, id);
if (!current) return bad('no such server', 404);
if (url && !isHttpUrl(url)) return bad('url must start with http:// or https://');
const base = url || normalizeBase(current.url);
let accessToken: string | undefined;
let jellyfinUserId: string | undefined;
let jellyfinUsername: string | null | undefined;
let serverName: string | null | undefined;
let version: string | null | undefined;
if (password) {
if (!username) return bad('username is required to sign in again');
try {
const info = await publicInfo(base);
const auth = await authenticate(base, current.deviceId, username, password);
accessToken = auth.token;
jellyfinUserId = auth.userId;
jellyfinUsername = auth.username ?? username;
serverName = info.serverName;
version = info.version;
} catch (err) {
return bad(upstreamError(err));
}
} else if (url && base !== normalizeBase(current.url)) {
// Moving a server to a new URL without re-authenticating is allowed — a reverse proxy in front of the
// same instance keeps the token valid — but it has to be checked, because pointing at a DIFFERENT
// Jellyfin would leave a row whose token belongs to another server.
const result = await probe({ ...current, base, token: current.accessToken });
if (!result.ok) return bad(result.error ?? 'that URL did not accept the stored token');
serverName = result.serverName;
version = result.version;
}
try {
const server = await updateJellyfinServer(userId, id, {
label: label || undefined,
url: url || undefined,
accessToken,
jellyfinUserId,
jellyfinUsername,
serverName,
version,
});
if (!server) return bad('no such server', 404);
invalidateConfig(userId);
return Response.json({ server });
} catch (err) {
if (duplicateLabel(err)) return bad(`you already have a server called "${label}"`);
throw err;
}
}
/** Check one stored server without switching to it — what the "test" button on each row calls. */
async function testServer(userId: number, id: number): Promise<Response> {
const creds = await getJellyfinCredentials(userId, id);
if (!creds) return bad('no such server', 404);
const started = Date.now();
const result = await probe({ ...creds, base: normalizeBase(creds.url), token: creds.accessToken });
const ms = Date.now() - started;
if (!result.ok) return Response.json({ ok: false, error: result.error, ms }, { status: 502 });
await noteProbe(userId, id, result.version);
return Response.json({ ok: true, version: result.version, serverName: result.serverName, ms });
}
export type { JellyfinServer };
/** `subpath` is '' for /_config, or '/<id>', '/<id>/activate', '/<id>/test'. */
export async function handleConfigRoute(req: Request, userId: number, subpath: string): Promise<Response> {
const [, rawId, action] = subpath.split('/');
if (!rawId) {
if (req.method === 'GET') return serverList(userId);
if (req.method === 'POST' || req.method === 'PUT') return addServer(req, userId);
return bad('method not allowed', 405);
}
const id = Number(rawId);
if (!Number.isInteger(id) || id <= 0) return bad('invalid server id', 404);
if (action === 'activate') {
if (req.method !== 'POST') return bad('method not allowed', 405);
const server = await setActiveJellyfinServer(userId, id);
if (!server) return bad('no such server', 404);
invalidateConfig(userId);
return serverList(userId);
}
// GET only. It probes one server and reports whether it answered — nothing is written, and nothing is
// switched (that is `activate`, below). It accepted POST as well until 2026-08-06, which made the
// method meaningless for deciding whether this is a read; the permission model needs that answer.
if (action === 'test') {
if (req.method !== 'GET') return bad('method not allowed', 405);
return testServer(userId, id);
}
if (action) return bad('not found', 404);
if (req.method === 'PATCH' || req.method === 'PUT') return editServer(req, userId, id);
if (req.method === 'DELETE') {
const removed = await deleteJellyfinServer(userId, id);
if (!removed) return bad('no such server', 404);
invalidateConfig(userId);
return serverList(userId);
}
return bad('method not allowed', 405);
}
+174
View File
@@ -0,0 +1,174 @@
import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
import { createSidecarConnector } from '@@/sidecar/connect';
import { handleConfigRoute, noteProbe } from './config';
import { handleBytesRoute, handleOfficerRoute } from './routes';
import { getConfig, probe } from './upstream';
import { API_URL } from '@@/officer-url.mjs';
// The officer-jellyfin sidecar. Owns the whole Jellyfin contract for Officer: the instance URL, the access
// token, the Jellyfin user it belongs to and the DeviceId its sessions are keyed by. The platform API is a
// thin auth-gated forwarder (src/servers/api/jellyfin/router.ts) holding no Jellyfin credentials.
//
// VIDEO ONLY, deliberately. This machine runs four Jellyfin instances — video, albums, DJ sets, and a second
// person's — and Officer already has a music player of its own. The library list filters music collections
// out (routes.ts, VIDEO_COLLECTION_TYPES) rather than showing two competing answers to "where is my music".
//
// ─────────────────────────────────────────────────────────────────────────────────────────────────
// HTTP CONTRACT — the platform strips its /api/jellyfin mount prefix before forwarding.
//
// GET /_health ours. Confirms the stored token is still accepted.
// GET /_config the server registry MINUS every token
// POST /_config { label?, url, username, password } — the password is traded once
// for an access token and never stored
// PATCH /_config/:id same fields, all optional; no password keeps the stored token
// POST /_config/:id/activate switch to that server
// GET /_config/:id/test probe one server without switching to it
// DEL /_config/:id remove it; the newest survivor is promoted if it was active
//
// GET /_officer/home views + resume + next-up + latest-per-view, one round trip
// GET /_officer/views the video libraries
// GET /_officer/items?parentId=… browse grid (allow-listed filters, poster fields always on)
// GET /_officer/items/:id full detail, incl. MediaSources
// GET /_officer/items/:id/similar
// POST /_officer/items/:id/playback negotiate → { playMethod, url, seekable, hlsUrl, playSessionId }
// POST|DEL /_officer/items/:id/played watched mark
// POST|DEL /_officer/items/:id/favorite
// GET /_officer/shows/:id/seasons
// GET /_officer/shows/:id/episodes?seasonId=
// GET /_officer/resume | /_officer/nextup | /_officer/genres
// GET /_officer/search?q=
// POST /_officer/sessions/playing|progress|stopped playback reporting, bodies forwarded as sent
//
// GET /_jf/* authenticated byte pass-through: images, video, HLS, subtitles.
// Allow-listed prefixes only — see routes.ts for why it is not a
// general proxy, and why HLS forces it to keep Jellyfin's own paths.
// ─────────────────────────────────────────────────────────────────────────────────────────────────
/** Grab an ephemeral free port by briefly binding one and releasing it. */
function getFreePort(): number {
const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
const p = probeServer.port;
probeServer.stop(true);
if (p == null) throw new Error('failed to acquire a free port');
return p;
}
const port = getFreePort();
const server = Bun.serve({
port,
hostname: '127.0.0.1',
// Playback reporting posts small JSON; nothing is uploaded to Jellyfin from here. The default cap would do,
// but a modest explicit one documents that this sidecar is a reader.
maxRequestBodySize: 4 * 1024 * 1024,
// A transcode start can take a while to answer its first playlist request while ffmpeg spins up, and the
// segment requests that follow are long-lived byte streams.
idleTimeout: 255,
async fetch(req) {
const url = new URL(req.url);
const officerUser = req.headers.get('X-Officer-User');
const userId = Number(officerUser);
if (!officerUser || !Number.isInteger(userId) || userId <= 0) {
return Response.json({ error: 'missing or invalid X-Officer-User' }, { status: 401 });
}
if (url.pathname === '/_config' || url.pathname.startsWith('/_config/')) {
try {
return await handleConfigRoute(req, userId, url.pathname.slice('/_config'.length));
} catch (err) {
console.error(`[jellyfin] ${req.method} ${url.pathname} failed`, err);
return Response.json({ error: 'internal error' }, { status: 500 });
}
}
const cfg = await getConfig(userId);
// 503 with `configured: false` is the signal the UI turns into the setup form. Distinguishing it from a
// configured-but-broken instance is the whole reason the flag is on the response.
if (url.pathname === '/_health') {
if (!cfg) return Response.json({ ok: false, configured: false, error: 'not connected' }, { status: 503 });
const started = Date.now();
const result = await probe(cfg);
const ms = Date.now() - started;
if (!result.ok) {
return Response.json(
{ ok: false, configured: true, server: cfg.label, error: result.error, ms },
{ status: 502 },
);
}
await noteProbe(userId, cfg.id, result.version);
return Response.json({
ok: true,
configured: true,
server: cfg.label,
serverName: result.serverName,
version: result.version,
ms,
});
}
const isOfficer = url.pathname.startsWith('/_officer/');
const isBytes = url.pathname.startsWith('/_jf/');
if (isOfficer || isBytes) {
if (!cfg) return Response.json({ error: 'jellyfin not connected', configured: false }, { status: 503 });
try {
const res = isOfficer ? await handleOfficerRoute(cfg, req, url) : await handleBytesRoute(cfg, req, url);
if (res) return res;
return Response.json({ error: 'not found' }, { status: 404 });
} catch (err) {
console.error(`[jellyfin] ${req.method} ${url.pathname} failed`, err);
return Response.json({ error: 'internal error' }, { status: 500 });
}
}
return Response.json({ error: 'not found' }, { status: 404 });
},
});
console.log(`[jellyfin] listening on 127.0.0.1:${port} (server configured from the UI, stored in jellyfin_servers)`);
type ReplyFn = (msg: SidecarEvent) => void;
function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
switch (cmd.type) {
case 'ping':
reply({ type: 'pong', id: cmd.id });
break;
default:
reply({
type: 'error',
id: (cmd as SidecarCommand).id,
error: `Unknown command type: ${(cmd as Record<string, unknown>).type}`,
});
}
}
const connection = createSidecarConnector({
apiUrl: `${API_URL}/api/sidecar/register`,
name: 'jellyfin',
handles: ['jellyfin'],
onCommand(cmd, reply) {
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
},
onConnected() {
connection.send({ type: 'jellyfin:server', port });
console.log(`[jellyfin] reported server port ${port} to API`);
},
});
function shutdown(signal: string) {
console.log(`[jellyfin] ${signal} received, shutting down...`);
try {
server.stop(true);
} catch {
/* already stopped */
}
connection.destroy();
process.exit(0);
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
+85
View File
@@ -0,0 +1,85 @@
// The device profile sent with every PlaybackInfo request.
//
// This is the single most consequential object in the whole Jellyfin integration and the least obvious: it is
// how the SERVER decides whether to hand back the original file or spin up an ffmpeg transcode. Describe the
// browser too generously and playback dies silently on an unsupported codec; too conservatively and every
// file is transcoded, which on this machine means CPU-only ffmpeg (no /dev/dri is passed into the container).
//
// It deliberately describes what a bare Chromium/WebKit `<video>` can play with no media library loaded, which
// is narrower than the file formats a personal library contains — Matroska in particular is NOT playable in any
// browser, so mkv is the common case that has to be remuxed even when its streams are already h264/aac.
//
// Both transcoding profiles are declared, and the ORDER is load-bearing. HLS is first, so `TranscodingUrl` in
// the PlaybackInfo response stays an m3u8 the player carries but does not use (see routes.ts → `hlsUrl`). The
// progressive http profile below is what our player actually requests, and it exists so the server accepts
// that request; without it, `/Videos/{id}/stream.mp4?static=false` is refused as outside the profile.
//
// Kept as a plain literal rather than probed from the client: it is server-side knowledge about a fixed
// target (our own web player), and a profile assembled from feature detection in the browser is the classic
// way to end up with a per-machine playback bug nobody can reproduce.
const VIDEO_CONTAINERS = 'mp4,m4v,webm';
const VIDEO_CODECS = 'h264,vp8,vp9,av1';
const AUDIO_CODECS = 'aac,mp3,opus,flac,vorbis';
export const DEVICE_PROFILE = {
MaxStreamingBitrate: 120_000_000,
MaxStaticBitrate: 100_000_000,
MusicStreamingTranscodingBitrate: 384_000,
DirectPlayProfiles: [
{ Container: VIDEO_CONTAINERS, Type: 'Video', VideoCodec: VIDEO_CODECS, AudioCodec: AUDIO_CODECS },
// Present so a trailer or a stray extra plays; the library itself is video and audio lives in /music.
{ Container: 'mp3,aac,flac,opus,webm', Type: 'Audio' },
],
TranscodingProfiles: [
{
// fMP4 segments rather than MPEG-TS: h264 in fMP4 is what jellyfin-web itself defaults to on 10.11,
// and it is the only path that can stream-copy an h264 track into HLS instead of re-encoding it.
Container: 'mp4',
Type: 'Video',
AudioCodec: 'aac,mp3,opus,flac',
VideoCodec: 'h264',
Context: 'Streaming',
Protocol: 'hls',
MaxAudioChannels: '2',
MinSegments: 1,
BreakOnNonKeyFrames: true,
},
{
// Progressive mp4 over plain http — what our player requests. `Streaming`, not `Static`: `Static` means
// "the whole file, transcoded up front", which for a live seek-by-restart player is the wrong contract
// and is refused for a stream opened at an offset.
Container: 'mp4',
Type: 'Video',
AudioCodec: 'aac',
VideoCodec: 'h264',
Context: 'Streaming',
Protocol: 'http',
MaxAudioChannels: '2',
},
],
CodecProfiles: [
{
Type: 'Video',
Codec: 'h264',
Conditions: [
// High 10 (10-bit h264) decodes in almost no browser, and hitting it is a black screen with audio
// rather than an error, so it is excluded explicitly instead of being left to chance.
{ Condition: 'NotEquals', Property: 'VideoProfile', Value: 'high 10', IsRequired: false },
{ Condition: 'LessThanEqual', Property: 'VideoLevel', Value: '52', IsRequired: false },
],
},
],
SubtitleProfiles: [
// Text subtitles are fetched separately and rendered by the player. Image-based ones (PGS, VOBSUB) have
// no such path in a browser and are left out, which makes Jellyfin burn them into the video instead.
{ Format: 'vtt', Method: 'External' },
{ Format: 'srt', Method: 'External' },
{ Format: 'ass', Method: 'External' },
{ Format: 'ssa', Method: 'External' },
],
} as const;
+753
View File
@@ -0,0 +1,753 @@
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 });
}
+232
View File
@@ -0,0 +1,232 @@
// Jellyfin upstream config + the one function that talks to it.
//
// All knowledge of the Jellyfin instance — its URL, its access token, the Jellyfin user the token belongs to
// and the DeviceId sessions are keyed by — lives here, mirroring officer-invoiceshelf: the platform API is a
// thin auth+forward proxy and holds NO Jellyfin credentials.
//
// The server is CONFIGURED BY THE OWNER FROM THE UI and stored encrypted in `jellyfin_servers` (see
// databases/officer_db/src/jellyfin/queries.ts). Nothing in this file reads process.env — Bun auto-loads
// `.env` into every process started in the platform directory, so a token there would also be sitting in
// `officer`'s own environment: a credential held by the one process that has no code to use it.
//
// Three things about Jellyfin's API are load-bearing:
//
// 1. Auth is ONE header — `Authorization: MediaBrowser Token="…", Client=…, Device=…, DeviceId=…,
// Version=…`. The older `X-Emby-Token` still works in 10.11 but carries no client identity, and
// playback reporting correlates sessions by DeviceId, so the full header is what we send.
// 2. The DeviceId must be STABLE per server. A fresh one per request fills Jellyfin's dashboard with
// hundreds of "devices" and detaches each progress report from the session that started playback.
// 3. Almost every per-user route needs the user id in the PATH (`/Users/{id}/Views`), and it is not
// derivable from the token without a round trip — which is why it is pinned on the row at sign-in.
import { getActiveJellyfinCredentials } from '../db';
/** Everything needed to make one call. A candidate being validated has this and nothing else yet. */
export type UpstreamTarget = {
base: string;
token: string;
jellyfinUserId: string;
deviceId: string;
};
/**
* A stored server, which is where every real call goes.
*
* `id` and `label` ride along because the owner has several instances registered (this machine runs four)
* and only one selected: a probe has to be recorded against the row it actually reached, and a log line
* saying which instance answered is the difference between "Jellyfin is broken" and "you are looking at
* the albums server".
*/
export type UpstreamConfig = UpstreamTarget & { id: number; label: string };
/** Trailing slashes off, so `${base}/Items/...` never doubles the separator. */
export const normalizeBase = (url: string): string => url.trim().replace(/\/+$/, '');
// A library grid is a burst of requests and each one needs the token, so the row is cached rather than
// re-read per request. Writes invalidate immediately; the TTL only covers someone editing the row in psql,
// which then takes effect within a minute instead of needing a restart.
const TTL_MS = 60_000;
const cache = new Map<number, { cfg: UpstreamConfig | null; at: number }>();
/**
* The owner's SELECTED server, or null when /jellyfin has none yet — the sidecar then answers 503, and the
* UI turns that into the setup form rather than a screen of empty shelves.
*/
export async function getConfig(userId: number): Promise<UpstreamConfig | null> {
const hit = cache.get(userId);
if (hit && Date.now() - hit.at < TTL_MS) return hit.cfg;
const creds = await getActiveJellyfinCredentials(userId);
const cfg = creds
? {
id: creds.id,
label: creds.label,
base: normalizeBase(creds.url),
token: creds.accessToken,
jellyfinUserId: creds.jellyfinUserId,
deviceId: creds.deviceId,
}
: null;
cache.set(userId, { cfg, at: Date.now() });
return cfg;
}
/** Drop the cached row — called by the config routes after any add, edit, switch or removal. */
export function invalidateConfig(userId: number): void {
cache.delete(userId);
}
/** Carries the upstream status so callers can tell a rejected token from an unreachable instance. */
export class UpstreamError extends Error {
constructor(
message: string,
readonly status: number,
) {
super(message);
}
}
const CLIENT = 'Officer';
const DEVICE = 'Officer Web';
const CLIENT_VERSION = '1.0.0';
/**
* The `Authorization` header Jellyfin expects, with or without a token.
*
* Signing in has to send the same header MINUS the token: Jellyfin refuses AuthenticateByName outright
* without a client identity, which is the single most common reason a hand-rolled login gets a 400.
*/
export function authHeader(deviceId: string, token?: string): string {
const parts = [`Client="${CLIENT}"`, `Device="${DEVICE}"`, `DeviceId="${deviceId}"`, `Version="${CLIENT_VERSION}"`];
if (token) parts.push(`Token="${token}"`);
return `MediaBrowser ${parts.join(', ')}`;
}
export type AuthResult = { token: string; userId: string; username: string | null; serverName: string | null };
/**
* Trade a username and password for an access token.
*
* The password is used for this one call and never stored — only the token it returns is persisted, the
* same trade officer-invoiceshelf makes. Jellyfin CAN issue API keys from its dashboard, but those are
* server-wide admin keys with no user identity, and every per-user route (resume points, watched state,
* favourites) needs a user. So we sign in as the owner rather than key the server.
*
* Each sign-in mints a NEW token; the previous one is deliberately left alone, because Officer cannot know
* whether the owner also uses it somewhere else. Old ones are revoked from Jellyfin's own Devices screen.
*/
export async function authenticate(
base: string,
deviceId: string,
username: string,
password: string,
): Promise<AuthResult> {
const res = await fetch(`${base}/Users/AuthenticateByName`, {
method: 'POST',
headers: {
Authorization: authHeader(deviceId),
'Content-Type': 'application/json',
Accept: 'application/json',
},
// `Pw` is the plaintext field. `Password` is the legacy SHA1 one and is ignored by modern servers.
body: JSON.stringify({ Username: username, Pw: password }),
redirect: 'manual',
});
if (!res.ok) {
const detail = res.status === 401 ? 'wrong username or password' : `sign-in failed with ${res.status}`;
throw new UpstreamError(detail, res.status);
}
const payload = (await res.json()) as {
AccessToken?: unknown;
User?: { Id?: unknown; Name?: unknown };
ServerId?: unknown;
};
if (typeof payload.AccessToken !== 'string' || !payload.AccessToken) {
throw new UpstreamError('sign-in returned no access token', res.status);
}
if (typeof payload.User?.Id !== 'string' || !payload.User.Id) {
throw new UpstreamError('sign-in returned no user id', res.status);
}
return {
token: payload.AccessToken,
userId: payload.User.Id,
username: typeof payload.User.Name === 'string' ? payload.User.Name : null,
serverName: null,
};
}
export type PublicInfo = { serverName: string | null; version: string | null };
/**
* The unauthenticated identity endpoint, used before any credential exists so the setup form can confirm
* the URL points at a Jellyfin at all — and name it — before asking for a password.
*/
export async function publicInfo(base: string): Promise<PublicInfo> {
const res = await fetch(`${base}/System/Info/Public`, { headers: { Accept: 'application/json' } });
if (!res.ok) throw new UpstreamError(`not a reachable Jellyfin (${res.status})`, res.status);
const payload = (await res.json()) as { ServerName?: unknown; Version?: unknown };
return {
serverName: typeof payload.ServerName === 'string' ? payload.ServerName : null,
version: typeof payload.Version === 'string' ? payload.Version : null,
};
}
type CallOptions = {
/** Absolute path on the Jellyfin host, e.g. `/Items`. */
path: string;
method?: string;
/** Raw search string including the leading `?`, or empty. */
query?: string;
body?: BodyInit | null;
contentType?: string | null;
/** Defaults to `application/json`; image and video routes ask for the bytes instead. */
accept?: string;
/** Forwarded verbatim for seeking — Jellyfin answers 206 for both direct play and HLS segments. */
range?: string | null;
};
/** The single door to Jellyfin. Everything the sidecar fetches goes through here. */
export async function callUpstream(cfg: UpstreamTarget, opts: CallOptions): Promise<Response> {
const headers: Record<string, string> = {
Authorization: authHeader(cfg.deviceId, cfg.token),
Accept: opts.accept ?? 'application/json',
};
if (opts.contentType) headers['Content-Type'] = opts.contentType;
if (opts.range) headers.Range = opts.range;
const res = await fetch(`${cfg.base}${opts.path}${opts.query ?? ''}`, {
method: opts.method ?? 'GET',
headers,
body: opts.body ?? undefined,
redirect: 'manual',
});
if (res.status === 401) {
console.warn(`[jellyfin] ${opts.method ?? 'GET'} ${opts.path} → 401 (token rejected)`);
}
return res;
}
export type ProbeResult = { ok: boolean; version: string | null; serverName: string | null; error?: string };
/**
* Is the stored token still good?
*
* `/System/Info` (no `/Public`) is the right probe precisely because it is authenticated: the public one
* answers 200 to a revoked token, so a probe against it would call a dead connection healthy.
*/
export async function probe(cfg: UpstreamTarget): Promise<ProbeResult> {
try {
const res = await callUpstream(cfg, { path: '/System/Info' });
if (res.status === 401) return { ok: false, version: null, serverName: null, error: 'access token rejected' };
if (!res.ok) return { ok: false, version: null, serverName: null, error: `server answered ${res.status}` };
const payload = (await res.json()) as { Version?: unknown; ServerName?: unknown };
return {
ok: true,
version: typeof payload.Version === 'string' ? payload.Version : null,
serverName: typeof payload.ServerName === 'string' ? payload.ServerName : null,
};
} catch (err) {
return { ok: false, version: null, serverName: null, error: err instanceof Error ? err.message : 'unreachable' };
}
}