Files
mesh-catalog/modules/bazarr/client.ts
T

166 lines
6.6 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";
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 {}; }
}
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 ?? 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<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];
}
}