Same gap as the photos plugin: users:list updated an in-memory array and nothing acted on it, so a person added to Officer later reached Video only if somebody re-ran provisioning by hand. The platform broadcasts on every create and delete and re-sends on every registration — nothing was listening. Safe to run unattended without changing what it does, because runProvisioning already draws the line in the right place: it creates the missing accounts and tokens them, and it RECORDS an adopted account without touching it. Minting for an adopted account resets a password its owner knows, and that stays behind includeAdopted, which only a person can ask for. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
228 lines
10 KiB
TypeScript
228 lines
10 KiB
TypeScript
import { OWNER_USER_ID } from 'officerdb';
|
|
import {
|
|
getActiveJellyfinCredentials,
|
|
listJellyfinAccounts,
|
|
linkJellyfinAccount,
|
|
setJellyfinAccountToken,
|
|
} from '../db';
|
|
import { mintTokenForLinked, provision, type ProvisionOutcome } from './provision';
|
|
import { getProvisionableUsers, hasProvisionableUsers } from './user-list';
|
|
import { invalidateConfig, normalizeBase, type UpstreamTarget } from './upstream';
|
|
|
|
// 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,
|
|
});
|
|
// The token, which is the half that makes the account reachable. Written as a second statement
|
|
// because `linkJellyfinAccount` does nothing on conflict — see `setJellyfinAccountToken`. Without
|
|
// this the person has a Jellyfin account, a link row, and no way to open either.
|
|
await setJellyfinAccountToken({
|
|
userId: made.userId,
|
|
serverId,
|
|
accessToken: made.token,
|
|
deviceId: made.deviceId,
|
|
});
|
|
invalidateConfig(made.userId);
|
|
}
|
|
|
|
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 };
|
|
}
|
|
}
|
|
|
|
export type BackfillReport = {
|
|
minted: string[];
|
|
/** Already had a token. Reported rather than silent, so a re-run visibly does nothing. */
|
|
alreadyLinked: string[];
|
|
/** Adopted accounts left alone, and why — the owner has to choose before these can be touched. */
|
|
skipped: { username: string; reason: string }[];
|
|
failed: { username: string; error: string }[];
|
|
};
|
|
|
|
/**
|
|
* Give a token to every linked account that has none.
|
|
*
|
|
* This is the repair for accounts provisioned BEFORE `jellyfin_accounts.access_token` existed. Those rows
|
|
* record a real Jellyfin account and a real pairing — the only missing piece is the credential, which was
|
|
* minted at the time and discarded. Nothing here creates or deletes an account; it only fills the gap.
|
|
*
|
|
* Idempotent, and cheap when there is nothing to do: an account that already has a token is skipped
|
|
* without a single upstream call, so this is safe to run on every re-provision.
|
|
*
|
|
* `includeAdopted` defaults to FALSE and that default is the safety property. Minting for an adopted
|
|
* account means resetting a password its owner knows and may use to sign into Jellyfin directly — see
|
|
* `mintTokenForLinked`. Turning it on is a decision about somebody else's account, so it is never the
|
|
* default and never inferred.
|
|
*/
|
|
export async function backfillAccountTokens(params: {
|
|
cfg: UpstreamTarget;
|
|
serverId: number;
|
|
includeAdopted?: boolean;
|
|
}): Promise<BackfillReport> {
|
|
const { cfg, serverId, includeAdopted = false } = params;
|
|
const report: BackfillReport = { minted: [], alreadyLinked: [], skipped: [], failed: [] };
|
|
|
|
for (const account of await listJellyfinAccounts(serverId)) {
|
|
if (account.hasToken) {
|
|
report.alreadyLinked.push(account.jellyfinUsername);
|
|
continue;
|
|
}
|
|
if (account.adopted && !includeAdopted) {
|
|
report.skipped.push({
|
|
username: account.jellyfinUsername,
|
|
reason: 'this account existed before Officer, so its password is theirs — resetting it would lock them out of Jellyfin itself',
|
|
});
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const { token, deviceId } = await mintTokenForLinked(cfg, {
|
|
officerUserId: account.userId,
|
|
jellyfinUserId: account.jellyfinUserId,
|
|
username: account.jellyfinUsername,
|
|
});
|
|
await setJellyfinAccountToken({ userId: account.userId, serverId, accessToken: token, deviceId });
|
|
// Their cached config says "nothing configured" and would hold that for up to a minute. They are
|
|
// most likely staring at the setup form right now, so drop it immediately.
|
|
invalidateConfig(account.userId);
|
|
report.minted.push(account.jellyfinUsername);
|
|
console.log(`[jellyfin:provision] minted a token for ${account.jellyfinUsername}`);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
report.failed.push({ username: account.jellyfinUsername, error: message });
|
|
console.error(`[jellyfin:provision] ${account.jellyfinUsername}: ${message}`);
|
|
}
|
|
}
|
|
|
|
return report;
|
|
}
|
|
|
|
/**
|
|
* Provision because the ACCOUNT LIST changed, rather than because somebody asked.
|
|
*
|
|
* This is what makes an Officer account created next month reach Video without anyone remembering that a
|
|
* plugin needed telling. `users:list` arrives on every create and delete — and on every sidecar
|
|
* registration, so a restart is also a reconciliation.
|
|
*
|
|
* `runProvisioning` logs its plan either way, so a restart prints a few lines describing the world as this
|
|
* plugin sees it; only the "something changed" summary below is suppressed when nothing did.
|
|
*
|
|
* Safe unattended because of what `runProvisioning` already does and does not do: it creates the accounts
|
|
* that are missing and gives each a token, and it RECORDS an adopted account without touching it. Minting
|
|
* for an adopted account is the destructive path — it resets a password its owner knows — and that stays
|
|
* behind `backfillAccountTokens({ includeAdopted: true })`, which only a person can ask for.
|
|
*
|
|
* Never throws. It runs detached from the command handler, and a plugin failing to provision must not take
|
|
* down the socket carrying the message.
|
|
*/
|
|
export async function provisionForAccountChange(): Promise<void> {
|
|
try {
|
|
// The server members inherit is the owner's selected one. A member who registered a Jellyfin of their
|
|
// own is not a provisioning target — that is their connection, not a shared library.
|
|
const creds = await getActiveJellyfinCredentials(OWNER_USER_ID);
|
|
if (!creds) return; // nothing connected yet; connecting is itself a provisioning trigger
|
|
|
|
const report = await runProvisioning({
|
|
userId: OWNER_USER_ID,
|
|
serverId: creds.id,
|
|
base: normalizeBase(creds.url),
|
|
token: creds.accessToken,
|
|
jellyfinUserId: creds.jellyfinUserId,
|
|
deviceId: creds.deviceId,
|
|
});
|
|
|
|
if (!report.ran) {
|
|
console.log(`[jellyfin:provision] not run — ${report.reason}`);
|
|
return;
|
|
}
|
|
if (report.created === 0 && report.linked === 0 && report.failed.length === 0) return; // nothing changed
|
|
|
|
console.log(
|
|
`[jellyfin:provision] account list changed: ${report.created} created, ${report.linked} linked`,
|
|
);
|
|
if (report.linked > 0) {
|
|
console.log(
|
|
'[jellyfin:provision] a linked account had a Jellyfin login already and has no token yet — ' +
|
|
'POST /_config/<id>/provision with { "includeAdopted": true } to give it one.',
|
|
);
|
|
}
|
|
} catch (error) {
|
|
console.error('[jellyfin:provision] automatic run failed:', error instanceof Error ? error.message : error);
|
|
}
|
|
}
|