Corrects the law pushed earlier today. The mechanism is `groupFileItemsIntoLibraryItemDirs` (utils/scandir.js), called on a live `recurseFiles` walk of the tree EVERY scan: each file attaches to the FIRST directory along its path that already has a group, so one media file at a collection root owns everything beneath it. Measured: one 949 MB `.cbr` hid 652 book folders behind a single 5,693-track item. - "Media file" is audio OR ebook (`audiobooksOnly=false` -> SupportedAudioTypes ∪ SupportedEbookTypes, 17 audio + 6 ebook extensions). An audio-only census cannot see the file that hides an Audiobooks library. - Deleting the covering item does not fix a shadow: grouping is recomputed from disk and the next scan rebuilds the item (measured: `1 Added`, same path). - Un-shadow by MOVE: one file per folder is ABS's own single-book layout. - `?force=1` is not a discovery arm (both arms walk the disk; force re-scans every existing item, 1:33 against 0:04). - Paging corrected: `offset` is ignored outright, `page` is the parameter. - `DELETE /api/items/:id` is a DB row; `?hard=1` is `fs.remove()` of the whole swallowed subtree. Never pass `hard`. Shape table gains the layout row and the move journal; the stale "1,103 shelves" scan count is destroyed.
12 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 |
| 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 (<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).
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_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, and paging never ends
itself. 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. Paging is onpage(0-based) pluslimit, andoffsetis ignored:limit=500&offset=500returns the same 500 ids asoffset=0, whilepage=1shares none of them. A census that pages onoffsettherefore readsceil(total / limit) × limitrows containing onlylimitdistinct items — the exact origin of the "exactly 1,500" and "exactly 4,000" counts two runs reported for one library. Page onpage, count unique ids, stop ontotal, and take the number after the pass settles (totalmoves during a scan). - 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. - 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), andutils/scandir.js:groupFileItemsIntoLibraryItemDirsassigns 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.cbrsitting 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 isSupportedAudioTypes∪SupportedEbookTypes: 20 audio extensions (includingmp4,mpg,webm) plusepub 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=1is not a discovery arm. Both arms ofPOST /libraries/:id/scanwalk 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/:idis a DB row;?hard=1isfs.remove(). InLibraryItemController.delete,hardDelete = req.query.hard == 1and thenfs.remove(libraryItemPath). Pointed at a covering item, that path is the whole swallowed subtree —hard=1there deletes every book beneath it off the array. Assert the item'spathfirst, and never passhard.Invalid data found when processing inputnames 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.mp3name, 2 were unreadable.stat -c %sbefore 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).