diff --git a/modules/jackett/client.ts b/modules/jackett/client.ts index 61f5d92..a4369ad 100644 --- a/modules/jackett/client.ts +++ b/modules/jackett/client.ts @@ -2,7 +2,8 @@ // 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"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; export interface JackettIndexer { 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 - * 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. + * 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; + 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"); + 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); @@ -64,18 +83,36 @@ export class JackettClient { 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 { - 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, - })); + 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; } /** diff --git a/modules/jackett/module.json b/modules/jackett/module.json index 0b61232..f969d2c 100644 --- a/modules/jackett/module.json +++ b/modules/jackett/module.json @@ -10,7 +10,7 @@ "port": 9117, "protocol": "tcp", "from": "mesh", - "why": "the indexer proxy" + "why": "the indexer proxy: its web UI, and the Torznab feeds the *arr apps search through" } ], "resources": [ @@ -20,10 +20,15 @@ "path": "/var/lib/mesh/jackett", "mode": "0700" }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": "." + }, { "id": "config", "type": "directory", - "path": "/services/jackett/config", "mode": "0700", "owner": "1000:1000" }, @@ -31,7 +36,7 @@ "id": "server", "type": "container", "name": "jackett", - "image": "lscr.io/linuxserver/jackett@sha256:fd72d42b731ebf750b5de9711127251cf3b3f609419c32083ea8b3b3ee840b77", + "image": "lscr.io/linuxserver/jackett@sha256:7b19f4f6ac33d855ca9226600ecbd096ee678f66da28b13a7c09980b035ff583", "env": { "PUID": "1000", "PGID": "1000", @@ -41,13 +46,13 @@ "9117" ], "volumes": [ - "/services/jackett/config:/config" + "${dir:config}:/config" ] }, { "id": "runtime-config", "type": "file", - "path": "/var/lib/mesh/jackett/config.json", + "path": "${dir:state}/config.json", "mode": "0600", "content": "{}\n", "merge": "json" @@ -59,12 +64,12 @@ "network": "host", "volumes": [ "/var/lib/mesh/jackett/broker:/run/secrets/broker:ro", - "/var/lib/mesh/jackett/config.json:/run/config/config.json:ro", - "/services/jackett/config:/var/lib/jackett/config:ro" + "${dir:state}/config.json:/run/config/config.json:ro", + "${dir:config}:/var/lib/jackett/config:ro" ], "env": { "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_DIR": "/var/lib/jackett/config" }, @@ -87,7 +92,7 @@ } }, "binds": { - "route": "/var/lib/mesh/jackett/route.json" + "route": "${dir:state}/route.json" }, "build": { "on": [ diff --git a/modules/jackett/tools/index.ts b/modules/jackett/tools/index.ts index ccb360a..8c62150 100644 --- a/modules/jackett/tools/index.ts +++ b/modules/jackett/tools/index.ts @@ -9,7 +9,7 @@ export function getJackettTools(jackett: JackettClient): ToolDefinition[] { return [ { 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)" } }, run: async (args) => { const indexers = await jackett.getIndexers(!args.all);