package app import ( "context" "net/http" "net/http/httptest" "os" "path/filepath" "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(string, tag.Metadata, string) error { return nil } 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 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 *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 *fakeAlbumProvider) LoggedIn() bool { return true } func (f *fakePlaylistProvider) LoggedIn() bool { return true } func (f *fakeVideoProvider) 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 *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 *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 *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 *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 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 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), 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 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", 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 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) } }