Same gap as the photos plugin: users:list updated an in-memory array and nothing acted on it, so a person added to Officer later reached Video only if somebody re-ran provisioning by hand. The platform broadcasts on every create and delete and re-sends on every registration — nothing was listening. Safe to run unattended without changing what it does, because runProvisioning already draws the line in the right place: it creates the missing accounts and tokens them, and it RECORDS an adopted account without touching it. Minting for an adopted account resets a password its owner knows, and that stays behind includeAdopted, which only a person can ask for. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
190 lines
8.9 KiB
TypeScript
190 lines
8.9 KiB
TypeScript
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<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'));
|