Files
jellyfin/db/schema.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

65 lines
3.6 KiB
TypeScript

import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { users } from 'officerdb/auth/schema';
// 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 offscale_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}`),
],
);