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).
261 lines
7.5 KiB
Go
261 lines
7.5 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 string(track.ID) == "" || !track.Available {
|
|
continue
|
|
}
|
|
|
|
// Get download URL (handles XML parsing and URL signing)
|
|
downloadURL, err := d.yandexClient.GetTrackDownloadURL(ctx, string(track.ID))
|
|
if err != nil {
|
|
results = append(results, DownloadResult{TrackID: string(track.ID), Success: false, Error: fmt.Errorf("get download URL: %w", err)})
|
|
continue
|
|
}
|
|
|
|
// Get download info for metadata
|
|
downloadInfos, err := d.yandexClient.GetTrackDownloadInfo(ctx, string(track.ID))
|
|
if err != nil {
|
|
results = append(results, DownloadResult{TrackID: string(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: string(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: string(track.ID), Success: false, Error: fmt.Errorf("save track: %w", err)})
|
|
continue
|
|
}
|
|
|
|
savedMeta, _ := d.storage.GetTrackMeta(string(track.ID))
|
|
results = append(results, DownloadResult{
|
|
TrackID: string(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)
|
|
}
|
|
|