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):
/status → if isInit is false, POST /init creates the root account.
POST /login → bearer token.
POST /api/libraries → the Audiobooks library on /audiobooks.
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.
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.
## 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
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.
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.
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.
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.
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.
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.
f71c7cccorrects 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.
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 main2026-11-02 07:16:45 -08:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
What
A freshly deployed Audiobookshelf answers
/healthcheck200 and is otherwise dead: noaccount, no library, no scan. The registration door is gone from the UI —
POST /initreturns 200 with plain text
"OK"(not JSON) and/statusreportsisInit, andthere is no
/setuproute at all. That is an API-only bootstrap, so it cannot be doneby 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):/status→ ifisInitis false,POST /initcreates the root account.POST /login→ bearer token.POST /api/libraries→ theAudiobookslibrary on/audiobooks.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) holdsABS_ADMIN_PASSWORDandABS_BEARER_TOKEN. Neither ever rides a URL or argv — thebearer travels in an
Authorizationheader, the password only in a POST body. Theseam 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: freshbootstrap 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_SCANoutside0|1failsbefore a single request; the deployer orders
initafter the health gate.scripts/test-changed.shroutes the new seam.serverAppConfigs-docs/Dankserver/Audiobookshelf.md— the first-run section is nowthe bootstrap (the "LAN-only account creation" claim it replaced was wrong: measured);
new laws: no
/setuproute,/initanswers text, the folder watcher cannot cross therootless podman bind so the deployer's re-run is the cheap scan trigger.
Architecture/Testing Policy.md+ regeneratedindex.md.Evidence (live, Dankserver 2026-11-02)
Run against the already-bootstrapped host it is a no-op that re-fires the scan:
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.numTracksis on the list DTO —GET /api/items/{id}returnsmedia.audioFiles, nottracks.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 audiobookshelfapplies the hook.cwd: /home/blank/.omp/wt/wt-20261030-163847-6d1fc1f · session: 01a18f2e-18b1-7573-bcd5-ac99636f8bcb
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.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.
f71c7cccorrects the earlier law: a book is not "the shallowest folder that holds audio". The real rule isgroupFileItemsIntoLibraryItemDirs(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 withaudiobooksOnly=false, "media" is audio or ebook. One 949 MB.cbrat the_unsortedroot was hiding 652 book folders behind a single 5,693-track item.DELETEthen scan ->1 Added, the same path back). 106 files into 102 folders throughmedia-apply.py, and the journal is the rollback. The library went 4,628 items -> 5,374, exactly the count of book folders on disk, withisMissing=0and no item whose files are gone from disk — nothing dead-links.ad95634adds the residual laws:Disc/CDsubfolders are parts of one item (110 of them, so a disk-vs-DB census over-reports gaps),.cbr/.cbzgroup like media butparseComicMetadatathrowsERAR_BAD_ARCHIVEso 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--sincelog window ages its own evidence out.?force=1is 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)