From 7ade57e0102de5f52c37835e862c9c7a26c6b6fb Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 5 Feb 2026 09:12:25 +0700 Subject: [PATCH] perf: optimize all providers for mobile networks with retry logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add retry logic with exponential backoff to all providers (Qobuz, Tidal, Amazon, Deezer) - Increase API timeouts: 15s → 25s (Qobuz/Tidal/Deezer), 30s (Amazon) - Extract QobuzID/TidalID directly from SongLink URLs - Add SongLink lookup strategy before ISRC search in Qobuz - Cache hit now uses GetTrackByID() directly instead of re-searching - Pre-warm cache tries SongLink first before direct ISRC search --- CHANGELOG.md | 18 +++ go_backend/amazon.go | 117 +++++++++++++---- go_backend/deezer.go | 43 ++++++- go_backend/parallel.go | 66 ++++++++-- go_backend/qobuz.go | 211 ++++++++++++++++++++++--------- go_backend/songlink.go | 156 ++++++++++++++++++----- go_backend/tidal.go | 281 +++++++++++++++++++++++++++-------------- 7 files changed, 668 insertions(+), 224 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca83bf5f..74d7f36d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [3.4.2] - 2026-02-04 + +### Improved + +- **Mobile Network Reliability**: All providers (Qobuz, Tidal, Amazon, Deezer) now have retry logic with exponential backoff + - Increased API timeouts: 15s → 25s (Deezer, Qobuz, Tidal), 30s (Amazon) + - Up to 3 retry attempts per API call (500ms → 1s → 2s backoff) + - Retryable: timeout, connection reset/refused, EOF, HTTP 5xx, HTTP 429 +- **SongLink ID Extraction**: Extract QobuzID/TidalID directly from SongLink URLs + - New fields in `TrackAvailability`: `QobuzID`, `TidalID` + - Qobuz/Tidal now use direct Track ID from SongLink instead of re-parsing URLs +- **Qobuz Download Flow**: New Strategy 3 - get QobuzID from SongLink before ISRC search + - Cache hit now uses `GetTrackByID()` directly instead of searching again + - Pre-warm cache tries SongLink first before direct ISRC search +- **Tidal Download Flow**: Use `availability.TidalID` directly from SongLink struct + +--- + ## [3.4.1] - 2026-02-04 ### Fixed diff --git a/go_backend/amazon.go b/go_backend/amazon.go index 0349d936..3e6e40de 100644 --- a/go_backend/amazon.go +++ b/go_backend/amazon.go @@ -17,6 +17,13 @@ import ( "time" ) +// Amazon API timeout and retry configuration for mobile networks +const ( + amazonAPITimeoutMobile = 30 * time.Second // Longer timeout for unstable mobile networks + amazonMaxRetries = 2 // Number of retry attempts + amazonRetryDelay = 500 * time.Millisecond +) + type AmazonDownloader struct { client *http.Client } @@ -36,15 +43,6 @@ type AfkarXYZResponse struct { } `json:"data"` } -func amazonIsASCIIString(s string) bool { - for _, r := range s { - if r > 127 { - return false - } - } - return true -} - func NewAmazonDownloader() *AmazonDownloader { amazonDownloaderOnce.Do(func() { globalAmazonDownloader = &AmazonDownloader{ @@ -54,12 +52,50 @@ func NewAmazonDownloader() *AmazonDownloader { return globalAmazonDownloader } -func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, error) { +// fetchAmazonURLWithRetry fetches from AfkarXYZ API with retry logic for mobile networks +func (a *AmazonDownloader) fetchAmazonURLWithRetry(amazonURL string) (string, string, error) { apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL) - GoLog("[Amazon] Fetching from AfkarXYZ API...\n") + var lastErr error + for attempt := 0; attempt <= amazonMaxRetries; attempt++ { + if attempt > 0 { + delay := amazonRetryDelay * time.Duration(1<<(attempt-1)) // Exponential backoff + GoLog("[Amazon] Retry %d/%d after %v...\n", attempt, amazonMaxRetries, delay) + time.Sleep(delay) + } - req, err := http.NewRequest("GET", apiURL, nil) + downloadURL, fileName, err := a.doAfkarXYZRequest(apiURL) + if err == nil { + return downloadURL, fileName, nil + } + + lastErr = err + errStr := err.Error() + + // Check if error is retryable + isRetryable := strings.Contains(errStr, "timeout") || + strings.Contains(errStr, "connection reset") || + strings.Contains(errStr, "connection refused") || + strings.Contains(errStr, "EOF") || + strings.Contains(errStr, "status 5") || + strings.Contains(errStr, "status 429") + + if !isRetryable { + return "", "", err + } + + GoLog("[Amazon] Attempt %d failed (retryable): %v\n", attempt+1, err) + } + + return "", "", fmt.Errorf("all %d attempts failed: %w", amazonMaxRetries+1, lastErr) +} + +// doAfkarXYZRequest performs a single request to AfkarXYZ API +func (a *AmazonDownloader) doAfkarXYZRequest(apiURL string) (string, string, error) { + ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) if err != nil { return "", "", fmt.Errorf("failed to create request: %w", err) } @@ -98,11 +134,21 @@ func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, strin reg := regexp.MustCompile(`[<>:"/\\|?*]`) fileName = reg.ReplaceAllString(fileName, "") - GoLog("[Amazon] AfkarXYZ returned: %s (%.2f MB)\n", fileName, float64(apiResp.Data.FileSize)/(1024*1024)) - return apiResp.Data.DirectLink, fileName, nil } +func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, error) { + GoLog("[Amazon] Fetching from AfkarXYZ API...\n") + + downloadURL, fileName, err := a.fetchAmazonURLWithRetry(amazonURL) + if err != nil { + return "", "", err + } + + GoLog("[Amazon] AfkarXYZ returned: %s\n", fileName) + return downloadURL, fileName, nil +} + func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error { ctx := context.Background() @@ -206,25 +252,40 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil } + amazonURL := "" + if req.ISRC != "" { + if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.AmazonURL != "" { + amazonURL = cached.AmazonURL + GoLog("[Amazon] Cache hit! Using cached Amazon URL for ISRC %s\n", req.ISRC) + } + } + songlink := NewSongLinkClient() var availability *TrackAvailability var err error - if deezerID, found := strings.CutPrefix(req.SpotifyID, "deezer:"); found { - GoLog("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID) - availability, err = songlink.CheckAvailabilityFromDeezer(deezerID) - } else if req.SpotifyID != "" { - availability, err = songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC) - } else { - return AmazonDownloadResult{}, fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup") - } + if amazonURL == "" { + if deezerID, found := strings.CutPrefix(req.SpotifyID, "deezer:"); found { + GoLog("[Amazon] Using Deezer ID for SongLink lookup: %s\n", deezerID) + availability, err = songlink.CheckAvailabilityFromDeezer(deezerID) + } else if req.SpotifyID != "" { + availability, err = songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC) + } else { + return AmazonDownloadResult{}, fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup") + } - if err != nil { - return AmazonDownloadResult{}, fmt.Errorf("failed to check Amazon availability via SongLink: %w", err) - } + if err != nil { + return AmazonDownloadResult{}, fmt.Errorf("failed to check Amazon availability via SongLink: %w", err) + } - if !availability.Amazon || availability.AmazonURL == "" { - return AmazonDownloadResult{}, fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)") + if !availability.Amazon || availability.AmazonURL == "" { + return AmazonDownloadResult{}, fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)") + } + + amazonURL = availability.AmazonURL + if req.ISRC != "" { + GetTrackIDCache().SetAmazonURL(req.ISRC, amazonURL) + } } if req.OutputDir != "." { @@ -234,7 +295,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { } // Download using AfkarXYZ API - downloadURL, _, err := downloader.downloadFromAfkarXYZ(availability.AmazonURL) + downloadURL, _, err := downloader.downloadFromAfkarXYZ(amazonURL) if err != nil { return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL from AfkarXYZ: %w", err) } diff --git a/go_backend/deezer.go b/go_backend/deezer.go index 5d26c391..9f1c4430 100644 --- a/go_backend/deezer.go +++ b/go_backend/deezer.go @@ -23,6 +23,11 @@ const ( deezerCacheTTL = 10 * time.Minute deezerMaxParallelISRC = 10 + + // Deezer API timeout and retry configuration for mobile networks + deezerAPITimeoutMobile = 25 * time.Second + deezerMaxRetries = 2 + deezerRetryDelay = 500 * time.Millisecond ) type DeezerClient struct { @@ -42,7 +47,7 @@ var ( func GetDeezerClient() *DeezerClient { deezerClientOnce.Do(func() { deezerClient = &DeezerClient{ - httpClient: NewHTTPClientWithTimeout(15 * time.Second), + httpClient: NewHTTPClientWithTimeout(deezerAPITimeoutMobile), searchCache: make(map[string]*cacheEntry), albumCache: make(map[string]*cacheEntry), artistCache: make(map[string]*cacheEntry), @@ -992,6 +997,42 @@ func (c *DeezerClient) GetExtendedMetadataByISRC(ctx context.Context, isrc strin } func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interface{}) error { + var lastErr error + + for attempt := 0; attempt <= deezerMaxRetries; attempt++ { + if attempt > 0 { + delay := deezerRetryDelay * time.Duration(1<<(attempt-1)) // Exponential backoff + GoLog("[Deezer] Retry %d/%d after %v...\n", attempt, deezerMaxRetries, delay) + time.Sleep(delay) + } + + err := c.doGetJSON(ctx, endpoint, dst) + if err == nil { + return nil + } + + lastErr = err + errStr := err.Error() + + // Check if error is retryable + isRetryable := strings.Contains(errStr, "timeout") || + strings.Contains(errStr, "connection reset") || + strings.Contains(errStr, "connection refused") || + strings.Contains(errStr, "EOF") || + strings.Contains(errStr, "status 5") || + strings.Contains(errStr, "status 429") + + if !isRetryable { + return err + } + + GoLog("[Deezer] Attempt %d failed (retryable): %v\n", attempt+1, err) + } + + return fmt.Errorf("all %d attempts failed: %w", deezerMaxRetries+1, lastErr) +} + +func (c *DeezerClient) doGetJSON(ctx context.Context, endpoint string, dst interface{}) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return err diff --git a/go_backend/parallel.go b/go_backend/parallel.go index 9f7c7030..b275ade9 100644 --- a/go_backend/parallel.go +++ b/go_backend/parallel.go @@ -1,6 +1,7 @@ package gobackend import ( + "encoding/json" "fmt" "sync" "time" @@ -9,7 +10,7 @@ import ( type TrackIDCacheEntry struct { TidalTrackID int64 QobuzTrackID int64 - AmazonTrackID string + AmazonURL string ExpiresAt time.Time } @@ -106,7 +107,7 @@ func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) { } } -func (c *TrackIDCache) SetAmazon(isrc string, trackID string) { +func (c *TrackIDCache) SetAmazonURL(isrc string, amazonURL string) { c.mu.Lock() defer c.mu.Unlock() @@ -115,7 +116,7 @@ func (c *TrackIDCache) SetAmazon(isrc string, trackID string) { entry = &TrackIDCacheEntry{} c.cache[isrc] = entry } - entry.AmazonTrackID = trackID + entry.AmazonURL = amazonURL now := time.Now() entry.ExpiresAt = now.Add(c.ttl) @@ -156,17 +157,20 @@ func FetchCoverAndLyricsParallel( ) *ParallelDownloadResult { result := &ParallelDownloadResult{} var wg sync.WaitGroup + var resultMu sync.Mutex if coverURL != "" { wg.Add(1) go func() { defer wg.Done() data, err := downloadCoverToMemory(coverURL, maxQualityCover) + resultMu.Lock() if err != nil { result.CoverErr = err } else { result.CoverData = data } + resultMu.Unlock() }() } @@ -177,6 +181,7 @@ func FetchCoverAndLyricsParallel( client := NewLyricsClient() durationSec := float64(durationMs) / 1000.0 lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName, durationSec) + resultMu.Lock() if err != nil { result.LyricsErr = err } else if lyrics != nil && len(lyrics.Lines) > 0 { @@ -185,6 +190,7 @@ func FetchCoverAndLyricsParallel( } else { result.LyricsErr = fmt.Errorf("no lyrics found") } + resultMu.Unlock() }() } @@ -211,6 +217,9 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) { var wg sync.WaitGroup for _, req := range requests { + if req.ISRC == "" { + continue + } if cached := cache.Get(req.ISRC); cached != nil { continue } @@ -225,7 +234,7 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) { case "tidal": preWarmTidalCache(r.ISRC, r.TrackName, r.ArtistName) case "qobuz": - preWarmQobuzCache(r.ISRC) + preWarmQobuzCache(r.ISRC, r.SpotifyID) case "amazon": preWarmAmazonCache(r.ISRC, r.SpotifyID) } @@ -243,10 +252,30 @@ func preWarmTidalCache(isrc, _, _ string) { } } -func preWarmQobuzCache(isrc string) { +// preWarmQobuzCache tries to get Qobuz Track ID in the following order: +// 1. From SongLink (fast, no Qobuz API call needed) +// 2. Direct ISRC search on Qobuz API (slower, may fail if ISRC not in Qobuz database) +func preWarmQobuzCache(isrc, spotifyID string) { + // First, try to get QobuzID from SongLink - this is faster and more reliable + if spotifyID != "" { + client := NewSongLinkClient() + availability, err := client.CheckTrackAvailability(spotifyID, isrc) + if err == nil && availability != nil && availability.QobuzID != "" { + // Parse QobuzID to int64 + var trackID int64 + if _, parseErr := fmt.Sscanf(availability.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 { + GoLog("[Qobuz] Pre-warm cache: Got Qobuz ID %d from SongLink for ISRC %s\n", trackID, isrc) + GetTrackIDCache().SetQobuz(isrc, trackID) + return + } + } + } + + // Fallback: Direct ISRC search on Qobuz API downloader := NewQobuzDownloader() track, err := downloader.SearchTrackByISRC(isrc) if err == nil && track != nil { + GoLog("[Qobuz] Pre-warm cache: Got Qobuz ID %d from direct ISRC search for %s\n", track.ID, isrc) GetTrackIDCache().SetQobuz(isrc, track.ID) } } @@ -254,13 +283,34 @@ func preWarmQobuzCache(isrc string) { func preWarmAmazonCache(isrc, spotifyID string) { client := NewSongLinkClient() availability, err := client.CheckTrackAvailability(spotifyID, isrc) - if err == nil && availability != nil && availability.Amazon { - GetTrackIDCache().SetAmazon(isrc, availability.AmazonURL) + if err == nil && availability != nil && availability.AmazonURL != "" { + GetTrackIDCache().SetAmazonURL(isrc, availability.AmazonURL) } } func PreWarmCache(tracksJSON string) error { - var requests []PreWarmCacheRequest + 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 fmt.Errorf("failed to parse tracks JSON: %w", err) + } + + 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, + } + } go PreWarmTrackCache(requests) return nil diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index c3132fa6..a8c38b8a 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -725,75 +725,141 @@ type qobuzAPIResult struct { duration time.Duration } +// Qobuz API timeout configuration +// Mobile networks are more unstable, so we use longer timeouts +const ( + qobuzAPITimeoutDesktop = 15 * time.Second + qobuzAPITimeoutMobile = 25 * time.Second + qobuzMaxRetries = 2 // Number of retries per API + qobuzRetryDelay = 500 * time.Millisecond +) + +// getQobuzAPITimeout returns appropriate timeout based on platform +// For mobile (gomobile builds), we use longer timeouts +func getQobuzAPITimeout() time.Duration { + // Since this runs in gomobile context, we always use mobile timeout + // The Go backend is only used on mobile (Android/iOS) + return qobuzAPITimeoutMobile +} + +// fetchQobuzURLWithRetry fetches download URL from a single Qobuz API with retry logic +func fetchQobuzURLWithRetry(api string, trackID int64, quality string, timeout time.Duration) (string, error) { + var lastErr error + retryDelay := qobuzRetryDelay + + for attempt := 0; attempt <= qobuzMaxRetries; attempt++ { + if attempt > 0 { + GoLog("[Qobuz] Retry %d/%d for %s after %v\n", attempt, qobuzMaxRetries, api, retryDelay) + time.Sleep(retryDelay) + retryDelay *= 2 // Exponential backoff + } + + client := NewHTTPClientWithTimeout(timeout) + reqURL := fmt.Sprintf("%s%d&quality=%s", api, trackID, quality) + + req, err := http.NewRequest("GET", reqURL, nil) + if err != nil { + lastErr = err + continue + } + + resp, err := client.Do(req) + if err != nil { + lastErr = err + // Check for retryable errors (timeout, connection reset) + errStr := strings.ToLower(err.Error()) + if strings.Contains(errStr, "timeout") || + strings.Contains(errStr, "reset") || + strings.Contains(errStr, "connection refused") || + strings.Contains(errStr, "eof") { + continue // Retry + } + break // Non-retryable error + } + // Server errors are retryable + if resp.StatusCode >= 500 { + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + lastErr = fmt.Errorf("HTTP %d", resp.StatusCode) + continue + } + + // 429 rate limit - wait and retry + if resp.StatusCode == 429 { + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + lastErr = fmt.Errorf("rate limited") + retryDelay = 2 * time.Second // Wait longer for rate limit + continue + } + + if resp.StatusCode != 200 { + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + return "", fmt.Errorf("HTTP %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + lastErr = err + continue + } + + if len(body) > 0 && body[0] == '<' { + return "", fmt.Errorf("received HTML instead of JSON") + } + + var errorResp struct { + Error string `json:"error"` + } + if json.Unmarshal(body, &errorResp) == nil && errorResp.Error != "" { + // API-level errors are usually not retryable (track not found, etc.) + return "", fmt.Errorf("%s", errorResp.Error) + } + + var result struct { + URL string `json:"url"` + } + if err := json.Unmarshal(body, &result); err != nil { + lastErr = fmt.Errorf("invalid JSON: %v", err) + continue + } + + if result.URL != "" { + return result.URL, nil + } + + return "", fmt.Errorf("no download URL in response") + } + + if lastErr != nil { + return "", lastErr + } + return "", fmt.Errorf("all retries failed") +} + func getQobuzDownloadURLParallel(apis []string, trackID int64, quality string) (string, string, error) { if len(apis) == 0 { return "", "", fmt.Errorf("no APIs available") } - GoLog("[Qobuz] Requesting download URL from %d APIs in parallel...\n", len(apis)) + GoLog("[Qobuz] Requesting download URL from %d APIs in parallel (with retry)...\n", len(apis)) resultChan := make(chan qobuzAPIResult, len(apis)) startTime := time.Now() + timeout := getQobuzAPITimeout() for _, apiURL := range apis { go func(api string) { reqStart := time.Now() - - client := NewHTTPClientWithTimeout(15 * time.Second) - - reqURL := fmt.Sprintf("%s%d&quality=%s", api, trackID, quality) - - req, err := http.NewRequest("GET", reqURL, nil) - if err != nil { - resultChan <- qobuzAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)} - return + downloadURL, err := fetchQobuzURLWithRetry(api, trackID, quality, timeout) + resultChan <- qobuzAPIResult{ + apiURL: api, + downloadURL: downloadURL, + err: err, + duration: time.Since(reqStart), } - - resp, err := client.Do(req) - if err != nil { - resultChan <- qobuzAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)} - return - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("HTTP %d", resp.StatusCode), duration: time.Since(reqStart)} - return - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - resultChan <- qobuzAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)} - return - } - - if len(body) > 0 && body[0] == '<' { - resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("received HTML instead of JSON"), duration: time.Since(reqStart)} - return - } - - var errorResp struct { - Error string `json:"error"` - } - if json.Unmarshal(body, &errorResp) == nil && errorResp.Error != "" { - resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("%s", errorResp.Error), duration: time.Since(reqStart)} - return - } - - var result struct { - URL string `json:"url"` - } - if err := json.Unmarshal(body, &result); err != nil { - resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("invalid JSON: %v", err), duration: time.Since(reqStart)} - return - } - - if result.URL != "" { - resultChan <- qobuzAPIResult{apiURL: api, downloadURL: result.URL, err: nil, duration: time.Since(reqStart)} - return - } - - resultChan <- qobuzAPIResult{apiURL: api, err: fmt.Errorf("no download URL in response"), duration: time.Since(reqStart)} }(apiURL) } @@ -964,6 +1030,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { var track *QobuzTrack var err error + // Strategy 1: Use Qobuz ID from Odesli enrichment (fastest, most accurate) if req.QobuzID != "" { GoLog("[Qobuz] Using Qobuz ID from Odesli enrichment: %s\n", req.QobuzID) var trackID int64 @@ -978,17 +1045,43 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { } } + // Strategy 2: Use cached Qobuz Track ID (fast, no search needed) if track == nil && req.ISRC != "" { if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 { GoLog("[Qobuz] Cache hit! Using cached track ID: %d\n", cached.QobuzTrackID) - track, err = downloader.SearchTrackByISRC(req.ISRC) + track, err = downloader.GetTrackByID(cached.QobuzTrackID) if err != nil { - GoLog("[Qobuz] Cache hit but search failed: %v\n", err) + GoLog("[Qobuz] Cache hit but GetTrackByID failed: %v\n", err) track = nil } } } + // Strategy 3: Try to get QobuzID from SongLink if we have SpotifyID + if track == nil && req.SpotifyID != "" && req.QobuzID == "" { + GoLog("[Qobuz] Trying to get Qobuz ID from SongLink for Spotify ID: %s\n", req.SpotifyID) + songLinkClient := NewSongLinkClient() + availability, slErr := songLinkClient.CheckTrackAvailability(req.SpotifyID, req.ISRC) + if slErr == nil && availability != nil && availability.QobuzID != "" { + var trackID int64 + if _, parseErr := fmt.Sscanf(availability.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 { + GoLog("[Qobuz] Got Qobuz ID %d from SongLink\n", trackID) + track, err = downloader.GetTrackByID(trackID) + if err != nil { + GoLog("[Qobuz] Failed to get track by SongLink ID %d: %v\n", trackID, err) + track = nil + } else if track != nil { + GoLog("[Qobuz] Successfully found track via SongLink ID: '%s' by '%s'\n", track.Title, track.Performer.Name) + // Cache for future use + if req.ISRC != "" { + GetTrackIDCache().SetQobuz(req.ISRC, track.ID) + } + } + } + } + } + + // Strategy 4: ISRC search with duration verification if track == nil && req.ISRC != "" { GoLog("[Qobuz] Trying ISRC search: %s\n", req.ISRC) track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec) @@ -1005,7 +1098,9 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { } } + // Strategy 5: Metadata search with strict matching (duration tolerance: 10 seconds) if track == nil { + GoLog("[Qobuz] Trying metadata search: '%s' by '%s'\n", req.TrackName, req.ArtistName) track, err = downloader.SearchTrackByMetadataWithDuration(req.TrackName, req.ArtistName, expectedDurationSec) if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) { GoLog("[Qobuz] Artist mismatch from metadata search: expected '%s', got '%s'. Rejecting.\n", diff --git a/go_backend/songlink.go b/go_backend/songlink.go index e7e0fe93..aec8e88f 100644 --- a/go_backend/songlink.go +++ b/go_backend/songlink.go @@ -8,7 +8,6 @@ import ( "net/url" "strings" "sync" - "time" ) type SongLinkClient struct { @@ -26,6 +25,8 @@ type TrackAvailability struct { QobuzURL string `json:"qobuz_url,omitempty"` DeezerURL string `json:"deezer_url,omitempty"` DeezerID string `json:"deezer_id,omitempty"` + QobuzID string `json:"qobuz_id,omitempty"` + TidalID string `json:"tidal_id,omitempty"` } var ( @@ -98,6 +99,7 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" { availability.Tidal = true availability.TidalURL = tidalLink.URL + availability.TidalID = extractTidalIDFromURL(tidalLink.URL) } if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" { @@ -111,6 +113,12 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri availability.DeezerID = extractDeezerIDFromURL(deezerLink.URL) } + if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" { + availability.Qobuz = true + availability.QobuzURL = qobuzLink.URL + availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL) + } + return availability, nil } @@ -131,40 +139,6 @@ func (s *SongLinkClient) GetStreamingURLs(spotifyTrackID string) (map[string]str return urls, nil } -func checkQobuzAvailability(isrc string) bool { - client := NewHTTPClientWithTimeout(10 * time.Second) - appID := "798273057" - - apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9") - searchURL := fmt.Sprintf("%s%s&limit=1&app_id=%s", string(apiBase), isrc, appID) - - req, err := http.NewRequest("GET", searchURL, nil) - if err != nil { - return false - } - - resp, err := DoRequestWithUserAgent(client, req) - if err != nil { - return false - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return false - } - - var searchResp struct { - Tracks struct { - Total int `json:"total"` - } `json:"tracks"` - } - if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil { - return false - } - - return searchResp.Tracks.Total > 0 -} - // extractDeezerIDFromURL extracts Deezer track/album/artist ID from URL func extractDeezerIDFromURL(deezerURL string) string { parts := strings.Split(deezerURL, "/") @@ -178,6 +152,102 @@ func extractDeezerIDFromURL(deezerURL string) string { return "" } +// extractQobuzIDFromURL extracts Qobuz track ID from URL +// URL formats: +// - https://www.qobuz.com/us-en/album/.../12345678 (album page with track highlight) +// - https://open.qobuz.com/track/12345678 +// - https://www.qobuz.com/track/12345678 +// - https://play.qobuz.com/track/12345678 +func extractQobuzIDFromURL(qobuzURL string) string { + if qobuzURL == "" { + return "" + } + + // Try to find /track/ID pattern first + if strings.Contains(qobuzURL, "/track/") { + parts := strings.Split(qobuzURL, "/track/") + if len(parts) > 1 { + idPart := parts[1] + // Remove query parameters + if idx := strings.Index(idPart, "?"); idx > 0 { + idPart = idPart[:idx] + } + // Remove trailing slash or path + if idx := strings.Index(idPart, "/"); idx > 0 { + idPart = idPart[:idx] + } + idPart = strings.TrimSpace(idPart) + // Validate it's a number + if idPart != "" && isNumeric(idPart) { + return idPart + } + } + } + + // Try to extract from album URL with track highlight + // Format: /album/albumname/trackid or ?trackId=12345678 + if strings.Contains(qobuzURL, "trackId=") { + parts := strings.Split(qobuzURL, "trackId=") + if len(parts) > 1 { + idPart := parts[1] + if idx := strings.Index(idPart, "&"); idx > 0 { + idPart = idPart[:idx] + } + idPart = strings.TrimSpace(idPart) + if idPart != "" && isNumeric(idPart) { + return idPart + } + } + } + + // Last resort: get last numeric segment from URL + parts := strings.Split(qobuzURL, "/") + for i := len(parts) - 1; i >= 0; i-- { + part := parts[i] + // Remove query parameters + if idx := strings.Index(part, "?"); idx > 0 { + part = part[:idx] + } + part = strings.TrimSpace(part) + if part != "" && isNumeric(part) { + return part + } + } + + return "" +} + +// extractTidalIDFromURL extracts Tidal track ID from URL +// URL formats: +// - https://tidal.com/browse/track/12345678 +// - https://listen.tidal.com/track/12345678 +func extractTidalIDFromURL(tidalURL string) string { + if tidalURL == "" { + return "" + } + + if strings.Contains(tidalURL, "/track/") { + parts := strings.Split(tidalURL, "/track/") + if len(parts) > 1 { + idPart := parts[1] + if idx := strings.Index(idPart, "?"); idx > 0 { + idPart = idPart[:idx] + } + if idx := strings.Index(idPart, "/"); idx > 0 { + idPart = idPart[:idx] + } + idPart = strings.TrimSpace(idPart) + if idPart != "" && isNumeric(idPart) { + return idPart + } + } + } + + return "" +} + +// isNumeric is defined in library_scan.go + func (s *SongLinkClient) GetDeezerIDFromSpotify(spotifyTrackID string) (string, error) { availability, err := s.CheckTrackAvailability(spotifyTrackID, "") if err != nil { @@ -353,6 +423,7 @@ func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID strin if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" { availability.Tidal = true availability.TidalURL = tidalLink.URL + availability.TidalID = extractTidalIDFromURL(tidalLink.URL) } if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" { @@ -360,6 +431,12 @@ func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID strin availability.AmazonURL = amazonLink.URL } + if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" { + availability.Qobuz = true + availability.QobuzURL = qobuzLink.URL + availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL) + } + if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" { availability.DeezerURL = deezerLink.URL } @@ -431,6 +508,7 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" { availability.Tidal = true availability.TidalURL = tidalLink.URL + availability.TidalID = extractTidalIDFromURL(tidalLink.URL) } if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" { @@ -438,6 +516,12 @@ func (s *SongLinkClient) CheckAvailabilityByPlatform(platform, entityType, entit availability.AmazonURL = amazonLink.URL } + if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" { + availability.Qobuz = true + availability.QobuzURL = qobuzLink.URL + availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL) + } + if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" { availability.Deezer = true availability.DeezerURL = deezerLink.URL @@ -552,6 +636,7 @@ func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvaila if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" { availability.Tidal = true availability.TidalURL = tidalLink.URL + availability.TidalID = extractTidalIDFromURL(tidalLink.URL) } if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" { availability.Amazon = true @@ -560,6 +645,7 @@ func (s *SongLinkClient) CheckAvailabilityFromURL(inputURL string) (*TrackAvaila if qobuzLink, ok := songLinkResp.LinksByPlatform["qobuz"]; ok && qobuzLink.URL != "" { availability.Qobuz = true availability.QobuzURL = qobuzLink.URL + availability.QobuzID = extractQobuzIDFromURL(qobuzLink.URL) } if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" { availability.Deezer = true diff --git a/go_backend/tidal.go b/go_backend/tidal.go index 464971d8..57632d26 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -582,12 +582,123 @@ type tidalAPIResult struct { duration time.Duration } +// Tidal API timeout configuration +// Mobile networks are more unstable, so we use longer timeouts +const ( + tidalAPITimeoutMobile = 25 * time.Second + tidalMaxRetries = 2 // Number of retries per API + tidalRetryDelay = 500 * time.Millisecond +) + +// fetchTidalURLWithRetry fetches download URL from a single Tidal API with retry logic +func fetchTidalURLWithRetry(api string, trackID int64, quality string, timeout time.Duration) (TidalDownloadInfo, error) { + var lastErr error + retryDelay := tidalRetryDelay + + for attempt := 0; attempt <= tidalMaxRetries; attempt++ { + if attempt > 0 { + GoLog("[Tidal] Retry %d/%d for %s after %v\n", attempt, tidalMaxRetries, api, retryDelay) + time.Sleep(retryDelay) + retryDelay *= 2 // Exponential backoff + } + + client := NewHTTPClientWithTimeout(timeout) + reqURL := fmt.Sprintf("%s/track/?id=%d&quality=%s", api, trackID, quality) + + req, err := http.NewRequest("GET", reqURL, nil) + if err != nil { + lastErr = err + continue + } + + resp, err := client.Do(req) + if err != nil { + lastErr = err + // Check for retryable errors (timeout, connection reset) + errStr := strings.ToLower(err.Error()) + if strings.Contains(errStr, "timeout") || + strings.Contains(errStr, "reset") || + strings.Contains(errStr, "connection refused") || + strings.Contains(errStr, "eof") { + continue // Retry + } + break // Non-retryable error + } + // Server errors are retryable + if resp.StatusCode >= 500 { + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + lastErr = fmt.Errorf("HTTP %d", resp.StatusCode) + continue + } + + // 429 rate limit - wait and retry + if resp.StatusCode == 429 { + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + lastErr = fmt.Errorf("rate limited") + retryDelay = 2 * time.Second // Wait longer for rate limit + continue + } + + if resp.StatusCode != 200 { + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + return TidalDownloadInfo{}, fmt.Errorf("HTTP %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + lastErr = err + continue + } + + // Try V2 response format (with manifest) + var v2Response TidalAPIResponseV2 + if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" { + if v2Response.Data.AssetPresentation == "PREVIEW" { + return TidalDownloadInfo{}, fmt.Errorf("returned PREVIEW instead of FULL") + } + + return TidalDownloadInfo{ + URL: "MANIFEST:" + v2Response.Data.Manifest, + BitDepth: v2Response.Data.BitDepth, + SampleRate: v2Response.Data.SampleRate, + }, nil + } + + // Try V1 response format + var v1Responses []struct { + OriginalTrackURL string `json:"OriginalTrackUrl"` + } + if err := json.Unmarshal(body, &v1Responses); err == nil { + for _, item := range v1Responses { + if item.OriginalTrackURL != "" { + return TidalDownloadInfo{ + URL: item.OriginalTrackURL, + BitDepth: 16, + SampleRate: 44100, + }, nil + } + } + } + + return TidalDownloadInfo{}, fmt.Errorf("no download URL or manifest in response") + } + + if lastErr != nil { + return TidalDownloadInfo{}, lastErr + } + return TidalDownloadInfo{}, fmt.Errorf("all retries failed") +} + func getDownloadURLParallel(apis []string, trackID int64, quality string) (string, TidalDownloadInfo, error) { if len(apis) == 0 { return "", TidalDownloadInfo{}, fmt.Errorf("no APIs available") } - GoLog("[Tidal] Requesting download URL from %d APIs in parallel...\n", len(apis)) + GoLog("[Tidal] Requesting download URL from %d APIs in parallel (with retry)...\n", len(apis)) resultChan := make(chan tidalAPIResult, len(apis)) startTime := time.Now() @@ -595,69 +706,13 @@ func getDownloadURLParallel(apis []string, trackID int64, quality string) (strin for _, apiURL := range apis { go func(api string) { reqStart := time.Now() - - client := NewHTTPClientWithTimeout(15 * time.Second) - - reqURL := fmt.Sprintf("%s/track/?id=%d&quality=%s", api, trackID, quality) - - req, err := http.NewRequest("GET", reqURL, nil) - if err != nil { - resultChan <- tidalAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)} - return + info, err := fetchTidalURLWithRetry(api, trackID, quality, tidalAPITimeoutMobile) + resultChan <- tidalAPIResult{ + apiURL: api, + info: info, + err: err, + duration: time.Since(reqStart), } - - resp, err := client.Do(req) - if err != nil { - resultChan <- tidalAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)} - return - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("HTTP %d", resp.StatusCode), duration: time.Since(reqStart)} - return - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - resultChan <- tidalAPIResult{apiURL: api, err: err, duration: time.Since(reqStart)} - return - } - - var v2Response TidalAPIResponseV2 - if err := json.Unmarshal(body, &v2Response); err == nil && v2Response.Data.Manifest != "" { - if v2Response.Data.AssetPresentation == "PREVIEW" { - resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("returned PREVIEW instead of FULL"), duration: time.Since(reqStart)} - return - } - - info := TidalDownloadInfo{ - URL: "MANIFEST:" + v2Response.Data.Manifest, - BitDepth: v2Response.Data.BitDepth, - SampleRate: v2Response.Data.SampleRate, - } - resultChan <- tidalAPIResult{apiURL: api, info: info, err: nil, duration: time.Since(reqStart)} - return - } - - var v1Responses []struct { - OriginalTrackURL string `json:"OriginalTrackUrl"` - } - if err := json.Unmarshal(body, &v1Responses); err == nil { - for _, item := range v1Responses { - if item.OriginalTrackURL != "" { - info := TidalDownloadInfo{ - URL: item.OriginalTrackURL, - BitDepth: 16, - SampleRate: 44100, - } - resultChan <- tidalAPIResult{apiURL: api, info: info, err: nil, duration: time.Since(reqStart)} - return - } - } - } - - resultChan <- tidalAPIResult{apiURL: api, err: fmt.Errorf("no download URL or manifest in response"), duration: time.Since(reqStart)} }(apiURL) } @@ -784,6 +839,10 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU GoLog("[Tidal] Total segments from regex: %d\n", segmentCount) } + if segmentCount == 0 { + return "", "", nil, fmt.Errorf("no segments found in manifest") + } + for i := 1; i <= segmentCount; i++ { mediaURL := strings.ReplaceAll(mediaTemplate, "$Number$", fmt.Sprintf("%d", i)) mediaURLs = append(mediaURLs, mediaURL) @@ -1404,49 +1463,83 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { if track == nil && req.SpotifyID != "" { GoLog("[Tidal] ISRC search failed, trying SongLink...\n") - var tidalURL string - var slErr error + + var trackID int64 + var gotTidalID bool if strings.HasPrefix(req.SpotifyID, "deezer:") { deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:") GoLog("[Tidal] Using Deezer ID for SongLink lookup: %s\n", deezerID) songlink := NewSongLinkClient() - tidalURL, slErr = songlink.GetTidalURLFromDeezer(deezerID) + availability, slErr := songlink.CheckAvailabilityFromDeezer(deezerID) + if slErr == nil && availability != nil && availability.TidalID != "" { + if _, parseErr := fmt.Sscanf(availability.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 { + GoLog("[Tidal] Got Tidal ID %d directly from SongLink\n", trackID) + gotTidalID = true + } + } + // Fallback to URL parsing if TidalID not in struct + if !gotTidalID && availability != nil && availability.TidalURL != "" { + var idErr error + trackID, idErr = downloader.GetTrackIDFromURL(availability.TidalURL) + if idErr == nil && trackID > 0 { + GoLog("[Tidal] Got Tidal ID %d from URL parsing\n", trackID) + gotTidalID = true + } + } } else { - tidalURL, slErr = downloader.GetTidalURLFromSpotify(req.SpotifyID) + songlink := NewSongLinkClient() + availability, slErr := songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC) + if slErr == nil && availability != nil && availability.TidalID != "" { + if _, parseErr := fmt.Sscanf(availability.TidalID, "%d", &trackID); parseErr == nil && trackID > 0 { + GoLog("[Tidal] Got Tidal ID %d directly from SongLink\n", trackID) + gotTidalID = true + } + } + // Fallback to URL parsing if TidalID not in struct + if !gotTidalID && availability != nil && availability.TidalURL != "" { + var idErr error + trackID, idErr = downloader.GetTrackIDFromURL(availability.TidalURL) + if idErr == nil && trackID > 0 { + GoLog("[Tidal] Got Tidal ID %d from URL parsing\n", trackID) + gotTidalID = true + } + } } - if slErr == nil && tidalURL != "" { - trackID, idErr := downloader.GetTrackIDFromURL(tidalURL) - if idErr == nil { - track, err = downloader.GetTrackInfoByID(trackID) - 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 gotTidalID && trackID > 0 { + track, err = downloader.GetTrackInfoByID(trackID) + 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) { - GoLog("[Tidal] Artist mismatch from SongLink: expected '%s', got '%s'. Rejecting.\n", - req.ArtistName, tidalArtist) - track = nil - } + if !artistsMatch(req.ArtistName, tidalArtist) { + GoLog("[Tidal] Artist mismatch from SongLink: expected '%s', got '%s'. Rejecting.\n", + req.ArtistName, tidalArtist) + track = nil + } - if track != nil && expectedDurationSec > 0 { - durationDiff := track.Duration - expectedDurationSec - if durationDiff < 0 { - durationDiff = -durationDiff - } - if durationDiff > 3 { - GoLog("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n", - expectedDurationSec, track.Duration) - track = nil // Reject this match - } + if track != nil && expectedDurationSec > 0 { + durationDiff := track.Duration - expectedDurationSec + if durationDiff < 0 { + durationDiff = -durationDiff } + if durationDiff > 3 { + GoLog("[Tidal] Duration mismatch from SongLink: expected %ds, got %ds. Rejecting.\n", + expectedDurationSec, track.Duration) + track = nil // Reject this match + } + } + + // Cache for future use + if track != nil && req.ISRC != "" { + GetTrackIDCache().SetTidal(req.ISRC, track.ID) } } }