Files
platform/src/servers/sidecar/jellyfin/config.ts
T
pastilhasandClaude Opus 5 904edefd62 jellyfin sidecar: server registry, video façade and byte pass-through
officer-jellyfin owns the whole Jellyfin contract: the instance URL, the access
token, the Jellyfin user it belongs to and the DeviceId its sessions are keyed
by. The platform side is a 17-line proxy holding no credentials.

Servers are a registry, not a single row — this machine runs four instances and
the owner switches between them. The password is never stored: it is traded once
for an access token through AuthenticateByName, and only that token is persisted,
encrypted.

Two doors. /_officer/* is a hand-written JSON façade for the things the browser
should not have to know — the user id in the path, the Fields lists that decide
whether a grid has posters, the PlaybackInfo negotiation. /_jf/* is a GET-only,
allow-listed byte pass-through for images, video, HLS and subtitles; it keeps
Jellyfin's own paths because a master playlist references its segments
relatively, so any renaming would mean rewriting m3u8 bodies.

TranscodingUrl arrives with api_key=<access token> in its query string and would
otherwise be handed straight to a video element. It is stripped before anything
is returned; the pass-through re-adds the credential as a header.

Video only — Officer's own player owns audio, so music collections are filtered
out of the library list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 17:59:50 +00:00

247 lines
9.8 KiB
TypeScript

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);
}
if (action === 'test') {
if (req.method !== 'POST' && 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);
}