// Where bazarr reaches Sonarr and Radarr — decided by the mesh, written into bazarr by bazarr's own // API. // // **Why this exists.** bazarr keeps its connection to each app in its own `config/config.yaml`, which // it holds in memory and writes back whenever its settings change — so the mesh cannot own that file // without the two overwriting each other. bazarr requires `sonarr-api` and `radarr-api`; the mesh // delivers, for each, a binding (where the app is: `at`, and what it serves: `port`, `scheme`, // `url-base`) and a pair credential (the app's API key, accepted by the operator — a Servarr app has // exactly one key and the mesh cannot mint it, novox/hq ADR 0092). This step reads those files and // makes bazarr's settings say the same thing, through `POST /api/system/settings` — the call bazarr's // own settings screen makes, which also restarts bazarr's SignalR feed from the app. // // **Only the connection, and only when it differs.** Host, port, TLS, base path and API key. Whether // bazarr uses the app at all (`general.use_sonarr`), sync intervals, excluded tags, path mappings and // every other choice the operator made are left exactly as they are: the mesh knows where the app is, // not what bazarr should do with it. // // **A credential the app refuses is never written.** Until the operator accepts the app's key for this // pair the mesh delivers a value it minted, which no Servarr app accepts. Writing it would replace a // working key in bazarr with a dead one, so the key is tried against the app first; refused, nothing // for that app is written and the step fails naming the `secret accept` that fixes it. // // The same shape as ombi's step (modules/ombi/servarr), repeated rather than shared because a module // is built from its own directory and nothing else (novox/hq ADR 0069). Pure logic and a small HTTP // seam, tested against fakes (test/servarr.test.ts). import { readFile } from "node:fs/promises"; /** One Servarr app bazarr connects to. */ export interface ServarrApp { /** The section of bazarr's settings that holds the connection: settings..* */ app: "sonarr" | "radarr"; /** The provision it is required as — the manifest's `requires`, `binds` and `secrets` key. */ provision: string; /** The app's own status endpoint, which answers 401 to a wrong key. */ statusPath: string; } export const APPS: readonly ServarrApp[] = [ { app: "sonarr", provision: "sonarr-api", statusPath: "/api/v3/system/status" }, { app: "radarr", provision: "radarr-api", statusPath: "/api/v3/system/status" }, ]; /** The connection fields bazarr keeps for an app — the only ones this step ever writes. */ export interface Connection { ip: string; port: number; ssl: boolean; /** bazarr's base_url: "" at the root, otherwise "/base". */ base_url: string; apikey: string; } /** What the mesh wrote at `binds.`: the binding document. */ export interface Binding { provision?: string; from?: string; at?: string; as?: string; serves?: Record; } export type Wanted = { ok: true; connection: Connection; from: string } | { ok: false; problem: string }; /** * The connection the mesh says bazarr should use. Refused rather than guessed when the binding cannot * be dialled from bazarr's own container: a loopback `at` is bazarr's container itself. */ export function wanted(spec: ServarrApp, binding: Binding | undefined, credential: string | undefined): Wanted { if (!binding) { return { ok: false, problem: `no binding for ${spec.provision} was delivered — the mesh writes it before this step runs` }; } const at = typeof binding.at === "string" ? binding.at.trim() : ""; const serves = binding.serves ?? {}; const port = Number(serves.port); if (!at) return { ok: false, problem: `the ${spec.provision} binding names no host (at)` }; if (isLoopback(at)) { return { ok: false, problem: `the ${spec.provision} binding says ${spec.app} is at ${at}, which from bazarr's own container is ` + `bazarr itself. The mesh hands loopback to a machine that is not on the private network; put it ` + `on the private network so ${spec.app} has an address bazarr can dial`, }; } if (!Number.isInteger(port) || port <= 0 || port > 65535) { return { ok: false, problem: `the ${spec.provision} binding serves no usable port (${String(serves.port)})` }; } const scheme = typeof serves.scheme === "string" && serves.scheme ? serves.scheme : "http"; if (scheme !== "http" && scheme !== "https") { return { ok: false, problem: `the ${spec.provision} binding serves scheme ${scheme}, which bazarr cannot dial` }; } const key = (credential ?? "").trim(); if (!key) return { ok: false, problem: `the ${spec.provision} credential is empty or was not delivered` }; return { ok: true, from: typeof binding.from === "string" ? binding.from : "", connection: { ip: at, port, ssl: scheme === "https", base_url: baseUrlOf(serves["url-base"]), apikey: key }, }; } /** A URL base as bazarr stores it: "" for none, else one leading slash and no trailing one. */ export function baseUrlOf(urlBase: unknown): string { const trimmed = typeof urlBase === "string" ? urlBase.trim().replace(/^\/+|\/+$/g, "") : ""; return trimmed === "" ? "" : `/${trimmed}`; } function isLoopback(host: string): boolean { const h = host.toLowerCase(); return h === "localhost" || h === "::1" || h === "[::1]" || /^127\./.test(h); } /** Which connection fields differ between what bazarr holds and what the mesh says. Names only. */ export function differing(current: Record | undefined, want: Connection): (keyof Connection)[] { const now = current ?? {}; const out: (keyof Connection)[] = []; if (String(now.ip ?? "") !== want.ip) out.push("ip"); if (Number(now.port ?? 0) !== want.port) out.push("port"); if (Boolean(now.ssl) !== want.ssl) out.push("ssl"); if (baseUrlOf(now.base_url) !== want.base_url) out.push("base_url"); if (String(now.apikey ?? "") !== want.apikey) out.push("apikey"); return out; } /** * The form bazarr's settings endpoint takes for the differing fields: `settings--`. * bazarr casts "true"/"false" to booleans and digit strings to integers itself. */ export function settingsForm(spec: ServarrApp, want: Connection, fields: readonly (keyof Connection)[]): URLSearchParams { const form = new URLSearchParams(); for (const f of fields) { const v = want[f]; form.append(`settings-${spec.app}-${f}`, typeof v === "boolean" ? (v ? "true" : "false") : String(v)); } return form; } /** The app's base URL as the step dials it — the same host and port bazarr will be given. */ export function appUrl(want: Connection): string { const scheme = want.ssl ? "https" : "http"; const host = want.ip.includes(":") && !want.ip.startsWith("[") ? `[${want.ip}]` : want.ip; return `${scheme}://${host}:${want.port}${want.base_url}`; } /** How one app came out. */ export type Outcome = | { app: string; result: "unchanged" } | { app: string; result: "written"; fields: string[] } | { app: string; result: "refused"; problem: string }; /** The HTTP the step needs, so a test can stand fakes in for bazarr and the apps. */ export interface Http { fetch(url: string, init?: { method?: string; headers?: Record; body?: string }): Promise<{ status: number; text(): Promise; }>; } export interface Bazarr { url: string; apiKey: string; } async function bazarrCall(http: Http, bazarr: Bazarr, method: string, path: string, form?: URLSearchParams): Promise { const res = await http.fetch(`${bazarr.url.replace(/\/$/, "")}/api${path}`, { method, headers: { "X-API-KEY": bazarr.apiKey, Accept: "application/json", ...(form ? { "Content-Type": "application/x-www-form-urlencoded" } : {}), }, body: form ? form.toString() : undefined, }); const text = await res.text(); if (res.status < 200 || res.status >= 300) { // bazarr's error body is a message, never a request echo, so it carries no key. throw new Error(`bazarr ${method} ${path} answered ${res.status}${text ? `: ${text.slice(0, 200)}` : ""}`); } return text ? (JSON.parse(text) as unknown) : undefined; } /** * Does the app take this key? `true` it does, `false` it refused it (401/403), and a thrown error * when it could not be asked. */ export async function appTakes(http: Http, spec: ServarrApp, want: Connection): Promise { const res = await http.fetch(`${appUrl(want)}${spec.statusPath}`, { method: "GET", headers: { "X-Api-Key": want.apikey, Accept: "application/json" }, }); if (res.status === 401 || res.status === 403) return false; if (res.status >= 200 && res.status < 300) return true; throw new Error(`${spec.app} answered ${res.status} at ${spec.statusPath}`); } /** The remedy for a refused key, in the controller's own words (ADR 0092). */ export function acceptRemedy(spec: ServarrApp, from: string): string { return ( `${spec.app} refuses the ${spec.provision} credential the mesh delivered, so it was not written ` + `into bazarr. A Servarr app has one API key and the mesh cannot make it: accept ${spec.app}'s own ` + `key for this pair — \`secret accept bazarr ${spec.provision} --provider ${from || ""} ` + `--from \`` ); } /** * Bring bazarr's connection to one app in line with the mesh: check the key against the app, compare, * write only the differing connection fields, then read bazarr's settings back to confirm they took. * Never throws: every failure is an outcome with a reason. */ export async function reconcileApp( http: Http, bazarr: Bazarr, spec: ServarrApp, binding: Binding | undefined, credential: string | undefined, ): Promise { const w = wanted(spec, binding, credential); // `in`, not `!w.ok`: the Dockerfile compiles without strict, where a boolean discriminant does not // narrow. if ("problem" in w) return { app: spec.app, result: "refused", problem: w.problem }; const want = w.connection; try { if (!(await appTakes(http, spec, want))) { return { app: spec.app, result: "refused", problem: acceptRemedy(spec, w.from) }; } } catch (err) { return { app: spec.app, result: "refused", problem: `${spec.app} could not be asked whether it takes the key at ${want.ip}:${want.port}: ${message(err)}`, }; } try { const before = await sectionOf(http, bazarr, spec); const fields = differing(before, want); if (fields.length === 0) return { app: spec.app, result: "unchanged" }; await bazarrCall(http, bazarr, "POST", "/system/settings", settingsForm(spec, want, fields)); const still = differing(await sectionOf(http, bazarr, spec), want); if (still.length > 0) { return { app: spec.app, result: "refused", problem: `bazarr did not keep its ${spec.app} settings (${still.join(", ")})` }; } return { app: spec.app, result: "written", fields }; } catch (err) { return { app: spec.app, result: "refused", problem: message(err) }; } } async function sectionOf(http: Http, bazarr: Bazarr, spec: ServarrApp): Promise | undefined> { const doc = (await bazarrCall(http, bazarr, "GET", "/system/settings")) as Record | undefined; return doc?.[spec.app] as Record | undefined; } /** Wait for bazarr to answer, because the step runs right after its container starts. */ export async function bazarrReady(http: Http, bazarr: Bazarr, waitMs: number, pauseMs = 2000): Promise { const until = Date.now() + waitMs; for (;;) { try { const res = await http.fetch(`${bazarr.url.replace(/\/$/, "")}/api/system/ping`, { method: "GET" }); if (res.status === 200) return true; } catch { // not listening yet } if (Date.now() >= until) return false; await new Promise((r) => setTimeout(r, pauseMs)); } } /** A file the mesh wrote, or undefined when it is not there. */ export async function readIfThere(path: string | undefined): Promise { if (!path) return undefined; return readFile(path, "utf8").catch(() => undefined); } /** A binding file parsed, or undefined when absent or not JSON. */ export async function readBinding(path: string | undefined): Promise { const raw = await readIfThere(path); if (raw === undefined) return undefined; try { return JSON.parse(raw) as Binding; } catch { return undefined; } } function message(err: unknown): string { return err instanceof Error ? err.message : String(err); }