fix(resolver): retire unavailable resolver endpoints

This commit is contained in:
zarzet
2026-08-30 18:51:20 +07:00
parent d2325dbcbf
commit 892d152e5c
31 changed files with 315 additions and 858 deletions
+1 -1
View File
@@ -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) | | |
---
@@ -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)
@@ -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")
}
}
-178
View File
@@ -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
}
@@ -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 {
+137 -2
View File
@@ -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 == "" {
+65 -28
View File
@@ -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, `<html><body>
<a href="https://open.spotify.com/track/source">Spotify</a>
<a href="https://www.deezer.com/track/101">Deezer</a>
<a href="https://listen.tidal.com/track/202">Tidal</a>
<a href="https://music.amazon.com/tracks/TESTASIN?ref=x&amp;tag=y">Amazon</a>
<a href="https://evil.example/track/ignored">Untrusted</a>
</body></html>`), 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, "&amp;") {
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)
}
}
-3
View File
@@ -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)
+15 -166
View File
@@ -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)
}
+86 -341
View File
@@ -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)
}
}
-4
View File
@@ -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"
-4
View File
@@ -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"
-4
View File
@@ -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"
-4
View File
@@ -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"
-4
View File
@@ -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"
-4
View File
@@ -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"
-4
View File
@@ -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"
-4
View File
@@ -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"
-4
View File
@@ -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"
-4
View File
@@ -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"
-4
View File
@@ -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"
-4
View File
@@ -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"
-4
View File
@@ -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"
-4
View File
@@ -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"
-4
View File
@@ -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"
-4
View File
@@ -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"
-4
View File
@@ -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"
-4
View File
@@ -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"
-4
View File
@@ -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"
-4
View File
@@ -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"
-6
View File
@@ -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',