package yandex import ( "context" "crypto/md5" "encoding/hex" "encoding/xml" "errors" "fmt" "io" "log/slog" "net/http" "os" "strings" "time" "ym-dl/internal/config" "gitea.mrixs.me/Mrixs/yandex-music-api-go/yandexmusic" ) const downloadSalt = "XGRlBW9FXlekgbPrRHuSiA" // Magic salt for URL signing // TrackPosition represents track position in album type TrackPosition struct { Volume int `json:"volume"` Index int `json:"index"` } // YandexClient wraps the external yandexmusic client type YandexClient struct { client *yandexmusic.Client config *config.YandexConfig httpClient *http.Client debug bool logger *slog.Logger } // NewYandexClient creates a new Yandex Music client wrapper func NewYandexClient(cfg *config.YandexConfig, debug bool) (*YandexClient, error) { if cfg.Token == "" { return nil, errors.New("yandex.token is required") } httpClient := &http.Client{ Timeout: 30 * time.Second, } logLevel := slog.LevelInfo if debug { logLevel = slog.LevelDebug } logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ Level: logLevel, })) client := yandexmusic.NewClient(yandexmusic.ClientConfig{ Token: cfg.Token, }) if err := client.Init(context.Background()); err != nil { return nil, fmt.Errorf("init client: %w", err) } return &YandexClient{ client: client, config: cfg, httpClient: httpClient, debug: debug, logger: logger, }, nil } // GetArtistTracks fetches tracks for an artist func (c *YandexClient) GetArtistTracks(ctx context.Context, artistID int, page, pageSize int) ([]*yandexmusic.Track, error) { return c.client.GetArtistTracks(ctx, fmt.Sprintf("%d", artistID), page, pageSize) } // GetArtistAlbums fetches albums for an artist func (c *YandexClient) GetArtistAlbums(ctx context.Context, artistID int, page, pageSize int) ([]*yandexmusic.Album, error) { return c.client.GetArtistAlbums(ctx, fmt.Sprintf("%d", artistID), page, pageSize) } // GetAlbumWithTracks fetches an album with its tracks func (c *YandexClient) GetAlbumWithTracks(ctx context.Context, albumID string) (*yandexmusic.Album, error) { albums, err := c.client.GetAlbumsWithTracks(ctx, []string{albumID}) if err != nil { return nil, err } if len(albums) == 0 { return nil, fmt.Errorf("album not found") } return albums[0], nil } // GetTrackInfo fetches a single track's info func (c *YandexClient) GetTrackInfo(ctx context.Context, trackID string) (*yandexmusic.Track, error) { return c.client.GetTrack(ctx, trackID) } // GetTrackInfoWithPosition fetches track info including track position in album func (c *YandexClient) GetTrackInfoWithPosition(ctx context.Context, trackID string) (*yandexmusic.Track, *TrackPosition, error) { track, err := c.client.GetTrack(ctx, trackID) if err != nil { return nil, nil, err } // Extract track position from first album var position *TrackPosition if len(track.Albums) > 0 { albumID := track.Albums[0].ID albums, err := c.client.GetAlbumsWithTracks(ctx, []string{albumID}) if err == nil && len(albums) > 0 { album := albums[0] // Find track in album.Volumes and get position for volIdx, volume := range album.Volumes { for trackIdx, t := range volume { if t.ID == trackID { position = &TrackPosition{ Volume: volIdx + 1, Index: trackIdx + 1, } break } } if position != nil { break } } } } return track, position, nil } // GetTrackDownloadInfo gets download info for a track func (c *YandexClient) GetTrackDownloadInfo(ctx context.Context, trackID string) ([]yandexmusic.DownloadInfo, error) { return c.client.GetTrackDownloadInfo(ctx, trackID, "") } // GetArtistInfo fetches artist info func (c *YandexClient) GetArtistInfo(ctx context.Context, artistID int) (*yandexmusic.Artist, error) { return c.client.GetArtist(ctx, fmt.Sprintf("%d", artistID)) } // DownloadTrackXML downloads the XML with track download URLs func (c *YandexClient) DownloadTrackXML(ctx context.Context, downloadInfoURL string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, "GET", downloadInfoURL, nil) if err != nil { return nil, fmt.Errorf("create download XML request: %w", err) } resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("download XML request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("download XML failed: %s: %s", resp.Status, string(body)) } return io.ReadAll(resp.Body) } // DownloadInfoXML describes the structure of the XML file with download info 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"` } // GetTrackDownloadURL gets the direct MP3 download URL for a track func (c *YandexClient) GetTrackDownloadURL(ctx context.Context, trackID string) (string, error) { // Get download info to find best MP3 quality downloadInfos, err := c.GetTrackDownloadInfo(ctx, trackID) if err != nil { return "", fmt.Errorf("get download info: %w", err) } // Find best quality MP3 var bestInfo *yandexmusic.DownloadInfo for _, info := range downloadInfos { if info.Codec == "mp3" && info.DirectLink { if bestInfo == nil || info.Bitrate > bestInfo.Bitrate { bestInfo = &info } } } if bestInfo == nil { for _, info := range downloadInfos { if info.Codec == "mp3" { if bestInfo == nil || info.Bitrate > bestInfo.Bitrate { bestInfo = &info } } } } if bestInfo == nil && len(downloadInfos) > 0 { bestInfo = &downloadInfos[0] } if bestInfo == nil { return "", fmt.Errorf("no suitable download info found") } // If we have a direct URL, use it if bestInfo.URL != "" && bestInfo.DirectLink { return bestInfo.URL, nil } // If we have a download info URL, use it to get the signed URL if bestInfo.URL != "" { // Download and parse XML xmlData, err := c.DownloadTrackXML(ctx, bestInfo.URL) if err != nil { return "", fmt.Errorf("download XML: %w", err) } var infoXML DownloadInfoXML if err := xml.Unmarshal(xmlData, &infoXML); err != nil { return "", fmt.Errorf("parse XML: %w", err) } // Generate signed URL 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) if c.debug { c.logger.Debug("constructed download URL", "url", finalURL) } return finalURL, nil } return "", fmt.Errorf("no download URL available") }