Files
serverAppConfigs/serverAppConfigs-docs/Dankserver/Audiobookshelf.md
T
Frank 7e72126a07 docs(audiobookshelf): an SSO identity cannot land on an existing account
authOpenIDMatchExistingBy is unset on this deployment, so ABS matches an
OpenID userinfo on sub only and auto-registers everyone else. A new authentik
username that equals an existing ABS username therefore dies on the unique
constraint inside the callback, reported only as 'Unauthorized' - which is
exactly what a future agent would hit by 'just creating a frank user' in the
IdP to reach the root account. Records the two real doors (match by username,
which links and keeps root, since setUserGroup refuses to downgrade a root;
or a non-colliding name), and corrects the admin-membership row: akadmin is
the operator's own authentik account, already granted site admin.
2026-11-02 20:29:30 -08:00

21 KiB
Raw Blame History

type, description, tags, timestamp
type description tags timestamp
reference Audiobookshelf on Dankserver: quadlet shape, the read-only array bind, SSD state, API bootstrap, authentik SSO, laws.
dankserver
audiobooks
quadlet
2026-11-02

Audiobookshelf

Audiobookshelf (ABS) serves the Entropy Volume audiobook library to browsers and phones: it scans /mnt/entropy/Audiobooks, keeps its own SQLite library with per-listener playback positions, and streams the audio. It is not a media server — it shares nothing with Plex or Jellyfin and owns no files on the array.

Host: Dankserver — Unit: audiobookshelf.service (rootless Quadlet, user manager) — Internal-only: LAN address http://192.168.0.235:8000/audiobookshelf, no vhost, no DNS name.

Shape

Item Value
Unit file dankserver/audiobookshelf/quadlet/audiobookshelf.container (the authority for every number here)
Image ghcr.io/advplyr/audiobookshelf pinned by amd64 manifest digest — see Laws
Port Environment=PORT=8000 + PublishPort=8000:8000 — web UI and API on one port
Base path Environment=ROUTER_BASE_PATH=/audiobookshelf — every URL below carries this prefix, and the shipped SPA cannot boot without it (Laws)
Network pasta (standalone; publishes nothing to other containers)
Audio bind /mnt/entropy/Audiobooks:/audiobooks:ro — read-only, by design
State /home/dank/Containers/audiobookshelf/config → /config, …/metadata → /metadata (both NVMe)
Mount gate ExecStartPre=…/wait-for-mount.sh /mnt/entropy
Health wget against /healthcheck inside the container, 30 s / 3 retries
I/O share IOWeight=10 — a scan loses the array to every other tenant
Self-healing Restart=always, RestartSec=5, WantedBy=default.target
Deploy ./scripts/deploy.sh dankserver --service audiobookshelf
Handler deploy_audiobookshelf in scripts/deploy-remote.sh
Single sign-on authentik provider audiobookshelf; the local frank account stays enabled as break-glass — see Single sign-on
Library layout one book per folder — ABS groups by the first directory holding a media file (see Laws); every move is journalled at /mnt/entropy/.media-campaign/media-relayout-audiobooks.jsonl

Bootstrap

scripts/audiobookshelf-init.sh — run by the deployer after the health gate, so a deployed ABS is a usable ABS: /healthcheck answers 200 on a server with no user, no library and no scan, and nothing else in the deploy notices the difference. The script is idempotent; re-running it costs one incremental scan.

Step Door Printed
credential ~/.config/secrets/audiobookshelf (mode 600, generated once, host-side) credential: created …
account POST /init with {"newRoot":{"username","password"}} account: created (frank) / already registered
session POST /login → bearer token token: obtained (root)
library POST /api/libraries, folder /audiobooks, provider audiobook library: created Audiobooks (<id>) / exists
scan POST /api/libraries/<id>/scan scan: accepted
proof GET /api/libraries/<id>/items?limit=1 items: <total>

Sign in at http://192.168.0.235:8000/audiobookshelf as frank; the password is awk -F= '/^ABS_ADMIN_PASSWORD=/{print $2}' ~/.config/secrets/audiobookshelf on Dankserver. Change it in Settings → Users → frank → Password; the secret file then holds a stale value and only matters again if the config bind is ever destroyed (a fresh DB makes the script create a new owner from it).

A full scan is slow and runs in the background; watch the items: total climb, never /healthcheck. It also reads files that are genuinely broken and logs [AudioFileScanner] Invalid data found when processing input per file — that is the file, not the bind: the same line count grows while the total climbs.

Phone app

