The page claimed the account can only be created from a LAN address; measured,
the door is POST /init and it answers plain text. Replaced with the bootstrap the
deployer now runs, and with the doors a consumer actually needs: /status reports
isInit with no /api prefix, the item count rides /api/libraries/{id}/items ->
total (the library DTO has no totals), and the list DTO carries media.numTracks
while /api/items/{id} returns media.audioFiles.
New law: the folder watcher cannot cross the rootless podman bind — the same gap
as Jellyfin's auto-scan note — so files added on the host stay invisible until a
scan runs, and the deployer's re-run of the bootstrapper is the cheap trigger.
Testing Policy lists the new seam; index.md regenerated from frontmatter.
8.2 KiB
type, description, tags, timestamp
| type | description | tags | timestamp | |||
|---|---|---|---|---|---|---|
| reference | Audiobookshelf on Dankserver: quadlet shape, the read-only array bind, state on the SSD, the API bootstrap, laws. |
|
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_PATHandROUTER_BASE_PATH; theABS_*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: withABS_PORT=8000the server listened on:80under base path/audiobookshelf, sosystemctlsaidactive (running)while every probe of:8000was 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/metadataon 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/.nfosidecars 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 andRequiresMountsFor=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.shis the only gate that works here — same law the media servers obey (Quadlet Lifecycle). - The
HealthCmdmust usewget. The image is Alpine and shipswget, notcurl; acurlhealth command reads a perfectly healthy server as unhealthy andRestart=alwaysturns that into a loop. - The image pin is the amd64 manifest digest, not the index digest.
podman inspectreports the platform manifest (RepoDigests[0]) for what was pulled, which differs from the manifest-list digestlatestpoints at. Pin the one this host runs.latestis newer than the newest version tag: upstream stopped publishing version tags after2.19.0whilelatestkept moving, so "highest version tag" is stale here — read the index, don't guess from tags. IOWeight=10is inert without the host drop-ins.dankserver/host/systemd/carriesuser.slice.d/io-accounting.confand theuser@1000delegate, 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.targetin the unit file is what carries the service across a reboot (Quadlet Lifecycle). - LAN-only until Frank says otherwise.
0.0.0.0:8000with 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 inAGENTS.md). - There is no
/setuproute; the creation door isPOST /init./status(no/apiprefix) reportsisInit= "a root user exists",POST /initwith{"newRoot":{"username","password"}}creates it,POST /loginreturns the bearer every/api/*route wants.POST /setupanswersUnauthorized— that is a missing route wearing an auth message. Read offdist-server/server/Server.jsinside the pinned digest rather than from a forum. - A 200 may carry plain text.
res.sendStatus(200)answers the bodyOK, and/initand the scan route use it; a client that parses every success as JSON crashes on the one call that matters (tests/audiobookshelf_init.batspins it). - The item count is not a field on the library DTO. The door is
GET /api/libraries/<id>/items?limit=1→total. A poller that guesses a plausible name (numItems) prints0forever beside a scan that is working. - A fresh scan is the only thing that notices new audio.
inotifydoes 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.
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).