Files
John O'Keefe 6216bd9bcf 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.
2026-10-19 07:54:28 -04:00

4.5 KiB

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.