package services // Unit tests for the multi-file audiobook grouping/manifest helpers // (media_scanner_audio_multifile.go). Pure filesystem logic — no DB. import ( "archive/zip" "os" "path/filepath" "testing" ) func TestNaturalLess(t *testing.T) { cases := []struct { a, b string less bool }{ {"01 Track.mp3", "02 Track.mp3", true}, {"02 Track.mp3", "10 Track.mp3", true}, // numeric, not lexicographic {"10 Track.mp3", "9 Track.mp3", false}, {"Track 2", "Track 10", true}, {"abc", "abd", true}, {"01", "1", false}, // leading zeros compare equal → strict-less false {"a1b", "a1c", true}, {"Disc 1", "Disc 2", true}, } for _, tc := range cases { if got := naturalLess(tc.a, tc.b); got != tc.less { t.Errorf("naturalLess(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.less) } } } func TestSortTracksNatural(t *testing.T) { in := []string{"/x/10.mp3", "/x/2.mp3", "/x/1.mp3"} sortTracksNatural(in) want := []string{"/x/1.mp3", "/x/2.mp3", "/x/10.mp3"} for i := range want { if in[i] != want[i] { t.Fatalf("sorted = %v, want %v", in, want) } } } // writeDummyTrack creates a file with plausible audio bytes (never probed // in these tests — only structure matters). func writeDummyTrack(t *testing.T, path string) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte("ID3 fake audio bytes"), 0o644); err != nil { t.Fatal(err) } } func TestBookRootForTrackFile(t *testing.T) { root := t.TempDir() // Layout: // root/Author/Title/01.mp3, 02.mp3 → one book at Title // root/Author/Parts/CD1/01.mp3, CD2/01.mp3 → one book at Parts // root/Author/Lone/book.mp3 → no folder book writeDummyTrack(t, filepath.Join(root, "Author", "Title", "01.mp3")) writeDummyTrack(t, filepath.Join(root, "Author", "Title", "02.mp3")) writeDummyTrack(t, filepath.Join(root, "Author", "Parts", "CD1", "01.mp3")) writeDummyTrack(t, filepath.Join(root, "Author", "Parts", "CD2", "01.mp3")) writeDummyTrack(t, filepath.Join(root, "Author", "Lone", "book.mp3")) t.Run("simple track folder", func(t *testing.T) { got, tracks, ok := bookRootForTrackFile(filepath.Join(root, "Author", "Title", "02.mp3"), root) if !ok { t.Fatalf("expected folder book, got none") } if got != filepath.Join(root, "Author", "Title") { t.Fatalf("root = %q", got) } if len(tracks) != 2 { t.Fatalf("tracks = %v", tracks) } }) t.Run("disc folders merge into parent", func(t *testing.T) { got, tracks, ok := bookRootForTrackFile(filepath.Join(root, "Author", "Parts", "CD1", "01.mp3"), root) if !ok { t.Fatalf("expected folder book") } if got != filepath.Join(root, "Author", "Parts") { t.Fatalf("root = %q (disc folders must merge up)", got) } if len(tracks) != 2 { t.Fatalf("tracks = %v", tracks) } }) t.Run("lone file is not a folder book", func(t *testing.T) { _, _, ok := bookRootForTrackFile(filepath.Join(root, "Author", "Lone", "book.mp3"), root) if ok { t.Fatalf("single track must not form a folder book") } }) t.Run("sibling books never merge at author level", func(t *testing.T) { writeDummyTrack(t, filepath.Join(root, "Author", "BookB", "01.mp3")) writeDummyTrack(t, filepath.Join(root, "Author", "BookB", "02.mp3")) got, _, ok := bookRootForTrackFile(filepath.Join(root, "Author", "Title", "01.mp3"), root) if !ok || got != filepath.Join(root, "Author", "Title") { t.Fatalf("root = %q, ok = %v; Title/BookB must stay separate", got, ok) } }) } func TestBuildMultiFileAudioPayload(t *testing.T) { tracks := []audioTrackInfo{ {relPath: "Author/Title/01.mp3", title: "Chapter One", duration: 100, size: 10}, {relPath: "Author/Title/02.mp3", title: "Chapter Two", duration: 50, size: 5}, } payload := buildMultiFileAudioPayload("folder", nil, tracks) if payload.Layout != "folder" { t.Fatalf("layout = %q", payload.Layout) } if payload.DurationSeconds != 150 { t.Fatalf("duration = %v", payload.DurationSeconds) } if len(payload.Tracks) != 2 { t.Fatalf("tracks = %d", len(payload.Tracks)) } if payload.Tracks[1].Start != 100 || payload.Tracks[1].End != 150 { t.Fatalf("cumulative bounds wrong: %+v", payload.Tracks[1]) } // No sidecar chapters: tracks ARE the chapters. if len(payload.Chapters) != 2 || payload.Chapters[0].Start != 0 || payload.Chapters[1].Start != 100 { t.Fatalf("chapters = %+v", payload.Chapters) } // Curated sidecar chapters keep priority; tracks still carry bounds. sidecar := &AudioMetadataPayload{ Chapters: []AudioChapter{{Index: 0, Title: "Part I", Start: 0, End: 150}}, } payload2 := buildMultiFileAudioPayload("zip", sidecar, tracks) if len(payload2.Chapters) != 1 || payload2.Chapters[0].Title != "Part I" { t.Fatalf("sidecar chapters must win: %+v", payload2.Chapters) } if len(payload2.Tracks) != 2 { t.Fatalf("tracks lost: %+v", payload2.Tracks) } } func TestZipAudioEntries(t *testing.T) { zipPath := filepath.Join(t.TempDir(), "book.zip") f, err := os.Create(zipPath) if err != nil { t.Fatal(err) } w := zip.NewWriter(f) for _, name := range []string{ "__MACOSX/._junk.mp3", ".hidden.mp3", "02 second.mp3", "10 tenth.mp3", "01 first.mp3", "cover.jpg", "notes.txt", } { if _, err := w.Create(name); err != nil { t.Fatal(err) } } if err := w.Close(); err != nil { t.Fatal(err) } _ = f.Close() entries, err := zipAudioEntries(zipPath) if err != nil { t.Fatal(err) } want := []string{"01 first.mp3", "02 second.mp3", "10 tenth.mp3"} if len(entries) != len(want) { t.Fatalf("entries = %v", entries) } for i, e := range entries { if e.name != want[i] { t.Fatalf("entry[%d] = %q, want %q", i, e.name, want[i]) } } } func TestCleanTrackTitle(t *testing.T) { cases := map[string]string{ "/x/01_Genesis001.mp3": "Genesis001", "/x/01 - Track.mp3": "Track", "/x/12_.Intro.mp3": "Intro", "/x/Chapter 3.mp3": "Chapter 3", "/x/JustTitle.m4b": "JustTitle", "/x/01.mp3": "01", // number-only name: number stays the title } for in, want := range cases { if got := cleanTrackTitle(in); got != want { t.Errorf("cleanTrackTitle(%q) = %q, want %q", in, got, want) } } }