Files
serverAppConfigs/serverAppConfigs-docs/Dankserver/Audiobookshelf.md
T
Frank f541566714 docs(audiobookshelf): a book is the shallowest audio folder, and it owns the subtree
Measured on the live library: 6 stray files at the root of _unsorted made the
whole collection dump ONE item of 5,693 tracks, with 542 real books folded into
it; no scan subdivides an existing item. Also pinned: the items endpoint hands
back ceil(total/limit)*limit rows because an out-of-range offset repeats the
tail (that is where "exactly 1,500" and "exactly 4,000" came from), and
DELETE /api/items/:id?hard=1 runs fs.remove() on the item's folder - pointed at
a covering item that is the whole subtree, so un-swallowing is a DB-only delete
after the stray root files are filed.
2026-11-02 03:34:28 -08:00

164 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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` |
## 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` 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).
The scan of 1,103 shelves is slow and background; watch `items:` climb from 0,
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}}' <image>`.
- **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/<id>/items?limit=1` → `total`; a poller
that guesses a plausible name (`numItems`) prints `0` forever beside a scan that
is working. An out-of-range `offset` returns the last item rather than an empty
page, so a census that loops "until empty" invents its own total: `limit=500`
against a `total` of 4,628 hands back exactly 5,000 rows — the collected count
is `ceil(total / limit) × limit`, which is why two runs reported "exactly 1,500"
and "exactly 4,000" for one library. Stop on `total`, count *unique ids*, and
take the number after the pass settles (`total` itself 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.
- **A book is the shallowest folder that holds audio — and that item owns the
subtree beneath it.** A folder with audio in its own file list becomes ONE book
and the scanner folds everything below it into that book's tracks instead of
making items: 6 stray files at the root of `_unsorted` turned the whole
collection dump into a single item of 5,693 tracks, titled after whichever file
supplied metadata, with 542 real books inside it. So "hundreds of books are
missing" is a layout fault, not a scan fault — and no scan, forced or not,
subdivides an existing item. Census the layout the server's way: a directory is
a book folder iff its *own* files include an audio extension.
- **`DELETE /api/items/:id` is a DB row; `?hard=1` is `fs.remove()`.** Read
`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 audiobook under it off the
array. Un-swallowing is: file the loose root files with the journal-guarded
mover, then delete the stale covering item with NO query string.
- **`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).