// 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 | 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 = { 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): Promise { 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 ombi ${PLEX_PROVISION} --provider ${from || ""} ` + `--from \`` ); } /** 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 | undefined): Record[] { const servers = document?.servers; return Array.isArray(servers) ? (servers as Record[]) : []; } /** * 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[], machineIdentifier: string): Promise> { const out = new Set(); 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 | undefined, machineIdentifier: string, want: PlexConnection, name: string, adopted: ReadonlySet = new Set(), ): { next: Record; fields: string[]; added: boolean; entry: Record } { const doc = document ?? {}; const servers = serversOf(doc); const fields = new Set(); let entry: Record | 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 = { 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 { 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 | 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); }