The official Audiobookshelf app connects with Server Address http://192.168.0.235:8000/audiobookshelf — the base path belongs in the address — username frank, and the same password as the web UI, or the SSO button; plain HTTP. Measured from a second host 2026-11-02 and re-measured on both base paths: /ping ({"success":true}) and /healthcheck (OK) answer at the ROOT whatever ROUTER_BASE_PATH says, so the app's connectivity probe works before it resolves the base path at all, and iptables -S INPUT carries no rule for 8000 — a phone on 192.168.0.0/24 connects with no further setup, and download: true on the root account is what makes offline listening work. Away from the LAN — see the LAN-only law, and note that the guard chain is not the remedy there.

The login token is at user.token, not top level. POST /login answers { user, serverSettings, ereaderDevices, Source, userDefaultLibraryId } and the bearer sits inside the user object. A client that reads response.token gets the empty string, sends Authorization: Bearer with nothing after it, and every /api/* call then answers 401 as though the password were wrong.

Single sign-on (authentik)

Both doors take the household identity: the web UI puts Sign in with Authentik beside the local form, and the phone app offers SSO on its server-address screen. The local frank account stays enabled on purpose — authActiveAuthMethods is local and openid, and local is how you get in when the IdP is down.

Piece Owner
Provider audiobookshelf, the two groups, the scope mapping scripts/authentik-audiobookshelf-sso.sh — re-run it after an authentik rebuild, because this state lives in the IdP's database and a blueprint re-import reverts it silently
Client secret ABS_OIDC_CLIENT_SECRET in ~/.config/secrets/audiobookshelf on Dankserver (mode 600; never in the repo, a URL or an argv)
The ABS half: issuer, the four IdP URLs, claim, auto-register scripts/audiobookshelf-init.sh, armed automatically when that secret exists in the credential file
Role at first SSO sign-in the abs_groups claim: audiobookshelf_admins → admin, audiobookshelf_users → user, in neither → denied (fail closed)
Admin group members akadmin (the operator's own authentik admin account — sign in as it and the claim grants site admin) and qa-e2e-admin (the identity the deploy gate signs in as, so the script re-adds it every run; drop it and the gate fails, not a phone)

The IdP saying yes is not the same as being allowed in: with authOpenIDAutoRegister on, a first sign-in creates the account, and the claim alone decides the type. A user in neither group is refused after a successful authentication — that is the mapping working, not a broken login.

Both halves — authentik's provider row and ABS's settings — are asserted end to end by the Playwright gate named in Verification: a sign-in as qa-e2e-admin that must land inside the app with site-admin rights.

Laws

  • Speak the image's env names, not the README's. The server reads PORT, CONFIG_PATH, METADATA_PATH and ROUTER_BASE_PATH; the ABS_* family printed by the server's own README is the npm package's naming and is inert in this image. Measured 2026-11-02 on the first deploy: with ABS_PORT=8000 the server listened on :80 under base path /audiobookshelf, so systemctl said active (running) while every probe of :8000 was refused and the deploy gate failed at 180 s. Two seconds with the pinned digest settles it: podman inspect --format '{{.Config.Env}}' <image>.
  • The URL carries /audiobookshelf, and that is not a style choice. The SPA in /app/client/dist/index.html is built with routerBasePath:"/audiobookshelf", so it asks for its assets under that prefix whatever the server is mounted at. Mounted at /, every script request falls through to the SPA fallback, comes back text/html, Chrome refuses to execute it, and the page never leaves "Loading…" — while /healthcheck, the API and the phone app stay perfectly healthy, which is how this hid behind an API-only verification (measured 2026-11-02). Prove a UI claim in a browser, never from /health.
  • The OIDC redirect subfolder is a value you send, not a default you inherit. The 2.17.4 upgrade only fills authOpenIDSubfolderForRedirectURLs on a database that ALREADY had OIDC enabled, so a DB created before SSO leaves it unset and the server builds http://host/undefined/auth/openid/callback. authentik answers the authorize request with "Redirect URI Error" and nothing on the ABS side looks wrong (measured 2026-11-02). The provider's registered URI must equal <origin><subfolder>/auth/openid/callback, so each script derives the value from one variable of its own (ABS_URL, ABS_BASE) and tests/audiobookshelf_init.bats pins what reaches the wire.
  • An ORM-created provider has no grant types. authentik 2026.8 defaults grant_types to [], and /application/o/authorize/ then refuses the code flow with oauthlib's generic "The request is otherwise malformed" — the wire names nothing, the server log line that names it is Invalid grant_type for provider. Worse, the redirect-URI check runs first, so a provider carrying both faults shows only "Redirect URI Error" until the URI is fixed, and the second wall appears after the fix ships (measured 2026-11-02: the browser reached authentik, got code+state, and ABS still bounced to /login?error=undefined). The script sets authorization_code + refresh_token; a blueprint re-import reverts the row silently.
  • The claim mapping is not the whole property_mappings list. A provider holding only the custom mapping grants a token scoped shorter than the scopes ABS requests (openid profile email abs_groups): the code is issued and exchanged normally, and ABS's /userinfo call then dies insufficient_scope (The request requires higher privileges than provided by the access token) — a fault that reads like an ABS bug and is an IdP row. Attach the built-in OpenID openid/profile/email mappings beside the claim mapping, which is what every working RP on this IdP carries (measured 2026-11-02).
  • With authOpenIDMatchExistingBy unset, an SSO identity never lands on an existing ABS account, and a name collision is a hard denial. In that mode findUserFromOpenIdUserInfo matches on sub only, so every first sign-in auto-registers a NEW user; if the IdP username equals an ABS username — the local root frank is the one here — the create hits the unique constraint and the callback dies Unauthorized. So "create a frank user in authentik" does not reach the root account: either set authOpenIDMatchExistingBy to username (links the IdP account to the existing one, and setUserGroup refuses to downgrade a root — root plus an admin claim is allowed, root plus user is denied) or pick a non-colliding IdP name. Read off server/models/User.js and server/auth/OidcAuthStrategy.js in the pinned digest (2026-11-02).
  • The audio bind stays :ro. ABS writes nothing that belongs beside the audio: its library, covers, progress and sidecar metadata all land in /metadata on the SSD. A writable bind would put stray writes on the spindle and hand the array to a service with no reason to hold it. If per-book .opf/.nfo sidecars on disk are ever wanted, the rollback is one line — point the audio bind at the Media Metadata View the media servers already use — but that view's upper layer exists for those two servers' sidecars, so a third writer needs a reason first (Media Metadata View).
  • Do not bind the raw array without the mount gate. In a rootless unit ConditionPathIsMountPoint= is fail-dead and RequiresMountsFor= is inert, so a missing array does not stop the start: podman invents an empty directory, ABS indexes it, and an empty library becomes recorded truth. wait-for-mount.sh is the only gate that works here — same law the media servers obey (Quadlet Lifecycle).
  • The HealthCmd must use wget. The image is Alpine and ships wget, not curl; a curl health command reads a perfectly healthy server as unhealthy and Restart=always turns that into a loop.
  • The image pin is the amd64 manifest digest, not the index digest. podman inspect reports the platform manifest (RepoDigests[0]) for what was pulled, which differs from the manifest-list digest latest points at. Pin the one this host runs. latest is newer than the newest version tag: upstream stopped publishing version tags after 2.19.0 while latest kept moving, so "highest version tag" is stale here — read the index, don't guess from tags.
  • IOWeight=10 is inert without the host drop-ins. dankserver/host/systemd/ carries user.slice.d/io-accounting.conf and the user@1000 delegate, and the deployer installs both; without them systemd silently drops the weight (Quadlet Lifecycle).
  • No systemctl --user enable. Generated Quadlet units cannot be enabled on this host; WantedBy=default.target in the unit file is what carries the service across a reboot (Quadlet Lifecycle).
  • LAN-only until Frank says otherwise. 0.0.0.0:8000 with no reverse proxy in front. Any public vhost requires the Anubis + Authentik chain first, and the outpost proxy provider has to exist before the vhost flips (external exposure policy in AGENTS.md). That chain serves browsers: the phone app is a non-browser client and cannot solve a challenge, so guarding a vhost does not make remote listening work — remote listening wants a VPN into the LAN, after which the LAN address in Phone app is correct unchanged.
  • There is no /setup route; the creation door is POST /init. /status (no /api prefix) reports isInit = "a root user exists", POST /init with {"newRoot":{"username","password"}} creates it, POST /login returns the bearer every /api/* route wants. POST /setup answers Unauthorized — that is a missing route wearing an auth message. Read off dist-server/server/Server.js inside the pinned digest rather than from a forum.
  • A 200 may carry plain text. res.sendStatus(200) answers the body OK, and /init and the scan route use it; a client that parses every success as JSON crashes on the one call that matters (tests/audiobookshelf_init.bats pins it).
  • The item count is not a field on the library DTO, and paging never ends itself. The door is GET /api/libraries/<id>/items?limit=1 → total; a poller that guesses a plausible name (numItems) prints 0 forever beside a scan that is working. Paging is on page (0-based) plus limit, and offset is ignored: limit=500&offset=500 returns the same 500 ids as offset=0, while page=1 shares none of them. A census that pages on offset therefore reads ceil(total / limit) × limit rows containing only limit distinct items — the exact origin of the "exactly 1,500" and "exactly 4,000" counts two runs reported for one library. Page on page, count unique ids, stop on total, and take the number after the pass settles (total moves during a scan).
  • A fresh scan is the only thing that notices new audio. inotify does not cross a rootless podman bind — measured for Jellyfin, whose page owns that law — so the in-container watcher sees nothing a host process files onto the array, and the deployer's re-run of the bootstrapper is the cheap scan trigger.
  • One stray media file at a collection root turns the whole collection into one book. Every scan re-walks the tree from disk (scanFolder → recurseFiles), and utils/scandir.js:groupFileItemsIntoLibraryItemDirs assigns each file to the FIRST directory along its path that already has a group: the first file seen at a root owns every deeper file, and the folders below it never become items. One 949 MB .cbr sitting at _unsorted's root collapsed 652 book folders into a single item of 5,693 tracks, titled after whichever file supplied metadata. "Hundreds of books are missing" is a layout fault, not a scan fault.
  • "Media file" means audio OR ebook, so an audio-only census cannot see the file that is hiding the library. With audiobooksOnly=false (the deployed shape) the predicate is SupportedAudioTypes ∪ SupportedEbookTypes: 20 audio extensions (including mp4, mpg, webm) plus epub pdf mobi azw3 cbr cbz. A census reporting "that root holds no loose audio" can be correct and still useless — the file doing the shadowing here was a comic archive.
  • Deleting the covering item does not un-shadow anything. Grouping is recomputed from disk, so the next scan rebuilds the item from the same stray file (measured: DELETE, then scan → 1 Added, the item back at the same path). The fix is the MOVE: give every stray root file a folder of its own (one file per folder is ABS's own single-book layout; formats of one book share a folder), through the journal-guarded mover, then delete the stale item so the old path is not kept, then scan.
  • ?force=1 is neither a discovery arm nor a repair arm. Both arms of POST /libraries/:id/scan walk the disk, so finding new folders needs neither and the deployer's cheap trigger stays on the plain arm. Force additionally re-scans every item that already exists (measured 1:33 against 0:04 on ~4,600 items) and repaired nothing on a library whose residuals were file-level: item count, path set and media-less count identical before and after.
  • DELETE /api/items/:id is a DB row; ?hard=1 is fs.remove(). In LibraryItemController.delete, hardDelete = req.query.hard == 1 and then fs.remove(libraryItemPath). Pointed at a covering item, that path is the whole swallowed subtree — hard=1 there deletes every book beneath it off the array. Assert the item's path first, and never pass hard.
  • Invalid data found when processing input names the file, not the bind. The scanner logs it per file and skips that file; a book whose files ALL fail never becomes an item at all, so the disk-vs-DB folder census shows it as a book with no item. That is corrupt media, not a lost book. Never clear a suspect file with a lenient probe: ffprobe -show_entries format=duration happily returns three hours for the WAV bytes wearing a .mp3 name that the scanner refuses. Read the log's file paths instead, and stat -c %s first — 0-byte files are the common case, and a truncated download reports moov atom not found.
  • A Disc 01 / CD 1 subfolder is part of a book, not a book. The extension census counts those folders; ABS deliberately does not — its grouping attaches their files to the ancestor item (measured: 110 such folders under one item carrying all 192 tracks). Read the basename before believing a disk-vs-DB gap.
  • .cbr/.cbz group like media and never play. They sit in the grouping predicate, so they hide books, but the comic parser throws ERAR_BAD_ARCHIVE (parseComicMetadata → CbrComicBookExtractor) and the item lands with no media. A media-less row beside those lines is the file, not the scanner.
  • A scan is finished when a NEW scan uuid completes. Match Library scan "<uuid>" completed in .... podman logs --since 30m is a sliding window: the set of visible completion lines changes on its own as old lines age out, so a poller keyed on that hash reads a mid-scan number and calls it done.

Verification

./tests/run.sh covers this unit through tests/dankserver_quadlet.bats (boot persistence, the SSD state roots, the :ro array bind, the mount gate, the wget health check, the base path the shipped SPA needs), tests/audiobookshelf_init.bats (the bootstrapper against a stub of the API above: create-once, idempotent re-run, no secret in a URL, refusal by URL when the server does not answer, the deployer calling it only after the health gate, and the SSO arm sending the mounted base path as the redirect subfolder); tests/media_overlay.bats (only Plex and Jellyfin may reference the metadata view); and the live Playwright gate tests/e2e/specs/23-audiobookshelf-sso.spec.ts, run by tests/e2e/run.sh at the end of every deploy.sh — the admin project signs in as qa-e2e-admin against http://192.168.0.235:8000/audiobookshelf and asserts the two things no stub can see: every .js the app loads arrives as JavaScript (a bundle built for the wrong base path arrives as text/html, which boots nothing), and /api/me answers 200 with type: "admin" — true only while grant types, the OpenID scope mappings and the group claim all hold together.

Five book folders hold media no scanner can read, so they stay out of the library: three 2024 Nebula nominees in the _unsorted box (two .m4b with no moov atom, one 36-file .mp3 set that is not MPEG audio at all), Anthony Heald/Bounty Hunter Wars - Book 1 and Star Wars Full Cast Audio Drama/Dark Empire III — the latter two also exist in readable form under their author folders. Fix by re-acquiring the files; the scanner names every one of them in its log.