--- type: reference description: "Audiobookshelf on Dankserver: quadlet shape, array bind, SSD state, API bootstrap, authentik SSO, public vhost, laws." tags: - dankserver - audiobooks - quadlet timestamp: 2026-11-03 --- # 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) — **Doors:** LAN `http://192.168.0.235:8000/audiobookshelf` (plain HTTP, nothing in front) and public `https://audiobooks.franksdank.cloud/audiobookshelf/` — Caddy straight to the container, **exempt from the guard chain** because the app must answer non-browser clients ([[Caddy]] *Unguard a vhost*). The public name is the address every client carries: Pi-hole answers it with `192.168.0.143` on the LAN, Porkbun answers it with the WAN address outside, and Caddy serves a real certificate either way. ## 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 ()` / `exists` | | scan | `POST /api/libraries//scan` | `scan: accepted` | | proof | `GET /api/libraries//items?limit=1` | `items: ` | 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 `https://audiobooks.franksdank.cloud/audiobookshelf` — the base path belongs in the address — username `frank`, and the same password as the web UI, or the SSO button. One address works at home and away: on the LAN Pi-hole answers that name with `192.168.0.143` and Caddy dials the container; off-LAN the same name resolves to the WAN address and takes the same route (inbound 443 is forwarded at the router — fetched from a datacenter node 2026-11-03). The raw LAN address still answers and is the break-glass door whenever Caddy is down. `/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 `download: true` on the account is what makes offline listening work. **A home device that bypasses Pi-hole cannot reach it.** The G36 does not hairpin: resolving the name to the WAN address from inside the LAN times out (measured 2026-11-03). Only a client pinned to an external resolver hits this — everything using the router's DNS gets the LAN answer. **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. ## Listeners (accounts) The door is public now, so the account is the only gate between a listener and the library. Two ways in, both per listener: - **Local account** — **Settings → Users → Add User**: type `user`, tick only the libraries they may hear, and leave `download` off unless they need offline listening. Hand them the server address above and their own password. - **Household SSO** — create the authentik account, and that is the whole of it: the first **Sign in with Authentik** auto-registers the ABS account (`authOpenIDAutoRegister`) as a listener, because the claim grants that role to every signer. To shut a listener off, disable or delete their user in Audiobookshelf — no IdP group membership can withhold it. Nothing rate-limits `/audiobookshelf/api/login` — this Caddy build carries no `rate_limit` module and ABS 2.37.2 ships no lockout — so the form is a guessing target exactly like `jelly.`, `plex.` and the *arr logins already are. The mitigation is credential hygiene: long unique passwords per listener, never a reuse of the root password. ## 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 admin group, 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: every signer gets `user`; `audiobookshelf_admins` adds `admin`. Nothing in the IdP can deny the listener role — the login itself is the gate (law since 2026-11-03; the `audiobookshelf_users` group was retired, it had zero members and every household account was being refused) | | 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) | | Guard state of the door | none since 2026-11-03: proxy provider `audiobooks-proxy` and application "Audiobookshelf Guard" were deleted and the embedded outpost un-claimed them, because that chain answers a native client with challenge HTML ([[Caddy]] *Unguard a vhost* for the order). The SSO relying party `audiobookshelf` above is a different object and stays | | Redirect URIs on the RP | `/auth/openid/callback` **and** `/auth/openid/mobile-redirect` for **both** origins (LAN address and public name): the app sends the mobile URI belonging to the door it was configured with, and STRICT matching answers an unregistered one with "Redirect URI Error" | With `authOpenIDAutoRegister` on, a first sign-in creates the account and the claim alone decides its type — and the claim always carries `user`, so **the IdP is now the library gate**: any authentik account that can reach this application becomes a listener. That is the intent (the household signs in with the accounts it already has), which makes two things worth checking when they change: whether authentik's own invitation/signup flow stays something only Frank drives, and whether an access policy should be bound to the `audiobookshelf` application to narrow who may even reach the login. Revoking one person is a disable/delete of their ABS user; revoking everyone is that policy. Both halves — authentik's provider row and ABS's settings — are asserted end to end by the Playwright gate named in *Verification*. `23-` signs in as `qa-e2e-admin` and must land inside the app with site-admin rights; `24-` signs in as the standard identity, which is in no Audiobookshelf group, and must land as a listener. Together they pin the escalation and the default: a mapping that stops granting silently locks the household out, and a mapping that over-grants turns the household into site admins. ## 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}}' `. - **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 `/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 guarded door has to tell the truth about its scheme.** ABS validates every OIDC `callback` against the origin it believes it is served from — `getRequestOrigin()` is `x-forwarded-proto://Host` (`server/utils/requestUtils.js`, read in the pinned digest 2026-11-03) — so behind a TLS edge that reaches the container over plain http it computes `http://audiobooks.franksdank.cloud` and answers its own https callback with a 400 "Invalid callback URL - must be same-origin" before the IdP is ever contacted. The Caddy `:8017` route pins `header_up X-Forwarded-Proto https` for exactly that reason ([[Caddy]] *Guard a new vhost*); the LAN door needs nothing, because there browser and server agree on `http`. - **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]]). - **One door for every client, and it sits outside the guard chain on purpose.** `https://audiobooks.franksdank.cloud/audiobookshelf` is what browsers and apps use; `http://192.168.0.235:8000/audiobookshelf` is the raw LAN socket that same Caddy dials, so it doubles as break-glass when the proxy is down. Behind the chain (until 2026-11-03) the vhost answered the phone app's `/ping` and `/socket.io/` handshake with Anubis' challenge HTML — Anubis demands a JavaScript proof and the outpost needs a session cookie, and a native client solves neither — so the app evaluated `'isInit' in e.data` on a non-object, reported "Failed to contact server", and the container's request log never recorded the attempt. Podcast RSS and OPDS readers died the same death. ABS's own auth is the gate, as it is for [[Plex]] and [[Jellyfin]] ([[Caddy]] *Unguard a vhost*). - **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//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 "" 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 gates `tests/e2e/specs/23-audiobookshelf-sso.spec.ts` and `tests/e2e/specs/24-audiobookshelf-sso-default-listener.spec.ts`, run by `tests/e2e/run.sh` at the end of every `deploy.sh`. `23-` (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. `24-` (the `standard` project) signs in as `qa-e2e`, an account in no Audiobookshelf group, and must come back `type: "user"`: the default grant that lets the household in, so a scope-mapping regression surfaces as a failed sign-in in the gate instead of as a friend's complaint. The public door is pinned by the guard audit `tests/e2e/specs/14-guard.spec.ts`, which now carries an **exemption** assertion where it used to carry a guarded one: an anonymous raw client must get the app's own `{"success":true}` on `/ping`, a machine-format socket handshake, and the app's own `401` on `/audiobookshelf/api/libraries` — never challenge HTML. `tests/anubis.bats` pins the same shape in the Caddyfile: the vhost never proxies to `anubis:8923`, and the post-auth listener carries no `@audiobooks` route. 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.