The SSO paragraph left "whether an access policy should be bound to the audiobookshelf application" open, which invites the next agent to relitigate it. Frank's answer of 2026-11-03: accepted on purpose — every signer becomes a listener with all libraries and downloads, no access policy on the application, narrowing done per account in Settings -> Users. What that makes load-bearing is authentik's own signup flow, now named in the same paragraph as the thing that must stay operator-only.
369 lines
28 KiB
Markdown
369 lines
28 KiB
Markdown
---
|
||
type: reference
|
||
description: "Audiobookshelf on Dankserver: quadlet shape, array bind, SSD state, API bootstrap, authentik SSO, public vhost, laws."
|
||
tags:
|
||
- dankserver
|
||
- audiobooks
|
||
- quadlet
|
||
timestamp: 2026-11-03
|
||
---
|
||
|
||
# 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) —
|
||
**Doors:** LAN `http://192.168.0.235:8000/audiobookshelf` (plain HTTP, nothing in front) and public `https://audiobooks.franksdank.cloud/audiobookshelf/` — Caddy straight to the container, **exempt from the guard chain** because the app must answer non-browser clients ([[Caddy]] *Unguard a vhost*). The public name is the address every client carries: Pi-hole answers it with `192.168.0.143` on the LAN, Porkbun answers it with the WAN address outside, and Caddy serves a real certificate either way.
|
||
|
||
## 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 | `Environment=PORT=8000` + `PublishPort=8000:8000` — web UI and API on one port |
|
||
| Base path | `Environment=ROUTER_BASE_PATH=/audiobookshelf` — every URL below carries this prefix, and the shipped SPA cannot boot without it (Laws) |
|
||
| 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` |
|
||
| Single sign-on | authentik provider `audiobookshelf`; the local `frank` account stays enabled as break-glass — see *Single sign-on* |
|
||
| Library layout | one book per folder — ABS groups by the first directory holding a media file (see Laws); every move is journalled at `/mnt/entropy/.media-campaign/media-relayout-audiobooks.jsonl` |
|
||
|
||
## Bootstrap
|
||
|
||
`scripts/audiobookshelf-init.sh` — run by the deployer after the health gate, so a
|
||
deployed ABS is a *usable* ABS: `/healthcheck` answers 200 on a server with no
|
||
user, no library and no scan, and nothing else in the deploy notices the
|
||
difference. The script is idempotent; re-running it costs one incremental scan.
|
||
|
||
| Step | Door | Printed |
|
||
|------|------|---------|
|
||
| credential | `~/.config/secrets/audiobookshelf` (mode 600, generated once, host-side) | `credential: created …` |
|
||
| account | `POST /init` with `{"newRoot":{"username","password"}}` | `account: created (frank)` / `already registered` |
|
||
| session | `POST /login` → bearer token | `token: obtained (root)` |
|
||
| library | `POST /api/libraries`, folder `/audiobooks`, provider `audiobook` | `library: created Audiobooks (<id>)` / `exists` |
|
||
| scan | `POST /api/libraries/<id>/scan` | `scan: accepted` |
|
||
| proof | `GET /api/libraries/<id>/items?limit=1` | `items: <total>` |
|
||
|
||
Sign in at `http://192.168.0.235:8000/audiobookshelf` as `frank`; the password is
|
||
`awk -F= '/^ABS_ADMIN_PASSWORD=/{print $2}' ~/.config/secrets/audiobookshelf` on
|
||
Dankserver. Change it in **Settings → Users → frank → Password**; the secret file
|
||
then holds a stale value and only matters again if the `config` bind is ever
|
||
destroyed (a fresh DB makes the script create a new owner from it).
|
||
|
||
That value is the live password, not a leftover: it bcrypt-matches `users.pash` in the state
|
||
DB (2026-11-03), so `local` does open the door when authentik is down.
|
||
|
||
A full scan is slow and runs in the background; watch the `items:` total climb,
|
||
never `/healthcheck`. It also reads files that are genuinely broken and logs
|
||
`[AudioFileScanner] Invalid data found when processing input` per file — that is
|
||
the file, not the bind: the same line count grows while the total climbs.
|
||
|
||
## Phone app
|
||
|
||
The official **Audiobookshelf** app connects with Server Address
|
||
`https://audiobooks.franksdank.cloud/audiobookshelf` — the base path belongs in the address —
|
||
username `frank`, and the same password as the web UI, or the SSO button. One address works
|
||
at home and away: on the LAN Pi-hole answers that name with `192.168.0.143` and Caddy dials
|
||
the container; off-LAN the same name resolves to the WAN address and takes the same route
|
||
(inbound 443 is forwarded at the router — fetched from a datacenter node 2026-11-03). The raw
|
||
LAN address still answers and is the break-glass door whenever Caddy is down. `/ping`
|
||
(`{"success":true}`) and `/healthcheck` (`OK`) answer at the ROOT whatever `ROUTER_BASE_PATH`
|
||
says, so the app's connectivity probe works before it resolves the base path at all, and
|
||
`download: true` on the account is what makes offline listening work.
|
||
|
||
**A home device that bypasses Pi-hole cannot reach it.** The G36 does not hairpin: resolving
|
||
the name to the WAN address from inside the LAN times out (measured 2026-11-03). Only a
|
||
client pinned to an external resolver hits this — everything using the router's DNS gets the
|
||
LAN answer.
|
||
|
||
**The login token is at `user.token`, not top level.** `POST /login` answers
|
||
`{ user, serverSettings, ereaderDevices, Source, userDefaultLibraryId }` and the bearer sits
|
||
inside the user object. A client that reads `response.token` gets the empty string, sends
|
||
`Authorization: Bearer ` with nothing after it, and every `/api/*` call then answers `401`
|
||
as though the password were wrong.
|
||
|
||
**`POST /api/login` is not a route.** Local login is `POST <base>/login`, and the `/api`
|
||
bearer guard answers anything else with a bare `401 Unauthorized` while logging **nothing** —
|
||
no `[LocalAuth]` line — so a wrong path is indistinguishable from a wrong password unless the
|
||
journal is read too. Same family as the absent `/setup` route named in
|
||
`scripts/audiobookshelf-init.sh`. To settle a suspected stale credential without signing in:
|
||
bcrypt-compare the recorded value against `users.pash` in
|
||
`/home/dank/Containers/audiobookshelf/config/absdatabase.sqlite`, opened read-only
|
||
(`file:<path>?mode=ro`).
|
||
|
||
## Listeners (accounts)
|
||
|
||
The door is public now, so the account is the only gate between a listener and the library.
|
||
Two ways in, both per listener:
|
||
|
||
- **Local account** — **Settings → Users → Add User**: type `user`, tick only the libraries
|
||
they may hear, and leave `download` off unless they need offline listening. Hand them the
|
||
server address above and their own password.
|
||
- **Household SSO** — create the authentik account, and that is the whole of it: the first
|
||
**Sign in with Authentik** auto-registers the ABS account (`authOpenIDAutoRegister`) as a
|
||
listener, because the claim grants that role to every signer. To shut a listener off,
|
||
disable or delete their user in Audiobookshelf — no IdP group membership can withhold it.
|
||
|
||
What auto-registration hands out (read off the state DB, 2026-11-03): `accessAllLibraries:
|
||
true` and `download: true` — every household signer lands on the **whole** library with
|
||
offline downloads already on. Narrow it per account in **Settings → Users → <name>**. The DB
|
||
is the census when no admin token is at hand; `GET /api/users` returns the *limited* shape
|
||
(no `active`, no `librariesAccessible`), so a missing key there is not a denial, and an
|
||
account created by SSO has `pash` NULL with its IdP link at `extraData.authOpenIDSub`.
|
||
|
||
Nothing rate-limits `POST /audiobookshelf/login` — this Caddy build carries no `rate_limit`
|
||
module and ABS 2.37.2 ships no lockout — so the form is a guessing target exactly like
|
||
`jelly.`, `plex.` and the *arr logins already are. The mitigation is credential hygiene:
|
||
long unique passwords per listener, never a reuse of the root password.
|
||
|
||
## Single sign-on (authentik)
|
||
|
||
Both doors take the household identity: the web UI puts **Sign in with Authentik** beside
|
||
the local form, and the phone app offers SSO on its server-address screen. The local
|
||
`frank` account stays enabled on purpose — `authActiveAuthMethods` is `local` **and**
|
||
`openid`, and `local` is how you get in when the IdP is down.
|
||
|
||
| Piece | Owner |
|
||
|-------|-------|
|
||
| Provider `audiobookshelf`, the admin group, the scope mapping | `scripts/authentik-audiobookshelf-sso.sh` — re-run it after an authentik rebuild, because this state lives in the IdP's database and a blueprint re-import reverts it silently |
|
||
| Client secret | `ABS_OIDC_CLIENT_SECRET` in `~/.config/secrets/audiobookshelf` on Dankserver (mode 600; never in the repo, a URL or an argv) |
|
||
| The ABS half: issuer, the four IdP URLs, claim, auto-register | `scripts/audiobookshelf-init.sh`, armed automatically when that secret exists in the credential file |
|
||
| Role at first SSO sign-in | the `abs_groups` claim: every signer gets `user`; `audiobookshelf_admins` adds `admin`. Nothing in the IdP can deny the listener role — the login itself is the gate (law since 2026-11-03; the `audiobookshelf_users` group was retired, it had zero members and every household account was being refused) |
|
||
| Admin group members | `akadmin` (the operator's own authentik admin account — sign in as it and the claim grants site admin) and `qa-e2e-admin` (the identity the deploy gate signs in as, so the script re-adds it every run; drop it and the gate fails, not a phone) |
|
||
| Guard state of the door | none since 2026-11-03: proxy provider `audiobooks-proxy` and application "Audiobookshelf Guard" were deleted and the embedded outpost un-claimed them, because that chain answers a native client with challenge HTML ([[Caddy]] *Unguard a vhost* for the order). The SSO relying party `audiobookshelf` above is a different object and stays |
|
||
| Redirect URIs on the RP | `<base>/auth/openid/callback` **and** `<base>/auth/openid/mobile-redirect` for **both** origins (LAN address and public name): the app sends the mobile URI belonging to the door it was configured with, and STRICT matching answers an unregistered one with "Redirect URI Error" |
|
||
|
||
With `authOpenIDAutoRegister` on, a first sign-in creates the account and the claim alone
|
||
decides its type — and the claim always carries `user`, so **the IdP is now the library gate**:
|
||
any authentik account that can reach this application becomes a listener. That is the
|
||
intent (the household signs in with the accounts it already has), and its breadth is
|
||
accepted on purpose: no access policy is bound to the `audiobookshelf` application, and
|
||
the wide auto-registered default (every library, downloads on) is **not a defect to fix** —
|
||
Frank's call, 2026-11-03. Narrowing stays a per-account judgement in **Settings → Users**.
|
||
The one thing still worth watching is authentik's own invitation/signup flow: it must
|
||
remain something only Frank drives, because an account created there is a listener the
|
||
moment it signs in. Revoking one person is a disable/delete of their ABS user; revoking
|
||
everyone is that signup flow.
|
||
|
||
Both halves — authentik's provider row and ABS's settings — are asserted end to end by the Playwright gate named in *Verification*. `23-` signs in as `qa-e2e-admin` and must land inside the app with site-admin rights; `24-` signs in as the standard identity, which is in no Audiobookshelf group, and must land as a listener. Together they pin the escalation and the default: a mapping that stops granting silently locks the household out, and a mapping that over-grants turns the household into site admins.
|
||
|
||
## Laws
|
||
|
||
- **Speak the image's env names, not the README's.** The server reads `PORT`,
|
||
`CONFIG_PATH`, `METADATA_PATH` and `ROUTER_BASE_PATH`; the `ABS_*` family printed
|
||
by the server's own README is the npm package's naming and is **inert** in this
|
||
image. Measured 2026-11-02 on the first deploy: with `ABS_PORT=8000` the server
|
||
listened on `:80` under base path `/audiobookshelf`, so `systemctl` said
|
||
`active (running)` while every probe of `:8000` was refused and the deploy gate
|
||
failed at 180 s. Two seconds with the pinned digest settles it:
|
||
`podman inspect --format '{{.Config.Env}}' <image>`.
|
||
- **The URL carries `/audiobookshelf`, and that is not a style choice.** The SPA in
|
||
`/app/client/dist/index.html` is built with `routerBasePath:"/audiobookshelf"`, so it asks
|
||
for its assets under that prefix whatever the server is mounted at. Mounted at `/`, every
|
||
script request falls through to the SPA fallback, comes back `text/html`, Chrome refuses to
|
||
execute it, and the page never leaves "Loading…" — while `/healthcheck`, the API and the
|
||
phone app stay perfectly healthy, which is how this hid behind an API-only verification
|
||
(measured 2026-11-02). Prove a UI claim in a browser, never from `/health`.
|
||
- **The OIDC redirect subfolder is a value you send, not a default you inherit.** The 2.17.4
|
||
upgrade only fills `authOpenIDSubfolderForRedirectURLs` on a database that ALREADY had OIDC
|
||
enabled, so a DB created before SSO leaves it unset and the server builds
|
||
`http://host/undefined/auth/openid/callback`. authentik answers the authorize request with
|
||
"Redirect URI Error" and nothing on the ABS side looks wrong (measured 2026-11-02). The
|
||
provider's registered URI must equal `<origin><subfolder>/auth/openid/callback`, so each
|
||
script derives the value from one variable of its own (`ABS_URL`, `ABS_BASE`) and
|
||
`tests/audiobookshelf_init.bats` pins what reaches the wire.
|
||
- **An ORM-created provider has no grant types.** authentik 2026.8 defaults `grant_types` to
|
||
`[]`, and `/application/o/authorize/` then refuses the code flow with oauthlib's generic
|
||
"The request is otherwise malformed" — the wire names nothing, the server log line that names
|
||
it is `Invalid grant_type for provider`. Worse, the redirect-URI check runs *first*, so a
|
||
provider carrying both faults shows only "Redirect URI Error" until the URI is fixed, and the
|
||
second wall appears after the fix ships (measured 2026-11-02: the browser reached authentik,
|
||
got `code`+`state`, and ABS still bounced to `/login?error=undefined`). The script sets
|
||
`authorization_code` + `refresh_token`; a blueprint re-import reverts the row silently.
|
||
- **The guarded door has to tell the truth about its scheme.** ABS validates every OIDC
|
||
`callback` against the origin it believes it is served from — `getRequestOrigin()` is
|
||
`x-forwarded-proto://Host` (`server/utils/requestUtils.js`, read in the pinned digest
|
||
2026-11-03) — so behind a TLS edge that reaches the container over plain http it computes
|
||
`http://audiobooks.franksdank.cloud` and answers its own https callback with a 400 "Invalid callback
|
||
URL - must be same-origin" before the IdP is ever contacted. The Caddy `:8017` route pins
|
||
`header_up X-Forwarded-Proto https` for exactly that reason ([[Caddy]] *Guard a new vhost*);
|
||
the LAN door needs nothing, because there browser and server agree on `http`.
|
||
- **The claim mapping is not the whole `property_mappings` list.** A provider holding only the
|
||
custom mapping grants a token scoped shorter than the scopes ABS requests
|
||
(`openid profile email abs_groups`): the code is issued and exchanged normally, and ABS's
|
||
`/userinfo` call then dies `insufficient_scope (The request requires higher privileges than
|
||
provided by the access token)` — a fault that reads like an ABS bug and is an IdP row. Attach
|
||
the built-in OpenID `openid`/`profile`/`email` mappings beside the claim mapping, which is
|
||
what every working RP on this IdP carries (measured 2026-11-02).
|
||
- **With `authOpenIDMatchExistingBy` unset, an SSO identity never lands on an existing ABS
|
||
account, and a name collision is a hard denial.** In that mode `findUserFromOpenIdUserInfo`
|
||
matches on `sub` only, so every first sign-in auto-registers a NEW user; if the IdP username
|
||
equals an ABS username — the local root `frank` is the one here — the create hits the unique
|
||
constraint and the callback dies `Unauthorized`. So "create a `frank` user in authentik" does
|
||
not reach the root account: either set `authOpenIDMatchExistingBy` to `username` (links the
|
||
IdP account to the existing one, and `setUserGroup` refuses to downgrade a root — root plus an
|
||
`admin` claim is allowed, root plus `user` is denied) or pick a non-colliding IdP name. Read
|
||
off `server/models/User.js` and `server/auth/OidcAuthStrategy.js` in the pinned digest
|
||
(2026-11-02).
|
||
- **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]]).
|
||
- **One door for every client, and it sits outside the guard chain on purpose.**
|
||
`https://audiobooks.franksdank.cloud/audiobookshelf` is what browsers and apps use;
|
||
`http://192.168.0.235:8000/audiobookshelf` is the raw LAN socket that same Caddy dials,
|
||
so it doubles as break-glass when the proxy is down. Behind the chain (until
|
||
2026-11-03) the vhost answered the phone app's `/ping` and `/socket.io/` handshake with
|
||
Anubis' challenge HTML — Anubis demands a JavaScript proof and the outpost needs a
|
||
session cookie, and a native client solves neither — so the app evaluated
|
||
`'isInit' in e.data` on a non-object, reported "Failed to contact server", and the
|
||
container's request log never recorded the attempt. Podcast RSS and OPDS readers died
|
||
the same death. ABS's own auth is the gate, as it is for [[Plex]] and [[Jellyfin]]
|
||
([[Caddy]] *Unguard a vhost*).
|
||
- **There is no `/setup` route; the creation door is `POST /init`.** `/status`
|
||
(no `/api` prefix) reports `isInit` = "a root user exists", `POST /init` with
|
||
`{"newRoot":{"username","password"}}` creates it, `POST /login` returns the
|
||
bearer every `/api/*` route wants. `POST /setup` answers `Unauthorized` — that
|
||
is a missing route wearing an auth message. Read off `dist-server/server/Server.js`
|
||
inside the pinned digest rather than from a forum.
|
||
- **A 200 may carry plain text.** `res.sendStatus(200)` answers the body `OK`, and
|
||
`/init` and the scan route use it; a client that parses every success as JSON
|
||
crashes on the one call that matters (`tests/audiobookshelf_init.bats` pins it).
|
||
- **The item count is not a field on the library DTO, and paging never ends
|
||
itself.** The door is `GET /api/libraries/<id>/items?limit=1` → `total`; a poller
|
||
that guesses a plausible name (`numItems`) prints `0` forever beside a scan that
|
||
is working. Paging is on `page` (0-based) plus `limit`, and **`offset` is
|
||
ignored**: `limit=500&offset=500` returns the same 500 ids as `offset=0`, while
|
||
`page=1` shares none of them. A census that pages on `offset` therefore reads
|
||
`ceil(total / limit) × limit` rows containing only `limit` distinct items — the
|
||
exact origin of the "exactly 1,500" and "exactly 4,000" counts two runs
|
||
reported for one library. Page on `page`, count *unique ids*, stop on `total`,
|
||
and take the number after the pass settles (`total` moves during a scan).
|
||
- **A fresh scan is the only thing that notices new audio.** `inotify` does not
|
||
cross a rootless podman bind — measured for [[Jellyfin]], whose page owns that
|
||
law — so the in-container watcher sees nothing a host process files onto the
|
||
array, and the deployer's re-run of the bootstrapper is the cheap scan trigger.
|
||
- **One stray media file at a collection root turns the whole collection into one
|
||
book.** Every scan re-walks the tree from disk (`scanFolder` → `recurseFiles`),
|
||
and `utils/scandir.js:groupFileItemsIntoLibraryItemDirs` assigns each file to the
|
||
FIRST directory along its path that already has a group: the first file seen at a
|
||
root owns every deeper file, and the folders below it never become items. One
|
||
949 MB `.cbr` sitting at `_unsorted`'s root collapsed 652 book folders into a
|
||
single item of 5,693 tracks, titled after whichever file supplied metadata.
|
||
"Hundreds of books are missing" is a layout fault, not a scan fault.
|
||
- **"Media file" means audio OR ebook, so an audio-only census cannot see the file
|
||
that is hiding the library.** With `audiobooksOnly=false` (the deployed shape)
|
||
the predicate is `SupportedAudioTypes` ∪ `SupportedEbookTypes`: 20 audio
|
||
extensions (including `mp4`, `mpg`, `webm`) plus `epub pdf mobi azw3 cbr cbz`. A
|
||
census reporting "that root holds no loose audio" can be correct and still
|
||
useless — the file doing the shadowing here was a comic archive.
|
||
- **Deleting the covering item does not un-shadow anything.** Grouping is recomputed
|
||
from disk, so the next scan rebuilds the item from the same stray file (measured:
|
||
`DELETE`, then scan → `1 Added`, the item back at the same path). The fix is the
|
||
MOVE: give every stray root file a folder of its own (one file per folder is
|
||
ABS's own single-book layout; formats of one book share a folder), through the
|
||
journal-guarded mover, then delete the stale item so the old path is not kept,
|
||
then scan.
|
||
- **`?force=1` is neither a discovery arm nor a repair arm.** Both arms of
|
||
`POST /libraries/:id/scan` walk the disk, so finding new folders needs neither
|
||
and the deployer's cheap trigger stays on the plain arm. Force additionally
|
||
re-scans every item that already exists (measured 1:33 against 0:04 on ~4,600
|
||
items) and repaired nothing on a library whose residuals were file-level: item
|
||
count, path set and media-less count identical before and after.
|
||
- **`DELETE /api/items/:id` is a DB row; `?hard=1` is `fs.remove()`.** In
|
||
`LibraryItemController.delete`, `hardDelete = req.query.hard == 1` and then
|
||
`fs.remove(libraryItemPath)`. Pointed at a covering item, that path is the whole
|
||
swallowed subtree — `hard=1` there deletes every book beneath it off the array.
|
||
Assert the item's `path` first, and never pass `hard`.
|
||
- **`Invalid data found when processing input` names the file, not the bind.** The
|
||
scanner logs it per file and skips that file; a book whose files ALL fail never
|
||
becomes an item at all, so the disk-vs-DB folder census shows it as a book with
|
||
no item. That is corrupt media, not a lost book. Never clear a suspect file with
|
||
a lenient probe: `ffprobe -show_entries format=duration` happily returns three
|
||
hours for the WAV bytes wearing a `.mp3` name that the scanner refuses. Read the
|
||
log's file paths instead, and `stat -c %s` first — 0-byte files are the common
|
||
case, and a truncated download reports `moov atom not found`.
|
||
- **A `Disc 01` / `CD 1` subfolder is part of a book, not a book.** The extension
|
||
census counts those folders; ABS deliberately does not — its grouping attaches
|
||
their files to the ancestor item (measured: 110 such folders under one item
|
||
carrying all 192 tracks). Read the basename before believing a disk-vs-DB gap.
|
||
- **`.cbr`/`.cbz` group like media and never play.** They sit in the grouping
|
||
predicate, so they hide books, but the comic parser throws `ERAR_BAD_ARCHIVE`
|
||
(`parseComicMetadata` → `CbrComicBookExtractor`) and the item lands with no
|
||
media. A media-less row beside those lines is the file, not the scanner.
|
||
- **A scan is finished when a NEW scan uuid completes.** Match
|
||
`Library scan "<uuid>" completed in ...`. `podman logs --since 30m` is a sliding
|
||
window: the set of visible completion lines changes on its own as old lines age
|
||
out, so a poller keyed on that hash reads a mid-scan number and calls it done.
|
||
|
||
## 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, the base path the shipped SPA needs), `tests/audiobookshelf_init.bats` (the
|
||
bootstrapper against a stub of the API above: create-once, idempotent re-run, no secret in a
|
||
URL, refusal by URL when the server does not answer, the deployer calling it only after the
|
||
health gate, and the SSO arm sending the mounted base path as the redirect subfolder);
|
||
`tests/media_overlay.bats` (only [[Plex]] and [[Jellyfin]] may reference the metadata view);
|
||
and the live Playwright gates `tests/e2e/specs/23-audiobookshelf-sso.spec.ts` and
|
||
`tests/e2e/specs/24-audiobookshelf-sso-default-listener.spec.ts`, run by `tests/e2e/run.sh` at
|
||
the end of every `deploy.sh`. `23-` (the `admin` project) signs in as `qa-e2e-admin` against
|
||
`http://192.168.0.235:8000/audiobookshelf` and asserts the two things no stub can see: every
|
||
`.js` the app loads arrives as JavaScript (a bundle built for the wrong base path arrives as
|
||
`text/html`, which boots nothing), and `/api/me` answers 200 with `type: "admin"` — true only
|
||
while grant types, the OpenID scope mappings and the group claim all hold together. `24-` (the
|
||
`standard` project) signs in as `qa-e2e`, an account in no Audiobookshelf group, and must come
|
||
back `type: "user"`: the default grant that lets the household in, so a scope-mapping
|
||
regression surfaces as a failed sign-in in the gate instead of as a friend's complaint.
|
||
|
||
The public door is pinned by the guard audit `tests/e2e/specs/14-guard.spec.ts`, which now
|
||
carries an **exemption** assertion where it used to carry a guarded one: an anonymous raw
|
||
client must get the app's own `{"success":true}` on `/ping`, a machine-format socket
|
||
handshake, and the app's own `401` on `/audiobookshelf/api/libraries` — never challenge
|
||
HTML. `tests/anubis.bats` pins the same shape in the Caddyfile: the vhost never proxies to
|
||
`anubis:8923`, and the post-auth listener carries no `@audiobooks` route.
|
||
|
||
Five book folders hold media no scanner can read, so they stay out of the library:
|
||
three 2024 Nebula nominees in the `_unsorted` box (two `.m4b` with no `moov` atom,
|
||
one 36-file `.mp3` set that is not MPEG audio at all), `Anthony Heald/Bounty
|
||
Hunter Wars - Book 1` and `Star Wars Full Cast Audio Drama/Dark Empire III` — the
|
||
latter two also exist in readable form under their author folders. Fix by
|
||
re-acquiring the files; the scanner names every one of them in its log.
|