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}`), ], );