{ "module": "tautulli", "version": "1", "emits": [ "watch.recorded" ], "own-secrets": { "broker": "/var/lib/mesh/tautulli/broker" }, "capabilities": [ "container-runtime" ], "listens": [ { "name": "web", "port": 8181, "protocol": "tcp", "from": "mesh", "why": "the watch statistics pages and API" } ], "resources": [ { "id": "mesh-state", "type": "directory", "path": "/var/lib/mesh/tautulli", "mode": "0700" }, { "id": "state", "type": "directory", "mode": "0700", "place": "." }, { "id": "config", "type": "directory", "mode": "0700", "owner": "1000:1000" }, { "id": "plex-init-code", "type": "file", "path": "${dir:state}/mesh-plex.py", "mode": "0644", "content": "# Where Tautulli reaches Plex — decided by the mesh, written into Tautulli's config.ini before\n# Tautulli starts.\n#\n# Written by the mesh from the tautulli module's manifest, and run by the linuxserver image's\n# custom-init (50-mesh-plex) each time the `server` container starts, as root, before Tautulli.\n# Editing it here lasts until the next apply.\n#\n# WHY BEFORE START, AND NOT A STEP AFTER IT. Tautulli keeps its Plex connection only in config.ini\n# ([PMS]), reads that file at start, and writes its whole in-memory config back on every shutdown.\n# A step editing the file while Tautulli runs is overwritten the moment the container is recreated;\n# its API has no command that sets the connection, and its settings form needs an admin login. The\n# one moment the file is Tautulli's to read and nobody's to overwrite is here: after the old\n# container stopped (and wrote), before the new one reads. The container restarts on its binding\n# and credential (`restart-on`), so a plex that moves or a token that is accepted lands here.\n#\n# WHAT IT WRITES, AND WHEN. Only [PMS] keys, and only when they differ from what the mesh says:\n# pms_ip, pms_port, pms_ssl, pms_url - from the binding; always, when the binding is usable\n# pms_identifier - plex's own machineIdentifier, when plex answers /identity\n# pms_token - the pair credential, ONLY when plex takes it\n# Every other key and section, comment and ordering stays as it was, byte for byte.\n#\n# A TOKEN PLEX REFUSES IS NEVER WRITTEN. Until the operator accepts the server's X-Plex-Token for\n# this pair, the mesh delivers a value it minted, which plex answers with 401 (400 on a network it\n# trusts). Writing it would replace a working token with a dead one. The address is still written:\n# it is right whatever the token, and Tautulli's existing token keeps working at the new address.\n# The refusal is loud here and in the `plex` step, which fails naming the `secret accept`.\n#\n# Never prints the token. Exits 0 even on a refusal: custom-init ignores the code, and Tautulli\n# starting on what it had is better than not starting. The step after the server is what fails.\n\nimport json\nimport os\nimport re\nimport sys\nimport tempfile\nimport time\nimport urllib.error\nimport urllib.request\n\nBINDING = os.environ.get(\"MESH_PLEX_BINDING\", \"/run/mesh/plex-api.json\")\nSECRET = os.environ.get(\"MESH_PLEX_SECRET\", \"/run/mesh/plex-api.secret\")\nCONFIG = os.environ.get(\"MESH_TAUTULLI_CONFIG\", \"/config/config.ini\")\nWAIT = float(os.environ.get(\"MESH_PLEX_WAIT_SECONDS\", \"60\"))\nPROVISION = \"plex-api\"\n\n\ndef say(message):\n print(\"[mesh-plex] \" + message, flush=True)\n\n\ndef is_loopback(host):\n h = host.lower()\n return h in (\"localhost\", \"::1\", \"[::1]\") or h.startswith(\"127.\")\n\n\ndef wanted_address(binding):\n \"\"\"The [PMS] address keys the binding says, or a reason it cannot say them.\"\"\"\n if not isinstance(binding, dict):\n return None, \"no binding for %s was delivered\" % PROVISION\n at = binding.get(\"at\")\n at = at.strip() if isinstance(at, str) else \"\"\n serves = binding.get(\"serves\") if isinstance(binding.get(\"serves\"), dict) else {}\n try:\n port = int(serves.get(\"port\"))\n except (TypeError, ValueError):\n port = 0\n scheme = serves.get(\"scheme\") or \"http\"\n if not at:\n return None, \"the %s binding names no host (at)\" % PROVISION\n if is_loopback(at):\n return None, (\n \"the %s binding says plex is at %s, which from Tautulli's container is Tautulli itself; \"\n \"the mesh hands loopback to a machine that is not on the private network\" % (PROVISION, at))\n if not 0 < port < 65536:\n return None, \"the %s binding serves no usable port (%r)\" % (PROVISION, serves.get(\"port\"))\n if scheme not in (\"http\", \"https\"):\n return None, \"the %s binding serves scheme %s, which Tautulli cannot dial\" % (PROVISION, scheme)\n host = \"[%s]\" % at if \":\" in at and not at.startswith(\"[\") else at\n url = \"%s://%s:%d\" % (scheme, host, port)\n return {\"pms_ip\": at, \"pms_port\": str(port), \"pms_ssl\": \"1\" if scheme == \"https\" else \"0\", \"pms_url\": url}, None\n\n\ndef plex_get(url, path, token=None):\n \"\"\"(status, parsed JSON or None); raises OSError when plex cannot be asked.\"\"\"\n headers = {\"Accept\": \"application/json\"}\n if token is not None:\n headers[\"X-Plex-Token\"] = token\n request = urllib.request.Request(url + path, headers=headers)\n try:\n with urllib.request.urlopen(request, timeout=10) as response:\n body = response.read()\n try:\n return response.status, json.loads(body)\n except ValueError:\n return response.status, None\n except urllib.error.HTTPError as err:\n return err.code, None\n\n\ndef ask_plex(url, token):\n \"\"\"(takes: True/False/None, machineIdentifier or None). None: plex could not be asked in time.\"\"\"\n deadline = time.monotonic() + WAIT\n while True:\n try:\n status, _ = plex_get(url, \"/\", token)\n if status in (400, 401, 403):\n takes = False\n elif 200 <= status < 300:\n takes = True\n else:\n raise OSError(\"plex answered %d at /\" % status)\n identifier = None\n status, body = plex_get(url, \"/identity\")\n if status == 200 and isinstance(body, dict):\n value = (body.get(\"MediaContainer\") or {}).get(\"machineIdentifier\")\n identifier = value if isinstance(value, str) and value else None\n return takes, identifier\n except OSError as err:\n if time.monotonic() >= deadline:\n say(\"plex could not be asked at %s: %s\" % (url, err))\n return None, None\n time.sleep(3)\n\n\nSECTION = re.compile(r\"^\\s*\\[([^\\]]+)\\]\\s*$\")\nKEY = re.compile(r\"^(\\s*)([A-Za-z0-9_]+)(\\s*=\\s*)(.*?)\\s*$\")\n\n\ndef unquoted(value):\n if len(value) >= 2 and value[0] == value[-1] and value[0] in \"\\\"'\":\n return value[1:-1]\n return value\n\n\ndef plain(value):\n \"\"\"ConfigObj reads a value unquoted unless it holds one of these; none of ours should.\"\"\"\n return not re.search(r\"[#,\\\"'\\r\\n]\", value) and value == value.strip()\n\n\ndef laid_over(text, wanted):\n \"\"\"config.ini's text with [PMS] saying `wanted`, and the names of the keys that changed.\"\"\"\n lines = text.splitlines(True)\n if lines and not lines[-1].endswith(\"\\n\"):\n lines[-1] += \"\\n\"\n changed = []\n start = end = None\n for i, line in enumerate(lines):\n m = SECTION.match(line)\n if m:\n if start is not None:\n end = i\n break\n if m.group(1).strip() == \"PMS\":\n start = i\n if start is None:\n if lines and lines[-1].strip():\n lines.append(\"\\n\")\n lines.append(\"[PMS]\\n\")\n start, end = len(lines) - 1, len(lines)\n elif end is None:\n end = len(lines)\n seen = set()\n for i in range(start + 1, end):\n m = KEY.match(lines[i])\n if not m or m.group(2) not in wanted:\n continue\n key = m.group(2)\n seen.add(key)\n if unquoted(m.group(4)) != wanted[key]:\n lines[i] = \"%s%s%s%s\\n\" % (m.group(1), key, m.group(3), wanted[key])\n changed.append(key)\n missing = [k for k in wanted if k not in seen]\n # Insert after the section's last key, not after the blank lines that separate it from the next.\n at = end\n while at > start + 1 and not lines[at - 1].strip():\n at -= 1\n for key in missing:\n lines.insert(at, \"%s = %s\\n\" % (key, wanted[key]))\n at += 1\n changed.append(key)\n return \"\".join(lines), changed\n\n\ndef write_config(text):\n \"\"\"Replace config.ini whole, keeping its owner and mode; a new one takes the directory's owner.\"\"\"\n directory = os.path.dirname(CONFIG) or \".\"\n try:\n st = os.stat(CONFIG)\n uid, gid, mode = st.st_uid, st.st_gid, st.st_mode & 0o7777\n except FileNotFoundError:\n st = os.stat(directory)\n uid, gid, mode = st.st_uid, st.st_gid, 0o644\n fd, tmp = tempfile.mkstemp(prefix=\".config.ini.\", dir=directory)\n try:\n with os.fdopen(fd, \"w\", encoding=\"utf-8\") as f:\n f.write(text)\n os.chmod(tmp, mode)\n try:\n os.chown(tmp, uid, gid)\n except PermissionError:\n pass\n os.replace(tmp, CONFIG)\n except BaseException:\n if os.path.exists(tmp):\n os.unlink(tmp)\n raise\n\n\ndef read(path):\n try:\n with open(path, encoding=\"utf-8\") as f:\n return f.read()\n except FileNotFoundError:\n return None\n\n\ndef main():\n raw = read(BINDING)\n try:\n binding = json.loads(raw) if raw is not None else None\n except ValueError:\n binding = None\n address, problem = wanted_address(binding)\n if problem:\n say(\"left Tautulli's Plex connection as it was: \" + problem)\n return 0\n wanted = dict(address)\n token = (read(SECRET) or \"\").strip()\n takes, identifier = ask_plex(address[\"pms_url\"], token) if token else (False, None)\n if identifier:\n wanted[\"pms_identifier\"] = identifier\n frm = binding.get(\"from\") or \"\"\n if not token:\n say(\"no %s credential was delivered; only the address was written\" % PROVISION)\n elif takes and plain(token):\n wanted[\"pms_token\"] = token\n elif takes is False:\n say(\"plex refuses the %s credential the mesh delivered, so it was not written; the address was. \"\n \"plex's token is issued by plex.tv and the mesh cannot make it: accept the server's own token \"\n \"for this pair - `secret accept tautulli %s --provider %s --from `\" % (PROVISION, PROVISION, frm))\n elif takes:\n say(\"the %s credential holds characters config.ini cannot carry unquoted; it was not written\" % PROVISION)\n else:\n say(\"plex could not be asked whether it takes the %s credential; only the address was written\" % PROVISION)\n if not all(plain(v) for v in wanted.values()):\n say(\"the %s binding holds characters config.ini cannot carry unquoted; nothing was written\" % PROVISION)\n return 0\n before = read(CONFIG)\n after, changed = laid_over(before or \"\", wanted)\n if not changed:\n say(\"Tautulli's Plex connection is already as the mesh says (%s)\" % address[\"pms_url\"])\n return 0\n write_config(after)\n say(\"wrote %s into Tautulli's [PMS] (%s)\" % (\", \".join(changed), address[\"pms_url\"]))\n return 0\n\n\nif __name__ == \"__main__\":\n sys.exit(main())\n" }, { "id": "plex-init", "type": "file", "path": "${dir:state}/50-mesh-plex", "mode": "0755", "content": "#!/bin/bash\n# Run by the linuxserver image's custom-init each time Tautulli's container starts, as root, before\n# Tautulli: puts where the mesh says plex is into Tautulli's config.ini (mesh-plex.py says why).\n#\n# Written by the mesh from the tautulli module's manifest. Editing it here lasts until the next apply.\nPY=/lsiopy/bin/python3\n[ -x \"$PY\" ] || PY=python3\nexec \"$PY\" /run/mesh/mesh-plex.py\n" }, { "id": "server", "type": "container", "name": "tautulli", "image": "lscr.io/linuxserver/tautulli@sha256:e35570d636471f8aabfac7044057712679f954844a763ba735baa9659ea142b5", "env": { "PUID": "1000", "PGID": "1000", "TZ": "Etc/UTC" }, "ports": [ "8181" ], "volumes": [ "${dir:config}:/config", "${dir:state}/50-mesh-plex:/custom-cont-init.d/50-mesh-plex:ro", "${dir:state}/mesh-plex.py:/run/mesh/mesh-plex.py:ro", "${dir:state}/plex-api.json:/run/mesh/plex-api.json:ro", "${dir:state}/plex-api.secret:/run/mesh/plex-api.secret:ro" ], "restart-on": [ "plex-init", "plex-init-code", "bound-plex-api", "secret-plex-api" ] }, { "id": "runtime-config", "type": "file", "path": "/var/lib/mesh/tautulli/config.json", "mode": "0600", "content": "{}\n", "merge": "json" }, { "id": "runtime", "type": "container", "name": "mesh-tautulli", "network": "host", "volumes": [ "/var/lib/mesh/tautulli/broker:/run/secrets/broker:ro", "/var/lib/mesh/tautulli/config.json:/run/config/config.json:ro", "${dir:config}:/var/lib/tautulli/config:ro" ], "env": { "MESH_BROKER_FILE": "/run/secrets/broker", "MESH_TAUTULLI_URL": "http://127.0.0.1:${port:8181}", "MESH_TAUTULLI_CONFIG_FILE": "/run/config/config.json", "MESH_TAUTULLI_CONFIG_DIR": "/var/lib/tautulli/config" }, "restart-on": [ "runtime-config" ], "artifact": "runtime" }, { "id": "plex", "type": "container", "name": "mesh-tautulli-plex", "network": "host", "run-once": true, "volumes": [ "${dir:state}/plex-api.json:/run/plex/plex-api.json:ro", "${dir:state}/plex-api.secret:/run/plex/plex-api.secret:ro", "${dir:config}:/var/lib/tautulli/config:ro" ], "env": { "MESH_TAUTULLI_URL": "http://127.0.0.1:${port:8181}", "MESH_TAUTULLI_CONFIG_DIR": "/var/lib/tautulli/config", "MESH_PLEX_DIR": "/run/plex" }, "args": [ "run", "/app/modules/tautulli/dist/plex/index.js" ], "restart-on": [ "server", "bound-plex-api", "secret-plex-api" ], "artifact": "runtime" } ], "requires": [ "plex-api", "route" ], "contributes": { "route": { "label": "tautulli", "endpoint": "web" } }, "binds": { "route": "${dir:state}/route.json", "plex-api": "${dir:state}/plex-api.json" }, "secrets": { "plex-api": "${dir:state}/plex-api.secret" }, "build": { "on": [ { "arg": "BUILD_BASE", "module": "mesh-tools", "artifact": "build" }, { "arg": "RUNTIME_BASE", "module": "mesh-tools", "artifact": "runtime" } ], "artifacts": [ { "name": "runtime", "kind": "image", "from": "Dockerfile" } ] } }