Plex.md and Jellyfin.md keep every law and point at the driver that now carries them, so the procedure has one owner and the pages keep the failure signatures. Jellyfin.md also records where `jellyfin.db` actually lives on this host (the quadlet's config bind, not the stock path) and demotes `immutable=1` on the `ApiKeys` read to the `-shm` fallback, because the database is WAL and `immutable` skips the log. Plex.md gains the live section keys and the `scanned_at == scannedAt` fact. Media Relayout.md's hand-off after an apply names both drivers and states the ordering law: Jellyfin first and alone, since its one door walks every library while Plex forks a scanner per section, and the array is what both share. Media Duplicate Prune.md gains the guard the 2026-11-04 hold taught: 32 files carrying real film titles were 8-20-second h264 clips (49 MiB, 25 of them in a `_release-group watermarks/` folder), invisible to every duplicate screen because nothing shares their size and the feature-length guard hides them. The census that finds the next batch is a duration floor, not a hash.
8.2 KiB
type, description, tags, timestamp
| type | description | tags | timestamp | ||
|---|---|---|---|---|---|
| reference | Plex Media Server is a **containerized media server on Dankserver**: a podman |
|
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(neverdisable— on these hostsdisabledeletes the unit file), chowns/var/lib/plexmediaserverto1000:1000, then starts the quadlet; the deployer blocks until/identityanswers 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
:32400exposure to the LAN is unauthenticated by design (Plex owns its own auth via claim tokens).
Gotchas
- 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.
- Updates are commit-driven, not apt. The OS package is retired at
cutover; upgrading PMS means pinning a new
-lstag in the unit file and redeploying — never run:latest. - 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 inPreferences.xmlinside the RW data bind, preserved by the migration. - 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 startissued after anunmaskis 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 — readis-activeandpodman psbefore declaring a fault, and give every step of such a script its owntimeout. 32400answers TLS only to remote clients. PMS requires secure connections, socurl http://192.168.0.235:32400/identityfrom 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 withcurl -k https://192.168.0.235:32400/identity. Two consequences:plex.franksdank.cloudworks only because itstransport httpblock carriestls_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 rideshttp://127.0.0.1inside 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
scripts/media-refresh-plex.sh status --library 1 --after "$(stat -c %Y <journal>)"
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. Progress needs no token and no container: section_locations.scanned_at in
the library database carries the same epoch GET /library/sections prints as
scannedAt (verified 2026-11-04), and that is what status compares --after
against.
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.