import type { ProvisionableUser } from 'officerdb'; // Deciding what to do about each account, with no side effects. // // Separated from the doing on purpose. The execution talks to a live Jellyfin and cannot be rehearsed; // the decision is where every mistake that matters lives, and it can be checked against the real names on // the real server without creating anything. // // ── The three outcomes, and why the third is not "delete" ── // // LINK an Officer user whose username already exists on Jellyfin. Record the pairing; create nothing. // CREATE an Officer user with no Jellyfin account. Make one. // LEAVE a Jellyfin account with no Officer user. Do nothing at all, and say so. // // On the live server all three are populated: `diogo` and `fedra` had Jellyfin accounts before Officer // knew about them, `conceiba` and `guipinto` have none, and `rezio` and `peter` exist only on Jellyfin. // // The third outcome is the one worth stating in code rather than leaving implicit. Deleting a Jellyfin // account because nobody holds a matching Officer login would destroy a person's watch history and media // access on the strength of an absence — and an absence is exactly what an incomplete list looks like. // So they are reported, never touched, and the report is the feature: it is how the owner notices that // two accounts exist which Officer does not manage. // // ── Matching is case-folded ── // // Jellyfin stores `Username` as typed and enforces uniqueness on `NormalizedUsername`, an uppercased copy // under a UNIQUE index (read from its own SQLite schema, 10.11.11). So a raw-name comparison would decide // `Diogo` needs creating when `diogo` exists, and Jellyfin would then refuse the create on that index — // an install failing on a constraint nobody could see from here. /** A Jellyfin account as `GET /Users` reports it. Only the fields a decision needs. */ export type JellyfinUser = { Id: string; Name: string }; /** An existing pairing, so a second run does not re-decide what is already recorded. */ export type ExistingLink = { userId: number; jellyfinUserId: string }; export type ReconcilePlan = { /** Officer users whose Jellyfin account already exists — record the pairing, create nothing. */ link: { user: ProvisionableUser; jellyfin: JellyfinUser }[]; /** Officer users with no Jellyfin account. */ create: ProvisionableUser[]; /** Jellyfin accounts with no Officer user. Reported, never touched. */ leave: JellyfinUser[]; /** Already linked, and still valid on both sides. Nothing to do. */ unchanged: ExistingLink[]; /** * Links whose Jellyfin account has vanished — deleted in Jellyfin's own UI, most likely. * * Separated from `create` even though the remedy is the same, because they are different events: one is * a new person, the other is someone's account disappearing behind Officer's back. Collapsing them * would make the second invisible. */ brokenLinks: ExistingLink[]; }; /** Jellyfin's own rule, applied on this side so the two agree. */ export const normalize = (name: string): string => name.trim().toUpperCase(); /** * Work out what should happen. Nothing here talks to anything. * * `users` is what the platform pushed (`users:list`), already excluding the owner — they hold the admin * account this whole flow authenticates as, and provisioning them would be a second admin nobody asked for. */ export function planReconcile(params: { users: ProvisionableUser[]; jellyfinUsers: JellyfinUser[]; existing: ExistingLink[]; }): ReconcilePlan { const { users, jellyfinUsers, existing } = params; const jellyfinByNormalized = new Map(jellyfinUsers.map((j) => [normalize(j.Name), j])); const jellyfinById = new Map(jellyfinUsers.map((j) => [j.Id, j])); const linkedUserIds = new Set(existing.map((e) => e.userId)); const linkedJellyfinIds = new Set(existing.map((e) => e.jellyfinUserId)); /** Jellyfin accounts spoken for — pre-existing links, plus anything claimed during this pass. */ const claimed = new Set(linkedJellyfinIds); const plan: ReconcilePlan = { link: [], create: [], leave: [], unchanged: [], brokenLinks: [] }; for (const link of existing) { (jellyfinById.has(link.jellyfinUserId) ? plan.unchanged : plan.brokenLinks).push(link); } for (const user of users) { if (linkedUserIds.has(user.id)) continue; // already recorded — handled above // An Officer account with no username cannot be matched or created: Jellyfin authenticates by name and // has no concept of an email login. Skipped rather than guessed at from the email's local part, which // would silently claim a name the person did not choose. if (!user.username) continue; const match = jellyfinByNormalized.get(normalize(user.username)); if (match && !claimed.has(match.Id)) { plan.link.push({ user, jellyfin: match }); // Claimed NOW, not just checked against what was already linked. Officer's `username` is unique // case-SENSITIVELY, so `diogo` and `DIOGO` can both exist here while colliding on Jellyfin's // uppercased index — and without this the pass hands both the same account. claimed.add(match.Id); } else if (!match) { plan.create.push(user); } // A match already claimed falls through deliberately: it is neither this user's account to adopt nor // safe to duplicate, and `leave` below reports it for a human to settle. } for (const jellyfin of jellyfinUsers) { if (!claimed.has(jellyfin.Id)) plan.leave.push(jellyfin); } return plan; } /** One line per outcome, for the install log and the panel. Says what will happen before it happens. */ export function describePlan(plan: ReconcilePlan): string[] { const lines: string[] = []; for (const { user, jellyfin } of plan.link) lines.push(`link ${user.username} → existing Jellyfin "${jellyfin.Name}"`); for (const user of plan.create) lines.push(`create ${user.username}`); for (const jellyfin of plan.leave) lines.push(`leave "${jellyfin.Name}" alone — no Officer account`); for (const broken of plan.brokenLinks) lines.push(`link for user ${broken.userId} is broken — Jellyfin account is gone`); if (plan.unchanged.length) lines.push(`${plan.unchanged.length} already linked`); return lines; }