Files
serverAppConfigs/serverAppConfigs-docs/Dankserver/Plex.md
T
dank 146c28da9f docs(plex,jellyfin): the scan doors are not waiters; supersedes b82655d
`status` compares a recorded timestamp with `--after`. Measured 2026-11-04: Plex
answered `settled` in the same second its own trigger was accepted (stamp
19:46:59Z for a request accepted 19:46:58Z) and still answered `settled` from that
one stamp 30 minutes later while the walk ran. So no epoch survives it — a journal
mtime fails and the trigger's epoch fails, because the trigger satisfies the door it
was asked to wait for. That is the correction of b82655d, which prescribed the
trigger's epoch for this door.

Jellyfin's `EndTimeUtc` only advances when a scan finishes, so there the trigger's
epoch is sound and a file mtime is not; its `wait` already refuses an epoch an
earlier task passes. The proof a scan did its work stays `media-verify.py`
replaying the journal: a scan can clear rows but cannot invent one.
2026-11-04 12:20:59 -08:00

9.4 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 the driver from the host shell; the section keys are 1 Movies, 3 TV Shows, 5 Audiobooks, 6 Music, 11 VODs & Live Events:

scripts/media-refresh-plex.sh trigger --library 1
python3 scripts/media-verify.py --db-kind plex \
    --journal /mnt/entropy/.media-campaign/<journal>.jsonl   # the real proof

Exactly ONE section: a refresh costs a full walk of that tree, and refreshing all five for a rename that touched one buys nothing. trigger answers refresh accepted (204) — 200 when a body is requested — and 000 means PMS is still warming, which the driver retries out loud; a service restart is never the fix.

status is a census, not a waiter: no epoch makes it watch a scan. It reads section_locations.scanned_at — the epoch of the last scan PMS has recorded for the section, the same value GET /library/sections prints as scannedAt (verified 2026-11-04) — and compares it against --after. Measured twice on 2026-11-04: a chain fell through its wait loop in the same second its own trigger was accepted, and a direct read 30 minutes later still answered settled off that one stamp (2026-11-04T19:46:59Z, for a request accepted at 19:46:58Z) while the walk ran. A journal's mtime fails, and so does the trigger's own epoch — the request satisfies the very door it was asked to wait for. The failure direction is a false success, never a false alarm, which is what makes it worth the warning: ask status whether a scan got recorded, and never let a chain depend on it.

The proof that a scan did its work is the journal replayed against the database: scripts/media-verify.py --db-kind plex --journal <journal> names every row that still points at a source path (still at src) and every entry whose file is physically still there (REAL). Both at zero is what gates a purge, and it needs no settle signal at all — a scan can clear rows but cannot invent one. Jellyfin's door has no such trap: scripts/media-refresh-jellyfin.py wait watches the scan task itself and refuses by name when no task reaches Running.

The driver ships its runner into the container with podman cp rather than quoting a command through podman exec sh -c, because PMS's HTTPD closes without a single byte on a request that arrives from another netns — the host address and the 10.89.x.x container address both read as "empty reply", never as a 401 — and because a three-layer ssh → podman exec → sh -c quote chain dies on a parse error long before it reaches the server. The request therefore rides plain http://127.0.0.1:32400 from inside the container, where insecure connections are allowed (gotcha 5). The credential is PlexOnlineToken in the container's Preferences.xml — NOT PlexHTTPToken, which a claimed server need not carry — read inside the container into a mode-600 curl config there, so it never enters argv (/proc/<pid>/cmdline is world-readable) and never a URL (proxies log URLs). Hand-writing that config has one more trap: inside a quoted printf format a backslash-newline is LITERAL, so a two-line string becomes one glued line plus a stray backslash, and curl answers option -K: is unknown — never "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.