Files
jellyfin/sidecar/account-view.ts
T
pastilhasandClaude Opus 5 591c0dadc3 settings: the owner's own account is not "not an Officer account"
pastilhas rendered on the Jellyfin side with nothing opposite it, labelled as belonging to nobody —
reported by the owner, about their own name, which is the only line on that screen that could not
possibly be true.

The cause is a correct exclusion used for the wrong question. `listProvisionableUsers` leaves the
owner out on purpose: they hold the admin account this 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", and an account absent from the input read as an account that does not exist.

So the viewer is looked up directly and joined onto the account the connection signs in as — known
exactly from `jellyfinUserId` on the server row, never matched by name. It gets its own state rather
than being called "linked", because there is no row in jellyfin_accounts and there should not be: it
is the credential, not a provisioned account. It is also excluded from the "Jellyfin only" count,
which was counting the one account both sides agree on as belonging to neither.

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

158 lines
6.1 KiB
TypeScript

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<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 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<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,
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,
},
};
}