Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dcdd45660 |
+16
-53
@@ -2,8 +2,7 @@
|
||||
// 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";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
export interface JackettIndexer {
|
||||
id: string;
|
||||
@@ -44,36 +43,18 @@ 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. 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.
|
||||
* 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
|
||||
?? JackettClient.detectApiKey(env.MESH_JACKETT_CONFIG_DIR ?? "/config");
|
||||
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 or make the config dir readable");
|
||||
if (!apiKey) throw new Error("no Jackett API key — set MESH_JACKETT_API_KEY");
|
||||
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);
|
||||
@@ -83,36 +64,18 @@ export class JackettClient {
|
||||
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.
|
||||
*/
|
||||
/** The configured indexers Jackett proxies. `configured=false` also lists the ones not set up. */
|
||||
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;
|
||||
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,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"port": 9117,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "the indexer proxy: its web UI, and the Torznab feeds the *arr apps search through"
|
||||
"why": "the indexer proxy"
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
@@ -20,15 +20,10 @@
|
||||
"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"
|
||||
},
|
||||
@@ -36,7 +31,7 @@
|
||||
"id": "server",
|
||||
"type": "container",
|
||||
"name": "jackett",
|
||||
"image": "lscr.io/linuxserver/jackett@sha256:7b19f4f6ac33d855ca9226600ecbd096ee678f66da28b13a7c09980b035ff583",
|
||||
"image": "lscr.io/linuxserver/jackett@sha256:fd72d42b731ebf750b5de9711127251cf3b3f609419c32083ea8b3b3ee840b77",
|
||||
"env": {
|
||||
"PUID": "1000",
|
||||
"PGID": "1000",
|
||||
@@ -46,13 +41,13 @@
|
||||
"9117"
|
||||
],
|
||||
"volumes": [
|
||||
"${dir:config}:/config"
|
||||
"/services/jackett/config:/config"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "runtime-config",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/config.json",
|
||||
"path": "/var/lib/mesh/jackett/config.json",
|
||||
"mode": "0600",
|
||||
"content": "{}\n",
|
||||
"merge": "json"
|
||||
@@ -64,12 +59,12 @@
|
||||
"network": "host",
|
||||
"volumes": [
|
||||
"/var/lib/mesh/jackett/broker:/run/secrets/broker:ro",
|
||||
"${dir:state}/config.json:/run/config/config.json:ro",
|
||||
"${dir:config}:/var/lib/jackett/config:ro"
|
||||
"/var/lib/mesh/jackett/config.json:/run/config/config.json:ro",
|
||||
"/services/jackett/config:/var/lib/jackett/config:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_JACKETT_URL": "http://127.0.0.1:${port:9117}",
|
||||
"MESH_JACKETT_URL": "http://127.0.0.1:9117",
|
||||
"MESH_JACKETT_CONFIG_FILE": "/run/config/config.json",
|
||||
"MESH_JACKETT_CONFIG_DIR": "/var/lib/jackett/config"
|
||||
},
|
||||
@@ -92,7 +87,7 @@
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
"route": "${dir:state}/route.json"
|
||||
"route": "/var/lib/mesh/jackett/route.json"
|
||||
},
|
||||
"build": {
|
||||
"on": [
|
||||
|
||||
@@ -9,7 +9,7 @@ export function getJackettTools(jackett: JackettClient): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "jackett_indexers",
|
||||
description: "List the indexers Jackett proxies, with their type and site.",
|
||||
description: "List the indexers Jackett proxies, with their type and any last error.",
|
||||
input: { all: { type: "boolean", description: "include indexers not yet configured (default false)" } },
|
||||
run: async (args) => {
|
||||
const indexers = await jackett.getIndexers(!args.all);
|
||||
|
||||
@@ -58,8 +58,10 @@ export class PlexClient {
|
||||
static fromEnv(env: NodeJS.ProcessEnv = process.env): PlexClient {
|
||||
const url = env.MESH_PLEX_URL ?? `http://127.0.0.1:${env.PLEX_PORT ?? "32400"}`;
|
||||
const dataDir = env.MESH_PLEX_DATA_DIR ?? "/var/lib/plex";
|
||||
// The operator-provided token is an own-secret the mesh mounts at MESH_PLEX_TOKEN_FILE (delivered
|
||||
// by `secret accept`); prefer it, fall back to a bare env var, then to discovery from the data dir.
|
||||
// The manifest sets neither MESH_PLEX_TOKEN_FILE nor MESH_PLEX_TOKEN: the server already keeps its
|
||||
// token in Preferences.xml, read here through the manifest's read-only mount of the config dir.
|
||||
// A token the mesh minted would be one plex.tv never issued, and preferring it would break every
|
||||
// call, so the module declares no token secret. The file and env overrides stay for hand runs.
|
||||
const token = readSecret(env.MESH_PLEX_TOKEN_FILE) ?? env.MESH_PLEX_TOKEN ?? PlexClient.detectToken(dataDir);
|
||||
if (!token) throw new Error("no Plex token — set MESH_PLEX_TOKEN or make the data dir readable");
|
||||
return new PlexClient(url, token);
|
||||
|
||||
+92
-19
@@ -13,8 +13,7 @@
|
||||
"*.download.completed"
|
||||
],
|
||||
"own-secrets": {
|
||||
"broker": "/var/lib/mesh/plex/broker",
|
||||
"token": "/var/lib/mesh/plex/token"
|
||||
"broker": "/var/lib/mesh/plex/broker"
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
@@ -22,7 +21,35 @@
|
||||
"port": 32400,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "streaming and the app; reaching it from outside is a route grant later"
|
||||
"why": "the server itself: the apps, streaming and the web player, direct and through its route"
|
||||
},
|
||||
{
|
||||
"name": "gdm-1",
|
||||
"port": 32410,
|
||||
"protocol": "udp",
|
||||
"from": "mesh",
|
||||
"why": "G'Day Mate discovery: players on the same network find the server without signing in"
|
||||
},
|
||||
{
|
||||
"name": "gdm-2",
|
||||
"port": 32412,
|
||||
"protocol": "udp",
|
||||
"from": "mesh",
|
||||
"why": "G'Day Mate discovery"
|
||||
},
|
||||
{
|
||||
"name": "gdm-3",
|
||||
"port": 32413,
|
||||
"protocol": "udp",
|
||||
"from": "mesh",
|
||||
"why": "G'Day Mate discovery"
|
||||
},
|
||||
{
|
||||
"name": "gdm-4",
|
||||
"port": 32414,
|
||||
"protocol": "udp",
|
||||
"from": "mesh",
|
||||
"why": "G'Day Mate discovery"
|
||||
}
|
||||
],
|
||||
"accesses": [
|
||||
@@ -45,6 +72,18 @@
|
||||
{
|
||||
"path": "/services/media/audiobooks",
|
||||
"mode": "read"
|
||||
},
|
||||
{
|
||||
"path": "/services/media/sport-games",
|
||||
"mode": "read"
|
||||
},
|
||||
{
|
||||
"path": "/services/media/live-shows",
|
||||
"mode": "read"
|
||||
},
|
||||
{
|
||||
"path": "/services/media/formula-1",
|
||||
"mode": "read"
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
@@ -54,39 +93,63 @@
|
||||
"path": "/var/lib/mesh/plex",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"place": "."
|
||||
},
|
||||
{
|
||||
"id": "config",
|
||||
"type": "directory",
|
||||
"path": "/services/plex/config",
|
||||
"mode": "0700",
|
||||
"mode": "0755",
|
||||
"owner": "1000:1000"
|
||||
},
|
||||
{
|
||||
"id": "data",
|
||||
"type": "directory",
|
||||
"mode": "0755",
|
||||
"owner": "1000:1000"
|
||||
},
|
||||
{
|
||||
"id": "transcode",
|
||||
"type": "directory",
|
||||
"path": "/services/plex/transcode",
|
||||
"mode": "0700",
|
||||
"mode": "0755",
|
||||
"owner": "1000:1000"
|
||||
},
|
||||
{
|
||||
"id": "server-env",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/server.env",
|
||||
"mode": "0600",
|
||||
"content": "ADVERTISE_IP=https://${bound:route:name}/\n"
|
||||
},
|
||||
{
|
||||
"id": "server",
|
||||
"type": "container",
|
||||
"name": "plex",
|
||||
"image": "plexinc/pms-docker@sha256:83a425ae9e133b1cb2cc3b809556e01c61cd8ff65c582e41b4374bc2210bac9e",
|
||||
"image": "plexinc/pms-docker@sha256:e0ab27395614a8e1a4fdf84c6bc60ac664915cfdde70c52d030c7728a1c48e14",
|
||||
"network": "host",
|
||||
"env": {
|
||||
"PLEX_UID": "1000",
|
||||
"PLEX_GID": "1000",
|
||||
"TZ": "Etc/UTC"
|
||||
},
|
||||
"env-file": [
|
||||
"${dir:state}/server.env"
|
||||
],
|
||||
"volumes": [
|
||||
"/services/plex/config:/config",
|
||||
"/services/plex/transcode:/transcode",
|
||||
"/services/media/movies:/movies",
|
||||
"/services/media/series:/series",
|
||||
"/services/media/anime:/anime",
|
||||
"/services/media/music:/music",
|
||||
"/services/media/audiobooks:/audiobooks"
|
||||
"${dir:config}:/config",
|
||||
"${dir:data}:/data",
|
||||
"${dir:transcode}:/transcode",
|
||||
"/services/media/movies:/movies:ro",
|
||||
"/services/media/series:/series:ro",
|
||||
"/services/media/anime:/anime:ro",
|
||||
"/services/media/music:/music:ro",
|
||||
"/services/media/audiobooks:/audiobooks:ro",
|
||||
"/services/media/sport-games:/sport-games:ro",
|
||||
"/services/media/live-shows:/live-shows:ro",
|
||||
"/services/media/formula-1:/formula-1:ro"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -96,18 +159,28 @@
|
||||
"network": "host",
|
||||
"volumes": [
|
||||
"/var/lib/mesh/plex/broker:/run/secrets/broker:ro",
|
||||
"/var/lib/mesh/plex/token:/run/secrets/token:ro",
|
||||
"/services/plex/config:/var/lib/plex/config:ro"
|
||||
"${dir:config}:/var/lib/plex/config:ro"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||
"MESH_PLEX_URL": "http://127.0.0.1:32400",
|
||||
"MESH_PLEX_TOKEN_FILE": "/run/secrets/token",
|
||||
"MESH_PLEX_URL": "http://127.0.0.1:${port:32400}",
|
||||
"MESH_PLEX_DATA_DIR": "/var/lib/plex"
|
||||
},
|
||||
"artifact": "runtime"
|
||||
}
|
||||
],
|
||||
"requires": [
|
||||
"route"
|
||||
],
|
||||
"contributes": {
|
||||
"route": {
|
||||
"label": "plex",
|
||||
"endpoint": "stream"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
"route": "${dir:state}/route.json"
|
||||
},
|
||||
"build": {
|
||||
"on": [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user