sonarr reached its download clients as nzbget:6789 and qbittorrent:8112 and its indexers through jackett:9117 or indexers.zurag.be - HAL container names and a public route, typed into its database by hand. Nothing on the mesh answers those names. It now requires nzbget-api, qbittorrent-api and jackett-api. A run-once step (downloads/, declared last, restart-on its bindings, credentials and settings) writes host, port, TLS, base path, user and credential into sonarr through sonarr's own API: - A download client is the mesh's when its name is downloads.<provision>.name and its kind the provider's; it is registered when missing. A jackett feed is the mesh's when its host is the bound one, one the step bound before, or one in downloads.jackett-api.adopt-hosts; the jackett indexers in downloads.jackett-api.indexers are registered when missing. Every other entry is left alone, and nothing is ever deleted. - Only connection fields, only when they differ. The stored password is masked, so the app tests the entry with the credential it holds; only if that fails is the delivered one written. Categories, priorities and "enabled" are never touched. - Each credential is tried against its provider first. A minted value (nothing accepted yet) is never written; the step exits 1 naming the exact secret accept. The step is byte-identical in sonarr, radarr, lidarr and bookshelf (the same Servarr API, v3 or v1): each module builds from its own directory, so each carries a copy, and test/downloads.test.ts fails if a sibling's copy differs. Verified: strict typecheck, the Dockerfile build, 14 unit tests; and the compiled step against fresh pinned sonarr/radarr/lidarr/bookshelf with throwaway nzbget, qBittorrent and jackett - minted credentials refused with nothing written, accepted ones registered and tested by the app, a migration-shaped radarr repointed, reruns unchanged.
189 lines
7.3 KiB
TypeScript
189 lines
7.3 KiB
TypeScript
// The downloads step — run once by the host after the app's server starts, and again whenever a
|
|
// binding, a pair credential or the step's settings change (the container's `restart-on`,
|
|
// novox/hq ADR 0099). Byte-identical in sonarr, radarr, lidarr and bookshelf; see settings.ts.
|
|
//
|
|
// **A step, not a loop**: everything it does is a function of files the mesh writes, and the host
|
|
// already knows when they change. It connects to no broker.
|
|
//
|
|
// Exits non-zero when anything could not be put right — a refused credential, an unreachable
|
|
// provider, an entry the app would not save — so the node reports the step failed and the host
|
|
// runs it again on the next apply. Declared last in the manifest, so its failing gates nothing
|
|
// else of the module's (novox/hq ADR 0136).
|
|
//
|
|
// Reads, in MESH_DOWNLOADS_DIR, `<provision>.json` (the binding), `<provision>.secret` (the pair
|
|
// credential) and the settings file; remembers in MESH_DOWNLOADS_MEMORY the jackett hosts it has
|
|
// pointed feeds at, so a jackett that moves takes its feeds with it. Never prints a credential.
|
|
|
|
import { mkdir, rename, writeFile } from "node:fs/promises";
|
|
import { dirname, join } from "node:path";
|
|
|
|
import {
|
|
CLIENTS,
|
|
JACKETT,
|
|
acceptRemedy,
|
|
appConfig,
|
|
appReady,
|
|
listEntries,
|
|
providerTakes,
|
|
readIfThere,
|
|
readJson,
|
|
reconcileClient,
|
|
reconcileIndexers,
|
|
scrub,
|
|
stepSettings,
|
|
wanted,
|
|
type App,
|
|
type Binding,
|
|
type Http,
|
|
type Entry,
|
|
type Outcome,
|
|
type Took,
|
|
} from "./settings.js";
|
|
|
|
const module = process.env.MESH_DOWNLOADS_APP ?? "app";
|
|
const dir = process.env.MESH_DOWNLOADS_DIR ?? "/run/downloads";
|
|
const settingsFile = process.env.MESH_DOWNLOADS_SETTINGS ?? join(dir, "downloads.json");
|
|
const memoryFile = process.env.MESH_DOWNLOADS_MEMORY ?? "/var/lib/downloads/memory.json";
|
|
const waitSeconds = Number(process.env.MESH_DOWNLOADS_WAIT_SECONDS ?? "180");
|
|
const tag = `[${module}-downloads]`;
|
|
|
|
const http: Http = { fetch: (u, init) => fetch(u, init) };
|
|
const secrets: string[] = [];
|
|
const say = (line: string) => console.log(scrub(`${tag} ${line}`, secrets));
|
|
const fail = (line: string) => console.error(scrub(`${tag} ${line}`, secrets));
|
|
|
|
const config = appConfig((await readIfThere(process.env.MESH_DOWNLOADS_APP_CONFIG)) ?? "");
|
|
if (!config.apiKey) {
|
|
fail(`no API key in ${module}'s config.xml yet — ${module} writes it on its first start; the step runs again on the next apply`);
|
|
process.exit(1);
|
|
}
|
|
secrets.push(config.apiKey);
|
|
const app: App = {
|
|
module,
|
|
url: `${(process.env.MESH_DOWNLOADS_APP_URL ?? "http://127.0.0.1").replace(/\/$/, "")}${config.urlBase}`,
|
|
apiKey: config.apiKey,
|
|
api: process.env.MESH_DOWNLOADS_API ?? "v3",
|
|
};
|
|
const settings = stepSettings(await readJson(settingsFile));
|
|
|
|
if (!(await appReady(http, app, waitSeconds * 1000))) {
|
|
fail(`${module} did not answer at ${app.url} within ${waitSeconds}s`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const outcomes: Outcome[] = [];
|
|
|
|
// The download clients.
|
|
let clients: Entry[];
|
|
try {
|
|
clients = await listEntries(http, app, "downloadclient");
|
|
} catch (err) {
|
|
fail(err instanceof Error ? err.message : String(err));
|
|
process.exit(1);
|
|
}
|
|
for (const kind of CLIENTS) {
|
|
const credential = await readIfThere(join(dir, `${kind.provision}.secret`));
|
|
if (credential) secrets.push(credential.trim());
|
|
const w = wanted(kind.provision, (await readJson(join(dir, `${kind.provision}.json`))) as Binding | undefined, credential, true);
|
|
// `in`, not `!w.ok`: the Dockerfile compiles without strict, where a boolean discriminant does not
|
|
// narrow.
|
|
if ("problem" in w) {
|
|
outcomes.push({ what: kind.provision, result: "refused", problem: w.problem });
|
|
continue;
|
|
}
|
|
const ep = w.endpoint;
|
|
try {
|
|
if (!(await providerTakes(http, kind.provision, ep)).took) {
|
|
outcomes.push({
|
|
what: kind.provision,
|
|
result: "refused",
|
|
problem: acceptRemedy(app, settings.node, kind.provision, kind.provider, kind.secretName, ep.from),
|
|
});
|
|
continue;
|
|
}
|
|
} catch (err) {
|
|
outcomes.push({
|
|
what: kind.provision,
|
|
result: "refused",
|
|
problem: `${kind.provider} could not be asked whether it takes the credential at ${ep.host}:${ep.port}: ${err instanceof Error ? err.message : String(err)}`,
|
|
});
|
|
continue;
|
|
}
|
|
outcomes.push(...(await reconcileClient(http, app, kind, settings.names[kind.provision], ep, clients)));
|
|
}
|
|
|
|
// The indexers, through jackett.
|
|
{
|
|
const credential = await readIfThere(join(dir, `${JACKETT}.secret`));
|
|
if (credential) secrets.push(credential.trim());
|
|
const w = wanted(JACKETT, (await readJson(join(dir, `${JACKETT}.json`))) as Binding | undefined, credential, false);
|
|
if ("problem" in w) {
|
|
outcomes.push({ what: JACKETT, result: "refused", problem: w.problem });
|
|
} else {
|
|
const ep = w.endpoint;
|
|
let took: Took | undefined;
|
|
try {
|
|
took = await providerTakes(http, JACKETT, ep);
|
|
} catch (err) {
|
|
outcomes.push({
|
|
what: JACKETT,
|
|
result: "refused",
|
|
problem: `jackett could not be asked whether it takes the key at ${ep.host}:${ep.port}: ${err instanceof Error ? err.message : String(err)}`,
|
|
});
|
|
}
|
|
if (took && !took.took) {
|
|
outcomes.push({ what: JACKETT, result: "refused", problem: acceptRemedy(app, settings.node, JACKETT, "jackett", "API key", ep.from) });
|
|
} else if (took) {
|
|
const memory = ((await readJson(memoryFile)) ?? {}) as Record<string, { hosts?: string[] } | undefined>;
|
|
const remembered = Array.isArray(memory[JACKETT]?.hosts) ? (memory[JACKETT]?.hosts as string[]) : [];
|
|
try {
|
|
const indexers = await listEntries(http, app, "indexer");
|
|
outcomes.push(
|
|
...(await reconcileIndexers(http, app, ep, took.configured ?? new Map(), settings.indexers, remembered, indexers)),
|
|
);
|
|
} catch (err) {
|
|
outcomes.push({ what: JACKETT, result: "refused", problem: err instanceof Error ? err.message : String(err) });
|
|
}
|
|
// Remember where the feeds now point, so they are still recognised as the mesh's if jackett
|
|
// moves. Written whole and renamed, so a crash leaves the old memory, never half of one.
|
|
const hosts = [...new Set([...remembered, ep.host.toLowerCase()])].sort();
|
|
if (hosts.join() !== [...remembered].sort().join()) {
|
|
try {
|
|
await mkdir(dirname(memoryFile), { recursive: true });
|
|
await writeFile(`${memoryFile}.tmp`, JSON.stringify({ ...memory, [JACKETT]: { hosts } }, null, 2) + "\n", { mode: 0o600 });
|
|
await rename(`${memoryFile}.tmp`, memoryFile);
|
|
} catch (err) {
|
|
outcomes.push({
|
|
what: JACKETT,
|
|
result: "notice",
|
|
note: `could not remember ${ep.host} as a jackett host (${err instanceof Error ? err.message : String(err)}); if jackett moves, list it in downloads.jackett-api.adopt-hosts`,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let failed = 0;
|
|
for (const o of outcomes) {
|
|
switch (o.result) {
|
|
case "unchanged":
|
|
say(`${o.what}: already as the mesh says${o.untested ? "" : "; connection tested"}`);
|
|
break;
|
|
case "written":
|
|
say(`${o.what}: wrote ${o.fields.join(", ")}${o.untested ? "" : "; connection tested"}`);
|
|
break;
|
|
case "created":
|
|
say(`${o.what}: registered; connection tested`);
|
|
break;
|
|
case "notice":
|
|
say(`${o.what}: ${o.note}`);
|
|
break;
|
|
case "refused":
|
|
failed++;
|
|
fail(`${o.what}: ${o.problem}`);
|
|
break;
|
|
}
|
|
}
|
|
process.exitCode = failed > 0 ? 1 : 0;
|