feat(audiobooks): multi-file audiobook folder support

Folders containing N audio files (one per chapter/part) get one
parsedAudiobookFile per file; each file's chapter list is synthesized
as a single chapter with title = filename stem. Title/author/series/
year come from the first file's tags.

Also drops the duplicate pickFirstNonEmpty helper added in Task 6 in
favor of the existing firstNonEmpty already in probe.go.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
RXWatcher
2026-05-24 14:11:31 +02:00
co-authored by Claude Opus 4.7
parent 51dc8ef53a
commit 9b50f0a88d
5 changed files with 70 additions and 23 deletions
+31 -23
View File
@@ -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.
+39
View File
@@ -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)
}
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.