Compare commits

..
Author SHA1 Message Date
jschoubben 0010b6bf21 ombi: adopt the Plex entry that plex itself answers for
ace's ombi holds one Plex entry, loaded from an older server and later
retyped to plex's public name: its stored machineIdentifier is not plex's,
while its address answers as plex. Matching by identifier alone would leave
it and add a second entry for the same server.

When no entry carries the server's identifier, each entry's own address is
asked for /identity, and an entry plex answers for is adopted: the bound
connection laid over, and the identifier corrected (ombi builds its "view in
Plex" links from it). Its name, libraries and every other choice stay. An
entry that cannot be asked, or answers as another server, is left alone;
nothing is guessed. Every call of the step is now bounded, since an entry may
name a host that no longer answers.
2026-09-30 13:14:12 +02:00
jschoubben b068a9d399 ombi: reach plex through the mesh, in the same step as the Servarr apps
ombi reached plex at its public name, typed into its settings screen, so it
depended on plex's public route and on nobody moving plex. ombi now requires
plex-api, and the run-once step that writes its Servarr connections writes
its Plex one too - renamed from `servarr` to `connections`, since it is no
longer only that.

ombi keeps several Plex servers. The entry this provision names is found by
the server's own machineIdentifier (plex answers it at /identity, and ombi
stored it when the server was loaded), and only its host, port, TLS, base
path and token are written, only when they differ. Another server's entry,
the selected libraries, whether Plex is enabled and every other choice are
left alone. An ombi with no entry for the server gets one.

The token is tried against plex first. Until the operator accepts the
server's X-Plex-Token for this pair the mesh delivers a value it minted,
which plex refuses (401, or 400 on a network it trusts); refused, nothing is
written and the step fails naming the secret accept, so a working token in
ombi is never replaced by a dead one.

