bazarr reached sonarr and radarr as `sonarr:8989` and `radarr:7878`, container names on HAL's shared network, which the mesh does not have. It now requires sonarr-api and radarr-api (provided since #156) and a run-once step writes host, port, TLS, base path and key into bazarr through bazarr's own POST /api/system/settings - the call its settings screen makes - only for the fields that differ, and reads them back. bazarr's config.yaml is not written by the mesh: bazarr holds it in memory and rewrites it, so the two would overwrite each other. The key is tried against the app first; a refused key (a minted pair credential before the operator accepts the app's own) is never written, and the step fails naming the `secret accept` that fixes it. Declared last so its failing gates nothing else (ADR 0136); restart-on its four inputs. The api-key own-secret is gone. bazarr makes its own key and nothing lets the mesh set it, so a minted one could never work; the tools and the step read auth.apikey from bazarr's own config/config.yaml (mounted read-only), which also stays right if the key is regenerated. Nothing to accept. The config dir is a pathless ${dir:config}; config.json and the bindings live in a placed state dir; /var/lib/mesh/bazarr keeps only the broker. The image is pinned to the digest ace runs (v1.6.1-ls364); the old pin was v1.6.0-ls361, older than ace's database. Based on feat/servarr-api-provision (#156); this branch contains it. Verified: catalogue tests with MESH_CATALOGUE pass (not skipped); a resolve of sonarr+radarr+bazarr on a fake ace renders both bindings and sealed credentials into the state dir with every ${} filled, and bazarr alone is refused naming sonarr and radarr; strict tsc passes and the Dockerfile's non-strict compile builds; 8 node tests pass; the compiled step against the pinned image in a throwaway container with fake sonarr/radarr wrote sonarr (ip, port, apikey), refused radarr's minted key and wrote nothing for it, wrote radarr once the key was right, and changed nothing on a third run - the values landed in config.yaml.
180 lines
7.2 KiB
TypeScript
180 lines
7.2 KiB
TypeScript
// The Bazarr API client — bazarr's own code, living in the module (novox/hq ADR 0039). Bazarr
|
|
// manages subtitles for a Sonarr/Radarr library: it tracks which episodes and movies are still
|
|
// missing subtitles, searches providers for them, and records what it downloaded. This client
|
|
// talks its /api surface (keyed by an X-API-KEY header); bazarr's tools and events import it.
|
|
|
|
import { readFileSync } from "node:fs";
|
|
|
|
import { apiKeyFromConfigDir } from "./apikey.js";
|
|
|
|
export interface WantedSubtitle {
|
|
kind: "episode" | "movie";
|
|
title: string; // series + episode, or movie title
|
|
path?: string;
|
|
seriesId?: number; // sonarr series id (episodes)
|
|
episodeId?: number; // sonarr episode id (episodes)
|
|
radarrId?: number; // radarr movie id (movies)
|
|
missing: string[]; // language names still missing
|
|
}
|
|
|
|
export interface ProviderSubtitle {
|
|
provider: string;
|
|
language: string;
|
|
hearingImpaired: boolean;
|
|
forced: boolean;
|
|
score?: number;
|
|
release?: string;
|
|
subtitle: string; // the opaque token Bazarr uses to download this exact result
|
|
}
|
|
|
|
export interface HistoryEntry {
|
|
kind: "episode" | "movie";
|
|
id: string; // stable dedup key across polls
|
|
title: string;
|
|
language?: string;
|
|
provider?: string;
|
|
path?: string;
|
|
timestamp?: string;
|
|
description?: string;
|
|
}
|
|
|
|
/** The settings-merged config the mesh delivers (novox/hq ADR 0046): { url, apiKey, token, password, user, ... }. */
|
|
function meshConfig(file?: string): Record<string, string> {
|
|
if (!file) return {};
|
|
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
|
|
catch { return {}; }
|
|
}
|
|
|
|
/** Read a secret the mesh mounted at a file path (an own-secret delivered by `secret accept`);
|
|
* absent or unreadable yields undefined so callers fall back rather than crash. */
|
|
function readSecret(file?: string): string | undefined {
|
|
if (!file) return undefined;
|
|
try { return readFileSync(file, "utf8").trim() || undefined; }
|
|
catch { return undefined; }
|
|
}
|
|
|
|
export class BazarrClient {
|
|
readonly baseUrl: string;
|
|
|
|
constructor(
|
|
url: string,
|
|
private readonly apiKey: string,
|
|
) {
|
|
this.baseUrl = url.replace(/\/$/, "");
|
|
}
|
|
|
|
/** Build from the module's resolved environment. Bazarr's API is keyed; without URL and key
|
|
* there is nothing to talk to, so this throws rather than run half-configured. */
|
|
static fromEnv(env: NodeJS.ProcessEnv = process.env): BazarrClient {
|
|
const cfg = meshConfig(env.MESH_BAZARR_CONFIG_FILE);
|
|
const url = cfg.url ?? env.MESH_BAZARR_URL;
|
|
const apiKey =
|
|
cfg.apiKey ??
|
|
apiKeyFromConfigDir(env.MESH_BAZARR_CONFIG_DIR) ??
|
|
readSecret(env.MESH_BAZARR_API_KEY_FILE) ??
|
|
env.MESH_BAZARR_API_KEY;
|
|
if (!url) throw new Error("no Bazarr URL — set MESH_BAZARR_URL");
|
|
if (!apiKey) throw new Error("no Bazarr API key — bazarr's config/config.yaml under MESH_BAZARR_CONFIG_DIR has none");
|
|
return new BazarrClient(url, apiKey);
|
|
}
|
|
|
|
private async request(method: string, path: string, params: Record<string, string> = {}): Promise<any> {
|
|
const url = new URL(`${this.baseUrl}/api${path}`);
|
|
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
|
|
const res = await fetch(url.toString(), { method, headers: { "X-API-KEY": this.apiKey, Accept: "application/json" } });
|
|
if (!res.ok) throw new Error(`Bazarr API ${method} ${path}: ${res.status} ${await res.text()}`);
|
|
// Downloads/patches return an empty body; only GETs carry JSON.
|
|
const text = await res.text();
|
|
return text ? JSON.parse(text) : {};
|
|
}
|
|
|
|
private get(path: string, params?: Record<string, string>): Promise<any> {
|
|
return this.request("GET", path, params);
|
|
}
|
|
|
|
private languageNames(missing: any[]): string[] {
|
|
return (missing ?? []).map((m: any) => m?.name ?? m?.code2 ?? m?.code3).filter(Boolean);
|
|
}
|
|
|
|
/** Episodes and movies still missing subtitles — Bazarr's core "what's left to do" list. */
|
|
async getWanted(limit = 50): Promise<WantedSubtitle[]> {
|
|
const [eps, movies] = await Promise.all([
|
|
this.get("/episodes/wanted", { start: "0", length: String(limit) }),
|
|
this.get("/movies/wanted", { start: "0", length: String(limit) }),
|
|
]);
|
|
const episodes: WantedSubtitle[] = (eps?.data ?? []).map((e: any) => ({
|
|
kind: "episode" as const,
|
|
title: `${e.seriesTitle ?? e.series ?? "Unknown"} — ${e.episodeTitle ?? e.episode_title ?? ""}`.trim(),
|
|
path: e.path,
|
|
seriesId: e.sonarrSeriesId,
|
|
episodeId: e.sonarrEpisodeId,
|
|
missing: this.languageNames(e.missing_subtitles),
|
|
}));
|
|
const films: WantedSubtitle[] = (movies?.data ?? []).map((m: any) => ({
|
|
kind: "movie" as const,
|
|
title: m.title ?? "Unknown",
|
|
path: m.path,
|
|
radarrId: m.radarrId,
|
|
missing: this.languageNames(m.missing_subtitles),
|
|
}));
|
|
return [...episodes, ...films];
|
|
}
|
|
|
|
/** Ask providers what subtitles are available for one wanted episode — a manual search. */
|
|
async searchEpisode(episodeId: number): Promise<ProviderSubtitle[]> {
|
|
const raw = await this.get("/providers/episodes", { episodeid: String(episodeId) });
|
|
return this.mapProviderResults(raw);
|
|
}
|
|
|
|
/** Ask providers what subtitles are available for one movie — a manual search. */
|
|
async searchMovie(radarrId: number): Promise<ProviderSubtitle[]> {
|
|
const raw = await this.get("/providers/movies", { radarrid: String(radarrId) });
|
|
return this.mapProviderResults(raw);
|
|
}
|
|
|
|
private mapProviderResults(raw: any): ProviderSubtitle[] {
|
|
const list = Array.isArray(raw) ? raw : (raw?.data ?? []);
|
|
return list.map((r: any) => ({
|
|
provider: r.provider,
|
|
language: r.language?.name ?? r.language ?? "unknown",
|
|
hearingImpaired: Boolean(r.hearing_impaired ?? r.hi),
|
|
forced: Boolean(r.forced),
|
|
score: r.score,
|
|
release: r.release_info?.[0] ?? r.release_info,
|
|
subtitle: r.subtitle,
|
|
}));
|
|
}
|
|
|
|
/** Recent subtitle-download history, episodes and movies together, newest first. Each entry
|
|
* carries a stable id so the events poller can tell a fresh download from one already seen. */
|
|
async getHistory(limit = 40): Promise<HistoryEntry[]> {
|
|
const [eps, movies] = await Promise.all([
|
|
this.get("/episodes/history", { start: "0", length: String(limit) }),
|
|
this.get("/movies/history", { start: "0", length: String(limit) }),
|
|
]);
|
|
const key = (kind: string, r: any): string =>
|
|
`${kind}:${r.timestamp ?? r.parsed_timestamp ?? ""}:${r.subtitles_path ?? r.path ?? ""}:${r.language?.code3 ?? r.language ?? ""}`;
|
|
const episodes: HistoryEntry[] = (eps?.data ?? []).map((r: any) => ({
|
|
kind: "episode" as const,
|
|
id: key("episode", r),
|
|
title: `${r.seriesTitle ?? "Unknown"} — ${r.episodeTitle ?? ""}`.trim(),
|
|
language: r.language?.name ?? r.language,
|
|
provider: r.provider,
|
|
path: r.subtitles_path,
|
|
timestamp: r.timestamp,
|
|
description: r.description,
|
|
}));
|
|
const films: HistoryEntry[] = (movies?.data ?? []).map((r: any) => ({
|
|
kind: "movie" as const,
|
|
id: key("movie", r),
|
|
title: r.title ?? "Unknown",
|
|
language: r.language?.name ?? r.language,
|
|
provider: r.provider,
|
|
path: r.subtitles_path,
|
|
timestamp: r.timestamp,
|
|
description: r.description,
|
|
}));
|
|
return [...episodes, ...films];
|
|
}
|
|
}
|