Files
pipu-music/services/api/internal/genius/metadata.go
T
mrleo1nidandClaude Sonnet 5.5 139dd226fa
ci / api / api (push) Successful in 27s
ci / db / db (push) Successful in 53s
feat(genius): fetch artist avatars by name and match names with transliteration suffix
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 13:50:55 +03:00

194 lines
5.2 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) }), " ")
}
// sameArtist сравнивает имя на Genius с нашим: у Genius часто «имя (транслитерация)»,
// поэтому скобочный хвост допустим.
func sameArtist(geniusName, name string) bool {
if normalize(geniusName) == normalize(name) {
return true
}
if i := strings.Index(geniusName, "("); i > 0 && strings.HasSuffix(strings.TrimSpace(geniusName), ")") {
return normalize(geniusName[:i]) == normalize(name)
}
return false
}
// 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) && sameArtist(s.Artist.Name, 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")
}
}
}