Files
mesh-catalog/modules/tautulli/plex/mesh-plex.py
T
jschoubben 991e33f749 tautulli: reach plex through the mesh, written before Tautulli starts
Tautulli reached plex at 172.18.0.1, the gateway of a HAL network that goes
away with HAL, and the plan was to retype it by hand in the window. Tautulli
now requires plex-api, and where plex is comes from the binding.

Tautulli keeps the connection only in config.ini, reads it at start and
writes its whole config back on every shutdown; its API cannot set it and
its settings form needs an admin login. So a step after start would be
overwritten the moment the container is recreated. The write is made where
nothing can overwrite it: the linuxserver image's custom-init runs
plex/mesh-plex.py as root before Tautulli starts, and the server restarts on
its binding and credential, so a moved plex or an accepted token lands.

It writes only [PMS] keys, only when they differ, every other line byte for
byte: pms_ip, pms_port, pms_ssl and pms_url from the binding; pms_identifier
from plex's /identity; pms_token only when plex takes it. A minted value -
before the operator accepts the server's X-Plex-Token for this pair - is
never written, while the address still is, so Tautulli's own working token
keeps working at plex's new address.

A failure in custom-init is a log line nobody reads, so a run-once `plex`
step, declared last so it gates nothing (ADR 0136), checks what the mesh can
report: plex takes the credential (else it names the secret accept), Tautulli
holds the bound URL, and Tautulli says it is connected. It writes nothing.

The script is kept as plex/mesh-plex.py and plex/50-mesh-plex; module.json
carries copies, and a test fails when they differ. Tests run the script with
python3 against a fake plex (skipped where there is none) and the step
against fakes; `npm test` builds first.
2026-09-30 13:09:43 +02:00

261 lines
10 KiB
Python

