// 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, getLinkedJellyfinCredentials } 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(); /** * What THIS account reads Jellyfin with, or null when there is nothing — the sidecar then answers 503, and * the UI turns that into the setup form rather than a screen of empty shelves. * * Two sources, in this order: * * 1. **A server this account registered itself.** The owner's normal case, and also a member who * deliberately connected a Jellyfin of their own. Checked first so that a person who went to the * trouble of adding one is not overridden by an inherited link. * 2. **A provisioned link on the owner's active server**, carrying that person's OWN token. This is how * a member reaches the library without ever holding a credential or seeing a setup form. * * The second source is what makes `userProvisioning` mean anything. Before 2026-08-21 it did not exist, * so every member fell through to null and got a 503 telling them to connect a Jellyfin server — advice * they could not act on, since the account Officer made for them has a password nobody knows. */ export async function getConfig(userId: number): Promise { const hit = cache.get(userId); if (hit && Date.now() - hit.at < TTL_MS) return hit.cfg; const creds = (await getActiveJellyfinCredentials(userId)) ?? (await getLinkedJellyfinCredentials(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 { 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 { 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 { const headers: Record = { 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 { 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' }; } }