mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-03 16:50:40 +02:00
fix(resolver): retire unavailable resolver endpoints
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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&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, "&") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user