// The Bazarr API client — bazarr's own code, living in the module (novox/hq ADR 0044). 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. 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; } 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 url = env.MESH_BAZARR_URL; const apiKey = 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 — set MESH_BAZARR_API_KEY"); return new BazarrClient(url, apiKey); } private async request(method: string, path: string, params: Record = {}): Promise { 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): Promise { 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 { 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 { 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 { 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 { 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]; } }