Files
streamrip-go/internal/app/app_test.go
Joren b2688ce949 add CLI parity flags and expand provider support
This brings the Go CLI closer to upstream behavior with global flag handling and clearer resolve failures, while adding Tidal video downloads plus initial Deezer and SoundCloud no-account flows for broader end-to-end coverage.
2026-04-20 00:56:10 +02:00

507 lines
15 KiB
Go

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)
}
}