100 lines
3.7 KiB
TypeScript
100 lines
3.7 KiB
TypeScript
// 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<string, string> {
|
|
if (!file) return {};
|
|
try { return JSON.parse(readFileSync(file, "utf8")) as Record<string, string>; }
|
|
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<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,
|
|
}));
|
|
}
|
|
}
|