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

+134 -35
View File
@@ -140,6 +140,12 @@ type MediaScanner struct {
newItems int
errors int
job *Job
// audioBookRoots tracks the folder-book roots (absolute paths)
// already ingested during THIS scan walk, so per-track walk visits
// skip instead of reprocessing. Reset at the top of ScanFolders; the
// archive pass reuses it to keep book roots off the missing list.
audioBookRoots map[string]bool
}
// CalibreOPFMetadata represents intermediate parsed metadata from Calibre metadata.opf files
@@ -434,6 +440,7 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
s.totalFiles = 0
s.newItems = 0
s.errors = 0
s.audioBookRoots = make(map[string]bool)
for _, folder := range s.folders {
if ctx.Err() != nil {
@@ -499,6 +506,19 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
}
}
// Multi-file audiobooks first: zip-of-audio books and
// folder-of-tracks books ingest as whole units, and tracks
// inside an already-ingested root skip entirely. Everything
// else (lone audio files, all non-audio media) falls
// through to the single-file path below.
if handled, err := s.maybeProcessAudioBook(ctx, path); handled {
if err != nil {
fmt.Printf("Error processing audio book %s: %v\n", path, err)
s.errors++
}
return nil
}
wasNew, err := s.processMediaFile(ctx, path)
if err != nil {
fmt.Printf("Error processing media file %s: %v\n", path, err)
@@ -569,6 +589,14 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
continue // Skip to next folder to avoid false archivals
}
// Folder-book roots count as present: the item's file_path is the
// DIRECTORY, which the file walk above never emits.
for root := range s.audioBookRoots {
if strings.HasPrefix(root, folder) {
scannedPaths[s.getRelativePath(root)] = true
}
}
for _, item := range dbItems {
if item.FilePath == "" || scannedPaths[item.FilePath] {
continue // File present; unarchive is handled in processMediaFile
@@ -827,16 +855,6 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
metadata = &MediaMetadata{}
}
// Audiobooks: sidecar narrators/chapters + ffprobe facts into the
// audio_metadata JSONB (sidecar-first, ffprobe-fills; the payload's
// shape is the chapter abstraction every format writes).
if isAudioFile(path) {
if metadata.AudioMeta == nil {
metadata.AudioMeta = readSidecarAudio(path)
}
s.enrichAudioMetadata(path, metadata)
}
// Extract hash information during metadata extraction
hashInfo, formatInfo, err := s.extractHashInfo(path)
if err != nil {
@@ -953,6 +971,21 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
}
}
// Audiobooks: sidecar narrators/chapters + ffprobe facts (incl. the
// full tag fallbacks) into the audio_metadata JSONB. Runs AFTER the
// folder-structure pass so the precedence chain holds: sidecars →
// folder structure → tags → filename. The payload's shape is the
// chapter abstraction every format writes.
if isAudioFile(path) {
if metadata.AudioMeta == nil {
metadata.AudioMeta = readSidecarAudio(path)
}
s.enrichAudioMetadata(path, metadata)
if metadata.CoverPath == "" {
s.applyEmbeddedCoverFallback(path, metadata)
}
}
// Final fallback if still missing essential metadata
if metadata.Title == "" {
metadata.Title = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
@@ -988,6 +1021,7 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
SeriesNumber: pgtype.Int4{Int32: metadata.SeriesNumber, Valid: metadata.SeriesNumber > 0},
Publisher: pgtype.Text{String: metadata.Publisher, Valid: metadata.Publisher != ""},
DatePublished: pgtype.Date{Time: metadata.PublishDate, Valid: !metadata.PublishDate.IsZero()},
Genre: pgtype.Text{String: metadata.Genre, Valid: metadata.Genre != ""},
Contributors: metadata.Contributors,
ContributorsSearch: contributorsSearch,
Narrators: nilToEmpty(metadata.Narrators),
@@ -1118,6 +1152,16 @@ func (s *MediaScanner) applyEmbeddedCoverFallback(path string, metadata *MediaMe
if coverPath, err := s.extractEPUBCover(path); err == nil && coverPath != "" {
metadata.CoverPath = s.getRelativePath(coverPath)
}
default:
// Audio containers (mp3 APIC, m4b/flac embedded picture): one
// ffmpeg call, written as a "<file>.cover.jpg" sibling — the
// same convention the EPUB path uses. Purely optional: absent
// ffmpeg or no embedded art simply leaves the cover empty.
if isAudioFile(path) && !strings.EqualFold(filepath.Ext(path), ".zip") {
if cover := extractEmbeddedAudioCover(path, path); cover != "" {
metadata.CoverPath = s.getRelativePath(cover)
}
}
}
}
@@ -1712,10 +1756,10 @@ func (s *MediaScanner) extractMetadata(path string) (*MediaMetadata, error) {
}
return metadata, nil
default:
// For other formats, return basic metadata
return &MediaMetadata{
Title: strings.TrimSuffix(filepath.Base(path), ext),
}, nil
// Audio containers and other unknown types: NO filename title
// here — processMediaFile's final fallback applies the filename
// LAST, after the audio tag-fallback chain has had its turn.
return &MediaMetadata{}, nil
}
}
@@ -3193,20 +3237,29 @@ func countArchiveImages(filePath string) (int, error) {
// from the existing row so rescans never clobber user customizations; only
// reset-to-scanned-defaults (RescanMediaItem with reset) clears them.
func (s *MediaScanner) updateMediaItem(ctx context.Context, existing database.MediaItems, path string) error {
// Re-extract metadata for the update
metadata, err := s.extractMetadata(path)
if err != nil {
fmt.Printf("Warning: failed to extract metadata for force rescan %s: %v\n", path, err)
metadata = &MediaMetadata{}
}
return s.updateMediaItemWithMetadata(ctx, existing, path, nil)
}
// Rescans must RE-DERIVE audio facts, never preserve stale ones
// (gotcha 39's principle applied to audio_metadata).
if isAudioFile(path) {
if metadata.AudioMeta == nil {
metadata.AudioMeta = readSidecarAudio(path)
// updateMediaItemWithMetadata is updateMediaItem with an optional
// pre-extracted metadata set. Multi-file audiobooks arrive through
// RescanMediaItem with book-level metadata (sidecars, folder structure,
// first-track tags) because the generic per-file extraction would read
// the book ROOT/CONTAINER as if it were the book file.
func (s *MediaScanner) updateMediaItemWithMetadata(ctx context.Context, existing database.MediaItems, path string, preExtracted *MediaMetadata) error {
var metadata *MediaMetadata
if preExtracted != nil {
metadata = preExtracted
} else {
// Re-extract metadata for the update
extracted, err := s.extractMetadata(path)
if err != nil {
fmt.Printf("Warning: failed to extract metadata for force rescan %s: %v\n", path, err)
metadata = &MediaMetadata{}
} else if extracted == nil {
metadata = &MediaMetadata{}
} else {
metadata = extracted
}
s.enrichAudioMetadata(path, metadata)
}
// Folder-structure fallback, mirroring the CREATE path
@@ -3233,6 +3286,35 @@ func (s *MediaScanner) updateMediaItem(ctx context.Context, existing database.Me
}
}
// Audiobooks (single-file): re-derive the audio facts + tag fallbacks
// AFTER the folder-structure pass — the precedence chain (sidecars →
// folder structure → tags → filename) must hold on rescans too.
// Multi-file books arrive with preExtracted metadata already carrying
// their enriched payload.
// Rescans must RE-DERIVE audio facts, never preserve stale ones
// (gotcha 39's principle applied to audio_metadata).
if preExtracted == nil && isAudioFile(path) {
if metadata.AudioMeta == nil {
metadata.AudioMeta = readSidecarAudio(path)
}
s.enrichAudioMetadata(path, metadata)
if metadata.CoverPath == "" {
s.applyEmbeddedCoverFallback(path, metadata)
}
}
// Final filename fallback, mirroring processMediaFile's create path:
// extractMetadata's default branch no longer pre-fills the filename
// as title (embedded tags must outrank it), so the update path needs
// the same last rung — a sidecar-less, tag-less book at a library
// root (no folder structure to match) keeps its filename-derived
// title instead of being blanked. Guarded to single-file updates:
// multi-file representatives are sentinels/containers whose names
// must never leak as titles.
if preExtracted == nil && metadata.Title == "" {
metadata.Title = strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
}
// Normalize metadata fields
metadata.Contributors = utils.NormalizeContributors(metadata.Contributors)
metadata.Tags = utils.NormalizeTags(metadata.Tags)
@@ -3289,21 +3371,26 @@ func (s *MediaScanner) updateMediaItem(ctx context.Context, existing database.Me
utils.ApplyMetadataOverrides(&params, existing)
params.MetadataOverrides = utils.MergeOverrides(existing.MetadataOverrides)
if _, err = s.db.UpdateMediaItem(ctx, params); err != nil {
if _, err := s.db.UpdateMediaItem(ctx, params); err != nil {
return err
}
// Format flags are file-derived facts, not user metadata: refresh them on
// every update so a force rescan (the admin's backfill tool) can
// reclassify rows created before a classifyFormatGroup arm existed.
// Mirrors the creation path above.
ext := strings.ToLower(filepath.Ext(path))
var epubIsFixedLayout bool
if ext == ".epub" {
isFixed, fixedErr := s.DetectFixedLayoutEPUB(path)
epubIsFixedLayout = fixedErr == nil && isFixed
// Mirrors the creation path above. Multi-file audiobooks keep the
// "audiobook" group: their representative path is a directory or a zip
// container, which no classify arm could name.
formatGroup, isReflowable, hasFixedLayout := "audiobook", false, false
if preExtracted == nil || preExtracted.AudioMeta == nil || preExtracted.AudioMeta.Layout == "" {
ext := strings.ToLower(filepath.Ext(path))
var epubIsFixedLayout bool
if ext == ".epub" {
isFixed, fixedErr := s.DetectFixedLayoutEPUB(path)
epubIsFixedLayout = fixedErr == nil && isFixed
}
formatGroup, isReflowable, hasFixedLayout = classifyFormatGroup(ext, epubIsFixedLayout)
}
formatGroup, isReflowable, hasFixedLayout := classifyFormatGroup(ext, epubIsFixedLayout)
mimeType := s.getMimeType(path)
if err := s.db.UpdateMediaItemFormatGroup(ctx, database.UpdateMediaItemFormatGroupParams{
ID: existing.ID,
@@ -3367,6 +3454,16 @@ func (s *MediaScanner) RescanMediaItem(ctx context.Context, mediaItemID pgtype.U
return fmt.Errorf("failed to stat media file: %w", err)
}
// Multi-file audiobooks (folder/zip layout) must NOT go through the
// generic per-file updater: it would read the book ROOT directory (or
// the zip container) as if it were the book file — deriving a filename
// title and classifying format_group "unknown", which drops the item
// out of the audio reader. The book-level path rebuilds the track
// manifest and re-derives metadata from sidecars/tags instead.
if item.FormatGroup == "audiobook" && audioLayoutOf(item.AudioMetadata) != "" {
return s.rescanMultiFileAudioBook(ctx, item, fullPath)
}
if err := s.updateMediaItem(ctx, item, fullPath); err != nil {
return fmt.Errorf("failed to update media item: %w", err)
}
@@ -4012,6 +4109,8 @@ func (s *MediaScanner) detectFormatType(filePath string) string {
return "pdf"
case ".cbz", ".cbr", ".cb7", ".cbt":
return "comic_archive"
case ".m4b", ".m4a", ".mp3", ".flac", ".opus", ".ogg", ".oga", ".aac", ".wav":
return "audiobook"
case ".mobi":
return "mobi"
case ".azw", ".azw3":
@@ -4042,7 +4141,7 @@ func classifyFormatGroup(ext string, epubIsFixedLayout bool) (formatGroup string
return "fixed_layout", false, true
case ".cbz", ".cbr", ".cb7", ".cbt":
return "comic_archive", false, true
case ".m4b", ".m4a", ".mp3", ".flac":
case ".m4b", ".m4a", ".mp3", ".flac", ".opus", ".ogg", ".oga", ".aac", ".wav":
// Audio is neither reflowable text nor fixed-layout pages; the
// "audiobook" group routes readers to the audio player instead
// of any document engine.