mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-21 23:47:04 +02:00
662 lines
20 KiB
Go
662 lines
20 KiB
Go
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),
|
|
"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
|
|
}
|