// Package genius retrieves song metadata from the API and plain lyrics from public HTML. package genius import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strings" "sync" "time" ) var ErrLyricsUnavailable = errors.New("genius: lyrics absent or page markup unsupported") // HTTPError preserves throttling information without exposing response bodies or credentials. type HTTPError struct { StatusCode int RetryAfter string } func (e *HTTPError) Error() string { return fmt.Sprintf("genius: HTTP %d", e.StatusCode) } type Artist struct { ID int64 `json:"id"` Name string `json:"name"` ImageURL string `json:"image_url"` } type Song struct { ID int64 `json:"id"` Title string `json:"title"` URL string `json:"url"` ImageURL string `json:"song_art_image_url"` ThumbURL string `json:"song_art_image_thumbnail_url"` HeaderURL string `json:"header_image_url"` Artist Artist `json:"primary_artist"` Description struct { Plain string `json:"plain"` } `json:"description"` Album struct { Name string `json:"name"` ImageURL string `json:"cover_art_url"` ThumbURL string `json:"cover_art_thumbnail_url"` } `json:"album"` } type Lyrics struct { Text string `json:"text"` SourceURL string `json:"source_url"` FetchedAt time.Time `json:"fetched_at"` } type Client struct { token string http *http.Client mu sync.Mutex next time.Time interval time.Duration } // SetInterval must be called before sharing the client with workers. func (c *Client) SetInterval(d time.Duration) { c.interval = max(d, time.Second) } func New(token string) *Client { return &Client{token: strings.TrimSpace(token), http: &http.Client{ Timeout: 15 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) >= 5 { return errors.New("genius: too many redirects") } // Do not forward credentials or fetch content outside the initial host. if req.URL.Scheme != "https" || req.URL.Host != via[0].URL.Host || req.URL.User != nil { return errors.New("genius: refused redirect") } return nil }, }} } // Search returns candidates; callers must select a version rather than assume the first hit matches. func (c *Client) Search(ctx context.Context, query string) ([]Song, error) { if c.token == "" { return nil, errors.New("genius: GENIUS_ACCESS_TOKEN is required for search") } if strings.TrimSpace(query) == "" { return nil, errors.New("genius: empty search query") } body, err := c.get(ctx, "https://api.genius.com/search?q="+url.QueryEscape(query), true) if err != nil { return nil, err } var result struct { Response struct { Hits []struct { Type string `json:"type"` Result Song `json:"result"` } `json:"hits"` } `json:"response"` } if err := json.Unmarshal(body, &result); err != nil { return nil, fmt.Errorf("genius: decode search: %w", err) } songs := make([]Song, 0, len(result.Response.Hits)) for _, hit := range result.Response.Hits { if hit.Type == "song" { songs = append(songs, hit.Result) } } return songs, nil } // FetchLyrics accepts a selected public song URL. The token is never sent to the HTML site. func (c *Client) FetchLyrics(ctx context.Context, rawURL string) (Lyrics, error) { u, err := url.Parse(rawURL) if err != nil || u.Scheme != "https" || u.Host != "genius.com" || u.User != nil || !strings.HasSuffix(u.Path, "-lyrics") { return Lyrics{}, errors.New("genius: expected https://genius.com/-lyrics") } u.RawQuery, u.Fragment = "", "" body, err := c.get(ctx, u.String(), false) if err != nil { return Lyrics{}, err } text, err := parseLyrics(body) if err != nil { return Lyrics{}, err } return Lyrics{Text: text, SourceURL: u.String(), FetchedAt: time.Now().UTC()}, nil } func (c *Client) get(ctx context.Context, endpoint string, api bool) ([]byte, error) { c.mu.Lock() wait := time.Until(c.next) c.next = time.Now().Add(max(wait, 0) + c.interval) c.mu.Unlock() if wait > 0 { timer := time.NewTimer(wait) defer timer.Stop() select { case <-ctx.Done(): return nil, ctx.Err() case <-timer.C: } } req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } req.Header.Set("User-Agent", "PipuMusic/1.0") if api { req.Header.Set("Authorization", "Bearer "+c.token) req.Header.Set("Accept", "application/json") } else { req.Header.Set("Accept", "text/html") } resp, err := c.http.Do(req) if err != nil { return nil, fmt.Errorf("genius: request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, &HTTPError{StatusCode: resp.StatusCode, RetryAfter: resp.Header.Get("Retry-After")} } const maxBody = 8 << 20 body, err := io.ReadAll(io.LimitReader(resp.Body, maxBody+1)) if err != nil { return nil, err } if len(body) > maxBody { return nil, errors.New("genius: response exceeds 8 MiB") } return body, nil }