From 904edefd62ec1800a8265e8e3cdea0425f5434ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 4 Aug 2026 17:59:50 +0000 Subject: [PATCH] =?UTF-8?q?jellyfin=20sidecar:=20server=20registry,=20vide?= =?UTF-8?q?o=20fa=C3=A7ade=20and=20byte=20pass-through?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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= 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 --- ecosystem.config.cjs | 9 + ecosystem.light.config.cjs | 1 + ecosystem.mac.light.config.cjs | 1 + src/databases/officer_db/src/index.ts | 11 + .../officer_db/src/queries/jellyfin.ts | 204 ++++++++ src/databases/officer_db/src/schema/index.ts | 1 + .../officer_db/src/schema/jellyfin.ts | 64 +++ src/servers/api/jellyfin/router.ts | 21 + src/servers/hono.ts | 2 + src/servers/sidecar/jellyfin/config.ts | 246 +++++++++ src/servers/sidecar/jellyfin/index.ts | 175 +++++++ src/servers/sidecar/jellyfin/profile.ts | 76 +++ src/servers/sidecar/jellyfin/routes.ts | 470 ++++++++++++++++++ src/servers/sidecar/jellyfin/upstream.ts | 232 +++++++++ src/servers/sidecar/protocol.ts | 2 + 15 files changed, 1515 insertions(+) create mode 100644 src/databases/officer_db/src/queries/jellyfin.ts create mode 100644 src/databases/officer_db/src/schema/jellyfin.ts create mode 100644 src/servers/api/jellyfin/router.ts create mode 100644 src/servers/sidecar/jellyfin/config.ts create mode 100644 src/servers/sidecar/jellyfin/index.ts create mode 100644 src/servers/sidecar/jellyfin/profile.ts create mode 100644 src/servers/sidecar/jellyfin/routes.ts create mode 100644 src/servers/sidecar/jellyfin/upstream.ts diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index 12b4f4d8..130e7018 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -92,6 +92,15 @@ module.exports = { args: 'run src/servers/sidecar/invoiceshelf/index.ts', watch: false, }, + // Video. Wraps a self-hosted Jellyfin. Servers, and the access token each one is signed in with, are set + // by the owner from /jellyfin and stored encrypted in `jellyfin_servers` — read here, never from the + // environment. Video only: Officer's own player owns audio. + { + name: 'officer-jellyfin', + script: 'bun', + args: 'run src/servers/sidecar/jellyfin/index.ts', + watch: false, + }, // Notes. Wraps a self-hosted Memos. The instance URL and its personal access token are set by the // owner from the UI and stored in `service_connections` — read here, never from the environment. { diff --git a/ecosystem.light.config.cjs b/ecosystem.light.config.cjs index 2d9ba2c8..969e1321 100644 --- a/ecosystem.light.config.cjs +++ b/ecosystem.light.config.cjs @@ -39,6 +39,7 @@ module.exports = defineProfile({ 'officer-headscale': 'fronts a headscale server', 'officer-transmission': 'fronts a transmission daemon', 'officer-invoiceshelf': 'fronts an InvoiceShelf container', + 'officer-jellyfin': 'fronts a Jellyfin container', 'officer-memos': 'needs an owner-configured Memos instance URL and token', 'officer-photos': 'needs an owner-configured Immich instance URL and API key', 'officer-caldav': 'supervises Radicale, which the light profile does not install', diff --git a/ecosystem.mac.light.config.cjs b/ecosystem.mac.light.config.cjs index 1a352c3e..8a6c3dfe 100644 --- a/ecosystem.mac.light.config.cjs +++ b/ecosystem.mac.light.config.cjs @@ -49,6 +49,7 @@ module.exports = defineProfile({ 'officer-headscale': 'fronts a headscale server', 'officer-transmission': 'fronts a transmission daemon', 'officer-invoiceshelf': 'fronts an InvoiceShelf container', + 'officer-jellyfin': 'fronts a Jellyfin container', // Needs an owner-configured external service. 'officer-memos': 'needs an owner-configured Memos instance URL and token', diff --git a/src/databases/officer_db/src/index.ts b/src/databases/officer_db/src/index.ts index 3a5f3194..446f7c20 100644 --- a/src/databases/officer_db/src/index.ts +++ b/src/databases/officer_db/src/index.ts @@ -152,6 +152,17 @@ export { recordInvoiceshelfProbe, } from './queries/invoiceshelf'; export type { InvoiceshelfAccount, InvoiceshelfCredentials } from './queries/invoiceshelf'; +export { + listJellyfinServers, + getActiveJellyfinCredentials, + getJellyfinCredentials, + createJellyfinServer, + updateJellyfinServer, + setActiveJellyfinServer, + deleteJellyfinServer, + recordJellyfinProbe, +} from './queries/jellyfin'; +export type { JellyfinServer, JellyfinCredentials } from './queries/jellyfin'; export { listPhotosAccounts, getActivePhotosCredentials, diff --git a/src/databases/officer_db/src/queries/jellyfin.ts b/src/databases/officer_db/src/queries/jellyfin.ts new file mode 100644 index 00000000..00e6650b --- /dev/null +++ b/src/databases/officer_db/src/queries/jellyfin.ts @@ -0,0 +1,204 @@ +import { eq, and, desc } from 'drizzle-orm'; +import { db } from '../db'; +import { jellyfinServers } from '../schema'; +import { encryptSecret, decryptSecret } from '../crypto'; + +// Jellyfin server registry for the officer-jellyfin sidecar. Callers deal in PLAINTEXT — encryption to and +// from at-rest ciphertext happens here. See ../crypto.ts and ../schema/jellyfin.ts. +// +// Two return types, and the split is the safety property: +// JellyfinServer — safe to serialize to the browser. Has NO token field at all, not even masked. +// JellyfinCredentials — url + decrypted token + the pinned Jellyfin user, for the sidecar's own calls. +// `serverCols` is what enforces it: a bare `select()` would put the ciphertext column into every list +// response the moment someone forgot to strip it. + +export type JellyfinServer = { + id: number; + label: string; + url: string; + jellyfinUserId: string; + jellyfinUsername: string | null; + serverName: string | null; + version: string | null; + isActive: boolean; + lastSeenAt: Date | null; + createdAt: Date; +}; + +export type JellyfinCredentials = { + id: number; + label: string; + url: string; + accessToken: string; + jellyfinUserId: string; + deviceId: string; +}; + +const serverCols = { + id: jellyfinServers.id, + label: jellyfinServers.label, + url: jellyfinServers.url, + jellyfinUserId: jellyfinServers.jellyfinUserId, + jellyfinUsername: jellyfinServers.jellyfinUsername, + serverName: jellyfinServers.serverName, + version: jellyfinServers.version, + isActive: jellyfinServers.isActive, + lastSeenAt: jellyfinServers.lastSeenAt, + createdAt: jellyfinServers.createdAt, +}; + +const toCredentials = (row: typeof jellyfinServers.$inferSelect): JellyfinCredentials => ({ + id: row.id, + label: row.label, + url: row.url, + accessToken: decryptSecret(row.accessToken), + jellyfinUserId: row.jellyfinUserId, + deviceId: row.deviceId, +}); + +/** Every server the owner has added, active first then newest. Never includes the token. */ +export async function listJellyfinServers(userId: number): Promise { + return db + .select(serverCols) + .from(jellyfinServers) + .where(eq(jellyfinServers.userId, userId)) + .orderBy(desc(jellyfinServers.isActive), desc(jellyfinServers.createdAt)); +} + +/** The selected server with its token decrypted, or null when none is added. */ +export async function getActiveJellyfinCredentials(userId: number): Promise { + const [row] = await db + .select() + .from(jellyfinServers) + .where(and(eq(jellyfinServers.userId, userId), eq(jellyfinServers.isActive, true))); + return row ? toCredentials(row) : null; +} + +/** One server's credentials by id — for probing a specific server rather than the active one. */ +export async function getJellyfinCredentials(userId: number, id: number): Promise { + const [row] = await db + .select() + .from(jellyfinServers) + .where(and(eq(jellyfinServers.userId, userId), eq(jellyfinServers.id, id))); + return row ? toCredentials(row) : null; +} + +type CreateJellyfinServerParams = { + userId: number; + label: string; + url: string; + accessToken: string; + jellyfinUserId: string; + jellyfinUsername: string | null; + deviceId: string; + serverName: string | null; + version: string | null; + /** Select it. True for the first server, so the UI is never left with servers added but none chosen. */ + activate: boolean; +}; + +/** Add a server. The token is encrypted before write; the returned row carries no token. */ +export async function createJellyfinServer(params: CreateJellyfinServerParams): Promise { + const { userId, activate, accessToken, ...rest } = params; + return db.transaction(async (tx) => { + if (activate) { + await tx + .update(jellyfinServers) + .set({ isActive: false, updatedAt: new Date() }) + .where(and(eq(jellyfinServers.userId, userId), eq(jellyfinServers.isActive, true))); + } + const [row] = await tx + .insert(jellyfinServers) + .values({ + userId, + ...rest, + accessToken: encryptSecret(accessToken), + isActive: activate, + lastSeenAt: rest.version ? new Date() : null, + }) + .returning(serverCols); + return row!; + }); +} + +type UpdateJellyfinServerParams = { + label?: string; + url?: string; + accessToken?: string; + jellyfinUserId?: string; + jellyfinUsername?: string | null; + serverName?: string | null; + version?: string | null; +}; + +/** Edit a server in place. Omitting `accessToken` keeps the stored one — re-authenticating replaces it. */ +export async function updateJellyfinServer( + userId: number, + id: number, + params: UpdateJellyfinServerParams, +): Promise { + const { accessToken, ...rest } = params; + const [row] = await db + .update(jellyfinServers) + .set({ + ...rest, + ...(accessToken ? { accessToken: encryptSecret(accessToken) } : {}), + updatedAt: new Date(), + }) + .where(and(eq(jellyfinServers.userId, userId), eq(jellyfinServers.id, id))) + .returning(serverCols); + return row ?? null; +} + +/** Switch the selected server. Clearing the others and setting this one is one transaction, never two. */ +export async function setActiveJellyfinServer(userId: number, id: number): Promise { + return db.transaction(async (tx) => { + await tx + .update(jellyfinServers) + .set({ isActive: false, updatedAt: new Date() }) + .where(and(eq(jellyfinServers.userId, userId), eq(jellyfinServers.isActive, true))); + const [row] = await tx + .update(jellyfinServers) + .set({ isActive: true, updatedAt: new Date() }) + .where(and(eq(jellyfinServers.userId, userId), eq(jellyfinServers.id, id))) + .returning(serverCols); + return row ?? null; + }); +} + +/** + * Remove a server. If it was the selected one, the newest remaining server takes its place — leaving the + * owner with servers registered but none active would show the setup form over a working registry. + */ +export async function deleteJellyfinServer(userId: number, id: number): Promise { + return db.transaction(async (tx) => { + const [gone] = await tx + .delete(jellyfinServers) + .where(and(eq(jellyfinServers.userId, userId), eq(jellyfinServers.id, id))) + .returning({ id: jellyfinServers.id, wasActive: jellyfinServers.isActive }); + if (!gone) return false; + if (gone.wasActive) { + const [next] = await tx + .select({ id: jellyfinServers.id }) + .from(jellyfinServers) + .where(eq(jellyfinServers.userId, userId)) + .orderBy(desc(jellyfinServers.createdAt)) + .limit(1); + if (next) { + await tx + .update(jellyfinServers) + .set({ isActive: true, updatedAt: new Date() }) + .where(eq(jellyfinServers.id, next.id)); + } + } + return true; + }); +} + +/** Stamp a successful probe: what version answered, and when. Failures deliberately leave the row alone. */ +export async function recordJellyfinProbe(userId: number, id: number, version: string | null): Promise { + await db + .update(jellyfinServers) + .set({ version, lastSeenAt: new Date(), updatedAt: new Date() }) + .where(and(eq(jellyfinServers.userId, userId), eq(jellyfinServers.id, id))); +} diff --git a/src/databases/officer_db/src/schema/index.ts b/src/databases/officer_db/src/schema/index.ts index 113dd238..ff09fa88 100644 --- a/src/databases/officer_db/src/schema/index.ts +++ b/src/databases/officer_db/src/schema/index.ts @@ -5,6 +5,7 @@ export * from './dav'; export * from './email'; export * from './headscale'; export * from './invoiceshelf'; +export * from './jellyfin'; export * from './music'; export * from './notify'; export * from './operations'; diff --git a/src/databases/officer_db/src/schema/jellyfin.ts b/src/databases/officer_db/src/schema/jellyfin.ts new file mode 100644 index 00000000..466cd59f --- /dev/null +++ b/src/databases/officer_db/src/schema/jellyfin.ts @@ -0,0 +1,64 @@ +import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'; +import { sql } from 'drizzle-orm'; +import { users } from './auth'; + +// The Jellyfin servers behind /jellyfin, for the officer-jellyfin sidecar. +// +// A REGISTRY rather than a single row, and here that is not speculative: this machine runs four Jellyfin +// instances on separate ports (video, albums, DJ sets, and a second person's), and the owner switches +// between them. Same shape as invoiceshelf_accounts and headscale_servers. +// +// `accessToken` is encrypted at rest via ../crypto.ts. A Jellyfin access token can read every item, every +// watch history and every stream on the instance, so a DB dump must not hand it over. Encryption is confined +// to queries/jellyfin.ts; nothing outside that file sees ciphertext, and no route ever returns the token. +// +// The PASSWORD is never stored. It is used once, by the sidecar, to trade for an access token through +// `POST /Users/AuthenticateByName` — the same shape as InvoiceShelf's mintToken. +export const jellyfinServers = pgTable( + 'jellyfin_servers', + { + id: serial('id').primaryKey(), + userId: integer('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + /** What the owner calls this server. The switcher shows nothing else, so it has to be theirs to set. */ + label: text('label').notNull(), + // Normalized without a trailing slash before write, so `${url}/Items/...` never doubles the separator. + url: text('url').notNull(), + accessToken: text('access_token').notNull(), // encrypted + /** + * The Jellyfin user the token belongs to, pinned on the row. + * + * Jellyfin's per-user routes (`/UserItems/Resume`, played state, favourites) need this id explicitly, and + * it is NOT derivable from the token without a round trip. Authenticating returns it, so it is stored + * once rather than looked up on every request. + */ + jellyfinUserId: text('jellyfin_user_id').notNull(), + /** The account name that was authenticated — shown in the UI so two logins on one server are told apart. */ + jellyfinUsername: text('jellyfin_username'), + /** + * The DeviceId sent on every call, generated once when the server is added. + * + * Jellyfin keys sessions and "remembered devices" by this. A fresh one per request would litter the + * dashboard with hundreds of devices and break playback reporting, which correlates by session. + */ + deviceId: text('device_id').notNull(), + /** The instance's own name (`ServerName` from /System/Info/Public), for the switcher. */ + serverName: text('server_name'), + /** Jellyfin version seen at the last successful probe — shown in the UI, never used for behaviour. */ + version: text('version'), + isActive: boolean('is_active').notNull().default(false), + lastSeenAt: timestamp('last_seen_at', { withTimezone: true }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + uniqueIndex('uq_jellyfin_servers_user_label').on(t.userId, t.label), + // At most one active server per owner, enforced by the DB rather than by convention: a partial unique + // index over the active rows only. setActiveJellyfinServer still clears the others in a transaction, but + // a bug there fails loudly here instead of silently leaving two active and the UI picking one. + uniqueIndex('uq_jellyfin_servers_one_active') + .on(t.userId) + .where(sql`${t.isActive}`), + ], +); diff --git a/src/servers/api/jellyfin/router.ts b/src/servers/api/jellyfin/router.ts new file mode 100644 index 00000000..db8a6dd3 --- /dev/null +++ b/src/servers/api/jellyfin/router.ts @@ -0,0 +1,21 @@ +import { createSidecarProxy } from '../../sidecar/create-proxy'; + +// /api/jellyfin/* — auth, then forward to officer-jellyfin. No routes of its own and no Jellyfin knowledge: +// this file must never grow app logic. +// +// The sidecar owns the Jellyfin contract and holds its credentials. +// +// `timeoutSeconds` is generous because this proxy carries VIDEO. A direct-play stream holds one connection +// open for the length of the film, and the first request against a fresh transcode waits on ffmpeg starting +// up. The default 60s idle drop would cut both. + +const proxy = createSidecarProxy({ + name: 'jellyfin', + prefix: '/api/jellyfin', + timeoutSeconds: 3600, +}); + +export const jellyfinRouter = proxy.router; + +/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */ +export const getJellyfinServerUrl = proxy.getHttpUrl; diff --git a/src/servers/hono.ts b/src/servers/hono.ts index 4caa81a1..94a751c6 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -26,6 +26,7 @@ import { slskdRouter } from './api/slskd/router'; import { headscaleRouter } from './api/headscale/router'; import { transmissionRouter } from './api/transmission/router'; import { invoiceshelfRouter } from './api/invoiceshelf/router'; +import { jellyfinRouter } from './api/jellyfin/router'; import { photosRouter } from './api/photos/router'; import { walletRouter } from './api/wallet/router'; import { vpnRouter } from './api/vpn/router'; @@ -169,6 +170,7 @@ protectedRouter.route('/notify', notifyRouter); protectedRouter.route('/headscale', headscaleRouter); protectedRouter.route('/transmission', transmissionRouter); protectedRouter.route('/invoiceshelf', invoiceshelfRouter); +protectedRouter.route('/jellyfin', jellyfinRouter); protectedRouter.route('/photos', photosRouter); protectedRouter.route('/wallet', walletRouter); protectedRouter.route('/vpn', vpnRouter); diff --git a/src/servers/sidecar/jellyfin/config.ts b/src/servers/sidecar/jellyfin/config.ts new file mode 100644 index 00000000..fe292cb6 --- /dev/null +++ b/src/servers/sidecar/jellyfin/config.ts @@ -0,0 +1,246 @@ +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 { + 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 { + 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 => + ((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 { + 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 { + 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 { + 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 '/', '//activate', '//test'. */ +export async function handleConfigRoute(req: Request, userId: number, subpath: string): Promise { + 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); +} diff --git a/src/servers/sidecar/jellyfin/index.ts b/src/servers/sidecar/jellyfin/index.ts new file mode 100644 index 00000000..35c85f03 --- /dev/null +++ b/src/servers/sidecar/jellyfin/index.ts @@ -0,0 +1,175 @@ +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'; + +// 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 +// POST /_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, isHls, 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. +// ───────────────────────────────────────────────────────────────────────────────────────────────── + +const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`; + +/** 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).type}`, + }); + } +} + +const connection = createSidecarConnector({ + apiUrl: `${API_URL}/api/sidecar/register`, + name: 'jellyfin', + capabilities: ['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')); diff --git a/src/servers/sidecar/jellyfin/profile.ts b/src/servers/sidecar/jellyfin/profile.ts new file mode 100644 index 00000000..222f9dbe --- /dev/null +++ b/src/servers/sidecar/jellyfin/profile.ts @@ -0,0 +1,76 @@ +// 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 modern Chromium/WebKit `