Files
yamusic-bot/pkg/yamusic/client.go
Vladimir Zagainov 587676be58
Some checks failed
continuous-integration/drone/push Build is failing
MVP done
2025-06-23 13:02:10 +03:00

264 lines
8.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package yamusic
import (
"context"
"crypto/md5"
"encoding/hex"
"encoding/xml"
"fmt"
"io"
"log/slog"
"net/http"
"regexp"
"strconv"
"strings"
"gitea.mrixs.me/Mrixs/yamusic-bot/internal/model"
)
const (
yandexMusicAPIHost = "https://api.music.yandex.net"
downloadSalt = "XGRlBW9FXlekgbPrRHuSiA" // "Магическая" соль для подписи ссылки
)
// DownloadInfoXML описывает структуру XML-файла с информацией для скачивания.
type DownloadInfoXML struct {
XMLName xml.Name `xml:"download-info"`
Host string `xml:"host"`
Path string `xml:"path"`
Ts string `xml:"ts"`
S string `xml:"s"`
}
var (
trackURLRegex = regexp.MustCompile(`/album/(\d+)/track/(\d+)`)
albumURLRegex = regexp.MustCompile(`/album/(\d+)`)
artistURLRegex = regexp.MustCompile(`/artist/(\d+)`)
)
type URLInfo struct {
Type string
ArtistID string
AlbumID string
TrackID string
}
func ParseYandexURL(url string) (*URLInfo, error) {
if trackMatches := trackURLRegex.FindStringSubmatch(url); len(trackMatches) == 3 {
return &URLInfo{Type: "track", AlbumID: trackMatches[1], TrackID: trackMatches[2]}, nil
}
if albumMatches := albumURLRegex.FindStringSubmatch(url); len(albumMatches) == 2 {
return &URLInfo{Type: "album", AlbumID: albumMatches[1]}, nil
}
if artistMatches := artistURLRegex.FindStringSubmatch(url); len(artistMatches) == 2 {
return &URLInfo{Type: "artist", ArtistID: artistMatches[1]}, nil
}
return nil, fmt.Errorf("unsupported yandex music url")
}
type ApiClient struct {
api *ClientWithResponses
}
func NewApiClient(token string) (*ApiClient, error) {
authInterceptor := func(ctx context.Context, req *http.Request) error {
if token != "" {
req.Header.Set("Authorization", "OAuth "+token)
}
return nil
}
apiClient, err := NewClientWithResponses(yandexMusicAPIHost, WithRequestEditorFn(authInterceptor))
if err != nil {
return nil, fmt.Errorf("failed to create yandex music api client: %w", err)
}
return &ApiClient{api: apiClient}, nil
}
func (c *ApiClient) getTracksByIDs(ctx context.Context, trackIDs []string) ([]Track, error) {
formPayload := "track-ids=" + strings.Join(trackIDs, ",")
bodyReader := strings.NewReader(formPayload)
rawResp, err := c.api.ClientInterface.GetTracksWithBody(ctx, "application/x-www-form-urlencoded", bodyReader)
if err != nil {
return nil, fmt.Errorf("failed to execute get tracks request: %w", err)
}
resp, err := ParseGetTracksResponse(rawResp)
if err != nil {
return nil, fmt.Errorf("failed to parse get tracks response: %w", err)
}
if resp.StatusCode() != http.StatusOK || resp.JSON200 == nil || resp.JSON200.Result == nil {
return nil, fmt.Errorf("failed to get tracks, status: %d, body: %s", resp.StatusCode(), string(resp.Body))
}
return resp.JSON200.Result, nil
}
func (c *ApiClient) GetTrackInfo(ctx context.Context, trackID string) (*model.TrackInfo, error) {
tracks, err := c.getTracksByIDs(ctx, []string{trackID})
if err != nil {
return nil, err
}
if len(tracks) == 0 {
return nil, fmt.Errorf("no track info returned for id %s", trackID)
}
return c.convertTrackToTrackInfo(&tracks[0])
}
func (c *ApiClient) GetAlbumTrackInfos(ctx context.Context, albumID string) ([]*model.TrackInfo, error) {
albumIDFloat, _ := strconv.ParseFloat(albumID, 32)
resp, err := c.api.GetAlbumsWithTracksWithResponse(ctx, float32(albumIDFloat))
if err != nil {
return nil, fmt.Errorf("failed to get album info from api: %w", err)
}
if resp.StatusCode() != http.StatusOK || resp.JSON200 == nil || resp.JSON200.Result.Id == 0 {
return nil, fmt.Errorf("failed to get album info, status: %d, body: %s", resp.StatusCode(), string(resp.Body))
}
album := resp.JSON200.Result
var trackInfos []*model.TrackInfo
// Определяем исполнителя альбома
var albumArtist string
if len(album.Artists) > 0 {
albumArtist = album.Artists[0].Name
}
if album.Volumes != nil {
for i, volume := range *album.Volumes {
discNumber := i + 1
for j, track := range volume {
trackPosition := j + 1
info, err := c.convertTrackToTrackInfo(&track)
if err != nil {
slog.Warn("Failed to convert track, skipping", "trackID", track.Id, "error", err)
continue
}
// Заполняем дополнительные поля
info.AlbumArtist = albumArtist
info.DiscNumber = discNumber
info.TrackPosition = trackPosition
trackInfos = append(trackInfos, info)
}
}
}
return trackInfos, nil
}
func (c *ApiClient) GetArtistTrackInfos(ctx context.Context, artistID string) ([]*model.TrackInfo, error) {
resp, err := c.api.GetPopularTracksWithResponse(ctx, artistID)
if err != nil {
return nil, fmt.Errorf("failed to get artist popular tracks from api: %w", err)
}
if resp.StatusCode() != http.StatusOK || resp.JSON200 == nil {
return nil, fmt.Errorf("failed to get artist popular tracks, status: %d, body: %s", resp.StatusCode(), string(resp.Body))
}
trackIDs := resp.JSON200.Result.Tracks
if len(trackIDs) == 0 {
return []*model.TrackInfo{}, nil
}
tracks, err := c.getTracksByIDs(ctx, trackIDs)
if err != nil {
return nil, err
}
var trackInfos []*model.TrackInfo
for i := range tracks {
info, err := c.convertTrackToTrackInfo(&tracks[i])
if err != nil {
slog.Warn("Failed to convert track, skipping", "trackID", tracks[i].Id, "error", err)
continue
}
trackInfos = append(trackInfos, info)
}
return trackInfos, nil
}
func (c *ApiClient) GetDownloadURL(ctx context.Context, trackID string) (string, error) {
resp, err := c.api.GetDownloadInfoWithResponse(ctx, trackID)
if err != nil {
return "", fmt.Errorf("failed to get download info from api: %w", err)
}
if resp.StatusCode() != http.StatusOK || resp.JSON200 == nil || resp.JSON200.Result == nil || len(resp.JSON200.Result) == 0 {
return "", fmt.Errorf("failed to get download info, status: %d, body: %s", resp.StatusCode(), string(resp.Body))
}
var bestURL string
var maxBitrate int = 0
for _, info := range resp.JSON200.Result {
if info.Codec == "mp3" && int(info.BitrateInKbps) > maxBitrate {
maxBitrate = int(info.BitrateInKbps)
bestURL = info.DownloadInfoUrl
}
}
if bestURL == "" {
return "", fmt.Errorf("no suitable mp3 download link found for track %s", trackID)
}
// Получили ссылку на XML, теперь скачиваем и парсим его
xmlReq, err := http.NewRequestWithContext(ctx, "GET", bestURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create request for download info xml: %w", err)
}
xmlResp, err := http.DefaultClient.Do(xmlReq)
if err != nil {
return "", fmt.Errorf("failed to get download info xml: %w", err)
}
defer xmlResp.Body.Close()
if xmlResp.StatusCode != http.StatusOK {
return "", fmt.Errorf("bad status on getting download info xml: %s", xmlResp.Status)
}
xmlBody, err := io.ReadAll(xmlResp.Body)
if err != nil {
return "", fmt.Errorf("failed to read download info xml body: %w", err)
}
var infoXML DownloadInfoXML
if err := xml.Unmarshal(xmlBody, &infoXML); err != nil {
return "", fmt.Errorf("failed to unmarshal download info xml: %w", err)
}
// Генерируем финальную ссылку
signData := []byte(downloadSalt + strings.TrimPrefix(infoXML.Path, "/") + infoXML.S)
sign := md5.Sum(signData)
hexSign := hex.EncodeToString(sign[:])
finalURL := fmt.Sprintf("https://%s/get-mp3/%s/%s%s", infoXML.Host, hexSign, infoXML.Ts, infoXML.Path)
slog.Debug("Constructed final download URL", "url", finalURL)
return finalURL, nil
}
func (c *ApiClient) convertTrackToTrackInfo(track *Track) (*model.TrackInfo, error) {
if track == nil || track.Id == "" {
return nil, fmt.Errorf("invalid track data")
}
info := &model.TrackInfo{
YandexTrackID: track.Id,
Title: track.Title,
}
if len(track.Artists) > 0 {
var artists []string
for _, artist := range track.Artists {
artists = append(artists, artist.Name)
}
info.Artist = strings.Join(artists, ", ")
}
if len(track.Albums) > 0 {
album := track.Albums[0]
info.YandexAlbumID = strconv.FormatFloat(float64(album.Id), 'f', -1, 32)
info.Album = album.Title
info.Year = int(album.Year)
info.Genre = album.Genre
// По умолчанию исполнитель альбома - это исполнитель трека, если не переопределено
info.AlbumArtist = info.Artist
}
// Запрашиваем обложку максимального качества
info.CoverURL = "https://" + strings.Replace(track.CoverUri, "%%", "1000x1000", 1)
info.DownloadURL = ""
return info, nil
}