Files
serverAppConfigs/serverAppConfigs-docs/Dankserver/Plex.md
T
Frank 13830865f6 docs(media): the Plex leg ran, and a script fed over stdin owns that stdin
The 2026-11-01 morning leg cleared every Plex row naming a held path, so the prune page records the outcome plus the residue that is normal: a section pass rewrites feature rows first and can leave rows for clips in a film's other/ or extras/ folder standing after the refreshing flag is back to 0. Beside REAL: 0 that is a dead disc-menu clip, not a false prune, and no reason to touch the cache or the database.

Plex gains the two operational laws the run taught: a start job issued after an unmask belongs to the manager, not the shell, and lands minutes later surviving the client that issued it; a refresh fired while PMS is warming answers 000, so retry it rather than restart the service.

The trap that made a finished job look hung belongs to the remote job runner: a script fed to bash -s IS the stdin, so any child that reads stdin (podman exec -i) swallows the rest of the script without a word.
2026-11-01 09:38:38 -08:00

8.0 KiB

type, description, tags, timestamp
type description tags timestamp
reference Plex Media Server is a **containerized media server on Dankserver**: a podman
dankserver
plex
2026-10-30

Plex

Plex Media Server is a containerized media server on Dankserver: a podman quadlet (plex.service) under the systemd user generator, generated from dankserver/plex/quadlet/plex.container. It shares the host with a second live media server, Jellyfin (jellyfin.service, listening on 8096); both serve from the same tree — the Media Metadata View overlay — so the Kodi-format sidecars Jellyfin writes are the files Plex reads, with no copy and no sync.

Host: Dankserver — Ports: 32400/tcp (PMS web + API), 32400 is the only port PMS needs for client streaming; LAN clients connect directly, public traffic can be fronted by Caddy like every other vhost.

Facts

