From cab1af18280cb8ac436c695db0505c8712e2376e Mon Sep 17 00:00:00 2001 From: zarzet Date: Sat, 29 Aug 2026 03:19:38 +0700 Subject: [PATCH] fix(download): restore reliable provider fallback retries --- ...exports_songlink_lyrics_supplement_test.go | 9 +- go_backend/extension_runtime.go | 16 +- go_backend/extension_signed_session.go | 3 +- go_backend/extension_signed_session_test.go | 52 +++++- go_backend/songlink.go | 176 ++++++------------ go_backend/songlink_test.go | 109 ++++++++--- 6 files changed, 202 insertions(+), 163 deletions(-) diff --git a/go_backend/exports_songlink_lyrics_supplement_test.go b/go_backend/exports_songlink_lyrics_supplement_test.go index 6db6c186..074c0d0c 100644 --- a/go_backend/exports_songlink_lyrics_supplement_test.go +++ b/go_backend/exports_songlink_lyrics_supplement_test.go @@ -90,15 +90,10 @@ func TestSongLinkExportWrappersWithFakeClient(t *testing.T) { return RetryConfig{MaxRetries: 0, InitialDelay: 0, MaxDelay: 0, BackoffFactor: 1} } globalSongLinkClient = &SongLinkClient{client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { - var body string - switch req.URL.Host { - case "api.zarz.moe": - body = `{"success":true,"songUrls":{"Spotify":"https://open.spotify.com/track/spotify-1","Deezer":"https://www.deezer.com/track/101","Tidal":"https://listen.tidal.com/track/202","YouTube":"https://youtu.be/yt1","AmazonMusic":"https://music.amazon.com/tracks/amz1","Qobuz":"https://open.qobuz.com/track/303"}}` - case "api.song.link": - body = `{"linksByPlatform":{"spotify":{"url":"https://open.spotify.com/track/spotify-1"},"deezer":{"url":"https://www.deezer.com/track/101"},"tidal":{"url":"https://listen.tidal.com/track/202"},"youtubeMusic":{"url":"https://music.youtube.com/watch?v=ytm1"},"amazonMusic":{"url":"https://music.amazon.com/tracks/amz1"},"qobuz":{"url":"https://open.qobuz.com/track/303"}}}` - default: + if req.URL.Host != "api.song.link" { t.Fatalf("unexpected SongLink request: %s", req.URL.String()) } + body := `{"linksByPlatform":{"spotify":{"url":"https://open.spotify.com/track/spotify-1"},"deezer":{"url":"https://www.deezer.com/track/101"},"tidal":{"url":"https://listen.tidal.com/track/202"},"youtubeMusic":{"url":"https://music.youtube.com/watch?v=ytm1"},"amazonMusic":{"url":"https://music.amazon.com/tracks/amz1"},"qobuz":{"url":"https://open.qobuz.com/track/303"}}}` return &http.Response{StatusCode: http.StatusOK, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil })}} songLinkClientOnce.Do(func() {}) diff --git a/go_backend/extension_runtime.go b/go_backend/extension_runtime.go index 4e13e216..e09d3987 100644 --- a/go_backend/extension_runtime.go +++ b/go_backend/extension_runtime.go @@ -293,17 +293,27 @@ func (r *extensionRuntime) bindDownloadCancelContext(req *http.Request) *http.Re if req == nil { return nil } + return req.WithContext(r.activeOperationContext(req.Context())) +} +// activeOperationContext is stable for the full extension operation. An +// http.Client with a finite Timeout derives a per-request child context and +// cancels it when that response body closes, so that request context must not +// be reused for provider retry delays between requests. +func (r *extensionRuntime) activeOperationContext(fallback context.Context) context.Context { itemID := r.getActiveDownloadItemID() if itemID == "" { requestID := r.getActiveRequestID() if requestID == "" { - return req + if fallback != nil { + return fallback + } + return context.Background() } - return req.WithContext(extensionRequestCancelContext(requestID)) + return extensionRequestCancelContext(requestID) } - return req.WithContext(downloadCancelContext(itemID)) + return downloadCancelContext(itemID) } // downloadStallTimeout is how long a download may go without receiving a single diff --git a/go_backend/extension_signed_session.go b/go_backend/extension_signed_session.go index 360744ac..5d4ccb67 100644 --- a/go_backend/extension_signed_session.go +++ b/go_backend/extension_signed_session.go @@ -903,6 +903,7 @@ func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value sessionRetries := 0 providerRetries := 0 requestAuthRetryUsed := false + providerRetryCtx := r.activeOperationContext(context.Background()) for { resp, respBody, respHeaders, requestErr := r.doSignedSessionRequest( config, @@ -931,7 +932,7 @@ func (r *extensionRuntime) signedSessionFetch(call goja.FunctionCall) goja.Value providerRetries+1, signedSessionMaxProviderRetries+1, ) - if waitErr := signedSessionProviderWait(resp.Request.Context(), delay); waitErr != nil { + if waitErr := signedSessionProviderWait(providerRetryCtx, delay); waitErr != nil { return r.vm.ToValue(map[string]any{"ok": false, "error": waitErr.Error()}) } continue diff --git a/go_backend/extension_signed_session_test.go b/go_backend/extension_signed_session_test.go index fd9c8b2c..13f62abe 100644 --- a/go_backend/extension_signed_session_test.go +++ b/go_backend/extension_signed_session_test.go @@ -1437,7 +1437,10 @@ func TestSignedSessionFetchProviderContractsNeverClearSession(t *testing.T) { previousWait := signedSessionProviderWait previousNow := signedSessionRequestNow var waits []time.Duration - signedSessionProviderWait = func(_ context.Context, delay time.Duration) error { + signedSessionProviderWait = func(ctx context.Context, delay time.Duration) error { + if err := ctx.Err(); err != nil { + return fmt.Errorf("retry context was already done after the response closed: %w", err) + } waits = append(waits, delay) return nil } @@ -1479,6 +1482,10 @@ func TestSignedSessionFetchProviderContractsNeverClearSession(t *testing.T) { }, nil }) runtime := newSignedSessionTestRuntime(t, "provider-unavailable", transport) + // Production extension API clients have a finite timeout. net/http + // cancels that per-request context after the response body is closed; + // provider retry waits must outlive the completed request. + runtime.httpClient.Timeout = 15 * time.Second config := SignedSessionConfig{Namespace: "provider-unavailable", BaseURL: "https://auth.example.com"} runtime.manifest.SignedSession = &config resolved := saveUsableSignedSession(t, runtime, config, "sess-provider-retry") @@ -1520,6 +1527,49 @@ func TestSignedSessionFetchProviderContractsNeverClearSession(t *testing.T) { } }) + t.Run("temporary provider retry still honors user cancellation", func(t *testing.T) { + previousWait := signedSessionProviderWait + const itemID = "provider-retry-user-cancel" + signedSessionProviderWait = func(ctx context.Context, _ time.Duration) error { + cancelDownload(itemID) + return sleepRetry(ctx, time.Hour) + } + t.Cleanup(func() { signedSessionProviderWait = previousWait }) + + calls := 0 + transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { + calls++ + return &http.Response{ + StatusCode: http.StatusServiceUnavailable, + Header: http.Header{"Retry-After": []string{"10"}}, + Body: io.NopCloser(strings.NewReader( + `{"error":"Provider temporarily unavailable","code":"PROVIDER_UNAVAILABLE","origin":"provider","retryable":true,"retry_mode":"same_operation","retry_after_seconds":10}`, + )), + Request: req, + }, nil + }) + runtime := newSignedSessionTestRuntime(t, "provider-retry-user-cancel", transport) + runtime.httpClient.Timeout = 15 * time.Second + runtime.setActiveDownloadItemID(itemID) + initDownloadCancel(itemID) + t.Cleanup(func() { + clearDownloadCancel(itemID) + runtime.clearActiveDownloadItemID() + }) + config := SignedSessionConfig{Namespace: runtime.extensionID, BaseURL: "https://auth.example.com"} + runtime.manifest.SignedSession = &config + saveUsableSignedSession(t, runtime, config, "sess-provider-cancel") + + call := goja.FunctionCall{Arguments: []goja.Value{ + runtime.vm.ToValue("POST"), + runtime.vm.ToValue("/tickets"), + }} + result := runtime.signedSessionFetch(call).Export().(map[string]any) + if calls != 1 || !strings.Contains(fmt.Sprint(result["error"]), context.Canceled.Error()) { + t.Fatalf("user cancellation did not stop provider retry: calls=%d result=%+v", calls, result) + } + }) + t.Run("temporary provider retry budget is bounded", func(t *testing.T) { previousWait := signedSessionProviderWait signedSessionProviderWait = func(context.Context, time.Duration) error { return nil } diff --git a/go_backend/songlink.go b/go_backend/songlink.go index 30114f0a..a31289ab 100644 --- a/go_backend/songlink.go +++ b/go_backend/songlink.go @@ -1,7 +1,6 @@ package gobackend import ( - "bytes" "context" "encoding/json" "fmt" @@ -88,148 +87,75 @@ func GetSongLinkRegion() string { return region } -const resolveAPIURL = "https://api.zarz.moe/v1/resolve" - func songLinkBaseURL() string { return "https://api.song.link/v1-alpha.1/links" } -// resolveTrackPlatforms resolves a music URL to all platforms. -// Spotify URLs use the resolve API; if that fails, falls back to SongLink. -// All other URLs go directly to SongLink. +// resolveTrackPlatforms resolves a music URL to all platforms. The retired +// Zarz v1 resolver must not be consulted here; SongLink is canonical for every +// source URL. func (s *SongLinkClient) resolveTrackPlatforms(inputURL string) (map[string]songLinkPlatformLink, error) { - if isSpotifyURL(inputURL) { - payload, err := json.Marshal(map[string]string{"url": inputURL}) - if err != nil { - return nil, fmt.Errorf("failed to encode resolve request: %w", err) - } - links, err := s.doResolveRequest(payload) - if err == nil { - return links, nil - } - GoLog("[SongLink] Resolve proxy failed for %s: %v, falling back to SongLink", inputURL, err) - return s.songLinkByTargetURL(inputURL) - } return s.songLinkByTargetURL(inputURL) } -// resolveTrackPlatformsByPlatform resolves using platform + type + id. -// Spotify uses the resolve API with SongLink fallback; all other platforms use SongLink directly. -func (s *SongLinkClient) resolveTrackPlatformsByPlatform(platform, entityType, entityID string) (map[string]songLinkPlatformLink, error) { - if strings.EqualFold(platform, "spotify") { - payload, err := json.Marshal(map[string]string{ - "platform": platform, - "type": entityType, - "id": entityID, - }) - if err != nil { - return nil, fmt.Errorf("failed to encode resolve request: %w", err) - } - links, err := s.doResolveRequest(payload) - if err == nil { - return links, nil - } - GoLog("[SongLink] Resolve proxy failed for %s/%s/%s: %v, falling back to SongLink", platform, entityType, entityID, err) - return s.songLinkByPlatform(platform, entityType, entityID) - } - return s.songLinkByPlatform(platform, entityType, entityID) -} - -func isSpotifyURL(u string) bool { - lower := strings.ToLower(u) - return strings.Contains(lower, "spotify.com/") || strings.Contains(lower, "spotify:") -} - -// doResolveRequest sends a JSON payload to the resolve API (api.zarz.moe) -// and parses the response into a platform link map. -func (s *SongLinkClient) doResolveRequest(payload []byte) (map[string]songLinkPlatformLink, error) { - req, err := http.NewRequest("POST", resolveAPIURL, bytes.NewReader(payload)) - if err != nil { - return nil, fmt.Errorf("failed to create resolve request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("User-Agent", userAgentForURL(req.URL)) - - resp, err := s.client.Do(req) - if err != nil { - return nil, fmt.Errorf("resolve API request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return nil, fmt.Errorf("resolve API returned status %d", resp.StatusCode) +// resolveTrackPlatformsWithIDHS keeps cross-platform lookups available when +// SongLink is rate-limited or unavailable. IDHS accepts the same source URL and +// returns a smaller but still useful set of verified platform links. +func (s *SongLinkClient) resolveTrackPlatformsWithIDHS(inputURL string) (map[string]songLinkPlatformLink, error) { + links, songLinkErr := s.resolveTrackPlatforms(inputURL) + if songLinkErr == nil { + return links, nil } - body, err := ReadResponseBody(resp) - if err != nil { - return nil, fmt.Errorf("failed to read resolve response: %w", err) + LogWarn("SongLink", "SongLink failed for %s, trying IDHS fallback: %v", inputURL, songLinkErr) + idhsResult, idhsErr := NewIDHSClient().Search(inputURL, nil) + if idhsErr != nil { + return nil, fmt.Errorf("SongLink failed: %v; IDHS failed: %w", songLinkErr, idhsErr) } - var resolveResp struct { - Success bool `json:"success"` - ISRC string `json:"isrc"` - SongUrls map[string]json.RawMessage `json:"songUrls"` - } - if err := json.Unmarshal(body, &resolveResp); err != nil { - return nil, fmt.Errorf("failed to decode resolve response: %w", err) - } - if !resolveResp.Success { - return nil, fmt.Errorf("resolve API returned success=false") - } - - keyMap := map[string]string{ - "Spotify": "spotify", - "Deezer": "deezer", - "Tidal": "tidal", - "YouTubeMusic": "youtubeMusic", - "YouTube": "youtube", - "AmazonMusic": "amazonMusic", - "Qobuz": "qobuz", - "AppleMusic": "appleMusic", - } - - links := make(map[string]songLinkPlatformLink) - for resolveKey, platformKey := range keyMap { - rawValue, ok := resolveResp.SongUrls[resolveKey] - if !ok { + links = make(map[string]songLinkPlatformLink) + for _, link := range idhsResult.Links { + if link.NotAvailable || strings.TrimSpace(link.URL) == "" { continue } - if u := extractResolveURLValue(rawValue); u != "" { - links[platformKey] = songLinkPlatformLink{URL: u} + platform := songLinkPlatformKeyFromIDHS(link.Type) + if platform != "" { + links[platform] = songLinkPlatformLink{URL: strings.TrimSpace(link.URL)} } } - if len(links) == 0 { - return nil, fmt.Errorf("resolve API returned no platform links") + return nil, fmt.Errorf("SongLink failed: %v; IDHS returned no platform links", songLinkErr) } + LogInfo("SongLink", "IDHS fallback returned %d platform links", len(links)) return links, nil } -func extractResolveURLValue(raw json.RawMessage) string { - trimmed := bytes.TrimSpace(raw) - if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) { +func songLinkPlatformKeyFromIDHS(platform string) string { + normalized := strings.ToLower(strings.NewReplacer("-", "", "_", "", " ", "").Replace(strings.TrimSpace(platform))) + switch normalized { + case "spotify", "deezer", "tidal", "qobuz": + return normalized + case "youtube", "youtubemusic": + return "youtubeMusic" + case "applemusic": + return "appleMusic" + case "amazonmusic": + return "amazonMusic" + case "soundcloud": + return "soundcloud" + default: return "" } - - var direct string - if err := json.Unmarshal(trimmed, &direct); err == nil { - return strings.TrimSpace(direct) - } - - var list []string - if err := json.Unmarshal(trimmed, &list); err == nil { - for _, candidate := range list { - if cleaned := strings.TrimSpace(candidate); cleaned != "" { - return cleaned - } - } - } - - return "" } -// songLinkByTargetURL calls the SongLink API with a target URL (for non-Spotify URLs). +// resolveTrackPlatformsByPlatform resolves using platform + type + id. +// SongLink accepts platform identifiers directly, including Spotify. +func (s *SongLinkClient) resolveTrackPlatformsByPlatform(platform, entityType, entityID string) (map[string]songLinkPlatformLink, error) { + return s.songLinkByPlatform(platform, entityType, entityID) +} + +// songLinkByTargetURL calls the SongLink API with a target URL. func (s *SongLinkClient) songLinkByTargetURL(targetURL string) (map[string]songLinkPlatformLink, error) { songLinkRateLimiter.WaitForSlot() @@ -405,7 +331,7 @@ func (s *SongLinkClient) fetchTrackPlatformLinks(spotifyTrackID string, isrc str var raw map[string]songLinkPlatformLink var err error if spotifyTrackID != "" { - raw, err = s.resolveTrackPlatforms( + raw, err = s.resolveTrackPlatformsWithIDHS( fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID), ) } else { @@ -419,7 +345,9 @@ func (s *SongLinkClient) fetchTrackPlatformLinks(spotifyTrackID string, isrc str if deezerTrackID == "" { return nil, fmt.Errorf("failed to resolve Deezer track ID from ISRC %s", isrc) } - raw, err = s.resolveTrackPlatformsByPlatform("deezer", "song", deezerTrackID) + raw, err = s.resolveTrackPlatformsWithIDHS( + fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID), + ) } if err != nil { return nil, err @@ -536,9 +464,9 @@ func trackAvailabilityCacheStore(key string, availability *TrackAvailability, er func (s *SongLinkClient) checkTrackAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) { spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID) - links, err := s.resolveTrackPlatforms(spotifyURL) + links, err := s.resolveTrackPlatformsWithIDHS(spotifyURL) if err != nil { - return nil, fmt.Errorf("resolve proxy failed for Spotify %s: %w", spotifyTrackID, err) + return nil, fmt.Errorf("platform resolution failed for Spotify %s: %w", spotifyTrackID, err) } return buildTrackAvailabilityFromSongLinkLinks(spotifyTrackID, links), nil } @@ -770,9 +698,9 @@ type AlbumAvailability struct { func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) { spotifyURL := fmt.Sprintf("https://open.spotify.com/album/%s", spotifyAlbumID) - links, err := s.resolveTrackPlatforms(spotifyURL) + links, err := s.resolveTrackPlatformsWithIDHS(spotifyURL) if err != nil { - return nil, fmt.Errorf("resolve proxy failed for album %s: %w", spotifyAlbumID, err) + return nil, fmt.Errorf("platform resolution failed for album %s: %w", spotifyAlbumID, err) } availability := &AlbumAvailability{ @@ -964,7 +892,7 @@ func (s *SongLinkClient) GetYouTubeURLFromDeezer(deezerTrackID string) (string, } func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvailability, error) { - links, err := s.resolveTrackPlatforms(inputURL) + links, err := s.resolveTrackPlatformsWithIDHS(inputURL) if err != nil { return nil, fmt.Errorf("resolve failed for URL %s: %w", inputURL, err) } diff --git a/go_backend/songlink_test.go b/go_backend/songlink_test.go index d950cb5c..c82107e6 100644 --- a/go_backend/songlink_test.go +++ b/go_backend/songlink_test.go @@ -31,7 +31,7 @@ func resetTrackAvailabilityCache() { trackAvailabilityCacheMu.Unlock() } -func TestCheckTrackAvailabilityFromSpotifyViaResolveAPI(t *testing.T) { +func TestCheckTrackAvailabilityFromSpotifyUsesSongLinkDirectly(t *testing.T) { resetTrackAvailabilityCache() origRetryConfig := songLinkRetryConfig defer func() { songLinkRetryConfig = origRetryConfig }() @@ -39,8 +39,8 @@ func TestCheckTrackAvailabilityFromSpotifyViaResolveAPI(t *testing.T) { client := &SongLinkClient{ client: &http.Client{ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { - if req.URL.Host == "api.zarz.moe" && req.URL.Path == "/v1/resolve" && req.Method == "POST" { - body := `{"success":true,"isrc":"USRC12345678","songUrls":{"Spotify":"https://open.spotify.com/track/testspotifyid","Deezer":"https://www.deezer.com/track/908604612","AmazonMusic":"https://music.amazon.com/albums/B086Q2QNLH?trackAsin=B086Q41M9C","Tidal":"https://listen.tidal.com/track/134858527","Qobuz":"https://open.qobuz.com/track/195125822","YouTubeMusic":"https://music.youtube.com/watch?v=testvideoid1"}}` + if req.URL.Host == "api.song.link" && req.Method == http.MethodGet { + body := `{"linksByPlatform":{"spotify":{"url":"https://open.spotify.com/track/testspotifyid"},"deezer":{"url":"https://www.deezer.com/track/908604612"},"amazonMusic":{"url":"https://music.amazon.com/albums/B086Q2QNLH?trackAsin=B086Q41M9C"},"tidal":{"url":"https://listen.tidal.com/track/134858527"},"qobuz":{"url":"https://open.qobuz.com/track/195125822"},"youtubeMusic":{"url":"https://music.youtube.com/watch?v=testvideoid1"}}}` return &http.Response{ StatusCode: 200, Header: make(http.Header), @@ -73,7 +73,7 @@ func TestCheckTrackAvailabilityFromSpotifyViaResolveAPI(t *testing.T) { } } -func TestCheckTrackAvailabilityFromSpotifyResolveAPIFailure(t *testing.T) { +func TestCheckTrackAvailabilityFromSpotifyFallsBackToIDHS(t *testing.T) { resetTrackAvailabilityCache() origRetryConfig := songLinkRetryConfig songLinkRetryConfig = func() RetryConfig { @@ -81,23 +81,78 @@ func TestCheckTrackAvailabilityFromSpotifyResolveAPIFailure(t *testing.T) { } defer func() { songLinkRetryConfig = origRetryConfig }() - var hitSongLink bool + origIDHSClient := NewIDHSClient() + origIDHSRateLimiter := idhsRateLimiter + idhsRateLimiter = NewRateLimiter(100, time.Minute) + globalIDHSClient = &IDHSClient{client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.Host != "idonthavespotify.sjdonado.com" { + t.Fatalf("unexpected IDHS request: %s", req.URL.String()) + } + body := `{"type":"song","links":[{"type":"deezer","url":"https://www.deezer.com/track/908604612"},{"type":"tidal","url":"https://listen.tidal.com/track/134858527"}]}` + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + Request: req, + }, nil + })}} + defer func() { + globalIDHSClient = origIDHSClient + idhsRateLimiter = origIDHSRateLimiter + }() + + client := &SongLinkClient{client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.Host != "api.song.link" { + t.Fatalf("retired resolver or unexpected host was called: %s", req.URL.String()) + } + return &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"error":"unauthorized"}`)), + Request: req, + }, nil + })}} + + availability, err := client.CheckTrackAvailability("spotify-idhs-fallback", "") + if err != nil { + t.Fatalf("CheckTrackAvailability() error = %v", err) + } + if availability.DeezerID != "908604612" || availability.TidalID != "134858527" { + t.Fatalf("IDHS fallback availability = %+v", availability) + } +} + +func TestSongLinkPlatformKeyFromIDHS(t *testing.T) { + tests := map[string]string{ + "youTube": "youtubeMusic", + "appleMusic": "appleMusic", + "amazon_music": "amazonMusic", + "soundCloud": "soundcloud", + "unknown": "", + } + for input, want := range tests { + if got := songLinkPlatformKeyFromIDHS(input); got != want { + t.Errorf("songLinkPlatformKeyFromIDHS(%q) = %q, want %q", input, got, want) + } + } +} + +func TestResolveTrackPlatformsByPlatformUsesSongLinkForSpotify(t *testing.T) { + origRetryConfig := songLinkRetryConfig + songLinkRetryConfig = func() RetryConfig { + return RetryConfig{MaxRetries: 0, InitialDelay: 0, MaxDelay: 0, BackoffFactor: 1} + } + defer func() { songLinkRetryConfig = origRetryConfig }() client := &SongLinkClient{ client: &http.Client{ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { - // Resolve proxy returns 500 - if req.URL.Host == "api.zarz.moe" && req.URL.Path == "/v1/resolve" { - return &http.Response{ - StatusCode: 500, - Header: make(http.Header), - Body: io.NopCloser(strings.NewReader("internal error")), - Request: req, - }, nil - } - // SongLink fallback should be called if req.URL.Host == "api.song.link" { - hitSongLink = true + if req.URL.Query().Get("platform") != "spotify" || + req.URL.Query().Get("type") != "song" || + req.URL.Query().Get("id") != "testspotifyid" { + t.Fatalf("unexpected SongLink query: %s", req.URL.RawQuery) + } body := `{"linksByPlatform":{"spotify":{"url":"https://open.spotify.com/track/testspotifyid"},"deezer":{"url":"https://www.deezer.com/track/908604612"},"tidal":{"url":"https://listen.tidal.com/track/134858527"}}}` return &http.Response{ StatusCode: 200, @@ -112,19 +167,16 @@ func TestCheckTrackAvailabilityFromSpotifyResolveAPIFailure(t *testing.T) { }, } - availability, err := client.CheckTrackAvailability("testspotifyid", "") + links, err := client.resolveTrackPlatformsByPlatform("spotify", "song", "testspotifyid") if err != nil { - t.Fatalf("expected SongLink fallback to succeed, got error: %v", err) + t.Fatalf("resolveTrackPlatformsByPlatform() error = %v", err) } - if !hitSongLink { - t.Fatal("expected fallback request to SongLink API, but it was never called") - } - if !availability.Deezer || availability.DeezerID != "908604612" { - t.Fatalf("Deezer availability via fallback = %+v, want DeezerID 908604612", availability) + if links["deezer"].URL != "https://www.deezer.com/track/908604612" { + t.Fatalf("Deezer link = %#v", links["deezer"]) } } -func TestCheckTrackAvailabilityFromSpotifyViaResolveAPIMixedSongURLShapes(t *testing.T) { +func TestCheckTrackAvailabilityFromSpotifySongLinkMixedURLShapes(t *testing.T) { resetTrackAvailabilityCache() origRetryConfig := songLinkRetryConfig defer func() { songLinkRetryConfig = origRetryConfig }() @@ -132,8 +184,8 @@ func TestCheckTrackAvailabilityFromSpotifyViaResolveAPIMixedSongURLShapes(t *tes client := &SongLinkClient{ client: &http.Client{ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { - if req.URL.Host == "api.zarz.moe" && req.URL.Path == "/v1/resolve" && req.Method == "POST" { - body := `{"success":true,"isrc":"TCAHA2367688","songUrls":{"Spotify":"https://open.spotify.com/track/5glgyj6zH0irbNGfukHacv","Deezer":"https://www.deezer.com/track/2248583177","Tidal":"https://tidal.com/browse/track/290565315","AppleMusic":"https://geo.music.apple.com/us/album/example?i=1","YouTubeMusic":null,"YouTube":"https://www.youtube.com/watch?v=wD_e59XUNdQ","AmazonMusic":"https://music.amazon.com/tracks/B0C35TG38Y/?ref=dm_ff_amazonmusic_3p","Beatport":null,"BeatSource":null,"SoundCloud":null,"Qobuz":null,"Other":[]}}` + if req.URL.Host == "api.song.link" && req.Method == http.MethodGet { + body := `{"linksByPlatform":{"spotify":{"url":"https://open.spotify.com/track/5glgyj6zH0irbNGfukHacv"},"deezer":{"url":"https://www.deezer.com/track/2248583177"},"tidal":{"url":"https://tidal.com/browse/track/290565315"},"appleMusic":{"url":"https://geo.music.apple.com/us/album/example?i=1"},"youtubeMusic":null,"youtube":{"url":"https://www.youtube.com/watch?v=wD_e59XUNdQ"},"amazonMusic":{"url":"https://music.amazon.com/tracks/B0C35TG38Y/?ref=dm_ff_amazonmusic_3p"},"qobuz":null}}` return &http.Response{ StatusCode: 200, Header: make(http.Header), @@ -176,7 +228,10 @@ func TestCheckTrackAvailabilityCachesResult(t *testing.T) { client: &http.Client{ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { atomic.AddInt32(&calls, 1) - body := `{"success":true,"songUrls":{"Spotify":"https://open.spotify.com/track/cachedid","Deezer":"https://www.deezer.com/track/111"}}` + if req.URL.Host != "api.song.link" { + t.Fatalf("unexpected resolver host: %s", req.URL.Host) + } + body := `{"linksByPlatform":{"spotify":{"url":"https://open.spotify.com/track/cachedid"},"deezer":{"url":"https://www.deezer.com/track/111"}}}` return &http.Response{ StatusCode: 200, Header: make(http.Header),