Both drivers' help text recommended `--after "$(stat -c %Y <journal>)"`. Measured
2026-11-04 that epoch is answered by whatever scan finished before the question was
asked: on Jellyfin it reads an earlier task's EndTimeUtc, and the Plex door cannot be
a waiter at all, since PMS stamps section_locations.scanned_at when it accepts the
request. The laws are on the vault pages (main 146c28d); this puts the warning at
point of use, in the text an agent reads before calling the door.
224 lines
10 KiB
Python
Executable File
224 lines
10 KiB
Python
Executable File
#!/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/<pid>/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 <journal mtime> 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()
|