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)
261 lines
7.4 KiB
Go
261 lines
7.4 KiB
Go
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)
|
|
}
|
|
|