the decision and the execution existed; nothing called them. connecting a server would have given a working media connection and zero accounts. the trigger is registering an ADMIN server, not installing the plugin. at install there is no credential, so there is nothing to provision with — and this is also the right moment for a re-run, since a revoked token or a moved server both come back through the same door. the sidecar now holds the pushed account list in memory. each users:list REPLACES what was held rather than merging: it is the platform's current truth, not a cache. persisting it would create a second copy of the platform's users that could disagree with it, and the recovery from disagreement is re-reading the platform anyway. `hasProvisionableUsers()` distinguishes never-told from told-there-is-nobody. an empty list means a server with only an owner; no list at all means the push has not arrived or the plugin is not entitled. provisioning nothing is right for the first and a silent failure for the second. awaited, so the response says what happened instead of the owner finding it in a log later. and it never throws: registering the server already succeeded by then, and losing it to a provisioning error would leave them with no media access AND no accounts, which is worse than the accounts alone. mapping rows are written one at a time as each result is known, not batched at the end. a create that succeeds upstream but whose row fails to write leaves an orphan account officer will try to create again — the narrower that window, the better. found while wiring: the first edit landed the block in editServer as well as addServer, where `auth` does not exist. caught by tsgo, removed.
182 lines
8.2 KiB
TypeScript
182 lines
8.2 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';
|
|
|
|
// 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
|
|
// 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);
|
|
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'));
|