// 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 { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; 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. The key is read from the settings-merged config or MESH_JACKETT_API_KEY, * or, failing those, discovered from Jackett's own ServerConfig.json under MESH_JACKETT_CONFIG_DIR * — the file Jackett writes it to, as sonarr/radarr read theirs from config.xml — so a running * server needs no key configured by hand and no secret has to be put in an assignment. Without a * URL or key 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 ?? JackettClient.detectApiKey(env.MESH_JACKETT_CONFIG_DIR ?? "/config"); 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 or make the config dir readable"); return new JackettClient(url, apiKey); } /** Discover the API key from Jackett's ServerConfig.json (the linuxserver image keeps it at * /Jackett/ServerConfig.json), falling back to null. */ static detectApiKey(configDir: string): string | null { for (const file of [join(configDir, "Jackett", "ServerConfig.json"), join(configDir, "ServerConfig.json")]) { if (!existsSync(file)) continue; try { const key = (JSON.parse(readFileSync(file, "utf8")) as { APIKey?: unknown }).APIKey; if (typeof key === "string" && key) return key; } catch { /* unreadable or mid-write: try the next, then give up */ } } return null; } 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. * Read from the Torznab `t=indexers` feed, not /api/v2.0/indexers: that one is the web UI's and * wants a login cookie (it answers an API-key request with a redirect), while the Torznab feed is * what the key is for. The feed carries no last error, so `lastError` stays unset. */ async getIndexers(configuredOnly = true): Promise { const url = new URL(`${this.baseUrl}/api/v2.0/indexers/all/results/torznab/api`); url.searchParams.set("apikey", this.apiKey); url.searchParams.set("t", "indexers"); url.searchParams.set("configured", configuredOnly ? "true" : "false"); const res = await fetch(url.toString(), { headers: { Accept: "application/xml" } }); if (!res.ok) throw new Error(`Jackett API torznab t=indexers: ${res.status} ${await res.text()}`); const xml = await res.text(); // Torznab reports failures (a wrong key among them) as 200 with an body. const err = xml.match(/ block.match(new RegExp(`<${tag}>([^<]*)`))?.[1]; const out: JackettIndexer[] = []; for (const m of xml.matchAll(/([\s\S]*?)<\/indexer>/g)) { out.push({ id: m[1], name: text(m[3], "title") ?? m[1], type: text(m[3], "type") ?? "unknown", configured: m[2] === "true", siteLink: text(m[3], "link"), }); } return out; } /** * 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, })); } }