diff --git a/dankserver/audiobookshelf/quadlet/audiobookshelf.container b/dankserver/audiobookshelf/quadlet/audiobookshelf.container new file mode 100644 index 0000000..8a0ddce --- /dev/null +++ b/dankserver/audiobookshelf/quadlet/audiobookshelf.container @@ -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 diff --git a/scripts/deploy-plan.py b/scripts/deploy-plan.py index 6053151..6408ee4 100755 --- a/scripts/deploy-plan.py +++ b/scripts/deploy-plan.py @@ -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": diff --git a/scripts/deploy-remote.sh b/scripts/deploy-remote.sh index a4f00a1..2c1bbfc 100755 --- a/scripts/deploy-remote.sh +++ b/scripts/deploy-remote.sh @@ -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 ;; diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 1ed4775..6cb2064 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -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|\ diff --git a/scripts/test-changed.sh b/scripts/test-changed.sh index 3d95496..35fdfe1 100755 --- a/scripts/test-changed.sh +++ b/scripts/test-changed.sh @@ -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 diff --git a/serverAppConfigs-docs/Dankserver/Audiobookshelf.md b/serverAppConfigs-docs/Dankserver/Audiobookshelf.md new file mode 100644 index 0000000..c7eaab0 --- /dev/null +++ b/serverAppConfigs-docs/Dankserver/Audiobookshelf.md @@ -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). diff --git a/serverAppConfigs-docs/Dankserver/Media Audiobook Relayout.md b/serverAppConfigs-docs/Dankserver/Media Audiobook Relayout.md index 4b0fdc7..7806b66 100644 --- a/serverAppConfigs-docs/Dankserver/Media Audiobook Relayout.md +++ b/serverAppConfigs-docs/Dankserver/Media Audiobook Relayout.md @@ -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 ``` diff --git a/serverAppConfigs-docs/index.md b/serverAppConfigs-docs/index.md index 27a3bc7..20673f6 100644 --- a/serverAppConfigs-docs/index.md +++ b/serverAppConfigs-docs/index.md @@ -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. | diff --git a/shared/homepage/img/apps/mono-ab.svg b/shared/homepage/img/apps/mono-ab.svg new file mode 100644 index 0000000..c18bcb4 --- /dev/null +++ b/shared/homepage/img/apps/mono-ab.svg @@ -0,0 +1 @@ +AB diff --git a/shared/homepage/services.json b/shared/homepage/services.json index 9414d14..d8ccc48 100644 --- a/shared/homepage/services.json +++ b/shared/homepage/services.json @@ -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", diff --git a/tests/dankserver_quadlet.bats b/tests/dankserver_quadlet.bats index 532c48a..b316055 100755 --- a/tests/dankserver_quadlet.bats +++ b/tests/dankserver_quadlet.bats @@ -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 +}