v3.6.5: audio format conversion, PC v7.0.8 backend merge, Amazon re-enabled

This commit is contained in:
zarzet 2026-02-10 23:35:41 +07:00
parent bae2bf63eb
commit fe1c96ea12
36 changed files with 3130 additions and 549 deletions

View file

@ -1,30 +1,56 @@
# Changelog # Changelog
## [3.6.1] - 2026-02-10 ## [3.6.5] - 2026-02-10
### Highlights
- **Audio Format Conversion**: Convert between FLAC, MP3, and Opus directly from Track Metadata screen with full metadata and cover art preservation
- **PC v7.0.8 Backend Merge**: Adopts several Go backend improvements from SpotiFLAC PC v7.0.8 including Amazon encrypted stream support, SpotFetch metadata fallback, and Qobuz API update
- **Amazon Music Re-enabled**: Amazon provider back in service with new API
### Added ### Added
- "Use Primary Artist Only" setting: strips featured artists from folder names (e.g. "Justin Bieber, Quavo" becomes "Justin Bieber") for cleaner folder organization - "Use Primary Artist Only" setting: strips featured artists from folder names (e.g. "Justin Bieber, Quavo" becomes "Justin Bieber") for cleaner folder organization
- Supports separators: `, ` `;` `&` `feat.` `ft.` `featuring` `with` `x` - Supports separators: `, ` `;` `&` `feat.` `ft.` `featuring` `with` `x`
- Available in Settings > Download > below "Use Album Artist for folders" - Available in Settings > Download > below "Use Album Artist for folders"
- Audio format conversion from Track Metadata screen
- Convert between FLAC, MP3, and Opus formats (any direction)
- Selectable bitrate: 128k, 192k, 256k, 320k
- Full metadata and cover art preservation during conversion
- Confirmation dialog before converting (original file deleted after)
- SAF storage support: copies to temp, converts, writes back via SAF
- Download history automatically updated with new file path
- Unified download request contract (`DownloadRequestPayload`) for all providers/flows - Unified download request contract (`DownloadRequestPayload`) for all providers/flows
- Includes full superset fields: lyrics mode, genre/label/copyright, provider IDs, SAF params, cover/quality settings - Includes full superset fields: lyrics mode, genre/label/copyright, provider IDs, SAF params, cover/quality settings
- Added strategy flags in payload: `use_extensions`, `use_fallback` - Added strategy flags in payload: `use_extensions`, `use_fallback`
- New Go unified router entrypoint: `DownloadByStrategy(requestJSON)` - New Go unified router entrypoint: `DownloadByStrategy(requestJSON)`
- Routing priority: YouTube service -> extension fallback -> built-in fallback -> direct service - Routing priority: YouTube service -> extension fallback -> built-in fallback -> direct service
- New Android method channel handler: `"downloadByStrategy"` -> `Gobackend.downloadByStrategy(...)` - New Android method channel handler: `"downloadByStrategy"` -> `Gobackend.downloadByStrategy(...)`
- SpotFetch metadata fallback integration for Spotify-blocked regions
- New backend client for `spotify.afkarxyz.fun/api`
- Automatic fallback in Spotify metadata fetch path when primary source fails
- Lyrics extraction now supports MP3 (ID3v2) and Opus/OGG (Vorbis comments) in addition to FLAC
- Includes heuristic detection of lyrics stored in Comment fields
### Changed ### Changed
- Merged several Go backend improvements from SpotiFLAC PC v7.0.8: Amazon new API with encrypted stream/decryption support, SpotFetch metadata fallback for Spotify-blocked regions, multi-format lyrics extraction (MP3/Opus/OGG), Qobuz Jumo API update.
- Download queue execution now builds one payload and uses a single bridge entrypoint (`PlatformBridge.downloadByStrategy`) instead of branching into multiple bridge methods - Download queue execution now builds one payload and uses a single bridge entrypoint (`PlatformBridge.downloadByStrategy`) instead of branching into multiple bridge methods
- Dart `downloadByStrategy` now sends a single request to Go (`downloadByStrategy` channel); routing concern is centralized in Go backend - Dart `downloadByStrategy` now sends a single request to Go (`downloadByStrategy` channel); routing concern is centralized in Go backend
- Legacy Dart bridge methods (`downloadTrack`, `downloadWithFallback`, `downloadWithExtensions`, `downloadFromYouTube`) are now thin wrappers and marked `@Deprecated` - Legacy Dart bridge methods (`downloadTrack`, `downloadWithFallback`, `downloadWithExtensions`, `downloadFromYouTube`) are now thin wrappers and marked `@Deprecated`
- Qobuz downloader updated to latest Jumo API contract (`/get` endpoint, required headers)
- Amazon download flow now returns `decryption_key` from Go and performs decryption in Flutter (local file + SAF paths)
- Amazon now uses the new `amazon.afkarxyz.fun` API flow (ASIN-based track endpoint + legacy fallback) with encrypted stream support
- Amazon ASIN extraction rewritten with robust URL/query-param parsing and regex fallback
- Amazon provider re-enabled in download service picker and download settings (alongside Tidal, Qobuz, and YouTube picker flow)
### Fixed ### Fixed
- Fixed lyrics mode "External .LRC" still embedding lyrics into metadata - `lyrics_mode` was not being sent to Go backend for single-service downloads and YouTube provider, causing Go to default to "embed" - Fixed lyrics mode "External .LRC" still embedding lyrics into metadata - `lyrics_mode` was not being sent to Go backend for single-service downloads and YouTube provider, causing Go to default to "embed"
- Fixed `flutter_local_notifications` v20 breaking changes - migrated all `initialize()`, `show()`, and `cancel()` calls from positional parameters to named parameters - Fixed `flutter_local_notifications` v20 breaking changes - migrated all `initialize()`, `show()`, and `cancel()` calls from positional parameters to named parameters
- Fixed SAF duplicate folder bug: concurrent batch downloads creating empty folders with `(1)`, `(2)`, `(3)` suffixes - added synchronized lock to `ensureDocumentDir` in Kotlin with duplicate detection and cleanup - Fixed SAF duplicate folder bug: concurrent batch downloads creating empty folders with `(1)`, `(2)`, `(3)` suffixes - added synchronized lock to `ensureDocumentDir` in Kotlin with duplicate detection and cleanup
- Track Metadata lyrics section now hides "Embed Lyrics" when lyrics are already embedded in file, preventing redundant embed attempts
- Fixed lyrics embed path to support FLAC/MP3/Opus consistently (including SAF files) without forcing unsupported parser paths
- Inconsistent parameter parity across download paths - Inconsistent parameter parity across download paths
- `downloadWithExtensions` now carries `copyright` - `downloadWithExtensions` now carries `copyright`
- YouTube path now carries `embed_max_quality_cover` and metadata parity fields - YouTube path now carries `embed_max_quality_cover` and metadata parity fields
@ -37,10 +63,12 @@
- Centralized request serialization in `PlatformBridge` via shared invoke helper and unified payload model - Centralized request serialization in `PlatformBridge` via shared invoke helper and unified payload model
- Go strategy router normalizes incoming service casing before dispatch - Go strategy router normalizes incoming service casing before dispatch
- Verified integration after AAR refresh with: - Extension runtime: `customSearch` now passes query/options via VM globals instead of string interpolation, preventing parser edge cases on certain devices
- `flutter analyze` - Extension runtime: JS panic handler now logs full stack trace for easier debugging
- `go test -v ./...`
- Android Kotlin compile check (`:app:compileDebugKotlin`) ### Removed
- Buy Me a Coffee references removed from donate page, FUNDING.yml, README, and all localization files (account suspended)
--- ---

View file

