#!/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()