Ported their real HTTP APIs (hal carried no client for these). jackett: an indexer proxy — tools only (list indexers, search), no events, no broker account, because it answers queries and has no timeline to observe. bazarr: subtitle tools + emits module.bazarr.subtitle.downloaded (poll history, diff). ombi: request tools + emits request.created/.approved. All pure emitters (their decisions originate here). Typecheck; manifests parse.
156 lines
6.2 KiB
TypeScript
156 lines
6.2 KiB
TypeScript
// 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<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];
|
|
}
|
|
}
|