package main import ( "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "time" ) // LyricsResponse represents the API response structure. type LyricsResponse struct { SyncedLyrics string `json:"syncedLyrics"` PlainLyrics string `json:"plainLyrics"` Code int `json:"code"` } var ( errLyricsNotFound = errors.New("lyrics not found") errLyricsTemporary = errors.New("temporary lyrics fetch failure") lyricsHTTPClient = &http.Client{Timeout: 15 * time.Second} ) type temporaryLyricsError struct { Reason string RetryAfter time.Duration } func (e *temporaryLyricsError) Error() string { if e.RetryAfter > 0 { return fmt.Sprintf("%s: retry after %s", e.Reason, e.RetryAfter) } return e.Reason } func (e *temporaryLyricsError) Unwrap() error { return errLyricsTemporary } func fetchLyrics(metadata Metadata) (LyricsResponse, error) { for attempt := 1; attempt <= 3; attempt++ { lyrics, err := fetchLyricsOnce(metadata) if err == nil { return lyrics, nil } if !errors.Is(err, errLyricsTemporary) || attempt == 3 { return LyricsResponse{}, err } retryDelay := time.Duration(attempt) * time.Second var temporaryErr *temporaryLyricsError if errors.As(err, &temporaryErr) && temporaryErr.RetryAfter > 0 { retryDelay = temporaryErr.RetryAfter } time.Sleep(retryDelay) } return LyricsResponse{}, fmt.Errorf("lyrics fetch retries exhausted") } func fetchLyricsOnce(metadata Metadata) (LyricsResponse, error) { apiURL := fmt.Sprintf("https://lrclib.net/api/get?track_name=%s&artist_name=%s&album_name=%s&duration=%d", url.QueryEscape(metadata.TrackName), url.QueryEscape(metadata.ArtistName), url.QueryEscape(metadata.AlbumName), metadata.Duration) resp, err := lyricsHTTPClient.Get(apiURL) if err != nil { return LyricsResponse{}, &temporaryLyricsError{Reason: fmt.Sprintf("request failed: %v", err)} } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { return LyricsResponse{}, errLyricsNotFound } if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= http.StatusInternalServerError { retryAfter := parseRetryAfter(resp.Header.Get("Retry-After")) return LyricsResponse{}, &temporaryLyricsError{Reason: fmt.Sprintf("http status %d", resp.StatusCode), RetryAfter: retryAfter} } if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { return LyricsResponse{}, fmt.Errorf("lyrics API returned status %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return LyricsResponse{}, err } var lyricsResp LyricsResponse err = json.Unmarshal(body, &lyricsResp) if err != nil { return LyricsResponse{}, err } if lyricsResp.Code == 404 { return LyricsResponse{}, errLyricsNotFound } if lyricsResp.Code >= 500 { return LyricsResponse{}, &temporaryLyricsError{Reason: fmt.Sprintf("api code %d", lyricsResp.Code)} } return lyricsResp, nil } func parseRetryAfter(value string) time.Duration { if value == "" { return 0 } seconds, err := strconv.Atoi(value) if err == nil { if seconds < 0 { return 0 } return time.Duration(seconds) * time.Second } retryTime, err := time.Parse(time.RFC1123, value) if err != nil { retryTime, err = time.Parse(time.RFC1123Z, value) if err != nil { return 0 } } if retryTime.Before(time.Now()) { return 0 } return time.Until(retryTime) }