diff --git a/scripts/media-refresh-jellyfin.py b/scripts/media-refresh-jellyfin.py new file mode 100755 index 0000000..e5906ba --- /dev/null +++ b/scripts/media-refresh-jellyfin.py @@ -0,0 +1,223 @@ +#!/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` (the trigger's epoch — a file mtime is already passed by an earlier scan) 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//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 the scan must have finished after — pass " + "the trigger's, not a file mtime an earlier scan already passes") + 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 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() diff --git a/scripts/media-refresh-plex.sh b/scripts/media-refresh-plex.sh new file mode 100755 index 0000000..bba4e30 --- /dev/null +++ b/scripts/media-refresh-plex.sh @@ -0,0 +1,130 @@ +#!/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//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 — which makes it a census of the last RECORDED scan, never a waiter: PMS +# stamps that epoch when it accepts the request, so the door answers `settled` in the +# same second its own `trigger` ran (measured 2026-11-04; no epoch survives it). +# `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 diff --git a/serverAppConfigs-docs/Dankserver/Jellyfin.md b/serverAppConfigs-docs/Dankserver/Jellyfin.md index bc7b980..2a77bf6 100644 --- a/serverAppConfigs-docs/Dankserver/Jellyfin.md +++ b/serverAppConfigs-docs/Dankserver/Jellyfin.md @@ -188,7 +188,7 @@ Client access paths and the public-exposure policy (auth wall required before any public vhost; Plex-style claim tokens do not exist here — Jellyfin uses its own users/API keys) follow the fleet [[Authentik]]/[[Anubis]] chain pattern. -## Authentik LDAP authentication (2026-09-25) +## Authentik LDAP authentication (live since 2026-09-25, re-verified 2026-11-04) Jellyfin has no native external-auth, so it authenticates users through the **LDAP-Auth plugin** (`jellyfin-plugin-ldapauth`) against an [[Authentik]] LDAP @@ -204,17 +204,35 @@ user to verify the password. publishing `389`/`636` on the Dankclaw host for cross-host binds. - Service account `svc-jellyfin-ldap` (DN `cn=svc-jellyfin-ldap,ou=users,dc=franksdank,dc=cloud`). -- Access groups `jellyfin_users` (search filter - `(memberOf=cn=jellyfin_users,ou=groups,dc=franksdank,dc=cloud)`) and - `jellyfin_admins` (admin filter `(cn=jellyfin_admins)`). Add a user to - `jellyfin_users` to grant them Jellyfin access; `jellyfin_admins` grants - the Jellyfin admin role. +- Access group `jellyfin_users`: the plugin's search filter is + `(memberOf=cn=jellyfin_users,ou=groups,dc=franksdank,dc=cloud)`, so a user + who is not in that group is **invisible** to the plugin and the login fails + as "Wrong username or password". Add members with + `Group.objects.get(name="jellyfin_users").users.add(user)` in `ak shell` + (directory data — the DB owns it, not this repo). + `jellyfin_admins` is **inert as configured** — see the admin-filter law. **Jellyfin side** (plugin config, set via admin → Plugins → LDAP-Auth): - Server `192.168.0.143:389` (plain LDAP over the trusted LAN), Base DN `dc=franksdank,dc=cloud`, bind user = the service-account DN, search attributes `uid,cn,mail,displayName`, uid attr `uid`, username attr `cn` `CreateUsersFromLdap=true`, all folders enabled. +- Admin base `ou=groups,dc=franksdank,dc=cloud`, admin filter + `(cn=jellyfin_admins)`, `EnableLdapAdminFilterMemberUid=false`. + +**Law — the admin filter grants nothing unless it can return the user's own +DN.** With `EnableLdapAdminFilterMemberUid=false` the plugin searches +`LdapAdminBaseDn` with `LdapAdminFilter` and sets the admin permission only when +a returned entry's DN equals the bound user's DN +(`LDAP-Auth/LDAPAuthenticationProviderPlugin.cs`). A group-OU base returns the +*group* entry, whose DN never matches, so **no LDAP user can ever become a +Jellyfin admin** — measured 2026-11-04: the search answers +`cn=jellyfin_admins,ou=groups,…` for every login. Working shape if group-granted +admins are ever wanted: base `dc=franksdank,dc=cloud` + +`(memberOf=cn=jellyfin_admins,ou=groups,dc=franksdank,dc=cloud)`. Deliberate +today: the server's own admin account stays **local**, which is the break-glass +door when authentik or the outpost is down. A *malformed* admin filter is not +inert — the plugin throws for every login, so this knob is all-or-nothing. **Gotcha — the bind credential is a PASSWORD, not a token.** The LDAP bind is verified by authentik's default authentication flow (standard-password @@ -224,10 +242,23 @@ password and the bind fails with `flow error password: Invalid`. The service-account password is stored only in the Jellyfin plugin config (the `LdapBindPassword` field) — it is never committed to this repo. -**Verification (2026-09-25):** `ldapsearch` bind + the `jellyfin_users` -search both succeed from Dankclaw; a real user (`alex`, in `jellyfin_users`) -logged into the web UI with their authentik password and the plugin created -the matching Jellyfin user. +**Verification (re-taken 2026-11-04, from Dankserver):** the container reaches +`192.168.0.143:389`; the service-account bind plus the `jellyfin_users` search +return every member; LDAP-Auth 23.0.0.0 loads on Jellyfin 12.4; the `alex` row +still carries +`AuthenticationProviderId=Jellyfin.Plugin.LDAP_Auth.LdapAuthenticationProviderPlugin`. + +**Law — "the authentik login doesn't work" has two opposite causes; read the +Users table before touching authentik.** Copy `config/data/jellyfin.db` with its +`-wal`/`-shm` and read it read-only (never sqlite the live file): +`Users.AuthenticationProviderId` names the door — `…Users.DefaultAuthentication +Provider` = local password, `…LDAP_Auth.LdapAuthenticationProviderPlugin` = +authentik. Because the LDAP search is scoped to `jellyfin_users`, a name that +exists only as a local account is rejected by the plugin and Jellyfin then +silently falls back to the local provider: same UI message, opposite fix. The +instrument for "did a bind ever reach authentik" is the **event registry**, never +the outpost journal — see [[Authentik Outpost Reference]]. + **Gotcha — the login endpoint changed in Jellyfin 12.1.** The legacy `POST /Users/Auth/Password` route (and its `LegacyEmbyRouteRewriteMiddleware`) was removed; probing it now returns a silent 401 with no plugin log, which @@ -309,33 +340,40 @@ 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 ` is the +"has the scan reached my moves" answer, read from the task's `EndTimeUtc` — and that +timestamp only advances when a scan *finishes*, so the epoch must be one your trigger +could not have followed: a journal mtime is already passed by whatever scan ran before +you asked, and bare `status` cannot tell that walk from yours, while `wait` refuses such +an epoch by name. The Plex door is worse still — [[Plex]]. -```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//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 diff --git a/serverAppConfigs-docs/Dankserver/Media Duplicate Prune.md b/serverAppConfigs-docs/Dankserver/Media Duplicate Prune.md index 7d5da43..5bb9c84 100644 --- a/serverAppConfigs-docs/Dankserver/Media Duplicate Prune.md +++ b/serverAppConfigs-docs/Dankserver/Media Duplicate Prune.md @@ -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 diff --git a/serverAppConfigs-docs/Dankserver/Media Relayout.md b/serverAppConfigs-docs/Dankserver/Media Relayout.md index 912152f..d771e3b 100644 --- a/serverAppConfigs-docs/Dankserver/Media Relayout.md +++ b/serverAppConfigs-docs/Dankserver/Media Relayout.md @@ -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 ` +with `… status --library --after $(stat -c %Y )` 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 diff --git a/serverAppConfigs-docs/Dankserver/Plex.md b/serverAppConfigs-docs/Dankserver/Plex.md index f54745b..4628b08 100644 --- a/serverAppConfigs-docs/Dankserver/Plex.md +++ b/serverAppConfigs-docs/Dankserver/Plex.md @@ -106,43 +106,57 @@ 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//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 .jsonl # the real proof ``` -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. + +**`status` is a census, not a waiter: no epoch makes it watch a scan.** It reads +`section_locations.scanned_at` — the epoch of the last scan PMS has *recorded* for the +section, the same value `GET /library/sections` prints as `scannedAt` (verified +2026-11-04) — and compares it against `--after`. Measured twice on 2026-11-04: a chain +fell through its wait loop in the same second its own trigger was accepted, and a direct +read 30 minutes later still answered `settled` off that one stamp +(`2026-11-04T19:46:59Z`, for a request accepted at `19:46:58Z`) while the walk ran. A +journal's mtime fails, and so does the trigger's own epoch — the request satisfies the +very door it was asked to wait for. The failure direction is a false *success*, never a +false alarm, which is what makes it worth the warning: ask `status` whether a scan got +recorded, and never let a chain depend on it. + +**The proof that a scan did its work is the journal replayed against the database:** +`scripts/media-verify.py --db-kind plex --journal ` names every row that still +points at a source path (`still at src`) and every entry whose file is physically still +there (`REAL`). Both at zero is what gates a purge, and it needs no settle signal at all +— a scan can clear rows but cannot invent one. Jellyfin's door has no such trap: +`scripts/media-refresh-jellyfin.py wait` watches the scan task itself and refuses by +name when no task reaches `Running`. + +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//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 diff --git a/serverAppConfigs-docs/Infrastructure/Authentik Outpost Reference.md b/serverAppConfigs-docs/Infrastructure/Authentik Outpost Reference.md index 6bbb7fa..9d021ce 100644 --- a/serverAppConfigs-docs/Infrastructure/Authentik Outpost Reference.md +++ b/serverAppConfigs-docs/Infrastructure/Authentik Outpost Reference.md @@ -148,36 +148,46 @@ Read from the live database (read-only `SELECT`s against the | Object | Value | |---|---| | LDAP provider | `Jellyfin LDAP` (pk 11), base DN `dc=franksdank,dc=cloud`, `search_mode=direct`, `bind_mode=direct` | -| Bind DN | `cn=svc-jellyfin-ldap,ou=users,dc=franksdank,dc=cloud` *(unverified 2026-10-18: base DN confirmed in the DB, the `ou=users` path was not probed — an LDAP bind needs the bind secret)* | -| Service account | `svc-jellyfin-ldap` (user pk 8). **It is not passwordless**: its password field is a real hash, not Django's `!`-prefixed unusable marker (`starts_with(password,'!')` → `f`) — an earlier revision's "passwordless/unusable" claim is wrong, which matters because an account with a usable password can sign in through the ordinary password flow *(unverified 2026-10-18: whether anyone holds that password)* | -| Bind token | exactly one row for pk 8, **`intent=app_password`**, non-expiring — an earlier revision recorded `intent=verification`; the live value is `app_password` | +| Bind DN | `cn=svc-jellyfin-ldap,ou=users,dc=franksdank,dc=cloud` — **verified by a live bind 2026-11-04** (the bind and the `memberOf` search both succeed, so the `ou=users` path is right) | +| Service account | `svc-jellyfin-ldap` (user pk 8). It has a usable password and authenticates through the default flow — that password is held by Jellyfin in the plugin config (`LdapBindPassword`) and is what the bind actually uses (live bind 2026-11-04) | +| Bind token | exactly one row for pk 8, **`intent=app_password`**, non-expiring — and **unused by Jellyfin**, which has no token field (see the rule below) | | RBAC role | `svc-jellyfin-ldap-search`, carrying exactly one object-level permission `authentik_providers_ldap.search_full_directory` (verified via `guardian_roleobjectpermission`) | | Groups | `jellyfin_users`, `jellyfin_admins` (both exist in `authentik_core_group`) | | Application | name `Jellyfin`, **slug `jellyfin-ldap`** (an earlier revision said slug `jellyfin`, which does not exist) | -Hard-won rules (the first is now contested by the live database): +Hard-won rules: -- **Token intent: contested, do not trust either value blindly.** An earlier - revision asserted that a bind token with `intent=app_password` is rejected by - the flow's password stage with `Invalid password` and only - `intent=verification` passes. The live token for `svc-jellyfin-ldap` is - `app_password` (`authentik_core_token`, 2026-10-18), so either the rule is - wrong or the current Jellyfin bind path is not that token. Nothing observable - settles it today: the LDAP outpost's retained log holds only its 5-minute - `initialized direct binder` / `Update providers` config cycles and no bind - activity *(unverified 2026-10-18: no bind was performed — reproducing it - requires the bind secret)*. Re-verify with a real bind before changing the - token's intent either way. +- **Token intent is moot for Jellyfin.** The earlier argument (a bind token with + `intent=app_password` is rejected by the flow's password stage, only + `intent=verification` passes) never applied here: LDAP-Auth sends the service + account's **password** from `LdapBindPassword`, not a token. A live bind plus a + `memberOf=cn=jellyfin_users` search (11 entries) succeeded from Dankserver on + 2026-11-04. Leave the token row alone. - **Search needs `search_full_directory`.** Without the RBAC grant the outpost returns only the bind user itself — a `(memberOf=…)` filter then matches zero entries and the plugin logs `Found no users matching X`. The permission is object-level on the provider; grant it via an RBAC role and retest. +- **The outpost log never shows a bind.** Its retained journal is only the + 5-minute `initialized direct binder` / `Update providers` config cycle; the + absence of a bind line is not evidence of anything. Instruments that do answer: + `ldapsearch` with the plugin's own DN, password and filter, and the authentik + event registry (`login` / `login_failed` with `client_ip` from the podman + gateway, since the published port SNATs). - `ak shell` import path for the provider model in 2026.8: - `from authentik.providers.ldap.models import LDAPProvider`. - - (unverified 2026-10-18: importing it outside a configured Django shell fails - with `ImproperlyConfigured`, and no `ak shell` was driven against the live - auth service for this check)* + `from authentik.providers.ldap.models import LDAPProvider` (verified inside a + live `ak shell`, 2026-11-04 — it only fails outside a configured Django shell). +- **`ak shell` field names, 2026.8:** `Event` is keyed by `created` and `action` + — there is no `timestamp` and no `type`; `Outpost` has `providers` but **no + `apps`**. Introspect first: `[f.name for f in Model._meta.get_fields()]`. +- **Never pipe `ak shell` inside a heredoc.** `podman exec -i … ak shell | grep + -v '^{'` binds the heredoc to `grep` (the redirection belongs to the LAST + element of the pipeline), so the shell gets no stdin and prints nothing at all. + And `2>/dev/null` on `ak shell` eats the traceback, which looks identical to "no + rows". Clean pattern: have the script write its results to a file and read that + file back — + `podman exec -i authentik-server ak shell >/dev/null 2>/dev/null <<'PY' … open("/tmp/x.txt","w") … PY` + then `podman exec authentik-server cat /tmp/x.txt`. ## Self-serve user-settings flow (password change) diff --git a/serverAppConfigs-docs/Infrastructure/Authentik.md b/serverAppConfigs-docs/Infrastructure/Authentik.md index aea2a09..181919a 100644 --- a/serverAppConfigs-docs/Infrastructure/Authentik.md +++ b/serverAppConfigs-docs/Infrastructure/Authentik.md @@ -145,18 +145,17 @@ relying-party quirks are reference lookup: [[Authentik Outpost Reference]]. The second outpost — `dankclaw/authentik/quadlet/authentik-outpost-ldap.container` → `authentik-outpost-ldap.service`, one of the five units in this stack — serves LDAP for Jellyfin's LDAP-Auth plugin (`enabled`=generated, `active` -`Restart=always`, container up, 2026-10-18). The image is pinned by its **amd64 -single-platform digest** -`ghcr.io/goauthentik/ldap@sha256:2e7b259eb8c37faac916946ddf48ec681ee9d3a9f724fe8521bf161973f13cf9` -(`dankclaw/authentik/quadlet/authentik-outpost-ldap.container:26`, matching the -running image in `podman ps`, 2026-10-18). The digest in this page before 2026-10-18 -(`…fa33bc8f…`) is superseded. The reason for pinning a digest at all: podman -5.7.0 refuses the plain tag because it resolves to a *nested* manifest list -(index → per-platform manifest lists) — `unexpectedly received a manifest list -instead of a manifest for a single image` — first hit on `:2026.8.0`, still the -rule at the current `2026.8.3` pin. Resolve once with `podman manifest inspect - | jq -r '.manifests[] | select(.platform.architecture=="amd64") | -.digest'` and pin that digest. +`Restart=always`, container up, re-checked 2026-11-04). **The image pin has one +owner: the `Image=` line of +`dankclaw/authentik/quadlet/authentik-outpost-ldap.container`** — never restate +the digest here, because every pin bump turns this page into a lie (the digest +cited here went stale twice: `…fa33bc8f…`, then `…2e7b259e…`). The reason a +digest is pinned at all: podman 5.7.0 refuses the plain tag because it resolves +to a *nested* manifest list (index → per-platform manifest lists) — +`unexpectedly received a manifest list instead of a manifest for a single image` +— first hit on `:2026.8.0` and still the rule. Resolve once with +`podman manifest inspect | jq -r '.manifests[] | +select(.platform.architecture=="amd64") | .digest'` and pin that digest. - Ports **389** (ldap) and **636** (ldaps) publish on the LAN (`PublishPort=389:3389`, `636:6636` diff --git a/tests/media_refresh.bats b/tests/media_refresh.bats new file mode 100755 index 0000000..1ee97ce --- /dev/null +++ b/tests/media_refresh.bats @@ -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 '\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"* ]] +}