Files
ym-dl-watcher/internal/yandex/client.go
T
Mrixs 4e6bef40e3
CI/CD / test (push) Successful in 48s
CI/CD / build (push) Successful in 54s
CI/CD / docker (push) Successful in 2m31s
Fix JSONID type conversions for yandex-music-api-go v0.0.0-20261018072517-d0173f795d2a
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).
2026-10-18 10:39:43 +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 := 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")
}