jellyfin sidecar: server registry, video façade and byte pass-through
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=<access token> 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<Response> {
|
||||
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<void> {
|
||||
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<ServerBody> =>
|
||||
((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<Response> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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 '/<id>', '/<id>/activate', '/<id>/test'. */
|
||||
export async function handleConfigRoute(req: Request, userId: number, subpath: string): Promise<Response> {
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user