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}`), ], ); // ── Which Officer account owns which Jellyfin account ── // // Officer's users and Jellyfin's users are two populations that overlap. This records where they meet, so // "does this person already have an account" is answered locally rather than by asking Jellyfin and // matching on a name. // // ── Why `adopted` earns its column ── // // Two rows that look identical mean very different things. `adopted = false` is an account Officer // created and may therefore delete. `adopted = true` is an account that already existed and was merely // linked — deleting it would destroy someone's watch history and media access because their Officer // login was removed, which nobody asked for. // // On the live server this is not hypothetical: `diogo` and `fedra` had Jellyfin accounts before Officer // knew about them, and `rezio` and `peter` have accounts with no Officer login at all. // // ── Matching is case-folded, because Jellyfin's is ── // // Jellyfin stores `Username` as typed but enforces uniqueness on `NormalizedUsername`, an uppercased copy // under a UNIQUE index (verified in its own SQLite schema, 10.11.11). So `Diogo` and `diogo` cannot both // exist there, and matching on the raw name would create a duplicate that Jellyfin then refuses. The // normalised form is stored here too, so the comparison is a lookup rather than a convention someone has // to remember at every call site. export const jellyfinAccounts = pgTable( 'jellyfin_accounts', { id: serial('id').primaryKey(), userId: integer('user_id') .notNull() .references(() => users.id, { onDelete: 'cascade' }), /** Which server registry row this account lives on. Jellyfin users are per-instance. */ serverId: integer('server_id') .notNull() .references(() => jellyfinServers.id, { onDelete: 'cascade' }), /** Jellyfin's own user id — a GUID. The only handle its API accepts for policy and delete. */ jellyfinUserId: text('jellyfin_user_id').notNull(), /** As Jellyfin holds it, for display. */ jellyfinUsername: text('jellyfin_username').notNull(), /** Uppercased, mirroring Jellyfin's own `NormalizedUsername`. What matching compares. */ normalizedUsername: text('normalized_username').notNull(), /** False = Officer created it and may remove it. True = it pre-existed and was linked. */ adopted: boolean('adopted').notNull().default(false), /** * That person's OWN Jellyfin access token, encrypted — the credential their Officer session reads the * library with. * * This column is the whole point of the per-user design and it was missing until 2026-08-21. * `provision.ts` already minted exactly this token, said in its own header that it exists so "the * person's Officer session can already read their library", and then dropped it on the floor because * `linkJellyfinAccount` had nowhere to put it. Every non-owner therefore resolved to no config and got * a 503 setup form, while the owner worked — which reads as a permissions fault and is not one. * * NULLABLE, and the null case is normal rather than an error: a token can only be obtained by signing * in, so an ADOPTED account has none until somebody supplies its password. An Officer-created account * always has one, because provisioning knows the password it just set. */ accessToken: text('access_token'), // encrypted /** * The DeviceId this person's sessions are keyed by, `officer-`. * * Written with the token and meaningless without it. Distinct from the owner's device id on purpose — * Jellyfin lists them separately under Devices, so revoking one person's access does not sign * everybody else out. */ deviceId: text('device_id'), linkedAt: timestamp('linked_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [ // One Jellyfin account per Officer user per server. uniqueIndex, not unique().on() — drizzle-kit // re-creates named composite CONSTRAINTS on every push. See src/databases/CLAUDE.md. uniqueIndex('uq_jellyfin_accounts_user_server').on(table.userId, table.serverId), // And one Officer user per Jellyfin account: two people cannot share a login. uniqueIndex('uq_jellyfin_accounts_server_jellyfin_user').on(table.serverId, table.jellyfinUserId), ], );