diff --git a/CHANGELOG.md b/CHANGELOG.md index 25d41bf0..49976882 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## [3.7.2] - 2026-03-07 + +### Changed + +- **Amazon Music is now an Extension**: Amazon Music has been moved from a built-in service to a separate installable extension. Install the "Amazon Music" extension from the Store to continue using it. + +### Fixed + +- **Deezer Downloads Timing Out**: Deezer downloads were failing with "context deadline exceeded" on larger files. Now uses the proper download timeout, matching Tidal and Qobuz. + +### Added + +- **Amazon Music Extension**: Available in `extension/Amazon-SpotiFLAC/` — same functionality as before, now as an installable extension. + +--- + ## [3.7.1] - 2026-03-06 ### Added diff --git a/README.md b/README.md index 83669b33..ae6db959 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ -Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music — no account required. +Download music in true lossless FLAC from Tidal, Qobuz & Deezer — no account required. ![Android](https://img.shields.io/badge/Android-7.0%2B-3DDC84?style=for-the-badge&logo=android&logoColor=white) ![iOS](https://img.shields.io/badge/iOS-14.0%2B-000000?style=for-the-badge&logo=apple&logoColor=white) @@ -51,10 +51,10 @@ Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music for Window ## FAQ **Q: Why is my download failing with "Song not found"?** -A: The track may not be available on Tidal, Qobuz, or Amazon Music. Try enabling more download services in Settings > Download > Provider Priority, or install additional extensions from the Store. +A: The track may not be available on the streaming services. Try enabling more download services in Settings > Download > Provider Priority, or install additional extensions like Amazon Music from the Store. **Q: Why are some tracks downloading in lower quality?** -A: Quality depends on what's available from the streaming service. Tidal offers up to 24-bit/192kHz, Qobuz up to 24-bit/192kHz, and Amazon up to 24-bit/48kHz. The app automatically selects the best available quality. +A: Quality depends on what's available from the streaming service and extensions. Built-in providers: Tidal offers up to 24-bit/192kHz, Qobuz up to 24-bit/192kHz, and Deezer up to 16-bit/44.1kHz. **Q: Can I download playlists?** A: Yes! Just paste the playlist URL in the search bar. The app will fetch all tracks and queue them for download. @@ -75,23 +75,6 @@ _If this software is useful and brings you value, consider supporting the projec [![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/zarzet) - -## Disclaimer - -This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement. - -**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Tidal, Qobuz, Amazon Music, Deezer, or any other streaming service. - -The application is purely a user interface that facilitates communication between your device and existing third-party services. - -You are solely responsible for: -1. Ensuring your use of this software complies with your local laws. -2. Reading and adhering to the Terms of Service of the respective platforms. -3. Any legal consequences resulting from the misuse of this tool. - -The software is provided "as is", without warranty of any kind. The author assumes no liability for any bans, damages, or legal issues arising from its use. - - ## API Credits [hifi-api](https://github.com/binimum/hifi-api) · [music.binimum.org](https://music.binimum.org) · [qqdl.site](https://qqdl.site) · [squid.wtf](https://squid.wtf) · [spotisaver.net](https://spotisaver.net) · [dabmusic.xyz](https://dabmusic.xyz) · [AfkarXYZ](https://github.com/afkarxyz) · [LRCLib](https://lrclib.net) · [Paxsenix](https://lyrics.paxsenix.org) · [Cobalt](https://cobalt.tools) · [qwkuns.me](https://qwkuns.me) · [SpotubeDL](https://spotubedl.com) · [Song.link](https://song.link) · [IDHS](https://github.com/sjdonado/idonthavespotify) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index c30a1775..da56220d 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -766,6 +766,27 @@ class MainActivity: FlutterFragmentActivity() { val response = downloader(req.toString()) val respObj = JSONObject(response) if (respObj.optBoolean("success", false)) { + // Extension providers write to a local temp path instead of the SAF FD. + // Copy the local file into the SAF document so it is not empty. + val goFilePath = respObj.optString("file_path", "") + if (goFilePath.isNotEmpty() && + !goFilePath.startsWith("content://") && + !goFilePath.startsWith("/proc/self/fd/") + ) { + try { + val srcFile = java.io.File(goFilePath) + if (srcFile.exists() && srcFile.length() > 0) { + contentResolver.openOutputStream(document.uri, "wt")?.use { output -> + srcFile.inputStream().use { input -> + input.copyTo(output) + } + } + srcFile.delete() + } + } catch (e: Exception) { + android.util.Log.w("SpotiFLAC", "Failed to copy extension output to SAF: ${e.message}") + } + } respObj.put("file_path", document.uri.toString()) respObj.put("file_name", document.name ?: fileName) } else { @@ -2239,13 +2260,6 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } - "getAmazonURLFromDeezerTrack" -> { - val deezerTrackId = call.argument("deezer_track_id") ?: "" - val response = withContext(Dispatchers.IO) { - Gobackend.getAmazonURLFromDeezerTrack(deezerTrackId) - } - result.success(response) - } // Log methods "getLogs" -> { val response = withContext(Dispatchers.IO) { diff --git a/go_backend/amazon.go b/go_backend/amazon.go deleted file mode 100644 index b30d669d..00000000 --- a/go_backend/amazon.go +++ /dev/null @@ -1,692 +0,0 @@ -package gobackend - -import ( - "bufio" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "os" - "path/filepath" - "regexp" - "strings" - "sync" - "time" -) - -// Amazon API timeout and retry configuration for mobile networks -const ( - amazonAPITimeoutMobile = 30 * time.Second // Longer timeout for unstable mobile networks - amazonMaxRetries = 2 // Number of retry attempts - amazonRetryDelay = 500 * time.Millisecond -) - -type AmazonDownloader struct { - client *http.Client -} - -var ( - globalAmazonDownloader *AmazonDownloader - amazonDownloaderOnce sync.Once - amazonASINRegex = regexp.MustCompile(`(?i)^B[0-9A-Z]{9}$`) - amazonASINFindRegex = regexp.MustCompile(`(?i)B[0-9A-Z]{9}`) -) - -// AfkarXYZResponse is the response from AfkarXYZ API -type AfkarXYZResponse struct { - Success bool `json:"success"` - Data struct { - DirectLink string `json:"direct_link"` - FileName string `json:"file_name"` - FileSize int64 `json:"file_size"` - } `json:"data"` -} - -// AmazonStreamResponse is the new response format from amzn.afkarxyz.fun/api/track/{asin} -type AmazonStreamResponse struct { - StreamURL string `json:"streamUrl"` - DecryptionKey string `json:"decryptionKey"` -} - -func NewAmazonDownloader() *AmazonDownloader { - amazonDownloaderOnce.Do(func() { - globalAmazonDownloader = &AmazonDownloader{ - client: NewHTTPClientWithTimeout(120 * time.Second), - } - }) - return globalAmazonDownloader -} - -// fetchAmazonURLWithRetry fetches from AfkarXYZ API with retry logic for mobile networks. -// Returns downloadURL, suggested fileName, optional decryptionKey. -func (a *AmazonDownloader) fetchAmazonURLWithRetry(amazonURL string) (string, string, string, error) { - var lastErr error - for attempt := 0; attempt <= amazonMaxRetries; attempt++ { - if attempt > 0 { - delay := amazonRetryDelay * time.Duration(1<<(attempt-1)) // Exponential backoff - GoLog("[Amazon] Retry %d/%d after %v...\n", attempt, amazonMaxRetries, delay) - time.Sleep(delay) - } - - downloadURL, fileName, decryptionKey, err := a.doAfkarXYZRequest(amazonURL) - if err == nil { - return downloadURL, fileName, decryptionKey, nil - } - - lastErr = err - errStr := strings.ToLower(err.Error()) - - // Check if error is retryable - isRetryable := strings.Contains(errStr, "timeout") || - strings.Contains(errStr, "connection reset") || - strings.Contains(errStr, "connection refused") || - strings.Contains(errStr, "eof") || - strings.Contains(errStr, "status 5") || - strings.Contains(errStr, "status 429") || - strings.Contains(errStr, "http 429") - - if !isRetryable { - return "", "", "", err - } - - GoLog("[Amazon] Attempt %d failed (retryable): %v\n", attempt+1, err) - } - - return "", "", "", fmt.Errorf("all %d attempts failed: %w", amazonMaxRetries+1, lastErr) -} - -func normalizeAmazonASIN(candidate string) string { - trimmed := strings.TrimSpace(candidate) - if trimmed == "" { - return "" - } - - if decoded, err := url.QueryUnescape(trimmed); err == nil { - trimmed = decoded - } - - trimmed = strings.ToUpper(trimmed) - if idx := strings.IndexAny(trimmed, "?#&/"); idx >= 0 { - trimmed = trimmed[:idx] - } - - if amazonASINRegex.MatchString(trimmed) { - return trimmed - } - - return "" -} - -func extractAmazonASIN(amazonURL string) string { - raw := strings.TrimSpace(amazonURL) - if raw == "" { - return "" - } - - parsed, err := url.Parse(raw) - if err == nil { - query := parsed.Query() - - // Prefer track-level ASIN when URL also contains albumAsin. - for _, key := range []string{"trackAsin", "trackasin", "trackASIN", "asin", "ASIN", "i"} { - if asin := normalizeAmazonASIN(query.Get(key)); asin != "" { - return asin - } - } - - path := strings.Trim(parsed.Path, "/") - if path != "" { - segments := strings.Split(path, "/") - - for i := 0; i < len(segments)-1; i++ { - segment := strings.ToLower(strings.TrimSpace(segments[i])) - if segment == "track" || segment == "tracks" { - if asin := normalizeAmazonASIN(segments[i+1]); asin != "" { - return asin - } - } - } - - if asin := normalizeAmazonASIN(segments[len(segments)-1]); asin != "" { - return asin - } - } - } - - match := amazonASINFindRegex.FindString(strings.ToUpper(raw)) - return normalizeAmazonASIN(match) -} - -// doAfkarXYZRequest performs a single request to Amazon API. -// It tries new endpoint first, then falls back to legacy /convert endpoint. -func (a *AmazonDownloader) doAfkarXYZRequest(amazonURL string) (string, string, string, error) { - asin := extractAmazonASIN(amazonURL) - if asin != "" { - GoLog("[Amazon] Using ASIN: %s\n", asin) - downloadURL, fileName, decryptKey, err := a.doAfkarXYZRequestNew(asin) - if err == nil { - return downloadURL, fileName, decryptKey, nil - } - GoLog("[Amazon] New API failed for ASIN %s, trying legacy endpoint: %v\n", asin, err) - } - return a.doAfkarXYZRequestLegacy(amazonURL) -} - -func (a *AmazonDownloader) doAfkarXYZRequestNew(asin string) (string, string, string, error) { - ctx, cancel := context.WithTimeout(context.Background(), amazonAPITimeoutMobile) - defer cancel() - - apiURL := fmt.Sprintf("https://amzn.afkarxyz.fun/api/track/%s", asin) - req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) - if err != nil { - return "", "", "", fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36") - - resp, err := a.client.Do(req) - if err != nil { - return "", "", "", fmt.Errorf("failed to call Amazon API: %w", err) - } - defer resp.Body.Close() - - body, readErr := io.ReadAll(resp.Body) - if readErr != nil { - return "", "", "", fmt.Errorf("failed to read response: %w", readErr) - } - - if resp.StatusCode != 200 { - return "", "", "", fmt.Errorf("Amazon API returned status %d", resp.StatusCode) - } - - 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://amzn.afkarxyz.fun/convert?url=" + url.QueryEscape(amazonURL) - req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) - if err != nil { - return "", "", "", fmt.Errorf("failed to create legacy request: %w", err) - } - - req.Header.Set("User-Agent", getRandomUserAgent()) - - resp, err := a.client.Do(req) - if err != nil { - return "", "", "", fmt.Errorf("failed to call legacy AfkarXYZ API: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return "", "", "", fmt.Errorf("legacy AfkarXYZ API returned status %d", resp.StatusCode) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", "", "", fmt.Errorf("failed to read legacy response: %w", err) - } - - var apiResp AfkarXYZResponse - if err := json.Unmarshal(body, &apiResp); err != nil { - return "", "", "", fmt.Errorf("failed to decode legacy response: %w", err) - } - - if !apiResp.Success || strings.TrimSpace(apiResp.Data.DirectLink) == "" { - return "", "", "", fmt.Errorf("legacy AfkarXYZ API failed or no download link found") - } - - fileName := apiResp.Data.FileName - if fileName == "" { - fileName = "track.flac" - } - - reg := regexp.MustCompile(`[<>:"/\\|?*]`) - fileName = reg.ReplaceAllString(fileName, "") - - return apiResp.Data.DirectLink, fileName, "", nil -} - -func (a *AmazonDownloader) downloadFromAfkarXYZ(amazonURL string) (string, string, string, error) { - GoLog("[Amazon] Fetching from AfkarXYZ API...\n") - - downloadURL, fileName, decryptionKey, err := a.fetchAmazonURLWithRetry(amazonURL) - if err != nil { - return "", "", "", err - } - - if decryptionKey != "" { - GoLog("[Amazon] AfkarXYZ returned encrypted stream (decryption key available)\n") - } - GoLog("[Amazon] AfkarXYZ returned: %s\n", fileName) - return downloadURL, fileName, decryptionKey, nil -} - -func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error { - ctx := context.Background() - - if itemID != "" { - StartItemProgress(itemID) - defer CompleteItemProgress(itemID) - ctx = initDownloadCancel(itemID) - defer clearDownloadCancel(itemID) - } - - if isDownloadCancelled(itemID) { - return ErrDownloadCancelled - } - - req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("User-Agent", getRandomUserAgent()) - - resp, err := a.client.Do(req) - if err != nil { - if isDownloadCancelled(itemID) { - return ErrDownloadCancelled - } - return err - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return fmt.Errorf("download failed: HTTP %d", resp.StatusCode) - } - - expectedSize := resp.ContentLength - if expectedSize > 0 && itemID != "" { - SetItemBytesTotal(itemID, expectedSize) - } - - out, err := openOutputForWrite(outputPath, outputFD) - if err != nil { - return err - } - - bufWriter := bufio.NewWriterSize(out, 256*1024) - - var written int64 - if itemID != "" { - pw := NewItemProgressWriter(bufWriter, itemID) - written, err = io.Copy(pw, resp.Body) - } else { - written, err = io.Copy(bufWriter, resp.Body) - } - - flushErr := bufWriter.Flush() - closeErr := out.Close() - - if err != nil { - cleanupOutputOnError(outputPath, outputFD) - if isDownloadCancelled(itemID) { - return ErrDownloadCancelled - } - return fmt.Errorf("download interrupted: %w", err) - } - if flushErr != nil { - cleanupOutputOnError(outputPath, outputFD) - return fmt.Errorf("failed to flush buffer: %w", flushErr) - } - if closeErr != nil { - cleanupOutputOnError(outputPath, outputFD) - return fmt.Errorf("failed to close file: %w", closeErr) - } - - if expectedSize > 0 && written != expectedSize { - cleanupOutputOnError(outputPath, outputFD) - return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written) - } - - GoLog("[Amazon] Downloaded: %.2f MB (Complete)\n", float64(written)/(1024*1024)) - return nil -} - -// AmazonDownloadResult contains download result with quality info -type AmazonDownloadResult struct { - FilePath string - BitDepth int - SampleRate int - Title string - Artist string - Album string - ReleaseDate string - TrackNumber int - DiscNumber int - ISRC string - LyricsLRC string - DecryptionKey string -} - -func resolveAmazonURLForRequest(req DownloadRequest, logPrefix string) (string, error) { - if strings.TrimSpace(logPrefix) == "" { - logPrefix = "Amazon" - } - - amazonURL := "" - if req.ISRC != "" { - if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.AmazonURL != "" { - amazonURL = cached.AmazonURL - GoLog("[%s] Cache hit! Using cached Amazon URL for ISRC %s\n", logPrefix, req.ISRC) - } - } - - if amazonURL != "" { - return amazonURL, nil - } - - songlink := NewSongLinkClient() - var availability *TrackAvailability - var err error - - deezerID := strings.TrimSpace(req.DeezerID) - if prefixedDeezerID, found := strings.CutPrefix(req.SpotifyID, "deezer:"); found && strings.TrimSpace(prefixedDeezerID) != "" { - deezerID = strings.TrimSpace(prefixedDeezerID) - } - - if deezerID != "" { - GoLog("[%s] Using Deezer ID for SongLink lookup: %s\n", logPrefix, deezerID) - availability, err = songlink.CheckAvailabilityFromDeezer(deezerID) - } else if req.SpotifyID != "" { - availability, err = songlink.CheckTrackAvailability(req.SpotifyID, req.ISRC) - } else { - return "", fmt.Errorf("no valid Spotify or Deezer ID provided for Amazon lookup") - } - - if err != nil { - return "", fmt.Errorf("failed to check Amazon availability via SongLink: %w", err) - } - - if availability == nil || !availability.Amazon || availability.AmazonURL == "" { - return "", fmt.Errorf("track not available on Amazon Music (SongLink returned no Amazon URL)") - } - - amazonURL = availability.AmazonURL - if req.ISRC != "" { - GetTrackIDCache().SetAmazonURL(req.ISRC, amazonURL) - } - - return amazonURL, nil -} - -func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { - downloader := NewAmazonDownloader() - - isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != "" - if !isSafOutput { - if existingFile, exists := checkISRCExistsInternal(req.OutputDir, req.ISRC); exists { - return AmazonDownloadResult{FilePath: "EXISTS:" + existingFile}, nil - } - } - - amazonURL, err := resolveAmazonURLForRequest(req, "Amazon") - if err != nil { - return AmazonDownloadResult{}, err - } - - if !isSafOutput && req.OutputDir != "." { - if err := os.MkdirAll(req.OutputDir, 0755); err != nil { - return AmazonDownloadResult{}, fmt.Errorf("failed to create output directory: %w", err) - } - } - - // Download using AfkarXYZ API - downloadURL, afkarFileName, decryptionKey, err := downloader.downloadFromAfkarXYZ(amazonURL) - if err != nil { - return AmazonDownloadResult{}, fmt.Errorf("failed to get download URL from AfkarXYZ: %w", err) - } - - GoLog("[Amazon] Match found: '%s' by '%s'\n", req.TrackName, req.ArtistName) - - filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]any{ - "title": req.TrackName, - "artist": req.ArtistName, - "album": req.AlbumName, - "track": req.TrackNumber, - "year": extractYear(req.ReleaseDate), - "date": req.ReleaseDate, - "disc": req.DiscNumber, - }) - var outputPath string - if isSafOutput { - outputPath = strings.TrimSpace(req.OutputPath) - if outputPath == "" && isFDOutput(req.OutputFD) { - outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD) - } - } else { - outputExt := strings.ToLower(filepath.Ext(afkarFileName)) - if outputExt == "" { - outputExt = ".flac" - } - filename = sanitizeFilename(filename) + outputExt - outputPath = filepath.Join(req.OutputDir, filename) - if fileInfo, statErr := os.Stat(outputPath); statErr == nil && fileInfo.Size() > 0 { - return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil - } - } - - // START PARALLEL: Fetch cover and lyrics while downloading audio - var parallelResult *ParallelDownloadResult - parallelDone := make(chan struct{}) - go func() { - defer close(parallelDone) - coverURL := req.CoverURL - embedLyrics := req.EmbedLyrics - if !req.EmbedMetadata { - coverURL = "" - embedLyrics = false - } - parallelResult = FetchCoverAndLyricsParallel( - coverURL, - req.EmbedMaxQualityCover, - req.SpotifyID, - req.TrackName, - req.ArtistName, - embedLyrics, - int64(req.DurationMS), - ) - }() - - // Download audio file with item ID for progress tracking - if err := downloader.DownloadFile(downloadURL, outputPath, req.OutputFD, req.ItemID); err != nil { - if errors.Is(err, ErrDownloadCancelled) { - return AmazonDownloadResult{}, ErrDownloadCancelled - } - return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err) - } - - actualOutputPath := outputPath - needsDecryption := strings.TrimSpace(decryptionKey) != "" - if needsDecryption { - GoLog("[Amazon] Download requires decryption; deferring decrypt to Flutter FFmpeg path\n") - } - - // Wait for parallel operations to complete - <-parallelDone - - if req.ItemID != "" { - SetItemProgress(req.ItemID, 1.0, 0, 0) - SetItemFinalizing(req.ItemID) - } - - actualTrackNum := req.TrackNumber - actualDiscNum := req.DiscNumber - actualDate := req.ReleaseDate - actualAlbum := req.AlbumName - actualTitle := req.TrackName - actualArtist := req.ArtistName - - if !needsDecryption { - existingMeta, metaErr := ReadMetadata(actualOutputPath) - if metaErr == nil && existingMeta != nil { - if existingMeta.TrackNumber > 0 && (req.TrackNumber == 0 || req.TrackNumber == 1) { - actualTrackNum = existingMeta.TrackNumber - GoLog("[Amazon] Using track number from file: %d (request had: %d)\n", actualTrackNum, req.TrackNumber) - } - if existingMeta.DiscNumber > 0 && (req.DiscNumber == 0 || req.DiscNumber == 1) { - actualDiscNum = existingMeta.DiscNumber - GoLog("[Amazon] Using disc number from file: %d (request had: %d)\n", actualDiscNum, req.DiscNumber) - } - if existingMeta.Date != "" && req.ReleaseDate == "" { - actualDate = existingMeta.Date - GoLog("[Amazon] Using release date from file: %s\n", actualDate) - } - if existingMeta.Album != "" && req.AlbumName == "" { - actualAlbum = existingMeta.Album - GoLog("[Amazon] Using album from file: %s\n", actualAlbum) - } - GoLog("[Amazon] Existing metadata - Title: %s, Artist: %s, Album: %s, Date: %s\n", - existingMeta.Title, existingMeta.Artist, existingMeta.Album, existingMeta.Date) - } - } - - metadata := Metadata{ - Title: actualTitle, - Artist: actualArtist, - Album: actualAlbum, - AlbumArtist: req.AlbumArtist, - Date: actualDate, - TrackNumber: actualTrackNum, - TotalTracks: req.TotalTracks, - DiscNumber: actualDiscNum, - ISRC: req.ISRC, - Genre: req.Genre, - Label: req.Label, - Copyright: req.Copyright, - } - - var coverData []byte - if parallelResult != nil && parallelResult.CoverData != nil && len(parallelResult.CoverData) > 0 { - coverData = parallelResult.CoverData - GoLog("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData)) - } else { - existingCover, coverErr := ExtractCoverArt(actualOutputPath) - if coverErr == nil && len(existingCover) > 0 { - coverData = existingCover - GoLog("[Amazon] Using existing cover from Amazon file (%d bytes)\n", len(coverData)) - } else { - GoLog("[Amazon] No cover available (parallel fetch failed and no existing cover)\n") - } - } - - if isSafOutput || needsDecryption || !req.EmbedMetadata { - if !req.EmbedMetadata { - GoLog("[Amazon] Metadata embedding disabled by settings, skipping in-backend metadata/lyrics embedding\n") - } else { - GoLog("[Amazon] SAF output detected - skipping in-backend metadata/lyrics embedding (handled in Flutter)\n") - } - } else { - isFlacOutput := strings.HasSuffix(strings.ToLower(actualOutputPath), ".flac") - if isFlacOutput { - if err := EmbedMetadataWithCoverData(actualOutputPath, metadata, coverData); err != nil { - GoLog("[Amazon] Warning: failed to embed metadata: %v\n", err) - } - } else { - GoLog("[Amazon] Non-FLAC output detected (%s), skipping native metadata embedding\n", filepath.Ext(actualOutputPath)) - } - - if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { - lyricsMode := req.LyricsMode - if lyricsMode == "" { - lyricsMode = "embed" - } - - if lyricsMode == "external" || lyricsMode == "both" { - GoLog("[Amazon] Saving external LRC file...\n") - if lrcPath, lrcErr := SaveLRCFile(actualOutputPath, parallelResult.LyricsLRC); lrcErr != nil { - GoLog("[Amazon] Warning: failed to save LRC file: %v\n", lrcErr) - } else { - GoLog("[Amazon] LRC file saved: %s\n", lrcPath) - } - } - - if (lyricsMode == "embed" || lyricsMode == "both") && isFlacOutput { - GoLog("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines)) - if embedErr := EmbedLyrics(actualOutputPath, parallelResult.LyricsLRC); embedErr != nil { - GoLog("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr) - } else { - GoLog("[Amazon] Lyrics embedded successfully\n") - } - } else if (lyricsMode == "embed" || lyricsMode == "both") && !isFlacOutput { - GoLog("[Amazon] Skipping embedded lyrics for non-FLAC output\n") - } - } else if req.EmbedLyrics { - GoLog("[Amazon] No lyrics available from parallel fetch\n") - } - } - - GoLog("[Amazon] Downloaded successfully from Amazon Music\n") - - quality := AudioQuality{} - if isSafOutput || needsDecryption { - GoLog("[Amazon] SAF output detected - skipping post-write file inspection in backend\n") - } else { - quality, err = GetAudioQuality(actualOutputPath) - if err != nil { - GoLog("[Amazon] Warning: couldn't read quality from file: %v\n", err) - } else { - GoLog("[Amazon] Actual quality: %d-bit/%dHz\n", quality.BitDepth, quality.SampleRate) - } - - finalMeta, metaReadErr := ReadMetadata(actualOutputPath) - if metaReadErr == nil && finalMeta != nil { - GoLog("[Amazon] Final metadata from file - Track: %d, Disc: %d, Date: %s\n", - finalMeta.TrackNumber, finalMeta.DiscNumber, finalMeta.Date) - actualTrackNum = finalMeta.TrackNumber - actualDiscNum = finalMeta.DiscNumber - if finalMeta.Date != "" { - req.ReleaseDate = finalMeta.Date - } - } - } - - // Add to ISRC index for fast duplicate checking. - // When decryption is pending in Flutter, postpone indexing until final file is settled. - if !isSafOutput && !needsDecryption { - AddToISRCIndex(req.OutputDir, req.ISRC, actualOutputPath) - } - - bitDepth := 0 - sampleRate := 0 - if err == nil { - bitDepth = quality.BitDepth - sampleRate = quality.SampleRate - } - - lyricsLRC := "" - if req.EmbedMetadata && req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" { - lyricsLRC = parallelResult.LyricsLRC - } - - return AmazonDownloadResult{ - FilePath: outputPath, - BitDepth: bitDepth, - SampleRate: sampleRate, - Title: req.TrackName, - Artist: req.ArtistName, - Album: req.AlbumName, - ReleaseDate: req.ReleaseDate, - TrackNumber: actualTrackNum, - DiscNumber: actualDiscNum, - ISRC: req.ISRC, - LyricsLRC: lyricsLRC, - DecryptionKey: decryptionKey, - }, nil -} diff --git a/go_backend/amazon_asin_test.go b/go_backend/amazon_asin_test.go deleted file mode 100644 index 705e9c01..00000000 --- a/go_backend/amazon_asin_test.go +++ /dev/null @@ -1,46 +0,0 @@ -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 4ccfc627..faf4ccf2 100644 --- a/go_backend/audio_metadata.go +++ b/go_backend/audio_metadata.go @@ -12,7 +12,6 @@ import ( "strings" ) -// AudioMetadata represents common audio file metadata type AudioMetadata struct { Title string Artist string @@ -31,7 +30,6 @@ type AudioMetadata struct { Comment string } -// MP3Quality represents MP3 specific quality info type MP3Quality struct { SampleRate int BitDepth int @@ -39,7 +37,6 @@ type MP3Quality struct { Bitrate int } -// OggQuality represents Ogg/Opus specific quality info type OggQuality struct { SampleRate int BitDepth int @@ -47,10 +44,6 @@ type OggQuality struct { Bitrate int // estimated bitrate in bps } -// ============================================================================= -// ID3 Tag Reading (MP3) -// ============================================================================= - func ReadID3Tags(filePath string) (*AudioMetadata, error) { file, err := os.Open(filePath) if err != nil { @@ -1210,10 +1203,6 @@ func readLastOggGranulePosition(file *os.File, fileSize int64) int64 { return 0 } -// ============================================================================= -// ID3v1 Genre List -// ============================================================================= - var id3v1Genres = []string{ "Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge", "Hip-Hop", "Jazz", "Metal", "New Age", "Oldies", "Other", "Pop", "R&B", @@ -1244,10 +1233,6 @@ var id3v1Genres = []string{ "Thrash Metal", "Anime", "J-Pop", "Synthpop", } -// ============================================================================= -// Cover Art Extraction -// ============================================================================= - func extractMP3CoverArt(filePath string) ([]byte, string, error) { file, err := os.Open(filePath) if err != nil { diff --git a/go_backend/deezer_download.go b/go_backend/deezer_download.go index 0c64b658..01e5c654 100644 --- a/go_backend/deezer_download.go +++ b/go_backend/deezer_download.go @@ -120,7 +120,7 @@ func (c *DeezerClient) DownloadFromYoinkify(spotifyURL, outputPath string, outpu req.Header.Set("Accept", "*/*") req.Header.Set("User-Agent", getRandomUserAgent()) - resp, err := c.httpClient.Do(req) + resp, err := GetDownloadClient().Do(req) if err != nil { if isDownloadCancelled(itemID) { return ErrDownloadCancelled @@ -324,7 +324,7 @@ func (c *DeezerClient) DownloadFromMusicDL(deezerTrackURL, outputPath string, ou } req.Header.Set("User-Agent", getRandomUserAgent()) - resp, err := c.httpClient.Do(req) + resp, err := GetDownloadClient().Do(req) if err != nil { if isDownloadCancelled(itemID) { return ErrDownloadCancelled diff --git a/go_backend/exports.go b/go_backend/exports.go index 3c05a0fd..72edd394 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -478,25 +478,6 @@ func DownloadTrack(requestJSON string) (string, error) { } } err = qobuzErr - case "amazon": - 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, - DecryptionKey: amazonResult.DecryptionKey, - } - } - err = amazonErr case "deezer": deezerResult, deezerErr := downloadFromDeezer(req) if deezerErr == nil { @@ -640,7 +621,7 @@ func DownloadWithFallback(requestJSON string) (string, error) { enrichRequestExtendedMetadata(&req) - allServices := []string{"tidal", "qobuz", "amazon", "deezer"} + allServices := []string{"tidal", "qobuz", "deezer"} preferredService := req.Service if preferredService == "" { preferredService = "tidal" @@ -707,27 +688,6 @@ func DownloadWithFallback(requestJSON string) (string, error) { GoLog("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr) } err = qobuzErr - case "amazon": - 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, - DecryptionKey: amazonResult.DecryptionKey, - } - } else if !errors.Is(amazonErr, ErrDownloadCancelled) { - GoLog("[DownloadWithFallback] Amazon error: %v\n", amazonErr) - } - err = amazonErr case "deezer": deezerResult, deezerErr := downloadFromDeezer(req) if deezerErr == nil { @@ -1579,11 +1539,6 @@ func GetTidalURLFromDeezerTrack(deezerTrackID string) (string, error) { return client.GetTidalURLFromDeezer(deezerTrackID) } -func GetAmazonURLFromDeezerTrack(deezerTrackID string) (string, error) { - client := NewSongLinkClient() - return client.GetAmazonURLFromDeezer(deezerTrackID) -} - func errorResponse(msg string) (string, error) { errorType := "unknown" lowerMsg := strings.ToLower(msg) @@ -2146,8 +2101,6 @@ func ReEnrichFile(requestJSON string) (string, error) { return string(jsonBytes), nil } -// ==================== EXTENSION SYSTEM ==================== - func InitExtensionSystem(extensionsDir, dataDir string) error { manager := GetExtensionManager() if err := manager.SetDirectories(extensionsDir, dataDir); err != nil { @@ -2519,8 +2472,6 @@ func GetAllPendingFFmpegCommandsJSON() (string, error) { return string(jsonBytes), nil } -// ==================== EXTENSION CUSTOM SEARCH ==================== - func EnrichTrackWithExtensionJSON(extensionID, trackJSON string) (string, error) { manager := GetExtensionManager() ext, err := manager.GetExtension(extensionID) @@ -3273,9 +3224,6 @@ func GetExtensionBrowseCategoriesJSON(extensionID string) (string, error) { return callExtensionFunctionJSON(extensionID, "getBrowseCategories", 30*time.Second) } -// ==================== LOCAL LIBRARY SCANNING ==================== - -// SetLibraryCoverCacheDirJSON sets the directory for caching extracted cover art func SetLibraryCoverCacheDirJSON(cacheDir string) { SetLibraryCoverCacheDir(cacheDir) } @@ -3284,9 +3232,6 @@ func ScanLibraryFolderJSON(folderPath string) (string, error) { return ScanLibraryFolder(folderPath) } -// ScanLibraryFolderIncrementalJSON performs an incremental library scan -// existingFilesJSON: JSON object mapping filePath -> modTime (unix millis) -// Returns IncrementalScanResult as JSON func ScanLibraryFolderIncrementalJSON(folderPath, existingFilesJSON string) (string, error) { return ScanLibraryFolderIncremental(folderPath, existingFilesJSON) } diff --git a/go_backend/extension_manager.go b/go_backend/extension_manager.go index b9b460c2..5c73e251 100644 --- a/go_backend/extension_manager.go +++ b/go_backend/extension_manager.go @@ -401,7 +401,6 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx return nil, fmt.Errorf("failed to read manifest.json: %w", err) } - // Parse and validate manifest manifest, err := ParseManifest(manifestData) if err != nil { return nil, fmt.Errorf("Invalid extension manifest: %w", err) @@ -467,17 +466,11 @@ func (m *ExtensionManager) RemoveExtension(extensionID string) error { } } - // Optionally remove data directory (keep for now to preserve settings) - // if ext.DataDir != "" { - // os.RemoveAll(ext.DataDir) - // } - return nil } // Only allows upgrades (new version > current version), not downgrades func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, error) { - // Validate file extension if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") { return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file") } @@ -529,7 +522,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, return nil, fmt.Errorf("Extension '%s' is not installed. Use install instead of upgrade.", newManifest.DisplayName) } - // Compare versions - only allow upgrade, not downgrade versionCompare := compareVersions(newManifest.Version, existing.Manifest.Version) if versionCompare < 0 { return nil, fmt.Errorf("Cannot downgrade extension. Current version: %s, New version: %s", existing.Manifest.Version, newManifest.Version) @@ -540,7 +532,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, GoLog("[Extension] Upgrading %s from v%s to v%s\n", newManifest.DisplayName, existing.Manifest.Version, newManifest.Version) - // Save data directory path and enabled state (we want to preserve them) extDataDir := existing.DataDir extDir := existing.SourceDir wasEnabled := existing.Enabled @@ -601,7 +592,6 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, SourceDir: extDir, } - // Initialize Goja VM if err := m.initializeVM(ext); err != nil { ext.Error = err.Error() ext.Enabled = false @@ -626,7 +616,6 @@ type ExtensionUpgradeInfo struct { } func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*ExtensionUpgradeInfo, error) { - // Validate file extension if !strings.HasSuffix(strings.ToLower(filePath), ".spotiflac-ext") { return nil, fmt.Errorf("Invalid file format. Please select a .spotiflac-ext file") } @@ -675,7 +664,6 @@ func (m *ExtensionManager) checkExtensionUpgradeInternal(filePath string) (*Exte } if !exists { - // Not installed - this is a new install, not upgrade info.CurrentVersion = "" info.CanUpgrade = false } else { @@ -739,7 +727,6 @@ func (m *ExtensionManager) GetInstalledExtensionsJSON() (string, error) { permissions = append(permissions, "storage:enabled") } - // Determine status status := "loaded" if ext.Error != "" { status = "error" @@ -940,7 +927,6 @@ func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) ( return nil, fmt.Errorf("extension is disabled") } - // Call the action function on the extension object script := fmt.Sprintf(` (function() { if (typeof extension !== 'undefined' && typeof extension.%s === 'function') { diff --git a/go_backend/extension_manifest.go b/go_backend/extension_manifest.go index f4164b4f..6166a667 100644 --- a/go_backend/extension_manifest.go +++ b/go_backend/extension_manifest.go @@ -1,4 +1,3 @@ -// Package gobackend provides extension manifest parsing and validation package gobackend import ( diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index ceac1af4..a1a419b5 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "os" "path/filepath" "sort" "strings" @@ -99,15 +100,16 @@ type ExtDownloadResult struct { ErrorMessage string `json:"error_message,omitempty"` ErrorType string `json:"error_type,omitempty"` - Title string `json:"title,omitempty"` - Artist string `json:"artist,omitempty"` - Album string `json:"album,omitempty"` - AlbumArtist string `json:"album_artist,omitempty"` - TrackNumber int `json:"track_number,omitempty"` - DiscNumber int `json:"disc_number,omitempty"` - ReleaseDate string `json:"release_date,omitempty"` - CoverURL string `json:"cover_url,omitempty"` - ISRC string `json:"isrc,omitempty"` + Title string `json:"title,omitempty"` + Artist string `json:"artist,omitempty"` + Album string `json:"album,omitempty"` + AlbumArtist string `json:"album_artist,omitempty"` + TrackNumber int `json:"track_number,omitempty"` + DiscNumber int `json:"disc_number,omitempty"` + ReleaseDate string `json:"release_date,omitempty"` + CoverURL string `json:"cover_url,omitempty"` + ISRC string `json:"isrc,omitempty"` + DecryptionKey string `json:"decryption_key,omitempty"` } type ExtensionProviderWrapper struct { @@ -388,7 +390,7 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra return &enrichedTrack, nil } -func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName string) (*ExtAvailabilityResult, error) { +func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName, spotifyID, deezerID string) (*ExtAvailabilityResult, error) { if !p.extension.Manifest.IsDownloadProvider() { return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID) } @@ -403,11 +405,11 @@ func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName script := fmt.Sprintf(` (function() { if (typeof extension !== 'undefined' && typeof extension.checkAvailability === 'function') { - return extension.checkAvailability(%q, %q, %q); + return extension.checkAvailability(%q, %q, %q, {spotify_id: %q, deezer_id: %q}); } return null; })() - `, isrc, trackName, artistName) + `, isrc, trackName, artistName, spotifyID, deezerID) result, err := RunWithTimeoutAndRecover(p.vm, script, DefaultJSTimeout) if err != nil { @@ -631,7 +633,7 @@ func GetProviderPriority() []string { defer providerPriorityMu.RUnlock() if len(providerPriority) == 0 { - return []string{"tidal", "qobuz", "amazon", "deezer"} + return []string{"tidal", "qobuz", "deezer"} } result := make([]string, len(providerPriority)) @@ -661,7 +663,7 @@ func GetMetadataProviderPriority() []string { func isBuiltInProvider(providerID string) bool { switch providerID { - case "tidal", "qobuz", "amazon", "deezer": + case "tidal", "qobuz", "deezer": return true default: return false @@ -694,6 +696,27 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro } priority = newPriority GoLog("[DownloadWithExtensionFallback] New priority order: %v\n", priority) + } else if !strictMode && req.Service != "" && !isBuiltInProvider(strings.ToLower(req.Service)) { + found := false + for _, p := range priority { + if strings.EqualFold(p, req.Service) { + found = true + break + } + } + newPriority := []string{req.Service} + for _, p := range priority { + if !strings.EqualFold(p, req.Service) { + newPriority = append(newPriority, p) + } + } + priority = newPriority + if !found { + GoLog("[DownloadWithExtensionFallback] Extension service '%s' added to priority front\n", req.Service) + } else { + GoLog("[DownloadWithExtensionFallback] Extension service '%s' moved to priority front\n", req.Service) + } + GoLog("[DownloadWithExtensionFallback] New priority order: %v\n", priority) } var lastErr error @@ -777,7 +800,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro GoLog("[DownloadWithExtensionFallback] Downloading from source extension with trackID: %s (skipBuiltInFallback: %v)\n", trackID, skipBuiltIn) - outputPath := buildOutputPath(req) + outputPath := buildOutputPathForExtension(req, ext) if req.ItemID != "" { StartItemProgress(req.ItemID) } @@ -813,6 +836,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro Genre: req.Genre, Label: req.Label, Copyright: req.Copyright, + DecryptionKey: result.DecryptionKey, } if req.EmbedMetadata && (req.Genre != "" || req.Label != "") { @@ -966,7 +990,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro provider := NewExtensionProviderWrapper(ext) - availability, err := provider.CheckAvailability(req.ISRC, req.TrackName, req.ArtistName) + availability, err := provider.CheckAvailability(req.ISRC, req.TrackName, req.ArtistName, req.SpotifyID, req.DeezerID) if err != nil || !availability.Available { GoLog("[DownloadWithExtensionFallback] %s: not available\n", providerID) if err != nil { @@ -975,7 +999,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro continue } - outputPath := buildOutputPath(req) + outputPath := buildOutputPathForExtension(req, ext) if req.ItemID != "" { StartItemProgress(req.ItemID) } @@ -1011,6 +1035,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro Genre: req.Genre, Label: req.Label, Copyright: req.Copyright, + DecryptionKey: result.DecryptionKey, } if req.EmbedMetadata && (req.Genre != "" || req.Label != "") { @@ -1128,25 +1153,6 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon } } err = qobuzErr - case "amazon": - 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, - DecryptionKey: amazonResult.DecryptionKey, - } - } - err = amazonErr case "deezer": deezerResult, deezerErr := downloadFromDeezer(req) if deezerErr == nil { @@ -1226,7 +1232,58 @@ func buildOutputPath(req DownloadRequest) string { ext = "." + ext } - return fmt.Sprintf("%s/%s%s", req.OutputDir, filename, ext) + outputDir := req.OutputDir + if strings.TrimSpace(outputDir) == "" { + outputDir = filepath.Join(os.TempDir(), "spotiflac-downloads") + os.MkdirAll(outputDir, 0755) + AddAllowedDownloadDir(outputDir) + } + + return filepath.Join(outputDir, filename+ext) +} + +func buildOutputPathForExtension(req DownloadRequest, ext *LoadedExtension) string { + if strings.TrimSpace(req.OutputPath) != "" { + return strings.TrimSpace(req.OutputPath) + } + + if strings.TrimSpace(req.OutputDir) != "" { + return buildOutputPath(req) + } + + // SAF mode: use extension's data dir as writable temp location + tempDir := filepath.Join(ext.DataDir, "downloads") + os.MkdirAll(tempDir, 0755) + AddAllowedDownloadDir(tempDir) + + metadata := map[string]interface{}{ + "title": req.TrackName, + "artist": req.ArtistName, + "album": req.AlbumName, + "album_artist": req.AlbumArtist, + "track": req.TrackNumber, + "track_number": req.TrackNumber, + "disc": req.DiscNumber, + "disc_number": req.DiscNumber, + "year": extractYear(req.ReleaseDate), + "date": req.ReleaseDate, + "release_date": req.ReleaseDate, + "isrc": req.ISRC, + } + + filename := buildFilenameFromTemplate(req.FilenameFormat, metadata) + if filename == "" { + filename = sanitizeFilename(fmt.Sprintf("%s - %s", req.ArtistName, req.TrackName)) + } + + outputExt := strings.TrimSpace(req.OutputExt) + if outputExt == "" { + outputExt = ".flac" + } else if !strings.HasPrefix(outputExt, ".") { + outputExt = "." + outputExt + } + + return filepath.Join(tempDir, filename+outputExt) } func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string]interface{}) ([]ExtTrackMetadata, error) { @@ -1653,7 +1710,6 @@ func (m *ExtensionManager) HandleURLWithExtension(url string) (*ExtURLHandleResu }, nil } -// GetPostProcessingProviders returns all extensions that provide post-processing func (m *ExtensionManager) GetPostProcessingProviders() []*ExtensionProviderWrapper { m.mu.RLock() defer m.mu.RUnlock() @@ -1667,7 +1723,6 @@ func (m *ExtensionManager) GetPostProcessingProviders() []*ExtensionProviderWrap return providers } -// RunPostProcessing runs all enabled post-processing hooks on a file func (m *ExtensionManager) RunPostProcessing(filePath string, metadata map[string]interface{}) (*PostProcessResult, error) { providers := m.GetPostProcessingProviders() if len(providers) == 0 { @@ -1713,7 +1768,6 @@ func (m *ExtensionManager) RunPostProcessing(filePath string, metadata map[strin return &PostProcessResult{Success: true, NewFilePath: currentPath}, nil } -// RunPostProcessingV2 runs all enabled post-processing hooks on a file input. func (m *ExtensionManager) RunPostProcessingV2(input PostProcessInput, metadata map[string]interface{}) (*PostProcessResult, error) { providers := m.GetPostProcessingProviders() if len(providers) == 0 { @@ -1768,9 +1822,6 @@ func (m *ExtensionManager) RunPostProcessingV2(input PostProcessInput, metadata return &PostProcessResult{Success: true, NewFilePath: currentInput.Path, NewFileURI: currentInput.URI}, nil } -// ==================== Lyrics Provider ==================== - -// ExtLyricsResult represents lyrics data returned from an extension type ExtLyricsResult struct { Lines []ExtLyricsLine `json:"lines"` SyncType string `json:"syncType"` @@ -1785,7 +1836,6 @@ type ExtLyricsLine struct { EndTimeMs int64 `json:"endTimeMs"` } -// FetchLyrics calls the extension's fetchLyrics function func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName string, durationSec float64) (*LyricsResponse, error) { if !p.extension.Manifest.IsLyricsProvider() { return nil, fmt.Errorf("extension '%s' is not a lyrics provider", p.extension.ID) @@ -1885,7 +1935,6 @@ func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName return response, nil } -// GetLyricsProviders returns all enabled extensions that provide lyrics func (m *ExtensionManager) GetLyricsProviders() []*ExtensionProviderWrapper { m.mu.RLock() defer m.mu.RUnlock() diff --git a/go_backend/extension_runtime_auth.go b/go_backend/extension_runtime_auth.go index de4ed06c..e17e0d4d 100644 --- a/go_backend/extension_runtime_auth.go +++ b/go_backend/extension_runtime_auth.go @@ -1,4 +1,3 @@ -// Package gobackend provides Auth API and PKCE support for extension runtime package gobackend import ( @@ -16,8 +15,6 @@ import ( "github.com/dop251/goja" ) -// ==================== Auth API (OAuth Support) ==================== - func validateExtensionAuthURL(urlStr string) error { parsed, err := url.Parse(urlStr) if err != nil { @@ -204,9 +201,6 @@ func (r *ExtensionRuntime) authGetTokens(call goja.FunctionCall) goja.Value { return r.vm.ToValue(result) } -// ==================== PKCE Support ==================== - -// generatePKCEVerifier generates a cryptographically random code verifier // Length should be between 43-128 characters (RFC 7636) func generatePKCEVerifier(length int) (string, error) { if length < 43 { @@ -394,9 +388,7 @@ func (r *ExtensionRuntime) authStartOAuthWithPKCE(call goja.FunctionCall) goja.V }) } -// authExchangeCodeWithPKCE exchanges auth code for tokens using PKCE // config: { tokenUrl, clientId, redirectUri, code, extraParams } -// Uses the stored PKCE verifier automatically func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 1 { return r.vm.ToValue(map[string]interface{}{ @@ -414,7 +406,6 @@ func (r *ExtensionRuntime) authExchangeCodeWithPKCE(call goja.FunctionCall) goja }) } - // Required fields tokenURL, _ := config["tokenUrl"].(string) clientID, _ := config["clientId"].(string) redirectURI, _ := config["redirectUri"].(string) diff --git a/go_backend/extension_runtime_ffmpeg.go b/go_backend/extension_runtime_ffmpeg.go index 19e1b67c..7c1e8d69 100644 --- a/go_backend/extension_runtime_ffmpeg.go +++ b/go_backend/extension_runtime_ffmpeg.go @@ -1,4 +1,3 @@ -// Package gobackend provides FFmpeg API for extension runtime package gobackend import ( @@ -10,9 +9,7 @@ import ( "github.com/dop251/goja" ) -// ==================== FFmpeg API (Post-Processing) ==================== - -// FFmpegCommand holds a pending FFmpeg command for Flutter to execute +// FFmpegCommand holds a pending FFmpeg command for Flutter to execute. type FFmpegCommand struct { ExtensionID string Command string @@ -24,7 +21,6 @@ type FFmpegCommand struct { Output string } -// Global FFmpeg command queue var ( ffmpegCommands = make(map[string]*FFmpegCommand) ffmpegCommandsMu sync.RWMutex diff --git a/go_backend/extension_runtime_file.go b/go_backend/extension_runtime_file.go index 9bb1191e..414f174c 100644 --- a/go_backend/extension_runtime_file.go +++ b/go_backend/extension_runtime_file.go @@ -1,4 +1,3 @@ -// Package gobackend provides File API for extension runtime package gobackend import ( @@ -13,8 +12,6 @@ import ( "github.com/dop251/goja" ) -// ==================== File API (Sandboxed) ==================== - var ( allowedDownloadDirs []string allowedDownloadDirsMu sync.RWMutex diff --git a/go_backend/extension_runtime_http.go b/go_backend/extension_runtime_http.go index dcdd32f4..65775832 100644 --- a/go_backend/extension_runtime_http.go +++ b/go_backend/extension_runtime_http.go @@ -1,4 +1,3 @@ -// Package gobackend provides HTTP API for extension runtime package gobackend import ( @@ -12,8 +11,6 @@ import ( "github.com/dop251/goja" ) -// ==================== HTTP API (Sandboxed) ==================== - type HTTPResponse struct { StatusCode int `json:"statusCode"` Body string `json:"body"` diff --git a/go_backend/extension_runtime_matching.go b/go_backend/extension_runtime_matching.go index 4ad4b0e3..30b61e7b 100644 --- a/go_backend/extension_runtime_matching.go +++ b/go_backend/extension_runtime_matching.go @@ -1,4 +1,3 @@ -// Package gobackend provides Track Matching API for extension runtime package gobackend import ( @@ -7,8 +6,6 @@ import ( "github.com/dop251/goja" ) -// ==================== Track Matching API ==================== - func (r *ExtensionRuntime) matchingCompareStrings(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 2 { return r.vm.ToValue(0.0) diff --git a/go_backend/extension_runtime_polyfills.go b/go_backend/extension_runtime_polyfills.go index 62892c70..a5334e06 100644 --- a/go_backend/extension_runtime_polyfills.go +++ b/go_backend/extension_runtime_polyfills.go @@ -1,4 +1,3 @@ -// Package gobackend provides Browser-like Polyfills for extension runtime package gobackend import ( @@ -13,12 +12,10 @@ import ( "github.com/dop251/goja" ) -// ==================== Browser-like Polyfills ==================== // These polyfills make porting browser/Node.js libraries easier -// without compromising sandbox security +// without compromising sandbox security. -// fetchPolyfill implements browser-compatible fetch() API -// Returns a Promise-like object with json(), text() methods +// Returns a Promise-like object with json(), text() methods. func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 1 { return r.createFetchError("URL is required") @@ -141,7 +138,6 @@ func (r *ExtensionRuntime) fetchPolyfill(call goja.FunctionCall) goja.Value { return responseObj } -// createFetchError creates a fetch error response func (r *ExtensionRuntime) createFetchError(message string) goja.Value { errorObj := r.vm.NewObject() errorObj.Set("ok", false) @@ -157,7 +153,6 @@ func (r *ExtensionRuntime) createFetchError(message string) goja.Value { return errorObj } -// atobPolyfill implements browser atob() - decode base64 to string func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 1 { return r.vm.ToValue("") @@ -174,7 +169,6 @@ func (r *ExtensionRuntime) atobPolyfill(call goja.FunctionCall) goja.Value { return r.vm.ToValue(string(decoded)) } -// btoaPolyfill implements browser btoa() - encode string to base64 func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 1 { return r.vm.ToValue("") @@ -183,7 +177,6 @@ func (r *ExtensionRuntime) btoaPolyfill(call goja.FunctionCall) goja.Value { return r.vm.ToValue(base64.StdEncoding.EncodeToString([]byte(input))) } -// registerTextEncoderDecoder registers TextEncoder and TextDecoder classes func (r *ExtensionRuntime) registerTextEncoderDecoder(vm *goja.Runtime) { vm.Set("TextEncoder", func(call goja.ConstructorCall) *goja.Object { encoder := call.This @@ -429,9 +422,8 @@ func (r *ExtensionRuntime) registerURLClass(vm *goja.Runtime) { }) } -// registerJSONGlobal ensures JSON global is properly set up +// JSON is already built-in to Goja; this ensures a fallback exists. func (r *ExtensionRuntime) registerJSONGlobal(vm *goja.Runtime) { - // JSON is already built-in to Goja, but we can enhance it jsonScript := ` if (typeof JSON === 'undefined') { var JSON = { diff --git a/go_backend/extension_runtime_storage.go b/go_backend/extension_runtime_storage.go index 06cbdd33..50815b40 100644 --- a/go_backend/extension_runtime_storage.go +++ b/go_backend/extension_runtime_storage.go @@ -1,4 +1,3 @@ -// Package gobackend provides Storage and Credentials API for extension runtime package gobackend import ( @@ -17,8 +16,6 @@ import ( "github.com/dop251/goja" ) -// ==================== Storage API ==================== - const ( defaultStorageFlushDelay = 400 * time.Millisecond storageFlushRetryDelay = 2 * time.Second diff --git a/go_backend/extension_runtime_utils.go b/go_backend/extension_runtime_utils.go index c3a7675e..f91918ff 100644 --- a/go_backend/extension_runtime_utils.go +++ b/go_backend/extension_runtime_utils.go @@ -1,4 +1,3 @@ -// Package gobackend provides Utility functions for extension runtime package gobackend import ( @@ -17,8 +16,6 @@ import ( "github.com/dop251/goja" ) -// ==================== Utility Functions ==================== - func (r *ExtensionRuntime) base64Encode(call goja.FunctionCall) goja.Value { if len(call.Arguments) < 1 { return r.vm.ToValue("") diff --git a/go_backend/extension_settings.go b/go_backend/extension_settings.go index 9ad0c0c1..e241f6e2 100644 --- a/go_backend/extension_settings.go +++ b/go_backend/extension_settings.go @@ -1,4 +1,3 @@ -// Package gobackend provides extension settings storage package gobackend import ( diff --git a/go_backend/extension_timeout.go b/go_backend/extension_timeout.go index e9a5605f..76e51cfa 100644 --- a/go_backend/extension_timeout.go +++ b/go_backend/extension_timeout.go @@ -1,4 +1,3 @@ -// Package gobackend provides timeout execution for extension JS code package gobackend import ( diff --git a/go_backend/httputil.go b/go_backend/httputil.go index 08137fa8..b54ec489 100644 --- a/go_backend/httputil.go +++ b/go_backend/httputil.go @@ -489,7 +489,6 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError { } } - // Check error message patterns for common ISP blocking indicators blockingPatterns := []struct { pattern string reason string @@ -532,7 +531,6 @@ func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool { return false } -// extractDomain extracts the domain from a URL string func extractDomain(rawURL string) string { if rawURL == "" { return "unknown" diff --git a/go_backend/httputil_utls.go b/go_backend/httputil_utls.go index bf5ec9ed..4b09deb7 100644 --- a/go_backend/httputil_utls.go +++ b/go_backend/httputil_utls.go @@ -91,7 +91,6 @@ func (t *utlsTransport) getPort(u *url.URL) string { return "80" } -// Cloudflare bypass client using uTLS Chrome fingerprint var cloudflareBypassTransport = newUTLSTransport() var cloudflareBypassClient = &http.Client{ @@ -111,7 +110,6 @@ func GetCloudflareBypassClient() *http.Client { func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) { req.Header.Set("User-Agent", getRandomUserAgent()) - // Try with standard client first resp, err := sharedClient.Do(req) if err == nil { // Check for Cloudflare challenge page (403 with specific markers) @@ -138,11 +136,9 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) { if isCloudflare { LogDebug("HTTP", "Cloudflare detected, retrying with Chrome TLS fingerprint...") - // Clone request for retry reqCopy := req.Clone(req.Context()) reqCopy.Header.Set("User-Agent", getRandomUserAgent()) - // Retry with uTLS Chrome fingerprint return cloudflareBypassClient.Do(reqCopy) } } @@ -168,11 +164,9 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) { if tlsRelated { LogDebug("HTTP", "TLS error detected, retrying with Chrome TLS fingerprint: %v", err) - // Clone request for retry reqCopy := req.Clone(req.Context()) reqCopy.Header.Set("User-Agent", getRandomUserAgent()) - // Retry with uTLS Chrome fingerprint return cloudflareBypassClient.Do(reqCopy) } diff --git a/go_backend/idhs.go b/go_backend/idhs.go index 72c8cff3..3b339ed0 100644 --- a/go_backend/idhs.go +++ b/go_backend/idhs.go @@ -22,13 +22,11 @@ var ( idhsRateLimiter = NewRateLimiter(8, time.Minute) // 8 req/min (below 10 limit) ) -// IDHSSearchRequest represents the request body for IDHS API type IDHSSearchRequest struct { Link string `json:"link"` Adapters []string `json:"adapters,omitempty"` } -// IDHSSearchResponse represents the response from IDHS API type IDHSSearchResponse struct { ID string `json:"id"` Type string `json:"type"` // song, album, artist, podcast, show @@ -41,7 +39,6 @@ type IDHSSearchResponse struct { Links []IDHSLink `json:"links"` } -// IDHSLink represents a link to a streaming platform type IDHSLink struct { Type string `json:"type"` // spotify, youTube, appleMusic, deezer, soundCloud, tidal URL string `json:"url"` @@ -49,7 +46,6 @@ type IDHSLink struct { NotAvailable bool `json:"notAvailable,omitempty"` } -// NewIDHSClient creates a new IDHS client func NewIDHSClient() *IDHSClient { idhsClientOnce.Do(func() { globalIDHSClient = &IDHSClient{ @@ -117,7 +113,6 @@ func (c *IDHSClient) Search(link string, adapters []string) (*IDHSSearchResponse func (c *IDHSClient) GetAvailabilityFromSpotify(spotifyTrackID string) (*TrackAvailability, error) { spotifyURL := fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID) - // Request only the platforms we need adapters := []string{"tidal", "deezer"} result, err := c.Search(spotifyURL, adapters) @@ -151,11 +146,9 @@ func (c *IDHSClient) GetAvailabilityFromSpotify(spotifyTrackID string) (*TrackAv return availability, nil } -// GetAvailabilityFromDeezer checks track availability using IDHS func (c *IDHSClient) GetAvailabilityFromDeezer(deezerTrackID string) (*TrackAvailability, error) { deezerURL := fmt.Sprintf("https://www.deezer.com/track/%s", deezerTrackID) - // Request only the platforms we need adapters := []string{"spotify", "tidal"} result, err := c.Search(deezerURL, adapters) diff --git a/go_backend/library_scan.go b/go_backend/library_scan.go index 52e91fb8..d9e53663 100644 --- a/go_backend/library_scan.go +++ b/go_backend/library_scan.go @@ -10,7 +10,6 @@ import ( "time" ) -// LibraryScanResult represents metadata from a scanned audio file type LibraryScanResult struct { ID string `json:"id"` TrackName string `json:"trackName"` @@ -42,7 +41,6 @@ type LibraryScanProgress struct { IsComplete bool `json:"is_complete"` } -// IncrementalScanResult contains results of an incremental library scan type IncrementalScanResult struct { Scanned []LibraryScanResult `json:"scanned"` // New or updated files DeletedPaths []string `json:"deletedPaths"` // Files that no longer exist @@ -216,7 +214,6 @@ func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) { Format: strings.TrimPrefix(ext, "."), } - // Get file modification time if info, err := os.Stat(filePath); err == nil { result.FileModTime = info.ModTime().UnixMilli() } @@ -466,7 +463,6 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, return "{}", fmt.Errorf("path is not a folder: %s", folderPath) } - // Parse existing files map existingFiles := make(map[string]int64) if existingFilesJSON != "" && existingFilesJSON != "{}" { if err := json.Unmarshal([]byte(existingFilesJSON), &existingFiles); err != nil { @@ -476,12 +472,10 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, GoLog("[LibraryScan] Incremental scan starting, %d existing files in database\n", len(existingFiles)) - // Reset progress libraryScanProgressMu.Lock() libraryScanProgress = LibraryScanProgress{} libraryScanProgressMu.Unlock() - // Setup cancellation libraryScanCancelMu.Lock() if libraryScanCancel != nil { close(libraryScanCancel) @@ -490,7 +484,6 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, cancelCh := libraryScanCancel libraryScanCancelMu.Unlock() - // Collect all audio files with their mod times currentFiles, err := collectLibraryAudioFiles(folderPath, cancelCh) if err != nil { return "{}", err @@ -512,18 +505,14 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, for _, f := range currentFiles { existingModTime, exists := existingFiles[f.path] if !exists { - // New file filesToScan = append(filesToScan, f) } else if f.modTime != existingModTime { - // Modified file filesToScan = append(filesToScan, f) } else { - // Unchanged file - skip skippedCount++ } } - // Find deleted files var deletedPaths []string for existingPath := range existingFiles { if !currentPathSet[existingPath] { @@ -551,7 +540,6 @@ func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, return string(jsonBytes), nil } - // Scan the files that need scanning results := make([]LibraryScanResult, 0, len(filesToScan)) scanTime := time.Now().UTC().Format(time.RFC3339) errorCount := 0 diff --git a/go_backend/lyrics.go b/go_backend/lyrics.go index 3ec16555..1a6c4771 100644 --- a/go_backend/lyrics.go +++ b/go_backend/lyrics.go @@ -41,7 +41,6 @@ var DefaultLyricsProviders = []string{ LyricsProviderQQMusic, } -// Global lyrics provider configuration var ( lyricsProvidersMu sync.RWMutex lyricsProviders []string // ordered list of enabled providers @@ -598,7 +597,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st return lyricsHasUsableText(l) } - // Try extension lyrics providers first if len(extensionProviders) > 0 { for _, provider := range extensionProviders { GoLog("[Lyrics] Trying extension lyrics provider: %s\n", provider.extension.ID) @@ -621,7 +619,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st return &cachedCopy, nil } - // Get configured provider order providerOrder := GetLyricsProviderOrder() simplifiedTrack := simplifyTrackName(trackName) diff --git a/go_backend/lyrics_apple.go b/go_backend/lyrics_apple.go index 957db6fc..538b0b89 100644 --- a/go_backend/lyrics_apple.go +++ b/go_backend/lyrics_apple.go @@ -97,7 +97,6 @@ func (m *appleTokenManager) clearToken() { m.token = "" } -// Apple Music API response models type appleMusicSearchResponse struct { Results struct { Songs *struct { @@ -239,15 +238,12 @@ func (c *AppleMusicClient) FetchLyricsByID(songID string) (string, error) { return bodyStr, nil } -// formatPaxLyricsToLRC converts a pax proxy response to standard LRC format. func formatPaxLyricsToLRC(rawJSON string, multiPersonWordByWord bool) (string, error) { - // Try to parse as PaxResponse first var paxResp paxResponse if err := json.Unmarshal([]byte(rawJSON), &paxResp); err == nil && paxResp.Content != nil { return formatPaxContent(paxResp.Type, paxResp.Content, multiPersonWordByWord), nil } - // Try to parse as a direct list of PaxLyrics var directLyrics []paxLyrics if err := json.Unmarshal([]byte(rawJSON), &directLyrics); err == nil && len(directLyrics) > 0 { return formatPaxContent("Syllable", directLyrics, multiPersonWordByWord), nil diff --git a/go_backend/lyrics_musixmatch.go b/go_backend/lyrics_musixmatch.go index 71d4544e..f962a110 100644 --- a/go_backend/lyrics_musixmatch.go +++ b/go_backend/lyrics_musixmatch.go @@ -16,7 +16,6 @@ type MusixmatchClient struct { baseURL string } -// Musixmatch proxy response models type musixmatchSearchResponse struct { ID int64 `json:"id"` SongName string `json:"songName"` @@ -116,7 +115,6 @@ func (c *MusixmatchClient) FetchLyricsInLanguage(songID int64, language string) return nil, fmt.Errorf("failed to decode musixmatch language response: %w", err) } - // Prefer synced lyrics for selected language if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" { lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics) if len(lines) > 0 { @@ -129,7 +127,6 @@ func (c *MusixmatchClient) FetchLyricsInLanguage(songID int64, language string) } } - // Fall back to unsynced lyrics for selected language if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" { lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics) @@ -162,7 +159,6 @@ func (c *MusixmatchClient) FetchLyrics(trackName, artistName string, durationSec GoLog("[Musixmatch] Language override '%s' failed: %v\n", preferred, localizedErr) } - // Prefer synced lyrics if result.SyncedLyrics != nil && strings.TrimSpace(result.SyncedLyrics.Lyrics) != "" { lines := parseSyncedLyrics(result.SyncedLyrics.Lyrics) if len(lines) > 0 { @@ -175,7 +171,6 @@ func (c *MusixmatchClient) FetchLyrics(trackName, artistName string, durationSec } } - // Fall back to unsynced lyrics if result.UnsyncedLyrics != nil && strings.TrimSpace(result.UnsyncedLyrics.Lyrics) != "" { lines := plainTextLyricsLines(result.UnsyncedLyrics.Lyrics) diff --git a/go_backend/lyrics_netease.go b/go_backend/lyrics_netease.go index e9fbf1e6..f6ce6b6c 100644 --- a/go_backend/lyrics_netease.go +++ b/go_backend/lyrics_netease.go @@ -15,7 +15,6 @@ type NeteaseClient struct { httpClient *http.Client } -// Netease API response models type neteaseSearchResponse struct { Result struct { Songs []struct { @@ -172,7 +171,6 @@ func (c *NeteaseClient) FetchLyrics( return nil, err } - // Parse the LRC text into LyricsResponse lines := parseSyncedLyrics(lrcText) if len(lines) == 0 { // May be plain text lyrics without timestamps diff --git a/go_backend/lyrics_qqmusic.go b/go_backend/lyrics_qqmusic.go index 0b76a49f..dde3631d 100644 --- a/go_backend/lyrics_qqmusic.go +++ b/go_backend/lyrics_qqmusic.go @@ -17,7 +17,6 @@ type QQMusicClient struct { httpClient *http.Client } -// QQ Music search response models type qqMusicSearchResponse struct { Data struct { Song struct { @@ -184,7 +183,6 @@ func (c *QQMusicClient) FetchLyrics( }, nil } - // Fall back to plain text resultLines := plainTextLyricsLines(lrcText) if len(resultLines) > 0 { diff --git a/go_backend/mobile_deps.go b/go_backend/mobile_deps.go index bbdb1890..57aaaeec 100644 --- a/go_backend/mobile_deps.go +++ b/go_backend/mobile_deps.go @@ -1,10 +1,8 @@ -// mobile_deps.go // This file ensures gomobile dependencies are not removed by go mod tidy. // These packages are required by gomobile bind but not directly imported in code. package gobackend import ( - // Required for gomobile bind to work _ "golang.org/x/mobile/bind" ) diff --git a/go_backend/parallel.go b/go_backend/parallel.go index b275ade9..2526e139 100644 --- a/go_backend/parallel.go +++ b/go_backend/parallel.go @@ -10,7 +10,6 @@ import ( type TrackIDCacheEntry struct { TidalTrackID int64 QobuzTrackID int64 - AmazonURL string ExpiresAt time.Time } @@ -107,25 +106,6 @@ func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) { } } -func (c *TrackIDCache) SetAmazonURL(isrc string, amazonURL string) { - c.mu.Lock() - defer c.mu.Unlock() - - entry, exists := c.cache[isrc] - if !exists { - entry = &TrackIDCacheEntry{} - c.cache[isrc] = entry - } - entry.AmazonURL = amazonURL - now := time.Now() - entry.ExpiresAt = now.Add(c.ttl) - - if c.cleanupInterval > 0 && (c.lastCleanup.IsZero() || now.Sub(c.lastCleanup) >= c.cleanupInterval) { - c.pruneExpiredLocked(now) - c.lastCleanup = now - } -} - func (c *TrackIDCache) Clear() { c.mu.Lock() defer c.mu.Unlock() @@ -235,8 +215,6 @@ func PreWarmTrackCache(requests []PreWarmCacheRequest) { preWarmTidalCache(r.ISRC, r.TrackName, r.ArtistName) case "qobuz": preWarmQobuzCache(r.ISRC, r.SpotifyID) - case "amazon": - preWarmAmazonCache(r.ISRC, r.SpotifyID) } }(req) } @@ -256,12 +234,10 @@ func preWarmTidalCache(isrc, _, _ string) { // 1. From SongLink (fast, no Qobuz API call needed) // 2. Direct ISRC search on Qobuz API (slower, may fail if ISRC not in Qobuz database) func preWarmQobuzCache(isrc, spotifyID string) { - // First, try to get QobuzID from SongLink - this is faster and more reliable if spotifyID != "" { client := NewSongLinkClient() availability, err := client.CheckTrackAvailability(spotifyID, isrc) if err == nil && availability != nil && availability.QobuzID != "" { - // Parse QobuzID to int64 var trackID int64 if _, parseErr := fmt.Sscanf(availability.QobuzID, "%d", &trackID); parseErr == nil && trackID > 0 { GoLog("[Qobuz] Pre-warm cache: Got Qobuz ID %d from SongLink for ISRC %s\n", trackID, isrc) @@ -271,7 +247,6 @@ func preWarmQobuzCache(isrc, spotifyID string) { } } - // Fallback: Direct ISRC search on Qobuz API downloader := NewQobuzDownloader() track, err := downloader.SearchTrackByISRC(isrc) if err == nil && track != nil { @@ -280,14 +255,6 @@ func preWarmQobuzCache(isrc, spotifyID string) { } } -func preWarmAmazonCache(isrc, spotifyID string) { - client := NewSongLinkClient() - availability, err := client.CheckTrackAvailability(spotifyID, isrc) - if err == nil && availability != nil && availability.AmazonURL != "" { - GetTrackIDCache().SetAmazonURL(isrc, availability.AmazonURL) - } -} - func PreWarmCache(tracksJSON string) error { var tracks []struct { ISRC string `json:"isrc"` diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index 015dca8d..092c0e96 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -923,18 +923,14 @@ type qobuzAPIResult struct { duration time.Duration } -// Qobuz API timeout configuration // Mobile networks are more unstable, so we use longer timeouts const ( qobuzAPITimeoutMobile = 25 * time.Second - qobuzMaxRetries = 2 // Number of retries per API + qobuzMaxRetries = 2 qobuzRetryDelay = 500 * time.Millisecond ) -// getQobuzAPITimeout returns appropriate timeout based on platform -// For mobile (gomobile builds), we use longer timeouts func getQobuzAPITimeout() time.Duration { - // Since this runs in gomobile context, we always use mobile timeout // The Go backend is only used on mobile (Android/iOS) return qobuzAPITimeoutMobile } @@ -944,7 +940,6 @@ func fetchQobuzURLWithRetry(provider qobuzAPIProvider, trackID int64, quality st return fetchQobuzURLSingleAttempt(provider, trackID, quality, timeout, "") } -// fetchQobuzURLSingleAttempt fetches download URL with retry logic for a single API+country combination func fetchQobuzURLSingleAttempt(provider qobuzAPIProvider, trackID int64, quality string, timeout time.Duration, country string) (qobuzDownloadInfo, error) { var lastErr error retryDelay := qobuzRetryDelay @@ -967,7 +962,7 @@ func fetchQobuzURLSingleAttempt(provider qobuzAPIProvider, trackID int64, qualit if attempt > 0 { GoLog("[Qobuz] Retry %d/%d for %s after %v\n", attempt, qobuzMaxRetries, provider.Name, retryDelay) time.Sleep(retryDelay) - retryDelay *= 2 // Exponential backoff + retryDelay *= 2 } client := NewHTTPClientWithTimeout(timeout) @@ -1014,11 +1009,10 @@ func fetchQobuzURLSingleAttempt(provider qobuzAPIProvider, trackID int64, qualit strings.Contains(errStr, "reset") || strings.Contains(errStr, "connection refused") || strings.Contains(errStr, "eof") { - continue // Retry + continue } - break // Non-retryable error + break } - // Server errors are retryable if resp.StatusCode >= 500 { io.Copy(io.Discard, resp.Body) resp.Body.Close() @@ -1031,7 +1025,7 @@ func fetchQobuzURLSingleAttempt(provider qobuzAPIProvider, trackID int64, qualit io.Copy(io.Discard, resp.Body) resp.Body.Close() lastErr = fmt.Errorf("rate limited") - retryDelay = 2 * time.Second // Wait longer for rate limit + retryDelay = 2 * time.Second continue } @@ -1308,7 +1302,6 @@ func resolveQobuzTrackForRequest(req DownloadRequest, downloader *QobuzDownloade track = nil } else if track != nil { GoLog("[%s] Successfully found track via SongLink ID: '%s' by '%s'\n", logPrefix, track.Title, track.Performer.Name) - // Cache for future use if req.ISRC != "" { GetTrackIDCache().SetQobuz(req.ISRC, track.ID) } diff --git a/go_backend/ratelimit.go b/go_backend/ratelimit.go index 1f2ac1f6..662d86e4 100644 --- a/go_backend/ratelimit.go +++ b/go_backend/ratelimit.go @@ -48,7 +48,6 @@ func (r *RateLimiter) WaitForSlot() { r.timestamps = append(r.timestamps, time.Now()) } -// cleanOldTimestamps removes timestamps that are outside the current window func (r *RateLimiter) cleanOldTimestamps(now time.Time) { cutoff := now.Add(-r.window) validStart := 0 diff --git a/go_backend/romaji.go b/go_backend/romaji.go index d5a73963..3c45d2d9 100644 --- a/go_backend/romaji.go +++ b/go_backend/romaji.go @@ -170,11 +170,9 @@ func JapaneseToRomaji(text string) string { } func BuildSearchQuery(trackName, artistName string) string { - // Convert Japanese to romaji trackRomaji := JapaneseToRomaji(trackName) artistRomaji := JapaneseToRomaji(artistName) - // Clean up the query - remove special characters that might interfere with search trackClean := cleanSearchQuery(trackRomaji) artistClean := cleanSearchQuery(artistRomaji) @@ -196,16 +194,13 @@ func cleanSearchQuery(s string) string { func CleanToASCII(s string) string { var result strings.Builder for _, r := range s { - // Keep only ASCII letters, numbers, spaces, and basic punctuation if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == ' ' || r == '-' || r == '\'' { result.WriteRune(r) } else if r == ',' || r == '.' { - // Convert punctuation to space result.WriteRune(' ') } } - // Clean up multiple spaces cleaned := strings.Join(strings.Fields(result.String()), " ") return strings.TrimSpace(cleaned) } diff --git a/go_backend/songlink.go b/go_backend/songlink.go index b5dfd58d..f38a8edb 100644 --- a/go_backend/songlink.go +++ b/go_backend/songlink.go @@ -291,7 +291,7 @@ func extractDeezerIDFromURL(deezerURL string) string { return "" } -// extractQobuzIDFromURL extracts Qobuz track ID from URL +// extractQobuzIDFromURL extracts Qobuz track ID from URL. // URL formats: // - https://www.qobuz.com/us-en/album/.../12345678 (album page with track highlight) // - https://open.qobuz.com/track/12345678 @@ -302,29 +302,24 @@ func extractQobuzIDFromURL(qobuzURL string) string { return "" } - // Try to find /track/ID pattern first if strings.Contains(qobuzURL, "/track/") { parts := strings.Split(qobuzURL, "/track/") if len(parts) > 1 { idPart := parts[1] - // Remove query parameters if idx := strings.Index(idPart, "?"); idx > 0 { idPart = idPart[:idx] } - // Remove trailing slash or path if idx := strings.Index(idPart, "/"); idx > 0 { idPart = idPart[:idx] } idPart = strings.TrimSpace(idPart) - // Validate it's a number if idPart != "" && isNumeric(idPart) { return idPart } } } - // Try to extract from album URL with track highlight - // Format: /album/albumname/trackid or ?trackId=12345678 + // Try to extract from album URL with track highlight (e.g. ?trackId=12345678) if strings.Contains(qobuzURL, "trackId=") { parts := strings.Split(qobuzURL, "trackId=") if len(parts) > 1 { @@ -343,7 +338,6 @@ func extractQobuzIDFromURL(qobuzURL string) string { parts := strings.Split(qobuzURL, "/") for i := len(parts) - 1; i >= 0; i-- { part := parts[i] - // Remove query parameters if idx := strings.Index(part, "?"); idx > 0 { part = part[:idx] } @@ -386,7 +380,6 @@ func extractYouTubeIDFromURL(youtubeURL string) string { return "" } - // Handle youtu.be short URLs if strings.Contains(youtubeURL, "youtu.be/") { parts := strings.Split(youtubeURL, "youtu.be/") if len(parts) >= 2 { @@ -401,7 +394,6 @@ func extractYouTubeIDFromURL(youtubeURL string) string { } } - // Handle youtube.com URLs with ?v= parameter parsed, err := url.Parse(youtubeURL) if err != nil { return "" @@ -411,7 +403,6 @@ func extractYouTubeIDFromURL(youtubeURL string) string { return v } - // Handle /embed/ format if strings.Contains(parsed.Path, "/embed/") { parts := strings.Split(parsed.Path, "/embed/") if len(parts) >= 2 { @@ -540,7 +531,6 @@ func (s *SongLinkClient) CheckAvailabilityFromDeezer(deezerTrackID string) (*Tra return availability, nil } -// checkAvailabilityFromDeezerSongLink is the original SongLink implementation for Deezer func (s *SongLinkClient) checkAvailabilityFromDeezerSongLink(deezerTrackID string) (*TrackAvailability, error) { songLinkRateLimiter.WaitForSlot() diff --git a/go_backend/tidal.go b/go_backend/tidal.go index 22fd2377..ea9a8886 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -103,7 +103,7 @@ type MPD struct { func NewTidalDownloader() *TidalDownloader { tidalDownloaderOnce.Do(func() { globalTidalDownloader = &TidalDownloader{ - client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout + client: NewHTTPClientWithTimeout(DefaultTimeout), } apis := globalTidalDownloader.GetAvailableAPIs() @@ -116,7 +116,7 @@ func NewTidalDownloader() *TidalDownloader { func (t *TidalDownloader) GetAvailableAPIs() []string { return []string{ - "https://tidal-api.binimum.org", // priority + "https://tidal-api.binimum.org", "https://tidal.kinoplus.online", "https://triton.squid.wtf", "https://vogel.qqdl.site", @@ -195,7 +195,6 @@ func (t *TidalDownloader) SearchTrackByISRC(isrc string) (*TidalTrack, error) { return nil, fmt.Errorf("tidal ISRC search API disabled: no client credentials mode") } -// Now includes romaji conversion for Japanese text (4 search strategies like PC) func (t *TidalDownloader) SearchTrackByMetadataWithISRC(trackName, artistName, albumName, spotifyISRC string, expectedDuration int) (*TidalTrack, error) { return nil, fmt.Errorf("tidal metadata search API disabled: no client credentials mode") } @@ -204,7 +203,6 @@ func (t *TidalDownloader) SearchTrackByMetadata(trackName, artistName string) (* return nil, fmt.Errorf("tidal metadata search API disabled: no client credentials mode") } -// TidalDownloadInfo contains download URL and quality info type TidalDownloadInfo struct { URL string BitDepth int @@ -218,15 +216,13 @@ type tidalAPIResult struct { duration time.Duration } -// Tidal API timeout configuration // Mobile networks are more unstable, so we use longer timeouts const ( tidalAPITimeoutMobile = 25 * time.Second - tidalMaxRetries = 2 // Number of retries per API + tidalMaxRetries = 2 tidalRetryDelay = 500 * time.Millisecond ) -// fetchTidalURLWithRetry fetches download URL from a single Tidal API with retry logic func fetchTidalURLWithRetry(api string, trackID int64, quality string, timeout time.Duration) (TidalDownloadInfo, error) { var lastErr error retryDelay := tidalRetryDelay @@ -235,7 +231,7 @@ func fetchTidalURLWithRetry(api string, trackID int64, quality string, timeout t if attempt > 0 { GoLog("[Tidal] Retry %d/%d for %s after %v\n", attempt, tidalMaxRetries, api, retryDelay) time.Sleep(retryDelay) - retryDelay *= 2 // Exponential backoff + retryDelay *= 2 } client := NewHTTPClientWithTimeout(timeout) @@ -250,17 +246,15 @@ func fetchTidalURLWithRetry(api string, trackID int64, quality string, timeout t resp, err := client.Do(req) if err != nil { lastErr = err - // Check for retryable errors (timeout, connection reset) errStr := strings.ToLower(err.Error()) if strings.Contains(errStr, "timeout") || strings.Contains(errStr, "reset") || strings.Contains(errStr, "connection refused") || strings.Contains(errStr, "eof") { - continue // Retry + continue } - break // Non-retryable error + break } - // Server errors are retryable if resp.StatusCode >= 500 { io.Copy(io.Discard, resp.Body) resp.Body.Close() @@ -273,7 +267,7 @@ func fetchTidalURLWithRetry(api string, trackID int64, quality string, timeout t io.Copy(io.Discard, resp.Body) resp.Body.Close() lastErr = fmt.Errorf("rate limited") - retryDelay = 2 * time.Second // Wait longer for rate limit + retryDelay = 2 * time.Second continue } diff --git a/go_backend/youtube.go b/go_backend/youtube.go index bfbedcbb..e43d0e39 100644 --- a/go_backend/youtube.go +++ b/go_backend/youtube.go @@ -1,4 +1,3 @@ -// Package gobackend - YouTube download via Cobalt API (lossy-only provider) package gobackend import ( @@ -161,7 +160,6 @@ func parseYouTubeQualityInput(raw string) (format string, bitrate int, normalize } } -// SearchYouTube returns a YouTube Music search URL for the given track func (y *YouTubeDownloader) SearchYouTube(trackName, artistName string) (string, error) { query := fmt.Sprintf("%s %s", artistName, trackName) searchQuery := url.QueryEscape(query) @@ -213,7 +211,6 @@ func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQua return resp, nil } -// requestCobaltDirect sends a download request to the primary Cobalt API. func (y *YouTubeDownloader) requestCobaltDirect(videoURL, audioFormat, audioBitrate string) (*CobaltResponse, error) { reqBody := CobaltRequest{ URL: videoURL, @@ -470,7 +467,6 @@ func BuildYouTubeWatchURL(videoID string) string { return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID) } -// isYouTubeVideoID checks if s is an 11-char YouTube video ID func isYouTubeVideoID(s string) bool { if len(s) != 11 { return false @@ -707,7 +703,6 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) { GoLog("[YouTube] Downloading to: %s\n", outputPath) - // Parallel fetch cover art + lyrics var parallelResult *ParallelDownloadResult if req.EmbedLyrics || req.CoverURL != "" { GoLog("[YouTube] Starting parallel fetch for cover and lyrics...\n") diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 8dfaa378..f3c93676 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -492,13 +492,6 @@ import Gobackend // Import Go framework if let error = error { throw error } return response - case "getAmazonURLFromDeezerTrack": - let args = call.arguments as! [String: Any] - let deezerTrackId = args["deezer_track_id"] as! String - let response = GobackendGetAmazonURLFromDeezerTrack(deezerTrackId, &error) - if let error = error { throw error } - return response - case "preWarmTrackCache": let args = call.arguments as! [String: Any] let tracksJson = args["tracks"] as! String diff --git a/lib/app.dart b/lib/app.dart index 981c3bae..cf0ec25b 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -17,7 +17,6 @@ final _routerProvider = Provider((ref) { settingsProvider.select((s) => s.hasCompletedTutorial), ); - // Determine initial location based on app state String initialLocation; if (isFirstLaunch) { initialLocation = '/setup'; diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index 48a1c1e2..d423fac6 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.7.1'; - static const String buildNumber = '104'; + static const String version = '3.7.2'; + static const String buildNumber = '105'; static const String fullVersion = '$version+$buildNumber'; diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index b321656f..822982eb 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -763,7 +763,7 @@ abstract class AppLocalizations { /// App description in header card /// /// In en, this message translates to: - /// **'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'** + /// **'Download Spotify tracks in lossless quality from Tidal and Qobuz.'** String get aboutAppDescription; /// Section header for artist albums @@ -1576,7 +1576,7 @@ abstract class AppLocalizations { /// **'If a track is not available on the first provider, the app will automatically try the next one.'** String get providerPriorityInfo; - /// Label for built-in providers (Tidal/Qobuz/Amazon) + /// Label for built-in providers (Tidal/Qobuz) /// /// In en, this message translates to: /// **'Built-in'** @@ -3271,7 +3271,7 @@ abstract class AppLocalizations { /// Tutorial welcome tip 2 /// /// In en, this message translates to: - /// **'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'** + /// **'Get FLAC quality audio from Tidal, Qobuz, or Deezer'** String get tutorialWelcomeTip2; /// Tutorial welcome tip 3 diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 2b846be4..af8f7b73 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -365,7 +365,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get aboutAppDescription => - 'Lade Spotify-Titel in verlustfreier Qualität von Tidal, Qobuz und Amazon Music herunter.'; + 'Lade Spotify-Titel in verlustfreier Qualität von Tidal und Qobuz herunter.'; @override String get artistAlbums => 'Alben'; @@ -1826,7 +1826,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get tutorialWelcomeTip2 => - 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; + 'FLAC-Qualität von Tidal, Qobuz oder Deezer'; @override String get tutorialWelcomeTip3 => diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 8f244a7f..c98cf0ee 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -356,7 +356,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get aboutAppDescription => - 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; + 'Download Spotify tracks in lossless quality from Tidal and Qobuz.'; @override String get artistAlbums => 'Albums'; @@ -1809,7 +1809,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get tutorialWelcomeTip2 => - 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; + 'Get FLAC quality audio from Tidal, Qobuz, or Deezer'; @override String get tutorialWelcomeTip3 => diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 58ae25af..efc7a016 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -356,7 +356,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String get aboutAppDescription => - 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; + 'Download Spotify tracks in lossless quality from Tidal and Qobuz.'; @override String get artistAlbums => 'Albums'; @@ -1809,7 +1809,7 @@ class AppLocalizationsEs extends AppLocalizations { @override String get tutorialWelcomeTip2 => - 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; + 'Get FLAC quality audio from Tidal, Qobuz, or Deezer'; @override String get tutorialWelcomeTip3 => @@ -2705,7 +2705,7 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get aboutAppDescription => - 'Descarga pistas de Spotify con calidad sin pérdida de Tidal, Qobuz y Amazon Music.'; + 'Descarga pistas de Spotify con calidad sin pérdida de Tidal y Qobuz.'; @override String get artistAlbums => 'Álbumes'; @@ -4150,7 +4150,7 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { @override String get tutorialWelcomeTip2 => - 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; + 'Obtén audio en calidad FLAC de Tidal, Qobuz o Deezer'; @override String get tutorialWelcomeTip3 => diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index adf0d48d..89d94355 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -358,7 +358,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String get aboutAppDescription => - 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; + 'Download Spotify tracks in lossless quality from Tidal and Qobuz.'; @override String get artistAlbums => 'Albums'; @@ -1811,7 +1811,7 @@ class AppLocalizationsFr extends AppLocalizations { @override String get tutorialWelcomeTip2 => - 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; + 'Audio en qualité FLAC depuis Tidal, Qobuz ou Deezer'; @override String get tutorialWelcomeTip3 => diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index 50ca83db..5b6ff05f 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -356,7 +356,7 @@ class AppLocalizationsHi extends AppLocalizations { @override String get aboutAppDescription => - 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; + 'Download Spotify tracks in lossless quality from Tidal and Qobuz.'; @override String get artistAlbums => 'Albums'; @@ -1809,7 +1809,7 @@ class AppLocalizationsHi extends AppLocalizations { @override String get tutorialWelcomeTip2 => - 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; + 'Tidal, Qobuz, या Deezer से FLAC गुणवत्ता ऑडियो प्राप्त करें'; @override String get tutorialWelcomeTip3 => diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index ff3af0fa..4312a41a 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -359,7 +359,7 @@ class AppLocalizationsId extends AppLocalizations { @override String get aboutAppDescription => - 'Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.'; + 'Unduh lagu Spotify dalam kualitas lossless dari Tidal dan Qobuz.'; @override String get artistAlbums => 'Album'; @@ -1816,7 +1816,7 @@ class AppLocalizationsId extends AppLocalizations { @override String get tutorialWelcomeTip2 => - 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; + 'Dapatkan audio kualitas FLAC dari Tidal, Qobuz, atau Deezer'; @override String get tutorialWelcomeTip3 => diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index b473562c..9aa5a2fb 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -352,7 +352,7 @@ class AppLocalizationsJa extends AppLocalizations { @override String get aboutAppDescription => - 'Tidal、Qobuz、Amazon Music から Spotify のトラックをロスレス品質でダウンロードします。'; + 'Tidal、Qobuz から Spotify のトラックをロスレス品質でダウンロードします。'; @override String get artistAlbums => 'アルバム'; @@ -1795,8 +1795,7 @@ class AppLocalizationsJa extends AppLocalizations { 'Download music from Spotify, Deezer, or paste any supported URL'; @override - String get tutorialWelcomeTip2 => - 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; + String get tutorialWelcomeTip2 => 'Tidal、Qobuz、Deezer から FLAC 品質のオーディオを取得'; @override String get tutorialWelcomeTip3 => diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index f94d1f3f..3796e499 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -355,7 +355,7 @@ class AppLocalizationsKo extends AppLocalizations { @override String get aboutAppDescription => - 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; + 'Download Spotify tracks in lossless quality from Tidal and Qobuz.'; @override String get artistAlbums => 'Albums'; @@ -1807,8 +1807,7 @@ class AppLocalizationsKo extends AppLocalizations { 'Download music from Spotify, Deezer, or paste any supported URL'; @override - String get tutorialWelcomeTip2 => - 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; + String get tutorialWelcomeTip2 => 'Tidal, Qobuz 또는 Deezer에서 FLAC 품질 오디오 받기'; @override String get tutorialWelcomeTip3 => diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index be1be644..81ecfd12 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -356,7 +356,7 @@ class AppLocalizationsNl extends AppLocalizations { @override String get aboutAppDescription => - 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; + 'Download Spotify tracks in lossless quality from Tidal and Qobuz.'; @override String get artistAlbums => 'Albums'; @@ -1809,7 +1809,7 @@ class AppLocalizationsNl extends AppLocalizations { @override String get tutorialWelcomeTip2 => - 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; + 'Krijg FLAC-kwaliteit audio van Tidal, Qobuz of Deezer'; @override String get tutorialWelcomeTip3 => diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 499adcbb..24b813f3 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -356,7 +356,7 @@ class AppLocalizationsPt extends AppLocalizations { @override String get aboutAppDescription => - 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; + 'Download Spotify tracks in lossless quality from Tidal and Qobuz.'; @override String get artistAlbums => 'Albums'; @@ -1809,7 +1809,7 @@ class AppLocalizationsPt extends AppLocalizations { @override String get tutorialWelcomeTip2 => - 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; + 'Get FLAC quality audio from Tidal, Qobuz, or Deezer'; @override String get tutorialWelcomeTip3 => @@ -2705,7 +2705,7 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get aboutAppDescription => - 'Baixe faixas do Spotify em qualidade sem perdas do Tidal, Qobuz e Amazon Music.'; + 'Baixe faixas do Spotify em qualidade sem perdas do Tidal e Qobuz.'; @override String get artistAlbums => 'Álbuns'; @@ -4147,7 +4147,7 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { @override String get tutorialWelcomeTip2 => - 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; + 'Obtenha áudio em qualidade FLAC do Tidal, Qobuz ou Deezer'; @override String get tutorialWelcomeTip3 => diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 85af897d..77913294 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -363,7 +363,7 @@ class AppLocalizationsRu extends AppLocalizations { @override String get aboutAppDescription => - 'Скачайте треки Spotify в Lossless качестве из Tidal, Qobuz и Amazon Music.'; + 'Скачайте треки Spotify в Lossless качестве из Tidal и Qobuz.'; @override String get artistAlbums => 'Альбомы'; @@ -1855,8 +1855,7 @@ class AppLocalizationsRu extends AppLocalizations { 'Скачивайте музыку из Spotify, Deezer, или вставьте любой поддерживаемый URL'; @override - String get tutorialWelcomeTip2 => - 'Скачайте FLAC с Tidal, Qobuz или Amazon Music'; + String get tutorialWelcomeTip2 => 'Скачайте FLAC с Tidal, Qobuz или Deezer'; @override String get tutorialWelcomeTip3 => diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index bcc77961..53034492 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -361,7 +361,7 @@ class AppLocalizationsTr extends AppLocalizations { @override String get aboutAppDescription => - 'Spotify şarkılarını Tidal, Qobuz ve Amazon Music\'den yüksek kalitede indir.'; + 'Spotify şarkılarını Tidal ve Qobuz\'den yüksek kalitede indir.'; @override String get artistAlbums => 'Albümler'; @@ -1821,7 +1821,7 @@ class AppLocalizationsTr extends AppLocalizations { @override String get tutorialWelcomeTip2 => - 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; + 'Tidal, Qobuz veya Deezer\'den FLAC kalitesinde ses alın'; @override String get tutorialWelcomeTip3 => diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 433e771b..a4fc7e77 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -356,7 +356,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String get aboutAppDescription => - 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; + 'Download Spotify tracks in lossless quality from Tidal and Qobuz.'; @override String get artistAlbums => 'Albums'; @@ -1809,7 +1809,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String get tutorialWelcomeTip2 => - 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; + 'Get FLAC quality audio from Tidal, Qobuz, or Deezer'; @override String get tutorialWelcomeTip3 => @@ -2696,7 +2696,7 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { @override String get aboutAppDescription => - 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; + 'Download Spotify tracks in lossless quality from Tidal and Qobuz.'; @override String get artistAlbums => 'Albums'; @@ -4124,8 +4124,7 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { 'Download music from Spotify, Deezer, or paste any supported URL'; @override - String get tutorialWelcomeTip2 => - 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; + String get tutorialWelcomeTip2 => '从 Tidal、Qobuz 或 Deezer 获取 FLAC 品质音频'; @override String get tutorialWelcomeTip3 => @@ -4808,7 +4807,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { @override String get aboutAppDescription => - 'Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.'; + 'Download Spotify tracks in lossless quality from Tidal and Qobuz.'; @override String get artistAlbums => 'Albums'; @@ -6236,8 +6235,7 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { 'Download music from Spotify, Deezer, or paste any supported URL'; @override - String get tutorialWelcomeTip2 => - 'Get FLAC quality audio from Tidal, Qobuz, or Amazon Music'; + String get tutorialWelcomeTip2 => '從 Tidal、Qobuz 或 Deezer 取得 FLAC 品質音訊'; @override String get tutorialWelcomeTip3 => diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index b40c1f71..5e37729d 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -450,7 +450,7 @@ "@aboutSpotiSaverDesc": { "description": "Credit for SpotiSaver API" }, - "aboutAppDescription": "Lade Spotify-Titel in verlustfreier Qualität von Tidal, Qobuz und Amazon Music herunter.", + "aboutAppDescription": "Lade Spotify-Titel in verlustfreier Qualität von Tidal und Qobuz herunter.", "@aboutAppDescription": { "description": "App description in header card" }, @@ -1089,7 +1089,7 @@ }, "providerBuiltIn": "Integriert", "@providerBuiltIn": { - "description": "Label for built-in providers (Tidal/Qobuz/Amazon)" + "description": "Label for built-in providers (Tidal/Qobuz)" }, "providerExtension": "Erweiterung", "@providerExtension": { @@ -2358,7 +2358,7 @@ "@tutorialWelcomeTip1": { "description": "Tutorial welcome tip 1" }, - "tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music", + "tutorialWelcomeTip2": "FLAC-Qualität von Tidal, Qobuz oder Deezer", "@tutorialWelcomeTip2": { "description": "Tutorial welcome tip 2" }, diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index ca416fa0..7eee4649 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -450,7 +450,7 @@ "@aboutSpotiSaverDesc": { "description": "Credit for SpotiSaver API" }, - "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", + "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.", "@aboutAppDescription": { "description": "App description in header card" }, @@ -1097,7 +1097,7 @@ }, "providerBuiltIn": "Built-in", "@providerBuiltIn": { - "description": "Label for built-in providers (Tidal/Qobuz/Amazon)" + "description": "Label for built-in providers (Tidal/Qobuz)" }, "providerExtension": "Extension", "@providerExtension": { @@ -2383,7 +2383,7 @@ "@tutorialWelcomeTip1": { "description": "Tutorial welcome tip 1" }, - "tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music", + "tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Deezer", "@tutorialWelcomeTip2": { "description": "Tutorial welcome tip 2" }, diff --git a/lib/l10n/arb/app_es.arb b/lib/l10n/arb/app_es.arb index 497c11bc..45733fc8 100644 --- a/lib/l10n/arb/app_es.arb +++ b/lib/l10n/arb/app_es.arb @@ -402,7 +402,7 @@ "@aboutDabMusicDesc": { "description": "Credit for DAB Music API" }, - "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", + "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.", "@aboutAppDescription": { "description": "App description in header card" }, @@ -1005,7 +1005,7 @@ }, "providerBuiltIn": "Built-in", "@providerBuiltIn": { - "description": "Label for built-in providers (Tidal/Qobuz/Amazon)" + "description": "Label for built-in providers (Tidal/Qobuz)" }, "providerExtension": "Extension", "@providerExtension": { diff --git a/lib/l10n/arb/app_es_ES.arb b/lib/l10n/arb/app_es_ES.arb index 304b9cd8..f7d481c3 100644 --- a/lib/l10n/arb/app_es_ES.arb +++ b/lib/l10n/arb/app_es_ES.arb @@ -450,7 +450,7 @@ "@aboutSpotiSaverDesc": { "description": "Credit for SpotiSaver API" }, - "aboutAppDescription": "Descarga pistas de Spotify con calidad sin pérdida de Tidal, Qobuz y Amazon Music.", + "aboutAppDescription": "Descarga pistas de Spotify con calidad sin pérdida de Tidal y Qobuz.", "@aboutAppDescription": { "description": "App description in header card" }, @@ -1089,7 +1089,7 @@ }, "providerBuiltIn": "Integrado", "@providerBuiltIn": { - "description": "Label for built-in providers (Tidal/Qobuz/Amazon)" + "description": "Label for built-in providers (Tidal/Qobuz)" }, "providerExtension": "Extensión", "@providerExtension": { @@ -2358,7 +2358,7 @@ "@tutorialWelcomeTip1": { "description": "Tutorial welcome tip 1" }, - "tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music", + "tutorialWelcomeTip2": "Obtén audio en calidad FLAC de Tidal, Qobuz o Deezer", "@tutorialWelcomeTip2": { "description": "Tutorial welcome tip 2" }, diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index f7e9c2d1..83bd7f3f 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -450,7 +450,7 @@ "@aboutSpotiSaverDesc": { "description": "Credit for SpotiSaver API" }, - "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", + "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.", "@aboutAppDescription": { "description": "App description in header card" }, @@ -1089,7 +1089,7 @@ }, "providerBuiltIn": "Built-in", "@providerBuiltIn": { - "description": "Label for built-in providers (Tidal/Qobuz/Amazon)" + "description": "Label for built-in providers (Tidal/Qobuz)" }, "providerExtension": "Extension", "@providerExtension": { @@ -2358,7 +2358,7 @@ "@tutorialWelcomeTip1": { "description": "Tutorial welcome tip 1" }, - "tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music", + "tutorialWelcomeTip2": "Audio en qualité FLAC depuis Tidal, Qobuz ou Deezer", "@tutorialWelcomeTip2": { "description": "Tutorial welcome tip 2" }, diff --git a/lib/l10n/arb/app_hi.arb b/lib/l10n/arb/app_hi.arb index 064202f2..b7d2f1e5 100644 --- a/lib/l10n/arb/app_hi.arb +++ b/lib/l10n/arb/app_hi.arb @@ -450,7 +450,7 @@ "@aboutSpotiSaverDesc": { "description": "Credit for SpotiSaver API" }, - "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", + "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.", "@aboutAppDescription": { "description": "App description in header card" }, @@ -1089,7 +1089,7 @@ }, "providerBuiltIn": "Built-in", "@providerBuiltIn": { - "description": "Label for built-in providers (Tidal/Qobuz/Amazon)" + "description": "Label for built-in providers (Tidal/Qobuz)" }, "providerExtension": "Extension", "@providerExtension": { @@ -2358,7 +2358,7 @@ "@tutorialWelcomeTip1": { "description": "Tutorial welcome tip 1" }, - "tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music", + "tutorialWelcomeTip2": "Tidal, Qobuz, या Deezer से FLAC गुणवत्ता ऑडियो प्राप्त करें", "@tutorialWelcomeTip2": { "description": "Tutorial welcome tip 2" }, diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 0d7caf67..c6f8a7ac 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -450,7 +450,7 @@ "@aboutSpotiSaverDesc": { "description": "Credit for SpotiSaver API" }, - "aboutAppDescription": "Unduh lagu Spotify dalam kualitas lossless dari Tidal, Qobuz, dan Amazon Music.", + "aboutAppDescription": "Unduh lagu Spotify dalam kualitas lossless dari Tidal dan Qobuz.", "@aboutAppDescription": { "description": "App description in header card" }, @@ -1097,7 +1097,7 @@ }, "providerBuiltIn": "Bawaan", "@providerBuiltIn": { - "description": "Label for built-in providers (Tidal/Qobuz/Amazon)" + "description": "Label for built-in providers (Tidal/Qobuz)" }, "providerExtension": "Ekstensi", "@providerExtension": { @@ -2383,7 +2383,7 @@ "@tutorialWelcomeTip1": { "description": "Tutorial welcome tip 1" }, - "tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music", + "tutorialWelcomeTip2": "Dapatkan audio kualitas FLAC dari Tidal, Qobuz, atau Deezer", "@tutorialWelcomeTip2": { "description": "Tutorial welcome tip 2" }, diff --git a/lib/l10n/arb/app_ja.arb b/lib/l10n/arb/app_ja.arb index b23b0de3..e3498509 100644 --- a/lib/l10n/arb/app_ja.arb +++ b/lib/l10n/arb/app_ja.arb @@ -450,7 +450,7 @@ "@aboutSpotiSaverDesc": { "description": "Credit for SpotiSaver API" }, - "aboutAppDescription": "Tidal、Qobuz、Amazon Music から Spotify のトラックをロスレス品質でダウンロードします。", + "aboutAppDescription": "Tidal、Qobuz から Spotify のトラックをロスレス品質でダウンロードします。", "@aboutAppDescription": { "description": "App description in header card" }, @@ -1089,7 +1089,7 @@ }, "providerBuiltIn": "内蔵", "@providerBuiltIn": { - "description": "Label for built-in providers (Tidal/Qobuz/Amazon)" + "description": "Label for built-in providers (Tidal/Qobuz)" }, "providerExtension": "拡張", "@providerExtension": { @@ -2358,7 +2358,7 @@ "@tutorialWelcomeTip1": { "description": "Tutorial welcome tip 1" }, - "tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music", + "tutorialWelcomeTip2": "Tidal、Qobuz、Deezer から FLAC 品質のオーディオを取得", "@tutorialWelcomeTip2": { "description": "Tutorial welcome tip 2" }, diff --git a/lib/l10n/arb/app_ko.arb b/lib/l10n/arb/app_ko.arb index 22b488a3..0c2e172c 100644 --- a/lib/l10n/arb/app_ko.arb +++ b/lib/l10n/arb/app_ko.arb @@ -450,7 +450,7 @@ "@aboutSpotiSaverDesc": { "description": "Credit for SpotiSaver API" }, - "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", + "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.", "@aboutAppDescription": { "description": "App description in header card" }, @@ -1089,7 +1089,7 @@ }, "providerBuiltIn": "Built-in", "@providerBuiltIn": { - "description": "Label for built-in providers (Tidal/Qobuz/Amazon)" + "description": "Label for built-in providers (Tidal/Qobuz)" }, "providerExtension": "Extension", "@providerExtension": { @@ -2358,7 +2358,7 @@ "@tutorialWelcomeTip1": { "description": "Tutorial welcome tip 1" }, - "tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music", + "tutorialWelcomeTip2": "Tidal, Qobuz 또는 Deezer에서 FLAC 품질 오디오 받기", "@tutorialWelcomeTip2": { "description": "Tutorial welcome tip 2" }, diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index 1fd0bec6..7699c63c 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -450,7 +450,7 @@ "@aboutSpotiSaverDesc": { "description": "Credit for SpotiSaver API" }, - "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", + "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.", "@aboutAppDescription": { "description": "App description in header card" }, @@ -1089,7 +1089,7 @@ }, "providerBuiltIn": "Built-in", "@providerBuiltIn": { - "description": "Label for built-in providers (Tidal/Qobuz/Amazon)" + "description": "Label for built-in providers (Tidal/Qobuz)" }, "providerExtension": "Extension", "@providerExtension": { @@ -2358,7 +2358,7 @@ "@tutorialWelcomeTip1": { "description": "Tutorial welcome tip 1" }, - "tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music", + "tutorialWelcomeTip2": "Krijg FLAC-kwaliteit audio van Tidal, Qobuz of Deezer", "@tutorialWelcomeTip2": { "description": "Tutorial welcome tip 2" }, diff --git a/lib/l10n/arb/app_pt.arb b/lib/l10n/arb/app_pt.arb index 503247dd..27a0b5e2 100644 --- a/lib/l10n/arb/app_pt.arb +++ b/lib/l10n/arb/app_pt.arb @@ -402,7 +402,7 @@ "@aboutDabMusicDesc": { "description": "Credit for DAB Music API" }, - "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", + "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.", "@aboutAppDescription": { "description": "App description in header card" }, @@ -1005,7 +1005,7 @@ }, "providerBuiltIn": "Built-in", "@providerBuiltIn": { - "description": "Label for built-in providers (Tidal/Qobuz/Amazon)" + "description": "Label for built-in providers (Tidal/Qobuz)" }, "providerExtension": "Extension", "@providerExtension": { diff --git a/lib/l10n/arb/app_pt_PT.arb b/lib/l10n/arb/app_pt_PT.arb index 8d844396..73190e72 100644 --- a/lib/l10n/arb/app_pt_PT.arb +++ b/lib/l10n/arb/app_pt_PT.arb @@ -450,7 +450,7 @@ "@aboutSpotiSaverDesc": { "description": "Credit for SpotiSaver API" }, - "aboutAppDescription": "Baixe faixas do Spotify em qualidade sem perdas do Tidal, Qobuz e Amazon Music.", + "aboutAppDescription": "Baixe faixas do Spotify em qualidade sem perdas do Tidal e Qobuz.", "@aboutAppDescription": { "description": "App description in header card" }, @@ -1089,7 +1089,7 @@ }, "providerBuiltIn": "Embutido", "@providerBuiltIn": { - "description": "Label for built-in providers (Tidal/Qobuz/Amazon)" + "description": "Label for built-in providers (Tidal/Qobuz)" }, "providerExtension": "Extensão", "@providerExtension": { @@ -2358,7 +2358,7 @@ "@tutorialWelcomeTip1": { "description": "Tutorial welcome tip 1" }, - "tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music", + "tutorialWelcomeTip2": "Obtenha áudio em qualidade FLAC do Tidal, Qobuz ou Deezer", "@tutorialWelcomeTip2": { "description": "Tutorial welcome tip 2" }, diff --git a/lib/l10n/arb/app_ru.arb b/lib/l10n/arb/app_ru.arb index 4ea4e618..c68667ff 100644 --- a/lib/l10n/arb/app_ru.arb +++ b/lib/l10n/arb/app_ru.arb @@ -450,7 +450,7 @@ "@aboutSpotiSaverDesc": { "description": "Credit for SpotiSaver API" }, - "aboutAppDescription": "Скачайте треки Spotify в Lossless качестве из Tidal, Qobuz и Amazon Music.", + "aboutAppDescription": "Скачайте треки Spotify в Lossless качестве из Tidal и Qobuz.", "@aboutAppDescription": { "description": "App description in header card" }, @@ -1089,7 +1089,7 @@ }, "providerBuiltIn": "Встроенные", "@providerBuiltIn": { - "description": "Label for built-in providers (Tidal/Qobuz/Amazon)" + "description": "Label for built-in providers (Tidal/Qobuz)" }, "providerExtension": "Расширение", "@providerExtension": { @@ -2358,7 +2358,7 @@ "@tutorialWelcomeTip1": { "description": "Tutorial welcome tip 1" }, - "tutorialWelcomeTip2": "Скачайте FLAC с Tidal, Qobuz или Amazon Music", + "tutorialWelcomeTip2": "Скачайте FLAC с Tidal, Qobuz или Deezer", "@tutorialWelcomeTip2": { "description": "Tutorial welcome tip 2" }, diff --git a/lib/l10n/arb/app_tr.arb b/lib/l10n/arb/app_tr.arb index b7b16c7f..7b808ec4 100644 --- a/lib/l10n/arb/app_tr.arb +++ b/lib/l10n/arb/app_tr.arb @@ -450,7 +450,7 @@ "@aboutSpotiSaverDesc": { "description": "Credit for SpotiSaver API" }, - "aboutAppDescription": "Spotify şarkılarını Tidal, Qobuz ve Amazon Music'den yüksek kalitede indir.", + "aboutAppDescription": "Spotify şarkılarını Tidal ve Qobuz'den yüksek kalitede indir.", "@aboutAppDescription": { "description": "App description in header card" }, @@ -1089,7 +1089,7 @@ }, "providerBuiltIn": "Dahili", "@providerBuiltIn": { - "description": "Label for built-in providers (Tidal/Qobuz/Amazon)" + "description": "Label for built-in providers (Tidal/Qobuz)" }, "providerExtension": "Eklenti", "@providerExtension": { @@ -2358,7 +2358,7 @@ "@tutorialWelcomeTip1": { "description": "Tutorial welcome tip 1" }, - "tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music", + "tutorialWelcomeTip2": "Tidal, Qobuz veya Deezer'den FLAC kalitesinde ses alın", "@tutorialWelcomeTip2": { "description": "Tutorial welcome tip 2" }, diff --git a/lib/l10n/arb/app_zh.arb b/lib/l10n/arb/app_zh.arb index f6f4895f..453c9f5c 100644 --- a/lib/l10n/arb/app_zh.arb +++ b/lib/l10n/arb/app_zh.arb @@ -402,7 +402,7 @@ "@aboutDabMusicDesc": { "description": "Credit for DAB Music API" }, - "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", + "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.", "@aboutAppDescription": { "description": "App description in header card" }, @@ -1005,7 +1005,7 @@ }, "providerBuiltIn": "Built-in", "@providerBuiltIn": { - "description": "Label for built-in providers (Tidal/Qobuz/Amazon)" + "description": "Label for built-in providers (Tidal/Qobuz)" }, "providerExtension": "Extension", "@providerExtension": { diff --git a/lib/l10n/arb/app_zh_CN.arb b/lib/l10n/arb/app_zh_CN.arb index 67d8b58c..e649da18 100644 --- a/lib/l10n/arb/app_zh_CN.arb +++ b/lib/l10n/arb/app_zh_CN.arb @@ -450,7 +450,7 @@ "@aboutSpotiSaverDesc": { "description": "Credit for SpotiSaver API" }, - "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", + "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.", "@aboutAppDescription": { "description": "App description in header card" }, @@ -1089,7 +1089,7 @@ }, "providerBuiltIn": "Built-in", "@providerBuiltIn": { - "description": "Label for built-in providers (Tidal/Qobuz/Amazon)" + "description": "Label for built-in providers (Tidal/Qobuz)" }, "providerExtension": "Extension", "@providerExtension": { @@ -2358,7 +2358,7 @@ "@tutorialWelcomeTip1": { "description": "Tutorial welcome tip 1" }, - "tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music", + "tutorialWelcomeTip2": "从 Tidal、Qobuz 或 Deezer 获取 FLAC 品质音频", "@tutorialWelcomeTip2": { "description": "Tutorial welcome tip 2" }, diff --git a/lib/l10n/arb/app_zh_TW.arb b/lib/l10n/arb/app_zh_TW.arb index d230aa50..b9349870 100644 --- a/lib/l10n/arb/app_zh_TW.arb +++ b/lib/l10n/arb/app_zh_TW.arb @@ -450,7 +450,7 @@ "@aboutSpotiSaverDesc": { "description": "Credit for SpotiSaver API" }, - "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal, Qobuz, and Amazon Music.", + "aboutAppDescription": "Download Spotify tracks in lossless quality from Tidal and Qobuz.", "@aboutAppDescription": { "description": "App description in header card" }, @@ -1089,7 +1089,7 @@ }, "providerBuiltIn": "Built-in", "@providerBuiltIn": { - "description": "Label for built-in providers (Tidal/Qobuz/Amazon)" + "description": "Label for built-in providers (Tidal/Qobuz)" }, "providerExtension": "Extension", "@providerExtension": { @@ -2358,7 +2358,7 @@ "@tutorialWelcomeTip1": { "description": "Tutorial welcome tip 1" }, - "tutorialWelcomeTip2": "Get FLAC quality audio from Tidal, Qobuz, or Amazon Music", + "tutorialWelcomeTip2": "從 Tidal、Qobuz 或 Deezer 取得 FLAC 品質音訊", "@tutorialWelcomeTip2": { "description": "Tutorial welcome tip 2" }, diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 545e7440..d42b9fa6 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -55,17 +55,14 @@ class AppSettings { final String songLinkRegion; // SongLink userCountry region code used for platform lookup - // Local Library Settings final bool localLibraryEnabled; // Enable local library scanning final String localLibraryPath; // Path to scan for audio files final bool localLibraryShowDuplicates; // Show indicator when searching for existing tracks - // Tutorial/Onboarding final bool hasCompletedTutorial; // Track if user has completed the app tutorial - // Lyrics Provider Settings final List lyricsProviders; // Ordered list of enabled lyrics provider IDs final bool @@ -77,7 +74,6 @@ class AppSettings { final String musixmatchLanguage; // Optional ISO language code for Musixmatch localized lyrics - // Version upgrade tracking final String lastSeenVersion; // Last app version the user has acknowledged (e.g. '3.7.0') @@ -124,13 +120,10 @@ class AppSettings { this.downloadNetworkMode = 'any', this.networkCompatibilityMode = false, this.songLinkRegion = 'US', - // Local Library defaults this.localLibraryEnabled = false, this.localLibraryPath = '', this.localLibraryShowDuplicates = true, - // Tutorial default this.hasCompletedTutorial = false, - // Lyrics providers default order this.lyricsProviders = const [ 'lrclib', 'spotify_api', @@ -143,7 +136,6 @@ class AppSettings { this.lyricsIncludeRomanizationNetease = false, this.lyricsMultiPersonWordByWord = false, this.musixmatchLanguage = '', - // Version upgrade tracking this.lastSeenVersion = '', }); @@ -191,19 +183,15 @@ class AppSettings { String? downloadNetworkMode, bool? networkCompatibilityMode, String? songLinkRegion, - // Local Library bool? localLibraryEnabled, String? localLibraryPath, bool? localLibraryShowDuplicates, - // Tutorial bool? hasCompletedTutorial, - // Lyrics providers List? lyricsProviders, bool? lyricsIncludeTranslationNetease, bool? lyricsIncludeRomanizationNetease, bool? lyricsMultiPersonWordByWord, String? musixmatchLanguage, - // Version upgrade tracking String? lastSeenVersion, }) { return AppSettings( @@ -259,14 +247,11 @@ class AppSettings { networkCompatibilityMode: networkCompatibilityMode ?? this.networkCompatibilityMode, songLinkRegion: songLinkRegion ?? this.songLinkRegion, - // Local Library localLibraryEnabled: localLibraryEnabled ?? this.localLibraryEnabled, localLibraryPath: localLibraryPath ?? this.localLibraryPath, localLibraryShowDuplicates: localLibraryShowDuplicates ?? this.localLibraryShowDuplicates, - // Tutorial hasCompletedTutorial: hasCompletedTutorial ?? this.hasCompletedTutorial, - // Lyrics providers lyricsProviders: lyricsProviders ?? this.lyricsProviders, lyricsIncludeTranslationNetease: lyricsIncludeTranslationNetease ?? @@ -277,7 +262,6 @@ class AppSettings { lyricsMultiPersonWordByWord: lyricsMultiPersonWordByWord ?? this.lyricsMultiPersonWordByWord, musixmatchLanguage: musixmatchLanguage ?? this.musixmatchLanguage, - // Version upgrade tracking lastSeenVersion: lastSeenVersion ?? this.lastSeenVersion, ); } diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 4f2059db..999a65a0 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -592,10 +592,8 @@ class DownloadHistoryNotifier extends Notifier { return 0; } - // Delete from database final deletedCount = await _db.deleteByIds(orphanedIds); - // Update in-memory state final orphanedSet = orphanedIds.toSet(); state = state.copyWith( items: state.items @@ -1596,18 +1594,12 @@ class DownloadQueueNotifier extends Notifier { } String _determineOutputExt(String quality, String service) { - // YouTube provider - lossy only (Opus or MP3) if (service.toLowerCase() == 'youtube') { if (quality.toLowerCase().contains('mp3')) { return '.mp3'; } 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'; } @@ -2903,7 +2895,6 @@ class DownloadQueueNotifier extends Notifier { failedCount: _failedInSession, ); - // Auto-export failed downloads if enabled final settings = ref.read(settingsProvider); if (settings.autoExportFailedDownloads && _failedInSession > 0) { final exportPath = await exportFailedDownloads(); @@ -3206,7 +3197,6 @@ class DownloadQueueNotifier extends Notifier { !trackToDownload.id.startsWith('deezer:') && !trackToDownload.id.startsWith('extension:')) { try { - // Extract clean Spotify ID (remove spotify: prefix if present) String spotifyId = trackToDownload.id; if (spotifyId.startsWith('spotify:track:')) { spotifyId = spotifyId.split(':').last; @@ -3508,9 +3498,8 @@ class DownloadQueueNotifier extends Notifier { if (!wasExisting && decryptionKey.isNotEmpty && - filePath != null && - actualService == 'amazon') { - _log.i('Amazon encrypted stream detected, decrypting via FFmpeg...'); + filePath != null) { + _log.i('Encrypted stream detected, decrypting via FFmpeg...'); updateItemStatus(item.id, DownloadStatus.downloading, progress: 0.9); if (effectiveSafMode && isContentUri(filePath)) { @@ -3539,7 +3528,7 @@ class DownloadQueueNotifier extends Notifier { updateItemStatus( item.id, DownloadStatus.failed, - error: 'Failed to decrypt Amazon stream', + error: 'Failed to decrypt encrypted stream', errorType: DownloadErrorType.unknown, ); return; @@ -3564,7 +3553,7 @@ class DownloadQueueNotifier extends Notifier { ); if (newUri == null) { - _log.e('Failed to write decrypted Amazon stream back to SAF'); + _log.e('Failed to write decrypted stream back to SAF'); updateItemStatus( item.id, DownloadStatus.failed, @@ -3579,7 +3568,7 @@ class DownloadQueueNotifier extends Notifier { } filePath = newUri; finalSafFileName = newFileName; - _log.i('Amazon SAF decryption completed'); + _log.i('SAF decryption completed'); } finally { try { await File(tempPath).delete(); @@ -3601,7 +3590,7 @@ class DownloadQueueNotifier extends Notifier { updateItemStatus( item.id, DownloadStatus.failed, - error: 'Failed to decrypt Amazon stream', + error: 'Failed to decrypt encrypted stream', errorType: DownloadErrorType.unknown, ); try { @@ -3610,7 +3599,7 @@ class DownloadQueueNotifier extends Notifier { return; } filePath = decryptedPath; - _log.i('Amazon local decryption completed'); + _log.i('Local decryption completed'); } } @@ -3832,7 +3821,6 @@ class DownloadQueueNotifier extends Notifier { } } } else { - // Local file path flow (original) if (quality == 'HIGH') { final tidalHighFormat = settings.tidalHighFormat; _log.i( @@ -4049,10 +4037,9 @@ class DownloadQueueNotifier extends Notifier { !effectiveSafMode && isFlacFile && !wasExisting && - actualService == 'amazon' && decryptionKey.isNotEmpty) { _log.d( - 'Local FLAC after Amazon decrypt detected, embedding metadata and cover...', + 'Local FLAC after decrypt detected, embedding metadata and cover...', ); try { updateItemStatus( @@ -4112,7 +4099,6 @@ class DownloadQueueNotifier extends Notifier { final isContentUriPath = isContentUri(filePath); if (isContentUriPath && effectiveSafMode) { - // SAF mode: copy to temp, embed, write back final tempPath = await _copySafToTemp(filePath); if (tempPath != null) { try { @@ -4133,7 +4119,6 @@ class DownloadQueueNotifier extends Notifier { copyright: backendCopyright, ); } - // Write back to SAF final ext = isMp3File ? '.mp3' : '.opus'; final newFileName = '${safBaseName ?? 'track'}$ext'; final newUri = await _writeTempToSaf( @@ -4162,7 +4147,6 @@ class DownloadQueueNotifier extends Notifier { } } } else { - // Non-SAF mode: embed directly try { if (isMp3File) { await _embedMetadataToMp3( diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart index 1b55f744..f53aca0c 100644 --- a/lib/providers/extension_provider.dart +++ b/lib/providers/extension_provider.dart @@ -757,7 +757,6 @@ class ExtensionNotifier extends Notifier { Future loadProviderPriority() async { try { - // Load from SharedPreferences first (persisted) final prefs = await SharedPreferences.getInstance(); final savedJson = prefs.getString(_providerPriorityKey); @@ -768,10 +767,8 @@ class ExtensionNotifier extends Notifier { priority = _sanitizeDownloadProviderPriority(priority); _log.d('Loaded provider priority from prefs: $priority'); await prefs.setString(_providerPriorityKey, jsonEncode(priority)); - // Sync to Go backend await PlatformBridge.setProviderPriority(priority); } else { - // Fallback to Go backend default priority = await PlatformBridge.getProviderPriority(); priority = _sanitizeDownloadProviderPriority(priority); await PlatformBridge.setProviderPriority(priority); @@ -787,11 +784,9 @@ class ExtensionNotifier extends Notifier { Future setProviderPriority(List priority) async { try { final sanitized = _sanitizeDownloadProviderPriority(priority); - // Save to SharedPreferences for persistence final prefs = await SharedPreferences.getInstance(); await prefs.setString(_providerPriorityKey, jsonEncode(sanitized)); - // Sync to Go backend await PlatformBridge.setProviderPriority(sanitized); state = state.copyWith(providerPriority: sanitized); _log.d('Saved provider priority: $sanitized'); @@ -811,7 +806,7 @@ class ExtensionNotifier extends Notifier { } } - for (final provider in const ['tidal', 'qobuz', 'amazon', 'deezer']) { + for (final provider in const ['tidal', 'qobuz', 'deezer']) { if (!result.contains(provider)) { result.add(provider); } @@ -822,7 +817,6 @@ class ExtensionNotifier extends Notifier { Future loadMetadataProviderPriority() async { try { - // Load from SharedPreferences first (persisted) final prefs = await SharedPreferences.getInstance(); final savedJson = prefs.getString(_metadataProviderPriorityKey); @@ -831,10 +825,8 @@ class ExtensionNotifier extends Notifier { final saved = jsonDecode(savedJson) as List; priority = saved.map((e) => e as String).toList(); _log.d('Loaded metadata provider priority from prefs: $priority'); - // Sync to Go backend await PlatformBridge.setMetadataProviderPriority(priority); } else { - // Fallback to Go backend default priority = await PlatformBridge.getMetadataProviderPriority(); _log.d('Using default metadata provider priority: $priority'); } @@ -847,11 +839,9 @@ class ExtensionNotifier extends Notifier { Future setMetadataProviderPriority(List priority) async { try { - // Save to SharedPreferences for persistence final prefs = await SharedPreferences.getInstance(); await prefs.setString(_metadataProviderPriorityKey, jsonEncode(priority)); - // Sync to Go backend await PlatformBridge.setMetadataProviderPriority(priority); state = state.copyWith(metadataProviderPriority: priority); _log.d('Saved metadata provider priority: $priority'); @@ -880,7 +870,7 @@ class ExtensionNotifier extends Notifier { } List getAllDownloadProviders() { - final providers = ['tidal', 'qobuz', 'amazon', 'deezer']; + final providers = ['tidal', 'qobuz', 'deezer']; for (final ext in state.extensions) { if (ext.enabled && ext.hasDownloadProvider) { providers.add(ext.id); diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index 32022b81..271f22f7 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -204,7 +204,6 @@ class TrackNotifier extends Notifier { state = TrackState(isLoading: true, hasSearchText: state.hasSearchText); try { - // Step 1: Check for extension URL handlers first (handles YT Music, etc.) final extensionHandler = await PlatformBridge.findURLHandler(url); if (extensionHandler != null) { _log.i('Found extension URL handler: $extensionHandler for URL: $url'); @@ -215,7 +214,6 @@ class TrackNotifier extends Notifier { result = await PlatformBridge.handleURLWithExtension(url); if (!_isRequestValid(requestId)) return; - // Check if we got valid data if (result != null && result['type'] == 'track' && result['track'] != null) { @@ -321,7 +319,6 @@ class TrackNotifier extends Notifier { } } - // Step 2: Try Deezer URL parsing if (url.contains('deezer.com') || url.contains('deezer.page.link')) { _log.i('Detected Deezer URL, parsing...'); final parsed = await PlatformBridge.parseDeezerUrl(url); @@ -387,7 +384,6 @@ class TrackNotifier extends Notifier { return; } - // Step 3: Try Tidal URL parsing if (url.contains('tidal.com')) { _log.i('Detected Tidal URL, parsing...'); final parsed = await PlatformBridge.parseTidalUrl(url); @@ -461,7 +457,6 @@ class TrackNotifier extends Notifier { return; } - // Step 4: Fall back to Spotify parsing final parsed = await PlatformBridge.parseSpotifyUrl(url); if (!_isRequestValid(requestId)) return; diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index a5411ae3..f226a80f 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -305,7 +305,6 @@ class _AlbumScreenState extends ConsumerState { background: Stack( fit: StackFit.expand, children: [ - // Full-screen cover background (no blur, full resolution) if (widget.coverUrl != null) CachedNetworkImage( imageUrl: @@ -326,7 +325,6 @@ class _AlbumScreenState extends ConsumerState { color: colorScheme.onSurfaceVariant, ), ), - // Bottom gradient for readability Positioned( left: 0, right: 0, @@ -345,7 +343,6 @@ class _AlbumScreenState extends ConsumerState { ), ), ), - // Album info overlay at bottom Positioned( left: 20, right: 20, diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 43cfa2eb..cdf58522 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -21,7 +21,6 @@ import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; -/// Simple in-memory cache for artist data class _ArtistCache { static final Map _cache = {}; static const Duration _ttl = Duration(minutes: 10); @@ -69,7 +68,6 @@ class _CacheEntry { }); } -/// Artist screen with Spotify-like design class ArtistScreen extends ConsumerStatefulWidget { final String artistId; final String artistName; @@ -717,7 +715,6 @@ class _ArtistScreenState extends ConsumerState { ), ), const Divider(height: 1), - // Options if (albums.isNotEmpty) _DiscographyOptionTile( icon: Icons.library_music, @@ -830,7 +827,7 @@ class _ArtistScreenState extends ConsumerState { int failedCount = 0; for (final album in albums) { - if (!_isFetchingDiscography) break; // Cancelled + if (!_isFetchingDiscography) break; try { final tracks = await _fetchAlbumTracks(album); @@ -1066,7 +1063,7 @@ class _ArtistScreenState extends ConsumerState { CachedNetworkImage( imageUrl: imageUrl, fit: BoxFit.cover, - alignment: Alignment.topCenter, // Show top of image (faces) + alignment: Alignment.topCenter, memCacheWidth: 800, cacheManager: CoverCacheManager.instance, placeholder: (context, url) => @@ -1155,7 +1152,6 @@ class _ArtistScreenState extends ConsumerState { ], ), ), - // Download Discography button (icon only, right-aligned) if (hasDiscography && !_isSelectionMode) ...[ const SizedBox(width: 12), Container( @@ -1201,7 +1197,6 @@ class _ArtistScreenState extends ConsumerState { ); } - /// Build Popular tracks section like Spotify Widget _buildPopularSection(ColorScheme colorScheme) { if (_topTracks == null || _topTracks!.isEmpty) { return const SizedBox.shrink(); @@ -1416,7 +1411,6 @@ class _ArtistScreenState extends ConsumerState { ); } - /// Handle tap on popular track item void _handlePopularTrackTap(Track track, {required bool isQueued}) async { if (isQueued) return; @@ -1636,7 +1630,6 @@ class _ArtistScreenState extends ConsumerState { ), ), ), - // Selection overlay if (_isSelectionMode) Positioned.fill( child: AnimatedContainer( @@ -1652,7 +1645,6 @@ class _ArtistScreenState extends ConsumerState { ), ), ), - // Checkbox if (_isSelectionMode) Positioned( top: 8, diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 0db150eb..425dfd4b 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -18,7 +18,6 @@ import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart'; -/// Screen to display downloaded tracks from a specific album class DownloadedAlbumScreen extends ConsumerStatefulWidget { final String albumName; final String artistName; @@ -361,7 +360,6 @@ class _DownloadedAlbumScreenState extends ConsumerState { ); final tracks = _getAlbumTracks(allHistoryItems); - // Show empty state if no tracks found if (tracks.isEmpty) { return Scaffold( appBar: AppBar(title: Text(widget.albumName)), @@ -480,7 +478,6 @@ class _DownloadedAlbumScreenState extends ConsumerState { background: Stack( fit: StackFit.expand, children: [ - // Full-screen cover background if (embeddedCoverPath != null) Image.file( File(embeddedCoverPath), @@ -508,7 +505,6 @@ class _DownloadedAlbumScreenState extends ConsumerState { color: colorScheme.onSurfaceVariant, ), ), - // Bottom gradient for readability Positioned( left: 0, right: 0, @@ -527,7 +523,6 @@ class _DownloadedAlbumScreenState extends ConsumerState { ), ), ), - // Album info overlay at bottom Positioned( left: 20, right: 20, @@ -711,10 +706,8 @@ class _DownloadedAlbumScreenState extends ConsumerState { final discTracks = discMap[discNumber]; if (discTracks == null || discTracks.isEmpty) continue; - // Add disc separator children.add(_buildDiscSeparator(context, colorScheme, discNumber)); - // Add tracks for this disc for (final track in discTracks) { children.add( KeyedSubtree( @@ -897,7 +890,6 @@ class _DownloadedAlbumScreenState extends ConsumerState { return; } - // Share SAF content URIs via native intent if (safUris.isNotEmpty) { try { if (safUris.length == 1) { @@ -908,13 +900,11 @@ class _DownloadedAlbumScreenState extends ConsumerState { } catch (_) {} } - // Share regular files via SharePlus if (filesToShare.isNotEmpty) { await SharePlus.instance.share(ShareParams(files: filesToShare)); } } - /// Show batch convert bottom sheet void _showBatchConvertSheet( BuildContext context, List allTracks, @@ -1388,7 +1378,6 @@ class _DownloadedAlbumScreenState extends ConsumerState { ), const SizedBox(height: 12), - // Action buttons row: Share, Convert Row( children: [ Expanded( diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 6462d952..b20d7b90 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -520,7 +520,6 @@ class _HomeTabState extends ConsumerState final settings = ref.read(settingsProvider); final extState = ref.read(extensionProvider); final searchProvider = settings.searchProvider; - // Use filterOverride if provided, otherwise read from state final selectedFilter = filterOverride ?? ref.read(trackProvider).selectedSearchFilter; @@ -535,7 +534,6 @@ class _HomeTabState extends ConsumerState extState.extensions.any((e) => e.id == searchProvider && e.enabled); if (isExtensionEnabled) { - // Build options with filter if selected Map? options; if (selectedFilter != null) { options = {'filter': selectedFilter}; @@ -1116,7 +1114,6 @@ class _HomeTabState extends ConsumerState ), ), - // Search filter bar (only shown when has search results) if (hasActualResults && !showRecentAccess) Consumer( builder: (context, ref, _) { @@ -1286,8 +1283,8 @@ class _HomeTabState extends ConsumerState ), ], ), - ), // Close RefreshIndicator - ), // Close GestureDetector + ), + ), ); } @@ -1434,7 +1431,6 @@ class _HomeTabState extends ConsumerState return _buildExploreSection(sections[sectionIndex], colorScheme); } - // Bottom padding return const SizedBox(height: 16); }, childCount: totalCount), ), @@ -2705,7 +2701,6 @@ class _HomeTabState extends ConsumerState scrollDirection: Axis.horizontal, child: Row( children: [ - // "All" chip (no filter) Padding( padding: const EdgeInsets.only(right: 8), child: FilterChip( @@ -2728,7 +2723,6 @@ class _HomeTabState extends ConsumerState ), ), ), - // Filter chips from extension ...filters.map((filter) { final isSelected = selectedFilter == filter.id; return Padding( @@ -2830,7 +2824,6 @@ class _HomeTabState extends ConsumerState prefixIcon: _SearchProviderDropdown( onProviderChanged: () { _lastSearchQuery = null; - // Reset filter when provider changes ref.read(trackProvider.notifier).setSearchFilter(null); setState(() {}); final text = _urlController.text.trim(); diff --git a/lib/screens/library_playlists_screen.dart b/lib/screens/library_playlists_screen.dart index 2f07b435..df68b16f 100644 --- a/lib/screens/library_playlists_screen.dart +++ b/lib/screens/library_playlists_screen.dart @@ -158,7 +158,6 @@ class LibraryPlaylistsScreen extends ConsumerWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - // Header: drag handle + thumbnail + playlist info Column( children: [ const SizedBox(height: 8), @@ -210,7 +209,6 @@ class LibraryPlaylistsScreen extends ConsumerWidget { color: colorScheme.outlineVariant.withValues(alpha: 0.5), ), - // Rename _PlaylistOptionTile( icon: Icons.edit_outlined, title: context.l10n.collectionRenamePlaylist, @@ -225,7 +223,6 @@ class LibraryPlaylistsScreen extends ConsumerWidget { }, ), - // Change cover _PlaylistOptionTile( icon: Icons.image_outlined, title: context.l10n.collectionPlaylistChangeCover, @@ -235,7 +232,6 @@ class LibraryPlaylistsScreen extends ConsumerWidget { }, ), - // Delete _PlaylistOptionTile( icon: Icons.delete_outline, iconColor: colorScheme.error, diff --git a/lib/screens/library_tracks_folder_screen.dart b/lib/screens/library_tracks_folder_screen.dart index d2442c22..ab437ec6 100644 --- a/lib/screens/library_tracks_folder_screen.dart +++ b/lib/screens/library_tracks_folder_screen.dart @@ -37,7 +37,6 @@ class _LibraryTracksFolderScreenState bool _showTitleInAppBar = false; final ScrollController _scrollController = ScrollController(); - // ── Multi-select state ── bool _isSelectionMode = false; final Set _selectedKeys = {}; @@ -145,8 +144,6 @@ class _LibraryTracksFolderScreenState return url; } - // ── Selection helpers ── - void _enterSelectionMode(String key) { HapticFeedback.mediumImpact(); setState(() { @@ -181,8 +178,6 @@ class _LibraryTracksFolderScreenState }); } - // ── Batch actions ── - Future _removeSelected(List entries) async { final keysToRemove = _selectedKeys.toSet(); if (keysToRemove.isEmpty) return; @@ -426,7 +421,6 @@ class _LibraryTracksFolderScreenState child: Column( mainAxisSize: MainAxisSize.min, children: [ - // Drag handle Container( width: 32, height: 4, @@ -437,7 +431,6 @@ class _LibraryTracksFolderScreenState ), ), - // Header: [X close] [count] [Select All / Deselect] Row( children: [ IconButton.filledTonal( @@ -493,7 +486,6 @@ class _LibraryTracksFolderScreenState const SizedBox(height: 12), - // Action buttons row Row( children: [ if (isWishlist) @@ -525,7 +517,6 @@ class _LibraryTracksFolderScreenState const SizedBox(height: 8), - // Remove button (full width, red) SizedBox( width: double.infinity, child: FilledButton.icon( @@ -714,7 +705,6 @@ class _LibraryTracksFolderScreenState ) else coverFallback, - // Bottom gradient for readability Positioned( left: 0, right: 0, @@ -733,7 +723,6 @@ class _LibraryTracksFolderScreenState ), ), ), - // Title and track count overlay Positioned( left: 20, right: 20, @@ -829,8 +818,6 @@ class _LibraryTracksFolderScreenState ); } - // ── Header actions ── - Widget _buildHeaderActionPlaceholder() => const SizedBox(width: 48, height: 48); Widget _buildDownloadAllCenterButton(List entries) { @@ -1263,7 +1250,6 @@ class _CollectionTrackTile extends ConsumerWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - // Header: drag handle + cover + track info Column( children: [ const SizedBox(height: 8), diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index 83298d72..cd8a4c7b 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -13,7 +13,6 @@ import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart'; -/// Screen to display tracks from a local library album class LocalAlbumScreen extends ConsumerStatefulWidget { final String albumName; final String artistName; @@ -83,7 +82,6 @@ class _LocalAlbumScreenState extends ConsumerState { List _buildSortedTracks() { final tracks = List.from(widget.tracks); tracks.sort((a, b) { - // Sort by disc number first, then by track number final aDisc = a.discNumber ?? 1; final bDisc = b.discNumber ?? 1; if (aDisc != bDisc) return aDisc.compareTo(bDisc); @@ -197,7 +195,6 @@ class _LocalAlbumScreenState extends ConsumerState { ), ); - // Go back if all tracks were deleted if (deletedCount == currentTracks.length) { Navigator.pop(context); } @@ -233,7 +230,6 @@ class _LocalAlbumScreenState extends ConsumerState { final bottomPadding = MediaQuery.of(context).padding.bottom; final tracks = _sortedTracksCache; - // Show empty state if no tracks found if (tracks.isEmpty) { return Scaffold( appBar: AppBar(title: Text(widget.albumName)), @@ -326,7 +322,6 @@ class _LocalAlbumScreenState extends ConsumerState { background: Stack( fit: StackFit.expand, children: [ - // Full-screen cover background if (widget.coverPath != null) Image.file( File(widget.coverPath!), @@ -343,7 +338,6 @@ class _LocalAlbumScreenState extends ConsumerState { color: colorScheme.onSurfaceVariant, ), ), - // Bottom gradient for readability Positioned( left: 0, right: 0, @@ -362,7 +356,6 @@ class _LocalAlbumScreenState extends ConsumerState { ), ), ), - // Album info overlay at bottom Positioned( left: 20, right: 20, @@ -888,7 +881,6 @@ class _LocalAlbumScreenState extends ConsumerState { return false; } - /// Batch re-enrich selected local tracks Future _reEnrichSelected(List allTracks) async { final tracksById = {for (final t in allTracks) t.id: t}; final selected = []; @@ -988,7 +980,6 @@ class _LocalAlbumScreenState extends ConsumerState { ).showSnackBar(SnackBar(content: Text(summary))); } - /// Show batch convert bottom sheet void _showBatchConvertSheet( BuildContext context, List allTracks, @@ -1261,7 +1252,6 @@ class _LocalAlbumScreenState extends ConsumerState { String? safTempPath; if (isSaf) { - // Copy SAF file to temp for conversion safTempPath = await PlatformBridge.copyContentUriToTemp( item.filePath, ); @@ -1296,7 +1286,6 @@ class _LocalAlbumScreenState extends ConsumerState { if (isSaf) { // For SAF: derive the parent tree URI and relative dir from the content URI, // then create new SAF file and delete old one - // // Parse the SAF URI to get the tree document path: // content://...tree/...document/.../oldName.flac // We need tree URI and relative dir to create the new file @@ -1375,14 +1364,12 @@ class _LocalAlbumScreenState extends ConsumerState { continue; } - // Delete old SAF file try { await PlatformBridge.safDelete(item.filePath); } catch (_) {} await localDb.deleteByPath(item.filePath); } - // Clean up temp files try { await File(newPath).delete(); } catch (_) {} @@ -1400,7 +1387,6 @@ class _LocalAlbumScreenState extends ConsumerState { } catch (_) {} } - // Reload local library to pick up converted files ref.read(localLibraryProvider.notifier).reloadFromStorage(); _exitSelectionMode(); @@ -1513,7 +1499,6 @@ class _LocalAlbumScreenState extends ConsumerState { ), const SizedBox(height: 12), - // Action buttons row: Re-enrich, Convert Row( children: [ Expanded( diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index d005678d..dc0f12ad 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -206,7 +206,6 @@ class _PlaylistScreenState extends ConsumerState { background: Stack( fit: StackFit.expand, children: [ - // Full-screen cover background if (widget.coverUrl != null) CachedNetworkImage( imageUrl: @@ -227,7 +226,6 @@ class _PlaylistScreenState extends ConsumerState { color: colorScheme.onSurfaceVariant, ), ), - // Bottom gradient for readability Positioned( left: 0, right: 0, @@ -246,7 +244,6 @@ class _PlaylistScreenState extends ConsumerState { ), ), ), - // Playlist info overlay at bottom Positioned( left: 20, right: 20, @@ -436,8 +433,6 @@ class _PlaylistScreenState extends ConsumerState { } } - // ── Shuffle / Love / Download buttons ── - Widget _buildCircleButton({ required IconData icon, required String tooltip, diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index ad462f37..774a6e26 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -211,7 +211,7 @@ class _GroupedAlbum { class _GroupedLocalAlbum { final String albumName; final String artistName; - final String? coverPath; // Local cover file path + final String? coverPath; final List tracks; final DateTime latestScanned; final String searchKey; @@ -229,12 +229,11 @@ class _GroupedLocalAlbum { class _HistoryStats { final Map albumCounts; - final Map localAlbumCounts; // For identifying local singles + final Map localAlbumCounts; final List<_GroupedAlbum> groupedAlbums; - final List<_GroupedLocalAlbum> groupedLocalAlbums; // Local library albums + final List<_GroupedLocalAlbum> groupedLocalAlbums; final int albumCount; final int singleTracks; - // Local library stats final int localAlbumCount; final int localSingleTracks; @@ -933,8 +932,6 @@ class _QueueTabState extends ConsumerState { overlay.insert(_playlistSelectionOverlayEntry!); } - // --- Playlist selection mode --- - void _enterPlaylistSelectionMode(String playlistId) { HapticFeedback.mediumImpact(); setState(() { @@ -1202,11 +1199,9 @@ class _QueueTabState extends ConsumerState { await deleteFile(cleanPath); } catch (_) {} - // Remove from appropriate database if (item.source == LibraryItemSource.downloaded) { historyNotifier.removeFromHistory(item.historyItem!.id); } else { - // Remove from local library database await localLibraryDb.deleteByPath(item.filePath); } deletedCount++; @@ -2024,7 +2019,6 @@ class _QueueTabState extends ConsumerState { Map albumCounts, [ String searchQuery = '', ]) { - // First apply search filter var filteredItems = items; if (searchQuery.isNotEmpty) { final query = searchQuery; @@ -2034,7 +2028,6 @@ class _QueueTabState extends ConsumerState { }).toList(); } - // Then apply filter mode if (filterMode == 'all') return filteredItems; switch (filterMode) { @@ -2639,7 +2632,6 @@ class _QueueTabState extends ConsumerState { ), ), - // Search bar - always at top if (allHistoryItems.isNotEmpty || hasQueueItems || localLibraryItems.isNotEmpty) diff --git a/lib/screens/settings/about_page.dart b/lib/screens/settings/about_page.dart index 5b5bc299..8ef0268c 100644 --- a/lib/screens/settings/about_page.dart +++ b/lib/screens/settings/about_page.dart @@ -49,7 +49,7 @@ class AboutPage extends StatelessWidget { title: Text( context.l10n.aboutTitle, style: TextStyle( - fontSize: 20 + (8 * expandRatio), // 20 -> 28 + fontSize: 20 + (8 * expandRatio), fontWeight: FontWeight.bold, color: colorScheme.onSurface, ), @@ -462,7 +462,6 @@ class _ContributorItem extends StatelessWidget { } } -/// Translator data model class _Translator { final String name; final String crowdinUsername; @@ -477,7 +476,6 @@ class _Translator { }); } -/// Translators section with compact chip-style layout class _TranslatorsSection extends StatelessWidget { const _TranslatorsSection(); @@ -558,7 +556,6 @@ class _TranslatorsSection extends StatelessWidget { } } -/// Individual translator chip class _TranslatorChip extends StatelessWidget { final _Translator translator; diff --git a/lib/screens/settings/appearance_settings_page.dart b/lib/screens/settings/appearance_settings_page.dart index 4778e95f..f5a4560b 100644 --- a/lib/screens/settings/appearance_settings_page.dart +++ b/lib/screens/settings/appearance_settings_page.dart @@ -148,7 +148,6 @@ class AppearanceSettingsPage extends ConsumerWidget { } } -/// A simplified preview of how the app looks with current settings class _ThemePreviewCard extends StatelessWidget { @override Widget build(BuildContext context) { @@ -423,7 +422,6 @@ class _ColorPaletteItem extends StatelessWidget { } } -/// Optimized app bar title with animation class _AppBarTitle extends StatelessWidget { final String title; final double topPadding; @@ -440,14 +438,14 @@ class _AppBarTitle extends StatelessWidget { final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)) .clamp(0.0, 1.0); - final leftPadding = 56 - (32 * expandRatio); // 56 -> 24 + final leftPadding = 56 - (32 * expandRatio); return FlexibleSpaceBar( expandedTitleScale: 1.0, titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16), title: Text( title, style: TextStyle( - fontSize: 20 + (8 * expandRatio), // 20 -> 28 + fontSize: 20 + (8 * expandRatio), fontWeight: FontWeight.bold, color: colorScheme.onSurface, ), diff --git a/lib/screens/settings/donate_page.dart b/lib/screens/settings/donate_page.dart index 4f20d118..80466739 100644 --- a/lib/screens/settings/donate_page.dart +++ b/lib/screens/settings/donate_page.dart @@ -56,17 +56,14 @@ class DonatePage extends StatelessWidget { padding: const EdgeInsets.all(16), child: Column( children: [ - // Donate links card _DonateLinksCard(colorScheme: colorScheme), const SizedBox(height: 24), - // Recent donors section _RecentDonorsCard(colorScheme: colorScheme), const SizedBox(height: 16), - // Combined notice card Card( elevation: 0, color: colorScheme.secondaryContainer.withValues( diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index 2e1e8e8c..fbf92f63 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -23,7 +23,7 @@ class DownloadSettingsPage extends ConsumerStatefulWidget { } class _DownloadSettingsPageState extends ConsumerState { - static const _builtInServices = ['tidal', 'qobuz', 'amazon', 'deezer']; + static const _builtInServices = ['tidal', 'qobuz', 'deezer']; static const _songLinkRegions = [ 'AD', 'AE', @@ -326,7 +326,7 @@ class _DownloadSettingsPageState extends ConsumerState { ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)) .clamp(0.0, 1.0); - final leftPadding = 56 - (32 * expandRatio); // 56 -> 24 + final leftPadding = 56 - (32 * expandRatio); return FlexibleSpaceBar( expandedTitleScale: 1.0, titlePadding: EdgeInsets.only( @@ -336,7 +336,7 @@ class _DownloadSettingsPageState extends ConsumerState { title: Text( context.l10n.downloadTitle, style: TextStyle( - fontSize: 20 + (8 * expandRatio), // 20 -> 28 + fontSize: 20 + (8 * expandRatio), fontWeight: FontWeight.bold, color: colorScheme.onSurface, ), @@ -450,7 +450,7 @@ class _DownloadSettingsPageState extends ConsumerState { const SizedBox(width: 8), Expanded( child: Text( - 'Select Tidal, Qobuz, or Amazon above to configure quality', + 'Select Tidal or Qobuz above to configure quality', style: Theme.of(context).textTheme.bodySmall ?.copyWith( color: colorScheme.onSurfaceVariant, @@ -720,7 +720,6 @@ class _DownloadSettingsPageState extends ConsumerState { ), ), - // Download Network Mode SliverToBoxAdapter( child: SettingsSectionHeader(title: context.l10n.sectionDownload), ), @@ -778,7 +777,6 @@ class _DownloadSettingsPageState extends ConsumerState { ), ), - // All Files Access section (Android 13+ only) if (Platform.isAndroid && _androidSdkVersion >= 33) ...[ SliverToBoxAdapter( child: SettingsSectionHeader( @@ -2039,7 +2037,7 @@ class _ServiceSelector extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final extState = ref.watch(extensionProvider); - final builtInServiceIds = ['tidal', 'qobuz', 'amazon', 'deezer', 'youtube']; + final builtInServiceIds = ['tidal', 'qobuz', 'deezer', 'youtube']; final extensionProviders = extState.extensions .where((e) => e.enabled && e.hasDownloadProvider) @@ -2076,15 +2074,6 @@ class _ServiceSelector extends ConsumerWidget { ), ), const SizedBox(width: 8), - Expanded( - child: _ServiceChip( - icon: Icons.shopping_bag_outlined, - label: 'Amazon', - isSelected: effectiveService == 'amazon', - onTap: () => onChanged('amazon'), - ), - ), - const SizedBox(width: 8), Expanded( child: _ServiceChip( icon: Icons.smart_display, diff --git a/lib/screens/settings/extension_detail_page.dart b/lib/screens/settings/extension_detail_page.dart index 2268a793..72a50a8e 100644 --- a/lib/screens/settings/extension_detail_page.dart +++ b/lib/screens/settings/extension_detail_page.dart @@ -658,7 +658,6 @@ class _SettingItemState extends State<_SettingItem> { ); } - // For button type, show a different layout if (widget.setting.type == 'button') { return Column( mainAxisSize: MainAxisSize.min, diff --git a/lib/screens/settings/library_settings_page.dart b/lib/screens/settings/library_settings_page.dart index d8d32dda..2d91f4a1 100644 --- a/lib/screens/settings/library_settings_page.dart +++ b/lib/screens/settings/library_settings_page.dart @@ -271,7 +271,6 @@ class _LibrarySettingsPageState extends ConsumerState { ), ), - // Scan Settings Section SliverToBoxAdapter( child: SettingsSectionHeader( title: context.l10n.libraryScanSettings, @@ -442,7 +441,6 @@ class _LibrarySettingsPageState extends ConsumerState { ), ], - // Info Section SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), @@ -558,7 +556,6 @@ class _LibraryHeroCard extends StatelessWidget { clipBehavior: Clip.antiAlias, child: Stack( children: [ - // Background decorative elements Positioned( right: -20, top: -20, @@ -581,7 +578,6 @@ class _LibraryHeroCard extends StatelessWidget { ), ), - // Content Padding( padding: const EdgeInsets.all(24), child: Column( diff --git a/lib/screens/settings/lyrics_provider_priority_page.dart b/lib/screens/settings/lyrics_provider_priority_page.dart index a33e8a50..426d82c8 100644 --- a/lib/screens/settings/lyrics_provider_priority_page.dart +++ b/lib/screens/settings/lyrics_provider_priority_page.dart @@ -122,8 +122,6 @@ class _LyricsProviderPriorityPageState ); } - // ── State mutations ── - void _enableProvider(String id) { setState(() => _enabledProviders.add(id)); _markChanged(); @@ -142,8 +140,6 @@ class _LyricsProviderPriorityPageState _markChanged(); } - // ── Save / Discard ── - Future _saveChanges() async { ref .read(settingsProvider.notifier) @@ -180,8 +176,6 @@ class _LyricsProviderPriorityPageState return result ?? false; } - // ── Provider metadata ── - static _LyricsProviderInfo _getLyricsProviderInfo(String id) { switch (id) { case 'spotify_api': @@ -230,10 +224,6 @@ class _LyricsProviderPriorityPageState } } -// ═══════════════════════════════════════════════════════════════════════════ -// Enabled provider card (reorderable) -// ═══════════════════════════════════════════════════════════════════════════ - class _EnabledProviderItem extends StatelessWidget { final String providerId; final _LyricsProviderInfo info; @@ -273,7 +263,6 @@ class _EnabledProviderItem extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ - // Numbered badge Container( width: 28, height: 28, @@ -296,10 +285,8 @@ class _EnabledProviderItem extends StatelessWidget { ), ), const SizedBox(width: 16), - // Icon Icon(info.icon, color: colorScheme.primary), const SizedBox(width: 12), - // Name + description Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -319,7 +306,6 @@ class _EnabledProviderItem extends StatelessWidget { ], ), ), - // Enable/disable switch SizedBox( height: 32, child: FittedBox( @@ -327,7 +313,6 @@ class _EnabledProviderItem extends StatelessWidget { ), ), const SizedBox(width: 4), - // Drag handle Icon(Icons.drag_handle, color: colorScheme.onSurfaceVariant), ], ), @@ -338,10 +323,6 @@ class _EnabledProviderItem extends StatelessWidget { } } -// ═══════════════════════════════════════════════════════════════════════════ -// Disabled provider card -// ═══════════════════════════════════════════════════════════════════════════ - class _DisabledProviderItem extends StatelessWidget { final String providerId; final _LyricsProviderInfo info; @@ -383,10 +364,8 @@ class _DisabledProviderItem extends StatelessWidget { // Empty space aligned with numbered badge const SizedBox(width: 28), const SizedBox(width: 16), - // Icon (muted) Icon(info.icon, color: colorScheme.outline), const SizedBox(width: 12), - // Name + description Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -407,7 +386,6 @@ class _DisabledProviderItem extends StatelessWidget { ], ), ), - // Switch SizedBox( height: 32, child: FittedBox( @@ -424,10 +402,6 @@ class _DisabledProviderItem extends StatelessWidget { } } -// ═══════════════════════════════════════════════════════════════════════════ -// Provider info model -// ═══════════════════════════════════════════════════════════════════════════ - class _LyricsProviderInfo { final String name; final String description; diff --git a/lib/screens/settings/options_settings_page.dart b/lib/screens/settings/options_settings_page.dart index a5a6e07c..9016586c 100644 --- a/lib/screens/settings/options_settings_page.dart +++ b/lib/screens/settings/options_settings_page.dart @@ -43,7 +43,7 @@ class OptionsSettingsPage extends ConsumerWidget { ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)) .clamp(0.0, 1.0); - final leftPadding = 56 - (32 * expandRatio); // 56 -> 24 + final leftPadding = 56 - (32 * expandRatio); return FlexibleSpaceBar( expandedTitleScale: 1.0, titlePadding: EdgeInsets.only( @@ -53,7 +53,7 @@ class OptionsSettingsPage extends ConsumerWidget { title: Text( context.l10n.optionsTitle, style: TextStyle( - fontSize: 20 + (8 * expandRatio), // 20 -> 28 + fontSize: 20 + (8 * expandRatio), fontWeight: FontWeight.bold, color: colorScheme.onSurface, ), @@ -331,7 +331,6 @@ class OptionsSettingsPage extends ConsumerWidget { BuildContext context, WidgetRef ref, ) async { - // Show loading indicator showDialog( context: context, barrierDismissible: false, diff --git a/lib/screens/settings/provider_priority_page.dart b/lib/screens/settings/provider_priority_page.dart index ed44ca46..a5a19f15 100644 --- a/lib/screens/settings/provider_priority_page.dart +++ b/lib/screens/settings/provider_priority_page.dart @@ -333,12 +333,6 @@ class _ProviderItem extends StatelessWidget { ); case 'qobuz': return _ProviderInfo(name: 'Qobuz', icon: Icons.album, isBuiltIn: true); - case 'amazon': - return _ProviderInfo( - name: 'Amazon Music', - icon: Icons.shopping_bag, - isBuiltIn: true, - ); case 'youtube': return _ProviderInfo( name: 'YouTube', diff --git a/lib/screens/setup_screen.dart b/lib/screens/setup_screen.dart index ede9017d..57a5caac 100644 --- a/lib/screens/setup_screen.dart +++ b/lib/screens/setup_screen.dart @@ -22,7 +22,6 @@ class _SetupScreenState extends ConsumerState { final PageController _pageController = PageController(); int _currentStep = 0; - // State variables bool _storagePermissionGranted = false; bool _notificationPermissionGranted = false; String? _selectedDirectory; @@ -474,7 +473,6 @@ class _SetupScreenState extends ConsumerState { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - // Calculate progress final progress = (_currentStep + 1) / _totalSteps; return Scaffold( @@ -482,7 +480,6 @@ class _SetupScreenState extends ConsumerState { body: SafeArea( child: Column( children: [ - // Top Bar Padding( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), child: Row( @@ -497,9 +494,8 @@ class _SetupScreenState extends ConsumerState { ), ) else - const SizedBox(width: 48), // Spacer + const SizedBox(width: 48), const Spacer(), - // Progress Indicator SizedBox( width: 48, height: 48, @@ -530,7 +526,6 @@ class _SetupScreenState extends ConsumerState { ), ), - // Content Expanded( child: PageView( controller: _pageController, diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index c34e7400..d39c04bc 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -627,7 +627,6 @@ class _TrackMetadataScreenState extends ConsumerState { return Stack( fit: StackFit.expand, children: [ - // Full-screen cover background if (_hasPath(_embeddedCoverPreviewPath)) Image.file( File(_embeddedCoverPreviewPath!), @@ -657,7 +656,6 @@ class _TrackMetadataScreenState extends ConsumerState { color: colorScheme.onSurfaceVariant, ), ), - // Bottom gradient for readability Positioned( left: 0, right: 0, @@ -676,7 +674,6 @@ class _TrackMetadataScreenState extends ConsumerState { ), ), ), - // Track info overlay at bottom Positioned( left: 20, right: 20, @@ -2606,7 +2603,6 @@ class _TrackMetadataScreenState extends ConsumerState { ), const SizedBox(height: 20), - // Target format Text( context.l10n.trackConvertTargetFormat, style: Theme.of(context).textTheme.titleSmall?.copyWith( @@ -2639,7 +2635,6 @@ class _TrackMetadataScreenState extends ConsumerState { ), const SizedBox(height: 16), - // Bitrate Text( context.l10n.trackConvertBitrate, style: Theme.of(context).textTheme.titleSmall?.copyWith( @@ -2664,7 +2659,6 @@ class _TrackMetadataScreenState extends ConsumerState { ), const SizedBox(height: 24), - // Convert button SizedBox( width: double.infinity, child: FilledButton( @@ -2750,7 +2744,6 @@ class _TrackMetadataScreenState extends ConsumerState { 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); @@ -2768,7 +2761,6 @@ class _TrackMetadataScreenState extends ConsumerState { _log.w('readFileMetadata threw, using fallback metadata: $e'); } - // Step 2: Extract cover art to temp file String? coverPath; try { final tempDir = await getTemporaryDirectory(); @@ -2783,7 +2775,6 @@ class _TrackMetadataScreenState extends ConsumerState { } } catch (_) {} - // Step 3: Handle SAF vs regular file String workingPath = cleanFilePath; final isSaf = _isSafFile; String? safTempPath; @@ -2803,7 +2794,6 @@ class _TrackMetadataScreenState extends ConsumerState { workingPath = safTempPath; } - // Step 4: Convert final newPath = await FFmpegService.convertAudioFormat( inputPath: workingPath, targetFormat: targetFormat.toLowerCase(), @@ -2838,7 +2828,6 @@ class _TrackMetadataScreenState extends ConsumerState { final newQuality = _buildConvertedQualityLabel(targetFormat, bitrate); - // Step 5: Handle SAF write-back if (isSaf) { final treeUri = _downloadItem?.downloadTreeUri; final relativeDir = _downloadItem?.safRelativeDir ?? ''; @@ -3064,7 +3053,6 @@ class _TrackMetadataScreenState extends ConsumerState { // Also remove from local library database // ref.read(localLibraryProvider.notifier).removeItem(_localLibraryItem!.id); } else { - // Existing download history deletion logic try { await deleteFile(cleanFilePath); } catch (e) { @@ -3661,7 +3649,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { expand: false, builder: (context, scrollController) => Column( children: [ - // Handle bar Padding( padding: const EdgeInsets.only(top: 12, bottom: 8), child: Container( @@ -3673,7 +3660,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ), ), ), - // Title row Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: Row( @@ -3698,7 +3684,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ), ), const SizedBox(height: 12), - // Fields Expanded( child: ListView( controller: scrollController, diff --git a/lib/screens/tutorial_screen.dart b/lib/screens/tutorial_screen.dart index b93ed5b7..04dfcf9c 100644 --- a/lib/screens/tutorial_screen.dart +++ b/lib/screens/tutorial_screen.dart @@ -90,7 +90,6 @@ class _TutorialScreenState extends ConsumerState { body: SafeArea( child: Column( children: [ - // Top Navigation Bar Padding( padding: EdgeInsets.symmetric( horizontal: topBarPaddingH, @@ -112,7 +111,6 @@ class _TutorialScreenState extends ConsumerState { ), ), - // Skip button TextButton( onPressed: _skipTutorial, style: TextButton.styleFrom( @@ -131,7 +129,6 @@ class _TutorialScreenState extends ConsumerState { ), ), - // Main Content Area Expanded( child: PageView( controller: _pageController, @@ -218,12 +215,10 @@ class _TutorialScreenState extends ConsumerState { ), ), - // Bottom Control Area Padding( padding: const EdgeInsets.all(24), child: Column( children: [ - // Expressive Page Indicators Row( mainAxisAlignment: MainAxisAlignment.center, children: List.generate(_totalPages, (index) { @@ -246,7 +241,6 @@ class _TutorialScreenState extends ConsumerState { }), ), SizedBox(height: bottomGap), - // Action Button SizedBox( width: double.infinity, height: actionButtonHeight, @@ -402,7 +396,6 @@ class _InteractiveSearchExampleState extends State<_InteractiveSearchExample> { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Search Input TextField( controller: _controller, onChanged: (value) { @@ -428,7 +421,6 @@ class _InteractiveSearchExampleState extends State<_InteractiveSearchExample> { ), ), - // Result Placeholder AnimatedSize( duration: const Duration(milliseconds: 400), curve: Curves.easeOutBack, @@ -541,7 +533,6 @@ class _InteractiveDownloadExampleState _isCompleted = true; }); - // Reset after a delay await Future.delayed(const Duration(seconds: 2)); if (mounted) { setState(() { diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index b352162f..2b28ca9d 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -198,7 +198,7 @@ class FFmpegService { final trimmedKey = decryptionKey.trim(); if (trimmedKey.isEmpty) return inputPath; - // Amazon encrypted streams are commonly MP4 container with FLAC audio. + // 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' @@ -217,7 +217,10 @@ class FFmpegService { required String key, }) { final audioMap = mapAudioOnly ? '-map 0:a ' : ''; - return '-v error -decryption_key "$key" -i "$inputPath" $audioMap-c copy "$outputPath" -y'; + // Force MOV demuxer: -decryption_key is only supported by the MOV/MP4 + // demuxer. The input may carry a .flac extension (SAF mode) while actually + // containing an encrypted M4A stream, so we must override auto-detection. + return '-v error -decryption_key "$key" -f mov -i "$inputPath" $audioMap-c copy "$outputPath" -y'; } final keyCandidates = _buildDecryptionKeyCandidates(trimmedKey); @@ -627,7 +630,7 @@ class FFmpegService { return null; } - static Future startAmazonLiveDecryptedStream({ + static Future startEncryptedLiveDecryptedStream({ required String encryptedStreamUrl, required String decryptionKey, String preferredFormat = 'flac', @@ -1225,7 +1228,6 @@ class FFmpegService { final extension = format == 'opus' ? '.opus' : '.mp3'; final outputPath = _buildOutputPath(inputPath, extension); - // Step 1: Convert audio String command; if (format == 'opus') { command = @@ -1245,7 +1247,6 @@ class FFmpegService { 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; @@ -1281,7 +1282,6 @@ class FFmpegService { } } - // Step 3: Delete original if requested if (deleteOriginal) { try { await File(inputPath).delete(); diff --git a/lib/services/history_database.dart b/lib/services/history_database.dart index 5a3c1739..619b424d 100644 --- a/lib/services/history_database.dart +++ b/lib/services/history_database.dart @@ -104,8 +104,6 @@ class HistoryDatabase { } } - // ==================== iOS Path Normalization ==================== - /// Pattern to match iOS container paths /// Example: /var/mobile/Containers/Data/Application/UUID-HERE/Documents/... static final _iosContainerPattern = RegExp( @@ -325,8 +323,6 @@ class HistoryDatabase { }; } - // ==================== CRUD Operations ==================== - /// Insert or update a history item Future upsert(Map json) async { final db = await database; diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index a0b35d54..840958ab 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -403,8 +403,6 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - // ==================== LYRICS PROVIDER SETTINGS ==================== - /// Sets the lyrics provider order. Providers not in the list are disabled. static Future setLyricsProviders(List providers) async { final providersJSON = jsonEncode(providers); @@ -1060,8 +1058,6 @@ class PlatformBridge { } } - // ==================== LOCAL LIBRARY SCANNING ==================== - /// Set the directory for caching extracted cover art static Future setLibraryCoverCacheDir(String cacheDir) async { _log.i('setLibraryCoverCacheDir: $cacheDir'); @@ -1261,5 +1257,4 @@ class PlatformBridge { await _channel.invokeMethod('clearStoreCache'); } - // ==================== YOUTUBE / COBALT ==================== } diff --git a/lib/widgets/donate_icons.dart b/lib/widgets/donate_icons.dart index 16e83ef8..21ba4195 100644 --- a/lib/widgets/donate_icons.dart +++ b/lib/widgets/donate_icons.dart @@ -28,14 +28,12 @@ class _KofiPainter extends CustomPainter { ..color = color ..style = PaintingStyle.fill; - // Cup body final cup = RRect.fromRectAndRadius( Rect.fromLTWH(s * 0.08, s * 0.28, s * 0.62, s * 0.52), Radius.circular(s * 0.12), ); canvas.drawRRect(cup, paint); - // Handle final handlePaint = Paint() ..color = color ..style = PaintingStyle.stroke diff --git a/lib/widgets/download_service_picker.dart b/lib/widgets/download_service_picker.dart index 881b6101..69eb4f1d 100644 --- a/lib/widgets/download_service_picker.dart +++ b/lib/widgets/download_service_picker.dart @@ -67,17 +67,6 @@ const _builtInServices = [ ), ], ), - BuiltInService( - id: 'amazon', - label: 'Amazon', - qualityOptions: [ - QualityOption( - id: 'LOSSLESS', - label: 'FLAC Best Available', - description: 'Amazon API delivers the best available lossless quality', - ), - ], - ), BuiltInService( id: 'deezer', label: 'Deezer', @@ -209,7 +198,6 @@ class _DownloadServicePickerState extends ConsumerState { return ext.qualityOptions; } - // Default fallback options return [ const QualityOption( id: 'DEFAULT', diff --git a/pubspec.yaml b/pubspec.yaml index ff4f6473..638e579d 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 +description: Download Spotify tracks in FLAC from Tidal, Qobuz & Deezer publish_to: "none" -version: 3.7.1+104 +version: 3.7.2+105 environment: sdk: ^3.10.0 diff --git a/site/docs.html b/site/docs.html index 2ad5c7f6..6a148a93 100644 --- a/site/docs.html +++ b/site/docs.html @@ -965,7 +965,7 @@ skipBuiltInFallback boolean No -If true, don't fallback to built-in providers (Tidal/Qobuz/Amazon) when extension download fails +If true, don't fallback to built-in providers (Tidal/Qobuz/Deezer) when extension download fails minAppVersion diff --git a/site/index.html b/site/index.html index 572d1152..47999b34 100644 --- a/site/index.html +++ b/site/index.html @@ -4,12 +4,12 @@ SpotiFLAC Mobile - Lossless Music Downloader - + - + @@ -404,7 +404,7 @@

