the decision and the execution existed; nothing called them. connecting a server would have given a working media connection and zero accounts. the trigger is registering an ADMIN server, not installing the plugin. at install there is no credential, so there is nothing to provision with — and this is also the right moment for a re-run, since a revoked token or a moved server both come back through the same door. the sidecar now holds the pushed account list in memory. each users:list REPLACES what was held rather than merging: it is the platform's current truth, not a cache. persisting it would create a second copy of the platform's users that could disagree with it, and the recovery from disagreement is re-reading the platform anyway. `hasProvisionableUsers()` distinguishes never-told from told-there-is-nobody. an empty list means a server with only an owner; no list at all means the push has not arrived or the plugin is not entitled. provisioning nothing is right for the first and a silent failure for the second. awaited, so the response says what happened instead of the owner finding it in a log later. and it never throws: registering the server already succeeded by then, and losing it to a provisioning error would leave them with no media access AND no accounts, which is worse than the accounts alone. mapping rows are written one at a time as each result is known, not batched at the end. a create that succeeds upstream but whose row fails to write leaves an orphan account officer will try to create again — the narrower that window, the better. found while wiring: the first edit landed the block in editServer as well as addServer, where `auth` does not exist. caught by tsgo, removed.
90 lines
3.8 KiB
TypeScript
90 lines
3.8 KiB
TypeScript
import { listJellyfinAccounts, linkJellyfinAccount } from '../db';
|
|
import { provision, type ProvisionOutcome } from './provision';
|
|
import { getProvisionableUsers, hasProvisionableUsers } from './user-list';
|
|
|
|
// The one place that turns "an admin just connected" into accounts on the server.
|
|
//
|
|
// Sits between `addServer` and `provision()` so neither has to know about the other: `provision` talks to
|
|
// Jellyfin and returns what it did, this writes it down, and `addServer` only has to say when.
|
|
//
|
|
// ── Why it never throws ──
|
|
//
|
|
// A failure here must not fail the connection. Registering the server is the thing the owner asked for and
|
|
// it has already succeeded by the time this runs — losing it because provisioning went wrong would leave
|
|
// them with no media access AND no accounts, which is strictly worse than the accounts alone. So the
|
|
// outcome is reported in the response and the server stays registered either way.
|
|
|
|
export type ProvisioningReport =
|
|
| { ran: false; reason: string }
|
|
| { ran: true; linked: number; created: number; untouched: number; failed: { username: string; error: string }[]; log: string[] };
|
|
|
|
export async function runProvisioning(params: {
|
|
userId: number;
|
|
serverId: number;
|
|
base: string;
|
|
token: string;
|
|
/** The ADMIN's own Jellyfin id — part of `UpstreamTarget`, not who is being provisioned. */
|
|
jellyfinUserId: string;
|
|
deviceId: string;
|
|
}): Promise<ProvisioningReport> {
|
|
const { userId, serverId, base, token, jellyfinUserId, deviceId } = params;
|
|
|
|
// Never told, versus told there is nobody. The first means the push has not arrived or this plugin is
|
|
// not entitled to it; the second is a server with only an owner. Provisioning nothing is correct for
|
|
// one of those and a silent failure for the other.
|
|
if (!hasProvisionableUsers()) {
|
|
return { ran: false, reason: 'the platform has not sent the account list yet' };
|
|
}
|
|
|
|
const users = getProvisionableUsers();
|
|
if (users.length === 0) return { ran: false, reason: 'no accounts to provision — this server has only an owner' };
|
|
|
|
try {
|
|
const existing = await listJellyfinAccounts(serverId);
|
|
const outcome: ProvisionOutcome = await provision({
|
|
cfg: { base, token, jellyfinUserId, deviceId },
|
|
users,
|
|
existing: existing.map((e) => ({ userId: e.userId, jellyfinUserId: e.jellyfinUserId })),
|
|
});
|
|
|
|
// Written AFTER the upstream work, one row at a time. A create that succeeded upstream but whose row
|
|
// fails to write is the bad case — it leaves an orphan Jellyfin account Officer will try to create
|
|
// again — so each is recorded as soon as it is known rather than in one batch at the end.
|
|
for (const link of outcome.linked) {
|
|
// `adopted: true` — this account existed before Officer. It must never be deleted on deprovision.
|
|
await linkJellyfinAccount({
|
|
userId: link.userId,
|
|
serverId,
|
|
jellyfinUserId: link.jellyfinUserId,
|
|
jellyfinUsername: link.username,
|
|
adopted: true,
|
|
});
|
|
}
|
|
for (const made of outcome.created) {
|
|
await linkJellyfinAccount({
|
|
userId: made.userId,
|
|
serverId,
|
|
jellyfinUserId: made.jellyfinUserId,
|
|
jellyfinUsername: made.username,
|
|
adopted: false,
|
|
});
|
|
}
|
|
|
|
for (const line of outcome.log) console.log(`[jellyfin:provision] ${line}`);
|
|
for (const f of outcome.failed) console.error(`[jellyfin:provision] ${f.username}: ${f.error}`);
|
|
|
|
return {
|
|
ran: true,
|
|
linked: outcome.linked.length,
|
|
created: outcome.created.length,
|
|
untouched: outcome.untouched.length,
|
|
failed: outcome.failed,
|
|
log: outcome.log,
|
|
};
|
|
} catch (error) {
|
|
const reason = error instanceof Error ? error.message : String(error);
|
|
console.error('[jellyfin:provision] failed:', reason);
|
|
return { ran: false, reason };
|
|
}
|
|
}
|