Nineteen modules gain a broker-bound runtime container that serves the module's tools under its own scoped account: bazarr, gitea, grafana, home-assistant, icecast, influxdb, jackett, keycloak, mailu, nextcloud, nodered, nzbget, ombi, photos, portainer, qbittorrent, searxng, tautulli, verdaccio. Config is the assignment's, not the manifest's (ADR 0051): each client's fromEnv overlays a settings-merged config file (MESH_<M>_CONFIG_FILE) over its env fallbacks, so URL and credentials come from `settings set`, with the URL defaulting to the server on the node. nextcloud and mailu also mount the docker socket for their exec-based tools. Proven in the mesh-lab: assigned-grafana green — settings deliver the URL and token, the runtime reads the merged config and serves grafana's tools under the scoped account, with nothing in the manifest. Two gaps this surfaced are filed as hq issues 008 (a provider runtime's seal key) and 009 (a settings change does not restart a container runtime). Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
115 lines
4.3 KiB
TypeScript
115 lines
4.3 KiB
TypeScript
// The Ombi API client — ombi's own code, living in the module (novox/hq ADR 0044). 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 0051): { 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 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 ?? 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<any> {
|
|
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<OmbiRequest[]> {
|
|
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<RequestCounts> {
|
|
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<void> {
|
|
await this.request("POST", `/Request/${kind}/approve`, { id });
|
|
}
|
|
}
|