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