import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol'; import { createSidecarConnector } from '@@/sidecar/connect'; import { handleConfigRoute, noteProbe } from './config'; import { handleBytesRoute, handleOfficerRoute } from './routes'; import { getConfig, probe } from './upstream'; import { API_URL } from '@@/officer-url.mjs'; import { setProvisionableUsers } from './user-list'; import { provisionForAccountChange } from './provision-run'; // 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/provision re-run account provisioning and mint any missing member tokens. // `{ includeAdopted }` opts into resetting the password of an // account that pre-dated Officer — off unless asked for // 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; // The platform's account list, pushed because this plugin's manifest declares `userProvisioning`. // Arrives on registration and again on every account create or delete. No reply — it is a statement, // not a question. case 'users:list': setProvisionableUsers(cmd.users); // And act on it. Detached deliberately: this reaches out to Jellyfin and can take seconds, while the // command handler is on the socket the platform is waiting on. Nothing replies to `users:list`, so // there is no answer to delay — but blocking here would stall every later message on this socket. void provisionForAccountChange(); 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', 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'));