jackett, bazarr, ombi: full nox modules (ADR 0044/0046)
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.
This commit is contained in:
@@ -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<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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string>();
|
||||||
|
let primed = false;
|
||||||
|
async function pollHistory(): Promise<void> {
|
||||||
|
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<void>, 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");
|
||||||
@@ -4,6 +4,12 @@
|
|||||||
"capabilities": [
|
"capabilities": [
|
||||||
"container-runtime"
|
"container-runtime"
|
||||||
],
|
],
|
||||||
|
"emits": [
|
||||||
|
"module.bazarr.subtitle.downloaded"
|
||||||
|
],
|
||||||
|
"own-secrets": {
|
||||||
|
"broker": "/var/lib/bazarr/broker"
|
||||||
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
"port": 6767,
|
"port": 6767,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 [];
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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<string, string> = {}): Promise<any> {
|
||||||
|
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<JackettIndexer[]> {
|
||||||
|
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<JackettResult[]> {
|
||||||
|
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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 [];
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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<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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, boolean>();
|
||||||
|
let primed = false;
|
||||||
|
|
||||||
|
const keyOf = (r: OmbiRequest): string => `${r.kind}:${r.id}`;
|
||||||
|
|
||||||
|
async function pollRequests(): Promise<void> {
|
||||||
|
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<void>, 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");
|
||||||
@@ -4,6 +4,13 @@
|
|||||||
"capabilities": [
|
"capabilities": [
|
||||||
"container-runtime"
|
"container-runtime"
|
||||||
],
|
],
|
||||||
|
"emits": [
|
||||||
|
"module.ombi.request.created",
|
||||||
|
"module.ombi.request.approved"
|
||||||
|
],
|
||||||
|
"own-secrets": {
|
||||||
|
"broker": "/var/lib/ombi/broker"
|
||||||
|
},
|
||||||
"listens": [
|
"listens": [
|
||||||
{
|
{
|
||||||
"port": 3579,
|
"port": 3579,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 [];
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user