// The Jackett API client — jackett's own code, living in the module (novox/hq ADR 0039). 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. import { readFileSync } from "node:fs"; 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; } /** 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 {}; } } 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 cfg = meshConfig(env.MESH_JACKETT_CONFIG_FILE); const url = cfg.url ?? env.MESH_JACKETT_URL; const apiKey = cfg.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, })); } }