#!/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 '\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 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 '\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"* ]] }