import { getUserById, type ProvisionableUser } from 'officerdb'; import { listJellyfinAccounts } from '../db'; import { listJellyfinUsers } from './provision'; import { normalize, type JellyfinUser } from './reconcile'; import { getProvisionableUsers, hasProvisionableUsers } from './user-list'; import type { UpstreamTarget } from './upstream'; // Who exists on each side, and which pairs are the same person. // // Read-only, and deliberately no part of provisioning: this answers "what is true now", where // `planReconcile` answers "what would happen if we ran". They share `normalize` so the screen cannot // disagree with the matching rule about whether `Diogo` and `diogo` are one person — a settings page that // showed them as two would be lying about what the next connection will do. // // The join is by the LINK ROW where one exists, and by the folded name where one does not. That ordering // matters: a recorded pairing is a fact, and a name that happens to match is only a candidate. export type AccountPair = { officer: { id: number; username: string | null; name: string | null; role: string } | null; jellyfin: { id: string; name: string } | null; /** A row in `jellyfin_accounts` says these two are the same person. */ linked: boolean; /** The Jellyfin account existed before Officer knew about it. Never deleted on deprovision. */ adopted: boolean; /** Names match but nothing is recorded — what connecting would adopt. */ matchable: boolean; /** The admin account this connection authenticates as. True on exactly one pair, or none. */ isConnection: boolean; }; export type AccountView = | { known: false; reason: string } | { known: true; pairs: AccountPair[]; counts: { linked: number; officerOnly: number; jellyfinOnly: number } }; const officerOf = (u: ProvisionableUser) => ({ id: u.id, username: u.username, name: u.name, role: u.role }); // ── The owner is missing from `officer`, and that is correct ── // // `listProvisionableUsers` excludes the owner deliberately: they hold the admin account this whole flow // authenticates as, and provisioning them would be a second admin nobody asked for. But this view is not // the provisioning list — it is "who exists on each side" — so leaving them out rendered the owner's own // Jellyfin account as "not an Officer account", which is the one line on the screen that could not // possibly be true. Reported by the owner, about their own name. // // So the viewer is looked up directly and joined onto the account this connection signs in as — known // exactly from `jellyfinUserId` on the server row, never matched by name. /** * Build the view for one server. * * `known: false` when the platform has not sent the account list. That is not the same as "no users" and * the screen must not render it as an empty table — it means this plugin is not entitled to the list or * the push has not arrived, and showing "0 Officer accounts" would read as a fact about the server. */ export async function buildAccountView( cfg: UpstreamTarget, serverId: number, viewerUserId: number, ): Promise { if (!hasProvisionableUsers()) { return { known: false, reason: 'the platform has not sent the account list yet' }; } const officer = getProvisionableUsers(); const jellyfin: JellyfinUser[] = await listJellyfinUsers(cfg); const links = await listJellyfinAccounts(serverId); const viewer = await getUserById(viewerUserId).catch(() => undefined); const linkByUserId = new Map(links.map((l) => [l.userId, l])); const jellyfinById = new Map(jellyfin.map((j) => [j.Id, j])); const jellyfinByName = new Map(jellyfin.map((j) => [normalize(j.Name), j])); const claimed = new Set(); const pairs: AccountPair[] = []; for (const user of officer) { const link = linkByUserId.get(user.id); const byName = user.username ? jellyfinByName.get(normalize(user.username)) : undefined; if (link) { // The linked account can be GONE — deleted in Jellyfin's own UI. Reported as an Officer account with // no Jellyfin side rather than quietly re-matching it by name, because those are different events // and only one of them is somebody's account disappearing. const live = jellyfinById.get(link.jellyfinUserId); if (live) claimed.add(live.Id); pairs.push({ officer: officerOf(user), jellyfin: live ? { id: live.Id, name: live.Name } : null, linked: true, adopted: link.adopted, matchable: false, isConnection: false, }); continue; } if (byName && !claimed.has(byName.Id)) { claimed.add(byName.Id); pairs.push({ officer: officerOf(user), jellyfin: { id: byName.Id, name: byName.Name }, linked: false, adopted: false, matchable: true, isConnection: false, }); continue; } pairs.push({ officer: officerOf(user), jellyfin: null, linked: false, adopted: false, matchable: false, isConnection: false, }); } for (const j of jellyfin) { if (claimed.has(j.Id)) continue; // The account this connection signs in as. Its Officer side is the viewer, who is absent from // `officer` by design — see above. if (j.Id === cfg.jellyfinUserId) { pairs.push({ officer: viewer ? { id: viewer.id, username: viewer.username, name: viewer.name, role: viewer.role } : null, jellyfin: { id: j.Id, name: j.Name }, linked: false, adopted: false, matchable: false, isConnection: true, }); continue; } pairs.push({ officer: null, jellyfin: { id: j.Id, name: j.Name }, linked: false, adopted: false, matchable: false, isConnection: false, }); } return { known: true, pairs, counts: { linked: pairs.filter((p) => p.linked).length, officerOnly: pairs.filter((p) => p.officer && !p.jellyfin).length, // The connection's own account is not "Jellyfin only" — it is the one account both sides agree on. jellyfinOnly: pairs.filter((p) => !p.officer && !p.isConnection).length, }, }; }