@ -31,6 +31,8 @@ type AmazonDownloader struct {
var ( var (
globalAmazonDownloader *AmazonDownloader globalAmazonDownloader *AmazonDownloader
amazonDownloaderOnce sync.Once 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 // AfkarXYZResponse is the response from AfkarXYZ API
@ -43,6 +45,12 @@ type AfkarXYZResponse struct {
} `json:"data"` } `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 { func NewAmazonDownloader() *AmazonDownloader {
amazonDownloaderOnce.Do(func() { amazonDownloaderOnce.Do(func() {
globalAmazonDownloader = &AmazonDownloader{ globalAmazonDownloader = &AmazonDownloader{
@ -52,10 +60,9 @@ func NewAmazonDownloader() *AmazonDownloader {
return globalAmazonDownloader return globalAmazonDownloader
} }
// fetchAmazonURLWithRetry fetches from AfkarXYZ API with retry logic for mobile networks // fetchAmazonURLWithRetry fetches from AfkarXYZ API with retry logic for mobile networks.
func (a *AmazonDownloader) fetchAmazonURLWithRetry(amazonURL string) (string, string, error) { // Returns downloadURL, suggested fileName, optional decryptionKey.
apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL) func (a *AmazonDownloader) fetchAmazonURLWithRetry(amazonURL string) (string, string, string, error) {
var lastErr error var lastErr error
for attempt := 0; attempt <= amazonMaxRetries; attempt++ { for attempt := 0; attempt <= amazonMaxRetries; attempt++ {
if attempt > 0 { if attempt > 0 {
@ -64,66 +71,184 @@ func (a *AmazonDownloader) fetchAmazonURLWithRetry(amazonURL string) (string, st
time.Sleep(delay) time.Sleep(delay)
} }
downloadURL, fileName, err := a.doAfkarXYZRequest(apiURL) downloadURL, fileName, decryptionKey, err := a.doAfkarXYZRequest(amazonURL)
if err == nil { if err == nil {
return downloadURL, fileName, nil return downloadURL, fileName, decryptionKey, nil
} }
lastErr = err lastErr = err
errStr := err.Error() errStr := strings.ToLower(err.Error())
// Check if error is retryable // Check if error is retryable
isRetryable := strings.Contains(errStr, "timeout") || isRetryable := strings.Contains(errStr, "timeout") ||
strings.Contains(errStr, "connection reset") || strings.Contains(errStr, "connection reset") ||
strings.Contains(errStr, "connection refused") || strings.Contains(errStr, "connection refused") ||
strings.Contains(errStr, "EOF") || strings.Contains(errStr, "eof") ||
strings.Contains(errStr, "status 5") || strings.Contains(errStr, "status 5") ||
strings.Contains(errStr, "status 429") strings.Contains(errStr, "status 429") ||
strings.Contains(errStr, "http 429")
if !isRetryable { if !isRetryable {
return "", "", err return "", "", "", err
} }
GoLog("[Amazon] Attempt %d failed (retryable): %v\n", attempt+1, err) GoLog("[Amazon] Attempt %d failed (retryable): %v\n", attempt+1, err)
} }
return "", "", fmt.Errorf("all %d attempts failed: %w", amazonMaxRetries+1, lastErr) return "", "", "", fmt.Errorf("all %d attempts failed: %w", amazonMaxRetries+1, lastErr)
} }
// doAfkarXYZRequest performs a single request to AfkarXYZ API func normalizeAmazonASIN(candidate string) string {
func (a *AmazonDownloader) doAfkarXYZRequest(apiURL string) (string, string, error) { 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) ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile)
defer cancel() defer cancel()
apiURL := fmt.Sprintf("https://amazon.afkarxyz.fun/api/track/%s", asin)
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
if err != nil { if err != nil {
return "", "", fmt.Errorf("failed to create request: %w", err) 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()) req.Header.Set("User-Agent", getRandomUserAgent())
resp, err := a.client.Do(req) resp, err := a.client.Do(req)
if err != nil { if err != nil {
return "", "", fmt.Errorf("failed to call AfkarXYZ API: %w", err) return "", "", "", fmt.Errorf("failed to call legacy AfkarXYZ API: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != 200 { if resp.StatusCode != 200 {
return "", "", fmt.Errorf("AfkarXYZ API returned status %d", resp.StatusCode) return "", "", "", fmt.Errorf("legacy AfkarXYZ API returned status %d", resp.StatusCode)
} }
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return "", "", fmt.Errorf("failed to read response: %w", err) return "", "", "", fmt.Errorf("failed to read legacy response: %w", err)
} }
var apiResp AfkarXYZResponse var apiResp AfkarXYZResponse
if err := json.Unmarshal(body, &apiResp); err != nil { if err := json.Unmarshal(body, &apiResp); err != nil {
return "", "", fmt.Errorf("failed to decode response: %w", err) return "", "", "", fmt.Errorf("failed to decode legacy response: %w", err)
} }
if !apiResp.Success || apiResp.Data.DirectLink == "" { if !apiResp.Success || strings.TrimSpace(apiResp.Data.DirectLink) == "" {
return "", "", fmt.Errorf("AfkarXYZ API failed or no download link found") return "", "", "", fmt.Errorf("legacy AfkarXYZ API failed or no download link found")
} }
fileName := apiResp.Data.FileName fileName := apiResp.Data.FileName
@ -134,19 +259,22 @@ func (a *AmazonDownloader) doAfkarXYZRequest(apiURL string) (string, string, err
reg := regexp.MustCompile(`[<>:"/\\|?*]`) reg := regexp.MustCompile(`[<>:"/\\|?*]`)
fileName = reg.ReplaceAllString(fileName, "") fileName = reg.ReplaceAllString(fileName, "")
return apiResp.Data.DirectLink, fileName, nil return apiResp.Data.DirectLink, fileName, "", nil
} }
func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, error) { func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, string, error) {
GoLog("[Amazon] Fetching from AfkarXYZ API...\n") GoLog("[Amazon] Fetching from AfkarXYZ API...\n")
downloadURL, fileName, err := a.fetchAmazonURLWithRetry(amazonURL) downloadURL, fileName, decryptionKey, err := a.fetchAmazonURLWithRetry(amazonURL)
if err != nil { if err != nil {
return "", "", err return "", "", "", err
} }
if decryptionKey != "" {
GoLog("[Amazon] AfkarXYZ returned encrypted stream (decryption key available)\n")
}
GoLog("[Amazon] AfkarXYZ returned: %s\n", fileName) GoLog("[Amazon] AfkarXYZ returned: %s\n", fileName)
return downloadURL, fileName, nil return downloadURL, fileName, decryptionKey, nil
} }
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error { func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error {
@ -233,17 +361,18 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string, outputFD
// AmazonDownloadResult contains download result with quality info // AmazonDownloadResult contains download result with quality info
type AmazonDownloadResult struct { type AmazonDownloadResult struct {
FilePath string FilePath string
BitDepth int BitDepth int
SampleRate int SampleRate int
Title string Title string
Artist string Artist string
Album string Album string
ReleaseDate string ReleaseDate string
TrackNumber int TrackNumber int
DiscNumber int DiscNumber int
ISRC string ISRC string
LyricsLRC string LyricsLRC string
DecryptionKey string
} }
func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
@ -299,7 +428,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
} }
// Download using AfkarXYZ API // Download using AfkarXYZ API
downloadURL, _, err := downloader.downloadFromAfkarXYZ(amazonURL) downloadURL, afkarFileName, decryptionKey, err := downloader.downloadFromAfkarXYZ(amazonURL)
if err != nil { if err != nil {
return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL from AfkarXYZ: %w", err) return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL from AfkarXYZ: %w", err)
} }
@ -321,7 +450,11 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD) outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD)
} }
} else { } else {
filename = sanitizeFilename(filename) + ".flac" outputExt := strings.ToLower(filepath.Ext(afkarFileName))
if outputExt == "" {
outputExt = ".flac"
}
filename = sanitizeFilename(filename) + outputExt
outputPath = filepath.Join(req.OutputDir, filename) outputPath = filepath.Join(req.OutputDir, filename)
if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 { if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 {
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
@ -352,6 +485,12 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err) 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 // Wait for parallel operations to complete
<-parallelDone <-parallelDone
@ -360,7 +499,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
SetItemFinalizing(req.ItemID) SetItemFinalizing(req.ItemID)
} }
existingMeta, metaErr := ReadMetadata(outputPath)
actualTrackNum := req.TrackNumber actualTrackNum := req.TrackNumber
actualDiscNum := req.DiscNumber actualDiscNum := req.DiscNumber
actualDate := req.ReleaseDate actualDate := req.ReleaseDate
@ -368,25 +506,28 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
actualTitle := req.TrackName actualTitle := req.TrackName
actualArtist := req.ArtistName actualArtist := req.ArtistName
if metaErr == nil && existingMeta != nil { if !needsDecryption {
if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) { existingMeta, metaErr := ReadMetadata(actualOutputPath)
actualTrackNum = existingMeta.TrackNumber if metaErr == nil && existingMeta != nil {
GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber) 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)
} }
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{ metadata := Metadata{
@ -409,7 +550,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
coverData = parallelResult.CoverData coverData = parallelResult.CoverData
GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData)) GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData))
} else { } else {
existingCover, coverErr := ExtractCoverArt(outputPath) existingCover, coverErr := ExtractCoverArt(actualOutputPath)
if coverErr == nil && len(existingCover) > 0 { if coverErr == nil && len(existingCover) > 0 {
coverData = existingCover coverData = existingCover
GoLog("[Amazon] Using existing cover from Amazon file (%d bytes)\n", len(coverData)) GoLog("[Amazon] Using existing cover from Amazon file (%d bytes)\n", len(coverData))
@ -418,11 +559,16 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
} }
} }
if isSafOutput { if isSafOutput || needsDecryption {
GoLog("[Amazon] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n") GoLog("[Amazon] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n")
} else { } else {
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil { isFlacOutput := strings.HasSuffix(strings.ToLower(actualOutputPath), ".flac")
GoLog("[Amazon] Warning: failed to embed metadata: %v\n", err) 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 != "" { if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
@ -433,20 +579,22 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
if lyricsMode == "external" || lyricsMode == "both" { if lyricsMode == "external" || lyricsMode == "both" {
GoLog("[Amazon] Saving external LRC file...\n") GoLog("[Amazon] Saving external LRC file...\n")
if lrcPath, lrcErr := SaveLRCFile(outputPath, parallelResult.LyricsLRC); lrcErr != nil { if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil {
GoLog("[Amazon] Warning: failed to save LRC file: %v\n", lrcErr) GoLog("[Amazon] Warning: failed to save LRC file: %v\n", lrcErr)
} else { } else {
GoLog("[Amazon] LRC file saved: %s\n", lrcPath) GoLog("[Amazon] LRC file saved: %s\n", lrcPath)
} }
} }
if lyricsMode == "embed" || lyricsMode == "both" { if (lyricsMode == "embed" || lyricsMode == "both") && isFlacOutput {
GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines)) GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil { if embedErr := EmbedLyrics(actualOutputPath, parallelResult.LyricsLRC); embedErr != nil {
GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr) GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
} else { } else {
GoLog("[Amazon] Lyrics embedded successfully\n") 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 { } else if req.EmbedLyrics {
GoLog("[Amazon] No lyrics available from parallel fetch\n") GoLog("[Amazon] No lyrics available from parallel fetch\n")
@ -456,17 +604,17 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
GoLog("[Amazon] Downloaded successfully from Amazon Music\n") GoLog("[Amazon] Downloaded successfully from Amazon Music\n")
quality := AudioQuality{} quality := AudioQuality{}
if isSafOutput { if isSafOutput || needsDecryption {
GoLog("[Amazon] SAF output detected - skipping post-write file inspection in backend\n") GoLog("[Amazon] SAF output detected - skipping post-write file inspection in backend\n")
} else { } else {
quality, err = GetAudioQuality(outputPath) quality, err = GetAudioQuality(actualOutputPath)
if err != nil { if err != nil {
GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err) GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err)
} else { } else {
GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate) GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate)
} }
finalMeta, metaReadErr := ReadMetadata(outputPath) finalMeta, metaReadErr := ReadMetadata(actualOutputPath)
if metaReadErr == nil && finalMeta != nil { if metaReadErr == nil && finalMeta != nil {
GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n", GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n",
finalMeta.TrackNumber, finalMeta.DiscNumber, finalMeta.Date) finalMeta.TrackNumber, finalMeta.DiscNumber, finalMeta.Date)
@ -478,9 +626,10 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
} }
} }
// Add to ISRC index for fast duplicate checking // Add to ISRC index for fast duplicate checking.
if !isSafOutput { // When decryption is pending in Flutter, postpone indexing until final file is settled.
AddToISRCIndex(req.OutputDir, req.ISRC, outputPath) if !isSafOutput && !needsDecryption {
AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath)
} }
bitDepth := 0 bitDepth := 0
@ -496,16 +645,17 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
} }
return AmazonDownloadResult{ return AmazonDownloadResult{
FilePath: outputPath, FilePath: outputPath,
BitDepth: bitDepth, BitDepth: bitDepth,
SampleRate: sampleRate, SampleRate: sampleRate,
Title: req.TrackName, Title: req.TrackName,
Artist: req.ArtistName, Artist: req.ArtistName,
Album: req.AlbumName, Album: req.AlbumName,
ReleaseDate: req.ReleaseDate, ReleaseDate: req.ReleaseDate,
TrackNumber: actualTrackNum, TrackNumber: actualTrackNum,
DiscNumber: actualDiscNum, DiscNumber: actualDiscNum,
ISRC: req.ISRC, ISRC: req.ISRC,
LyricsLRC: lyricsLRC, LyricsLRC: lyricsLRC,
DecryptionKey: decryptionKey,
}, nil }, nil
} }

View file

@ -0,0 +1,46 @@
package gobackend
import "testing"
func TestExtractAmazonASIN(t *testing.T) {
tests := []struct {
name string
url string
want string
}{
{
name: "prefers trackAsin over albumAsin",
url: "https://music.amazon.com/albums/B0ALBUM123?trackAsin=B0TRACK456&musicTerritory=US",
want: "B0TRACK456",
},
{
name: "extract from tracks path",
url: "https://music.amazon.com/tracks/B0CYQHGWZJ?musicTerritory=US",
want: "B0CYQHGWZJ",
},
{
name: "extract from plain query asin",
url: "https://example.com/?asin=B0CYQHGWZJ",
want: "B0CYQHGWZJ",
},
{
name: "fallback regex",
url: "https://example.com/path/B0CYQHGWZJ",
want: "B0CYQHGWZJ",
},
{
name: "invalid url",
url: "https://music.amazon.com/tracks/not-valid",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractAmazonASIN(tt.url)
if got != tt.want {
t.Fatalf("extractAmazonASIN() = %q, want %q", got, tt.want)
}
})
}
}

View file

@ -23,6 +23,7 @@ type AudioMetadata struct {
TrackNumber int TrackNumber int
DiscNumber int DiscNumber int
ISRC string ISRC string
Lyrics string
Label string Label string
Copyright string Copyright string
Composer string Composer string
@ -181,6 +182,15 @@ func parseID3v22Frames(data []byte, metadata *AudioMetadata, tagUnsync bool) {
metadata.Label = value metadata.Label = value
case "TCR": case "TCR":
metadata.Copyright = value metadata.Copyright = value
case "ULT":
if v := extractLyricsFrame(frameData); v != "" && metadata.Lyrics == "" {
metadata.Lyrics = v
}
case "TXX":
desc, userValue := extractUserTextFrame(frameData)
if isLyricsDescription(desc) && userValue != "" && metadata.Lyrics == "" {
metadata.Lyrics = userValue
}
} }
pos += 6 + frameSize pos += 6 + frameSize
@ -297,6 +307,15 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn
if v := extractCommentFrame(frameData); v != "" { if v := extractCommentFrame(frameData); v != "" {
metadata.Comment = v metadata.Comment = v
} }
case "USLT":
if v := extractLyricsFrame(frameData); v != "" && metadata.Lyrics == "" {
metadata.Lyrics = v
}
case "TXXX":
desc, userValue := extractUserTextFrame(frameData)
if isLyricsDescription(desc) && userValue != "" && metadata.Lyrics == "" {
metadata.Lyrics = userValue
}
} }
pos += 10 + frameSize pos += 10 + frameSize
@ -399,6 +418,98 @@ func extractCommentFrame(data []byte) string {
return extractTextFrame(framed) return extractTextFrame(framed)
} }
// extractLyricsFrame parses ID3 unsynchronized lyrics frames (USLT/ULT).
// Format: encoding(1) + language(3) + description(null-terminated) + lyrics text.
func extractLyricsFrame(data []byte) string {
if len(data) < 5 {
return ""
}
encoding := data[0]
rest := data[4:] // skip 3-byte language code
var text []byte
switch encoding {
case 1, 2: // UTF-16 variants use double-null terminator
for i := 0; i+1 < len(rest); i += 2 {
if rest[i] == 0 && rest[i+1] == 0 {
text = rest[i+2:]
break
}
}
default: // ISO-8859-1 or UTF-8
idx := bytes.IndexByte(rest, 0)
if idx >= 0 && idx+1 < len(rest) {
text = rest[idx+1:]
} else {
text = rest
}
}
if len(text) == 0 {
return ""
}
framed := make([]byte, 1+len(text))
framed[0] = encoding
copy(framed[1:], text)
return extractTextFrame(framed)
}
// extractUserTextFrame parses ID3 TXXX/TXX user text frame:
// encoding(1) + description + separator + value.
func extractUserTextFrame(data []byte) (string, string) {
if len(data) < 2 {
return "", ""
}
encoding := data[0]
payload := data[1:]
var descRaw, valueRaw []byte
switch encoding {
case 1, 2: // UTF-16 variants
for i := 0; i+1 < len(payload); i += 2 {
if payload[i] == 0 && payload[i+1] == 0 {
descRaw = payload[:i]
valueRaw = payload[i+2:]
break
}
}
default: // ISO-8859-1 or UTF-8
idx := bytes.IndexByte(payload, 0)
if idx >= 0 {
descRaw = payload[:idx]
if idx+1 <= len(payload) {
valueRaw = payload[idx+1:]
}
}
}
if len(valueRaw) == 0 {
return "", ""
}
descFramed := make([]byte, 1+len(descRaw))
descFramed[0] = encoding
copy(descFramed[1:], descRaw)
valueFramed := make([]byte, 1+len(valueRaw))
valueFramed[0] = encoding
copy(valueFramed[1:], valueRaw)
return strings.TrimSpace(extractTextFrame(descFramed)), strings.TrimSpace(extractTextFrame(valueFramed))
}
func isLyricsDescription(description string) bool {
switch strings.ToLower(strings.TrimSpace(description)) {
case "lyrics", "lyric", "unsyncedlyrics", "unsynced lyrics", "lrc":
return true
default:
return false
}
}
func decodeUTF16(data []byte) string { func decodeUTF16(data []byte) string {
if len(data) < 2 { if len(data) < 2 {
return "" return ""
@ -843,6 +954,10 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) {
metadata.Composer = value metadata.Composer = value
case "COMMENT", "DESCRIPTION": case "COMMENT", "DESCRIPTION":
metadata.Comment = value metadata.Comment = value
case "LYRICS", "UNSYNCEDLYRICS":
if metadata.Lyrics == "" {
metadata.Lyrics = value
}
case "ORGANIZATION", "LABEL", "PUBLISHER": case "ORGANIZATION", "LABEL", "PUBLISHER":
metadata.Label = value metadata.Label = value
case "COPYRIGHT": case "COPYRIGHT":

View file

@ -47,10 +47,30 @@ func GetSpotifyMetadata(spotifyURL string) (string, error) {
client, err := NewSpotifyMetadataClient() client, err := NewSpotifyMetadataClient()
if err != nil { if err != nil {
if shouldTrySpotFetchFallback(err) {
data, apiErr := GetSpotifyDataWithAPI(ctx, spotifyURL, DefaultSpotFetchAPIBaseURL)
if apiErr == nil {
jsonBytes, marshalErr := json.Marshal(data)
if marshalErr != nil {
return "", marshalErr
}
return string(jsonBytes), nil
}
}
return "", err return "", err
} }
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0) data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
if err != nil { if err != nil {
if shouldTrySpotFetchFallback(err) {
fallbackData, apiErr := GetSpotifyDataWithAPI(ctx, spotifyURL, DefaultSpotFetchAPIBaseURL)
if apiErr == nil {
jsonBytes, marshalErr := json.Marshal(fallbackData)
if marshalErr != nil {
return "", marshalErr
}
return string(jsonBytes), nil
}
}
return "", err return "", err
} }
@ -178,20 +198,22 @@ type DownloadResponse struct {
Copyright string `json:"copyright,omitempty"` Copyright string `json:"copyright,omitempty"`
SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"` SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"`
LyricsLRC string `json:"lyrics_lrc,omitempty"` LyricsLRC string `json:"lyrics_lrc,omitempty"`
DecryptionKey string `json:"decryption_key,omitempty"`
} }
type DownloadResult struct { type DownloadResult struct {
FilePath string FilePath string
BitDepth int BitDepth int
SampleRate int SampleRate int
Title string Title string
Artist string Artist string
Album string Album string
ReleaseDate string ReleaseDate string
TrackNumber int TrackNumber int
DiscNumber int DiscNumber int
ISRC string ISRC string
LyricsLRC string LyricsLRC string
DecryptionKey string
} }
func buildDownloadSuccessResponse( func buildDownloadSuccessResponse(
@ -258,6 +280,7 @@ func buildDownloadSuccessResponse(
Label: req.Label, Label: req.Label,
Copyright: req.Copyright, Copyright: req.Copyright,
LyricsLRC: result.LyricsLRC, LyricsLRC: result.LyricsLRC,
DecryptionKey: result.DecryptionKey,
} }
} }
@ -323,17 +346,18 @@ func DownloadTrack(requestJSON string) (string, error) {
amazonResult, amazonErr := downloadFromAmazon(req) amazonResult, amazonErr := downloadFromAmazon(req)
if amazonErr == nil { if amazonErr == nil {
result = DownloadResult{ result = DownloadResult{
FilePath: amazonResult.FilePath, FilePath: amazonResult.FilePath,
BitDepth: amazonResult.BitDepth, BitDepth: amazonResult.BitDepth,
SampleRate: amazonResult.SampleRate, SampleRate: amazonResult.SampleRate,
Title: amazonResult.Title, Title: amazonResult.Title,
Artist: amazonResult.Artist, Artist: amazonResult.Artist,
Album: amazonResult.Album, Album: amazonResult.Album,
ReleaseDate: amazonResult.ReleaseDate, ReleaseDate: amazonResult.ReleaseDate,
TrackNumber: amazonResult.TrackNumber, TrackNumber: amazonResult.TrackNumber,
DiscNumber: amazonResult.DiscNumber, DiscNumber: amazonResult.DiscNumber,
ISRC: amazonResult.ISRC, ISRC: amazonResult.ISRC,
LyricsLRC: amazonResult.LyricsLRC, LyricsLRC: amazonResult.LyricsLRC,
DecryptionKey: amazonResult.DecryptionKey,
} }
} }
err = amazonErr err = amazonErr
@ -534,17 +558,18 @@ func DownloadWithFallback(requestJSON string) (string, error) {
amazonResult, amazonErr := downloadFromAmazon(req) amazonResult, amazonErr := downloadFromAmazon(req)
if amazonErr == nil { if amazonErr == nil {
result = DownloadResult{ result = DownloadResult{
FilePath: amazonResult.FilePath, FilePath: amazonResult.FilePath,
BitDepth: amazonResult.BitDepth, BitDepth: amazonResult.BitDepth,
SampleRate: amazonResult.SampleRate, SampleRate: amazonResult.SampleRate,
Title: amazonResult.Title, Title: amazonResult.Title,
Artist: amazonResult.Artist, Artist: amazonResult.Artist,
Album: amazonResult.Album, Album: amazonResult.Album,
ReleaseDate: amazonResult.ReleaseDate, ReleaseDate: amazonResult.ReleaseDate,
TrackNumber: amazonResult.TrackNumber, TrackNumber: amazonResult.TrackNumber,
DiscNumber: amazonResult.DiscNumber, DiscNumber: amazonResult.DiscNumber,
ISRC: amazonResult.ISRC, ISRC: amazonResult.ISRC,
LyricsLRC: amazonResult.LyricsLRC, LyricsLRC: amazonResult.LyricsLRC,
DecryptionKey: amazonResult.DecryptionKey,
} }
} else if !errors.Is(amazonErr, ErrDownloadCancelled) { } else if !errors.Is(amazonErr, ErrDownloadCancelled) {
GoLog("[DownloadWithFallback] Amazon error: %v\n", amazonErr) GoLog("[DownloadWithFallback] Amazon error: %v\n", amazonErr)
@ -699,6 +724,7 @@ func ReadFileMetadata(filePath string) (string, error) {
result["track_number"] = meta.TrackNumber result["track_number"] = meta.TrackNumber
result["disc_number"] = meta.DiscNumber result["disc_number"] = meta.DiscNumber
result["isrc"] = meta.ISRC result["isrc"] = meta.ISRC
result["lyrics"] = meta.Lyrics
result["genre"] = meta.Genre result["genre"] = meta.Genre
result["composer"] = meta.Composer result["composer"] = meta.Composer
result["comment"] = meta.Comment result["comment"] = meta.Comment
@ -723,6 +749,7 @@ func ReadFileMetadata(filePath string) (string, error) {
result["track_number"] = meta.TrackNumber result["track_number"] = meta.TrackNumber
result["disc_number"] = meta.DiscNumber result["disc_number"] = meta.DiscNumber
result["isrc"] = meta.ISRC result["isrc"] = meta.ISRC
result["lyrics"] = meta.Lyrics
result["genre"] = meta.Genre result["genre"] = meta.Genre
result["composer"] = meta.Composer result["composer"] = meta.Composer
result["comment"] = meta.Comment result["comment"] = meta.Comment
@ -1178,9 +1205,12 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
var spotifyErr error
client, err := NewSpotifyMetadataClient() client, err := NewSpotifyMetadataClient()
if err != nil { if err != nil {
LogWarn("Spotify", "Credentials not configured, falling back to Deezer") LogWarn("Spotify", "Credentials not configured, falling back to Deezer")
spotifyErr = err
} else { } else {
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0) data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
if err == nil { if err == nil {
@ -1191,28 +1221,81 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
errStr := strings.ToLower(err.Error()) spotifyErr = err
if !strings.Contains(errStr, "429") && !strings.Contains(errStr, "rate") && !strings.Contains(errStr, "limit") { if !shouldTrySpotFetchFallback(err) {
return "", err return "", err
} }
} }
spotFetchData, apiErr := GetSpotifyDataWithAPI(ctx, spotifyURL, DefaultSpotFetchAPIBaseURL)
if apiErr == nil {
GoLog("[Fallback] Spotify metadata fetched via SpotFetch API\n")
jsonBytes, err := json.Marshal(spotFetchData)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
GoLog("[Fallback] SpotFetch API fallback failed: %v\n", apiErr)
parsed, parseErr := parseSpotifyURI(spotifyURL) parsed, parseErr := parseSpotifyURI(spotifyURL)
if parseErr != nil { if parseErr != nil {
return "", fmt.Errorf("spotify rate limited and failed to parse URL: %w", parseErr) if spotifyErr != nil {
return "", fmt.Errorf("spotify failed (%v), SpotFetch fallback failed (%v), and URL parsing failed: %w", spotifyErr, apiErr, parseErr)
}
return "", fmt.Errorf("SpotFetch fallback failed (%v) and URL parsing failed: %w", apiErr, parseErr)
} }
GoLog("[Fallback] Spotify rate limited for %s, trying Deezer...\n", parsed.Type) GoLog("[Fallback] Trying Deezer conversion fallback for %s...\n", parsed.Type)
if parsed.Type == "track" || parsed.Type == "album" { if parsed.Type == "track" || parsed.Type == "album" {
return ConvertSpotifyToDeezer(parsed.Type, parsed.ID) return ConvertSpotifyToDeezer(parsed.Type, parsed.ID)
} }
if parsed.Type == "artist" { if parsed.Type == "artist" {
return "", fmt.Errorf("spotify rate limited. Artist pages require Spotify API - please try again later") if spotifyErr != nil {
return "", fmt.Errorf("spotify metadata unavailable (%v) and SpotFetch fallback failed (%v). Artist pages require Spotify/SpotFetch API", spotifyErr, apiErr)
}
return "", fmt.Errorf("SpotFetch fallback failed (%v). Artist pages require Spotify/SpotFetch API", apiErr)
} }
return "", fmt.Errorf("spotify rate limited. Playlists are user-specific and require Spotify API") if spotifyErr != nil {
return "", fmt.Errorf("spotify metadata unavailable (%v), SpotFetch fallback failed (%v), and Deezer conversion is unavailable for playlists", spotifyErr, apiErr)
}
return "", fmt.Errorf("SpotFetch fallback failed (%v), and Deezer conversion is unavailable for playlists", apiErr)
}
func shouldTrySpotFetchFallback(err error) bool {
if err == nil {
return false
}
if errors.Is(err, ErrNoSpotifyCredentials) {
return true
}
errStr := strings.ToLower(err.Error())
indicators := []string{
"429",
"rate",
"limit",
"403",
"forbidden",
"401",
"unauthorized",
"timeout",
"connection",
"spotify error",
"access token",
"client token",
"eof",
}
for _, indicator := range indicators {
if strings.Contains(errStr, indicator) {
return true
}
}
return false
} }
func CheckAvailabilityFromDeezerID(deezerTrackID string) (string, error) { func CheckAvailabilityFromDeezerID(deezerTrackID string) (string, error) {

View file

@ -1082,16 +1082,18 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
amazonResult, amazonErr := downloadFromAmazon(req) amazonResult, amazonErr := downloadFromAmazon(req)
if amazonErr == nil { if amazonErr == nil {
result = DownloadResult{ result = DownloadResult{
FilePath: amazonResult.FilePath, FilePath: amazonResult.FilePath,
BitDepth: amazonResult.BitDepth, BitDepth: amazonResult.BitDepth,
SampleRate: amazonResult.SampleRate, SampleRate: amazonResult.SampleRate,
Title: amazonResult.Title, Title: amazonResult.Title,
Artist: amazonResult.Artist, Artist: amazonResult.Artist,
Album: amazonResult.Album, Album: amazonResult.Album,
ReleaseDate: amazonResult.ReleaseDate, ReleaseDate: amazonResult.ReleaseDate,
TrackNumber: amazonResult.TrackNumber, TrackNumber: amazonResult.TrackNumber,
DiscNumber: amazonResult.DiscNumber, DiscNumber: amazonResult.DiscNumber,
ISRC: amazonResult.ISRC, ISRC: amazonResult.ISRC,
LyricsLRC: amazonResult.LyricsLRC,
DecryptionKey: amazonResult.DecryptionKey,
} }
} }
err = amazonErr err = amazonErr
@ -1119,6 +1121,8 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
Genre: req.Genre, Genre: req.Genre,
Label: req.Label, Label: req.Label,
Copyright: req.Copyright, Copyright: req.Copyright,
LyricsLRC: result.LyricsLRC,
DecryptionKey: result.DecryptionKey,
}, nil }, nil
} }
@ -1164,16 +1168,30 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string
p.extension.VMMu.Lock() p.extension.VMMu.Lock()
defer p.extension.VMMu.Unlock() defer p.extension.VMMu.Unlock()
optionsJSON, _ := json.Marshal(options) if options == nil {
options = map[string]interface{}{}
}
script := fmt.Sprintf(` // Avoid embedding user input directly into JS source. Some inputs can trigger
// parser/runtime edge cases on specific devices/Goja builds.
const queryVar = "__sf_custom_search_query"
const optionsVar = "__sf_custom_search_options"
global := p.vm.GlobalObject()
_ = global.Set(queryVar, query)
_ = global.Set(optionsVar, options)
defer func() {
global.Delete(queryVar)
global.Delete(optionsVar)
}()
const script = `
(function() { (function() {
if (typeof extension !== 'undefined' && typeof extension.customSearch === 'function') { if (typeof extension !== 'undefined' && typeof extension.customSearch === 'function') {
return extension.customSearch(%q, %s); return extension.customSearch(__sf_custom_search_query, __sf_custom_search_options);
} }
return null; return null;
})() })()
`, query, string(optionsJSON)) `
result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout)
if err != nil { if err != nil {
@ -1358,12 +1376,12 @@ type PostProcessResult struct {
} }
type PostProcessInput struct { type PostProcessInput struct {
Path string `json:"path,omitempty"` Path string `json:"path,omitempty"`
URI string `json:"uri,omitempty"` URI string `json:"uri,omitempty"`
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
MimeType string `json:"mime_type,omitempty"` MimeType string `json:"mime_type,omitempty"`
Size int64 `json:"size,omitempty"` Size int64 `json:"size,omitempty"`
IsSAF bool `json:"is_saf,omitempty"` IsSAF bool `json:"is_saf,omitempty"`
} }
const PostProcessTimeout = 2 * time.Minute const PostProcessTimeout = 2 * time.Minute

View file

@ -4,6 +4,7 @@ package gobackend
import ( import (
"context" "context"
"fmt" "fmt"
"runtime/debug"
"sync" "sync"
"time" "time"
@ -49,6 +50,7 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj
IsTimeout: true, IsTimeout: true,
}} }}
} else { } else {
GoLog("[ExtensionRuntime] panic during JS execution: %v\n%s\n", r, string(debug.Stack()))
resultCh <- result{nil, fmt.Errorf("panic during execution: %v", r)} resultCh <- result{nil, fmt.Errorf("panic during execution: %v", r)}
} }
} }

