import { listJellyfinAccounts, linkJellyfinAccount } from '../db'; import { provision, type ProvisionOutcome } from './provision'; import { getProvisionableUsers, hasProvisionableUsers } from './user-list'; // 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, }); } 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 }; } }