From e7d7c6eea2f2995c20a861e9eb02f1587c623bc1 Mon Sep 17 00:00:00 2001 From: Frank Date: Wed, 4 Nov 2026 11:29:03 -0800 Subject: [PATCH 1/4] feat(scripts): the two media scan doors become commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- scripts/media-refresh-jellyfin.py | 222 ++++++++++++++++++ scripts/media-refresh-plex.sh | 127 +++++++++++ tests/media_refresh.bats | 361 ++++++++++++++++++++++++++++++ 3 files changed, 710 insertions(+) create mode 100755 scripts/media-refresh-jellyfin.py create mode 100755 scripts/media-refresh-plex.sh create mode 100755 tests/media_refresh.bats diff --git a/scripts/media-refresh-jellyfin.py b/scripts/media-refresh-jellyfin.py new file mode 100755 index 0000000..0f17280 --- /dev/null +++ b/scripts/media-refresh-jellyfin.py @@ -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//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 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..d797500 --- /dev/null +++ b/scripts/media-refresh-plex.sh @@ -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//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 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"* ]] +} From 95375683d5ac14d13da9d60f255921290cf6ba24 Mon Sep 17 00:00:00 2001 From: Frank Date: Wed, 4 Nov 2026 11:29:03 -0800 Subject: [PATCH 2/4] docs(vault): the scan pages name their drivers, and a class no screen sees Plex.md and Jellyfin.md keep every law and point at the driver that now carries them, so the procedure has one owner and the pages keep the failure signatures. Jellyfin.md also records where `jellyfin.db` actually lives on this host (the quadlet's config bind, not the stock path) and demotes `immutable=1` on the `ApiKeys` read to the `-shm` fallback, because the database is WAL and `immutable` skips the log. Plex.md gains the live section keys and the `scanned_at == scannedAt` fact. Media Relayout.md's hand-off after an apply names both drivers and states the ordering law: Jellyfin first and alone, since its one door walks every library while Plex forks a scanner per section, and the array is what both share. Media Duplicate Prune.md gains the guard the 2026-11-04 hold taught: 32 files carrying real film titles were 8-20-second h264 clips (49 MiB, 25 of them in a `_release-group watermarks/` folder), invisible to every duplicate screen because nothing shares their size and the feature-length guard hides them. The census that finds the next batch is a duration floor, not a hash. --- serverAppConfigs-docs/Dankserver/Jellyfin.md | 43 +++++++------ .../Dankserver/Media Duplicate Prune.md | 10 +++ .../Dankserver/Media Relayout.md | 17 +++-- serverAppConfigs-docs/Dankserver/Plex.md | 62 +++++++++---------- 4 files changed, 73 insertions(+), 59 deletions(-) diff --git a/serverAppConfigs-docs/Dankserver/Jellyfin.md b/serverAppConfigs-docs/Dankserver/Jellyfin.md index bc7b980..e5435b8 100644 --- a/serverAppConfigs-docs/Dankserver/Jellyfin.md +++ b/serverAppConfigs-docs/Dankserver/Jellyfin.md @@ -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 )` +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//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 336ceeb..13d26bf 100644 --- a/serverAppConfigs-docs/Dankserver/Media Duplicate Prune.md +++ b/serverAppConfigs-docs/Dankserver/Media Duplicate Prune.md @@ -151,6 +151,16 @@ 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` carried real titles (`The Descent (2005) - SD.mkv`) and were + 8–20-second h264 clips — 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. Held as `.AGENTTRASH/20261104-promo-clips`, journalled at + `/mnt/entropy/.media-campaign/promo-hold.jsonl`. 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..477e4c5 100644 --- a/serverAppConfigs-docs/Dankserver/Plex.md +++ b/serverAppConfigs-docs/Dankserver/Plex.md @@ -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//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 )" ``` -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//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 From 24d793eb7d260dbc0ee6fc68461077fbc3b18971 Mon Sep 17 00:00:00 2001 From: Frank Date: Wed, 4 Nov 2026 11:30:07 -0800 Subject: [PATCH 3/4] docs(vault): the promo-clip hold is 31 clips and one sidecar --- serverAppConfigs-docs/Dankserver/Media Duplicate Prune.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/serverAppConfigs-docs/Dankserver/Media Duplicate Prune.md b/serverAppConfigs-docs/Dankserver/Media Duplicate Prune.md index 13d26bf..eeabc9c 100644 --- a/serverAppConfigs-docs/Dankserver/Media Duplicate Prune.md +++ b/serverAppConfigs-docs/Dankserver/Media Duplicate Prune.md @@ -152,8 +152,9 @@ Each of these is a file that would have been destroyed without it: 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` carried real titles (`The Descent (2005) - SD.mkv`) and were - 8–20-second h264 clips — 640×360, 1024×576 or 1280×720, 49 MiB all together, + 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 From e519f6b554a13db5801afdd1dae90760a7a325d4 Mon Sep 17 00:00:00 2001 From: dank Date: Wed, 4 Nov 2026 11:59:00 -0800 Subject: [PATCH 4/4] docs(vault): the promo-clip hold is purged; merge main's purge record --- .../Dankserver/Media Duplicate Prune.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/serverAppConfigs-docs/Dankserver/Media Duplicate Prune.md b/serverAppConfigs-docs/Dankserver/Media Duplicate Prune.md index b3c97c4..5bb9c84 100644 --- a/serverAppConfigs-docs/Dankserver/Media Duplicate Prune.md +++ b/serverAppConfigs-docs/Dankserver/Media Duplicate Prune.md @@ -158,10 +158,11 @@ Each of these is a file that would have been destroyed without it: 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. Held as `.AGENTTRASH/20261104-promo-clips`, journalled at - `/mnt/entropy/.media-campaign/promo-hold.jsonl`. 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. + 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