feat(genius): load song metadata, lyrics, and artwork
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

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>
This commit is contained in:
mrleo1nidandCursor committed 2026-10-22 08:23:54 +03:00
1 parent fb20258489
commit 73ad61e7a5
34 files changed
+2214 -303

No files matched your search

+176
View File
@@ -0,0 +1,176 @@
// 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
}
@@ -0,0 +1,92 @@
package genius
import (
"context"
"errors"
"io"
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
type transportFunc func(*http.Request) (*http.Response, error)
func (f transportFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func TestLyricsFormatting(t *testing.T) {
text, err := parseLyrics([]byte(`<header>Not lyrics</header><div data-lyrics-container="true"><div class="LyricsHeader-x">Title</div>[Куплет 1]<br>Привет &amp; <a href="/annotation">мир</a><br><br>Next <b>line</b><span data-exclude-from-selection="true">Embed</span><script>bad()</script></div><div data-lyrics-container="true">[Припев]<br>Again<button>Share</button></div>`))
require.NoError(t, err)
require.Equal(t, "[Куплет 1]\nПривет & мир\n\nNext line\n\n[Припев]\nAgain", text)
}
func TestUnavailable(t *testing.T) {
for _, page := range []string{`<h1>Just a moment</h1>`, `<div data-lyrics-container="true"> </div>`, `<script>window.__PRELOADED_STATE__ = JSON.parse('broken')</script>`} {
_, err := parseLyrics([]byte(page))
require.ErrorIs(t, err, ErrLyricsUnavailable)
}
}
func TestEmbeddedLyrics(t *testing.T) {
text, err := parseLyrics([]byte(`<script>window.__PRELOADED_STATE__ = JSON.parse('{"songPage":{"lyricsData":{"body":{"html":"<p>[Verse]<br>It\'s \\"fine\\" &amp; good<br>Привет</p>"}}}}')</script>`))
require.NoError(t, err)
require.Equal(t, "[Verse]\nIt's \"fine\" & good\nПривет", text)
}
func TestURLValidation(t *testing.T) {
c := New("secret")
c.http.Transport = transportFunc(func(*http.Request) (*http.Response, error) { t.Fatal("unexpected network call"); return nil, nil })
for _, u := range []string{"https://evil.test/song-lyrics", "http://genius.com/song-lyrics", "https://genius.com:443/song-lyrics", "https://secret@genius.com/song-lyrics", "https://genius.com/artists/test"} {
_, err := c.FetchLyrics(context.Background(), u)
require.Error(t, err)
}
}
func TestRequests(t *testing.T) {
c := New("secret")
c.http.Transport = transportFunc(func(r *http.Request) (*http.Response, error) {
body := `<div data-lyrics-container="true">Synthetic test text</div>`
if r.URL.Host == "api.genius.com" {
require.Equal(t, "Bearer secret", r.Header.Get("Authorization"))
require.Equal(t, "artist & title", r.URL.Query().Get("q"))
body = `{"response":{"hits":[{"type":"song","result":{"id":12,"title":"Example","url":"https://genius.com/example-lyrics","primary_artist":{"name":"Artist"}}}]}}`
} else {
require.Empty(t, r.Header.Get("Authorization"))
require.Empty(t, r.URL.RawQuery)
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
})
songs, err := c.Search(context.Background(), "artist & title")
require.NoError(t, err)
require.Len(t, songs, 1)
lyrics, err := c.FetchLyrics(context.Background(), songs[0].URL+"?tracking=1")
require.NoError(t, err)
require.Equal(t, "Synthetic test text", lyrics.Text)
require.Equal(t, songs[0].URL, lyrics.SourceURL)
require.False(t, lyrics.FetchedAt.IsZero())
}
func TestHTTPFailures(t *testing.T) {
for _, status := range []int{401, 403, 404, 429, 500} {
c := New("secret")
c.http.Transport = transportFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: status, Header: http.Header{"Retry-After": {"60"}}, Body: io.NopCloser(strings.NewReader("private upstream body"))}, nil
})
_, err := c.Search(context.Background(), "test")
var httpErr *HTTPError
require.True(t, errors.As(err, &httpErr))
require.Equal(t, status, httpErr.StatusCode)
require.Equal(t, "60", httpErr.RetryAfter)
require.NotContains(t, err.Error(), "private")
}
}
func TestRedirects(t *testing.T) {
c := New("secret")
first, _ := http.NewRequest("GET", "https://api.genius.com/search", nil)
for _, endpoint := range []string{"http://api.genius.com/search", "https://genius.com/song-lyrics", "https://evil.test/"} {
req, _ := http.NewRequest("GET", endpoint, nil)
require.Error(t, c.http.CheckRedirect(req, []*http.Request{first}))
}
}
+98
View File
@@ -0,0 +1,98 @@
package genius
import (
"context"
"encoding/json"
"errors"
"github.com/jackc/pgx/v5"
"pipu/api/internal/api/gen"
"pipu/api/internal/auth"
"pipu/api/internal/platform/apperr"
)
type Handler struct{ svc *Service }
func NewHandler(s *Service) *Handler { return &Handler{svc: s} }
func requireAdmin(ctx context.Context) error {
p, ok := auth.PrincipalFrom(ctx)
if !ok {
return apperr.Unauthorized("auth.unauthorized", "authentication required")
}
if p.Role != auth.RoleAdmin {
return apperr.Forbidden("auth.forbidden", "administrator required")
}
return nil
}
func (h *Handler) GetGeniusStats(ctx context.Context, _ gen.GetGeniusStatsRequestObject) (gen.GetGeniusStatsResponseObject, error) {
if err := requireAdmin(ctx); err != nil {
return nil, err
}
stats, err := h.svc.Stats(ctx)
return gen.GetGeniusStats200JSONResponse(stats), err
}
func (h *Handler) ControlGenius(ctx context.Context, req gen.ControlGeniusRequestObject) (gen.ControlGeniusResponseObject, error) {
if err := requireAdmin(ctx); err != nil {
return nil, err
}
if !h.svc.configured {
return nil, apperr.Conflict("genius.disabled", "configure GENIUS_ACCESS_TOKEN in deploy/.env")
}
if req.Body == nil {
return nil, apperr.Validation("action", "validation.required", "action required", nil)
}
tx, err := h.svc.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
switch string(req.Body.Action) {
case "pause":
_, err = tx.Exec(ctx, `UPDATE genius_state SET paused=true`)
case "start":
_, err = tx.Exec(ctx, `UPDATE genius_state SET paused=false,last_error=''`)
case "retry":
_, err = tx.Exec(ctx, `UPDATE genius_tracks SET status='pending',next_attempt=now(),last_error='' WHERE status IN ('error','partial','unmatched') AND locked_until<=now()`)
if err == nil {
_, err = tx.Exec(ctx, `UPDATE genius_state SET paused=false,last_error=''`)
}
default:
return nil, apperr.Validation("action", "validation.invalid", "unknown action", nil)
}
if err != nil {
return nil, err
}
if err = tx.Commit(ctx); err != nil {
return nil, err
}
h.svc.Wake()
stats, err := h.svc.Stats(ctx)
return gen.ControlGenius200JSONResponse(stats), err
}
func (h *Handler) GetTrackGenius(ctx context.Context, req gen.GetTrackGeniusRequestObject) (gen.GetTrackGeniusResponseObject, error) {
if _, ok := auth.PrincipalFrom(ctx); !ok {
return nil, apperr.Unauthorized("auth.unauthorized", "authentication required")
}
content := emptyContent()
var body []byte
err := h.svc.pool.QueryRow(ctx, `SELECT COALESCE(g.content,'{}'),COALESCE(g.status,'pending') FROM tracks t LEFT JOIN genius_tracks g ON g.track_id=t.id WHERE t.id=$1`, req.Id).Scan(&body, &content.Status)
if errors.Is(err, pgx.ErrNoRows) {
return nil, apperr.NotFound("catalog.not_found", "track not found")
}
if err != nil {
return nil, err
}
status := content.Status
if err = json.Unmarshal(body, &content); err != nil {
return nil, err
}
content.Status = status
if !h.svc.configured && status == "pending" {
content.Status = "disabled"
}
return gen.GetTrackGenius200JSONResponse(content), nil
}
+174
View File
@@ -0,0 +1,174 @@
package genius
import (
"bytes"
"encoding/json"
"regexp"
"strings"
"golang.org/x/net/html"
)
// Parse only designated lyrics containers, never the entire page (navigation, ads, annotations).
func parseLyrics(body []byte) (string, error) {
doc, err := html.Parse(bytes.NewReader(body))
if err != nil {
return "", err
}
var blocks []string
var walk func(*html.Node)
walk = func(n *html.Node) {
if excluded(n) {
return
}
if attr(n, "data-lyrics-container") == "true" {
if text := nodeText(n); text != "" {
blocks = append(blocks, text)
}
return
}
for child := n.FirstChild; child != nil; child = child.NextSibling {
walk(child)
}
}
walk(doc)
if len(blocks) == 0 {
if text := embeddedLyrics(doc); text != "" {
return text, nil
}
return "", ErrLyricsUnavailable
}
return strings.Join(blocks, "\n\n"), nil
}
func attr(n *html.Node, name string) string {
for _, a := range n.Attr {
if a.Key == name {
return a.Val
}
}
return ""
}
func excluded(n *html.Node) bool {
return n.Type == html.ElementNode && (n.Data == "script" || n.Data == "style" || n.Data == "button" ||
attr(n, "data-exclude-from-selection") == "true" || attr(n, "aria-hidden") == "true" ||
strings.Contains(attr(n, "class"), "LyricsHeader"))
}
var statePattern = regexp.MustCompile(`(?s)window\.__PRELOADED_STATE__\s*=\s*JSON\.parse\('((?:[^'\\]|\\.)*)'\)`)
// Read a JSON string literal only; never evaluate JavaScript from a remote page.
func embeddedLyrics(doc *html.Node) string {
var result string
var walk func(*html.Node)
walk = func(n *html.Node) {
if result != "" {
return
}
if n.Type == html.ElementNode && n.Data == "script" && n.FirstChild != nil {
match := statePattern.FindStringSubmatch(n.FirstChild.Data)
if len(match) == 2 {
decoded, ok := decodeJSString(match[1])
if ok {
var state struct {
SongPage struct {
LyricsData struct {
Body struct {
HTML string `json:"html"`
} `json:"body"`
} `json:"lyricsData"`
} `json:"songPage"`
}
if json.Unmarshal([]byte(decoded), &state) == nil {
fragment, err := html.Parse(strings.NewReader(strings.ReplaceAll(state.SongPage.LyricsData.Body.HTML, "\n", "")))
if err == nil {
result = nodeText(fragment)
}
}
}
}
return
}
for child := n.FirstChild; child != nil; child = child.NextSibling {
walk(child)
}
}
walk(doc)
return result
}
func decodeJSString(raw string) (string, bool) {
var quoted strings.Builder
quoted.WriteByte('"')
for i := 0; i < len(raw); i++ {
switch raw[i] {
case '"':
quoted.WriteString(`\"`)
case '\\':
i++
if i >= len(raw) {
return "", false
}
switch raw[i] {
case '\'':
quoted.WriteByte('\'')
case 'x':
if i+2 >= len(raw) {
return "", false
}
quoted.WriteString(`\u00`)
quoted.WriteString(raw[i+1 : i+3])
i += 2
default:
quoted.WriteByte('\\')
quoted.WriteByte(raw[i])
}
default:
quoted.WriteByte(raw[i])
}
}
quoted.WriteByte('"')
var decoded string
err := json.Unmarshal([]byte(quoted.String()), &decoded)
return decoded, err == nil
}
func nodeText(root *html.Node) string {
var out strings.Builder
var walk func(*html.Node)
walk = func(n *html.Node) {
if excluded(n) {
return
}
if n.Type == html.TextNode {
out.WriteString(n.Data)
return
}
if n.Type == html.ElementNode && n.Data == "br" {
out.WriteByte('\n')
return
}
block := n.Type == html.ElementNode && (n.Data == "p" || n.Data == "div") && n != root
if block {
out.WriteByte('\n')
}
for child := n.FirstChild; child != nil; child = child.NextSibling {
walk(child)
}
if block {
out.WriteByte('\n')
}
}
walk(root)
lines := strings.Split(strings.ReplaceAll(out.String(), "\r", ""), "\n")
clean := make([]string, 0, len(lines))
for _, line := range lines {
line = strings.Join(strings.Fields(line), " ")
if line == "" && (len(clean) == 0 || clean[len(clean)-1] == "") {
continue
}
clean = append(clean, line)
}
return strings.TrimSpace(strings.Join(clean, "\n"))
}
+181
View File
@@ -0,0 +1,181 @@
package genius
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"strings"
"unicode"
"pipu/api/internal/api/gen"
)
func normalize(s string) string {
return strings.Join(strings.FieldsFunc(strings.ToLower(s), func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsNumber(r) }), " ")
}
// Exact normalized identity; retain version qualifiers such as live/remix.
func matchSong(songs []Song, artist, title string) (Song, bool) {
var matched Song
for _, s := range songs {
if normalize(s.Title) == normalize(title) && normalize(s.Artist.Name) == normalize(artist) {
if matched.ID != 0 && matched.ID != s.ID {
return Song{}, false
}
matched = s
}
}
return matched, matched.ID != 0
}
func imageURL(raw string) string {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || u.Scheme != "https" || u.User != nil || strings.Contains(strings.ToLower(u.Path), "default") {
return ""
}
switch u.Host {
case "images.genius.com", "images.rapgenius.com", "t2.genius.com":
return u.String()
default:
return ""
}
}
func firstImage(urls ...string) string {
for _, raw := range urls {
if u := imageURL(raw); u != "" {
return u
}
}
return ""
}
// songArtwork prefers the full song resource: search hits often omit album art and the artist image.
func songArtwork(song Song) (songImage, artistImage, albumImage string) {
return firstImage(song.ImageURL, song.ThumbURL, song.HeaderURL), imageURL(song.Artist.ImageURL), firstImage(song.Album.ImageURL, song.Album.ThumbURL)
}
func urlPath(raw string) string {
u, err := url.Parse(raw)
if err != nil {
return ""
}
return u.Path
}
func sourceURL(raw string) string {
u, err := url.Parse(raw)
if err != nil || u.Scheme != "https" || u.Host != "genius.com" || u.User != nil {
return ""
}
return u.String()
}
func (c *Client) Song(ctx context.Context, id int64) (Song, error) {
if id <= 0 {
return Song{}, errors.New("genius: song id required")
}
body, err := c.get(ctx, fmt.Sprintf("https://api.genius.com/songs/%d?text_format=plain", id), true)
if err != nil {
return Song{}, err
}
var data struct {
Response struct {
Song Song `json:"song"`
} `json:"response"`
}
if err = json.Unmarshal(body, &data); err != nil {
return Song{}, fmt.Errorf("genius: decode song: %w", err)
}
if data.Response.Song.ID == 0 {
return Song{}, errors.New("genius: song missing")
}
return data.Response.Song, nil
}
func (c *Client) Artist(ctx context.Context, id int64) (Artist, error) {
if id <= 0 {
return Artist{}, errors.New("genius: artist id required")
}
body, err := c.get(ctx, fmt.Sprintf("https://api.genius.com/artists/%d", id), true)
if err != nil {
return Artist{}, err
}
var data struct {
Response struct {
Artist Artist `json:"artist"`
} `json:"response"`
}
if err = json.Unmarshal(body, &data); err != nil {
return Artist{}, fmt.Errorf("genius: decode artist: %w", err)
}
if data.Response.Artist.ID == 0 {
return Artist{}, errors.New("genius: artist missing")
}
return data.Response.Artist, nil
}
func (c *Client) Annotations(ctx context.Context, id int64) ([]gen.GeniusAnnotation, error) {
out := []gen.GeniusAnnotation{}
var seen int64
haveSeen := false
for page := 1; ; page++ {
body, err := c.get(ctx, fmt.Sprintf("https://api.genius.com/referents?song_id=%d&text_format=plain&per_page=50&page=%d", id, page), true)
if err != nil {
return nil, err
}
var data struct {
Response struct {
Referents []struct {
ID int64 `json:"id"`
Fragment string `json:"fragment"`
Annotations []struct {
Body struct {
Plain string `json:"plain"`
} `json:"body"`
URL string `json:"url"`
Verified bool `json:"verified"`
Authors []struct {
User struct {
Name string `json:"name"`
} `json:"user"`
} `json:"authors"`
} `json:"annotations"`
} `json:"referents"`
} `json:"response"`
}
if err := json.Unmarshal(body, &data); err != nil {
return nil, fmt.Errorf("genius: decode annotations: %w", err)
}
refs := data.Response.Referents
if len(refs) == 0 || (haveSeen && refs[0].ID == seen) {
return out, nil
}
if refs[0].ID != 0 {
seen = refs[0].ID
haveSeen = true
}
for _, r := range refs {
for _, a := range r.Annotations {
if a.Body.Plain == "" {
continue
}
names := []string{}
for _, author := range a.Authors {
if author.User.Name != "" {
names = append(names, author.User.Name)
}
}
out = append(out, gen.GeniusAnnotation{Fragment: r.Fragment, Text: a.Body.Plain, Url: sourceURL(a.URL), Verified: a.Verified, Authors: strings.Join(names, ", ")})
}
}
if len(refs) < 50 {
return out, nil
}
if page == 20 {
return nil, fmt.Errorf("genius: annotation pagination limit")
}
}
}
@@ -0,0 +1,97 @@
package genius
import (
"context"
"io"
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestMatchSong(t *testing.T) {
songs := []Song{
{ID: 1, Title: "Alright (Live)", Artist: Artist{Name: "Kendrick Lamar"}},
{ID: 2, Title: "Alright!", Artist: Artist{Name: "Kendrick Lamar"}},
{ID: 3, Title: "Alright", Artist: Artist{Name: "Someone Else"}},
}
got, ok := matchSong(songs, "kendrick-lamar", "Alright")
require.True(t, ok)
require.Equal(t, int64(2), got.ID)
_, ok = matchSong(append(songs, Song{ID: 4, Title: "Alright", Artist: Artist{Name: "Kendrick Lamar"}}), "Kendrick Lamar", "Alright")
require.False(t, ok)
_, ok = matchSong(songs, "Kendrick Lamar", "Alright Remix")
require.False(t, ok)
}
func TestImageURL(t *testing.T) {
require.Equal(t, "https://images.genius.com/cover.jpg", imageURL("https://images.genius.com/cover.jpg"))
require.Equal(t, "https://images.rapgenius.com/a.png", imageURL("https://images.rapgenius.com/a.png"))
for _, raw := range []string{
"http://images.genius.com/a.jpg",
"https://evil.test/a.jpg",
"https://images.genius.com/default_avatar.png",
"https://user@images.genius.com/a.jpg",
"",
} {
require.Empty(t, imageURL(raw))
}
song := Song{
ImageURL: "https://images.genius.com/default_cover_image.png",
ThumbURL: "https://images.genius.com/song.jpg",
Artist: Artist{ImageURL: "https://images.genius.com/artist.jpg"},
}
song.Album.ImageURL = "https://images.genius.com/album.jpg"
songImage, artistImage, albumImage := songArtwork(song)
require.Equal(t, "https://images.genius.com/song.jpg", songImage)
require.Equal(t, "https://images.genius.com/artist.jpg", artistImage)
require.Equal(t, "https://images.genius.com/album.jpg", albumImage)
}
func TestSongAndArtist(t *testing.T) {
c := New("secret")
c.http.Transport = transportFunc(func(r *http.Request) (*http.Response, error) {
require.Equal(t, "Bearer secret", r.Header.Get("Authorization"))
body := `{"response":{"artist":{"id":9,"name":"Artist","image_url":"https://images.genius.com/artist.jpg"}}}`
if strings.Contains(r.URL.Path, "/songs/") {
require.Equal(t, "plain", r.URL.Query().Get("text_format"))
body = `{"response":{"song":{"id":12,"title":"Example","url":"https://genius.com/example-lyrics","song_art_image_url":"https://images.genius.com/song.jpg","primary_artist":{"id":9,"name":"Artist"},"album":{"name":"Album","cover_art_url":"https://images.genius.com/album.jpg"},"description":{"plain":"About the song"}}}}`
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
})
song, err := c.Song(context.Background(), 12)
require.NoError(t, err)
require.Equal(t, "About the song", song.Description.Plain)
require.Equal(t, "Album", song.Album.Name)
artist, err := c.Artist(context.Background(), 9)
require.NoError(t, err)
require.Equal(t, "https://images.genius.com/artist.jpg", artist.ImageURL)
_, err = c.Song(context.Background(), 0)
require.Error(t, err)
}
func TestAnnotations(t *testing.T) {
c := New("secret")
pages := 0
one := `{"id":1,"fragment":"line","annotations":[{"body":{"plain":"means this"},"url":"https://genius.com/annotations/1","verified":true,"authors":[{"user":{"name":"Editor"}}]}]}`
full := `{"response":{"referents":[` + strings.TrimRight(strings.Repeat(one+",", 50), ",") + `]}}`
c.http.Transport = transportFunc(func(r *http.Request) (*http.Response, error) {
pages++
require.Equal(t, "plain", r.URL.Query().Get("text_format"))
body := full
if pages > 1 {
body = `{"response":{"referents":[` + one + `]}}`
}
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
})
notes, err := c.Annotations(context.Background(), 12)
require.NoError(t, err)
require.Equal(t, 2, pages)
require.Len(t, notes, 50)
require.Equal(t, "line", notes[0].Fragment)
require.Equal(t, "means this", notes[0].Text)
require.Equal(t, "Editor", notes[0].Authors)
require.True(t, notes[0].Verified)
require.Equal(t, "https://genius.com/annotations/1", notes[0].Url)
}
+272
View File
@@ -0,0 +1,272 @@
package genius
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"pipu/api/internal/api/gen"
)
type Service struct {
pool *pgxpool.Pool
client *Client
configured bool
log *slog.Logger
wake chan struct{}
}
func NewService(pool *pgxpool.Pool, token string, interval time.Duration, log *slog.Logger) *Service {
client := New(token)
client.SetInterval(interval)
return &Service{pool: pool, client: client, configured: strings.TrimSpace(token) != "", log: log, wake: make(chan struct{}, 1)}
}
func (s *Service) Wake() {
select {
case s.wake <- struct{}{}:
default:
}
}
// Run discovers new tracks, including imports, without making catalog requests wait for Genius.
func (s *Service) Run(ctx context.Context) {
if !s.configured {
return
}
timer := time.NewTicker(30 * time.Second)
defer timer.Stop()
for {
if err := s.drain(ctx); err != nil && ctx.Err() == nil {
s.log.Error("genius worker", "err", err)
}
select {
case <-ctx.Done():
return
case <-timer.C:
case <-s.wake:
}
}
}
func (s *Service) drain(ctx context.Context) error {
_, err := s.pool.Exec(ctx, `INSERT INTO genius_tracks(track_id) SELECT id FROM tracks ON CONFLICT DO NOTHING`)
if err != nil {
return err
}
for ctx.Err() == nil {
var id uuid.UUID
// Atomic claim and persisted lease recover interrupted work after a restart.
err := s.pool.QueryRow(ctx, `UPDATE genius_tracks SET status='processing', locked_until=now()+interval '10 minutes', attempts=attempts+1
WHERE track_id=(SELECT track_id FROM genius_tracks WHERE status IN ('pending','processing','error','partial')
AND next_attempt<=now() AND locked_until<=now()
AND EXISTS(SELECT 1 FROM genius_state WHERE NOT paused AND cooldown_until<=now())
ORDER BY next_attempt FOR UPDATE SKIP LOCKED LIMIT 1) RETURNING track_id`).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
return nil
}
if err != nil {
return err
}
jobCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
content, albumImage, err := s.enrich(jobCtx, id)
cancel()
if ctx.Err() != nil {
return ctx.Err()
}
status, message, next := "ready", "", time.Now().Add(30*24*time.Hour)
if content.SourceUrl == "" {
status = "unmatched"
}
if err != nil {
message = errorCode(err)
status = "error"
if content.SourceUrl != "" {
status = "partial"
}
next = time.Now().Add(6 * time.Hour)
}
content.Status = status
body, marshalErr := json.Marshal(content)
if marshalErr != nil {
return marshalErr
}
tx, txErr := s.pool.Begin(ctx)
if txErr != nil {
return txErr
}
saveErr := func() error {
if _, e := tx.Exec(ctx, `UPDATE genius_tracks SET status=$2,content=$3,last_error=$4,next_attempt=$5,
locked_until='1970-01-01',updated_at=now() WHERE track_id=$1`, id, status, body, message, next); e != nil {
return e
}
if content.ArtistImageUrl != "" {
if _, e := tx.Exec(ctx, `UPDATE artists SET genius_image_url=$2 WHERE id=(SELECT artist_id FROM track_artists
WHERE track_id=$1 AND role='main' ORDER BY position LIMIT 1) AND image_path IS NULL AND genius_image_url IS NULL`, id, content.ArtistImageUrl); e != nil {
return e
}
}
if albumImage != "" {
if _, e := tx.Exec(ctx, `UPDATE albums SET genius_cover_url=$2 WHERE id=(SELECT album_id FROM tracks WHERE id=$1)
AND cover_path IS NULL AND genius_cover_url IS NULL`, id, albumImage); e != nil {
return e
}
}
if err != nil {
pause := false
cooldown := time.Now()
var he *HTTPError
if errors.As(err, &he) {
pause = he.StatusCode == 401 || he.StatusCode == 403
if he.StatusCode == 429 {
cooldown = time.Now().Add(retryDelay(he.RetryAfter))
}
}
if _, e := tx.Exec(ctx, `UPDATE genius_state SET last_error=$1, paused=paused OR $2,
cooldown_until=GREATEST(cooldown_until,$3)`, message, pause, cooldown); e != nil {
return e
}
}
return tx.Commit(ctx)
}()
_ = tx.Rollback(ctx)
if saveErr != nil {
return saveErr
}
}
return ctx.Err()
}
func emptyContent() gen.GeniusContent {
return gen.GeniusContent{Status: "pending", Annotations: []gen.GeniusAnnotation{}}
}
func clip(s string, n int) string {
s = strings.TrimSpace(s)
if r := []rune(s); len(r) > n {
return strings.TrimSpace(string(r[:n]))
}
return s
}
func (s *Service) enrich(ctx context.Context, id uuid.UUID) (gen.GeniusContent, string, error) {
content := emptyContent()
var title, artist, album string
var old []byte
err := s.pool.QueryRow(ctx, `SELECT t.title, COALESCE((SELECT a.name FROM track_artists ta JOIN artists a ON a.id=ta.artist_id
WHERE ta.track_id=t.id AND ta.role='main' ORDER BY ta.position LIMIT 1),''), al.title, g.content
FROM tracks t JOIN albums al ON al.id=t.album_id JOIN genius_tracks g ON g.track_id=t.id WHERE t.id=$1`, id).Scan(&title, &artist, &album, &old)
if err != nil {
return content, "", err
}
if err = json.Unmarshal(old, &content); err != nil {
return content, "", err
}
if artist == "" || title == "" {
return content, "", nil
}
songs, err := s.client.Search(ctx, artist+" "+title)
if err != nil {
return content, "", err
}
song, ok := matchSong(songs, artist, title)
if !ok {
return content, "", nil
}
content.SourceUrl = sourceURL(song.URL)
content.SongImageUrl, content.ArtistImageUrl, _ = songArtwork(song)
details, err := s.client.Song(ctx, song.ID)
if err != nil {
return content, "", err
}
if u := sourceURL(details.URL); u != "" {
content.SourceUrl = u
}
songImage, artistImage, albumImage := songArtwork(details)
if songImage != "" {
content.SongImageUrl = songImage
}
if artistImage != "" {
content.ArtistImageUrl = artistImage
}
if normalize(details.Album.Name) != normalize(album) {
albumImage = ""
}
if text := clip(details.Description.Plain, 8000); text != "" {
content.Description = text
}
if content.ArtistImageUrl == "" && details.Artist.ID > 0 {
geniusArtist, artistErr := s.client.Artist(ctx, details.Artist.ID)
if artistErr != nil {
var he *HTTPError
if !errors.As(artistErr, &he) || he.StatusCode != http.StatusNotFound {
return content, albumImage, artistErr
}
} else {
content.ArtistImageUrl = imageURL(geniusArtist.ImageURL)
}
}
pageURL := song.URL
if candidate := sourceURL(details.URL); strings.HasSuffix(urlPath(candidate), "-lyrics") {
pageURL = candidate
}
annotations, err := s.client.Annotations(ctx, song.ID)
if err != nil {
return content, albumImage, err
}
content.Annotations = annotations
lyrics, err := s.client.FetchLyrics(ctx, pageURL)
if err != nil {
return content, albumImage, err
}
content.Lyrics = lyrics.Text
return content, albumImage, nil
}
func errorCode(err error) string {
var he *HTTPError
if errors.As(err, &he) {
return "http_" + strconv.Itoa(he.StatusCode)
}
if errors.Is(err, ErrLyricsUnavailable) {
return "lyrics_unavailable"
}
return "request_failed"
}
func retryDelay(raw string) time.Duration {
if seconds, err := strconv.Atoi(raw); err == nil {
return max(time.Minute, min(time.Duration(seconds)*time.Second, 24*time.Hour))
}
if date, err := http.ParseTime(raw); err == nil {
return max(time.Minute, min(time.Until(date), 24*time.Hour))
}
return 15 * time.Minute
}
func (s *Service) Stats(ctx context.Context) (gen.GeniusStats, error) {
out := gen.GeniusStats{Configured: s.configured}
err := s.pool.QueryRow(ctx, `SELECT paused,last_error,cooldown_until FROM genius_state`).Scan(&out.Paused, &out.LastError, &out.CooldownUntil)
if err != nil {
return out, err
}
err = s.pool.QueryRow(ctx, `SELECT count(*),
count(*) FILTER(WHERE g.track_id IS NULL OR g.status IN ('pending','processing')),
count(*) FILTER(WHERE COALESCE(g.content->>'source_url','')<>''),
count(*) FILTER(WHERE COALESCE(g.content->>'lyrics','')<>''),
count(*) FILTER(WHERE jsonb_array_length(COALESCE(g.content->'annotations','[]'))>0),
count(*) FILTER(WHERE COALESCE(g.content->>'song_image_url','')<>'' OR COALESCE(g.content->>'artist_image_url','')<>''),
count(*) FILTER(WHERE g.status='unmatched'),
count(*) FILTER(WHERE g.status IN ('error','partial')),
COALESCE(bool_or(g.status='processing' AND g.locked_until>now()),false)
FROM tracks t LEFT JOIN genius_tracks g ON g.track_id=t.id`).Scan(&out.Total, &out.Pending, &out.Matched, &out.Lyrics, &out.Annotations, &out.Images, &out.Unmatched, &out.Errors, &out.Running)
return out, err
}