From 9b50f0a88d40f86e9d23c725ccfd02dca63fdb7e Mon Sep 17 00:00:00 2001 From: RXWatcher <14085001+RXWatcher@users.noreply.github.com> Date: Sun, 24 May 2026 14:11:31 +0200 Subject: [PATCH] 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) --- internal/scanner/audiobook.go | 54 ++++++++++-------- internal/scanner/audiobook_test.go | 39 +++++++++++++ .../audiobook_fixtures/multi_file/part1.mp3 | Bin 0 -> 20602 bytes .../audiobook_fixtures/multi_file/part2.mp3 | Bin 0 -> 20602 bytes .../audiobook_fixtures/multi_file/part3.mp3 | Bin 0 -> 20602 bytes 5 files changed, 70 insertions(+), 23 deletions(-) create mode 100644 internal/scanner/testdata/audiobook_fixtures/multi_file/part1.mp3 create mode 100644 internal/scanner/testdata/audiobook_fixtures/multi_file/part2.mp3 create mode 100644 internal/scanner/testdata/audiobook_fixtures/multi_file/part3.mp3 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 0000000000000000000000000000000000000000..1ab3dbf337d6d3b8f857d3678742a36dbfeabc4d GIT binary patch literal 20602 zcmeZtF=k<4U|?Y64)F{zVqjnpWME+SEzK#(RB+48NmU3*EiPdQ32-%JU|`^bOF5R7 zWaJkyggE**p@;{k7G=dowUFFf%YP8#06h2fH#b zFz_=lF#9ByrI}jl8JX)D8W@0F0mc767aV{x85lhC(()nd84fWp_&;S}VdvuE7Zeef zl#y3dQPb4XH83(Yx3aZ&a&h~O|_Vdp_kmWv!Wyz+N zddB7;4h*X>PlGW1pGJLp?ge2($^V}nj(~jW0pj~O`nno}ycG%uScKqmq%i(}b~+Nx zz`zW)2b)IfS>$kJ2_B26XOGj7#dz!qrJhBj9>SJj2#SG{+-M9D3??iJK!w6+9>NkZ zgoH+u9H9`xB|nsL4#jnG|Ax)AKbE|Ne*||;FTFoa(IIW zkK|~Q!xK38q(+k*zRq*^M<|4F$&V&ETp>e1 zd^E`s2q2u&qe%{D(BPLCO>+3d2e<5KlEWP~cx6VD9Nyr;BRQJn@B|J%snH~dFLdz9 z4VEM~I{t?vmPV5{j({O3JXn(U=z>OEQ8gMvxI%`2_-LU(Ab@a6k0v>sL4#jnG|Ax) zAKbE|Ne*||;FTFoa(IIWkK|~Q!xK38q(+k*zR9zg{4%3S4uAOIlqHnpM#ukf1RbG(8y#K35pZ-|2|Bu54p$uD7auK6@rMsi z+0jA)XV4Ik7)^2n0tlD%Xp+MfGK9oOlN_NC!XZDJ)>mg&46xljMkTEHP>bd59P@h*1Lo;?JUb literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..c7ba2bf02aeebbcea419900194bd3f4b3566008f GIT binary patch literal 20602 zcmeZtF=k<4U|?Y64)F{zVqjnpWME+SEzK#(RB+48NmU3*EiPdQ32-%JU|`^bOF5R7 zWaJkyggE**p@;{k7G=dowUFFoXCEA;H0}3=9nX z3=GUZiDhY~mU>3!dWHrDAXh-~|IY;npiBk^&%CsJhdMLj;2fivm!gFq((31Pmde z(IiJGgmB4^COKRoLqL2q$q@)3oYJF74rkEdml#cQ_`?Uc>}Zn19X5DnMw1-g;K3s~ zn&j{V4nC>TB!@3_@X3uPIeejmM{YF9;RzhPQiCPQQESjAlztu>9bLkkvT#X`=2cuF zLr9!{NqcmJmQY+^mmiHG?BPOCbhJ<)7(_S}j3zl80YgZ5G|3SPAzbpKNe)-Y5D*_t zas&bhr}Su&!x=RAB}S7R{_w#qJDTKhhYent(IkgAc<@M$COJHTgHLKS$>9qfd~$;& z$&HTx;fSTtq>UqB2nr9Dq&>Qz5m!`=#t^QMAs{|lC=duBoYJF74rkEdml#cQ_`?Uc z>}Zn19X5DnMw1-g;K3s~n&j{V4nC>TB!@3_@X3uPIeejmM{YF9;RzhPQlm)@Z}8xj z98Ge#!v??1Xp+MpJ~(9wCArb@KO8|vDBwm%mv96e9an;mE|mxk0v=B0YgxDG|3SRBJ2uAlN|PNAuKwY x=dowUFFf%YP8#9Ci2fH#b zFz_=lF#9ByrI}jl8JX)D8W@0F0mc767aV{x85lhC(()nd84fWp_&;S}VdvuE7Zeef zl#y3dQPb4XH83(Yx3aZ&a&h~O|_Vdp_kmWv!Wyz+N zddB7;4h*X>PlGW1pGJLp?ge2($^V}nj(~jW0pj~O`nno}ycG%uScKqmq%i(}b~+Nx zz`zW)2b)IfS>$kJ2_B26XOGj7#dz!qrJhBj9>SJj2#SG{+-M9D3??iJK!w6+9>NkZ zgoH+u9H9`xB|nsL4#jnG|Ax)AKbE|Ne*||;FTFoa(IIW zkK|~Q!xK38q(+k*zRq*^M<|4F$&V&ETp>e1 zd^E`s2q2u&qe%{D(BPLCO>+3d2e<5KlEWP~cx6VD9Nyr;BRQJn@B|J%snH~dFLdz9 z4VEM~I{t?vmPV5{j({O3JXn(U=z>OEQ8gMvxI%`2_-LU(Ab@a6k0v>sL4#jnG|Ax) zAKbE|Ne*||;FTFoa(IIWkK|~Q!xK38q(+k*zR9zg{4%3S4uAOIlqHnpM#ukf1RbG(8y#K35pZ-|2|Bu54p$uD7auK6@rMsi z+0jA)XV4Ik7)^2n0tlD%Xp+MfGK9oOlN_NC!XZDJ)>mg&46xljMkTEHP>bd59P@h*1Log7Tt# literal 0 HcmV?d00001