Item Value
Runtime podman quadlet, plex.service (user manager) — migrated from the native plexmediaserver.service OS-package unit (2026-09)
Image lscr.io/linuxserver/plex:1.43.5.11029-6bf8af3f0-ls329 (pinned; verified == the registry's latest manifest digest at pin time: sha256:8f655bad…e9e2334)
Data root /var/lib/plexmediaserver bind-mounted in place as /config (RW) — the migration moved zero bytes
Media /var/lib/media-meta/view (the Media Metadata View overlay) bound at the same absolute path /mnt/entropy read-only — live since the 2026-10-31 cutover, so the PMS database's recorded paths keep resolving with zero re-scan
Identity fleet rootless model — container root maps to the dank user (1000:1000); the deployer chowns the data root on first cutover
Port 32400/tcp published on 0.0.0.0 — zero client reconfiguration
Network dankserver_appnet, alias plex; Tautulli reaches it at http://plex:32400
GPU none by decision — CPU decode only; the two R9700s stay dedicated to frank-archon
Deploy ./scripts/deploy.sh dankserver --service plex
Docs https://support.plex.tv

Why in-place bind mounts

The migration moved zero bytes: the container bind-mounts the native data root where it already lived, and the media archive re-mounts at /mnt/entropy inside the container — only the HOST side of that bind has moved (the array until the 2026-10-31 view cutover, the overlay since), so every absolute library path in the PMS database keeps resolving. The one metadata change: rootless podman cannot map the real host uid 997, so the fleet model applies — container root runs as the dank user, and the deployer adopts the data root with chown -R 1000:1000 on first cutover (rollback chowns it back).

Cutover & rollback

  • First deploy stops and masks plexmediaserver.service (never disable — on these hosts disable deletes the unit file), chowns /var/lib/plexmediaserver to 1000:1000, then starts the quadlet; the deployer blocks until /identity answers on 32400.
  • Rollback lever while masked: systemctl --user stop plex.service && sudo chown -R 997:984 /var/lib/plexmediaserver && sudo systemctl unmask plexmediaserver.service && sudo systemctl start plexmediaserver.service (ownership-only churn — zero bytes ever moved).
  • Decommission (sudo apt-get -y purge plexmediaserver) only after explicit operator sign-off.

Integration points

  • Caddy: Plex is exempt from the Anubis bot challenge (plain-HTTP legacy clients cannot solve JS challenges) — Anubis "Exempt by construction".
  • Tautulli (Tautulli) is the companion: it polls PMS on the appnet for statistics and notifications and makes no sense without Plex.
  • Auth wall: if a public vhost is ever pointed at Plex, put the Authentik outpost + Anubis chain in front of it like every other guarded vhost (Authentik, Caddy); direct :32400 exposure to the LAN is unauthenticated by design (Plex owns its own auth via claim tokens).

Gotchas

  1. Do not benchmark on it blind. Transcoding is CPU-only now but still contends with the LLM fleet's host-RAM expert streaming; stop the service before benchmark runs, per the Benchmarking Policy.
  2. Updates are commit-driven, not apt. The OS package is retired at cutover; upgrading PMS means pinning a new -ls tag in the unit file and redeploying — never run :latest.
  3. The claim token is the identity of this server; document it in the host .env, never in this repo (secret rules apply). The token lives in Preferences.xml inside the RW data bind, preserved by the migration.
  4. Cold container boot is slow. PMS pre-binds 32400 and empty-replies while the HTTP layer finishes loading; the first boot of a years-old library took ~10 minutes (2026-09 cutover). The deployer gate now allows 600 s before failing; later boots are much faster (warm page cache). A systemctl --user start issued after an unmask is a job in the manager, not in the shell that issued it: it lands minutes later and survives that client dying, so a wrapper script that looks hung is usually only waiting — read is-active and podman ps before declaring a fault, and give every step of such a script its own timeout.
  5. 32400 answers TLS only to remote clients. PMS requires secure connections, so curl http://192.168.0.235:32400/identity from any other machine closes without a single byte (curl exit 52) — that reads as a dead server beside a healthy one, and it is how a post-cutover health check gets misread. Probe with curl -k https://192.168.0.235:32400/identity. Two consequences: plex.franksdank.cloud works only because its transport http block carries tls_insecure_skip_verify (that option makes Caddy speak TLS to PMS's self-signed cert — delete it and the vhost 502s), and the scan block below rides http://127.0.0.1 inside the container, where insecure connections are allowed.

Triggering a scan

A rename on disk is invisible until the section is rescanned, and a moved file is a Couldn't find the file to stream 404 until that scan passes it. Run this from the host shell through podman exec, never from a neighbour container: PMS's HTTPD closes without a single byte on any request that arrives from another container's netns (10.89.x.x reads as "empty reply", not as a 401). The token rides stdin, never argv, where any process on the host could read it out of /proc/<pid>/cmdline; it lives in Preferences.xml inside the RW data bind.

umask 077
podman exec plex sh -c "sed -n 's/.*PlexOnlineToken=\"\\([^\"]*\\)\".*/\\1/p' \
  '/config/Library/Application Support/Plex Media Server/Preferences.xml'" >/tmp/px.tok
cat >/tmp/pxscan.sh <<'EOS'
read -r T
cfg=$(mktemp)
mk() { { printf 'header = "X-Plex-Token: %s"\nheader = "Accept: application/json"\n' "$T"
        printf 'url = "%s"\n' "$1"; } > "$cfg"; }
mk "http://127.0.0.1:32400/library/sections"
for k in $(curl -s -K "$cfg" | grep -o '"key":"[0-9]*"' | cut -d\" -f4); do
  mk "http://127.0.0.1:32400/library/sections/$k/refresh"
  printf 'request = "POST"\n' >> "$cfg"
  echo "plex section $k: $(curl -s -o /dev/null -w '%{http_code}' -K "$cfg")"
done
shred -u "$cfg"
EOS
podman cp /tmp/pxscan.sh plex:/tmp/pxscan.sh
podman exec -i plex sh /tmp/pxscan.sh </tmp/px.tok
shred -u /tmp/px.tok

Every section answers 204 — or 200 with a Directory body when Accept: application/json is sent. A refresh fired while PMS is still warming answers 000 instead (the empty reply of gotcha 4, arriving mid-request): retry until it answers and never restart the service over it. Inside the quoted printf format a backslash-newline is LITERAL, not a continuation, so mk writes two printfs: one glued line and one stray backslash is a config file curl rejects, and the failure prints as option -K: is unknown, never as a bad config line.

Proof that a rename landed is not a scan response: scripts/media-verify.py replays the run's journal against the database — the only read that distinguishes "not scanned yet" from "gone"; see Media Relayout.