The manifest named /services/jackett/config and /var/lib/mesh/jackett/config.json — host
paths ADR 0112 takes out of definitions. The config dir is now a pathless directory
(${dir:config}) and the runtime's config and route binding live in a placed state dir, as
searxng does.
The image is pinned to v0.24.2627-ls34, the digest ace runs today; the old pin
(v0.24.2517-ls16) was older than the running version.
The runtime reached jackett at a fixed 127.0.0.1:9117; it now uses ${port:9117}, the
machine port the mesh actually assigned.
The tools never loaded: the client needed an API key nobody set. Like sonarr/radarr read
config.xml, it now reads APIKey from Jackett's own ServerConfig.json (the config dir is
already mounted read-only), so no secret goes into an assignment. jackett_indexers called
/api/v2.0/indexers, which is the web UI's endpoint and answers an API key with a redirect;
it now reads the Torznab t=indexers feed, and treats Torznab's 200-with-<error> as a
failure.
Verified: catalogue key tests (MESH_CATALOGUE set, not skipped); a throwaway container of
the pinned image on a fresh 0700 1000:1000 config dir serves its UI; the client discovers
the key from the generated ServerConfig.json, lists 617 indexers through the Torznab feed,
searches via /results, and a wrong key is refused; client.ts typechecks under --strict.
137 lines
5.9 KiB
TypeScript
137 lines
5.9 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 { 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<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. 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
|
|
* <config>/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<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.
|
|
* 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<JackettIndexer[]> {
|
|
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 <error> body.
|
|
const err = xml.match(/<error code="(\d+)" description="([^"]*)"/);
|
|
if (err) throw new Error(`Jackett API torznab t=indexers: error ${err[1]} ${err[2]}`);
|
|
const text = (block: string, tag: string) =>
|
|
block.match(new RegExp(`<${tag}>([^<]*)</${tag}>`))?.[1];
|
|
const out: JackettIndexer[] = [];
|
|
for (const m of xml.matchAll(/<indexer id="([^"]+)" configured="([^"]+)">([\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<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,
|
|
}));
|
|
}
|
|
}
|