Audiobookshelf, deployed on Dankserver as a boot-persistent rootless Quadlet, so the
audiobook library on the array finally has a server that understands audiobooks:
its own library, its own logins, per-listener playback positions, browser and phone
clients. It shares nothing with Plex or Jellyfin and owns no file on the array.
The shape that matters
Unit
dankserver/audiobookshelf/quadlet/audiobookshelf.container (authority for every number)
Image
ghcr.io/advplyr/audiobookshelf:latest@sha256:37c2057e…4ef3 — the amd64 manifest
/mnt/entropy/Audiobooks:/audiobooks:ro (1103 shelves, 2.5 T today)
Port
8000:8000, pasta, LAN only — no vhost, no DNS name
Gate
ExecStartPre=…/wait-for-mount.sh /mnt/entropy, health via wget /healthcheck
Every byte the server writes is its own state, and all of it lands on the SSD. The
audio bind is read-only, which makes a stray sidecar write onto the spindle
structurally impossible instead of merely discouraged — the array keeps exactly one
sanctioned write path (scripts/media-view.sh).
Three traps the unit is built around
Bind-source ordering. In a rootless unit RequiresMountsFor= is inert and ConditionPathIsMountPoint= is fail-dead, so a missing array does not stop the
start: podman invents an empty directory, the first scan indexes it, and an empty
library becomes recorded truth. wait-for-mount.sh is the only gate that works
here — the same one copyparty uses.
The health binary. The image is Alpine: wget, no curl. A curl HealthCmd reads a healthy server as unhealthy and Restart=always turns that
into a loop.
The pin form.podman records the linux/amd64 manifest for what it pulled,
not the manifest-list digest latest points at — so the amd64 digest is the pin
that names the build this host runs. Upstream version tags stop at 2.19.0
(2025-02) while latest is a year and a half newer, so latest@digest is the
only form that is both current and exact. Same reasoning as jellyfin/romm.
IOWeight=10 keeps the first-boot scan (one full pass over the tree) behind
playback; the weight is only real because the user.slice accounting drop-in and
the user@1000 delegation exist and the deployer installs them.
Deployer
The four registration touch-points: client gate, remote allowlist + deploy_audiobookshelf
handler + dispatcher, planner SERVICE_ORDER / DANKSERVER_SERVICES / quadlet path map,
and test-changed.sh routing. The handler creates both state roots before the
first start — letting podman invent them leaves the SQLite file unwritable by the
container user on a rootless first boot — then gates on /healthcheck. The library
scan that follows runs in the background and does not gate the deploy.
No systemctl --user enable: generated Quadlet units cannot be enabled on this
host; WantedBy=default.target carries it across a reboot.
Tests
./tests/run.sh → ALL TESTS PASSED, 811 ok (806 before; +5 new assertions in tests/dankserver_quadlet.bats): boot persistence and self-healing, all state on
the SSD with the array bound :ro, the mount gate present, the health command
naming a binary the image actually ships, and the deployer creating both roots
before it starts. tests/media_overlay.bats is deliberately untouched — it still
pins that only Plex and Jellyfin may reference the metadata view, and Audiobookshelf
is a reader that stays out of it.
Homepage: one admin card, LAN url and probe like Tautulli. The card counts in the
e2e specs come from the live registry, so the entry is also the test update. The
service deploys before the homepage ships, so the admin walk never follows a
link to a port nobody owns.
./scripts/deploy.sh dankclaw --service homepage for the card.
One manual step, LAN only: open http://192.168.0.235:8000 and create the owner
account. Audiobookshelf accepts the initial account only from a local-network
address, so it cannot be scripted from the deployer — and the credential should
be yours, not a file on a server.
## What
Audiobookshelf, deployed on Dankserver as a boot-persistent rootless Quadlet, so the
audiobook library on the array finally has a server that understands audiobooks:
its own library, its own logins, per-listener playback positions, browser and phone
clients. It shares nothing with Plex or Jellyfin and owns no file on the array.
## The shape that matters
| | |
|---|---|
| Unit | `dankserver/audiobookshelf/quadlet/audiobookshelf.container` (authority for every number) |
| Image | `ghcr.io/advplyr/audiobookshelf:latest@sha256:37c2057e…4ef3` — the **amd64** manifest |
| State | `/home/dank/Containers/audiobookshelf/{config,metadata}` — NVMe |
| Audio | `/mnt/entropy/Audiobooks:/audiobooks:ro` (1103 shelves, 2.5 T today) |
| Port | `8000:8000`, `pasta`, LAN only — no vhost, no DNS name |
| Gate | `ExecStartPre=…/wait-for-mount.sh /mnt/entropy`, health via `wget /healthcheck` |
Every byte the server writes is its own state, and all of it lands on the SSD. The
audio bind is read-only, which makes a stray sidecar write onto the spindle
structurally impossible instead of merely discouraged — the array keeps exactly one
sanctioned write path (`scripts/media-view.sh`).
## Three traps the unit is built around
- **Bind-source ordering.** In a rootless unit `RequiresMountsFor=` is inert and
`ConditionPathIsMountPoint=` is fail-*dead*, so a missing array does not stop the
start: podman invents an empty directory, the first scan indexes it, and an empty
library becomes recorded truth. `wait-for-mount.sh` is the only gate that works
here — the same one `copyparty` uses.
- **The health binary.** The image is Alpine: `wget`, no `curl`. A `curl`
`HealthCmd` reads a healthy server as unhealthy and `Restart=always` turns that
into a loop.
- **The pin form.** `podman` records the linux/amd64 manifest for what it pulled,
not the manifest-list digest `latest` points at — so the amd64 digest is the pin
that names the build this host runs. Upstream version tags stop at `2.19.0`
(2025-02) while `latest` is a year and a half newer, so `latest@digest` is the
only form that is both current and exact. Same reasoning as `jellyfin`/`romm`.
`IOWeight=10` keeps the first-boot scan (one full pass over the tree) behind
playback; the weight is only real because the `user.slice` accounting drop-in and
the `user@1000` delegation exist and the deployer installs them.
## Deployer
The four registration touch-points: client gate, remote allowlist + `deploy_audiobookshelf`
handler + dispatcher, planner `SERVICE_ORDER` / `DANKSERVER_SERVICES` / quadlet path map,
and `test-changed.sh` routing. The handler creates both state roots **before** the
first start — letting podman invent them leaves the SQLite file unwritable by the
container user on a rootless first boot — then gates on `/healthcheck`. The library
scan that follows runs in the background and does not gate the deploy.
No `systemctl --user enable`: generated Quadlet units cannot be enabled on this
host; `WantedBy=default.target` carries it across a reboot.
## Tests
`./tests/run.sh` → **ALL TESTS PASSED, 811 ok** (806 before; +5 new assertions in
`tests/dankserver_quadlet.bats`): boot persistence and self-healing, all state on
the SSD with the array bound `:ro`, the mount gate present, the health command
naming a binary the image actually ships, and the deployer creating both roots
before it starts. `tests/media_overlay.bats` is deliberately untouched — it still
pins that only Plex and Jellyfin may reference the metadata view, and Audiobookshelf
is a reader that stays out of it.
Homepage: one admin card, LAN url and probe like Tautulli. The card counts in the
e2e specs come from the live registry, so the entry is also the test update. The
service deploys **before** the homepage ships, so the admin walk never follows a
link to a port nobody owns.
## After merge
1. `./scripts/deploy.sh dankserver --service audiobookshelf` (health gate).
2. `./scripts/deploy.sh dankclaw --service homepage` for the card.
3. One manual step, LAN only: open `http://192.168.0.235:8000` and create the owner
account. Audiobookshelf accepts the initial account only from a local-network
address, so it cannot be scripted from the deployer — and the credential should
be yours, not a file on a server.
---
cwd: /home/blank/.omp/wt/wt-20261030-163847-6d1fc1f · session: 01a18f2e-18b1-7573-bcd5-ac99636f8bcb
Audiobookshelf serves /mnt/entropy/Audiobooks to browsers and phones with its own
library and per-listener positions. The design point of the unit: every byte this
server writes is its own state - SQLite library, accounts, progress, cached covers
- and all of it lands on the NVMe under /home/dank/Containers/audiobookshelf. The
audio bind is :ro, so a stray sidecar write onto the spindle is structurally
impossible rather than merely discouraged, and the array keeps exactly one
sanctioned write path (scripts/media-view.sh).
Three failure modes the unit is shaped around:
- Bind-source ordering. In a rootless unit RequiresMountsFor= is inert and
ConditionPathIsMountPoint= is fail-dead, so a missing array does not stop the
start: podman invents an empty directory and the first scan records an empty
library as truth. ExecStartPre=wait-for-mount.sh is the gate that works here,
the same one copyparty uses.
- The health binary. The image is Alpine: wget, no curl. A curl HealthCmd reads a
healthy server as unhealthy and Restart=always turns that into a loop.
- The image pin. podman records the linux/amd64 manifest for what it pulled, not
the manifest-list digest `latest` points at, so the amd64 digest is the pin that
names the build this host runs. Upstream version tags stop at 2.19.0 while
`latest` is newer, so latest@digest is the only form that is both current and
exact - the jellyfin/romm precedent.
The first-boot scan reads the whole tree once, so IOWeight=10 keeps it behind
playback; that weight is only honoured because the user.slice accounting drop-in
and the user@1000 delegation are installed by the deployer.
Deployer registration is the standard four touch-points (client gate, remote
allowlist + handler + dispatcher, planner order/set/path map, test routing). The
handler creates both state roots before the first start - letting podman invent
them leaves the SQLite file unwritable by the container user under a rootless
first boot - and gates on /healthcheck.
Admin category, Media Library Apps section, LAN-direct url and probe like Tautulli:
the service is internal-only, with no vhost and no DNS name, so it earns a card and
no public link. The art is a mono-ab.svg monogram in the existing hexagon family.
Card counts in the e2e specs come from the live registry, so this entry is the test
update as well as the page change - and the admin walk will follow the link, which
is why the service deploys before the homepage ships.
The page owns the shape, the first-run account step (ABS accepts the initial
account only from a local-network address) and the laws: why the array bind is
read-only and what the one-line rollback is if on-disk sidecars are ever wanted;
why the metadata view is not that rollback; why the mount gate, the wget health
check and the amd64 digest pin are each mandatory; and that LAN-only is a posture,
not an accident.
Media Audiobook Relayout gains the third reader: ABS stores absolute paths, so the
relayout procedure has to reach its library too or it keeps rows for folders that
are gone. The vault index is regenerated, not hand-carved.
dank
merged commit a6ec644ecb into main2026-11-02 02:20:03 -08:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
What
Audiobookshelf, deployed on Dankserver as a boot-persistent rootless Quadlet, so the
audiobook library on the array finally has a server that understands audiobooks:
its own library, its own logins, per-listener playback positions, browser and phone
clients. It shares nothing with Plex or Jellyfin and owns no file on the array.
The shape that matters
dankserver/audiobookshelf/quadlet/audiobookshelf.container(authority for every number)ghcr.io/advplyr/audiobookshelf:latest@sha256:37c2057e…4ef3— the amd64 manifest/home/dank/Containers/audiobookshelf/{config,metadata}— NVMe/mnt/entropy/Audiobooks:/audiobooks:ro(1103 shelves, 2.5 T today)8000:8000,pasta, LAN only — no vhost, no DNS nameExecStartPre=…/wait-for-mount.sh /mnt/entropy, health viawget /healthcheckEvery byte the server writes is its own state, and all of it lands on the SSD. The
audio bind is read-only, which makes a stray sidecar write onto the spindle
structurally impossible instead of merely discouraged — the array keeps exactly one
sanctioned write path (
scripts/media-view.sh).Three traps the unit is built around
RequiresMountsFor=is inert andConditionPathIsMountPoint=is fail-dead, so a missing array does not stop thestart: podman invents an empty directory, the first scan indexes it, and an empty
library becomes recorded truth.
wait-for-mount.shis the only gate that workshere — the same one
copypartyuses.wget, nocurl. AcurlHealthCmdreads a healthy server as unhealthy andRestart=alwaysturns thatinto a loop.
podmanrecords the linux/amd64 manifest for what it pulled,not the manifest-list digest
latestpoints at — so the amd64 digest is the pinthat names the build this host runs. Upstream version tags stop at
2.19.0(2025-02) while
latestis a year and a half newer, solatest@digestis theonly form that is both current and exact. Same reasoning as
jellyfin/romm.IOWeight=10keeps the first-boot scan (one full pass over the tree) behindplayback; the weight is only real because the
user.sliceaccounting drop-in andthe
user@1000delegation exist and the deployer installs them.Deployer
The four registration touch-points: client gate, remote allowlist +
deploy_audiobookshelfhandler + dispatcher, planner
SERVICE_ORDER/DANKSERVER_SERVICES/ quadlet path map,and
test-changed.shrouting. The handler creates both state roots before thefirst start — letting podman invent them leaves the SQLite file unwritable by the
container user on a rootless first boot — then gates on
/healthcheck. The libraryscan that follows runs in the background and does not gate the deploy.
No
systemctl --user enable: generated Quadlet units cannot be enabled on thishost;
WantedBy=default.targetcarries it across a reboot.Tests
./tests/run.sh→ ALL TESTS PASSED, 811 ok (806 before; +5 new assertions intests/dankserver_quadlet.bats): boot persistence and self-healing, all state onthe SSD with the array bound
:ro, the mount gate present, the health commandnaming a binary the image actually ships, and the deployer creating both roots
before it starts.
tests/media_overlay.batsis deliberately untouched — it stillpins that only Plex and Jellyfin may reference the metadata view, and Audiobookshelf
is a reader that stays out of it.
Homepage: one admin card, LAN url and probe like Tautulli. The card counts in the
e2e specs come from the live registry, so the entry is also the test update. The
service deploys before the homepage ships, so the admin walk never follows a
link to a port nobody owns.
After merge
./scripts/deploy.sh dankserver --service audiobookshelf(health gate)../scripts/deploy.sh dankclaw --service homepagefor the card.http://192.168.0.235:8000and create the owneraccount. Audiobookshelf accepts the initial account only from a local-network
address, so it cannot be scripted from the deployer — and the credential should
be yours, not a file on a server.
cwd: /home/blank/.omp/wt/wt-20261030-163847-6d1fc1f · session: 01a18f2e-18b1-7573-bcd5-ac99636f8bcb