From 855845037898427103f7fffd2e428b9e60395817 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sat, 27 Jun 2026 06:32:37 +0700 Subject: [PATCH] fix(lyrics): improve provider fallback and health handling --- go_backend/deezer.go | 34 +- go_backend/extension_health.go | 14 +- .../extension_health_misc_supplement_test.go | 8 + go_backend/httputil.go | 190 +++-- go_backend/httputil_supplement_test.go | 19 +- go_backend/httputil_utls.go | 8 +- go_backend/lyrics.go | 651 ++++++++++++++---- go_backend/lyrics_apple.go | 9 +- go_backend/lyrics_lyricsplus.go | 2 +- go_backend/lyrics_netease.go | 15 +- go_backend/lyrics_paxsenix.go | 2 +- go_backend/lyrics_supplement_test.go | 132 +++- go_backend/output_fd.go | 2 +- 13 files changed, 833 insertions(+), 253 deletions(-) diff --git a/go_backend/deezer.go b/go_backend/deezer.go index 26865723..7fce759e 100644 --- a/go_backend/deezer.go +++ b/go_backend/deezer.go @@ -3,6 +3,7 @@ package gobackend import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -1266,16 +1267,7 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa } lastErr = err - errStr := err.Error() - - isRetryable := strings.Contains(errStr, "timeout") || - strings.Contains(errStr, "connection reset") || - strings.Contains(errStr, "connection refused") || - strings.Contains(errStr, "EOF") || - strings.Contains(errStr, "status 5") || - strings.Contains(errStr, "status 429") - - if !isRetryable { + if !isDeezerRetryableError(err) { return err } @@ -1285,6 +1277,26 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa return fmt.Errorf("all %d attempts failed: %w", deezerMaxRetries+1, lastErr) } +type deezerAPIError struct { + StatusCode int + Body string +} + +func (e *deezerAPIError) Error() string { + return fmt.Sprintf("deezer API returned status %d: %s", e.StatusCode, e.Body) +} + +func isDeezerRetryableError(err error) bool { + if isConnectivityFailure(err) || errors.Is(err, io.ErrUnexpectedEOF) { + return true + } + var apiErr *deezerAPIError + if errors.As(err, &apiErr) { + return apiErr.StatusCode == http.StatusTooManyRequests || apiErr.StatusCode >= http.StatusInternalServerError + } + return false +} + func (c *DeezerClient) doGetJSON(ctx context.Context, endpoint string, dst interface{}) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { @@ -1305,7 +1317,7 @@ func (c *DeezerClient) doGetJSON(ctx context.Context, endpoint string, dst inter } if resp.StatusCode != http.StatusOK { - return fmt.Errorf("deezer API returned status %d: %s", resp.StatusCode, string(body)) + return &deezerAPIError{StatusCode: resp.StatusCode, Body: string(body)} } return json.Unmarshal(body, dst) diff --git a/go_backend/extension_health.go b/go_backend/extension_health.go index 85b44151..c1f60d02 100644 --- a/go_backend/extension_health.go +++ b/go_backend/extension_health.go @@ -16,6 +16,7 @@ const ( extensionHealthDefaultTimeout = 4 * time.Second extensionHealthMaxBodyBytes = 64 * 1024 extensionHealthDefaultCache = 60 * time.Second + extensionHealthUnknownCache = 20 * time.Second ) type ExtensionHealthResult struct { @@ -86,6 +87,9 @@ func CheckExtensionHealthCached(ext *loadedExtension) ExtensionHealthResult { result := CheckExtensionHealth(ext) ttl := extensionHealthCacheTTL(ext.Manifest.ServiceHealth) + if result.Status == "unknown" && ttl > extensionHealthUnknownCache { + ttl = extensionHealthUnknownCache + } extensionHealthCacheMu.Lock() extensionHealthCache[cacheKey] = cachedExtensionHealthResult{ @@ -226,7 +230,11 @@ func runExtensionHealthCheck(manifest *ExtensionManifest, check ExtensionHealthC resp, err := NewMetadataHTTPClient(timeout).Do(req) result.LatencyMs = time.Since(start).Milliseconds() if err != nil { - result.Status = "offline" + if isTransientExtensionHealthError(err) { + result.Status = "unknown" + } else { + result.Status = "offline" + } result.Error = err.Error() return result } @@ -262,6 +270,10 @@ func runExtensionHealthCheck(manifest *ExtensionManifest, check ExtensionHealthC return result } +func isTransientExtensionHealthError(err error) bool { + return isTransientNetworkError(err) +} + func classifyExtensionHealthBody(body []byte, serviceKey string) (string, string) { if len(strings.TrimSpace(string(body))) == 0 { return "online", "" diff --git a/go_backend/extension_health_misc_supplement_test.go b/go_backend/extension_health_misc_supplement_test.go index 5f0093bf..e3c3a4a8 100644 --- a/go_backend/extension_health_misc_supplement_test.go +++ b/go_backend/extension_health_misc_supplement_test.go @@ -1,8 +1,10 @@ package gobackend import ( + "context" "encoding/json" "io" + "net" "net/http" "strings" "testing" @@ -27,6 +29,12 @@ func TestExtensionHealthClassificationAndValidation(t *testing.T) { if !isExtensionHealthAuthRequired(" unauthorized ") { t.Fatal("expected auth required") } + if !isTransientExtensionHealthError(context.DeadlineExceeded) || !isTransientExtensionHealthError(&net.DNSError{IsTimeout: true}) { + t.Fatal("expected timeout health errors to be transient") + } + if isTransientExtensionHealthError(&net.DNSError{IsNotFound: true}) { + t.Fatal("expected non-timeout DNS errors to be non-transient") + } if result := CheckExtensionHealth(nil); result.Status != "offline" { t.Fatalf("nil health = %#v", result) diff --git a/go_backend/httputil.go b/go_backend/httputil.go index 9e1145e8..3169f628 100644 --- a/go_backend/httputil.go +++ b/go_backend/httputil.go @@ -1,7 +1,9 @@ package gobackend import ( + "context" "crypto/tls" + "crypto/x509" "errors" "fmt" "io" @@ -437,101 +439,143 @@ func (e *ISPBlockingError) Error() string { return fmt.Sprintf("ISP blocking detected for %s: %s", e.Domain, e.Reason) } -func IsISPBlocking(err error, requestURL string) *ISPBlockingError { +// isTransientNetworkError reports retryable transport failures such as +// timeouts and temporary DNS errors. Permanent DNS misses are excluded. +func isTransientNetworkError(err error) bool { if err == nil { - return nil + return false + } + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + return true + } + var netErr net.Error + return errors.As(err, &netErr) && (netErr.Timeout() || netErr.Temporary()) +} + +// isConnectivityFailure reports DNS, dial, timeout, TLS, or truncated transport +// errors. Application-level API messages are excluded. +func isConnectivityFailure(err error) bool { + return connectivityFailureReason(err) != "" +} + +func connectivityFailureReason(err error) string { + if err == nil { + return "" + } + if errors.Is(err, context.DeadlineExceeded) { + return "Request timed out - ISP may be throttling" + } + if errors.Is(err, io.ErrUnexpectedEOF) { + return "Connection closed unexpectedly - ISP may be blocking" } - domain := extractDomain(requestURL) - errStr := strings.ToLower(err.Error()) + var urlErr *url.Error + if errors.As(err, &urlErr) { + if urlErr.Timeout() { + return "Connection timed out - ISP may be blocking access" + } + if urlErr.Err != nil { + if reason := connectivityFailureReason(urlErr.Err); reason != "" { + return reason + } + } + } var dnsErr *net.DNSError if errors.As(err, &dnsErr) { - if dnsErr.IsNotFound || dnsErr.IsTemporary { - return &ISPBlockingError{ - Domain: domain, - Reason: "DNS resolution failed - domain may be blocked by ISP", - OriginalErr: err, - } + if dnsErr.IsNotFound || dnsErr.IsTimeout || dnsErr.IsTemporary { + return "DNS resolution failed - domain may be blocked by ISP" } } var opErr *net.OpError if errors.As(err, &opErr) { - if opErr.Op == "dial" { - var syscallErr syscall.Errno - if errors.As(opErr.Err, &syscallErr) { - switch syscallErr { - case syscall.ECONNREFUSED: - return &ISPBlockingError{ - Domain: domain, - Reason: "Connection refused - port may be blocked by ISP/firewall", - OriginalErr: err, - } - case syscall.ECONNRESET: - return &ISPBlockingError{ - Domain: domain, - Reason: "Connection reset - ISP may be intercepting traffic", - OriginalErr: err, - } - case syscall.ETIMEDOUT: - return &ISPBlockingError{ - Domain: domain, - Reason: "Connection timed out - ISP may be blocking access", - OriginalErr: err, - } - case syscall.ENETUNREACH: - return &ISPBlockingError{ - Domain: domain, - Reason: "Network unreachable - ISP may be blocking route", - OriginalErr: err, - } - case syscall.EHOSTUNREACH: - return &ISPBlockingError{ - Domain: domain, - Reason: "Host unreachable - ISP may be blocking destination", - OriginalErr: err, - } - } + if opErr.Timeout() { + return "Connection timed out - ISP may be blocking access" + } + var errno syscall.Errno + if errors.As(opErr.Err, &errno) { + switch errno { + case syscall.ECONNREFUSED: + return "Connection refused - port may be blocked by ISP/firewall" + case syscall.ECONNRESET: + return "Connection reset - ISP may be intercepting traffic" + case syscall.ETIMEDOUT: + return "Connection timed out - ISP may be blocking access" + case syscall.ENETUNREACH: + return "Network unreachable - ISP may be blocking route" + case syscall.EHOSTUNREACH: + return "Host unreachable - ISP may be blocking destination" } } } var tlsErr *tls.RecordHeaderError if errors.As(err, &tlsErr) { - return &ISPBlockingError{ - Domain: domain, - Reason: "TLS handshake failed - ISP may be intercepting HTTPS traffic", - OriginalErr: err, + return "TLS handshake failed - ISP may be intercepting HTTPS traffic" + } + + var certErr x509.CertificateInvalidError + if errors.As(err, &certErr) { + return "Certificate error - ISP may be using MITM proxy" + } + var hostnameErr x509.HostnameError + if errors.As(err, &hostnameErr) { + return "Certificate error - ISP may be using MITM proxy" + } + var unknownAuth x509.UnknownAuthorityError + if errors.As(err, &unknownAuth) { + return "Certificate error - ISP may be using MITM proxy" + } + + return "" +} + +// isTLSHandshakeOrResetError reports TLS handshake/cert failures and TCP resets +// that should trigger a Chrome fingerprint retry. +func isTLSHandshakeOrResetError(err error) bool { + if err == nil { + return false + } + var recordErr *tls.RecordHeaderError + if errors.As(err, &recordErr) { + return true + } + var certErr x509.CertificateInvalidError + if errors.As(err, &certErr) { + return true + } + var hostnameErr x509.HostnameError + if errors.As(err, &hostnameErr) { + return true + } + var unknownAuth x509.UnknownAuthorityError + if errors.As(err, &unknownAuth) { + return true + } + var opErr *net.OpError + if errors.As(err, &opErr) { + var errno syscall.Errno + if errors.As(opErr.Err, &errno) && errno == syscall.ECONNRESET { + return true } } + return false +} - blockingPatterns := []struct { - pattern string - reason string - }{ - {"connection reset by peer", "Connection reset - ISP may be intercepting traffic"}, - {"connection refused", "Connection refused - port may be blocked"}, - {"no such host", "DNS lookup failed - domain may be blocked by ISP"}, - {"i/o timeout", "Connection timed out - ISP may be blocking access"}, - {"network is unreachable", "Network unreachable - ISP may be blocking route"}, - {"tls: ", "TLS error - ISP may be intercepting HTTPS traffic"}, - {"certificate", "Certificate error - ISP may be using MITM proxy"}, - {"eof", "Connection closed unexpectedly - ISP may be blocking"}, - {"context deadline exceeded", "Request timed out - ISP may be throttling"}, +func IsISPBlocking(err error, requestURL string) *ISPBlockingError { + if err == nil { + return nil } - - for _, bp := range blockingPatterns { - if strings.Contains(errStr, bp.pattern) { - return &ISPBlockingError{ - Domain: domain, - Reason: bp.reason, - OriginalErr: err, - } - } + reason := connectivityFailureReason(err) + if reason == "" { + return nil + } + return &ISPBlockingError{ + Domain: extractDomain(requestURL), + Reason: reason, + OriginalErr: err, } - - return nil } func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool { diff --git a/go_backend/httputil_supplement_test.go b/go_backend/httputil_supplement_test.go index 7a8ccaca..8a79d544 100644 --- a/go_backend/httputil_supplement_test.go +++ b/go_backend/httputil_supplement_test.go @@ -1,13 +1,15 @@ package gobackend import ( + "context" "crypto/x509" "encoding/pem" - "errors" "io" + "net" "net/http" "net/url" "strings" + "syscall" "testing" "time" ) @@ -131,15 +133,24 @@ func TestHTTPUtilityHelpers(t *testing.T) { if getRetryAfterDuration(&http.Response{Header: http.Header{"Retry-After": []string{"bad"}}}) != 0 { t.Fatal("invalid retry-after should be zero") } - if isp := IsISPBlocking(errors.New("connection reset by peer"), "https://example.com/x"); isp == nil || !strings.Contains(isp.Error(), "example.com") { + resetErr := &net.OpError{Op: "read", Err: syscall.ECONNRESET} + if isp := IsISPBlocking(resetErr, "https://example.com/x"); isp == nil || !strings.Contains(isp.Error(), "example.com") { t.Fatalf("IsISPBlocking = %#v", isp) } - if !CheckAndLogISPBlocking(errors.New("i/o timeout"), "https://timeout.example/x", "test") { + timeoutErr := &net.OpError{Op: "dial", Err: syscall.ETIMEDOUT} + if !CheckAndLogISPBlocking(timeoutErr, "https://timeout.example/x", "test") { t.Fatal("expected logged ISP blocking") } - if wrapped := WrapErrorWithISPCheck(errors.New("connection refused"), "https://refused.example/x", "test"); wrapped == nil || !strings.Contains(wrapped.Error(), "ISP blocking") { + refusedErr := &net.OpError{Op: "dial", Err: syscall.ECONNREFUSED} + if wrapped := WrapErrorWithISPCheck(refusedErr, "https://refused.example/x", "test"); wrapped == nil || !strings.Contains(wrapped.Error(), "ISP blocking") { t.Fatalf("WrapErrorWithISPCheck = %v", wrapped) } + if !isTransientNetworkError(context.DeadlineExceeded) || isTransientNetworkError(&net.DNSError{IsNotFound: true}) { + t.Fatal("isTransientNetworkError mismatch") + } + if !isConnectivityFailure(&net.DNSError{IsNotFound: true}) || !isConnectivityFailure(context.DeadlineExceeded) { + t.Fatal("isConnectivityFailure mismatch") + } if WrapErrorWithISPCheck(nil, "", "test") != nil { t.Fatal("nil wrap should stay nil") } diff --git a/go_backend/httputil_utls.go b/go_backend/httputil_utls.go index 6c0e0302..2e713ab3 100644 --- a/go_backend/httputil_utls.go +++ b/go_backend/httputil_utls.go @@ -144,13 +144,7 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) { return resp, nil } - errStr := strings.ToLower(err.Error()) - tlsRelated := strings.Contains(errStr, "tls") || - strings.Contains(errStr, "handshake") || - strings.Contains(errStr, "certificate") || - strings.Contains(errStr, "connection reset") - - if tlsRelated { + if isTLSHandshakeOrResetError(err) { LogDebug("HTTP", "TLS error detected, retrying with Chrome TLS fingerprint: %v", err) reqCopy := req.Clone(req.Context()) diff --git a/go_backend/lyrics.go b/go_backend/lyrics.go index a2187651..ffcfb4ad 100644 --- a/go_backend/lyrics.go +++ b/go_backend/lyrics.go @@ -20,6 +20,12 @@ const ( durationToleranceSec = 10.0 ) +const ( + lyricsProviderUnavailableCooldown = 10 * time.Minute + lyricsProviderParallelism = 3 + lyricsProviderPriorityGrace = 1200 * time.Millisecond +) + const ( LyricsProviderLRCLIB = "lrclib" LyricsProviderNetease = "netease" @@ -46,6 +52,33 @@ var ( appVersion string ) +type lyricsProviderHealthEntry struct { + unavailableUntil time.Time + reason string +} + +type lyricsProviderSearchRequest struct { + spotifyID string + trackName string + artistName string + primaryArtist string + simplifiedTrack string + durationSec float64 + fetchOptions LyricsFetchOptions +} + +type lyricsProviderSearchResult struct { + index int + providerName string + lyrics *LyricsResponse + err error +} + +var ( + lyricsProviderHealthMu sync.RWMutex + lyricsProviderHealth = make(map[string]lyricsProviderHealthEntry) +) + func SetAppVersion(version string) { normalized := strings.TrimSpace(version) @@ -99,6 +132,7 @@ func SetLyricsProviderOrder(providers []string) { if len(providers) == 0 { lyricsProviders = nil + clearLyricsProviderHealth() return } @@ -125,9 +159,131 @@ func SetLyricsProviderOrder(providers []string) { } lyricsProviders = valid + clearLyricsProviderHealth() GoLog("[Lyrics] Provider order set to: %v\n", valid) } +func clearLyricsProviderHealth() { + lyricsProviderHealthMu.Lock() + defer lyricsProviderHealthMu.Unlock() + lyricsProviderHealth = make(map[string]lyricsProviderHealthEntry) +} + +func lyricsProviderHealthKey(providerName string) string { + return strings.ToLower(strings.TrimSpace(providerName)) +} + +func shouldSkipLyricsProvider(providerName string) (bool, time.Duration, string) { + key := lyricsProviderHealthKey(providerName) + if key == "" { + return false, 0, "" + } + + now := time.Now() + lyricsProviderHealthMu.RLock() + entry, ok := lyricsProviderHealth[key] + lyricsProviderHealthMu.RUnlock() + if !ok { + return false, 0, "" + } + if !now.Before(entry.unavailableUntil) { + lyricsProviderHealthMu.Lock() + if current, exists := lyricsProviderHealth[key]; exists && !now.Before(current.unavailableUntil) { + delete(lyricsProviderHealth, key) + } + lyricsProviderHealthMu.Unlock() + return false, 0, "" + } + return true, time.Until(entry.unavailableUntil), entry.reason +} + +func markLyricsProviderAvailable(providerName string) { + key := lyricsProviderHealthKey(providerName) + if key == "" { + return + } + lyricsProviderHealthMu.Lock() + delete(lyricsProviderHealth, key) + lyricsProviderHealthMu.Unlock() +} + +func markLyricsProviderUnavailable(providerName string, err error) { + if err == nil || !isLyricsProviderUnavailableError(err) { + return + } + key := lyricsProviderHealthKey(providerName) + if key == "" { + return + } + reason := strings.TrimSpace(err.Error()) + if len(reason) > 160 { + reason = reason[:160] + } + unavailableUntil := time.Now().Add(lyricsProviderUnavailableCooldown) + + lyricsProviderHealthMu.Lock() + lyricsProviderHealth[key] = lyricsProviderHealthEntry{ + unavailableUntil: unavailableUntil, + reason: reason, + } + lyricsProviderHealthMu.Unlock() + GoLog("[Lyrics] Provider %s marked unavailable for %s: %s\n", providerName, lyricsProviderUnavailableCooldown, reason) +} + +var lyricsNotFoundSignals = []string{ + "lyrics not found", + "no lyrics found", + "no songs found", + "not found on", + "empty track", + "empty search query", + "needs a deezer id", +} + +// Provider/API-level failures that should temporarily disable a lyrics source. +// Transport failures are handled by isConnectivityFailure via typed errors. +var lyricsServiceUnavailableSignals = []string{ + "fetch failed", + "missing required parameters", + "request failed", + "request unsuccessful", + "search failed", + "search unavailable", + "rate limit", + "too many requests", + "operation too frequent", + "操作频繁", + "proxy returned http 429", + "proxy returned http 5", + "unexpected status code: 429", + "unexpected status code: 5", + "unexpected response code", + "returned http 429", + "returned http 5", +} + +func isLyricsProviderUnavailableError(err error) bool { + if err == nil { + return false + } + + msg := strings.ToLower(err.Error()) + for _, signal := range lyricsNotFoundSignals { + if strings.Contains(msg, signal) { + return false + } + } + if isConnectivityFailure(err) { + return true + } + for _, signal := range lyricsServiceUnavailableSignals { + if strings.Contains(msg, signal) { + return true + } + } + return false +} + func GetLyricsProviderOrder() []string { lyricsProvidersMu.RLock() defer lyricsProvidersMu.RUnlock() @@ -474,15 +630,22 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st if len(extensionProviders) > 0 { for _, provider := range extensionProviders { + providerName := "extension:" + provider.extension.ID + if skip, remaining, reason := shouldSkipLyricsProvider(providerName); skip { + GoLog("[Lyrics] Skipping unavailable extension lyrics provider %s for %s: %s\n", provider.extension.ID, remaining.Round(time.Second), reason) + continue + } GoLog("[Lyrics] Trying extension lyrics provider: %s\n", provider.extension.ID) lyrics, err := provider.FetchLyrics(trackName, artistName, "", durationSec) if err == nil && isValidResult(lyrics) { GoLog("[Lyrics] Got lyrics from extension: %s\n", provider.extension.ID) + markLyricsProviderAvailable(providerName) globalLyricsCache.Set(artistName, trackName, durationSec, lyrics) return lyrics, nil } if err != nil { GoLog("[Lyrics] Extension %s failed: %v\n", provider.extension.ID, err) + markLyricsProviderUnavailable(providerName, err) } } } @@ -496,175 +659,338 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st providerOrder := GetLyricsProviderOrder() simplifiedTrack := simplifyTrackName(trackName) + request := lyricsProviderSearchRequest{ + spotifyID: spotifyID, + trackName: trackName, + artistName: artistName, + primaryArtist: primaryArtist, + simplifiedTrack: simplifiedTrack, + durationSec: durationSec, + fetchOptions: fetchOptions, + } GoLog("[Lyrics] Searching for: %s - %s (providers: %v)\n", artistName, trackName, providerOrder) - for _, providerName := range providerOrder { - GoLog("[Lyrics] Trying provider: %s\n", providerName) + lyrics, err := fetchBuiltInLyricsProviders(providerOrder, request, c.fetchBuiltInLyricsProvider) + if err == nil && isValidResult(lyrics) { + globalLyricsCache.Set(artistName, trackName, durationSec, lyrics) + return lyrics, nil + } - var lyrics *LyricsResponse - var err error + return nil, fmt.Errorf("lyrics not found from any source") +} - switch providerName { - case LyricsProviderLRCLIB: - lyrics, err = c.tryLRCLIB(primaryArtist, artistName, trackName, simplifiedTrack, durationSec) +func fetchBuiltInLyricsProviders( + providerOrder []string, + request lyricsProviderSearchRequest, + fetchProvider func(string, lyricsProviderSearchRequest) (*LyricsResponse, error, bool), +) (*LyricsResponse, error) { + type providerCandidate struct { + index int + name string + } - case LyricsProviderNetease: - neteaseClient := NewNeteaseClient() - lyrics, err = neteaseClient.FetchLyrics( - trackName, - primaryArtist, - durationSec, - fetchOptions.IncludeTranslationNetease, - fetchOptions.IncludeRomanizationNetease, - ) - if err != nil && primaryArtist != artistName { - lyrics, err = neteaseClient.FetchLyrics( - trackName, - artistName, - durationSec, - fetchOptions.IncludeTranslationNetease, - fetchOptions.IncludeRomanizationNetease, - ) - } - if err != nil && simplifiedTrack != trackName { - lyrics, err = neteaseClient.FetchLyrics( - simplifiedTrack, - primaryArtist, - durationSec, - fetchOptions.IncludeTranslationNetease, - fetchOptions.IncludeRomanizationNetease, - ) - } + candidates := make([]providerCandidate, 0, len(providerOrder)) + results := make(chan lyricsProviderSearchResult, len(providerOrder)) + sem := make(chan struct{}, lyricsProviderParallelism) + var wg sync.WaitGroup - case LyricsProviderMusixmatch: - musixmatchClient := NewMusixmatchClient() - lyrics, err = musixmatchClient.FetchLyrics( - trackName, - primaryArtist, - durationSec, - fetchOptions.MusixmatchLanguage, - ) - if err != nil && primaryArtist != artistName { - lyrics, err = musixmatchClient.FetchLyrics( - trackName, - artistName, - durationSec, - fetchOptions.MusixmatchLanguage, - ) - } + for index, providerName := range providerOrder { + if skip, remaining, reason := shouldSkipLyricsProvider(providerName); skip { + GoLog("[Lyrics] Skipping unavailable provider %s for %s: %s\n", providerName, remaining.Round(time.Second), reason) + continue + } - case LyricsProviderAppleMusic: - appleClient := NewAppleMusicClient() - lyrics, err = appleClient.FetchLyrics(trackName, primaryArtist, durationSec, fetchOptions.MultiPersonWordByWord, fetchOptions.AppleElrcWordSync) - if err != nil && primaryArtist != artistName { - lyrics, err = appleClient.FetchLyrics(trackName, artistName, durationSec, fetchOptions.MultiPersonWordByWord, fetchOptions.AppleElrcWordSync) - } - - case LyricsProviderQQMusic: - qqClient := NewQQMusicClient() - lyrics, err = qqClient.FetchLyrics(trackName, primaryArtist, durationSec, fetchOptions.MultiPersonWordByWord) - if err != nil && primaryArtist != artistName { - lyrics, err = qqClient.FetchLyrics(trackName, artistName, durationSec, fetchOptions.MultiPersonWordByWord) - } - - case LyricsProviderSpotify: - spotifyClient := NewSpotifyLyricsClient() - lyrics, err = spotifyClient.FetchLyrics(spotifyID, trackName, primaryArtist, durationSec) - if err != nil && primaryArtist != artistName { - lyrics, err = spotifyClient.FetchLyrics(spotifyID, trackName, artistName, durationSec) - } - if err != nil && simplifiedTrack != trackName { - lyrics, err = spotifyClient.FetchLyrics("", simplifiedTrack, primaryArtist, durationSec) - } - - case LyricsProviderDeezer: - deezerClient := NewDeezerLyricsClient() - lyrics, err = deezerClient.FetchLyrics(spotifyID, trackName, primaryArtist, durationSec) - if err != nil && primaryArtist != artistName { - lyrics, err = deezerClient.FetchLyrics(spotifyID, trackName, artistName, durationSec) - } - - case LyricsProviderYouTube: - youtubeClient := NewYouTubeLyricsClient() - lyrics, err = youtubeClient.FetchLyrics(trackName, primaryArtist, durationSec) - if err != nil && primaryArtist != artistName { - lyrics, err = youtubeClient.FetchLyrics(trackName, artistName, durationSec) - } - if err != nil && simplifiedTrack != trackName { - lyrics, err = youtubeClient.FetchLyrics(simplifiedTrack, primaryArtist, durationSec) - } - - case LyricsProviderKugou: - kugouClient := NewKugouLyricsClient() - lyrics, err = kugouClient.FetchLyrics(trackName, primaryArtist, durationSec) - if err != nil && primaryArtist != artistName { - lyrics, err = kugouClient.FetchLyrics(trackName, artistName, durationSec) - } - if err != nil && simplifiedTrack != trackName { - lyrics, err = kugouClient.FetchLyrics(simplifiedTrack, primaryArtist, durationSec) - } - - case LyricsProviderGenius: - geniusClient := NewGeniusLyricsClient() - lyrics, err = geniusClient.FetchLyrics(trackName, primaryArtist, durationSec) - if err != nil && primaryArtist != artistName { - lyrics, err = geniusClient.FetchLyrics(trackName, artistName, durationSec) - } - if err != nil && simplifiedTrack != trackName { - lyrics, err = geniusClient.FetchLyrics(simplifiedTrack, primaryArtist, durationSec) - } - - case LyricsProviderLyricsPlus: - lyricsPlusClient := NewLyricsPlusClient() - lyrics, err = lyricsPlusClient.FetchLyrics( - trackName, - primaryArtist, - "", - durationSec, - fetchOptions.MultiPersonWordByWord, - fetchOptions.AppleElrcWordSync, - ) - if err != nil && primaryArtist != artistName { - lyrics, err = lyricsPlusClient.FetchLyrics( - trackName, - artistName, - "", - durationSec, - fetchOptions.MultiPersonWordByWord, - fetchOptions.AppleElrcWordSync, - ) - } - if err != nil && simplifiedTrack != trackName { - lyrics, err = lyricsPlusClient.FetchLyrics( - simplifiedTrack, - primaryArtist, - "", - durationSec, - fetchOptions.MultiPersonWordByWord, - fetchOptions.AppleElrcWordSync, - ) - } - - default: + knownProvider := isKnownBuiltInLyricsProvider(providerName) + if !knownProvider { GoLog("[Lyrics] Unknown provider: %s, skipping\n", providerName) continue } - if err == nil && isValidResult(lyrics) { - GoLog("[Lyrics] Got lyrics from: %s\n", providerName) - globalLyricsCache.Set(artistName, trackName, durationSec, lyrics) - return lyrics, nil + candidate := providerCandidate{index: index, name: providerName} + candidates = append(candidates, candidate) + wg.Add(1) + go func() { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + + GoLog("[Lyrics] Trying provider: %s\n", candidate.name) + lyrics, err, ok := fetchProvider(candidate.name, request) + if !ok { + results <- lyricsProviderSearchResult{index: candidate.index, providerName: candidate.name, err: fmt.Errorf("unknown provider")} + return + } + if err == nil && lyricsHasUsableText(lyrics) { + GoLog("[Lyrics] Got lyrics from: %s\n", candidate.name) + markLyricsProviderAvailable(candidate.name) + } else if err != nil { + GoLog("[Lyrics] Provider %s failed: %v\n", candidate.name, err) + markLyricsProviderUnavailable(candidate.name, err) + } + results <- lyricsProviderSearchResult{index: candidate.index, providerName: candidate.name, lyrics: lyrics, err: err} + }() + } + + if len(candidates) == 0 { + return nil, fmt.Errorf("lyrics not found from any source") + } + + go func() { + wg.Wait() + close(results) + }() + + completed := make(map[int]bool, len(candidates)) + var best *lyricsProviderSearchResult + var lastErr error + var graceTimer *time.Timer + var grace <-chan time.Time + + stopGrace := func() { + if graceTimer != nil { + if !graceTimer.Stop() { + select { + case <-graceTimer.C: + default: + } + } + graceTimer = nil + grace = nil + } + } + defer stopGrace() + + hasPendingEarlier := func(index int) bool { + for _, candidate := range candidates { + if candidate.index >= index { + return false + } + if !completed[candidate.index] { + return true + } + } + return false + } + + for remaining := len(candidates); remaining > 0; { + if best != nil && !hasPendingEarlier(best.index) { + return best.lyrics, nil + } + if best != nil && graceTimer == nil { + graceTimer = time.NewTimer(lyricsProviderPriorityGrace) + grace = graceTimer.C } - if err != nil { - GoLog("[Lyrics] Provider %s failed: %v\n", providerName, err) + select { + case result, ok := <-results: + if !ok { + remaining = 0 + break + } + remaining-- + completed[result.index] = true + if result.err != nil { + lastErr = result.err + } + if lyricsHasUsableText(result.lyrics) && (best == nil || result.index < best.index) { + copied := result + best = &copied + stopGrace() + } + case <-grace: + if best != nil { + GoLog("[Lyrics] Returning provider %s after %s priority grace\n", best.providerName, lyricsProviderPriorityGrace) + return best.lyrics, nil + } } } + if best != nil { + return best.lyrics, nil + } + if lastErr != nil { + return nil, lastErr + } return nil, fmt.Errorf("lyrics not found from any source") } +func isKnownBuiltInLyricsProvider(providerName string) bool { + switch providerName { + case LyricsProviderLRCLIB, + LyricsProviderNetease, + LyricsProviderMusixmatch, + LyricsProviderAppleMusic, + LyricsProviderQQMusic, + LyricsProviderSpotify, + LyricsProviderDeezer, + LyricsProviderYouTube, + LyricsProviderKugou, + LyricsProviderGenius, + LyricsProviderLyricsPlus: + return true + default: + return false + } +} + +func (c *LyricsClient) fetchBuiltInLyricsProvider(providerName string, request lyricsProviderSearchRequest) (*LyricsResponse, error, bool) { + switch providerName { + case LyricsProviderLRCLIB: + lyrics, err := c.tryLRCLIB(request.primaryArtist, request.artistName, request.trackName, request.simplifiedTrack, request.durationSec) + return lyrics, err, true + + case LyricsProviderNetease: + neteaseClient := NewNeteaseClient() + lyrics, err := neteaseClient.FetchLyrics( + request.trackName, + request.primaryArtist, + request.durationSec, + request.fetchOptions.IncludeTranslationNetease, + request.fetchOptions.IncludeRomanizationNetease, + ) + if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName { + lyrics, err = neteaseClient.FetchLyrics( + request.trackName, + request.artistName, + request.durationSec, + request.fetchOptions.IncludeTranslationNetease, + request.fetchOptions.IncludeRomanizationNetease, + ) + } + if err != nil && !isLyricsProviderUnavailableError(err) && request.simplifiedTrack != request.trackName { + lyrics, err = neteaseClient.FetchLyrics( + request.simplifiedTrack, + request.primaryArtist, + request.durationSec, + request.fetchOptions.IncludeTranslationNetease, + request.fetchOptions.IncludeRomanizationNetease, + ) + } + return lyrics, err, true + + case LyricsProviderMusixmatch: + musixmatchClient := NewMusixmatchClient() + lyrics, err := musixmatchClient.FetchLyrics( + request.trackName, + request.primaryArtist, + request.durationSec, + request.fetchOptions.MusixmatchLanguage, + ) + if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName { + lyrics, err = musixmatchClient.FetchLyrics( + request.trackName, + request.artistName, + request.durationSec, + request.fetchOptions.MusixmatchLanguage, + ) + } + return lyrics, err, true + + case LyricsProviderAppleMusic: + appleClient := NewAppleMusicClient() + lyrics, err := appleClient.FetchLyrics(request.trackName, request.primaryArtist, request.durationSec, request.fetchOptions.MultiPersonWordByWord, request.fetchOptions.AppleElrcWordSync) + if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName { + lyrics, err = appleClient.FetchLyrics(request.trackName, request.artistName, request.durationSec, request.fetchOptions.MultiPersonWordByWord, request.fetchOptions.AppleElrcWordSync) + } + return lyrics, err, true + + case LyricsProviderQQMusic: + qqClient := NewQQMusicClient() + lyrics, err := qqClient.FetchLyrics(request.trackName, request.primaryArtist, request.durationSec, request.fetchOptions.MultiPersonWordByWord) + if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName { + lyrics, err = qqClient.FetchLyrics(request.trackName, request.artistName, request.durationSec, request.fetchOptions.MultiPersonWordByWord) + } + return lyrics, err, true + + case LyricsProviderSpotify: + spotifyClient := NewSpotifyLyricsClient() + lyrics, err := spotifyClient.FetchLyrics(request.spotifyID, request.trackName, request.primaryArtist, request.durationSec) + if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName { + lyrics, err = spotifyClient.FetchLyrics(request.spotifyID, request.trackName, request.artistName, request.durationSec) + } + if err != nil && !isLyricsProviderUnavailableError(err) && request.simplifiedTrack != request.trackName { + lyrics, err = spotifyClient.FetchLyrics("", request.simplifiedTrack, request.primaryArtist, request.durationSec) + } + return lyrics, err, true + + case LyricsProviderDeezer: + deezerClient := NewDeezerLyricsClient() + lyrics, err := deezerClient.FetchLyrics(request.spotifyID, request.trackName, request.primaryArtist, request.durationSec) + if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName { + lyrics, err = deezerClient.FetchLyrics(request.spotifyID, request.trackName, request.artistName, request.durationSec) + } + return lyrics, err, true + + case LyricsProviderYouTube: + youtubeClient := NewYouTubeLyricsClient() + lyrics, err := youtubeClient.FetchLyrics(request.trackName, request.primaryArtist, request.durationSec) + if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName { + lyrics, err = youtubeClient.FetchLyrics(request.trackName, request.artistName, request.durationSec) + } + if err != nil && !isLyricsProviderUnavailableError(err) && request.simplifiedTrack != request.trackName { + lyrics, err = youtubeClient.FetchLyrics(request.simplifiedTrack, request.primaryArtist, request.durationSec) + } + return lyrics, err, true + + case LyricsProviderKugou: + kugouClient := NewKugouLyricsClient() + lyrics, err := kugouClient.FetchLyrics(request.trackName, request.primaryArtist, request.durationSec) + if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName { + lyrics, err = kugouClient.FetchLyrics(request.trackName, request.artistName, request.durationSec) + } + if err != nil && !isLyricsProviderUnavailableError(err) && request.simplifiedTrack != request.trackName { + lyrics, err = kugouClient.FetchLyrics(request.simplifiedTrack, request.primaryArtist, request.durationSec) + } + return lyrics, err, true + + case LyricsProviderGenius: + geniusClient := NewGeniusLyricsClient() + lyrics, err := geniusClient.FetchLyrics(request.trackName, request.primaryArtist, request.durationSec) + if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName { + lyrics, err = geniusClient.FetchLyrics(request.trackName, request.artistName, request.durationSec) + } + if err != nil && !isLyricsProviderUnavailableError(err) && request.simplifiedTrack != request.trackName { + lyrics, err = geniusClient.FetchLyrics(request.simplifiedTrack, request.primaryArtist, request.durationSec) + } + return lyrics, err, true + + case LyricsProviderLyricsPlus: + lyricsPlusClient := NewLyricsPlusClient() + lyrics, err := lyricsPlusClient.FetchLyrics( + request.trackName, + request.primaryArtist, + "", + request.durationSec, + request.fetchOptions.MultiPersonWordByWord, + request.fetchOptions.AppleElrcWordSync, + ) + if err != nil && !isLyricsProviderUnavailableError(err) && request.primaryArtist != request.artistName { + lyrics, err = lyricsPlusClient.FetchLyrics( + request.trackName, + request.artistName, + "", + request.durationSec, + request.fetchOptions.MultiPersonWordByWord, + request.fetchOptions.AppleElrcWordSync, + ) + } + if err != nil && !isLyricsProviderUnavailableError(err) && request.simplifiedTrack != request.trackName { + lyrics, err = lyricsPlusClient.FetchLyrics( + request.simplifiedTrack, + request.primaryArtist, + "", + request.durationSec, + request.fetchOptions.MultiPersonWordByWord, + request.fetchOptions.AppleElrcWordSync, + ) + } + return lyrics, err, true + default: + return nil, fmt.Errorf("unknown provider: %s", providerName), false + } +} + func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifiedTrack string, durationSec float64) (*LyricsResponse, error) { var lyrics *LyricsResponse var err error @@ -674,6 +1000,9 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie lyrics.Source = "LRCLIB" return lyrics, nil } + if isLyricsProviderUnavailableError(err) { + return nil, err + } if primaryArtist != artistName { lyrics, err = c.FetchLyricsWithMetadata(artistName, trackName) @@ -681,6 +1010,9 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie lyrics.Source = "LRCLIB" return lyrics, nil } + if isLyricsProviderUnavailableError(err) { + return nil, err + } } if simplifiedTrack != trackName { @@ -689,6 +1021,9 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie lyrics.Source = "LRCLIB (simplified)" return lyrics, nil } + if isLyricsProviderUnavailableError(err) { + return nil, err + } } query := primaryArtist + " " + trackName @@ -697,6 +1032,9 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie lyrics.Source = "LRCLIB Search" return lyrics, nil } + if isLyricsProviderUnavailableError(err) { + return nil, err + } if simplifiedTrack != trackName { query = primaryArtist + " " + simplifiedTrack @@ -705,6 +1043,9 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie lyrics.Source = "LRCLIB Search (simplified)" return lyrics, nil } + if isLyricsProviderUnavailableError(err) { + return nil, err + } } return nil, fmt.Errorf("LRCLIB: no lyrics found") @@ -848,6 +1189,18 @@ func detectLyricsErrorPayload(raw string) (string, bool) { if success, ok := payload["success"].(bool); ok && !success && !hasLyricsKey { return "request unsuccessful", true } + if isError, ok := payload["isError"].(bool); ok && isError && !hasLyricsKey { + return "request unsuccessful", true + } + if code, ok := payload["code"].(float64); ok && code != 0 && code != 200 && !hasLyricsKey { + if msg, ok := payload["message"].(string); ok && strings.TrimSpace(msg) != "" { + return strings.TrimSpace(msg), true + } + if msg, ok := payload["msg"].(string); ok && strings.TrimSpace(msg) != "" { + return strings.TrimSpace(msg), true + } + return fmt.Sprintf("unexpected response code %.0f", code), true + } return "", false } diff --git a/go_backend/lyrics_apple.go b/go_backend/lyrics_apple.go index 9e9decf5..00a6991d 100644 --- a/go_backend/lyrics_apple.go +++ b/go_backend/lyrics_apple.go @@ -2,6 +2,7 @@ package gobackend import ( "encoding/json" + "errors" "fmt" "io" "math" @@ -13,6 +14,8 @@ import ( "time" ) +var errAppleMusicUnauthorized = errors.New("apple music catalog search unauthorized") + type AppleMusicClient struct { httpClient *http.Client } @@ -188,7 +191,7 @@ func (c *AppleMusicClient) getAppleMusicToken() (string, error) { return "", fmt.Errorf("failed to read apple music script: %w", err) } - token := regexp.MustCompile(`eyJh[^"' <]+`).FindString(string(jsBody)) + token := regexp.MustCompile(`eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+`).FindString(string(jsBody)) if token == "" { return "", fmt.Errorf("apple music token not found") } @@ -235,7 +238,7 @@ func (c *AppleMusicClient) searchSongWithToken(token, query string) ([]appleMusi defer resp.Body.Close() if resp.StatusCode == http.StatusUnauthorized { - return nil, fmt.Errorf("apple music catalog search unauthorized") + return nil, errAppleMusicUnauthorized } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("apple music catalog search returned HTTP %d", resp.StatusCode) @@ -281,7 +284,7 @@ func (c *AppleMusicClient) SearchSong(trackName, artistName string, durationSec } searchResp, err := c.searchSongWithToken(token, strings.TrimSpace(query)) - if err != nil && strings.Contains(strings.ToLower(err.Error()), "unauthorized") { + if errors.Is(err, errAppleMusicUnauthorized) { clearAppleMusicToken() token, tokenErr := c.getAppleMusicToken() if tokenErr != nil { diff --git a/go_backend/lyrics_lyricsplus.go b/go_backend/lyrics_lyricsplus.go index 1987726c..ff39f893 100644 --- a/go_backend/lyrics_lyricsplus.go +++ b/go_backend/lyrics_lyricsplus.go @@ -24,9 +24,9 @@ import ( // Public LyricsPlus / KPOE servers (mirrors). Tried in order with failover. // Sourced from the upstream YouLy+ client server list. var lyricsPlusServers = []string{ + "https://lyricsplus.binimum.org", "https://lyricsplus.prjktla.my.id", "https://lyricsplus.atomix.one", - "https://lyricsplus.binimum.org", "https://lyricsplus.prjktla.workers.dev", "https://lyricsplus-seven.vercel.app", "https://lyrics-plus-backend.vercel.app", diff --git a/go_backend/lyrics_netease.go b/go_backend/lyrics_netease.go index 827ce057..2e662755 100644 --- a/go_backend/lyrics_netease.go +++ b/go_backend/lyrics_netease.go @@ -24,7 +24,9 @@ type neteaseSearchResponse struct { } `json:"songs"` SongCount int `json:"songCount"` } `json:"result"` - Code int `json:"code"` + Code int `json:"code"` + Message string `json:"message"` + Msg string `json:"msg"` } type neteaseLyricsResponse struct { @@ -87,6 +89,17 @@ func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error) return 0, fmt.Errorf("failed to decode netease search: %w", err) } + if searchResp.Code != 0 && searchResp.Code != 200 { + message := strings.TrimSpace(searchResp.Message) + if message == "" { + message = strings.TrimSpace(searchResp.Msg) + } + if message == "" { + message = "unexpected response code" + } + return 0, fmt.Errorf("netease search unavailable: code %d: %s", searchResp.Code, message) + } + if searchResp.Result.SongCount == 0 || len(searchResp.Result.Songs) == 0 { return 0, fmt.Errorf("no songs found on netease") } diff --git a/go_backend/lyrics_paxsenix.go b/go_backend/lyrics_paxsenix.go index f9612386..1c369e6c 100644 --- a/go_backend/lyrics_paxsenix.go +++ b/go_backend/lyrics_paxsenix.go @@ -463,7 +463,7 @@ func (c *GeniusLyricsClient) SearchSong(trackName, artistName string, durationSe params := url.Values{} params.Set("q", query) - params.Set("per_page", "10") + params.Set("per_page", "5") raw, err := fetchPaxsenixBody(c.httpClient, "https://genius.com/api/search/multi", params) if err != nil { return "", fmt.Errorf("genius search failed: %w", err) diff --git a/go_backend/lyrics_supplement_test.go b/go_backend/lyrics_supplement_test.go index ddc56f2f..95aac2f4 100644 --- a/go_backend/lyrics_supplement_test.go +++ b/go_backend/lyrics_supplement_test.go @@ -1,6 +1,7 @@ package gobackend import ( + "errors" "io" "net/http" "path/filepath" @@ -54,6 +55,15 @@ func TestLyricsCacheParsingAndLRCLibClient(t *testing.T) { if msg, ok := detectLyricsErrorPayload(`{"success":false,"message":"nope"}`); !ok || msg != "nope" { t.Fatalf("error payload = %q/%v", msg, ok) } + if msg, ok := detectLyricsErrorPayload(`{"isError":true,"error":"Missing required parameters"}`); !ok || msg != "Missing required parameters" { + t.Fatalf("isError payload = %q/%v", msg, ok) + } + if msg, ok := detectLyricsErrorPayload(`{"code":405,"message":"rate limited"}`); !ok || msg != "rate limited" { + t.Fatalf("coded error payload = %q/%v", msg, ok) + } + if !isLyricsProviderUnavailableError(errors.New("rate limit")) { + t.Fatal("expected rate-limit errors to mark provider unavailable") + } if lrcTimestampToMs("01", "02", "345") != 62345 || msToLRCTimestamp(62340) != "[01:02.34]" { t.Fatal("unexpected LRC timestamp conversion") } @@ -130,9 +140,120 @@ func TestLyricsCacheParsingAndLRCLibClient(t *testing.T) { } } +func TestLyricsProviderHealthSkipsUnavailableProvider(t *testing.T) { + SetLyricsProviderOrder([]string{LyricsProviderLRCLIB}) + defer SetLyricsProviderOrder(nil) + globalLyricsCache.ClearAll() + clearLyricsProviderHealth() + defer clearLyricsProviderHealth() + + calls := 0 + downClient := &LyricsClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + calls++ + return &http.Response{StatusCode: 503, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`service unavailable`)), Request: req}, nil + })}} + + if lyrics, err := downClient.FetchLyricsAllSources("", "Down Song", "Artist", 180); err == nil || lyrics != nil { + t.Fatalf("expected unavailable provider error, got %#v/%v", lyrics, err) + } + if calls != 1 { + t.Fatalf("expected one HTTP call before cooldown, got %d", calls) + } + if skip, _, _ := shouldSkipLyricsProvider(LyricsProviderLRCLIB); !skip { + t.Fatal("expected LRCLIB to be marked unavailable") + } + if lyrics, err := downClient.FetchLyricsAllSources("", "Another Song", "Artist", 180); err == nil || lyrics != nil { + t.Fatalf("expected skipped provider error, got %#v/%v", lyrics, err) + } + if calls != 1 { + t.Fatalf("provider was called while in cooldown, calls=%d", calls) + } + + clearLyricsProviderHealth() + globalLyricsCache.ClearAll() + notFoundCalls := 0 + notFoundClient := &LyricsClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + notFoundCalls++ + switch req.URL.Path { + case "/api/get": + return &http.Response{StatusCode: 404, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil + case "/api/search": + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`[]`)), Request: req}, nil + default: + return &http.Response{StatusCode: 404, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{}`)), Request: req}, nil + } + })}} + + if lyrics, err := notFoundClient.FetchLyricsAllSources("", "missing song", "Artist", 180); err == nil || lyrics != nil { + t.Fatalf("expected not found error, got %#v/%v", lyrics, err) + } + if skip, _, _ := shouldSkipLyricsProvider(LyricsProviderLRCLIB); skip { + t.Fatal("not-found result must not mark provider unavailable") + } + if lyrics, err := notFoundClient.FetchLyricsAllSources("", "missing song 2", "Artist", 180); err == nil || lyrics != nil { + t.Fatalf("expected second not found error, got %#v/%v", lyrics, err) + } + if notFoundCalls != 4 { + t.Fatalf("expected not-found provider to be retried, calls=%d", notFoundCalls) + } +} + +func TestConcurrentLyricsProvidersReturnFastFallback(t *testing.T) { + clearLyricsProviderHealth() + defer clearLyricsProviderHealth() + + start := time.Now() + lyrics, err := fetchBuiltInLyricsProviders( + []string{LyricsProviderLRCLIB, LyricsProviderAppleMusic}, + lyricsProviderSearchRequest{}, + func(providerName string, _ lyricsProviderSearchRequest) (*LyricsResponse, error, bool) { + if providerName == LyricsProviderLRCLIB { + time.Sleep(lyricsProviderPriorityGrace + 800*time.Millisecond) + return &LyricsResponse{Provider: "LRCLIB", PlainLyrics: "slow"}, nil, true + } + return &LyricsResponse{Provider: "Apple Music", PlainLyrics: "fast"}, nil, true + }, + ) + if err != nil { + t.Fatalf("concurrent providers returned error: %v", err) + } + if lyrics == nil || lyrics.Provider != "Apple Music" { + t.Fatalf("expected fast fallback lyrics, got %#v", lyrics) + } + if elapsed := time.Since(start); elapsed >= lyricsProviderPriorityGrace+700*time.Millisecond { + t.Fatalf("fallback waited too long: %s", elapsed) + } +} + +func TestConcurrentLyricsProvidersPreferEarlierProviderWithinGrace(t *testing.T) { + clearLyricsProviderHealth() + defer clearLyricsProviderHealth() + + lyrics, err := fetchBuiltInLyricsProviders( + []string{LyricsProviderLRCLIB, LyricsProviderAppleMusic}, + lyricsProviderSearchRequest{}, + func(providerName string, _ lyricsProviderSearchRequest) (*LyricsResponse, error, bool) { + if providerName == LyricsProviderLRCLIB { + time.Sleep(50 * time.Millisecond) + return &LyricsResponse{Provider: "LRCLIB", PlainLyrics: "preferred"}, nil, true + } + return &LyricsResponse{Provider: "Apple Music", PlainLyrics: "fast"}, nil, true + }, + ) + if err != nil { + t.Fatalf("concurrent providers returned error: %v", err) + } + if lyrics == nil || lyrics.Provider != "LRCLIB" { + t.Fatalf("expected preferred provider lyrics, got %#v", lyrics) + } +} + func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) { clearAppleMusicToken() defer clearAppleMusicToken() + if len(lyricsPlusServers) == 0 || lyricsPlusServers[0] != "https://lyricsplus.binimum.org" { + t.Fatalf("unexpected LyricsPlus server order = %#v", lyricsPlusServers) + } paxJSON := `{"type":"Syllable","content":[{"timestamp":1000,"oppositeTurn":true,"background":true,"text":[{"text":"Hel","part":true,"timestamp":1000},{"text":"lo","part":false,"timestamp":1200,"endtime":1500}],"backgroundText":[{"text":"bg","part":false,"timestamp":900}]}]}` apple := &AppleMusicClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { @@ -140,7 +261,7 @@ func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) { case req.URL.Host == "beta.music.apple.com" && (req.URL.Path == "" || req.URL.Path == "/"): return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(``)), Request: req}, nil case req.URL.Host == "beta.music.apple.com" && req.URL.Path == "/assets/index~test.js": - return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`const token="eyJhbGci.test";`)), Request: req}, nil + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`const token="eyJ0eXAiOiJKV1Q.eyJpc3MiOiJ0ZXN0.c2ln";`)), Request: req}, nil case req.URL.Host == "amp-api.music.apple.com" && strings.Contains(req.URL.Path, "/v1/catalog/us/search"): return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"results":{"songs":{"data":[{"id":"apple-2"},{"id":"apple-1"}]}},"resources":{"songs":{"apple-2":{"attributes":{"name":"Other","artistName":"Other","durationInMillis":1000}},"apple-1":{"attributes":{"name":"Song","artistName":"Artist","albumName":"Album","durationInMillis":180000}}}}}`)), Request: req}, nil case strings.Contains(req.URL.Path, "/apple-music/lyrics"): @@ -236,6 +357,12 @@ func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) { if _, err := netease.SearchSong("", ""); err == nil { t.Fatal("expected empty netease search error") } + rateLimitedNetease := &NeteaseClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"msg":"操作频繁,请稍候再试","code":405,"message":"操作频繁,请稍候再试"}`)), Request: req}, nil + })}} + if _, err := rateLimitedNetease.SearchSong("Song", "Artist"); err == nil || !isLyricsProviderUnavailableError(err) { + t.Fatalf("expected unavailable netease rate-limit error, got %v", err) + } qq := &QQMusicClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { if req.Method != http.MethodPost { @@ -311,6 +438,9 @@ func TestExternalLyricsProvidersWithFakeHTTP(t *testing.T) { genius := &GeniusLyricsClient{httpClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { switch { case strings.Contains(req.URL.Path, "/api/search/multi"): + if got := req.URL.Query().Get("per_page"); got != "5" { + t.Fatalf("genius per_page = %q", got) + } return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"response":{"sections":[{"hits":[{"type":"song","result":{"title":"Song","primary_artist_names":"Artist","url":"https://genius.com/artist-song-lyrics"}}]}]}}`)), Request: req}, nil case strings.Contains(req.URL.Path, "/genius/lyrics"): return &http.Response{StatusCode: 200, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(`{"error":false,"lyrics":"Genius line"}`)), Request: req}, nil diff --git a/go_backend/output_fd.go b/go_backend/output_fd.go index d5519dce..9ec3b135 100644 --- a/go_backend/output_fd.go +++ b/go_backend/output_fd.go @@ -35,7 +35,7 @@ func openOutputForWrite(outputPath string, outputFD int) (*os.File, error) { if err == nil { return file, nil } - if strings.Contains(strings.ToLower(err.Error()), "permission denied") { + if os.IsPermission(err) { return os.OpenFile(path, os.O_WRONLY, 0) } return nil, err