Media refresh drivers: the two scan doors become commands #140

Merged
dank merged 5 commits from feat/media-refresh-drivers into main 2026-11-04 12:15:38 -08:00
Owner

Why

The 2026-11-04 promo-clip hold left both media servers indexing paths that no
longer exist, so the reconcile step had to run. The only door for it was a
hand-written curl -K block copied out of the vault — the same block that has
bitten this fleet twice (the \" vs \\" quoting 401, and curl silently
performing a second transfer when a config url meets a command-line URL). Both
servers' scan procedures are now commands, and the vault pages point at them.

What

scripts/media-refresh-jellyfin.py — trigger / status / wait.

  • The API key is read from ~/.config/secrets/jellyfin into a mode-600 curl
    config and reaches the wire only as Authorization: MediaBrowser Token="…";
    it never enters argv (/proc/<pid>/cmdline is world-readable) and never the
    URL.
  • trigger accepts only 204, retries the identical request otherwise, and
    refuses to fire a second refresh while one is running: a second accepted
    refresh moves Scan Media Library to Cancelling, after which every 204 is
    accepted and discarded. --force overrides for the documented restart case.
  • wait calls the library settled only on the page's rule: the Scan Media Library task seen Running (or a run that finished while it watched, or an
    --after the task already passes) FIRST, then --require-quiet consecutive
    reads with no scan and no database writes. Without a start sighting it refuses
    by name rather than reporting the pre-scan library as fresh — and
    ScheduledTasks is the only honest progress read: RefreshStatus on the
    virtual-folder record stayed Idle through a scan verifiably at 75 %, and
    GET /Library/ScanStatus is 404 on 12.4.
  • status --after <epoch> (a journal mtime) answers "has the scan reached my
    moves" from the task's EndTimeUtc and exits non-zero while it is owed;
    recentMedia counts BaseItems.DateModified rows in jellyfin.db, opened
    mode=ro (the DB is WAL; immutable=1 would skip the log). The database path
    is discovered, because on this host it is the quadlet's config bind at
    /home/dank/Containers/jellyfin/config/data/, not the stock location.

scripts/media-refresh-plex.sh — trigger --library <key> / status --library <key> --after <epoch>.

  • PMS's HTTPD answers nothing to a request from another netns (host and
    10.89.x.x both read as "empty reply"), so the runner is podman cp-shipped
    into the container and rides plain http://127.0.0.1:32400 from inside. No
    quoting chain survives ssh → podman exec → sh -c.
  • PlexOnlineToken is read from the container's Preferences.xml inside the
    container into a mode-600 curl config there — the token never crosses the
    host, never argv, never the URL. 000 (server warming) retries with a
    parameterised sleep instead of restarting anything.
  • status needs no token at all: section_locations.scanned_at in the library
    database carries the same epoch the API prints as scannedAt — verified live
    (section 1: 2026-11-04T18:31:33Z in the DB, 1793817093 from the API).

tests/media_refresh.bats — 17 tests over the contracts an operator
observes, no live server. Jellyfin: the driver authenticates with the
MediaBrowser form and puts no key in argv; a non-204 retry loop names the code
it saw; a missing credential is refused before any request; wait returns only
after the library reads quiet repeatedly, and refuses outright when it never saw
the scan start; status --after refuses to certify a scan that predates the
moves. Plex: the container token reaches X-Plex-Token and never argv or the
URL, and a section with no location, an unreadable database and an un-answered
000 all fail by name.

Docs

  • Dankserver/Plex.md — the driver is the door; adds the live section keys and
    the scanned_at == scannedAt fact; keeps every law (one section, the netns
    behaviour, Preferences.xml vs PlexHTTPToken, the literal
    backslash-newline in a quoted printf).
  • Dankserver/Jellyfin.md — the driver is the door; records where jellyfin.db
    actually lives on this host, and corrects the ApiKeys read to plain
    mode=ro with immutable=1 demoted to the -shm fallback.
  • Dankserver/Media Relayout.md — the hand-off after an apply now names both
    drivers and states the ordering law: Jellyfin first and alone, because its one
    door walks every library and Plex forks a scanner per section.
  • Dankserver/Media Duplicate Prune.md — a new guard: 32 promo clips in Movies
    (25 in _release-group watermarks/) were 8–20-second h264 files carrying real
    film titles; no duplicate screen can see them, so the census that finds them is
    a duration floor.

