feat(audiobookshelf): the audiobook server as a boot-persistent quadlet #117
No files matched your search
@@ -0,0 +1,59 @@
|
||||
# Quadlet unit: Audiobookshelf audiobook server (rootless, boot-persistent).
|
||||
#
|
||||
# Install path on dankserver: ~/.config/containers/systemd/audiobookshelf.container
|
||||
# Unit name: audiobookshelf.service (podman 5.x drops the container- prefix)
|
||||
#
|
||||
# Everything this server writes is its own state — the SQLite library, accounts,
|
||||
# playback progress, cached covers — and it lives under
|
||||
# /home/dank/Containers/audiobookshelf on the NVMe, so no metadata byte ever
|
||||
# lands on the spindle. The audio itself is bound READ-ONLY off the array: the
|
||||
# only sanctioned write path into /mnt/entropy is the overlay view
|
||||
# (scripts/media-view.sh), Audiobookshelf has no need of it, and a read-only bind
|
||||
# makes a stray sidecar write structurally impossible rather than discouraged.
|
||||
#
|
||||
# The digest is the linux/amd64 manifest of the tag, which is what podman
|
||||
# verifies and what RepoDigests records on this single-arch host. Pinning the
|
||||
# manifest-list digest instead would let a re-pull resolve to a different image.
|
||||
# The upstream version tags stop at 2.19.0 (built 2025-02-02) while `latest` is
|
||||
# newer by a year and a half, so `latest@digest` is the only pin that names the
|
||||
# build actually running — same reasoning as the jellyfin and romm units.
|
||||
|
||||
[Unit]
|
||||
Description=Audiobookshelf audiobook server container
|
||||
After=network.target
|
||||
|
||||
[Container]
|
||||
ContainerName=audiobookshelf
|
||||
Image=ghcr.io/advplyr/audiobookshelf:latest@sha256:37c2057e150694970442579815c646fe6bdc01c95b9d1547c4c9068be8844ef3
|
||||
Network=pasta
|
||||
Environment=ABS_CONFIG_DIR=/config
|
||||
Environment=ABS_METADATA_DIR=/metadata
|
||||
Environment=ABS_PGID=1000
|
||||
Environment=ABS_PORT=8000
|
||||
Environment=ABS_PUID=1000
|
||||
Environment=TZ=America/Vancouver
|
||||
PublishPort=8000:8000
|
||||
Volume=/home/dank/Containers/audiobookshelf/config:/config
|
||||
Volume=/home/dank/Containers/audiobookshelf/metadata:/metadata
|
||||
Volume=/mnt/entropy/Audiobooks:/audiobooks:ro
|
||||
# The image is Alpine: it ships wget and no curl, so the health check must name
|
||||
# the binary that exists or the container is unhealthy while serving perfectly.
|
||||
HealthCmd=wget -q -O /dev/null http://127.0.0.1:8000/healthcheck
|
||||
HealthInterval=30s
|
||||
HealthTimeout=10s
|
||||
HealthRetries=3
|
||||
|
||||
[Service]
|
||||
# Refuse to start until the array is a real mount — with the bind source missing,
|
||||
# podman invents an empty directory and Audiobookshelf records an empty library as
|
||||
# truth. See the header of scripts/wait-for-mount.sh for the blind-boot incident.
|
||||
ExecStartPre=/home/dank/serverAppConfigs/scripts/wait-for-mount.sh /mnt/entropy
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
# The first boot scans the whole audiobook tree. Weighted LOW (default 100) so a
|
||||
# scan loses to playback when the array is contended. Proportional share, not a
|
||||
# cap: inert while the disk is idle.
|
||||
IOWeight=10
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -60,6 +60,7 @@ SERVICE_ORDER = (
|
||||
"calibre-server",
|
||||
"plex",
|
||||
"tautulli",
|
||||
"audiobookshelf",
|
||||
"prowlarr",
|
||||
"qbittorrent",
|
||||
"radarr",
|
||||
@@ -123,6 +124,7 @@ DANKSERVER_SERVICES = {
|
||||
"calibre-server",
|
||||
"plex",
|
||||
"tautulli",
|
||||
"audiobookshelf",
|
||||
"prowlarr",
|
||||
"qbittorrent",
|
||||
"radarr",
|
||||
@@ -420,6 +422,7 @@ def services_for_path(path: str, target: str) -> set[str]:
|
||||
"copyparty": {"copyparty"},
|
||||
"plex": {"plex"},
|
||||
"tautulli": {"tautulli"},
|
||||
"audiobookshelf": {"audiobookshelf"},
|
||||
}
|
||||
_add(services, *quadlet_services.get(parts[1], set()))
|
||||
if len(parts) >= 2 and parts[1] == "host":
|
||||
|
||||
@@ -95,6 +95,7 @@ service_is_allowed() {
|
||||
dankclaw:frank-tts-engine|dankclaw:frank-tts|\
|
||||
dankserver:romm-mariadb|dankserver:romm-redis|dankserver:romm|\
|
||||
dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:calibre-server|dankserver:plex|dankserver:tautulli|\
|
||||
dankserver:audiobookshelf|\
|
||||
dankserver:prowlarr|dankserver:qbittorrent|dankserver:radarr|\
|
||||
dankserver:sonarr|dankserver:lidarr|dankserver:readarr|\
|
||||
dankserver:frank-archon|dankserver:node-exporter|dankserver:cadvisor|dankserver:gpu-exporter|\
|
||||
@@ -1300,6 +1301,25 @@ deploy_calibre_web() {
|
||||
systemctl --user restart calibre-web.service
|
||||
}
|
||||
|
||||
# Audiobookshelf keeps its whole state (SQLite library, accounts, progress,
|
||||
# cached covers) on the NVMe under /home/dank/Containers/audiobookshelf; the
|
||||
# audio is a read-only bind off the array, so the only thing the deployer has to
|
||||
# create is the two state roots. mkdir names the owner — letting podman invent
|
||||
# the directories leaves the SQLite file unwritable by the container user on a
|
||||
# rootless first boot. No `systemctl --user enable`: a generated Quadlet unit
|
||||
# cannot be enabled, and WantedBy=default.target is what carries it across a
|
||||
# reboot.
|
||||
deploy_audiobookshelf() {
|
||||
cp "dankserver/audiobookshelf/quadlet/audiobookshelf.container" "$quadlet_unit_dir/"
|
||||
systemctl --user daemon-reload
|
||||
mkdir -p /home/dank/Containers/audiobookshelf/config \
|
||||
/home/dank/Containers/audiobookshelf/metadata
|
||||
systemctl --user restart audiobookshelf.service
|
||||
# /healthcheck answers once the HTTP server is bound; the library scan that
|
||||
# follows runs in the background and must not gate the deploy.
|
||||
wait_for_health audiobookshelf http://127.0.0.1:8000/healthcheck 90
|
||||
}
|
||||
|
||||
# The content server's single admin account and the desktop's basic-auth
|
||||
# credential (same pair) are bootstrapped exactly once: the env file is the
|
||||
# plaintext store (mode 600), the userdb's pw column is plaintext too, so
|
||||
@@ -2176,6 +2196,7 @@ deploy_service() {
|
||||
copyparty) deploy_copyparty ;;
|
||||
plex) deploy_plex ;;
|
||||
tautulli) deploy_tautulli ;;
|
||||
audiobookshelf) deploy_audiobookshelf ;;
|
||||
alloy) deploy_alloy ;;
|
||||
prometheus|grafana|loki|node-exporter|cadvisor|alertmanager|blackbox-exporter) deploy_observability "$service" ;;
|
||||
gpu-exporter) deploy_gpu_exporter ;;
|
||||
|
||||
@@ -148,6 +148,7 @@ valid_service() {
|
||||
dankclaw:frank-dragoon|dankclaw:frank-zealot|\
|
||||
dankserver:romm-mariadb|dankserver:romm-redis|dankserver:romm|\
|
||||
dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:calibre-server|dankserver:plex|dankserver:tautulli|\
|
||||
dankserver:audiobookshelf|\
|
||||
dankserver:qbittorrent|dankserver:radarr|dankserver:sonarr|\
|
||||
dankserver:lidarr|dankserver:readarr|dankserver:node-exporter|dankserver:cadvisor|dankserver:gpu-exporter|\
|
||||
dankserver:prowlarr|dankserver:frank-archon|\
|
||||
|
||||
@@ -334,7 +334,7 @@ for path in "${CHANGED[@]}"; do
|
||||
# Cutover-era dankserver quadlet units (romm trio, jellyfin, calibre-web,
|
||||
# copyparty, plex, tautulli): the seam checks boot-persistence +
|
||||
# v1-generator contract; deploy-flow seams guard the deployer routing.
|
||||
dankserver/romm/*|dankserver/jellyfin/*|dankserver/calibre-web/*|dankserver/copyparty/*|dankserver/plex/*|dankserver/tautulli/*)
|
||||
dankserver/romm/*|dankserver/jellyfin/*|dankserver/calibre-web/*|dankserver/copyparty/*|dankserver/plex/*|dankserver/tautulli/*|dankserver/audiobookshelf/*)
|
||||
add_test tests/dankserver_quadlet.bats
|
||||
add_test tests/deployment_flow.bats
|
||||
add_test tests/deployment_scoping.bats
|
||||
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
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 —
|
||||
@@ -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 in step 5 of the movie Procedure — from inside the container, never over
|
||||
# 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 \
|
||||
--journal /mnt/entropy/media-relayout-audiobooks.jsonl --apply
|
||||
```
|
||||
|
||||
@@ -44,6 +44,7 @@ is a locator: one row, one clause, no numbers.
|
||||
|
||||
| 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-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. |
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96"><polygon points="48,6 86,27 86,69 48,90 10,69 10,27" fill="#1c1300" stroke="#3fc1c9" stroke-width="3"/><text x="48" y="60" font-family="monospace" font-size="30" font-weight="bold" fill="#3fc1c9" text-anchor="middle">AB</text></svg>
|
||||
|
After Width: | Height: | Size: 293 B |
@@ -511,6 +511,19 @@
|
||||
"showLink": true,
|
||||
"art": "/img/apps/mono-tt.svg"
|
||||
},
|
||||
{
|
||||
"name": "Audiobookshelf",
|
||||
"category": "admin",
|
||||
"section": "Media Library Apps",
|
||||
"description": "Audiobook server for the entropy library: its own SQLite library, its own login, per-listener positions.",
|
||||
"url": "http://192.168.0.235:8000",
|
||||
"docs": "",
|
||||
"probe": "192.168.0.235:8000",
|
||||
"method": "http",
|
||||
"wip": false,
|
||||
"showLink": true,
|
||||
"art": "/img/apps/mono-ab.svg"
|
||||
},
|
||||
{
|
||||
"name": "Postgres",
|
||||
"category": "admin",
|
||||
|
||||
@@ -127,3 +127,51 @@ load helpers
|
||||
grep -q "dankserver/host/systemd/$f" "$REPO_ROOT/scripts/deploy-remote.sh" || return 1
|
||||
done
|
||||
}
|
||||
|
||||
@test "audiobookshelf unit exists, is boot-persistent and self-healing" {
|
||||
unit="$REPO_ROOT/dankserver/audiobookshelf/quadlet/audiobookshelf.container"
|
||||
[ -f "$unit" ] || return 1
|
||||
grep -q '^WantedBy=default.target$' "$unit" || return 1
|
||||
grep -q '^Restart=always$' "$unit" || return 1
|
||||
grep -q '^RestartSec=' "$unit" || return 1
|
||||
}
|
||||
|
||||
@test "audiobookshelf keeps every byte it writes on the SSD" {
|
||||
# The point of the unit: its library database, accounts, progress and cached
|
||||
# covers are all server state, and server state belongs on the NVMe. A bind
|
||||
# of /mnt/entropy that is not :ro would silently put writes on the spindle.
|
||||
unit="$REPO_ROOT/dankserver/audiobookshelf/quadlet/audiobookshelf.container"
|
||||
grep -q '^Volume=/home/dank/Containers/audiobookshelf/config:/config$' "$unit" || return 1
|
||||
grep -q '^Volume=/home/dank/Containers/audiobookshelf/metadata:/metadata$' "$unit" || return 1
|
||||
grep -q '^Volume=/mnt/entropy/Audiobooks:/audiobooks:ro$' "$unit" || return 1
|
||||
# The overlay view is reserved for the two media servers that write sidecars
|
||||
# (tests/media_overlay.bats pins that set); a reader must not join it.
|
||||
! grep -q 'media-meta/view' "$unit" || return 1
|
||||
}
|
||||
|
||||
@test "audiobookshelf refuses to start against an unmounted array" {
|
||||
# Without the gate podman invents an empty bind source and the first scan
|
||||
# records an empty library as truth.
|
||||
unit="$REPO_ROOT/dankserver/audiobookshelf/quadlet/audiobookshelf.container"
|
||||
grep -q '^ExecStartPre=.*/wait-for-mount.sh /mnt/entropy$' "$unit" || return 1
|
||||
}
|
||||
|
||||
@test "audiobookshelf health check names a binary its image actually ships" {
|
||||
# The image is Alpine: wget yes, curl no. A curl HealthCmd reads the
|
||||
# container as unhealthy while it is serving perfectly.
|
||||
unit="$REPO_ROOT/dankserver/audiobookshelf/quadlet/audiobookshelf.container"
|
||||
grep -q '^HealthCmd=wget ' "$unit" || return 1
|
||||
! grep -q '^HealthCmd=curl' "$unit" || return 1
|
||||
grep -q '^PublishPort=8000:8000$' "$unit" || return 1
|
||||
}
|
||||
|
||||
@test "the audiobookshelf deployer creates both state roots before it starts" {
|
||||
body="$(sed -n '/^deploy_audiobookshelf()/,/^\}/p' "$REPO_ROOT/scripts/deploy-remote.sh")"
|
||||
mkdir_line="$(printf '%s\n' "$body" | grep -n 'mkdir -p' | head -1 | cut -d: -f1)"
|
||||
start_line="$(printf '%s\n' "$body" | grep -n 'systemctl --user restart' | head -1 | cut -d: -f1)"
|
||||
[ -n "$mkdir_line" ] && [ -n "$start_line" ] || return 1
|
||||
[ "$mkdir_line" -lt "$start_line" ] || return 1
|
||||
printf '%s\n' "$body" | grep -q 'audiobookshelf/config' || return 1
|
||||
printf '%s\n' "$body" | grep -q 'audiobookshelf/metadata' || return 1
|
||||
printf '%s\n' "$body" | grep -q 'wait_for_health audiobookshelf' || return 1
|
||||
}
|
||||
Reference in new issue
Block a user