From 21a732379b84b4bd99c0e2d524d3ba0ac5b9c5e8 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sat, 31 Jan 2026 11:24:35 +0700 Subject: [PATCH] feat(backend): update Amazon and Qobuz download APIs Amazon: - Replace DoubleDouble service with AfkarXYZ API - Simpler implementation without polling mechanism - Remove rate limiting (not needed for AfkarXYZ) Qobuz: - Add qobuz.squid.wtf as additional API endpoint - Add Jumo API as fallback when standard APIs fail - Add XOR decoding for Jumo response - Add quality fallback (27 -> 7 -> 6) for Jumo --- go_backend/amazon.go | 325 +++++++++---------------------------------- go_backend/qobuz.go | 126 ++++++++++++++++- 2 files changed, 188 insertions(+), 263 deletions(-) diff --git a/go_backend/amazon.go b/go_backend/amazon.go index c16d30eb..753e581f 100644 --- a/go_backend/amazon.go +++ b/go_backend/amazon.go @@ -3,7 +3,6 @@ package gobackend import ( "bufio" "context" - "encoding/base64" "encoding/json" "errors" "fmt" @@ -12,39 +11,29 @@ import ( "net/url" "os" "path/filepath" + "regexp" "strings" "sync" "time" ) type AmazonDownloader struct { - client *http.Client - regions []string - lastAPICallTime time.Time - apiCallCount int - apiCallResetTime time.Time + client *http.Client } var ( globalAmazonDownloader *AmazonDownloader amazonDownloaderOnce sync.Once - amazonRateLimitMu sync.Mutex ) -// DoubleDoubleSubmitResponse is the response from DoubleDouble submit endpoint -type DoubleDoubleSubmitResponse struct { - Success bool `json:"success"` - ID string `json:"id"` -} - -type DoubleDoubleStatusResponse struct { - Status string `json:"status"` - FriendlyStatus string `json:"friendlyStatus"` - URL string `json:"url"` - Current struct { - Name string `json:"name"` - Artist string `json:"artist"` - } `json:"current"` +// AfkarXYZResponse is the response from AfkarXYZ API +type AfkarXYZResponse struct { + Success bool `json:"success"` + Data struct { + DirectLink string `json:"direct_link"` + FileName string `json:"file_name"` + FileSize int64 `json:"file_size"` + } `json:"data"` } func amazonArtistsMatch(expectedArtist, foundArtist string) bool { @@ -99,228 +88,63 @@ func amazonIsASCIIString(s string) bool { func NewAmazonDownloader() *AmazonDownloader { amazonDownloaderOnce.Do(func() { globalAmazonDownloader = &AmazonDownloader{ - client: NewHTTPClientWithTimeout(120 * time.Second), // 120s timeout like PC - regions: []string{"us", "eu"}, // Same regions as PC - apiCallResetTime: time.Now(), + client: NewHTTPClientWithTimeout(120 * time.Second), } }) return globalAmazonDownloader } -// waitForRateLimit implements rate limiting similar to PC version -func (a *AmazonDownloader) waitForRateLimit() { - amazonRateLimitMu.Lock() - defer amazonRateLimitMu.Unlock() +// downloadFromAfkarXYZ downloads a track using AfkarXYZ API +// Returns: downloadURL, fileName, error +func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, error) { + // AfkarXYZ API endpoint + apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL) - now := time.Now() + GoLog("[Amazon] Fetching from AfkarXYZ API...\n") - if now.Sub(a.apiCallResetTime) >= time.Minute { - a.apiCallCount = 0 - a.apiCallResetTime = now + req, err := http.NewRequest("GET", apiURL, nil) + if err != nil { + return "", "", fmt.Errorf("failed to create request: %w", err) } - if a.apiCallCount >= 9 { - waitTime := time.Minute - now.Sub(a.apiCallResetTime) - if waitTime > 0 { - GoLog("[Amazon] Rate limit reached, waiting %v...\n", waitTime.Round(time.Second)) - time.Sleep(waitTime) - a.apiCallCount = 0 - a.apiCallResetTime = time.Now() - } + req.Header.Set("User-Agent", getRandomUserAgent()) + + resp, err := a.client.Do(req) + if err != nil { + return "", "", fmt.Errorf("failed to call AfkarXYZ API: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return "", "", fmt.Errorf("AfkarXYZ API returned status %d", resp.StatusCode) } - if !a.lastAPICallTime.IsZero() { - timeSinceLastCall := now.Sub(a.lastAPICallTime) - minDelay := 7 * time.Second - if timeSinceLastCall < minDelay { - waitTime := minDelay - timeSinceLastCall - GoLog("[Amazon] Rate limiting: waiting %v...\n", waitTime.Round(time.Second)) - time.Sleep(waitTime) - } + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", "", fmt.Errorf("failed to read response: %w", err) } - a.lastAPICallTime = time.Now() - a.apiCallCount++ -} - -// Uses same service as PC version (doubledouble.top) -func (a *AmazonDownloader) GetAvailableAPIs() []string { - // DoubleDouble service regions (same as PC) - // Format: https://{region}.doubledouble.top - var apis []string - for _, region := range a.regions { - apis = append(apis, fmt.Sprintf("https://%s.doubledouble.top", region)) - } - return apis -} - -// downloadFromDoubleDoubleService downloads a track using DoubleDouble service (same as PC) -// This uses submit → poll → download mechanism -// Internal function - not exported to gomobile -func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, _ string) (string, string, string, error) { - var lastError error - - for _, region := range a.regions { - GoLog("[Amazon] Trying region: %s...\n", region) - - serviceBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly8=") // https:// - serviceDomain, _ := base64.StdEncoding.DecodeString("LmRvdWJsZWRvdWJsZS50b3A=") // .doubledouble.top - baseURL := fmt.Sprintf("%s%s%s", string(serviceBase), region, string(serviceDomain)) - - encodedURL := url.QueryEscape(amazonURL) - submitURL := fmt.Sprintf("%s/dl?url=%s", baseURL, encodedURL) - - a.waitForRateLimit() - - req, err := http.NewRequest("GET", submitURL, nil) - if err != nil { - lastError = fmt.Errorf("failed to create request: %w", err) - continue - } - - req.Header.Set("User-Agent", getRandomUserAgent()) - - fmt.Println("[Amazon] Submitting download request...") - - // Retry logic for 429 errors (like PC version: 3 retries with 15s wait) - var resp *http.Response - maxRetries := 3 - for retry := 0; retry < maxRetries; retry++ { - resp, err = a.client.Do(req) - if err != nil { - lastError = fmt.Errorf("failed to submit request: %w", err) - break - } - - if resp.StatusCode == 429 { // Too Many Requests - resp.Body.Close() - if retry < maxRetries-1 { - waitTime := 15 * time.Second - GoLog("[Amazon] Rate limited (429), waiting %v before retry %d/%d...\n", waitTime, retry+2, maxRetries) - time.Sleep(waitTime) - continue - } - lastError = fmt.Errorf("API rate limit exceeded after %d retries", maxRetries) - break - } - - if resp.StatusCode != 200 { - resp.Body.Close() - lastError = fmt.Errorf("submit failed with status %d", resp.StatusCode) - break - } - - // Success - break retry loop - break - } - - if err != nil || lastError != nil { - if resp != nil { - resp.Body.Close() - } - continue - } - - var submitResp DoubleDoubleSubmitResponse - if err := json.NewDecoder(resp.Body).Decode(&submitResp); err != nil { - resp.Body.Close() - lastError = fmt.Errorf("failed to decode submit response: %w", err) - continue - } - resp.Body.Close() - - if !submitResp.Success || submitResp.ID == "" { - lastError = fmt.Errorf("submit request failed") - continue - } - - downloadID := submitResp.ID - GoLog("[Amazon] Download ID: %s\n", downloadID) - - // Step 2: Poll for completion - statusURL := fmt.Sprintf("%s/dl/%s", baseURL, downloadID) - fmt.Println("[Amazon] Waiting for download to complete...") - - maxWait := 300 * time.Second // 5 minutes max wait - elapsed := time.Duration(0) - pollInterval := 3 * time.Second - - for elapsed < maxWait { - time.Sleep(pollInterval) - elapsed += pollInterval - - statusReq, err := http.NewRequest("GET", statusURL, nil) - if err != nil { - continue - } - - statusReq.Header.Set("User-Agent", getRandomUserAgent()) - - statusResp, err := a.client.Do(statusReq) - if err != nil { - fmt.Printf("\r[Amazon] Status check failed, retrying...") - continue - } - - if statusResp.StatusCode != 200 { - statusResp.Body.Close() - fmt.Printf("\r[Amazon] Status check failed (status %d), retrying...", statusResp.StatusCode) - continue - } - - var status DoubleDoubleStatusResponse - if err := json.NewDecoder(statusResp.Body).Decode(&status); err != nil { - statusResp.Body.Close() - fmt.Printf("\r[Amazon] Invalid JSON response, retrying...") - continue - } - statusResp.Body.Close() - - if status.Status == "done" { - fmt.Println("\n[Amazon] Download ready!") - - fileURL := status.URL - if strings.HasPrefix(fileURL, "./") { - fileURL = fmt.Sprintf("%s/%s", baseURL, fileURL[2:]) - } else if strings.HasPrefix(fileURL, "/") { - fileURL = fmt.Sprintf("%s%s", baseURL, fileURL) - } - - trackName := status.Current.Name - artist := status.Current.Artist - - GoLog("[Amazon] Downloading: %s - %s\n", artist, trackName) - return fileURL, trackName, artist, nil - - } else if status.Status == "error" { - errorMsg := status.FriendlyStatus - if errorMsg == "" { - errorMsg = "Unknown error" - } - lastError = fmt.Errorf("processing failed: %s", errorMsg) - break - } else { - // Still processing - friendlyStatus := status.FriendlyStatus - if friendlyStatus == "" { - friendlyStatus = status.Status - } - fmt.Printf("\r[Amazon] %s...", friendlyStatus) - } - } - - if elapsed >= maxWait { - lastError = fmt.Errorf("download timeout") - fmt.Printf("\n[Amazon] Error with %s region: %v\n", region, lastError) - continue - } - - if lastError != nil { - fmt.Printf("\n[Amazon] Error with %s region: %v\n", region, lastError) - } + var apiResp AfkarXYZResponse + if err := json.Unmarshal(body, &apiResp); err != nil { + return "", "", fmt.Errorf("failed to decode response: %w", err) } - return "", "", "", fmt.Errorf("all regions failed. Last error: %v", lastError) + if !apiResp.Success || apiResp.Data.DirectLink == "" { + return "", "", fmt.Errorf("AfkarXYZ API failed or no download link found") + } + + fileName := apiResp.Data.FileName + if fileName == "" { + fileName = "track.flac" + } + + // Sanitize filename + 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) DownloadFile(downloadURL, outputPath, itemID string) error { @@ -404,7 +228,7 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written) } - fmt.Printf("\r[Amazon] Downloaded: %.2f MB (Complete)\n", float64(written)/(1024*1024)) + GoLog("[Amazon] Downloaded: %.2f MB (Complete)\n", float64(written)/(1024*1024)) return nil } @@ -422,7 +246,7 @@ type AmazonDownloadResult struct { ISRC string } -// Uses DoubleDouble service (same as PC version) +// downloadFromAmazon uses AfkarXYZ API to download from Amazon Music func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { downloader := NewAmazonDownloader() @@ -434,8 +258,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { var availability *TrackAvailability var err error - if strings.HasPrefix(req.SpotifyID, "deezer:") { - deezerID := strings.TrimPrefix(req.SpotifyID, "deezer:") + 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 != "" { @@ -458,21 +281,15 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { } } - // Download using DoubleDouble service (same as PC) - downloadURL, trackName, artistName, err := downloader.downloadFromDoubleDoubleService(availability.AmazonURL, req.OutputDir) + // Download using AfkarXYZ API + downloadURL, _, err := downloader.downloadFromAfkarXYZ(availability.AmazonURL) if err != nil { - return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err) + return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL from AfkarXYZ: %w", err) } - // Verify artist matches - if artistName != "" && !amazonArtistsMatch(req.ArtistName, artistName) { - GoLog("[Amazon] Artist mismatch: expected '%s', got '%s'. Rejecting.\n", req.ArtistName, artistName) - return AmazonDownloadResult{}, fmt.Errorf("artist mismatch: expected '%s', got '%s'", req.ArtistName, artistName) - } + GoLog("[Amazon] Match found: '%s' by '%s'\n", req.TrackName, req.ArtistName) - GoLog("[Amazon] Match found: '%s' by '%s'\n", trackName, artistName) - - filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{ + filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]any{ "title": req.TrackName, "artist": req.ArtistName, "album": req.AlbumName, @@ -519,11 +336,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { SetItemFinalizing(req.ItemID) } - // Log track info from DoubleDouble (for debugging) - if trackName != "" && artistName != "" { - GoLog("[Amazon] DoubleDouble returned: %s - %s\n", artistName, trackName) - } - existingMeta, metaErr := ReadMetadata(outputPath) actualTrackNum := req.TrackNumber actualDiscNum := req.DiscNumber @@ -539,8 +351,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { } } - // Embed metadata using Spotify data (more accurate than DoubleDouble) - // But preserve track/disc numbers from file if they were better + // Embed metadata using Spotify data metadata := Metadata{ Title: req.TrackName, Artist: req.ArtistName, @@ -551,9 +362,9 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { TotalTracks: req.TotalTracks, DiscNumber: actualDiscNum, ISRC: req.ISRC, - Genre: req.Genre, // From Deezer album metadata - Label: req.Label, // From Deezer album metadata - Copyright: req.Copyright, // From Deezer album metadata + Genre: req.Genre, + Label: req.Label, + Copyright: req.Copyright, } // Use cover data from parallel fetch @@ -564,7 +375,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { } if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil { - fmt.Printf("Warning: failed to embed metadata: %v\n", err) + GoLog("[Amazon] Warning: failed to embed metadata: %v\n", err) } if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { @@ -587,14 +398,14 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil { GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr) } else { - fmt.Println("[Amazon] Lyrics embedded successfully") + GoLog("[Amazon] Lyrics embedded successfully\n") } } } else if req.EmbedLyrics { - fmt.Println("[Amazon] No lyrics available from parallel fetch") + GoLog("[Amazon] No lyrics available from parallel fetch\n") } - fmt.Println("[Amazon] ✓ Downloaded successfully from Amazon Music") + GoLog("[Amazon] ✓ Downloaded successfully from Amazon Music\n") quality, err := GetAudioQuality(outputPath) if err != nil { diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index 06ce66d6..a07a5b95 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -375,10 +375,11 @@ func (q *QobuzDownloader) GetTrackByID(trackID int64) (*QobuzTrack, error) { // Uses same APIs as PC version for compatibility func (q *QobuzDownloader) GetAvailableAPIs() []string { // Same APIs as PC version (referensi/backend/qobuz.go) - // Primary: dab.yeet.su, Fallback: dabmusic.xyz + // Primary: dab.yeet.su, Fallback: dabmusic.xyz, qobuz.squid.wtf encodedAPIs := []string{ - "ZGFiLnllZXQuc3UvYXBpL3N0cmVhbT90cmFja0lkPQ==", // dab.yeet.su/api/stream?trackId= (PRIMARY - same as PC) - "ZGFibXVzaWMueHl6L2FwaS9zdHJlYW0/dHJhY2tJZD0=", // dabmusic.xyz/api/stream?trackId= (FALLBACK - same as PC) + "ZGFiLnllZXQuc3UvYXBpL3N0cmVhbT90cmFja0lkPQ==", // dab.yeet.su/api/stream?trackId= + "ZGFibXVzaWMueHl6L2FwaS9zdHJlYW0/dHJhY2tJZD0=", // dabmusic.xyz/api/stream?trackId= + "cW9idXouc3F1aWQud3RmL2FwaS9kb3dubG9hZC1tdXNpYz90cmFja19pZD0=", // qobuz.squid.wtf/api/download-music?track_id= } var apis []string @@ -393,6 +394,95 @@ func (q *QobuzDownloader) GetAvailableAPIs() []string { return apis } +// mapJumoQuality maps Qobuz quality codes to Jumo format +func mapJumoQuality(quality string) int { + switch quality { + case "6": + return 6 // 16-bit FLAC + case "7": + return 7 // 24-bit 96kHz + case "27": + return 27 // 24-bit 192kHz + default: + return 6 + } +} + +// decodeXOR decodes XOR-encoded response from Jumo API +func decodeXOR(data []byte) string { + text := string(data) + runes := []rune(text) + result := make([]rune, len(runes)) + for i, char := range runes { + key := rune((i * 17) % 128) + result[i] = char ^ 253 ^ key + } + return string(result) +} + +// downloadFromJumo gets download URL from Jumo API (fallback) +func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (string, error) { + formatID := mapJumoQuality(quality) + region := "US" + + // Jumo API endpoint + jumoURL := fmt.Sprintf("https://jumo-dl.pages.dev/file?track_id=%d&format_id=%d®ion=%s", trackID, formatID, region) + + GoLog("[Qobuz] Trying Jumo API fallback...\n") + + client := NewHTTPClientWithTimeout(30 * time.Second) + req, err := http.NewRequest("GET", jumoURL, nil) + if err != nil { + return "", err + } + + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return "", fmt.Errorf("Jumo API returned HTTP %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + var result map[string]any + + // Try parsing as plain JSON first + if err := json.Unmarshal(body, &result); err != nil { + // Try XOR decoding + decoded := decodeXOR(body) + if err := json.Unmarshal([]byte(decoded), &result); err != nil { + return "", fmt.Errorf("failed to parse Jumo response (plain or XOR): %w", err) + } + } + + // Check for URL in various response formats + if urlVal, ok := result["url"].(string); ok && urlVal != "" { + GoLog("[Qobuz] Jumo API returned URL successfully\n") + return urlVal, nil + } + + if data, ok := result["data"].(map[string]any); ok { + if urlVal, ok := data["url"].(string); ok && urlVal != "" { + GoLog("[Qobuz] Jumo API returned URL successfully (from data)\n") + return urlVal, nil + } + } + + if linkVal, ok := result["link"].(string); ok && linkVal != "" { + GoLog("[Qobuz] Jumo API returned URL successfully (from link)\n") + return linkVal, nil + } + + return "", fmt.Errorf("URL not found in Jumo response") +} + func (q *QobuzDownloader) SearchTrackByISRC(isrc string) (*QobuzTrack, error) { apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9") searchURL := fmt.Sprintf("%s%s&limit=50&app_id=%s", string(apiBase), url.QueryEscape(isrc), q.appID) @@ -812,11 +902,35 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, } _, downloadURL, err := getQobuzDownloadURLParallel(apis, trackID, quality) - if err != nil { - return "", err + if err == nil { + return downloadURL, nil } - return downloadURL, nil + // All standard APIs failed, try Jumo as fallback + GoLog("[Qobuz] Standard APIs failed, trying Jumo fallback...\n") + jumoURL, jumoErr := q.downloadFromJumo(trackID, quality) + if jumoErr == nil { + return jumoURL, nil + } + + // If quality is 27 (hi-res), try fallback to lower quality + if quality == "27" { + GoLog("[Qobuz] Hi-res (27) failed, trying 24-bit (7)...\n") + jumoURL, jumoErr = q.downloadFromJumo(trackID, "7") + if jumoErr == nil { + return jumoURL, nil + } + } + + if quality == "27" || quality == "7" { + GoLog("[Qobuz] 24-bit failed, trying 16-bit (6)...\n") + jumoURL, jumoErr = q.downloadFromJumo(trackID, "6") + if jumoErr == nil { + return jumoURL, nil + } + } + + return "", fmt.Errorf("all Qobuz APIs and Jumo fallback failed: %w", err) } // DownloadFile downloads a file from URL with User-Agent and progress tracking