fix(lyrics): improve provider fallback and health handling

This commit is contained in:
zarzet
2026-06-27 06:32:37 +07:00
parent f9e68b628d
commit 8558450378
13 changed files with 833 additions and 253 deletions
+23 -11
View File
@@ -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)
+13 -1
View File
@@ -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
View File
@@ -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 {
+15 -4
View File
@@ -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")
}
+1 -7
View File
@@ -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
View File
@@ -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
}
+6 -3
View File
@@ -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 {
+1 -1
View File
@@ -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",
+14 -1
View File
@@ -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")
}
+1 -1
View File
@@ -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)
+131 -1
View File
@@ -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
+1 -1
View File
@@ -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