fix(download): restore reliable provider fallback retries

This commit is contained in:
zarzet
2026-08-29 03:19:38 +07:00
parent 5dac33dfa4
commit cab1af1828
6 changed files with 202 additions and 163 deletions
@@ -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() {})
+13 -3
View File
@@ -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
+2 -1
View File
@@ -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
+51 -1
View File
@@ -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 }
+52 -124
View File
@@ -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)
}
+82 -27
View File
@@ -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),