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",
|
"calibre-server",
|
||||||
"plex",
|
"plex",
|
||||||
"tautulli",
|
"tautulli",
|
||||||
|
"audiobookshelf",
|
||||||
"prowlarr",
|
"prowlarr",
|
||||||
"qbittorrent",
|
"qbittorrent",
|
||||||
"radarr",
|
"radarr",
|
||||||
@@ -123,6 +124,7 @@ DANKSERVER_SERVICES = {
|
|||||||
"calibre-server",
|
"calibre-server",
|
||||||
"plex",
|
"plex",
|
||||||
"tautulli",
|
"tautulli",
|
||||||
|
"audiobookshelf",
|
||||||
"prowlarr",
|
"prowlarr",
|
||||||
"qbittorrent",
|
"qbittorrent",
|
||||||
"radarr",
|
"radarr",
|
||||||
@@ -420,6 +422,7 @@ def services_for_path(path: str, target: str) -> set[str]:
|
|||||||
"copyparty": {"copyparty"},
|
"copyparty": {"copyparty"},
|
||||||
"plex": {"plex"},
|
"plex": {"plex"},
|
||||||
"tautulli": {"tautulli"},
|
"tautulli": {"tautulli"},
|
||||||
|
"audiobookshelf": {"audiobookshelf"},
|
||||||
}
|
}
|
||||||
_add(services, *quadlet_services.get(parts[1], set()))
|
_add(services, *quadlet_services.get(parts[1], set()))
|
||||||
if len(parts) >= 2 and parts[1] == "host":
|
if len(parts) >= 2 and parts[1] == "host":
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ service_is_allowed() {
|
|||||||
dankclaw:frank-tts-engine|dankclaw:frank-tts|\
|
dankclaw:frank-tts-engine|dankclaw:frank-tts|\
|
||||||
dankserver:romm-mariadb|dankserver:romm-redis|dankserver:romm|\
|
dankserver:romm-mariadb|dankserver:romm-redis|dankserver:romm|\
|
||||||
dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:calibre-server|dankserver:plex|dankserver:tautulli|\
|
dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:calibre-server|dankserver:plex|dankserver:tautulli|\
|
||||||
|
dankserver:audiobookshelf|\
|
||||||
dankserver:prowlarr|dankserver:qbittorrent|dankserver:radarr|\
|
dankserver:prowlarr|dankserver:qbittorrent|dankserver:radarr|\
|
||||||
dankserver:sonarr|dankserver:lidarr|dankserver:readarr|\
|
dankserver:sonarr|dankserver:lidarr|dankserver:readarr|\
|
||||||
dankserver:frank-archon|dankserver:node-exporter|dankserver:cadvisor|dankserver:gpu-exporter|\
|
dankserver:frank-archon|dankserver:node-exporter|dankserver:cadvisor|dankserver:gpu-exporter|\
|
||||||
@@ -1300,6 +1301,25 @@ deploy_calibre_web() {
|
|||||||
systemctl --user restart calibre-web.service
|
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
|
# 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
|
# 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
|
# plaintext store (mode 600), the userdb's pw column is plaintext too, so
|
||||||
@@ -2176,6 +2196,7 @@ deploy_service() {
|
|||||||
copyparty) deploy_copyparty ;;
|
copyparty) deploy_copyparty ;;
|
||||||
plex) deploy_plex ;;
|
plex) deploy_plex ;;
|
||||||
tautulli) deploy_tautulli ;;
|
tautulli) deploy_tautulli ;;
|
||||||
|
audiobookshelf) deploy_audiobookshelf ;;
|
||||||
alloy) deploy_alloy ;;
|
alloy) deploy_alloy ;;
|
||||||
prometheus|grafana|loki|node-exporter|cadvisor|alertmanager|blackbox-exporter) deploy_observability "$service" ;;
|
prometheus|grafana|loki|node-exporter|cadvisor|alertmanager|blackbox-exporter) deploy_observability "$service" ;;
|
||||||
gpu-exporter) deploy_gpu_exporter ;;
|
gpu-exporter) deploy_gpu_exporter ;;
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ valid_service() {
|
|||||||
dankclaw:frank-dragoon|dankclaw:frank-zealot|\
|
dankclaw:frank-dragoon|dankclaw:frank-zealot|\
|
||||||
dankserver:romm-mariadb|dankserver:romm-redis|dankserver:romm|\
|
dankserver:romm-mariadb|dankserver:romm-redis|dankserver:romm|\
|
||||||
dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:calibre-server|dankserver:plex|dankserver:tautulli|\
|
dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:calibre-server|dankserver:plex|dankserver:tautulli|\
|
||||||
|
dankserver:audiobookshelf|\
|
||||||
dankserver:qbittorrent|dankserver:radarr|dankserver:sonarr|\
|
dankserver:qbittorrent|dankserver:radarr|dankserver:sonarr|\
|
||||||
dankserver:lidarr|dankserver:readarr|dankserver:node-exporter|dankserver:cadvisor|dankserver:gpu-exporter|\
|
dankserver:lidarr|dankserver:readarr|dankserver:node-exporter|dankserver:cadvisor|dankserver:gpu-exporter|\
|
||||||
dankserver:prowlarr|dankserver:frank-archon|\
|
dankserver:prowlarr|dankserver:frank-archon|\
|
||||||
|
|||||||
@@ -334,7 +334,7 @@ for path in "${CHANGED[@]}"; do
|
|||||||
# Cutover-era dankserver quadlet units (romm trio, jellyfin, calibre-web,
|
# Cutover-era dankserver quadlet units (romm trio, jellyfin, calibre-web,
|
||||||
# copyparty, plex, tautulli): the seam checks boot-persistence +
|
# copyparty, plex, tautulli): the seam checks boot-persistence +
|
||||||
# v1-generator contract; deploy-flow seams guard the deployer routing.
|
# 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/dankserver_quadlet.bats
|
||||||
add_test tests/deployment_flow.bats
|
add_test tests/deployment_flow.bats
|
||||||
add_test tests/deployment_scoping.bats
|
add_test tests/deployment_scoping.bats
|
||||||
|
|||||||
@@ -127,3 +127,51 @@ load helpers
|
|||||||
grep -q "dankserver/host/systemd/$f" "$REPO_ROOT/scripts/deploy-remote.sh" || return 1
|
grep -q "dankserver/host/systemd/$f" "$REPO_ROOT/scripts/deploy-remote.sh" || return 1
|
||||||
done
|
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