View file

@ -475,33 +475,91 @@ func EmbedGenreLabel(filePath string, genre, label string) error {
} }
func ExtractLyrics(filePath string) (string, error) { func ExtractLyrics(filePath string) (string, error) {
lower := strings.ToLower(filePath)
if strings.HasSuffix(lower, ".flac") {
return extractLyricsFromFlac(filePath)
}
if strings.HasSuffix(lower, ".mp3") {
meta, err := ReadID3Tags(filePath)
if err != nil || meta == nil {
return "", fmt.Errorf("no lyrics found in file")
}
if strings.TrimSpace(meta.Lyrics) != "" {
return meta.Lyrics, nil
}
if looksLikeEmbeddedLyrics(meta.Comment) {
return meta.Comment, nil
}
return "", fmt.Errorf("no lyrics found in file")
}
if strings.HasSuffix(lower, ".opus") || strings.HasSuffix(lower, ".ogg") {
meta, err := ReadOggVorbisComments(filePath)
if err != nil || meta == nil {
return "", fmt.Errorf("no lyrics found in file")
}
if strings.TrimSpace(meta.Lyrics) != "" {
return meta.Lyrics, nil
}
if looksLikeEmbeddedLyrics(meta.Comment) {
return meta.Comment, nil
}
return "", fmt.Errorf("no lyrics found in file")
}
return "", fmt.Errorf("unsupported file format for lyrics extraction")
}
func extractLyricsFromFlac(filePath string) (string, error) {
f, err := flac.ParseFile(filePath) f, err := flac.ParseFile(filePath)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to parse FLAC file: %w", err) return "", fmt.Errorf("failed to parse FLAC file: %w", err)
} }
for _, meta := range f.Meta { for _, meta := range f.Meta {
if meta.Type == flac.VorbisComment { if meta.Type != flac.VorbisComment {
cmt, err := flacvorbis.ParseFromMetaDataBlock(*meta) continue
if err != nil { }
continue
}
lyrics, err := cmt.Get("LYRICS") cmt, err := flacvorbis.ParseFromMetaDataBlock(*meta)
if err == nil && len(lyrics) > 0 && lyrics[0] != "" { if err != nil {
return lyrics[0], nil continue
} }
lyrics, err = cmt.Get("UNSYNCEDLYRICS") lyrics, err := cmt.Get("LYRICS")
if err == nil && len(lyrics) > 0 && lyrics[0] != "" { if err == nil && len(lyrics) > 0 && strings.TrimSpace(lyrics[0]) != "" {
return lyrics[0], nil return lyrics[0], nil
} }
lyrics, err = cmt.Get("UNSYNCEDLYRICS")
if err == nil && len(lyrics) > 0 && strings.TrimSpace(lyrics[0]) != "" {
return lyrics[0], nil
} }
} }
return "", fmt.Errorf("no lyrics found in file") return "", fmt.Errorf("no lyrics found in file")
} }
func looksLikeEmbeddedLyrics(value string) bool {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return false
}
lower := strings.ToLower(trimmed)
if strings.Contains(lower, "[ar:") || strings.Contains(lower, "[ti:") {
return true
}
if strings.Contains(trimmed, "\n") && strings.Contains(trimmed, "[") && strings.Contains(trimmed, "]") {
return true
}
return false
}
type AudioQuality struct { type AudioQuality struct {
BitDepth int `json:"bit_depth"` BitDepth int `json:"bit_depth"`
SampleRate int `json:"sample_rate"` SampleRate int `json:"sample_rate"`

View file

@ -419,7 +419,7 @@ func extractQobuzDownloadURLFromBody(body []byte) (string, error) {
func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (string, error) { func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (string, error) {
formatID := mapJumoQuality(quality) formatID := mapJumoQuality(quality)
region := "US" region := "US"
jumoURL := fmt.Sprintf("https://jumo-dl.pages.dev/file?track_id=%d&format_id=%d&region=%s", trackID, formatID, region) jumoURL := fmt.Sprintf("https://jumo-dl.pages.dev/get?track_id=%d&format_id=%d&region=%s", trackID, formatID, region)
GoLog("[Qobuz] Trying Jumo API fallback...\n") GoLog("[Qobuz] Trying Jumo API fallback...\n")
@ -428,6 +428,8 @@ func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (strin
if err != nil { if err != nil {
return "", err return "", err
} }
req.Header.Set("User-Agent", getRandomUserAgent())
req.Header.Set("Referer", "https://jumo-dl.pages.dev/")
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {

View file

@ -0,0 +1,80 @@
package gobackend
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
const DefaultSpotFetchAPIBaseURL = "https://spotify.afkarxyz.fun/api"
// GetSpotifyDataWithAPI fetches Spotify metadata through SpotFetch-compatible API.
// This is used as a fallback when direct Spotify API access is blocked/limited.
func GetSpotifyDataWithAPI(ctx context.Context, spotifyURL, apiBaseURL string) (interface{}, error) {
parsed, err := parseSpotifyURI(spotifyURL)
if err != nil {
return nil, fmt.Errorf("invalid Spotify URL: %w", err)
}
base := strings.TrimSpace(apiBaseURL)
if base == "" {
base = DefaultSpotFetchAPIBaseURL
}
endpoint := fmt.Sprintf("%s/%s/%s", strings.TrimSuffix(base, "/"), parsed.Type, parsed.ID)
req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to create SpotFetch API request: %w", err)
}
req.Header.Set("User-Agent", getRandomUserAgent())
req.Header.Set("Accept", "application/json")
client := NewHTTPClientWithTimeout(30 * time.Second)
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("SpotFetch API request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("SpotFetch API error: HTTP %d", resp.StatusCode)
}
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read SpotFetch API response: %w", err)
}
switch parsed.Type {
case "track":
var trackResp TrackResponse
if err := json.Unmarshal(bodyBytes, &trackResp); err != nil {
return nil, fmt.Errorf("failed to decode track response: %w", err)
}
return trackResp, nil
case "album":
var albumResp AlbumResponsePayload
if err := json.Unmarshal(bodyBytes, &albumResp); err != nil {
return nil, fmt.Errorf("failed to decode album response: %w", err)
}
return &albumResp, nil
case "playlist":
var playlistResp PlaylistResponsePayload
if err := json.Unmarshal(bodyBytes, &playlistResp); err != nil {
return nil, fmt.Errorf("failed to decode playlist response: %w", err)
}
return playlistResp, nil
case "artist":
var artistResp ArtistResponsePayload
if err := json.Unmarshal(bodyBytes, &artistResp); err != nil {
return nil, fmt.Errorf("failed to decode artist response: %w", err)
}
return &artistResp, nil
default:
return nil, fmt.Errorf("unsupported Spotify type: %s", parsed.Type)
}
}

View file

@ -1,8 +1,8 @@
/// App version and info constants /// App version and info constants
/// Update version here only - all other files will reference this /// Update version here only - all other files will reference this
class AppInfo { class AppInfo {
static const String version = '3.6.1'; static const String version = '3.6.5';
static const String buildNumber = '78'; static const String buildNumber = '79';
static const String fullVersion = '$version+$buildNumber'; static const String fullVersion = '$version+$buildNumber';

View file

@ -5139,6 +5139,70 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Failed: {error}'** /// **'Failed: {error}'**
String trackSaveFailed(String error); String trackSaveFailed(String error);
/// Menu item - convert audio format
///
/// In en, this message translates to:
/// **'Convert Format'**
String get trackConvertFormat;
/// Subtitle for convert format menu item
///
/// In en, this message translates to:
/// **'Convert to MP3 or Opus'**
String get trackConvertFormatSubtitle;
/// Title of convert bottom sheet
///
/// In en, this message translates to:
/// **'Convert Audio'**
String get trackConvertTitle;
/// Label for format selection
///
/// In en, this message translates to:
/// **'Target Format'**
String get trackConvertTargetFormat;
/// Label for bitrate selection
///
/// In en, this message translates to:
/// **'Bitrate'**
String get trackConvertBitrate;
/// Confirmation dialog title
///
/// In en, this message translates to:
/// **'Confirm Conversion'**
String get trackConvertConfirmTitle;
/// Confirmation dialog message
///
/// In en, this message translates to:
/// **'Convert from {sourceFormat} to {targetFormat} at {bitrate}?\n\nThe original file will be deleted after conversion.'**
String trackConvertConfirmMessage(
String sourceFormat,
String targetFormat,
String bitrate,
);
/// Snackbar while converting
///
/// In en, this message translates to:
/// **'Converting audio...'**
String get trackConvertConverting;
/// Snackbar after successful conversion
///
/// In en, this message translates to:
/// **'Converted to {format} successfully'**
String trackConvertSuccess(String format);
/// Snackbar when conversion fails
///
/// In en, this message translates to:
/// **'Conversion failed'**
String get trackConvertFailed;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View file

@ -2908,4 +2908,42 @@ class AppLocalizationsDe extends AppLocalizations {
String trackSaveFailed(String error) { String trackSaveFailed(String error) {
return 'Failed: $error'; return 'Failed: $error';
} }
@override
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
@override
String get trackConvertTitle => 'Convert Audio';
@override
String get trackConvertTargetFormat => 'Target Format';
@override
String get trackConvertBitrate => 'Bitrate';
@override
String get trackConvertConfirmTitle => 'Confirm Conversion';
@override
String trackConvertConfirmMessage(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertConverting => 'Converting audio...';
@override
String trackConvertSuccess(String format) {
return 'Converted to $format successfully';
}
@override
String get trackConvertFailed => 'Conversion failed';
} }

View file

@ -2894,4 +2894,42 @@ class AppLocalizationsEn extends AppLocalizations {
String trackSaveFailed(String error) { String trackSaveFailed(String error) {
return 'Failed: $error'; return 'Failed: $error';
} }
@override
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
@override
String get trackConvertTitle => 'Convert Audio';
@override
String get trackConvertTargetFormat => 'Target Format';
@override
String get trackConvertBitrate => 'Bitrate';
@override
String get trackConvertConfirmTitle => 'Confirm Conversion';
@override
String trackConvertConfirmMessage(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertConverting => 'Converting audio...';
@override
String trackConvertSuccess(String format) {
return 'Converted to $format successfully';
}
@override
String get trackConvertFailed => 'Conversion failed';
} }

View file

@ -2894,6 +2894,44 @@ class AppLocalizationsEs extends AppLocalizations {
String trackSaveFailed(String error) { String trackSaveFailed(String error) {
return 'Failed: $error'; return 'Failed: $error';
} }
@override
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
@override
String get trackConvertTitle => 'Convert Audio';
@override
String get trackConvertTargetFormat => 'Target Format';
@override
String get trackConvertBitrate => 'Bitrate';
@override
String get trackConvertConfirmTitle => 'Confirm Conversion';
@override
String trackConvertConfirmMessage(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertConverting => 'Converting audio...';
@override
String trackConvertSuccess(String format) {
return 'Converted to $format successfully';
}
@override
String get trackConvertFailed => 'Conversion failed';
} }
/// The translations for Spanish Castilian, as used in Spain (`es_ES`). /// The translations for Spanish Castilian, as used in Spain (`es_ES`).

View file

@ -2894,4 +2894,42 @@ class AppLocalizationsFr extends AppLocalizations {
String trackSaveFailed(String error) { String trackSaveFailed(String error) {
return 'Failed: $error'; return 'Failed: $error';
} }
@override
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
@override
String get trackConvertTitle => 'Convert Audio';
@override
String get trackConvertTargetFormat => 'Target Format';
@override
String get trackConvertBitrate => 'Bitrate';
@override
String get trackConvertConfirmTitle => 'Confirm Conversion';
@override
String trackConvertConfirmMessage(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertConverting => 'Converting audio...';
@override
String trackConvertSuccess(String format) {
return 'Converted to $format successfully';
}
@override
String get trackConvertFailed => 'Conversion failed';
} }

View file

@ -2894,4 +2894,42 @@ class AppLocalizationsHi extends AppLocalizations {
String trackSaveFailed(String error) { String trackSaveFailed(String error) {
return 'Failed: $error'; return 'Failed: $error';
} }
@override
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
@override
String get trackConvertTitle => 'Convert Audio';
@override
String get trackConvertTargetFormat => 'Target Format';
@override
String get trackConvertBitrate => 'Bitrate';
@override
String get trackConvertConfirmTitle => 'Confirm Conversion';
@override
String trackConvertConfirmMessage(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertConverting => 'Converting audio...';
@override
String trackConvertSuccess(String format) {
return 'Converted to $format successfully';
}
@override
String get trackConvertFailed => 'Conversion failed';
} }

View file

@ -2913,4 +2913,42 @@ class AppLocalizationsId extends AppLocalizations {
String trackSaveFailed(String error) { String trackSaveFailed(String error) {
return 'Gagal: $error'; return 'Gagal: $error';
} }
@override
String get trackConvertFormat => 'Konversi Format';
@override
String get trackConvertFormatSubtitle => 'Konversi ke MP3 atau Opus';
@override
String get trackConvertTitle => 'Konversi Audio';
@override
String get trackConvertTargetFormat => 'Format Tujuan';
@override
String get trackConvertBitrate => 'Bitrate';
@override
String get trackConvertConfirmTitle => 'Konfirmasi Konversi';
@override
String trackConvertConfirmMessage(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return 'Konversi dari $sourceFormat ke $targetFormat pada $bitrate?\n\nFile asli akan dihapus setelah konversi.';
}
@override
String get trackConvertConverting => 'Mengkonversi audio...';
@override
String trackConvertSuccess(String format) {
return 'Berhasil dikonversi ke $format';
}
@override
String get trackConvertFailed => 'Konversi gagal';
} }

View file

@ -2880,4 +2880,42 @@ class AppLocalizationsJa extends AppLocalizations {
String trackSaveFailed(String error) { String trackSaveFailed(String error) {
return 'Failed: $error'; return 'Failed: $error';
} }
@override
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
@override
String get trackConvertTitle => 'Convert Audio';
@override
String get trackConvertTargetFormat => 'Target Format';
@override
String get trackConvertBitrate => 'Bitrate';
@override
String get trackConvertConfirmTitle => 'Confirm Conversion';
@override
String trackConvertConfirmMessage(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertConverting => 'Converting audio...';
@override
String trackConvertSuccess(String format) {
return 'Converted to $format successfully';
}
@override
String get trackConvertFailed => 'Conversion failed';
} }

View file

@ -2894,4 +2894,42 @@ class AppLocalizationsKo extends AppLocalizations {
String trackSaveFailed(String error) { String trackSaveFailed(String error) {
return 'Failed: $error'; return 'Failed: $error';
} }
@override
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
@override
String get trackConvertTitle => 'Convert Audio';
@override
String get trackConvertTargetFormat => 'Target Format';
@override
String get trackConvertBitrate => 'Bitrate';
@override
String get trackConvertConfirmTitle => 'Confirm Conversion';
@override
String trackConvertConfirmMessage(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertConverting => 'Converting audio...';
@override
String trackConvertSuccess(String format) {
return 'Converted to $format successfully';
}
@override
String get trackConvertFailed => 'Conversion failed';
} }

View file

@ -2894,4 +2894,42 @@ class AppLocalizationsNl extends AppLocalizations {
String trackSaveFailed(String error) { String trackSaveFailed(String error) {
return 'Failed: $error'; return 'Failed: $error';
} }
@override
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
@override
String get trackConvertTitle => 'Convert Audio';
@override
String get trackConvertTargetFormat => 'Target Format';
@override
String get trackConvertBitrate => 'Bitrate';
@override
String get trackConvertConfirmTitle => 'Confirm Conversion';
@override
String trackConvertConfirmMessage(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertConverting => 'Converting audio...';
@override
String trackConvertSuccess(String format) {
return 'Converted to $format successfully';
}
@override
String get trackConvertFailed => 'Conversion failed';
} }

View file

@ -2894,6 +2894,44 @@ class AppLocalizationsPt extends AppLocalizations {
String trackSaveFailed(String error) { String trackSaveFailed(String error) {
return 'Failed: $error'; return 'Failed: $error';
} }
@override
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
@override
String get trackConvertTitle => 'Convert Audio';
@override
String get trackConvertTargetFormat => 'Target Format';
@override
String get trackConvertBitrate => 'Bitrate';
@override
String get trackConvertConfirmTitle => 'Confirm Conversion';
@override
String trackConvertConfirmMessage(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertConverting => 'Converting audio...';
@override
String trackConvertSuccess(String format) {
return 'Converted to $format successfully';
}
@override
String get trackConvertFailed => 'Conversion failed';
} }
/// The translations for Portuguese, as used in Portugal (`pt_PT`). /// The translations for Portuguese, as used in Portugal (`pt_PT`).

View file

@ -2940,4 +2940,42 @@ class AppLocalizationsRu extends AppLocalizations {
String trackSaveFailed(String error) { String trackSaveFailed(String error) {
return 'Failed: $error'; return 'Failed: $error';
} }
@override
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
@override
String get trackConvertTitle => 'Convert Audio';
@override
String get trackConvertTargetFormat => 'Target Format';
@override
String get trackConvertBitrate => 'Bitrate';
@override
String get trackConvertConfirmTitle => 'Confirm Conversion';
@override
String trackConvertConfirmMessage(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertConverting => 'Converting audio...';
@override
String trackConvertSuccess(String format) {
return 'Converted to $format successfully';
}
@override
String get trackConvertFailed => 'Conversion failed';
} }

View file

@ -2909,4 +2909,42 @@ class AppLocalizationsTr extends AppLocalizations {
String trackSaveFailed(String error) { String trackSaveFailed(String error) {
return 'Failed: $error'; return 'Failed: $error';
} }
@override
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
@override
String get trackConvertTitle => 'Convert Audio';
@override
String get trackConvertTargetFormat => 'Target Format';
@override
String get trackConvertBitrate => 'Bitrate';
@override
String get trackConvertConfirmTitle => 'Confirm Conversion';
@override
String trackConvertConfirmMessage(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertConverting => 'Converting audio...';
@override
String trackConvertSuccess(String format) {
return 'Converted to $format successfully';
}
@override
String get trackConvertFailed => 'Conversion failed';
} }

View file

@ -2894,6 +2894,44 @@ class AppLocalizationsZh extends AppLocalizations {
String trackSaveFailed(String error) { String trackSaveFailed(String error) {
return 'Failed: $error'; return 'Failed: $error';
} }
@override
String get trackConvertFormat => 'Convert Format';
@override
String get trackConvertFormatSubtitle => 'Convert to MP3 or Opus';
@override
String get trackConvertTitle => 'Convert Audio';
@override
String get trackConvertTargetFormat => 'Target Format';
@override
String get trackConvertBitrate => 'Bitrate';
@override
String get trackConvertConfirmTitle => 'Confirm Conversion';
@override
String trackConvertConfirmMessage(
String sourceFormat,
String targetFormat,
String bitrate,
) {
return 'Convert from $sourceFormat to $targetFormat at $bitrate?\n\nThe original file will be deleted after conversion.';
}
@override
String get trackConvertConverting => 'Converting audio...';
@override
String trackConvertSuccess(String format) {
return 'Converted to $format successfully';
}
@override
String get trackConvertFailed => 'Conversion failed';
} }
/// The translations for Chinese, as used in China (`zh_CN`). /// The translations for Chinese, as used in China (`zh_CN`).

View file

@ -2184,5 +2184,38 @@
"placeholders": { "placeholders": {
"error": {"type": "String"} "error": {"type": "String"}
} }
} },
"trackConvertFormat": "Convert Format",
"@trackConvertFormat": {"description": "Menu item - convert audio format"},
"trackConvertFormatSubtitle": "Convert to MP3 or Opus",
"@trackConvertFormatSubtitle": {"description": "Subtitle for convert format menu item"},
"trackConvertTitle": "Convert Audio",
"@trackConvertTitle": {"description": "Title of convert bottom sheet"},
"trackConvertTargetFormat": "Target Format",
"@trackConvertTargetFormat": {"description": "Label for format selection"},
"trackConvertBitrate": "Bitrate",
"@trackConvertBitrate": {"description": "Label for bitrate selection"},
"trackConvertConfirmTitle": "Confirm Conversion",
"@trackConvertConfirmTitle": {"description": "Confirmation dialog title"},
"trackConvertConfirmMessage": "Convert from {sourceFormat} to {targetFormat} at {bitrate}?\n\nThe original file will be deleted after conversion.",
"@trackConvertConfirmMessage": {
"description": "Confirmation dialog message",
"placeholders": {
"sourceFormat": {"type": "String"},
"targetFormat": {"type": "String"},
"bitrate": {"type": "String"}
}
},
"trackConvertConverting": "Converting audio...",
"@trackConvertConverting": {"description": "Snackbar while converting"},
"trackConvertSuccess": "Converted to {format} successfully",
"@trackConvertSuccess": {
"description": "Snackbar after successful conversion",
"placeholders": {
"format": {"type": "String"}
}
},
"trackConvertFailed": "Conversion failed",
"@trackConvertFailed": {"description": "Snackbar when conversion fails"}
} }

View file

@ -587,7 +587,7 @@
"aboutSupport": "Dukungan", "aboutSupport": "Dukungan",
"@aboutSupport": { "@aboutSupport": {
"description": "Section for support/donation links" "description": "Section for support/donation links"
}, },
"aboutApp": "Aplikasi", "aboutApp": "Aplikasi",
"@aboutApp": { "@aboutApp": {
"description": "Section for app info" "description": "Section for app info"
@ -3206,5 +3206,38 @@
"placeholders": { "placeholders": {
"error": {"type": "String"} "error": {"type": "String"}
} }
} },
"trackConvertFormat": "Konversi Format",
"@trackConvertFormat": {"description": "Menu item - convert audio format"},
"trackConvertFormatSubtitle": "Konversi ke MP3 atau Opus",
"@trackConvertFormatSubtitle": {"description": "Subtitle for convert format menu item"},
"trackConvertTitle": "Konversi Audio",
"@trackConvertTitle": {"description": "Title of convert bottom sheet"},
"trackConvertTargetFormat": "Format Tujuan",
"@trackConvertTargetFormat": {"description": "Label for format selection"},
"trackConvertBitrate": "Bitrate",
"@trackConvertBitrate": {"description": "Label for bitrate selection"},
"trackConvertConfirmTitle": "Konfirmasi Konversi",
"@trackConvertConfirmTitle": {"description": "Confirmation dialog title"},
"trackConvertConfirmMessage": "Konversi dari {sourceFormat} ke {targetFormat} pada {bitrate}?\n\nFile asli akan dihapus setelah konversi.",
"@trackConvertConfirmMessage": {
"description": "Confirmation dialog message",
"placeholders": {
"sourceFormat": {"type": "String"},
"targetFormat": {"type": "String"},
"bitrate": {"type": "String"}
}
},
"trackConvertConverting": "Mengkonversi audio...",
"@trackConvertConverting": {"description": "Snackbar while converting"},
"trackConvertSuccess": "Berhasil dikonversi ke {format}",
"@trackConvertSuccess": {
"description": "Snackbar after successful conversion",
"placeholders": {
"format": {"type": "String"}
}
},
"trackConvertFailed": "Konversi gagal",
"@trackConvertFailed": {"description": "Snackbar when conversion fails"}
} }

