// Whether Tautulli reaches Plex as the mesh says — the half of tautulli's plex-api consumer the // mesh can see fail. // // **The write is not here.** Tautulli keeps its Plex connection only in config.ini, rewrites that // file from memory on every shutdown, and has no API command that sets it; so the write happens in // the server container itself, before Tautulli starts (plex/mesh-plex.py, run by the image's // custom-init). A failure there is a line in Tautulli's log that nobody reads. This step runs after // the server, as a run-once container declared last, and fails the node's report when: // // - the pair credential is one plex refuses — the mesh's own minted value, before the operator // accepts the server's X-Plex-Token for this pair — naming the `secret accept` that fixes it; // - Tautulli is not pointed where the binding says (the start-time write did not land); // - Tautulli is pointed there and still not connected to plex (its own connection state). // // It writes nothing, to Tautulli or to plex. Pure logic and a small HTTP seam, tested against fakes // (test/plex.test.ts). /** What the mesh wrote at `binds.plex-api`: the binding document (controller's boundFile). */ export interface Binding { provision?: string; from?: string; at?: string; as?: string; serves?: Record; } export const PROVISION = "plex-api"; export interface Wanted { url: string; token: string; from: string; } /** The HTTP the step needs, so a test can stand fakes in for Tautulli and plex. */ export interface Http { fetch(url: string, init?: { method?: string; headers?: Record }): Promise<{ status: number; text(): Promise; }>; } export type Outcome = { result: "connected"; url: string } | { result: "refused"; problem: string }; function isLoopback(host: string): boolean { const h = host.toLowerCase(); return h === "localhost" || h === "::1" || h === "[::1]" || /^127\./.test(h); } /** The URL Tautulli should hold as pms_url — the same one mesh-plex.py writes. */ export function wanted(binding: Binding | undefined, credential: string | undefined): Wanted | { problem: string } { if (!binding) return { problem: `no binding for ${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); const scheme = typeof serves.scheme === "string" && serves.scheme ? serves.scheme : "http"; if (!at) return { problem: `the ${PROVISION} binding names no host (at)` }; if (isLoopback(at)) { return { problem: `the ${PROVISION} binding says plex is at ${at}, which from Tautulli's container is Tautulli itself; ` + `the mesh hands loopback to a machine that is not on the private network`, }; } if (!Number.isInteger(port) || port <= 0 || port > 65535) { return { problem: `the ${PROVISION} binding serves no usable port (${String(serves.port)})` }; } if (scheme !== "http" && scheme !== "https") return { problem: `the ${PROVISION} binding serves scheme ${scheme}, which Tautulli cannot dial` }; const token = (credential ?? "").trim(); if (!token) return { problem: `the ${PROVISION} credential is empty or was not delivered` }; const host = at.includes(":") && !at.startsWith("[") ? `[${at}]` : at; return { url: `${scheme}://${host}:${port}`, token, from: typeof binding.from === "string" ? binding.from : "" }; } /** The remedy for a refused token, in the controller's own words (ADR 0092). */ export function acceptRemedy(from: string): string { return ( `plex refuses the ${PROVISION} credential the mesh delivered, so Tautulli was not given it. 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 tautulli ${PROVISION} --provider ${from || ""} ` + `--from \`` ); } /** Does plex take the token? 401/403, or 400 on a network plex trusts, is a refusal. */ export async function plexTakes(http: Http, want: Wanted): Promise { const res = await http.fetch(`${want.url}/`, { method: "GET", headers: { "X-Plex-Token": want.token, Accept: "application/json" } }); if (res.status === 400 || res.status === 401 || res.status === 403) return false; if (res.status >= 200 && res.status < 300) return true; throw new Error(`plex answered ${res.status} at /`); } export interface Tautulli { url: string; apiKey: string; } /** One Tautulli API command's `data`, or a thrown error. The error never carries the key. */ export async function tautulliCmd(http: Http, t: Tautulli, cmd: string): Promise { const q = new URLSearchParams({ apikey: t.apiKey, cmd }); const res = await http.fetch(`${t.url.replace(/\/$/, "")}/api/v2?${q.toString()}`, { method: "GET" }); const text = await res.text(); if (res.status !== 200) throw new Error(`Tautulli ${cmd} answered ${res.status}`); const body = (JSON.parse(text) as { response?: { result?: string; message?: string; data?: unknown } }).response ?? {}; if (body.result !== "success") throw new Error(`Tautulli ${cmd}: ${body.message ?? "error"}`); return body.data; } /** Wait for Tautulli to answer, because the step runs right after its container starts. */ export async function tautulliReady(http: Http, t: Tautulli, waitMs: number, pauseMs = 2000): Promise { const until = Date.now() + waitMs; for (;;) { try { const res = await http.fetch(`${t.url.replace(/\/$/, "")}/status`, { 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)); } } /** * Check Tautulli against the mesh: the token plex takes, the URL Tautulli holds, and Tautulli's own * connection state, polled for `connectMs` because Tautulli connects to plex a moment after start. * Never throws. */ export async function check( http: Http, t: Tautulli, binding: Binding | undefined, credential: string | undefined, connectMs = 60_000, pauseMs = 3000, ): Promise { const w = wanted(binding, credential); if ("problem" in w) return { result: "refused", problem: w.problem }; try { if (!(await plexTakes(http, w))) return { result: "refused", problem: acceptRemedy(w.from) }; } catch (err) { return { result: "refused", problem: `plex could not be asked whether it takes the token at ${w.url}: ${message(err)}` }; } try { const info = (await tautulliCmd(http, t, "get_server_info")) as { pms_url?: unknown } | undefined; const holds = typeof info?.pms_url === "string" ? info.pms_url : ""; if (holds.replace(/\/$/, "") !== w.url) { return { result: "refused", problem: `Tautulli reaches plex at ${holds || "nothing"}, not ${w.url} as the mesh says. Its container writes ` + `this into config.ini as it starts (custom-init 50-mesh-plex); its log says why it did not`, }; } const until = Date.now() + connectMs; for (;;) { // server_status answers {connected}, not wrapped in `data` on every version: accept both. const status = (await tautulliCmd(http, t, "server_status")) as { connected?: unknown } | undefined; if (status?.connected === true) return { result: "connected", url: w.url }; if (Date.now() >= until) { return { result: "refused", problem: `Tautulli holds ${w.url} and is not connected to plex there — its log says why (a token plex no longer takes, or plex down)`, }; } await new Promise((r) => setTimeout(r, pauseMs)); } } catch (err) { return { result: "refused", problem: message(err) }; } } function message(err: unknown): string { return err instanceof Error ? err.message : String(err); }