mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-02 11:05:38 +02:00
fix(lyrics): improve provider fallback and health handling
This commit is contained in:
+23
-11
@@ -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)
|
||||
|
||||
@@ -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", ""
|
||||
|
||||
@@ -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)
|
||||
|
||||
+117
-73
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
+502
-149
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(`<script src="/assets/index~test.js"></script>`)), 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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user