Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bad78079d7 | ||
|
|
cae283047f | ||
|
|
51007ac604 | ||
|
|
f7c41966b0 | ||
|
|
25114ca622 | ||
|
|
a07f492b89 | ||
|
|
9961ed62c0 | ||
|
|
70be24e9d5 | ||
|
|
70aa40a24a | ||
|
|
8f2c3bb378 | ||
|
|
146c28da9f | ||
|
|
b82655d30a | ||
|
|
0161ab2aa8 | ||
|
|
085a5d570c | ||
|
|
9ca69d4e7e | ||
|
|
64b06b9426 | ||
|
|
e519f6b554 | ||
|
|
1e0247f29e | ||
|
|
71e7e94b94 | ||
|
|
24d793eb7d | ||
|
|
95375683d5 | ||
|
|
e7d7c6eea2 | ||
|
|
e1bf1d6e80 |
No files matched your search
Executable
+223
@@ -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/<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()
|
||||
Executable
+130
@@ -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/<pid>/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
|
||||
@@ -222,5 +222,5 @@ harness bundle and measured the day of the flip:
|
||||
| Endpoint answers, synthesis fails | the engine: `podman healthcheck run frank-tts-engine`, then `systemctl --user status frank-tts-engine.service` |
|
||||
| First request of the day is ~0.6 s slower than the rest — or every name returns 400 | the warm pass did not run; it carries `-`, so it cannot fail the unit. `journalctl --user -u frank-tts-engine` for the warmer's lines, then `podman exec frank-tts-engine /app/warm-voices.sh` — the registry is memory-only (law 1 of [[Frank TTS Engine]]) |
|
||||
| The unit never appears after a deploy | the generator dropped it: `frank-tts-engine.network` is missing from `~/.config/containers/systemd/` |
|
||||
| A consumer rejects `wilwheaton` by listing 29 names (`af_heart … bm_fable`) and no request ever reaches this endpoint | that consumer is synthesizing **in the browser** with Kokoro.js, whose voice pack is fixed — OpenWebUI 0.11.4 ships it as the `browser-kokoro` TTS engine; see [[OpenWebUI]] |
|
||||
| A consumer rejects `wilwheaton` by listing 29 names (`af_heart … bm_fable`) and no request ever reaches this endpoint | that consumer is synthesizing **in the browser** with Kokoro.js, whose voice pack is fixed. In OpenWebUI the `browser-kokoro` choice is that user's own `user.settings` row (`ui.audio.tts.engine`) and the SPA re-serves it on every load, so flipping the dropdown does not stick; see [[OpenWebUI]] |
|
||||
---
|
||||
@@ -21,7 +21,7 @@ https://github.com/open-webui/open-webui
|
||||
| Item | Value |
|
||||
|------|-------|
|
||||
| Image | `ghcr.io/open-webui/open-webui:main` (verified from the live quadlet unit) |
|
||||
| Deployed build | `0.11.4` — `GET http://127.0.0.1:3005/api/version` → `{"version":"0.11.4"}` (2026-10-18). `:main` is the rolling image, not a stable pin, so the running build lags upstream: releases `0.12.0` (2026-10-10) through `0.12.3` (2026-10-16) exist and are not deployed (https://github.com/open-webui/open-webui/releases). The 2026-09-21 `0.11.4` security/access-control advisory is what pinned this era of the page. Imagegen endpoint is `POST /api/v1/images/generations` (the `comfyui` engine is still upstream). |
|
||||
| Deployed build | `0.12.5` — `GET http://127.0.0.1:3005/api/version` → `{"version":"0.12.5"}` (re-measured 2026-11-04). The repo pin `@sha256:0b399a8b…` (`dankclaw/open-webui/quadlet/open-webui.container`, bumped 2026-10-30) is what makes that build reproducible; `:main` is a rolling tag, so re-measure `/api/version` instead of quoting a build from this page. Imagegen endpoint is `POST /api/v1/images/generations` (the `comfyui` engine is still upstream). |
|
||||
| Port | 3005 (host) → 8080 (container). Port 3000 is Gitea's HTTP, so it is not usable. |
|
||||
| Data volume | `/home/dank/Containers/open-webui/data` → `/app/backend/data` |
|
||||
| Backends | The picker's content is `webui.db` state, not repo state: as recorded on 2026-10-07 it exposes **one** model, `frank-zealot`, with `frank-dragoon`/`frank-archon` configured but disabled — *unverified 2026-10-18* (the DB is the authority and is not readable from the repo). What the repo does fix is the container's own OpenAI target: `OPENAI_API_BASE_URL=http://192.168.0.143:7770` (`dankclaw/open-webui/quadlet/open-webui.container:32`, same value in the live container env) — see [The LLM backends](#the-llm-backends) |
|
||||
@@ -163,14 +163,11 @@ account under Profile → API Keys). Anonymous web visitors get the `pending`
|
||||
role (`ui.default_user_role=pending`), which cannot chat. Verified
|
||||
2026-10-07 from loopback `:3005` and over the public vhost:
|
||||
`GET /api/v1/models/list`, `GET /api/v1/models` and
|
||||
`POST /api/chat/completions` all return **401** with no key (also with a
|
||||
bogus bearer token); only `/api/config`, `/api/version` and `/health`
|
||||
`POST /api/chat/completions` all return **401** with no key (also with a bogus bearer
|
||||
token); only `/api/config`, `/api/version` and `/health` answer publicly, and none of
|
||||
them expose the model list. An external program needs an account + API key — there is
|
||||
no keyless access. Re-verified 2026-10-18 from loopback: `/health` → `{"status":true}`
|
||||
`/api/version` → 0.11.4, `/api/v1/models/list`, `/api/v1/models` and
|
||||
`POST /api/chat/completions` → 401; `/api/config` additionally publishes
|
||||
no keyless access. Re-measured 2026-11-04 from loopback: `/api/version` → `0.12.5`,
|
||||
and `/api/config` publishes
|
||||
`features.enable_login_form=false`, `features.enable_signup=false` and
|
||||
`oauth.providers.oidc="authentik"` — the SSO-only posture, visible from outside.
|
||||
|
||||
@@ -219,18 +216,26 @@ browser-driven and only touches the two exempt vhosts (`ai.` and `auth.`).
|
||||
into the existing local account with the same email — the pre-SSO admin
|
||||
(frank8465@protonmail.com) keeps its data. `ENABLE_OAUTH_SIGNUP=true` lets
|
||||
other `openwebui_users` create accounts on first login.
|
||||
- **First login mints role `pending`.** `ui.default_user_role=pending` (config
|
||||
DB, verified 2026-10-09), so a freshly-provisioned `openwebui_users` account
|
||||
completes SSO and then cannot chat (picker empty) until its role is flipped:
|
||||
`UPDATE "user" SET role='user' WHERE email='<user>@franksdank.cloud';` —
|
||||
run with host `python3` over ssh against
|
||||
- **First login mints the role from the claim, not from
|
||||
`ui.default_user_role`.** The quadlet sets `ENABLE_OAUTH_ROLE_MANAGEMENT=true`
|
||||
and `OAUTH_ROLES_CLAIM` defaults to `roles`, so the `roles: ["user"]` claim
|
||||
decides the role. Measured 2026-11-04: a freshly-provisioned
|
||||
`openwebui_users` account completed SSO in a clean browser and the row
|
||||
appeared as `role='user'` — chat answered and Read Aloud synthesized, with no
|
||||
manual step. `ui.default_user_role=pending` (config DB) is the fallback for an
|
||||
account the claim does *not* describe, and 0.12.5's `get_user_role` **refuses
|
||||
the login with 403** when role management is on and no roles claim is
|
||||
readable — so "SSO succeeded but I cannot chat" points at a missing
|
||||
`openwebui_users` group/entitlement, not at a row waiting to be flipped. Flip
|
||||
only a row that is actually stuck:
|
||||
`UPDATE "user" SET role='user' WHERE email='<user>@franksdank.cloud';` — run
|
||||
with host `python3` over ssh against
|
||||
`/home/dank/Containers/open-webui/data/webui.db` (no `sqlite3` CLI on
|
||||
Dankclaw; table is `user`, singular; no `active` column). No per-user model
|
||||
grant is needed: the picker model `frank-zealot` carries the global
|
||||
`access_grant` row `(model, frank-zealot, user, *, read)`, so any
|
||||
`role='user'` account sees it. Flip on FIRST-LOGIN SIGHTING — the row does
|
||||
not exist until the account signs in (2026-10-09: five provisioned accounts
|
||||
had no row).
|
||||
Dankclaw; table is `user`, singular; no `active` column). The row does not
|
||||
exist until the account signs in, so census it after a first-login sighting
|
||||
(2026-10-09: five provisioned accounts had no row). No per-user model grant is
|
||||
needed: the picker models carry the global `access_grant` row
|
||||
`(model, <id>, user, *, read)`, so any `role='user'` account sees them.
|
||||
- **IdP reachability:** the container is on its own bridge (10.89.3.0/24) and
|
||||
cannot reach `authentik-server:9000` by name. It reaches the IdP over the
|
||||
public URL `https://auth.franksdank.cloud` — Caddy publishes `0.0.0.0:443`
|
||||
@@ -305,28 +310,58 @@ port is the rootless quadlet `frank-tts` (this repo:
|
||||
400 listing the voices that exist, never a silent fallback.
|
||||
|
||||
**The 29-voice `af_* … bm_fable` list is NOT frank-tts.** That enum belongs
|
||||
to **Kokoro.js running in the browser**: the deployed front end (0.11.4)
|
||||
ships an in-page Kokoro implementation (chunk carrying a frozen voice table
|
||||
`{af_heart:{name:"Heart",…}}` and `_validate_voice`, model
|
||||
`onnx-community/Kokoro-82M-v1.0-ONNX`, plus a personal *Kokoro.js dtype*
|
||||
setting). Its engine select takes `""` (Default) or `browser-kokoro`; when
|
||||
`browser-kokoro` is chosen the page synthesizes locally and rejects any
|
||||
voice outside its fixed pack — so `wilwheaton` fails with
|
||||
`Voice: "wilwheaton" not found. Should be one of: af_heart, …` **without a
|
||||
single request reaching the server**. Personal audio settings are
|
||||
browser: no `user.settings` row and no container env carries an audio
|
||||
override, yet the toast still appears ⇒ the choice lives in that browser's
|
||||
own storage. Fix in the UI: Settings → Audio → TTS Engine → **Default**,
|
||||
which hands synthesis to the admin config (frank-tts); picking `af_heart`
|
||||
instead keeps Kokoro, and Kokoro cannot clone — fixed pack only.
|
||||
to **Kokoro.js running in the browser**: the deployed front end ships an
|
||||
in-page Kokoro implementation (frozen voice table plus `_validate_voice`,
|
||||
model `onnx-community/Kokoro-82M-v1.0-ONNX`, and a personal *Kokoro.js dtype*
|
||||
setting). Its engine select takes `""` (Default) or `browser-kokoro`; with
|
||||
`browser-kokoro` the page synthesizes locally and rejects any voice outside
|
||||
its fixed pack, so `wilwheaton` fails with `Voice: "wilwheaton" not found.
|
||||
Should be one of: af_heart, …` **without a single request leaving the
|
||||
browser**. Kokoro cannot clone — fixed pack only.
|
||||
|
||||
Verified 2026-10-18 from inside the container with the exact OpenWebUI body
|
||||
(`{"model":"frank-tts","input":…,"voice":"narrator1",
|
||||
"response_format":"mp3","speed":1.0}`) → **200, `audio/mpeg`, 7,629 B**,
|
||||
while `podman logs open-webui` recorded **zero** `audio/speech` requests for
|
||||
the failing click — that contrast is the instrument: a server-path failure
|
||||
leaves a log line and a frank-tts error, a `browser-kokoro` failure leaves
|
||||
neither.
|
||||
**The switch is a DB row, not browser storage.** Personal audio settings are
|
||||
the `settings` JSON column of table `user`, and in 0.12.x they nest under
|
||||
`ui`: `ui.audio.tts.engine`, `.voice`, `.defaultVoice`. In 0.11.x the same
|
||||
keys sat at the top level (`audio.tts.*`), and the 2026-10-18 probe listed
|
||||
only top-level keys, found no audio key, and concluded the choice lived in
|
||||
that browser's own storage — a probe-depth bug. The row carried the override
|
||||
the whole way; the 2026-10-30 pin bump to 0.12.5 moved the keys, not the bug.
|
||||
The SPA reads that row at load (`GET /api/v1/users/user/settings`) and posts
|
||||
the **whole** blob back whenever any preference changes
|
||||
(`POST …/settings/update`, body `{...settings}`). So the dropdown can fix it —
|
||||
but a second tab still holding the old copy writes `browser-kokoro` straight
|
||||
back, which is the loop that makes the toast return after it was "fixed". The
|
||||
user row is the authority here; the admin `config` table was never the problem
|
||||
(its `audio.tts.engine` has been `openai` throughout). Repair
|
||||
`settings.ui.audio.tts` with the image's stdlib `sqlite3` (`mode=ro`; quote
|
||||
identifiers, `user` and `group` are reserved words — there is no `sqlite3` CLI
|
||||
in the image or on the host), then reload every tab of that account.
|
||||
|
||||
**Repair the engine *and* the voice.** With engine `""` the front end still
|
||||
sends its stored voice in the request body, so a Kokoro-era `voice`/
|
||||
`defaultVoice` survives the engine fix as frank-tts's own rejection —
|
||||
measured 2026-11-04: `voice: "alloy"` → **400 `unknown voice 'alloy';
|
||||
available voices: dr.kern, garrickhagon, jeffhays, vaati, wilwheaton`**. Set
|
||||
`ui.audio.tts` to `{"engine": "", "voice": "wilwheaton", "defaultVoice":
|
||||
"wilwheaton"}` and reload. Engine `""` means "the admin config decides", and
|
||||
the handler fills `voice` from `audio.tts.voice` only when the body omits it.
|
||||
|
||||
**Two tells settle the case in one line:**
|
||||
|
||||
- a session that fetched `/wasm/ort-wasm-simd-threaded.jsep.wasm` loaded
|
||||
onnxruntime, so Kokoro.js is running in that browser — the server path
|
||||
never asks for that file;
|
||||
- a server-path failure leaves a uvicorn request line for
|
||||
`/api/v1/audio/speech` **and** a frank-tts log line; a `browser-kokoro`
|
||||
failure leaves neither. uvicorn logs every request, so silence is evidence.
|
||||
|
||||
Verified 2026-11-04: the handler's own outbound shape
|
||||
(`{"model":"frank-tts","input":…,"voice":"wilwheaton"}` with
|
||||
`Authorization: Bearer none`, the configured `audio.tts.openai.api_key`) →
|
||||
**200 `audio/wav`** — a body without `response_format` comes back WAV and
|
||||
either MIME is browser-playable, so OpenWebUI accepts it. The full route as a
|
||||
real user over the vhost, `POST /api/v1/audio/speech` → **200 `audio/mpeg`**,
|
||||
with frank-tts logging `speech voice=wilwheaton`.
|
||||
|
||||
## Retired instances
|
||||
|
||||
|
||||
@@ -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 <the trigger's epoch>` 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/<pid>/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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -179,21 +191,57 @@ keeper is missing — either one drops the whole group rather than half-applying
|
||||
era, not this pass — read the mtimes before blaming the prune.
|
||||
- The 2026-11-01 pass held 226 files (~1.1 TiB) in
|
||||
`/mnt/entropy/.AGENTTRASH/20261101-duplicate-prune`, journals
|
||||
`dup-prune-exact.jsonl` and `dup-prune-similar.jsonl`. Purge pending Frank's word.
|
||||
- The 2026-11-02 TV pass holds 220 files (54 GiB) in
|
||||
`dup-prune-exact.jsonl` and `dup-prune-similar.jsonl`.
|
||||
- The 2026-11-02 TV pass held 220 files (54 GiB) in
|
||||
`/mnt/entropy/.AGENTTRASH/20261102-tv-duplicates` — plan `tv-dupes-plan.tsv`,
|
||||
journal `tv-dupes.jsonl`, 220 moves / 16 hold folders / 7 husks swept — and the
|
||||
same day's TVShows detritus pass holds 109 files (552 KiB) in
|
||||
same day's TVShows detritus pass staged 109 files (552 KiB) in
|
||||
`/mnt/entropy/.AGENTTRASH/20261102-tvshows-detritus` with plan
|
||||
`tvshows-detritus-plan.tsv` and journal `tvshows-detritus.jsonl`. Both purges
|
||||
pending Frank's word, beside the 2026-11-01 hold above. All four journals and
|
||||
plans live at the `entropy` root, not `/tmp`.
|
||||
- **Re-verify each survivor by size, not by the recorded path, before purging.**
|
||||
Detection artifacts and plans key on paths, and a folder renamed by an
|
||||
interactive session ([[Entropy Volume]], *Write doors*) makes its keeper look
|
||||
deleted while it sits untouched under the new name. The 2026-11-01 audit hit
|
||||
three such groups: every keeper was present, same bytes, different folder. A
|
||||
purge decided from the plan's paths alone would have reported survivors gone.
|
||||
`tvshows-detritus-plan.tsv` and journal `tvshows-detritus.jsonl`. All four
|
||||
journals and plans live at the `entropy` root, not `/tmp`.
|
||||
- **Purged 2026-11-04, on Frank's word, after the gate below:** the duplicate
|
||||
prune, the TV duplicate pass, the Ghibli duplicates (6 files — both `Nausicaä`
|
||||
copies among them) and the 2026-11-04 promo-clip hold left the array trash,
|
||||
~1.2 TB freed (the array went 218 GB → 1.4 TB available). The journals survive as
|
||||
the only record of where a purged file came from; rollback after a purge is
|
||||
impossible, which is why the gate runs first.
|
||||
|
||||
### The purge gate: a carrier for every held file
|
||||
|
||||
`REAL: 0` in both databases says only that no row points at a moved path; it says
|
||||
nothing about whether the film survived. So a purge waits on a stronger proof:
|
||||
**every held file has a same-work carrier in the live library at >= its size,
|
||||
re-derived at purge time**, never copied from the plan. The 2026-11-04 purge re-found
|
||||
all 452 held files this way; the first pass of the gate could not place 142 of them,
|
||||
and every one of those was a matcher gap, not a lost film. What the re-derivation has
|
||||
to absorb:
|
||||
|
||||
- **The layout moves under the plan.** The relayout renames folders to
|
||||
`Title (YYYY)` and `[tvdbid-N]/Season N` and renames the keeper files too, so the
|
||||
paths *and* the sizes a plan recorded no longer describe the library. Keys are work
|
||||
identity — normalized title+year for a film, `(S,E)` for a TV pack — never a path.
|
||||
- **Fold accents, then compare.** The library holds `Nausicaä`; a plan, a
|
||||
`find -ipath "*Nausicaa*"`, and an ASCII substring search all miss it. NFKD-fold and
|
||||
drop combining marks, or a real keeper reads absent for two rounds and the file
|
||||
beside it starts to look like an orphan.
|
||||
- **The carrier is often another container, and usually larger.** A held `.mp4` can be
|
||||
kept as the sibling `.mkv`; keep-larger means the keeper is frequently far bigger (a
|
||||
377 MB held episode matched a 501 MB keeper; a 1.6 GiB film stands beside a 34 GiB
|
||||
BDRemux of itself in another folder). "Same folder, same bytes" reports survivors as
|
||||
lost — it is the wrong test.
|
||||
- **An exact twin can sit in a sibling folder**, so "no same-size file in the folder"
|
||||
needs an array-wide name search before it means anything.
|
||||
|
||||
### Running the purge
|
||||
|
||||
`scripts/purge-agenttrash.sh` is the only door — never `rm`. It acts on exactly one
|
||||
object: a root's own `.AGENTTRASH`. A partial purge is therefore **staged, not
|
||||
filtered**: move the approved buckets into a scratch root
|
||||
(`.PURGE-<date>/.AGENTTRASH/`) and point the purger at that root, and park anything
|
||||
not approved at the array root as `.HOLD-<date>-*`, out of its reach. Dry-run first —
|
||||
the plan must print exactly one `plan empty` row, and it is the same row `--execute`
|
||||
acts on — and send `--log` into `.media-campaign/`, because `/tmp` is not a record.
|
||||
Then read `df`: a purge is the only media act that moves the free-space number.
|
||||
|
||||
## After the pass
|
||||
|
||||
|
||||
@@ -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 <key>`
|
||||
with `… status --library <key> --after $(stat -c %Y <journal>)` 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
|
||||
|
||||
|
||||
@@ -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/<pid>/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 </tmp/px.tok
|
||||
shred -u /tmp/px.tok
|
||||
scripts/media-refresh-plex.sh trigger --library 1
|
||||
python3 scripts/media-verify.py --db-kind plex \
|
||||
--journal /mnt/entropy/.media-campaign/<journal>.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 <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/<pid>/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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
<image> | 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 <image> | 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`
|
||||
@@ -393,9 +392,10 @@ downstream —
|
||||
- `Non-Admin Users` = the plain-user marker group (precedents: qa-e2e, and
|
||||
every human account).
|
||||
- **The human-user template** (ridley, kassie, alex, artem, kirby, ry, nic,
|
||||
debo, freya, mitch, eric). The pks in this page are confirmed against
|
||||
`authentik_core_user` on 2026-10-18: ridley 13, kassie 14, alex 15,
|
||||
artem 16, kirby 17, ry 18, nic 19, debo 20, freya 21, mitch 22, eric 23 —
|
||||
debo, freya, mitch, eric, jason). The pks were confirmed against
|
||||
`authentik_core_user` on 2026-10-18 and the table re-read 2026-11-04: ridley
|
||||
13, kassie 14, alex 15, artem 16, kirby 17, ry 18, nic 19, debo 20, freya 21,
|
||||
mitch 22, eric 23, **jason 24 (created 2026-11-04)** —
|
||||
and every one carries the `<user>@franksdank.cloud` address the template
|
||||
prescribes. Creation dates (2026-10-07 → 2026-10-15) were not re-checked
|
||||
- (unverified 2026-10-18)*. Template: `username` +
|
||||
|
||||
@@ -28,11 +28,12 @@ descriptions and a `docs` link to `/docs#models`.
|
||||
|
||||
**Sections + WIP split (2026-09-21):** both pages now group their panels
|
||||
under section headings rendered by shared `renderSection()`/`renderPanel()`
|
||||
components in `app.js`. The user page orders **AI, Web Apps, Media Library
|
||||
Apps**; the admin page orders **Media Library Apps, Observability, AI,
|
||||
Infrastructure**. The WIP badge split into **WIP: OPEN** (work in progress)
|
||||
and **WIP: CLOSED** (work finished by decision — e.g. a card that is
|
||||
deliberately unprobeable) via the `wip` value (`"open"` / `"closed"`).
|
||||
components in `app.js`. The user page orders **Web Apps, Media Library
|
||||
Apps, AI, Work in progress** (the three not-open-yet apps moved under their
|
||||
own heading, below AI, on 2026-11-04); the admin page orders **Media Library
|
||||
Apps, Observability, AI, Infrastructure**. The WIP badge split into **WIP:
|
||||
OPEN** (work in progress) and **WIP: CLOSED** (work finished by decision — e.g.
|
||||
a card that is deliberately unprobeable) via the `wip` value.
|
||||
Tautulli (Plex statistics, `192.168.0.235:8181`) joined the admin page's
|
||||
Media Library Apps section.
|
||||
|
||||
@@ -314,9 +315,10 @@ flowchart TD
|
||||
## Service classification
|
||||
|
||||
**User services** (root page), in section order:
|
||||
- **AI** — OpenWebUI, and the three LLM fleet cards (frank-dragoon, frank-archon, frank-zealot — status + docs link, no Open link).
|
||||
- **Web Apps** — SearXNG, Gitea, Copyparty, Marreta.
|
||||
- **Media Library Apps** — Calibre-Web, RomM, Plex, Jellyfin, Seerr, Audiobookshelf (moved up from the admin page 2026-11-03, once Authentik SSO made it a household app).
|
||||
- **Media Library Apps** — Plex, Jellyfin, Audiobookshelf (moved up from the admin page 2026-11-03, once Authentik SSO made it a household app).
|
||||
- **AI** — OpenWebUI, and the three LLM fleet cards (frank-dragoon, frank-archon, frank-zealot — status + docs link, no Open link); their model table renders under this heading.
|
||||
- **Work in progress** — Calibre-Web, RomM, Seerr. WIP badge, no Open link, no household account yet: the ask-Frank door. See the docs page's *Three doors that are not open to the household yet*.
|
||||
|
||||
**Admin services** (`/admin/`), in section order:
|
||||
- **Media Library Apps** — the *arr stack (Prowlarr, Sonarr, Radarr, Lidarr, Readarr), qBittorrent, and Tautulli — live on FrankBook-Pro and Dankserver, operator-only: no vhosts (Tautulli included), direct IP:port access only. See [[FrankBook-Pro]].
|
||||
@@ -432,15 +434,15 @@ agent knowledge base (`~/.pi/agent/docs/caddy/caddyfile.md`).
|
||||
|
||||
- `category` — `user` or `admin`. Selects the page.
|
||||
- `section` — the heading the panel renders under. Must be one of the page's
|
||||
`SECTION_ORDER` entries (user: AI, Web Apps, Media Library Apps; admin:
|
||||
Media Library Apps, Observability, AI, Infrastructure), or the panel is
|
||||
silently dropped from the page.
|
||||
`SECTION_ORDER` entries (user: Web Apps, Media Library Apps, AI, Work in
|
||||
progress; admin: Media Library Apps, Observability, AI, Infrastructure), or
|
||||
the panel is silently dropped from the page.
|
||||
- `url` — the "Open" link (the Caddy site).
|
||||
- `docs` — the per-service docs page (`/docs/<svc>/`).
|
||||
- `probe` — the backend `host:port` the checker probes.
|
||||
- `method` — `tcp` or `http`. The probe type for this service (Q10).
|
||||
- `wip` — `false`, or `"open"` / `"closed"`. When set, the badge shows
|
||||
**WIP: OPEN** or **WIP: CLOSED** and the checker skips the probe.
|
||||
- `wip` — `false`, or `"open"` / `"closed"` / `"down"`. When set, the badge shows
|
||||
**WIP: OPEN** / **WIP: CLOSED** / **WIP: DOWN** and the checker skips the probe.
|
||||
|
||||
**A card must be probeable from the monitor's host, or it must be `wip`.** The
|
||||
checker runs on Dankclaw, so its reach is: that host's own loopback, its own
|
||||
|
||||
@@ -10,7 +10,7 @@ const CATEGORY = window.location.pathname.indexOf('/admin') !== -1 ? 'admin' : '
|
||||
// same renderSection()/renderPanel() below. SECTION_ORDER fixes each page's
|
||||
// section order; a section with no services renders nothing.
|
||||
const SECTION_ORDER = {
|
||||
user: ['Web Apps', 'Media Library Apps', 'AI'],
|
||||
user: ['Web Apps', 'Media Library Apps', 'AI', 'Work in progress'],
|
||||
admin: ['Media Library Apps', 'Observability', 'AI', 'Infrastructure'],
|
||||
};
|
||||
|
||||
|
||||
+62
-10
@@ -33,7 +33,7 @@ There is no signup button on purpose.</p>
|
||||
<p><strong>2. Pick what you want to do:</strong></p>
|
||||
<table>
|
||||
<tr><th>I want to…</th><th>Open</th><th>Needs its own login?</th></tr>
|
||||
<tr><td>watch a film or a show</td><td><a href="https://jelly.franksdank.cloud">Jellyfin</a></td><td>yes — ask Frank</td></tr>
|
||||
<tr><td>watch a film or a show</td><td><a href="https://jelly.franksdank.cloud">Jellyfin</a></td><td>no — your franksdank.cloud login</td></tr>
|
||||
<tr><td>watch it on a TV that already has Plex</td><td><a href="https://plex.franksdank.cloud/web">Plex</a></td><td>yes — a Plex account, Frank invites it</td></tr>
|
||||
<tr><td>listen to an audiobook</td><td><a href="https://audiobooks.franksdank.cloud/audiobookshelf">Audiobookshelf</a></td><td>no — the family password</td></tr>
|
||||
<tr><td>ask a computer a question, privately</td><td><a href="https://ai.franksdank.cloud">AI chat</a></td><td>no</td></tr>
|
||||
@@ -77,15 +77,17 @@ There is no signup button on purpose.</p>
|
||||
<li><strong>Authentik</strong> is the front desk. It asks who you are, once per device
|
||||
per few days, and after that you drift from app to app without being stopped again.</li>
|
||||
</ul>
|
||||
<p>One account opens: the homepage, search, Marreta, the AI chat, Files, Gitea.</p>
|
||||
<p>One account opens: the homepage, search, Marreta, the AI chat, Files, Gitea, and Jellyfin
|
||||
(there it is typed as username + password, not the automatic sign-in).</p>
|
||||
<figure>
|
||||
<img src="/img/onboarding/door-authentik.webp" alt="The sign-in screen: an identification field, then a password field">
|
||||
<figcaption>The front desk. Your username first, then your password. Nothing here signs
|
||||
you up for anything; if you don't have an account yet, ask Frank.</figcaption>
|
||||
</figure>
|
||||
<p class="key-warn">Three doors keep their own accounts and will not take the family
|
||||
password: <strong>Jellyfin</strong>, <strong>Plex</strong> (a Plex account, its own e-mail
|
||||
login) and the e-book door. Frank creates those by hand.</p>
|
||||
<p class="key-warn">Two doors keep their own accounts and will not take the family
|
||||
password: <strong>Plex</strong> (a Plex account, its own e-mail
|
||||
login) and the e-book door. Frank creates those by hand. <strong>Jellyfin does take the
|
||||
family login</strong> — your franksdank.cloud username and password.</p>
|
||||
<p class="foot-note">Want your own password? It lives at
|
||||
<a href="https://auth.franksdank.cloud/if/flow/default-user-settings-flow/">account settings</a>
|
||||
— the same button you get at the bottom of the homepage.</p>
|
||||
@@ -128,8 +130,10 @@ account of any company's, and the posters and episode guides are all filled in.<
|
||||
<h3>Getting in</h3>
|
||||
<ul>
|
||||
<li>Pick your name. There is no list of names on the login screen — type yours.</li>
|
||||
<li>Type the password Frank set for you. <strong>It is not the family password</strong>;
|
||||
this is the most common wrong door.</li>
|
||||
<li>Type your <strong>franksdank.cloud password</strong> — the same one that opens the
|
||||
homepage, search and the AI chat. There is no separate Jellyfin password.</li>
|
||||
<li>Refused, but the same password opens the homepage? Your name hasn't been given library
|
||||
access yet — ask Frank, it's a one-line change on his side.</li>
|
||||
<li>No name yet? Ask Frank to make one. There is no self-service signup, on purpose.</li>
|
||||
</ul>
|
||||
<figure>
|
||||
@@ -147,7 +151,8 @@ account of any company's, and the posters and episode guides are all filled in.<
|
||||
</ul>
|
||||
<h3>If it misbehaves</h3>
|
||||
<ul>
|
||||
<li>Wrong password and no error you understand? That's the family password again.</li>
|
||||
<li>It rejects a password that works everywhere else? Your name isn't on the library-access
|
||||
list yet — ask Frank.</li>
|
||||
<li>A film with a blank poster is missing artwork, not missing files — Frank can refresh it.</li>
|
||||
<li>Buffering away from home usually means the pipe out of the house, not the server.</li>
|
||||
</ul>
|
||||
@@ -167,7 +172,8 @@ Plex's own sign-in, and that is correct: Plex is closed-source and insists on a
|
||||
both; the artwork, watch-state and profiles are separate.</li>
|
||||
<li>Choose Plex when your TV already has the Plex app on it, or you want Plex's trailers
|
||||
and music. Otherwise Jellyfin is the one the household uses.</li>
|
||||
<li>Ask Frank for an invitation; he can also tell you which of the two has the thing you're looking for.</li>
|
||||
<li>Need a Plex account of your own? Ask Frank to invite it; he can also tell you which of
|
||||
the two has the thing you're looking for.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
@@ -359,6 +365,51 @@ Qwen3.8-Flash-Next v2, abliterated
|
||||
(<a href="https://huggingface.co/prism-ml/Ternary-Bonsai-2-9B-gguf">prism-ml/Ternary-Bonsai-2-9B-gguf</a>)
|
||||
with a small adapter fitted that removes its refusal reflex. All three accept pictures. Ask
|
||||
Frank before pointing a script at one of them directly; the address is on the homepage.</p>
|
||||
|
||||
<h3>And the voices</h3>
|
||||
<p>Two speech models live in this house, and they are different animals. One of them
|
||||
speaks as a <em>specific person</em>; the other is a pack of stock voices that ships inside
|
||||
the tools you already use.</p>
|
||||
<table>
|
||||
<tr><th>Name</th><th>Runs on</th><th>Good at</th><th>Notes</th></tr>
|
||||
<tr>
|
||||
<td><code>frank-tts</code></td>
|
||||
<td>the Strix Halo machine's built-in graphics, sharing the card with <code>frank-dragoon</code></td>
|
||||
<td>cloning: it speaks as one particular voice, chosen by name</td>
|
||||
<td>five voices today — <code>dr.kern</code>, <code>garrickhagon</code>,
|
||||
<code>jeffhays</code>, <code>vaati</code>, <code>wilwheaton</code>; ask for one by name and
|
||||
you get it. No key, but it is a house-hardware door: it answers on the house network only</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Kokoro</strong></td>
|
||||
<td>your own browser, or Frank's own terminal — no server in the loop</td>
|
||||
<td>instant plain narration, quick and cheap</td>
|
||||
<td>about two dozen preset voices with coded names (<code>af_heart</code>,
|
||||
<code>bm_george</code>…). It cannot clone, and it will never answer to the five names
|
||||
above</td>
|
||||
</tr>
|
||||
</table>
|
||||
<ul>
|
||||
<li><strong>The AI chat's speaker button is Kokoro.</strong> That is why it offers a
|
||||
different list of names, and why it starts talking a moment before anything on the server
|
||||
could. It speaks in your browser, on your machine.</li>
|
||||
<li><strong>The podcast maker uses the clones.</strong> An episode's two hosts are
|
||||
<code>wilwheaton</code> and <code>vaati</code>, rendered by <code>frank-tts</code>. That door
|
||||
is Frank's for now.</li>
|
||||
<li><strong>Frank's coding agent narrates through the clones too</strong> — the default
|
||||
voice is <code>wilwheaton</code>.</li>
|
||||
<li><strong>Want your own voice in the list?</strong> Ask Frank. It needs about twenty
|
||||
seconds of you talking plainly, once; the voice then carries your name.</li>
|
||||
</ul>
|
||||
<p class="foot-note">The exact weights: <code>frank-tts</code> is a Qwen3 talker
|
||||
(1.7 billion parameters, 4-bit) plus its 12 Hz audio codec, driven by
|
||||
<code>qwentts.cpp</code>; Kokoro is a much smaller 82-million-parameter model whose voices are
|
||||
baked in. A cloned voice is not a setting — it is a directory of reference audio on the
|
||||
server, which is why adding one is a fifteen-minute job and not a checkbox. Programmers on the
|
||||
house network: <code>POST http://192.168.0.143:7772/v1/audio/speech</code> with
|
||||
<code>{"input": "…", "voice": "vaati"}</code> gets you WAV or MP3, and
|
||||
<a href="http://192.168.0.143:7772/v1/audio/voices"><code>/v1/audio/voices</code></a> answers
|
||||
the live list, so this page can never lie to you for long.</p>
|
||||
</section>
|
||||
|
||||
<section id="api-access">
|
||||
@@ -401,7 +452,8 @@ one you were given? Ask Frank to widen the key.</p>
|
||||
scripts it needs (or an extension is). Allow scripts for that site, or try another browser.
|
||||
Still looping? Ask Frank — the guard can be told you're a person.</li>
|
||||
<li><strong>"Invalid password" on the front desk.</strong> Right password, wrong door: the
|
||||
family login does not open Jellyfin, Plex or the e-book door.</li>
|
||||
family login does not open Plex or the e-book door. Jellyfin does take it, but only for the
|
||||
names Frank has given library access.</li>
|
||||
<li><strong>The AI is slow.</strong> Each model only holds a couple of conversations at once
|
||||
(two, three or four, depending which). It is a queue, not a crash — or Frank is
|
||||
mid-conversation; the big one is on the same machine as the films.</li>
|
||||
|
||||
@@ -57,8 +57,8 @@
|
||||
{
|
||||
"name": "Calibre-Web",
|
||||
"category": "user",
|
||||
"section": "Media Library Apps",
|
||||
"description": "E-book library. Work in progress: not open to family users yet.",
|
||||
"section": "Work in progress",
|
||||
"description": "E-book library. Not open to family users yet — ask Frank.",
|
||||
"url": "",
|
||||
"docs": "",
|
||||
"probe": "192.168.0.235:8083",
|
||||
@@ -70,8 +70,8 @@
|
||||
{
|
||||
"name": "RomM",
|
||||
"category": "user",
|
||||
"section": "Media Library Apps",
|
||||
"description": "ROM and emulator library. Work in progress: not open to family users yet.",
|
||||
"section": "Work in progress",
|
||||
"description": "ROM and emulator library. Not open to family users yet — ask Frank.",
|
||||
"url": "",
|
||||
"docs": "",
|
||||
"probe": "192.168.0.235:8085",
|
||||
@@ -110,7 +110,7 @@
|
||||
"name": "Jellyfin",
|
||||
"category": "user",
|
||||
"section": "Media Library Apps",
|
||||
"description": "Open source media server for the film and TV library: its own logins (no Authentik here), so ask Frank for an account.",
|
||||
"description": "Open source media server for the film and TV library. Sign in with your franksdank.cloud username and password — the same login as the rest of the house; ask Frank if yours isn't in yet.",
|
||||
"url": "http://jelly.franksdank.cloud",
|
||||
"docs": "",
|
||||
"probe": "192.168.0.235:8096",
|
||||
@@ -122,8 +122,8 @@
|
||||
{
|
||||
"name": "Seerr",
|
||||
"category": "user",
|
||||
"section": "Media Library Apps",
|
||||
"description": "Download requests. Work in progress: not open to family users yet.",
|
||||
"section": "Work in progress",
|
||||
"description": "Download requests. Not open to family users yet — ask Frank.",
|
||||
"url": "",
|
||||
"docs": "",
|
||||
"probe": "127.0.0.1:5055",
|
||||
@@ -136,7 +136,7 @@
|
||||
"name": "Marreta",
|
||||
"category": "user",
|
||||
"section": "Web Apps",
|
||||
"description": "Paywall sledgehammer: hand it an article URL and it drags the page back through the VPN with the paywall chrome, cookie wall and trackers stripped; a headless browser renders the JS-heavy ones. Best effort — hard paywalls and login walls still win.",
|
||||
"description": "Paywall sledgehammer: hand it an article URL and it will attempt to smash its paywall. Note: does not work on all sites.",
|
||||
"url": "http://marreta.franksdank.cloud",
|
||||
"docs": "",
|
||||
"probe": "127.0.0.1:8082",
|
||||
|
||||
@@ -37,7 +37,7 @@ PY
|
||||
import json, sys
|
||||
data = json.load(open(sys.argv[1]))
|
||||
allowed = {
|
||||
"user": {"AI", "Web Apps", "Media Library Apps"},
|
||||
"user": {"AI", "Web Apps", "Media Library Apps", "Work in progress"},
|
||||
"admin": {"Media Library Apps", "Observability", "AI", "Infrastructure"},
|
||||
}
|
||||
for svc in data:
|
||||
|
||||
@@ -41,8 +41,8 @@ setup() {
|
||||
grep -q "repeat(auto-fill" "$BASE/style.css"
|
||||
}
|
||||
|
||||
@test "the user page keeps the AI section at the bottom" {
|
||||
grep -q "user: \['Web Apps', 'Media Library Apps', 'AI'\]" "$BASE/app.js"
|
||||
@test "the user page puts the Work in progress section last, below AI" {
|
||||
grep -q "user: \['Web Apps', 'Media Library Apps', 'AI', 'Work in progress'\]" "$BASE/app.js"
|
||||
}
|
||||
|
||||
@test "WIP badges distinguish open from closed work" {
|
||||
|
||||
Executable
+361
@@ -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 '<Preferences PlexOnlineToken="stubplextoken"/>\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 <path> <scanned_at epoch>
|
||||
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 '<Preferences/>\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"* ]]
|
||||
}
|
||||
Reference in new issue
Block a user