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:
co-authored by
Claude Opus 4.7
parent
51dc8ef53a
commit
9b50f0a88d
@@ -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.
|
||||
|
||||
@@ -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.
Reference in New Issue
Block a user