Files
jellyfin/db/queries.ts
T
officer a7f1a71785 jellyfin, extracted from the platform as a plugin
Eighth plugin out, and the second resurrection: like soulseek, hono.ts carried
the router import and its mount commented out since 2026-08-13, with the
permission reduced to api: [], so the server booted clean while every panel
request 404'd. jellyfin_servers was also absent from the drizzle barrel, so
bun db:push would have dropped it.

Easier than soulseek in one specific way: mountPrefix derives /jellyfin from
the appName, which is exactly what the router already hardcoded, so no frontend
call site needed repointing. Soulseek needed 47 because its appName and its old
prefix differed. The frontend also already routed through one helper, so there
were five call sites rather than forty-seven.

Self-contained, unlike transmission and soulseek: jellyfin_servers is a
registry this plugin owns outright — label, URL, encrypted token, one active —
because more than one Jellyfin is a thing someone has, and more than one
Transmission is not.

Carries its own .prettierignore for the vendored hls.mjs, rather than leaving a
stale rule behind in the platform naming a path that no longer exists.
2026-08-19 03:17:32 +00:00

205 lines
7.2 KiB
TypeScript

import { eq, and, desc } from 'drizzle-orm';
import { db } from 'officerdb/db';
import { jellyfinServers } from './schema';
import { encryptSecret, decryptSecret } from 'officerdb/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('jellyfin', 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('jellyfin', 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('jellyfin', 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)));
}