The page's own example passed the journal mtime. Measured 2026-11-04: a chain using it fell straight through its wait loop, because status only asks whether the section has scanned since that epoch and the section had been walked earlier the same day. The verify that followed read a database that had not yet processed that day's renames — a false success, which is the direction that gets believed.
157 lines
9.0 KiB
Markdown
157 lines
9.0 KiB
Markdown
---
|
|
type: reference
|
|
description: "Plex Media Server is a **containerized media server on Dankserver**: a podman"
|
|
tags:
|
|
- dankserver
|
|
- plex
|
|
timestamp: 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:
|
|
|
|
```bash
|
|
after="$(date +%s)" # the TRIGGER's epoch — never a file mtime
|
|
scripts/media-refresh-plex.sh trigger --library 1
|
|
scripts/media-refresh-plex.sh status --library 1 --after "$after"
|
|
```
|
|
|
|
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 epoch `--after` receives must be the trigger's, not a file's.** `status` answers
|
|
one question — has this section scanned since that epoch — so any journal or plan whose
|
|
mtime predates the most recent scan makes it report settled the instant you ask.
|
|
Measured 2026-11-04: a chain that passed `stat -c %Y <journal>` fell straight through
|
|
its wait loop and the verify after it read a database that had not yet processed that
|
|
day's renames. The failure mode is a false *success*, never a false alarm, which is
|
|
what makes it worth two lines of warning. A purge gate survives it —
|
|
`scripts/media-verify.py` asks whether any row still points at a path with no file, and
|
|
a scan can clear rows but cannot invent one — but a "the scan is done" claim does not.
|
|
|
|
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]].
|