Eighth plugin out, and the second resurrection: like soulseek, hono.ts carried the router import and its mount commented out since 2026-08-13, with the permission reduced to api: [], so the server booted clean while every panel request 404'd. jellyfin_servers was also absent from the drizzle barrel, so bun db:push would have dropped it. Easier than soulseek in one specific way: mountPrefix derives /jellyfin from the appName, which is exactly what the router already hardcoded, so no frontend call site needed repointing. Soulseek needed 47 because its appName and its old prefix differed. The frontend also already routed through one helper, so there were five call sites rather than forty-seven. Self-contained, unlike transmission and soulseek: jellyfin_servers is a registry this plugin owns outright — label, URL, encrypted token, one active — because more than one Jellyfin is a thing someone has, and more than one Transmission is not. Carries its own .prettierignore for the vendored hls.mjs, rather than leaving a stale rule behind in the platform naming a path that no longer exists.
250 lines
10 KiB
TypeScript
250 lines
10 KiB
TypeScript
import type { JellyfinServer } from '../db';
|
|
import {
|
|
createJellyfinServer,
|
|
deleteJellyfinServer,
|
|
getJellyfinCredentials,
|
|
listJellyfinServers,
|
|
recordJellyfinProbe,
|
|
setActiveJellyfinServer,
|
|
updateJellyfinServer,
|
|
} from '../db';
|
|
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);
|
|
}
|
|
|
|
// GET only. It probes one server and reports whether it answered — nothing is written, and nothing is
|
|
// switched (that is `activate`, below). It accepted POST as well until 2026-08-06, which made the
|
|
// method meaningless for deciding whether this is a read; the permission model needs that answer.
|
|
if (action === 'test') {
|
|
if (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);
|
|
}
|