package gobackend import ( "bufio" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "os" "path/filepath" "regexp" "strings" "sync" "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 } var ( globalAmazonDownloader *AmazonDownloader amazonDownloaderOnce sync.Once amazonASINRegex = regexp.MustCompile(`(?i)^B[0-9A-Z]{9}$`) amazonASINFindRegex = regexp.MustCompile(`(?i)B[0-9A-Z]{9}`) ) // 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"` } // AmazonStreamResponse is the new response format from amazon.afkarxyz.fun/api/track/{asin} type AmazonStreamResponse struct { StreamURL string `json:"streamUrl"` DecryptionKey string `json:"decryptionKey"` } func NewAmazonDownloader() *AmazonDownloader { amazonDownloaderOnce.Do(func() { globalAmazonDownloader = &AmazonDownloader{ client: NewHTTPClientWithTimeout(120 * time.Second), } }) return globalAmazonDownloader } // fetchAmazonURLWithRetry fetches from AfkarXYZ API with retry logic for mobile networks. // Returns downloadURL, suggested fileName, optional decryptionKey. func (a *AmazonDownloader) fetchAmazonURLWithRetry(amazonURL string) (string, string, string, error) { 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) } downloadURL, fileName, decryptionKey, err := a.doAfkarXYZRequest(amazonURL) if err == nil { return downloadURL, fileName, decryptionKey, nil } lastErr = err errStr := strings.ToLower(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") || strings.Contains(errStr, "http 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) } func normalizeAmazonASIN(candidate string) string { trimmed := strings.TrimSpace(candidate) if trimmed == "" { return "" } if decoded, err := url.QueryUnescape(trimmed); err == nil { trimmed = decoded } trimmed = strings.ToUpper(trimmed) if idx := strings.IndexAny(trimmed, "?#&/"); idx >= 0 { trimmed = trimmed[:idx] } if amazonASINRegex.MatchString(trimmed) { return trimmed } return "" } func extractAmazonASIN(amazonURL string) string { raw := strings.TrimSpace(amazonURL) if raw == "" { return "" } parsed, err := url.Parse(raw) if err == nil { query := parsed.Query() // Prefer track-level ASIN when URL also contains albumAsin. for _, key := range []string{"trackAsin", "trackasin", "trackASIN", "asin", "ASIN", "i"} { if asin := normalizeAmazonASIN(query.Get(key)); asin != "" { return asin } } path := strings.Trim(parsed.Path, "/") if path != "" { segments := strings.Split(path, "/") for i := 0; i < len(segments)-1; i++ { segment := strings.ToLower(strings.TrimSpace(segments[i])) if segment == "track" || segment == "tracks" { if asin := normalizeAmazonASIN(segments[i+1]); asin != "" { return asin } } } if asin := normalizeAmazonASIN(segments[len(segments)-1]); asin != "" { return asin } } } match := amazonASINFindRegex.FindString(strings.ToUpper(raw)) return normalizeAmazonASIN(match) } // doAfkarXYZRequest performs a single request to Amazon API. // It tries new endpoint first, then falls back to legacy /convert endpoint. func (a *AmazonDownloader) doAfkarXYZRequest(amazonURL string) (string, string, string, error) { asin := extractAmazonASIN(amazonURL) if asin != "" { GoLog("[Amazon] Using ASIN: %s\n", asin) downloadURL, fileName, decryptKey, err := a.doAfkarXYZRequestNew(asin) if err == nil { return downloadURL, fileName, decryptKey, nil } GoLog("[Amazon] New API failed for ASIN %s, trying legacy endpoint: %v\n", asin, err) } return a.doAfkarXYZRequestLegacy(amazonURL) } func (a *AmazonDownloader) doAfkarXYZRequestNew(asin string) (string, string, string, error) { ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile) defer cancel() apiURL := fmt.Sprintf("https://amazon.afkarxyz.fun/api/track/%s", asin) req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) if err != nil { return "", "", "", fmt.Errorf("failed to create request: %w", err) } req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36") resp, err := a.client.Do(req) if err != nil { return "", "", "", fmt.Errorf("failed to call Amazon API: %w", err) } defer resp.Body.Close() if resp.StatusCode != 200 { return "", "", "", fmt.Errorf("Amazon API returned status %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return "", "", "", fmt.Errorf("failed to read response: %w", err) } var apiResp AmazonStreamResponse if err := json.Unmarshal(body, &apiResp); err != nil { return "", "", "", fmt.Errorf("failed to decode response: %w", err) } if strings.TrimSpace(apiResp.StreamURL) == "" { return "", "", "", fmt.Errorf("Amazon API returned empty stream URL") } fileName := asin + ".m4a" return apiResp.StreamURL, fileName, strings.TrimSpace(apiResp.DecryptionKey), nil } func (a *AmazonDownloader) doAfkarXYZRequestLegacy(amazonURL string) (string, string, string, error) { ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile) defer cancel() apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL) req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) if err != nil { return "", "", "", fmt.Errorf("failed to create legacy request: %w", err) } req.Header.Set("User-Agent", getRandomUserAgent()) resp, err := a.client.Do(req) if err != nil { return "", "", "", fmt.Errorf("failed to call legacy AfkarXYZ API: %w", err) } defer resp.Body.Close() if resp.StatusCode != 200 { return "", "", "", fmt.Errorf("legacy AfkarXYZ API returned status %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return "", "", "", fmt.Errorf("failed to read legacy response: %w", err) } var apiResp AfkarXYZResponse if err := json.Unmarshal(body, &apiResp); err != nil { return "", "", "", fmt.Errorf("failed to decode legacy response: %w", err) } if !apiResp.Success || strings.TrimSpace(apiResp.Data.DirectLink) == "" { return "", "", "", fmt.Errorf("legacy AfkarXYZ API failed or no download link found") } fileName := apiResp.Data.FileName if fileName == "" { fileName = "track.flac" } reg := regexp.MustCompile(`[<>:"/\\|?*]`) fileName = reg.ReplaceAllString(fileName, "") return apiResp.Data.DirectLink, fileName, "", nil } func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, string, error) { GoLog("[Amazon] Fetching from AfkarXYZ API...\n") downloadURL, fileName, decryptionKey, err := a.fetchAmazonURLWithRetry(amazonURL) if err != nil { return "", "", "", err } if decryptionKey != "" { GoLog("[Amazon] AfkarXYZ returned encrypted stream (decryption key available)\n") } GoLog("[Amazon] AfkarXYZ returned: %s\n", fileName) return downloadURL, fileName, decryptionKey, nil } func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error { ctx := context.Background() if itemID != "" { StartItemProgress(itemID) defer CompleteItemProgress(itemID) ctx = initDownloadCancel(itemID) defer clearDownloadCancel(itemID) } if isDownloadCancelled(itemID) { return ErrDownloadCancelled } req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil) if err != nil { return fmt.Errorf("failed to create request: %w", err) } req.Header.Set("User-Agent", getRandomUserAgent()) resp, err := a.client.Do(req) if err != nil { if isDownloadCancelled(itemID) { return ErrDownloadCancelled } return err } defer resp.Body.Close() if resp.StatusCode != 200 { return fmt.Errorf("download failed: HTTP %d", resp.StatusCode) } expectedSize := resp.ContentLength if expectedSize > 0 && itemID != "" { SetItemBytesTotal(itemID, expectedSize) } out, err := openOutputForWrite(outputPath, outputFD) if err != nil { return err } bufWriter := bufio.NewWriterSize(out, 256*1024) var written int64 if itemID != "" { pw := NewItemProgressWriter(bufWriter, itemID) written, err = io.Copy(pw, resp.Body) } else { written, err = io.Copy(bufWriter, resp.Body) } flushErr := bufWriter.Flush() closeErr := out.Close() if err != nil { cleanupOutputOnError(outputPath, outputFD) if isDownloadCancelled(itemID) { return ErrDownloadCancelled } return fmt.Errorf("download interrupted: %w", err) } if flushErr != nil { cleanupOutputOnError(outputPath, outputFD) return fmt.Errorf("failed to flush buffer: %w", flushErr) } if closeErr != nil { cleanupOutputOnError(outputPath, outputFD) return fmt.Errorf("failed to close file: %w", closeErr) } if expectedSize > 0 && written != expectedSize { cleanupOutputOnError(outputPath, outputFD) return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written) } GoLog("[Amazon] Downloaded: %.2f MB (Complete)\n", float64(written)/(1024*1024)) return nil } // AmazonDownloadResult contains download result with quality info type AmazonDownloadResult struct { FilePath string BitDepth int SampleRate int Title string Artist string Album string ReleaseDate string TrackNumber int DiscNumber int ISRC string LyricsLRC string DecryptionKey string } func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { downloader := NewAmazonDownloader() isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != "" if !isSafOutput { if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists { 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 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 !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 !isSafOutput && req.OutputDir != "." { if err := os.MkdirAll(req.OutputDir, 0755); err != nil { return AmazonDownloadResult{}, fmt.Errorf("failed to create output directory: %w", err) } } // Download using AfkarXYZ API downloadURL, afkarFileName, decryptionKey, err := downloader.downloadFromAfkarXYZ(amazonURL) if err != nil { return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL from AfkarXYZ: %w", err) } GoLog("[Amazon] Match found: '%s' by '%s'\n", req.TrackName, req.ArtistName) filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]any{ "title": req.TrackName, "artist": req.ArtistName, "album": req.AlbumName, "track": req.TrackNumber, "year": extractYear(req.ReleaseDate), "date": req.ReleaseDate, "disc": req.DiscNumber, }) var outputPath string if isSafOutput { outputPath = strings.TrimSpace(req.OutputPath) if outputPath == "" && isFDOutput(req.OutputFD) { outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD) } } else { outputExt := strings.ToLower(filepath.Ext(afkarFileName)) if outputExt == "" { outputExt = ".flac" } filename = sanitizeFilename(filename) + outputExt outputPath = filepath.Join(req.OutputDir, filename) if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 { return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil } } // 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, int64(req.DurationMS), ) }() // Download audio file with item ID for progress tracking if err := downloader.DownloadFile(downloadURL, outputPath, req.OutputFD, req.ItemID); err != nil { if errors.Is(err, ErrDownloadCancelled) { return AmazonDownloadResult{}, ErrDownloadCancelled } return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err) } actualOutputPath := outputPath needsDecryption := strings.TrimSpace(decryptionKey) != "" if needsDecryption { GoLog("[Amazon] Download requires decryption; deferring decrypt to Flutter FFmpeg path\n") } // Wait for parallel operations to complete <-parallelDone if req.ItemID != "" { SetItemProgress(req.ItemID, 1.0, 0, 0) SetItemFinalizing(req.ItemID) } actualTrackNum := req.TrackNumber actualDiscNum := req.DiscNumber actualDate := req.ReleaseDate actualAlbum := req.AlbumName actualTitle := req.TrackName actualArtist := req.ArtistName if !needsDecryption { existingMeta, metaErr := ReadMetadata(actualOutputPath) if metaErr == nil && existingMeta != nil { if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) { actualTrackNum = existingMeta.TrackNumber GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber) } if existingMeta.DiscNumber > 0 && (req.DiscNumber == 0 || req.DiscNumber == 1) { actualDiscNum = existingMeta.DiscNumber GoLog("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.DiscNumber) } if existingMeta.Date != "" && req.ReleaseDate == "" { actualDate = existingMeta.Date GoLog("[Amazon] Using release date from file: %s\n", actualDate) } if existingMeta.Album != "" && req.AlbumName == "" { actualAlbum = existingMeta.Album GoLog("[Amazon] Using album from file: %s\n", actualAlbum) } GoLog("[Amazon] Existing metadata - Title: %s, Artist: %s, Album: %s, Date: %s\n", existingMeta.Title, existingMeta.Artist, existingMeta.Album, existingMeta.Date) } } metadata := Metadata{ Title: actualTitle, Artist: actualArtist, Album: actualAlbum, AlbumArtist: req.AlbumArtist, Date: actualDate, TrackNumber: actualTrackNum, TotalTracks: req.TotalTracks, DiscNumber: actualDiscNum, ISRC: req.ISRC, Genre: req.Genre, Label: req.Label, Copyright: req.Copyright, } var coverData []byte if parallelResult != nil && parallelResult.CoverData != nil && len(parallelResult.CoverData) > 0 { coverData = parallelResult.CoverData GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData)) } else { existingCover, coverErr := ExtractCoverArt(actualOutputPath) if coverErr == nil && len(existingCover) > 0 { coverData = existingCover GoLog("[Amazon] Using existing cover from Amazon file (%d bytes)\n", len(coverData)) } else { GoLog("[Amazon] No cover available (parallel fetch failed and no existing cover)\n") } } if isSafOutput || needsDecryption { GoLog("[Amazon] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n") } else { isFlacOutput := strings.HasSuffix(strings.ToLower(actualOutputPath), ".flac") if isFlacOutput { if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil { GoLog("[Amazon] Warning: failed to embed metadata: %v\n", err) } } else { GoLog("[Amazon] Non-FLAC output detected (%s), skipping native metadata embedding\n", filepath.Ext(actualOutputPath)) } if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { lyricsMode := req.LyricsMode if lyricsMode == "" { lyricsMode = "embed" } if lyricsMode == "external" || lyricsMode == "both" { GoLog("[Amazon] Saving external LRC file...\n") if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil { GoLog("[Amazon] Warning: failed to save LRC file: %v\n", lrcErr) } else { GoLog("[Amazon] LRC file saved: %s\n", lrcPath) } } if (lyricsMode == "embed" || lyricsMode == "both") && isFlacOutput { GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines)) if embedErr := EmbedLyrics(actualOutputPath, parallelResult.LyricsLRC); embedErr != nil { GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr) } else { GoLog("[Amazon] Lyrics embedded successfully\n") } } else if (lyricsMode == "embed" || lyricsMode == "both") && !isFlacOutput { GoLog("[Amazon] Skipping embedded lyrics for non-FLAC output\n") } } else if req.EmbedLyrics { GoLog("[Amazon] No lyrics available from parallel fetch\n") } } GoLog("[Amazon] Downloaded successfully from Amazon Music\n") quality := AudioQuality{} if isSafOutput || needsDecryption { GoLog("[Amazon] SAF output detected - skipping post-write file inspection in backend\n") } else { quality, err = GetAudioQuality(actualOutputPath) if err != nil { GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err) } else { GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate) } finalMeta, metaReadErr := ReadMetadata(actualOutputPath) if metaReadErr == nil && finalMeta != nil { GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n", finalMeta.TrackNumber, finalMeta.DiscNumber, finalMeta.Date) actualTrackNum = finalMeta.TrackNumber actualDiscNum = finalMeta.DiscNumber if finalMeta.Date != "" { req.ReleaseDate = finalMeta.Date } } } // Add to ISRC index for fast duplicate checking. // When decryption is pending in Flutter, postpone indexing until final file is settled. if !isSafOutput && !needsDecryption { AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath) } bitDepth := 0 sampleRate := 0 if err == nil { bitDepth = quality.BitDepth sampleRate = quality.SampleRate } lyricsLRC := "" if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { lyricsLRC = parallelResult.LyricsLRC } return AmazonDownloadResult{ FilePath: outputPath, BitDepth: bitDepth, SampleRate: sampleRate, Title: req.TrackName, Artist: req.ArtistName, Album: req.AlbumName, ReleaseDate: req.ReleaseDate, TrackNumber: actualTrackNum, DiscNumber: actualDiscNum, ISRC: req.ISRC, LyricsLRC: lyricsLRC, DecryptionKey: decryptionKey, }, nil }