diff --git a/modules/ombi/connections/index.ts b/modules/ombi/connections/index.ts index 0d9b645..356e597 100644 --- a/modules/ombi/connections/index.ts +++ b/modules/ombi/connections/index.ts @@ -24,7 +24,9 @@ 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) }; +// 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"); diff --git a/modules/ombi/plex/settings.ts b/modules/ombi/plex/settings.ts index a4dff9c..b9dac91 100644 --- a/modules/ombi/plex/settings.ts +++ b/modules/ombi/plex/settings.ts @@ -9,12 +9,20 @@ // **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 is this server's, -// one is added, named as plex names itself — it is the mesh's, so later runs keep it true. +// brought in line; an entry for any other server is never touched. // -// **Only the connection, and only when it differs.** Host, port, TLS, base path and token. 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. +// 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 @@ -116,8 +124,10 @@ export async function plexTakes(http: Http, want: PlexConnection): Promise<{ tak } /** The server's own machineIdentifier, which plex answers without a token. */ -export async function plexIdentity(http: Http, want: PlexConnection): Promise { - const res = await plexGet(http, want, "/identity", false); +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; @@ -138,25 +148,57 @@ export function plexAcceptRemedy(from: string): string { /** 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 gets the connection, every other entry is left as it was, and when - * none names it one is added. Returns the document to save and the fields that changed. + * 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 = Array.isArray(doc.servers) ? (doc.servers as Record[]) : []; + const servers = serversOf(doc); const fields = new Set(); let entry: Record | undefined; - const next = servers.map((s) => { - if (s?.machineIdentifier !== machineIdentifier) return s; + 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); - const laid = { ...s, ip: want.ip, port: want.port, ssl: want.ssl, subDir: want.subDir, plexAuthToken: want.plexAuthToken }; + 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; }); @@ -201,7 +243,8 @@ export async function reconcilePlex(http: Http, ombi: Ombi, binding: Binding | u try { const document = (await ombiCall(http, ombi, "GET", "/Settings/Plex")) as Record | undefined; - const laid = withPlexServer(document, machineIdentifier, want, name); + 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" }; diff --git a/modules/ombi/test/plex.test.ts b/modules/ombi/test/plex.test.ts index 7ed83b0..45d8a28 100644 --- a/modules/ombi/test/plex.test.ts +++ b/modules/ombi/test/plex.test.ts @@ -45,6 +45,13 @@ function fakes(plexSettings: Record, opts: { reachable?: boolea 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 } }); @@ -171,3 +178,46 @@ test("every entry naming the server is laid over, not only the first", () => { 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[]; + 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"); +});