diff --git a/internal/scanner/audiobook.go b/internal/scanner/audiobook.go index 09dab4c3..3dea4675 100644 --- a/internal/scanner/audiobook.go +++ b/internal/scanner/audiobook.go @@ -2,7 +2,6 @@ package scanner import ( "context" - "errors" "fmt" "os" "path/filepath" @@ -32,10 +31,10 @@ type parsedAudiobookFile struct { // parseAudiobookFolder reads a single audiobook folder and returns its // structured representation. Recognized layouts: -// - one .m4b (or other audio file) in the folder, optionally with -// embedded chapters -// - multiple audio files in the folder (Task 7 — currently returns an -// error for this case) +// - one audio file in the folder, optionally with embedded chapters +// - multiple audio files in the folder; each becomes its own +// parsedAudiobookFile with a single synthesized chapter (title = +// filename stem); metadata comes from the first file's tags // // Returns an error wrapping os.ErrNotExist when the folder contains zero // audio files, so the caller can skip it. @@ -74,36 +73,45 @@ func parseAudiobookFolder(ctx context.Context, ffprobePath string, folderPath st return book, nil } - // Multi-file case is Task 7. - return nil, errors.New("multi-file audiobook folders not yet supported") + // Multi-file case: read header from the first file, synthesize one + // chapter per file with title = filename stem. + probedFirst, err := ProbeFile(ctx, ffprobePath, audioFiles[0]) + if err != nil { + return nil, fmt.Errorf("probe first audiobook file %s: %w", audioFiles[0], err) + } + book.populateFromTags(probedFirst.FormatTags) + + book.Files = make([]parsedAudiobookFile, 0, len(audioFiles)) + for i, path := range audioFiles { + stem := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + book.Files = append(book.Files, parsedAudiobookFile{ + Path: path, + Chapters: []ChapterInfo{{ + Index: i, + Title: stem, + StartSeconds: 0, + EndSeconds: 0, + }}, + }) + } + return book, nil } // populateFromTags fills the audiobook's header fields (Title, Author, // Narrator, Series, Year) from the ffprobe format tags. Tags are // lower-cased by normalizeFormatTags upstream. func (b *parsedAudiobook) populateFromTags(tags map[string]string) { - b.Title = pickFirstNonEmpty(tags["title"], tags["album"]) - b.Author = pickFirstNonEmpty(tags["artist"], tags["album_artist"], tags["composer"]) - b.Narrator = pickFirstNonEmpty(tags["narrator"], tags["performer"]) - b.Series = pickFirstNonEmpty(tags["album"], tags["series"], tags["mvnm"]) - if year := pickFirstNonEmpty(tags["date"], tags["year"]); year != "" { + b.Title = firstNonEmpty(tags["title"], tags["album"]) + b.Author = firstNonEmpty(tags["artist"], tags["album_artist"], tags["composer"]) + b.Narrator = firstNonEmpty(tags["narrator"], tags["performer"]) + b.Series = firstNonEmpty(tags["album"], tags["series"], tags["mvnm"]) + if year := firstNonEmpty(tags["date"], tags["year"]); year != "" { if y := parseTagYear(year); y > 0 { b.Year = y } } } -// pickFirstNonEmpty returns the first non-empty trimmed value from the -// supplied candidates. -func pickFirstNonEmpty(values ...string) string { - for _, v := range values { - if trimmed := strings.TrimSpace(v); trimmed != "" { - return trimmed - } - } - return "" -} - // parseTagYear extracts a 4-digit year (e.g. 1900-9999) from a tag value // that may be a bare year ("2024"), an ISO date ("2024-05-23"), or a // padded form ("(2024)"). Returns 0 if no plausible year is found. diff --git a/internal/scanner/audiobook_test.go b/internal/scanner/audiobook_test.go index 4801b010..7c4e5420 100644 --- a/internal/scanner/audiobook_test.go +++ b/internal/scanner/audiobook_test.go @@ -2,6 +2,7 @@ package scanner import ( "context" + "fmt" "os/exec" "testing" ) @@ -39,3 +40,41 @@ func TestParseAudiobookFolderSingleM4B(t *testing.T) { t.Errorf("file 0 chapters = %d, want 2", len(got.Files[0].Chapters)) } } + +func TestParseAudiobookFolderMultiFile(t *testing.T) { + ffprobePath := FFprobePathFromFFmpeg("ffmpeg") + if _, err := exec.LookPath(ffprobePath); err != nil { + ffprobePath = "ffprobe" + if _, err := exec.LookPath(ffprobePath); err != nil { + t.Skip("ffprobe not available") + } + } + + ctx := context.Background() + got, err := parseAudiobookFolder(ctx, ffprobePath, "testdata/audiobook_fixtures/multi_file") + if err != nil { + t.Fatalf("parseAudiobookFolder: %v", err) + } + if got.Title != "Multi File Test" { + t.Errorf("Title = %q, want %q", got.Title, "Multi File Test") + } + if got.Author != "Multi Author" { + t.Errorf("Author = %q, want %q", got.Author, "Multi Author") + } + if got.Year != 2023 { + t.Errorf("Year = %d, want 2023", got.Year) + } + if len(got.Files) != 3 { + t.Fatalf("got %d files, want 3", len(got.Files)) + } + for i, f := range got.Files { + if len(f.Chapters) != 1 { + t.Errorf("file %d: %d synthesized chapters, want 1", i, len(f.Chapters)) + continue + } + wantStem := fmt.Sprintf("part%d", i+1) + if f.Chapters[0].Title != wantStem { + t.Errorf("file %d: chapter title = %q, want %q", i, f.Chapters[0].Title, wantStem) + } + } +} diff --git a/internal/scanner/testdata/audiobook_fixtures/multi_file/part1.mp3 b/internal/scanner/testdata/audiobook_fixtures/multi_file/part1.mp3 new file mode 100644 index 00000000..1ab3dbf3 Binary files /dev/null and b/internal/scanner/testdata/audiobook_fixtures/multi_file/part1.mp3 differ diff --git a/internal/scanner/testdata/audiobook_fixtures/multi_file/part2.mp3 b/internal/scanner/testdata/audiobook_fixtures/multi_file/part2.mp3 new file mode 100644 index 00000000..c7ba2bf0 Binary files /dev/null and b/internal/scanner/testdata/audiobook_fixtures/multi_file/part2.mp3 differ diff --git a/internal/scanner/testdata/audiobook_fixtures/multi_file/part3.mp3 b/internal/scanner/testdata/audiobook_fixtures/multi_file/part3.mp3 new file mode 100644 index 00000000..7b3d1908 Binary files /dev/null and b/internal/scanner/testdata/audiobook_fixtures/multi_file/part3.mp3 differ