// 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, `.json` (the binding), `.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; 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;