jellyfin, extracted into plugins/jellyfin — the platform half

Eighth extraction, second resurrection. Same shape as soulseek: hono.ts had the
router import and its PROTECTED_MOUNTS entry commented out since 2026-08-13 and
the permission reduced to api: [], so assertPermissionTotality was satisfied,
the server booted clean, and every panel request 404'd. Also closes the same
data hazard — jellyfin_servers was absent from the drizzle barrel, so bun
db:push would have dropped it.

No frontend call site needed repointing: mountPrefix derives /jellyfin from the
appName, which is what the router already hardcoded.

Test fixtures fixed properly rather than re-anchored again. Extracting jellyfin
broke 18 tests, the FOURTH extraction in a row to break the same files — photos
and transmission yesterday, slskd an hour ago, jellyfin now. installer.test.ts
now builds literal CatalogueEntry fixtures and asks the catalogue nothing: it
pins planSteps and runInstall, which are installer logic, and a service leaving
for plugins/ says nothing about whether the installer still plans the right
steps. catalogue.test.ts stays the place real entries are checked well-formed.
members/catalogue/preflight re-anchored to memos, which has jellyfin's exact
shape.

The .prettierignore rule for the vendored hls.mjs moved into the plugin rather
than being left behind naming a deleted path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 03:17:43 +00:00
co-authored by Claude Opus 5
parent b196c9cb55
commit aebd6ff0c0
49 changed files with 52 additions and 41512 deletions
-249
View File
@@ -1,249 +0,0 @@
import type { JellyfinServer } from 'officerdb';
import {
createJellyfinServer,
deleteJellyfinServer,
getJellyfinCredentials,
listJellyfinServers,
recordJellyfinProbe,
setActiveJellyfinServer,
updateJellyfinServer,
} from 'officerdb';
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
@@ -1,174 +0,0 @@
import type { SidecarCommand, SidecarEvent } from '../protocol';
import { createSidecarConnector } from '../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
@@ -1,85 +0,0 @@
// 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
@@ -1,753 +0,0 @@
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
@@ -1,232 +0,0 @@
// 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 'officerdb';
/** 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' };
}
}