feat(audiobooks): multi-file books (folder-of-tracks + zip) + exhaustive metadata ingestion

Multi-file book ingest: a directory of ≥2 audio files (recursively —
disc-part folders like CD1/Disc 2 merge upward, siblings never merge)
ingests as ONE book whose file_path is the book-root directory; a zip
of audio tracks is its own book (file_path = the archive). Both write
the documented chapters abstraction {index,title,start,end} into
audio_metadata and add additive keys: layout ("folder"|"zip") and a
tracks[] manifest (per-track path, global-seconds start/end, duration,
size). Pre-grouping one-track strays under a book root are absorbed on
rescan (annotations cascade). Rescans take a stat-only fast path
(track count + total size + known durations unchanged → no re-probe —
a 1189-track book must not re-ffprobe every auto-scan); a zero-duration
book counts as changed so a ffprobe-equipped rescan fills durations in.
Folder-book roots count as present in the archive lifecycle pass, and
per-item rescan (RescanMediaItem) routes multi-file books through a
book-level refresh — the generic updater would read the root/zip as if
it were the book file and clobber format_group with "unknown".

Serving: GET /api/media-items/:id/chapters gains additive layout +
tracks keys (urls → the new endpoint). New GET
/api/media-items/:id/audio/:trackIndex streams one track — folder
tracks served from the real file (full Range via ServeContent), zip
tracks via a reopen-and-skip ReadSeeker over the entry (deflate has no
random access; acceptable at audio bitrates; stored entries stay
section readers). Both endpoints gate on library visibility (403 on
hidden libraries — family-lens rule). ServeFile and OPDS DownloadBook
refuse directory paths (folder books have no single-file download).

Web player (next commit) consumes the manifest; progress/bookmarks/
deep-links keep their global-seconds coordinates, so the server-side
progress model is untouched. sync learns FormatGroupAudiobook
(percentage passthrough in ConvertProgress); detectFormatType stops
reporting "unknown" for audio.

Metadata ingestion (Batch B) — read everything available, degrade
gracefully, documented precedence sidecars → folder structure →
embedded tags → filename: ffprobe tags are now a case-insensitive
lookup map (freeform keys pass through verbatim); composer fills
narrators (Audible's own convention, kept as ONE string — no reliable
multi-narrator delimiter); grouping + Audible freeform atoms
(SERIES/SERIES-PART/SUBTITLE/ASIN/ABRIDGED) fill series/index/
subtitle-join/ASIN/abridged (explicit sidecar abridged-false is
guarded); date/genre fall back to tags; album/album_artist/artist
fill title/author. Genre persists at create for all formats.
Embedded cover art (mp3 APIC, m4b covr, flac picture) extracts via one
optional ffmpeg call as a "<book>.cover.jpg" sibling — sidecar covers
win. CUE sheets (FILE-matched, INDEX 01 at 75fps) are the lowest-
priority chapter source under sidecars and embedded m4b chapters. A
lone m3u/m3u8 in a book root imposes playlist ordering + #EXTINF
names; (disc, track) tags reorder tracks when coverage is complete.
extractMetadata's default branch no longer pre-fills the filename as
title (tags must outrank it) — final filename fallbacks now exist on
BOTH the create and rescan paths (TestRescanKeepsFilenameTitle pins
that rescans cannot blank a title); tagless zip books take the zip's
own name, not their parent folder's.

Accepted containers widen to .m4b .m4a .mp3 .flac .zip .opus .ogg .oga
.aac .wav with real media types registered (audio/ogg per RFC 7845 for
Opus-in-Ogg) — all browser-playable; .wma deliberately excluded.

Tests: unit tests for CUE parsing, playlist hints, tag fallbacks
(synthetic ffprobe reports — ffmpeg cannot write Audible freeform
atoms), ordinal parsing, tag ordering, natural sort, grouping rules;
integration suite (11 tests) over the live dev DB: folder/zip ingest +
idempotency, stray absorption, chapters manifest, per-track streaming
with Range, hidden-library 403s, embedded covers + sidecar-wins, CUE
chapters, embedded m4b chapters, playlist ordering, tag fallbacks, all
five new containers with content-type assertions, rescan title pin.
Fixture media is real committed-format binary content living untracked
under internal/services/testdata/ (gitignored by policy; recipes in
docs/contributing/development.md → Media fixtures). Test image now
ships ffmpeg to match the runtime's audio toolchain.
This commit is contained in:
John O'Keefe committed 2026-10-19 07:54:28 -04:00
1 parent 24228e7147
commit 6216bd9bcf
26 files changed
+3419 -60

No files matched your search

