package app import ( "context" "errors" "fmt" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "strings" "sync" "testing" "streamrip-go/internal/audio/tag" "streamrip-go/internal/config" "streamrip-go/internal/download" "streamrip-go/internal/provider" "streamrip-go/internal/store" ) type noopTagger struct{} func (n noopTagger) TagFLAC(context.Context, string, tag.Metadata, string) error { return nil } type failingTagger struct { err error } func (f failingTagger) TagFLAC(context.Context, string, tag.Metadata, string) error { return f.err } type fakeProvider struct { url string } func (f *fakeProvider) Source() string { return "qobuz" } func (f *fakeProvider) Login(context.Context) error { return nil } func (f *fakeProvider) LoggedIn() bool { return true } func (f *fakeProvider) Close() error { return nil } func (f *fakeProvider) Search(context.Context, string, string, int) ([]map[string]any, error) { return nil, nil } func (f *fakeProvider) GetMetadata(context.Context, string, string) (map[string]any, error) { return map[string]any{ "title": "Dreams/Live", "track_number": float64(3), "performer": map[string]any{ "name": "Fleetwood Mac", }, "album": map[string]any{ "artist": map[string]any{"name": "Fleetwood Mac"}, }, }, nil } type fakeAlbumProvider struct { url string } type fakePlaylistProvider struct { url string } type fakeVideoProvider struct { url string } type fakeResolvedAlbumProvider struct { url string downloadable provider.Downloadable downloadableHits int } type fakeFailProvider struct{} func (f *fakeAlbumProvider) Source() string { return "qobuz" } func (f *fakePlaylistProvider) Source() string { return "qobuz" } func (f *fakeVideoProvider) Source() string { return "tidal" } func (f *fakeResolvedAlbumProvider) Source() string { return "qobuz" } func (f *fakeAlbumProvider) Login(context.Context) error { return nil } func (f *fakePlaylistProvider) Login(context.Context) error { return nil } func (f *fakeVideoProvider) Login(context.Context) error { return nil } func (f *fakeResolvedAlbumProvider) Login(context.Context) error { return nil } func (f *fakeAlbumProvider) LoggedIn() bool { return true } func (f *fakePlaylistProvider) LoggedIn() bool { return true } func (f *fakeVideoProvider) LoggedIn() bool { return true } func (f *fakeResolvedAlbumProvider) LoggedIn() bool { return true } func (f *fakeAlbumProvider) Close() error { return nil } func (f *fakePlaylistProvider) Close() error { return nil } func (f *fakeVideoProvider) Close() error { return nil } func (f *fakeResolvedAlbumProvider) Close() error { return nil } func (f *fakeAlbumProvider) Search(context.Context, string, string, int) ([]map[string]any, error) { return nil, nil } func (f *fakePlaylistProvider) Search(context.Context, string, string, int) ([]map[string]any, error) { return nil, nil } func (f *fakeVideoProvider) Search(context.Context, string, string, int) ([]map[string]any, error) { return nil, nil } func (f *fakeResolvedAlbumProvider) Search(context.Context, string, string, int) ([]map[string]any, error) { return nil, nil } func (f *fakeAlbumProvider) GetMetadata(_ context.Context, id string, mediaType string) (map[string]any, error) { if mediaType == "album" { return map[string]any{ "title": "Rumours", "release_date_original": "1977-02-04", "media_count": float64(2), "maximum_bit_depth": float64(24), "maximum_sampling_rate": float64(96), "artist": map[string]any{"name": "Fleetwood Mac"}, "tracks": map[string]any{"items": []any{ map[string]any{"id": "t1"}, map[string]any{"id": "t2"}, }}, }, nil } tn := float64(1) disc := float64(1) title := "Dreams" if id == "t2" { tn = 2 disc = 2 title = "Go Your Own Way" } return map[string]any{ "title": title, "track_number": tn, "media_number": disc, "performer": map[string]any{ "name": "Fleetwood Mac", }, "album": map[string]any{ "title": "Rumours", "artist": map[string]any{"name": "Fleetwood Mac"}, }, }, nil } func (f *fakePlaylistProvider) GetMetadata(_ context.Context, id string, mediaType string) (map[string]any, error) { if mediaType == "playlist" { return map[string]any{ "name": "Road Trip", "tracks": map[string]any{ "items": []any{map[string]any{"id": "p1"}, map[string]any{"id": "p2"}}, }, }, nil } trackNum := float64(7) title := "Track One" if id == "p2" { trackNum = 9 title = "Track Two" } return map[string]any{ "title": title, "track_number": trackNum, "performer": map[string]any{ "name": "Artist", }, "album": map[string]any{ "title": "Original Album", "artist": map[string]any{"name": "Artist"}, }, }, nil } func (f *fakeVideoProvider) GetMetadata(_ context.Context, id string, mediaType string) (map[string]any, error) { if mediaType == "video" { return map[string]any{"title": "Live Clip"}, nil } return nil, nil } func (f *fakeResolvedAlbumProvider) GetMetadata(_ context.Context, id string, mediaType string) (map[string]any, error) { if mediaType == "album" { return map[string]any{ "title": "Fallback Album", "release_date_original": "2020-01-01", "artist": map[string]any{"name": "Fallback Artist"}, "tracks": map[string]any{"items": []any{map[string]any{"id": "t1"}}}, }, nil } return map[string]any{ "title": "Fallback Song", "track_number": float64(1), "performer": map[string]any{"name": "Fallback Artist"}, "album": map[string]any{"title": "Fallback Album", "artist": map[string]any{"name": "Fallback Artist"}}, }, nil } func (f *fakeProvider) GetDownloadable(context.Context, string, int) (*provider.Downloadable, error) { return &provider.Downloadable{URL: f.url, Extension: "flac", Source: "qobuz"}, nil } func (f *fakeAlbumProvider) GetDownloadable(context.Context, string, int) (*provider.Downloadable, error) { return &provider.Downloadable{URL: f.url, Extension: "flac", Source: "qobuz"}, nil } func (f *fakePlaylistProvider) GetDownloadable(context.Context, string, int) (*provider.Downloadable, error) { return &provider.Downloadable{URL: f.url, Extension: "flac", Source: "qobuz"}, nil } func (f *fakeVideoProvider) GetDownloadable(context.Context, string, int) (*provider.Downloadable, error) { return nil, nil } func (f *fakeResolvedAlbumProvider) GetDownloadable(context.Context, string, int) (*provider.Downloadable, error) { f.downloadableHits++ d := f.downloadable if d.URL == "" { d.URL = f.url } return &d, nil } func (f *fakeVideoProvider) GetVideoDownloadable(context.Context, string) (*provider.Downloadable, error) { return &provider.Downloadable{URL: f.url, Extension: "mp4", Source: "tidal"}, nil } func (f *fakeFailProvider) Source() string { return "qobuz" } func (f *fakeFailProvider) Login(context.Context) error { return nil } func (f *fakeFailProvider) LoggedIn() bool { return true } func (f *fakeFailProvider) Close() error { return nil } func (f *fakeFailProvider) Search(context.Context, string, string, int) ([]map[string]any, error) { return nil, nil } func (f *fakeFailProvider) GetMetadata(context.Context, string, string) (map[string]any, error) { return nil, os.ErrNotExist } func (f *fakeFailProvider) GetDownloadable(context.Context, string, int) (*provider.Downloadable, error) { return nil, os.ErrNotExist } func TestTrackRipPipeline(t *testing.T) { tmp := t.TempDir() ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("audio-bytes")) })) defer ts.Close() d := config.DefaultConfigData() d.Downloads.Folder = tmp d.Downloads.SourceSubdirectories = false cfg := &config.Config{File: d, Session: d} sqlite, err := store.NewSQLite(filepath.Join(tmp, "db.sqlite")) if err != nil { t.Fatalf("NewSQLite() error = %v", err) } defer func() { _ = sqlite.Close() }() m := &Main{ Config: cfg, Providers: map[string]provider.Client{ "qobuz": &fakeProvider{url: ts.URL}, }, Store: sqlite, DL: download.New(), Tagger: noopTagger{}, Pending: nil, Media: nil, } ctx := context.Background() if err = m.AddByID(ctx, "qobuz", "track", "19512574"); err != nil { t.Fatalf("AddByID() error = %v", err) } if err = m.Resolve(ctx); err != nil { t.Fatalf("Resolve() error = %v", err) } if err = m.Rip(ctx); err != nil { t.Fatalf("Rip() error = %v", err) } if _, err = os.Stat(filepath.Join(tmp, "03. Fleetwood Mac - Dreams_Live.flac")); err != nil { t.Fatalf("expected downloaded file: %v", err) } ok, err := sqlite.IsDownloaded(ctx, "qobuz", "19512574") if err != nil { t.Fatalf("IsDownloaded() error = %v", err) } if !ok { t.Fatalf("expected track marked downloaded") } } func TestTrackRipFailsWhenTaggerReportsMissingFFmpeg(t *testing.T) { tmp := t.TempDir() ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("audio-bytes")) })) defer ts.Close() d := config.DefaultConfigData() d.Downloads.Folder = tmp d.Downloads.SourceSubdirectories = false cfg := &config.Config{File: d, Session: d} sqlite, err := store.NewSQLite(filepath.Join(tmp, "db.sqlite")) if err != nil { t.Fatalf("NewSQLite() error = %v", err) } defer func() { _ = sqlite.Close() }() m := &Main{ Config: cfg, Providers: map[string]provider.Client{ "qobuz": &fakeProvider{url: ts.URL}, }, Store: sqlite, DL: download.NewWithOptions(true, false, 0), Tagger: failingTagger{err: fmt.Errorf("ffmpeg not found: %w", exec.ErrNotFound)}, } ctx := context.Background() if err = m.AddByID(ctx, "qobuz", "track", "19512574"); err != nil { t.Fatalf("AddByID() error = %v", err) } if err = m.Resolve(ctx); err != nil { t.Fatalf("Resolve() error = %v", err) } err = m.Rip(ctx) if err == nil { t.Fatalf("expected rip failure") } ok, err := sqlite.IsDownloaded(ctx, "qobuz", "19512574") if err != nil { t.Fatalf("IsDownloaded() error = %v", err) } if ok { t.Fatalf("expected track not marked downloaded") } } func TestAlbumRipPipeline(t *testing.T) { tmp := t.TempDir() ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("audio-bytes")) })) defer ts.Close() d := config.DefaultConfigData() d.Downloads.Folder = tmp d.Downloads.SourceSubdirectories = false d.Downloads.Concurrency = false cfg := &config.Config{File: d, Session: d} sqlite, err := store.NewSQLite(filepath.Join(tmp, "db.sqlite")) if err != nil { t.Fatalf("NewSQLite() error = %v", err) } defer func() { _ = sqlite.Close() }() m := &Main{ Config: cfg, Providers: map[string]provider.Client{ "qobuz": &fakeAlbumProvider{url: ts.URL}, }, Store: sqlite, DL: download.New(), Tagger: noopTagger{}, } ctx := context.Background() if err = m.AddByID(ctx, "qobuz", "album", "a1"); err != nil { t.Fatalf("AddByID() error = %v", err) } if err = m.Resolve(ctx); err != nil { t.Fatalf("Resolve() error = %v", err) } if err = m.Rip(ctx); err != nil { t.Fatalf("Rip() error = %v", err) } folder := filepath.Join(tmp, "Fleetwood Mac - Rumours (1977) [FLAC] [24B-96kHz]") if _, err = os.Stat(filepath.Join(folder, "Disc 1", "01. Fleetwood Mac - Dreams.flac")); err != nil { t.Fatalf("missing first album track: %v", err) } if _, err = os.Stat(filepath.Join(folder, "Disc 2", "02. Fleetwood Mac - Go Your Own Way.flac")); err != nil { t.Fatalf("missing second album track: %v", err) } } func TestVideoRipPipeline(t *testing.T) { tmp := t.TempDir() ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("video-bytes")) })) defer ts.Close() d := config.DefaultConfigData() d.Downloads.Folder = tmp d.Downloads.SourceSubdirectories = false cfg := &config.Config{File: d, Session: d} sqlite, err := store.NewSQLite(filepath.Join(tmp, "db.sqlite")) if err != nil { t.Fatalf("NewSQLite() error = %v", err) } defer func() { _ = sqlite.Close() }() m := &Main{ Config: cfg, Providers: map[string]provider.Client{ "tidal": &fakeVideoProvider{url: ts.URL}, }, Store: sqlite, DL: download.New(), Tagger: noopTagger{}, Pending: nil, Media: nil, } ctx := context.Background() if err = m.AddByID(ctx, "tidal", "video", "v1"); err != nil { t.Fatalf("AddByID() error = %v", err) } if err = m.Resolve(ctx); err != nil { t.Fatalf("Resolve() error = %v", err) } if err = m.Rip(ctx); err != nil { t.Fatalf("Rip() error = %v", err) } if _, err = os.Stat(filepath.Join(tmp, "Live Clip.mp4")); err != nil { t.Fatalf("expected downloaded video file: %v", err) } ok, err := sqlite.IsDownloaded(ctx, "tidal", "v1") if err != nil { t.Fatalf("IsDownloaded() error = %v", err) } if !ok { t.Fatalf("expected video marked downloaded") } } func TestResolveAllFailedReturnsError(t *testing.T) { tmp := t.TempDir() d := config.DefaultConfigData() d.Downloads.Folder = tmp cfg := &config.Config{File: d, Session: d} m := &Main{ Config: cfg, Providers: map[string]provider.Client{ "qobuz": &fakeFailProvider{}, }, Store: store.NewDummy(), DL: download.New(), Tagger: noopTagger{}, Pending: nil, Media: nil, } ctx := context.Background() if err := m.AddByID(ctx, "qobuz", "track", "x"); err != nil { t.Fatalf("AddByID() error = %v", err) } if err := m.Resolve(ctx); err == nil { t.Fatalf("expected Resolve() to return error when all items fail") } } func TestBuildTagMetadataUsesAlbumArtistOverride(t *testing.T) { meta := map[string]any{ "title": "One Step Too Far", "track_number": float64(15), "performer": map[string]any{"name": "Faithless"}, "artist": map[string]any{"name": "Faithless"}, "album": map[string]any{ "id": "23324600", "title": "Greatest Hits (Deluxe)", "artist": map[string]any{"name": "Faithless"}, }, } tags := buildTagMetadata(meta, "One Step Too Far", "tidal", "23324615", ripTrackOptions{albumArtist: "Dido", albumDiscTotal: 2}) if tags.AlbumArtist != "Dido" { t.Fatalf("album artist = %q, want Dido", tags.AlbumArtist) } if tags.DiscNumber != 1 { t.Fatalf("disc number = %d, want 1", tags.DiscNumber) } if tags.DiscTotal != 2 { t.Fatalf("disc total = %d, want 2", tags.DiscTotal) } } func TestBuildTagMetadataUsesAlbumContextTrackTotal(t *testing.T) { meta := map[string]any{ "title": "ideal world (feat. higma)", "track_number": float64(2), "performer": map[string]any{"name": "Seren Azuma"}, "album": map[string]any{ "title": "YUKIHASU", "artist": map[string]any{"name": "Seren Azuma"}, }, } tags := buildTagMetadata(meta, "ideal world (feat. higma)", "qobuz", "295525879", ripTrackOptions{total: 11}) if tags.TrackTotal != 11 { t.Fatalf("track total = %d, want 11", tags.TrackTotal) } } func TestBuildTagMetadataPlaylistOmitsDiscTags(t *testing.T) { meta := map[string]any{ "title": "One Step Too Far", "track_number": float64(15), "media_number": float64(2), "numberOfVolumes": float64(2), "numberOfTracks": float64(18), "performer": map[string]any{"name": "Faithless"}, "artist": map[string]any{"name": "Faithless"}, "release_date": "2005-01-01", "release_date_original": "2005-01-01", "album": map[string]any{ "id": "23324600", "title": "Greatest Hits (Deluxe)", "artist": map[string]any{"name": "Faithless"}, }, } playlistCfg := config.DefaultConfigData().Metadata applyPlaylistMetadataOverrides(meta, playlistCfg, "Road Trip", 3) tags := buildTagMetadata(meta, "One Step Too Far", "tidal", "23324615", ripTrackOptions{forPlaylist: true, playlistName: "Road Trip", playlistPos: 3, total: 20}) if tags.Album != "Road Trip" { t.Fatalf("album = %q, want Road Trip", tags.Album) } if tags.TrackNumber != 3 { t.Fatalf("track number = %d, want 3", tags.TrackNumber) } if tags.TrackTotal != 20 { t.Fatalf("track total = %d, want 20", tags.TrackTotal) } if tags.DiscNumber != 0 { t.Fatalf("disc number = %d, want 0", tags.DiscNumber) } if tags.DiscTotal != 0 { t.Fatalf("disc total = %d, want 0", tags.DiscTotal) } if !tags.OmitDiscTags { t.Fatalf("omit disc tags = %v, want true", tags.OmitDiscTags) } } func TestTrackOutputPathFallsBackToDisc1(t *testing.T) { tmp := t.TempDir() d := config.DefaultConfigData() d.Downloads.Folder = tmp d.Downloads.DiscSubdirectories = true d.Downloads.SourceSubdirectories = false cfg := &config.Config{File: d, Session: d} m := &Main{Config: cfg} meta := map[string]any{ "title": "Song", "track_number": float64(1), "performer": map[string]any{"name": "Dido"}, "album": map[string]any{"id": "a", "title": "Greatest Hits", "artist": map[string]any{"name": "Dido"}}, } path := m.trackOutputPath("tidal", "1", "Song", "flac", nil, meta, filepath.Join(tmp, "Album"), 2) if !strings.Contains(path, string(filepath.Separator)+"Disc 1"+string(filepath.Separator)) { t.Fatalf("expected Disc 1 subdir in path, got %q", path) } } func TestPlaylistRipPipeline(t *testing.T) { tmp := t.TempDir() ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("audio-bytes")) })) defer ts.Close() d := config.DefaultConfigData() d.Downloads.Folder = tmp d.Downloads.Concurrency = false d.Filepaths.RestrictCharacters = false cfg := &config.Config{File: d, Session: d} sqlite, err := store.NewSQLite(filepath.Join(tmp, "db.sqlite")) if err != nil { t.Fatalf("NewSQLite() error = %v", err) } defer func() { _ = sqlite.Close() }() m := &Main{ Config: cfg, Providers: map[string]provider.Client{ "qobuz": &fakePlaylistProvider{url: ts.URL}, }, Store: sqlite, DL: download.NewWithOptions(true, false, 0), Tagger: noopTagger{}, } ctx := context.Background() if err = m.AddByID(ctx, "qobuz", "playlist", "pl1"); err != nil { t.Fatalf("AddByID() error = %v", err) } if err = m.Resolve(ctx); err != nil { t.Fatalf("Resolve() error = %v", err) } if err = m.Rip(ctx); err != nil { t.Fatalf("Rip() error = %v", err) } folder := filepath.Join(tmp, "Road Trip") if _, err = os.Stat(filepath.Join(folder, "01. Artist - Track One.flac")); err != nil { t.Fatalf("missing first playlist track: %v", err) } if _, err = os.Stat(filepath.Join(folder, "02. Artist - Track Two.flac")); err != nil { t.Fatalf("missing second playlist track: %v", err) } } func TestPlaylistRipUsesSourceSubdirectory(t *testing.T) { tmp := t.TempDir() ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("audio-bytes")) })) defer ts.Close() d := config.DefaultConfigData() d.Downloads.Folder = tmp d.Downloads.Concurrency = false d.Downloads.SourceSubdirectories = true d.Filepaths.RestrictCharacters = false cfg := &config.Config{File: d, Session: d} sqlite, err := store.NewSQLite(filepath.Join(tmp, "db.sqlite")) if err != nil { t.Fatalf("NewSQLite() error = %v", err) } defer func() { _ = sqlite.Close() }() m := &Main{ Config: cfg, Providers: map[string]provider.Client{ "qobuz": &fakePlaylistProvider{url: ts.URL}, }, Store: sqlite, DL: download.NewWithOptions(true, false, 0), Tagger: noopTagger{}, } ctx := context.Background() if err = m.AddByID(ctx, "qobuz", "playlist", "pl1"); err != nil { t.Fatalf("AddByID() error = %v", err) } if err = m.Resolve(ctx); err != nil { t.Fatalf("Resolve() error = %v", err) } if err = m.Rip(ctx); err != nil { t.Fatalf("Rip() error = %v", err) } folder := filepath.Join(tmp, "Qobuz", "Road Trip") if _, err = os.Stat(filepath.Join(folder, "01. Artist - Track One.flac")); err != nil { t.Fatalf("missing first playlist track in source subdir: %v", err) } } func TestRipPlaylistRequiresDeezerARL(t *testing.T) { d := config.DefaultConfigData() m := &Main{Config: &config.Config{File: d, Session: d}} err := m.ripPlaylist(context.Background(), nil, "deezer", "pl1", map[string]any{ "name": "Road Trip", "tracks": map[string]any{"items": []any{map[string]any{"id": "p1"}}}, }) if err == nil || !strings.Contains(err.Error(), "deezer") { t.Fatalf("expected deezer arl error, got %v", err) } } func TestRipAlbumRequiresDeezerARL(t *testing.T) { d := config.DefaultConfigData() m := &Main{Config: &config.Config{File: d, Session: d}} err := m.ripAlbum(context.Background(), nil, "deezer", "alb1", map[string]any{}) if err == nil || !strings.Contains(err.Error(), "deezer") { t.Fatalf("expected deezer arl error, got %v", err) } } func TestRipPlaylistMixedRequiresDeezerAuth(t *testing.T) { d := config.DefaultConfigData() m := &Main{Config: &config.Config{File: d, Session: d}} err := m.ripPlaylistMixed(context.Background(), "mix1", "Mix", []PlaylistTrackRef{{Source: "deezer", ID: "1"}}) if err == nil || !strings.Contains(err.Error(), "deezer") { t.Fatalf("expected deezer auth error, got %v", err) } } func TestApplyQobuzArtistFiltersRepeats(t *testing.T) { albums := []collectionAlbum{ {ID: "a1", Title: "Album X", BitDepth: 16, Sampling: 44.1, Explicit: false}, {ID: "a2", Title: "Album X (Deluxe)", BitDepth: 24, Sampling: 96, Explicit: false}, {ID: "b1", Title: "Album B", BitDepth: 16, Sampling: 44.1, Explicit: false}, } filtered := applyQobuzArtistFilters("artist", albums, config.QobuzDiscographyFilterConfig{Repeats: true}) if len(filtered) != 2 { t.Fatalf("len(filtered)=%d want 2", len(filtered)) } ids := map[string]bool{} for _, a := range filtered { ids[a.ID] = true } if !ids["a2"] || !ids["b1"] { t.Fatalf("unexpected winners: %+v", ids) } } func TestApplyQobuzArtistFiltersNonRemaster(t *testing.T) { albums := []collectionAlbum{ {ID: "rm", Title: "Album X (Remastered)"}, {ID: "orig", Title: "Album X"}, } filtered := applyQobuzArtistFilters("artist", albums, config.QobuzDiscographyFilterConfig{NonRemaster: true}) if len(filtered) != 1 { t.Fatalf("len(filtered)=%d want 1", len(filtered)) } if filtered[0].ID != "orig" { t.Fatalf("unexpected album kept: %+v", filtered[0]) } } func TestTrackOutputPathSinglesUsesAlbumID(t *testing.T) { tmp := t.TempDir() d := config.DefaultConfigData() d.Downloads.Folder = tmp d.Downloads.SourceSubdirectories = false d.Filepaths.AddSinglesToFolder = true d.Filepaths.FolderFormat = "{id}" d.Filepaths.TrackFormat = "{title}" d.Filepaths.RestrictCharacters = false m := &Main{Config: &config.Config{File: d, Session: d}} meta := map[string]any{ "album": map[string]any{ "id": "album-123", "title": "Album", "artist": map[string]any{"name": "Artist"}, }, "performer": map[string]any{"name": "Artist"}, } out := m.trackOutputPath("qobuz", "track-999", "Song", "flac", nil, meta, "", 0) if got, want := filepath.Dir(out), filepath.Join(tmp, "album-123"); got != want { t.Fatalf("trackOutputPath() dir=%q want %q", got, want) } } func TestTrackOutputPathSinglesUsesResolvedAudioProfile(t *testing.T) { tmp := t.TempDir() d := config.DefaultConfigData() d.Downloads.Folder = tmp d.Downloads.SourceSubdirectories = false d.Filepaths.AddSinglesToFolder = true d.Filepaths.FolderFormat = "{container}-{bit_depth}-{sampling_rate}" d.Filepaths.TrackFormat = "{title}" d.Filepaths.RestrictCharacters = false m := &Main{Config: &config.Config{File: d, Session: d}} meta := map[string]any{ "album": map[string]any{ "id": "album-123", "title": "Album", "artist": map[string]any{"name": "Artist"}, }, "performer": map[string]any{"name": "Artist"}, } dl := &provider.Downloadable{Extension: "mp3", Audio: provider.AudioProfile{Container: "MP3", Codec: "MP3", Quality: "HIGH", BitDepth: 16, SamplingRate: "44.1", BitrateKbps: 320}} out := m.trackOutputPath("qobuz", "track-999", "Song", "mp3", dl, meta, "", 0) if got, want := filepath.Dir(out), filepath.Join(tmp, "MP3-16-44.1"); got != want { t.Fatalf("trackOutputPath() dir=%q want %q", got, want) } } func TestRipAlbumUsesResolvedAudioProfileForFolderName(t *testing.T) { tmp := t.TempDir() ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("audio-bytes")) })) defer ts.Close() d := config.DefaultConfigData() d.Downloads.Folder = tmp d.Downloads.SourceSubdirectories = false d.Downloads.Concurrency = false d.Filepaths.RestrictCharacters = false cfg := &config.Config{File: d, Session: d} sqlite, err := store.NewSQLite(filepath.Join(tmp, "db.sqlite")) if err != nil { t.Fatalf("NewSQLite() error = %v", err) } defer func() { _ = sqlite.Close() }() fake := &fakeResolvedAlbumProvider{ url: ts.URL, downloadable: provider.Downloadable{ URL: ts.URL, Extension: "mp3", Source: "qobuz", Audio: provider.AudioProfile{ Container: "MP3", Codec: "MP3", Quality: "HIGH", BitDepth: 16, SamplingRate: "44.1", BitrateKbps: 320, }, }, } m := &Main{ Config: cfg, Providers: map[string]provider.Client{ "qobuz": fake, }, Store: sqlite, DL: download.NewWithOptions(true, false, 0), Tagger: noopTagger{}, } ctx := context.Background() if err = m.AddByID(ctx, "qobuz", "album", "a1"); err != nil { t.Fatalf("AddByID() error = %v", err) } if err = m.Resolve(ctx); err != nil { t.Fatalf("Resolve() error = %v", err) } if err = m.Rip(ctx); err != nil { t.Fatalf("Rip() error = %v", err) } folder := filepath.Join(tmp, "Fallback Artist - Fallback Album (2020) [MP3] [16B-44.1kHz]") if _, err = os.Stat(filepath.Join(folder, "01. Fallback Artist - Fallback Song.mp3")); err != nil { t.Fatalf("missing track in resolved-quality folder: %v", err) } if fake.downloadableHits != 1 { t.Fatalf("GetDownloadable() calls=%d, want 1 (prefetched reuse)", fake.downloadableHits) } } func TestBuildTagMetadataReplayGainFallbacks(t *testing.T) { meta := map[string]any{ "replayGain": float64(-7.25), "peak": float64(0.989), "album": map[string]any{ "title": "Album", "replaygain_album_gain": float64(-8.1), "replaygain_album_peak": float64(1.001), }, "performer": map[string]any{"name": "Artist"}, } tags := buildTagMetadata(meta, "Song", "tidal", "t1", ripTrackOptions{}) if tags.ReplaygainTrackGain != "-7.25 dB" { t.Fatalf("track replaygain gain=%q", tags.ReplaygainTrackGain) } if tags.ReplaygainAlbumGain != "-8.1 dB" { t.Fatalf("album replaygain gain=%q", tags.ReplaygainAlbumGain) } if tags.ReplaygainTrackPeak != "0.989" { t.Fatalf("track replaygain peak=%q", tags.ReplaygainTrackPeak) } if tags.ReplaygainAlbumPeak != "1.001" { t.Fatalf("album replaygain peak=%q", tags.ReplaygainAlbumPeak) } } func TestBuildTagMetadataInitialKey(t *testing.T) { meta := map[string]any{ "key": "E Minor", "album": map[string]any{"title": "Album"}, "performer": map[string]any{"name": "Artist"}, } tags := buildTagMetadata(meta, "Song", "beatport", "42", ripTrackOptions{}) if tags.InitialKey != "Em" { t.Fatalf("InitialKey=%q", tags.InitialKey) } } func TestBuildTagMetadataArtistList(t *testing.T) { meta := map[string]any{ "artist_names": []string{"Lost Frequencies", "Calum Scott", "Kungs"}, "performer": map[string]any{"name": "Lost Frequencies, Calum Scott & Kungs"}, "album": map[string]any{"title": "Album"}, } tags := buildTagMetadata(meta, "Song", "beatport", "42", ripTrackOptions{}) if tags.Artist != "Lost Frequencies, Calum Scott & Kungs" { t.Fatalf("artist=%q", tags.Artist) } if got := strings.Join(tags.Artists, ";"); got != "Lost Frequencies;Calum Scott;Kungs" { t.Fatalf("artists=%q", got) } } func TestTitleFromMetadataSkipsOriginalMix(t *testing.T) { meta := map[string]any{"title": "Dance Done", "version": "Original Mix"} if got := titleFromMetadata(meta, "42"); got != "Dance Done" { t.Fatalf("titleFromMetadata()=%q", got) } } func TestTitleFromMetadataIncludesNonOriginalMix(t *testing.T) { meta := map[string]any{"title": "Dance Done", "version": "Extended Mix"} if got := titleFromMetadata(meta, "42"); got != "Dance Done (Extended Mix)" { t.Fatalf("titleFromMetadata()=%q", got) } } func TestNormalizeInitialKey(t *testing.T) { tests := map[string]string{ "C Major": "C", "Db Major": "Db", "F# Major": "F#", "E Minor": "Em", "C# Minor": "C#m", "Bb Minor": "Bbm", "g# minor": "G#m", "F#m": "F#m", "Ab": "Ab", "Not A Key": "Not A Key", "C# Major": "C# Major", "Db Minor": "Db Minor", } for input, want := range tests { if got := normalizeInitialKey(input); got != want { t.Fatalf("normalizeInitialKey(%q)=%q want %q", input, got, want) } } } func TestBuildTagMetadataReplayGainFallsBackToDeezerGain(t *testing.T) { meta := map[string]any{ "gain": float64(-10), "performer": map[string]any{"name": "Artist"}, "album": map[string]any{"title": "Album"}, } tags := buildTagMetadata(meta, "Song", "deezer", "2675762392", ripTrackOptions{}) if tags.ReplaygainTrackGain != "-10 dB" { t.Fatalf("track replaygain gain=%q", tags.ReplaygainTrackGain) } } // countingAlbumProvider serves a large album and records how many track // downloads were attempted, so a cancellation test can assert the rip actually // stopped instead of grinding through the rest of the queue. type countingAlbumProvider struct { url string trackCount int mu sync.Mutex attempts int cancel context.CancelFunc cancelAt int } func (f *countingAlbumProvider) Source() string { return "qobuz" } func (f *countingAlbumProvider) Login(context.Context) error { return nil } func (f *countingAlbumProvider) LoggedIn() bool { return true } func (f *countingAlbumProvider) Close() error { return nil } func (f *countingAlbumProvider) Search(context.Context, string, string, int) ([]map[string]any, error) { return nil, nil } func (f *countingAlbumProvider) GetMetadata(_ context.Context, id string, mediaType string) (map[string]any, error) { if mediaType == "album" { items := make([]any, 0, f.trackCount) for i := 0; i < f.trackCount; i++ { items = append(items, map[string]any{"id": fmt.Sprintf("t%d", i)}) } return map[string]any{ "title": "Big Album", "release_date_original": "1977-02-04", "artist": map[string]any{"name": "Fleetwood Mac"}, "tracks": map[string]any{"items": items}, }, nil } // Count metadata fetches as download attempts and trip the cancel once // enough tracks have been seen. f.mu.Lock() f.attempts++ n := f.attempts f.mu.Unlock() if f.cancel != nil && n == f.cancelAt { f.cancel() } return map[string]any{ "title": "Track " + id, "track_number": float64(1), "performer": map[string]any{"name": "Fleetwood Mac"}, "album": map[string]any{ "title": "Big Album", "artist": map[string]any{"name": "Fleetwood Mac"}, }, }, nil } func (f *countingAlbumProvider) GetDownloadable(_ context.Context, _ string, _ int) (*provider.Downloadable, error) { return &provider.Downloadable{URL: f.url, Extension: "flac", Source: "qobuz"}, nil } func (f *countingAlbumProvider) attemptCount() int { f.mu.Lock() defer f.mu.Unlock() return f.attempts } // TestRipStopsOnCancellation is the regression test for Ctrl-C doing nothing. // ctx was threaded through the rip but never checked, so cancelling left the // loops running through every remaining track (and the retry ladder retrying // on a dead context). func TestRipStopsOnCancellation(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("fLaC-test-bytes")) })) defer ts.Close() tmp := t.TempDir() d := config.DefaultConfigData() d.Downloads.Folder = tmp d.Downloads.SourceSubdirectories = false // Serial, so the cancellation point is deterministic. d.Downloads.Concurrency = false d.Artwork.Embed = false d.Artwork.SaveArtwork = false cfg := &config.Config{File: d, Session: d} const total = 40 const cancelAfter = 5 ctx, cancel := context.WithCancel(context.Background()) defer cancel() prov := &countingAlbumProvider{url: ts.URL, trackCount: total, cancel: cancel, cancelAt: cancelAfter} m := &Main{ Config: cfg, Providers: map[string]provider.Client{"qobuz": prov}, Store: store.NewDummy(), DL: download.New(), Tagger: noopTagger{}, } if err := m.AddByID(context.Background(), "qobuz", "album", "a1"); err != nil { t.Fatalf("AddByID() error = %v", err) } if err := m.Resolve(context.Background()); err != nil { t.Fatalf("Resolve() error = %v", err) } err := m.Rip(ctx) if err == nil { t.Fatal("Rip() returned nil after cancellation, want an error") } if !errors.Is(err, context.Canceled) { t.Errorf("Rip() error = %v, want it to wrap context.Canceled", err) } // The rip must stop promptly. Without the cancellation checks it walked // all 40 tracks; allow a small margin for the in-flight track. if got := prov.attemptCount(); got > cancelAfter+2 { t.Errorf("attempted %d track(s) after cancelling at %d; the rip did not stop", got, cancelAfter) } } // TestRipTrackReturnsEarlyOnCancellation pins the funnel check: every worker // path goes through ripTrack, so one guard there stops all of them. func TestRipTrackReturnsEarlyOnCancellation(t *testing.T) { tmp := t.TempDir() d := config.DefaultConfigData() d.Downloads.Folder = tmp cfg := &config.Config{File: d, Session: d} m := &Main{ Config: cfg, Providers: map[string]provider.Client{"qobuz": &fakeProvider{}}, Store: store.NewDummy(), DL: download.New(), Tagger: noopTagger{}, } ctx, cancel := context.WithCancel(context.Background()) cancel() err := m.ripTrack(ctx, &fakeProvider{}, "qobuz", "t1", "", ripTrackOptions{}) if !errors.Is(err, context.Canceled) { t.Fatalf("ripTrack() error = %v, want context.Canceled", err) } } // TestBookkeepingSurvivesCancellation checks that the failure record still // lands when the rip was cancelled, which is when it matters most. The store // writes deliberately use a detached context. func TestBookkeepingSurvivesCancellation(t *testing.T) { tmp := t.TempDir() sqlite, err := store.NewSQLite(filepath.Join(tmp, "db.sqlite")) if err != nil { t.Fatal(err) } defer func() { _ = sqlite.Close() }() d := config.DefaultConfigData() d.Downloads.Folder = tmp m := &Main{Config: &config.Config{File: d, Session: d}, Store: sqlite, DL: download.New()} ctx, cancel := context.WithCancel(context.Background()) cancel() m.markFailed(ctx, "qobuz", "track", "t1") // Read back with a live context. failed, err := sqlite.IsFailed(context.Background(), "qobuz", "track", "t1") if err != nil { t.Fatal(err) } if !failed { t.Fatal("failure record not written under a cancelled context") } } // countingMetaProvider records how many per-track metadata lookups happen, so // the album-payload optimization can be asserted rather than assumed. type countingMetaProvider struct { url string complete bool mu sync.Mutex trackMeta int albumMeta int downloads int } func (f *countingMetaProvider) Source() string { return "qobuz" } func (f *countingMetaProvider) Login(context.Context) error { return nil } func (f *countingMetaProvider) LoggedIn() bool { return true } func (f *countingMetaProvider) Close() error { return nil } func (f *countingMetaProvider) Search(context.Context, string, string, int) ([]map[string]any, error) { return nil, nil } // AlbumTracksAreComplete is only consulted when complete is true; the false // case still satisfies the interface, which mirrors a provider that opts out. func (f *countingMetaProvider) AlbumTracksAreComplete() bool { return f.complete } func (f *countingMetaProvider) GetMetadata(_ context.Context, id string, mediaType string) (map[string]any, error) { f.mu.Lock() if mediaType == "album" { f.albumMeta++ } else { f.trackMeta++ } f.mu.Unlock() if mediaType == "album" { items := make([]any, 0, 3) for i := 1; i <= 3; i++ { items = append(items, map[string]any{ "id": fmt.Sprintf("t%d", i), "title": fmt.Sprintf("Track %d", i), "track_number": float64(i), "media_number": float64(1), "isrc": fmt.Sprintf("ISRC%d", i), "copyright": "(C) Label", "performer": map[string]any{"name": "Fleetwood Mac"}, }) } return map[string]any{ "title": "Rumours", "release_date_original": "1977-02-04", "artist": map[string]any{"name": "Fleetwood Mac"}, "tracks": map[string]any{"items": items}, }, nil } return map[string]any{ "title": "Fetched " + id, "track_number": float64(1), "performer": map[string]any{"name": "Fleetwood Mac"}, "album": map[string]any{"title": "Rumours"}, }, nil } func (f *countingMetaProvider) GetDownloadable(_ context.Context, _ string, _ int) (*provider.Downloadable, error) { f.mu.Lock() f.downloads++ f.mu.Unlock() return &provider.Downloadable{URL: f.url, Extension: "flac", Source: "qobuz"}, nil } func (f *countingMetaProvider) counts() (album, track, downloads int) { f.mu.Lock() defer f.mu.Unlock() return f.albumMeta, f.trackMeta, f.downloads } func runAlbumRip(t *testing.T, prov *countingMetaProvider) string { t.Helper() tmp := t.TempDir() d := config.DefaultConfigData() d.Downloads.Folder = tmp d.Downloads.SourceSubdirectories = false d.Downloads.Concurrency = false d.Artwork.Embed = false d.Artwork.SaveArtwork = false cfg := &config.Config{File: d, Session: d} m := &Main{ Config: cfg, Providers: map[string]provider.Client{"qobuz": prov}, Store: store.NewDummy(), DL: download.New(), Tagger: noopTagger{}, } ctx := context.Background() if err := m.AddByID(ctx, "qobuz", "album", "a1"); err != nil { t.Fatalf("AddByID() error = %v", err) } if err := m.Resolve(ctx); err != nil { t.Fatalf("Resolve() error = %v", err) } if err := m.Rip(ctx); err != nil { t.Fatalf("Rip() error = %v", err) } return tmp } // TestAlbumRipSkipsPerTrackMetadataWhenEmbedded is the performance regression // test: a provider that embeds complete track objects in its album payload // must not be asked for each track again. Every such call goes through the // requests-per-minute limiter, so on a 68-track album the redundant lookups // were ~68 seconds of pure waiting. func TestAlbumRipSkipsPerTrackMetadataWhenEmbedded(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("fLaC")) })) defer ts.Close() prov := &countingMetaProvider{url: ts.URL, complete: true} tmp := runAlbumRip(t, prov) album, track, _ := prov.counts() if album != 1 { t.Errorf("album metadata fetches = %d, want 1", album) } if track != 0 { t.Errorf("per-track metadata fetches = %d, want 0 (album payload is complete)", track) } // The embedded titles must be what lands on disk, not the fetched ones. albumDir := soleSubdir(t, tmp) for i := 1; i <= 3; i++ { name := fmt.Sprintf("%02d. Fleetwood Mac - Track %d.flac", i, i) if _, err := os.Stat(filepath.Join(albumDir, name)); err != nil { entries, _ := os.ReadDir(albumDir) var got []string for _, e := range entries { got = append(got, e.Name()) } t.Fatalf("expected %q from the embedded metadata; album dir contains %v", name, got) } } } // TestAlbumRipFetchesPerTrackMetadataWhenNotEmbedded pins the opt-out path, so // providers like Tidal (whose track lookup also fetches lyrics) keep working. func TestAlbumRipFetchesPerTrackMetadataWhenNotEmbedded(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("fLaC")) })) defer ts.Close() prov := &countingMetaProvider{url: ts.URL, complete: false} runAlbumRip(t, prov) _, track, _ := prov.counts() if track != 3 { t.Errorf("per-track metadata fetches = %d, want 3 (provider opted out)", track) } } // TestAlbumTrackMetadataAttachesParentAlbum covers the one field the live // Qobuz check showed track/get adds: the nested album object. func TestAlbumTrackMetadataAttachesParentAlbum(t *testing.T) { albumMeta := map[string]any{ "title": "Rumours", "replaygain_album_gain": -7.5, "tracks": map[string]any{"items": []any{ map[string]any{"id": "t1", "title": "Dreams", "track_number": float64(1)}, }}, } items, _ := anySlice(albumMeta["tracks"].(map[string]any)["items"]) got := albumTrackMetadata(albumMeta, items) meta, ok := got["t1"] if !ok { t.Fatal("no metadata built for t1") } alb, ok := meta["album"].(map[string]any) if !ok { t.Fatal("parent album not attached") } if alb["title"] != "Rumours" { t.Errorf("album title = %v", alb["title"]) } // Album-level ReplayGain is read through trackMeta["album"]. if alb["replaygain_album_gain"] != -7.5 { t.Errorf("album replaygain lost: %v", alb["replaygain_album_gain"]) } // The attached album must not carry the whole track list back in. if _, has := alb["tracks"]; has { t.Error("attached album still has its track list; that is O(n^2) memory on a large album") } } func TestTrackMetaUsable(t *testing.T) { tests := []struct { name string meta map[string]any want bool }{ {"nil", nil, false}, {"empty", map[string]any{}, false}, {"complete", map[string]any{"title": "A", "track_number": float64(1)}, true}, {"no title", map[string]any{"track_number": float64(1)}, false}, {"blank title", map[string]any{"title": " ", "track_number": float64(1)}, false}, {"no track number", map[string]any{"title": "A"}, false}, {"zero track number", map[string]any{"title": "A", "track_number": float64(0)}, false}, {"string track number", map[string]any{"title": "A", "track_number": "2"}, true}, } for _, tc := range tests { if got := trackMetaUsable(tc.meta); got != tc.want { t.Errorf("%s: trackMetaUsable = %v, want %v", tc.name, got, tc.want) } } } // soleSubdir returns the single directory inside dir, which for an album rip // is the generated album folder. func soleSubdir(t *testing.T, dir string) string { t.Helper() entries, err := os.ReadDir(dir) if err != nil { t.Fatal(err) } var dirs []string for _, e := range entries { if e.IsDir() { dirs = append(dirs, filepath.Join(dir, e.Name())) } } if len(dirs) != 1 { t.Fatalf("expected exactly one album folder in %s, got %v", dir, dirs) } return dirs[0] } // lyricsProvider is a countingMetaProvider that also serves lyrics, to prove // lyrics still reach the tagger on the album fast path, where the per-track // metadata call is skipped. type lyricsProvider struct { *countingMetaProvider plain string synced string calls int err error } func (f *lyricsProvider) TrackLyrics(context.Context, string) (string, string, error) { f.mu.Lock() f.calls++ f.mu.Unlock() if f.err != nil { return "", "", f.err } return f.plain, f.synced, nil } type capturingTagger struct { mu sync.Mutex seen []tag.Metadata } func (c *capturingTagger) TagFLAC(_ context.Context, _ string, meta tag.Metadata, _ string) error { c.mu.Lock() defer c.mu.Unlock() c.seen = append(c.seen, meta) return nil } func (c *capturingTagger) lyrics() []string { c.mu.Lock() defer c.mu.Unlock() out := make([]string, 0, len(c.seen)) for _, m := range c.seen { out = append(out, m.Lyrics) } return out } func runAlbumRipWithTagger(t *testing.T, prov provider.Client, tagger trackTagger, exclude ...string) { t.Helper() d := config.DefaultConfigData() d.Downloads.Folder = t.TempDir() d.Downloads.Concurrency = false d.Artwork.Embed = false d.Artwork.SaveArtwork = false d.Metadata.Exclude = exclude cfg := &config.Config{File: d, Session: d} m := &Main{ Config: cfg, Providers: map[string]provider.Client{"qobuz": prov}, Store: store.NewDummy(), DL: download.New(), Tagger: tagger, } ctx := context.Background() if err := m.AddByID(ctx, "qobuz", "album", "a1"); err != nil { t.Fatalf("AddByID() error = %v", err) } if err := m.Resolve(ctx); err != nil { t.Fatalf("Resolve() error = %v", err) } if err := m.Rip(ctx); err != nil { t.Fatalf("Rip() error = %v", err) } } func TestAlbumRipAttachesLyricsOnFastPath(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("fLaC")) })) defer ts.Close() prov := &lyricsProvider{ countingMetaProvider: &countingMetaProvider{url: ts.URL, complete: true}, plain: "line one\nline two", synced: "[00:01.00]line one", } tagger := &capturingTagger{} runAlbumRipWithTagger(t, prov, tagger) _, trackMeta, _ := prov.counts() if trackMeta != 0 { t.Errorf("per-track metadata fetches = %d, want 0 (fast path must be preserved)", trackMeta) } if prov.calls != 3 { t.Errorf("TrackLyrics calls = %d, want 3 (one per track)", prov.calls) } got := tagger.lyrics() if len(got) != 3 { t.Fatalf("tagged %d tracks, want 3", len(got)) } for i, l := range got { // Synced lyrics win over plain, matching the existing behaviour. if l != "[00:01.00]line one" { t.Errorf("track %d lyrics = %q", i, l) } } } func TestAlbumRipSkipsLyricsWhenExcluded(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("fLaC")) })) defer ts.Close() prov := &lyricsProvider{ countingMetaProvider: &countingMetaProvider{url: ts.URL, complete: true}, plain: "should not be fetched", } runAlbumRipWithTagger(t, prov, noopTagger{}, "lyrics") if prov.calls != 0 { t.Errorf("TrackLyrics calls = %d, want 0 when metadata.exclude contains lyrics", prov.calls) } } // A lyrics failure is cosmetic and must never fail the download. func TestAlbumRipSurvivesLyricsFailure(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("fLaC")) })) defer ts.Close() prov := &lyricsProvider{ countingMetaProvider: &countingMetaProvider{url: ts.URL, complete: true}, err: errors.New("lyrics service down"), } tagger := &capturingTagger{} runAlbumRipWithTagger(t, prov, tagger) if n := len(tagger.lyrics()); n != 3 { t.Fatalf("tagged %d tracks, want 3 despite the lyrics failure", n) } for i, l := range tagger.lyrics() { if l != "" { t.Errorf("track %d should have no lyrics, got %q", i, l) } } } // Providers that already attach lyrics in their metadata (Tidal, Deezer) must // not be asked again. func TestLyricsNotRefetchedWhenMetadataAlreadyHasThem(t *testing.T) { d := config.DefaultConfigData() m := &Main{Config: &config.Config{File: d, Session: d}, DL: download.New()} prov := &lyricsProvider{countingMetaProvider: &countingMetaProvider{}, plain: "fresh"} meta := map[string]any{"lyrics_synced": "[00:00.00]already here"} m.attachLyrics(context.Background(), prov, meta, "t1") if prov.calls != 0 { t.Errorf("TrackLyrics calls = %d, want 0 when metadata already carries lyrics", prov.calls) } if meta["lyrics_synced"] != "[00:00.00]already here" { t.Errorf("existing lyrics overwritten: %v", meta["lyrics_synced"]) } }