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:
Mrixs committed 2026-10-17 17:09:42 +03:00
commit fe62910311
16 files changed
+6688

No files matched your search

+261
View File
@@ -0,0 +1,261 @@
package downloader
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
"ym-dl/internal/storage"
"ym-dl/internal/yandex"
"gitea.mrixs.me/Mrixs/yandex-music-api-go/yandexmusic"
)
// DownloadResult represents the result of a download operation
type DownloadResult struct {
TrackID string
AlbumID string
Success bool
Error error
TrackMeta storage.TrackMeta
AlbumMeta storage.AlbumMeta
}
// Downloader orchestrates the download of tracks and albums
type Downloader struct {
yandexClient *yandex.YandexClient
storage *storage.Storage
httpClient *http.Client
}
// NewDownloader creates a new downloader
func NewDownloader(yandexClient *yandex.YandexClient, storage *storage.Storage) *Downloader {
return &Downloader{
yandexClient: yandexClient,
storage: storage,
httpClient: &http.Client{Timeout: 5 * 60 * time.Second}, // 5 min timeout for large files
}
}
// DownloadTrack downloads a single track
func (d *Downloader) DownloadTrack(ctx context.Context, trackID string) DownloadResult {
// Check if already downloaded
if d.storage.IsTrackDownloaded(trackID) {
meta, _ := d.storage.GetTrackMeta(trackID)
return DownloadResult{
TrackID: trackID,
Success: true,
TrackMeta: meta,
}
}
// Get track info from API with position
track, position, err := d.yandexClient.GetTrackInfoWithPosition(ctx, trackID)
if err != nil {
return DownloadResult{TrackID: trackID, Success: false, Error: fmt.Errorf("get track info: %w", err)}
}
// Get album info if available
var album *yandexmusic.Album
var coverData []byte
if len(track.Albums) > 0 {
album = &track.Albums[0]
// Fetch album cover
if album.CoverURI != "" {
coverURL := strings.Replace(album.CoverURI, "%%", "1000x1000", 1)
coverURL = "https://" + strings.TrimPrefix(coverURL, "//")
coverData, _ = d.downloadFile(ctx, coverURL)
}
}
// Determine track number and disc number from API response
trackNum := 1
discNum := 1
if position != nil {
trackNum = position.Index
discNum = position.Volume
}
// Get direct download URL (handles XML parsing and URL signing)
downloadURL, err := d.yandexClient.GetTrackDownloadURL(ctx, trackID)
if err != nil {
return DownloadResult{TrackID: trackID, Success: false, Error: fmt.Errorf("get download URL: %w", err)}
}
// Download the actual audio file
audioData, err := d.downloadFile(ctx, downloadURL)
if err != nil {
return DownloadResult{TrackID: trackID, Success: false, Error: fmt.Errorf("download audio: %w", err)}
}
// Get download info for metadata (codec, bitrate)
downloadInfos, err := d.yandexClient.GetTrackDownloadInfo(ctx, trackID)
if err != nil {
return DownloadResult{TrackID: trackID, Success: false, Error: fmt.Errorf("get download info: %w", err)}
}
// Find best quality MP3 for metadata
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]
}
// Build track meta with full metadata
trackMeta := storage.BuildTrackMetaFromYandex(track, album, trackNum, discNum, *bestInfo)
if err := d.storage.SaveTrack(trackMeta, audioData, coverData); err != nil {
return DownloadResult{TrackID: trackID, Success: false, Error: fmt.Errorf("save track: %w", err)}
}
savedMeta, _ := d.storage.GetTrackMeta(trackID)
return DownloadResult{
TrackID: trackID,
Success: true,
TrackMeta: savedMeta,
}
}
// DownloadAlbum downloads all tracks from an album
func (d *Downloader) DownloadAlbum(ctx context.Context, albumID string) []DownloadResult {
var results []DownloadResult
// Get album with tracks
album, err := d.yandexClient.GetAlbumWithTracks(ctx, albumID)
if err != nil {
return []DownloadResult{{AlbumID: albumID, Success: false, Error: fmt.Errorf("get album: %w", err)}}
}
// Get album cover
var coverData []byte
if album.CoverURI != "" {
coverURL := strings.Replace(album.CoverURI, "%%", "1000x1000", 1)
coverURL = "https://" + strings.TrimPrefix(coverURL, "//")
coverData, _ = d.downloadFile(ctx, coverURL)
}
// Build track metas
var trackMetas []storage.TrackMeta
if album.Volumes != nil {
for i, volume := range album.Volumes {
for j := range volume {
track := &volume[j]
if track.ID == "" || !track.Available {
continue
}
// Get download URL (handles XML parsing and URL signing)
downloadURL, err := d.yandexClient.GetTrackDownloadURL(ctx, track.ID)
if err != nil {
results = append(results, DownloadResult{TrackID: track.ID, Success: false, Error: fmt.Errorf("get download URL: %w", err)})
continue
}
// Get download info for metadata
downloadInfos, err := d.yandexClient.GetTrackDownloadInfo(ctx, track.ID)
if err != nil {
results = append(results, DownloadResult{TrackID: track.ID, Success: false, Error: fmt.Errorf("get download info: %w", err)})
continue
}
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]
}
trackMeta := storage.BuildTrackMetaFromYandex(track, album, j+1, i+1, *bestInfo)
trackMetas = append(trackMetas, trackMeta)
// Download audio
audioData, err := d.downloadFile(ctx, downloadURL)
if err != nil {
results = append(results, DownloadResult{TrackID: track.ID, Success: false, Error: fmt.Errorf("download audio: %w", err)})
continue
}
// Save track
if err := d.storage.SaveTrack(trackMeta, audioData, coverData); err != nil {
results = append(results, DownloadResult{TrackID: track.ID, Success: false, Error: fmt.Errorf("save track: %w", err)})
continue
}
savedMeta, _ := d.storage.GetTrackMeta(track.ID)
results = append(results, DownloadResult{
TrackID: track.ID,
AlbumID: albumID,
Success: true,
TrackMeta: savedMeta,
})
// Delay between downloads to be respectful to the API
select {
case <-ctx.Done():
return results
case <-time.After(1 * time.Second):
}
}
}
}
// Save album meta
albumMeta := storage.BuildAlbumMetaFromYandex(album, trackMetas)
if err := d.storage.SaveAlbum(albumMeta, coverData); err != nil {
results = append(results, DownloadResult{AlbumID: albumID, Success: false, Error: fmt.Errorf("save album: %w", err)})
}
return results
}
// downloadFile downloads a file from a URL
func (d *Downloader) downloadFile(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
resp, err := d.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}