the platform pushes the account list (users:list, declared by userProvisioning in the manifest); this decides what to do about each one and does it. DECIDING IS SEPARATE FROM DOING. reconcile.ts is pure and has no idea what jellyfin is — which is why it can be tested against the real populations on alpha without creating anything. nine tests, built on the actual overlap: diogo and fedra already have jellyfin accounts, conceiba and guipinto do not, rezio and peter exist only on jellyfin. that test found a real bug before it shipped. officer's `username` was unique CASE-SENSITIVELY, so `diogo` and `DIOGO` could both exist here while colliding on jellyfin's uppercased NormalizedUsername index — and the first version handed both the same jellyfin account, because it only checked links that already existed rather than ones claimed during the pass. the platform side is now case-insensitive too, so the collision cannot be created in the first place; the guard stays because the constraint and the guard protect different moments. three outcomes, and the third is the point: LINK an account that already exists, CREATE one that does not, LEAVE ALONE a jellyfin account with no officer user. never delete. an absence in officer is indistinguishable from a list that has not been told about someone yet, and a wrong deletion costs somebody their watch history. `adopted` carries that distinction into the mapping table. false means officer made the account and may remove it; true means it existed first and was merely linked. NOBODY IS EVER TOLD THEIR PASSWORD. jellyfin has no must-change flag — verified against its openapi, UserPolicy has 44 properties and none of them is one — so a handed-over password stays valid forever. instead the generated one is used once, immediately, to mint that person's own access token, and discarded. the account exists and their officer session can already read their library. which also makes rule 4 unskippable: the sign-in that verifies the account is the same call that obtains the token, so there is no version of this that "verifies later". one failure does not stop the rest. a partially provisioned server is fixed by running again; a run that aborts on the first error leaves everyone after them unprovisioned and no record of who.
124 lines
6.2 KiB
TypeScript
124 lines
6.2 KiB
TypeScript
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;
|
|
}
|