+17
View File
@@ -102,3 +102,20 @@ cover_image_path, has_conflict, format_group, badge`.
origin — its rows are unchanged. Request-parse widening documented per
rule 2; response shapes untouched; locked by
TestOriginWriterAttribution.
## Changelog entry — multi-file audiobooks (2026-10-18)
- `GET /api/media-items/:id/chapters` gained ADDITIVE response keys
`layout` ("" for single-file books, `"folder"` or `"zip"` for
multi-file books) and `tracks[]` (`index, url, title, path, start,
end, duration_seconds, size_bytes`; empty for single-file books).
Existing keys (`media_item_id, chapters, duration_seconds,
codec_profile`) unchanged — no pin existed on this endpoint, so no
pin widened; recorded here per rule 2's spirit.
- New sibling endpoint `GET /api/media-items/:id/audio/:trackIndex`
streams one track of a multi-file book (Range-capable). New surface,
not a modification; gated by library visibility like `ServeFile`
(403 on hidden libraries).
- `media_items.audio_metadata` payload gained additive JSON keys
`layout` and `tracks` (see schema.sql comment). Consumers parse
additively; stored single-file payloads are unchanged.
@@ -0,0 +1,53 @@
# Get Audio Chapters
The format-abstraction chapter list for audiobooks, sourced from
`media_items.audio_metadata`. Embedded m4b chapters, folder-of-tracks
books and zip-of-audio books all write the same chapter shape; the
locator `{chapter, audio_offset_seconds}` and this endpoint never learn
which file model produced them.
**Endpoint**: `GET /api/media-items/{id}/chapters`
**Auth**: Required (JWT)
## Response
```json
{
"media_item_id": "…",
"chapters": [
{ "index": 0, "title": "Chapter One", "start": 0.0, "end": 168.0 }
],
"duration_seconds": 36000.0,
"codec_profile": "mp3",
"layout": "folder",
"tracks": [
{
"index": 0,
"url": "/api/media-items/{id}/audio/0",
"title": "01 First",
"path": "Author/Title/01 First.mp3",
"start": 0.0,
"end": 168.0,
"duration_seconds": 168.0,
"size_bytes": 2700000
}
]
}
```
- `layout` is `""` for single-file books (the Batch A model — the whole
file streams through the `/uploads` path), `"folder"` for a directory
of track files and `"zip"` for a zip-of-audio book.
- `tracks` is empty for single-file books and carries one entry per
playable file otherwise. `start`/`end` are cumulative GLOBAL seconds —
the same coordinate system chapters, bookmarks and
`audio_offset_seconds` use.
## Error Responses
| Code | Description |
| ---- | ------------------------------- |
| 400 | Invalid media item id |
| 401 | Missing/invalid token |
| 404 | Book not found |
| 500 | audio_metadata failed to parse |
@@ -0,0 +1,44 @@
# Get Audio Track
Streams one playable track of a multi-file audiobook (folder-of-tracks
or zip-of-audio book). Track indexes and bounds come from the track
manifest on [Get Audio Chapters](get_audio_chapters.md).
**Endpoint**: `GET /api/media-items/{id}/audio/{trackIndex}`
**Auth**: Required (JWT — Bearer header or session cookie; the web
player's `<audio>` element authenticates by cookie)
## Serving
- **Folder books**: the track is served straight from its real file
(`http.ServeContent`) — full `Range` support.
- **Zip books**: the entry is extracted in place. Forward seeks discard
bytes in the open stream; backward seeks re-open the entry and skip
(deflate has no random access — cheap at audio bitrates). `Range`
requests are honored.
Single-file books have no track manifest — they stream through the
`/uploads` path exactly as before and this endpoint answers 404.
## Library visibility
The item's library must be visible to the requester; a hidden library
answers **403** on every track (same gate as the download endpoint).
## Error Responses
| Code | Description |
| ---- | -------------------------------------------- |
| 400 | Invalid media item id or track index |
| 401 | Missing/invalid token |
| 403 | Library not visible to the requester |
| 404 | Book/track not found, or single-file book |
| 500 | audio_metadata failed to parse |
## Example
```bash
curl -H "Authorization: Bearer $TOKEN" \
-H "Range: bytes=0-1023" \
"http://localhost:8765/api/media-items/550e8400-.../audio/0"
```
@@ -90,8 +90,11 @@ Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
### Absent data
`Audio Tracks` (Audiobookshelf's per-file count) is deliberately absent:
Bookhoard audiobooks are one file per book until multi-file support
lands, so a file count would just equal `items`. See HANDOFF item 9.
multi-file audiobooks (folder-of-tracks / zip books) now ingest as ONE
item whose track list lives in `audio_metadata.tracks`, so a file count
would still not equal a per-book "tracks" metric without a new query.
Add it by aggregating `jsonb_array_length(audio_metadata->'tracks')` if
the panel is ever wanted.
## Error Responses
+83
View File
@@ -0,0 +1,83 @@
# Audiobook metadata: sources & precedence
Every audiobook ingestion path (single-file, folder-of-tracks, zip-of-
audio) fills the same fields from the same ranked sources. Each source
**only fills fields the earlier ones left empty** — curated data always
outranks guesses — and every source is optional: a book still ingests
(title from the filename) when all of them are absent. Admin edits made
after ingest are protected by the metadata-override mechanism on
rescans, regardless of source quality.
## Book-level metadata (title, author, narrators, series, …)
| Rank | Source | Feeds |
|---|---|---|
| 1 | **Sidecars** — `metadata.opf` (Calibre), `metadata.json` (Audiobookshelf/Libation, array-or-string tolerant) | title (+subtitle join), author, series + index, narrators, abridged, ASIN, tags/genres, dates, publisher, description |
| 2 | **Folder structure** — `Author/Title`, `Author/Series, Book #N - Title`, `Author/Series/Title` | title, author, series + index |
| 3 | **Embedded tags** (via ffprobe) | `album` → title; `album_artist` → author (`artist` fallback); `composer` → **narrators** (Audible's own convention); `grouping` → series; `date` → publish date; `genre` → genre; Audible freeform atoms `----:com.apple.iTunes:SERIES`/`:SERIES-PART`/`:SUBTITLE`/`:ASIN`/`:ABRIDGED` |
| 4 | **Filename/folder name** | title last of all (created only — never lets a rescan blank a title) |
Abridged is the one guarded field: an explicit sidecar `false` wins even
when a tag says `true` (the sidecar's presence is tracked, not just its
value). Narrators from the composer tag stay ONE string — composer
values have no reliable multi-narrator delimiter, and splitting
"Vance, Simon" would manufacture fake people.
## Chapters (the `{index, title, start, end}` array)
| Rank | Source |
|---|---|
| 1 | Sidecar `metadata.json` `chapters[]` (curated, global seconds) |
| 2 | Embedded m4b chapter atoms (`chpl` / QuickTime text track, read by ffprobe) |
| 3 | **CUE sheet** — `.cue` next to the audio file (FILE-directive matched, `INDEX 01 MM:SS:FF` at 75 fps); the classic one-big-flac + cue rip shape |
| 4 | Track boundaries — for folder/zip books, each file is a chapter |
## Track ordering (multi-file books)
| Rank | Source |
|---|---|
| 1 | **Playlist** — exactly one `.m3u`/`.m3u8` among the book root's direct children; its entry order wins and `#EXTINF` names the tracks |
| 2 | **Tags** — `(disc, track)` sort when *every* track carries a track number (fixes flat `01.mp3` dumps from multi-disc rips) |
| 3 | **Natural sort** of relative paths (`02` before `10`), recursive — the default |
Track display titles, highest first: `movement_name` → `title` tag →
playlist `#EXTINF` → cleaned filename (leading ordinals/separators
stripped).
## Book grouping (which files form one book)
- The book root is the nearest ancestor holding audio files DIRECTLY,
merged upward through disc-part directories (`CD1`, `Disc 2`, `Part 3`,
`05`) when the parent has no audio of its own.
- Sibling directories are NEVER merged (two books under one author stay
two books); a root with a single track falls back to the single-file
model.
- A lone `.zip` of audio files is its own book (layout "zip"); a folder
of tracks is one book with `file_path` = the directory (layout
"folder"); a single audio file is the Batch A model (no layout key).
- Rescans take a fast path (track count + total size + known durations
unchanged → no re-probe) and absorb pre-grouping one-track strays.
## Covers
| Rank | Source |
|---|---|
| 1 | Sidecar `cover.jpg` / `folder.jpg` / … next to the book |
| 2 | **Embedded art** (`APIC` mp3, `covr` m4b, flac picture) — one optional ffmpeg call, written as a `<book>.cover.jpg` sibling following the EPUB convention |
## Accepted containers (audiobook libraries)
`.m4b` `.m4a` `.mp3` `.flac` `.zip` (book container) `.opus` `.ogg`
`.oga` `.aac` `.wav` — all browser-playable; served with their real
media types (`audio/ogg` per RFC 7845 for Opus-in-Ogg, etc.).
## Documented non-goals
- **ID3v2 CHAP/CTOC chapter frames** — the written MP3 standard for
embedded chapters, near-zero audiobook adoption; would need a
hand-rolled ID3v2.4 parser. Revisit only if real files show up.
- **NFO sidecars** — no genuine audiobook NFO standard exists (Jellyfin's
Bookshelf uses Calibre OPF, which we read).
- **ReplayGain / EBU R128 loudness tags** — a playback feature, not
ingestion; separate plan if ever wanted.
- **`.wma`** — browsers cannot play it; excluded deliberately.