mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-02 16:20:57 +02:00
feat(resolver): add cross-platform fallback chain
This commit is contained in:
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user