diff --git a/modules/bazarr/client.ts b/modules/bazarr/client.ts new file mode 100644 index 0000000..b84f6c5 --- /dev/null +++ b/modules/bazarr/client.ts @@ -0,0 +1,155 @@ +// 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]; + } +} diff --git a/modules/bazarr/index.ts b/modules/bazarr/index.ts new file mode 100644 index 0000000..d915972 --- /dev/null +++ b/modules/bazarr/index.ts @@ -0,0 +1,47 @@ +// bazarr's events. The tool runtime imports this once the broker is bound. Bazarr's one genuinely +// observable thing is a subtitle arriving: it works away in the background, searching providers for +// the missing-subtitle list, and when it succeeds a subtitle appears in its history. That is worth +// announcing to the mesh. +// +// Emits (novox/hq ADR 0046/0047): +// module.bazarr.subtitle.downloaded — a subtitle was fetched for an episode or movie +// +// Bazarr has nothing on the mesh it usefully reacts to (a download completing is Sonarr/Radarr's +// business, and they trigger Bazarr directly), so it consumes nothing — a pure emitter. +// +// The event is observation-based: poll history and diff. Primed silently on the first look, or a +// restart would re-announce the whole recent history as freshly downloaded. + +import { emit } from "@novox/mesh-sdk/events"; +import { BazarrClient } from "./client.js"; + +const bazarr = BazarrClient.fromEnv(); + +const seen = new Set(); +let primed = false; +async function pollHistory(): Promise { + const entries = await bazarr.getHistory(40); + for (const entry of entries) { + if (seen.has(entry.id)) continue; + if (primed) { + await emit("module.bazarr.subtitle.downloaded", { + kind: entry.kind, + title: entry.title, + language: entry.language, + provider: entry.provider, + path: entry.path, + }); + } + seen.add(entry.id); + } + primed = true; +} + +const tick = (fn: () => Promise, everyMs: number): void => { + const run = (): void => void fn().catch((err) => console.error(`[bazarr] ${err}`)); + setInterval(run, everyMs); + run(); +}; +tick(pollHistory, 60_000); + +console.log("[bazarr] watching subtitle-download history"); diff --git a/modules/bazarr/module.json b/modules/bazarr/module.json index 90e6ac2..050700b 100644 --- a/modules/bazarr/module.json +++ b/modules/bazarr/module.json @@ -4,6 +4,12 @@ "capabilities": [ "container-runtime" ], + "emits": [ + "module.bazarr.subtitle.downloaded" + ], + "own-secrets": { + "broker": "/var/lib/bazarr/broker" + }, "listens": [ { "port": 6767, diff --git a/modules/bazarr/package.json b/modules/bazarr/package.json new file mode 100644 index 0000000..20c3e13 --- /dev/null +++ b/modules/bazarr/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-bazarr", + "version": "0.1.0", + "description": "bazarr — subtitle management. Its API client, tools and events live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/bazarr/tools/index.ts b/modules/bazarr/tools/index.ts new file mode 100644 index 0000000..3e0a574 --- /dev/null +++ b/modules/bazarr/tools/index.ts @@ -0,0 +1,57 @@ +// bazarr's tools — its own code (novox/hq ADR 0044), importing bazarr's client. They return +// structured data; the mesh serves them through the sdk's tool harness. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { BazarrClient } from "../client.js"; + +export function getBazarrTools(bazarr: BazarrClient): ToolDefinition[] { + return [ + { + name: "bazarr_wanted", + description: "Episodes and movies still missing subtitles, with the languages each still needs.", + input: { limit: { type: "number", description: "max items per kind (default 50)" } }, + run: async (args) => { + const wanted = await bazarr.getWanted(args.limit ? Number(args.limit) : 50); + return { count: wanted.length, wanted }; + }, + }, + { + name: "bazarr_search_subtitles", + description: "Manually search subtitle providers for one wanted item — pass an episodeId or a radarrId.", + input: { + episodeId: { type: "number", description: "a Sonarr episode id (from bazarr_wanted)" }, + radarrId: { type: "number", description: "a Radarr movie id (from bazarr_wanted)" }, + }, + run: async (args) => { + if (args.episodeId !== undefined) { + const results = await bazarr.searchEpisode(Number(args.episodeId)); + return { kind: "episode", episodeId: Number(args.episodeId), count: results.length, results }; + } + if (args.radarrId !== undefined) { + const results = await bazarr.searchMovie(Number(args.radarrId)); + return { kind: "movie", radarrId: Number(args.radarrId), count: results.length, results }; + } + throw new Error("pass either episodeId or radarrId"); + }, + }, + { + name: "bazarr_history", + description: "Recent subtitle-download history — what was downloaded, for which title, from which provider.", + input: { limit: { type: "number", description: "max entries per kind (default 40)" } }, + run: async (args) => { + const history = await bazarr.getHistory(args.limit ? Number(args.limit) : 40); + return { count: history.length, history }; + }, + }, + ]; +} + +// Exposed only when Bazarr is configured; otherwise bazarr contributes no tools rather than +// failing the whole runtime. +registerModuleTools("bazarr", (env) => { + try { + return getBazarrTools(BazarrClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/bazarr/tsconfig.json b/modules/bazarr/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/bazarr/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "tools/index.ts"] +} diff --git a/modules/jackett/client.ts b/modules/jackett/client.ts new file mode 100644 index 0000000..bf52da9 --- /dev/null +++ b/modules/jackett/client.ts @@ -0,0 +1,89 @@ +// The Jackett API client — jackett's own code, living in the module (novox/hq ADR 0044). Jackett is +// an indexer proxy: it normalises many torrent trackers behind one Torznab surface. This client +// talks its /api/v2.0 REST API, and only jackett's tools import it. + +export interface JackettIndexer { + id: string; + name: string; + type: string; // "public" | "private" | "semi-public" + configured: boolean; + siteLink?: string; + lastError?: string; +} + +export interface JackettResult { + title: string; + tracker: string; + category?: string; + size: number; + seeders?: number; + peers?: number; + publishDate?: string; + link?: string; +} + +export class JackettClient { + readonly baseUrl: string; + + constructor( + url: string, + private readonly apiKey: string, + ) { + this.baseUrl = url.replace(/\/$/, ""); + } + + /** + * Build from the module's resolved environment. Jackett's REST API is keyed, so both the URL and + * the key must be present — without them there is nothing to talk to, so this throws and the + * module contributes no tools rather than failing half-configured. + */ + static fromEnv(env: NodeJS.ProcessEnv = process.env): JackettClient { + const url = env.MESH_JACKETT_URL; + const apiKey = env.MESH_JACKETT_API_KEY; + if (!url) throw new Error("no Jackett URL — set MESH_JACKETT_URL"); + if (!apiKey) throw new Error("no Jackett API key — set MESH_JACKETT_API_KEY"); + return new JackettClient(url, apiKey); + } + + private async get(path: string, params: Record = {}): Promise { + const url = new URL(`${this.baseUrl}${path}`); + url.searchParams.set("apikey", this.apiKey); + for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v); + const res = await fetch(url.toString(), { headers: { Accept: "application/json" } }); + if (!res.ok) throw new Error(`Jackett API ${path}: ${res.status} ${await res.text()}`); + return res.json(); + } + + /** The configured indexers Jackett proxies. `configured=false` also lists the ones not set up. */ + async getIndexers(configuredOnly = true): Promise { + const raw = await this.get("/api/v2.0/indexers", { configured: configuredOnly ? "true" : "false" }); + const list = Array.isArray(raw) ? raw : []; + return list.map((i: any) => ({ + id: i.id, + name: i.name, + type: i.type, + configured: i.configured ?? false, + siteLink: i.site_link, + lastError: i.last_error || undefined, + })); + } + + /** + * A Torznab search across one indexer, or the "all" aggregate. Jackett returns a normalised JSON + * result set regardless of the underlying tracker, which is the whole point of the proxy. + */ + async search(query: string, indexer = "all", limit = 25): Promise { + const raw = await this.get(`/api/v2.0/indexers/${encodeURIComponent(indexer)}/results`, { Query: query }); + const results = Array.isArray(raw?.Results) ? raw.Results : []; + return results.slice(0, limit).map((r: any) => ({ + title: r.Title, + tracker: r.Tracker ?? r.TrackerId ?? "unknown", + category: Array.isArray(r.CategoryDesc) ? r.CategoryDesc.join(", ") : r.CategoryDesc, + size: r.Size ?? 0, + seeders: r.Seeders, + peers: r.Peers, + publishDate: r.PublishDate, + link: r.Link ?? r.Details, + })); + } +} diff --git a/modules/jackett/package.json b/modules/jackett/package.json new file mode 100644 index 0000000..8ef721f --- /dev/null +++ b/modules/jackett/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-jackett", + "version": "0.1.0", + "description": "jackett — indexer proxy. Its API client and tools live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/jackett/tools/index.ts b/modules/jackett/tools/index.ts new file mode 100644 index 0000000..b10ccad --- /dev/null +++ b/modules/jackett/tools/index.ts @@ -0,0 +1,48 @@ +// jackett's tools — its own code (novox/hq ADR 0044), importing jackett's client. Jackett has +// nothing worth watching (an indexer proxy answers queries; it has no timeline of its own), so it +// is a tools-only module: no events entrypoint, no broker. What is useful is asking it things. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { JackettClient } from "../client.js"; + +export function getJackettTools(jackett: JackettClient): ToolDefinition[] { + return [ + { + name: "jackett_indexers", + description: "List the indexers Jackett proxies, with their type and any last error.", + input: { all: { type: "boolean", description: "include indexers not yet configured (default false)" } }, + run: async (args) => { + const indexers = await jackett.getIndexers(!args.all); + return { count: indexers.length, indexers }; + }, + }, + { + name: "jackett_search", + description: "Torznab search across Jackett's indexers, returning normalised torrent results.", + input: { + query: { type: "string", description: "the search query" }, + indexer: { type: "string", description: 'an indexer id, or "all" to aggregate (default "all")' }, + limit: { type: "number", description: "max results (default 25)" }, + }, + run: async (args) => { + const query = String(args.query); + const results = await jackett.search( + query, + args.indexer ? String(args.indexer) : "all", + args.limit ? Number(args.limit) : 25, + ); + return { query, count: results.length, results }; + }, + }, + ]; +} + +// Only exposed when Jackett is configured; otherwise jackett contributes no tools rather than +// failing the whole runtime. +registerModuleTools("jackett", (env) => { + try { + return getJackettTools(JackettClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/jackett/tsconfig.json b/modules/jackett/tsconfig.json new file mode 100644 index 0000000..426d382 --- /dev/null +++ b/modules/jackett/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "tools/index.ts"] +} diff --git a/modules/ombi/client.ts b/modules/ombi/client.ts new file mode 100644 index 0000000..870e37e --- /dev/null +++ b/modules/ombi/client.ts @@ -0,0 +1,104 @@ +// 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. + +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; +} + +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 url = env.MESH_OMBI_URL; + const 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 { + 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 }); + } +} diff --git a/modules/ombi/index.ts b/modules/ombi/index.ts new file mode 100644 index 0000000..4b6ca9f --- /dev/null +++ b/modules/ombi/index.ts @@ -0,0 +1,58 @@ +// ombi's events. The tool runtime imports this once the broker is bound. Ombi's timeline is the +// request lifecycle: a viewer files a request, and later an operator approves it. Both transitions +// are worth announcing — the mesh can notify on a new request, and act on an approval (that is when +// a downloader should start looking). +// +// Emits (novox/hq ADR 0046/0047): +// module.ombi.request.created — a viewer filed a new request +// module.ombi.request.approved — a request was approved +// +// Ombi is the origin of these decisions, not a reactor to the mesh, so it consumes nothing. +// +// Both events are observation-based: poll the request list and diff. Creation is diffed on the set +// of request ids; approval on each request's approved flag flipping true. Primed silently on the +// first look, or a restart would re-announce every existing request and approval. + +import { emit } from "@novox/mesh-sdk/events"; +import { OmbiClient, type OmbiRequest } from "./client.js"; + +const ombi = OmbiClient.fromEnv(); + +// Remember each seen request and whether it was approved last time, keyed by kind+id (ids are only +// unique within a kind). +const approvedState = new Map(); +let primed = false; + +const keyOf = (r: OmbiRequest): string => `${r.kind}:${r.id}`; + +async function pollRequests(): Promise { + const requests = await ombi.getRequests(); + for (const r of requests) { + const key = keyOf(r); + const known = approvedState.has(key); + if (primed && !known) { + await emit("module.ombi.request.created", { + kind: r.kind, + id: r.id, + title: r.title, + requestedBy: r.requestedBy, + tmdbId: r.tmdbId, + }); + } + // Approval: the flag went from false to true for a request we already knew about. + if (primed && known && r.approved && approvedState.get(key) === false) { + await emit("module.ombi.request.approved", { kind: r.kind, id: r.id, title: r.title, tmdbId: r.tmdbId }); + } + approvedState.set(key, r.approved); + } + primed = true; +} + +const tick = (fn: () => Promise, everyMs: number): void => { + const run = (): void => void fn().catch((err) => console.error(`[ombi] ${err}`)); + setInterval(run, everyMs); + run(); +}; +tick(pollRequests, 30_000); + +console.log("[ombi] watching requests for new filings and approvals"); diff --git a/modules/ombi/module.json b/modules/ombi/module.json index 45d8a62..edc4fb4 100644 --- a/modules/ombi/module.json +++ b/modules/ombi/module.json @@ -4,6 +4,13 @@ "capabilities": [ "container-runtime" ], + "emits": [ + "module.ombi.request.created", + "module.ombi.request.approved" + ], + "own-secrets": { + "broker": "/var/lib/ombi/broker" + }, "listens": [ { "port": 3579, diff --git a/modules/ombi/package.json b/modules/ombi/package.json new file mode 100644 index 0000000..d7542a1 --- /dev/null +++ b/modules/ombi/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-ombi", + "version": "0.1.0", + "description": "ombi — media requests. Its API client, tools and events live here (novox/hq ADR 0044).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/ombi/tools/index.ts b/modules/ombi/tools/index.ts new file mode 100644 index 0000000..00d854e --- /dev/null +++ b/modules/ombi/tools/index.ts @@ -0,0 +1,46 @@ +// ombi's tools — its own code (novox/hq ADR 0044), importing ombi's client. They return structured +// data; the mesh serves them through the sdk's tool harness. + +import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; +import { OmbiClient } from "../client.js"; + +export function getOmbiTools(ombi: OmbiClient): ToolDefinition[] { + return [ + { + name: "ombi_requests", + description: "List media requests — movies and shows — with who asked and whether each is approved or available.", + input: { pending: { type: "boolean", description: "only requests not yet approved (default false)" } }, + run: async (args) => { + let requests = await ombi.getRequests(); + if (args.pending) requests = requests.filter((r) => !r.approved && !r.denied); + const counts = await ombi.getCounts(); + return { counts, count: requests.length, requests }; + }, + }, + { + name: "ombi_approve", + description: "Approve a media request by its kind and id (from ombi_requests).", + input: { + kind: { type: "string", description: '"movie" or "tv"' }, + id: { type: "number", description: "the request id" }, + }, + run: async (args) => { + const kind = String(args.kind); + if (kind !== "movie" && kind !== "tv") throw new Error('kind must be "movie" or "tv"'); + const id = Number(args.id); + await ombi.approve(kind, id); + return { approved: { kind, id } }; + }, + }, + ]; +} + +// Exposed only when Ombi is configured; otherwise ombi contributes no tools rather than failing the +// whole runtime. +registerModuleTools("ombi", (env) => { + try { + return getOmbiTools(OmbiClient.fromEnv(env)); + } catch { + return []; + } +}); diff --git a/modules/ombi/tsconfig.json b/modules/ombi/tsconfig.json new file mode 100644 index 0000000..3677859 --- /dev/null +++ b/modules/ombi/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["client.ts", "index.ts", "tools/index.ts"] +}