feat(audiobookshelf): the audiobook server as a boot-persistent quadlet #117

Merged
dank merged 3 commits from feat/audiobookshelf into main 2026-11-02 02:20:03 -08:00
3 changed files with 101 additions and 1 deletions
Showing only changes of commit b7f4b7e148 - Show all commits

No files matched your search

@@ -0,0 +1,95 @@
---
type: reference
description: "Audiobookshelf on Dankserver: quadlet shape, the read-only array bind, state on the SSD, first-run account, 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 | `PublishPort=8000:8000` — web UI and API on the same port |
| 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` |
## First run
1. Open `http://192.168.0.235:8000` **from the LAN**. ABS accepts the initial
account creation only from a local-network address; a remote address gets a
refusal, not a form.
2. Create the owner account, then add one **Audi Book** library with folder
`/audiobooks`. The path is the in-container name of the read-only bind.
3. The first scan reads the whole tree once. It runs in the background — the
deploy gate is already green by then, and `IOWeight=10` keeps it off the
critical path.
Nothing in this repo holds an ABS credential: the account is created in the
browser and lives in the `config` bind on the SSD.
## Laws
- **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`).
## 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) and `tests/media_overlay.bats` (only [[Plex]] and
[[Jellyfin]] may reference the metadata view).
@@ -11,7 +11,8 @@ timestamp: 2026-10-31T00:00:00Z
`/mnt/entropy/Audiobooks` is the one library here that [[Jellyfin]] does not read `/mnt/entropy/Audiobooks` is the one library here that [[Jellyfin]] does not read
at all — its music scanner has no audiobook concept — while [[Plex]] indexes all at all — its music scanner has no audiobook concept — while [[Plex]] indexes all
of it as Artist → Album → Track: the album is the BOOK, that book's single artist of it as Artist → Album → Track: the album is the BOOK, that book's single artist
is the NARRATOR, both matched out of the files' own tags. Today one book is split is the NARRATOR, both matched out of the files' own tags. [[Audiobookshelf]]
streams the same tree, read-only, and owns nothing in it. Today one book is split
across `… [Part 1]`, `… [Part 2]` folders three or four deep inside a collection across `… [Part 1]`, `… [Part 2]` folders three or four deep inside a collection
pack, so a scan serves three quarters of a novel; `scripts/media-audiobooks.py` pack, so a scan serves three quarters of a novel; `scripts/media-audiobooks.py`
re-files the tree so the property both servers' book scanners assume is true — re-files the tree so the property both servers' book scanners assume is true —
@@ -36,6 +37,9 @@ python3 scripts/media-audiobooks.py --root /mnt/entropy/Audiobooks \
# as [[Media Relayout]] shows, on its own journal, and refresh the Plex section # as [[Media Relayout]] shows, on its own journal, and refresh the Plex section
# as in step 5 of the movie Procedure — from inside the container, never over # as in step 5 of the movie Procedure — from inside the container, never over
# the host port. # the host port.
# Audiobookshelf stores absolute paths, so the same refresh has to reach its
# library too ([[Audiobookshelf]]) — otherwise it keeps rows for folders that
# no longer exist.
scripts/media-apply.py apply --plan /tmp/ab_plan.tsv --root /mnt/entropy \ scripts/media-apply.py apply --plan /tmp/ab_plan.tsv --root /mnt/entropy \
--journal /mnt/entropy/media-relayout-audiobooks.jsonl --apply --journal /mnt/entropy/media-relayout-audiobooks.jsonl --apply
``` ```
+1
View File
@@ -44,6 +44,7 @@ is a locator: one row, one clause, no numbers.
| Page | Owns | | Page | Owns |
|---|---| |---|---|
| [[Audiobookshelf]] | Audiobookshelf on Dankserver: quadlet shape, the read-only array bind, state on the SSD, first-run account, laws. |
| [[Calibre]] | Calibre is the e-book library manager that backs Calibre-Web. | | [[Calibre]] | Calibre is the e-book library manager that backs Calibre-Web. |
| [[Calibre-Web]] | Calibre-Web is a web application that browses, reads, and downloads eBooks from an existing Calibre library database. | | [[Calibre-Web]] | Calibre-Web is a web application that browses, reads, and downloads eBooks from an existing Calibre library database. |
| [[Copyparty]] | Copyparty is a lightweight file server and media streamer that runs as a single Python binary. | | [[Copyparty]] | Copyparty is a lightweight file server and media streamer that runs as a single Python binary. |