diff --git a/internal/client/tracks.go b/internal/client/tracks.go index 9c28cd4..b7906be 100644 --- a/internal/client/tracks.go +++ b/internal/client/tracks.go @@ -3,12 +3,47 @@ package client import ( "context" + "crypto/md5" + "encoding/xml" "fmt" "gitea.mrixs.me/Mrixs/yandex-music-api-go/internal/models" "gitea.mrixs.me/Mrixs/yandex-music-api-go/internal/utils" ) +const downloadSignSalt = "XGRlBW9FXlekgbPrRHuSiA" + +// downloadInfoXML represents the XML response from download_info_url. +type downloadInfoXML struct { + Host string `xml:"host"` + Path string `xml:"path"` + Ts string `xml:"ts"` + S string `xml:"s"` +} + +// resolveDownloadURL downloads the XML from downloadInfoURL, parses host/path/ts/s, +// computes the MD5 signature, and returns the signed download URL: +// https://{host}/get-mp3/{sign}/{ts}{path} +func resolveDownloadURL(ctx context.Context, downloadInfoURL string, ri models.RequestInterface) (string, error) { + data, err := ri.Download(ctx, downloadInfoURL) + if err != nil { + return "", err + } + + var xmlData downloadInfoXML + if err := xml.Unmarshal(data, &xmlData); err != nil { + return "", fmt.Errorf("failed to parse download info XML: %w", err) + } + + if xmlData.Path == "" || xmlData.S == "" { + return "", utils.NewYandexMusicError("invalid download info XML: missing path or s", utils.ErrBadRequest) + } + + sign := fmt.Sprintf("%x", md5.Sum([]byte(downloadSignSalt+xmlData.Path[1:]+xmlData.S))) + + return fmt.Sprintf("https://%s/get-mp3/%s/%s%s", xmlData.Host, sign, xmlData.Ts, xmlData.Path), nil +} + // GetTracks gets tracks by their IDs (GET /tracks). // Returns a slice of Track pointers, or nil if no IDs are provided. func (c *Client) GetTracks(ctx context.Context, trackIDs []string) ([]*models.Track, error) { @@ -49,11 +84,12 @@ func (c *Client) GetTrack(ctx context.Context, trackID string) (*models.Track, e // GetTrackDownloadInfo gets download info for a track (GET /tracks/{track_id}/download-info). // The quality parameter specifies the desired audio quality (e.g., "mp3"). func (c *Client) GetTrackDownloadInfo(ctx context.Context, trackID, quality string) ([]models.DownloadInfo, error) { - // This requires signing - use the sign utility - // For now, return basic implementation params := map[string]string{ "track_id": trackID, } + if quality != "" { + params["quality"] = quality + } var result []models.DownloadInfo if err := c.request.Get(ctx, fmt.Sprintf("/tracks/%s/download-info", trackID), params, &result); err != nil { @@ -62,8 +98,45 @@ func (c *Client) GetTrackDownloadInfo(ctx context.Context, trackID, quality stri return result, nil } +// GetTrackDownloadURL returns a signed direct download URL for a track. +// It fetches download info, finds a matching entry, downloads the XML from its +// download_info_url, parses host/path/ts/s, computes the MD5 signature, and +// returns https://{host}/get-mp3/{sign}/{ts}{path}. +func (c *Client) GetTrackDownloadURL(ctx context.Context, trackID, format string, bitrate int) (string, error) { + info, err := c.GetTrackDownloadInfo(ctx, trackID, format) + if err != nil { + return "", err + } + + // Find matching download info + var di *models.DownloadInfo + for i := range info { + if info[i].Codec == format && (bitrate == 0 || info[i].Bitrate == bitrate) { + di = &info[i] + break + } + } + if di == nil && len(info) > 0 { + di = &info[0] + } + if di == nil { + return "", utils.NewYandexMusicError("no download info available", utils.ErrNotFound) + } + + if di.URL != "" { + return di.URL, nil + } + + if di.DownloadInfoURL == "" { + return "", utils.NewYandexMusicError("no download URL available", utils.ErrNotFound) + } + + return resolveDownloadURL(ctx, di.DownloadInfoURL, c.request) +} + // DownloadTrack downloads a track by ID. -// It first fetches download info, finds a matching format/bitrate, then downloads the file content. +// It first fetches download info, finds a matching format/bitrate, resolves the +// signed URL if needed, then downloads the file content. func (c *Client) DownloadTrack(ctx context.Context, trackID, format string, bitrate int) ([]byte, error) { info, err := c.GetTrackDownloadInfo(ctx, trackID, format) if err != nil { @@ -71,20 +144,29 @@ func (c *Client) DownloadTrack(ctx context.Context, trackID, format string, bitr } // Find matching download info - var url string - for _, di := range info { - if di.Codec == format && (bitrate == 0 || di.Bitrate == bitrate) { - url = di.URL + var di *models.DownloadInfo + for i := range info { + if info[i].Codec == format && (bitrate == 0 || info[i].Bitrate == bitrate) { + di = &info[i] break } } - - if url == "" && len(info) > 0 { - url = info[0].URL + if di == nil && len(info) > 0 { + di = &info[0] + } + if di == nil { + return nil, utils.NewYandexMusicError("no download info available", utils.ErrNotFound) } + url := di.URL if url == "" { - return nil, utils.NewYandexMusicError("no download URL available", utils.ErrNotFound) + if di.DownloadInfoURL == "" { + return nil, utils.NewYandexMusicError("no download URL available", utils.ErrNotFound) + } + url, err = resolveDownloadURL(ctx, di.DownloadInfoURL, c.request) + if err != nil { + return nil, err + } } return c.request.Download(ctx, url) diff --git a/internal/models/track.go b/internal/models/track.go index 4ef73ad..3244bb6 100644 --- a/internal/models/track.go +++ b/internal/models/track.go @@ -165,10 +165,12 @@ type TrackLyricsInfo struct { // DownloadInfo represents file download info for a track. type DownloadInfo struct { - Quality string `json:"quality"` - Codec string `json:"codec"` - Bitrate int `json:"bitrate"` - Size int64 `json:"size"` - URL string `json:"url"` - DirectLink bool `json:"direct_link"` + Quality string `json:"quality"` + Codec string `json:"codec"` + Bitrate int `json:"bitrate"` + Size int64 `json:"size"` + URL string `json:"url"` + DirectLink bool `json:"direct_link"` + DownloadInfoURL string `json:"download_info_url"` + DirectLinkURL string `json:"-"` // Computed signed download URL }