// The Ombi API client — ombi's own code, living in the module (novox/hq ADR 0039). Ombi is the // request front-end: viewers ask for movies and shows, and an operator approves them. This client // talks its /api/v1 REST API (keyed by an ApiKey header); ombi's tools and events import it. import { readFileSync } from "node:fs"; export interface OmbiRequest { kind: "movie" | "tv"; id: number; title: string; requestedBy?: string; requestedDate?: string; approved: boolean; available: boolean; denied: boolean; tmdbId?: number; } export interface RequestCounts { pending: number; approved: number; available: number; } /** The settings-merged config the mesh delivers (novox/hq ADR 0046): { url, apiKey, token, password, user, ... }. */ function meshConfig(file?: string): Record { if (!file) return {}; try { return JSON.parse(readFileSync(file, "utf8")) as Record; } 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(); } catch { return undefined; } } export class OmbiClient { readonly baseUrl: string; constructor( url: string, private readonly apiKey: string, ) { this.baseUrl = url.replace(/\/$/, ""); } /** Build from the module's resolved environment. Ombi'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): OmbiClient { const cfg = meshConfig(env.MESH_OMBI_CONFIG_FILE); const url = cfg.url ?? env.MESH_OMBI_URL; const apiKey = cfg.apiKey ?? readSecret(env.MESH_OMBI_API_KEY_FILE) ?? env.MESH_OMBI_API_KEY; if (!url) throw new Error("no Ombi URL — set MESH_OMBI_URL"); if (!apiKey) throw new Error("no Ombi API key — set MESH_OMBI_API_KEY"); return new OmbiClient(url, apiKey); } private async request(method: string, path: string, body?: unknown): Promise { const res = await fetch(`${this.baseUrl}/api/v1${path}`, { method, headers: { ApiKey: this.apiKey, Accept: "application/json", ...(body !== undefined ? { "Content-Type": "application/json" } : {}), }, body: body !== undefined ? JSON.stringify(body) : undefined, }); if (!res.ok) throw new Error(`Ombi API ${method} ${path}: ${res.status} ${await res.text()}`); const text = await res.text(); return text ? JSON.parse(text) : {}; } /** All requests, movies and TV together — who asked for what, and where each stands. */ async getRequests(): Promise { const [movies, tv] = await Promise.all([ this.request("GET", "/Request/movie"), this.request("GET", "/Request/tv"), ]); const films: OmbiRequest[] = (Array.isArray(movies) ? movies : []).map((r: any) => ({ kind: "movie" as const, id: r.id, title: r.title ?? "Unknown", requestedBy: r.requestedUser?.userName ?? r.requestedUser?.userAlias, requestedDate: r.requestedDate, approved: Boolean(r.approved), available: Boolean(r.available), denied: Boolean(r.denied), tmdbId: r.theMovieDbId, })); // TV requests carry per-season child requests; the top-level record is approved when all its // children are, which is the grain an operator acts on. const shows: OmbiRequest[] = (Array.isArray(tv) ? tv : []).map((r: any) => { const children: any[] = r.childRequests ?? []; return { kind: "tv" as const, id: r.id, title: r.title ?? "Unknown", requestedBy: children[0]?.requestedUser?.userName, requestedDate: children[0]?.requestedDate, approved: children.length > 0 && children.every((c) => c.approved), available: children.length > 0 && children.every((c) => c.available), denied: children.some((c) => c.denied), tmdbId: r.theMovieDbId, }; }); return [...films, ...shows]; } /** Live pending/approved/available counts — a one-line health read without listing everything. */ async getCounts(): Promise { const c = await this.request("GET", "/Request/count"); return { pending: c.pending ?? 0, approved: c.approved ?? 0, available: c.available ?? 0 }; } /** Approve a request. TV approval fans out to the request's child (per-season) requests. */ async approve(kind: "movie" | "tv", id: number): Promise { await this.request("POST", `/Request/${kind}/approve`, { id }); } }