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>
182 lines
4.7 KiB
Go
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")
|
|
}
|
|
}
|
|
}
|