View file

@ -1237,6 +1237,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
return '.opus'; return '.opus';
} }
// Amazon stream is delivered as MP4/M4A container (may contain FLAC audio),
// so SAF should keep .m4a before decrypt/convert pipeline.
if (service.toLowerCase() == 'amazon') {
return '.m4a';
}
if (service.toLowerCase() == 'tidal' && quality == 'HIGH') { if (service.toLowerCase() == 'tidal' && quality == 'HIGH') {
return '.m4a'; return '.m4a';
} }
@ -2897,6 +2902,123 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final actualService = final actualService =
((result['service'] as String?)?.toLowerCase()) ?? ((result['service'] as String?)?.toLowerCase()) ??
item.service.toLowerCase(); item.service.toLowerCase();
final decryptionKey =
(result['decryption_key'] as String?)?.trim() ?? '';
if (!wasExisting &&
decryptionKey.isNotEmpty &&
filePath != null &&
actualService == 'amazon') {
_log.i(
'Amazon encrypted stream detected, decrypting via FFmpeg...',
);
updateItemStatus(
item.id,
DownloadStatus.downloading,
progress: 0.9,
);
if (effectiveSafMode && isContentUri(filePath)) {
final currentFilePath = filePath;
final tempPath = await _copySafToTemp(currentFilePath);
if (tempPath == null) {
_log.e('Failed to copy encrypted SAF file to temp for decrypt');
updateItemStatus(
item.id,
DownloadStatus.failed,
error: 'Failed to access encrypted SAF file',
errorType: DownloadErrorType.unknown,
);
return;
}
String? decryptedTempPath;
try {
decryptedTempPath = await FFmpegService.decryptAudioFile(
inputPath: tempPath,
decryptionKey: decryptionKey,
deleteOriginal: false,
);
if (decryptedTempPath == null) {
_log.e('FFmpeg decrypt failed for SAF file');
updateItemStatus(
item.id,
DownloadStatus.failed,
error: 'Failed to decrypt Amazon stream',
errorType: DownloadErrorType.unknown,
);
return;
}
final dotIndex = decryptedTempPath.lastIndexOf('.');
final decryptedExt = dotIndex >= 0
? decryptedTempPath.substring(dotIndex).toLowerCase()
: '.flac';
final allowedExt = <String>{'.flac', '.m4a', '.mp3', '.opus'};
final finalExt = allowedExt.contains(decryptedExt)
? decryptedExt
: '.flac';
final newFileName = '${safBaseName ?? 'track'}$finalExt';
final newUri = await _writeTempToSaf(
treeUri: settings.downloadTreeUri,
relativeDir: effectiveOutputDir,
fileName: newFileName,
mimeType: _mimeTypeForExt(finalExt),
srcPath: decryptedTempPath,
);
if (newUri == null) {
_log.e('Failed to write decrypted Amazon stream back to SAF');
updateItemStatus(
item.id,
DownloadStatus.failed,
error: 'Failed to write decrypted file to storage',
errorType: DownloadErrorType.unknown,
);
return;
}
if (newUri != currentFilePath) {
await _deleteSafFile(currentFilePath);
}
filePath = newUri;
finalSafFileName = newFileName;
_log.i('Amazon SAF decryption completed');
} finally {
try {
await File(tempPath).delete();
} catch (_) {}
if (decryptedTempPath != null && decryptedTempPath != tempPath) {
try {
await File(decryptedTempPath).delete();
} catch (_) {}
}
}
} else {
final decryptedPath = await FFmpegService.decryptAudioFile(
inputPath: filePath,
decryptionKey: decryptionKey,
deleteOriginal: true,
);
if (decryptedPath == null) {
_log.e('FFmpeg decrypt failed for local file');
updateItemStatus(
item.id,
DownloadStatus.failed,
error: 'Failed to decrypt Amazon stream',
errorType: DownloadErrorType.unknown,
);
try {
await deleteFile(filePath);
} catch (_) {}
return;
}
filePath = decryptedPath;
_log.i('Amazon local decryption completed');
}
}
final isContentUriPath = filePath != null && isContentUri(filePath); final isContentUriPath = filePath != null && isContentUri(filePath);
final mimeType = isContentUriPath final mimeType = isContentUriPath
? await _getSafMimeType(filePath) ? await _getSafMimeType(filePath)
@ -3323,7 +3445,43 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
await File(tempPath).delete(); await File(tempPath).delete();
} catch (_) {} } catch (_) {}
} }
} }
} else if (!isContentUriPath &&
!effectiveSafMode &&
isFlacFile &&
!wasExisting &&
actualService == 'amazon' &&
decryptionKey.isNotEmpty) {
_log.d(
'Local FLAC after Amazon decrypt detected, embedding metadata and cover...',
);
try {
updateItemStatus(
item.id,
DownloadStatus.downloading,
progress: 0.99,
);
final finalTrack = _buildTrackForMetadataEmbedding(
trackToDownload,
result,
normalizedAlbumArtist,
);
final backendGenre = result['genre'] as String?;
final backendLabel = result['label'] as String?;
final backendCopyright = result['copyright'] as String?;
await _embedMetadataAndCover(
filePath,
finalTrack,
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
);
_log.d('Local FLAC metadata embedding completed');
} catch (e) {
_log.w('Local FLAC metadata embedding failed: $e');
}
} }
// YouTube downloads: embed metadata to raw Opus/MP3 files from Cobalt // YouTube downloads: embed metadata to raw Opus/MP3 files from Cobalt

