@@ -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 ) G etTrackInfo ( ctx context . Context , trackID string ) ( * model . TrackInfo , error ) {
body := GetTracksFormdataRequestBody { TrackIds : & [ ] string{ trackID } }
resp , err := c . api . GetTracksWithFormdataBodyWithResponse ( ctx , body )
func ( c * ApiClient ) g etTracksByIDs ( 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 ( rawR esp )
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 {
trackN um := 1
for _ , volume := range * album . Volumes {
for _ , track := range volume {
for i , vol ume := 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