package tidal import ( "context" "encoding/base64" "encoding/json" "net/http" "net/http/httptest" "net/url" "reflect" "strconv" "testing" "streamrip-go/internal/config" ) func TestLoginMissingToken(t *testing.T) { cfgData := config.DefaultConfigData() cfgData.Tidal.AccessToken = "" c := New(&config.Config{File: cfgData, Session: cfgData}) err := c.Login(context.Background()) if err == nil { t.Fatalf("expected error") } } func TestSearch(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/v1/sessions": _ = json.NewEncoder(w).Encode(map[string]any{"countryCode": "US", "userId": 123}) case "/v1/search/albums": _ = json.NewEncoder(w).Encode(map[string]any{"items": []any{map[string]any{"id": 1, "title": "x"}}}) default: w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() cfgData := config.DefaultConfigData() cfgData.Tidal.AccessToken = "token" cfgData.Tidal.CountryCode = "US" c := New(&config.Config{File: cfgData, Session: cfgData}) c.baseURL = ts.URL + "/v1" if err := c.Login(context.Background()); err != nil { t.Fatalf("login err = %v", err) } pages, err := c.Search(context.Background(), "album", "x", 10) if err != nil { t.Fatalf("search err = %v", err) } if len(pages) != 1 { t.Fatalf("pages = %d", len(pages)) } } func TestGetVideoDownloadable(t *testing.T) { var server *httptest.Server server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/v1/sessions": _ = json.NewEncoder(w).Encode(map[string]any{"countryCode": "US", "userId": 123}) case "/v1/videos/42/playbackinfopostpaywall": manifest := map[string]any{"urls": []string{server.URL + "/master.m3u8"}} b, _ := json.Marshal(manifest) _ = json.NewEncoder(w).Encode(map[string]any{"manifest": base64.StdEncoding.EncodeToString(b)}) case "/master.m3u8": _, _ = w.Write([]byte("#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1000,CODECS=\"avc1.42E01E,mp4a.40.2\",RESOLUTION=640x360\nlow/stream.m3u8\n#EXT-X-STREAM-INF:BANDWIDTH=2000,CODECS=\"avc1.4D401F,mp4a.40.2\",RESOLUTION=1280x720\nhi/stream.m3u8\n")) default: w.WriteHeader(http.StatusNotFound) } })) defer server.Close() cfgData := config.DefaultConfigData() cfgData.Tidal.AccessToken = "token" cfgData.Tidal.CountryCode = "US" c := New(&config.Config{File: cfgData, Session: cfgData}) c.baseURL = server.URL + "/v1" if err := c.Login(context.Background()); err != nil { t.Fatalf("login err = %v", err) } d, err := c.GetVideoDownloadable(context.Background(), "42") if err != nil { t.Fatalf("GetVideoDownloadable() err = %v", err) } if d.Extension != "mp4" { t.Fatalf("extension = %q, want mp4", d.Extension) } if d.URL != server.URL+"/hi/stream.m3u8" { t.Fatalf("url = %q, want %q", d.URL, server.URL+"/hi/stream.m3u8") } } func TestBestHLSVariantURLFallsBackToMaster(t *testing.T) { master := "https://example.com/master.m3u8" got := bestHLSVariantURL(master, "#EXTM3U\n#comment") if got != master { t.Fatalf("url = %q, want %q", got, master) } } func TestGetMetadataArtistPaginatesAlbums(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/v1/sessions": _ = json.NewEncoder(w).Encode(map[string]any{"countryCode": "US", "userId": 123}) case "/v1/artists/9": _ = json.NewEncoder(w).Encode(map[string]any{"id": 9, "name": "Artist X"}) case "/v1/artists/9/albums": offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) filter := r.URL.Query().Get("filter") if filter == "" { if offset == 0 { items := make([]any, 0, 100) for i := 0; i < 100; i++ { items = append(items, map[string]any{"id": i + 1}) } _ = json.NewEncoder(w).Encode(map[string]any{"items": items}) return } if offset == 100 { _ = json.NewEncoder(w).Encode(map[string]any{"items": []any{map[string]any{"id": 101}}}) return } _ = json.NewEncoder(w).Encode(map[string]any{"items": []any{}}) return } if filter == "EPSANDSINGLES" { if offset == 0 { _ = json.NewEncoder(w).Encode(map[string]any{"items": []any{map[string]any{"id": 101}, map[string]any{"id": 102}}}) return } _ = json.NewEncoder(w).Encode(map[string]any{"items": []any{}}) return } w.WriteHeader(http.StatusBadRequest) default: w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() cfgData := config.DefaultConfigData() cfgData.Tidal.AccessToken = "token" cfgData.Tidal.CountryCode = "US" c := New(&config.Config{File: cfgData, Session: cfgData}) c.baseURL = ts.URL + "/v1" if err := c.Login(context.Background()); err != nil { t.Fatalf("login err = %v", err) } meta, err := c.GetMetadata(context.Background(), "9", "artist") if err != nil { t.Fatalf("GetMetadata() err = %v", err) } albumsObj, _ := meta["albums"].(map[string]any) items, _ := albumsObj["items"].([]map[string]any) if len(items) != 102 { t.Fatalf("albums len = %d, want 102", len(items)) } } func TestGetMetadataTrackAddsLyricsAndSyncedLyrics(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/v1/tracks/42": _ = json.NewEncoder(w).Encode(map[string]any{"id": 42, "title": "Song", "album": map[string]any{"id": 10, "title": "Album"}}) case "/v1/tracks/42/lyrics": q := r.URL.Query() if q.Get("deviceType") != "PHONE" || q.Get("locale") != "en_US" || q.Get("platform") != "ANDROID" || q.Get("countryCode") != "MY" { w.WriteHeader(http.StatusBadRequest) _ = json.NewEncoder(w).Encode(map[string]any{"error": "bad query"}) return } _ = json.NewEncoder(w).Encode(map[string]any{ "lyrics": "plain lyrics line", "subtitles": "[00:00.00]plain lyrics line", }) default: w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() cfgData := config.DefaultConfigData() cfgData.Tidal.AccessToken = "token" cfgData.Tidal.CountryCode = "MY" c := New(&config.Config{File: cfgData, Session: cfgData}) c.loggedIn = true c.baseURL = ts.URL + "/v1" c.lyricsAPI = ts.URL + "/v1" meta, err := c.GetMetadata(context.Background(), "42", "track") if err != nil { t.Fatalf("GetMetadata() err = %v", err) } if got := stringify(meta["lyrics"]); got != "plain lyrics line" { t.Fatalf("lyrics = %q, want plain lyrics line", got) } if got := stringify(meta["lyrics_synced"]); got != "[00:00.00]plain lyrics line" { t.Fatalf("lyrics_synced = %q, want synced lrc", got) } } func TestGetMetadataTrackIgnoresLyricsEndpointFailure(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/v1/tracks/42": _ = json.NewEncoder(w).Encode(map[string]any{"id": 42, "title": "Song"}) case "/v1/tracks/42/lyrics": w.WriteHeader(http.StatusNotFound) _ = json.NewEncoder(w).Encode(map[string]any{"error": "not found"}) default: w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() cfgData := config.DefaultConfigData() cfgData.Tidal.AccessToken = "token" cfgData.Tidal.CountryCode = "US" c := New(&config.Config{File: cfgData, Session: cfgData}) c.loggedIn = true c.baseURL = ts.URL + "/v1" c.lyricsAPI = ts.URL + "/v1" meta, err := c.GetMetadata(context.Background(), "42", "track") if err != nil { t.Fatalf("GetMetadata() err = %v", err) } if _, ok := meta["lyrics"]; ok { t.Fatalf("did not expect lyrics when endpoint fails") } if _, ok := meta["lyrics_synced"]; ok { t.Fatalf("did not expect lyrics_synced when endpoint fails") } } func TestAPIRequestRetriesTooManyRequests(t *testing.T) { calls := 0 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/tracks/42" { w.WriteHeader(http.StatusNotFound) return } calls++ if calls == 1 { w.Header().Set("Retry-After", "0") w.WriteHeader(http.StatusTooManyRequests) _, _ = w.Write([]byte("slow down")) return } _ = json.NewEncoder(w).Encode(map[string]any{"id": 42, "title": "Song"}) })) defer ts.Close() cfgData := config.DefaultConfigData() cfgData.Downloads.RequestsPerMinute = 0 cfgData.Tidal.AccessToken = "token" cfgData.Tidal.CountryCode = "US" c := New(&config.Config{File: cfgData, Session: cfgData}) c.baseURL = ts.URL + "/v1" resp, status, err := c.apiRequest(context.Background(), "tracks/42", nil, c.baseURL) if err != nil { t.Fatalf("apiRequest() err = %v", err) } if status != http.StatusOK { t.Fatalf("status = %d, want %d", status, http.StatusOK) } if calls != 2 { t.Fatalf("calls = %d, want 2", calls) } if stringify(resp["title"]) != "Song" { t.Fatalf("title = %q, want Song", stringify(resp["title"])) } } func TestAPIPostRetriesTooManyRequests(t *testing.T) { calls := 0 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/token" { w.WriteHeader(http.StatusNotFound) return } calls++ if calls == 1 { w.Header().Set("Retry-After", "0") w.WriteHeader(http.StatusTooManyRequests) _, _ = w.Write([]byte("slow down")) return } _ = json.NewEncoder(w).Encode(map[string]any{"access_token": "fresh-token"}) })) defer ts.Close() cfgData := config.DefaultConfigData() cfgData.Downloads.RequestsPerMinute = 0 c := New(&config.Config{File: cfgData, Session: cfgData}) resp, status, err := c.apiPost(context.Background(), ts.URL+"/token", url.Values{"grant_type": []string{"refresh_token"}}, false) if err != nil { t.Fatalf("apiPost() err = %v", err) } if status != http.StatusOK { t.Fatalf("status = %d, want %d", status, http.StatusOK) } if calls != 2 { t.Fatalf("calls = %d, want 2", calls) } if stringify(resp["access_token"]) != "fresh-token" { t.Fatalf("access_token = %q, want fresh-token", stringify(resp["access_token"])) } } func TestGetDownloadablePrefersAtmosWhenEnabled(t *testing.T) { var calls []string allImmersive := true var ts *httptest.Server ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/v1/tracks/42": _ = json.NewEncoder(w).Encode(map[string]any{"id": 42, "audioModes": []any{"DOLBY_ATMOS", "STEREO"}, "mediaMetadata": map[string]any{"tags": []any{"LOSSLESS", "DOLBY_ATMOS"}}}) case "/v1/tracks/42/playbackinfopostpaywall": if r.URL.Query().Get("immersiveaudio") != "true" { allImmersive = false } aq := r.URL.Query().Get("audioquality") calls = append(calls, aq) manifest := map[string]any{"urls": []string{ts.URL + "/stereo.m3u8"}, "codecs": "flac"} if aq == "HI_RES" { manifest = map[string]any{"urls": []string{ts.URL + "/atmos.m3u8"}, "codecs": "ec-3", "audioMode": "DOLBY_ATMOS"} } b, _ := json.Marshal(manifest) _ = json.NewEncoder(w).Encode(map[string]any{"manifest": base64.StdEncoding.EncodeToString(b), "audioMode": manifest["audioMode"]}) default: w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() cfgData := config.DefaultConfigData() cfgData.Tidal.AccessToken = "token" cfgData.Tidal.CountryCode = "US" cfgData.Tidal.PreferAtmos = true c := New(&config.Config{File: cfgData, Session: cfgData}) c.loggedIn = true c.baseURL = ts.URL + "/v1" d, err := c.GetDownloadable(context.Background(), "42", 3) if err != nil { t.Fatalf("GetDownloadable() err = %v", err) } if d.URL != ts.URL+"/atmos.m3u8" { t.Fatalf("url = %q, want %q", d.URL, ts.URL+"/atmos.m3u8") } if d.Extension != "mka" { t.Fatalf("extension = %q, want mka", d.Extension) } if len(calls) < 2 || calls[0] != "HI_RES_LOSSLESS" || calls[1] != "HI_RES" { t.Fatalf("unexpected audioquality call order: %+v", calls) } if !allImmersive { t.Fatalf("expected immersiveaudio=true on Atmos probing calls") } } func TestPlaybackLooksAtmosFromManifest(t *testing.T) { manifest := map[string]any{"urls": []string{"https://cdn.example/stream.m3u8"}, "codecs": "ec-3"} b, _ := json.Marshal(manifest) resp := map[string]any{"manifest": base64.StdEncoding.EncodeToString(b)} if !playbackLooksAtmos(resp) { t.Fatalf("expected atmos detection from manifest codec") } } func TestTrackSupportsAtmosFromTags(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/tracks/42" { w.WriteHeader(http.StatusNotFound) return } _ = json.NewEncoder(w).Encode(map[string]any{"id": 42, "audioModes": []any{"STEREO"}, "mediaMetadata": map[string]any{"tags": []any{"LOSSLESS", "DOLBY_ATMOS"}}}) })) defer ts.Close() cfgData := config.DefaultConfigData() cfgData.Tidal.AccessToken = "token" cfgData.Tidal.CountryCode = "US" c := New(&config.Config{File: cfgData, Session: cfgData}) c.loggedIn = true c.baseURL = ts.URL + "/v1" if !c.trackSupportsAtmos(context.Background(), "42") { t.Fatalf("expected atmos support from mediaMetadata tags") } } func TestFormatsForQuality(t *testing.T) { tests := []struct { name string q int atmos bool wants []string }{ {name: "low", q: 0, wants: []string{"HEAACV1"}}, {name: "high", q: 1, wants: []string{"HEAACV1", "AACLC"}}, {name: "lossless", q: 2, wants: []string{"HEAACV1", "AACLC", "FLAC"}}, {name: "hires", q: 4, wants: []string{"HEAACV1", "AACLC", "FLAC", "FLAC_HIRES"}}, {name: "atmos adds eac3", q: 2, atmos: true, wants: []string{"HEAACV1", "AACLC", "FLAC", "EAC3_JOC"}}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got := formatsForQuality(tc.q, tc.atmos) if !reflect.DeepEqual(got, tc.wants) { t.Fatalf("formatsForQuality(%d, atmos=%v) = %#v, want %#v", tc.q, tc.atmos, got, tc.wants) } }) } } func TestGetDownloadableLosslessUsesTrackManifestWhenPlaybackIsAAC(t *testing.T) { var gotFormats string var ts *httptest.Server ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/v1/tracks/42/playbackinfopostpaywall": manifest := map[string]any{"urls": []string{ts.URL + "/aac.m3u8"}, "codecs": "mp4a.40.2"} b, _ := json.Marshal(manifest) _ = json.NewEncoder(w).Encode(map[string]any{"manifest": base64.StdEncoding.EncodeToString(b)}) case "/v2/trackManifests/42": gotFormats = r.URL.Query().Get("formats") _ = json.NewEncoder(w).Encode(map[string]any{ "data": map[string]any{ "attributes": map[string]any{ "uri": ts.URL + "/song.flac", "formats": []any{"FLAC"}, }, }, }) default: w.WriteHeader(http.StatusNotFound) } })) defer ts.Close() cfgData := config.DefaultConfigData() cfgData.Tidal.AccessToken = "token" cfgData.Tidal.CountryCode = "US" c := New(&config.Config{File: cfgData, Session: cfgData}) c.loggedIn = true c.baseURL = ts.URL + "/v1" c.openAPI = ts.URL + "/v2" d, err := c.GetDownloadable(context.Background(), "42", 2) if err != nil { t.Fatalf("GetDownloadable() err = %v", err) } if gotFormats != "HEAACV1,AACLC,FLAC" { t.Fatalf("formats query = %q, want %q", gotFormats, "HEAACV1,AACLC,FLAC") } if d.URL != ts.URL+"/song.flac" { t.Fatalf("url = %q, want %q", d.URL, ts.URL+"/song.flac") } if d.Extension != "flac" { t.Fatalf("extension = %q, want flac", d.Extension) } if d.Audio.Container != "FLAC" || d.Audio.Quality != "LOSSLESS" { t.Fatalf("unexpected audio profile: %+v", d.Audio) } }