View file

@ -646,13 +646,26 @@ class _QueueTabState extends ConsumerState<QueueTab> {
} }
String _getQualityBadgeText(String quality) { String _getQualityBadgeText(String quality) {
if (quality.contains('bit')) { final q = quality.trim().toLowerCase();
if (q.contains('bit')) {
return quality.split('/').first; return quality.split('/').first;
} }
final bitrateMatch = RegExp(r'(\d+)kbps').firstMatch(quality);
if (bitrateMatch != null) { // Supports "MP3 320k", "Opus 256kbps", etc.
return '${bitrateMatch.group(1)}k'; final bitrateTextMatch = RegExp(
r'(\d+)\s*k(?:bps)?',
caseSensitive: false,
).firstMatch(quality);
if (bitrateTextMatch != null) {
return '${bitrateTextMatch.group(1)}k';
} }
// Supports legacy quality IDs like "opus_256" / "mp3_320".
final bitrateIdMatch = RegExp(r'_(\d+)$').firstMatch(q);
if (bitrateIdMatch != null) {
return '${bitrateIdMatch.group(1)}k';
}
return quality.split(' ').first; return quality.split(' ').first;
} }
@ -1647,7 +1660,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
), ),
// Search bar - always at top // Search bar - always at top
if (allHistoryItems.isNotEmpty || hasQueueItems || localLibraryItems.isNotEmpty) if (allHistoryItems.isNotEmpty ||
hasQueueItems ||
localLibraryItems.isNotEmpty)
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
@ -2946,13 +2961,13 @@ class _QueueTabState extends ConsumerState<QueueTab> {
// show bytes downloaded instead of percentage // show bytes downloaded instead of percentage
item.progress > 0 item.progress > 0
? (item.speedMBps > 0 ? (item.speedMBps > 0
? '${(item.progress * 100).toStringAsFixed(0)}% • ${item.speedMBps.toStringAsFixed(1)} MB/s' ? '${(item.progress * 100).toStringAsFixed(0)}% • ${item.speedMBps.toStringAsFixed(1)} MB/s'
: '${(item.progress * 100).toStringAsFixed(0)}%') : '${(item.progress * 100).toStringAsFixed(0)}%')
: (item.bytesReceived > 0 : (item.bytesReceived > 0
? '${(item.bytesReceived / (1024 * 1024)).toStringAsFixed(1)} MB • ${item.speedMBps.toStringAsFixed(1)} MB/s' ? '${(item.bytesReceived / (1024 * 1024)).toStringAsFixed(1)} MB • ${item.speedMBps.toStringAsFixed(1)} MB/s'
: (item.speedMBps > 0 : (item.speedMBps > 0
? 'Downloading • ${item.speedMBps.toStringAsFixed(1)} MB/s' ? 'Downloading • ${item.speedMBps.toStringAsFixed(1)} MB/s'
: 'Starting...')), : 'Starting...')),
style: Theme.of(context).textTheme.labelSmall style: Theme.of(context).textTheme.labelSmall
?.copyWith( ?.copyWith(
color: colorScheme.primary, color: colorScheme.primary,

View file

@ -202,6 +202,7 @@ class _RecentDonorsCard extends StatelessWidget {
const SizedBox(height: 16), const SizedBox(height: 16),
_DonorTile(name: 'J', colorScheme: colorScheme), _DonorTile(name: 'J', colorScheme: colorScheme),
_DonorTile(name: 'Julian', colorScheme: colorScheme), _DonorTile(name: 'Julian', colorScheme: colorScheme),
_DonorTile(name: 'matt_3050', colorScheme: colorScheme),
_DonorTile(name: 'Daniel', colorScheme: colorScheme), _DonorTile(name: 'Daniel', colorScheme: colorScheme),
_DonorTile( _DonorTile(
name: '283Fabio', name: '283Fabio',

View file

@ -22,7 +22,7 @@ class DownloadSettingsPage extends ConsumerStatefulWidget {
} }
class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> { class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
static const _builtInServices = ['tidal', 'qobuz']; static const _builtInServices = ['tidal', 'qobuz', 'amazon'];
int _androidSdkVersion = 0; int _androidSdkVersion = 0;
bool _hasAllFilesAccess = false; bool _hasAllFilesAccess = false;
@ -248,7 +248,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
'Select Tidal or Qobuz above to configure quality', 'Select Tidal, Qobuz, or Amazon above to configure quality',
style: Theme.of(context).textTheme.bodySmall style: Theme.of(context).textTheme.bodySmall
?.copyWith( ?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
@ -1366,6 +1366,7 @@ class _ServiceSelector extends ConsumerWidget {
final isExtensionService = ![ final isExtensionService = ![
'tidal', 'tidal',
'qobuz', 'qobuz',
'amazon',
].contains(currentService); ].contains(currentService);
final isCurrentExtensionEnabled = isExtensionService final isCurrentExtensionEnabled = isExtensionService
? extensionProviders.any((e) => e.id == currentService) ? extensionProviders.any((e) => e.id == currentService)
@ -1392,6 +1393,13 @@ class _ServiceSelector extends ConsumerWidget {
isSelected: effectiveService == 'qobuz', isSelected: effectiveService == 'qobuz',
onTap: () => onChanged('qobuz'), onTap: () => onChanged('qobuz'),
), ),
const SizedBox(width: 8),
_ServiceChip(
icon: Icons.shopping_bag_outlined,
label: 'Amazon',
isSelected: effectiveService == 'amazon',
onTap: () => onChanged('amazon'),
),
], ],
), ),
if (extensionProviders.isNotEmpty) ...[ if (extensionProviders.isNotEmpty) ...[

File diff suppressed because it is too large Load diff

View file

@ -56,6 +56,48 @@ class FFmpegService {
return '$tempDirPath${Platform.pathSeparator}temp_embed_${timestamp}_${processId}_$_tempEmbedCounter$normalizedExt'; return '$tempDirPath${Platform.pathSeparator}temp_embed_${timestamp}_${processId}_$_tempEmbedCounter$normalizedExt';
} }
static List<String> _buildDecryptionKeyCandidates(String rawKey) {
final candidates = <String>[];
void addCandidate(String key) {
final normalized = key.trim();
if (normalized.isEmpty) return;
if (!candidates.contains(normalized)) {
candidates.add(normalized);
}
}
final trimmed = rawKey.trim();
if (trimmed.isEmpty) return candidates;
addCandidate(trimmed);
final noPrefix = trimmed.startsWith(RegExp(r'0x', caseSensitive: false))
? trimmed.substring(2)
: trimmed;
addCandidate(noPrefix);
final compactHex = noPrefix.replaceAll(RegExp(r'[^0-9a-fA-F]'), '');
if (compactHex.isNotEmpty && compactHex.length.isEven) {
addCandidate(compactHex);
}
try {
final b64 = noPrefix.replaceAll(RegExp(r'\s+'), '');
final decoded = base64Decode(b64);
if (decoded.isNotEmpty) {
final hex = decoded
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
if (hex.isNotEmpty) {
addCandidate(hex);
}
}
} catch (_) {}
return candidates;
}
static Future<FFmpegResult> _execute(String command) async { static Future<FFmpegResult> _execute(String command) async {
try { try {
final session = await FFmpegKit.execute(command); final session = await FFmpegKit.execute(command);
@ -77,7 +119,7 @@ class FFmpegService {
final outputPath = _buildOutputPath(inputPath, '.flac'); final outputPath = _buildOutputPath(inputPath, '.flac');
final command = final command =
'-i "$inputPath" -c:a flac -compression_level 8 "$outputPath" -y'; '-v error -xerror -i "$inputPath" -c:a flac -compression_level 8 "$outputPath" -y';
final result = await _execute(command); final result = await _execute(command);
@ -133,6 +175,111 @@ class FFmpegService {
return null; return null;
} }
static Future<String?> decryptAudioFile({
required String inputPath,
required String decryptionKey,
bool deleteOriginal = true,
}) async {
final trimmedKey = decryptionKey.trim();
if (trimmedKey.isEmpty) return inputPath;
// Amazon encrypted streams are commonly MP4 container with FLAC audio.
// Prefer FLAC output to avoid MP4 muxing errors during decrypt copy.
final preferredExt = inputPath.toLowerCase().endsWith('.m4a')
? '.flac'
: inputPath.toLowerCase().endsWith('.flac')
? '.flac'
: inputPath.toLowerCase().endsWith('.mp3')
? '.mp3'
: inputPath.toLowerCase().endsWith('.opus')
? '.opus'
: '.flac';
var tempOutput = _buildOutputPath(inputPath, preferredExt);
String buildDecryptCommand(
String outputPath, {
required bool mapAudioOnly,
required String key,
}) {
final audioMap = mapAudioOnly ? '-map 0:a ' : '';
return '-v error -decryption_key "$key" -i "$inputPath" $audioMap-c copy "$outputPath" -y';
}
final keyCandidates = _buildDecryptionKeyCandidates(trimmedKey);
if (keyCandidates.isEmpty) {
_log.e('No usable decryption key candidates');
return null;
}
FFmpegResult? lastResult;
var decryptSucceeded = false;
for (final keyCandidate in keyCandidates) {
_log.d(
'Executing FFmpeg decrypt command (key length: ${keyCandidate.length})',
);
var result = await _execute(
buildDecryptCommand(
tempOutput,
mapAudioOnly: preferredExt == '.flac',
key: keyCandidate,
),
);
// Fallback for uncommon streams that cannot be remuxed into FLAC.
if (!result.success && preferredExt == '.flac') {
final fallbackOutput = _buildOutputPath(inputPath, '.m4a');
final fallbackResult = await _execute(
buildDecryptCommand(
fallbackOutput,
mapAudioOnly: false,
key: keyCandidate,
),
);
if (fallbackResult.success) {
tempOutput = fallbackOutput;
result = fallbackResult;
}
}
if (result.success) {
decryptSucceeded = true;
lastResult = result;
break;
}
try {
final tempFile = File(tempOutput);
if (await tempFile.exists()) {
await tempFile.delete();
}
} catch (_) {}
lastResult = result;
}
if (!decryptSucceeded) {
_log.e('FFmpeg decrypt failed: ${lastResult?.output ?? 'unknown error'}');
return null;
}
try {
final tempFile = File(tempOutput);
final inputFile = File(inputPath);
if (!await tempFile.exists()) {
_log.e('Decrypted output file not found: $tempOutput');
return null;
}
if (deleteOriginal && await inputFile.exists()) {
await inputFile.delete();
}
return tempOutput;
} catch (e) {
_log.e('Failed to finalize decrypted file: $e');
return null;
}
}
static Future<String?> convertFlacToMp3( static Future<String?> convertFlacToMp3(
String inputPath, { String inputPath, {
String bitrate = '320k', String bitrate = '320k',
@ -616,6 +763,97 @@ class FFmpegService {
} }
} }
/// Unified audio format conversion with full metadata + cover preservation.
/// Supports: FLAC/MP3/Opus -> MP3/Opus (any direction except same format).
/// Returns the new file path on success, null on failure.
static Future<String?> convertAudioFormat({
required String inputPath,
required String targetFormat,
required String bitrate,
required Map<String, String> metadata,
String? coverPath,
bool deleteOriginal = true,
}) async {
final format = targetFormat.toLowerCase();
if (format != 'mp3' && format != 'opus') {
_log.e('Unsupported target format: $targetFormat');
return null;
}
final extension = format == 'opus' ? '.opus' : '.mp3';
final outputPath = _buildOutputPath(inputPath, extension);
// Step 1: Convert audio
String command;
if (format == 'opus') {
command =
'-i "$inputPath" -codec:a libopus -b:a $bitrate -vbr on -compression_level 10 -map 0:a "$outputPath" -y';
} else {
command =
'-i "$inputPath" -codec:a libmp3lame -b:a $bitrate -map 0:a -id3v2_version 3 "$outputPath" -y';
}
_log.i(
'Converting ${inputPath.split(Platform.pathSeparator).last} to $format @ $bitrate',
);
final result = await _execute(command);
if (!result.success) {
_log.e('Audio conversion failed: ${result.output}');
return null;
}
// Step 2: Embed metadata + cover into the converted file.
// Treat embed failure as conversion failure when metadata/cover was requested.
final hasMetadata = metadata.values.any((v) => v.trim().isNotEmpty);
final hasCover = coverPath != null && coverPath.trim().isNotEmpty;
if (hasMetadata || hasCover) {
String? embedResult;
if (format == 'mp3') {
embedResult = await embedMetadataToMp3(
mp3Path: outputPath,
coverPath: coverPath,
metadata: metadata,
);
} else {
embedResult = await embedMetadataToOpus(
opusPath: outputPath,
coverPath: coverPath,
metadata: metadata,
);
}
if (embedResult == null) {
_log.e(
'Metadata/Cover preservation failed, rolling back converted file',
);
try {
final out = File(outputPath);
if (await out.exists()) {
await out.delete();
}
} catch (e) {
_log.w('Failed to cleanup failed converted file: $e');
}
return null;
}
}
// Step 3: Delete original if requested
if (deleteOriginal) {
try {
await File(inputPath).delete();
_log.i(
'Deleted original: ${inputPath.split(Platform.pathSeparator).last}',
);
} catch (e) {
_log.w('Failed to delete original: $e');
}
}
return outputPath;
}
static Map<String, String> _convertToId3Tags( static Map<String, String> _convertToId3Tags(
Map<String, String> vorbisMetadata, Map<String, String> vorbisMetadata,
) { ) {

View file

@ -17,21 +17,21 @@ String? _currentContainerPath;
class HistoryDatabase { class HistoryDatabase {
static final HistoryDatabase instance = HistoryDatabase._init(); static final HistoryDatabase instance = HistoryDatabase._init();
static Database? _database; static Database? _database;
HistoryDatabase._init(); HistoryDatabase._init();
Future<Database> get database async { Future<Database> get database async {
if (_database != null) return _database!; if (_database != null) return _database!;
_database = await _initDB('history.db'); _database = await _initDB('history.db');
return _database!; return _database!;
} }
Future<Database> _initDB(String fileName) async { Future<Database> _initDB(String fileName) async {
final dbPath = await getApplicationDocumentsDirectory(); final dbPath = await getApplicationDocumentsDirectory();
final path = join(dbPath.path, fileName); final path = join(dbPath.path, fileName);
_log.i('Initializing database at: $path'); _log.i('Initializing database at: $path');
return await openDatabase( return await openDatabase(
path, path,
version: 3, version: 3,
@ -39,10 +39,10 @@ class HistoryDatabase {
onUpgrade: _upgradeDB, onUpgrade: _upgradeDB,
); );
} }
Future<void> _createDB(Database db, int version) async { Future<void> _createDB(Database db, int version) async {
_log.i('Creating database schema v$version'); _log.i('Creating database schema v$version');
await db.execute(''' await db.execute('''
CREATE TABLE history ( CREATE TABLE history (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@ -73,16 +73,20 @@ class HistoryDatabase {
copyright TEXT copyright TEXT
) )
'''); ''');
// Indexes for fast lookups // Indexes for fast lookups
await db.execute('CREATE INDEX idx_spotify_id ON history(spotify_id)'); await db.execute('CREATE INDEX idx_spotify_id ON history(spotify_id)');
await db.execute('CREATE INDEX idx_isrc ON history(isrc)'); await db.execute('CREATE INDEX idx_isrc ON history(isrc)');
await db.execute('CREATE INDEX idx_downloaded_at ON history(downloaded_at DESC)'); await db.execute(
await db.execute('CREATE INDEX idx_album ON history(album_name, album_artist)'); 'CREATE INDEX idx_downloaded_at ON history(downloaded_at DESC)',
);
await db.execute(
'CREATE INDEX idx_album ON history(album_name, album_artist)',
);
_log.i('Database schema created with indexes'); _log.i('Database schema created with indexes');
} }
Future<void> _upgradeDB(Database db, int oldVersion, int newVersion) async { Future<void> _upgradeDB(Database db, int oldVersion, int newVersion) async {
_log.i('Upgrading database from v$oldVersion to v$newVersion'); _log.i('Upgrading database from v$oldVersion to v$newVersion');
if (oldVersion < 2) { if (oldVersion < 2) {
@ -95,20 +99,20 @@ class HistoryDatabase {
await db.execute('ALTER TABLE history ADD COLUMN saf_repaired INTEGER'); await db.execute('ALTER TABLE history ADD COLUMN saf_repaired INTEGER');
} }
} }
// ==================== iOS Path Normalization ==================== // ==================== iOS Path Normalization ====================
/// Pattern to match iOS container paths /// Pattern to match iOS container paths
/// Example: /var/mobile/Containers/Data/Application/UUID-HERE/Documents/... /// Example: /var/mobile/Containers/Data/Application/UUID-HERE/Documents/...
static final _iosContainerPattern = RegExp( static final _iosContainerPattern = RegExp(
r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+/', r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+/',
caseSensitive: false, caseSensitive: false,
); );
/// Initialize and cache the current iOS container path /// Initialize and cache the current iOS container path
Future<void> _initContainerPath() async { Future<void> _initContainerPath() async {
if (!Platform.isIOS || _currentContainerPath != null) return; if (!Platform.isIOS || _currentContainerPath != null) return;
try { try {
final docDir = await getApplicationDocumentsDirectory(); final docDir = await getApplicationDocumentsDirectory();
// Extract container path up to and including the UUID folder // Extract container path up to and including the UUID folder
@ -122,55 +126,58 @@ class HistoryDatabase {
_log.w('Failed to get iOS container path: $e'); _log.w('Failed to get iOS container path: $e');
} }
} }
/// Normalize iOS file path by replacing old container UUID with current one /// Normalize iOS file path by replacing old container UUID with current one
/// This fixes the issue where iOS changes container UUID after app updates /// This fixes the issue where iOS changes container UUID after app updates
String _normalizeIosPath(String? filePath) { String _normalizeIosPath(String? filePath) {
if (filePath == null || filePath.isEmpty) return filePath ?? ''; if (filePath == null || filePath.isEmpty) return filePath ?? '';
if (!Platform.isIOS || _currentContainerPath == null) return filePath; if (!Platform.isIOS || _currentContainerPath == null) return filePath;
// Check if path contains an iOS container path // Check if path contains an iOS container path
if (_iosContainerPattern.hasMatch(filePath)) { if (_iosContainerPattern.hasMatch(filePath)) {
final normalized = filePath.replaceFirst(_iosContainerPattern, _currentContainerPath!); final normalized = filePath.replaceFirst(
_iosContainerPattern,
_currentContainerPath!,
);
if (normalized != filePath) { if (normalized != filePath) {
_log.d('Normalized iOS path: $filePath -> $normalized'); _log.d('Normalized iOS path: $filePath -> $normalized');
} }
return normalized; return normalized;
} }
return filePath; return filePath;
} }
/// Migrate iOS paths in database to use current container UUID /// Migrate iOS paths in database to use current container UUID
/// This is called once after app update if container changed /// This is called once after app update if container changed
Future<bool> migrateIosContainerPaths() async { Future<bool> migrateIosContainerPaths() async {
if (!Platform.isIOS) return false; if (!Platform.isIOS) return false;
await _initContainerPath(); await _initContainerPath();
if (_currentContainerPath == null) return false; if (_currentContainerPath == null) return false;
final prefs = await _prefs; final prefs = await _prefs;
final lastContainer = prefs.getString('ios_last_container_path'); final lastContainer = prefs.getString('ios_last_container_path');
if (lastContainer == _currentContainerPath) { if (lastContainer == _currentContainerPath) {
_log.d('iOS container path unchanged, skipping migration'); _log.d('iOS container path unchanged, skipping migration');
return false; return false;
} }
_log.i('iOS container changed: $lastContainer -> $_currentContainerPath'); _log.i('iOS container changed: $lastContainer -> $_currentContainerPath');
try { try {
final db = await database; final db = await database;
// Get all items with iOS paths // Get all items with iOS paths
final rows = await db.query('history', columns: ['id', 'file_path']); final rows = await db.query('history', columns: ['id', 'file_path']);
int updatedCount = 0; int updatedCount = 0;
final batch = db.batch(); final batch = db.batch();
for (final row in rows) { for (final row in rows) {
final id = row['id'] as String; final id = row['id'] as String;
final oldPath = row['file_path'] as String?; final oldPath = row['file_path'] as String?;
if (oldPath != null && _iosContainerPattern.hasMatch(oldPath)) { if (oldPath != null && _iosContainerPattern.hasMatch(oldPath)) {
final newPath = _normalizeIosPath(oldPath); final newPath = _normalizeIosPath(oldPath);
if (newPath != oldPath) { if (newPath != oldPath) {
@ -184,14 +191,14 @@ class HistoryDatabase {
} }
} }
} }
if (updatedCount > 0) { if (updatedCount > 0) {
await batch.commit(noResult: true); await batch.commit(noResult: true);
} }
// Save current container path // Save current container path
await prefs.setString('ios_last_container_path', _currentContainerPath!); await prefs.setString('ios_last_container_path', _currentContainerPath!);
_log.i('iOS path migration complete: $updatedCount paths updated'); _log.i('iOS path migration complete: $updatedCount paths updated');
return updatedCount > 0; return updatedCount > 0;
} catch (e, stack) { } catch (e, stack) {
@ -199,32 +206,34 @@ class HistoryDatabase {
return false; return false;
} }
} }
/// Migrate data from SharedPreferences to SQLite /// Migrate data from SharedPreferences to SQLite
/// Returns true if migration was performed, false if already migrated /// Returns true if migration was performed, false if already migrated
Future<bool> migrateFromSharedPreferences() async { Future<bool> migrateFromSharedPreferences() async {
final prefs = await _prefs; final prefs = await _prefs;
final migrationKey = 'history_migrated_to_sqlite'; final migrationKey = 'history_migrated_to_sqlite';
if (prefs.getBool(migrationKey) == true) { if (prefs.getBool(migrationKey) == true) {
_log.d('Already migrated to SQLite'); _log.d('Already migrated to SQLite');
return false; return false;
} }
final jsonStr = prefs.getString('download_history'); final jsonStr = prefs.getString('download_history');
if (jsonStr == null || jsonStr.isEmpty) { if (jsonStr == null || jsonStr.isEmpty) {
_log.d('No SharedPreferences history to migrate'); _log.d('No SharedPreferences history to migrate');
await prefs.setBool(migrationKey, true); await prefs.setBool(migrationKey, true);
return false; return false;
} }
try { try {
final List<dynamic> jsonList = jsonDecode(jsonStr); final List<dynamic> jsonList = jsonDecode(jsonStr);
_log.i('Migrating ${jsonList.length} items from SharedPreferences to SQLite'); _log.i(
'Migrating ${jsonList.length} items from SharedPreferences to SQLite',
);
final db = await database; final db = await database;
final batch = db.batch(); final batch = db.batch();
for (final json in jsonList) { for (final json in jsonList) {
final map = json as Map<String, dynamic>; final map = json as Map<String, dynamic>;
batch.insert( batch.insert(
@ -233,20 +242,20 @@ class HistoryDatabase {
conflictAlgorithm: ConflictAlgorithm.replace, conflictAlgorithm: ConflictAlgorithm.replace,
); );
} }
await batch.commit(noResult: true); await batch.commit(noResult: true);
// Mark as migrated but keep old data for safety // Mark as migrated but keep old data for safety
await prefs.setBool(migrationKey, true); await prefs.setBool(migrationKey, true);
_log.i('Migration complete: ${jsonList.length} items'); _log.i('Migration complete: ${jsonList.length} items');
return true; return true;
} catch (e, stack) { } catch (e, stack) {
_log.e('Migration failed: $e', e, stack); _log.e('Migration failed: $e', e, stack);
return false; return false;
} }
} }
/// Convert JSON format (camelCase) to DB row (snake_case) /// Convert JSON format (camelCase) to DB row (snake_case)
Map<String, dynamic> _jsonToDbRow(Map<String, dynamic> json) { Map<String, dynamic> _jsonToDbRow(Map<String, dynamic> json) {
return { return {
@ -278,7 +287,7 @@ class HistoryDatabase {
'copyright': json['copyright'], 'copyright': json['copyright'],
}; };
} }
/// Convert DB row (snake_case) to JSON format (camelCase) /// Convert DB row (snake_case) to JSON format (camelCase)
/// Also normalizes iOS paths if container UUID changed /// Also normalizes iOS paths if container UUID changed
Map<String, dynamic> _dbRowToJson(Map<String, dynamic> row) { Map<String, dynamic> _dbRowToJson(Map<String, dynamic> row) {
@ -311,9 +320,9 @@ class HistoryDatabase {
'copyright': row['copyright'], 'copyright': row['copyright'],
}; };
} }
// ==================== CRUD Operations ==================== // ==================== CRUD Operations ====================
/// Insert or update a history item /// Insert or update a history item
Future<void> upsert(Map<String, dynamic> json) async { Future<void> upsert(Map<String, dynamic> json) async {
final db = await database; final db = await database;
@ -323,7 +332,7 @@ class HistoryDatabase {
conflictAlgorithm: ConflictAlgorithm.replace, conflictAlgorithm: ConflictAlgorithm.replace,
); );
} }
/// Get all history items ordered by download date (newest first) /// Get all history items ordered by download date (newest first)
Future<List<Map<String, dynamic>>> getAll({int? limit, int? offset}) async { Future<List<Map<String, dynamic>>> getAll({int? limit, int? offset}) async {
final db = await database; final db = await database;
@ -335,7 +344,7 @@ class HistoryDatabase {
); );
return rows.map(_dbRowToJson).toList(); return rows.map(_dbRowToJson).toList();
} }
/// Get item by ID /// Get item by ID
Future<Map<String, dynamic>?> getById(String id) async { Future<Map<String, dynamic>?> getById(String id) async {
final db = await database; final db = await database;
@ -348,7 +357,7 @@ class HistoryDatabase {
if (rows.isEmpty) return null; if (rows.isEmpty) return null;
return _dbRowToJson(rows.first); return _dbRowToJson(rows.first);
} }
/// Get item by Spotify ID - O(1) with index /// Get item by Spotify ID - O(1) with index
Future<Map<String, dynamic>?> getBySpotifyId(String spotifyId) async { Future<Map<String, dynamic>?> getBySpotifyId(String spotifyId) async {
final db = await database; final db = await database;
@ -361,7 +370,7 @@ class HistoryDatabase {
if (rows.isEmpty) return null; if (rows.isEmpty) return null;
return _dbRowToJson(rows.first); return _dbRowToJson(rows.first);
} }
/// Get item by ISRC - O(1) with index /// Get item by ISRC - O(1) with index
Future<Map<String, dynamic>?> getByIsrc(String isrc) async { Future<Map<String, dynamic>?> getByIsrc(String isrc) async {
final db = await database; final db = await database;
@ -374,7 +383,7 @@ class HistoryDatabase {
if (rows.isEmpty) return null; if (rows.isEmpty) return null;
return _dbRowToJson(rows.first); return _dbRowToJson(rows.first);
} }
/// Check if spotify_id exists - O(1) with index /// Check if spotify_id exists - O(1) with index
Future<bool> existsBySpotifyId(String spotifyId) async { Future<bool> existsBySpotifyId(String spotifyId) async {
final db = await database; final db = await database;
@ -384,42 +393,42 @@ class HistoryDatabase {
); );
return result.isNotEmpty; return result.isNotEmpty;
} }
/// Get all spotify_ids as Set for fast in-memory lookup /// Get all spotify_ids as Set for fast in-memory lookup
Future<Set<String>> getAllSpotifyIds() async { Future<Set<String>> getAllSpotifyIds() async {
final db = await database; final db = await database;
final rows = await db.rawQuery( final rows = await db.rawQuery(
'SELECT spotify_id FROM history WHERE spotify_id IS NOT NULL AND spotify_id != ""' 'SELECT spotify_id FROM history WHERE spotify_id IS NOT NULL AND spotify_id != ""',
); );
return rows.map((r) => r['spotify_id'] as String).toSet(); return rows.map((r) => r['spotify_id'] as String).toSet();
} }
/// Delete by ID /// Delete by ID
Future<void> deleteById(String id) async { Future<void> deleteById(String id) async {
final db = await database; final db = await database;
await db.delete('history', where: 'id = ?', whereArgs: [id]); await db.delete('history', where: 'id = ?', whereArgs: [id]);
} }
/// Delete by Spotify ID /// Delete by Spotify ID
Future<void> deleteBySpotifyId(String spotifyId) async { Future<void> deleteBySpotifyId(String spotifyId) async {
final db = await database; final db = await database;
await db.delete('history', where: 'spotify_id = ?', whereArgs: [spotifyId]); await db.delete('history', where: 'spotify_id = ?', whereArgs: [spotifyId]);
} }
/// Clear all history /// Clear all history
Future<void> clearAll() async { Future<void> clearAll() async {
final db = await database; final db = await database;
await db.delete('history'); await db.delete('history');
_log.i('Cleared all history'); _log.i('Cleared all history');
} }
/// Get total count /// Get total count
Future<int> getCount() async { Future<int> getCount() async {
final db = await database; final db = await database;
final result = await db.rawQuery('SELECT COUNT(*) as count FROM history'); final result = await db.rawQuery('SELECT COUNT(*) as count FROM history');
return Sqflite.firstIntValue(result) ?? 0; return Sqflite.firstIntValue(result) ?? 0;
} }
/// Find existing item by spotify_id or isrc (for deduplication) /// Find existing item by spotify_id or isrc (for deduplication)
Future<Map<String, dynamic>?> findExisting({ Future<Map<String, dynamic>?> findExisting({
String? spotifyId, String? spotifyId,
@ -428,7 +437,7 @@ class HistoryDatabase {
if (spotifyId != null && spotifyId.isNotEmpty) { if (spotifyId != null && spotifyId.isNotEmpty) {
final bySpotify = await getBySpotifyId(spotifyId); final bySpotify = await getBySpotifyId(spotifyId);
if (bySpotify != null) return bySpotify; if (bySpotify != null) return bySpotify;
// Check for deezer: prefix matching // Check for deezer: prefix matching
if (spotifyId.startsWith('deezer:')) { if (spotifyId.startsWith('deezer:')) {
final deezerId = spotifyId.substring(7); final deezerId = spotifyId.substring(7);
@ -442,31 +451,63 @@ class HistoryDatabase {
if (rows.isNotEmpty) return _dbRowToJson(rows.first); if (rows.isNotEmpty) return _dbRowToJson(rows.first);
} }
} }
if (isrc != null && isrc.isNotEmpty) { if (isrc != null && isrc.isNotEmpty) {
return await getByIsrc(isrc); return await getByIsrc(isrc);
} }
return null; return null;
} }
/// Close database /// Close database
Future<void> close() async { Future<void> close() async {
final db = await database; final db = await database;
await db.close(); await db.close();
_database = null; _database = null;
} }
/// Update file path for a history entry (e.g. after format conversion)
Future<void> updateFilePath(
String id,
String newFilePath, {
String? newSafFileName,
String? newQuality,
int? newBitDepth,
int? newSampleRate,
bool clearAudioSpecs = false,
}) async {
final db = await database;
final values = <String, dynamic>{'file_path': newFilePath};
if (newSafFileName != null) {
values['saf_file_name'] = newSafFileName;
}
if (newQuality != null) {
values['quality'] = newQuality;
}
if (clearAudioSpecs) {
values['bit_depth'] = null;
values['sample_rate'] = null;
} else {
if (newBitDepth != null) {
values['bit_depth'] = newBitDepth;
}
if (newSampleRate != null) {
values['sample_rate'] = newSampleRate;
}
}
await db.update('history', values, where: 'id = ?', whereArgs: [id]);
}
/// Get all file paths from download history /// Get all file paths from download history
/// Used to exclude downloaded files from local library scan /// Used to exclude downloaded files from local library scan
Future<Set<String>> getAllFilePaths() async { Future<Set<String>> getAllFilePaths() async {
final db = await database; final db = await database;
final rows = await db.rawQuery( final rows = await db.rawQuery(
'SELECT file_path FROM history WHERE file_path IS NOT NULL AND file_path != ""' 'SELECT file_path FROM history WHERE file_path IS NOT NULL AND file_path != ""',
); );
return rows.map((r) => r['file_path'] as String).toSet(); return rows.map((r) => r['file_path'] as String).toSet();
} }
/// Get all entries with file paths for orphan detection /// Get all entries with file paths for orphan detection
/// Returns list of (id, file_path, storage_mode, download_tree_uri, saf_relative_dir, saf_file_name) /// Returns list of (id, file_path, storage_mode, download_tree_uri, saf_relative_dir, saf_file_name)
Future<List<Map<String, dynamic>>> getAllEntriesWithPaths() async { Future<List<Map<String, dynamic>>> getAllEntriesWithPaths() async {
@ -478,11 +519,11 @@ class HistoryDatabase {
'''); ''');
return rows.map((r) => Map<String, dynamic>.from(r)).toList(); return rows.map((r) => Map<String, dynamic>.from(r)).toList();
} }
/// Delete multiple entries by IDs /// Delete multiple entries by IDs
Future<int> deleteByIds(List<String> ids) async { Future<int> deleteByIds(List<String> ids) async {
if (ids.isEmpty) return 0; if (ids.isEmpty) return 0;
final db = await database; final db = await database;
final placeholders = List.filled(ids.length, '?').join(','); final placeholders = List.filled(ids.length, '?').join(',');
final count = await db.rawDelete( final count = await db.rawDelete(

View file

@ -22,8 +22,7 @@ class BuiltInService {
}); });
} }
/// Default quality options for built-in services (Tidal, Qobuz, YouTube) /// Default quality options for built-in services (Tidal, Qobuz, Amazon, YouTube)
/// Note: Amazon is fallback-only and not shown in picker
/// Note: Tidal lossy (HIGH) removed - use YouTube for lossy downloads /// Note: Tidal lossy (HIGH) removed - use YouTube for lossy downloads
const _builtInServices = [ const _builtInServices = [
BuiltInService( BuiltInService(
@ -44,6 +43,17 @@ const _builtInServices = [
QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res FLAC Max', description: '24-bit / up to 192kHz'), QualityOption(id: 'HI_RES_LOSSLESS', label: 'Hi-Res FLAC Max', description: '24-bit / up to 192kHz'),
], ],
), ),
BuiltInService(
id: 'amazon',
label: 'Amazon',
qualityOptions: [
QualityOption(
id: 'LOSSLESS',
label: 'FLAC Best Available',
description: 'Amazon API delivers the best available lossless quality',
),
],
),
BuiltInService( BuiltInService(
id: 'youtube', id: 'youtube',
label: 'YouTube', label: 'YouTube',

View file

@ -1,7 +1,7 @@
name: spotiflac_android name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
publish_to: "none" publish_to: "none"
version: 3.6.1+78 version: 3.6.5+79
environment: environment:
sdk: ^3.10.0 sdk: ^3.10.0