The library returns download_info but GetTrackDownloadURL cannot extract a working URL (bestInfo.URL is empty). Added debug logging of all download info candidates and a more descriptive error message to diagnose why Yandex API returns entries without URL. This is a ym-dl issue, not a library issue — the library correctly returns download_info; ym-dl fails to build a download URL from it.
264 lines
7.3 KiB
Go
264 lines
7.3 KiB
Go
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) {
|
|
result, err := c.client.GetArtistTracks(ctx, fmt.Sprintf("%d", artistID), page, pageSize)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
tracks := make([]*yandexmusic.Track, 0, len(result.Tracks))
|
|
for i := range result.Tracks {
|
|
tracks = append(tracks, &result.Tracks[i])
|
|
}
|
|
return tracks, nil
|
|
}
|
|
|
|
// GetArtistAlbums fetches albums for an artist
|
|
func (c *YandexClient) GetArtistAlbums(ctx context.Context, artistID int, page, pageSize int) ([]*yandexmusic.Album, error) {
|
|
result, err := c.client.GetArtistAlbums(ctx, fmt.Sprintf("%d", artistID), page, pageSize)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
albums := make([]*yandexmusic.Album, 0, len(result.Albums))
|
|
for i := range result.Albums {
|
|
albums = append(albums, &result.Albums[i])
|
|
}
|
|
return albums, nil
|
|
}
|
|
|
|
// 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 := string(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 string(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 c.debug {
|
|
for i, info := range downloadInfos {
|
|
c.logger.Debug("download info candidate", "index", i, "codec", info.Codec, "bitrate", info.Bitrate, "direct_link", info.DirectLink, "url", info.URL)
|
|
}
|
|
}
|
|
|
|
// 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: bestInfo.URL is empty (codec=%s, bitrate=%d, direct_link=%v)", bestInfo.Codec, bestInfo.Bitrate, bestInfo.DirectLink)
|
|
}
|