Initial commit: ym-dl with yandex-music-api-go library migration
Migrate ym-dl from custom oapi-codegen Yandex Music API client to external gitea.mrixs.me/Mrixs/yandex-music-api-go library with token-only authentication. Key changes: - Replace custom Yandex API client with yandexmusic library wrapper - Token-based OAuth authentication only (no username/password fallback) - SQLite storage index with JSON index migration path - Extended ID3 tag support: genre, comment, lyrics, publisher, ISRC, copyright - High-resolution album covers (1000x1000) - Fixed album download check (requires all tracks, not just one)
This commit is contained in:
commit
fe62910311
16 files changed
+6688
No files matched your search
@@ -0,0 +1,241 @@
|
||||
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")
|
||||
}
|
||||
Reference in new issue
Block a user