feat(audiobookshelf): bootstrap the account, library and scan from the deployer #119

Merged
dank merged 7 commits from feat/audiobookshelf-bootstrap into main 2026-11-02 07:16:45 -08:00
Owner

What

A freshly deployed Audiobookshelf answers /healthcheck 200 and is otherwise dead: no
account, no library, no scan. The registration door is gone from the UI — POST /init
returns 200 with plain text "OK" (not JSON) and /status reports isInit, and
there is no /setup route at all. That is an API-only bootstrap, so it cannot be done
by hand once per deploy. This adds it as a script and wires it into the deployer.

scripts/audiobookshelf-init.sh (idempotent, safe to re-run):

  1. /status → if isInit is false, POST /init creates the root account.
  2. POST /login → bearer token.
  3. POST /api/libraries → the Audiobooks library on /audiobooks.
  4. POST /api/libraries/{id}/scan — the cheap rescan trigger.

The deployer runs it after the health gate, never before (a start-then-immediately-
bootstrap run races the server's own boot).

Credentials

~/.config/secrets/audiobookshelf (mode 600, created by the script) holds
ABS_ADMIN_PASSWORD and ABS_BEARER_TOKEN. Neither ever rides a URL or argv — the
bearer travels in an Authorization header, the password only in a POST body. The
seam asserts this behaviourally: a stub server logs every request line and the test
greps those lines for the token and password.

Seams and docs

  • tests/audiobookshelf_init.bats — 6 tests against an inline stub server: fresh
    bootstrap creates account + library + scan; second run creates nothing new but still
    fires the incremental scan; no secret in any URL; a server that refuses is reported
    as refused: <url> with exit 1, never as success; ABS_SCAN outside 0|1 fails
    before a single request; the deployer orders init after the health gate.
  • scripts/test-changed.sh routes the new seam.
  • serverAppConfigs-docs/Dankserver/Audiobookshelf.md — the first-run section is now
    the bootstrap (the "LAN-only account creation" claim it replaced was wrong: measured);
    new laws: no /setup route, /init answers text, the folder watcher cannot cross the
    rootless podman bind so the deployer's re-run is the cheap scan trigger.
  • Architecture/Testing Policy.md + regenerated index.md.

Evidence (live, Dankserver 2026-11-02)

Run against the already-bootstrapped host it is a no-op that re-fires the scan:

INIT: already registered
LIBRARY: exists Audiobooks (lib-…)
SCAN: accepted

Library census after the scan started: 1,500 items, all 1,500 with audio tracks,
0 invalid, 0 missing, ≈23,200 h of audio. media.numTracks is on the list DTO —
GET /api/items/{id} returns media.audioFiles, not tracks.

Not deployed

No deploy is run: the live service is already bootstrapped, and a redeploy would restart
the container and discard its in-flight library scan. The next deploy.sh dankserver --service audiobookshelf applies the hook.


cwd: /home/blank/.omp/wt/wt-20261030-163847-6d1fc1f · session: 01a18f2e-18b1-7573-bcd5-ac99636f8bcb

## What A freshly deployed Audiobookshelf answers `/healthcheck` 200 and is otherwise dead: no account, no library, no scan. The registration door is gone from the UI — `POST /init` returns 200 with **plain text `"OK"`** (not JSON) and `/status` reports `isInit`, and there is no `/setup` route at all. That is an API-only bootstrap, so it cannot be done by hand once per deploy. This adds it as a script and wires it into the deployer. `scripts/audiobookshelf-init.sh` (idempotent, safe to re-run): 1. `/status` → if `isInit` is false, `POST /init` creates the root account. 2. `POST /login` → bearer token. 3. `POST /api/libraries` → the `Audiobooks` library on `/audiobooks`. 4. `POST /api/libraries/{id}/scan` — the cheap rescan trigger. The deployer runs it **after** the health gate, never before (a start-then-immediately- bootstrap run races the server's own boot). ## Credentials `~/.config/secrets/audiobookshelf` (mode 600, created by the script) holds `ABS_ADMIN_PASSWORD` and `ABS_BEARER_TOKEN`. Neither ever rides a URL or argv — the bearer travels in an `Authorization` header, the password only in a POST body. The seam asserts this behaviourally: a stub server logs every request line and the test greps those lines for the token and password. ## Seams and docs - `tests/audiobookshelf_init.bats` — 6 tests against an inline stub server: fresh bootstrap creates account + library + scan; second run creates nothing new but still fires the incremental scan; no secret in any URL; a server that refuses is reported as `refused: <url>` with exit 1, never as success; `ABS_SCAN` outside `0|1` fails before a single request; the deployer orders `init` after the health gate. - `scripts/test-changed.sh` routes the new seam. - `serverAppConfigs-docs/Dankserver/Audiobookshelf.md` — the first-run section is now the bootstrap (the "LAN-only account creation" claim it replaced was wrong: measured); new laws: no `/setup` route, `/init` answers text, the folder watcher cannot cross the rootless podman bind so the deployer's re-run is the cheap scan trigger. - `Architecture/Testing Policy.md` + regenerated `index.md`. ## Evidence (live, Dankserver 2026-11-02) Run against the already-bootstrapped host it is a no-op that re-fires the scan: ``` INIT: already registered LIBRARY: exists Audiobooks (lib-…) SCAN: accepted ``` Library census after the scan started: 1,500 items, **all 1,500 with audio tracks**, 0 invalid, 0 missing, ≈23,200 h of audio. `media.numTracks` is on the list DTO — `GET /api/items/{id}` returns `media.audioFiles`, not `tracks`. ## Not deployed No deploy is run: the live service is already bootstrapped, and a redeploy would restart the container and discard its in-flight library scan. The next `deploy.sh dankserver --service audiobookshelf` applies the hook. --- cwd: /home/blank/.omp/wt/wt-20261030-163847-6d1fc1f · session: 01a18f2e-18b1-7573-bcd5-ac99636f8bcb
dank added 2 commits 2026-11-02 02:56:48 -08:00
A freshly deployed Audiobookshelf answers /healthcheck and is otherwise dead: no
account, no library, no scan. The UI registration step is gone by design — the
doors are POST /init (200 with plain text "OK", not JSON) and /status (isInit),
and there is no /setup route. That is an API-only bootstrap, so it must be code.

scripts/audiobookshelf-init.sh is idempotent: /status -> /init when unregistered,
POST /login for the bearer, POST /api/libraries for the Audiobooks library on
/audiobooks, then POST /api/libraries/{id}/scan. The deployer runs it only after
the health gate has answered.

Credentials live in ~/.config/secrets/audiobookshelf (mode 600): the bearer rides
an Authorization header and the password only a POST body — never a URL or argv,
where any ps on the host would print them. The seam drives an inline stub server
that logs every request line, and asserts: fresh bootstrap creates account,
library and scan; a second run creates nothing new and still fires the
incremental scan; no secret appears in any logged request line; a server that
refuses is reported as "refused: <url>" with exit 1 rather than success; ABS_SCAN
outside 0|1 fails before a single request; the deployer orders init after health.

Measured on Dankserver: run against the already-bootstrapped host it is a no-op
that re-fires the scan; the library then scans (1,500+ books, every item with
audio tracks, none invalid).
The page claimed the account can only be created from a LAN address; measured,
the door is POST /init and it answers plain text. Replaced with the bootstrap the
deployer now runs, and with the doors a consumer actually needs: /status reports
isInit with no /api prefix, the item count rides /api/libraries/{id}/items ->
total (the library DTO has no totals), and the list DTO carries media.numTracks
while /api/items/{id} returns media.audioFiles.

New law: the folder watcher cannot cross the rootless podman bind — the same gap
as Jellyfin's auto-scan note — so files added on the host stay invisible until a
scan runs, and the deployer's re-run of the bootstrapper is the cheap trigger.

Testing Policy lists the new seam; index.md regenerated from frontmatter.
dank added 1 commit 2026-11-02 02:57:33 -08:00
The first full scan logged "Invalid data found when processing input" per file and
carried on. That line is about the file: the scanner skips it, so the book never
appears in the library at all. Census of the 43 refused files: 38 were 0 bytes, 3
held WAV bytes behind a .mp3 name, 2 were unreadable. The check to run first is a
byte count, not a server investigation.

Full suite: 818 tests, exit 0.
dank added 1 commit 2026-11-02 03:18:04 -08:00
Two censuses this session reported exactly 1,500 and exactly 4,000 books for one
library mid-scan. Cause: an out-of-range offset returns the last item instead of an
empty page, so a loop written "until the page is empty" manufactures its own
total, and total itself moves while a scan is running. Stop on total; count after
the pass settles.
dank added 1 commit 2026-11-02 03:34:29 -08:00
Measured on the live library: 6 stray files at the root of _unsorted made the
whole collection dump ONE item of 5,693 tracks, with 542 real books folded into
it; no scan subdivides an existing item. Also pinned: the items endpoint hands
back ceil(total/limit)*limit rows because an out-of-range offset repeats the
tail (that is where "exactly 1,500" and "exactly 4,000" came from), and
DELETE /api/items/:id?hard=1 runs fs.remove() on the item's folder - pointed at
a covering item that is the whole subtree, so un-swallowing is a DB-only delete
after the stray root files are filed.
dank added 2 commits 2026-11-02 04:00:00 -08:00
Corrects the law pushed earlier today. The mechanism is
`groupFileItemsIntoLibraryItemDirs` (utils/scandir.js), called on a live
`recurseFiles` walk of the tree EVERY scan: each file attaches to the FIRST
directory along its path that already has a group, so one media file at a
collection root owns everything beneath it. Measured: one 949 MB `.cbr` hid
652 book folders behind a single 5,693-track item.

- "Media file" is audio OR ebook (`audiobooksOnly=false` -> SupportedAudioTypes
  ∪ SupportedEbookTypes, 17 audio + 6 ebook extensions). An audio-only census
  cannot see the file that hides an Audiobooks library.
- Deleting the covering item does not fix a shadow: grouping is recomputed from
  disk and the next scan rebuilds the item (measured: `1 Added`, same path).
- Un-shadow by MOVE: one file per folder is ABS's own single-book layout.
- `?force=1` is not a discovery arm (both arms walk the disk; force re-scans
  every existing item, 1:33 against 0:04).
- Paging corrected: `offset` is ignored outright, `page` is the parameter.
- `DELETE /api/items/:id` is a DB row; `?hard=1` is `fs.remove()` of the whole
  swallowed subtree. Never pass `hard`.

Shape table gains the layout row and the move journal; the stale "1,103
shelves" scan count is destroyed.
Four laws from finishing the un-shadow, correcting one:

- A book whose files ALL fail to probe never becomes an item, so a disk-vs-DB
  folder gap beside `Invalid data` lines is corrupt media, not a lost book. A
  format-only ffprobe clears files the scanner rejects — do not trust it.
- `Disc 01`/`CD 1` subfolders are parts of one item (110 of them here, under a
  192-track book); the extension census counts them, ABS does not.
- `.cbr`/`.cbz` group like media but `parseComicMetadata` throws `ERAR_BAD_ARCHIVE`,
  so those items land media-less: the file, not the scanner.
- Scan completion is a NEW scan uuid; `podman logs --since` is a sliding window
  and ages its own evidence out.
- `?force=1` is now recorded as neither discovery nor repair: a forced full
  re-scan left item count, path set and media-less count identical.

Verification names the five book folders that need re-acquiring.
Author
Owner

Two docs commits landed since the review request; the library procedure is now measured end to end on Dankserver, and one law this PR carried was wrong.

  • f71c7cc corrects the earlier law: a book is not "the shallowest folder that holds audio". The real rule is groupFileItemsIntoLibraryItemDirs (utils/scandir.js) — every scan re-walks the tree and attaches each file to the FIRST directory along its path that already has a group — and with audiobooksOnly=false, "media" is audio or ebook. One 949 MB .cbr at the _unsorted root was hiding 652 book folders behind a single 5,693-track item.
  • Un-shadowing is a MOVE, not a delete (measured: DELETE then scan -> 1 Added, the same path back). 106 files into 102 folders through media-apply.py, and the journal is the rollback. The library went 4,628 items -> 5,374, exactly the count of book folders on disk, with isMissing=0 and no item whose files are gone from disk — nothing dead-links.
  • ad95634 adds the residual laws: Disc/CD subfolders are parts of one item (110 of them, so a disk-vs-DB census over-reports gaps), .cbr/.cbz group like media but parseComicMetadata throws ERAR_BAD_ARCHIVE so those rows land media-less, a book whose files ALL fail to probe never becomes an item (five folders need re-acquiring, named in the page), and scan completion is a NEW scan uuid — a --since log window ages its own evidence out. ?force=1 is recorded as neither a discovery nor a repair arm: a forced full re-scan left item count, path set and media-less count identical.

No code changed in these two commits. ./tests/run.sh: 818/818.


cwd: /home/blank/.omp/wt/wt-20261030-163847-6d1fc1f · session: 01a18f2e-18b1-7573-bcd5-ac99636f8bcb (build worktree: /tmp/wt-abs-fix)

Two docs commits landed since the review request; the library procedure is now measured end to end on Dankserver, and one law this PR carried was wrong. - `f71c7cc` **corrects the earlier law**: a book is not "the shallowest folder that holds audio". The real rule is `groupFileItemsIntoLibraryItemDirs` (`utils/scandir.js`) — every scan re-walks the tree and attaches each file to the FIRST directory along its path that already has a group — and with `audiobooksOnly=false`, "media" is audio **or** ebook. One 949 MB `.cbr` at the `_unsorted` root was hiding 652 book folders behind a single 5,693-track item. - Un-shadowing is a MOVE, not a delete (measured: `DELETE` then scan -> `1 Added`, the same path back). 106 files into 102 folders through `media-apply.py`, and the journal is the rollback. The library went 4,628 items -> **5,374**, exactly the count of book folders on disk, with `isMissing=0` and **no item whose files are gone from disk** — nothing dead-links. - `ad95634` adds the residual laws: `Disc`/`CD` subfolders are parts of one item (110 of them, so a disk-vs-DB census over-reports gaps), `.cbr`/`.cbz` group like media but `parseComicMetadata` throws `ERAR_BAD_ARCHIVE` so those rows land media-less, a book whose files ALL fail to probe never becomes an item (five folders need re-acquiring, named in the page), and scan completion is a NEW scan uuid — a `--since` log window ages its own evidence out. `?force=1` is recorded as neither a discovery nor a repair arm: a forced full re-scan left item count, path set and media-less count identical. No code changed in these two commits. `./tests/run.sh`: 818/818. --- cwd: /home/blank/.omp/wt/wt-20261030-163847-6d1fc1f · session: 01a18f2e-18b1-7573-bcd5-ac99636f8bcb (build worktree: /tmp/wt-abs-fix)
dank merged commit a651c81dfb into main 2026-11-02 07:16:45 -08:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: dank/serverAppConfigs#119