Tests import the compiled step, as keycloak's do: the step imports its
sibling with the .js specifier the build needs, which type stripping does
not resolve. `npm test` builds first.
2026-09-30 12:59:12 +02:00
18 changed files with 604 additions and 696 deletions
+1 -4
View File
@@ -13,7 +13,7 @@ ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/bazarr WORKDIR /app/modules/bazarr
COPY . . COPY . .
RUN node /app/node_modules/typescript/bin/tsc apikey.ts client.ts index.ts tools/index.ts servarr/settings.ts servarr/index.ts \ RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE} FROM ${RUNTIME_BASE}
@@ -22,6 +22,3 @@ COPY --from=build /app/modules/bazarr/dist /app/modules/bazarr/dist
# provider's provisioner runs its reconcile loop in the same process, with the broker connected — # provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled. # the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/bazarr/dist/index.js,/app/modules/bazarr/dist/tools/index.js ENV MESH_TOOL_MODULES=/app/modules/bazarr/dist/index.js,/app/modules/bazarr/dist/tools/index.js
# NOT dist/servarr/index.js: that is a step the host runs to completion, named by the `servarr`
# container's args as `mesh-tools run …` (novox/hq ADR 0052). Listed here it would run inside the
# serving sidecar too, and exit it.
-37
View File
@@ -1,37 +0,0 @@
// bazarr's own API key, found where bazarr keeps it. Shared by the client (tools, events) and the
// Servarr step, and kept apart from client.ts so the step and its test load it without the client.
import { readFileSync } from "node:fs";
/**
* bazarr's own API key, read from where bazarr keeps it: `auth.apikey` in `config/config.yaml` under
* its config directory. bazarr makes this key itself on first start and nothing lets the mesh set it,
* so a key the mesh minted could never work; reading bazarr's own file needs nothing accepted and
* stays right if the operator regenerates the key in bazarr's settings screen. The file is read, never
* written. Undefined when the file or the key is not there.
*/
export function apiKeyFromConfigDir(configDir?: string): string | undefined {
if (!configDir) return undefined;
let text: string;
try { text = readFileSync(`${configDir.replace(/\/$/, "")}/config/config.yaml`, "utf8"); }
catch { return undefined; }
return apiKeyFromConfigYaml(text);
}
/** `auth.apikey` from the text of bazarr's config.yaml — a top-level `auth:` mapping, one level deep. */
export function apiKeyFromConfigYaml(text: string): string | undefined {
let inAuth = false;
for (const line of text.split(/\r?\n/)) {
if (/^\S/.test(line)) {
inAuth = /^auth:\s*$/.test(line);
continue;
}
if (!inAuth) continue;
const m = line.match(/^\s+apikey:\s*(.*?)\s*$/);
if (m) {
const v = m[1].replace(/^(['"])(.*)\1$/, "$2").trim();
return v || undefined;
}
}
return undefined;
}
+3 -9
View File
@@ -5,8 +5,6 @@
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { apiKeyFromConfigDir } from "./apikey.js";
export interface WantedSubtitle { export interface WantedSubtitle {
kind: "episode" | "movie"; kind: "episode" | "movie";
title: string; // series + episode, or movie title title: string; // series + episode, or movie title
@@ -49,7 +47,7 @@ function meshConfig(file?: string): Record<string, string> {
* absent or unreadable yields undefined so callers fall back rather than crash. */ * absent or unreadable yields undefined so callers fall back rather than crash. */
function readSecret(file?: string): string | undefined { function readSecret(file?: string): string | undefined {
if (!file) return undefined; if (!file) return undefined;
try { return readFileSync(file, "utf8").trim() || undefined; } try { return readFileSync(file, "utf8").trim(); }
catch { return undefined; } catch { return undefined; }
} }
@@ -68,13 +66,9 @@ export class BazarrClient {
static fromEnv(env: NodeJS.ProcessEnv = process.env): BazarrClient { static fromEnv(env: NodeJS.ProcessEnv = process.env): BazarrClient {
const cfg = meshConfig(env.MESH_BAZARR_CONFIG_FILE); const cfg = meshConfig(env.MESH_BAZARR_CONFIG_FILE);
const url = cfg.url ?? env.MESH_BAZARR_URL; const url = cfg.url ?? env.MESH_BAZARR_URL;
const apiKey = const apiKey = cfg.apiKey ?? readSecret(env.MESH_BAZARR_API_KEY_FILE) ?? env.MESH_BAZARR_API_KEY;
cfg.apiKey ??
apiKeyFromConfigDir(env.MESH_BAZARR_CONFIG_DIR) ??
readSecret(env.MESH_BAZARR_API_KEY_FILE) ??
env.MESH_BAZARR_API_KEY;
if (!url) throw new Error("no Bazarr URL — set MESH_BAZARR_URL"); if (!url) throw new Error("no Bazarr URL — set MESH_BAZARR_URL");
if (!apiKey) throw new Error("no Bazarr API key — bazarr's config/config.yaml under MESH_BAZARR_CONFIG_DIR has none"); if (!apiKey) throw new Error("no Bazarr API key — set MESH_BAZARR_API_KEY");
return new BazarrClient(url, apiKey); return new BazarrClient(url, apiKey);
} }
+12 -52
View File
@@ -8,7 +8,8 @@
"subtitle.downloaded" "subtitle.downloaded"
], ],
"own-secrets": { "own-secrets": {
"broker": "/var/lib/mesh/bazarr/broker" "broker": "/var/lib/mesh/bazarr/broker",
"api-key": "/var/lib/mesh/bazarr/api-key"
}, },
"listens": [ "listens": [
{ {
@@ -44,15 +45,10 @@
"path": "/var/lib/mesh/bazarr", "path": "/var/lib/mesh/bazarr",
"mode": "0700" "mode": "0700"
}, },
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
},
{ {
"id": "config", "id": "config",
"type": "directory", "type": "directory",
"path": "/services/bazarr/config",
"mode": "0700", "mode": "0700",
"owner": "1000:1000" "owner": "1000:1000"
}, },
@@ -60,7 +56,7 @@
"id": "server", "id": "server",
"type": "container", "type": "container",
"name": "bazarr", "name": "bazarr",
"image": "lscr.io/linuxserver/bazarr@sha256:d24bd0048c759a468970989e9df11a6b96a7628d556d00f923e60a35ba59237b", "image": "lscr.io/linuxserver/bazarr@sha256:3a820372f19fcb2981ea19fe4b5382934d67414afaba974bce831ddda0a64a02",
"env": { "env": {
"PUID": "1000", "PUID": "1000",
"PGID": "1000", "PGID": "1000",
@@ -70,7 +66,7 @@
"6767" "6767"
], ],
"volumes": [ "volumes": [
"${dir:config}:/config", "/services/bazarr/config:/config",
"/services/media/movies:/movies", "/services/media/movies:/movies",
"/services/media/series:/series", "/services/media/series:/series",
"/services/media/anime:/anime", "/services/media/anime:/anime",
@@ -80,7 +76,7 @@
{ {
"id": "runtime-config", "id": "runtime-config",
"type": "file", "type": "file",
"path": "${dir:state}/config.json", "path": "/var/lib/mesh/bazarr/config.json",
"mode": "0600", "mode": "0600",
"content": "{}\n", "content": "{}\n",
"merge": "json" "merge": "json"
@@ -92,12 +88,14 @@
"network": "host", "network": "host",
"volumes": [ "volumes": [
"/var/lib/mesh/bazarr/broker:/run/secrets/broker:ro", "/var/lib/mesh/bazarr/broker:/run/secrets/broker:ro",
"${dir:state}/config.json:/run/config/config.json:ro", "/var/lib/mesh/bazarr/api-key:/run/secrets/api-key:ro",
"${dir:config}:/var/lib/bazarr/config:ro" "/var/lib/mesh/bazarr/config.json:/run/config/config.json:ro",
"/services/bazarr/config:/var/lib/bazarr/config:ro"
], ],
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_BAZARR_URL": "http://127.0.0.1:${port:6767}", "MESH_BAZARR_URL": "http://127.0.0.1:${port:6767}",
"MESH_BAZARR_API_KEY_FILE": "/run/secrets/api-key",
"MESH_BAZARR_CONFIG_FILE": "/run/config/config.json", "MESH_BAZARR_CONFIG_FILE": "/run/config/config.json",
"MESH_BAZARR_CONFIG_DIR": "/var/lib/bazarr/config" "MESH_BAZARR_CONFIG_DIR": "/var/lib/bazarr/config"
}, },
@@ -105,42 +103,10 @@
"runtime-config" "runtime-config"
], ],
"artifact": "runtime" "artifact": "runtime"
},
{
"id": "servarr",
"type": "container",
"name": "mesh-bazarr-servarr",
"network": "host",
"run-once": true,
"volumes": [
"${dir:config}:/var/lib/bazarr/config:ro",
"${dir:state}/sonarr-api.json:/run/servarr/sonarr-api.json:ro",
"${dir:state}/sonarr-api.secret:/run/servarr/sonarr-api.secret:ro",
"${dir:state}/radarr-api.json:/run/servarr/radarr-api.json:ro",
"${dir:state}/radarr-api.secret:/run/servarr/radarr-api.secret:ro"
],
"env": {
"MESH_BAZARR_URL": "http://127.0.0.1:${port:6767}",
"MESH_BAZARR_CONFIG_DIR": "/var/lib/bazarr/config",
"MESH_SERVARR_DIR": "/run/servarr"
},
"args": [
"run",
"/app/modules/bazarr/dist/servarr/index.js"
],
"restart-on": [
"bound-sonarr-api",
"secret-sonarr-api",
"bound-radarr-api",
"secret-radarr-api"
],
"artifact": "runtime"
} }
], ],
"requires": [ "requires": [
"radarr-api", "route"
"route",
"sonarr-api"
], ],
"contributes": { "contributes": {
"route": { "route": {
@@ -149,13 +115,7 @@
} }
}, },
"binds": { "binds": {
"route": "${dir:state}/route.json", "route": "/var/lib/mesh/bazarr/route.json"
"sonarr-api": "${dir:state}/sonarr-api.json",
"radarr-api": "${dir:state}/radarr-api.json"
},
"secrets": {
"sonarr-api": "${dir:state}/sonarr-api.secret",
"radarr-api": "${dir:state}/radarr-api.secret"
}, },
"build": { "build": {
"on": [ "on": [
-5
View File
@@ -4,11 +4,6 @@
"description": "bazarr — subtitle management. Its API client, tools and events live here (novox/hq ADR 0039).", "description": "bazarr — subtitle management. Its API client, tools and events live here (novox/hq ADR 0039).",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": {
"build": "tsc apikey.ts client.ts index.ts tools/index.ts servarr/settings.ts servarr/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
"typecheck": "tsc -p tsconfig.json",
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
},
"dependencies": { "dependencies": {
"@novox/mesh-sdk": "^0.1.0" "@novox/mesh-sdk": "^0.1.0"
}, },
-63
View File
@@ -1,63 +0,0 @@
// bazarr's Servarr step — run once by the host after bazarr's server starts, and run again whenever a
// binding or pair credential it reads changes (the container's `restart-on`, novox/hq ADR 0099).
//
// **A step, not a loop**: everything it does is a function of files the mesh writes, and the host
// already knows when they change. It connects to no broker.
//
// Exits non-zero when any app could not be put right — a refused credential, an unreachable app, a
// bazarr that would not keep the settings — so the node reports the step failed and the host runs it
// again on the next apply. Declared last in the manifest, so its failing gates nothing else of
// bazarr's (novox/hq ADR 0136).
//
// Reads, per app, `<dir>/<provision>.json` (the binding) and `<dir>/<provision>.secret` (the pair
// credential), where <dir> is MESH_SERVARR_DIR; and bazarr's own key from bazarr's own config.yaml
// under MESH_BAZARR_CONFIG_DIR. Never prints a key.
import { join } from "node:path";
import { apiKeyFromConfigDir } from "../apikey.js";
import { APPS, bazarrReady, readBinding, readIfThere, reconcileApp, type Http } from "./settings.js";
const dir = process.env.MESH_SERVARR_DIR ?? "/run/servarr";
const url = process.env.MESH_BAZARR_URL ?? "http://127.0.0.1:6767";
const waitSeconds = Number(process.env.MESH_BAZARR_WAIT_SECONDS ?? "180");
const http: Http = { fetch: (u, init) => fetch(u, init) };
const bazarrUp = await bazarrReady(http, { url, apiKey: "" }, waitSeconds * 1000);
if (!bazarrUp) {
console.error(`[bazarr-servarr] bazarr did not answer at ${url} within ${waitSeconds}s`);
process.exit(1);
}
// Read after bazarr answers: on a first start bazarr writes its config.yaml, key included, as it boots.
const apiKey = apiKeyFromConfigDir(process.env.MESH_BAZARR_CONFIG_DIR);
if (!apiKey) {
console.error("[bazarr-servarr] no bazarr API key in bazarr's config/config.yaml under MESH_BAZARR_CONFIG_DIR");
process.exit(1);
}
const bazarr = { url, apiKey };
let failed = 0;
for (const spec of APPS) {
const outcome = await reconcileApp(
http,
bazarr,
spec,
await readBinding(join(dir, `${spec.provision}.json`)),
await readIfThere(join(dir, `${spec.provision}.secret`)),
);
switch (outcome.result) {
case "unchanged":
console.log(`[bazarr-servarr] ${outcome.app}: already as the mesh says; key taken by ${outcome.app}`);
break;
case "written":
console.log(`[bazarr-servarr] ${outcome.app}: wrote ${outcome.fields.join(", ")}; key taken by ${outcome.app}`);
break;
case "refused":
failed++;
console.error(`[bazarr-servarr] ${outcome.app}: ${outcome.problem}`);
break;
}
}
process.exitCode = failed > 0 ? 1 : 0;
-290
View File
@@ -1,290 +0,0 @@
// Where bazarr reaches Sonarr and Radarr — decided by the mesh, written into bazarr by bazarr's own
// API.
//
// **Why this exists.** bazarr keeps its connection to each app in its own `config/config.yaml`, which
// it holds in memory and writes back whenever its settings change — so the mesh cannot own that file
// without the two overwriting each other. bazarr requires `sonarr-api` and `radarr-api`; the mesh
// delivers, for each, a binding (where the app is: `at`, and what it serves: `port`, `scheme`,
// `url-base`) and a pair credential (the app's API key, accepted by the operator — a Servarr app has
// exactly one key and the mesh cannot mint it, novox/hq ADR 0092). This step reads those files and
// makes bazarr's settings say the same thing, through `POST /api/system/settings` — the call bazarr's
// own settings screen makes, which also restarts bazarr's SignalR feed from the app.
//
// **Only the connection, and only when it differs.** Host, port, TLS, base path and API key. Whether
// bazarr uses the app at all (`general.use_sonarr`), sync intervals, excluded tags, path mappings and
// every other choice the operator made are left exactly as they are: the mesh knows where the app is,
// not what bazarr should do with it.
//
// **A credential the app refuses is never written.** Until the operator accepts the app's key for this
// pair the mesh delivers a value it minted, which no Servarr app accepts. Writing it would replace a
// working key in bazarr with a dead one, so the key is tried against the app first; refused, nothing
// for that app is written and the step fails naming the `secret accept` that fixes it.
//
// The same shape as ombi's step (modules/ombi/servarr), repeated rather than shared because a module
// is built from its own directory and nothing else (novox/hq ADR 0069). Pure logic and a small HTTP
// seam, tested against fakes (test/servarr.test.ts).
import { readFile } from "node:fs/promises";
/** One Servarr app bazarr connects to. */
export interface ServarrApp {
/** The section of bazarr's settings that holds the connection: settings.<app>.* */
app: "sonarr" | "radarr";
/** The provision it is required as — the manifest's `requires`, `binds` and `secrets` key. */
provision: string;
/** The app's own status endpoint, which answers 401 to a wrong key. */
statusPath: string;
}
export const APPS: readonly ServarrApp[] = [
{ app: "sonarr", provision: "sonarr-api", statusPath: "/api/v3/system/status" },
{ app: "radarr", provision: "radarr-api", statusPath: "/api/v3/system/status" },
];
/** The connection fields bazarr keeps for an app — the only ones this step ever writes. */
export interface Connection {
ip: string;
port: number;
ssl: boolean;
/** bazarr's base_url: "" at the root, otherwise "/base". */
base_url: string;
apikey: string;
}
/** What the mesh wrote at `binds.<provision>`: the binding document. */
export interface Binding {
provision?: string;
from?: string;
at?: string;
as?: string;
serves?: Record<string, unknown>;
}
export type Wanted = { ok: true; connection: Connection; from: string } | { ok: false; problem: string };
/**
* The connection the mesh says bazarr should use. Refused rather than guessed when the binding cannot
* be dialled from bazarr's own container: a loopback `at` is bazarr's container itself.
*/
export function wanted(spec: ServarrApp, binding: Binding | undefined, credential: string | undefined): Wanted {
if (!binding) {
return { ok: false, problem: `no binding for ${spec.provision} was delivered — the mesh writes it before this step runs` };
}
const at = typeof binding.at === "string" ? binding.at.trim() : "";
const serves = binding.serves ?? {};
const port = Number(serves.port);
if (!at) return { ok: false, problem: `the ${spec.provision} binding names no host (at)` };
if (isLoopback(at)) {
return {
ok: false,
problem:
`the ${spec.provision} binding says ${spec.app} is at ${at}, which from bazarr's own container is ` +
`bazarr itself. The mesh hands loopback to a machine that is not on the private network; put it ` +
`on the private network so ${spec.app} has an address bazarr can dial`,
};
}
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
return { ok: false, problem: `the ${spec.provision} binding serves no usable port (${String(serves.port)})` };
}
const scheme = typeof serves.scheme === "string" && serves.scheme ? serves.scheme : "http";
if (scheme !== "http" && scheme !== "https") {
return { ok: false, problem: `the ${spec.provision} binding serves scheme ${scheme}, which bazarr cannot dial` };
}
const key = (credential ?? "").trim();
if (!key) return { ok: false, problem: `the ${spec.provision} credential is empty or was not delivered` };
return {
ok: true,
from: typeof binding.from === "string" ? binding.from : "",
connection: { ip: at, port, ssl: scheme === "https", base_url: baseUrlOf(serves["url-base"]), apikey: key },
};
}
/** A URL base as bazarr stores it: "" for none, else one leading slash and no trailing one. */
export function baseUrlOf(urlBase: unknown): string {
const trimmed = typeof urlBase === "string" ? urlBase.trim().replace(/^\/+|\/+$/g, "") : "";
return trimmed === "" ? "" : `/${trimmed}`;
}
function isLoopback(host: string): boolean {
const h = host.toLowerCase();
return h === "localhost" || h === "::1" || h === "[::1]" || /^127\./.test(h);
}
/** Which connection fields differ between what bazarr holds and what the mesh says. Names only. */
export function differing(current: Record<string, unknown> | undefined, want: Connection): (keyof Connection)[] {
const now = current ?? {};
const out: (keyof Connection)[] = [];
if (String(now.ip ?? "") !== want.ip) out.push("ip");
if (Number(now.port ?? 0) !== want.port) out.push("port");
if (Boolean(now.ssl) !== want.ssl) out.push("ssl");
if (baseUrlOf(now.base_url) !== want.base_url) out.push("base_url");
if (String(now.apikey ?? "") !== want.apikey) out.push("apikey");
return out;
}
/**
* The form bazarr's settings endpoint takes for the differing fields: `settings-<app>-<field>`.
* bazarr casts "true"/"false" to booleans and digit strings to integers itself.
*/
export function settingsForm(spec: ServarrApp, want: Connection, fields: readonly (keyof Connection)[]): URLSearchParams {
const form = new URLSearchParams();
for (const f of fields) {
const v = want[f];
form.append(`settings-${spec.app}-${f}`, typeof v === "boolean" ? (v ? "true" : "false") : String(v));
}
return form;
}
/** The app's base URL as the step dials it — the same host and port bazarr will be given. */
export function appUrl(want: Connection): string {
const scheme = want.ssl ? "https" : "http";
const host = want.ip.includes(":") && !want.ip.startsWith("[") ? `[${want.ip}]` : want.ip;
return `${scheme}://${host}:${want.port}${want.base_url}`;
}
/** How one app came out. */
export type Outcome =
| { app: string; result: "unchanged" }
| { app: string; result: "written"; fields: string[] }
| { app: string; result: "refused"; problem: string };
/** The HTTP the step needs, so a test can stand fakes in for bazarr and the apps. */
export interface Http {
fetch(url: string, init?: { method?: string; headers?: Record<string, string>; body?: string }): Promise<{
status: number;
text(): Promise<string>;
}>;
}
export interface Bazarr {
url: string;
apiKey: string;
}
async function bazarrCall(http: Http, bazarr: Bazarr, method: string, path: string, form?: URLSearchParams): Promise<unknown> {
const res = await http.fetch(`${bazarr.url.replace(/\/$/, "")}/api${path}`, {
method,
headers: {
"X-API-KEY": bazarr.apiKey,
Accept: "application/json",
...(form ? { "Content-Type": "application/x-www-form-urlencoded" } : {}),
},
body: form ? form.toString() : undefined,
});
const text = await res.text();
if (res.status < 200 || res.status >= 300) {
// bazarr's error body is a message, never a request echo, so it carries no key.
throw new Error(`bazarr ${method} ${path} answered ${res.status}${text ? `: ${text.slice(0, 200)}` : ""}`);
}
return text ? (JSON.parse(text) as unknown) : undefined;
}
/**
* Does the app take this key? `true` it does, `false` it refused it (401/403), and a thrown error
* when it could not be asked.
*/
export async function appTakes(http: Http, spec: ServarrApp, want: Connection): Promise<boolean> {
const res = await http.fetch(`${appUrl(want)}${spec.statusPath}`, {
method: "GET",
headers: { "X-Api-Key": want.apikey, Accept: "application/json" },
});
if (res.status === 401 || res.status === 403) return false;
if (res.status >= 200 && res.status < 300) return true;
throw new Error(`${spec.app} answered ${res.status} at ${spec.statusPath}`);
}
/** The remedy for a refused key, in the controller's own words (ADR 0092). */
export function acceptRemedy(spec: ServarrApp, from: string): string {
return (
`${spec.app} refuses the ${spec.provision} credential the mesh delivered, so it was not written ` +
`into bazarr. A Servarr app has one API key and the mesh cannot make it: accept ${spec.app}'s own ` +
`key for this pair — \`secret accept <this node> bazarr ${spec.provision} --provider ${from || "<its node>"} ` +
`--from <file holding ${spec.app}'s ApiKey>\``
);
}
/**
* Bring bazarr's connection to one app in line with the mesh: check the key against the app, compare,
* write only the differing connection fields, then read bazarr's settings back to confirm they took.
* Never throws: every failure is an outcome with a reason.
*/
export async function reconcileApp(
http: Http,
bazarr: Bazarr,
spec: ServarrApp,
binding: Binding | undefined,
credential: string | undefined,
): Promise<Outcome> {
const w = wanted(spec, binding, credential);
// `in`, not `!w.ok`: the Dockerfile compiles without strict, where a boolean discriminant does not
// narrow.
if ("problem" in w) return { app: spec.app, result: "refused", problem: w.problem };
const want = w.connection;
try {
if (!(await appTakes(http, spec, want))) {
return { app: spec.app, result: "refused", problem: acceptRemedy(spec, w.from) };
}
} catch (err) {
return {
app: spec.app,
result: "refused",
problem: `${spec.app} could not be asked whether it takes the key at ${want.ip}:${want.port}: ${message(err)}`,
};
}
try {
const before = await sectionOf(http, bazarr, spec);
const fields = differing(before, want);
if (fields.length === 0) return { app: spec.app, result: "unchanged" };
await bazarrCall(http, bazarr, "POST", "/system/settings", settingsForm(spec, want, fields));
const still = differing(await sectionOf(http, bazarr, spec), want);
if (still.length > 0) {
return { app: spec.app, result: "refused", problem: `bazarr did not keep its ${spec.app} settings (${still.join(", ")})` };
}
return { app: spec.app, result: "written", fields };
} catch (err) {
return { app: spec.app, result: "refused", problem: message(err) };
}
}
async function sectionOf(http: Http, bazarr: Bazarr, spec: ServarrApp): Promise<Record<string, unknown> | undefined> {
const doc = (await bazarrCall(http, bazarr, "GET", "/system/settings")) as Record<string, unknown> | undefined;
return doc?.[spec.app] as Record<string, unknown> | undefined;
}
/** Wait for bazarr to answer, because the step runs right after its container starts. */
export async function bazarrReady(http: Http, bazarr: Bazarr, waitMs: number, pauseMs = 2000): Promise<boolean> {
const until = Date.now() + waitMs;
for (;;) {
try {
const res = await http.fetch(`${bazarr.url.replace(/\/$/, "")}/api/system/ping`, { method: "GET" });
if (res.status === 200) return true;
} catch {
// not listening yet
}
if (Date.now() >= until) return false;
await new Promise((r) => setTimeout(r, pauseMs));
}
}
/** A file the mesh wrote, or undefined when it is not there. */
export async function readIfThere(path: string | undefined): Promise<string | undefined> {
if (!path) return undefined;
return readFile(path, "utf8").catch(() => undefined);
}
/** A binding file parsed, or undefined when absent or not JSON. */
export async function readBinding(path: string | undefined): Promise<Binding | undefined> {
const raw = await readIfThere(path);
if (raw === undefined) return undefined;
try {
return JSON.parse(raw) as Binding;
} catch {
return undefined;
}
}
function message(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
-156
View File
@@ -1,156 +0,0 @@
// What holds bazarr's Servarr step (servarr/settings.ts): the connection bazarr keeps for Sonarr and
// Radarr is made to say what the mesh bound — host, port, TLS, base path, key — and nothing else
// bazarr keeps is sent; nothing is written when nothing differs; and a key the app refuses (the mesh's
// own minted value, before the operator accepts the app's key) is never written, with the
// `secret accept` that fixes it named. Also: bazarr's own key is found in its config.yaml's `auth`
// section and not in the `sonarr`/`radarr` sections that also carry an `apikey`.
//
// bazarr and the apps are fakes answering as the real ones do (checked against
// lscr.io/linuxserver/bazarr v1.6.1-ls364: GET/POST /api/system/settings with X-API-KEY, the form
// keys `settings-<section>-<field>`, 204 on save).
import { test } from "node:test";
import assert from "node:assert/strict";
import { apiKeyFromConfigYaml } from "../apikey.ts";
import { APPS, baseUrlOf, differing, reconcileApp, wanted, type Binding, type Http, type ServarrApp } from "../servarr/settings.ts";
const SONARR = APPS.find((a) => a.app === "sonarr") as ServarrApp;
const RADARR = APPS.find((a) => a.app === "radarr") as ServarrApp;
const THE_KEY = "the-apps-own-key";
const BAZARR = { url: "http://127.0.0.1:6767", apiKey: "bazarr-key" };
function binding(provision: string, port: number, at = "ace.internal"): Binding {
return { binding: 1, provision, from: "ace", at, as: "mesh_ace_bazarr", serves: { scheme: "http", port, "url-base": "" } } as Binding;
}
interface Call {
method: string;
url: string;
body?: string;
}
/** bazarr's settings (one document, sections per app) and the apps' key check, behind one fetch. */
function fakes(settings: Record<string, Record<string, unknown>>, opts: { appKey?: string; reachable?: boolean } = {}) {
const calls: Call[] = [];
const appKey = opts.appKey ?? THE_KEY;
const http: Http = {
async fetch(url, init) {
const method = init?.method ?? "GET";
calls.push({ method, url, body: init?.body });
const reply = (status: number, value?: unknown) => ({
status,
text: async () => (value === undefined ? "" : JSON.stringify(value)),
});
const u = new URL(url);
if (u.pathname.endsWith("/system/status")) {
if (opts.reachable === false) throw new Error("connect ECONNREFUSED");
return init?.headers?.["X-Api-Key"] === appKey ? reply(200, { version: "4" }) : reply(401);
}
if (init?.headers?.["X-API-KEY"] !== BAZARR.apiKey) return reply(401);
if (u.pathname !== "/api/system/settings") return reply(404);
if (method === "GET") return reply(200, settings);
// bazarr's save_settings: split the key, cast as bazarr casts, store.
for (const [k, raw] of new URLSearchParams(init?.body ?? "")) {
const [, section, field] = k.split("-");
let v: unknown = raw;
if (raw === "true") v = true;
else if (raw === "false") v = false;
else if (/^\d+$/.test(raw)) v = Number(raw);
settings[section] = { ...(settings[section] ?? {}), [field]: v };
}
return reply(204);
},
};
return { http, calls, settings };
}
/** ace's bazarr today: the apps by container name on HAL's shared network. */
function aceToday(): Record<string, Record<string, unknown>> {
return {
general: { use_sonarr: true, use_radarr: true, port: 6767 },
sonarr: { ip: "sonarr", port: 8989, ssl: false, base_url: "", apikey: THE_KEY, series_sync: 15, excluded_series_types: ["anime"] },
radarr: { ip: "radarr", port: 7878, ssl: false, base_url: "", apikey: THE_KEY, movies_sync: 15 },
};
}
test("moving an app writes only host and port, and leaves every other setting alone", async () => {
const f = fakes(aceToday());
const out = await reconcileApp(f.http, BAZARR, SONARR, binding("sonarr-api", 20010), THE_KEY);
assert.deepEqual(out, { app: "sonarr", result: "written", fields: ["ip", "port"] });
const post = f.calls.find((c) => c.method === "POST");
assert.ok(post);
assert.deepEqual([...new URLSearchParams(post.body ?? "").keys()].sort(), ["settings-sonarr-ip", "settings-sonarr-port"]);
assert.equal(f.settings.sonarr.ip, "ace.internal");
assert.equal(f.settings.sonarr.port, 20010);
assert.deepEqual(f.settings.sonarr.excluded_series_types, ["anime"]);
assert.equal(f.settings.radarr.ip, "radarr", "radarr is its own app and was not touched");
});
test("nothing is written when bazarr already says what the mesh says", async () => {
const s = aceToday();
s.radarr = { ...s.radarr, ip: "ace.internal", port: 20011 };
const f = fakes(s);
const out = await reconcileApp(f.http, BAZARR, RADARR, binding("radarr-api", 20011), THE_KEY);
assert.deepEqual(out, { app: "radarr", result: "unchanged" });
assert.equal(f.calls.filter((c) => c.method === "POST").length, 0);
});
test("a key the app refuses is never written, and the remedy is named", async () => {
const f = fakes(aceToday());
const out = await reconcileApp(f.http, BAZARR, SONARR, binding("sonarr-api", 20010), "a-value-the-mesh-minted");
assert.equal(out.result, "refused");
assert.match((out as { problem: string }).problem, /secret accept <this node> bazarr sonarr-api --provider ace/);
assert.equal(f.calls.filter((c) => c.method === "POST").length, 0);
assert.equal(f.settings.sonarr.apikey, THE_KEY, "the working key stays");
assert.equal(f.settings.sonarr.ip, "sonarr", "nothing moved either");
});
test("an unreachable app writes nothing", async () => {
const f = fakes(aceToday(), { reachable: false });
const out = await reconcileApp(f.http, BAZARR, SONARR, binding("sonarr-api", 20010), THE_KEY);
assert.equal(out.result, "refused");
assert.equal(f.calls.filter((c) => c.method === "POST").length, 0);
});
test("a loopback binding is refused: from bazarr's container that is bazarr", () => {
const w = wanted(SONARR, binding("sonarr-api", 8989, "127.0.0.1"), THE_KEY);
assert.equal(w.ok, false);
});
test("a new key is written when the operator accepted a different one", async () => {
const s = aceToday();
s.sonarr = { ...s.sonarr, ip: "ace.internal", port: 20010, apikey: "an-old-key" };
const f = fakes(s);
const out = await reconcileApp(f.http, BAZARR, SONARR, binding("sonarr-api", 20010), THE_KEY);
assert.deepEqual(out, { app: "sonarr", result: "written", fields: ["apikey"] });
assert.equal(f.settings.sonarr.apikey, THE_KEY);
});
test("base paths compare as bazarr stores them", () => {
assert.equal(baseUrlOf(""), "");
assert.equal(baseUrlOf("/"), "");
assert.equal(baseUrlOf("sonarr/"), "/sonarr");
const want = { ip: "a", port: 1, ssl: false, base_url: "", apikey: "k" };
assert.deepEqual(differing({ ip: "a", port: 1, ssl: false, base_url: "/", apikey: "k" }, want), []);
});
test("bazarr's own key is auth.apikey, not an app's", () => {
const yaml = [
"analytics:",
" enabled: false",
"auth:",
" apikey: 0123456789abcdef0123456789abcdef",
" password: ''",
" type: form",
"general:",
" port: 6767",
"sonarr:",
" apikey: not-this-one",
"",
].join("\n");
assert.equal(apiKeyFromConfigYaml(yaml), "0123456789abcdef0123456789abcdef");
assert.equal(apiKeyFromConfigYaml("sonarr:\n apikey: x\n"), undefined);
assert.equal(apiKeyFromConfigYaml("auth:\n apikey: ''\n"), undefined);
assert.equal(apiKeyFromConfigYaml("auth:\r\n apikey: 'abc'\r\n"), "abc");
});
+1 -1
View File
@@ -8,5 +8,5 @@
"skipLibCheck": true, "skipLibCheck": true,
"noEmit": true "noEmit": true
}, },
"include": ["apikey.ts", "client.ts", "index.ts", "tools/index.ts", "servarr/settings.ts", "servarr/index.ts"] "include": ["client.ts", "index.ts", "tools/index.ts"]
} }
+2 -2
View File
@@ -13,7 +13,7 @@ ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build FROM ${BUILD_BASE} AS build
WORKDIR /app/modules/ombi WORKDIR /app/modules/ombi
COPY . . COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts servarr/settings.ts servarr/index.ts \ RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts servarr/settings.ts plex/settings.ts connections/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
FROM ${RUNTIME_BASE} FROM ${RUNTIME_BASE}
@@ -22,6 +22,6 @@ COPY --from=build /app/modules/ombi/dist /app/modules/ombi/dist
# provider's provisioner runs its reconcile loop in the same process, with the broker connected — # provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled. # the convention novox/hq issues 060/061 settled.
ENV MESH_TOOL_MODULES=/app/modules/ombi/dist/index.js,/app/modules/ombi/dist/tools/index.js ENV MESH_TOOL_MODULES=/app/modules/ombi/dist/index.js,/app/modules/ombi/dist/tools/index.js
# NOT dist/servarr/index.js: that is a step the host runs to completion, named by the `servarr` # NOT dist/connections/index.js: that is a step the host runs to completion, named by the `connections`
# container's args as `mesh-tools run …` (novox/hq ADR 0052). Listed here it would run inside the # container's args as `mesh-tools run …` (novox/hq ADR 0052). Listed here it would run inside the
# serving sidecar too, and exit it. # serving sidecar too, and exit it.
+66
View File
@@ -0,0 +1,66 @@
// ombi's connections step — run once by the host after ombi's server starts, and run again whenever
// a binding or pair credential it reads changes (the container's `restart-on`, novox/hq ADR 0099).
// It brings ombi's connections to Sonarr, Radarr, Lidarr (servarr/settings.ts) and Plex
// (plex/settings.ts) in line with what the mesh bound.
//
// **A step, not a loop**, for the reason route-adapter gives: everything it does is a function of
// files the mesh writes, and the host already knows when they change. It connects to no broker.
//
// Exits non-zero when any app could not be put right — a refused credential, an unreachable app, an
// ombi that cannot reach it — so the node reports the step failed and the host runs it again on the
// next apply. It is declared last in the manifest, so its failing gates nothing else of ombi's
// (novox/hq ADR 0136). One app failing does not stop the others being put right.
//
// Reads, per provision, `<dir>/<provision>.json` (the binding) and `<dir>/<provision>.secret` (the
// pair credential), where <dir> is MESH_CONNECTIONS_DIR. Never prints a key or a token.
import { join } from "node:path";
import { PLEX_PROVISION, reconcilePlex } from "../plex/settings.js";
import { APPS, ombiReady, readBinding, readIfThere, reconcileApp, type Http, type Outcome } from "../servarr/settings.js";
const dir = process.env.MESH_CONNECTIONS_DIR ?? "/run/connections";
const url = process.env.MESH_OMBI_URL ?? "http://127.0.0.1:3579";
const apiKey = (await readIfThere(process.env.MESH_OMBI_API_KEY_FILE))?.trim() ?? process.env.MESH_OMBI_API_KEY ?? "";
const waitSeconds = Number(process.env.MESH_OMBI_WAIT_SECONDS ?? "180");
// Every call bounded: an entry ombi keeps may name a host that no longer answers, and a step that
// hangs on it holds the apply.
const http: Http = { fetch: (u, init) => fetch(u, { ...init, signal: AbortSignal.timeout(20_000) }) };
if (!apiKey) {
console.error("[ombi-connections] no ombi API key — ombi's own `api-key` secret has not been accepted");
process.exit(1);
}
const ombi = { url, apiKey };
if (!(await ombiReady(http, ombi, waitSeconds * 1000))) {
console.error(`[ombi-connections] ombi did not answer at ${url} within ${waitSeconds}s`);
process.exit(1);
}
const inputs = async (provision: string) =>
[await readBinding(join(dir, `${provision}.json`)), await readIfThere(join(dir, `${provision}.secret`))] as const;
const outcomes: Outcome[] = [];
for (const spec of APPS) {
outcomes.push(await reconcileApp(http, ombi, spec, ...(await inputs(spec.provision))));
}
outcomes.push(await reconcilePlex(http, ombi, ...(await inputs(PLEX_PROVISION))));
let failed = 0;
for (const outcome of outcomes) {
switch (outcome.result) {
case "unchanged":
console.log(`[ombi-connections] ${outcome.app}: already as the mesh says; connection tested`);
break;
case "written":
console.log(`[ombi-connections] ${outcome.app}: wrote ${outcome.fields.join(", ")}; connection tested`);
break;
case "refused":
failed++;
console.error(`[ombi-connections] ${outcome.app}: ${outcome.problem}`);
break;
}
}
process.exitCode = failed > 0 ? 1 : 0;
+20 -13
View File
@@ -87,28 +87,30 @@
"artifact": "runtime" "artifact": "runtime"
}, },
{ {
"id": "servarr", "id": "connections",
"type": "container", "type": "container",
"name": "mesh-ombi-servarr", "name": "mesh-ombi-connections",
"network": "host", "network": "host",
"run-once": true, "run-once": true,
"volumes": [ "volumes": [
"/var/lib/mesh/ombi/api-key:/run/secrets/api-key:ro", "/var/lib/mesh/ombi/api-key:/run/secrets/api-key:ro",
"${dir:state}/sonarr-api.json:/run/servarr/sonarr-api.json:ro", "${dir:state}/sonarr-api.json:/run/connections/sonarr-api.json:ro",
"${dir:state}/sonarr-api.secret:/run/servarr/sonarr-api.secret:ro", "${dir:state}/sonarr-api.secret:/run/connections/sonarr-api.secret:ro",
"${dir:state}/radarr-api.json:/run/servarr/radarr-api.json:ro", "${dir:state}/radarr-api.json:/run/connections/radarr-api.json:ro",
"${dir:state}/radarr-api.secret:/run/servarr/radarr-api.secret:ro", "${dir:state}/radarr-api.secret:/run/connections/radarr-api.secret:ro",
"${dir:state}/lidarr-api.json:/run/servarr/lidarr-api.json:ro", "${dir:state}/lidarr-api.json:/run/connections/lidarr-api.json:ro",
"${dir:state}/lidarr-api.secret:/run/servarr/lidarr-api.secret:ro" "${dir:state}/lidarr-api.secret:/run/connections/lidarr-api.secret:ro",
"${dir:state}/plex-api.json:/run/connections/plex-api.json:ro",
"${dir:state}/plex-api.secret:/run/connections/plex-api.secret:ro"
], ],
"env": { "env": {
"MESH_OMBI_URL": "http://127.0.0.1:${port:3579}", "MESH_OMBI_URL": "http://127.0.0.1:${port:3579}",
"MESH_OMBI_API_KEY_FILE": "/run/secrets/api-key", "MESH_OMBI_API_KEY_FILE": "/run/secrets/api-key",
"MESH_SERVARR_DIR": "/run/servarr" "MESH_CONNECTIONS_DIR": "/run/connections"
}, },
"args": [ "args": [
"run", "run",
"/app/modules/ombi/dist/servarr/index.js" "/app/modules/ombi/dist/connections/index.js"
], ],
"restart-on": [ "restart-on": [
"bound-sonarr-api", "bound-sonarr-api",
@@ -116,13 +118,16 @@
"bound-radarr-api", "bound-radarr-api",
"secret-radarr-api", "secret-radarr-api",
"bound-lidarr-api", "bound-lidarr-api",
"secret-lidarr-api" "secret-lidarr-api",
"bound-plex-api",
"secret-plex-api"
], ],
"artifact": "runtime" "artifact": "runtime"
} }
], ],
"requires": [ "requires": [
"lidarr-api", "lidarr-api",
"plex-api",
"radarr-api", "radarr-api",
"route", "route",
"sonarr-api" "sonarr-api"
@@ -137,12 +142,14 @@
"route": "${dir:state}/route.json", "route": "${dir:state}/route.json",
"sonarr-api": "${dir:state}/sonarr-api.json", "sonarr-api": "${dir:state}/sonarr-api.json",
"radarr-api": "${dir:state}/radarr-api.json", "radarr-api": "${dir:state}/radarr-api.json",
"lidarr-api": "${dir:state}/lidarr-api.json" "lidarr-api": "${dir:state}/lidarr-api.json",
"plex-api": "${dir:state}/plex-api.json"
}, },
"secrets": { "secrets": {
"sonarr-api": "${dir:state}/sonarr-api.secret", "sonarr-api": "${dir:state}/sonarr-api.secret",
"radarr-api": "${dir:state}/radarr-api.secret", "radarr-api": "${dir:state}/radarr-api.secret",
"lidarr-api": "${dir:state}/lidarr-api.secret" "lidarr-api": "${dir:state}/lidarr-api.secret",
"plex-api": "${dir:state}/plex-api.secret"
}, },
"build": { "build": {
"on": [ "on": [
+2 -2
View File
@@ -5,9 +5,9 @@
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "tsc client.ts index.ts tools/index.ts servarr/settings.ts servarr/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist", "build": "tsc client.ts index.ts tools/index.ts servarr/settings.ts plex/settings.ts connections/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
"typecheck": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json",
"test": "node --test --experimental-strip-types 'test/*.test.ts'" "test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'"
}, },
"dependencies": { "dependencies": {
"@novox/mesh-sdk": "^0.1.0" "@novox/mesh-sdk": "^0.1.0"
+271
View File
@@ -0,0 +1,271 @@
// Where ombi reaches Plex — decided by the mesh, written into ombi by ombi's own API.
//
// **Why this exists.** ombi keeps its Plex servers in its own database (OmbiSettings.db), so the
// mesh has no file to write `${bound:plex-api:at}` into. ombi requires `plex-api`; the mesh delivers
// a binding (where plex is: `at`, and what it serves: `port`, `scheme`) and a pair credential (the
// server owner's X-Plex-Token, accepted by the operator — plex.tv issues it and the mesh cannot
// mint it). This step makes ombi's Plex settings say the same thing, beside its Servarr ones.
//
// **Which entry is plex's.** ombi may list several Plex servers. The one this provision names is
// found by the server's own machineIdentifier, which plex answers at /identity — the same value
// ombi stored when an operator loaded the server in its settings screen. That entry's connection is
// brought in line; an entry for any other server is never touched.
//
// When no entry carries that identifier, an entry may still be this server reached another way:
// ace's ombi holds one loaded from an older server and later retyped to plex's public name, so its
// stored identifier is stale while its address answers as this plex. Each entry's OWN address is
// asked for /identity, and an entry plex itself answers for is this server's — adopted: its
// connection laid over and its identifier corrected (ombi builds its "view in Plex" links from it).
// Nothing is guessed: an entry whose address is unreachable, or answers as another server, is left
// as it was. Only when no entry is this server's either way is one added, named as plex names
// itself — it is the mesh's, so later runs keep it true.
//
// **Only the connection, and only when it differs.** Host, port, TLS, base path and token — and the
// identifier of an adopted entry. Whether Plex is enabled in ombi, watchlist import, the selected
// libraries, the batch size and everything else an operator chose are left exactly as they are.
//
// **A token plex refuses is never written.** Until the operator accepts the server's token for this
// pair, the mesh delivers a value it minted itself, which plex answers with 401 (or 400 on its own
// network). Writing it would replace a working token in ombi with a dead one, so the token is tried
// against plex first; refused, nothing is written and the step fails naming the `secret accept`.
import { isLoopback, ombiCall, type Binding, type Http, type Ombi, type Outcome } from "../servarr/settings.js";
/** The provision ombi requires for Plex — the manifest's `requires`, `binds` and `secrets` key. */
export const PLEX_PROVISION = "plex-api";
/** The connection fields ombi keeps for a Plex server — the only ones this step ever writes. */
export interface PlexConnection {
ip: string;
port: number;
ssl: boolean;
subDir: string | null;
plexAuthToken: string;
}
export type PlexWanted = { ok: true; connection: PlexConnection; from: string } | { ok: false; problem: string };
/** The connection the mesh says ombi should use, from the binding and the pair credential. */
export function wantedPlex(binding: Binding | undefined, credential: string | undefined): PlexWanted {
if (!binding) {
return { ok: false, problem: `no binding for ${PLEX_PROVISION} was delivered — the mesh writes it before this step runs` };
}
const at = typeof binding.at === "string" ? binding.at.trim() : "";
const serves = binding.serves ?? {};
const port = Number(serves.port);
if (!at) return { ok: false, problem: `the ${PLEX_PROVISION} binding names no host (at)` };
if (isLoopback(at)) {
return {
ok: false,
problem:
`the ${PLEX_PROVISION} binding says plex is at ${at}, which from ombi's own container is ombi itself. ` +
`The mesh hands loopback to a machine that is not on the private network; put it on the private ` +
`network so plex has an address ombi can dial`,
};
}
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
return { ok: false, problem: `the ${PLEX_PROVISION} binding serves no usable port (${String(serves.port)})` };
}
const scheme = typeof serves.scheme === "string" && serves.scheme ? serves.scheme : "http";
if (scheme !== "http" && scheme !== "https") {
return { ok: false, problem: `the ${PLEX_PROVISION} binding serves scheme ${scheme}, which ombi cannot dial` };
}
const token = (credential ?? "").trim();
if (!token) return { ok: false, problem: `the ${PLEX_PROVISION} credential is empty or was not delivered` };
return {
ok: true,
from: typeof binding.from === "string" ? binding.from : "",
connection: { ip: at, port, ssl: scheme === "https", subDir: null, plexAuthToken: token },
};
}
/** plex's base URL as the step dials it — the same host and port ombi will be given. */
export function plexUrl(want: PlexConnection): string {
const host = want.ip.includes(":") && !want.ip.startsWith("[") ? `[${want.ip}]` : want.ip;
return `${want.ssl ? "https" : "http"}://${host}:${want.port}`;
}
/** Which connection fields differ between an entry ombi holds and what the mesh says. Names only. */
export function differingPlex(current: Record<string, unknown> | undefined, want: PlexConnection): (keyof PlexConnection)[] {
const now = current ?? {};
const out: (keyof PlexConnection)[] = [];
if (String(now.ip ?? "") !== want.ip) out.push("ip");
if (Number(now.port ?? 0) !== want.port) out.push("port");
if (Boolean(now.ssl) !== want.ssl) out.push("ssl");
const sub = typeof now.subDir === "string" && now.subDir.trim() !== "" ? now.subDir : null;
if (sub !== want.subDir) out.push("subDir");
if (String(now.plexAuthToken ?? "") !== want.plexAuthToken) out.push("plexAuthToken");
return out;
}
async function plexGet(http: Http, want: PlexConnection, path: string, withToken: boolean) {
const headers: Record<string, string> = { Accept: "application/json" };
if (withToken) headers["X-Plex-Token"] = want.plexAuthToken;
return http.fetch(`${plexUrl(want)}${path}`, { method: "GET", headers });
}
/**
* Does plex take this token? `true` it does; `false` it refused it — 401 or 403, or 400, which is
* what plex answers a token it never issued on a network it trusts. A thrown error when plex could
* not be asked.
*/
export async function plexTakes(http: Http, want: PlexConnection): Promise<{ takes: boolean; friendlyName?: string }> {
const res = await plexGet(http, want, "/", true);
if (res.status === 400 || res.status === 401 || res.status === 403) return { takes: false };
if (res.status < 200 || res.status >= 300) throw new Error(`plex answered ${res.status} at /`);
let friendlyName: string | undefined;
try {
const body = JSON.parse(await res.text()) as { MediaContainer?: { friendlyName?: unknown } };
if (typeof body.MediaContainer?.friendlyName === "string") friendlyName = body.MediaContainer.friendlyName;
} catch {
// a name is a nicety for a new entry, not a condition
}
return { takes: true, friendlyName };
}
/** The server's own machineIdentifier, which plex answers without a token. */
export async function plexIdentity(http: Http, want: Pick<PlexConnection, "ip" | "port" | "ssl" | "subDir">): Promise<string> {
const host = want.ip.includes(":") && !want.ip.startsWith("[") ? `[${want.ip}]` : want.ip;
const base = `${want.ssl ? "https" : "http"}://${host}:${want.port}${want.subDir ? `/${want.subDir.replace(/^\/+|\/+$/g, "")}` : ""}`;
const res = await http.fetch(`${base}/identity`, { method: "GET", headers: { Accept: "application/json" } });
if (res.status !== 200) throw new Error(`plex answered ${res.status} at /identity`);
const body = JSON.parse(await res.text()) as { MediaContainer?: { machineIdentifier?: unknown } };
const id = body.MediaContainer?.machineIdentifier;
if (typeof id !== "string" || id === "") throw new Error("plex's /identity names no machineIdentifier");
return id;
}
/** The remedy for a refused token, in the controller's own words (ADR 0092). */
export function plexAcceptRemedy(from: string): string {
return (
`plex refuses the ${PLEX_PROVISION} credential the mesh delivered, so it was not written into ombi. ` +
`plex's token is issued by plex.tv and the mesh cannot make it: accept the server's own token for ` +
`this pair — \`secret accept <this node> ombi ${PLEX_PROVISION} --provider ${from || "<its node>"} ` +
`--from <file holding the server's X-Plex-Token>\``
);
}
/** The batch size ombi's settings screen fills in for a server it adds ("150 by default"). */
const EPISODE_BATCH_SIZE = 150;
/** ombi's server entries, as its settings document holds them (null on a fresh ombi). */
export function serversOf(document: Record<string, unknown> | undefined): Record<string, unknown>[] {
const servers = document?.servers;
return Array.isArray(servers) ? (servers as Record<string, unknown>[]) : [];
}
/**
* Which entries, holding another identifier, plex answers for at their own address — this server,
* reached another way. Asked only when no entry carries the identifier. An entry that cannot be
* asked is not this server's: nothing is guessed.
*/
export async function answeringAs(http: Http, servers: Record<string, unknown>[], machineIdentifier: string): Promise<Set<number>> {
const out = new Set<number>();
if (servers.some((s) => s?.machineIdentifier === machineIdentifier)) return out;
for (const [i, s] of servers.entries()) {
const ip = typeof s?.ip === "string" ? s.ip.trim() : "";
const port = Number(s?.port);
if (!ip || !Number.isInteger(port) || port <= 0 || port > 65535) continue;
const subDir = typeof s.subDir === "string" && s.subDir.trim() !== "" ? s.subDir : null;
try {
if ((await plexIdentity(http, { ip, port, ssl: Boolean(s.ssl), subDir })) === machineIdentifier) out.add(i);
} catch {
// unreachable, or not a plex: not this server's
}
}
return out;
}
/**
* ombi's Plex settings with this server's connection laid over them: every entry naming the
* server's machineIdentifier — or adopted, its address answering as this server — gets the
* connection (an adopted one also the identifier), every other entry is left as it was, and when
* none is this server's one is added. Returns the document to save and the fields that changed.
*/
export function withPlexServer(
document: Record<string, unknown> | undefined,
machineIdentifier: string,
want: PlexConnection,
name: string,
adopted: ReadonlySet<number> = new Set(),
): { next: Record<string, unknown>; fields: string[]; added: boolean; entry: Record<string, unknown> } {
const doc = document ?? {};
const servers = serversOf(doc);
const fields = new Set<string>();
let entry: Record<string, unknown> | undefined;
const next = servers.map((s, i) => {
const adopt = adopted.has(i) && s?.machineIdentifier !== machineIdentifier;
if (s?.machineIdentifier !== machineIdentifier && !adopt) return s;
for (const f of differingPlex(s, want)) fields.add(f);
if (adopt) fields.add("machineIdentifier");
const laid = { ...s, machineIdentifier, ip: want.ip, port: want.port, ssl: want.ssl, subDir: want.subDir, plexAuthToken: want.plexAuthToken };
entry ??= laid;
return laid;
});
if (entry) return { next: { ...doc, servers: next }, fields: [...fields], added: false, entry };
const added: Record<string, unknown> = {
name,
machineIdentifier,
ip: want.ip,
port: want.port,
ssl: want.ssl,
subDir: want.subDir,
plexAuthToken: want.plexAuthToken,
episodeBatchSize: EPISODE_BATCH_SIZE,
plexSelectedLibraries: [],
};
return { next: { ...doc, servers: [...next, added] }, fields: ["server"], added: true, entry: added };
}
/**
* Bring ombi's connection to plex in line with the mesh: check the token against plex, find the
* server's entry by its machineIdentifier, write only the connection fields when they differ (or add
* the entry), then have ombi test the connection from its own container. Never throws.
*/
export async function reconcilePlex(http: Http, ombi: Ombi, binding: Binding | undefined, credential: string | undefined): Promise<Outcome> {
const app = "plex";
const w = wantedPlex(binding, credential);
// `in`, not `!w.ok`: the Dockerfile compiles without strict, where a boolean discriminant does not
// narrow.
if ("problem" in w) return { app, result: "refused", problem: w.problem };
const want = w.connection;
let name: string;
let machineIdentifier: string;
try {
const taken = await plexTakes(http, want);
if (!taken.takes) return { app, result: "refused", problem: plexAcceptRemedy(w.from) };
machineIdentifier = await plexIdentity(http, want);
name = taken.friendlyName || "Plex";
} catch (err) {
return { app, result: "refused", problem: `plex could not be asked whether it takes the token at ${want.ip}:${want.port}: ${message(err)}` };
}
try {
const document = (await ombiCall(http, ombi, "GET", "/Settings/Plex")) as Record<string, unknown> | undefined;
const adopted = await answeringAs(http, serversOf(document), machineIdentifier);
const laid = withPlexServer(document, machineIdentifier, want, name, adopted);
if (laid.fields.length > 0) {
const saved = await ombiCall(http, ombi, "POST", "/Settings/Plex", laid.next);
if (saved === false) return { app, result: "refused", problem: "ombi declined to save its Plex settings" };
}
// ombi's own test, from ombi's own container — the path the step's check above did not take.
const tested = await ombiCall(http, ombi, "POST", "/Tester/plex", laid.entry);
if (tested !== true) {
return {
app,
result: "refused",
problem:
`ombi cannot reach plex at ${want.ip}:${want.port} from its own container` +
(laid.fields.length > 0 ? `; its settings were written (${laid.fields.join(", ")})` : ""),
};
}
return laid.fields.length > 0 ? { app, result: "written", fields: laid.fields } : { app, result: "unchanged" };
} catch (err) {
return { app, result: "refused", problem: message(err) };
}
}
function message(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
-59
View File
@@ -1,59 +0,0 @@
// ombi's Servarr step — run once by the host after ombi's server starts, and run again whenever a
// binding or pair credential it reads changes (the container's `restart-on`, novox/hq ADR 0099).
//
// **A step, not a loop**, for the reason route-adapter gives: everything it does is a function of
// files the mesh writes, and the host already knows when they change. It connects to no broker.
//
// Exits non-zero when any app could not be put right — a refused credential, an unreachable app, an
// ombi that cannot reach it — so the node reports the step failed and the host runs it again on the
// next apply. It is declared last in the manifest, so its failing gates nothing else of ombi's
// (novox/hq ADR 0136).
//
// Reads, per app, `<dir>/<provision>.json` (the binding) and `<dir>/<provision>.secret` (the pair
// credential), where <dir> is MESH_SERVARR_DIR. Never prints a key.
import { join } from "node:path";
import { APPS, ombiReady, readBinding, readIfThere, reconcileApp, type Http } from "./settings.js";
const dir = process.env.MESH_SERVARR_DIR ?? "/run/servarr";
const url = process.env.MESH_OMBI_URL ?? "http://127.0.0.1:3579";
const apiKey = (await readIfThere(process.env.MESH_OMBI_API_KEY_FILE))?.trim() ?? process.env.MESH_OMBI_API_KEY ?? "";
const waitSeconds = Number(process.env.MESH_OMBI_WAIT_SECONDS ?? "180");
const http: Http = { fetch: (u, init) => fetch(u, init) };
if (!apiKey) {
console.error("[ombi-servarr] no ombi API key — ombi's own `api-key` secret has not been accepted");
process.exit(1);
}
const ombi = { url, apiKey };
if (!(await ombiReady(http, ombi, waitSeconds * 1000))) {
console.error(`[ombi-servarr] ombi did not answer at ${url} within ${waitSeconds}s`);
process.exit(1);
}
let failed = 0;
for (const spec of APPS) {
const outcome = await reconcileApp(
http,
ombi,
spec,
await readBinding(join(dir, `${spec.provision}.json`)),
await readIfThere(join(dir, `${spec.provision}.secret`)),
);
switch (outcome.result) {
case "unchanged":
console.log(`[ombi-servarr] ${outcome.app}: already as the mesh says; connection tested`);
break;
case "written":
console.log(`[ombi-servarr] ${outcome.app}: wrote ${outcome.fields.join(", ")}; connection tested`);
break;
case "refused":
failed++;
console.error(`[ombi-servarr] ${outcome.app}: ${outcome.problem}`);
break;
}
}
process.exitCode = failed > 0 ? 1 : 0;
+2 -2
View File
@@ -115,7 +115,7 @@ export function subDirOf(urlBase: unknown): string | null {
return trimmed === "" ? null : trimmed; return trimmed === "" ? null : trimmed;
} }
function isLoopback(host: string): boolean { export function isLoopback(host: string): boolean {
const h = host.toLowerCase(); const h = host.toLowerCase();
return h === "localhost" || h === "::1" || h === "[::1]" || /^127\./.test(h); return h === "localhost" || h === "::1" || h === "[::1]" || /^127\./.test(h);
} }
@@ -163,7 +163,7 @@ export interface Ombi {
apiKey: string; apiKey: string;
} }
async function ombiCall(http: Http, ombi: Ombi, method: string, path: string, body?: unknown): Promise<unknown> { export async function ombiCall(http: Http, ombi: Ombi, method: string, path: string, body?: unknown): Promise<unknown> {
const res = await http.fetch(`${ombi.url.replace(/\/$/, "")}/api/v1${path}`, { const res = await http.fetch(`${ombi.url.replace(/\/$/, "")}/api/v1${path}`, {
method, method,
headers: { headers: {
+223
View File
@@ -0,0 +1,223 @@
// What holds ombi's Plex step (plex/settings.ts): the entry for the server plex says it is — found
// by machineIdentifier — is made to say what the mesh bound (host, port, TLS, token) and nothing
// else it keeps is touched; an entry for another server is left alone; an ombi with no entry for it
// gets one; nothing is written when nothing differs; and a token plex refuses (the mesh's own minted
// value, before the operator accepts the server's token) is never written, with the `secret accept`
// that fixes it named.
//
// ombi and plex are fakes answering the routes the step touches as the real ones do (checked against
// lscr.io/linuxserver/ombi 4.53.10 and plexinc/pms-docker 1.43.4: plex answers 401 to an unknown
// token from another network and 400 on one it trusts; ombi's /Tester/plex answers a bare boolean).
//
// Imports the compiled step, as keycloak's tests do: plex/settings.ts imports its sibling with the
// `.js` specifier the build needs, which Node's type stripping does not resolve to a `.ts` file.
import { test } from "node:test";
import assert from "node:assert/strict";
import { differingPlex, reconcilePlex, wantedPlex, withPlexServer } from "../dist/plex/settings.js";
import type { Binding, Http } from "../servarr/settings.ts";
const TOKEN = "the-servers-own-token";
const MACHINE = "5c47d9a165d10b622995d55b3ae1f168242f33bd";
const OMBI = { url: "http://127.0.0.1:3579", apiKey: "ombi-key" };
function binding(port = 32400, at = "ace.internal", scheme = "http"): Binding {
return { binding: 1, provision: "plex-api", from: "ace", at, as: "mesh_ace_ombi", serves: { scheme, port } } as Binding;
}
interface Call {
method: string;
url: string;
body?: unknown;
}
function fakes(plexSettings: Record<string, unknown>, opts: { reachable?: boolean; trusted?: boolean } = {}) {
const calls: Call[] = [];
const store = { plex: plexSettings };
const http: Http = {
async fetch(url, init) {
const method = init?.method ?? "GET";
const body = init?.body ? (JSON.parse(init.body) as unknown) : undefined;
calls.push({ method, url, body });
const reply = (status: number, value?: unknown) => ({
status,
text: async () => (value === undefined ? "" : JSON.stringify(value)),
});
const u = new URL(url);
// Other servers an entry may name: a friend's, and plex's own public name (the same server).
if (u.hostname === "10.0.0.9") return reply(200, { MediaContainer: { machineIdentifier: "another-server" } });
if (u.hostname === "gone.example") throw new Error("getaddrinfo ENOTFOUND");
if (u.hostname === "plex.zurag.be") {
if (u.pathname === "/identity") return reply(200, { MediaContainer: { machineIdentifier: MACHINE } });
return reply(401);
}
if (u.port === "32400" || u.hostname === "ace.internal") {
if (opts.reachable === false) throw new Error("connect ECONNREFUSED");
if (u.pathname === "/identity") return reply(200, { MediaContainer: { machineIdentifier: MACHINE } });
const token = init?.headers?.["X-Plex-Token"];
if (token !== TOKEN) return reply(opts.trusted ? 400 : 401);
return reply(200, { MediaContainer: { friendlyName: "ace", machineIdentifier: MACHINE } });
}
if (init?.headers?.ApiKey !== "ombi-key") return reply(401);
if (u.pathname === "/api/v1/Settings/Plex" && method === "GET") return reply(200, store.plex);
if (u.pathname === "/api/v1/Settings/Plex" && method === "POST") {
store.plex = body as Record<string, unknown>;
return reply(200, true);
}
if (u.pathname === "/api/v1/Tester/plex") {
const tried = body as { plexAuthToken?: string; ip?: string };
return reply(200, tried.plexAuthToken === TOKEN && tried.ip === "ace.internal");
}
return reply(404);
},
};
return { http, calls, store };
}
// What an operator's ombi holds: plex loaded through its public name, plus a friend's server.
const operatorPlex = () => ({
enable: true,
enableWatchlistImport: true,
monitorAll: false,
installId: "b358a2a2-2ab0-4025-a3f3-450313c3c418",
servers: [
{
name: "ace", plexAuthToken: TOKEN, machineIdentifier: MACHINE, episodeBatchSize: 150,
serverHostname: "https://app.plex.tv", plexSelectedLibraries: [{ key: "1", title: "Films", enabled: true }],
ssl: true, subDir: null, ip: "plex.zurag.be", port: 443, id: 1,
},
{
name: "a friend", plexAuthToken: "their-token", machineIdentifier: "another-server", episodeBatchSize: 150,
plexSelectedLibraries: [], ssl: false, subDir: null, ip: "10.0.0.9", port: 32400, id: 2,
},
],
id: 4,
});
test("the server's own entry gets the bound connection; its libraries and every other setting stay", async () => {
const f = fakes(operatorPlex());
const out = await reconcilePlex(f.http, OMBI, binding(), `${TOKEN}\n`);
assert.deepEqual(out, { app: "plex", result: "written", fields: ["ip", "port", "ssl"] });
const want = operatorPlex();
Object.assign(want.servers[0], { ip: "ace.internal", port: 32400, ssl: false });
assert.deepEqual(f.store.plex, want);
});
test("another server's entry is never touched", async () => {
const f = fakes(operatorPlex());
await reconcilePlex(f.http, OMBI, binding(), TOKEN);
const servers = f.store.plex.servers as Record<string, unknown>[];
assert.deepEqual(servers[1], operatorPlex().servers[1]);
});
test("nothing is written when ombi already says what the mesh says", async () => {
const doc = operatorPlex();
Object.assign(doc.servers[0], { ip: "ace.internal", port: 32400, ssl: false });
const f = fakes(doc);
const out = await reconcilePlex(f.http, OMBI, binding(), TOKEN);
assert.deepEqual(out, { app: "plex", result: "unchanged" });
assert.equal(f.calls.filter((c) => c.method === "POST" && c.url.includes("/Settings/")).length, 0);
});
test("an ombi with no entry for this server gets one, named as plex names itself", async () => {
const fresh = { enable: false, enableWatchlistImport: false, monitorAll: false, installId: "x", servers: null, id: 0 };
const f = fakes(fresh);
const out = await reconcilePlex(f.http, OMBI, binding(), TOKEN);
assert.deepEqual(out, { app: "plex", result: "written", fields: ["server"] });
assert.deepEqual(f.store.plex, {
...fresh,
servers: [{
name: "ace", machineIdentifier: MACHINE, ip: "ace.internal", port: 32400, ssl: false, subDir: null,
plexAuthToken: TOKEN, episodeBatchSize: 150, plexSelectedLibraries: [],
}],
});
assert.equal(f.store.plex.enable, false, "whether plex is enabled in ombi is the operator's choice");
});
test("a token plex refuses is never written, and the accept that fixes it is named", async () => {
for (const trusted of [false, true]) {
const f = fakes(operatorPlex(), { trusted });
const out = await reconcilePlex(f.http, OMBI, binding(), "a-value-the-mesh-minted");
assert.equal(out.result, "refused");
const problem = (out as { problem: string }).problem;
assert.match(problem, /secret accept <this node> ombi plex-api --provider ace/);
assert.doesNotMatch(problem, /a-value-the-mesh-minted/);
assert.deepEqual(f.store.plex, operatorPlex(), "ombi's working settings were left alone");
assert.equal(f.calls.some((c) => c.url.includes("/api/v1/")), false, "ombi was not even asked");
}
});
test("a plex it cannot reach is reported, and ombi is left alone", async () => {
const f = fakes(operatorPlex(), { reachable: false });
const out = await reconcilePlex(f.http, OMBI, binding(), TOKEN);
assert.equal(out.result, "refused");
assert.match((out as { problem: string }).problem, /could not be asked.*ECONNREFUSED/);
assert.deepEqual(f.store.plex, operatorPlex());
});
test("a loopback binding is refused: from ombi's container it is ombi itself", () => {
const w = wantedPlex(binding(32400, "127.0.0.1"), TOKEN);
assert.equal(w.ok, false);
assert.match((w as { problem: string }).problem, /private network/);
});
test("an https binding sets ombi's ssl flag; an empty subDir is none", () => {
const w = wantedPlex(binding(32400, "ace.internal", "https"), TOKEN);
assert.equal(w.ok && w.connection.ssl, true);
assert.deepEqual(
differingPlex({ ip: "h", port: 1, ssl: false, subDir: "", plexAuthToken: "k" }, { ip: "h", port: 1, ssl: false, subDir: null, plexAuthToken: "k" }),
[],
);
});
test("every entry naming the server is laid over, not only the first", () => {
const doc = { servers: [{ machineIdentifier: MACHINE, ip: "a" }, { machineIdentifier: MACHINE, ip: "b" }] };
const want = { ip: "ace.internal", port: 32400, ssl: false, subDir: null, plexAuthToken: TOKEN };
const laid = withPlexServer(doc, MACHINE, want, "ace");
assert.equal(laid.added, false);
assert.deepEqual((laid.next.servers as { ip: string }[]).map((s) => s.ip), ["ace.internal", "ace.internal"]);
});
// ace's own ombi: its one entry was loaded from an older server (a stale identifier) and retyped to
// plex's public name, so it IS this server, reached another way (read from ace, 2026-09-30).
const acesOmbi = () => ({
enable: true,
enableWatchlistImport: true,
servers: [{
name: "Nami", plexAuthToken: TOKEN, machineIdentifier: "76562198623e708eef85b46aedb72c8f2fe671aa", episodeBatchSize: 0,
plexSelectedLibraries: [1, 2, 3, 4, 5, 6].map((k) => ({ key: String(k), enabled: true })), ssl: true, subDir: null,
ip: "plex.zurag.be", port: 443, id: 1,
}],
id: 4,
});
test("an entry whose own address answers as this server is adopted: connection and identifier, nothing else", async () => {
const f = fakes(acesOmbi());
const out = await reconcilePlex(f.http, OMBI, binding(), TOKEN);
assert.deepEqual(out, { app: "plex", result: "written", fields: ["ip", "port", "ssl", "machineIdentifier"] });
const want = acesOmbi();
Object.assign(want.servers[0], { ip: "ace.internal", port: 32400, ssl: false, machineIdentifier: MACHINE });
assert.deepEqual(f.store.plex, want, "one entry, still named Nami, its six libraries kept; none added");
});
test("an entry answering as another server, or not at all, is not adopted; this server gets its own", async () => {
const doc = {
servers: [
{ name: "a friend", machineIdentifier: "stale-1", ip: "10.0.0.9", port: 32400, ssl: false, plexAuthToken: "theirs" },
{ name: "gone", machineIdentifier: "stale-2", ip: "gone.example", port: 32400, ssl: false, plexAuthToken: "old" },
],
};
const f = fakes(structuredClone(doc));
const out = await reconcilePlex(f.http, OMBI, binding(), TOKEN);
assert.deepEqual(out, { app: "plex", result: "written", fields: ["server"] });
const servers = f.store.plex.servers as Record<string, unknown>[];
assert.deepEqual(servers.slice(0, 2), doc.servers, "both left exactly as they were");
assert.equal(servers[2].machineIdentifier, MACHINE);
});
test("no entry is probed once one carries the server's identifier", async () => {
const f = fakes(operatorPlex());
await reconcilePlex(f.http, OMBI, binding(), TOKEN);
assert.equal(f.calls.some((c) => c.url.startsWith("http://10.0.0.9")), false, "the friend's server was not asked");
});
+1 -1
View File
@@ -8,5 +8,5 @@
"skipLibCheck": true, "skipLibCheck": true,
"noEmit": true "noEmit": true
}, },
"include": ["client.ts", "index.ts", "tools/index.ts", "servarr/settings.ts", "servarr/index.ts"] "include": ["client.ts", "index.ts", "tools/index.ts", "servarr/settings.ts", "plex/settings.ts", "connections/index.ts"]
} }