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:
@@ -92,6 +92,15 @@ module.exports = {
|
|||||||
args: 'run src/servers/sidecar/invoiceshelf/index.ts',
|
args: 'run src/servers/sidecar/invoiceshelf/index.ts',
|
||||||
watch: false,
|
watch: false,
|
||||||
},
|
},
|
||||||
|
// Video. Wraps a self-hosted Jellyfin. Servers, and the access token each one is signed in with, are set
|
||||||
|
// by the owner from /jellyfin and stored encrypted in `jellyfin_servers` — read here, never from the
|
||||||
|
// environment. Video only: Officer's own player owns audio.
|
||||||
|
{
|
||||||
|
name: 'officer-jellyfin',
|
||||||
|
script: 'bun',
|
||||||
|
args: 'run src/servers/sidecar/jellyfin/index.ts',
|
||||||
|
watch: false,
|
||||||
|
},
|
||||||
// Notes. Wraps a self-hosted Memos. The instance URL and its personal access token are set by the
|
// Notes. Wraps a self-hosted Memos. The instance URL and its personal access token are set by the
|
||||||
// owner from the UI and stored in `service_connections` — read here, never from the environment.
|
// owner from the UI and stored in `service_connections` — read here, never from the environment.
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ module.exports = defineProfile({
|
|||||||
'officer-headscale': 'fronts a headscale server',
|
'officer-headscale': 'fronts a headscale server',
|
||||||
'officer-transmission': 'fronts a transmission daemon',
|
'officer-transmission': 'fronts a transmission daemon',
|
||||||
'officer-invoiceshelf': 'fronts an InvoiceShelf container',
|
'officer-invoiceshelf': 'fronts an InvoiceShelf container',
|
||||||
|
'officer-jellyfin': 'fronts a Jellyfin container',
|
||||||
'officer-memos': 'needs an owner-configured Memos instance URL and token',
|
'officer-memos': 'needs an owner-configured Memos instance URL and token',
|
||||||
'officer-photos': 'needs an owner-configured Immich instance URL and API key',
|
'officer-photos': 'needs an owner-configured Immich instance URL and API key',
|
||||||
'officer-caldav': 'supervises Radicale, which the light profile does not install',
|
'officer-caldav': 'supervises Radicale, which the light profile does not install',
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ module.exports = defineProfile({
|
|||||||
'officer-headscale': 'fronts a headscale server',
|
'officer-headscale': 'fronts a headscale server',
|
||||||
'officer-transmission': 'fronts a transmission daemon',
|
'officer-transmission': 'fronts a transmission daemon',
|
||||||
'officer-invoiceshelf': 'fronts an InvoiceShelf container',
|
'officer-invoiceshelf': 'fronts an InvoiceShelf container',
|
||||||
|
'officer-jellyfin': 'fronts a Jellyfin container',
|
||||||
|
|
||||||
// Needs an owner-configured external service.
|
// Needs an owner-configured external service.
|
||||||
'officer-memos': 'needs an owner-configured Memos instance URL and token',
|
'officer-memos': 'needs an owner-configured Memos instance URL and token',
|
||||||
|
|||||||
@@ -152,6 +152,17 @@ export {
|
|||||||
recordInvoiceshelfProbe,
|
recordInvoiceshelfProbe,
|
||||||
} from './queries/invoiceshelf';
|
} from './queries/invoiceshelf';
|
||||||
export type { InvoiceshelfAccount, InvoiceshelfCredentials } from './queries/invoiceshelf';
|
export type { InvoiceshelfAccount, InvoiceshelfCredentials } from './queries/invoiceshelf';
|
||||||
|
export {
|
||||||
|
listJellyfinServers,
|
||||||
|
getActiveJellyfinCredentials,
|
||||||
|
getJellyfinCredentials,
|
||||||
|
createJellyfinServer,
|
||||||
|
updateJellyfinServer,
|
||||||
|
setActiveJellyfinServer,
|
||||||
|
deleteJellyfinServer,
|
||||||
|
recordJellyfinProbe,
|
||||||
|
} from './queries/jellyfin';
|
||||||
|
export type { JellyfinServer, JellyfinCredentials } from './queries/jellyfin';
|
||||||
export {
|
export {
|
||||||
listPhotosAccounts,
|
listPhotosAccounts,
|
||||||
getActivePhotosCredentials,
|
getActivePhotosCredentials,
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import { eq, and, desc } from 'drizzle-orm';
|
||||||
|
import { db } from '../db';
|
||||||
|
import { jellyfinServers } from '../schema';
|
||||||
|
import { encryptSecret, decryptSecret } from '../crypto';
|
||||||
|
|
||||||
|
// Jellyfin server registry for the officer-jellyfin sidecar. Callers deal in PLAINTEXT — encryption to and
|
||||||
|
// from at-rest ciphertext happens here. See ../crypto.ts and ../schema/jellyfin.ts.
|
||||||
|
//
|
||||||
|
// Two return types, and the split is the safety property:
|
||||||
|
// JellyfinServer — safe to serialize to the browser. Has NO token field at all, not even masked.
|
||||||
|
// JellyfinCredentials — url + decrypted token + the pinned Jellyfin user, for the sidecar's own calls.
|
||||||
|
// `serverCols` is what enforces it: a bare `select()` would put the ciphertext column into every list
|
||||||
|
// response the moment someone forgot to strip it.
|
||||||
|
|
||||||
|
export type JellyfinServer = {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
jellyfinUserId: string;
|
||||||
|
jellyfinUsername: string | null;
|
||||||
|
serverName: string | null;
|
||||||
|
version: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
lastSeenAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type JellyfinCredentials = {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
accessToken: string;
|
||||||
|
jellyfinUserId: string;
|
||||||
|
deviceId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const serverCols = {
|
||||||
|
id: jellyfinServers.id,
|
||||||
|
label: jellyfinServers.label,
|
||||||
|
url: jellyfinServers.url,
|
||||||
|
jellyfinUserId: jellyfinServers.jellyfinUserId,
|
||||||
|
jellyfinUsername: jellyfinServers.jellyfinUsername,
|
||||||
|
serverName: jellyfinServers.serverName,
|
||||||
|
version: jellyfinServers.version,
|
||||||
|
isActive: jellyfinServers.isActive,
|
||||||
|
lastSeenAt: jellyfinServers.lastSeenAt,
|
||||||
|
createdAt: jellyfinServers.createdAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
const toCredentials = (row: typeof jellyfinServers.$inferSelect): JellyfinCredentials => ({
|
||||||
|
id: row.id,
|
||||||
|
label: row.label,
|
||||||
|
url: row.url,
|
||||||
|
accessToken: decryptSecret(row.accessToken),
|
||||||
|
jellyfinUserId: row.jellyfinUserId,
|
||||||
|
deviceId: row.deviceId,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Every server the owner has added, active first then newest. Never includes the token. */
|
||||||
|
export async function listJellyfinServers(userId: number): Promise<JellyfinServer[]> {
|
||||||
|
return db
|
||||||
|
.select(serverCols)
|
||||||
|
.from(jellyfinServers)
|
||||||
|
.where(eq(jellyfinServers.userId, userId))
|
||||||
|
.orderBy(desc(jellyfinServers.isActive), desc(jellyfinServers.createdAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The selected server with its token decrypted, or null when none is added. */
|
||||||
|
export async function getActiveJellyfinCredentials(userId: number): Promise<JellyfinCredentials | null> {
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(jellyfinServers)
|
||||||
|
.where(and(eq(jellyfinServers.userId, userId), eq(jellyfinServers.isActive, true)));
|
||||||
|
return row ? toCredentials(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One server's credentials by id — for probing a specific server rather than the active one. */
|
||||||
|
export async function getJellyfinCredentials(userId: number, id: number): Promise<JellyfinCredentials | null> {
|
||||||
|
const [row] = await db
|
||||||
|
.select()
|
||||||
|
.from(jellyfinServers)
|
||||||
|
.where(and(eq(jellyfinServers.userId, userId), eq(jellyfinServers.id, id)));
|
||||||
|
return row ? toCredentials(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateJellyfinServerParams = {
|
||||||
|
userId: number;
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
accessToken: string;
|
||||||
|
jellyfinUserId: string;
|
||||||
|
jellyfinUsername: string | null;
|
||||||
|
deviceId: string;
|
||||||
|
serverName: string | null;
|
||||||
|
version: string | null;
|
||||||
|
/** Select it. True for the first server, so the UI is never left with servers added but none chosen. */
|
||||||
|
activate: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Add a server. The token is encrypted before write; the returned row carries no token. */
|
||||||
|
export async function createJellyfinServer(params: CreateJellyfinServerParams): Promise<JellyfinServer> {
|
||||||
|
const { userId, activate, accessToken, ...rest } = params;
|
||||||
|
return db.transaction(async (tx) => {
|
||||||
|
if (activate) {
|
||||||
|
await tx
|
||||||
|
.update(jellyfinServers)
|
||||||
|
.set({ isActive: false, updatedAt: new Date() })
|
||||||
|
.where(and(eq(jellyfinServers.userId, userId), eq(jellyfinServers.isActive, true)));
|
||||||
|
}
|
||||||
|
const [row] = await tx
|
||||||
|
.insert(jellyfinServers)
|
||||||
|
.values({
|
||||||
|
userId,
|
||||||
|
...rest,
|
||||||
|
accessToken: encryptSecret(accessToken),
|
||||||
|
isActive: activate,
|
||||||
|
lastSeenAt: rest.version ? new Date() : null,
|
||||||
|
})
|
||||||
|
.returning(serverCols);
|
||||||
|
return row!;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateJellyfinServerParams = {
|
||||||
|
label?: string;
|
||||||
|
url?: string;
|
||||||
|
accessToken?: string;
|
||||||
|
jellyfinUserId?: string;
|
||||||
|
jellyfinUsername?: string | null;
|
||||||
|
serverName?: string | null;
|
||||||
|
version?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Edit a server in place. Omitting `accessToken` keeps the stored one — re-authenticating replaces it. */
|
||||||
|
export async function updateJellyfinServer(
|
||||||
|
userId: number,
|
||||||
|
id: number,
|
||||||
|
params: UpdateJellyfinServerParams,
|
||||||
|
): Promise<JellyfinServer | null> {
|
||||||
|
const { accessToken, ...rest } = params;
|
||||||
|
const [row] = await db
|
||||||
|
.update(jellyfinServers)
|
||||||
|
.set({
|
||||||
|
...rest,
|
||||||
|
...(accessToken ? { accessToken: encryptSecret(accessToken) } : {}),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(and(eq(jellyfinServers.userId, userId), eq(jellyfinServers.id, id)))
|
||||||
|
.returning(serverCols);
|
||||||
|
return row ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Switch the selected server. Clearing the others and setting this one is one transaction, never two. */
|
||||||
|
export async function setActiveJellyfinServer(userId: number, id: number): Promise<JellyfinServer | null> {
|
||||||
|
return db.transaction(async (tx) => {
|
||||||
|
await tx
|
||||||
|
.update(jellyfinServers)
|
||||||
|
.set({ isActive: false, updatedAt: new Date() })
|
||||||
|
.where(and(eq(jellyfinServers.userId, userId), eq(jellyfinServers.isActive, true)));
|
||||||
|
const [row] = await tx
|
||||||
|
.update(jellyfinServers)
|
||||||
|
.set({ isActive: true, updatedAt: new Date() })
|
||||||
|
.where(and(eq(jellyfinServers.userId, userId), eq(jellyfinServers.id, id)))
|
||||||
|
.returning(serverCols);
|
||||||
|
return row ?? null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a server. If it was the selected one, the newest remaining server takes its place — leaving the
|
||||||
|
* owner with servers registered but none active would show the setup form over a working registry.
|
||||||
|
*/
|
||||||
|
export async function deleteJellyfinServer(userId: number, id: number): Promise<boolean> {
|
||||||
|
return db.transaction(async (tx) => {
|
||||||
|
const [gone] = await tx
|
||||||
|
.delete(jellyfinServers)
|
||||||
|
.where(and(eq(jellyfinServers.userId, userId), eq(jellyfinServers.id, id)))
|
||||||
|
.returning({ id: jellyfinServers.id, wasActive: jellyfinServers.isActive });
|
||||||
|
if (!gone) return false;
|
||||||
|
if (gone.wasActive) {
|
||||||
|
const [next] = await tx
|
||||||
|
.select({ id: jellyfinServers.id })
|
||||||
|
.from(jellyfinServers)
|
||||||
|
.where(eq(jellyfinServers.userId, userId))
|
||||||
|
.orderBy(desc(jellyfinServers.createdAt))
|
||||||
|
.limit(1);
|
||||||
|
if (next) {
|
||||||
|
await tx
|
||||||
|
.update(jellyfinServers)
|
||||||
|
.set({ isActive: true, updatedAt: new Date() })
|
||||||
|
.where(eq(jellyfinServers.id, next.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stamp a successful probe: what version answered, and when. Failures deliberately leave the row alone. */
|
||||||
|
export async function recordJellyfinProbe(userId: number, id: number, version: string | null): Promise<void> {
|
||||||
|
await db
|
||||||
|
.update(jellyfinServers)
|
||||||
|
.set({ version, lastSeenAt: new Date(), updatedAt: new Date() })
|
||||||
|
.where(and(eq(jellyfinServers.userId, userId), eq(jellyfinServers.id, id)));
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ export * from './dav';
|
|||||||
export * from './email';
|
export * from './email';
|
||||||
export * from './headscale';
|
export * from './headscale';
|
||||||
export * from './invoiceshelf';
|
export * from './invoiceshelf';
|
||||||
|
export * from './jellyfin';
|
||||||
export * from './music';
|
export * from './music';
|
||||||
export * from './notify';
|
export * from './notify';
|
||||||
export * from './operations';
|
export * from './operations';
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import { users } from './auth';
|
||||||
|
|
||||||
|
// The Jellyfin servers behind /jellyfin, for the officer-jellyfin sidecar.
|
||||||
|
//
|
||||||
|
// A REGISTRY rather than a single row, and here that is not speculative: this machine runs four Jellyfin
|
||||||
|
// instances on separate ports (video, albums, DJ sets, and a second person's), and the owner switches
|
||||||
|
// between them. Same shape as invoiceshelf_accounts and headscale_servers.
|
||||||
|
//
|
||||||
|
// `accessToken` is encrypted at rest via ../crypto.ts. A Jellyfin access token can read every item, every
|
||||||
|
// watch history and every stream on the instance, so a DB dump must not hand it over. Encryption is confined
|
||||||
|
// to queries/jellyfin.ts; nothing outside that file sees ciphertext, and no route ever returns the token.
|
||||||
|
//
|
||||||
|
// The PASSWORD is never stored. It is used once, by the sidecar, to trade for an access token through
|
||||||
|
// `POST /Users/AuthenticateByName` — the same shape as InvoiceShelf's mintToken.
|
||||||
|
export const jellyfinServers = pgTable(
|
||||||
|
'jellyfin_servers',
|
||||||
|
{
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
userId: integer('user_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
/** What the owner calls this server. The switcher shows nothing else, so it has to be theirs to set. */
|
||||||
|
label: text('label').notNull(),
|
||||||
|
// Normalized without a trailing slash before write, so `${url}/Items/...` never doubles the separator.
|
||||||
|
url: text('url').notNull(),
|
||||||
|
accessToken: text('access_token').notNull(), // encrypted
|
||||||
|
/**
|
||||||
|
* The Jellyfin user the token belongs to, pinned on the row.
|
||||||
|
*
|
||||||
|
* Jellyfin's per-user routes (`/UserItems/Resume`, played state, favourites) need this id explicitly, and
|
||||||
|
* it is NOT derivable from the token without a round trip. Authenticating returns it, so it is stored
|
||||||
|
* once rather than looked up on every request.
|
||||||
|
*/
|
||||||
|
jellyfinUserId: text('jellyfin_user_id').notNull(),
|
||||||
|
/** The account name that was authenticated — shown in the UI so two logins on one server are told apart. */
|
||||||
|
jellyfinUsername: text('jellyfin_username'),
|
||||||
|
/**
|
||||||
|
* The DeviceId sent on every call, generated once when the server is added.
|
||||||
|
*
|
||||||
|
* Jellyfin keys sessions and "remembered devices" by this. A fresh one per request would litter the
|
||||||
|
* dashboard with hundreds of devices and break playback reporting, which correlates by session.
|
||||||
|
*/
|
||||||
|
deviceId: text('device_id').notNull(),
|
||||||
|
/** The instance's own name (`ServerName` from /System/Info/Public), for the switcher. */
|
||||||
|
serverName: text('server_name'),
|
||||||
|
/** Jellyfin version seen at the last successful probe — shown in the UI, never used for behaviour. */
|
||||||
|
version: text('version'),
|
||||||
|
isActive: boolean('is_active').notNull().default(false),
|
||||||
|
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }),
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex('uq_jellyfin_servers_user_label').on(t.userId, t.label),
|
||||||
|
// At most one active server per owner, enforced by the DB rather than by convention: a partial unique
|
||||||
|
// index over the active rows only. setActiveJellyfinServer still clears the others in a transaction, but
|
||||||
|
// a bug there fails loudly here instead of silently leaving two active and the UI picking one.
|
||||||
|
uniqueIndex('uq_jellyfin_servers_one_active')
|
||||||
|
.on(t.userId)
|
||||||
|
.where(sql`${t.isActive}`),
|
||||||
|
],
|
||||||
|
);
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { createSidecarProxy } from '../../sidecar/create-proxy';
|
||||||
|
|
||||||
|
// /api/jellyfin/* — auth, then forward to officer-jellyfin. No routes of its own and no Jellyfin knowledge:
|
||||||
|
// this file must never grow app logic.
|
||||||
|
//
|
||||||
|
// The sidecar owns the Jellyfin contract and holds its credentials.
|
||||||
|
//
|
||||||
|
// `timeoutSeconds` is generous because this proxy carries VIDEO. A direct-play stream holds one connection
|
||||||
|
// open for the length of the film, and the first request against a fresh transcode waits on ffmpeg starting
|
||||||
|
// up. The default 60s idle drop would cut both.
|
||||||
|
|
||||||
|
const proxy = createSidecarProxy({
|
||||||
|
name: 'jellyfin',
|
||||||
|
prefix: '/api/jellyfin',
|
||||||
|
timeoutSeconds: 3600,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const jellyfinRouter = proxy.router;
|
||||||
|
|
||||||
|
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
|
||||||
|
export const getJellyfinServerUrl = proxy.getHttpUrl;
|
||||||
@@ -26,6 +26,7 @@ import { slskdRouter } from './api/slskd/router';
|
|||||||
import { headscaleRouter } from './api/headscale/router';
|
import { headscaleRouter } from './api/headscale/router';
|
||||||
import { transmissionRouter } from './api/transmission/router';
|
import { transmissionRouter } from './api/transmission/router';
|
||||||
import { invoiceshelfRouter } from './api/invoiceshelf/router';
|
import { invoiceshelfRouter } from './api/invoiceshelf/router';
|
||||||
|
import { jellyfinRouter } from './api/jellyfin/router';
|
||||||
import { photosRouter } from './api/photos/router';
|
import { photosRouter } from './api/photos/router';
|
||||||
import { walletRouter } from './api/wallet/router';
|
import { walletRouter } from './api/wallet/router';
|
||||||
import { vpnRouter } from './api/vpn/router';
|
import { vpnRouter } from './api/vpn/router';
|
||||||
@@ -169,6 +170,7 @@ protectedRouter.route('/notify', notifyRouter);
|
|||||||
protectedRouter.route('/headscale', headscaleRouter);
|
protectedRouter.route('/headscale', headscaleRouter);
|
||||||
protectedRouter.route('/transmission', transmissionRouter);
|
protectedRouter.route('/transmission', transmissionRouter);
|
||||||
protectedRouter.route('/invoiceshelf', invoiceshelfRouter);
|
protectedRouter.route('/invoiceshelf', invoiceshelfRouter);
|
||||||
|
protectedRouter.route('/jellyfin', jellyfinRouter);
|
||||||
protectedRouter.route('/photos', photosRouter);
|
protectedRouter.route('/photos', photosRouter);
|
||||||
protectedRouter.route('/wallet', walletRouter);
|
protectedRouter.route('/wallet', walletRouter);
|
||||||
protectedRouter.route('/vpn', vpnRouter);
|
protectedRouter.route('/vpn', vpnRouter);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||||
|
import { createSidecarConnector } from '../connect';
|
||||||
|
import { handleConfigRoute, noteProbe } from './config';
|
||||||
|
import { handleBytesRoute, handleOfficerRoute } from './routes';
|
||||||
|
import { getConfig, probe } from './upstream';
|
||||||
|
|
||||||
|
// 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/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, isHls, 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.
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
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',
|
||||||
|
capabilities: ['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'));
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// The device profile sent with every PlaybackInfo request.
|
||||||
|
//
|
||||||
|
// This is the single most consequential object in the whole Jellyfin integration and the least obvious: it is
|
||||||
|
// how the SERVER decides whether to hand back the original file or spin up an ffmpeg transcode. Describe the
|
||||||
|
// browser too generously and playback dies silently on an unsupported codec; too conservatively and every
|
||||||
|
// file is transcoded, which on this machine means CPU-only ffmpeg (no /dev/dri is passed into the container).
|
||||||
|
//
|
||||||
|
// It deliberately describes what a modern Chromium/WebKit `<video>` plus hls.js can actually play, which is
|
||||||
|
// narrower than the file formats a personal library contains — Matroska in particular is NOT playable in any
|
||||||
|
// browser, so mkv is the common case that has to go out as HLS even when its streams are already h264/aac.
|
||||||
|
//
|
||||||
|
// Kept as a plain literal rather than probed from the client: it is server-side knowledge about a fixed
|
||||||
|
// target (our own web player), and a profile assembled from feature detection in the browser is the classic
|
||||||
|
// way to end up with a per-machine playback bug nobody can reproduce.
|
||||||
|
|
||||||
|
const VIDEO_CONTAINERS = 'mp4,m4v,webm';
|
||||||
|
const VIDEO_CODECS = 'h264,vp8,vp9,av1';
|
||||||
|
const AUDIO_CODECS = 'aac,mp3,opus,flac,vorbis';
|
||||||
|
|
||||||
|
export const DEVICE_PROFILE = {
|
||||||
|
MaxStreamingBitrate: 120_000_000,
|
||||||
|
MaxStaticBitrate: 100_000_000,
|
||||||
|
MusicStreamingTranscodingBitrate: 384_000,
|
||||||
|
|
||||||
|
DirectPlayProfiles: [
|
||||||
|
{ Container: VIDEO_CONTAINERS, Type: 'Video', VideoCodec: VIDEO_CODECS, AudioCodec: AUDIO_CODECS },
|
||||||
|
// Present so a trailer or a stray extra plays; the library itself is video and audio lives in /music.
|
||||||
|
{ Container: 'mp3,aac,flac,opus,webm', Type: 'Audio' },
|
||||||
|
],
|
||||||
|
|
||||||
|
TranscodingProfiles: [
|
||||||
|
{
|
||||||
|
// fMP4 segments rather than MPEG-TS: h264 in fMP4 is what jellyfin-web itself defaults to on 10.11,
|
||||||
|
// and it is the only path that can stream-copy an h264 track into HLS instead of re-encoding it.
|
||||||
|
Container: 'mp4',
|
||||||
|
Type: 'Video',
|
||||||
|
AudioCodec: 'aac,mp3,opus,flac',
|
||||||
|
VideoCodec: 'h264',
|
||||||
|
Context: 'Streaming',
|
||||||
|
Protocol: 'hls',
|
||||||
|
MaxAudioChannels: '2',
|
||||||
|
MinSegments: 1,
|
||||||
|
BreakOnNonKeyFrames: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Container: 'mp4',
|
||||||
|
Type: 'Video',
|
||||||
|
AudioCodec: 'aac',
|
||||||
|
VideoCodec: 'h264',
|
||||||
|
Context: 'Static',
|
||||||
|
Protocol: 'http',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
CodecProfiles: [
|
||||||
|
{
|
||||||
|
Type: 'Video',
|
||||||
|
Codec: 'h264',
|
||||||
|
Conditions: [
|
||||||
|
// High 10 (10-bit h264) decodes in almost no browser, and hitting it is a black screen with audio
|
||||||
|
// rather than an error, so it is excluded explicitly instead of being left to chance.
|
||||||
|
{ Condition: 'NotEquals', Property: 'VideoProfile', Value: 'high 10', IsRequired: false },
|
||||||
|
{ Condition: 'LessThanEqual', Property: 'VideoLevel', Value: '52', IsRequired: false },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
SubtitleProfiles: [
|
||||||
|
// Text subtitles are fetched separately and rendered by the player. Image-based ones (PGS, VOBSUB) have
|
||||||
|
// no such path in a browser and are left out, which makes Jellyfin burn them into the video instead.
|
||||||
|
{ Format: 'vtt', Method: 'External' },
|
||||||
|
{ Format: 'srt', Method: 'External' },
|
||||||
|
{ Format: 'ass', Method: 'External' },
|
||||||
|
{ Format: 'ssa', Method: 'External' },
|
||||||
|
],
|
||||||
|
} as const;
|
||||||
@@ -0,0 +1,470 @@
|
|||||||
|
import type { UpstreamConfig } from './upstream';
|
||||||
|
import { DEVICE_PROFILE } from './profile';
|
||||||
|
import { callUpstream } from './upstream';
|
||||||
|
|
||||||
|
// The two halves of the Jellyfin surface Officer exposes.
|
||||||
|
//
|
||||||
|
// `/_officer/*` — a small hand-written façade. Every route here exists because the raw Jellyfin call needs
|
||||||
|
// something the browser should not have to know: the user id in the path, a Fields list that decides whether
|
||||||
|
// a grid has posters, or a PlaybackInfo negotiation. It is JSON in, JSON out.
|
||||||
|
//
|
||||||
|
// `/_jf/*` — a transparent, authenticated, GET-only pass-through for BYTES: images, video, HLS playlists and
|
||||||
|
// segments, subtitles. It exists because HLS cannot be faked. A master playlist references its variant
|
||||||
|
// playlists and every segment RELATIVELY, so the URLs a browser derives from it must resolve back onto the
|
||||||
|
// same prefix; any façade that renamed those paths would have to rewrite the playlists on the way out, and
|
||||||
|
// rewriting m3u8 bodies to keep a prettier URL scheme is a trade nobody should take.
|
||||||
|
//
|
||||||
|
// The pass-through is deny-by-default over a prefix allow-list. Jellyfin's API is the whole server —
|
||||||
|
// `/System/*`, `/ScheduledTasks/*`, `/Plugins/*`, user administration, the setup wizard — and the token this
|
||||||
|
// sidecar holds belongs to a real account, so an "everything under /_jf" proxy would put library scanning and
|
||||||
|
// user creation one URL away from the browser.
|
||||||
|
//
|
||||||
|
// One thing here is a security fix rather than plumbing: `TranscodingUrl` comes back from Jellyfin with
|
||||||
|
// `api_key=<the access token>` embedded in its query string. That URL is handed to the video element, so it
|
||||||
|
// would put the token in the DOM, in history and in any log that records URLs. It is stripped in
|
||||||
|
// `sanitizeUpstreamPath` before anything is returned, and the pass-through re-adds the credential as a header
|
||||||
|
// where it belongs.
|
||||||
|
|
||||||
|
/** Ticks are 100-nanosecond units. Jellyfin speaks these everywhere; the UI speaks seconds. */
|
||||||
|
const TICKS_PER_SECOND = 10_000_000;
|
||||||
|
|
||||||
|
const json = (data: unknown, status = 200) => Response.json(data, { status });
|
||||||
|
const bad = (error: string, status = 400) => json({ error }, status);
|
||||||
|
|
||||||
|
/** Pass a Jellyfin JSON response through, keeping its status so a 404 upstream stays a 404 here. */
|
||||||
|
async function relayJson(res: Response): Promise<Response> {
|
||||||
|
const text = await res.text();
|
||||||
|
return new Response(text, {
|
||||||
|
status: res.status,
|
||||||
|
headers: { 'Content-Type': res.headers.get('content-type') ?? 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET a Jellyfin path as parsed JSON, or throw with the upstream status attached. */
|
||||||
|
async function fetchJson<T>(cfg: UpstreamConfig, path: string, params: Record<string, string | undefined>): Promise<T> {
|
||||||
|
const res = await callUpstream(cfg, { path, query: buildQuery(params) });
|
||||||
|
if (!res.ok) throw new Error(`${path} → ${res.status}`);
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildQuery(params: Record<string, string | undefined>): string {
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (value != null && value !== '') search.set(key, value);
|
||||||
|
}
|
||||||
|
const qs = search.toString();
|
||||||
|
return qs ? `?${qs}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Fields lists. A grid without ImageTags renders grey rectangles, which is the single most common way a
|
||||||
|
// Jellyfin client looks broken, so the poster-bearing fields are not optional anywhere.
|
||||||
|
const GRID_FIELDS = 'PrimaryImageAspectRatio,BasicSyncInfo,ProductionYear,Status,EndDate';
|
||||||
|
const DETAIL_FIELDS =
|
||||||
|
'Overview,Genres,Studios,People,Taglines,ProductionYear,OfficialRating,CommunityRating,MediaSources,MediaStreams,Chapters,ExternalUrls,RemoteTrailers,PrimaryImageAspectRatio,ParentId,SeriesStudio';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The libraries worth showing.
|
||||||
|
*
|
||||||
|
* Music is filtered OUT on purpose and not as a stylistic choice: the owner's audio lives in Officer's own
|
||||||
|
* /music player, and this machine runs separate Jellyfin instances for albums and DJ sets. A Jellyfin panel
|
||||||
|
* that also listed those would be two music libraries competing for the same job.
|
||||||
|
*/
|
||||||
|
const VIDEO_COLLECTION_TYPES = new Set(['movies', 'tvshows', 'homevideos', 'musicvideos', 'boxsets', 'playlists']);
|
||||||
|
|
||||||
|
type JellyfinItem = { Id?: string; Name?: string; CollectionType?: string; Type?: string };
|
||||||
|
type ItemsResponse = { Items?: JellyfinItem[]; TotalRecordCount?: number };
|
||||||
|
|
||||||
|
async function videoViews(cfg: UpstreamConfig): Promise<JellyfinItem[]> {
|
||||||
|
const data = await fetchJson<ItemsResponse>(cfg, `/Users/${cfg.jellyfinUserId}/Views`, {});
|
||||||
|
return (data.Items ?? []).filter((view) => !view.CollectionType || VIDEO_COLLECTION_TYPES.has(view.CollectionType));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The home screen in ONE round trip: libraries, what is half-watched, what is next, and the newest thing in
|
||||||
|
* each library. Jellyfin needs four different calls for that, and issuing them from the browser would mean
|
||||||
|
* four proxy hops and a screen that assembles itself in visible stages.
|
||||||
|
*
|
||||||
|
* Latest-per-library is fetched concurrently and failures are swallowed per library: one library that is
|
||||||
|
* mid-scan should cost its own shelf, not the whole page.
|
||||||
|
*/
|
||||||
|
async function home(cfg: UpstreamConfig): Promise<Response> {
|
||||||
|
const views = await videoViews(cfg);
|
||||||
|
|
||||||
|
const [resume, nextUp, latest] = await Promise.all([
|
||||||
|
fetchJson<ItemsResponse>(cfg, '/UserItems/Resume', {
|
||||||
|
userId: cfg.jellyfinUserId,
|
||||||
|
limit: '12',
|
||||||
|
mediaTypes: 'Video',
|
||||||
|
fields: GRID_FIELDS,
|
||||||
|
enableTotalRecordCount: 'false',
|
||||||
|
}).catch(() => ({ Items: [] })),
|
||||||
|
fetchJson<ItemsResponse>(cfg, '/Shows/NextUp', {
|
||||||
|
userId: cfg.jellyfinUserId,
|
||||||
|
limit: '12',
|
||||||
|
fields: GRID_FIELDS,
|
||||||
|
enableTotalRecordCount: 'false',
|
||||||
|
}).catch(() => ({ Items: [] })),
|
||||||
|
Promise.all(
|
||||||
|
views.map(async (view) => ({
|
||||||
|
viewId: view.Id ?? '',
|
||||||
|
viewName: view.Name ?? '',
|
||||||
|
collectionType: view.CollectionType ?? null,
|
||||||
|
items: await fetchJson<JellyfinItem[]>(cfg, `/Users/${cfg.jellyfinUserId}/Items/Latest`, {
|
||||||
|
parentId: view.Id,
|
||||||
|
limit: '12',
|
||||||
|
fields: GRID_FIELDS,
|
||||||
|
}).catch(() => []),
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return json({ views, resume: resume.Items ?? [], nextUp: nextUp.Items ?? [], latest });
|
||||||
|
}
|
||||||
|
|
||||||
|
// What a browse grid is allowed to ask for. Deny-by-default again — `/Items` accepts filters that reach
|
||||||
|
// outside the library (`path`, `userId`) and this list is what keeps the query the UI's business only.
|
||||||
|
const ITEM_QUERY_PARAMS = [
|
||||||
|
'parentId',
|
||||||
|
'includeItemTypes',
|
||||||
|
'excludeItemTypes',
|
||||||
|
'recursive',
|
||||||
|
'sortBy',
|
||||||
|
'sortOrder',
|
||||||
|
'startIndex',
|
||||||
|
'limit',
|
||||||
|
'searchTerm',
|
||||||
|
'filters',
|
||||||
|
'genres',
|
||||||
|
'genreIds',
|
||||||
|
'years',
|
||||||
|
'officialRatings',
|
||||||
|
'tags',
|
||||||
|
'studioIds',
|
||||||
|
'personIds',
|
||||||
|
'isPlayed',
|
||||||
|
'isFavorite',
|
||||||
|
'nameStartsWith',
|
||||||
|
'imageTypeLimit',
|
||||||
|
'enableImageTypes',
|
||||||
|
'collapseBoxSetItems',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function passthroughItemQuery(url: URL): Record<string, string | undefined> {
|
||||||
|
const params: Record<string, string | undefined> = {};
|
||||||
|
for (const key of ITEM_QUERY_PARAMS) {
|
||||||
|
const value = url.searchParams.get(key);
|
||||||
|
if (value != null) params[key] = value;
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A browse grid. Defaults to recursive video items so a library id is enough to get something sensible. */
|
||||||
|
async function items(cfg: UpstreamConfig, url: URL): Promise<Response> {
|
||||||
|
const params = passthroughItemQuery(url);
|
||||||
|
const res = await callUpstream(cfg, {
|
||||||
|
path: '/Items',
|
||||||
|
query: buildQuery({
|
||||||
|
userId: cfg.jellyfinUserId,
|
||||||
|
recursive: params.recursive ?? 'true',
|
||||||
|
includeItemTypes: params.includeItemTypes ?? 'Movie,Series,Video',
|
||||||
|
sortBy: params.sortBy ?? 'SortName',
|
||||||
|
sortOrder: params.sortOrder ?? 'Ascending',
|
||||||
|
limit: params.limit ?? '100',
|
||||||
|
fields: GRID_FIELDS,
|
||||||
|
imageTypeLimit: '1',
|
||||||
|
enableImageTypes: 'Primary,Backdrop,Thumb,Logo',
|
||||||
|
...params,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return relayJson(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One item, with everything the detail page draws — including MediaSources, which drive the codec line. */
|
||||||
|
async function item(cfg: UpstreamConfig, id: string): Promise<Response> {
|
||||||
|
const res = await callUpstream(cfg, {
|
||||||
|
path: `/Items/${encodeURIComponent(id)}`,
|
||||||
|
query: buildQuery({ userId: cfg.jellyfinUserId, fields: DETAIL_FIELDS }),
|
||||||
|
});
|
||||||
|
return relayJson(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaSource = {
|
||||||
|
Id?: string;
|
||||||
|
SupportsDirectPlay?: boolean;
|
||||||
|
SupportsDirectStream?: boolean;
|
||||||
|
TranscodingUrl?: string;
|
||||||
|
};
|
||||||
|
type PlaybackInfoResponse = { MediaSources?: MediaSource[]; PlaySessionId?: string; ErrorCode?: string | null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip the credential Jellyfin embeds in the URLs it hands back.
|
||||||
|
*
|
||||||
|
* `TranscodingUrl` arrives as `/videos/…/main.m3u8?…&api_key=<access token>&…`. Returning it untouched would
|
||||||
|
* publish the token to the browser. The pass-through supplies the token as a header on every hop, so the
|
||||||
|
* parameter is not merely unsafe, it is redundant.
|
||||||
|
*/
|
||||||
|
function sanitizeUpstreamPath(raw: string): string {
|
||||||
|
const [path, search = ''] = raw.split('?');
|
||||||
|
const params = new URLSearchParams(search);
|
||||||
|
params.delete('api_key');
|
||||||
|
params.delete('ApiKey');
|
||||||
|
params.delete('X-Emby-Token');
|
||||||
|
const qs = params.toString();
|
||||||
|
return `${path}${qs ? `?${qs}` : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Negotiate playback: ask the server what it can do with this file for this profile, and turn the answer into
|
||||||
|
* one URL the player can use.
|
||||||
|
*
|
||||||
|
* The three outcomes are direct play (the original container is browser-playable), direct stream (remuxed on
|
||||||
|
* the fly, still `/Videos/{id}/stream`) and transcode (HLS). They are reported explicitly rather than
|
||||||
|
* inferred, because "why is my CPU pinned" is a question the UI should be able to answer.
|
||||||
|
*/
|
||||||
|
async function playbackInfo(cfg: UpstreamConfig, id: string, req: Request, url: URL): Promise<Response> {
|
||||||
|
const startSeconds = Number(url.searchParams.get('startSeconds') ?? '0');
|
||||||
|
const body = (await req.json().catch(() => ({}))) as {
|
||||||
|
mediaSourceId?: string;
|
||||||
|
audioStreamIndex?: number;
|
||||||
|
subtitleStreamIndex?: number;
|
||||||
|
maxStreamingBitrate?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await callUpstream(cfg, {
|
||||||
|
path: `/Items/${encodeURIComponent(id)}/PlaybackInfo`,
|
||||||
|
method: 'POST',
|
||||||
|
query: buildQuery({ userId: cfg.jellyfinUserId }),
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
DeviceProfile: DEVICE_PROFILE,
|
||||||
|
UserId: cfg.jellyfinUserId,
|
||||||
|
MaxStreamingBitrate: body.maxStreamingBitrate ?? DEVICE_PROFILE.MaxStreamingBitrate,
|
||||||
|
StartTimeTicks: Math.max(0, Math.round(startSeconds * TICKS_PER_SECOND)),
|
||||||
|
MediaSourceId: body.mediaSourceId,
|
||||||
|
AudioStreamIndex: body.audioStreamIndex,
|
||||||
|
SubtitleStreamIndex: body.subtitleStreamIndex,
|
||||||
|
EnableDirectPlay: true,
|
||||||
|
EnableDirectStream: true,
|
||||||
|
EnableTranscoding: true,
|
||||||
|
AllowVideoStreamCopy: true,
|
||||||
|
AllowAudioStreamCopy: true,
|
||||||
|
// Without this the transcode is only prepared, not started, and the first segment request 404s.
|
||||||
|
AutoOpenLiveStream: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) return relayJson(res);
|
||||||
|
|
||||||
|
const info = (await res.json()) as PlaybackInfoResponse;
|
||||||
|
const source = info.MediaSources?.find((s) => s.Id === body.mediaSourceId) ?? info.MediaSources?.[0];
|
||||||
|
if (!source) return bad('the server returned no playable source for this item', 502);
|
||||||
|
|
||||||
|
const playSessionId = info.PlaySessionId ?? null;
|
||||||
|
const direct = source.SupportsDirectPlay || source.SupportsDirectStream;
|
||||||
|
|
||||||
|
const path = direct
|
||||||
|
? `/Videos/${encodeURIComponent(id)}/stream${buildQuery({
|
||||||
|
static: 'true',
|
||||||
|
mediaSourceId: source.Id,
|
||||||
|
playSessionId: playSessionId ?? undefined,
|
||||||
|
})}`
|
||||||
|
: source.TranscodingUrl
|
||||||
|
? sanitizeUpstreamPath(source.TranscodingUrl)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!path) {
|
||||||
|
return bad(info.ErrorCode ? `playback refused: ${info.ErrorCode}` : 'the server offered no way to play this', 502);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({
|
||||||
|
playMethod: direct ? (source.SupportsDirectPlay ? 'DirectPlay' : 'DirectStream') : 'Transcode',
|
||||||
|
// Relative to the panel's mount, so the browser prefixes /api/jellyfin and the pass-through does the rest.
|
||||||
|
url: `/_jf${path}`,
|
||||||
|
isHls: !direct,
|
||||||
|
playSessionId,
|
||||||
|
mediaSourceId: source.Id ?? null,
|
||||||
|
mediaSource: source,
|
||||||
|
startSeconds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Playback reporting — start, heartbeat, stop. Bodies are the upstream's own shapes, forwarded as sent. */
|
||||||
|
async function reportPlaystate(cfg: UpstreamConfig, action: string, req: Request): Promise<Response> {
|
||||||
|
const path =
|
||||||
|
action === 'playing'
|
||||||
|
? '/Sessions/Playing'
|
||||||
|
: action === 'progress'
|
||||||
|
? '/Sessions/Playing/Progress'
|
||||||
|
: action === 'stopped'
|
||||||
|
? '/Sessions/Playing/Stopped'
|
||||||
|
: null;
|
||||||
|
if (!path) return bad('not found', 404);
|
||||||
|
|
||||||
|
const body = await req.text();
|
||||||
|
const res = await callUpstream(cfg, { path, method: 'POST', contentType: 'application/json', body });
|
||||||
|
// 204 with no body is the normal answer; relaying it as JSON would invent content that is not there.
|
||||||
|
return new Response(null, { status: res.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Watched and favourite marks. POST sets, DELETE clears — the same shape Jellyfin's own routes use. */
|
||||||
|
async function userItemFlag(cfg: UpstreamConfig, kind: 'played' | 'favorite', id: string, method: string) {
|
||||||
|
const base = kind === 'played' ? '/UserPlayedItems' : '/UserFavoriteItems';
|
||||||
|
const res = await callUpstream(cfg, {
|
||||||
|
path: `${base}/${encodeURIComponent(id)}`,
|
||||||
|
method: method === 'DELETE' ? 'DELETE' : 'POST',
|
||||||
|
query: buildQuery({ userId: cfg.jellyfinUserId }),
|
||||||
|
});
|
||||||
|
return relayJson(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The `/_officer/*` façade. Returns null when nothing matched, so index.ts can answer a single 404. */
|
||||||
|
export async function handleOfficerRoute(cfg: UpstreamConfig, req: Request, url: URL): Promise<Response | null> {
|
||||||
|
const [head, a, b] = url.pathname.slice('/_officer/'.length).split('/');
|
||||||
|
const method = req.method;
|
||||||
|
|
||||||
|
if (head === 'home' && method === 'GET') return home(cfg);
|
||||||
|
|
||||||
|
if (head === 'views' && method === 'GET') return json({ views: await videoViews(cfg) });
|
||||||
|
|
||||||
|
if (head === 'items' && !a && method === 'GET') return items(cfg, url);
|
||||||
|
|
||||||
|
if (head === 'items' && a) {
|
||||||
|
if (!b && method === 'GET') return item(cfg, a);
|
||||||
|
if (b === 'similar' && method === 'GET') {
|
||||||
|
const res = await callUpstream(cfg, {
|
||||||
|
path: `/Items/${encodeURIComponent(a)}/Similar`,
|
||||||
|
query: buildQuery({ userId: cfg.jellyfinUserId, limit: '12', fields: GRID_FIELDS }),
|
||||||
|
});
|
||||||
|
return relayJson(res);
|
||||||
|
}
|
||||||
|
if (b === 'playback' && method === 'POST') return playbackInfo(cfg, a, req, url);
|
||||||
|
if ((b === 'played' || b === 'favorite') && (method === 'POST' || method === 'DELETE')) {
|
||||||
|
return userItemFlag(cfg, b, a, method);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (head === 'shows' && a && method === 'GET') {
|
||||||
|
if (b === 'seasons') {
|
||||||
|
const res = await callUpstream(cfg, {
|
||||||
|
path: `/Shows/${encodeURIComponent(a)}/Seasons`,
|
||||||
|
query: buildQuery({ userId: cfg.jellyfinUserId, fields: GRID_FIELDS }),
|
||||||
|
});
|
||||||
|
return relayJson(res);
|
||||||
|
}
|
||||||
|
if (b === 'episodes') {
|
||||||
|
const res = await callUpstream(cfg, {
|
||||||
|
path: `/Shows/${encodeURIComponent(a)}/Episodes`,
|
||||||
|
query: buildQuery({
|
||||||
|
userId: cfg.jellyfinUserId,
|
||||||
|
seasonId: url.searchParams.get('seasonId') ?? undefined,
|
||||||
|
fields: `${GRID_FIELDS},Overview,MediaSources`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return relayJson(res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (head === 'resume' && method === 'GET') {
|
||||||
|
const res = await callUpstream(cfg, {
|
||||||
|
path: '/UserItems/Resume',
|
||||||
|
query: buildQuery({ userId: cfg.jellyfinUserId, limit: '24', mediaTypes: 'Video', fields: GRID_FIELDS }),
|
||||||
|
});
|
||||||
|
return relayJson(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (head === 'nextup' && method === 'GET') {
|
||||||
|
const res = await callUpstream(cfg, {
|
||||||
|
path: '/Shows/NextUp',
|
||||||
|
query: buildQuery({ userId: cfg.jellyfinUserId, limit: '24', fields: GRID_FIELDS }),
|
||||||
|
});
|
||||||
|
return relayJson(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (head === 'search' && method === 'GET') {
|
||||||
|
const term = url.searchParams.get('q')?.trim();
|
||||||
|
if (!term) return json({ Items: [], TotalRecordCount: 0 });
|
||||||
|
const res = await callUpstream(cfg, {
|
||||||
|
path: '/Items',
|
||||||
|
query: buildQuery({
|
||||||
|
userId: cfg.jellyfinUserId,
|
||||||
|
searchTerm: term,
|
||||||
|
recursive: 'true',
|
||||||
|
includeItemTypes: 'Movie,Series,Episode,Video,BoxSet,Person',
|
||||||
|
limit: '48',
|
||||||
|
fields: GRID_FIELDS,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return relayJson(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (head === 'genres' && method === 'GET') {
|
||||||
|
const res = await callUpstream(cfg, {
|
||||||
|
path: '/Genres',
|
||||||
|
query: buildQuery({ userId: cfg.jellyfinUserId, parentId: url.searchParams.get('parentId') ?? undefined }),
|
||||||
|
});
|
||||||
|
return relayJson(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (head === 'sessions' && a && method === 'POST') return reportPlaystate(cfg, a, req);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The pass-through allow-list. Anything not matching one of these is refused, including every administrative
|
||||||
|
// route. Matched case-insensitively because Jellyfin's own HLS playlists reference `/videos/…` in lower case
|
||||||
|
// while its OpenAPI document says `/Videos/…`, and a browser resolving a relative segment URL will send back
|
||||||
|
// whatever the playlist said.
|
||||||
|
const BYTES_PREFIXES = [
|
||||||
|
'items/', // artwork: /Items/{id}/Images/{type}
|
||||||
|
'videos/', // direct stream, HLS playlists and segments, subtitles
|
||||||
|
'users/', // /Users/{id}/Images/Primary — the account avatar
|
||||||
|
'persons/', // cast portraits
|
||||||
|
'studios/',
|
||||||
|
'genres/',
|
||||||
|
'musicgenres/',
|
||||||
|
];
|
||||||
|
|
||||||
|
const isAllowedBytesPath = (path: string): boolean => {
|
||||||
|
const lower = path.toLowerCase();
|
||||||
|
return BYTES_PREFIXES.some((prefix) => lower.startsWith(prefix));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `/_jf/*` — bytes only, GET only, allow-listed.
|
||||||
|
*
|
||||||
|
* `Accept: * / *` matters: the default JSON Accept makes Jellyfin answer some image routes with a JSON error
|
||||||
|
* instead of the picture. Range is forwarded so seeking works, and the response headers are relayed nearly
|
||||||
|
* whole — content-range, etag and cache-control are what make the `<video>` element and the browser cache
|
||||||
|
* behave, and dropping them turns seeking into a re-download.
|
||||||
|
*/
|
||||||
|
export async function handleBytesRoute(cfg: UpstreamConfig, req: Request, url: URL): Promise<Response | null> {
|
||||||
|
if (req.method !== 'GET' && req.method !== 'HEAD') return null;
|
||||||
|
|
||||||
|
const subpath = url.pathname.slice('/_jf/'.length);
|
||||||
|
if (!subpath || subpath.includes('..')) return null;
|
||||||
|
if (!isAllowedBytesPath(subpath)) return null;
|
||||||
|
|
||||||
|
const upstream = await callUpstream(cfg, {
|
||||||
|
path: `/${subpath}`,
|
||||||
|
method: req.method,
|
||||||
|
query: url.search,
|
||||||
|
accept: '*/*',
|
||||||
|
range: req.headers.get('range'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const headers = new Headers();
|
||||||
|
for (const header of [
|
||||||
|
'content-type',
|
||||||
|
'content-length',
|
||||||
|
'content-range',
|
||||||
|
'accept-ranges',
|
||||||
|
'etag',
|
||||||
|
'last-modified',
|
||||||
|
'cache-control',
|
||||||
|
] as const) {
|
||||||
|
const value = upstream.headers.get(header);
|
||||||
|
if (value) headers.set(header, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(upstream.body, { status: upstream.status, headers });
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
// Jellyfin upstream config + the one function that talks to it.
|
||||||
|
//
|
||||||
|
// All knowledge of the Jellyfin instance — its URL, its access token, the Jellyfin user the token belongs to
|
||||||
|
// and the DeviceId sessions are keyed by — lives here, mirroring officer-invoiceshelf: the platform API is a
|
||||||
|
// thin auth+forward proxy and holds NO Jellyfin credentials.
|
||||||
|
//
|
||||||
|
// The server is CONFIGURED BY THE OWNER FROM THE UI and stored encrypted in `jellyfin_servers` (see
|
||||||
|
// databases/officer_db/src/queries/jellyfin.ts). Nothing in this file reads process.env — Bun auto-loads
|
||||||
|
// `.env` into every process started in the platform directory, so a token there would also be sitting in
|
||||||
|
// `officer`'s own environment: a credential held by the one process that has no code to use it.
|
||||||
|
//
|
||||||
|
// Three things about Jellyfin's API are load-bearing:
|
||||||
|
//
|
||||||
|
// 1. Auth is ONE header — `Authorization: MediaBrowser Token="…", Client=…, Device=…, DeviceId=…,
|
||||||
|
// Version=…`. The older `X-Emby-Token` still works in 10.11 but carries no client identity, and
|
||||||
|
// playback reporting correlates sessions by DeviceId, so the full header is what we send.
|
||||||
|
// 2. The DeviceId must be STABLE per server. A fresh one per request fills Jellyfin's dashboard with
|
||||||
|
// hundreds of "devices" and detaches each progress report from the session that started playback.
|
||||||
|
// 3. Almost every per-user route needs the user id in the PATH (`/Users/{id}/Views`), and it is not
|
||||||
|
// derivable from the token without a round trip — which is why it is pinned on the row at sign-in.
|
||||||
|
|
||||||
|
import { getActiveJellyfinCredentials } from 'officerdb';
|
||||||
|
|
||||||
|
/** Everything needed to make one call. A candidate being validated has this and nothing else yet. */
|
||||||
|
export type UpstreamTarget = {
|
||||||
|
base: string;
|
||||||
|
token: string;
|
||||||
|
jellyfinUserId: string;
|
||||||
|
deviceId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A stored server, which is where every real call goes.
|
||||||
|
*
|
||||||
|
* `id` and `label` ride along because the owner has several instances registered (this machine runs four)
|
||||||
|
* and only one selected: a probe has to be recorded against the row it actually reached, and a log line
|
||||||
|
* saying which instance answered is the difference between "Jellyfin is broken" and "you are looking at
|
||||||
|
* the albums server".
|
||||||
|
*/
|
||||||
|
export type UpstreamConfig = UpstreamTarget & { id: number; label: string };
|
||||||
|
|
||||||
|
/** Trailing slashes off, so `${base}/Items/...` never doubles the separator. */
|
||||||
|
export const normalizeBase = (url: string): string => url.trim().replace(/\/+$/, '');
|
||||||
|
|
||||||
|
// A library grid is a burst of requests and each one needs the token, so the row is cached rather than
|
||||||
|
// re-read per request. Writes invalidate immediately; the TTL only covers someone editing the row in psql,
|
||||||
|
// which then takes effect within a minute instead of needing a restart.
|
||||||
|
const TTL_MS = 60_000;
|
||||||
|
const cache = new Map<number, { cfg: UpstreamConfig | null; at: number }>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The owner's SELECTED server, or null when /jellyfin has none yet — the sidecar then answers 503, and the
|
||||||
|
* UI turns that into the setup form rather than a screen of empty shelves.
|
||||||
|
*/
|
||||||
|
export async function getConfig(userId: number): Promise<UpstreamConfig | null> {
|
||||||
|
const hit = cache.get(userId);
|
||||||
|
if (hit && Date.now() - hit.at < TTL_MS) return hit.cfg;
|
||||||
|
|
||||||
|
const creds = await getActiveJellyfinCredentials(userId);
|
||||||
|
const cfg = creds
|
||||||
|
? {
|
||||||
|
id: creds.id,
|
||||||
|
label: creds.label,
|
||||||
|
base: normalizeBase(creds.url),
|
||||||
|
token: creds.accessToken,
|
||||||
|
jellyfinUserId: creds.jellyfinUserId,
|
||||||
|
deviceId: creds.deviceId,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
cache.set(userId, { cfg, at: Date.now() });
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop the cached row — called by the config routes after any add, edit, switch or removal. */
|
||||||
|
export function invalidateConfig(userId: number): void {
|
||||||
|
cache.delete(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Carries the upstream status so callers can tell a rejected token from an unreachable instance. */
|
||||||
|
export class UpstreamError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
readonly status: number,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLIENT = 'Officer';
|
||||||
|
const DEVICE = 'Officer Web';
|
||||||
|
const CLIENT_VERSION = '1.0.0';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `Authorization` header Jellyfin expects, with or without a token.
|
||||||
|
*
|
||||||
|
* Signing in has to send the same header MINUS the token: Jellyfin refuses AuthenticateByName outright
|
||||||
|
* without a client identity, which is the single most common reason a hand-rolled login gets a 400.
|
||||||
|
*/
|
||||||
|
export function authHeader(deviceId: string, token?: string): string {
|
||||||
|
const parts = [`Client="${CLIENT}"`, `Device="${DEVICE}"`, `DeviceId="${deviceId}"`, `Version="${CLIENT_VERSION}"`];
|
||||||
|
if (token) parts.push(`Token="${token}"`);
|
||||||
|
return `MediaBrowser ${parts.join(', ')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AuthResult = { token: string; userId: string; username: string | null; serverName: string | null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trade a username and password for an access token.
|
||||||
|
*
|
||||||
|
* The password is used for this one call and never stored — only the token it returns is persisted, the
|
||||||
|
* same trade officer-invoiceshelf makes. Jellyfin CAN issue API keys from its dashboard, but those are
|
||||||
|
* server-wide admin keys with no user identity, and every per-user route (resume points, watched state,
|
||||||
|
* favourites) needs a user. So we sign in as the owner rather than key the server.
|
||||||
|
*
|
||||||
|
* Each sign-in mints a NEW token; the previous one is deliberately left alone, because Officer cannot know
|
||||||
|
* whether the owner also uses it somewhere else. Old ones are revoked from Jellyfin's own Devices screen.
|
||||||
|
*/
|
||||||
|
export async function authenticate(
|
||||||
|
base: string,
|
||||||
|
deviceId: string,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<AuthResult> {
|
||||||
|
const res = await fetch(`${base}/Users/AuthenticateByName`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: authHeader(deviceId),
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
// `Pw` is the plaintext field. `Password` is the legacy SHA1 one and is ignored by modern servers.
|
||||||
|
body: JSON.stringify({ Username: username, Pw: password }),
|
||||||
|
redirect: 'manual',
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const detail = res.status === 401 ? 'wrong username or password' : `sign-in failed with ${res.status}`;
|
||||||
|
throw new UpstreamError(detail, res.status);
|
||||||
|
}
|
||||||
|
const payload = (await res.json()) as {
|
||||||
|
AccessToken?: unknown;
|
||||||
|
User?: { Id?: unknown; Name?: unknown };
|
||||||
|
ServerId?: unknown;
|
||||||
|
};
|
||||||
|
if (typeof payload.AccessToken !== 'string' || !payload.AccessToken) {
|
||||||
|
throw new UpstreamError('sign-in returned no access token', res.status);
|
||||||
|
}
|
||||||
|
if (typeof payload.User?.Id !== 'string' || !payload.User.Id) {
|
||||||
|
throw new UpstreamError('sign-in returned no user id', res.status);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
token: payload.AccessToken,
|
||||||
|
userId: payload.User.Id,
|
||||||
|
username: typeof payload.User.Name === 'string' ? payload.User.Name : null,
|
||||||
|
serverName: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PublicInfo = { serverName: string | null; version: string | null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The unauthenticated identity endpoint, used before any credential exists so the setup form can confirm
|
||||||
|
* the URL points at a Jellyfin at all — and name it — before asking for a password.
|
||||||
|
*/
|
||||||
|
export async function publicInfo(base: string): Promise<PublicInfo> {
|
||||||
|
const res = await fetch(`${base}/System/Info/Public`, { headers: { Accept: 'application/json' } });
|
||||||
|
if (!res.ok) throw new UpstreamError(`not a reachable Jellyfin (${res.status})`, res.status);
|
||||||
|
const payload = (await res.json()) as { ServerName?: unknown; Version?: unknown };
|
||||||
|
return {
|
||||||
|
serverName: typeof payload.ServerName === 'string' ? payload.ServerName : null,
|
||||||
|
version: typeof payload.Version === 'string' ? payload.Version : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type CallOptions = {
|
||||||
|
/** Absolute path on the Jellyfin host, e.g. `/Items`. */
|
||||||
|
path: string;
|
||||||
|
method?: string;
|
||||||
|
/** Raw search string including the leading `?`, or empty. */
|
||||||
|
query?: string;
|
||||||
|
body?: BodyInit | null;
|
||||||
|
contentType?: string | null;
|
||||||
|
/** Defaults to `application/json`; image and video routes ask for the bytes instead. */
|
||||||
|
accept?: string;
|
||||||
|
/** Forwarded verbatim for seeking — Jellyfin answers 206 for both direct play and HLS segments. */
|
||||||
|
range?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The single door to Jellyfin. Everything the sidecar fetches goes through here. */
|
||||||
|
export async function callUpstream(cfg: UpstreamTarget, opts: CallOptions): Promise<Response> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Authorization: authHeader(cfg.deviceId, cfg.token),
|
||||||
|
Accept: opts.accept ?? 'application/json',
|
||||||
|
};
|
||||||
|
if (opts.contentType) headers['Content-Type'] = opts.contentType;
|
||||||
|
if (opts.range) headers.Range = opts.range;
|
||||||
|
|
||||||
|
const res = await fetch(`${cfg.base}${opts.path}${opts.query ?? ''}`, {
|
||||||
|
method: opts.method ?? 'GET',
|
||||||
|
headers,
|
||||||
|
body: opts.body ?? undefined,
|
||||||
|
redirect: 'manual',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
console.warn(`[jellyfin] ${opts.method ?? 'GET'} ${opts.path} → 401 (token rejected)`);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProbeResult = { ok: boolean; version: string | null; serverName: string | null; error?: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Is the stored token still good?
|
||||||
|
*
|
||||||
|
* `/System/Info` (no `/Public`) is the right probe precisely because it is authenticated: the public one
|
||||||
|
* answers 200 to a revoked token, so a probe against it would call a dead connection healthy.
|
||||||
|
*/
|
||||||
|
export async function probe(cfg: UpstreamTarget): Promise<ProbeResult> {
|
||||||
|
try {
|
||||||
|
const res = await callUpstream(cfg, { path: '/System/Info' });
|
||||||
|
if (res.status === 401) return { ok: false, version: null, serverName: null, error: 'access token rejected' };
|
||||||
|
if (!res.ok) return { ok: false, version: null, serverName: null, error: `server answered ${res.status}` };
|
||||||
|
const payload = (await res.json()) as { Version?: unknown; ServerName?: unknown };
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
version: typeof payload.Version === 'string' ? payload.Version : null,
|
||||||
|
serverName: typeof payload.ServerName === 'string' ? payload.ServerName : null,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, version: null, serverName: null, error: err instanceof Error ? err.message : 'unreachable' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,6 +71,8 @@ export type SidecarEvent =
|
|||||||
| { type: 'transmission:server'; port: number }
|
| { type: 'transmission:server'; port: number }
|
||||||
// InvoiceShelf — the sidecar reports where its HTTP server is listening (random port) on connect
|
// InvoiceShelf — the sidecar reports where its HTTP server is listening (random port) on connect
|
||||||
| { type: 'invoiceshelf:server'; port: number }
|
| { type: 'invoiceshelf:server'; port: number }
|
||||||
|
// Jellyfin — the sidecar reports where its HTTP server is listening (random port) on connect
|
||||||
|
| { type: 'jellyfin:server'; port: number }
|
||||||
// Photos (Immich) — the sidecar reports where its HTTP server is listening (random port) on connect
|
// Photos (Immich) — the sidecar reports where its HTTP server is listening (random port) on connect
|
||||||
| { type: 'photos:server'; port: number }
|
| { type: 'photos:server'; port: number }
|
||||||
// Memos — the sidecar reports where its HTTP server is listening (random port) on connect
|
// Memos — the sidecar reports where its HTTP server is listening (random port) on connect
|
||||||
|
|||||||
Reference in New Issue
Block a user