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.
150 lines
8.0 KiB
Markdown
150 lines
8.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 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.
|
|
|
|
```bash
|
|
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 `printf`s: 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]].
|