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>
247 lines
9.8 KiB
TypeScript
247 lines
9.8 KiB
TypeScript
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);
|
|
}
|