From fe1c96ea1286d80bbd06929aac5a7a970296410f Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 10 Feb 2026 23:35:41 +0700 Subject: [PATCH] v3.6.5: audio format conversion, PC v7.0.8 backend merge, Amazon re-enabled --- CHANGELOG.md | 38 +- go_backend/amazon.go | 312 +++- go_backend/amazon_asin_test.go | 46 + go_backend/audio_metadata.go | 115 ++ go_backend/exports.go | 161 +- go_backend/extension_providers.go | 56 +- go_backend/extension_timeout.go | 2 + go_backend/metadata.go | 84 +- go_backend/qobuz.go | 4 +- go_backend/spotfetch_api.go | 80 + lib/constants/app_info.dart | 4 +- lib/l10n/app_localizations.dart | 64 + lib/l10n/app_localizations_de.dart | 38 + lib/l10n/app_localizations_en.dart | 38 + lib/l10n/app_localizations_es.dart | 38 + lib/l10n/app_localizations_fr.dart | 38 + lib/l10n/app_localizations_hi.dart | 38 + lib/l10n/app_localizations_id.dart | 38 + lib/l10n/app_localizations_ja.dart | 38 + lib/l10n/app_localizations_ko.dart | 38 + lib/l10n/app_localizations_nl.dart | 38 + lib/l10n/app_localizations_pt.dart | 38 + lib/l10n/app_localizations_ru.dart | 38 + lib/l10n/app_localizations_tr.dart | 38 + lib/l10n/app_localizations_zh.dart | 38 + lib/l10n/arb/app_en.arb | 35 +- lib/l10n/arb/app_id.arb | 37 +- lib/providers/download_queue_provider.dart | 160 +- lib/screens/queue_tab.dart | 37 +- lib/screens/settings/donate_page.dart | 1 + .../settings/download_settings_page.dart | 12 +- lib/screens/track_metadata_screen.dart | 1504 +++++++++++++---- lib/services/ffmpeg_service.dart | 240 ++- lib/services/history_database.dart | 177 +- lib/widgets/download_service_picker.dart | 14 +- pubspec.yaml | 2 +- 36 files changed, 3130 insertions(+), 549 deletions(-) create mode 100644 go_backend/amazon_asin_test.go create mode 100644 go_backend/spotfetch_api.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 19c1ec95..314a9930 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,30 +1,56 @@ # 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 - "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` - 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 - 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` - New Go unified router entrypoint: `DownloadByStrategy(requestJSON)` - Routing priority: YouTube service -> extension fallback -> built-in fallback -> direct service - 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 +- 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 - 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` +- 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 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 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 - `downloadWithExtensions` now carries `copyright` - 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 - Go strategy router normalizes incoming service casing before dispatch -- Verified integration after AAR refresh with: - - `flutter analyze` - - `go test -v ./...` - - Android Kotlin compile check (`:app:compileDebugKotlin`) +- Extension runtime: `customSearch` now passes query/options via VM globals instead of string interpolation, preventing parser edge cases on certain devices +- Extension runtime: JS panic handler now logs full stack trace for easier debugging + +### Removed + +- Buy Me a Coffee references removed from donate page, FUNDING.yml, README, and all localization files (account suspended) --- diff --git a/go_backend/amazon.go b/go_backend/amazon.go index 33bd6203..75cdac82 100644 --- a/go_backend/amazon.go +++ b/go_backend/amazon.go @@ -31,6 +31,8 @@ type AmazonDownloader struct { var ( globalAmazonDownloader *AmazonDownloader amazonDownloaderOnce sync.Once + amazonASINRegex = regexp.MustCompile(`(?i)^B[0-9A-Z]{9}$`) + amazonASINFindRegex = regexp.MustCompile(`(?i)B[0-9A-Z]{9}`) ) // AfkarXYZResponse is the response from AfkarXYZ API @@ -43,6 +45,12 @@ type AfkarXYZResponse struct { } `json:"data"` } +// AmazonStreamResponse is the new response format from amazon.afkarxyz.fun/api/track/{asin} +type AmazonStreamResponse struct { + StreamURL string `json:"streamUrl"` + DecryptionKey string `json:"decryptionKey"` +} + func NewAmazonDownloader() *AmazonDownloader { amazonDownloaderOnce.Do(func() { globalAmazonDownloader = &AmazonDownloader{ @@ -52,10 +60,9 @@ func NewAmazonDownloader() *AmazonDownloader { return globalAmazonDownloader } -// fetchAmazonURLWithRetry fetches from AfkarXYZ API with retry logic for mobile networks -func (a *AmazonDownloader) fetchAmazonURLWithRetry(amazonURL string) (string, string, error) { - apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL) - +// fetchAmazonURLWithRetry fetches from AfkarXYZ API with retry logic for mobile networks. +// Returns downloadURL, suggested fileName, optional decryptionKey. +func (a *AmazonDownloader) fetchAmazonURLWithRetry(amazonURL string) (string, string, string, error) { var lastErr error for attempt := 0; attempt <= amazonMaxRetries; attempt++ { if attempt > 0 { @@ -64,66 +71,184 @@ func (a *AmazonDownloader) fetchAmazonURLWithRetry(amazonURL string) (string, st time.Sleep(delay) } - downloadURL, fileName, err := a.doAfkarXYZRequest(apiURL) + downloadURL, fileName, decryptionKey, err := a.doAfkarXYZRequest(amazonURL) if err == nil { - return downloadURL, fileName, nil + return downloadURL, fileName, decryptionKey, nil } lastErr = err - errStr := err.Error() + errStr := strings.ToLower(err.Error()) // Check if error is retryable isRetryable := strings.Contains(errStr, "timeout") || strings.Contains(errStr, "connection reset") || strings.Contains(errStr, "connection refused") || - strings.Contains(errStr, "EOF") || + strings.Contains(errStr, "eof") || strings.Contains(errStr, "status 5") || - strings.Contains(errStr, "status 429") + strings.Contains(errStr, "status 429") || + strings.Contains(errStr, "http 429") if !isRetryable { - return "", "", err + return "", "", "", 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 (a *AmazonDownloader) doAfkarXYZRequest(apiURL string) (string, string, error) { +func normalizeAmazonASIN(candidate string) string { + trimmed := strings.TrimSpace(candidate) + if trimmed == "" { + return "" + } + + if decoded, err := url.QueryUnescape(trimmed); err == nil { + trimmed = decoded + } + + trimmed = strings.ToUpper(trimmed) + if idx := strings.IndexAny(trimmed, "?#&/"); idx >= 0 { + trimmed = trimmed[:idx] + } + + if amazonASINRegex.MatchString(trimmed) { + return trimmed + } + + return "" +} + +func extractAmazonASIN(amazonURL string) string { + raw := strings.TrimSpace(amazonURL) + if raw == "" { + return "" + } + + parsed, err := url.Parse(raw) + if err == nil { + query := parsed.Query() + + // Prefer track-level ASIN when URL also contains albumAsin. + for _, key := range []string{"trackAsin", "trackasin", "trackASIN", "asin", "ASIN", "i"} { + if asin := normalizeAmazonASIN(query.Get(key)); asin != "" { + return asin + } + } + + path := strings.Trim(parsed.Path, "/") + if path != "" { + segments := strings.Split(path, "/") + + for i := 0; i < len(segments)-1; i++ { + segment := strings.ToLower(strings.TrimSpace(segments[i])) + if segment == "track" || segment == "tracks" { + if asin := normalizeAmazonASIN(segments[i+1]); asin != "" { + return asin + } + } + } + + if asin := normalizeAmazonASIN(segments[len(segments)-1]); asin != "" { + return asin + } + } + } + + match := amazonASINFindRegex.FindString(strings.ToUpper(raw)) + return normalizeAmazonASIN(match) +} + +// doAfkarXYZRequest performs a single request to Amazon API. +// It tries new endpoint first, then falls back to legacy /convert endpoint. +func (a *AmazonDownloader) doAfkarXYZRequest(amazonURL string) (string, string, string, error) { + asin := extractAmazonASIN(amazonURL) + if asin != "" { + GoLog("[Amazon] Using ASIN: %s\n", asin) + downloadURL, fileName, decryptKey, err := a.doAfkarXYZRequestNew(asin) + if err == nil { + return downloadURL, fileName, decryptKey, nil + } + GoLog("[Amazon] New API failed for ASIN %s, trying legacy endpoint: %v\n", asin, err) + } + return a.doAfkarXYZRequestLegacy(amazonURL) +} + +func (a *AmazonDownloader) doAfkarXYZRequestNew(asin string) (string, string, string, error) { ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile) defer cancel() + apiURL := fmt.Sprintf("https://amazon.afkarxyz.fun/api/track/%s", asin) req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) if err != nil { - return "", "", fmt.Errorf("failed to create request: %w", err) + return "", "", "", fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36") + + resp, err := a.client.Do(req) + if err != nil { + return "", "", "", fmt.Errorf("failed to call Amazon API: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return "", "", "", fmt.Errorf("Amazon API returned status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", "", "", fmt.Errorf("failed to read response: %w", err) + } + + var apiResp AmazonStreamResponse + if err := json.Unmarshal(body, &apiResp); err != nil { + return "", "", "", fmt.Errorf("failed to decode response: %w", err) + } + + if strings.TrimSpace(apiResp.StreamURL) == "" { + return "", "", "", fmt.Errorf("Amazon API returned empty stream URL") + } + + fileName := asin + ".m4a" + return apiResp.StreamURL, fileName, strings.TrimSpace(apiResp.DecryptionKey), nil +} + +func (a *AmazonDownloader) doAfkarXYZRequestLegacy(amazonURL string) (string, string, string, error) { + ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile) + defer cancel() + + apiURL := "https://amazon.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL) + req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) + if err != nil { + return "", "", "", fmt.Errorf("failed to create legacy request: %w", err) } req.Header.Set("User-Agent", getRandomUserAgent()) resp, err := a.client.Do(req) if err != nil { - return "", "", fmt.Errorf("failed to call AfkarXYZ API: %w", err) + return "", "", "", fmt.Errorf("failed to call legacy AfkarXYZ API: %w", err) } defer resp.Body.Close() 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) 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 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 == "" { - return "", "", fmt.Errorf("AfkarXYZ API failed or no download link found") + if !apiResp.Success || strings.TrimSpace(apiResp.Data.DirectLink) == "" { + return "", "", "", fmt.Errorf("legacy AfkarXYZ API failed or no download link found") } fileName := apiResp.Data.FileName @@ -134,19 +259,22 @@ func (a *AmazonDownloader) doAfkarXYZRequest(apiURL string) (string, string, err reg := regexp.MustCompile(`[<>:"/\\|?*]`) 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") - downloadURL, fileName, err := a.fetchAmazonURLWithRetry(amazonURL) + downloadURL, fileName, decryptionKey, err := a.fetchAmazonURLWithRetry(amazonURL) 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) - return downloadURL, fileName, nil + return downloadURL, fileName, decryptionKey, nil } 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 type AmazonDownloadResult struct { - FilePath string - BitDepth int - SampleRate int - Title string - Artist string - Album string - ReleaseDate string - TrackNumber int - DiscNumber int - ISRC string - LyricsLRC string + FilePath string + BitDepth int + SampleRate int + Title string + Artist string + Album string + ReleaseDate string + TrackNumber int + DiscNumber int + ISRC string + LyricsLRC string + DecryptionKey string } func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { @@ -299,7 +428,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { } // Download using AfkarXYZ API - downloadURL, _, err := downloader.downloadFromAfkarXYZ(amazonURL) + downloadURL, afkarFileName, decryptionKey, err := downloader.downloadFromAfkarXYZ(amazonURL) if err != nil { 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) } } 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) if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 { return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil @@ -352,6 +485,12 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err) } + actualOutputPath := outputPath + needsDecryption := strings.TrimSpace(decryptionKey) != "" + if needsDecryption { + GoLog("[Amazon] Download requires decryption; deferring decrypt to Flutter FFmpeg path\n") + } + // Wait for parallel operations to complete <-parallelDone @@ -360,7 +499,6 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { SetItemFinalizing(req.ItemID) } - existingMeta, metaErr := ReadMetadata(outputPath) actualTrackNum := req.TrackNumber actualDiscNum := req.DiscNumber actualDate := req.ReleaseDate @@ -368,25 +506,28 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { actualTitle := req.TrackName actualArtist := req.ArtistName - if metaErr == nil && existingMeta != nil { - if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) { - actualTrackNum = existingMeta.TrackNumber - GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber) + if !needsDecryption { + existingMeta, metaErr := ReadMetadata(actualOutputPath) + if metaErr == nil && existingMeta != nil { + if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) { + actualTrackNum = existingMeta.TrackNumber + GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber) + } + if existingMeta.DiscNumber > 0 && (req.DiscNumber == 0 || req.DiscNumber == 1) { + actualDiscNum = existingMeta.DiscNumber + GoLog("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.DiscNumber) + } + if existingMeta.Date != "" && req.ReleaseDate == "" { + actualDate = existingMeta.Date + GoLog("[Amazon] Using release date from file: %s\n", actualDate) + } + if existingMeta.Album != "" && req.AlbumName == "" { + actualAlbum = existingMeta.Album + GoLog("[Amazon] Using album from file: %s\n", actualAlbum) + } + GoLog("[Amazon] Existing metadata - Title: %s, Artist: %s, Album: %s, Date: %s\n", + existingMeta.Title, existingMeta.Artist, existingMeta.Album, existingMeta.Date) } - 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{ @@ -409,7 +550,7 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { coverData = parallelResult.CoverData GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData)) } else { - existingCover, coverErr := ExtractCoverArt(outputPath) + existingCover, coverErr := ExtractCoverArt(actualOutputPath) if coverErr == nil && len(existingCover) > 0 { coverData = existingCover GoLog("[Amazon] Using existing cover from Amazon file (%d bytes)\n", len(coverData)) @@ -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") } else { - if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil { - GoLog("[Amazon] Warning: failed to embed metadata: %v\n", err) + isFlacOutput := strings.HasSuffix(strings.ToLower(actualOutputPath), ".flac") + if isFlacOutput { + if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil { + GoLog("[Amazon] Warning: failed to embed metadata: %v\n", err) + } + } else { + GoLog("[Amazon] Non-FLAC output detected (%s), skipping native metadata embedding\n", filepath.Ext(actualOutputPath)) } if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { @@ -433,20 +579,22 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { if lyricsMode == "external" || lyricsMode == "both" { 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) } else { 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)) - 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) } else { GoLog("[Amazon] Lyrics embedded successfully\n") } + } else if (lyricsMode == "embed" || lyricsMode == "both") && !isFlacOutput { + GoLog("[Amazon] Skipping embedded lyrics for non-FLAC output\n") } } else if req.EmbedLyrics { GoLog("[Amazon] No lyrics available from parallel fetch\n") @@ -456,17 +604,17 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { GoLog("[Amazon] Downloaded successfully from Amazon Music\n") quality := AudioQuality{} - if isSafOutput { + if isSafOutput || needsDecryption { GoLog("[Amazon] SAF output detected - skipping post-write file inspection in backend\n") } else { - quality, err = GetAudioQuality(outputPath) + quality, err = GetAudioQuality(actualOutputPath) if err != nil { GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err) } else { GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate) } - finalMeta, metaReadErr := ReadMetadata(outputPath) + finalMeta, metaReadErr := ReadMetadata(actualOutputPath) if metaReadErr == nil && finalMeta != nil { GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n", finalMeta.TrackNumber, finalMeta.DiscNumber, finalMeta.Date) @@ -478,9 +626,10 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { } } - // Add to ISRC index for fast duplicate checking - if !isSafOutput { - AddToISRCIndex(req.OutputDir, req.ISRC, outputPath) + // Add to ISRC index for fast duplicate checking. + // When decryption is pending in Flutter, postpone indexing until final file is settled. + if !isSafOutput && !needsDecryption { + AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath) } bitDepth := 0 @@ -496,16 +645,17 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { } return AmazonDownloadResult{ - FilePath: outputPath, - BitDepth: bitDepth, - SampleRate: sampleRate, - Title: req.TrackName, - Artist: req.ArtistName, - Album: req.AlbumName, - ReleaseDate: req.ReleaseDate, - TrackNumber: actualTrackNum, - DiscNumber: actualDiscNum, - ISRC: req.ISRC, - LyricsLRC: lyricsLRC, + FilePath: outputPath, + BitDepth: bitDepth, + SampleRate: sampleRate, + Title: req.TrackName, + Artist: req.ArtistName, + Album: req.AlbumName, + ReleaseDate: req.ReleaseDate, + TrackNumber: actualTrackNum, + DiscNumber: actualDiscNum, + ISRC: req.ISRC, + LyricsLRC: lyricsLRC, + DecryptionKey: decryptionKey, }, nil } diff --git a/go_backend/amazon_asin_test.go b/go_backend/amazon_asin_test.go new file mode 100644 index 00000000..705e9c01 --- /dev/null +++ b/go_backend/amazon_asin_test.go @@ -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) + } + }) + } +} diff --git a/go_backend/audio_metadata.go b/go_backend/audio_metadata.go index 73279971..a681c88a 100644 --- a/go_backend/audio_metadata.go +++ b/go_backend/audio_metadata.go @@ -23,6 +23,7 @@ type AudioMetadata struct { TrackNumber int DiscNumber int ISRC string + Lyrics string Label string Copyright string Composer string @@ -181,6 +182,15 @@ func parseID3v22Frames(data []byte, metadata *AudioMetadata, tagUnsync bool) { metadata.Label = value case "TCR": 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 @@ -297,6 +307,15 @@ func parseID3v23Frames(data []byte, metadata *AudioMetadata, version byte, tagUn if v := extractCommentFrame(frameData); 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 @@ -399,6 +418,98 @@ func extractCommentFrame(data []byte) string { 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 { if len(data) < 2 { return "" @@ -843,6 +954,10 @@ func parseVorbisComments(data []byte, metadata *AudioMetadata) { metadata.Composer = value case "COMMENT", "DESCRIPTION": metadata.Comment = value + case "LYRICS", "UNSYNCEDLYRICS": + if metadata.Lyrics == "" { + metadata.Lyrics = value + } case "ORGANIZATION", "LABEL", "PUBLISHER": metadata.Label = value case "COPYRIGHT": diff --git a/go_backend/exports.go b/go_backend/exports.go index 1dacbac2..b09ce74c 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -47,10 +47,30 @@ func GetSpotifyMetadata(spotifyURL string) (string, error) { client, err := NewSpotifyMetadataClient() 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 } data, err := client.GetFilteredData(ctx, spotifyURL, false, 0) 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 } @@ -178,20 +198,22 @@ type DownloadResponse struct { Copyright string `json:"copyright,omitempty"` SkipMetadataEnrichment bool `json:"skip_metadata_enrichment,omitempty"` LyricsLRC string `json:"lyrics_lrc,omitempty"` + DecryptionKey string `json:"decryption_key,omitempty"` } type DownloadResult struct { - FilePath string - BitDepth int - SampleRate int - Title string - Artist string - Album string - ReleaseDate string - TrackNumber int - DiscNumber int - ISRC string - LyricsLRC string + FilePath string + BitDepth int + SampleRate int + Title string + Artist string + Album string + ReleaseDate string + TrackNumber int + DiscNumber int + ISRC string + LyricsLRC string + DecryptionKey string } func buildDownloadSuccessResponse( @@ -258,6 +280,7 @@ func buildDownloadSuccessResponse( Label: req.Label, Copyright: req.Copyright, LyricsLRC: result.LyricsLRC, + DecryptionKey: result.DecryptionKey, } } @@ -323,17 +346,18 @@ func DownloadTrack(requestJSON string) (string, error) { amazonResult, amazonErr := downloadFromAmazon(req) if amazonErr == nil { result = DownloadResult{ - FilePath: amazonResult.FilePath, - BitDepth: amazonResult.BitDepth, - SampleRate: amazonResult.SampleRate, - Title: amazonResult.Title, - Artist: amazonResult.Artist, - Album: amazonResult.Album, - ReleaseDate: amazonResult.ReleaseDate, - TrackNumber: amazonResult.TrackNumber, - DiscNumber: amazonResult.DiscNumber, - ISRC: amazonResult.ISRC, - LyricsLRC: amazonResult.LyricsLRC, + FilePath: amazonResult.FilePath, + BitDepth: amazonResult.BitDepth, + SampleRate: amazonResult.SampleRate, + Title: amazonResult.Title, + Artist: amazonResult.Artist, + Album: amazonResult.Album, + ReleaseDate: amazonResult.ReleaseDate, + TrackNumber: amazonResult.TrackNumber, + DiscNumber: amazonResult.DiscNumber, + ISRC: amazonResult.ISRC, + LyricsLRC: amazonResult.LyricsLRC, + DecryptionKey: amazonResult.DecryptionKey, } } err = amazonErr @@ -534,17 +558,18 @@ func DownloadWithFallback(requestJSON string) (string, error) { amazonResult, amazonErr := downloadFromAmazon(req) if amazonErr == nil { result = DownloadResult{ - FilePath: amazonResult.FilePath, - BitDepth: amazonResult.BitDepth, - SampleRate: amazonResult.SampleRate, - Title: amazonResult.Title, - Artist: amazonResult.Artist, - Album: amazonResult.Album, - ReleaseDate: amazonResult.ReleaseDate, - TrackNumber: amazonResult.TrackNumber, - DiscNumber: amazonResult.DiscNumber, - ISRC: amazonResult.ISRC, - LyricsLRC: amazonResult.LyricsLRC, + FilePath: amazonResult.FilePath, + BitDepth: amazonResult.BitDepth, + SampleRate: amazonResult.SampleRate, + Title: amazonResult.Title, + Artist: amazonResult.Artist, + Album: amazonResult.Album, + ReleaseDate: amazonResult.ReleaseDate, + TrackNumber: amazonResult.TrackNumber, + DiscNumber: amazonResult.DiscNumber, + ISRC: amazonResult.ISRC, + LyricsLRC: amazonResult.LyricsLRC, + DecryptionKey: amazonResult.DecryptionKey, } } else if !errors.Is(amazonErr, ErrDownloadCancelled) { GoLog("[DownloadWithFallback] Amazon error: %v\n", amazonErr) @@ -699,6 +724,7 @@ func ReadFileMetadata(filePath string) (string, error) { result["track_number"] = meta.TrackNumber result["disc_number"] = meta.DiscNumber result["isrc"] = meta.ISRC + result["lyrics"] = meta.Lyrics result["genre"] = meta.Genre result["composer"] = meta.Composer result["comment"] = meta.Comment @@ -723,6 +749,7 @@ func ReadFileMetadata(filePath string) (string, error) { result["track_number"] = meta.TrackNumber result["disc_number"] = meta.DiscNumber result["isrc"] = meta.ISRC + result["lyrics"] = meta.Lyrics result["genre"] = meta.Genre result["composer"] = meta.Composer result["comment"] = meta.Comment @@ -1178,9 +1205,12 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() + var spotifyErr error + client, err := NewSpotifyMetadataClient() if err != nil { LogWarn("Spotify", "Credentials not configured, falling back to Deezer") + spotifyErr = err } else { data, err := client.GetFilteredData(ctx, spotifyURL, false, 0) if err == nil { @@ -1191,28 +1221,81 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) { return string(jsonBytes), nil } - errStr := strings.ToLower(err.Error()) - if !strings.Contains(errStr, "429") && !strings.Contains(errStr, "rate") && !strings.Contains(errStr, "limit") { + spotifyErr = err + if !shouldTrySpotFetchFallback(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) 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" { return ConvertSpotifyToDeezer(parsed.Type, parsed.ID) } 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) { diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index 93f3a825..1fa0e3f0 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -1082,16 +1082,18 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon amazonResult, amazonErr := downloadFromAmazon(req) if amazonErr == nil { result = DownloadResult{ - FilePath: amazonResult.FilePath, - BitDepth: amazonResult.BitDepth, - SampleRate: amazonResult.SampleRate, - Title: amazonResult.Title, - Artist: amazonResult.Artist, - Album: amazonResult.Album, - ReleaseDate: amazonResult.ReleaseDate, - TrackNumber: amazonResult.TrackNumber, - DiscNumber: amazonResult.DiscNumber, - ISRC: amazonResult.ISRC, + FilePath: amazonResult.FilePath, + BitDepth: amazonResult.BitDepth, + SampleRate: amazonResult.SampleRate, + Title: amazonResult.Title, + Artist: amazonResult.Artist, + Album: amazonResult.Album, + ReleaseDate: amazonResult.ReleaseDate, + TrackNumber: amazonResult.TrackNumber, + DiscNumber: amazonResult.DiscNumber, + ISRC: amazonResult.ISRC, + LyricsLRC: amazonResult.LyricsLRC, + DecryptionKey: amazonResult.DecryptionKey, } } err = amazonErr @@ -1119,6 +1121,8 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon Genre: req.Genre, Label: req.Label, Copyright: req.Copyright, + LyricsLRC: result.LyricsLRC, + DecryptionKey: result.DecryptionKey, }, nil } @@ -1164,16 +1168,30 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string p.extension.VMMu.Lock() 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() { 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; })() - `, query, string(optionsJSON)) + ` result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) if err != nil { @@ -1358,12 +1376,12 @@ type PostProcessResult struct { } type PostProcessInput struct { - Path string `json:"path,omitempty"` - URI string `json:"uri,omitempty"` - Name string `json:"name,omitempty"` + Path string `json:"path,omitempty"` + URI string `json:"uri,omitempty"` + Name string `json:"name,omitempty"` MimeType string `json:"mime_type,omitempty"` - Size int64 `json:"size,omitempty"` - IsSAF bool `json:"is_saf,omitempty"` + Size int64 `json:"size,omitempty"` + IsSAF bool `json:"is_saf,omitempty"` } const PostProcessTimeout = 2 * time.Minute diff --git a/go_backend/extension_timeout.go b/go_backend/extension_timeout.go index 79fbdaa6..e9a5605f 100644 --- a/go_backend/extension_timeout.go +++ b/go_backend/extension_timeout.go @@ -4,6 +4,7 @@ package gobackend import ( "context" "fmt" + "runtime/debug" "sync" "time" @@ -49,6 +50,7 @@ func RunWithTimeout(vm *goja.Runtime, script string, timeout time.Duration) (goj IsTimeout: true, }} } 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)} } } diff --git a/go_backend/metadata.go b/go_backend/metadata.go index 7f8c9723..370218e4 100644 --- a/go_backend/metadata.go +++ b/go_backend/metadata.go @@ -475,33 +475,91 @@ func EmbedGenreLabel(filePath string, genre, label 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) if err != nil { return "", fmt.Errorf("failed to parse FLAC file: %w", err) } for _, meta := range f.Meta { - if meta.Type == flac.VorbisComment { - cmt, err := flacvorbis.ParseFromMetaDataBlock(*meta) - if err != nil { - continue - } + if meta.Type != flac.VorbisComment { + continue + } - lyrics, err := cmt.Get("LYRICS") - if err == nil && len(lyrics) > 0 && lyrics[0] != "" { - return lyrics[0], nil - } + cmt, err := flacvorbis.ParseFromMetaDataBlock(*meta) + if err != nil { + continue + } - lyrics, err = cmt.Get("UNSYNCEDLYRICS") - if err == nil && len(lyrics) > 0 && lyrics[0] != "" { - return lyrics[0], nil - } + lyrics, err := cmt.Get("LYRICS") + if err == nil && len(lyrics) > 0 && strings.TrimSpace(lyrics[0]) != "" { + 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") } +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 { BitDepth int `json:"bit_depth"` SampleRate int `json:"sample_rate"` diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index a74a1ea1..5336394c 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -419,7 +419,7 @@ func extractQobuzDownloadURLFromBody(body []byte) (string, error) { func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (string, error) { formatID := mapJumoQuality(quality) region := "US" - jumoURL := fmt.Sprintf("https://jumo-dl.pages.dev/file?track_id=%d&format_id=%d®ion=%s", trackID, formatID, region) + jumoURL := fmt.Sprintf("https://jumo-dl.pages.dev/get?track_id=%d&format_id=%d®ion=%s", trackID, formatID, region) GoLog("[Qobuz] Trying Jumo API fallback...\n") @@ -428,6 +428,8 @@ func (q *QobuzDownloader) downloadFromJumo(trackID int64, quality string) (strin if err != nil { return "", err } + req.Header.Set("User-Agent", getRandomUserAgent()) + req.Header.Set("Referer", "https://jumo-dl.pages.dev/") resp, err := client.Do(req) if err != nil { diff --git a/go_backend/spotfetch_api.go b/go_backend/spotfetch_api.go new file mode 100644 index 00000000..d6bfab19 --- /dev/null +++ b/go_backend/spotfetch_api.go @@ -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) + } +} diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index 4ebab8ef..4fa3ab82 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -1,8 +1,8 @@ /// App version and info constants /// Update version here only - all other files will reference this class AppInfo { - static const String version = '3.6.1'; - static const String buildNumber = '78'; + static const String version = '3.6.5'; + static const String buildNumber = '79'; static const String fullVersion = '$version+$buildNumber'; diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index d7fd0338..f05056d5 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -5139,6 +5139,70 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Failed: {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 diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 052b9474..61c28595 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2908,4 +2908,42 @@ class AppLocalizationsDe extends AppLocalizations { String trackSaveFailed(String 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'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 0bcd87bf..bafc4f3f 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2894,4 +2894,42 @@ class AppLocalizationsEn extends AppLocalizations { String trackSaveFailed(String 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'; } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 574edcf6..e2c130b7 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -2894,6 +2894,44 @@ class AppLocalizationsEs extends AppLocalizations { String trackSaveFailed(String 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`). diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 61057994..6c658887 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -2894,4 +2894,42 @@ class AppLocalizationsFr extends AppLocalizations { String trackSaveFailed(String 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'; } diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index a10d26cb..6a3ce249 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -2894,4 +2894,42 @@ class AppLocalizationsHi extends AppLocalizations { String trackSaveFailed(String 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'; } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 0a011ec9..52cf8925 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -2913,4 +2913,42 @@ class AppLocalizationsId extends AppLocalizations { String trackSaveFailed(String 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'; } diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 973227d6..3e9a1b3d 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -2880,4 +2880,42 @@ class AppLocalizationsJa extends AppLocalizations { String trackSaveFailed(String 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'; } diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index dceb933f..a74e36d4 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -2894,4 +2894,42 @@ class AppLocalizationsKo extends AppLocalizations { String trackSaveFailed(String 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'; } diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 5a4eca35..52c05aa9 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -2894,4 +2894,42 @@ class AppLocalizationsNl extends AppLocalizations { String trackSaveFailed(String 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'; } diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 4e8748a5..cea6c5fe 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -2894,6 +2894,44 @@ class AppLocalizationsPt extends AppLocalizations { String trackSaveFailed(String 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`). diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 9ffb5d94..79438058 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -2940,4 +2940,42 @@ class AppLocalizationsRu extends AppLocalizations { String trackSaveFailed(String 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'; } diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 7a6e5c3a..004e1119 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -2909,4 +2909,42 @@ class AppLocalizationsTr extends AppLocalizations { String trackSaveFailed(String 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'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 2e87063e..eb41c0d9 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -2894,6 +2894,44 @@ class AppLocalizationsZh extends AppLocalizations { String trackSaveFailed(String 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`). diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index ca845fcf..03e26018 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -2184,5 +2184,38 @@ "placeholders": { "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"} } diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 2c6e8a2b..43b98f13 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -587,7 +587,7 @@ "aboutSupport": "Dukungan", "@aboutSupport": { "description": "Section for support/donation links" - }, + }, "aboutApp": "Aplikasi", "@aboutApp": { "description": "Section for app info" @@ -3206,5 +3206,38 @@ "placeholders": { "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"} } diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index b65f77ac..5d5de696 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -1237,6 +1237,11 @@ class DownloadQueueNotifier extends Notifier { } 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') { return '.m4a'; } @@ -2897,6 +2902,123 @@ class DownloadQueueNotifier extends Notifier { final actualService = ((result['service'] as String?)?.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 = {'.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 mimeType = isContentUriPath ? await _getSafMimeType(filePath) @@ -3323,7 +3445,43 @@ class DownloadQueueNotifier extends Notifier { await File(tempPath).delete(); } 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 diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 0c0265b3..ad967413 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -646,13 +646,26 @@ class _QueueTabState extends ConsumerState { } String _getQualityBadgeText(String quality) { - if (quality.contains('bit')) { + final q = quality.trim().toLowerCase(); + if (q.contains('bit')) { return quality.split('/').first; } - final bitrateMatch = RegExp(r'(\d+)kbps').firstMatch(quality); - if (bitrateMatch != null) { - return '${bitrateMatch.group(1)}k'; + + // Supports "MP3 320k", "Opus 256kbps", etc. + 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; } @@ -1647,7 +1660,9 @@ class _QueueTabState extends ConsumerState { ), // Search bar - always at top - if (allHistoryItems.isNotEmpty || hasQueueItems || localLibraryItems.isNotEmpty) + if (allHistoryItems.isNotEmpty || + hasQueueItems || + localLibraryItems.isNotEmpty) SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), @@ -2946,13 +2961,13 @@ class _QueueTabState extends ConsumerState { // show bytes downloaded instead of percentage item.progress > 0 ? (item.speedMBps > 0 - ? '${(item.progress * 100).toStringAsFixed(0)}% • ${item.speedMBps.toStringAsFixed(1)} MB/s' - : '${(item.progress * 100).toStringAsFixed(0)}%') + ? '${(item.progress * 100).toStringAsFixed(0)}% • ${item.speedMBps.toStringAsFixed(1)} MB/s' + : '${(item.progress * 100).toStringAsFixed(0)}%') : (item.bytesReceived > 0 - ? '${(item.bytesReceived / (1024 * 1024)).toStringAsFixed(1)} MB • ${item.speedMBps.toStringAsFixed(1)} MB/s' - : (item.speedMBps > 0 - ? 'Downloading • ${item.speedMBps.toStringAsFixed(1)} MB/s' - : 'Starting...')), + ? '${(item.bytesReceived / (1024 * 1024)).toStringAsFixed(1)} MB • ${item.speedMBps.toStringAsFixed(1)} MB/s' + : (item.speedMBps > 0 + ? 'Downloading • ${item.speedMBps.toStringAsFixed(1)} MB/s' + : 'Starting...')), style: Theme.of(context).textTheme.labelSmall ?.copyWith( color: colorScheme.primary, diff --git a/lib/screens/settings/donate_page.dart b/lib/screens/settings/donate_page.dart index 9608bab9..1ec3e784 100644 --- a/lib/screens/settings/donate_page.dart +++ b/lib/screens/settings/donate_page.dart @@ -202,6 +202,7 @@ class _RecentDonorsCard extends StatelessWidget { const SizedBox(height: 16), _DonorTile(name: 'J', colorScheme: colorScheme), _DonorTile(name: 'Julian', colorScheme: colorScheme), + _DonorTile(name: 'matt_3050', colorScheme: colorScheme), _DonorTile(name: 'Daniel', colorScheme: colorScheme), _DonorTile( name: '283Fabio', diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index 0638e847..9f0456d0 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -22,7 +22,7 @@ class DownloadSettingsPage extends ConsumerStatefulWidget { } class _DownloadSettingsPageState extends ConsumerState { - static const _builtInServices = ['tidal', 'qobuz']; + static const _builtInServices = ['tidal', 'qobuz', 'amazon']; int _androidSdkVersion = 0; bool _hasAllFilesAccess = false; @@ -248,7 +248,7 @@ class _DownloadSettingsPageState extends ConsumerState { const SizedBox(width: 8), Expanded( 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 ?.copyWith( color: colorScheme.onSurfaceVariant, @@ -1366,6 +1366,7 @@ class _ServiceSelector extends ConsumerWidget { final isExtensionService = ![ 'tidal', 'qobuz', + 'amazon', ].contains(currentService); final isCurrentExtensionEnabled = isExtensionService ? extensionProviders.any((e) => e.id == currentService) @@ -1392,6 +1393,13 @@ class _ServiceSelector extends ConsumerWidget { isSelected: effectiveService == '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) ...[ diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 8a695600..857f4508 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -5,43 +5,53 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; +import 'package:spotiflac_android/services/history_database.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/utils/file_access.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:share_plus/share_plus.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/utils/logger.dart'; + +final _log = AppLogger('TrackMetadata'); class TrackMetadataScreen extends ConsumerStatefulWidget { final DownloadHistoryItem? item; final LocalLibraryItem? localItem; const TrackMetadataScreen({super.key, this.item, this.localItem}) - : assert(item != null || localItem != null, 'Either item or localItem must be provided'); + : assert( + item != null || localItem != null, + 'Either item or localItem must be provided', + ); @override - ConsumerState createState() => _TrackMetadataScreenState(); + ConsumerState createState() => + _TrackMetadataScreenState(); } class _TrackMetadataScreenState extends ConsumerState { bool _fileExists = false; int? _fileSize; - String? _lyrics; // Cleaned lyrics for display (no timestamps) - String? _rawLyrics; // Raw LRC with timestamps for embedding + String? _lyrics; // Cleaned lyrics for display (no timestamps) + String? _rawLyrics; // Raw LRC with timestamps for embedding bool _lyricsLoading = false; String? _lyricsError; bool _showTitleInAppBar = false; - bool _lyricsEmbedded = false; // Track if lyrics are embedded in file - bool _isEmbedding = false; // Track embed operation in progress - bool _isInstrumental = false; // Track if detected as instrumental + bool _lyricsEmbedded = false; // Track if lyrics are embedded in file + bool _isEmbedding = false; // Track embed operation in progress + bool _isInstrumental = false; // Track if detected as instrumental + bool _isConverting = false; // Track convert operation in progress Map? _editedMetadata; // Overrides after metadata edit final ScrollController _scrollController = ScrollController(); - static final RegExp _lrcTimestampPattern = - RegExp(r'^\[\d{2}:\d{2}\.\d{2,3}\]'); - static final RegExp _lrcMetadataPattern = - RegExp(r'^\[[a-zA-Z]+:.*\]$'); + static final RegExp _lrcTimestampPattern = RegExp( + r'^\[\d{2}:\d{2}\.\d{2,3}\]', + ); + static final RegExp _lrcMetadataPattern = RegExp(r'^\[[a-zA-Z]+:.*\]$'); static const List _months = [ 'Jan', 'Feb', @@ -117,49 +127,87 @@ class _TrackMetadataScreenState extends ConsumerState { bool get _isLocalItem => widget.localItem != null; DownloadHistoryItem? get _downloadItem => widget.item; LocalLibraryItem? get _localLibraryItem => widget.localItem; - - String get _itemId => _isLocalItem ? _localLibraryItem!.id : _downloadItem!.id; - String get trackName => _editedMetadata?['title']?.toString() ?? (_isLocalItem ? _localLibraryItem!.trackName : _downloadItem!.trackName); - String get artistName => _editedMetadata?['artist']?.toString() ?? (_isLocalItem ? _localLibraryItem!.artistName : _downloadItem!.artistName); - String get albumName => _editedMetadata?['album']?.toString() ?? (_isLocalItem ? _localLibraryItem!.albumName : _downloadItem!.albumName); + + String get _itemId => + _isLocalItem ? _localLibraryItem!.id : _downloadItem!.id; + String get trackName => + _editedMetadata?['title']?.toString() ?? + (_isLocalItem ? _localLibraryItem!.trackName : _downloadItem!.trackName); + String get artistName => + _editedMetadata?['artist']?.toString() ?? + (_isLocalItem + ? _localLibraryItem!.artistName + : _downloadItem!.artistName); + String get albumName => + _editedMetadata?['album']?.toString() ?? + (_isLocalItem ? _localLibraryItem!.albumName : _downloadItem!.albumName); String? get albumArtist { final edited = _editedMetadata?['album_artist']?.toString(); if (edited != null && edited.isNotEmpty) return edited; - return _normalizeOptionalString(_isLocalItem ? _localLibraryItem!.albumArtist : _downloadItem!.albumArtist); + return _normalizeOptionalString( + _isLocalItem + ? _localLibraryItem!.albumArtist + : _downloadItem!.albumArtist, + ); } + int? get trackNumber { final edited = _editedMetadata?['track_number']; if (edited != null) { final v = int.tryParse(edited.toString()); if (v != null && v > 0) return v; } - return _isLocalItem ? _localLibraryItem!.trackNumber : _downloadItem!.trackNumber; + return _isLocalItem + ? _localLibraryItem!.trackNumber + : _downloadItem!.trackNumber; } + int? get discNumber { final edited = _editedMetadata?['disc_number']; if (edited != null) { final v = int.tryParse(edited.toString()); if (v != null && v > 0) return v; } - return _isLocalItem ? _localLibraryItem!.discNumber : _downloadItem!.discNumber; + return _isLocalItem + ? _localLibraryItem!.discNumber + : _downloadItem!.discNumber; } - String? get releaseDate => _editedMetadata?['date']?.toString() ?? (_isLocalItem ? _localLibraryItem!.releaseDate : _downloadItem!.releaseDate); - String? get isrc => _editedMetadata?['isrc']?.toString() ?? (_isLocalItem ? _localLibraryItem!.isrc : _downloadItem!.isrc); - String? get genre => _editedMetadata?['genre']?.toString() ?? (_isLocalItem ? _localLibraryItem!.genre : _downloadItem!.genre); - String? get label => _editedMetadata?['label']?.toString() ?? (_isLocalItem ? null : _downloadItem!.label); - String? get copyright => _editedMetadata?['copyright']?.toString() ?? (_isLocalItem ? null : _downloadItem!.copyright); - int? get duration => _isLocalItem ? _localLibraryItem!.duration : _downloadItem!.duration; - int? get bitDepth => _isLocalItem ? _localLibraryItem!.bitDepth : _downloadItem!.bitDepth; - int? get sampleRate => _isLocalItem ? _localLibraryItem!.sampleRate : _downloadItem!.sampleRate; - - String get _filePath => _isLocalItem ? _localLibraryItem!.filePath : _downloadItem!.filePath; + + String? get releaseDate => + _editedMetadata?['date']?.toString() ?? + (_isLocalItem + ? _localLibraryItem!.releaseDate + : _downloadItem!.releaseDate); + String? get isrc => + _editedMetadata?['isrc']?.toString() ?? + (_isLocalItem ? _localLibraryItem!.isrc : _downloadItem!.isrc); + String? get genre => + _editedMetadata?['genre']?.toString() ?? + (_isLocalItem ? _localLibraryItem!.genre : _downloadItem!.genre); + String? get label => + _editedMetadata?['label']?.toString() ?? + (_isLocalItem ? null : _downloadItem!.label); + String? get copyright => + _editedMetadata?['copyright']?.toString() ?? + (_isLocalItem ? null : _downloadItem!.copyright); + int? get duration => + _isLocalItem ? _localLibraryItem!.duration : _downloadItem!.duration; + int? get bitDepth => + _isLocalItem ? _localLibraryItem!.bitDepth : _downloadItem!.bitDepth; + int? get sampleRate => + _isLocalItem ? _localLibraryItem!.sampleRate : _downloadItem!.sampleRate; + + String get _filePath => + _isLocalItem ? _localLibraryItem!.filePath : _downloadItem!.filePath; String? get _coverUrl => _isLocalItem ? null : _downloadItem!.coverUrl; - String? get _localCoverPath => _isLocalItem ? _localLibraryItem!.coverPath : null; + String? get _localCoverPath => + _isLocalItem ? _localLibraryItem!.coverPath : null; String? get _spotifyId => _isLocalItem ? null : _downloadItem!.spotifyId; String get _service => _isLocalItem ? 'local' : _downloadItem!.service; - DateTime get _addedAt => _isLocalItem ? _localLibraryItem!.scannedAt : _downloadItem!.downloadedAt; + DateTime get _addedAt => + _isLocalItem ? _localLibraryItem!.scannedAt : _downloadItem!.downloadedAt; String? get _quality => _isLocalItem ? null : _downloadItem!.quality; - + String get cleanFilePath { final path = _filePath; return path.startsWith('EXISTS:') ? path.substring(7) : path; @@ -179,7 +227,8 @@ class _TrackMetadataScreenState extends ConsumerState { expandedHeight: 320, pinned: true, stretch: true, - backgroundColor: colorScheme.surface, // Use theme color for collapsed state + backgroundColor: + colorScheme.surface, // Use theme color for collapsed state surfaceTintColor: Colors.transparent, title: AnimatedOpacity( duration: const Duration(milliseconds: 200), @@ -197,12 +246,19 @@ class _TrackMetadataScreenState extends ConsumerState { ), flexibleSpace: LayoutBuilder( builder: (context, constraints) { - final collapseRatio = (constraints.maxHeight - kToolbarHeight) / (320 - kToolbarHeight); + final collapseRatio = + (constraints.maxHeight - kToolbarHeight) / + (320 - kToolbarHeight); final showContent = collapseRatio > 0.3; - + return FlexibleSpaceBar( collapseMode: CollapseMode.none, - background: _buildHeaderBackground(context, colorScheme, coverSize, showContent), + background: _buildHeaderBackground( + context, + colorScheme, + coverSize, + showContent, + ), stretchModes: const [ StretchMode.zoomBackground, StretchMode.blurBackground, @@ -243,23 +299,28 @@ class _TrackMetadataScreenState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildTrackInfoCard(context, colorScheme, _fileExists), - + const SizedBox(height: 16), - + _buildMetadataCard(context, colorScheme, _fileSize), - + const SizedBox(height: 16), - - _buildFileInfoCard(context, colorScheme, _fileExists, _fileSize), - + + _buildFileInfoCard( + context, + colorScheme, + _fileExists, + _fileSize, + ), + const SizedBox(height: 16), - + _buildLyricsCard(context, colorScheme), - + const SizedBox(height: 24), - + _buildActionButtons(context, ref, colorScheme, _fileExists), - + const SizedBox(height: 32), ], ), @@ -270,7 +331,12 @@ class _TrackMetadataScreenState extends ConsumerState { ); } - Widget _buildHeaderBackground(BuildContext context, ColorScheme colorScheme, double coverSize, bool showContent) { + Widget _buildHeaderBackground( + BuildContext context, + ColorScheme colorScheme, + double coverSize, + bool showContent, + ) { return Stack( fit: StackFit.expand, children: [ @@ -291,17 +357,15 @@ class _TrackMetadataScreenState extends ConsumerState { ) else Container(color: colorScheme.surface), - + // Blur filter ClipRect( child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 30, sigmaY: 30), - child: Container( - color: colorScheme.surface.withValues(alpha: 0.4), - ), + child: Container(color: colorScheme.surface.withValues(alpha: 0.4)), ), ), - + // Bottom fade to surface Positioned( left: 0, @@ -321,7 +385,7 @@ class _TrackMetadataScreenState extends ConsumerState { ), ), ), - + // Cover art AnimatedOpacity( duration: const Duration(milliseconds: 150), @@ -362,18 +426,15 @@ class _TrackMetadataScreenState extends ConsumerState { ), ) : _localCoverPath != null && _localCoverPath!.isNotEmpty - ? Image.file( - File(_localCoverPath!), - fit: BoxFit.cover, - ) - : Container( - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.music_note, - size: 64, - color: colorScheme.onSurfaceVariant, - ), - ), + ? Image.file(File(_localCoverPath!), fit: BoxFit.cover) + : Container( + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + size: 64, + color: colorScheme.onSurfaceVariant, + ), + ), ), ), ), @@ -384,7 +445,11 @@ class _TrackMetadataScreenState extends ConsumerState { ); } - Widget _buildTrackInfoCard(BuildContext context, ColorScheme colorScheme, bool fileExists) { + Widget _buildTrackInfoCard( + BuildContext context, + ColorScheme colorScheme, + bool fileExists, + ) { return Card( elevation: 0, color: colorScheme.surfaceContainerLow, @@ -402,15 +467,15 @@ class _TrackMetadataScreenState extends ConsumerState { ), ), const SizedBox(height: 4), - + Text( artistName, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: colorScheme.primary, - ), + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(color: colorScheme.primary), ), const SizedBox(height: 8), - + Row( children: [ Icon( @@ -429,11 +494,14 @@ class _TrackMetadataScreenState extends ConsumerState { ), ], ), - + if (!fileExists) ...[ const SizedBox(height: 12), Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), decoration: BoxDecoration( color: colorScheme.errorContainer, borderRadius: BorderRadius.circular(20), @@ -465,7 +533,11 @@ class _TrackMetadataScreenState extends ConsumerState { ); } - Widget _buildMetadataCard(BuildContext context, ColorScheme colorScheme, int? fileSize) { + Widget _buildMetadataCard( + BuildContext context, + ColorScheme colorScheme, + int? fileSize, + ) { return Card( elevation: 0, color: colorScheme.surfaceContainerLow, @@ -477,11 +549,7 @@ class _TrackMetadataScreenState extends ConsumerState { children: [ Row( children: [ - Icon( - Icons.info_outline, - size: 20, - color: colorScheme.primary, - ), + Icon(Icons.info_outline, size: 20, color: colorScheme.primary), const SizedBox(width: 8), Text( context.l10n.trackMetadata, @@ -493,9 +561,9 @@ class _TrackMetadataScreenState extends ConsumerState { ], ), const SizedBox(height: 16), - + _buildMetadataGrid(context, colorScheme), - + if (_spotifyId != null && _spotifyId!.isNotEmpty) ...[ const SizedBox(height: 8), Builder( @@ -504,15 +572,22 @@ class _TrackMetadataScreenState extends ConsumerState { return OutlinedButton.icon( onPressed: () => _openServiceUrl(context), icon: const Icon(Icons.open_in_new, size: 18), - label: Text(isDeezer ? context.l10n.trackOpenInDeezer : context.l10n.trackOpenInSpotify), + label: Text( + isDeezer + ? context.l10n.trackOpenInDeezer + : context.l10n.trackOpenInSpotify, + ), style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ), ); - } + }, ), ], ], @@ -523,24 +598,24 @@ class _TrackMetadataScreenState extends ConsumerState { Future _openServiceUrl(BuildContext context) async { if (_spotifyId == null) return; - + final isDeezer = _spotifyId!.contains('deezer'); final rawId = _spotifyId!.replaceAll('deezer:', ''); - - final webUrl = isDeezer + + final webUrl = isDeezer ? 'https://www.deezer.com/track/$rawId' : 'https://open.spotify.com/track/$rawId'; - + final appUri = isDeezer ? Uri.parse('deezer://www.deezer.com/track/$rawId') : Uri.parse('spotify:track:$rawId'); - + try { final launched = await launchUrl( appUri, mode: LaunchMode.externalApplication, ); - + if (!launched) { await launchUrl( Uri.parse(webUrl), @@ -557,7 +632,11 @@ class _TrackMetadataScreenState extends ConsumerState { if (context.mounted) { _copyToClipboard(context, webUrl); ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.snackbarUrlCopied(isDeezer ? 'Deezer' : 'Spotify'))), + SnackBar( + content: Text( + context.l10n.snackbarUrlCopied(isDeezer ? 'Deezer' : 'Spotify'), + ), + ), ); } } @@ -568,8 +647,10 @@ class _TrackMetadataScreenState extends ConsumerState { // Determine audio quality string - prefer stored quality from download String? audioQualityStr; final fileName = _filePath.split('/').last; - final fileExt = fileName.contains('.') ? fileName.split('.').last.toUpperCase() : ''; - + final fileExt = fileName.contains('.') + ? fileName.split('.').last.toUpperCase() + : ''; + // Use stored quality from download history if available if (_quality != null && _quality!.isNotEmpty) { audioQualityStr = _quality; @@ -587,7 +668,7 @@ class _TrackMetadataScreenState extends ConsumerState { audioQualityStr = 'AAC'; } } - + final items = <_MetadataItem>[ _MetadataItem(context.l10n.trackTrackName, trackName), _MetadataItem(context.l10n.trackArtist, artistName), @@ -610,28 +691,30 @@ class _TrackMetadataScreenState extends ConsumerState { _MetadataItem(context.l10n.trackLabel, label!), if (copyright != null && copyright!.isNotEmpty) _MetadataItem(context.l10n.trackCopyright, copyright!), - if (isrc != null && isrc!.isNotEmpty) - _MetadataItem('ISRC', isrc!), + if (isrc != null && isrc!.isNotEmpty) _MetadataItem('ISRC', isrc!), ]; - + if (!_isLocalItem && _spotifyId != null && _spotifyId!.isNotEmpty) { final isDeezer = _spotifyId!.contains('deezer'); final cleanId = _spotifyId!.replaceAll('deezer:', ''); items.add(_MetadataItem(isDeezer ? 'Deezer ID' : 'Spotify ID', cleanId)); } - - items.add(_MetadataItem(context.l10n.trackMetadataService, _service.toUpperCase())); - items.add(_MetadataItem( - context.l10n.trackDownloaded, - _formatFullDate(_addedAt), - )); + + items.add( + _MetadataItem(context.l10n.trackMetadataService, _service.toUpperCase()), + ); + items.add( + _MetadataItem(context.l10n.trackDownloaded, _formatFullDate(_addedAt)), + ); return Column( children: items.map((metadata) { - final isCopyable = metadata.label == 'ISRC' || - metadata.label == 'Spotify ID'; + final isCopyable = + metadata.label == 'ISRC' || metadata.label == 'Spotify ID'; return InkWell( - onTap: isCopyable ? () => _copyToClipboard(context, metadata.value) : null, + onTap: isCopyable + ? () => _copyToClipboard(context, metadata.value) + : null, borderRadius: BorderRadius.circular(8), child: Padding( padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4), @@ -675,10 +758,18 @@ class _TrackMetadataScreenState extends ConsumerState { return '$minutes:${secs.toString().padLeft(2, '0')}'; } - Widget _buildFileInfoCard(BuildContext context, ColorScheme colorScheme, bool fileExists, int? fileSize) { + Widget _buildFileInfoCard( + BuildContext context, + ColorScheme colorScheme, + bool fileExists, + int? fileSize, + ) { final fileName = cleanFilePath.split(Platform.pathSeparator).last; - final fileExtension = fileName.contains('.') ? fileName.split('.').last.toUpperCase() : 'Unknown'; - + final fileExtension = fileName.contains('.') + ? fileName.split('.').last.toUpperCase() + : 'Unknown'; + final lossyBitrateLabel = _extractLossyBitrateLabel(_quality); + return Card( elevation: 0, color: colorScheme.surfaceContainerLow, @@ -706,13 +797,16 @@ class _TrackMetadataScreenState extends ConsumerState { ], ), const SizedBox(height: 16), - + Wrap( spacing: 8, runSpacing: 8, children: [ Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), decoration: BoxDecoration( color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(20), @@ -728,7 +822,10 @@ class _TrackMetadataScreenState extends ConsumerState { ), if (fileSize != null) Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), decoration: BoxDecoration( color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(20), @@ -742,15 +839,21 @@ class _TrackMetadataScreenState extends ConsumerState { ), ), ), - if (fileExtension == 'MP3') + if ((fileExtension == 'MP3' || + fileExtension == 'OPUS' || + fileExtension == 'OGG') && + lossyBitrateLabel != null) Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), decoration: BoxDecoration( color: colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(20), ), child: Text( - '320kbps', + lossyBitrateLabel, style: TextStyle( color: colorScheme.onTertiaryContainer, fontWeight: FontWeight.w600, @@ -760,7 +863,10 @@ class _TrackMetadataScreenState extends ConsumerState { ) else if (bitDepth != null && sampleRate != null) Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), decoration: BoxDecoration( color: colorScheme.tertiaryContainer, borderRadius: BorderRadius.circular(20), @@ -775,7 +881,10 @@ class _TrackMetadataScreenState extends ConsumerState { ), ), Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), decoration: BoxDecoration( color: _getServiceColor(_service, colorScheme), borderRadius: BorderRadius.circular(20), @@ -802,9 +911,9 @@ class _TrackMetadataScreenState extends ConsumerState { ), ], ), - + const SizedBox(height: 16), - + InkWell( onTap: () => _copyToClipboard(context, cleanFilePath), borderRadius: BorderRadius.circular(12), @@ -878,7 +987,7 @@ class _TrackMetadataScreenState extends ConsumerState { ], ), const SizedBox(height: 12), - + if (_lyricsLoading) const Center( child: Padding( @@ -895,7 +1004,11 @@ class _TrackMetadataScreenState extends ConsumerState { ), child: Row( children: [ - Icon(Icons.error_outline, color: colorScheme.error, size: 20), + Icon( + Icons.error_outline, + color: colorScheme.error, + size: 20, + ), const SizedBox(width: 12), Expanded( child: Text( @@ -920,7 +1033,11 @@ class _TrackMetadataScreenState extends ConsumerState { child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.music_note, color: colorScheme.tertiary, size: 20), + Icon( + Icons.music_note, + color: colorScheme.tertiary, + size: 20, + ), const SizedBox(width: 12), Text( context.l10n.trackInstrumental, @@ -958,7 +1075,9 @@ class _TrackMetadataScreenState extends ConsumerState { ? const SizedBox( width: 18, height: 18, - child: CircularProgressIndicator(strokeWidth: 2), + child: CircularProgressIndicator( + strokeWidth: 2, + ), ) : const Icon(Icons.save_alt), label: Text(context.l10n.trackEmbedLyrics), @@ -983,7 +1102,7 @@ class _TrackMetadataScreenState extends ConsumerState { Future _fetchLyrics() async { if (_lyricsLoading) return; - + setState(() { _lyricsLoading = true; _lyricsError = null; @@ -993,7 +1112,7 @@ class _TrackMetadataScreenState extends ConsumerState { try { // Convert duration from seconds to milliseconds final durationMs = (duration ?? 0) * 1000; - + // First, check if lyrics are embedded in the file if (_fileExists) { final embeddedResult = await PlatformBridge.getLyricsLRC( @@ -1003,7 +1122,7 @@ class _TrackMetadataScreenState extends ConsumerState { filePath: cleanFilePath, durationMs: 0, ).timeout(const Duration(seconds: 5), onTimeout: () => ''); - + if (embeddedResult.isNotEmpty) { // Lyrics found in file if (mounted) { @@ -1017,7 +1136,7 @@ class _TrackMetadataScreenState extends ConsumerState { return; } } - + // No embedded lyrics, fetch from online final result = await PlatformBridge.getLyricsLRC( _spotifyId ?? '', @@ -1025,11 +1144,8 @@ class _TrackMetadataScreenState extends ConsumerState { artistName, filePath: null, // Don't check file again durationMs: durationMs, - ).timeout( - const Duration(seconds: 20), - onTimeout: () => '', - ); - + ).timeout(const Duration(seconds: 20), onTimeout: () => ''); + if (mounted) { // Check for instrumental marker if (result == '[instrumental:true]') { @@ -1054,7 +1170,7 @@ class _TrackMetadataScreenState extends ConsumerState { } } catch (e) { if (mounted) { - final errorMsg = e.toString().contains('TimeoutException') + final errorMsg = e.toString().contains('TimeoutException') ? context.l10n.trackLyricsTimeout : context.l10n.trackLyricsLoadFailed; setState(() { @@ -1064,21 +1180,119 @@ class _TrackMetadataScreenState extends ConsumerState { } } } - + Future _embedLyrics() async { if (_isEmbedding || _rawLyrics == null || !_fileExists) return; - + setState(() => _isEmbedding = true); - + + String? safTempPath; + String? coverPath; + try { - // Use raw LRC content directly - it already has timestamps and metadata - final result = await PlatformBridge.embedLyricsToFile( - cleanFilePath, - _rawLyrics!, - ); - - if (mounted) { + final rawLyrics = _rawLyrics!; + var workingPath = cleanFilePath; + + if (_isSafFile) { + safTempPath = await PlatformBridge.copyContentUriToTemp(cleanFilePath); + if (safTempPath == null || safTempPath.isEmpty) { + throw Exception('Failed to access SAF file'); + } + workingPath = safTempPath; + } + + final lower = workingPath.toLowerCase(); + final isFlac = lower.endsWith('.flac'); + final isMp3 = lower.endsWith('.mp3'); + final isOpus = lower.endsWith('.opus') || lower.endsWith('.ogg'); + + bool success = false; + String? error; + + if (isFlac) { + final result = await PlatformBridge.embedLyricsToFile( + workingPath, + rawLyrics, + ); if (result['success'] == true) { + if (_isSafFile) { + final ok = await PlatformBridge.writeTempToSaf( + workingPath, + cleanFilePath, + ); + success = ok; + if (!ok) { + error = 'Failed to write back to storage'; + } + } else { + success = true; + } + } else { + error = result['error']?.toString() ?? 'Failed to embed lyrics'; + } + } else if (isMp3 || isOpus) { + final metadata = _buildFallbackMetadata(); + try { + final result = await PlatformBridge.readFileMetadata(workingPath); + if (result['error'] == null) { + final mapped = _mapMetadataForTagEmbed(result); + metadata.addAll(mapped); + } + } catch (e) { + _log.w('Failed reading file metadata before lyrics embed: $e'); + } + + metadata['LYRICS'] = rawLyrics; + metadata['UNSYNCEDLYRICS'] = rawLyrics; + + try { + final tempDir = await getTemporaryDirectory(); + final coverOutput = + '${tempDir.path}${Platform.pathSeparator}lyrics_cover_${DateTime.now().millisecondsSinceEpoch}.jpg'; + final coverResult = await PlatformBridge.extractCoverToFile( + workingPath, + coverOutput, + ); + if (coverResult['error'] == null) { + coverPath = coverOutput; + } + } catch (_) {} + + String? ffmpegResult; + if (isMp3) { + ffmpegResult = await FFmpegService.embedMetadataToMp3( + mp3Path: workingPath, + coverPath: coverPath, + metadata: metadata, + ); + } else { + ffmpegResult = await FFmpegService.embedMetadataToOpus( + opusPath: workingPath, + coverPath: coverPath, + metadata: metadata, + ); + } + + if (ffmpegResult == null) { + error = 'Failed to embed lyrics'; + } else if (_isSafFile) { + final ok = await PlatformBridge.writeTempToSaf( + ffmpegResult, + cleanFilePath, + ); + success = ok; + if (!ok) { + error = 'Failed to write back to storage'; + } + } else { + success = true; + } + } else { + error = 'Unsupported audio format'; + } + + if (mounted) { + if (success) { setState(() { _lyricsEmbedded = true; _isEmbedding = false; @@ -1089,16 +1303,27 @@ class _TrackMetadataScreenState extends ConsumerState { } else { setState(() => _isEmbedding = false); ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(result['error'] ?? 'Failed to embed lyrics')), + SnackBar(content: Text(error ?? 'Failed to embed lyrics')), ); } } } catch (e) { if (mounted) { setState(() => _isEmbedding = false); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Error: $e')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Error: $e'))); + } + } finally { + if (coverPath != null) { + try { + await File(coverPath).delete(); + } catch (_) {} + } + if (safTempPath != null) { + try { + await File(safTempPath).delete(); + } catch (_) {} } } } @@ -1127,7 +1352,8 @@ class _TrackMetadataScreenState extends ConsumerState { if (_isSafFile) { // SAF file: save to temp, then copy to SAF tree final tempDir = await Directory.systemTemp.createTemp('cover_'); - final tempOutput = '${tempDir.path}${Platform.pathSeparator}$baseName.jpg'; + final tempOutput = + '${tempDir.path}${Platform.pathSeparator}$baseName.jpg'; Map result; if (_coverUrl != null && _coverUrl!.isNotEmpty) { @@ -1153,10 +1379,16 @@ class _TrackMetadataScreenState extends ConsumerState { if (result['error'] != null) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.trackSaveFailed(result['error'].toString()))), + SnackBar( + content: Text( + context.l10n.trackSaveFailed(result['error'].toString()), + ), + ), ); } - try { await Directory(tempDir.path).delete(recursive: true); } catch (_) {} + try { + await Directory(tempDir.path).delete(recursive: true); + } catch (_) {} return; } @@ -1171,7 +1403,9 @@ class _TrackMetadataScreenState extends ConsumerState { mimeType: 'image/jpeg', srcPath: tempOutput, ); - try { await Directory(tempDir.path).delete(recursive: true); } catch (_) {} + try { + await Directory(tempDir.path).delete(recursive: true); + } catch (_) {} if (mounted) { if (safUri != null) { ScaffoldMessenger.of(context).showSnackBar( @@ -1179,16 +1413,26 @@ class _TrackMetadataScreenState extends ConsumerState { ); } else { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.trackSaveFailed('Failed to write to storage'))), + SnackBar( + content: Text( + context.l10n.trackSaveFailed('Failed to write to storage'), + ), + ), ); } } } else { // No SAF tree info, keep in temp - try { await Directory(tempDir.path).delete(recursive: true); } catch (_) {} + try { + await Directory(tempDir.path).delete(recursive: true); + } catch (_) {} if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.trackSaveFailed('No storage access'))), + SnackBar( + content: Text( + context.l10n.trackSaveFailed('No storage access'), + ), + ), ); } } @@ -1223,7 +1467,11 @@ class _TrackMetadataScreenState extends ConsumerState { if (mounted) { if (result['error'] != null) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.trackSaveFailed(result['error'].toString()))), + SnackBar( + content: Text( + context.l10n.trackSaveFailed(result['error'].toString()), + ), + ), ); } else { ScaffoldMessenger.of(context).showSnackBar( @@ -1248,7 +1496,8 @@ class _TrackMetadataScreenState extends ConsumerState { if (_isSafFile) { // SAF file: save to temp, then copy to SAF tree final tempDir = await Directory.systemTemp.createTemp('lyrics_'); - final tempOutput = '${tempDir.path}${Platform.pathSeparator}$baseName.lrc'; + final tempOutput = + '${tempDir.path}${Platform.pathSeparator}$baseName.lrc'; final result = await PlatformBridge.fetchAndSaveLyrics( trackName: trackName, @@ -1261,10 +1510,16 @@ class _TrackMetadataScreenState extends ConsumerState { if (result['error'] != null) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.trackSaveFailed(result['error'].toString()))), + SnackBar( + content: Text( + context.l10n.trackSaveFailed(result['error'].toString()), + ), + ), ); } - try { await Directory(tempDir.path).delete(recursive: true); } catch (_) {} + try { + await Directory(tempDir.path).delete(recursive: true); + } catch (_) {} return; } @@ -1279,23 +1534,37 @@ class _TrackMetadataScreenState extends ConsumerState { mimeType: 'text/plain', srcPath: tempOutput, ); - try { await Directory(tempDir.path).delete(recursive: true); } catch (_) {} + try { + await Directory(tempDir.path).delete(recursive: true); + } catch (_) {} if (mounted) { if (safUri != null) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.trackLyricsSaved(baseName))), + SnackBar( + content: Text(context.l10n.trackLyricsSaved(baseName)), + ), ); } else { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.trackSaveFailed('Failed to write to storage'))), + SnackBar( + content: Text( + context.l10n.trackSaveFailed('Failed to write to storage'), + ), + ), ); } } } else { - try { await Directory(tempDir.path).delete(recursive: true); } catch (_) {} + try { + await Directory(tempDir.path).delete(recursive: true); + } catch (_) {} if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.trackSaveFailed('No storage access'))), + SnackBar( + content: Text( + context.l10n.trackSaveFailed('No storage access'), + ), + ), ); } } @@ -1317,7 +1586,11 @@ class _TrackMetadataScreenState extends ConsumerState { if (mounted) { if (result['error'] != null) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.trackSaveFailed(result['error'].toString()))), + SnackBar( + content: Text( + context.l10n.trackSaveFailed(result['error'].toString()), + ), + ), ); } else { ScaffoldMessenger.of(context).showSnackBar( @@ -1402,8 +1675,9 @@ class _TrackMetadataScreenState extends ConsumerState { final ffmpegTarget = tempPath ?? cleanFilePath; final coverPath = result['cover_path'] as String?; - final metadata = (result['metadata'] as Map?) - ?.map((k, v) => MapEntry(k, v.toString())); + final metadata = (result['metadata'] as Map?)?.map( + (k, v) => MapEntry(k, v.toString()), + ); final lower = cleanFilePath.toLowerCase(); String? ffmpegResult; @@ -1426,14 +1700,24 @@ class _TrackMetadataScreenState extends ConsumerState { final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri); if (!ok && mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.trackSaveFailed('Failed to write back to storage'))), + SnackBar( + content: Text( + context.l10n.trackSaveFailed( + 'Failed to write back to storage', + ), + ), + ), ); // Cleanup temp files if (coverPath != null && coverPath.isNotEmpty) { - try { await File(coverPath).delete(); } catch (_) {} + try { + await File(coverPath).delete(); + } catch (_) {} } if (tempPath.isNotEmpty) { - try { await File(tempPath).delete(); } catch (_) {} + try { + await File(tempPath).delete(); + } catch (_) {} } return; } @@ -1441,7 +1725,9 @@ class _TrackMetadataScreenState extends ConsumerState { // Cleanup temp files if (tempPath != null && tempPath.isNotEmpty) { - try { await File(tempPath).delete(); } catch (_) {} + try { + await File(tempPath).delete(); + } catch (_) {} } if (mounted) { @@ -1458,7 +1744,9 @@ class _TrackMetadataScreenState extends ConsumerState { // Cleanup temp cover from Go backend if (coverPath != null && coverPath.isNotEmpty) { - try { await File(coverPath).delete(); } catch (_) {} + try { + await File(coverPath).delete(); + } catch (_) {} } } else { if (mounted) { @@ -1480,32 +1768,39 @@ class _TrackMetadataScreenState extends ConsumerState { String _cleanLrcForDisplay(String lrc) { final lines = lrc.split('\n'); final cleanLines = []; - + for (final line in lines) { final trimmedLine = line.trim(); - + // Skip metadata tags if (_lrcMetadataPattern.hasMatch(trimmedLine)) { continue; } - + // Remove timestamp and clean up final cleanLine = trimmedLine.replaceAll(_lrcTimestampPattern, '').trim(); if (cleanLine.isNotEmpty) { cleanLines.add(cleanLine); } } - + return cleanLines.join('\n'); } - Widget _buildActionButtons(BuildContext context, WidgetRef ref, ColorScheme colorScheme, bool fileExists) { + Widget _buildActionButtons( + BuildContext context, + WidgetRef ref, + ColorScheme colorScheme, + bool fileExists, + ) { return Row( children: [ Expanded( flex: 2, child: FilledButton.icon( - onPressed: fileExists ? () => _openFile(context, cleanFilePath) : null, + onPressed: fileExists + ? () => _openFile(context, cleanFilePath) + : null, icon: const Icon(Icons.play_arrow), label: Text(context.l10n.trackMetadataPlay), style: FilledButton.styleFrom( @@ -1517,12 +1812,15 @@ class _TrackMetadataScreenState extends ConsumerState { ), ), const SizedBox(width: 12), - + Expanded( child: OutlinedButton.icon( onPressed: () => _confirmDelete(context, ref, colorScheme), icon: Icon(Icons.delete_outline, color: colorScheme.error), - label: Text(context.l10n.trackMetadataDelete, style: TextStyle(color: colorScheme.error)), + label: Text( + context.l10n.trackMetadataDelete, + style: TextStyle(color: colorScheme.error), + ), style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 16), shape: RoundedRectangleBorder( @@ -1536,7 +1834,11 @@ class _TrackMetadataScreenState extends ConsumerState { ); } - void _showOptionsMenu(BuildContext context, WidgetRef ref, ColorScheme colorScheme) { + void _showOptionsMenu( + BuildContext context, + WidgetRef ref, + ColorScheme colorScheme, + ) { showModalBottomSheet( context: context, shape: const RoundedRectangleBorder( @@ -1549,91 +1851,624 @@ class _TrackMetadataScreenState extends ConsumerState { builder: (context) => SafeArea( child: SingleChildScrollView( child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 8), - Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(2), + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), ), - ), - const SizedBox(height: 16), - ListTile( - leading: const Icon(Icons.copy), - title: Text(context.l10n.trackCopyFilePath), - onTap: () { - Navigator.pop(context); - _copyToClipboard(context, cleanFilePath); - }, - ), - if (_fileExists) + const SizedBox(height: 16), ListTile( - leading: const Icon(Icons.edit_outlined), - title: Text(context.l10n.trackEditMetadata), + leading: const Icon(Icons.copy), + title: Text(context.l10n.trackCopyFilePath), onTap: () { Navigator.pop(context); - _showEditMetadataSheet(context, ref, colorScheme); + _copyToClipboard(context, cleanFilePath); }, ), - if (!_isLocalItem && (_coverUrl != null || _fileExists)) + if (_fileExists) + ListTile( + leading: const Icon(Icons.edit_outlined), + title: Text(context.l10n.trackEditMetadata), + onTap: () { + Navigator.pop(context); + _showEditMetadataSheet(context, ref, colorScheme); + }, + ), + if (!_isLocalItem && (_coverUrl != null || _fileExists)) + ListTile( + leading: const Icon(Icons.image_outlined), + title: Text(context.l10n.trackSaveCoverArt), + subtitle: Text(context.l10n.trackSaveCoverArtSubtitle), + onTap: () { + Navigator.pop(context); + _saveCoverArt(); + }, + ), + if (!_isLocalItem) + ListTile( + leading: const Icon(Icons.lyrics_outlined), + title: Text(context.l10n.trackSaveLyrics), + subtitle: Text(context.l10n.trackSaveLyricsSubtitle), + onTap: () { + Navigator.pop(context); + _saveLyrics(); + }, + ), + if (_fileExists) + ListTile( + leading: const Icon(Icons.travel_explore), + title: Text(context.l10n.trackReEnrich), + subtitle: Text(context.l10n.trackReEnrichOnlineSubtitle), + onTap: () { + Navigator.pop(context); + _reEnrichMetadata(); + }, + ), + if (_fileExists && _isConvertibleFormat) + ListTile( + leading: const Icon(Icons.swap_horiz), + title: Text(context.l10n.trackConvertFormat), + subtitle: Text(context.l10n.trackConvertFormatSubtitle), + onTap: () { + Navigator.pop(context); + _showConvertSheet(context); + }, + ), + const Divider(height: 1), ListTile( - leading: const Icon(Icons.image_outlined), - title: Text(context.l10n.trackSaveCoverArt), - subtitle: Text(context.l10n.trackSaveCoverArtSubtitle), + leading: const Icon(Icons.share), + title: Text(context.l10n.trackMetadataShare), onTap: () { Navigator.pop(context); - _saveCoverArt(); + _shareFile(context); }, ), - if (!_isLocalItem) ListTile( - leading: const Icon(Icons.lyrics_outlined), - title: Text(context.l10n.trackSaveLyrics), - subtitle: Text(context.l10n.trackSaveLyricsSubtitle), + leading: Icon(Icons.delete, color: colorScheme.error), + title: Text( + context.l10n.trackRemoveFromDevice, + style: TextStyle(color: colorScheme.error), + ), onTap: () { Navigator.pop(context); - _saveLyrics(); + _confirmDelete(context, ref, colorScheme); }, ), - if (_fileExists) - ListTile( - leading: const Icon(Icons.travel_explore), - title: Text(context.l10n.trackReEnrich), - subtitle: Text(context.l10n.trackReEnrichOnlineSubtitle), - onTap: () { - Navigator.pop(context); - _reEnrichMetadata(); - }, - ), - const Divider(height: 1), - ListTile( - leading: const Icon(Icons.share), - title: Text(context.l10n.trackMetadataShare), - onTap: () { - Navigator.pop(context); - _shareFile(context); - }, - ), - ListTile( - leading: Icon(Icons.delete, color: colorScheme.error), - title: Text(context.l10n.trackRemoveFromDevice, style: TextStyle(color: colorScheme.error)), - onTap: () { - Navigator.pop(context); - _confirmDelete(context, ref, colorScheme); - }, - ), - const SizedBox(height: 16), - ], - ), + const SizedBox(height: 16), + ], + ), ), ), ); } - void _showEditMetadataSheet(BuildContext context, WidgetRef ref, ColorScheme colorScheme) async { + /// Whether the current file format supports conversion + bool get _isConvertibleFormat { + final lower = cleanFilePath.toLowerCase(); + return lower.endsWith('.flac') || + lower.endsWith('.mp3') || + lower.endsWith('.opus') || + lower.endsWith('.ogg'); + } + + String get _currentFileFormat { + final lower = cleanFilePath.toLowerCase(); + if (lower.endsWith('.flac')) return 'FLAC'; + if (lower.endsWith('.mp3')) return 'MP3'; + if (lower.endsWith('.opus') || lower.endsWith('.ogg')) return 'Opus'; + return 'Unknown'; + } + + Map _buildFallbackMetadata() { + return { + 'TITLE': trackName, + 'ARTIST': artistName, + 'ALBUM': albumName, + if (albumArtist != null && albumArtist!.isNotEmpty) + 'ALBUMARTIST': albumArtist!, + if (trackNumber != null) 'TRACKNUMBER': trackNumber.toString(), + if (discNumber != null) 'DISCNUMBER': discNumber.toString(), + if (releaseDate != null && releaseDate!.isNotEmpty) 'DATE': releaseDate!, + if (isrc != null && isrc!.isNotEmpty) 'ISRC': isrc!, + if (genre != null && genre!.isNotEmpty) 'GENRE': genre!, + if (label != null && label!.isNotEmpty) 'LABEL': label!, + if (copyright != null && copyright!.isNotEmpty) 'COPYRIGHT': copyright!, + }; + } + + Map _mapMetadataForTagEmbed(Map source) { + final mapped = {}; + + void put(String key, dynamic value) { + final normalized = value?.toString().trim(); + if (normalized == null || normalized.isEmpty) return; + mapped[key] = normalized; + } + + put('TITLE', source['title']); + put('ARTIST', source['artist']); + put('ALBUM', source['album']); + put('ALBUMARTIST', source['album_artist']); + put('DATE', source['date']); + put('ISRC', source['isrc']); + put('GENRE', source['genre']); + put('ORGANIZATION', source['label']); + put('COPYRIGHT', source['copyright']); + put('COMPOSER', source['composer']); + put('COMMENT', source['comment']); + + final trackNumber = source['track_number']; + if (trackNumber != null && trackNumber.toString() != '0') { + put('TRACKNUMBER', trackNumber); + } + final discNumber = source['disc_number']; + if (discNumber != null && discNumber.toString() != '0') { + put('DISCNUMBER', discNumber); + } + + return mapped; + } + + String _buildConvertedQualityLabel(String targetFormat, String bitrate) { + final normalizedBitrate = bitrate.trim().toLowerCase(); + return '${targetFormat.toUpperCase()} $normalizedBitrate'; + } + + String? _extractLossyBitrateLabel(String? quality) { + if (quality == null || quality.isEmpty) return null; + final match = RegExp( + r'(\d+)\s*k(?:bps)?', + caseSensitive: false, + ).firstMatch(quality); + if (match == null) return null; + return '${match.group(1)}kbps'; + } + + String _extractFileNameFromPathOrUri(String pathOrUri) { + if (pathOrUri.isEmpty) return ''; + try { + if (pathOrUri.startsWith('content://')) { + final uri = Uri.parse(pathOrUri); + if (uri.pathSegments.isNotEmpty) { + var last = Uri.decodeComponent(uri.pathSegments.last); + if (last.contains('/')) { + last = last.split('/').last; + } + if (last.contains(':')) { + last = last.split(':').last; + } + if (last.isNotEmpty) return last; + } + } + } catch (_) {} + + final normalized = pathOrUri.replaceAll('\\', '/'); + if (normalized.contains('/')) { + return normalized.split('/').last; + } + return normalized; + } + + void _showConvertSheet(BuildContext context) { + final currentFormat = _currentFileFormat; + // Available target formats (exclude current) + final formats = [ + 'MP3', + 'Opus', + ].where((f) => f != currentFormat).toList(); + if (currentFormat == 'FLAC') { + // FLAC can convert to both + } + + String selectedFormat = formats.first; + String selectedBitrate = selectedFormat == 'Opus' ? '128k' : '320k'; + + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (sheetContext) { + return StatefulBuilder( + builder: (context, setSheetState) { + final colorScheme = Theme.of(context).colorScheme; + final bitrates = ['128k', '192k', '256k', '320k']; + + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues( + alpha: 0.4, + ), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 16), + Text( + context.l10n.trackConvertTitle, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 20), + + // Target format + Text( + context.l10n.trackConvertTargetFormat, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Row( + children: formats.map((format) { + final isSelected = format == selectedFormat; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: ChoiceChip( + label: Text(format), + selected: isSelected, + onSelected: (selected) { + if (selected) { + setSheetState(() { + selectedFormat = format; + // Reset bitrate to default for format + selectedBitrate = format == 'Opus' + ? '128k' + : '320k'; + }); + } + }, + ), + ); + }).toList(), + ), + const SizedBox(height: 16), + + // Bitrate + Text( + context.l10n.trackConvertBitrate, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: bitrates.map((br) { + final isSelected = br == selectedBitrate; + return ChoiceChip( + label: Text(br), + selected: isSelected, + onSelected: (selected) { + if (selected) { + setSheetState(() => selectedBitrate = br); + } + }, + ); + }).toList(), + ), + const SizedBox(height: 24), + + // Convert button + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: () { + Navigator.pop(context); + _confirmAndConvert( + context: this.context, + sourceFormat: currentFormat, + targetFormat: selectedFormat, + bitrate: selectedBitrate, + ); + }, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text( + '$currentFormat -> $selectedFormat @ $selectedBitrate', + ), + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ); + }, + ); + }, + ); + } + + void _confirmAndConvert({ + required BuildContext context, + required String sourceFormat, + required String targetFormat, + required String bitrate, + }) { + showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: Text(dialogContext.l10n.trackConvertConfirmTitle), + content: Text( + dialogContext.l10n.trackConvertConfirmMessage( + sourceFormat, + targetFormat, + bitrate, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(dialogContext.l10n.dialogCancel), + ), + FilledButton( + onPressed: () { + Navigator.pop(dialogContext); + _performConversion( + targetFormat: targetFormat, + bitrate: bitrate, + ); + }, + child: Text(dialogContext.l10n.trackConvertFormat), + ), + ], + ); + }, + ); + } + + Future _performConversion({ + required String targetFormat, + required String bitrate, + }) async { + if (_isConverting) return; + setState(() => _isConverting = true); + + try { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackConvertConverting)), + ); + + // Step 1: Read metadata from file (fallback to known item metadata). + final metadata = _buildFallbackMetadata(); + try { + final result = await PlatformBridge.readFileMetadata(cleanFilePath); + if (result['error'] == null) { + result.forEach((key, value) { + if (key == 'error' || value == null) return; + final normalizedValue = value.toString().trim(); + if (normalizedValue.isEmpty) return; + metadata[key.toUpperCase()] = normalizedValue; + }); + } else { + _log.w('readFileMetadata returned error, using fallback metadata'); + } + } catch (e) { + _log.w('readFileMetadata threw, using fallback metadata: $e'); + } + + // Step 2: Extract cover art to temp file + String? coverPath; + try { + final tempDir = await getTemporaryDirectory(); + final coverOutput = + '${tempDir.path}${Platform.pathSeparator}convert_cover_${DateTime.now().millisecondsSinceEpoch}.jpg'; + final coverResult = await PlatformBridge.extractCoverToFile( + cleanFilePath, + coverOutput, + ); + if (coverResult['error'] == null) { + coverPath = coverOutput; + } + } catch (_) {} + + // Step 3: Handle SAF vs regular file + String workingPath = cleanFilePath; + final isSaf = _isSafFile; + String? safTempPath; + + if (isSaf) { + // Copy SAF file to temp for processing + safTempPath = await PlatformBridge.copyContentUriToTemp(cleanFilePath); + if (safTempPath == null) { + if (mounted) { + setState(() => _isConverting = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackConvertFailed)), + ); + } + return; + } + workingPath = safTempPath; + } + + // Step 4: Convert + final newPath = await FFmpegService.convertAudioFormat( + inputPath: workingPath, + targetFormat: targetFormat.toLowerCase(), + bitrate: bitrate, + metadata: metadata, + coverPath: coverPath, + deleteOriginal: !isSaf, // Don't delete temp copy for SAF, we handle it + ); + + // Cleanup cover temp + if (coverPath != null) { + try { + await File(coverPath).delete(); + } catch (_) {} + } + + if (newPath == null) { + // Cleanup SAF temp if needed + if (safTempPath != null) { + try { + await File(safTempPath).delete(); + } catch (_) {} + } + if (mounted) { + setState(() => _isConverting = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackConvertFailed)), + ); + } + return; + } + + final newQuality = _buildConvertedQualityLabel(targetFormat, bitrate); + + // Step 5: Handle SAF write-back + if (isSaf) { + final treeUri = _downloadItem?.downloadTreeUri; + final relativeDir = _downloadItem?.safRelativeDir ?? ''; + if (treeUri == null || treeUri.isEmpty) { + try { + await File(newPath).delete(); + } catch (_) {} + if (safTempPath != null) { + try { + await File(safTempPath).delete(); + } catch (_) {} + } + if (mounted) { + setState(() => _isConverting = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackConvertFailed)), + ); + } + return; + } + + final oldFileName = + (_downloadItem?.safFileName != null && + _downloadItem!.safFileName!.isNotEmpty) + ? _downloadItem!.safFileName! + : _extractFileNameFromPathOrUri(cleanFilePath); + final dotIdx = oldFileName.lastIndexOf('.'); + final baseName = dotIdx > 0 + ? oldFileName.substring(0, dotIdx) + : oldFileName; + final newExt = targetFormat.toLowerCase() == 'opus' ? '.opus' : '.mp3'; + final newFileName = '$baseName$newExt'; + final mimeType = targetFormat.toLowerCase() == 'opus' + ? 'audio/opus' + : 'audio/mpeg'; + + final safUri = await PlatformBridge.createSafFileFromPath( + treeUri: treeUri, + relativeDir: relativeDir, + fileName: newFileName, + mimeType: mimeType, + srcPath: newPath, + ); + + if (safUri == null || safUri.isEmpty) { + try { + await File(newPath).delete(); + } catch (_) {} + if (safTempPath != null) { + try { + await File(safTempPath).delete(); + } catch (_) {} + } + if (mounted) { + setState(() => _isConverting = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackConvertFailed)), + ); + } + return; + } + + final deletedOriginal = await PlatformBridge.safDelete( + cleanFilePath, + ).catchError((_) => false); + if (deletedOriginal != true) { + _log.w('Converted SAF file created but failed deleting original URI'); + } + + // Update history with new SAF info + if (!_isLocalItem) { + await HistoryDatabase.instance.updateFilePath( + _downloadItem!.id, + safUri, + newSafFileName: newFileName, + newQuality: newQuality, + clearAudioSpecs: true, + ); + await ref.read(downloadHistoryProvider.notifier).reloadFromStorage(); + } + + // Cleanup temp files + try { + await File(newPath).delete(); + } catch (_) {} + if (safTempPath != null) { + try { + await File(safTempPath).delete(); + } catch (_) {} + } + } else { + // Regular file: update history with new path + if (!_isLocalItem) { + await HistoryDatabase.instance.updateFilePath( + _downloadItem!.id, + newPath, + newQuality: newQuality, + clearAudioSpecs: true, + ); + await ref.read(downloadHistoryProvider.notifier).reloadFromStorage(); + } + } + + if (mounted) { + setState(() => _isConverting = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(context.l10n.trackConvertSuccess(targetFormat)), + ), + ); + // Pop and let the caller refresh + Navigator.pop(context, true); + } + } catch (e) { + if (mounted) { + setState(() => _isConverting = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.trackSaveFailed(e.toString()))), + ); + } + } + } + + void _showEditMetadataSheet( + BuildContext context, + WidgetRef ref, + ColorScheme colorScheme, + ) async { // Read current metadata from file, fall back to item data on failure Map? fileMetadata; try { @@ -1657,8 +2492,10 @@ class _TrackMetadataScreenState extends ConsumerState { 'album': val('album', albumName), 'album_artist': val('album_artist', albumArtist), 'date': val('date', releaseDate), - 'track_number': (fileMetadata?['track_number'] ?? trackNumber ?? '').toString(), - 'disc_number': (fileMetadata?['disc_number'] ?? discNumber ?? '').toString(), + 'track_number': (fileMetadata?['track_number'] ?? trackNumber ?? '') + .toString(), + 'disc_number': (fileMetadata?['disc_number'] ?? discNumber ?? '') + .toString(), 'genre': val('genre', genre), 'isrc': val('isrc', isrc), 'label': val('label', label), @@ -1697,7 +2534,11 @@ class _TrackMetadataScreenState extends ConsumerState { } } - void _confirmDelete(BuildContext context, WidgetRef ref, ColorScheme colorScheme) { + void _confirmDelete( + BuildContext context, + WidgetRef ref, + ColorScheme colorScheme, + ) { showDialog( context: context, builder: (context) => AlertDialog( @@ -1726,16 +2567,21 @@ class _TrackMetadataScreenState extends ConsumerState { } catch (e) { debugPrint('Failed to delete file: $e'); } - - ref.read(downloadHistoryProvider.notifier).removeFromHistory(_downloadItem!.id); + + ref + .read(downloadHistoryProvider.notifier) + .removeFromHistory(_downloadItem!.id); } - + if (context.mounted) { Navigator.pop(context); Navigator.pop(context); } }, - child: Text(context.l10n.dialogDelete, style: TextStyle(color: colorScheme.error)), + child: Text( + context.l10n.dialogDelete, + style: TextStyle(color: colorScheme.error), + ), ), ], ), @@ -1748,7 +2594,9 @@ class _TrackMetadataScreenState extends ConsumerState { } catch (e) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.snackbarCannotOpenFile(e.toString()))), + SnackBar( + content: Text(context.l10n.snackbarCannotOpenFile(e.toString())), + ), ); } } @@ -1784,31 +2632,34 @@ class _TrackMetadataScreenState extends ConsumerState { } catch (_) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.snackbarCannotOpenFile('Failed to share file'))), + SnackBar( + content: Text( + context.l10n.snackbarCannotOpenFile('Failed to share file'), + ), + ), ); } } return; } - + await SharePlus.instance.share( - ShareParams( - files: [XFile(sharePath)], - text: shareTitle, - ), + ShareParams(files: [XFile(sharePath)], text: shareTitle), ); } String _formatFullDate(DateTime date) { return '${date.day} ${_months[date.month - 1]} ${date.year}, ' - '${date.hour.toString().padLeft(2, '0')}:' - '${date.minute.toString().padLeft(2, '0')}'; + '${date.hour.toString().padLeft(2, '0')}:' + '${date.minute.toString().padLeft(2, '0')}'; } String _formatFileSize(int bytes) { if (bytes < 1024) return '$bytes B'; if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; - if (bytes < 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + if (bytes < 1024 * 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB'; } @@ -1936,9 +2787,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { if (result['error'] != null) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('${result['error']}')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('${result['error']}'))); } setState(() => _saving = false); return; @@ -1958,30 +2809,58 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { final isOpus = lower.endsWith('.opus') || lower.endsWith('.ogg'); final vorbisMap = {}; - if (metadata['title']?.isNotEmpty == true) vorbisMap['TITLE'] = metadata['title']!; - if (metadata['artist']?.isNotEmpty == true) vorbisMap['ARTIST'] = metadata['artist']!; - if (metadata['album']?.isNotEmpty == true) vorbisMap['ALBUM'] = metadata['album']!; - if (metadata['album_artist']?.isNotEmpty == true) vorbisMap['ALBUMARTIST'] = metadata['album_artist']!; - if (metadata['date']?.isNotEmpty == true) vorbisMap['DATE'] = metadata['date']!; - if (metadata['track_number']?.isNotEmpty == true && metadata['track_number'] != '0') { + if (metadata['title']?.isNotEmpty == true) { + vorbisMap['TITLE'] = metadata['title']!; + } + if (metadata['artist']?.isNotEmpty == true) { + vorbisMap['ARTIST'] = metadata['artist']!; + } + if (metadata['album']?.isNotEmpty == true) { + vorbisMap['ALBUM'] = metadata['album']!; + } + if (metadata['album_artist']?.isNotEmpty == true) { + vorbisMap['ALBUMARTIST'] = metadata['album_artist']!; + } + if (metadata['date']?.isNotEmpty == true) { + vorbisMap['DATE'] = metadata['date']!; + } + if (metadata['track_number']?.isNotEmpty == true && + metadata['track_number'] != '0') { vorbisMap['TRACKNUMBER'] = metadata['track_number']!; } - if (metadata['disc_number']?.isNotEmpty == true && metadata['disc_number'] != '0') { + if (metadata['disc_number']?.isNotEmpty == true && + metadata['disc_number'] != '0') { vorbisMap['DISCNUMBER'] = metadata['disc_number']!; } - if (metadata['genre']?.isNotEmpty == true) vorbisMap['GENRE'] = metadata['genre']!; - if (metadata['isrc']?.isNotEmpty == true) vorbisMap['ISRC'] = metadata['isrc']!; - if (metadata['label']?.isNotEmpty == true) vorbisMap['ORGANIZATION'] = metadata['label']!; - if (metadata['copyright']?.isNotEmpty == true) vorbisMap['COPYRIGHT'] = metadata['copyright']!; - if (metadata['composer']?.isNotEmpty == true) vorbisMap['COMPOSER'] = metadata['composer']!; - if (metadata['comment']?.isNotEmpty == true) vorbisMap['COMMENT'] = metadata['comment']!; + if (metadata['genre']?.isNotEmpty == true) { + vorbisMap['GENRE'] = metadata['genre']!; + } + if (metadata['isrc']?.isNotEmpty == true) { + vorbisMap['ISRC'] = metadata['isrc']!; + } + if (metadata['label']?.isNotEmpty == true) { + vorbisMap['ORGANIZATION'] = metadata['label']!; + } + if (metadata['copyright']?.isNotEmpty == true) { + vorbisMap['COPYRIGHT'] = metadata['copyright']!; + } + if (metadata['composer']?.isNotEmpty == true) { + vorbisMap['COMPOSER'] = metadata['composer']!; + } + if (metadata['comment']?.isNotEmpty == true) { + vorbisMap['COMMENT'] = metadata['comment']!; + } // Extract existing cover art before re-embedding metadata String? existingCoverPath; try { final tempDir = await Directory.systemTemp.createTemp('cover_'); - final coverOutput = '${tempDir.path}${Platform.pathSeparator}cover.jpg'; - final coverResult = await PlatformBridge.extractCoverToFile(ffmpegTarget, coverOutput); + final coverOutput = + '${tempDir.path}${Platform.pathSeparator}cover.jpg'; + final coverResult = await PlatformBridge.extractCoverToFile( + ffmpegTarget, + coverOutput, + ); if (coverResult['error'] == null) { existingCoverPath = coverOutput; } @@ -2006,13 +2885,17 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { // Cleanup temp cover if (existingCoverPath != null) { - try { await File(existingCoverPath).delete(); } catch (_) {} + try { + await File(existingCoverPath).delete(); + } catch (_) {} } if (ffmpegResult == null) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Failed to save metadata via FFmpeg')), + const SnackBar( + content: Text('Failed to save metadata via FFmpeg'), + ), ); } setState(() => _saving = false); @@ -2024,7 +2907,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri); if (!ok && mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Failed to write metadata back to storage')), + const SnackBar( + content: Text('Failed to write metadata back to storage'), + ), ); setState(() => _saving = false); return; @@ -2037,9 +2922,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { } } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Failed to save metadata: $e')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Failed to save metadata: $e'))); } } finally { if (mounted) setState(() => _saving = false); @@ -2093,10 +2978,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { child: CircularProgressIndicator(strokeWidth: 2), ) else - FilledButton( - onPressed: _save, - child: const Text('Save'), - ), + FilledButton(onPressed: _save, child: const Text('Save')), ], ), ), @@ -2107,6 +2989,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { controller: scrollController, padding: const EdgeInsets.symmetric(horizontal: 24), children: [ + const SizedBox(height: 6), _field('Title', _titleCtrl), _field('Artist', _artistCtrl), _field('Album', _albumCtrl), @@ -2114,9 +2997,21 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { _field('Date', _dateCtrl, hint: 'YYYY-MM-DD or YYYY'), Row( children: [ - Expanded(child: _field('Track #', _trackNumCtrl, keyboard: TextInputType.number)), + Expanded( + child: _field( + 'Track #', + _trackNumCtrl, + keyboard: TextInputType.number, + ), + ), const SizedBox(width: 12), - Expanded(child: _field('Disc #', _discNumCtrl, keyboard: TextInputType.number)), + Expanded( + child: _field( + 'Disc #', + _discNumCtrl, + keyboard: TextInputType.number, + ), + ), ], ), _field('Genre', _genreCtrl), @@ -2125,23 +3020,25 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { Padding( padding: const EdgeInsets.only(top: 8, bottom: 4), child: InkWell( - onTap: () => setState(() => _showAdvanced = !_showAdvanced), + onTap: () => + setState(() => _showAdvanced = !_showAdvanced), borderRadius: BorderRadius.circular(8), child: Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Row( children: [ Icon( - _showAdvanced ? Icons.expand_less : Icons.expand_more, + _showAdvanced + ? Icons.expand_less + : Icons.expand_more, size: 20, color: cs.onSurfaceVariant, ), const SizedBox(width: 8), Text( 'Advanced', - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: cs.onSurfaceVariant, - ), + style: Theme.of(context).textTheme.labelLarge + ?.copyWith(color: cs.onSurfaceVariant), ), ], ), @@ -2185,17 +3082,24 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { fillColor: cs.surfaceContainerHighest.withValues(alpha: 0.5), border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: cs.outlineVariant.withValues(alpha: 0.5)), + borderSide: BorderSide( + color: cs.outlineVariant.withValues(alpha: 0.5), + ), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: cs.outlineVariant.withValues(alpha: 0.5)), + borderSide: BorderSide( + color: cs.outlineVariant.withValues(alpha: 0.5), + ), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: cs.primary, width: 2), ), - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), ), ), ); @@ -2205,6 +3109,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { class _MetadataItem { final String label; final String value; - + _MetadataItem(this.label, this.value); } diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index b7194bb1..81b511d2 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -56,6 +56,48 @@ class FFmpegService { return '$tempDirPath${Platform.pathSeparator}temp_embed_${timestamp}_${processId}_$_tempEmbedCounter$normalizedExt'; } + static List _buildDecryptionKeyCandidates(String rawKey) { + final candidates = []; + + 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 _execute(String command) async { try { final session = await FFmpegKit.execute(command); @@ -77,7 +119,7 @@ class FFmpegService { final outputPath = _buildOutputPath(inputPath, '.flac'); 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); @@ -133,6 +175,111 @@ class FFmpegService { return null; } + static Future 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 convertFlacToMp3( String inputPath, { 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 convertAudioFormat({ + required String inputPath, + required String targetFormat, + required String bitrate, + required Map 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 _convertToId3Tags( Map vorbisMetadata, ) { diff --git a/lib/services/history_database.dart b/lib/services/history_database.dart index 7cb314d4..1e9f91d7 100644 --- a/lib/services/history_database.dart +++ b/lib/services/history_database.dart @@ -17,21 +17,21 @@ String? _currentContainerPath; class HistoryDatabase { static final HistoryDatabase instance = HistoryDatabase._init(); static Database? _database; - + HistoryDatabase._init(); - + Future get database async { if (_database != null) return _database!; _database = await _initDB('history.db'); return _database!; } - + Future _initDB(String fileName) async { final dbPath = await getApplicationDocumentsDirectory(); final path = join(dbPath.path, fileName); - + _log.i('Initializing database at: $path'); - + return await openDatabase( path, version: 3, @@ -39,10 +39,10 @@ class HistoryDatabase { onUpgrade: _upgradeDB, ); } - + Future _createDB(Database db, int version) async { _log.i('Creating database schema v$version'); - + await db.execute(''' CREATE TABLE history ( id TEXT PRIMARY KEY, @@ -73,16 +73,20 @@ class HistoryDatabase { copyright TEXT ) '''); - + // Indexes for fast lookups 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_downloaded_at ON history(downloaded_at DESC)'); - await db.execute('CREATE INDEX idx_album ON history(album_name, album_artist)'); - + await db.execute( + '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'); } - + Future _upgradeDB(Database db, int oldVersion, int newVersion) async { _log.i('Upgrading database from v$oldVersion to v$newVersion'); if (oldVersion < 2) { @@ -95,20 +99,20 @@ class HistoryDatabase { await db.execute('ALTER TABLE history ADD COLUMN saf_repaired INTEGER'); } } - + // ==================== iOS Path Normalization ==================== - + /// Pattern to match iOS container paths /// Example: /var/mobile/Containers/Data/Application/UUID-HERE/Documents/... static final _iosContainerPattern = RegExp( r'/var/mobile/Containers/Data/Application/[A-F0-9\-]+/', caseSensitive: false, ); - + /// Initialize and cache the current iOS container path Future _initContainerPath() async { if (!Platform.isIOS || _currentContainerPath != null) return; - + try { final docDir = await getApplicationDocumentsDirectory(); // 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'); } } - + /// Normalize iOS file path by replacing old container UUID with current one /// This fixes the issue where iOS changes container UUID after app updates String _normalizeIosPath(String? filePath) { if (filePath == null || filePath.isEmpty) return filePath ?? ''; if (!Platform.isIOS || _currentContainerPath == null) return filePath; - + // Check if path contains an iOS container path if (_iosContainerPattern.hasMatch(filePath)) { - final normalized = filePath.replaceFirst(_iosContainerPattern, _currentContainerPath!); + final normalized = filePath.replaceFirst( + _iosContainerPattern, + _currentContainerPath!, + ); if (normalized != filePath) { _log.d('Normalized iOS path: $filePath -> $normalized'); } return normalized; } - + return filePath; } - + /// Migrate iOS paths in database to use current container UUID /// This is called once after app update if container changed Future migrateIosContainerPaths() async { if (!Platform.isIOS) return false; - + await _initContainerPath(); if (_currentContainerPath == null) return false; - + final prefs = await _prefs; final lastContainer = prefs.getString('ios_last_container_path'); - + if (lastContainer == _currentContainerPath) { _log.d('iOS container path unchanged, skipping migration'); return false; } - + _log.i('iOS container changed: $lastContainer -> $_currentContainerPath'); - + try { final db = await database; - + // Get all items with iOS paths final rows = await db.query('history', columns: ['id', 'file_path']); int updatedCount = 0; final batch = db.batch(); - + for (final row in rows) { final id = row['id'] as String; final oldPath = row['file_path'] as String?; - + if (oldPath != null && _iosContainerPattern.hasMatch(oldPath)) { final newPath = _normalizeIosPath(oldPath); if (newPath != oldPath) { @@ -184,14 +191,14 @@ class HistoryDatabase { } } } - + if (updatedCount > 0) { await batch.commit(noResult: true); } - + // Save current container path await prefs.setString('ios_last_container_path', _currentContainerPath!); - + _log.i('iOS path migration complete: $updatedCount paths updated'); return updatedCount > 0; } catch (e, stack) { @@ -199,32 +206,34 @@ class HistoryDatabase { return false; } } - + /// Migrate data from SharedPreferences to SQLite /// Returns true if migration was performed, false if already migrated Future migrateFromSharedPreferences() async { final prefs = await _prefs; final migrationKey = 'history_migrated_to_sqlite'; - + if (prefs.getBool(migrationKey) == true) { _log.d('Already migrated to SQLite'); return false; } - + final jsonStr = prefs.getString('download_history'); if (jsonStr == null || jsonStr.isEmpty) { _log.d('No SharedPreferences history to migrate'); await prefs.setBool(migrationKey, true); return false; } - + try { final List 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 batch = db.batch(); - + for (final json in jsonList) { final map = json as Map; batch.insert( @@ -233,20 +242,20 @@ class HistoryDatabase { conflictAlgorithm: ConflictAlgorithm.replace, ); } - + await batch.commit(noResult: true); - + // Mark as migrated but keep old data for safety await prefs.setBool(migrationKey, true); _log.i('Migration complete: ${jsonList.length} items'); - + return true; } catch (e, stack) { _log.e('Migration failed: $e', e, stack); return false; } } - + /// Convert JSON format (camelCase) to DB row (snake_case) Map _jsonToDbRow(Map json) { return { @@ -278,7 +287,7 @@ class HistoryDatabase { 'copyright': json['copyright'], }; } - + /// Convert DB row (snake_case) to JSON format (camelCase) /// Also normalizes iOS paths if container UUID changed Map _dbRowToJson(Map row) { @@ -311,9 +320,9 @@ class HistoryDatabase { 'copyright': row['copyright'], }; } - + // ==================== CRUD Operations ==================== - + /// Insert or update a history item Future upsert(Map json) async { final db = await database; @@ -323,7 +332,7 @@ class HistoryDatabase { conflictAlgorithm: ConflictAlgorithm.replace, ); } - + /// Get all history items ordered by download date (newest first) Future>> getAll({int? limit, int? offset}) async { final db = await database; @@ -335,7 +344,7 @@ class HistoryDatabase { ); return rows.map(_dbRowToJson).toList(); } - + /// Get item by ID Future?> getById(String id) async { final db = await database; @@ -348,7 +357,7 @@ class HistoryDatabase { if (rows.isEmpty) return null; return _dbRowToJson(rows.first); } - + /// Get item by Spotify ID - O(1) with index Future?> getBySpotifyId(String spotifyId) async { final db = await database; @@ -361,7 +370,7 @@ class HistoryDatabase { if (rows.isEmpty) return null; return _dbRowToJson(rows.first); } - + /// Get item by ISRC - O(1) with index Future?> getByIsrc(String isrc) async { final db = await database; @@ -374,7 +383,7 @@ class HistoryDatabase { if (rows.isEmpty) return null; return _dbRowToJson(rows.first); } - + /// Check if spotify_id exists - O(1) with index Future existsBySpotifyId(String spotifyId) async { final db = await database; @@ -384,42 +393,42 @@ class HistoryDatabase { ); return result.isNotEmpty; } - + /// Get all spotify_ids as Set for fast in-memory lookup Future> getAllSpotifyIds() async { final db = await database; 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(); } - + /// Delete by ID Future deleteById(String id) async { final db = await database; await db.delete('history', where: 'id = ?', whereArgs: [id]); } - + /// Delete by Spotify ID Future deleteBySpotifyId(String spotifyId) async { final db = await database; await db.delete('history', where: 'spotify_id = ?', whereArgs: [spotifyId]); } - + /// Clear all history Future clearAll() async { final db = await database; await db.delete('history'); _log.i('Cleared all history'); } - + /// Get total count Future getCount() async { final db = await database; final result = await db.rawQuery('SELECT COUNT(*) as count FROM history'); return Sqflite.firstIntValue(result) ?? 0; } - + /// Find existing item by spotify_id or isrc (for deduplication) Future?> findExisting({ String? spotifyId, @@ -428,7 +437,7 @@ class HistoryDatabase { if (spotifyId != null && spotifyId.isNotEmpty) { final bySpotify = await getBySpotifyId(spotifyId); if (bySpotify != null) return bySpotify; - + // Check for deezer: prefix matching if (spotifyId.startsWith('deezer:')) { final deezerId = spotifyId.substring(7); @@ -442,31 +451,63 @@ class HistoryDatabase { if (rows.isNotEmpty) return _dbRowToJson(rows.first); } } - + if (isrc != null && isrc.isNotEmpty) { return await getByIsrc(isrc); } - + return null; } - -/// Close database + + /// Close database Future close() async { final db = await database; await db.close(); _database = null; } - + + /// Update file path for a history entry (e.g. after format conversion) + Future updateFilePath( + String id, + String newFilePath, { + String? newSafFileName, + String? newQuality, + int? newBitDepth, + int? newSampleRate, + bool clearAudioSpecs = false, + }) async { + final db = await database; + final values = {'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 /// Used to exclude downloaded files from local library scan Future> getAllFilePaths() async { final db = await database; 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(); } - + /// 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) Future>> getAllEntriesWithPaths() async { @@ -478,11 +519,11 @@ class HistoryDatabase { '''); return rows.map((r) => Map.from(r)).toList(); } - + /// Delete multiple entries by IDs Future deleteByIds(List ids) async { if (ids.isEmpty) return 0; - + final db = await database; final placeholders = List.filled(ids.length, '?').join(','); final count = await db.rawDelete( diff --git a/lib/widgets/download_service_picker.dart b/lib/widgets/download_service_picker.dart index f87713ab..d87386b2 100644 --- a/lib/widgets/download_service_picker.dart +++ b/lib/widgets/download_service_picker.dart @@ -22,8 +22,7 @@ class BuiltInService { }); } -/// Default quality options for built-in services (Tidal, Qobuz, YouTube) -/// Note: Amazon is fallback-only and not shown in picker +/// Default quality options for built-in services (Tidal, Qobuz, Amazon, YouTube) /// Note: Tidal lossy (HIGH) removed - use YouTube for lossy downloads const _builtInServices = [ BuiltInService( @@ -44,6 +43,17 @@ const _builtInServices = [ 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( id: 'youtube', label: 'YouTube', diff --git a/pubspec.yaml b/pubspec.yaml index c7ce0a98..1af38e5f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: "none" -version: 3.6.1+78 +version: 3.6.5+79 environment: sdk: ^3.10.0