Files
pipu-music/services/api/internal/genius/metadata.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

182 lines
4.7 KiB
Go

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")
}
}
}