Files
pipu-music/services/api/internal/genius/client.go
T
mrleo1nidandCursor 73ad61e7a5
ci / ts / ts (packages + web) (push) Canceled after 1m0s
ci / api / api (push) Successful in 46s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 56s
ci / db / db (push) Successful in 1m32s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m27s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m31s
feat(genius): load song metadata, lyrics, and artwork
Search hits omit album covers and artist images. The worker now reads the full song and artist, stores the description, and leaves Genius image URLs unchanged on mobile.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 08:23:54 +03:00

177 lines
4.9 KiB
Go

// 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/<song>-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
}