Two censuses this session reported exactly 1,500 and exactly 4,000 books for one library mid-scan. Cause: an out-of-range offset returns the last item instead of an empty page, so a loop written "until the page is empty" manufactures its own total, and total itself moves while a scan is running. Stop on total; count after the pass settles.
147 lines
9.0 KiB
Markdown
147 lines
9.0 KiB
Markdown
---
|
|
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 — two runs
|
|
reported exactly 1,500 and 4,000 for one library — and `total` itself moves while
|
|
a scan runs. Stop on `total`, and take the count after the pass settles.
|
|
- **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.
|
|
- **`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).
|