Compare commits

...
Author SHA1 Message Date
Frank bad78079d7 feat(homepage): the not-open-yet apps get their own section, below AI
Calibre-Web, RomM and Seerr sat inside "Media Library Apps" carrying a WIP
badge and a "not open to family users yet" sentence each, so the household
grid mixed doors they can use with doors they cannot. They now render under
their own "Work in progress" heading, placed after AI on the user page, and
the copy drops the restated "Work in progress:" for the ask-Frank line the
section heading no longer needs to repeat.

- app.js: SECTION_ORDER.user gains "Work in progress" last.
- services.json: the three cards' section, plus one-line copy trim.
- homepage_config.bats / homepage_pages.bats: the section contract now
  includes the new heading and pins the new order (the old test pinned AI
  last, which this change deliberately overturns).
- Homepage.md: the classification list and the SECTION_ORDER/wip contract
  lines catch up (the user order in the page was already stale, and "down"
  was an undocumented wip value).

The admin page is untouched: its WIP cards (Gluetun, mariadb, valkey,
Postgres) are plumbing inside Infrastructure, not applications.
2026-11-04 13:09:16 -08:00
dank cae283047f Merge pull request 'docs(authentik,openwebui): the roles claim mints the OpenWebUI role; jason joins the census' (#144) from docs/authentik-jason-provision into main 2026-11-04 12:57:07 -08:00
dank 51007ac604 Merge pull request 'fix(homepage): Jellyfin takes the family login; document the voice models' (#145) from docs/homepage-jellyfin-login-and-tts into main 2026-11-04 12:56:44 -08:00
Frank f7c41966b0 fix(homepage): Jellyfin takes the family login; document the voice models
Jellyfin authenticates against Authentik over LDAP, so telling the household
that it "will not take the family password" sent people to the wrong door. The
tile's provider id (alex -> LdapAuthenticationProviderPlugin) and a live bind +
memberOf search against the running outpost are the proof; six places repeated
the claim, and all six now say to sign in with the franksdank.cloud username and
password. Added the failure mode that actually happens: a password that works on
the homepage but is refused here means the name is not in jellyfin_users yet.
Plex and the e-book door stay the two genuinely separate accounts.

Marreta's tile loses its VPN/headless-browser mechanics and keeps one honest
clause: "does not work on all sites".

The models page gains the TTS pair the household keeps meeting: frank-tts
(Qwen3 talker + 12 Hz codec on the iGPU, five cloned voices, live list at
/v1/audio/voices) and Kokoro (82M, stock presets, runs in the user's own
browser) - which is why the AI chat's speaker list never matches the server's.

Verified in a browser against an isolated copy of the tree: both tiles render
the new copy, #models carries both tables, zero broken images, and every
retired phrase is absent from the rendered text. ./tests/run.sh: 841 pass.
2026-11-04 12:49:42 -08:00
Frank 25114ca622 Merge remote-tracking branch 'origin/main' into wt/20261104-104444 2026-11-04 12:42:19 -08:00
Frank a07f492b89 docs(authentik,openwebui): the roles claim mints the OpenWebUI role
Provisioned jason (pk 24, human-user template) and drove his first SSO
login in a clean browser: the row appeared as role='user' with chat and
wilwheaton TTS working, no manual step. The pending-then-flip law is
therefore wrong for a claim-carrying account - ENABLE_OAUTH_ROLE_MANAGEMENT
+ the roles scope decide it, and 0.12.5 refuses a claim-less login with 403
instead of falling back to ui.default_user_role. Census gains jason.
2026-11-04 12:42:19 -08:00
dank 9961ed62c0 Merge pull request 'docs(jellyfin,authentik): the LDAP admin filter is inert; the auth door lives in Users' (#143) from docs/jellyfin-ldap-sso-laws into main 2026-11-04 12:27:00 -08:00
Frank 70be24e9d5 docs(jellyfin,authentik): the LDAP admin filter is inert; the auth door lives in Users
Diagnosed 'authentik login does not work in Jellyfin' 2026-11-04: nothing was
broken. The Jellyfin admin account 'dank' is a LOCAL-provider account, and the
operator's authentik identity (akadmin) was not in jellyfin_users, so the plugin
search found nothing and Jellyfin fell back to local auth - same UI message,
opposite fix. akadmin is now in jellyfin_users (directory data, DB-owned).

- Jellyfin: the admin-filter law (DN match, so a group-OU base grants nothing;
  memberOf shape is the working one), the AuthenticationProviderId diagnostic,
  re-verified LDAP path (bind + memberOf search from Dankserver).
- Authentik Outpost Reference: settle the token-intent argument (the plugin
  sends a password, never a token), verify the bind DN live, and record the
  instruments/traps: the outpost journal never shows a bind, Event is keyed by
  created/action, Outpost has no apps, and piping ak shell steals its heredoc.
- Authentik: stop restating the LDAP outpost digest - the unit file owns it.
2026-11-04 12:25:57 -08:00
dank 70aa40a24a Merge pull request 'The --after help names the trigger’s epoch, not a file mtime' (#142) from fix/after-epoch-help into main 2026-11-04 12:25:46 -08:00
dank 8f2c3bb378 docs(scripts): --after names the trigger's epoch, never a file mtime
Both drivers' help text recommended `--after "$(stat -c %Y <journal>)"`. Measured
2026-11-04 that epoch is answered by whatever scan finished before the question was
asked: on Jellyfin it reads an earlier task's EndTimeUtc, and the Plex door cannot be
a waiter at all, since PMS stamps section_locations.scanned_at when it accepts the
request. The laws are on the vault pages (main 146c28d); this puts the warning at
point of use, in the text an agent reads before calling the door.
2026-11-04 12:24:56 -08:00
dank 146c28da9f docs(plex,jellyfin): the scan doors are not waiters; supersedes b82655d
`status` compares a recorded timestamp with `--after`. Measured 2026-11-04: Plex
answered `settled` in the same second its own trigger was accepted (stamp
19:46:59Z for a request accepted 19:46:58Z) and still answered `settled` from that
one stamp 30 minutes later while the walk ran. So no epoch survives it — a journal
mtime fails and the trigger's epoch fails, because the trigger satisfies the door it
was asked to wait for. That is the correction of b82655d, which prescribed the
trigger's epoch for this door.

Jellyfin's `EndTimeUtc` only advances when a scan finishes, so there the trigger's
epoch is sound and a file mtime is not; its `wait` already refuses an epoch an
earlier task passes. The proof a scan did its work stays `media-verify.py`
replaying the journal: a scan can clear rows but cannot invent one.
2026-11-04 12:20:59 -08:00
dank b82655d30a docs(plex): --after takes the trigger's epoch; a file mtime reports a false settle
The page's own example passed the journal mtime. Measured 2026-11-04: a chain
using it fell straight through its wait loop, because status only asks whether
the section has scanned since that epoch and the section had been walked earlier
the same day. The verify that followed read a database that had not yet processed
that day's renames — a false success, which is the direction that gets believed.
2026-11-04 12:17:20 -08:00
dank 0161ab2aa8 Merge pull request 'Media refresh drivers: the two scan doors become commands' (#140) from feat/media-refresh-drivers into main 2026-11-04 12:15:38 -08:00
dank 085a5d570c Merge pull request 'docs(openwebui): the browser-kokoro switch is the user's settings row, not browser storage' (#141) from docs/openwebui-tts-kokoro-user-row into main 2026-11-04 12:14:35 -08:00
Frank 9ca69d4e7e Merge remote-tracking branch 'origin/main' into wt/20261104-104444 2026-11-04 12:09:57 -08:00
Frank 64b06b9426 docs(openwebui): the browser-kokoro switch is the user's settings row, not browser storage
`wilwheaton` kept failing in OpenWebUI with the 29-name Kokoro enum even
after the dropdown was set to Default. The page's 2026-10-18 conclusion —
"no user.settings row carries an audio override, so the choice lives in that
browser's own storage" — was a probe-depth bug: the personal audio settings
live in table `user`, and the 0.12.x front end nests them under `ui`
(`settings.ui.audio.tts.engine`). The row did carry `browser-kokoro`, and the
SPA re-applies the server copy on every load, so the UI fix never stuck.

Corrected facts on the owner page:
- where the override really lives, and the version-dependent nesting;
- repair BOTH engine and voice: engine "" still sends the stored voice, so a
  Kokoro-era `voice: "alloy"` returns as frank-tts's own 400;
- the one-line tells: an `/wasm/ort-wasm-simd-threaded.jsep.wasm` fetch means
  Kokoro.js is live in that browser, and uvicorn logs every request so a
  missing `/api/v1/audio/speech` line is evidence;
- deployed build is 0.12.5 (pin `0b399a8b`, bumped 2026-10-30) — the page
  still claimed 0.11.4 — and the 401 paragraph's duplicated sentence is gone.

Verified 2026-11-04: with the row repaired, `POST /api/v1/audio/speech` as a
real user over the vhost returns 200 audio/mpeg and frank-tts logs
`speech voice=wilwheaton`.
2026-11-04 12:09:57 -08:00
dank e519f6b554 docs(vault): the promo-clip hold is purged; merge main's purge record 2026-11-04 11:59:00 -08:00
Frank 1e0247f29e Merge main into feat/media-refresh-drivers (main moved: the purge record) 2026-11-04 11:58:36 -08:00
dank 71e7e94b94 docs(media): the 2026-11-04 purge, and the gate that proves a hold is safe to delete
The duplicate-prune, TV-duplicate, Ghibli and promo-clip holds went out of the
array trash on Frank's word: ~1.2 TB back. This replaces the three "purge pending
Frank's word" rows with what happened.

Two procedures the page did not carry:

- The purge gate. `REAL: 0` in both databases only says no row points at a moved
  path; it says nothing about whether the film survived. Every held file needs a
  same-work carrier in the live library at >= its size, re-derived at purge time,
  because the relayout renames folders AND keeper files, accents defeat ASCII
  matching (Nausicaä), and a carrier is usually another container at another
  size. That supersedes the 2026-11-01 "re-verify by size" law, which is exactly
  the test that reports survivors as lost.
- The purge mechanics. The repo purger acts on a root's own .AGENTTRASH and
  nothing else, so a partial purge is staged into a scratch root while the
  unapproved buckets sit parked at the array root as .HOLD-<date>-*.

./tests/run.sh: 824 assertions, all pass.
2026-11-04 11:58:21 -08:00
Frank 24d793eb7d docs(vault): the promo-clip hold is 31 clips and one sidecar 2026-11-04 11:30:07 -08:00
Frank 95375683d5 docs(vault): the scan pages name their drivers, and a class no screen sees
Plex.md and Jellyfin.md keep every law and point at the driver that now carries
them, so the procedure has one owner and the pages keep the failure signatures.
Jellyfin.md also records where `jellyfin.db` actually lives on this host (the
quadlet's config bind, not the stock path) and demotes `immutable=1` on the
`ApiKeys` read to the `-shm` fallback, because the database is WAL and
`immutable` skips the log. Plex.md gains the live section keys and the
`scanned_at == scannedAt` fact.

Media Relayout.md's hand-off after an apply names both drivers and states the
ordering law: Jellyfin first and alone, since its one door walks every library
while Plex forks a scanner per section, and the array is what both share.

Media Duplicate Prune.md gains the guard the 2026-11-04 hold taught: 32 files
carrying real film titles were 8-20-second h264 clips (49 MiB, 25 of them in a
`_release-group watermarks/` folder), invisible to every duplicate screen
because nothing shares their size and the feature-length guard hides them. The
census that finds the next batch is a duration floor, not a hash.
2026-11-04 11:29:03 -08:00
Frank e7d7c6eea2 feat(scripts): the two media scan doors become commands
Both media servers' scan procedure lived only as a hand-written curl block in
the vault, and that block has bitten this fleet twice: the escaped-quote form
that reads exactly like a dead API key, and a config `url` line meeting a
command-line URL so curl quietly performs a second transfer and dumps the whole
task table on your stdout. Jellyfin's door is now
`scripts/media-refresh-jellyfin.py` (trigger / status / wait) and Plex's is
`scripts/media-refresh-plex.sh` (trigger / status);
`tests/media_refresh.bats` pins the contracts an operator observes, against a
stub and with no live server.

The Jellyfin `wait` encodes the settle law instead of a poll count: the Scan
Media Library task must be seen Running — or finish while watching, or already
pass the `--after` it is given — before any quiet read counts toward settled.
A watcher that saw no Running otherwise reports the pre-scan library as the
fresh one, which is how a rename looks applied while nothing has rescanned.
Plex's `status` needs no token at all: `section_locations.scanned_at` carries
the same epoch the API prints as `scannedAt` (measured on section 1 today).

Neither driver lets a secret reach argv or a URL: the Jellyfin key rides a
mode-600 curl config as the quoted MediaBrowser form, and the Plex token is
read inside the container, where the HTTPD actually answers, into a config file
that is shredded there.
2026-11-04 11:29:03 -08:00
dank e1bf1d6e80 Merge pull request 'Household docs: rewrite the family guide, and make the model cards tell the truth' (#139) from docs/household-guide-refresh into main 2026-11-03 04:08:09 -08:00
17 changed files with 1106 additions and 188 deletions

No files matched your search

+223
View File
@@ -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()
+130
View File
@@ -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
+1 -1
View File
@@ -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]] |
---
+73 -38
View File
@@ -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
+68 -30
View File
@@ -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
+47 -33
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -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&hellip;</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 &mdash; 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 &mdash; 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 &mdash; 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 &mdash; 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> &mdash; 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>
&mdash; 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 &mdash; 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> &mdash; 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 &mdash; 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 &mdash; ask Frank.</li>
<li>A film with a blank poster is missing artwork, not missing files &mdash; 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 &mdash; <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 &mdash; 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>&hellip;). 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> &mdash; 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&nbsp;billion parameters, 4-bit) plus its 12&nbsp;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 &mdash; 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": "&hellip;", "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 &mdash; 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 &mdash; or Frank is
mid-conversation; the big one is on the same machine as the films.</li>
+8 -8
View File
@@ -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",
+1 -1
View File
@@ -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:
+2 -2
View File
@@ -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" {
+361
View File
@@ -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"* ]]
}