docs(audiobookshelf): a book is the shallowest audio folder, and it owns the subtree

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.
This commit is contained in:
Frank committed 2026-11-02 03:34:28 -08:00
1 parent 006bbcef28
commit f541566714
1 file changed
+20 -3
@@ -121,13 +121,30 @@ the file, not the bind: the same line count grows while the total climbs.
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. An out-of-range `offset` returns the last item rather than an empty
page, so a census that loops "until empty" invents its own total — two runs
reported exactly 1,500 and 4,000 for one library — and `total` itself moves while
a scan runs. Stop on `total`, and take the count after the pass settles.
page, so a census that loops "until empty" invents its own total: `limit=500`
against a `total` of 4,628 hands back exactly 5,000 rows — the collected count
is `ceil(total / limit) × limit`, which is why two runs reported "exactly 1,500"
and "exactly 4,000" for one library. Stop on `total`, count *unique ids*, and
take the number after the pass settles (`total` itself 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.
- **A book is the shallowest folder that holds audio — and that item owns the
subtree beneath it.** A folder with audio in its own file list becomes ONE book
and the scanner folds everything below it into that book's tracks instead of
making items: 6 stray files at the root of `_unsorted` turned the whole
collection dump into a single item of 5,693 tracks, titled after whichever file
supplied metadata, with 542 real books inside it. So "hundreds of books are
missing" is a layout fault, not a scan fault — and no scan, forced or not,
subdivides an existing item. Census the layout the server's way: a directory is
a book folder iff its *own* files include an audio extension.
- **`DELETE /api/items/:id` is a DB row; `?hard=1` is `fs.remove()`.** Read
`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 audiobook under it off the
array. Un-swallowing is: file the loose root files with the journal-guarded
mover, then delete the stale covering item with NO query string.
- **`Invalid data found when processing input` names the file, not the bind.** The
scanner logs it per file and skips the file — the book never appears — so a
missing title beside those lines is library debris, not a server fault. Census of