Files
jellyfin/sidecar/provision-run.ts
T
pastilhas d7af39f834 wire the trigger: connecting an admin server provisions everyone
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.
2026-08-20 20:20:56 +00:00

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