# Where Tautulli reaches Plex — decided by the mesh, written into Tautulli's config.ini before
# Tautulli starts.
#
# Written by the mesh from the tautulli module's manifest, and run by the linuxserver image's
# custom-init (50-mesh-plex) each time the `server` container starts, as root, before Tautulli.
# Editing it here lasts until the next apply.
#
# WHY BEFORE START, AND NOT A STEP AFTER IT. Tautulli keeps its Plex connection only in config.ini
# ([PMS]), reads that file at start, and writes its whole in-memory config back on every shutdown.
# A step editing the file while Tautulli runs is overwritten the moment the container is recreated;
# its API has no command that sets the connection, and its settings form needs an admin login. The
# one moment the file is Tautulli's to read and nobody's to overwrite is here: after the old
# container stopped (and wrote), before the new one reads. The container restarts on its binding
# and credential (`restart-on`), so a plex that moves or a token that is accepted lands here.
#
# WHAT IT WRITES, AND WHEN. Only [PMS] keys, and only when they differ from what the mesh says:
# pms_ip, pms_port, pms_ssl, pms_url - from the binding; always, when the binding is usable
# pms_identifier - plex's own machineIdentifier, when plex answers /identity
# pms_token - the pair credential, ONLY when plex takes it
# Every other key and section, comment and ordering stays as it was, byte for byte.
#
# A TOKEN PLEX REFUSES IS NEVER WRITTEN. Until the operator accepts the server's X-Plex-Token for
# this pair, the mesh delivers a value it minted, which plex answers with 401 (400 on a network it
# trusts). Writing it would replace a working token with a dead one. The address is still written:
# it is right whatever the token, and Tautulli's existing token keeps working at the new address.
# The refusal is loud here and in the `plex` step, which fails naming the `secret accept`.
#
# Never prints the token. Exits 0 even on a refusal: custom-init ignores the code, and Tautulli
# starting on what it had is better than not starting. The step after the server is what fails.
import json
import os
import re
import sys
import tempfile
import time
import urllib.error
import urllib.request
BINDING = os.environ.get("MESH_PLEX_BINDING", "/run/mesh/plex-api.json")
SECRET = os.environ.get("MESH_PLEX_SECRET", "/run/mesh/plex-api.secret")
CONFIG = os.environ.get("MESH_TAUTULLI_CONFIG", "/config/config.ini")
WAIT = float(os.environ.get("MESH_PLEX_WAIT_SECONDS", "60"))
PROVISION = "plex-api"
def say(message):
print("[mesh-plex] " + message, flush=True)
def is_loopback(host):
h = host.lower()
return h in ("localhost", "::1", "[::1]") or h.startswith("127.")
def wanted_address(binding):
"""The [PMS] address keys the binding says, or a reason it cannot say them."""
if not isinstance(binding, dict):
return None, "no binding for %s was delivered" % PROVISION
at = binding.get("at")
at = at.strip() if isinstance(at, str) else ""
serves = binding.get("serves") if isinstance(binding.get("serves"), dict) else {}
try:
port = int(serves.get("port"))
except (TypeError, ValueError):
port = 0
scheme = serves.get("scheme") or "http"
if not at:
return None, "the %s binding names no host (at)" % PROVISION
if is_loopback(at):
return None, (
"the %s binding says plex is at %s, which from Tautulli's container is Tautulli itself; "
"the mesh hands loopback to a machine that is not on the private network" % (PROVISION, at))
if not 0 < port < 65536:
return None, "the %s binding serves no usable port (%r)" % (PROVISION, serves.get("port"))
if scheme not in ("http", "https"):
return None, "the %s binding serves scheme %s, which Tautulli cannot dial" % (PROVISION, scheme)
host = "[%s]" % at if ":" in at and not at.startswith("[") else at
url = "%s://%s:%d" % (scheme, host, port)
return {"pms_ip": at, "pms_port": str(port), "pms_ssl": "1" if scheme == "https" else "0", "pms_url": url}, None
def plex_get(url, path, token=None):
"""(status, parsed JSON or None); raises OSError when plex cannot be asked."""
headers = {"Accept": "application/json"}
if token is not None:
headers["X-Plex-Token"] = token
request = urllib.request.Request(url + path, headers=headers)
try:
with urllib.request.urlopen(request, timeout=10) as response:
body = response.read()
try:
return response.status, json.loads(body)
except ValueError:
return response.status, None
except urllib.error.HTTPError as err:
return err.code, None
def ask_plex(url, token):
"""(takes: True/False/None, machineIdentifier or None). None: plex could not be asked in time."""
deadline = time.monotonic() + WAIT
while True:
try:
status, _ = plex_get(url, "/", token)
if status in (400, 401, 403):
takes = False
elif 200 <= status < 300:
takes = True
else:
raise OSError("plex answered %d at /" % status)
identifier = None
status, body = plex_get(url, "/identity")
if status == 200 and isinstance(body, dict):
value = (body.get("MediaContainer") or {}).get("machineIdentifier")
identifier = value if isinstance(value, str) and value else None
return takes, identifier
except OSError as err:
if time.monotonic() >= deadline:
say("plex could not be asked at %s: %s" % (url, err))
return None, None
time.sleep(3)
SECTION = re.compile(r"^\s*\[([^\]]+)\]\s*$")
KEY = re.compile(r"^(\s*)([A-Za-z0-9_]+)(\s*=\s*)(.*?)\s*$")
def unquoted(value):
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
return value[1:-1]
return value
def plain(value):
"""ConfigObj reads a value unquoted unless it holds one of these; none of ours should."""
return not re.search(r"[#,\"'\r\n]", value) and value == value.strip()
def laid_over(text, wanted):
"""config.ini's text with [PMS] saying `wanted`, and the names of the keys that changed."""
lines = text.splitlines(True)
if lines and not lines[-1].endswith("\n"):
lines[-1] += "\n"
changed = []
start = end = None
for i, line in enumerate(lines):
m = SECTION.match(line)
if m:
if start is not None:
end = i
break
if m.group(1).strip() == "PMS":
start = i
if start is None:
if lines and lines[-1].strip():
lines.append("\n")
lines.append("[PMS]\n")
start, end = len(lines) - 1, len(lines)
elif end is None:
end = len(lines)
seen = set()
for i in range(start + 1, end):
m = KEY.match(lines[i])
if not m or m.group(2) not in wanted:
continue
key = m.group(2)
seen.add(key)
if unquoted(m.group(4)) != wanted[key]:
lines[i] = "%s%s%s%s\n" % (m.group(1), key, m.group(3), wanted[key])
changed.append(key)
missing = [k for k in wanted if k not in seen]
# Insert after the section's last key, not after the blank lines that separate it from the next.
at = end
while at > start + 1 and not lines[at - 1].strip():
at -= 1
for key in missing:
lines.insert(at, "%s = %s\n" % (key, wanted[key]))
at += 1
changed.append(key)
return "".join(lines), changed
def write_config(text):
"""Replace config.ini whole, keeping its owner and mode; a new one takes the directory's owner."""
directory = os.path.dirname(CONFIG) or "."
try:
st = os.stat(CONFIG)
uid, gid, mode = st.st_uid, st.st_gid, st.st_mode & 0o7777
except FileNotFoundError:
st = os.stat(directory)
uid, gid, mode = st.st_uid, st.st_gid, 0o644
fd, tmp = tempfile.mkstemp(prefix=".config.ini.", dir=directory)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
f.write(text)
os.chmod(tmp, mode)
try:
os.chown(tmp, uid, gid)
except PermissionError:
pass
os.replace(tmp, CONFIG)
except BaseException:
if os.path.exists(tmp):
os.unlink(tmp)
raise
def read(path):
try:
with open(path, encoding="utf-8") as f:
return f.read()
except FileNotFoundError:
return None
def main():
raw = read(BINDING)
try:
binding = json.loads(raw) if raw is not None else None
except ValueError:
binding = None
address, problem = wanted_address(binding)
if problem:
say("left Tautulli's Plex connection as it was: " + problem)
return 0
wanted = dict(address)
token = (read(SECRET) or "").strip()
takes, identifier = ask_plex(address["pms_url"], token) if token else (False, None)
if identifier:
wanted["pms_identifier"] = identifier
frm = binding.get("from") or "<its node>"
if not token:
say("no %s credential was delivered; only the address was written" % PROVISION)
elif takes and plain(token):
wanted["pms_token"] = token
elif takes is False:
say("plex refuses the %s credential the mesh delivered, so it was not written; the address was. "
"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> tautulli %s --provider %s --from <file holding the "
"server's X-Plex-Token>`" % (PROVISION, PROVISION, frm))
elif takes:
say("the %s credential holds characters config.ini cannot carry unquoted; it was not written" % PROVISION)
else:
say("plex could not be asked whether it takes the %s credential; only the address was written" % PROVISION)
if not all(plain(v) for v in wanted.values()):
say("the %s binding holds characters config.ini cannot carry unquoted; nothing was written" % PROVISION)
return 0
before = read(CONFIG)
after, changed = laid_over(before or "", wanted)
if not changed:
say("Tautulli's Plex connection is already as the mesh says (%s)" % address["pms_url"])
return 0
write_config(after)
say("wrote %s into Tautulli's [PMS] (%s)" % (", ".join(changed), address["pms_url"]))
return 0
if __name__ == "__main__":
sys.exit(main())