sonarr, radarr, lidarr, bazarr, nzbget, qbittorrent, jackett, bookshelf, plex, tautulli, kometa and ombi, taken from the novox/mesh-catalog branches that prepared them for ace (PRs 145-168), consolidated in stack order. kometa gains a minimal runtime sidecar (kometa_status, kometa_config) and declares its tmdb key as an own secret instead of a "secret" requirement.
272 lines
14 KiB
TypeScript
272 lines
14 KiB
TypeScript
// 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);
|
|
}
|