diff --git a/README.md b/README.md index 83c8b0d4..c3715f3e 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ Interested in contributing? Check out the [Contributing Guide](CONTRIBUTING.md) | | | | | | |---|---|---|---|---| | [MusicDL](https://www.musicdl.me) | [LRCLib](https://lrclib.net) | [Paxsenix](https://lyrics.paxsenix.org) | [Cobalt](https://cobalt.tools) | [Song.link](https://song.link) | -| [IDHS](https://github.com/sjdonado/idonthavespotify) | | | | | +| [UniTune](https://github.com/FlazeIGuess/unitune-api) | [MusicBrainz](https://musicbrainz.org) | [Squigly](https://squigly.link) | | | --- diff --git a/go_backend/exports_songlink_lyrics_supplement_test.go b/go_backend/exports_songlink_lyrics_supplement_test.go index 074c0d0c..8c295e92 100644 --- a/go_backend/exports_songlink_lyrics_supplement_test.go +++ b/go_backend/exports_songlink_lyrics_supplement_test.go @@ -80,22 +80,20 @@ func TestLyricsExportWrappersRejectMetadataOnlySidecar(t *testing.T) { func TestSongLinkExportWrappersWithFakeClient(t *testing.T) { origClient := globalSongLinkClient - origRetryConfig := songLinkRetryConfig defer func() { globalSongLinkClient = origClient - songLinkRetryConfig = origRetryConfig SetSongLinkNetworkOptions(false, false) }() - songLinkRetryConfig = func() RetryConfig { - return RetryConfig{MaxRetries: 0, InitialDelay: 0, MaxDelay: 0, BackoffFactor: 1} - } - globalSongLinkClient = &SongLinkClient{client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { - 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 - })}} + globalSongLinkClient = &SongLinkClient{fallbackResolver: &stubPlatformResolver{result: resolverResult{ + Links: map[string]songLinkPlatformLink{ + "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"}, + }, + }}} songLinkClientOnce.Do(func() {}) SetSongLinkNetworkOptions(true, true) diff --git a/go_backend/extension_health_misc_supplement_test.go b/go_backend/extension_health_misc_supplement_test.go index 35b2601f..0faf6d7d 100644 --- a/go_backend/extension_health_misc_supplement_test.go +++ b/go_backend/extension_health_misc_supplement_test.go @@ -3,9 +3,7 @@ package gobackend import ( "context" "encoding/json" - "io" "net" - "net/http" "strings" "testing" ) @@ -71,42 +69,8 @@ func TestExtensionHealthClassificationAndValidation(t *testing.T) { } } -func TestCoverAndIDHSHelpers(t *testing.T) { +func TestCoverHelpersRejectEmptyURL(t *testing.T) { if data, err := downloadCoverToMemory(""); err == nil || data != nil { t.Fatalf("expected empty cover error") } - - client := &IDHSClient{client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { - if req.Method != http.MethodPost { - t.Fatalf("method = %s", req.Method) - } - body := `{"id":"1","type":"song","title":"Song","links":[{"type":"tidal","url":"https://tidal.com/browse/track/7"},{"type":"deezer","url":"https://www.deezer.com/track/9"},{"type":"spotify","url":"https://open.spotify.com/track/abc"}]}` - return &http.Response{ - StatusCode: 200, - Header: make(http.Header), - Body: io.NopCloser(strings.NewReader(body)), - Request: req, - }, nil - })}} - availability, err := client.GetAvailabilityFromSpotify("spotify-track") - if err != nil { - t.Fatalf("GetAvailabilityFromSpotify: %v", err) - } - if !availability.Tidal || !availability.Deezer || availability.DeezerID != "9" { - t.Fatalf("spotify availability = %#v", availability) - } - deezerAvailability, err := client.GetAvailabilityFromDeezer("9") - if err != nil { - t.Fatalf("GetAvailabilityFromDeezer: %v", err) - } - if deezerAvailability.SpotifyID != "abc" || !deezerAvailability.Tidal { - t.Fatalf("deezer availability = %#v", deezerAvailability) - } - - errorClient := &IDHSClient{client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { - return &http.Response{StatusCode: 429, Body: io.NopCloser(strings.NewReader("")), Request: req}, nil - })}} - if _, err := errorClient.Search("bad", nil); err == nil { - t.Fatal("expected rate limit error") - } } diff --git a/go_backend/idhs.go b/go_backend/idhs.go deleted file mode 100644 index 31093a21..00000000 --- a/go_backend/idhs.go +++ /dev/null @@ -1,178 +0,0 @@ -package gobackend - -import ( - "bytes" - "encoding/json" - "fmt" - "net/http" - "strings" - "sync" - "time" -) - -type IDHSClient struct { - client *http.Client -} - -var ( - globalIDHSClient *IDHSClient - idhsClientOnce sync.Once - idhsRateLimiter = NewRateLimiter(8, time.Minute) // 8 req/min (below 10 limit) -) - -type IDHSSearchRequest struct { - Link string `json:"link"` - Adapters []string `json:"adapters,omitempty"` -} - -type IDHSSearchResponse struct { - ID string `json:"id"` - Type string `json:"type"` // song, album, artist, podcast, show - Title string `json:"title"` - Description string `json:"description"` - Image string `json:"image,omitempty"` - Audio string `json:"audio,omitempty"` - Source string `json:"source"` - UniversalLink string `json:"universalLink"` - Links []IDHSLink `json:"links"` -} - -type IDHSLink struct { - Type string `json:"type"` // spotify, youTube, appleMusic, deezer, soundCloud, tidal - URL string `json:"url"` - IsVerified bool `json:"isVerified,omitempty"` - NotAvailable bool `json:"notAvailable,omitempty"` -} - -func NewIDHSClient() *IDHSClient { - idhsClientOnce.Do(func() { - globalIDHSClient = &IDHSClient{ - client: NewHTTPClientWithTimeout(15 * time.Second), - } - }) - return globalIDHSClient -} - -func (c *IDHSClient) Search(link string, adapters []string) (*IDHSSearchResponse, error) { - idhsRateLimiter.WaitForSlot() - - reqBody := IDHSSearchRequest{ - Link: link, - Adapters: adapters, - } - - jsonBody, err := json.Marshal(reqBody) - if err != nil { - return nil, fmt.Errorf("failed to marshal request: %w", err) - } - - req, err := http.NewRequest("POST", "https://idonthavespotify.sjdonado.com/api/search?v=1", bytes.NewBuffer(jsonBody)) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("User-Agent", getRandomUserAgent()) - - resp, err := c.client.Do(req) - if err != nil { - return nil, fmt.Errorf("request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode == 400 { - return nil, fmt.Errorf("invalid link or missing parameters") - } - if resp.StatusCode == 429 { - return nil, fmt.Errorf("IDHS rate limit exceeded") - } - if resp.StatusCode == 500 { - return nil, fmt.Errorf("IDHS processing failed") - } - if resp.StatusCode != 200 { - return nil, fmt.Errorf("IDHS API returned status %d", resp.StatusCode) - } - - body, err := ReadResponseBody(resp) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) - } - - var result IDHSSearchResponse - if err := json.Unmarshal(body, &result); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) - } - - return &result, nil -} - -func (c *IDHSClient) GetAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) { - spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID) - - adapters := []string{"tidal", "deezer"} - - result, err := c.Search(spotifyURL, adapters) - if err != nil { - return nil, err - } - - availability := &TrackAvailability{ - SpotifyID: spotifyTrackID, - } - - for _, link := range result.Links { - if link.NotAvailable { - continue - } - - switch strings.ToLower(link.Type) { - case "tidal": - availability.Tidal = true - availability.TidalURL = link.URL - case "deezer": - availability.Deezer = true - availability.DeezerURL = link.URL - availability.DeezerID = extractDeezerIDFromURL(link.URL) - } - } - - LogDebug("IDHS", "Availability from Spotify %s: Tidal=%v, Deezer=%v", - spotifyTrackID, availability.Tidal, availability.Deezer) - - return availability, nil -} - -func (c *IDHSClient) GetAvailabilityFromDeezer(deezerTrackID string) (*TrackAvailability, error) { - deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID) - - adapters := []string{"spotify", "tidal"} - - result, err := c.Search(deezerURL, adapters) - if err != nil { - return nil, err - } - - availability := &TrackAvailability{ - Deezer: true, - DeezerID: deezerTrackID, - } - - for _, link := range result.Links { - if link.NotAvailable { - continue - } - - switch strings.ToLower(link.Type) { - case "spotify": - availability.SpotifyID = extractSpotifyIDFromURL(link.URL) - case "tidal": - availability.Tidal = true - availability.TidalURL = link.URL - } - } - - LogDebug("IDHS", "Availability from Deezer %s: Spotify=%s, Tidal=%v", - deezerTrackID, availability.SpotifyID, availability.Tidal) - - return availability, nil -} diff --git a/go_backend/misc_coverage_supplement_test.go b/go_backend/misc_coverage_supplement_test.go index edb5163f..0531e7c4 100644 --- a/go_backend/misc_coverage_supplement_test.go +++ b/go_backend/misc_coverage_supplement_test.go @@ -101,10 +101,6 @@ func TestMoreSmallConstructorsRuntimeAndMetadataHelpers(t *testing.T) { if NewAppleMusicClient().httpClient == nil || NewNeteaseClient().httpClient == nil || NewMusixmatchClient().httpClient == nil || NewQQMusicClient().httpClient == nil { t.Fatal("expected lyric provider HTTP clients") } - if NewIDHSClient().client == nil { - t.Fatal("expected IDHS HTTP client") - } - vm := goja.New() runtime := &extensionRuntime{extensionID: "misc-runtime", vm: vm, settings: map[string]any{}} if parseExtensionTimeoutSeconds(" 42 ") != 42 || parseExtensionTimeoutSeconds("bad") != 0 || parseExtensionTimeoutSeconds(float64(7)) != 7 { diff --git a/go_backend/platform_resolver_fallbacks.go b/go_backend/platform_resolver_fallbacks.go index 57541889..12f5920a 100644 --- a/go_backend/platform_resolver_fallbacks.go +++ b/go_backend/platform_resolver_fallbacks.go @@ -11,6 +11,8 @@ import ( "net/url" "strings" "time" + + "golang.org/x/net/html" ) const ( @@ -34,12 +36,17 @@ type platformFallbackResolver interface { } type platformResolverChain struct { + songLinkWeb platformFallbackResolver unitune platformFallbackResolver musicBrainz platformFallbackResolver squigly platformFallbackResolver } var defaultPlatformResolverFallbacks platformFallbackResolver = &platformResolverChain{ + songLinkWeb: &songLinkWebResolver{ + client: NewMetadataHTTPClient(6 * time.Second), + rateLimiter: NewRateLimiter(20, time.Minute), + }, unitune: &unituneResolver{ client: NewMetadataHTTPClient(6 * time.Second), rateLimiter: NewRateLimiter(30, time.Minute), @@ -66,6 +73,7 @@ func (c *platformResolverChain) Resolve( name string resolver platformFallbackResolver }{ + {name: "Song.link Web", resolver: c.songLinkWeb}, {name: "UniTune", resolver: c.unitune}, {name: "MusicBrainz", resolver: c.musicBrainz}, {name: "Squigly", resolver: c.squigly}, @@ -78,7 +86,7 @@ func (c *platformResolverChain) Resolve( resolved, err := candidate.resolver.Resolve(ctx, inputURL, result.Metadata) if err != nil { resolverErrors = append(resolverErrors, fmt.Errorf("%s: %w", candidate.name, err)) - LogDebug("SongLink", "%s fallback failed: %v", candidate.name, err) + LogDebug("PlatformResolver", "%s resolver failed: %v", candidate.name, err) continue } @@ -89,7 +97,7 @@ func (c *platformResolverChain) Resolve( if result.Metadata.Artist == "" { result.Metadata.Artist = strings.TrimSpace(resolved.Metadata.Artist) } - LogInfo("SongLink", "%s fallback contributed %d direct platform links", candidate.name, len(resolved.Links)) + LogInfo("PlatformResolver", "%s contributed %d direct platform links", candidate.name, len(resolved.Links)) if hasUsefulResolverCoverage(result.Links) { break @@ -106,6 +114,92 @@ func (c *platformResolverChain) Resolve( return resolverResult{}, errors.Join(resolverErrors...) } +type songLinkWebResolver struct { + client *http.Client + rateLimiter *RateLimiter +} + +func (r *songLinkWebResolver) Resolve( + ctx context.Context, + inputURL string, + _ resolverMetadata, +) (resolverResult, error) { + platform := resolverPlatformFromURL(inputURL) + if directResolverURL(platform, inputURL) == "" { + return resolverResult{}, fmt.Errorf("unsupported source URL") + } + if err := r.rateLimiter.WaitForSlotContext(ctx); err != nil { + return resolverResult{}, err + } + + endpoint := "https://song.link/" + url.PathEscape(inputURL) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return resolverResult{}, err + } + req.Header.Set("Accept", "text/html,application/xhtml+xml") + req.Header.Set("User-Agent", getRandomUserAgent()) + resp, err := r.client.Do(req) + if err != nil { + return resolverResult{}, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return resolverResult{}, fmt.Errorf("web page returned status %d", resp.StatusCode) + } + if resp.Request == nil || resp.Request.URL == nil || !isSongLinkLandingHost(resp.Request.URL.Hostname()) { + return resolverResult{}, fmt.Errorf("web page redirected to an unexpected host") + } + + body, err := readResolverResponse(resp, squiglyPageLimit) + if err != nil { + return resolverResult{}, err + } + document, err := html.Parse(bytes.NewReader(body)) + if err != nil { + return resolverResult{}, fmt.Errorf("failed to parse web page: %w", err) + } + + result := resolverResult{Links: make(map[string]songLinkPlatformLink)} + var visit func(*html.Node) + visit = func(node *html.Node) { + if node.Type == html.ElementNode && node.Data == "a" { + for _, attr := range node.Attr { + if attr.Key != "href" { + continue + } + platform := resolverPlatformFromURL(attr.Val) + if _, exists := result.Links[platform]; exists { + break + } + if directURL := directResolverURL(platform, attr.Val); directURL != "" { + result.Links[platform] = songLinkPlatformLink{URL: directURL} + } + break + } + } + for child := node.FirstChild; child != nil; child = child.NextSibling { + visit(child) + } + } + visit(document) + addResolverSourceLink(result.Links, inputURL) + if len(result.Links) < 2 { + return resolverResult{}, fmt.Errorf("web page returned no cross-platform links") + } + return result, nil +} + +func isSongLinkLandingHost(host string) bool { + host = strings.ToLower(strings.TrimSpace(host)) + switch host { + case "song.link", "album.link", "artist.link", "odesli.co", "www.odesli.co": + return true + default: + return false + } +} + func mergeResolverLinks(dst, src map[string]songLinkPlatformLink) { for platform, link := range src { if _, exists := dst[platform]; exists { @@ -148,6 +242,47 @@ func canonicalResolverPlatform(platform string) string { } } +func resolverURLFromPlatformID(platform, entityType, entityID string) (string, error) { + platform = canonicalResolverPlatform(platform) + entityID = strings.TrimSpace(entityID) + if platform == "" || entityID == "" { + return "", fmt.Errorf("invalid platform or entity ID") + } + + entityType = strings.ToLower(strings.TrimSpace(entityType)) + if entityType == "song" { + entityType = "track" + } + if entityType != "track" && entityType != "album" && entityType != "artist" { + return "", fmt.Errorf("unsupported entity type %q", entityType) + } + + id := url.PathEscape(entityID) + switch platform { + case "spotify": + return fmt.Sprintf("https://open.spotify.com/%s/%s", entityType, id), nil + case "deezer": + return fmt.Sprintf("https://www.deezer.com/%s/%s", entityType, id), nil + case "tidal": + return fmt.Sprintf("https://tidal.com/browse/%s/%s", entityType, id), nil + case "qobuz": + return fmt.Sprintf("https://open.qobuz.com/%s/%s", entityType, id), nil + case "amazonMusic": + return fmt.Sprintf("https://music.amazon.com/%ss/%s", entityType, id), nil + case "youtube", "youtubeMusic": + if entityType != "track" { + return "", fmt.Errorf("unsupported %s entity type %q", platform, entityType) + } + host := "www.youtube.com" + if platform == "youtubeMusic" { + host = "music.youtube.com" + } + return fmt.Sprintf("https://%s/watch?v=%s", host, url.QueryEscape(entityID)), nil + default: + return "", fmt.Errorf("cannot build a direct %s URL from an ID", platform) + } +} + func directResolverURL(platform, value string) string { platform = canonicalResolverPlatform(platform) if platform == "" { diff --git a/go_backend/platform_resolver_fallbacks_test.go b/go_backend/platform_resolver_fallbacks_test.go index e6f2dac2..73f98ac2 100644 --- a/go_backend/platform_resolver_fallbacks_test.go +++ b/go_backend/platform_resolver_fallbacks_test.go @@ -18,6 +18,35 @@ func resolverTestResponse(req *http.Request, status int, body string) *http.Resp } } +func TestSongLinkWebResolverParsesServerRenderedLinks(t *testing.T) { + resolver := &songLinkWebResolver{ + client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if req.URL.Host != "song.link" || !strings.Contains(req.URL.EscapedPath(), "https:%2F%2Fopen.spotify.com") { + t.Fatalf("unexpected Song.link web request: %s", req.URL.String()) + } + return resolverTestResponse(req, http.StatusOK, `
+ Spotify + Deezer + Tidal + Amazon + Untrusted + `), nil + })}, + rateLimiter: NewRateLimiter(100, time.Minute), + } + + result, err := resolver.Resolve(context.Background(), "https://open.spotify.com/track/source", resolverMetadata{}) + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + if len(result.Links) != 4 || result.Links["deezer"].URL == "" || result.Links["amazonMusic"].URL == "" { + t.Fatalf("Song.link web links = %#v", result.Links) + } + if strings.Contains(result.Links["amazonMusic"].URL, "&") { + t.Fatalf("HTML entity was not decoded: %s", result.Links["amazonMusic"].URL) + } +} + func TestUnituneResolverKeepsOnlyDirectTrustedLinks(t *testing.T) { resolver := &unituneResolver{ client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { @@ -171,38 +200,46 @@ func TestPlatformResolverChainMergesFallbacksWithoutReplacingEarlierLinks(t *tes } } -func TestAdditionalResolversRunOnlyAfterSongLinkAndIDHSFail(t *testing.T) { - originalIDHSClient := NewIDHSClient() - originalIDHSLimiter := idhsRateLimiter - originalRetryConfig := songLinkRetryConfig - defer func() { - globalIDHSClient = originalIDHSClient - idhsRateLimiter = originalIDHSLimiter - songLinkRetryConfig = originalRetryConfig - }() - - idhsRateLimiter = NewRateLimiter(100, time.Minute) - globalIDHSClient = &IDHSClient{client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { - return resolverTestResponse(req, http.StatusBadGateway, `{"error":"unavailable"}`), nil - })}} - songLinkRetryConfig = func() RetryConfig { - return RetryConfig{MaxRetries: 0, BackoffFactor: 1} - } - additional := &stubPlatformResolver{result: resolverResult{Links: map[string]songLinkPlatformLink{ - "deezer": {URL: "https://www.deezer.com/track/123"}, +func TestPlatformResolverChainPrefersSongLinkWeb(t *testing.T) { + songLinkWeb := &stubPlatformResolver{result: resolverResult{Links: map[string]songLinkPlatformLink{ + "spotify": {URL: "https://open.spotify.com/track/source"}, + "deezer": {URL: "https://www.deezer.com/track/101"}, + "tidal": {URL: "https://listen.tidal.com/track/202"}, + "amazonMusic": {URL: "https://music.amazon.com/tracks/TESTASIN"}, }}} - client := &SongLinkClient{ - client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { - return resolverTestResponse(req, http.StatusUnauthorized, `{"error":"deprecated"}`), nil - })}, - fallbackResolver: additional, + unitune := &stubPlatformResolver{} + musicBrainz := &stubPlatformResolver{} + squigly := &stubPlatformResolver{} + chain := &platformResolverChain{ + songLinkWeb: songLinkWeb, + unitune: unitune, + musicBrainz: musicBrainz, + squigly: squigly, } - links, err := client.resolveTrackPlatformsWithIDHSUncoalesced("https://open.spotify.com/track/source") + result, err := chain.Resolve(context.Background(), "https://open.spotify.com/track/source", resolverMetadata{}) if err != nil { - t.Fatalf("resolveTrackPlatformsWithIDHSUncoalesced() error = %v", err) + t.Fatalf("Resolve() error = %v", err) } - if additional.calls != 1 || links["deezer"].URL == "" { - t.Fatalf("additional resolver calls/links = %d/%#v", additional.calls, links) + if songLinkWeb.calls != 1 || unitune.calls != 0 || musicBrainz.calls != 0 || squigly.calls != 0 { + t.Fatalf("resolver calls = %d/%d/%d/%d, want 1/0/0/0", songLinkWeb.calls, unitune.calls, musicBrainz.calls, squigly.calls) + } + if len(result.Links) != 4 { + t.Fatalf("Song.link web links = %#v", result.Links) + } +} + +func TestActiveResolversRunWithoutRetiredNetworkHop(t *testing.T) { + active := &stubPlatformResolver{result: resolverResult{Links: map[string]songLinkPlatformLink{ + "deezer": {URL: "https://www.deezer.com/track/123"}, + }}} + client := &SongLinkClient{fallbackResolver: active} + + links, err := client.resolveTrackPlatformsUncoalesced("https://open.spotify.com/track/source") + if err != nil { + t.Fatalf("resolveTrackPlatformsUncoalesced() error = %v", err) + } + if active.calls != 1 || links["deezer"].URL == "" { + t.Fatalf("active resolver calls/links = %d/%#v", active.calls, links) } } diff --git a/go_backend/ratelimit.go b/go_backend/ratelimit.go index be0159f7..56ca1a8e 100644 --- a/go_backend/ratelimit.go +++ b/go_backend/ratelimit.go @@ -100,6 +100,3 @@ func (r *RateLimiter) Available() int { r.cleanOldTimestamps(time.Now()) return r.maxRequests - len(r.timestamps) } - -// Global SongLink rate limiter - 9 requests per minute (to be safe, limit is 10) -var songLinkRateLimiter = NewRateLimiter(9, time.Minute) diff --git a/go_backend/songlink.go b/go_backend/songlink.go index ea2d4942..59a633c7 100644 --- a/go_backend/songlink.go +++ b/go_backend/songlink.go @@ -2,9 +2,7 @@ package gobackend import ( "context" - "encoding/json" "fmt" - "net/http" "net/url" "strings" "sync" @@ -14,9 +12,7 @@ import ( ) type SongLinkClient struct { - client *http.Client fallbackResolver platformFallbackResolver - requestFlight singleflight.Group resolutionFlight singleflight.Group availabilityFlight singleflight.Group platformLinksFlight singleflight.Group @@ -55,13 +51,11 @@ var ( songLinkCheckAvailabilityFromDeezer = func(s *SongLinkClient, deezerTrackID string) (*TrackAvailability, error) { return s.CheckAvailabilityFromDeezer(deezerTrackID) } - songLinkRetryConfig = DefaultRetryConfig ) func NewSongLinkClient() *SongLinkClient { songLinkClientOnce.Do(func() { globalSongLinkClient = &SongLinkClient{ - client: NewMetadataHTTPClient(SongLinkTimeout), fallbackResolver: defaultPlatformResolverFallbacks, } }) @@ -95,24 +89,12 @@ func GetSongLinkRegion() string { return region } -func songLinkBaseURL() string { - return "https://api.song.link/v1-alpha.1/links" -} - -// 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. +// resolveTrackPlatforms resolves a music URL through the active resolver +// chain. SongLinkClient remains as the compatibility facade used by the Dart +// and native bridges, but retired resolver services are not contacted. func (s *SongLinkClient) resolveTrackPlatforms(inputURL string) (map[string]songLinkPlatformLink, error) { - return s.songLinkByTargetURL(inputURL) -} - -// resolveTrackPlatformsWithIDHS keeps cross-platform lookups available when -// SongLink is rate-limited or unavailable. The established IDHS fallback keeps -// priority; UniTune, MusicBrainz, and Squigly are only consulted when IDHS also -// fails or returns no usable platform links. -func (s *SongLinkClient) resolveTrackPlatformsWithIDHS(inputURL string) (map[string]songLinkPlatformLink, error) { value, err, _ := s.resolutionFlight.Do(inputURL, func() (any, error) { - return s.resolveTrackPlatformsWithIDHSUncoalesced(inputURL) + return s.resolveTrackPlatformsUncoalesced(inputURL) }) if err != nil { return nil, err @@ -120,33 +102,7 @@ func (s *SongLinkClient) resolveTrackPlatformsWithIDHS(inputURL string) (map[str return cloneSongLinkPlatformLinks(value.(map[string]songLinkPlatformLink)), nil } -func (s *SongLinkClient) resolveTrackPlatformsWithIDHSUncoalesced(inputURL string) (map[string]songLinkPlatformLink, error) { - links, songLinkErr := s.resolveTrackPlatforms(inputURL) - if songLinkErr == nil { - return links, nil - } - - LogWarn("SongLink", "SongLink failed for %s, trying IDHS fallback: %v", inputURL, songLinkErr) - idhsResult, idhsErr := NewIDHSClient().Search(inputURL, nil) - if idhsErr == nil { - links = make(map[string]songLinkPlatformLink) - for _, link := range idhsResult.Links { - if link.NotAvailable || strings.TrimSpace(link.URL) == "" { - continue - } - platform := songLinkPlatformKeyFromIDHS(link.Type) - if directURL := directResolverURL(platform, link.URL); directURL != "" { - links[platform] = songLinkPlatformLink{URL: directURL} - } - } - if len(links) > 0 { - LogInfo("SongLink", "IDHS fallback returned %d platform links", len(links)) - return links, nil - } - idhsErr = fmt.Errorf("IDHS returned no direct platform links") - } - - LogWarn("SongLink", "IDHS failed for %s, trying additional resolvers: %v", inputURL, idhsErr) +func (s *SongLinkClient) resolveTrackPlatformsUncoalesced(inputURL string) (map[string]songLinkPlatformLink, error) { fallbackResolver := s.fallbackResolver if fallbackResolver == nil { fallbackResolver = defaultPlatformResolverFallbacks @@ -155,119 +111,23 @@ func (s *SongLinkClient) resolveTrackPlatformsWithIDHSUncoalesced(inputURL strin defer cancel() additional, additionalErr := fallbackResolver.Resolve(ctx, inputURL, resolverMetadata{}) if additionalErr == nil && len(additional.Links) > 0 { - LogInfo("SongLink", "Additional resolver fallback returned %d platform links", len(additional.Links)) + LogInfo("PlatformResolver", "Resolver chain returned %d platform links", len(additional.Links)) return additional.Links, nil } if additionalErr == nil { additionalErr = fmt.Errorf("additional resolvers returned no platform links") } - return nil, fmt.Errorf( - "SongLink failed: %v; IDHS failed: %v; additional resolvers failed: %w", - songLinkErr, - idhsErr, - additionalErr, - ) -} - -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 "" - } + return nil, fmt.Errorf("platform resolvers failed: %w", additionalErr) } // 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) { - apiURL := fmt.Sprintf("%s?url=%s&userCountry=%s", - songLinkBaseURL(), - url.QueryEscape(targetURL), - url.QueryEscape(GetSongLinkRegion())) - - return s.doSongLinkRequest(apiURL) -} - -// songLinkByPlatform calls the SongLink API with platform + type + id (for non-Spotify platforms). -func (s *SongLinkClient) songLinkByPlatform(platform, entityType, entityID string) (map[string]songLinkPlatformLink, error) { - apiURL := fmt.Sprintf("%s?platform=%s&type=%s&id=%s&userCountry=%s", - songLinkBaseURL(), - url.QueryEscape(platform), - url.QueryEscape(entityType), - url.QueryEscape(entityID), - url.QueryEscape(GetSongLinkRegion())) - - return s.doSongLinkRequest(apiURL) -} - -// doSongLinkRequest calls the SongLink API and parses the response. -func (s *SongLinkClient) doSongLinkRequest(apiURL string) (map[string]songLinkPlatformLink, error) { - value, err, _ := s.requestFlight.Do(apiURL, func() (any, error) { - return s.doSongLinkRequestUncoalesced(apiURL) - }) + inputURL, err := resolverURLFromPlatformID(platform, entityType, entityID) if err != nil { return nil, err } - return cloneSongLinkPlatformLinks(value.(map[string]songLinkPlatformLink)), nil -} - -func (s *SongLinkClient) doSongLinkRequestUncoalesced(apiURL string) (map[string]songLinkPlatformLink, error) { - // Reserve the rate-limit slot inside the singleflight owner. Waiters for an - // identical URL share this request without consuming the remaining budget. - songLinkRateLimiter.WaitForSlot() - - req, err := http.NewRequest("GET", apiURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create SongLink request: %w", err) - } - - retryConfig := songLinkRetryConfig() - resp, err := DoRequestWithRetry(s.client, req, retryConfig) - if err != nil { - return nil, fmt.Errorf("SongLink request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode == 429 { - return nil, fmt.Errorf("SongLink rate limit exceeded") - } - if resp.StatusCode != 200 { - return nil, fmt.Errorf("SongLink returned status %d", resp.StatusCode) - } - - body, err := ReadResponseBody(resp) - if err != nil { - return nil, fmt.Errorf("failed to read SongLink response: %w", err) - } - - var songLinkResp struct { - LinksByPlatform map[string]songLinkPlatformLink `json:"linksByPlatform"` - } - if err := json.Unmarshal(body, &songLinkResp); err != nil { - return nil, fmt.Errorf("failed to decode SongLink response: %w", err) - } - - if len(songLinkResp.LinksByPlatform) == 0 { - return nil, fmt.Errorf("SongLink returned no platform links") - } - - return songLinkResp.LinksByPlatform, nil + return s.resolveTrackPlatforms(inputURL) } func cloneSongLinkPlatformLinks(links map[string]songLinkPlatformLink) map[string]songLinkPlatformLink { @@ -411,7 +271,7 @@ func (s *SongLinkClient) fetchTrackPlatformLinks(spotifyTrackID string, isrc str var raw map[string]songLinkPlatformLink var err error if spotifyTrackID != "" { - raw, err = s.resolveTrackPlatformsWithIDHS( + raw, err = s.resolveTrackPlatforms( fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID), ) } else { @@ -425,7 +285,7 @@ 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.resolveTrackPlatformsWithIDHS( + raw, err = s.resolveTrackPlatforms( fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID), ) } @@ -544,7 +404,7 @@ 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.resolveTrackPlatformsWithIDHS(spotifyURL) + links, err := s.resolveTrackPlatforms(spotifyURL) if err != nil { return nil, fmt.Errorf("platform resolution failed for Spotify %s: %w", spotifyTrackID, err) } @@ -778,7 +638,7 @@ type AlbumAvailability struct { func (s *SongLinkClient) CheckAlbumAvailability(spotifyAlbumID string) (*AlbumAvailability, error) { spotifyURL := fmt.Sprintf("https://open.spotify.com/album/%s", spotifyAlbumID) - links, err := s.resolveTrackPlatformsWithIDHS(spotifyURL) + links, err := s.resolveTrackPlatforms(spotifyURL) if err != nil { return nil, fmt.Errorf("platform resolution failed for album %s: %w", spotifyAlbumID, err) } @@ -815,18 +675,7 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra return nil, fmt.Errorf("deezer track ID is empty") } - availability, err := s.checkAvailabilityFromDeezerSongLink(deezerTrackID) - if err != nil { - LogWarn("SongLink", "SongLink failed for Deezer, trying IDHS fallback: %v", err) - idhsClient := NewIDHSClient() - availability, err = idhsClient.GetAvailabilityFromDeezer(deezerTrackID) - if err != nil { - return nil, fmt.Errorf("both SongLink and IDHS failed: %w", err) - } - LogInfo("SongLink", "IDHS fallback successful for Deezer %s", deezerTrackID) - } - - return availability, nil + return s.checkAvailabilityFromDeezerSongLink(deezerTrackID) } func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID string) (*TrackAvailability, error) { @@ -972,7 +821,7 @@ func (s *SongLinkClient) GetYouTubeURLFromDeezer(deezerTrackID string) (string, } func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvailability, error) { - links, err := s.resolveTrackPlatformsWithIDHS(inputURL) + links, err := s.resolveTrackPlatforms(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 2739e2b8..687996fe 100644 --- a/go_backend/songlink_test.go +++ b/go_backend/songlink_test.go @@ -1,35 +1,45 @@ package gobackend import ( + "context" "io" "net/http" - "strings" "sync" "sync/atomic" "testing" "time" ) -func TestSongLinkIdenticalRequestsAreCoalesced(t *testing.T) { - origRateLimiter := songLinkRateLimiter - // A single slot proves duplicate waiters join singleflight before reserving - // rate-limit capacity. Reserving first would block 15 workers for an hour. - songLinkRateLimiter = NewRateLimiter(1, time.Hour) - defer func() { songLinkRateLimiter = origRateLimiter }() +type roundTripFunc func(*http.Request) (*http.Response, error) - var calls int32 - release := make(chan struct{}) - client := &SongLinkClient{client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { - atomic.AddInt32(&calls, 1) - <-release - body := `{"linksByPlatform":{"deezer":{"url":"https://www.deezer.com/track/123"}}}` - return &http.Response{ - StatusCode: http.StatusOK, - Header: make(http.Header), - Body: io.NopCloser(strings.NewReader(body)), - Request: req, - }, nil - })}} +func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return fn(req) +} + +type blockingPlatformResolver struct { + calls atomic.Int32 + started chan struct{} + release chan struct{} + result resolverResult +} + +func (r *blockingPlatformResolver) Resolve(context.Context, string, resolverMetadata) (resolverResult, error) { + if r.calls.Add(1) == 1 { + close(r.started) + } + <-r.release + return r.result, nil +} + +func TestIdenticalPlatformResolverRequestsAreCoalesced(t *testing.T) { + resolver := &blockingPlatformResolver{ + started: make(chan struct{}), + release: make(chan struct{}), + result: resolverResult{Links: map[string]songLinkPlatformLink{ + "deezer": {URL: "https://www.deezer.com/track/123"}, + }}, + } + client := &SongLinkClient{fallbackResolver: resolver} const workers = 16 start := make(chan struct{}) @@ -49,109 +59,27 @@ func TestSongLinkIdenticalRequestsAreCoalesced(t *testing.T) { } close(start) - deadline := time.Now().Add(time.Second) - for atomic.LoadInt32(&calls) == 0 && time.Now().Before(deadline) { - time.Sleep(time.Millisecond) + select { + case <-resolver.started: + case <-time.After(time.Second): + t.Fatal("resolver did not start") } time.Sleep(20 * time.Millisecond) - close(release) + close(resolver.release) wg.Wait() close(errs) for err := range errs { if err != nil { - t.Fatalf("coalesced request failed: %v", err) + t.Fatalf("coalesced resolution failed: %v", err) } } - if got := atomic.LoadInt32(&calls); got != 1 { - t.Fatalf("network calls = %d, want 1", got) + if got := resolver.calls.Load(); got != 1 { + t.Fatalf("resolver calls = %d, want 1", got) } } -func TestSongLinkIdenticalFallbacksShareOneIDHSRequest(t *testing.T) { - origSongLinkLimiter := songLinkRateLimiter - origIDHSLimiter := idhsRateLimiter - origIDHSClient := NewIDHSClient() - songLinkRateLimiter = NewRateLimiter(1, time.Hour) - idhsRateLimiter = NewRateLimiter(1, time.Hour) - defer func() { - songLinkRateLimiter = origSongLinkLimiter - idhsRateLimiter = origIDHSLimiter - globalIDHSClient = origIDHSClient - }() - - var songLinkCalls atomic.Int32 - var idhsCalls atomic.Int32 - idhsStarted := make(chan struct{}) - releaseIDHS := make(chan struct{}) - globalIDHSClient = &IDHSClient{client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { - if idhsCalls.Add(1) == 1 { - close(idhsStarted) - } - <-releaseIDHS - return &http.Response{ - StatusCode: http.StatusOK, - Header: make(http.Header), - Body: io.NopCloser(strings.NewReader( - `{"type":"song","links":[{"type":"deezer","url":"https://www.deezer.com/track/123"}]}`, - )), - Request: req, - }, nil - })}} - client := &SongLinkClient{client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { - songLinkCalls.Add(1) - return &http.Response{ - StatusCode: http.StatusUnauthorized, - Header: make(http.Header), - Body: io.NopCloser(strings.NewReader(`{}`)), - Request: req, - }, nil - })}} - - const workers = 16 - start := make(chan struct{}) - errs := make(chan error, workers) - for range workers { - go func() { - <-start - links, err := client.resolveTrackPlatformsWithIDHS("https://open.spotify.com/track/fallback-coalesced") - if err == nil && links["deezer"].URL == "" { - err = io.ErrUnexpectedEOF - } - errs <- err - }() - } - close(start) - select { - case <-idhsStarted: - case <-time.After(time.Second): - t.Fatal("IDHS fallback did not start") - } - time.Sleep(20 * time.Millisecond) - close(releaseIDHS) - for range workers { - if err := <-errs; err != nil { - t.Fatalf("coalesced fallback failed: %v", err) - } - } - if got := songLinkCalls.Load(); got != 1 { - t.Fatalf("SongLink calls = %d, want 1", got) - } - if got := idhsCalls.Load(); got != 1 { - t.Fatalf("IDHS calls = %d, want 1", got) - } -} - -type roundTripFunc func(*http.Request) (*http.Response, error) - -func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { - return fn(req) -} - func TestGetRetryAfterDurationMissingHeaderReturnsZero(t *testing.T) { - resp := &http.Response{ - Header: make(http.Header), - } - + resp := &http.Response{Header: make(http.Header)} if got := getRetryAfterDuration(resp); got != 0 { t.Fatalf("getRetryAfterDuration() = %v, want 0", got) } @@ -163,216 +91,70 @@ func resetTrackAvailabilityCache() { trackAvailabilityCacheMu.Unlock() } -func TestCheckTrackAvailabilityFromSpotifyUsesSongLinkDirectly(t *testing.T) { - resetTrackAvailabilityCache() - origRetryConfig := songLinkRetryConfig - defer func() { songLinkRetryConfig = origRetryConfig }() +// testResolverResult is an in-memory fixture; none of these URLs are fetched. +func testResolverResult() resolverResult { + return resolverResult{Links: map[string]songLinkPlatformLink{ + "spotify": {URL: "https://open.spotify.com/track/testspotifyid"}, + "deezer": {URL: "https://www.deezer.com/track/101"}, + "amazonMusic": {URL: "https://music.amazon.com/tracks/TESTASIN"}, + "tidal": {URL: "https://listen.tidal.com/track/202"}, + "qobuz": {URL: "https://open.qobuz.com/track/303"}, + "youtubeMusic": {URL: "https://music.youtube.com/watch?v=testvideoid1"}, + }} +} - client := &SongLinkClient{ - client: &http.Client{ - Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { - 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), - Body: io.NopCloser(strings.NewReader(body)), - Request: req, - }, nil - } - t.Fatalf("unexpected request: %s %s", req.Method, req.URL.String()) - return nil, nil - }), - }, - } +func TestCheckTrackAvailabilityFromSpotifyUsesActiveResolverChain(t *testing.T) { + resetTrackAvailabilityCache() + client := &SongLinkClient{fallbackResolver: &stubPlatformResolver{result: testResolverResult()}} availability, err := client.CheckTrackAvailability("testspotifyid", "") if err != nil { t.Fatalf("CheckTrackAvailability() error = %v", err) } - - if availability.SpotifyID != "testspotifyid" { - t.Fatalf("SpotifyID = %q, want %q", availability.SpotifyID, "testspotifyid") + if availability.SpotifyID != "testspotifyid" || availability.DeezerID != "101" { + t.Fatalf("availability IDs = %+v", availability) } - if !availability.Deezer || availability.DeezerID != "908604612" { - t.Fatalf("Deezer availability = %+v, want DeezerID 908604612", availability) - } - if !availability.Amazon || !availability.Tidal || !availability.Qobuz || !availability.YouTube { - t.Fatalf("availability flags = %+v, want Amazon/Tidal/Qobuz/YouTube true", availability) + if !availability.Deezer || !availability.Amazon || !availability.Tidal || !availability.Qobuz || !availability.YouTube { + t.Fatalf("availability flags = %+v", availability) } if availability.YouTubeID != "testvideoid1" { - t.Fatalf("YouTubeID = %q, want %q", availability.YouTubeID, "testvideoid1") + t.Fatalf("YouTubeID = %q", availability.YouTubeID) } } -func TestCheckTrackAvailabilityFromSpotifyFallsBackToIDHS(t *testing.T) { - resetTrackAvailabilityCache() - origRetryConfig := songLinkRetryConfig - songLinkRetryConfig = func() RetryConfig { - return RetryConfig{MaxRetries: 0, InitialDelay: 0, MaxDelay: 0, BackoffFactor: 1} - } - defer func() { songLinkRetryConfig = origRetryConfig }() - - 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) - } +type inputCapturingResolver struct { + input string + result resolverResult } -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 (r *inputCapturingResolver) Resolve(_ context.Context, input string, _ resolverMetadata) (resolverResult, error) { + r.input = input + return r.result, nil } -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) { - if req.URL.Host == "api.song.link" { - 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, - Header: make(http.Header), - Body: io.NopCloser(strings.NewReader(body)), - Request: req, - }, nil - } - t.Fatalf("unexpected request: %s %s", req.Method, req.URL.String()) - return nil, nil - }), - }, - } +func TestResolveTrackPlatformsByPlatformBuildsDirectURL(t *testing.T) { + resolver := &inputCapturingResolver{result: testResolverResult()} + client := &SongLinkClient{fallbackResolver: resolver} links, err := client.resolveTrackPlatformsByPlatform("spotify", "song", "testspotifyid") if err != nil { t.Fatalf("resolveTrackPlatformsByPlatform() error = %v", err) } - if links["deezer"].URL != "https://www.deezer.com/track/908604612" { - t.Fatalf("Deezer link = %#v", links["deezer"]) + if resolver.input != "https://open.spotify.com/track/testspotifyid" { + t.Fatalf("resolver input = %q", resolver.input) } -} - -func TestCheckTrackAvailabilityFromSpotifySongLinkMixedURLShapes(t *testing.T) { - resetTrackAvailabilityCache() - origRetryConfig := songLinkRetryConfig - defer func() { songLinkRetryConfig = origRetryConfig }() - - client := &SongLinkClient{ - client: &http.Client{ - Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { - 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), - Body: io.NopCloser(strings.NewReader(body)), - Request: req, - }, nil - } - t.Fatalf("unexpected request: %s %s", req.Method, req.URL.String()) - return nil, nil - }), - }, - } - - availability, err := client.CheckTrackAvailability("5glgyj6zH0irbNGfukHacv", "") - if err != nil { - t.Fatalf("CheckTrackAvailability() error = %v", err) - } - - if availability.SpotifyID != "5glgyj6zH0irbNGfukHacv" { - t.Fatalf("SpotifyID = %q, want %q", availability.SpotifyID, "5glgyj6zH0irbNGfukHacv") - } - if !availability.Deezer || availability.DeezerID != "2248583177" { - t.Fatalf("Deezer availability = %+v, want DeezerID 2248583177", availability) - } - if !availability.Tidal || availability.TidalID != "290565315" { - t.Fatalf("Tidal availability = %+v, want TidalID 290565315", availability) - } - if availability.Qobuz { - t.Fatalf("Qobuz should remain false when resolve response contains null, got %+v", availability) + if links["deezer"].URL == "" { + t.Fatalf("resolver links = %#v", links) } } func TestCheckTrackAvailabilityCachesResult(t *testing.T) { resetTrackAvailabilityCache() - origRetryConfig := songLinkRetryConfig - defer func() { songLinkRetryConfig = origRetryConfig }() - - var calls int32 - client := &SongLinkClient{ - client: &http.Client{ - Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { - atomic.AddInt32(&calls, 1) - 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), - Body: io.NopCloser(strings.NewReader(body)), - Request: req, - }, nil - }), - }, - } + resolver := &stubPlatformResolver{result: resolverResult{Links: map[string]songLinkPlatformLink{ + "spotify": {URL: "https://open.spotify.com/track/cachedid"}, + "deezer": {URL: "https://www.deezer.com/track/111"}, + }}} + client := &SongLinkClient{fallbackResolver: resolver} first, err := client.CheckTrackAvailability("cachedid", "") if err != nil { @@ -382,21 +164,16 @@ func TestCheckTrackAvailabilityCachesResult(t *testing.T) { if err != nil { t.Fatalf("second CheckTrackAvailability() error = %v", err) } - - if got := atomic.LoadInt32(&calls); got != 1 { - t.Fatalf("expected 1 network call with caching, got %d", got) + if resolver.calls != 1 { + t.Fatalf("resolver calls = %d, want 1", resolver.calls) } - if first == second { - t.Fatal("expected cache to return a distinct clone, got same pointer") - } - if second.DeezerID != "111" { - t.Fatalf("cached DeezerID = %q, want 111", second.DeezerID) + if first == second || second.DeezerID != "111" { + t.Fatalf("cached result = %+v", second) } } func TestCheckTrackAvailabilityNegativeCacheTTL(t *testing.T) { resetTrackAvailabilityCache() - entry := trackAvailabilityCacheEntry{err: true, expiresAt: time.Now().Add(-time.Second)} key := GetSongLinkRegion() + "|spotify:expiredneg" trackAvailabilityCacheMu.Lock() @@ -408,45 +185,13 @@ func TestCheckTrackAvailabilityNegativeCacheTTL(t *testing.T) { } } -func TestCheckAvailabilityFromDeezerUsesSongLink(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) { - // Non-Spotify should go to SongLink, not resolve API - if req.URL.Host == "api.zarz.moe" { - t.Fatalf("non-Spotify URL should not hit resolve API, got: %s", req.URL.String()) - return nil, nil - } - if req.URL.Host == "api.song.link" { - body := `{"linksByPlatform":{"spotify":{"url":"https://open.spotify.com/track/testid"},"deezer":{"url":"https://www.deezer.com/track/908604612"},"tidal":{"url":"https://listen.tidal.com/track/134858527"},"qobuz":{"url":"https://open.qobuz.com/track/195125822"},"youtubeMusic":{"url":"https://music.youtube.com/watch?v=testvid"}}}` - return &http.Response{ - StatusCode: 200, - Header: make(http.Header), - Body: io.NopCloser(strings.NewReader(body)), - Request: req, - }, nil - } - t.Fatalf("unexpected request: %s %s", req.Method, req.URL.String()) - return nil, nil - }), - }, - } - - availability, err := client.checkAvailabilityFromDeezerSongLink("908604612") +func TestCheckAvailabilityFromDeezerUsesActiveResolverChain(t *testing.T) { + client := &SongLinkClient{fallbackResolver: &stubPlatformResolver{result: testResolverResult()}} + availability, err := client.CheckAvailabilityFromDeezer("908604612") if err != nil { - t.Fatalf("checkAvailabilityFromDeezerSongLink() error = %v", err) + t.Fatalf("CheckAvailabilityFromDeezer() error = %v", err) } - - if !availability.Deezer || availability.DeezerID != "908604612" { - t.Fatalf("Deezer = %+v, want DeezerID 908604612", availability) - } - if availability.SpotifyID != "testid" { - t.Fatalf("SpotifyID = %q, want %q", availability.SpotifyID, "testid") + if !availability.Deezer || availability.DeezerID != "908604612" || availability.SpotifyID != "testspotifyid" { + t.Fatalf("availability = %+v", availability) } } diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 50c3b83e..e6a51c04 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -461,10 +461,6 @@ "@aboutSachinsenalDesc": { "description": "Credit description for sachinsenal0x64" }, - "aboutSjdonadoDesc": "منشئ ل I Don't Have Spotify (IHDS). محلل الروابط الذي ينقذ اليوم!", - "@aboutSjdonadoDesc": { - "description": "Credit description for sjdonado" - }, "aboutAppDescription": "البحث عن بيانات التعريف الموسيقي، وإدارة الملحقات، وتنظيم مكتبتك.", "@aboutAppDescription": { "description": "App description in header card" diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 24907864..0ca121e6 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -214,10 +214,6 @@ "@searchSortDateOldest": { "description": "Sort option - oldest release first" }, - "aboutSjdonadoDesc": "Ersteller von I Don't Have Spotify (IDHS). Der Fallback-Link-Resolver, der den Tag rettet!", - "@aboutSjdonadoDesc": { - "description": "Credit description for sjdonado" - }, "nowPlayingShuffleOn": "Shuffle on", "@nowPlayingShuffleOn": { "description": "Tooltip when shuffle mode is enabled" diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 16ef9292..1af7c7b4 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -461,10 +461,6 @@ "@aboutSachinsenalDesc": { "description": "Credit description for sachinsenal0x64" }, - "aboutSjdonadoDesc": "Creator of I Don't Have Spotify (IDHS). The fallback link resolver that saves the day!", - "@aboutSjdonadoDesc": { - "description": "Credit description for sjdonado" - }, "aboutUnituneDesc": "Open-source music link resolver by Flaze, used for cross-platform fallback matching.", "@aboutUnituneDesc": { "description": "Credit for the UniTune resolver" diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index 325cf2a1..745597ab 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -1646,10 +1646,6 @@ "@aboutSocial": { "description": "Section for social links" }, - "aboutSjdonadoDesc": "Creator of I Don't Have Spotify (IDHS). The fallback link resolver that saves the day!", - "@aboutSjdonadoDesc": { - "description": "Credit description for sjdonado" - }, "artistPopular": "Popular", "@artistPopular": { "description": "Section header for popular/top tracks" diff --git a/lib/l10n/arb/app_es_ES.arb b/lib/l10n/arb/app_es_ES.arb index 54923ff1..05c71450 100644 --- a/lib/l10n/arb/app_es_ES.arb +++ b/lib/l10n/arb/app_es_ES.arb @@ -461,10 +461,6 @@ "@aboutSachinsenalDesc": { "description": "Credit description for sachinsenal0x64" }, - "aboutSjdonadoDesc": "Creador de I No tengo Spotify (IDHS). ¡La solución de enlace de reserva que salva el día!", - "@aboutSjdonadoDesc": { - "description": "Credit description for sjdonado" - }, "aboutAppDescription": "Busca información musical, gestiona extensiones y organiza tu biblioteca.", "@aboutAppDescription": { "description": "App description in header card" diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index b5204d13..6a5319dc 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -214,10 +214,6 @@ "@searchSortDateOldest": { "description": "Sort option - oldest release first" }, - "aboutSjdonadoDesc": "Créateur de « I Don't Have Spotify » (IDHS). Le résolveur de liens de secours qui sauve la mise !", - "@aboutSjdonadoDesc": { - "description": "Credit description for sjdonado" - }, "nowPlayingShuffleOn": "Lecture aléatoire activée", "@nowPlayingShuffleOn": { "description": "Tooltip when shuffle mode is enabled" diff --git a/lib/l10n/arb/app_hi.arb b/lib/l10n/arb/app_hi.arb index 50950508..da2d8680 100644 --- a/lib/l10n/arb/app_hi.arb +++ b/lib/l10n/arb/app_hi.arb @@ -461,10 +461,6 @@ "@aboutSachinsenalDesc": { "description": "Credit description for sachinsenal0x64" }, - "aboutSjdonadoDesc": "Creator of I Don't Have Spotify (IDHS). The fallback link resolver that saves the day!", - "@aboutSjdonadoDesc": { - "description": "Credit description for sjdonado" - }, "aboutAppDescription": "Search music metadata, manage extensions, and organize your library.", "@aboutAppDescription": { "description": "App description in header card" diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index de9bd4a7..c7d1ba8a 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -214,10 +214,6 @@ "@searchSortDateOldest": { "description": "Sort option - oldest release first" }, - "aboutSjdonadoDesc": "Pencipta I Don't Have Spotify (IDHS). Penyelesai tautan cadangan yang menyelamatkan keadaan!", - "@aboutSjdonadoDesc": { - "description": "Credit description for sjdonado" - }, "nowPlayingShuffleOn": "Shuffle on", "@nowPlayingShuffleOn": { "description": "Tooltip when shuffle mode is enabled" diff --git a/lib/l10n/arb/app_it.arb b/lib/l10n/arb/app_it.arb index caa524de..82b1d5f2 100644 --- a/lib/l10n/arb/app_it.arb +++ b/lib/l10n/arb/app_it.arb @@ -461,10 +461,6 @@ "@aboutSachinsenalDesc": { "description": "Credit description for sachinsenal0x64" }, - "aboutSjdonadoDesc": "Creatore di \"I Don't Have Spotify (IDHS)\". Il risolutore di link che ci salva la giornata!", - "@aboutSjdonadoDesc": { - "description": "Credit description for sjdonado" - }, "aboutAppDescription": "Cerca i metadati, gestisci le estensioni e organizza la tua libreria.", "@aboutAppDescription": { "description": "App description in header card" diff --git a/lib/l10n/arb/app_ja.arb b/lib/l10n/arb/app_ja.arb index 3cf3cdbd..bdb67b05 100644 --- a/lib/l10n/arb/app_ja.arb +++ b/lib/l10n/arb/app_ja.arb @@ -214,10 +214,6 @@ "@searchSortDateOldest": { "description": "Sort option - oldest release first" }, - "aboutSjdonadoDesc": "Creator of I Don't Have Spotify (IDHS). The fallback link resolver that saves the day!", - "@aboutSjdonadoDesc": { - "description": "Credit description for sjdonado" - }, "nowPlayingShuffleOn": "Shuffle on", "@nowPlayingShuffleOn": { "description": "Tooltip when shuffle mode is enabled" diff --git a/lib/l10n/arb/app_ko.arb b/lib/l10n/arb/app_ko.arb index 5a719c91..1ae0153b 100644 --- a/lib/l10n/arb/app_ko.arb +++ b/lib/l10n/arb/app_ko.arb @@ -461,10 +461,6 @@ "@aboutSachinsenalDesc": { "description": "Credit description for sachinsenal0x64" }, - "aboutSjdonadoDesc": "I Don't Have Spotify(IDHS) 개발자입니다. 위급 상황 발생 시 해결해 주는 대체 링크 해결 도구를 만들었습니다!", - "@aboutSjdonadoDesc": { - "description": "Credit description for sjdonado" - }, "aboutAppDescription": "음악 메타데이터를 검색하고, 확장 기능을 관리하고, 라이브러리를 정리하세요", "@aboutAppDescription": { "description": "App description in header card" diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index d9c5a272..64cfe2c9 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -461,10 +461,6 @@ "@aboutSachinsenalDesc": { "description": "Credit description for sachinsenal0x64" }, - "aboutSjdonadoDesc": "Creator of I Don't Have Spotify (IDHS). The fallback link resolver that saves the day!", - "@aboutSjdonadoDesc": { - "description": "Credit description for sjdonado" - }, "aboutAppDescription": "Search music metadata, manage extensions, and organize your library.", "@aboutAppDescription": { "description": "App description in header card" diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index b98d83b5..54393ebe 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -1646,10 +1646,6 @@ "@aboutSocial": { "description": "Section for social links" }, - "aboutSjdonadoDesc": "Creator of I Don't Have Spotify (IDHS). The fallback link resolver that saves the day!", - "@aboutSjdonadoDesc": { - "description": "Credit description for sjdonado" - }, "artistPopular": "Popular", "@artistPopular": { "description": "Section header for popular/top tracks" diff --git a/lib/l10n/arb/app_pt_PT.arb b/lib/l10n/arb/app_pt_PT.arb index a97d9240..51938ee9 100644 --- a/lib/l10n/arb/app_pt_PT.arb +++ b/lib/l10n/arb/app_pt_PT.arb @@ -214,10 +214,6 @@ "@searchSortDateOldest": { "description": "Sort option - oldest release first" }, - "aboutSjdonadoDesc": "Criador do I Don't Have Spotify (IDHS). O resolvedor de link alternativo que salva o dia!", - "@aboutSjdonadoDesc": { - "description": "Credit description for sjdonado" - }, "nowPlayingShuffleOn": "Shuffle on", "@nowPlayingShuffleOn": { "description": "Tooltip when shuffle mode is enabled" diff --git a/lib/l10n/arb/app_ru.arb b/lib/l10n/arb/app_ru.arb index e4eb8b49..4a981021 100644 --- a/lib/l10n/arb/app_ru.arb +++ b/lib/l10n/arb/app_ru.arb @@ -214,10 +214,6 @@ "@searchSortDateOldest": { "description": "Sort option - oldest release first" }, - "aboutSjdonadoDesc": "Создатель I Don't Have Spotify (IDHS). Резервный резолвер ссылки", - "@aboutSjdonadoDesc": { - "description": "Credit description for sjdonado" - }, "nowPlayingShuffleOn": "Shuffle on", "@nowPlayingShuffleOn": { "description": "Tooltip when shuffle mode is enabled" diff --git a/lib/l10n/arb/app_tr.arb b/lib/l10n/arb/app_tr.arb index 99101741..6b5ffd06 100644 --- a/lib/l10n/arb/app_tr.arb +++ b/lib/l10n/arb/app_tr.arb @@ -214,10 +214,6 @@ "@searchSortDateOldest": { "description": "Sort option - oldest release first" }, - "aboutSjdonadoDesc": "I Don't Have Spotify (IDHS) yaratıcısı. Günü kurtaran yedek bağlantı çözücü!", - "@aboutSjdonadoDesc": { - "description": "Credit description for sjdonado" - }, "nowPlayingShuffleOn": "Shuffle on", "@nowPlayingShuffleOn": { "description": "Tooltip when shuffle mode is enabled" diff --git a/lib/l10n/arb/app_uk.arb b/lib/l10n/arb/app_uk.arb index cabdecd4..84840a1d 100644 --- a/lib/l10n/arb/app_uk.arb +++ b/lib/l10n/arb/app_uk.arb @@ -214,10 +214,6 @@ "@searchSortDateOldest": { "description": "Sort option - oldest release first" }, - "aboutSjdonadoDesc": "Творець I Don't Have Spotify (IDHS). Резервний розв'язувач посилань, який рятує становище!", - "@aboutSjdonadoDesc": { - "description": "Credit description for sjdonado" - }, "nowPlayingShuffleOn": "Shuffle on", "@nowPlayingShuffleOn": { "description": "Tooltip when shuffle mode is enabled" diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index bb9eff23..36f383ba 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -461,10 +461,6 @@ "@aboutSachinsenalDesc": { "description": "Credit description for sachinsenal0x64" }, - "aboutSjdonadoDesc": "I Don't Have Spotify (IDHS) 的创建者。备用链接解析器很有帮助!", - "@aboutSjdonadoDesc": { - "description": "Credit description for sjdonado" - }, "aboutAppDescription": "搜索音乐元数据、管理扩展并整理你的乐库。", "@aboutAppDescription": { "description": "App description in header card" diff --git a/lib/l10n/arb/app_zh_CN.arb b/lib/l10n/arb/app_zh_CN.arb index c9aece6a..71b17308 100644 --- a/lib/l10n/arb/app_zh_CN.arb +++ b/lib/l10n/arb/app_zh_CN.arb @@ -461,10 +461,6 @@ "@aboutSachinsenalDesc": { "description": "Credit description for sachinsenal0x64" }, - "aboutSjdonadoDesc": "I Don't Have Spotify (IDHS) 的创建者。备用链接解析器很有帮助!", - "@aboutSjdonadoDesc": { - "description": "Credit description for sjdonado" - }, "aboutAppDescription": "搜索音乐元数据、管理扩展并整理你的乐库。", "@aboutAppDescription": { "description": "App description in header card" diff --git a/lib/l10n/arb/app_zh_TW.arb b/lib/l10n/arb/app_zh_TW.arb index 210a6341..bd134658 100644 --- a/lib/l10n/arb/app_zh_TW.arb +++ b/lib/l10n/arb/app_zh_TW.arb @@ -461,10 +461,6 @@ "@aboutSachinsenalDesc": { "description": "Credit description for sachinsenal0x64" }, - "aboutSjdonadoDesc": "I Don't Have Spotify (IDHS) 的創建者。他的備用連結解析氣十分有幫助!", - "@aboutSjdonadoDesc": { - "description": "Credit description for sjdonado" - }, "aboutAppDescription": "搜尋音樂元數據、管理擴充功能,並整理各個音樂庫。", "@aboutAppDescription": { "description": "App description in header card" diff --git a/lib/screens/settings/about_page.dart b/lib/screens/settings/about_page.dart index 3889d000..104227ed 100644 --- a/lib/screens/settings/about_page.dart +++ b/lib/screens/settings/about_page.dart @@ -98,12 +98,6 @@ class AboutPage extends StatelessWidget { githubUsername: 'sachinsenal0x64', showDivider: true, ), - _ContributorItem( - name: 'sjdonado', - description: context.l10n.aboutSjdonadoDesc, - githubUsername: 'sjdonado', - showDivider: true, - ), _AboutSettingsItem( icon: Icons.swap_horiz_rounded, title: 'UniTune',