264 lines
8.5 KiB
Go
264 lines
8.5 KiB
Go
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
|
||
}
|