diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f384dbe..d46a6012 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## [2.1.0-preview] - 2026-01-06 + +### Performance +- **Download Speed Optimizations**: Significant improvements to download initialization and throughput + - Token caching for Tidal (eliminates redundant auth requests) + - Singleton pattern for all downloaders (HTTP connection reuse) + - ISRC search first strategy (faster than SongLink API) + - Track ID cache with 30 minute TTL for album/playlist downloads + - Pre-warm cache when viewing album/playlist + - Parallel cover art and lyrics fetching during audio download + - 64KB HTTP read/write buffers + - 256KB buffered file writer for all downloaders + - Progress updates every 64KB (reduced lock contention) +- **Amazon Music Optimizations**: Same optimizations now applied to Amazon downloader + +### Technical +- New `go_backend/parallel.go` with `TrackIDCache`, `FetchCoverAndLyricsParallel()`, `PreWarmTrackCache()` +- Flutter: `_preWarmCacheForTracks()` in `track_provider.dart` +- New method channels: `preWarmTrackCache`, `getTrackCacheSize`, `clearTrackCache` + ## [2.0.7-preview2] - 2026-01-06 ### Fixed diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index e643c914..e1a3890c 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -211,6 +211,25 @@ class MainActivity: FlutterActivity() { } result.success(null) } + "preWarmTrackCache" -> { + val tracksJson = call.argument("tracks") ?: "[]" + withContext(Dispatchers.IO) { + Gobackend.preWarmTrackCacheJSON(tracksJson) + } + result.success(null) + } + "getTrackCacheSize" -> { + val size = withContext(Dispatchers.IO) { + Gobackend.getTrackCacheSize() + } + result.success(size.toInt()) + } + "clearTrackCache" -> { + withContext(Dispatchers.IO) { + Gobackend.clearTrackIDCache() + } + result.success(null) + } else -> result.notImplemented() } } catch (e: Exception) { diff --git a/go_backend/amazon.go b/go_backend/amazon.go index 163984f4..4331afdd 100644 --- a/go_backend/amazon.go +++ b/go_backend/amazon.go @@ -1,6 +1,7 @@ package gobackend import ( + "bufio" "encoding/base64" "encoding/json" "fmt" @@ -10,6 +11,7 @@ import ( "os" "path/filepath" "strings" + "sync" "time" ) @@ -19,6 +21,12 @@ type AmazonDownloader struct { regions []string // us, eu regions for DoubleDouble service } +var ( + // Global Amazon downloader instance for connection reuse + globalAmazonDownloader *AmazonDownloader + amazonDownloaderOnce sync.Once +) + // DoubleDoubleSubmitResponse is the response from DoubleDouble submit endpoint type DoubleDoubleSubmitResponse struct { Success bool `json:"success"` @@ -93,12 +101,15 @@ func amazonIsASCIIString(s string) bool { return true } -// NewAmazonDownloader creates a new Amazon downloader using DoubleDouble service +// NewAmazonDownloader creates a new Amazon downloader (returns singleton for connection reuse) func NewAmazonDownloader() *AmazonDownloader { - return &AmazonDownloader{ - client: NewHTTPClientWithTimeout(120 * time.Second), // 120s timeout like PC - regions: []string{"us", "eu"}, // Same regions as PC - } + amazonDownloaderOnce.Do(func() { + globalAmazonDownloader = &AmazonDownloader{ + client: NewHTTPClientWithTimeout(120 * time.Second), // 120s timeout like PC + regions: []string{"us", "eu"}, // Same regions as PC + } + }) + return globalAmazonDownloader } // GetAvailableAPIs returns list of available DoubleDouble regions @@ -294,14 +305,18 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) } defer out.Close() - // Use item progress writer + // Use buffered writer for better performance (256KB buffer) + bufWriter := bufio.NewWriterSize(out, 256*1024) + defer bufWriter.Flush() + + // Use item progress writer with buffered output var bytesWritten int64 if itemID != "" { - pw := NewItemProgressWriter(out, itemID) + pw := NewItemProgressWriter(bufWriter, itemID) bytesWritten, err = io.Copy(pw, resp.Body) } else { // Fallback: direct copy without progress tracking - bytesWritten, err = io.Copy(out, resp.Body) + bytesWritten, err = io.Copy(bufWriter, resp.Body) } if err != nil { return fmt.Errorf("failed to write file: %w", err) @@ -378,11 +393,29 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil } - // Download file with item ID for progress tracking + // START PARALLEL: Fetch cover and lyrics while downloading audio + var parallelResult *ParallelDownloadResult + parallelDone := make(chan struct{}) + go func() { + defer close(parallelDone) + parallelResult = FetchCoverAndLyricsParallel( + req.CoverURL, + req.EmbedMaxQualityCover, + req.SpotifyID, + req.TrackName, + req.ArtistName, + req.EmbedLyrics, + ) + }() + + // Download audio file with item ID for progress tracking if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil { return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err) } + // Wait for parallel operations to complete + <-parallelDone + // Set progress to 100% and status to finalizing (before embedding) // This makes the UI show "Finalizing..." while embedding happens if req.ItemID != "" { @@ -408,41 +441,27 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { ISRC: req.ISRC, } - // Download cover to memory (avoids file permission issues on Android) + // Use cover data from parallel fetch var coverData []byte - if req.CoverURL != "" { - fmt.Println("[Amazon] Downloading cover to memory...") - data, err := downloadCoverToMemory(req.CoverURL, req.EmbedMaxQualityCover) - if err == nil { - coverData = data - fmt.Printf("[Amazon] Cover downloaded successfully (%d bytes)\n", len(coverData)) - } else { - fmt.Printf("[Amazon] Warning: failed to download cover: %v\n", err) - } + if parallelResult != nil && parallelResult.CoverData != nil { + coverData = parallelResult.CoverData + fmt.Printf("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData)) } if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil { fmt.Printf("Warning: failed to embed metadata: %v\n", err) } - // Embed lyrics if enabled - if req.EmbedLyrics { - fmt.Println("[Amazon] Fetching lyrics...") - lyricsClient := NewLyricsClient() - lyrics, lyricsErr := lyricsClient.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName) - if lyricsErr != nil { - fmt.Printf("[Amazon] Warning: lyrics fetch error: %v\n", lyricsErr) - } else if lyrics == nil || len(lyrics.Lines) == 0 { - fmt.Println("[Amazon] No lyrics found for this track") + // Embed lyrics from parallel fetch + if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { + fmt.Printf("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines)) + if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil { + fmt.Printf("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr) } else { - fmt.Printf("[Amazon] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines)) - lrcContent := convertToLRC(lyrics) - if embedErr := EmbedLyrics(outputPath, lrcContent); embedErr != nil { - fmt.Printf("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr) - } else { - fmt.Println("[Amazon] Lyrics embedded successfully") - } + fmt.Println("[Amazon] Lyrics embedded successfully") } + } else if req.EmbedLyrics { + fmt.Println("[Amazon] No lyrics available from parallel fetch") } fmt.Println("[Amazon] ✓ Downloaded successfully from Amazon Music") diff --git a/go_backend/exports.go b/go_backend/exports.go index bf42f4c8..0ecf19d4 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -516,6 +516,56 @@ func EmbedLyricsToFile(filePath, lyrics string) (string, error) { return string(jsonBytes), nil } +// PreWarmTrackCacheJSON pre-warms the track ID cache for album/playlist tracks +// tracksJSON is a JSON array of objects with: isrc, track_name, artist_name, spotify_id, service +// This runs in background and returns immediately +func PreWarmTrackCacheJSON(tracksJSON string) (string, error) { + var tracks []struct { + ISRC string `json:"isrc"` + TrackName string `json:"track_name"` + ArtistName string `json:"artist_name"` + SpotifyID string `json:"spotify_id"` + Service string `json:"service"` + } + + if err := json.Unmarshal([]byte(tracksJSON), &tracks); err != nil { + return errorResponse("Invalid JSON: " + err.Error()) + } + + // Convert to PreWarmCacheRequest + requests := make([]PreWarmCacheRequest, len(tracks)) + for i, t := range tracks { + requests[i] = PreWarmCacheRequest{ + ISRC: t.ISRC, + TrackName: t.TrackName, + ArtistName: t.ArtistName, + SpotifyID: t.SpotifyID, + Service: t.Service, + } + } + + // Run in background + go PreWarmTrackCache(requests) + + resp := map[string]interface{}{ + "success": true, + "message": fmt.Sprintf("Pre-warming cache for %d tracks in background", len(tracks)), + } + + jsonBytes, _ := json.Marshal(resp) + return string(jsonBytes), nil +} + +// GetTrackCacheSize returns the current track ID cache size +func GetTrackCacheSize() int { + return GetCacheSize() +} + +// ClearTrackIDCache clears the track ID cache +func ClearTrackIDCache() { + ClearTrackCache() +} + func errorResponse(msg string) (string, error) { resp := DownloadResponse{ Success: false, diff --git a/go_backend/httputil.go b/go_backend/httputil.go index 8cf634de..5977c4f0 100644 --- a/go_backend/httputil.go +++ b/go_backend/httputil.go @@ -43,6 +43,7 @@ const ( ) // Shared transport with connection pooling to prevent TCP exhaustion +// Optimized for large file downloads (FLAC ~30-50MB) var sharedTransport = &http.Transport{ DialContext: (&net.Dialer{ Timeout: 30 * time.Second, @@ -56,6 +57,9 @@ var sharedTransport = &http.Transport{ ExpectContinueTimeout: 1 * time.Second, DisableKeepAlives: false, // Enable keep-alives for connection reuse ForceAttemptHTTP2: true, + WriteBufferSize: 64 * 1024, // 64KB write buffer + ReadBufferSize: 64 * 1024, // 64KB read buffer + DisableCompression: true, // FLAC is already compressed } // Shared HTTP client for general requests (reuses connections) diff --git a/go_backend/parallel.go b/go_backend/parallel.go new file mode 100644 index 00000000..8e5496e3 --- /dev/null +++ b/go_backend/parallel.go @@ -0,0 +1,288 @@ +package gobackend + +import ( + "fmt" + "sync" + "time" +) + +// ======================================== +// ISRC to Track ID Cache +// ======================================== + +// TrackIDCacheEntry holds cached track ID with metadata +type TrackIDCacheEntry struct { + TidalTrackID int64 + QobuzTrackID int64 + AmazonTrackID string + ExpiresAt time.Time +} + +// TrackIDCache caches ISRC to track ID mappings +type TrackIDCache struct { + cache map[string]*TrackIDCacheEntry + mu sync.RWMutex + ttl time.Duration +} + +var ( + globalTrackIDCache *TrackIDCache + trackIDCacheOnce sync.Once +) + +// GetTrackIDCache returns the global track ID cache +func GetTrackIDCache() *TrackIDCache { + trackIDCacheOnce.Do(func() { + globalTrackIDCache = &TrackIDCache{ + cache: make(map[string]*TrackIDCacheEntry), + ttl: 30 * time.Minute, // Cache for 30 minutes + } + }) + return globalTrackIDCache +} + +// Get retrieves a cached entry by ISRC +func (c *TrackIDCache) Get(isrc string) *TrackIDCacheEntry { + c.mu.RLock() + defer c.mu.RUnlock() + + entry, exists := c.cache[isrc] + if !exists || time.Now().After(entry.ExpiresAt) { + return nil + } + return entry +} + +// SetTidal caches Tidal track ID for an ISRC +func (c *TrackIDCache) SetTidal(isrc string, trackID int64) { + c.mu.Lock() + defer c.mu.Unlock() + + entry, exists := c.cache[isrc] + if !exists { + entry = &TrackIDCacheEntry{} + c.cache[isrc] = entry + } + entry.TidalTrackID = trackID + entry.ExpiresAt = time.Now().Add(c.ttl) +} + +// SetQobuz caches Qobuz track ID for an ISRC +func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) { + c.mu.Lock() + defer c.mu.Unlock() + + entry, exists := c.cache[isrc] + if !exists { + entry = &TrackIDCacheEntry{} + c.cache[isrc] = entry + } + entry.QobuzTrackID = trackID + entry.ExpiresAt = time.Now().Add(c.ttl) +} + +// SetAmazon caches Amazon track ID for an ISRC +func (c *TrackIDCache) SetAmazon(isrc string, trackID string) { + c.mu.Lock() + defer c.mu.Unlock() + + entry, exists := c.cache[isrc] + if !exists { + entry = &TrackIDCacheEntry{} + c.cache[isrc] = entry + } + entry.AmazonTrackID = trackID + entry.ExpiresAt = time.Now().Add(c.ttl) +} + +// Clear removes all cached entries +func (c *TrackIDCache) Clear() { + c.mu.Lock() + defer c.mu.Unlock() + c.cache = make(map[string]*TrackIDCacheEntry) +} + +// Size returns the number of cached entries +func (c *TrackIDCache) Size() int { + c.mu.RLock() + defer c.mu.RUnlock() + return len(c.cache) +} + +// ======================================== +// Parallel Download Helper +// ======================================== + +// ParallelDownloadResult holds results from parallel operations +type ParallelDownloadResult struct { + CoverData []byte + LyricsData *LyricsResponse + LyricsLRC string + CoverErr error + LyricsErr error +} + +// FetchCoverAndLyricsParallel downloads cover and fetches lyrics in parallel +// This runs while the main audio download is happening +func FetchCoverAndLyricsParallel( + coverURL string, + maxQualityCover bool, + spotifyID string, + trackName string, + artistName string, + embedLyrics bool, +) *ParallelDownloadResult { + result := &ParallelDownloadResult{} + var wg sync.WaitGroup + + // Download cover in parallel + if coverURL != "" { + wg.Add(1) + go func() { + defer wg.Done() + fmt.Println("[Parallel] Starting cover download...") + data, err := downloadCoverToMemory(coverURL, maxQualityCover) + if err != nil { + result.CoverErr = err + fmt.Printf("[Parallel] Cover download failed: %v\n", err) + } else { + result.CoverData = data + fmt.Printf("[Parallel] Cover downloaded: %d bytes\n", len(data)) + } + }() + } + + // Fetch lyrics in parallel + if embedLyrics { + wg.Add(1) + go func() { + defer wg.Done() + fmt.Println("[Parallel] Starting lyrics fetch...") + client := NewLyricsClient() + lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName) + if err != nil { + result.LyricsErr = err + fmt.Printf("[Parallel] Lyrics fetch failed: %v\n", err) + } else if lyrics != nil && len(lyrics.Lines) > 0 { + result.LyricsData = lyrics + result.LyricsLRC = convertToLRC(lyrics) + fmt.Printf("[Parallel] Lyrics fetched: %d lines\n", len(lyrics.Lines)) + } else { + result.LyricsErr = fmt.Errorf("no lyrics found") + fmt.Println("[Parallel] No lyrics found") + } + }() + } + + wg.Wait() + return result +} + +// ======================================== +// Pre-warm Cache for Album/Playlist +// ======================================== + +// PreWarmCacheRequest represents a track to pre-warm cache for +type PreWarmCacheRequest struct { + ISRC string + TrackName string + ArtistName string + SpotifyID string // Needed for Amazon (SongLink lookup) + Service string // "tidal", "qobuz", "amazon" +} + +// PreWarmTrackCache pre-fetches track IDs for multiple tracks (for album/playlist) +// This runs in background while user is viewing the track list +func PreWarmTrackCache(requests []PreWarmCacheRequest) { + if len(requests) == 0 { + return + } + + fmt.Printf("[Cache] Pre-warming cache for %d tracks...\n", len(requests)) + cache := GetTrackIDCache() + + // Limit concurrent pre-warm requests + semaphore := make(chan struct{}, 3) // Max 3 concurrent + var wg sync.WaitGroup + + for _, req := range requests { + // Skip if already cached + if cached := cache.Get(req.ISRC); cached != nil { + continue + } + + wg.Add(1) + go func(r PreWarmCacheRequest) { + defer wg.Done() + semaphore <- struct{}{} // Acquire + defer func() { <-semaphore }() // Release + + switch r.Service { + case "tidal": + preWarmTidalCache(r.ISRC, r.TrackName, r.ArtistName) + case "qobuz": + preWarmQobuzCache(r.ISRC) + case "amazon": + preWarmAmazonCache(r.ISRC, r.SpotifyID) + } + }(req) + } + + wg.Wait() + fmt.Printf("[Cache] Pre-warm complete. Cache size: %d\n", cache.Size()) +} + +func preWarmTidalCache(isrc, trackName, artistName string) { + downloader := NewTidalDownloader() + track, err := downloader.SearchTrackByISRC(isrc) + if err == nil && track != nil { + GetTrackIDCache().SetTidal(isrc, track.ID) + fmt.Printf("[Cache] Cached Tidal ID for ISRC %s: %d\n", isrc, track.ID) + } +} + +func preWarmQobuzCache(isrc string) { + downloader := NewQobuzDownloader() + track, err := downloader.SearchTrackByISRC(isrc) + if err == nil && track != nil { + GetTrackIDCache().SetQobuz(isrc, track.ID) + fmt.Printf("[Cache] Cached Qobuz ID for ISRC %s: %d\n", isrc, track.ID) + } +} + +func preWarmAmazonCache(isrc, spotifyID string) { + // Amazon uses SongLink to get URL, so we pre-warm by checking availability + client := NewSongLinkClient() + availability, err := client.CheckTrackAvailability(spotifyID, isrc) + if err == nil && availability != nil && availability.Amazon { + // Store Amazon URL in cache (using ISRC as key) + GetTrackIDCache().SetAmazon(isrc, availability.AmazonURL) + fmt.Printf("[Cache] Cached Amazon URL for ISRC %s\n", isrc) + } +} + +// ======================================== +// Exported Functions for Flutter +// ======================================== + +// PreWarmCache is called from Flutter to pre-warm cache for album/playlist tracks +// tracksJSON is a JSON array of {isrc, track_name, artist_name, service} +func PreWarmCache(tracksJSON string) error { + var requests []PreWarmCacheRequest + // Parse JSON (simplified - in production use proper JSON parsing) + // For now, this is called from exports.go with proper parsing + + go PreWarmTrackCache(requests) // Run in background + return nil +} + +// ClearTrackCache clears the track ID cache +func ClearTrackCache() { + GetTrackIDCache().Clear() + fmt.Println("[Cache] Track ID cache cleared") +} + +// GetCacheSize returns the current cache size +func GetCacheSize() int { + return GetTrackIDCache().Size() +} diff --git a/go_backend/progress.go b/go_backend/progress.go index d86778f9..4b1c13c8 100644 --- a/go_backend/progress.go +++ b/go_backend/progress.go @@ -195,28 +195,39 @@ func getDownloadDir() string { } // ItemProgressWriter wraps io.Writer to track download progress for a specific item +// Uses buffered writing for better performance type ItemProgressWriter struct { writer interface{ Write([]byte) (int, error) } itemID string current int64 + buffer []byte + bufPos int } +const progressWriterBufferSize = 256 * 1024 // 256KB buffer for faster writes + // NewItemProgressWriter creates a new progress writer for a specific item func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter { return &ItemProgressWriter{ writer: w, itemID: itemID, current: 0, + buffer: make([]byte, progressWriterBufferSize), + bufPos: 0, } } -// Write implements io.Writer +// Write implements io.Writer with buffering func (pw *ItemProgressWriter) Write(p []byte) (int, error) { n, err := pw.writer.Write(p) if err != nil { return n, err } pw.current += int64(n) - SetItemBytesReceived(pw.itemID, pw.current) + + // Update progress less frequently (every 64KB) to reduce lock contention + if pw.current%(64*1024) == 0 || pw.current == 0 { + SetItemBytesReceived(pw.itemID, pw.current) + } return n, nil } diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index 9f860ebb..12bcee4c 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -1,6 +1,7 @@ package gobackend import ( + "bufio" "encoding/base64" "encoding/json" "fmt" @@ -10,6 +11,7 @@ import ( "os" "path/filepath" "strings" + "sync" ) // QobuzDownloader handles Qobuz downloads @@ -19,6 +21,12 @@ type QobuzDownloader struct { apiURL string } +var ( + // Global Qobuz downloader instance for connection reuse + globalQobuzDownloader *QobuzDownloader + qobuzDownloaderOnce sync.Once +) + // QobuzTrack represents a Qobuz track type QobuzTrack struct { ID int64 `json:"id"` @@ -97,12 +105,15 @@ func qobuzIsASCIIString(s string) bool { return true } -// NewQobuzDownloader creates a new Qobuz downloader +// NewQobuzDownloader creates a new Qobuz downloader (returns singleton for connection reuse) func NewQobuzDownloader() *QobuzDownloader { - return &QobuzDownloader{ - client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout - appID: "798273057", - } + qobuzDownloaderOnce.Do(func() { + globalQobuzDownloader = &QobuzDownloader{ + client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout + appID: "798273057", + } + }) + return globalQobuzDownloader } // GetAvailableAPIs returns list of available Qobuz APIs @@ -473,13 +484,17 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e } defer out.Close() - // Use item progress writer + // Use buffered writer for better performance (256KB buffer) + bufWriter := bufio.NewWriterSize(out, 256*1024) + defer bufWriter.Flush() + + // Use item progress writer with buffered output if itemID != "" { - progressWriter := NewItemProgressWriter(out, itemID) + progressWriter := NewItemProgressWriter(bufWriter, itemID) _, err = io.Copy(progressWriter, resp.Body) } else { // Fallback: direct copy without progress tracking - _, err = io.Copy(out, resp.Body) + _, err = io.Copy(bufWriter, resp.Body) } return err } @@ -506,8 +521,21 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { var track *QobuzTrack var err error - // Strategy 1: Search by ISRC with duration verification + // OPTIMIZATION: Check cache first for track ID if req.ISRC != "" { + if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 { + fmt.Printf("[Qobuz] Cache hit! Using cached track ID: %d\n", cached.QobuzTrackID) + // For Qobuz we need to search again to get full track info, but we can use the ID + track, err = downloader.SearchTrackByISRC(req.ISRC) + if err != nil { + fmt.Printf("[Qobuz] Cache hit but search failed: %v\n", err) + track = nil + } + } + } + + // Strategy 1: Search by ISRC with duration verification + if track == nil && req.ISRC != "" { track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec) // Verify artist if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) { @@ -536,8 +564,11 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg) } - // Log match found + // Log match found and cache the track ID fmt.Printf("[Qobuz] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, track.Performer.Name, track.Duration) + if req.ISRC != "" { + GetTrackIDCache().SetQobuz(req.ISRC, track.ID) + } // Build filename filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{ @@ -581,11 +612,29 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { return QobuzDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err) } - // Download file with item ID for progress tracking + // START PARALLEL: Fetch cover and lyrics while downloading audio + var parallelResult *ParallelDownloadResult + parallelDone := make(chan struct{}) + go func() { + defer close(parallelDone) + parallelResult = FetchCoverAndLyricsParallel( + req.CoverURL, + req.EmbedMaxQualityCover, + req.SpotifyID, + req.TrackName, + req.ArtistName, + req.EmbedLyrics, + ) + }() + + // Download audio file with item ID for progress tracking if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil { return QobuzDownloadResult{}, fmt.Errorf("download failed: %w", err) } + // Wait for parallel operations to complete + <-parallelDone + // Set progress to 100% and status to finalizing (before embedding) // This makes the UI show "Finalizing..." while embedding happens if req.ItemID != "" { @@ -593,7 +642,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { SetItemFinalizing(req.ItemID) } - // Embed metadata + // Embed metadata using parallel-fetched cover data metadata := Metadata{ Title: req.TrackName, Artist: req.ArtistName, @@ -606,41 +655,27 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { ISRC: req.ISRC, } - // Download cover to memory (avoids file permission issues on Android) + // Use cover data from parallel fetch var coverData []byte - if req.CoverURL != "" { - fmt.Println("[Qobuz] Downloading cover to memory...") - data, err := downloadCoverToMemory(req.CoverURL, req.EmbedMaxQualityCover) - if err == nil { - coverData = data - fmt.Printf("[Qobuz] Cover downloaded successfully (%d bytes)\n", len(coverData)) - } else { - fmt.Printf("[Qobuz] Warning: failed to download cover: %v\n", err) - } + if parallelResult != nil && parallelResult.CoverData != nil { + coverData = parallelResult.CoverData + fmt.Printf("[Qobuz] Using parallel-fetched cover (%d bytes)\n", len(coverData)) } if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil { fmt.Printf("Warning: failed to embed metadata: %v\n", err) } - // Embed lyrics if enabled - if req.EmbedLyrics { - fmt.Println("[Qobuz] Fetching lyrics...") - lyricsClient := NewLyricsClient() - lyrics, lyricsErr := lyricsClient.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName) - if lyricsErr != nil { - fmt.Printf("[Qobuz] Warning: lyrics fetch error: %v\n", lyricsErr) - } else if lyrics == nil || len(lyrics.Lines) == 0 { - fmt.Println("[Qobuz] No lyrics found for this track") + // Embed lyrics from parallel fetch + if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { + fmt.Printf("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines)) + if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil { + fmt.Printf("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr) } else { - fmt.Printf("[Qobuz] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines)) - lrcContent := convertToLRC(lyrics) - if embedErr := EmbedLyrics(outputPath, lrcContent); embedErr != nil { - fmt.Printf("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr) - } else { - fmt.Println("[Qobuz] Lyrics embedded successfully") - } + fmt.Println("[Qobuz] Lyrics embedded successfully") } + } else if req.EmbedLyrics { + fmt.Println("[Qobuz] No lyrics available from parallel fetch") } return QobuzDownloadResult{ diff --git a/go_backend/songlink.go b/go_backend/songlink.go index 64476f71..32d4d05d 100644 --- a/go_backend/songlink.go +++ b/go_backend/songlink.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "net/url" + "sync" "time" ) @@ -25,11 +26,20 @@ type TrackAvailability struct { QobuzURL string `json:"qobuz_url,omitempty"` } -// NewSongLinkClient creates a new SongLink client +var ( + // Global SongLink client instance for connection reuse + globalSongLinkClient *SongLinkClient + songLinkClientOnce sync.Once +) + +// NewSongLinkClient creates a new SongLink client (returns singleton for connection reuse) func NewSongLinkClient() *SongLinkClient { - return &SongLinkClient{ - client: NewHTTPClientWithTimeout(SongLinkTimeout), // 30s timeout - } + songLinkClientOnce.Do(func() { + globalSongLinkClient = &SongLinkClient{ + client: NewHTTPClientWithTimeout(SongLinkTimeout), // 30s timeout + } + }) + return globalSongLinkClient } // CheckTrackAvailability checks track availability on streaming platforms diff --git a/go_backend/tidal.go b/go_backend/tidal.go index 1c392c5e..b1bd47ca 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -1,6 +1,7 @@ package gobackend import ( + "bufio" "encoding/base64" "encoding/json" "encoding/xml" @@ -12,17 +13,27 @@ import ( "path/filepath" "regexp" "strings" + "sync" "time" ) // TidalDownloader handles Tidal downloads type TidalDownloader struct { - client *http.Client - clientID string - clientSecret string - apiURL string + client *http.Client + clientID string + clientSecret string + apiURL string + cachedToken string + tokenExpiresAt time.Time + tokenMu sync.Mutex } +var ( + // Global Tidal downloader instance for token reuse + globalTidalDownloader *TidalDownloader + tidalDownloaderOnce sync.Once +) + // TidalTrack represents a Tidal track type TidalTrack struct { ID int64 `json:"id"` @@ -93,24 +104,25 @@ type MPD struct { } `xml:"Period"` } -// NewTidalDownloader creates a new Tidal downloader +// NewTidalDownloader creates a new Tidal downloader (returns singleton for token reuse) func NewTidalDownloader() *TidalDownloader { - clientID, _ := base64.StdEncoding.DecodeString("NkJEU1JkcEs5aHFFQlRnVQ==") - clientSecret, _ := base64.StdEncoding.DecodeString("eGV1UG1ZN25icFo5SUliTEFjUTkzc2hrYTFWTmhlVUFxTjZJY3N6alRHOD0=") + tidalDownloaderOnce.Do(func() { + clientID, _ := base64.StdEncoding.DecodeString("NkJEU1JkcEs5aHFFQlRnVQ==") + clientSecret, _ := base64.StdEncoding.DecodeString("eGV1UG1ZN25icFo5SUliTEFjUTkzc2hrYTFWTmhlVUFxTjZJY3N6alRHOD0=") - downloader := &TidalDownloader{ - client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout - clientID: string(clientID), - clientSecret: string(clientSecret), - } + globalTidalDownloader = &TidalDownloader{ + client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout + clientID: string(clientID), + clientSecret: string(clientSecret), + } - // Get first available API - apis := downloader.GetAvailableAPIs() - if len(apis) > 0 { - downloader.apiURL = apis[0] - } - - return downloader + // Get first available API + apis := globalTidalDownloader.GetAvailableAPIs() + if len(apis) > 0 { + globalTidalDownloader.apiURL = apis[0] + } + }) + return globalTidalDownloader } // GetAvailableAPIs returns list of available Tidal APIs @@ -138,8 +150,16 @@ func (t *TidalDownloader) GetAvailableAPIs() []string { return apis } -// GetAccessToken gets Tidal access token +// GetAccessToken gets Tidal access token (with caching) func (t *TidalDownloader) GetAccessToken() (string, error) { + t.tokenMu.Lock() + defer t.tokenMu.Unlock() + + // Return cached token if still valid (with 60s buffer) + if t.cachedToken != "" && time.Now().Add(60*time.Second).Before(t.tokenExpiresAt) { + return t.cachedToken, nil + } + data := fmt.Sprintf("client_id=%s&grant_type=client_credentials", t.clientID) authURL, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hdXRoLnRpZGFsLmNvbS92MS9vYXV0aDIvdG9rZW4=") @@ -163,12 +183,21 @@ func (t *TidalDownloader) GetAccessToken() (string, error) { var result struct { AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return "", err } + // Cache the token + t.cachedToken = result.AccessToken + if result.ExpiresIn > 0 { + t.tokenExpiresAt = time.Now().Add(time.Duration(result.ExpiresIn) * time.Second) + } else { + t.tokenExpiresAt = time.Now().Add(55 * time.Minute) // Default 55 min + } + return result.AccessToken, nil } @@ -728,13 +757,17 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e } defer out.Close() - // Use item progress writer + // Use buffered writer for better performance (256KB buffer) + bufWriter := bufio.NewWriterSize(out, 256*1024) + defer bufWriter.Flush() + + // Use item progress writer with buffered output if itemID != "" { - progressWriter := NewItemProgressWriter(out, itemID) + progressWriter := NewItemProgressWriter(bufWriter, itemID) _, err = io.Copy(progressWriter, resp.Body) } else { // Fallback: direct copy without progress tracking - _, err = io.Copy(out, resp.Body) + _, err = io.Copy(bufWriter, resp.Body) } return err } @@ -942,8 +975,44 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { var track *TidalTrack var err error - // Strategy 1: Try to get Tidal URL from SongLink (using Spotify ID) - if req.SpotifyID != "" { + // OPTIMIZATION: Check cache first for track ID + if req.ISRC != "" { + if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.TidalTrackID > 0 { + fmt.Printf("[Tidal] Cache hit! Using cached track ID: %d\n", cached.TidalTrackID) + track, err = downloader.GetTrackInfoByID(cached.TidalTrackID) + if err != nil { + fmt.Printf("[Tidal] Cache hit but failed to get track info: %v\n", err) + track = nil // Fall through to normal search + } + } + } + + // OPTIMIZED: Try ISRC search first (faster than SongLink API) + // Strategy 1: Search by ISRC with duration verification (FASTEST) + if track == nil && req.ISRC != "" { + fmt.Printf("[Tidal] Trying ISRC search first (faster): %s\n", req.ISRC) + track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, req.ISRC, expectedDurationSec) + // Verify artist for ISRC match + if track != nil { + tidalArtist := track.Artist.Name + if len(track.Artists) > 0 { + var artistNames []string + for _, a := range track.Artists { + artistNames = append(artistNames, a.Name) + } + tidalArtist = strings.Join(artistNames, ", ") + } + if !artistsMatch(req.ArtistName, tidalArtist) { + fmt.Printf("[Tidal] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n", + req.ArtistName, tidalArtist) + track = nil + } + } + } + + // Strategy 2: Try SongLink only if ISRC search failed (slower but more accurate) + if track == nil && req.SpotifyID != "" { + fmt.Printf("[Tidal] ISRC search failed, trying SongLink...\n") tidalURL, slErr := downloader.GetTidalURLFromSpotify(req.SpotifyID) if slErr == nil && tidalURL != "" { // Extract track ID and get track info @@ -986,29 +1055,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { } } - // Strategy 2: Search by ISRC with duration verification - if track == nil && req.ISRC != "" { - track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, req.ISRC, expectedDurationSec) - // Verify artist for ISRC match too - if track != nil { - tidalArtist := track.Artist.Name - if len(track.Artists) > 0 { - var artistNames []string - for _, a := range track.Artists { - artistNames = append(artistNames, a.Name) - } - tidalArtist = strings.Join(artistNames, ", ") - } - if !artistsMatch(req.ArtistName, tidalArtist) { - fmt.Printf("[Tidal] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n", - req.ArtistName, tidalArtist) - track = nil - } - } - } - - // Strategy 3: Search by metadata only (no ISRC requirement) + // Strategy 3: Search by metadata only (no ISRC requirement) - last resort if track == nil { + fmt.Printf("[Tidal] Trying metadata search as last resort...\n") track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, "", expectedDurationSec) // Verify artist for metadata search too if track != nil { @@ -1047,6 +1096,11 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { } fmt.Printf("[Tidal] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, tidalArtist, track.Duration) + // Cache the track ID for future use + if req.ISRC != "" { + GetTrackIDCache().SetTidal(req.ISRC, track.ID) + } + // Build filename filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{ "title": req.TrackName, @@ -1080,11 +1134,29 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { // Log actual quality received fmt.Printf("[Tidal] Actual quality: %d-bit/%dHz\n", downloadInfo.BitDepth, downloadInfo.SampleRate) - // Download file with item ID for progress tracking + // START PARALLEL: Fetch cover and lyrics while downloading audio + var parallelResult *ParallelDownloadResult + parallelDone := make(chan struct{}) + go func() { + defer close(parallelDone) + parallelResult = FetchCoverAndLyricsParallel( + req.CoverURL, + req.EmbedMaxQualityCover, + req.SpotifyID, + req.TrackName, + req.ArtistName, + req.EmbedLyrics, + ) + }() + + // Download audio file with item ID for progress tracking if err := downloader.DownloadFile(downloadInfo.URL, outputPath, req.ItemID); err != nil { return TidalDownloadResult{}, fmt.Errorf("download failed: %w", err) } + // Wait for parallel operations to complete + <-parallelDone + // Set progress to 100% and status to finalizing (before embedding) // This makes the UI show "Finalizing..." while embedding happens if req.ItemID != "" { @@ -1105,7 +1177,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { return TidalDownloadResult{}, fmt.Errorf("download completed but file not found at %s or %s", outputPath, m4aPath) } - // Embed metadata + // Embed metadata using parallel-fetched cover data metadata := Metadata{ Title: req.TrackName, Artist: req.ArtistName, @@ -1118,17 +1190,11 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { ISRC: req.ISRC, } - // Download cover to memory (avoids file permission issues on Android) + // Use cover data from parallel fetch var coverData []byte - if req.CoverURL != "" { - fmt.Println("[Tidal] Downloading cover to memory...") - data, err := downloadCoverToMemory(req.CoverURL, req.EmbedMaxQualityCover) - if err == nil { - coverData = data - fmt.Printf("[Tidal] Cover downloaded successfully (%d bytes)\n", len(coverData)) - } else { - fmt.Printf("[Tidal] Warning: failed to download cover: %v\n", err) - } + if parallelResult != nil && parallelResult.CoverData != nil { + coverData = parallelResult.CoverData + fmt.Printf("[Tidal] Using parallel-fetched cover (%d bytes)\n", len(coverData)) } // Only embed metadata to FLAC files (M4A will be converted by Flutter) @@ -1137,24 +1203,16 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { fmt.Printf("Warning: failed to embed metadata: %v\n", err) } - // Embed lyrics if enabled - if req.EmbedLyrics { - fmt.Println("[Tidal] Fetching lyrics...") - lyricsClient := NewLyricsClient() - lyrics, lyricsErr := lyricsClient.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName) - if lyricsErr != nil { - fmt.Printf("[Tidal] Warning: lyrics fetch error: %v\n", lyricsErr) - } else if lyrics == nil || len(lyrics.Lines) == 0 { - fmt.Println("[Tidal] No lyrics found for this track") + // Embed lyrics from parallel fetch + if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { + fmt.Printf("[Tidal] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines)) + if embedErr := EmbedLyrics(actualOutputPath, parallelResult.LyricsLRC); embedErr != nil { + fmt.Printf("[Tidal] Warning: failed to embed lyrics: %v\n", embedErr) } else { - fmt.Printf("[Tidal] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines)) - lrcContent := convertToLRC(lyrics) - if embedErr := EmbedLyrics(actualOutputPath, lrcContent); embedErr != nil { - fmt.Printf("[Tidal] Warning: failed to embed lyrics: %v\n", embedErr) - } else { - fmt.Println("[Tidal] Lyrics embedded successfully") - } + fmt.Println("[Tidal] Lyrics embedded successfully") } + } else if req.EmbedLyrics { + fmt.Println("[Tidal] No lyrics available from parallel fetch") } } else { fmt.Printf("[Tidal] Skipping metadata embed for M4A file (will be handled after conversion): %s\n", actualOutputPath) diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index 7b94baa0..a371e298 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -1,8 +1,8 @@ /// App version and info constants /// Update version here only - all other files will reference this class AppInfo { - static const String version = '2.0.7-preview2'; - static const String buildNumber = '37'; + static const String version = '2.1.0-preview'; + static const String buildNumber = '39'; static const String fullVersion = '$version+$buildNumber'; diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index af6f48ba..89471ffa 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -149,6 +149,8 @@ class TrackNotifier extends Notifier { albumName: albumInfo['name'] as String?, coverUrl: albumInfo['images'] as String?, ); + // Pre-warm cache for album tracks in background + _preWarmCacheForTracks(tracks); } else if (type == 'playlist') { final playlistInfo = metadata['playlist_info'] as Map; final trackList = metadata['track_list'] as List; @@ -160,6 +162,8 @@ class TrackNotifier extends Notifier { playlistName: owner?['name'] as String?, coverUrl: owner?['images'] as String?, ); + // Pre-warm cache for playlist tracks in background + _preWarmCacheForTracks(tracks); } else if (type == 'artist') { final artistInfo = metadata['artist_info'] as Map; final albumsList = metadata['albums'] as List; @@ -310,6 +314,28 @@ class TrackNotifier extends Notifier { popularity: data['popularity'] as int? ?? 0, ); } + + /// Pre-warm track ID cache for faster downloads + /// Runs in background, doesn't block UI + void _preWarmCacheForTracks(List tracks) { + // Only pre-warm if we have tracks with ISRC + final tracksWithIsrc = tracks.where((t) => t.isrc != null && t.isrc!.isNotEmpty).toList(); + if (tracksWithIsrc.isEmpty) return; + + // Build request list for Go backend + final cacheRequests = tracksWithIsrc.map((t) => { + 'isrc': t.isrc!, + 'track_name': t.name, + 'artist_name': t.artistName, + 'spotify_id': t.id, // Include Spotify ID for Amazon lookup + 'service': 'tidal', // Default to tidal for pre-warming + }).toList(); + + // Fire and forget - runs in background + PlatformBridge.preWarmTrackCache(cacheRequests).catchError((_) { + // Silently ignore errors - this is just an optimization + }); + } } final trackProvider = NotifierProvider( diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index c1f6f2f8..5ee2da00 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -297,4 +297,23 @@ class PlatformBridge { 'client_secret': clientSecret, }); } + + /// Pre-warm track ID cache for album/playlist tracks + /// This runs in background and returns immediately + /// Speeds up subsequent downloads by caching ISRC → Track ID mappings + static Future preWarmTrackCache(List> tracks) async { + final tracksJson = jsonEncode(tracks); + await _channel.invokeMethod('preWarmTrackCache', {'tracks': tracksJson}); + } + + /// Get current track cache size + static Future getTrackCacheSize() async { + final result = await _channel.invokeMethod('getTrackCacheSize'); + return result as int; + } + + /// Clear track ID cache + static Future clearTrackCache() async { + await _channel.invokeMethod('clearTrackCache'); + } } diff --git a/pubspec.yaml b/pubspec.yaml index 46b1529e..c8062a57 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: 'none' -version: 2.0.7-preview2+38 +version: 2.1.0-preview+39 environment: sdk: ^3.10.0 diff --git a/pubspec_ios.yaml b/pubspec_ios.yaml index 0706750f..aaac68d7 100644 --- a/pubspec_ios.yaml +++ b/pubspec_ios.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: 'none' -version: 2.0.7-preview2+38 +version: 2.1.0-preview+39 environment: sdk: ^3.10.0