import { OWNER_USER_ID } from 'officerdb'; import { getActiveJellyfinCredentials, listJellyfinAccounts, linkJellyfinAccount, setJellyfinAccountToken, } from '../db'; import { mintTokenForLinked, provision, type ProvisionOutcome } from './provision'; import { getProvisionableUsers, hasProvisionableUsers } from './user-list'; import { invalidateConfig, normalizeBase, type UpstreamTarget } from './upstream'; // The one place that turns "an admin just connected" into accounts on the server. // // Sits between `addServer` and `provision()` so neither has to know about the other: `provision` talks to // Jellyfin and returns what it did, this writes it down, and `addServer` only has to say when. // // ── Why it never throws ── // // A failure here must not fail the connection. Registering the server is the thing the owner asked for and // it has already succeeded by the time this runs — losing it because provisioning went wrong would leave // them with no media access AND no accounts, which is strictly worse than the accounts alone. So the // outcome is reported in the response and the server stays registered either way. export type ProvisioningReport = | { ran: false; reason: string } | { ran: true; linked: number; created: number; untouched: number; failed: { username: string; error: string }[]; log: string[] }; export async function runProvisioning(params: { userId: number; serverId: number; base: string; token: string; /** The ADMIN's own Jellyfin id — part of `UpstreamTarget`, not who is being provisioned. */ jellyfinUserId: string; deviceId: string; }): Promise { const { userId, serverId, base, token, jellyfinUserId, deviceId } = params; // Never told, versus told there is nobody. The first means the push has not arrived or this plugin is // not entitled to it; the second is a server with only an owner. Provisioning nothing is correct for // one of those and a silent failure for the other. if (!hasProvisionableUsers()) { return { ran: false, reason: 'the platform has not sent the account list yet' }; } const users = getProvisionableUsers(); if (users.length === 0) return { ran: false, reason: 'no accounts to provision — this server has only an owner' }; try { const existing = await listJellyfinAccounts(serverId); const outcome: ProvisionOutcome = await provision({ cfg: { base, token, jellyfinUserId, deviceId }, users, existing: existing.map((e) => ({ userId: e.userId, jellyfinUserId: e.jellyfinUserId })), }); // Written AFTER the upstream work, one row at a time. A create that succeeded upstream but whose row // fails to write is the bad case — it leaves an orphan Jellyfin account Officer will try to create // again — so each is recorded as soon as it is known rather than in one batch at the end. for (const link of outcome.linked) { // `adopted: true` — this account existed before Officer. It must never be deleted on deprovision. await linkJellyfinAccount({ userId: link.userId, serverId, jellyfinUserId: link.jellyfinUserId, jellyfinUsername: link.username, adopted: true, }); } for (const made of outcome.created) { await linkJellyfinAccount({ userId: made.userId, serverId, jellyfinUserId: made.jellyfinUserId, jellyfinUsername: made.username, adopted: false, }); // The token, which is the half that makes the account reachable. Written as a second statement // because `linkJellyfinAccount` does nothing on conflict — see `setJellyfinAccountToken`. Without // this the person has a Jellyfin account, a link row, and no way to open either. await setJellyfinAccountToken({ userId: made.userId, serverId, accessToken: made.token, deviceId: made.deviceId, }); invalidateConfig(made.userId); } for (const line of outcome.log) console.log(`[jellyfin:provision] ${line}`); for (const f of outcome.failed) console.error(`[jellyfin:provision] ${f.username}: ${f.error}`); return { ran: true, linked: outcome.linked.length, created: outcome.created.length, untouched: outcome.untouched.length, failed: outcome.failed, log: outcome.log, }; } catch (error) { const reason = error instanceof Error ? error.message : String(error); console.error('[jellyfin:provision] failed:', reason); return { ran: false, reason }; } } export type BackfillReport = { minted: string[]; /** Already had a token. Reported rather than silent, so a re-run visibly does nothing. */ alreadyLinked: string[]; /** Adopted accounts left alone, and why — the owner has to choose before these can be touched. */ skipped: { username: string; reason: string }[]; failed: { username: string; error: string }[]; }; /** * Give a token to every linked account that has none. * * This is the repair for accounts provisioned BEFORE `jellyfin_accounts.access_token` existed. Those rows * record a real Jellyfin account and a real pairing — the only missing piece is the credential, which was * minted at the time and discarded. Nothing here creates or deletes an account; it only fills the gap. * * Idempotent, and cheap when there is nothing to do: an account that already has a token is skipped * without a single upstream call, so this is safe to run on every re-provision. * * `includeAdopted` defaults to FALSE and that default is the safety property. Minting for an adopted * account means resetting a password its owner knows and may use to sign into Jellyfin directly — see * `mintTokenForLinked`. Turning it on is a decision about somebody else's account, so it is never the * default and never inferred. */ export async function backfillAccountTokens(params: { cfg: UpstreamTarget; serverId: number; includeAdopted?: boolean; }): Promise { const { cfg, serverId, includeAdopted = false } = params; const report: BackfillReport = { minted: [], alreadyLinked: [], skipped: [], failed: [] }; for (const account of await listJellyfinAccounts(serverId)) { if (account.hasToken) { report.alreadyLinked.push(account.jellyfinUsername); continue; } if (account.adopted && !includeAdopted) { report.skipped.push({ username: account.jellyfinUsername, reason: 'this account existed before Officer, so its password is theirs — resetting it would lock them out of Jellyfin itself', }); continue; } try { const { token, deviceId } = await mintTokenForLinked(cfg, { officerUserId: account.userId, jellyfinUserId: account.jellyfinUserId, username: account.jellyfinUsername, }); await setJellyfinAccountToken({ userId: account.userId, serverId, accessToken: token, deviceId }); // Their cached config says "nothing configured" and would hold that for up to a minute. They are // most likely staring at the setup form right now, so drop it immediately. invalidateConfig(account.userId); report.minted.push(account.jellyfinUsername); console.log(`[jellyfin:provision] minted a token for ${account.jellyfinUsername}`); } catch (error) { const message = error instanceof Error ? error.message : String(error); report.failed.push({ username: account.jellyfinUsername, error: message }); console.error(`[jellyfin:provision] ${account.jellyfinUsername}: ${message}`); } } return report; } /** * Provision because the ACCOUNT LIST changed, rather than because somebody asked. * * This is what makes an Officer account created next month reach Video without anyone remembering that a * plugin needed telling. `users:list` arrives on every create and delete — and on every sidecar * registration, so a restart is also a reconciliation. * * `runProvisioning` logs its plan either way, so a restart prints a few lines describing the world as this * plugin sees it; only the "something changed" summary below is suppressed when nothing did. * * Safe unattended because of what `runProvisioning` already does and does not do: it creates the accounts * that are missing and gives each a token, and it RECORDS an adopted account without touching it. Minting * for an adopted account is the destructive path — it resets a password its owner knows — and that stays * behind `backfillAccountTokens({ includeAdopted: true })`, which only a person can ask for. * * Never throws. It runs detached from the command handler, and a plugin failing to provision must not take * down the socket carrying the message. */ export async function provisionForAccountChange(): Promise { try { // The server members inherit is the owner's selected one. A member who registered a Jellyfin of their // own is not a provisioning target — that is their connection, not a shared library. const creds = await getActiveJellyfinCredentials(OWNER_USER_ID); if (!creds) return; // nothing connected yet; connecting is itself a provisioning trigger const report = await runProvisioning({ userId: OWNER_USER_ID, serverId: creds.id, base: normalizeBase(creds.url), token: creds.accessToken, jellyfinUserId: creds.jellyfinUserId, deviceId: creds.deviceId, }); if (!report.ran) { console.log(`[jellyfin:provision] not run — ${report.reason}`); return; } if (report.created === 0 && report.linked === 0 && report.failed.length === 0) return; // nothing changed console.log( `[jellyfin:provision] account list changed: ${report.created} created, ${report.linked} linked`, ); if (report.linked > 0) { console.log( '[jellyfin:provision] a linked account had a Jellyfin login already and has no token yet — ' + 'POST /_config//provision with { "includeAdopted": true } to give it one.', ); } } catch (error) { console.error('[jellyfin:provision] automatic run failed:', error instanceof Error ? error.message : error); } }