From 511d8dca93a30dc3360b540afbf57305e4b83cd3 Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 2 Nov 2026 02:19:24 -0800 Subject: [PATCH 1/3] feat(audiobookshelf): the audiobook server as a boot-persistent quadlet 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. --- .../quadlet/audiobookshelf.container | 59 +++++++++++++++++++ scripts/deploy-plan.py | 3 + scripts/deploy-remote.sh | 21 +++++++ scripts/deploy.sh | 1 + scripts/test-changed.sh | 2 +- tests/dankserver_quadlet.bats | 48 +++++++++++++++ 6 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 dankserver/audiobookshelf/quadlet/audiobookshelf.container 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/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 +} -- 2.54.0 From ed1a20a960f55d7e16f46dbd921368c4ca470a72 Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 2 Nov 2026 02:19:29 -0800 Subject: [PATCH 2/3] feat(homepage): an Audiobookshelf card on the admin page 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. --- shared/homepage/img/apps/mono-ab.svg | 1 + shared/homepage/services.json | 13 +++++++++++++ 2 files changed, 14 insertions(+) create mode 100644 shared/homepage/img/apps/mono-ab.svg 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", -- 2.54.0 From b7f4b7e148c56656b1dae46dae33b54994bd256e Mon Sep 17 00:00:00 2001 From: Frank Date: Mon, 2 Nov 2026 02:19:29 -0800 Subject: [PATCH 3/3] docs(audiobookshelf): one page for the endpoint, and the rescan law at the relayout 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. --- .../Dankserver/Audiobookshelf.md | 95 +++++++++++++++++++ .../Dankserver/Media Audiobook Relayout.md | 6 +- serverAppConfigs-docs/index.md | 1 + 3 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 serverAppConfigs-docs/Dankserver/Audiobookshelf.md 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. | -- 2.54.0