Verification

  • ./tests/run.sh — full suite green, including the new seam (auto-discovered by
    tests/run.sh's bats *.bats).
  • Live on Dankserver: trigger printed refresh accepted (204), and status
    then read
    {"scan": true, "taskState": "Running", "lastScan": null, "recentMedia": 0, "scannedAfter": false} with exit 1 and "a refresh is still owed" — that is
    the never-completed EndTimeUtc signature the page describes, now surfaced
    instead of guessed at. Before the trigger, Plex status --library 1 --after <journal mtime> had returned the same verdict from the database, and
    media-verify.py replayed promo-hold.jsonl against both databases, finding
    the same stale rows in each — the rule that says a rescan is owed.
  • The end-to-end chain (Jellyfin to quiet, then Plex section 1, then the journal
    replay against both DBs) is running on the host; its log will be posted to this
    PR as a comment when it finishes.

Not deployed by this PR: the drivers are new files; nothing in the deploy plan
touches a running service.


cwd: /home/blank/.omp/wt/wt-20261030-163847-6d1fc1f · session: 01a18f2e-18b1-7573-bcd5-ac99636f8bcb
(committed and tested in the build worktree /tmp/wt-deploy)

## Why The 2026-11-04 promo-clip hold left both media servers indexing paths that no longer exist, so the reconcile step had to run. The only door for it was a hand-written `curl -K` block copied out of the vault — the same block that has bitten this fleet twice (the `\"` vs `\\"` quoting 401, and curl silently performing a second transfer when a config `url` meets a command-line URL). Both servers' scan procedures are now commands, and the vault pages point at them. ## What **`scripts/media-refresh-jellyfin.py`** — `trigger` / `status` / `wait`. - The API key is read from `~/.config/secrets/jellyfin` into a mode-600 curl config and reaches the wire only as `Authorization: MediaBrowser Token="…"`; it never enters argv (`/proc/<pid>/cmdline` is world-readable) and never the URL. - `trigger` accepts only `204`, retries the identical request otherwise, and refuses to fire a second refresh while one is running: a second accepted refresh moves `Scan Media Library` to `Cancelling`, after which every `204` is accepted and discarded. `--force` overrides for the documented restart case. - `wait` calls the library settled only on the page's rule: the `Scan Media Library` task seen `Running` (or a run that finished while it watched, or an `--after` the task already passes) FIRST, then `--require-quiet` consecutive reads with no scan and no database writes. Without a start sighting it refuses by name rather than reporting the pre-scan library as fresh — and `ScheduledTasks` is the only honest progress read: `RefreshStatus` on the virtual-folder record stayed `Idle` through a scan verifiably at 75 %, and `GET /Library/ScanStatus` is 404 on 12.4. - `status --after <epoch>` (a journal mtime) answers "has the scan reached my moves" from the task's `EndTimeUtc` and exits non-zero while it is owed; `recentMedia` counts `BaseItems.DateModified` rows in `jellyfin.db`, opened `mode=ro` (the DB is WAL; `immutable=1` would skip the log). The database path is discovered, because on this host it is the quadlet's config bind at `/home/dank/Containers/jellyfin/config/data/`, not the stock location. **`scripts/media-refresh-plex.sh`** — `trigger --library <key>` / `status --library <key> --after <epoch>`. - PMS's HTTPD answers nothing to a request from another netns (host and `10.89.x.x` both read as "empty reply"), so the runner is `podman cp`-shipped into the container and rides plain `http://127.0.0.1:32400` from inside. No quoting chain survives `ssh → podman exec → sh -c`. - `PlexOnlineToken` is read from the container's `Preferences.xml` *inside* the container into a mode-600 curl config there — the token never crosses the host, never argv, never the URL. `000` (server warming) retries with a parameterised sleep instead of restarting anything. - `status` needs no token at all: `section_locations.scanned_at` in the library database carries the same epoch the API prints as `scannedAt` — verified live (section 1: `2026-11-04T18:31:33Z` in the DB, `1793817093` from the API). **`tests/media_refresh.bats`** — 17 tests over the contracts an operator observes, no live server. Jellyfin: the driver authenticates with the `MediaBrowser` form and puts no key in argv; a non-204 retry loop names the code it saw; a missing credential is refused before any request; `wait` returns only after the library reads quiet repeatedly, and refuses outright when it never saw the scan start; `status --after` refuses to certify a scan that predates the moves. Plex: the container token reaches `X-Plex-Token` and never argv or the URL, and a section with no location, an unreadable database and an un-answered `000` all fail by name. ## Docs - `Dankserver/Plex.md` — the driver is the door; adds the live section keys and the `scanned_at == scannedAt` fact; keeps every law (one section, the netns behaviour, `Preferences.xml` vs `PlexHTTPToken`, the literal backslash-newline in a quoted `printf`). - `Dankserver/Jellyfin.md` — the driver is the door; records where `jellyfin.db` actually lives on this host, and corrects the `ApiKeys` read to plain `mode=ro` with `immutable=1` demoted to the `-shm` fallback. - `Dankserver/Media Relayout.md` — the hand-off after an apply now names both drivers and states the ordering law: Jellyfin first and alone, because its one door walks every library and Plex forks a scanner per section. - `Dankserver/Media Duplicate Prune.md` — a new guard: 32 promo clips in `Movies` (25 in `_release-group watermarks/`) were 8–20-second h264 files carrying real film titles; no duplicate screen can see them, so the census that finds them is a duration floor. ## Verification - `./tests/run.sh` — full suite green, including the new seam (auto-discovered by `tests/run.sh`'s `bats *.bats`). - Live on Dankserver: `trigger` printed `refresh accepted (204)`, and `status` then read `{"scan": true, "taskState": "Running", "lastScan": null, "recentMedia": 0, "scannedAfter": false}` with exit 1 and *"a refresh is still owed"* — that is the never-completed `EndTimeUtc` signature the page describes, now surfaced instead of guessed at. Before the trigger, Plex `status --library 1 --after <journal mtime>` had returned the same verdict from the database, and `media-verify.py` replayed `promo-hold.jsonl` against both databases, finding the same stale rows in each — the rule that says a rescan is owed. - The end-to-end chain (Jellyfin to quiet, then Plex section 1, then the journal replay against both DBs) is running on the host; its log will be posted to this PR as a comment when it finishes. Not deployed by this PR: the drivers are new files; nothing in the deploy plan touches a running service. --- cwd: /home/blank/.omp/wt/wt-20261030-163847-6d1fc1f · session: 01a18f2e-18b1-7573-bcd5-ac99636f8bcb (committed and tested in the build worktree `/tmp/wt-deploy`)
dank added 2 commits 2026-11-04 11:29:10 -08:00
Both media servers' scan procedure lived only as a hand-written curl block in
the vault, and that block has bitten this fleet twice: the escaped-quote form
that reads exactly like a dead API key, and a config `url` line meeting a
command-line URL so curl quietly performs a second transfer and dumps the whole
task table on your stdout. Jellyfin's door is now
`scripts/media-refresh-jellyfin.py` (trigger / status / wait) and Plex's is
`scripts/media-refresh-plex.sh` (trigger / status);
`tests/media_refresh.bats` pins the contracts an operator observes, against a
stub and with no live server.

The Jellyfin `wait` encodes the settle law instead of a poll count: the Scan
Media Library task must be seen Running — or finish while watching, or already
pass the `--after` it is given — before any quiet read counts toward settled.
A watcher that saw no Running otherwise reports the pre-scan library as the
fresh one, which is how a rename looks applied while nothing has rescanned.
Plex's `status` needs no token at all: `section_locations.scanned_at` carries
the same epoch the API prints as `scannedAt` (measured on section 1 today).

Neither driver lets a secret reach argv or a URL: the Jellyfin key rides a
mode-600 curl config as the quoted MediaBrowser form, and the Plex token is
read inside the container, where the HTTPD actually answers, into a config file
that is shredded there.
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.
dank added 1 commit 2026-11-04 11:30:07 -08:00
Author
Owner

Reconcile chain ran on the live fleet, 2026-11-04 19:17:51Z → 19:46:59Z, after the promo-clip hold (32 renames) landed in the SSD-tier view. Durable log: /mnt/entropy/.media-campaign/rescan-20261104.log.

  • scripts/media-refresh-jellyfin.py trigger → 204 accepted; … wait --interval 20 --require-quiet 3 --timeout 5400 polled ~28 min of scan (scan: true, recentMedia: 0 per poll), then scan: false plus three quiet reads → exit 0.
  • scripts/media-refresh-plex.sh trigger --library 1 → 200 accepted; … status --library 1 --after <journal mtime> exited 0 in the same second, so that wait was a no-op.
  • scripts/media-verify.py against both databases: still at src: 0 and REAL: 0. That is the purge proof for the promo-clip hold, and it holds on Jellyfin and Plex.

Two traps this chain exposed. Both of my earlier drafts of this comment were wrong, so the corrections are in the record:

  1. Driver names. The merged drivers are scripts/media-refresh-jellyfin.py and scripts/media-refresh-plex.sh. The first draft named jellyfin-refresh.py / plex-refresh.py, which do not exist, and claimed the Plex door never polls.
  2. Plex's status --after is not a waiter, and no epoch makes it one. It compares section_locations.scanned_at — the last scan PMS recorded — with --after. Measured: trigger accepted 19:46:58Z → settled 19:46:58Z, and a direct read at 20:17Z still answered settled off that single stamp while the walk ran. A journal mtime fails, and so does the trigger's own epoch: the request satisfies the very door it was asked to wait for. The purge conclusion is untouched — 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. Jellyfin's door is honest: its EndTimeUtc advances only at completion, and wait refuses by name an epoch an earlier task already passes.

Laws recorded on Plex and Jellyfin (main 146c28d).

Reconcile chain ran on the live fleet, 2026-11-04 19:17:51Z → 19:46:59Z, after the promo-clip hold (32 renames) landed in the SSD-tier view. Durable log: `/mnt/entropy/.media-campaign/rescan-20261104.log`. * `scripts/media-refresh-jellyfin.py trigger` → 204 accepted; `… wait --interval 20 --require-quiet 3 --timeout 5400` polled ~28 min of scan (`scan: true`, `recentMedia: 0` per poll), then `scan: false` plus three quiet reads → exit 0. * `scripts/media-refresh-plex.sh trigger --library 1` → 200 accepted; `… status --library 1 --after <journal mtime>` exited 0 **in the same second**, so that wait was a no-op. * `scripts/media-verify.py` against both databases: `still at src: 0` and `REAL: 0`. That is the purge proof for the promo-clip hold, and it holds on Jellyfin *and* Plex. Two traps this chain exposed. Both of my earlier drafts of this comment were wrong, so the corrections are in the record: 1. **Driver names.** The merged drivers are `scripts/media-refresh-jellyfin.py` and `scripts/media-refresh-plex.sh`. The first draft named `jellyfin-refresh.py` / `plex-refresh.py`, which do not exist, and claimed the Plex door never polls. 2. **Plex's `status --after` is not a waiter, and no epoch makes it one.** It compares `section_locations.scanned_at` — the last scan PMS *recorded* — with `--after`. Measured: trigger accepted 19:46:58Z → `settled` 19:46:58Z, and a direct read at 20:17Z still answered `settled` off that single stamp while the walk ran. A journal mtime fails, and so does the trigger's own epoch: the request satisfies the very door it was asked to wait for. The purge conclusion is untouched — `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. Jellyfin's door is honest: its `EndTimeUtc` advances only at completion, and `wait` refuses by name an epoch an earlier task already passes. Laws recorded on [[Plex]] and [[Jellyfin]] (`main` 146c28d).
dank added 2 commits 2026-11-04 11:59:00 -08:00
dank merged commit 0161ab2aa8 into main 2026-11-04 12:15:38 -08:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: dank/serverAppConfigs#140