Files
jellyfin/sidecar/account-view.ts
T
pastilhasandClaude Opus 5 e8aa85a35b settings: show who exists on each side, and which pairs are the same person
Provisioning was invisible. Connecting a server can create accounts, adopt accounts and leave
accounts alone, and the only record of which happened was a JSON field in a response nobody reads and
rows in a table nobody can see. "Did it work" could only be answered by opening Jellyfin and counting.

The nav entry is Settings rather than Connection, since it is now more than the connection.

The join is computed in the sidecar and shares `normalize` with the reconcile planner, so the screen
cannot disagree with the matching rule about whether Diogo and diogo are one person — a settings page
saying two would be lying about what the next connection will do. It joins by the link ROW first and
by folded name only where none exists: a recorded pairing is a fact, a matching name is a candidate.

Four states, because they mean different things — linked (adopted or created), names match but not
recorded yet, Officer account with no Jellyfin one, and a Jellyfin account with no Officer user. The
last is never touched, and a linked pairing whose Jellyfin account has vanished is shown as such
rather than silently re-matched by name.

"Not known" is kept distinct from "none": if the platform has not sent the account list, or the
server cannot be reached, it says so instead of rendering an empty table that would read as a fact
about the server.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 22:01:43 +00:00

114 lines
4.4 KiB
TypeScript

import 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;
};
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 });
/**
* 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): Promise<AccountView> {
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 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<string>();
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,
});
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,
});
continue;
}
pairs.push({ officer: officerOf(user), jellyfin: null, linked: false, adopted: false, matchable: false });
}
for (const j of jellyfin) {
if (claimed.has(j.Id)) continue;
pairs.push({
officer: null,
jellyfin: { id: j.Id, name: j.Name },
linked: false,
adopted: false,
matchable: false,
});
}
return {
known: true,
pairs,
counts: {
linked: pairs.filter((p) => p.linked).length,
officerOnly: pairs.filter((p) => p.officer && !p.jellyfin).length,
jellyfinOnly: pairs.filter((p) => !p.officer).length,
},
};
}