The new library version uses models.JSONID (custom string type) for ID fields instead of plain string. Convert to string where IDs are passed to functions expecting string or compared with string values. Affected: client.go (GetTrackInfoWithPosition), downloader.go (track.ID usage), monitor.go (track.ID, album.ID usage), storage.go (album/track ID assignment).
258 lines
7.0 KiB
Go
258 lines
7.0 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 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")
|
|
}
|