194 lines
5.2 KiB
Go
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")
|
|
}
|
|
}
|
|
}
|