// Package client provides methods for interacting with Yandex Music API endpoints. package client import ( "context" "crypto/md5" "encoding/xml" "fmt" "strings" "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) { if len(trackIDs) == 0 { return nil, nil } params := map[string]string{ "track-ids": joinIDs(trackIDs), } var result []models.Track if err := c.request.Get(ctx, "/tracks", params, &result); err != nil { return nil, err } tracks := make([]*models.Track, len(result)) for i := range result { result[i].SetClient(c) tracks[i] = &result[i] } return tracks, nil } // GetTrack gets a single track by ID using GetTracks. // Returns an error if no track is found with the given ID. func (c *Client) GetTrack(ctx context.Context, trackID string) (*models.Track, error) { tracks, err := c.GetTracks(ctx, []string{trackID}) if err != nil { return nil, err } if len(tracks) == 0 { return nil, utils.NewYandexMusicError("track not found", utils.ErrNotFound) } return tracks[0], nil } // 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) { 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 { return nil, err } 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, 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 { return nil, 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 nil, utils.NewYandexMusicError("no download info available", utils.ErrNotFound) } url := di.URL if url == "" { 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) } // GetTracksFileInfo gets file info for a track (GET /get-file-info). func (c *Client) GetTracksFileInfo(ctx context.Context, trackID, quality string, codecs []string, transport string) (*models.FileInfo, error) { params := map[string]string{ "track_id": trackID, "quality": quality, } if len(codecs) > 0 { params["codecs"] = strings.Join(codecs, ",") } if transport != "" { params["transport"] = transport } var result models.FileInfo if err := c.request.Get(ctx, "/get-file-info", params, &result); err != nil { return nil, err } result.SetClient(c) return &result, nil } // GetTracksSimilar gets similar tracks (GET /tracks/{track_id}/similar). func (c *Client) GetTracksSimilar(ctx context.Context, trackID string) (*models.SimilarTracks, error) { var result models.SimilarTracks if err := c.request.Get(ctx, fmt.Sprintf("/tracks/%s/similar", trackID), nil, &result); err != nil { return nil, err } result.SetClient(c) return &result, nil } // GetTrackTrailer gets track trailer (GET /tracks/{track_id}/trailer). func (c *Client) GetTrackTrailer(ctx context.Context, trackID string) (*models.TrackTrailer, error) { var result models.TrackTrailer if err := c.request.Get(ctx, fmt.Sprintf("/tracks/%s/trailer", trackID), nil, &result); err != nil { return nil, err } result.SetClient(c) return &result, nil } // GetTrackFullInfo gets full track info (GET /tracks/{track_id}/full-info). func (c *Client) GetTrackFullInfo(ctx context.Context, trackID string) (*models.TrackFullInfo, error) { var result models.TrackFullInfo if err := c.request.Get(ctx, fmt.Sprintf("/tracks/%s/full-info", trackID), nil, &result); err != nil { return nil, err } result.SetClient(c) return &result, nil } // SearchResult represents search results containing tracks. type SearchResult struct { Tracks *SearchTracksResult `json:"tracks"` } // SearchTracksResult represents track search results with total count and results list. type SearchTracksResult struct { Total int `json:"total"` Results []models.Track `json:"results"` } func joinIDs(ids []string) string { result := "" for i, id := range ids { if i > 0 { result += "," } result += id } return result }