mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-02 16:20:57 +02:00
fix(download): restore reliable provider fallback retries
This commit is contained in:
@@ -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() {})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user