diff --git a/go_backend/lyrics.go b/go_backend/lyrics.go index 49008593..406f0c85 100644 --- a/go_backend/lyrics.go +++ b/go_backend/lyrics.go @@ -2,6 +2,7 @@ package gobackend import ( "encoding/json" + "errors" "fmt" "math" "net/http" @@ -230,58 +231,21 @@ func markLyricsProviderUnavailable(providerName string, err error) { 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", -} - +// isLyricsProviderUnavailableError reports whether err is a provider/API-level +// failure that should temporarily disable a lyrics source. Providers classify +// their failures with the typed errors in lyrics_errors.go at the point of +// origin; transport failures are handled by isConnectivityFailure. 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 errors.Is(err, errLyricsNotFound) { + return false } - if isConnectivityFailure(err) { + if errors.Is(err, errLyricsServiceUnavailable) { return true } - for _, signal := range lyricsServiceUnavailableSignals { - if strings.Contains(msg, signal) { - return true - } - } - return false + return isConnectivityFailure(err) } func GetLyricsProviderOrder() []string { @@ -486,11 +450,11 @@ func (c *LyricsClient) FetchLyricsWithMetadata(artist, track string) (*LyricsRes defer resp.Body.Close() if resp.StatusCode == 404 { - return nil, fmt.Errorf("lyrics not found") + return nil, lyricsNotFoundErrorf("lyrics not found") } if resp.StatusCode != 200 { - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + return nil, lyricsHTTPStatusError(resp.StatusCode, "unexpected status code: %d", resp.StatusCode) } var lrcResp LRCLibResponse @@ -521,7 +485,7 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec flo defer resp.Body.Close() if resp.StatusCode != 200 { - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + return nil, lyricsHTTPStatusError(resp.StatusCode, "unexpected status code: %d", resp.StatusCode) } var results []LRCLibResponse @@ -530,7 +494,7 @@ func (c *LyricsClient) FetchLyricsFromLRCLibSearch(query string, durationSec flo } if len(results) == 0 { - return nil, fmt.Errorf("no lyrics found") + return nil, lyricsNotFoundErrorf("no lyrics found") } bestMatch := c.findBestMatch(results, durationSec) @@ -1048,7 +1012,7 @@ func (c *LyricsClient) tryLRCLIB(primaryArtist, artistName, trackName, simplifie } } - return nil, fmt.Errorf("LRCLIB: no lyrics found") + return nil, lyricsNotFoundErrorf("LRCLIB: no lyrics found") } func (c *LyricsClient) parseLRCLibResponse(resp *LRCLibResponse) *LyricsResponse { diff --git a/go_backend/lyrics_apple.go b/go_backend/lyrics_apple.go index 00a6991d..db300916 100644 --- a/go_backend/lyrics_apple.go +++ b/go_backend/lyrics_apple.go @@ -157,7 +157,7 @@ func (c *AppleMusicClient) getAppleMusicToken() (string, error) { defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("apple music page returned HTTP %d", resp.StatusCode) + return "", lyricsHTTPStatusError(resp.StatusCode, "apple music page returned HTTP %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) @@ -183,7 +183,7 @@ func (c *AppleMusicClient) getAppleMusicToken() (string, error) { defer jsResp.Body.Close() if jsResp.StatusCode != http.StatusOK { - return "", fmt.Errorf("apple music script returned HTTP %d", jsResp.StatusCode) + return "", lyricsHTTPStatusError(jsResp.StatusCode, "apple music script returned HTTP %d", jsResp.StatusCode) } jsBody, err := io.ReadAll(jsResp.Body) @@ -241,7 +241,7 @@ func (c *AppleMusicClient) searchSongWithToken(token, query string) ([]appleMusi return nil, errAppleMusicUnauthorized } if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("apple music catalog search returned HTTP %d", resp.StatusCode) + return nil, lyricsHTTPStatusError(resp.StatusCode, "apple music catalog search returned HTTP %d", resp.StatusCode) } var searchResp appleMusicCatalogSearchResponse @@ -275,7 +275,7 @@ func (c *AppleMusicClient) searchSongWithToken(token, query string) ([]appleMusi func (c *AppleMusicClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) { query := trackName + " " + artistName if strings.TrimSpace(query) == "" { - return "", fmt.Errorf("empty search query") + return "", lyricsNotFoundErrorf("empty search query") } token, err := c.getAppleMusicToken() @@ -298,7 +298,7 @@ func (c *AppleMusicClient) SearchSong(trackName, artistName string, durationSec best := selectBestAppleMusicSearchResult(searchResp, trackName, artistName, durationSec) if best == nil || strings.TrimSpace(best.ID) == "" { - return "", fmt.Errorf("no songs found on apple music") + return "", lyricsNotFoundErrorf("no songs found on apple music") } return strings.TrimSpace(best.ID), nil @@ -321,7 +321,7 @@ func (c *AppleMusicClient) FetchLyricsByID(songID string) (string, error) { defer resp.Body.Close() if resp.StatusCode != 200 { - return "", fmt.Errorf("apple music lyrics proxy returned HTTP %d", resp.StatusCode) + return "", lyricsHTTPStatusError(resp.StatusCode, "apple music lyrics proxy returned HTTP %d", resp.StatusCode) } bodyBytes, err := io.ReadAll(resp.Body) @@ -454,7 +454,7 @@ func (c *AppleMusicClient) FetchLyrics( return nil, err } if errMsg, isErrorPayload := detectLyricsErrorPayload(rawLyrics); isErrorPayload { - return nil, fmt.Errorf("apple music proxy returned non-lyric payload: %s", errMsg) + return nil, classifyLyricsPayloadError(0, errMsg, "apple music proxy returned non-lyric payload: %s", errMsg) } lrcText, err := formatPaxLyricsToLRC(rawLyrics, multiPersonWordByWord, preserveWordTiming) @@ -487,5 +487,5 @@ func (c *AppleMusicClient) FetchLyrics( }, nil } - return nil, fmt.Errorf("no lyrics found on apple music") + return nil, lyricsNotFoundErrorf("no lyrics found on apple music") } diff --git a/go_backend/lyrics_errors.go b/go_backend/lyrics_errors.go new file mode 100644 index 00000000..c434b6c7 --- /dev/null +++ b/go_backend/lyrics_errors.go @@ -0,0 +1,100 @@ +package gobackend + +import ( + "errors" + "fmt" + "net/http" + "strings" +) + +// Sentinel classifications for lyrics provider failures. Providers wrap their +// errors with the typed constructors below at the point where the failure kind +// is known, so isLyricsProviderUnavailableError can classify with errors.Is +// instead of parsing error text. +var ( + errLyricsNotFound = errors.New("lyrics not found") + errLyricsServiceUnavailable = errors.New("lyrics service unavailable") +) + +// lyricsNotFoundError: the provider responded but has nothing for this track +// (including "nothing to search for" inputs). Never disables the provider. +type lyricsNotFoundError struct{ err error } + +func (e *lyricsNotFoundError) Error() string { return e.err.Error() } +func (e *lyricsNotFoundError) Unwrap() error { return e.err } +func (e *lyricsNotFoundError) Is(target error) bool { return target == errLyricsNotFound } + +func lyricsNotFoundErrorf(format string, args ...any) error { + return &lyricsNotFoundError{err: fmt.Errorf(format, args...)} +} + +// lyricsServiceUnavailableError: a provider/API-level failure that should +// temporarily disable the lyrics source (see markLyricsProviderUnavailable). +type lyricsServiceUnavailableError struct{ err error } + +func (e *lyricsServiceUnavailableError) Error() string { return e.err.Error() } +func (e *lyricsServiceUnavailableError) Unwrap() error { return e.err } +func (e *lyricsServiceUnavailableError) Is(target error) bool { + return target == errLyricsServiceUnavailable +} + +func lyricsServiceUnavailableErrorf(format string, args ...any) error { + return &lyricsServiceUnavailableError{err: fmt.Errorf(format, args...)} +} + +// lyricsHTTPStatusError classifies an upstream HTTP status at the response +// site: 429 and 5xx mean the service is temporarily unusable; anything else +// stays a plain error. +func lyricsHTTPStatusError(statusCode int, format string, args ...any) error { + if statusCode == http.StatusTooManyRequests || statusCode >= 500 { + return lyricsServiceUnavailableErrorf(format, args...) + } + return fmt.Errorf(format, args...) +} + +// Error payloads from third-party lyrics proxies arrive as free text, often +// with HTTP 200 (see detectLyricsErrorPayload). Their meaning can only be +// recognized from the message, so this is the one place lyrics failures are +// still classified by substring. Errors generated by our own code must use +// the typed constructors above instead. +var ( + lyricsPayloadNotFoundSignals = []string{ + "lyrics not found", + "no lyrics found", + "no songs found", + "not found", + } + lyricsPayloadUnavailableSignals = []string{ + "rate limit", + "too many requests", + "operation too frequent", + "操作频繁", + "missing required parameters", + } +) + +func lyricsPayloadIndicatesNotFound(payloadMsg string) bool { + msg := strings.ToLower(payloadMsg) + for _, signal := range lyricsPayloadNotFoundSignals { + if strings.Contains(msg, signal) { + return true + } + } + return false +} + +// classifyLyricsPayloadError builds a provider error from an upstream error +// payload, typed by what the payload message describes. statusCode is the +// HTTP status of the response (0 when it arrived with HTTP 200). +func classifyLyricsPayloadError(statusCode int, payloadMsg string, format string, args ...any) error { + if lyricsPayloadIndicatesNotFound(payloadMsg) { + return lyricsNotFoundErrorf(format, args...) + } + msg := strings.ToLower(payloadMsg) + for _, signal := range lyricsPayloadUnavailableSignals { + if strings.Contains(msg, signal) { + return lyricsServiceUnavailableErrorf(format, args...) + } + } + return lyricsHTTPStatusError(statusCode, format, args...) +} diff --git a/go_backend/lyrics_lyricsplus.go b/go_backend/lyrics_lyricsplus.go index f7937b92..9233b261 100644 --- a/go_backend/lyrics_lyricsplus.go +++ b/go_backend/lyrics_lyricsplus.go @@ -85,7 +85,7 @@ func (c *LyricsPlusClient) FetchLyrics( if lastErr != nil { return nil, lastErr } - return nil, fmt.Errorf("lyricsplus: no lyrics found") + return nil, lyricsNotFoundErrorf("lyricsplus: no lyrics found") } func (c *LyricsPlusClient) fetchFromServer( @@ -132,10 +132,10 @@ func (c *LyricsPlusClient) fetchFromServer( if strings.TrimSpace(isrc) != "" { return c.fetchFromServer(server, trackName, artistName, "", durationSec, multiPersonWordByWord, preserveWordTiming) } - return nil, fmt.Errorf("lyrics not found") + return nil, lyricsNotFoundErrorf("lyrics not found") } if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + return nil, lyricsHTTPStatusError(resp.StatusCode, "HTTP %d", resp.StatusCode) } var payload lyricsPlusResponse diff --git a/go_backend/lyrics_musixmatch.go b/go_backend/lyrics_musixmatch.go index 027188de..bc48fca7 100644 --- a/go_backend/lyrics_musixmatch.go +++ b/go_backend/lyrics_musixmatch.go @@ -25,7 +25,7 @@ func NewMusixmatchClient() *MusixmatchClient { func (c *MusixmatchClient) fetchLyricsPayload(trackName, artistName string, durationSec float64, lyricsType, language string) (string, error) { if strings.TrimSpace(trackName) == "" || strings.TrimSpace(artistName) == "" { - return "", fmt.Errorf("empty track or artist name") + return "", lyricsNotFoundErrorf("empty track or artist name") } params := url.Values{} @@ -62,9 +62,9 @@ func (c *MusixmatchClient) fetchLyricsPayload(trackName, artistName string, dura if resp.StatusCode != 200 { trimmed := strings.TrimSpace(string(body)) if errMsg, isErrorPayload := detectLyricsErrorPayload(trimmed); isErrorPayload { - return "", fmt.Errorf("musixmatch proxy returned HTTP %d: %s", resp.StatusCode, errMsg) + return "", classifyLyricsPayloadError(resp.StatusCode, errMsg, "musixmatch proxy returned HTTP %d: %s", resp.StatusCode, errMsg) } - return "", fmt.Errorf("musixmatch proxy returned HTTP %d", resp.StatusCode) + return "", lyricsHTTPStatusError(resp.StatusCode, "musixmatch proxy returned HTTP %d", resp.StatusCode) } var lrcPayload string @@ -78,7 +78,7 @@ func (c *MusixmatchClient) fetchLyricsPayload(trackName, artistName string, dura trimmed := strings.TrimSpace(string(body)) if errMsg, isErrorPayload := detectLyricsErrorPayload(trimmed); isErrorPayload { - return "", fmt.Errorf("%s", errMsg) + return "", classifyLyricsPayloadError(0, errMsg, "%s", errMsg) } if trimmed != "" && !strings.HasPrefix(trimmed, "{") { return trimmed, nil @@ -119,7 +119,7 @@ func (c *MusixmatchClient) FetchLyricsInLanguage(trackName, artistName string, d }, nil } - return nil, fmt.Errorf("no lyrics found on musixmatch for language %s", lang) + return nil, lyricsNotFoundErrorf("no lyrics found on musixmatch for language %s", lang) } func (c *MusixmatchClient) FetchLyrics(trackName, artistName string, durationSec float64, preferredLanguage string) (*LyricsResponse, error) { @@ -158,5 +158,5 @@ func (c *MusixmatchClient) FetchLyrics(trackName, artistName string, durationSec }, nil } - return nil, fmt.Errorf("no lyrics found on musixmatch") + return nil, lyricsNotFoundErrorf("no lyrics found on musixmatch") } diff --git a/go_backend/lyrics_netease.go b/go_backend/lyrics_netease.go index 2e662755..20bec370 100644 --- a/go_backend/lyrics_netease.go +++ b/go_backend/lyrics_netease.go @@ -55,7 +55,7 @@ func NewNeteaseClient() *NeteaseClient { func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error) { query := trackName + " " + artistName if strings.TrimSpace(query) == "" { - return 0, fmt.Errorf("empty search query") + return 0, lyricsNotFoundErrorf("empty search query") } searchURL := "https://lyrics.paxsenix.org/netease/search" @@ -81,7 +81,7 @@ func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error) defer resp.Body.Close() if resp.StatusCode != 200 { - return 0, fmt.Errorf("netease search returned HTTP %d", resp.StatusCode) + return 0, lyricsHTTPStatusError(resp.StatusCode, "netease search returned HTTP %d", resp.StatusCode) } var searchResp neteaseSearchResponse @@ -97,11 +97,11 @@ func (c *NeteaseClient) SearchSong(trackName, artistName string) (int64, error) if message == "" { message = "unexpected response code" } - return 0, fmt.Errorf("netease search unavailable: code %d: %s", searchResp.Code, message) + return 0, lyricsServiceUnavailableErrorf("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") + return 0, lyricsNotFoundErrorf("no songs found on netease") } return searchResp.Result.Songs[0].ID, nil @@ -131,7 +131,7 @@ func (c *NeteaseClient) FetchLyricsByID(songID int64, includeTranslation, includ defer resp.Body.Close() if resp.StatusCode != 200 { - return "", fmt.Errorf("netease lyrics returned HTTP %d", resp.StatusCode) + return "", lyricsHTTPStatusError(resp.StatusCode, "netease lyrics returned HTTP %d", resp.StatusCode) } var lyricsResp neteaseLyricsResponse @@ -140,7 +140,7 @@ func (c *NeteaseClient) FetchLyricsByID(songID int64, includeTranslation, includ } if lyricsResp.LRC == nil || strings.TrimSpace(lyricsResp.LRC.Lyric) == "" { - return "", fmt.Errorf("no lyrics available on netease") + return "", lyricsNotFoundErrorf("no lyrics available on netease") } lyric := lyricsResp.LRC.Lyric diff --git a/go_backend/lyrics_paxsenix.go b/go_backend/lyrics_paxsenix.go index 1c369e6c..c2c911c8 100644 --- a/go_backend/lyrics_paxsenix.go +++ b/go_backend/lyrics_paxsenix.go @@ -122,18 +122,26 @@ func fetchPaxsenixBody(httpClient *http.Client, endpoint string, params url.Valu return "", fmt.Errorf("failed to read response: %w", err) } + // Any failure of the shared proxy fetch disables the source until the + // cooldown expires, except when the payload says the track has no lyrics. trimmed := strings.TrimSpace(string(body)) if resp.StatusCode != http.StatusOK { if errMsg, isErrorPayload := detectLyricsErrorPayload(trimmed); isErrorPayload { - return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, errMsg) + if lyricsPayloadIndicatesNotFound(errMsg) { + return "", lyricsNotFoundErrorf("HTTP %d: %s", resp.StatusCode, errMsg) + } + return "", lyricsServiceUnavailableErrorf("HTTP %d: %s", resp.StatusCode, errMsg) } - return "", fmt.Errorf("HTTP %d", resp.StatusCode) + return "", lyricsServiceUnavailableErrorf("HTTP %d", resp.StatusCode) } if errMsg, isErrorPayload := detectLyricsErrorPayload(trimmed); isErrorPayload { - return "", fmt.Errorf("%s", errMsg) + if lyricsPayloadIndicatesNotFound(errMsg) { + return "", lyricsNotFoundErrorf("%s", errMsg) + } + return "", lyricsServiceUnavailableErrorf("%s", errMsg) } if trimmed == "" { - return "", fmt.Errorf("empty response") + return "", lyricsServiceUnavailableErrorf("empty response") } return trimmed, nil } @@ -236,7 +244,7 @@ var regexpSpotifyTrackID = regexp.MustCompile(`^[A-Za-z0-9]{22}$`) func (c *SpotifyLyricsClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) { query := strings.TrimSpace(trackName + " " + artistName) if query == "" { - return "", fmt.Errorf("empty search query") + return "", lyricsNotFoundErrorf("empty search query") } params := url.Values{} @@ -252,7 +260,7 @@ func (c *SpotifyLyricsClient) SearchSong(trackName, artistName string, durationS } best := selectBestSpotifyLyricsSearchResult(results, trackName, artistName, durationSec) if best == nil || strings.TrimSpace(best.TrackID) == "" { - return "", fmt.Errorf("no songs found on spotify") + return "", lyricsNotFoundErrorf("no songs found on spotify") } return strings.TrimSpace(best.TrackID), nil } @@ -327,7 +335,7 @@ func (c *DeezerLyricsClient) FetchLyrics(spotifyID, trackName, artistName string if deezerID == "" { spotifyTrackID := normalizeSpotifyLyricsID(spotifyID) if spotifyTrackID == "" { - return nil, fmt.Errorf("deezer provider needs a deezer id or spotify id") + return nil, lyricsNotFoundErrorf("deezer provider needs a deezer id or spotify id") } resolvedID, err := NewSongLinkClient().GetDeezerIDFromSpotify(spotifyTrackID) if err != nil { @@ -344,7 +352,7 @@ func (c *DeezerLyricsClient) FetchLyrics(spotifyID, trackName, artistName string func (c *YouTubeLyricsClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) { query := strings.TrimSpace(trackName + " " + artistName) if query == "" { - return "", fmt.Errorf("empty search query") + return "", lyricsNotFoundErrorf("empty search query") } params := url.Values{} @@ -360,7 +368,7 @@ func (c *YouTubeLyricsClient) SearchSong(trackName, artistName string, durationS } best := selectBestYouTubeLyricsSearchResult(results, trackName, artistName, durationSec) if best == nil || strings.TrimSpace(best.VideoID) == "" { - return "", fmt.Errorf("no songs found on youtube") + return "", lyricsNotFoundErrorf("no songs found on youtube") } return strings.TrimSpace(best.VideoID), nil } @@ -401,7 +409,7 @@ func (c *YouTubeLyricsClient) FetchLyrics(trackName, artistName string, duration func (c *KugouLyricsClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) { query := strings.TrimSpace(trackName + " " + artistName) if query == "" { - return "", fmt.Errorf("empty search query") + return "", lyricsNotFoundErrorf("empty search query") } params := url.Values{} @@ -417,7 +425,7 @@ func (c *KugouLyricsClient) SearchSong(trackName, artistName string, durationSec } best := selectBestKugouLyricsSearchResult(results, trackName, artistName, durationSec) if best == nil || strings.TrimSpace(best.Hash) == "" { - return "", fmt.Errorf("no songs found on kugou") + return "", lyricsNotFoundErrorf("no songs found on kugou") } return strings.TrimSpace(best.Hash), nil } @@ -458,7 +466,7 @@ func (c *KugouLyricsClient) FetchLyrics(trackName, artistName string, durationSe func (c *GeniusLyricsClient) SearchSong(trackName, artistName string, durationSec float64) (string, error) { query := strings.TrimSpace(trackName + " " + artistName) if query == "" { - return "", fmt.Errorf("empty search query") + return "", lyricsNotFoundErrorf("empty search query") } params := url.Values{} @@ -495,7 +503,7 @@ func (c *GeniusLyricsClient) SearchSong(trackName, artistName string, durationSe } if bestURL == "" { - return "", fmt.Errorf("no songs found on genius") + return "", lyricsNotFoundErrorf("no songs found on genius") } return bestURL, nil } diff --git a/go_backend/lyrics_qqmusic.go b/go_backend/lyrics_qqmusic.go index 4f936d14..163f25e5 100644 --- a/go_backend/lyrics_qqmusic.go +++ b/go_backend/lyrics_qqmusic.go @@ -63,7 +63,7 @@ func (c *QQMusicClient) fetchLyricsByMetadata(trackName, artistName string, dura defer resp.Body.Close() if resp.StatusCode != 200 { - return "", fmt.Errorf("qqmusic lyrics proxy returned HTTP %d", resp.StatusCode) + return "", lyricsHTTPStatusError(resp.StatusCode, "qqmusic lyrics proxy returned HTTP %d", resp.StatusCode) } bodyBytes, err := io.ReadAll(resp.Body) @@ -101,7 +101,7 @@ func (c *QQMusicClient) FetchLyrics( return nil, err } if errMsg, isErrorPayload := detectLyricsErrorPayload(rawLyrics); isErrorPayload { - return nil, fmt.Errorf("qqmusic proxy returned non-lyric payload: %s", errMsg) + return nil, classifyLyricsPayloadError(0, errMsg, "qqmusic proxy returned non-lyric payload: %s", errMsg) } lrcText, err := formatQQLyricsMetadataToLRC(rawLyrics, multiPersonWordByWord) @@ -134,5 +134,5 @@ func (c *QQMusicClient) FetchLyrics( }, nil } - return nil, fmt.Errorf("no lyrics found on qqmusic") + return nil, lyricsNotFoundErrorf("no lyrics found on qqmusic") } diff --git a/go_backend/lyrics_supplement_test.go b/go_backend/lyrics_supplement_test.go index 235dcbd4..bcb49437 100644 --- a/go_backend/lyrics_supplement_test.go +++ b/go_backend/lyrics_supplement_test.go @@ -2,6 +2,7 @@ package gobackend import ( "errors" + "fmt" "io" "net/http" "path/filepath" @@ -61,8 +62,23 @@ func TestLyricsCacheParsingAndLRCLibClient(t *testing.T) { 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 !isLyricsProviderUnavailableError(classifyLyricsPayloadError(0, "rate limit", "proxy error: %s", "rate limit")) { + t.Fatal("expected rate-limit payloads to mark provider unavailable") + } + if !isLyricsProviderUnavailableError(fmt.Errorf("spotify search failed: %w", lyricsServiceUnavailableErrorf("HTTP 503"))) { + t.Fatal("expected wrapped unavailable errors to keep their classification") + } + if !isLyricsProviderUnavailableError(lyricsHTTPStatusError(503, "proxy returned HTTP 503")) { + t.Fatal("expected 5xx statuses to mark provider unavailable") + } + if isLyricsProviderUnavailableError(lyricsHTTPStatusError(403, "proxy returned HTTP 403")) { + t.Fatal("4xx statuses other than 429 must not mark provider unavailable") + } + if isLyricsProviderUnavailableError(classifyLyricsPayloadError(500, "lyrics not found", "HTTP 500: lyrics not found")) { + t.Fatal("not-found payloads must never mark provider unavailable") + } + if isLyricsProviderUnavailableError(errors.New("rate limit")) { + t.Fatal("untyped errors must not be classified by message text") } if lrcTimestampToMs("01", "02", "345") != 62345 || msToLRCTimestamp(62340) != "[01:02.34]" { t.Fatal("unexpected LRC timestamp conversion")