From d0a9868c2d7b20636be47d9a1f69f2b39cb75032 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 30 Aug 2026 16:45:28 +0700 Subject: [PATCH] feat(resolver): add cross-platform fallback chain --- go_backend/platform_resolver_fallbacks.go | 528 ++++++++++++++++++ .../platform_resolver_fallbacks_test.go | 208 +++++++ go_backend/songlink.go | 59 +- lib/l10n/arb/app_en.arb | 12 + lib/screens/settings/about_page.dart | 23 + 5 files changed, 812 insertions(+), 18 deletions(-) create mode 100644 go_backend/platform_resolver_fallbacks.go create mode 100644 go_backend/platform_resolver_fallbacks_test.go diff --git a/go_backend/platform_resolver_fallbacks.go b/go_backend/platform_resolver_fallbacks.go new file mode 100644 index 00000000..57541889 --- /dev/null +++ b/go_backend/platform_resolver_fallbacks.go @@ -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 +} diff --git a/go_backend/platform_resolver_fallbacks_test.go b/go_backend/platform_resolver_fallbacks_test.go new file mode 100644 index 00000000..e6f2dac2 --- /dev/null +++ b/go_backend/platform_resolver_fallbacks_test.go @@ -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, ``), 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) + } +} diff --git a/go_backend/songlink.go b/go_backend/songlink.go index fe22f900..ea2d4942 100644 --- a/go_backend/songlink.go +++ b/go_backend/songlink.go @@ -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 { diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 5eb06894..3d49625e 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -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" diff --git a/lib/screens/settings/about_page.dart b/lib/screens/settings/about_page.dart index f55f24ea..3889d000 100644 --- a/lib/screens/settings/about_page.dart +++ b/lib/screens/settings/about_page.dart @@ -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',