Merge pull request 'Media refresh drivers: the two scan doors become commands' (#140) from feat/media-refresh-drivers into main

This commit was merged in pull request #140.
This commit is contained in:
dank committed 2026-11-04 12:15:38 -08:00
commit 0161ab2aa8
7 files changed
+785 -59

No files matched your search

+23 -20
View File
@@ -309,33 +309,36 @@ scripts/jellyfin-providers.sh verify # plugin + key LENGTHS, never key bytes
## Triggering a scan
The scan key lives in `~/.config/secrets/jellyfin` (mode 600; the same value is
`JELLYFIN_API_KEY` in the ignored `dankserver/.env`). It rides a mode-600 curl
config, never a header argument, in the `MediaBrowser` form: 12.4 authenticates
only `Authorization: MediaBrowser Token="…"` and refuses a bare `X-Emby-Token`
for every key, valid or not. Before believing a 401, prove the key against the
server's own table — `select count(*) from ApiKeys where AccessToken = ?` on
`jellyfin.db` opened `mode=ro&immutable=1`. The backslashes below matter:
Run the driver from the deploy checkout: `scripts/media-refresh-jellyfin.py
trigger`, then `… wait`. It calls the library settled only on the rule below — the
task seen `Running` (or a run that finished while it watched) first, then
`--require-quiet` reads with no scan and no writes — and refuses by name when the
scan never started, because a watcher that saw no `Running` would otherwise report
the pre-scan library as the fresh one. `… status --after $(stat -c %Y <journal>)`
is the "has the scan reached my moves" answer, read from the task's `EndTimeUtc`.
```bash
umask 077
{ printf 'header = "Authorization: MediaBrowser Token=\\"%s\\""\n' \
"$(cat ~/.config/secrets/jellyfin)"
echo 'url = "http://127.0.0.1:8096/Library/Refresh"'
echo 'request = "POST"'; } >/tmp/jf.hdr
curl -s -K /tmp/jf.hdr -o /dev/null -w "jellyfin scan: %{http_code}\n"
shred -u /tmp/jf.hdr
```
It carries every law below. Hand-written, the door is a `curl -K` on a mode-600
config: the scan key lives in `~/.config/secrets/jellyfin` (mode 600; the same
value is `JELLYFIN_API_KEY` in the ignored `dankserver/.env`) and rides as
`Authorization: MediaBrowser Token="…"` — 12.4 refuses a bare `X-Emby-Token` for
every key, valid or not — never as a header argument, because
`/proc/<pid>/cmdline` is world-readable. `recentMedia` counts
`BaseItems.DateModified` rows in `jellyfin.db`, which on this host sits in the
quadlet's config bind at `/home/dank/Containers/jellyfin/config/data/`, not the
stock `~/.local/share/jellyfin`.
`204` is acceptance; anything else queued no scan, so re-issue the identical
request instead of diagnosing. A `401` from a key that answers `200` on a
hand-typed request is this block's quoting, not the server: bash `printf`
collapses `\"` to `"`, curl ends the quoted value at that quote, and Jellyfin
receives `Authorization: MediaBrowser Token=` with nothing after it (`\\"` answers
204, `\"` answers 401 — both run on this host).
Reading the progress with the same config trick takes **one** URL: a config `url`
line *plus* a command-line URL makes curl perform both transfers, and the second
dumps the whole `ScheduledTasks` body onto your stdout.
204, `\"` answers 401 — both run on this host). Before believing a 401, prove the
key against the server's own table — `select count(*) from ApiKeys where
AccessToken = ?` on `jellyfin.db` opened plain `mode=ro` (`immutable=1` only as
the fallback for a database whose `-shm` cannot be read; it skips the write-ahead
log, see [[Media Relayout]]). Reading progress with the same config trick takes
**one** URL: a config `url` line *plus* a command-line URL makes curl perform both
transfers, and the second dumps the whole `ScheduledTasks` body onto your stdout.
One accepted refresh is the whole procedure. A second one while a scan runs moves
`Scan Media Library` to `Cancelling`, and once the runner holds `Cancelling` every
@@ -151,6 +151,18 @@ Each of these is a file that would have been destroyed without it:
- Media containers only. Grouping every extension buries the report in subtitle
and cover pairs — and a duration guard that appears to fire on 720 groups is
counting subtitles, not protecting films.
- **A short file named after a film is promotion, not a movie.** Thirty-two files
in `Movies` — 31 clips and the `.nfo` filed beside one of them — carried real
titles (`The Descent (2005) - SD.mkv`) and were 8–20-second h264 video at
640×360, 1024×576 or 1280×720, 49 MiB all together —
25 of them in a `_release-group watermarks/` folder and the rest at the library
root or inside one film's folder. No duplicate screen ever catches them: nothing
shares their size, and the feature-length guard above makes them invisible to the
similar-title pass too. They were held as `.AGENTTRASH/20261104-promo-clips`
(journal `/mnt/entropy/.media-campaign/promo-hold.jsonl`, which survives) and purged
the same evening, once both databases read `REAL: 0` against that journal.
The census that finds the next batch is a duration floor, not a hash: a video under
a minute on a name that reads like a feature is advertising.
## The hold
@@ -99,12 +99,17 @@ interrupted apply still replays the batch and resumes with nothing lost. Batch 1
is the old behaviour; the only reason to name it is debugging, because on the
spinning array it spends most of a large apply's wall time in fsyncs.
After it lands, both servers need a scan or the change is invisible. [[Plex]] and
[[Jellyfin]] own the exact requests; two laws hold at this step: `204` is
acceptance and anything else queued nothing — re-issue the identical request
rather than diagnosing — and ONE accepted refresh is the whole procedure, because
a second while a scan runs moves `Scan Media Library` to `Cancelling` and every
later `204` is accepted and then discarded.
After it lands, both servers need a scan or the change is invisible. The two
drivers are the doors — `scripts/media-refresh-jellyfin.py trigger` then `… wait`,
and only after it settles, `scripts/media-refresh-plex.sh trigger --library <key>`
with `… status --library <key> --after $(stat -c %Y <journal>)` as the poll. Run
them in that order and never together: Jellyfin's only door walks every library,
every Plex section forks its own scanner walk, and the array is the bottleneck
both servers share. [[Plex]] and [[Jellyfin]] own the laws behind the drivers; two
of them decide this step: `204` is acceptance and anything else queued nothing —
re-issue the identical request rather than diagnosing — and ONE accepted refresh is
the whole procedure, because a second while a scan runs moves `Scan Media Library`
to `Cancelling` and every later `204` is accepted and then discarded.
### Proving the move landed
+29 -33
View File
@@ -106,43 +106,39 @@ root runs as the `dank` user, and the deployer adopts the data root with
## 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.
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
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
scripts/media-refresh-plex.sh trigger --library 1
scripts/media-refresh-plex.sh status --library 1 --after "$(stat -c %Y <journal>)"
```
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.
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