refactor(lyrics): classify provider errors by type, not message text

Replace the substring signal lists in isLyricsProviderUnavailableError
with typed errors (errLyricsNotFound / errLyricsServiceUnavailable in
the new lyrics_errors.go). Providers now classify failures at the point
of origin: HTTP statuses via lyricsHTTPStatusError (429/5xx cooldown),
netease API codes and paxsenix proxy failures as explicit unavailable,
and every "no lyrics/songs found" path as typed not-found.

Substring matching survives only for error payloads from third-party
proxies (rate limit, missing parameters), whose messages genuinely
arrive as free text.

Two deliberate behavior changes: LyricsPlus 429/5xx responses now cool
the provider down (previously unclassified), and proxy payloads saying
"not found" are treated as not-found instead of disabling the provider.
This commit is contained in:
zarzet
2026-07-10 15:11:34 +07:00
parent db477f970c
commit 270496b3d5
9 changed files with 179 additions and 91 deletions
+14 -50
View File
@@ -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 {
+8 -8
View File
@@ -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")
}
+100
View File
@@ -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...)
}
+3 -3
View File
@@ -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
+6 -6
View File
@@ -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")
}
+6 -6
View File
@@ -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
+21 -13
View File
@@ -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
}
+3 -3
View File
@@ -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")
}
+18 -2
View File
@@ -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")