SpotiFLAC Mobile

-

Download music in true lossless FLAC from Tidal, Qobuz & Amazon Music — no account required.

+

Download music in true lossless FLAC from Tidal, Qobuz & Deezer — no account required.

@@ -451,7 +451,7 @@

Multiple Providers

-

Download from Tidal, Qobuz, Amazon Music, and more. Automatic fallback if a source is unavailable.

+

Download from Tidal, Qobuz, Deezer, and more via extensions. Automatic fallback if a source is unavailable.

@@ -494,11 +494,11 @@
Why is my download failing with "Song not found"? -
The track may not be available on Tidal, Qobuz, or Amazon Music. Try enabling more download services in Settings > Download > Provider Priority, or install additional extensions from the Store.
+
The track may not be available on the streaming services. Try enabling more download services in Settings > Download > Provider Priority, or install additional extensions like Amazon Music from the Store.
Why are some tracks downloading in lower quality? -
Quality depends on what's available from the streaming service. Tidal offers up to 24-bit/192kHz, Qobuz up to 24-bit/192kHz, and Amazon up to 24-bit/48kHz. The app automatically selects the best available quality.
+
Quality depends on what's available from the streaming service and extensions. Built-in providers: Tidal offers up to 24-bit/192kHz, Qobuz up to 24-bit/192kHz, and Deezer up to 16-bit/44.1kHz.
Can I download entire playlists?