Compare commits

...
Author SHA1 Message Date
jschoubben 9d716ed875 jackett: provide its Torznab API as jackett-api
sonarr, radarr, lidarr and bookshelf reached jackett as http://jackett:9117 (a HAL
container name) or https://indexers.zurag.be (its public route), typed into each app by
hand. The mesh has neither: an app now requires jackett-api and its downloads step writes
the bound address into the app.

Serves scheme, port and url-base; `at` and the machine port come from the binding. Mesh
scope, like sonarr-api: an indexer proxy shares no files with its consumers.

The pair credential is jackett's one API key. The mesh cannot mint it, so the operator
accepts it per consumer pair (ADR 0092), as #156 does for sonarr-api; a consumer's step
refuses a minted value and names the accept.
2026-09-30 13:05:36 +02:00
jschoubben 75eee9d4a0 jackett: its config dir is placed, and its tools find their own key
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.
2026-09-29 23:41:21 +02:00
3 changed files with 81 additions and 26 deletions
+53 -16
View File
@@ -2,7 +2,8 @@
// an indexer proxy: it normalises many torrent trackers behind one Torznab surface. This client // 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. // talks its /api/v2.0 REST API, and only jackett's tools import it.
import { readFileSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
export interface JackettIndexer { export interface JackettIndexer {
id: string; id: string;
@@ -43,18 +44,36 @@ export class JackettClient {
/** /**
* Build from the module's resolved environment. Jackett's REST API is keyed, so both the URL and * 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 * the key must be present. The key is read from the settings-merged config or MESH_JACKETT_API_KEY,
* module contributes no tools rather than failing half-configured. * 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 { static fromEnv(env: NodeJS.ProcessEnv = process.env): JackettClient {
const cfg = meshConfig(env.MESH_JACKETT_CONFIG_FILE); const cfg = meshConfig(env.MESH_JACKETT_CONFIG_FILE);
const url = cfg.url ?? env.MESH_JACKETT_URL; const url = cfg.url ?? env.MESH_JACKETT_URL;
const apiKey = cfg.apiKey ?? env.MESH_JACKETT_API_KEY; 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 (!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"); 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); 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> { private async get(path: string, params: Record<string, string> = {}): Promise<any> {
const url = new URL(`${this.baseUrl}${path}`); const url = new URL(`${this.baseUrl}${path}`);
url.searchParams.set("apikey", this.apiKey); url.searchParams.set("apikey", this.apiKey);
@@ -64,18 +83,36 @@ export class JackettClient {
return res.json(); return res.json();
} }
/** The configured indexers Jackett proxies. `configured=false` also lists the ones not set up. */ /**
* 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[]> { async getIndexers(configuredOnly = true): Promise<JackettIndexer[]> {
const raw = await this.get("/api/v2.0/indexers", { configured: configuredOnly ? "true" : "false" }); const url = new URL(`${this.baseUrl}/api/v2.0/indexers/all/results/torznab/api`);
const list = Array.isArray(raw) ? raw : []; url.searchParams.set("apikey", this.apiKey);
return list.map((i: any) => ({ url.searchParams.set("t", "indexers");
id: i.id, url.searchParams.set("configured", configuredOnly ? "true" : "false");
name: i.name, const res = await fetch(url.toString(), { headers: { Accept: "application/xml" } });
type: i.type, if (!res.ok) throw new Error(`Jackett API torznab t=indexers: ${res.status} ${await res.text()}`);
configured: i.configured ?? false, const xml = await res.text();
siteLink: i.site_link, // Torznab reports failures (a wrong key among them) as 200 with an <error> body.
lastError: i.last_error || undefined, 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;
} }
/** /**
+27 -9
View File
@@ -1,6 +1,19 @@
{ {
"module": "jackett", "module": "jackett",
"version": "1", "version": "1",
"provides": [
{
"name": "jackett-api",
"scope": "mesh"
}
],
"serves": {
"jackett-api": {
"scheme": "http",
"port": 9117,
"url-base": ""
}
},
"capabilities": [ "capabilities": [
"container-runtime" "container-runtime"
], ],
@@ -10,7 +23,7 @@
"port": 9117, "port": 9117,
"protocol": "tcp", "protocol": "tcp",
"from": "mesh", "from": "mesh",
"why": "the indexer proxy" "why": "the indexer proxy: its web UI, and the Torznab feeds the *arr apps search through, which other modules reach as jackett-api"
} }
], ],
"resources": [ "resources": [
@@ -20,10 +33,15 @@
"path": "/var/lib/mesh/jackett", "path": "/var/lib/mesh/jackett",
"mode": "0700" "mode": "0700"
}, },
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
},
{ {
"id": "config", "id": "config",
"type": "directory", "type": "directory",
"path": "/services/jackett/config",
"mode": "0700", "mode": "0700",
"owner": "1000:1000" "owner": "1000:1000"
}, },
@@ -31,7 +49,7 @@
"id": "server", "id": "server",
"type": "container", "type": "container",
"name": "jackett", "name": "jackett",
"image": "lscr.io/linuxserver/jackett@sha256:fd72d42b731ebf750b5de9711127251cf3b3f609419c32083ea8b3b3ee840b77", "image": "lscr.io/linuxserver/jackett@sha256:7b19f4f6ac33d855ca9226600ecbd096ee678f66da28b13a7c09980b035ff583",
"env": { "env": {
"PUID": "1000", "PUID": "1000",
"PGID": "1000", "PGID": "1000",
@@ -41,13 +59,13 @@
"9117" "9117"
], ],
"volumes": [ "volumes": [
"/services/jackett/config:/config" "${dir:config}:/config"
] ]
}, },
{ {
"id": "runtime-config", "id": "runtime-config",
"type": "file", "type": "file",
"path": "/var/lib/mesh/jackett/config.json", "path": "${dir:state}/config.json",
"mode": "0600", "mode": "0600",
"content": "{}\n", "content": "{}\n",
"merge": "json" "merge": "json"
@@ -59,12 +77,12 @@
"network": "host", "network": "host",
"volumes": [ "volumes": [
"/var/lib/mesh/jackett/broker:/run/secrets/broker:ro", "/var/lib/mesh/jackett/broker:/run/secrets/broker:ro",
"/var/lib/mesh/jackett/config.json:/run/config/config.json:ro", "${dir:state}/config.json:/run/config/config.json:ro",
"/services/jackett/config:/var/lib/jackett/config:ro" "${dir:config}:/var/lib/jackett/config:ro"
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_JACKETT_URL": "http://127.0.0.1:9117", "MESH_JACKETT_URL": "http://127.0.0.1:${port:9117}",
"MESH_JACKETT_CONFIG_FILE": "/run/config/config.json", "MESH_JACKETT_CONFIG_FILE": "/run/config/config.json",
"MESH_JACKETT_CONFIG_DIR": "/var/lib/jackett/config" "MESH_JACKETT_CONFIG_DIR": "/var/lib/jackett/config"
}, },
@@ -87,7 +105,7 @@
} }
}, },
"binds": { "binds": {
"route": "/var/lib/mesh/jackett/route.json" "route": "${dir:state}/route.json"
}, },
"build": { "build": {
"on": [ "on": [
+1 -1
View File
@@ -9,7 +9,7 @@ export function getJackettTools(jackett: JackettClient): ToolDefinition[] {
return [ return [
{ {
name: "jackett_indexers", name: "jackett_indexers",
description: "List the indexers Jackett proxies, with their type and any last error.", description: "List the indexers Jackett proxies, with their type and site.",
input: { all: { type: "boolean", description: "include indexers not yet configured (default false)" } }, input: { all: { type: "boolean", description: "include indexers not yet configured (default false)" } },
run: async (args) => { run: async (args) => {
const indexers = await jackett.getIndexers(!args.all); const indexers = await jackett.getIndexers(!args.all);