--- type: reference description: "Audiobookshelf on Dankserver: quadlet shape, the read-only array bind, state on the SSD, the API bootstrap, laws." tags: - dankserver - audiobooks - quadlet timestamp: 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, 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=/` — the URL is the plain `host:port`, no `/audiobookshelf` prefix | | 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` | | 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-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` 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. ## 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 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`). - **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 not a discovery arm.** Both arms of `POST /libraries/:id/scan` walk the disk; force additionally re-scans every item that already exists (measured 1:33 against 0:04 on ~4,600 items). Finding new folders needs neither, so the deployer's cheap trigger stays on the plain arm. - **`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 the file — the book never appears — so a missing title beside those lines is library debris, not a server fault. Census of the refusals on the first full scan: 38 of 43 files were 0 bytes, 3 were WAV bytes wearing a `.mp3` name, 2 were unreadable. `stat -c %s` before any investigation of the server. ## 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), `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, and the deployer calling it only after the health gate) and `tests/media_overlay.bats` (only [[Plex]] and [[Jellyfin]] may reference the metadata view).