This commit is contained in:
@@ -2,7 +2,11 @@ package yamusic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"regexp"
|
||||
@@ -14,8 +18,18 @@ import (
|
||||
|
||||
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+)`)
|
||||
@@ -60,18 +74,32 @@ func NewApiClient(token string) (*ApiClient, error) {
|
||||
return &ApiClient{api: apiClient}, nil
|
||||
}
|
||||
|
||||
func (c *ApiClient) GetTrackInfo(ctx context.Context, trackID string) (*model.TrackInfo, error) {
|
||||
body := GetTracksFormdataRequestBody{TrackIds: &[]string{trackID}}
|
||||
resp, err := c.api.GetTracksWithFormdataBodyWithResponse(ctx, body)
|
||||
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 get track info from api: %w", err)
|
||||
return nil, fmt.Errorf("failed to execute get tracks request: %w", err)
|
||||
}
|
||||
if resp.StatusCode() != http.StatusOK || resp.JSON200 == nil || resp.JSON200.Result == nil || len(resp.JSON200.Result) == 0 {
|
||||
return nil, fmt.Errorf("failed to get track info, status: %d, body: %s", resp.StatusCode(), string(resp.Body))
|
||||
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
|
||||
}
|
||||
|
||||
track := (resp.JSON200.Result)[0]
|
||||
return c.convertTrackToTrackInfo(&track)
|
||||
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) {
|
||||
@@ -86,17 +114,27 @@ func (c *ApiClient) GetAlbumTrackInfos(ctx context.Context, albumID string) ([]*
|
||||
|
||||
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 {
|
||||
trackNum := 1
|
||||
for _, volume := range *album.Volumes {
|
||||
for _, track := range volume {
|
||||
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.TrackPosition = trackNum
|
||||
trackNum++
|
||||
// Заполняем дополнительные поля
|
||||
info.AlbumArtist = albumArtist
|
||||
info.DiscNumber = discNumber
|
||||
info.TrackPosition = trackPosition
|
||||
trackInfos = append(trackInfos, info)
|
||||
}
|
||||
}
|
||||
@@ -112,26 +150,19 @@ func (c *ApiClient) GetArtistTrackInfos(ctx context.Context, artistID string) ([
|
||||
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
|
||||
}
|
||||
|
||||
body := GetTracksFormdataRequestBody{TrackIds: &trackIDs}
|
||||
tracksResp, err := c.api.GetTracksWithFormdataBodyWithResponse(ctx, body)
|
||||
tracks, err := c.getTracksByIDs(ctx, trackIDs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get full info for popular tracks: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
if tracksResp.StatusCode() != http.StatusOK || tracksResp.JSON200 == nil || tracksResp.JSON200.Result == nil {
|
||||
return nil, fmt.Errorf("failed to get full info for popular tracks, status: %d, body: %s", tracksResp.StatusCode(), string(tracksResp.Body))
|
||||
}
|
||||
|
||||
var trackInfos []*model.TrackInfo
|
||||
for _, track := range tracksResp.JSON200.Result {
|
||||
info, err := c.convertTrackToTrackInfo(&track)
|
||||
for i := range tracks {
|
||||
info, err := c.convertTrackToTrackInfo(&tracks[i])
|
||||
if err != nil {
|
||||
slog.Warn("Failed to convert track, skipping", "trackID", track.Id, "error", err)
|
||||
slog.Warn("Failed to convert track, skipping", "trackID", tracks[i].Id, "error", err)
|
||||
continue
|
||||
}
|
||||
trackInfos = append(trackInfos, info)
|
||||
@@ -156,13 +187,44 @@ func (c *ApiClient) GetDownloadURL(ctx context.Context, trackID string) (string,
|
||||
bestURL = info.DownloadInfoUrl
|
||||
}
|
||||
}
|
||||
|
||||
if bestURL == "" {
|
||||
return "", fmt.Errorf("no suitable mp3 download link found for track %s", trackID)
|
||||
}
|
||||
|
||||
slog.Warn("Returning XML info URL instead of direct download link. Real implementation needed.", "url", bestURL)
|
||||
return "https://example.com/download/track.mp3", nil
|
||||
// Получили ссылку на 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) {
|
||||
@@ -189,9 +251,12 @@ func (c *ApiClient) convertTrackToTrackInfo(track *Track) (*model.TrackInfo, err
|
||||
info.Album = album.Title
|
||||
info.Year = int(album.Year)
|
||||
info.Genre = album.Genre
|
||||
// По умолчанию исполнитель альбома - это исполнитель трека, если не переопределено
|
||||
info.AlbumArtist = info.Artist
|
||||
}
|
||||
|
||||
info.CoverURL = "https://" + strings.Replace(track.CoverUri, "%%", "400x400", 1)
|
||||
// Запрашиваем обложку максимального качества
|
||||
info.CoverURL = "https://" + strings.Replace(track.CoverUri, "%%", "1000x1000", 1)
|
||||
info.DownloadURL = ""
|
||||
|
||||
return info, nil
|
||||
|
||||
@@ -1366,7 +1366,7 @@ type TrackDownloadInfo struct {
|
||||
Gain bool `json:"gain"`
|
||||
|
||||
// Preview Предварительный просмотр
|
||||
Preview string `json:"preview"`
|
||||
Preview bool `json:"preview"`
|
||||
}
|
||||
|
||||
// TrackDownloadInfoCodec Кодек аудиофайла
|
||||
@@ -1667,7 +1667,7 @@ type GetTokenFormdataBodyGrantType string
|
||||
// GetTracksFormdataBody defines parameters for GetTracks.
|
||||
type GetTracksFormdataBody struct {
|
||||
// TrackIds Уникальные идентификаторы треков
|
||||
TrackIds *[]string `form:"track-ids,omitempty" json:"track-ids,omitempty"`
|
||||
TrackIds *[]string `form:"trackIds,omitempty" json:"trackIds,omitempty"`
|
||||
|
||||
// WithPositions С позициями
|
||||
WithPositions *bool `form:"with-positions,omitempty" json:"with-positions,omitempty"`
|
||||
|
||||
Reference in New Issue
Block a user