package qobuz import ( "context" "crypto/md5" "encoding/hex" "encoding/json" "net/http" "net/http/httptest" "testing" "streamrip-go/internal/config" ) func TestQualityMap(t *testing.T) { tests := []struct { in int want int }{ {1, 5}, {2, 6}, {3, 7}, {4, 27}, {0, 7}, {99, 7}, } for _, tt := range tests { got := qualityMap(tt.in) if got != tt.want { t.Fatalf("qualityMap(%d)=%d want %d", tt.in, got, tt.want) } } } func TestParseTrackMetadata(t *testing.T) { resp := map[string]any{ "id": "19512574", "title": "Dreams", "version": "Remastered", "track_number": float64(2), "media_number": float64(1), "parental_warning": false, "maximum_bit_depth": float64(24), "maximum_sampling_rate": float64(96), "performer": map[string]any{ "name": "Fleetwood Mac", }, "album": map[string]any{ "title": "Rumours", }, } m, err := ParseTrackMetadata(resp) if err != nil { t.Fatalf("ParseTrackMetadata() error = %v", err) } if m.ID != "19512574" || m.Title != "Dreams" || m.Album != "Rumours" || m.Artist != "Fleetwood Mac" { t.Fatalf("unexpected metadata: %+v", m) } if m.Quality != 3 { t.Fatalf("quality = %d, want 3", m.Quality) } } func TestGetPlaylistPagination(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { offset := r.URL.Query().Get("offset") if offset == "" { offset = "0" } resp := map[string]any{} switch offset { case "0": resp = map[string]any{ "tracks_count": 1200, "tracks": map[string]any{"items": makeItems(0, 500)}, } case "500": resp = map[string]any{"tracks": map[string]any{"items": makeItems(500, 1000)}} case "1000": resp = map[string]any{"tracks": map[string]any{"items": makeItems(1000, 1200)}} default: w.WriteHeader(http.StatusNotFound) _ = json.NewEncoder(w).Encode(map[string]any{"message": "not found"}) return } _ = json.NewEncoder(w).Encode(resp) })) defer ts.Close() c := newTestClient(t) c.loggedIn = true c.baseURL = ts.URL raw, err := c.GetMetadata(context.Background(), "playlist-id", "playlist") if err != nil { t.Fatalf("GetMetadata() error = %v", err) } tracks, ok := mapValue(raw["tracks"]) if !ok { t.Fatalf("tracks missing") } items, ok := tracks["items"].([]any) if !ok { t.Fatalf("items missing") } if len(items) != 1200 { t.Fatalf("len(items) = %d, want 1200", len(items)) } } func TestGetLabelPagination(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { offset := r.URL.Query().Get("offset") if offset == "" { offset = "0" } resp := map[string]any{} switch offset { case "0": resp = map[string]any{ "albums_count": 700, "albums": map[string]any{"items": makeItems(0, 500)}, } case "500": resp = map[string]any{"albums": map[string]any{"items": makeItems(500, 700)}} default: w.WriteHeader(http.StatusNotFound) _ = json.NewEncoder(w).Encode(map[string]any{"message": "not found"}) return } _ = json.NewEncoder(w).Encode(resp) })) defer ts.Close() c := newTestClient(t) c.loggedIn = true c.baseURL = ts.URL raw, err := c.GetMetadata(context.Background(), "label-id", "label") if err != nil { t.Fatalf("GetMetadata() error = %v", err) } albums, ok := mapValue(raw["albums"]) if !ok { t.Fatalf("albums missing") } items, ok := albums["items"].([]any) if !ok { t.Fatalf("items missing") } if len(items) != 700 { t.Fatalf("len(items) = %d, want 700", len(items)) } } func TestGetArtistPagination(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { offset := r.URL.Query().Get("offset") if offset == "" { offset = "0" } resp := map[string]any{} switch offset { case "0": resp = map[string]any{ "albums_count": 620, "albums": map[string]any{"items": makeItems(0, 500)}, } case "500": resp = map[string]any{"albums": map[string]any{"items": makeItems(500, 620)}} default: w.WriteHeader(http.StatusNotFound) _ = json.NewEncoder(w).Encode(map[string]any{"message": "not found"}) return } _ = json.NewEncoder(w).Encode(resp) })) defer ts.Close() c := newTestClient(t) c.loggedIn = true c.baseURL = ts.URL raw, err := c.GetMetadata(context.Background(), "artist-id", "artist") if err != nil { t.Fatalf("GetMetadata() error = %v", err) } albums, ok := mapValue(raw["albums"]) if !ok { t.Fatalf("albums missing") } items, ok := albums["items"].([]any) if !ok { t.Fatalf("items missing") } if len(items) != 620 { t.Fatalf("len(items) = %d, want 620", len(items)) } } func newTestClient(t *testing.T) *Client { t.Helper() d := testConfigData() d.Qobuz.AppID = "12345" cfg := &config.Config{File: d, Session: d} return New(cfg) } func makeItems(start, end int) []map[string]any { items := make([]map[string]any, 0, end-start) for i := start; i < end; i++ { items = append(items, map[string]any{"id": i}) } return items } func TestLoginRefreshesAppCredentialsWhenSecretInvalid(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/user/login": _ = json.NewEncoder(w).Encode(map[string]any{"user_auth_token": "uat-token"}) case "/track/getFileUrl": if r.Header.Get("X-App-Id") != "new-app" { w.WriteHeader(http.StatusBadRequest) _ = json.NewEncoder(w).Encode(map[string]any{"message": "bad app"}) return } tsValue := r.URL.Query().Get("request_ts") sig := r.URL.Query().Get("request_sig") if sig != qobuzSecretSig(tsValue, "good-secret") { w.WriteHeader(http.StatusBadRequest) _ = json.NewEncoder(w).Encode(map[string]any{"message": "bad secret"}) return } w.WriteHeader(http.StatusUnauthorized) _ = json.NewEncoder(w).Encode(map[string]any{"message": "ok secret"}) default: w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() d := testConfigData() d.Qobuz.EmailOrUserID = "user@example.com" d.Qobuz.PasswordOrToken = "hash" d.Qobuz.AppID = "old-app" d.Qobuz.Secrets = []string{"bad-secret"} cfg := &config.Config{File: d, Session: d} c := New(cfg) c.baseURL = ts.URL c.fetchCfg = func(context.Context) (string, []string, error) { return "new-app", []string{"good-secret"}, nil } if err := c.Login(context.Background()); err != nil { t.Fatalf("Login() error = %v", err) } if !c.loggedIn { t.Fatalf("expected logged-in client") } if c.secret != "good-secret" { t.Fatalf("secret = %q, want good-secret", c.secret) } if c.cfg.Session.Qobuz.AppID != "new-app" { t.Fatalf("session app id = %q", c.cfg.Session.Qobuz.AppID) } } func TestLoginRetriesAfterRefreshingAppCredentials(t *testing.T) { loginCalls := 0 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/user/login": loginCalls++ if r.URL.Query().Get("app_id") != "new-app" { w.WriteHeader(http.StatusUnauthorized) _ = json.NewEncoder(w).Encode(map[string]any{"message": "expired app id"}) return } _ = json.NewEncoder(w).Encode(map[string]any{"user_auth_token": "uat-token"}) case "/track/getFileUrl": tsValue := r.URL.Query().Get("request_ts") sig := r.URL.Query().Get("request_sig") if r.Header.Get("X-App-Id") == "new-app" && sig == qobuzSecretSig(tsValue, "good-secret") { w.WriteHeader(http.StatusUnauthorized) _ = json.NewEncoder(w).Encode(map[string]any{"message": "ok secret"}) return } w.WriteHeader(http.StatusBadRequest) _ = json.NewEncoder(w).Encode(map[string]any{"message": "bad secret"}) default: w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() d := testConfigData() d.Qobuz.EmailOrUserID = "user@example.com" d.Qobuz.PasswordOrToken = "hash" d.Qobuz.AppID = "old-app" d.Qobuz.Secrets = []string{"old-secret"} cfg := &config.Config{File: d, Session: d} c := New(cfg) c.baseURL = ts.URL c.fetchCfg = func(context.Context) (string, []string, error) { return "new-app", []string{"good-secret"}, nil } if err := c.Login(context.Background()); err != nil { t.Fatalf("Login() error = %v", err) } if loginCalls < 2 { t.Fatalf("expected login retry after refresh, calls=%d", loginCalls) } } func TestQobuzDownloadExtension(t *testing.T) { tests := []struct { name string resp map[string]any quality int url string want string }{ {name: "from url flac", resp: map[string]any{}, quality: 1, url: "https://cdn.example/a.flac?token=1", want: "flac"}, {name: "from url mp3", resp: map[string]any{}, quality: 4, url: "https://cdn.example/a.mp3", want: "mp3"}, {name: "from mime type", resp: map[string]any{"mime_type": "audio/flac"}, quality: 1, url: "https://cdn.example/stream", want: "flac"}, {name: "from format id", resp: map[string]any{"format_id": float64(5)}, quality: 4, url: "https://cdn.example/stream", want: "mp3"}, {name: "fallback quality", resp: map[string]any{}, quality: 3, url: "https://cdn.example/stream", want: "flac"}, } for _, tt := range tests { if got := qobuzDownloadExtension(tt.resp, tt.quality, tt.url); got != tt.want { t.Fatalf("%s: qobuzDownloadExtension()=%q want %q", tt.name, got, tt.want) } } } func TestGetDownloadableUsesReturnedURLExtension(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/track/getFileUrl" { w.WriteHeader(http.StatusNotFound) return } _ = json.NewEncoder(w).Encode(map[string]any{"url": "https://cdn.example/track.mp3?token=abc"}) })) defer ts.Close() c := newTestClient(t) c.loggedIn = true c.secret = "secret" c.baseURL = ts.URL d, err := c.GetDownloadable(context.Background(), "19512574", 4) if err != nil { t.Fatalf("GetDownloadable() error = %v", err) } if d.Extension != "mp3" { t.Fatalf("extension = %q, want mp3", d.Extension) } if d.Audio.Container != "MP3" || d.Audio.Codec != "MP3" { t.Fatalf("unexpected audio profile: %+v", d.Audio) } } func TestQobuzAudioProfileUsesExactReturnedQuality(t *testing.T) { profile := qobuzAudioProfile(map[string]any{ "format_id": float64(7), "bit_depth": float64(24), "sampling_rate": float64(44.1), }, 4, "flac") if profile.BitDepth != 24 || profile.SamplingRate != "44.1" || profile.Quality != "HI_RES" { t.Fatalf("unexpected profile: %+v", profile) } } func TestQobuzAudioProfileAvoidsGuessingHiResSampleRate(t *testing.T) { profile := qobuzAudioProfile(map[string]any{"format_id": float64(7)}, 4, "flac") if profile.BitDepth != 24 || profile.SamplingRate != "" || profile.Quality != "HI_RES" { t.Fatalf("unexpected profile: %+v", profile) } } func qobuzSecretSig(requestTS, secret string) string { raw := "trackgetFileUrlformat_id27intentstreamtrack_id19512574" + requestTS + secret hash := md5.Sum([]byte(raw)) return hex.EncodeToString(hash[:]) } func TestRefreshAppCredentialsRejectsEmptyData(t *testing.T) { d := testConfigData() c := New(&config.Config{File: d, Session: d}) c.fetchCfg = func(context.Context) (string, []string, error) { return "", []string{" "}, nil } err := c.refreshAppCredentials(context.Background(), &c.cfg.Session.Qobuz) if err == nil { t.Fatalf("expected error for empty refreshed app credentials") } } // testConfigData returns defaults with request throttling disabled. // // At the default 60 requests/minute the rate limiter sleeps 1s per request, // which accounted for most of this suite's runtime (deezer alone was 31s). func testConfigData() config.ConfigData { d := config.DefaultConfigData() d.Downloads.RequestsPerMinute = -1 return d } // newLyricsServer serves the two-step Qobuz lyrics flow: track/lyricsUrl // returns a URL on this same server, which then serves the document. func newLyricsServer(t *testing.T, doc any, docStatus int) *httptest.Server { t.Helper() var srv *httptest.Server srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/track/lyricsUrl": if r.URL.Query().Get("request_sig") == "" { t.Error("track/lyricsUrl called without a request signature") } if r.URL.Query().Get("language") != "" { t.Error("language must be omitted so the original lyrics are returned, not a translation") } _ = json.NewEncoder(w).Encode(map[string]any{ "track_id": r.URL.Query().Get("track_id"), "album_id": "a1", "lyrics_url": srv.URL + "/doc.json?Signature=x", }) case "/doc.json": if docStatus != http.StatusOK { w.WriteHeader(docStatus) _, _ = w.Write([]byte(`AccessDenied`)) return } _ = json.NewEncoder(w).Encode(doc) default: w.WriteHeader(http.StatusNotFound) } })) t.Cleanup(srv.Close) return srv } func lyricsClient(t *testing.T, srv *httptest.Server, exclude ...string) *Client { t.Helper() d := testConfigData() d.Metadata.Exclude = exclude c := New(&config.Config{File: d, Session: d}) c.baseURL = srv.URL c.loggedIn = true c.secret = "secret" return c } func TestTrackLyricsLineSynced(t *testing.T) { srv := newLyricsServer(t, map[string]any{ "track_id": "42", "translation_langs": []any{"fr", "de"}, "original": map[string]any{ "type": "lsync", "lang": "en", "lines": []any{ map[string]any{"line": "First line", "start": 5780, "end": 8710}, map[string]any{"line": "", "start": 9000, "end": 9100}, map[string]any{"line": "Second line", "start": 65250, "end": 68000}, }, }, }, http.StatusOK) plain, lrc, err := lyricsClient(t, srv).TrackLyrics(context.Background(), "42") if err != nil { t.Fatalf("TrackLyrics() error = %v", err) } if plain != "First line\nSecond line" { t.Errorf("plain = %q", plain) } want := "[00:05.78]First line\n[01:05.25]Second line" if lrc != want { t.Errorf("lrc = %q, want %q", lrc, want) } } func TestTrackLyricsPlainHasNoTimestamps(t *testing.T) { srv := newLyricsServer(t, map[string]any{ "original": map[string]any{ "type": "plain", "lines": []any{ map[string]any{"line": "Just text"}, map[string]any{"line": "More text"}, }, }, }, http.StatusOK) plain, lrc, err := lyricsClient(t, srv).TrackLyrics(context.Background(), "42") if err != nil { t.Fatal(err) } if plain != "Just text\nMore text" { t.Errorf("plain = %q", plain) } if lrc != "" { t.Errorf("plain lyrics must not produce an LRC transcript, got %q", lrc) } } func TestTrackLyricsWordSyncedUsesLineTimings(t *testing.T) { srv := newLyricsServer(t, map[string]any{ "original": map[string]any{ "type": "wsync", "lines": []any{ map[string]any{ "line": "Hello world", "start": 1500, "words": []any{ map[string]any{"text": "Hello", "start": 1500, "end": 1800}, map[string]any{"text": "world", "start": 1800, "end": 2100}, }, }, }, }, }, http.StatusOK) plain, lrc, err := lyricsClient(t, srv).TrackLyrics(context.Background(), "42") if err != nil { t.Fatal(err) } if plain != "Hello world" { t.Errorf("plain = %q", plain) } if lrc != "[00:01.50]Hello world" { t.Errorf("lrc = %q", lrc) } } // A missing document 403s on the signed URL; that is how Qobuz says "no // lyrics", so it must not fail the rip. func TestTrackLyricsMissingDocumentIsNotAnError(t *testing.T) { srv := newLyricsServer(t, nil, http.StatusForbidden) plain, lrc, err := lyricsClient(t, srv).TrackLyrics(context.Background(), "42") if err != nil { t.Fatalf("a missing lyrics document must not be an error, got %v", err) } if plain != "" || lrc != "" { t.Errorf("expected no lyrics, got plain=%q lrc=%q", plain, lrc) } } func TestTrackLyricsRespectsMetadataExclude(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Errorf("no request should be made when lyrics are excluded, got %s", r.URL.Path) })) defer srv.Close() plain, lrc, err := lyricsClient(t, srv, "lyrics").TrackLyrics(context.Background(), "42") if err != nil || plain != "" || lrc != "" { t.Fatalf("exclude should short-circuit; got plain=%q lrc=%q err=%v", plain, lrc, err) } } func TestTrackLyricsEmptyIDIsNoOp(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Errorf("no request expected for an empty track id, got %s", r.URL.Path) })) defer srv.Close() if plain, lrc, err := lyricsClient(t, srv).TrackLyrics(context.Background(), " "); err != nil || plain != "" || lrc != "" { t.Fatalf("got plain=%q lrc=%q err=%v", plain, lrc, err) } }