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:
@@ -152,6 +152,17 @@ export {
|
||||
recordInvoiceshelfProbe,
|
||||
} 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 {
|
||||
listPhotosAccounts,
|
||||
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 './headscale';
|
||||
export * from './invoiceshelf';
|
||||
export * from './jellyfin';
|
||||
export * from './music';
|
||||
export * from './notify';
|
||||
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}`),
|
||||
],
|
||||
);
|
||||
Reference in New Issue
Block a user