package services // Unit tests for the exhaustive audiobook metadata ingestion: CUE // sheets, m3u playlists, tag fallbacks (incl. Audible freeform atoms) // and tag-based track ordering. import ( "os" "path/filepath" "strings" "testing" ) func TestCueTimeToSeconds(t *testing.T) { cases := map[string]float64{ "00:00:00": 0, "01:00:00": 60, "03:25:12": 3*60 + 25 + 12.0/75.0, "75:00:37": 75*60 + 37.0/75.0, // minutes exceed 59 per spec "not-a-time": 0, "1:2": 0, } for in, want := range cases { if got := cueTimeToSeconds(in); (got-want) > 0.001 || (want-got) > 0.001 { t.Errorf("cueTimeToSeconds(%q) = %v, want %v", in, got, want) } } } func TestParseCueSheet(t *testing.T) { dir := t.TempDir() cuePath := filepath.Join(dir, "book.cue") cue := `TITLE "Cue Book" FILE "book.flac" WAVE TRACK 01 AUDIO TITLE "Opening" INDEX 01 00:00:00 TRACK 02 AUDIO TITLE "The Middle Bit" INDEX 01 00:01:30 TRACK 03 AUDIO TITLE "Closing" INDEX 01 00:03:00 FILE "other.flac" WAVE TRACK 01 AUDIO TITLE "Foreign Track" INDEX 01 00:00:00 ` if err := os.WriteFile(cuePath, []byte(cue), 0o644); err != nil { t.Fatal(err) } chapters := parseCueSheet(cuePath, "book.flac") if len(chapters) != 3 { t.Fatalf("chapters = %d (%+v), want 3 (foreign FILE block excluded)", len(chapters), chapters) } if chapters[0].Title != "Opening" || chapters[0].Start != 0 { t.Fatalf("first = %+v", chapters[0]) } // MM:SS:FF: "00:01:30" = 1 second + 30/75 frames = 1.4s if chapters[1].Start != 1.4 || chapters[1].End != 3.0 { t.Fatalf("second = %+v", chapters[1]) } if chapters[2].End != 0 { t.Fatalf("last end must stay 0 (unknown): %+v", chapters[2]) } } func TestReadCueChaptersMatched(t *testing.T) { dir := t.TempDir() cue := "FILE \"audio.mp3\" WAVE\n TRACK 01 AUDIO\n TITLE \"One\"\n INDEX 01 00:00:00\n TRACK 02 AUDIO\n TITLE \"Two\"\n INDEX 01 00:00:10\n" if err := os.WriteFile(filepath.Join(dir, "audio.cue"), []byte(cue), 0o644); err != nil { t.Fatal(err) } chapters := readCueChapters(filepath.Join(dir, "audio.mp3")) if len(chapters) != 2 { t.Fatalf("chapters = %d", len(chapters)) } // No cue at all → nil, never an error. if chapters := readCueChapters(filepath.Join(dir, "other.mp3")); chapters != nil { t.Fatalf("expected nil for missing cue, got %+v", chapters) } } func TestFindAndApplyPlaylistHint(t *testing.T) { dir := t.TempDir() playlist := "#EXTM3U\n#EXTINF:120,Chapter Zero\n02_Second.mp3\n#EXTINF:60,Chapter One\n./01_First.mp3\nmissing.mp3\n" if err := os.WriteFile(filepath.Join(dir, "book.m3u"), []byte(playlist), 0o644); err != nil { t.Fatal(err) } // Exactly one playlist → hint; two → none. hint := findPlaylistHint(dir) if hint == nil { t.Fatal("expected hint") } if err := os.WriteFile(filepath.Join(dir, "other.m3u8"), []byte("#EXTM3U\n"), 0o644); err != nil { t.Fatal(err) } if findPlaylistHint(dir) != nil { t.Fatal("multiple playlists must produce no hint") } abs := []string{ filepath.Join(dir, "01_First.mp3"), filepath.Join(dir, "02_Second.mp3"), filepath.Join(dir, "10_Later.mp3"), // not in playlist → appended last } ordered := hint.reorder(abs) if len(ordered) != 3 || ordered[0] != abs[1] || ordered[1] != abs[0] || ordered[2] != abs[2] { t.Fatalf("playlist order not applied: %v", ordered) } if got := hint.titleFor(abs[0]); got != "Chapter One" { t.Fatalf("titleFor(./01_First.mp3) = %q", got) } if got := hint.titleFor(abs[1]); got != "Chapter Zero" { t.Fatalf("titleFor(02_Second.mp3) = %q", got) } if got := hint.titleFor(abs[2]); got != "" { t.Fatalf("unlisted track must have no title, got %q", got) } if h := (*playlistHint)(nil); h.titleFor(abs[0]) != "" { t.Fatal("nil hint must be safe") } } func syntheticReport(tags map[string]string) *ffprobeReport { r := &ffprobeReport{} r.Format.Tags = tags return r } func TestApplyAudioTagFallbacks(t *testing.T) { t.Run("full fill", func(t *testing.T) { meta := &MediaMetadata{} applyAudioTagFallbacks(meta, syntheticReport(map[string]string{ "album": "The Tagged Album", "album_artist": "Tag Author", "artist": "Wrong Author", "composer": "Tag Narrator", "grouping": "Tag Series", "genre": "Audiobook", "date": "2020", "----:com.apple.iTunes:SUBTITLE": "Or, A Life", "----:com.apple.iTunes:SERIES": "Freeform Series", "----:com.apple.iTunes:SERIES-PART": "4", "----:com.apple.iTunes:ASIN": "B0ASIN1234", "----:com.apple.iTunes:ABRIDGED": "1", })) // A2b: subtitle is a first-class column — no title joining. if meta.Title != "The Tagged Album" { t.Fatalf("title = %q", meta.Title) } if meta.Subtitle != "Or, A Life" { t.Fatalf("subtitle = %q", meta.Subtitle) } if meta.Author != "Tag Author" { t.Fatalf("author = %q", meta.Author) } if len(meta.Narrators) != 1 || meta.Narrators[0] != "Tag Narrator" { t.Fatalf("narrators = %v", meta.Narrators) } // grouping outranks freeform SERIES for the series name only // because it is checked first; both are tag-level fallbacks. if meta.Series == "" { t.Fatal("series not filled from tags") } if meta.SeriesNumber != 4 { t.Fatalf("series number = %v", meta.SeriesNumber) } if meta.ASIN != "B0ASIN1234" { t.Fatalf("asin = %q", meta.ASIN) } if !meta.Abridged { t.Fatal("abridged not filled") } if meta.PublishDate.Year() != 2020 { t.Fatalf("publish date = %v", meta.PublishDate) } if meta.Genre != "Audiobook" { t.Fatalf("genre = %q", meta.Genre) } }) t.Run("fills only empty fields", func(t *testing.T) { meta := &MediaMetadata{Title: "Sidecar Title", Author: "Sidecar Author", Narrators: []string{"Sidecar Narrator"}} applyAudioTagFallbacks(meta, syntheticReport(map[string]string{ "album": "Tag Album", "artist": "Tag Artist", "composer": "Tag Narrator", })) if meta.Title != "Sidecar Title" || meta.Author != "Sidecar Author" { t.Fatalf("tags must not override earlier sources: %+v", meta) } if len(meta.Narrators) != 1 || meta.Narrators[0] != "Sidecar Narrator" { t.Fatalf("narrators = %v", meta.Narrators) } }) t.Run("explicit sidecar false survives abridged tag", func(t *testing.T) { abridgedFalse := false meta := &MediaMetadata{AudioMeta: &AudioMetadataPayload{Abridged: &abridgedFalse}} applyAudioTagFallbacks(meta, syntheticReport(map[string]string{ "----:com.apple.iTunes:ABRIDGED": "1", })) if meta.Abridged { t.Fatal("explicit sidecar false must not be overridden") } }) t.Run("nil report is a no-op", func(t *testing.T) { meta := &MediaMetadata{} applyAudioTagFallbacks(meta, nil) if meta.Title != "" { t.Fatal("nil report must change nothing") } }) } func TestParseTagOrdinal(t *testing.T) { cases := map[string]int{ "2": 2, "3/9": 3, " 12 ": 12, "": 0, "x": 0, "0": 0, "2/": 2, "-1": 0, } for in, want := range cases { if got := parseTagOrdinal(in); got != want { t.Errorf("parseTagOrdinal(%q) = %d, want %d", in, got, want) } } } func TestOrderTracksByTags(t *testing.T) { mk := func(disc, track int, rel string) audioTrackInfo { return audioTrackInfo{disc: disc, trackNum: track, relPath: rel} } t.Run("tag order wins when complete", func(t *testing.T) { probed := []audioTrackInfo{ mk(2, 1, "b/01.mp3"), mk(1, 2, "a/02.mp3"), mk(1, 10, "a/10.mp3"), } orderTracksByTags(probed) want := []string{"a/02.mp3", "a/10.mp3", "b/01.mp3"} for i, tr := range probed { if tr.relPath != want[i] { t.Fatalf("order = %v", probed) } } }) t.Run("incomplete coverage keeps natural order", func(t *testing.T) { probed := []audioTrackInfo{mk(1, 1, "a/01.mp3"), mk(0, 0, "a/02.mp3")} orderTracksByTags(probed) if probed[0].relPath != "a/01.mp3" || probed[1].relPath != "a/02.mp3" { t.Fatalf("order = %v", probed) } }) } func TestTagLookupCaseInsensitive(t *testing.T) { r := syntheticReport(map[string]string{ "ALBUM": "Cased Album", "----:com.apple.iTunes:ASIN": "B0CASE", }) if got := r.tag("album"); got != "Cased Album" { t.Fatalf("tag(album) = %q", got) } if got := r.tag("album", "title"); got != "Cased Album" { t.Fatalf("multi-key lookup = %q", got) } if got := r.tag("----:COM.APPLE.ITUNES:asin"); !strings.EqualFold(got, "B0CASE") { t.Fatalf("freeform case-insensitive lookup = %q", got) } if got := r.tag("missing"); got != "" { t.Fatalf("missing key = %q", got) } }