feat(resolver): add cross-platform fallback chain

This commit is contained in:
zarzet
2026-08-30 16:55:16 +07:00
parent e9bb3ae303
commit d0a9868c2d
5 changed files with 812 additions and 18 deletions
+528
View File
@@ -0,0 +1,528 @@
package gobackend
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
const (
resolverFallbackTimeout = 12 * time.Second
resolverResponseLimit = 2 << 20
squiglyPageLimit = 8 << 20
)
type resolverMetadata struct {
Title string
Artist string
}
type resolverResult struct {
Links map[string]songLinkPlatformLink
Metadata resolverMetadata
}
type platformFallbackResolver interface {
Resolve(context.Context, string, resolverMetadata) (resolverResult, error)
}
type platformResolverChain struct {
unitune platformFallbackResolver
musicBrainz platformFallbackResolver
squigly platformFallbackResolver
}
var defaultPlatformResolverFallbacks platformFallbackResolver = &platformResolverChain{
unitune: &unituneResolver{
client: NewMetadataHTTPClient(6 * time.Second),
rateLimiter: NewRateLimiter(30, time.Minute),
},
musicBrainz: &musicBrainzPlatformResolver{
client: NewMetadataHTTPClient(6 * time.Second),
rateLimiter: NewRateLimiter(1, time.Second),
},
squigly: &squiglyResolver{
client: NewMetadataHTTPClient(6 * time.Second),
rateLimiter: NewRateLimiter(18, time.Minute),
},
}
func (c *platformResolverChain) Resolve(
ctx context.Context,
inputURL string,
hint resolverMetadata,
) (resolverResult, error) {
result := resolverResult{Links: make(map[string]songLinkPlatformLink), Metadata: hint}
var resolverErrors []error
resolvers := []struct {
name string
resolver platformFallbackResolver
}{
{name: "UniTune", resolver: c.unitune},
{name: "MusicBrainz", resolver: c.musicBrainz},
{name: "Squigly", resolver: c.squigly},
}
for _, candidate := range resolvers {
if candidate.resolver == nil {
continue
}
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)
continue
}
mergeResolverLinks(result.Links, resolved.Links)
if result.Metadata.Title == "" {
result.Metadata.Title = strings.TrimSpace(resolved.Metadata.Title)
}
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))
if hasUsefulResolverCoverage(result.Links) {
break
}
}
addResolverSourceLink(result.Links, inputURL)
if len(result.Links) > 0 {
return result, nil
}
if len(resolverErrors) == 0 {
return resolverResult{}, fmt.Errorf("no additional resolver was available")
}
return resolverResult{}, errors.Join(resolverErrors...)
}
func mergeResolverLinks(dst, src map[string]songLinkPlatformLink) {
for platform, link := range src {
if _, exists := dst[platform]; exists {
continue
}
if directURL := directResolverURL(platform, link.URL); directURL != "" {
dst[platform] = songLinkPlatformLink{URL: directURL}
}
}
}
func hasUsefulResolverCoverage(links map[string]songLinkPlatformLink) bool {
if len(links) < 4 {
return false
}
downloadProviders := 0
for _, platform := range []string{"deezer", "tidal", "amazonMusic", "qobuz"} {
if link, ok := links[platform]; ok && link.URL != "" {
downloadProviders++
}
}
return downloadProviders >= 2
}
func canonicalResolverPlatform(platform string) string {
normalized := strings.ToLower(strings.NewReplacer("-", "", "_", "", " ", "").Replace(strings.TrimSpace(platform)))
switch normalized {
case "spotify", "deezer", "tidal", "qobuz", "soundcloud", "bandcamp":
return normalized
case "apple", "applemusic":
return "appleMusic"
case "amazon", "amazonmusic":
return "amazonMusic"
case "youtube":
return "youtube"
case "youtubemusic":
return "youtubeMusic"
default:
return ""
}
}
func directResolverURL(platform, value string) string {
platform = canonicalResolverPlatform(platform)
if platform == "" {
return ""
}
parsed, err := url.Parse(strings.TrimSpace(value))
if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" {
return ""
}
host := strings.ToLower(parsed.Hostname())
if strings.Contains(strings.ToLower(parsed.EscapedPath()), "/search") {
return ""
}
hostAllowed := false
switch platform {
case "spotify":
hostAllowed = host == "open.spotify.com"
case "deezer":
hostAllowed = host == "deezer.com" || host == "www.deezer.com"
case "tidal":
hostAllowed = host == "tidal.com" || host == "www.tidal.com" || host == "listen.tidal.com"
case "qobuz":
hostAllowed = host == "open.qobuz.com" || host == "play.qobuz.com" || host == "www.qobuz.com"
case "appleMusic":
hostAllowed = host == "music.apple.com" || host == "geo.music.apple.com"
case "amazonMusic":
hostAllowed = host == "music.amazon.com"
case "youtubeMusic":
hostAllowed = host == "music.youtube.com"
case "youtube":
hostAllowed = host == "youtube.com" || host == "www.youtube.com" || host == "youtu.be"
case "soundcloud":
hostAllowed = host == "soundcloud.com" || host == "www.soundcloud.com" || host == "m.soundcloud.com"
case "bandcamp":
hostAllowed = host == "bandcamp.com" || strings.HasSuffix(host, ".bandcamp.com")
}
if !hostAllowed {
return ""
}
return parsed.String()
}
func addResolverSourceLink(links map[string]songLinkPlatformLink, inputURL string) {
platform := resolverPlatformFromURL(inputURL)
if platform == "" {
return
}
if directURL := directResolverURL(platform, inputURL); directURL != "" {
if _, exists := links[platform]; !exists {
links[platform] = songLinkPlatformLink{URL: directURL}
}
}
}
func readResolverResponse(resp *http.Response, limit int64) ([]byte, error) {
if resp == nil || resp.Body == nil {
return nil, fmt.Errorf("response is empty")
}
body, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
if err != nil {
return nil, err
}
if int64(len(body)) > limit {
return nil, fmt.Errorf("response exceeds %d bytes", limit)
}
if len(body) == 0 {
return nil, fmt.Errorf("response body is empty")
}
return body, nil
}
type unituneResolver struct {
client *http.Client
rateLimiter *RateLimiter
}
func (r *unituneResolver) Resolve(ctx context.Context, inputURL string, _ resolverMetadata) (resolverResult, error) {
if err := r.rateLimiter.WaitForSlotContext(ctx); err != nil {
return resolverResult{}, err
}
endpoint := "https://api.unitune.art/v1-alpha.1/links?url=" + url.QueryEscape(inputURL)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return resolverResult{}, err
}
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("API returned status %d", resp.StatusCode)
}
body, err := readResolverResponse(resp, resolverResponseLimit)
if err != nil {
return resolverResult{}, err
}
var payload struct {
EntityUniqueID string `json:"entityUniqueId"`
Links map[string]songLinkPlatformLink `json:"linksByPlatform"`
Entities map[string]struct {
Title string `json:"title"`
ArtistName string `json:"artistName"`
} `json:"entitiesByUniqueId"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return resolverResult{}, err
}
result := resolverResult{Links: make(map[string]songLinkPlatformLink)}
for platform, link := range payload.Links {
canonical := canonicalResolverPlatform(platform)
if directURL := directResolverURL(canonical, link.URL); directURL != "" {
result.Links[canonical] = songLinkPlatformLink{URL: directURL}
}
}
if entity, ok := payload.Entities[payload.EntityUniqueID]; ok {
result.Metadata = resolverMetadata{Title: entity.Title, Artist: entity.ArtistName}
} else {
for _, entity := range payload.Entities {
result.Metadata = resolverMetadata{Title: entity.Title, Artist: entity.ArtistName}
break
}
}
if len(result.Links) == 0 && result.Metadata.Title == "" {
return resolverResult{}, fmt.Errorf("API returned no direct platform links or metadata")
}
return result, nil
}
type musicBrainzPlatformResolver struct {
client *http.Client
rateLimiter *RateLimiter
}
func (r *musicBrainzPlatformResolver) Resolve(
ctx context.Context,
_ string,
hint resolverMetadata,
) (resolverResult, error) {
title := strings.TrimSpace(hint.Title)
artist := strings.TrimSpace(hint.Artist)
if title == "" || artist == "" {
return resolverResult{}, fmt.Errorf("title and artist metadata are required")
}
query := fmt.Sprintf("recording:\"%s\" AND artist:\"%s\"", escapeMusicBrainzQuery(title), escapeMusicBrainzQuery(artist))
searchURL := musicBrainzAPIBase + "/recording?fmt=json&limit=5&query=" + url.QueryEscape(query)
var search struct {
Recordings []struct {
ID string `json:"id"`
Score int `json:"score"`
Title string `json:"title"`
ArtistCredit []struct {
Name string `json:"name"`
} `json:"artist-credit"`
} `json:"recordings"`
}
if err := r.getJSON(ctx, searchURL, &search); err != nil {
return resolverResult{}, err
}
var recordingID string
for _, candidate := range search.Recordings {
candidateArtist := ""
if len(candidate.ArtistCredit) > 0 {
candidateArtist = candidate.ArtistCredit[0].Name
}
if candidate.Score < 90 || normalizeLooseTitle(candidate.Title) != normalizeLooseTitle(title) || !artistsMatch(artist, candidateArtist) {
continue
}
recordingID = candidate.ID
break
}
if recordingID == "" {
return resolverResult{}, fmt.Errorf("no verified recording match")
}
lookupURL := fmt.Sprintf("%s/recording/%s?fmt=json&inc=url-rels+isrcs", musicBrainzAPIBase, url.PathEscape(recordingID))
var recording struct {
Relations []struct {
URL struct {
Resource string `json:"resource"`
} `json:"url"`
} `json:"relations"`
}
if err := r.getJSON(ctx, lookupURL, &recording); err != nil {
return resolverResult{}, err
}
result := resolverResult{Links: make(map[string]songLinkPlatformLink), Metadata: hint}
for _, relation := range recording.Relations {
platform := resolverPlatformFromURL(relation.URL.Resource)
if directURL := directResolverURL(platform, relation.URL.Resource); directURL != "" {
if _, exists := result.Links[platform]; !exists {
result.Links[platform] = songLinkPlatformLink{URL: directURL}
}
}
}
if len(result.Links) == 0 {
return resolverResult{}, fmt.Errorf("recording has no supported platform relations")
}
return result, nil
}
func (r *musicBrainzPlatformResolver) getJSON(ctx context.Context, endpoint string, payload any) error {
if err := r.rateLimiter.WaitForSlotContext(ctx); err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := r.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("API returned status %d", resp.StatusCode)
}
body, err := readResolverResponse(resp, resolverResponseLimit)
if err != nil {
return err
}
return json.Unmarshal(body, payload)
}
func escapeMusicBrainzQuery(value string) string {
value = strings.ReplaceAll(value, `\`, `\\`)
return strings.ReplaceAll(value, `"`, `\"`)
}
func resolverPlatformFromURL(value string) string {
parsed, err := url.Parse(strings.TrimSpace(value))
if err != nil {
return ""
}
host := strings.ToLower(parsed.Hostname())
switch {
case host == "open.spotify.com":
return "spotify"
case host == "deezer.com" || host == "www.deezer.com":
return "deezer"
case host == "tidal.com" || host == "www.tidal.com" || host == "listen.tidal.com":
return "tidal"
case host == "music.apple.com" || host == "geo.music.apple.com":
return "appleMusic"
case host == "music.amazon.com":
return "amazonMusic"
case host == "music.youtube.com":
return "youtubeMusic"
case host == "youtube.com" || host == "www.youtube.com" || host == "youtu.be":
return "youtube"
case host == "soundcloud.com" || host == "www.soundcloud.com" || host == "m.soundcloud.com":
return "soundcloud"
case host == "open.qobuz.com" || host == "play.qobuz.com" || host == "www.qobuz.com":
return "qobuz"
case host == "bandcamp.com" || strings.HasSuffix(host, ".bandcamp.com"):
return "bandcamp"
default:
return ""
}
}
type squiglyResolver struct {
client *http.Client
rateLimiter *RateLimiter
}
func (r *squiglyResolver) Resolve(ctx context.Context, inputURL string, _ resolverMetadata) (resolverResult, error) {
if err := r.rateLimiter.WaitForSlotContext(ctx); err != nil {
return resolverResult{}, err
}
requestBody, err := json.Marshal(map[string]string{"url": inputURL})
if err != nil {
return resolverResult{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://squigly.link/api/create", bytes.NewReader(requestBody))
if err != nil {
return resolverResult{}, err
}
req.Header.Set("Content-Type", "application/json")
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.StatusCreated && resp.StatusCode != http.StatusOK {
return resolverResult{}, fmt.Errorf("create endpoint returned status %d", resp.StatusCode)
}
body, err := readResolverResponse(resp, resolverResponseLimit)
if err != nil {
return resolverResult{}, err
}
var created struct {
FullURL string `json:"full_url"`
Title string `json:"title"`
Artist string `json:"artist"`
}
if err := json.Unmarshal(body, &created); err != nil {
return resolverResult{}, err
}
parsedPageURL, err := url.Parse(strings.TrimSpace(created.FullURL))
if err != nil || parsedPageURL.Scheme != "https" || parsedPageURL.Hostname() != "squigly.link" {
return resolverResult{}, fmt.Errorf("create endpoint returned an invalid page URL")
}
pageReq, err := http.NewRequestWithContext(ctx, http.MethodGet, parsedPageURL.String(), nil)
if err != nil {
return resolverResult{}, err
}
pageReq.Header.Set("User-Agent", getRandomUserAgent())
pageResp, err := r.client.Do(pageReq)
if err != nil {
return resolverResult{}, err
}
defer pageResp.Body.Close()
if pageResp.StatusCode != http.StatusOK {
return resolverResult{}, fmt.Errorf("result page returned status %d", pageResp.StatusCode)
}
pageBody, err := readResolverResponse(pageResp, squiglyPageLimit)
if err != nil {
return resolverResult{}, err
}
const marker = "window.__SQUIGLY_LINK__ ="
markerIndex := bytes.Index(pageBody, []byte(marker))
if markerIndex < 0 {
return resolverResult{}, fmt.Errorf("result page contains no resolver payload")
}
decoder := json.NewDecoder(bytes.NewReader(pageBody[markerIndex+len(marker):]))
var embedded struct {
Data struct {
Title string `json:"title"`
Artist string `json:"artist"`
Services map[string]*struct {
URL string `json:"url"`
} `json:"services"`
} `json:"data"`
}
if err := decoder.Decode(&embedded); err != nil {
return resolverResult{}, fmt.Errorf("failed to decode result page: %w", err)
}
result := resolverResult{
Links: make(map[string]songLinkPlatformLink),
Metadata: resolverMetadata{
Title: embedded.Data.Title,
Artist: embedded.Data.Artist,
},
}
if result.Metadata.Title == "" {
result.Metadata.Title = created.Title
}
if result.Metadata.Artist == "" {
result.Metadata.Artist = created.Artist
}
for platform, service := range embedded.Data.Services {
if service == nil {
continue
}
canonical := canonicalResolverPlatform(platform)
if directURL := directResolverURL(canonical, service.URL); directURL != "" {
result.Links[canonical] = songLinkPlatformLink{URL: directURL}
}
}
if len(result.Links) == 0 {
return resolverResult{}, fmt.Errorf("result page returned no direct platform links")
}
return result, nil
}
@@ -0,0 +1,208 @@
package gobackend
import (
"context"
"io"
"net/http"
"strings"
"testing"
"time"
)
func resolverTestResponse(req *http.Request, status int, body string) *http.Response {
return &http.Response{
StatusCode: status,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
Request: req,
}
}
func TestUnituneResolverKeepsOnlyDirectTrustedLinks(t *testing.T) {
resolver := &unituneResolver{
client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.URL.Host != "api.unitune.art" || req.URL.Query().Get("url") == "" {
t.Fatalf("unexpected UniTune request: %s", req.URL.String())
}
return resolverTestResponse(req, http.StatusOK, `{
"entityUniqueId":"SPOTIFY::TRACK::source",
"entitiesByUniqueId":{"SPOTIFY::TRACK::source":{"title":"Track","artistName":"Artist"}},
"linksByPlatform":{
"spotify":{"url":"https://open.spotify.com/track/source"},
"deezer":{"url":"https://www.deezer.com/track/123"},
"tidal":{"url":"https://listen.tidal.com/search?q=Track"},
"appleMusic":{"url":"https://music.apple.com/search?term=Track"},
"amazonMusic":{"url":"https://evil.example/track/123"}
}
}`), 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) != 2 || result.Links["deezer"].URL == "" || result.Links["spotify"].URL == "" {
t.Fatalf("direct links = %#v, want only Spotify and Deezer", result.Links)
}
if result.Metadata.Title != "Track" || result.Metadata.Artist != "Artist" {
t.Fatalf("metadata = %+v", result.Metadata)
}
}
func TestMusicBrainzResolverAcceptsVerifiedProviderRelations(t *testing.T) {
resolver := &musicBrainzPlatformResolver{
client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
switch req.URL.Path {
case "/ws/2/recording":
return resolverTestResponse(req, http.StatusOK, `{
"recordings":[
{"id":"wrong","score":100,"title":"Live Version","artist-credit":[{"name":"Artist"}]},
{"id":"match","score":100,"title":"Track","artist-credit":[{"name":"Artist"}]}
]
}`), nil
case "/ws/2/recording/match":
return resolverTestResponse(req, http.StatusOK, `{
"relations":[
{"url":{"resource":"https://open.spotify.com/track/spotify-id"}},
{"url":{"resource":"https://tidal.com/browse/track/123"}},
{"url":{"resource":"https://evil.example/track/not-allowed"}}
]
}`), nil
default:
t.Fatalf("unexpected MusicBrainz request: %s", req.URL.String())
return nil, nil
}
})},
rateLimiter: NewRateLimiter(100, time.Minute),
}
result, err := resolver.Resolve(
context.Background(),
"https://example.invalid/source",
resolverMetadata{Title: "Track", Artist: "Artist"},
)
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if len(result.Links) != 2 || result.Links["spotify"].URL == "" || result.Links["tidal"].URL == "" {
t.Fatalf("MusicBrainz links = %#v", result.Links)
}
}
func TestSquiglyResolverParsesEmbeddedPayload(t *testing.T) {
resolver := &squiglyResolver{
client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case req.Method == http.MethodPost && req.URL.Path == "/api/create":
return resolverTestResponse(req, http.StatusCreated, `{
"full_url":"https://squigly.link/song/artist/track",
"title":"Track",
"artist":"Artist"
}`), nil
case req.Method == http.MethodGet && req.URL.Path == "/song/artist/track":
return resolverTestResponse(req, http.StatusOK, `<html><script>
window.__SQUIGLY_LINK__ = {"data":{"title":"Track","artist":"Artist","services":{
"spotify":{"url":"https://open.spotify.com/track/spotify-id"},
"apple":{"url":"https://music.apple.com/us/album/track/1?i=2"},
"tidal":{"url":"https://tidal.com/browse/track/3"},
"bandcamp":null
}}};
</script></html>`), nil
default:
t.Fatalf("unexpected Squigly request: %s %s", req.Method, req.URL.String())
return nil, 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) != 3 || result.Links["appleMusic"].URL == "" || result.Links["tidal"].URL == "" {
t.Fatalf("Squigly links = %#v", result.Links)
}
}
type stubPlatformResolver struct {
calls int
result resolverResult
err error
}
func (r *stubPlatformResolver) Resolve(context.Context, string, resolverMetadata) (resolverResult, error) {
r.calls++
return r.result, r.err
}
func TestPlatformResolverChainMergesFallbacksWithoutReplacingEarlierLinks(t *testing.T) {
unitune := &stubPlatformResolver{result: resolverResult{
Metadata: resolverMetadata{Title: "Track", Artist: "Artist"},
Links: map[string]songLinkPlatformLink{
"spotify": {URL: "https://open.spotify.com/track/source"},
"deezer": {URL: "https://www.deezer.com/track/1"},
"youtubeMusic": {URL: "https://music.youtube.com/watch?v=one"},
},
}}
musicBrainz := &stubPlatformResolver{result: resolverResult{Links: map[string]songLinkPlatformLink{
"spotify": {URL: "https://open.spotify.com/track/different"},
"appleMusic": {URL: "https://music.apple.com/us/album/track/1?i=2"},
}}}
squigly := &stubPlatformResolver{result: resolverResult{Links: map[string]songLinkPlatformLink{
"tidal": {URL: "https://tidal.com/browse/track/3"},
}}}
chain := &platformResolverChain{unitune: unitune, musicBrainz: musicBrainz, squigly: squigly}
result, err := chain.Resolve(context.Background(), "https://open.spotify.com/track/source", resolverMetadata{})
if err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if unitune.calls != 1 || musicBrainz.calls != 1 || squigly.calls != 1 {
t.Fatalf("resolver calls = %d/%d/%d, want 1/1/1", unitune.calls, musicBrainz.calls, squigly.calls)
}
if result.Links["spotify"].URL != "https://open.spotify.com/track/source" {
t.Fatalf("earlier resolver link was replaced: %#v", result.Links["spotify"])
}
if len(result.Links) != 5 || result.Links["tidal"].URL == "" {
t.Fatalf("merged links = %#v", result.Links)
}
}
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"},
}}}
client := &SongLinkClient{
client: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return resolverTestResponse(req, http.StatusUnauthorized, `{"error":"deprecated"}`), nil
})},
fallbackResolver: additional,
}
links, err := client.resolveTrackPlatformsWithIDHSUncoalesced("https://open.spotify.com/track/source")
if err != nil {
t.Fatalf("resolveTrackPlatformsWithIDHSUncoalesced() error = %v", err)
}
if additional.calls != 1 || links["deezer"].URL == "" {
t.Fatalf("additional resolver calls/links = %d/%#v", additional.calls, links)
}
}
+41 -18
View File
@@ -15,6 +15,7 @@ import (
type SongLinkClient struct {
client *http.Client
fallbackResolver platformFallbackResolver
requestFlight singleflight.Group
resolutionFlight singleflight.Group
availabilityFlight singleflight.Group
@@ -60,7 +61,8 @@ var (
func NewSongLinkClient() *SongLinkClient {
songLinkClientOnce.Do(func() {
globalSongLinkClient = &SongLinkClient{
client: NewMetadataHTTPClient(SongLinkTimeout),
client: NewMetadataHTTPClient(SongLinkTimeout),
fallbackResolver: defaultPlatformResolverFallbacks,
}
})
return globalSongLinkClient
@@ -105,8 +107,9 @@ func (s *SongLinkClient) resolveTrackPlatforms(inputURL string) (map[string]song
}
// resolveTrackPlatformsWithIDHS keeps cross-platform lookups available when
// SongLink is rate-limited or unavailable. IDHS accepts the same source URL and
// returns a smaller but still useful set of verified platform links.
// 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)
@@ -125,26 +128,46 @@ func (s *SongLinkClient) resolveTrackPlatformsWithIDHSUncoalesced(inputURL strin
LogWarn("SongLink", "SongLink failed for %s, trying IDHS fallback: %v", inputURL, songLinkErr)
idhsResult, idhsErr := NewIDHSClient().Search(inputURL, nil)
if idhsErr != nil {
return nil, fmt.Errorf("SongLink failed: %v; IDHS failed: %w", songLinkErr, idhsErr)
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")
}
links = make(map[string]songLinkPlatformLink)
for _, link := range idhsResult.Links {
if link.NotAvailable || strings.TrimSpace(link.URL) == "" {
continue
}
platform := songLinkPlatformKeyFromIDHS(link.Type)
if platform != "" {
links[platform] = songLinkPlatformLink{URL: strings.TrimSpace(link.URL)}
}
LogWarn("SongLink", "IDHS failed for %s, trying additional resolvers: %v", inputURL, idhsErr)
fallbackResolver := s.fallbackResolver
if fallbackResolver == nil {
fallbackResolver = defaultPlatformResolverFallbacks
}
if len(links) == 0 {
return nil, fmt.Errorf("SongLink failed: %v; IDHS returned no platform links", songLinkErr)
ctx, cancel := context.WithTimeout(context.Background(), resolverFallbackTimeout)
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))
return additional.Links, nil
}
if additionalErr == nil {
additionalErr = fmt.Errorf("additional resolvers returned no platform links")
}
LogInfo("SongLink", "IDHS fallback returned %d platform links", len(links))
return links, nil
return nil, fmt.Errorf(
"SongLink failed: %v; IDHS failed: %v; additional resolvers failed: %w",
songLinkErr,
idhsErr,
additionalErr,
)
}
func songLinkPlatformKeyFromIDHS(platform string) string {
+12
View File
@@ -465,6 +465,18 @@
"@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"
},
"aboutMusicBrainzDesc": "Open music database maintained by MetaBrainz and its community, used for recording cross-references.",
"@aboutMusicBrainzDesc": {
"description": "Credit for the MusicBrainz database"
},
"aboutSquiglyDesc": "Universal music link service by Liam Macmillan, used as a final cross-platform resolver fallback.",
"@aboutSquiglyDesc": {
"description": "Credit for the Squigly resolver"
},
"aboutAppDescription": "Search music metadata, manage extensions, and organize your library.",
"@aboutAppDescription": {
"description": "App description in header card"
+23
View File
@@ -104,6 +104,29 @@ class AboutPage extends StatelessWidget {
githubUsername: 'sjdonado',
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.swap_horiz_rounded,
title: 'UniTune',
subtitle: context.l10n.aboutUnituneDesc,
onTap: () => _launchUrl(
'https://github.com/FlazeIGuess/unitune-api',
),
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.library_music_outlined,
title: 'MusicBrainz',
subtitle: context.l10n.aboutMusicBrainzDesc,
onTap: () => _launchUrl('https://musicbrainz.org'),
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.hub_outlined,
title: 'Squigly',
subtitle: context.l10n.aboutSquiglyDesc,
onTap: () => _launchUrl('https://squigly.link'),
showDivider: true,
),
_AboutSettingsItem(
icon: Icons.lyrics_outlined,
title: 'Paxsenix',