Files
ym-dl-watcher/internal/yandex/client.go
T
Mrixs c228e7e2b7
CI/CD / test (push) Successful in 1m35s
CI/CD / build (push) Successful in 52s
CI/CD / docker (push) Canceled after 13s
Update yandex-music-api-go library
Upgrade to v0.0.0-20261018055743-0456ca0665a6 which fixes the
page=0 validation bug in GetArtistTracks/GetArtistAlbums.

The library now returns *ArtistTracks/*ArtistAlbums wrappers
instead of raw slices, so the YandexClient wrapper extracts
the inner slices for backward compatibility.

Reverted page numbering back to 0 in monitor since the API
bug is fixed server-side/library-side.
2026-10-18 09:15:19 +03:00

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 := 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")
}