feat(scripts): the two media scan doors become commands
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.
This commit is contained in:
1 parent
e1bf1d6e80
commit
e7d7c6eea2
3 files changed
+710
No files matched your search
Executable
+361
@@ -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"* ]]
|
||||
}
|
||||
Reference in new issue
Block a user