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
7 changed files with 785 additions and 59 deletions

No files matched your search

+222
View File
@@ -0,0 +1,222 @@
#!/usr/bin/env python3
"""Trigger and poll Jellyfin's library scan — the step that makes a media move visible.
media-refresh-jellyfin.py trigger [--force]
media-refresh-jellyfin.py status [--after EPOCH]
media-refresh-jellyfin.py wait [--after EPOCH] [--interval S] [--require-quiet N]
`status` answers {"scan": true, "taskState": "Running", "recentMedia": 17}: the
Scan Media Library row plus the rows the database has taken in recently; with
`--after` (a journal mtime) it also answers `scannedAfter` and exits non-zero
while the last finished scan predates it. `wait` calls the library settled only
on the page's law: a sighting of the task Running — or a run that finished while
it watched, or a `--after` the task already passes — and THEN `--require-quiet`
consecutive reads with no scan and no writes. Both the scan and the metadata
fetch must be quiet, and the Running sighting is what stops a watcher from
declaring a pre-scan library settled (Dankserver/Jellyfin.md).
Laws this encodes:
* Auth is `Authorization: MediaBrowser Token="…"`. A bare X-Emby-Token answers
401 (measured on franksdankcloud 12.4.0), which reads exactly like a dead
key. The key comes from a mode-600 file and never rides argv or a URL:
/proc/<pid>/cmdline is world-readable.
* `POST /Library/Refresh` (204) is the ONLY door that queues work on 12.4, and
it walks every library. `POST /Items/{id}/Refresh` answers 204 and queues
NOTHING (measured twice, 2026-11-02) — so there is no --library here.
* A SECOND accepted POST cancels and restarts the scan already running, so
progress looks frozen while you hammer it. `trigger` therefore refuses while
the Scan Media Library task is not Idle; --force says you want the restart.
* 429 means busy. Waiting is the only response that helps, and it is bounded
so a wedged server ends the run with a named failure instead of a loop.
* `RefreshStatus` on /Library/VirtualFolders stays Idle through a live scan;
never poll it. The task table is the only honest read.
Defaults are this fleet's layout: http://127.0.0.1:8096, the key at
~/.config/secrets/jellyfin, and the database found by trying, in order, $JELLYFIN_DB,
~/.local/share/jellyfin/jellyfin.db and the quadlet's config bind.
"""
import argparse
import json
import os
import pathlib
import sqlite3
import sys
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
SCAN_TASK = "Scan Media Library"
IDLE = ("Idle",) # Running, Cancelling and Waiting all mean: not settled
DB_CANDIDATES = ("~/.local/share/jellyfin/jellyfin.db",
"/home/dank/Containers/jellyfin/config/data/jellyfin.db")
def _epoch(iso: str | None) -> float | None:
"""Jellyfin's `2026-11-04T18:31:33Z` EndTimeUtc as a unix epoch, or None."""
if not iso:
return None
for fmt in ("%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ"):
try:
return datetime.strptime(iso, fmt).replace(tzinfo=timezone.utc).timestamp()
except ValueError:
continue
sys.exit(f"the server sent an unparsable EndTimeUtc: {iso!r}")
def read_key(value: str) -> str:
path = pathlib.Path(value).expanduser()
try:
token = path.read_text().strip()
except OSError as exc:
sys.exit(f"no Jellyfin API key at {path} ({exc.strerror}); "
"that file is the credential this driver authenticates with")
if not token:
sys.exit(f"Jellyfin API key file {path} is empty")
return token
def request(base: str, token: str, method: str, path: str,
timeout: float = 30.0) -> tuple[int, bytes]:
req = urllib.request.Request(
base.rstrip("/") + path, method=method,
headers={"Authorization": f'MediaBrowser Token="{token}"'})
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return resp.status, resp.read()
except urllib.error.HTTPError as exc:
return exc.code, exc.read()
def scan_task(base: str, token: str) -> dict:
"""The one task row that means something, and when its last run finished."""
code, body = request(base, token, "GET", "/ScheduledTasks")
if code != 200:
sys.exit(f"GET /ScheduledTasks -> HTTP {code}: {body[:200]!r}")
for task in json.loads(body):
if task.get("Name") == SCAN_TASK:
state = task.get("State")
return {"state": state, "running": state not in IDLE,
"lastScan": _epoch(task.get("EndTimeUtc"))}
sys.exit(f"the server does not list a {SCAN_TASK!r} task; refusing to guess")
def find_db(value: str) -> pathlib.Path:
wanted = [value] if value != "-" else []
wanted += [p for p in (os.environ.get("JELLYFIN_DB"),) if p]
wanted += list(DB_CANDIDATES)
for candidate in wanted:
path = pathlib.Path(candidate).expanduser()
if path.is_file():
return path
sys.exit("no Jellyfin database found; tried: " + ", ".join(wanted))
def recent_media(db: str, window: int) -> int:
since = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(time.time() - window * 60))
con = sqlite3.connect(f"file:{find_db(db)}?mode=ro", uri=True)
try:
return con.execute(
"SELECT COUNT(*) FROM BaseItems WHERE DateModified >= ?", (since,)
).fetchone()[0]
finally:
con.close()
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("command", choices=["trigger", "status", "wait"])
ap.add_argument("--url", default="http://127.0.0.1:8096")
ap.add_argument("--key-file", default="~/.config/secrets/jellyfin")
ap.add_argument("--db", default="-", help="jellyfin.db; - searches the known homes")
ap.add_argument("--window", type=int, default=5,
help="minutes of DB writes that still count as recent")
ap.add_argument("--retries", type=int, default=3, help="extra attempts against a 429")
ap.add_argument("--retry-sleep", type=float, default=20.0)
ap.add_argument("--force", action="store_true",
help="trigger even though a scan is already running")
ap.add_argument("--interval", type=float, default=12.0, help="wait: seconds between reads")
ap.add_argument("--require-quiet", type=int, default=3,
help="wait: consecutive quiet reads before the library counts as settled")
ap.add_argument("--after", type=float, default=None,
help="status/wait: an epoch (a journal mtime) the scan must have "
"finished after; status exits non-zero while it has not")
ap.add_argument("--start-window", type=float, default=120.0,
help="wait: seconds to allow the scan to start before reporting "
"that the 204 queued nothing")
ap.add_argument("--timeout", type=float, default=1800.0, help="wait: seconds before giving up")
args = ap.parse_args()
token = read_key(args.key_file)
def state() -> dict:
task = scan_task(args.url, token)
out = {"scan": task["running"], "taskState": task["state"],
"lastScan": task["lastScan"],
"recentMedia": recent_media(args.db, args.window)}
if args.after is not None:
out["scannedAfter"] = bool(task["lastScan"] and task["lastScan"] >= args.after)
return out
if args.command == "status":
current = state()
print(json.dumps(current))
if args.after is not None and not current["scannedAfter"]:
sys.exit(f"the last scan finished before {args.after:.0f}; "
"a refresh is still owed")
return
if args.command == "wait":
start = time.monotonic()
ended_at_entry, quiet, started, first = None, 0, False, True
while True:
current = state()
if first:
first, ended_at_entry = False, current["lastScan"]
if (current["scan"]
or (current["lastScan"] and current["lastScan"] != ended_at_entry)
or current.get("scannedAfter")):
# The settle law: a Running sighting (or a run that finished while
# watching, or a --after the task already passes) must come BEFORE
# any claim that the library is quiet.
started = True
busy = current["scan"] or current["recentMedia"]
quiet = quiet + 1 if started and not busy else 0
print(json.dumps(current | {"quiet": quiet}), flush=True)
if quiet >= args.require_quiet:
return
elapsed = time.monotonic() - start
if not started and elapsed >= args.start_window:
sys.exit(f"{SCAN_TASK} never started: it read {current['taskState']!r} "
f"for {elapsed:.0f}s and the database took no writes. Either "
"the POST queued nothing — a POST that arrives while a scan "
"is already running cancels it, and every later 204 is "
"accepted and discarded, so restart the service and trigger "
"once — or the scan finished before this watch began, in "
"which case ask with --after <journal mtime> instead.")
if elapsed >= args.timeout:
sys.exit(f"still busy after {args.timeout:.0f}s: {current}")
time.sleep(args.interval)
if scan_task(args.url, token)["running"] and not args.force:
sys.exit(f"{SCAN_TASK} is already running on {args.url}: a second "
"POST /Library/Refresh cancels and restarts it, so the index "
"looks frozen while you wait. Poll `wait` instead of triggering "
"again (--force overrides).")
for attempt in range(args.retries + 1):
code, body = request(args.url, token, "POST", "/Library/Refresh")
if code == 204:
print("refresh accepted (204); the scan walks every library")
return
if code == 429:
if attempt < args.retries:
time.sleep(args.retry_sleep)
continue
sys.exit(f"Jellyfin is busy: {args.retries + 1} attempts all answered "
"429. Nothing was queued; run `wait`, then trigger again.")
sys.exit(f"POST /Library/Refresh -> HTTP {code}: {body[:200]!r}")
if __name__ == "__main__":
main()
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env bash
# Trigger and poll ONE Plex library section — the step that makes a media move visible.
#
# media-refresh-plex.sh trigger --library KEY
# media-refresh-plex.sh status --library KEY [--after EPOCH]
#
# One section only: refreshing all five costs a full walk of each and buys
# nothing for a rename that touched one tree. And a rename on the array is
# invisible until the section is rescanned — the server is bound at the SSD
# metadata view, so an array-side move is not an event its watcher ever sees.
#
# Two host realities shape this script:
# * PMS's HTTPD closes without a single byte on a request that arrives from
# another netns (the host and the 10.89.x.x container address both read as
# "empty reply"), and it serves TLS to remote clients. So the request rides
# plain http://127.0.0.1:32400 INSIDE the container, shipped in as a file —
# a three-layer ssh/podman/sh quoting chain only produces parse errors.
# * The token is `PlexOnlineToken` in the container's Preferences.xml (not
# PlexHTTPToken, which a claimed server may not carry). It is read inside
# the container and used from a curl config there: never argv (any process
# on the host reads /proc/<pid>/cmdline), never a URL (proxies log URLs).
#
# Progress needs no token and no container: `section_locations.scanned_at` in
# the library database is the same epoch the API prints as `scannedAt`
# (verified 2026-11-04), so `status` reads the database and compares it against
# --after. `trigger` answering 000 means PMS is still warming — retry, never
# restart the service over it. Proof that a rename actually landed is
# scripts/media-verify.py replaying the journal; this script only moves the
# scanner along.
set -euo pipefail
CONTAINER=plex
URL="http://127.0.0.1:32400"
PREFS='/config/Library/Application Support/Plex Media Server/Preferences.xml'
DB="/var/lib/plexmediaserver/Library/Application Support/Plex Media Server/Plug-in Support/Databases/com.plexapp.plugins.library.db"
SECTION=""
AFTER=""
TRIES=6
WARM_SLEEP=10
die() { echo "$*" >&2; exit 1; }
CMD="${1:-}"; shift || true
case "$CMD" in
trigger|status) ;;
*) die "usage: $(basename "$0") trigger|status --library KEY [--after EPOCH] [--container NAME] [--db PATH]" ;;
esac
while [ $# -gt 0 ]; do
case "$1" in
--library) SECTION="${2:-}"; shift 2 ;;
--after) AFTER="${2:-}"; shift 2 ;;
--tries) TRIES="${2:-}"; shift 2 ;;
--warm-sleep) WARM_SLEEP="${2:-}"; shift 2 ;;
--container) CONTAINER="${2:-}"; shift 2 ;;
--url) URL="${2:-}"; shift 2 ;;
--prefs) PREFS="${2:-}"; shift 2 ;;
--db) DB="${2:-}"; shift 2 ;;
*) die "unknown argument: $1" ;;
esac
done
[ -n "$SECTION" ] || die "--library is required (the section id from GET /library/sections; Movies is 1)"
command -v python3 >/dev/null || die "python3 is required to read the library database"
if [ "$CMD" = "status" ]; then
[ -r "$DB" ] || die "no readable Plex library database at $DB"
python3 - "$DB" "$SECTION" "$AFTER" <<'PY'
import datetime, sqlite3, sys
db, section, after = sys.argv[1], int(sys.argv[2]), sys.argv[3]
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
rows = con.execute(
"SELECT root_path, available, scanned_at FROM section_locations "
"WHERE library_section_id = ? ORDER BY id", (section,)).fetchall()
if not rows:
sys.exit(f"section {section} has no location in {db}; is the id right?")
worst = min(r[2] or 0 for r in rows)
for root, available, at in rows:
iso = (datetime.datetime.fromtimestamp(at, datetime.UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
if at else "never")
print(f" available={available} scanned_at={iso} {root}")
print(f"section={section} scanned_at={worst}")
if after:
if worst < int(after):
sys.exit(f"section {section} was last scanned before {after}; a refresh is still owed")
PY
exit 0
fi
command -v podman >/dev/null || die "podman is required to reach PMS inside its container"
INNER="$(mktemp -t pxscan.XXXXXX)"
trap 'rm -f "$INNER"' EXIT
cat > "$INNER" <<'EOS'
# Runs INSIDE the plex container: $1 section key, $2 base URL, $3 Preferences.xml.
key="$1"; url="$2"; prefs="$3"
[ -r "$prefs" ] || { echo "no readable $prefs inside the container" >&2; exit 3; }
T=$(sed -n 's/.*PlexOnlineToken="\([^"]*\)".*/\1/p' "$prefs" | head -1)
[ -n "$T" ] || { echo "no PlexOnlineToken in $prefs" >&2; exit 3; }
cfg=$(mktemp)
( umask 077
{ printf 'header = "X-Plex-Token: %s"\n' "$T"
printf 'silent\nshow-error\nrequest = "POST"\n'
printf 'url = "%s/library/sections/%s/refresh"\n' "$url" "$key"; } > "$cfg" )
code=$(curl -K "$cfg" -o /dev/null -w '%{http_code}' || true)
shred -u "$cfg" 2>/dev/null || rm -f "$cfg"
[ "$code" = "401" ] && { echo "section $key: HTTP 401 — the token in $prefs is not accepted" >&2; exit 4; }
echo "$code"
EOS
attempt=1
while :; do
podman cp "$INNER" "$CONTAINER:/tmp/media-refresh-plex.sh" \
|| die "could not copy the runner into container $CONTAINER"
code="$(podman exec "$CONTAINER" sh /tmp/media-refresh-plex.sh "$SECTION" "$URL" "$PREFS" || true)"
podman exec "$CONTAINER" rm -f /tmp/media-refresh-plex.sh >/dev/null 2>&1 || true
case "$code" in
204|200) echo "refresh accepted ($code) for section $SECTION"; exit 0 ;;
000)
[ "$attempt" -ge "$TRIES" ] && die "PMS never answered after $TRIES attempts (000); it may still be warming — do not restart the service over it"
echo "section $SECTION: no answer yet ($attempt/$TRIES), PMS is warming" >&2 ;;
*) die "refresh of section $SECTION failed: HTTP ${code:-curl-error}" ;;
esac
attempt=$((attempt + 1))
sleep "$WARM_SLEEP"
done
+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
+361
View File
@@ -0,0 +1,361 @@
#!/usr/bin/env bats
# Seam: scripts/media-refresh-jellyfin.py + scripts/media-refresh-plex.sh —
# the two library-refresh drivers every media pass ends with.
#
# The rescan is the part of a media move that actually makes it visible, and
# both servers punish the naive version of it. Jellyfin 12.4: the quoted
# MediaBrowser header is the only auth form; POST /Library/Refresh is the only
# door that queues work; a SECOND accepted POST cancels the scan already
# running, so the index looks frozen while you hammer it; 429 means wait. Plex:
# its HTTPD answers nobody outside its own netns and speaks TLS to everyone who
# is inside the LAN, so the request has to be shipped INTO the container; the
# token is PlexOnlineToken in the container's Preferences.xml; and progress is
# readable without any of that, from section_locations.scanned_at. This seam
# pins, against stubs that speak those APIs:
# 1. the Jellyfin driver authenticates as `Authorization: MediaBrowser
# Token="…"`, and an auth refusal ends the run naming the code;
# 2. a trigger while Scan Media Library is running is REFUSED with no POST at
# all (the cancel-restart trap); --force overrides;
# 3. a 429 is waited out with a bounded number of attempts, never hammered,
# and a still-busy server ends in a non-zero exit that says 429;
# 4. `status` prints {"scan": …, "recentMedia": …} from the task table plus a
# fixture DB, and `wait` returns only after N consecutive quiet reads;
# 5. the Plex driver carries the token from the container's Preferences.xml
# into a header — never argv, never a URL;
# 6. a Plex trigger that gets no answer is a warming server: retried, bounded,
# and reported as 000 rather than a service restart;
# 7. Plex `status` answers from section_locations.scanned_at and is exit-0
# only when that epoch has passed --after;
# 8. a missing credential or database is refused by NAME before any request.
load helpers
setup() {
JF="$REPO_ROOT/scripts/media-refresh-jellyfin.py"
PX="$REPO_ROOT/scripts/media-refresh-plex.sh"
TMP="$(mktemp -d)"
PORT=18947
printf 'stubkey\n' > "$TMP/jf-key"
printf '<Preferences PlexOnlineToken="stubplextoken"/>\n' > "$TMP/prefs.xml"
echo Idle > "$TMP/scan-state"
echo ok > "$TMP/refresh-mode"
echo 0 > "$TMP/polls"
mkdir -p "$TMP/bin" "$TMP/container"
# A podman that is really a local directory: the shim runs the runner the
# driver copied in, so the in-container half is exercised for real.
cat > "$TMP/bin/podman" <<'SH'
#!/usr/bin/env bash
echo "podman $*" >> "$PODMAN_LOG"
case "$1" in
cp) cp "$2" "$CONTAINER_FS/$(basename "$3" | sed 's#.*:##')" ;;
exec) shift; [ "$1" = "$CONTAINER" ] || exit 1; shift
[ "$1" = rm ] && exit 0
[ "$1" = sh ] || exit 1; shift
script="$1"; shift
sh "$CONTAINER_FS/$(basename "$script")" "$@" ;;
*) exit 1 ;;
esac
SH
chmod +x "$TMP/bin/podman"
cat > "$TMP/stub.py" <<'PY'
import json, os, sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
port, logf = int(sys.argv[1]), sys.argv[2]
def read(var, default=""):
# Scenarios are flipped by rewriting a file; the env var names that file.
try:
return open(os.environ.get(var, "")).read().strip()
except OSError:
return default
def log(line):
with open(logf, "a") as f:
f.write(line + "\n")
class H(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def send(self, code, body, as_json=False):
data = json.dumps(body).encode() if as_json else str(body).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json" if as_json else "text/xml")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def jf_authed(self):
# Jellyfin 12.4 accepts the quoted MediaBrowser form ONLY: a bare
# X-Emby-Token reads as unauthenticated here, exactly as it does live.
return self.headers.get("Authorization") == 'MediaBrowser Token="stubkey"'
def do_GET(self):
path = self.path.split("?")[0]
log("GET %s auth=%s" % (self.path, "yes" if self.headers.get("Authorization") else "no"))
if path == "/System/Info":
if self.jf_authed():
return self.send(200, {"ServerName": "stub", "Version": "12.4.0"}, True)
return self.send(401, "Unauthorized")
if path == "/ScheduledTasks":
with open(os.environ["POLL"]) as f:
n = int(f.read().strip() or 0)
with open(os.environ["POLL"], "w") as f:
f.write(str(n + 1))
state = read("SCAN", "Idle")
flip = read("FLIP", "")
if flip and n + 1 >= int(flip):
state = "Idle"
return self.send(200, [{"Name": "Audio Normalization", "State": "Running"},
{"Name": "Scan Media Library", "State": state,
"EndTimeUtc": read("ENDED", "") or None}], True)
return self.send(404, "nope")
def do_POST(self):
path = self.path.split("?")[0]
log("POST %s auth=%s" % (self.path, "yes" if self.headers.get("Authorization") else "no"))
if path == "/Library/Refresh":
if not self.jf_authed():
return self.send(401, "Unauthorized")
if read("MODE", "ok") == "busy":
return self.send(429, "too many requests")
return self.send(204, "")
if path.endswith("/library/refresh") or "/library/sections/" in path:
if self.headers.get("X-Plex-Token") != "stubplextoken":
return self.send(401, "Unauthorized")
return self.send(204, "")
return self.send(404, "nope")
ThreadingHTTPServer(("127.0.0.1", port), H).serve_forever()
PY
export SCAN="$TMP/scan-state" MODE="$TMP/refresh-mode" POLL="$TMP/polls"
export FLIP="$TMP/flip-after"
export ENDED="$TMP/scan-ended"
export PODMAN_LOG="$TMP/podman.log" CONTAINER_FS="$TMP/container" CONTAINER=plex
: > "$PODMAN_LOG"
python3 "$TMP/stub.py" "$PORT" "$TMP/requests.log" > "$TMP/stub.out" 2>&1 &
STUB_PID=$!
for _ in $(seq 1 50); do
curl -s -o /dev/null "http://127.0.0.1:$PORT/System/Info" && break
sleep 0.1
done
}
teardown() {
[ -n "${STUB_PID:-}" ] && kill "$STUB_PID" 2>/dev/null || true
rm -rf "$TMP"
}
plex_db() { # plex_db <path> <scanned_at epoch>
python3 - "$1" "$2" <<'PY'
import sqlite3, sys
db = sqlite3.connect(sys.argv[1])
db.execute("CREATE TABLE section_locations (id INTEGER PRIMARY KEY, library_section_id INT,"
" root_path TEXT, available INT, scanned_at INT)")
db.execute("INSERT INTO section_locations VALUES (1, 1, '/mnt/entropy/Movies', 1, ?)",
(int(sys.argv[2]),))
db.commit()
PY
}
# ---------------------------------------------------------------- jellyfin
@test "the jellyfin driver authenticates as MediaBrowser Token, never argv" {
run python3 "$JF" trigger --url "http://127.0.0.1:$PORT" --key-file "$TMP/jf-key"
[ "$status" -eq 0 ]
grep -q '^POST /Library/Refresh' "$TMP/requests.log"
! grep -q "stubkey" "$TMP/requests.log"
}
@test "a server that rejects the auth form is a failure, not a success" {
# The stub 401s anything but the quoted form; the driver must report it
# rather than exit 0 — a green trigger over a 401 is a lie.
printf 'wrongkey\n' > "$TMP/bad-key"
run python3 "$JF" trigger --url "http://127.0.0.1:$PORT" --key-file "$TMP/bad-key"
[ "$status" -ne 0 ]
[[ "$output" == *"401"* ]]
}
@test "a trigger during a running scan is refused with no POST at all" {
echo Running > "$TMP/scan-state"
run python3 "$JF" trigger --url "http://127.0.0.1:$PORT" --key-file "$TMP/jf-key"
[ "$status" -ne 0 ]
[[ "$output" == *"running"* ]]
! grep -q '^POST /Library/Refresh' "$TMP/requests.log"
}
@test "--force triggers anyway while a scan runs" {
echo Running > "$TMP/scan-state"
run python3 "$JF" trigger --url "http://127.0.0.1:$PORT" --key-file "$TMP/jf-key" --force
[ "$status" -eq 0 ]
grep -q '^POST /Library/Refresh' "$TMP/requests.log"
}
@test "a busy server is waited out with a bounded number of attempts" {
echo busy > "$TMP/refresh-mode"
run python3 "$JF" trigger --url "http://127.0.0.1:$PORT" --key-file "$TMP/jf-key" \
--retries 2 --retry-sleep 0.1
[ "$status" -ne 0 ]
[[ "$output" == *"429"* ]]
posts=$(grep -c '^POST /Library/Refresh' "$TMP/requests.log")
[ "$posts" -le 3 ]
}
@test "status reports the scan task and recent media writes" {
python3 - "$TMP/jellyfin.db" <<'PY'
import sqlite3, sys, time
db = sqlite3.connect(sys.argv[1])
db.execute("CREATE TABLE BaseItems (Id TEXT, DateModified TEXT)")
now = int(time.time())
for i in range(3):
db.execute("INSERT INTO BaseItems VALUES (?, datetime(?, 'unixepoch'))", (str(i), now - 30))
db.execute("INSERT INTO BaseItems VALUES ('old', datetime(?, 'unixepoch'))", (now - 86400,))
db.commit()
PY
run python3 "$JF" status --url "http://127.0.0.1:$PORT" --key-file "$TMP/jf-key" \
--db "$TMP/jellyfin.db" --window 5
[ "$status" -eq 0 ]
python3 -c 'import json,sys; d=json.loads(sys.argv[1]); assert d["scan"] is False, d; assert d["recentMedia"] == 3, d' "$output"
echo Running > "$TMP/scan-state"
run python3 "$JF" status --url "http://127.0.0.1:$PORT" --key-file "$TMP/jf-key" \
--db "$TMP/jellyfin.db" --window 5
python3 -c 'import json,sys; assert json.loads(sys.argv[1])["scan"] is True' "$output"
}
@test "wait returns only after the scan reads quiet repeatedly" {
python3 - "$TMP/jellyfin.db" <<'PY'
import sqlite3, sys
db = sqlite3.connect(sys.argv[1])
db.execute("CREATE TABLE BaseItems (Id TEXT, DateModified TEXT)")
db.commit()
PY
echo Running > "$TMP/scan-state"
echo 4 > "$TMP/flip-after"
run python3 "$JF" wait --url "http://127.0.0.1:$PORT" --key-file "$TMP/jf-key" \
--db "$TMP/jellyfin.db" --interval 0 --require-quiet 2 --timeout 30
[ "$status" -eq 0 ]
[[ "${lines[-1]}" == *'"quiet": 2'* ]]
# every read before poll 4 was busy, so no earlier line may claim quiet
[ "$(printf '%s\n' "$output" | grep -c '"quiet": 0')" -ge 3 ]
}
@test "wait gives up out loud when the library never settles" {
python3 - "$TMP/jellyfin.db" <<'PY'
import sqlite3, sys
db = sqlite3.connect(sys.argv[1])
db.execute("CREATE TABLE BaseItems (Id TEXT, DateModified TEXT)")
db.commit()
PY
echo Running > "$TMP/scan-state"
run python3 "$JF" wait --url "http://127.0.0.1:$PORT" --key-file "$TMP/jf-key" \
--db "$TMP/jellyfin.db" --interval 0 --timeout 0.2
[ "$status" -ne 0 ]
[[ "$output" == *"still busy"* ]]
}
@test "wait refuses a scan that never started instead of calling the library settled" {
python3 - "$TMP/jellyfin.db" <<'PY'
import sqlite3, sys
db = sqlite3.connect(sys.argv[1])
db.execute("CREATE TABLE BaseItems (Id TEXT, DateModified TEXT)")
db.commit()
PY
echo Idle > "$TMP/scan-state"
run python3 "$JF" wait --url "http://127.0.0.1:$PORT" --key-file "$TMP/jf-key" \
--db "$TMP/jellyfin.db" --interval 0.05 --start-window 0.4 --timeout 60
[ "$status" -ne 0 ]
[[ "$output" == *"never started"* ]]
[[ "$output" == *"cancels it"* ]]
# a watcher that never saw the scan may not hand back a quiet count
! grep -Eq '"quiet": [1-9]' <<<"$output"
}
@test "status --after refuses to certify a scan that predates the moves" {
python3 - "$TMP/jellyfin.db" <<'PY'
import sqlite3, sys
db = sqlite3.connect(sys.argv[1])
db.execute("CREATE TABLE BaseItems (Id TEXT, DateModified TEXT)")
db.commit()
PY
owed=$(python3 -c 'import time; print(int(time.time()) - 7200)')
future=$(python3 -c 'import time; print(int(time.time()) + 7200)')
date -u -d '3 hours ago' +%Y-%m-%dT%H:%M:%SZ > "$TMP/scan-ended"
run python3 "$JF" status --url "http://127.0.0.1:$PORT" --key-file "$TMP/jf-key" \
--db "$TMP/jellyfin.db" --after "$future"
[ "$status" -ne 0 ]
[[ "$output" == *"still owed"* ]]
date -u +%Y-%m-%dT%H:%M:%SZ > "$TMP/scan-ended"
run python3 "$JF" status --url "http://127.0.0.1:$PORT" --key-file "$TMP/jf-key" \
--db "$TMP/jellyfin.db" --after "$owed"
[ "$status" -eq 0 ]
[[ "$output" == *'"scannedAfter": true'* ]]
}
@test "a missing jellyfin credential file is refused by name" {
run python3 "$JF" trigger --url "http://127.0.0.1:$PORT" --key-file "$TMP/nope"
[ "$status" -ne 0 ]
[[ "$output" == *"$TMP/nope"* ]]
! grep -q '^POST' "$TMP/requests.log"
}
# --------------------------------------------------------------------- plex
@test "the plex driver carries the token from Preferences.xml into a header" {
run env PATH="$TMP/bin:$PATH" bash "$PX" trigger --library 1 \
--url "http://127.0.0.1:$PORT" --prefs "$TMP/prefs.xml"
[ "$status" -eq 0 ]
[[ "$output" == *"refresh accepted (204)"* ]]
grep -q "^POST /library/sections/1/refresh" "$TMP/requests.log"
# the token reaches PMS as a header only: never in a URL, never in an argv
! grep -q "stubplextoken" "$TMP/requests.log"
! grep -q "stubplextoken" "$TMP/podman.log"
}
@test "a plex trigger with no answer is a warming server, retried and named" {
run env PATH="$TMP/bin:$PATH" bash "$PX" trigger --library 1 \
--url "http://127.0.0.1:1" --prefs "$TMP/prefs.xml" --tries 2 --warm-sleep 0.1
[ "$status" -ne 0 ]
[[ "$output" == *"000"* ]]
[[ "$output" == *"warming"* ]] || [[ "$output" == *"never answered"* ]]
[ "$(grep -c '^podman cp' "$TMP/podman.log")" -eq 2 ]
}
@test "a plex trigger with no token in Preferences.xml fails before any request" {
printf '<Preferences/>\n' > "$TMP/prefs-empty.xml"
run env PATH="$TMP/bin:$PATH" bash "$PX" trigger --library 1 \
--url "http://127.0.0.1:$PORT" --prefs "$TMP/prefs-empty.xml"
[ "$status" -ne 0 ]
[[ "$output" == *"PlexOnlineToken"* ]]
! grep -q '^POST' "$TMP/requests.log"
}
@test "plex status reads the scan epoch from the database" {
plex_db "$TMP/library.db" 1000
run bash "$PX" status --library 1 --db "$TMP/library.db"
[ "$status" -eq 0 ]
[[ "$output" == *"section=1 scanned_at=1000"* ]]
[[ "$output" == *"/mnt/entropy/Movies"* ]]
run bash "$PX" status --library 1 --db "$TMP/library.db" --after 2000
[ "$status" -ne 0 ]
[[ "$output" == *"still owed"* ]]
run bash "$PX" status --library 1 --db "$TMP/library.db" --after 500
[ "$status" -eq 0 ]
}
@test "a plex status with no readable database is refused by name" {
run bash "$PX" status --library 1 --db "$TMP/no-library.db"
[ "$status" -ne 0 ]
[[ "$output" == *"$TMP/no-library.db"* ]]
}
@test "plex status names a section that has no location" {
plex_db "$TMP/library.db" 1000
run bash "$PX" status --library 77 --db "$TMP/library.db"
[ "$status" -ne 0 ]
[[ "$output" == *"section 77"* ]]
}