From b193bc0b8f085a3bd5ab3f7b33f0b81d49f6e1d3 Mon Sep 17 00:00:00 2001 From: zarzet Date: Fri, 16 Jan 2026 03:46:31 +0700 Subject: [PATCH] feat: download cancellation, duplicate detection, progress tracking improvements --- CHANGELOG.md | 11 +- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 7 ++ go_backend/amazon.go | 21 +++- go_backend/cancel.go | 79 ++++++++++++++ go_backend/duplicate.go | 25 ++++- go_backend/exports.go | 18 +++- go_backend/extension_providers.go | 25 +++++ go_backend/progress.go | 3 + go_backend/qobuz.go | 21 +++- go_backend/tidal.go | 83 +++++++++++++-- ios/Runner/AppDelegate.swift | 6 ++ lib/providers/download_queue_provider.dart | 100 +++++++++++++++--- lib/providers/track_provider.dart | 11 +- lib/screens/downloaded_album_screen.dart | 4 +- lib/screens/queue_tab.dart | 4 +- lib/screens/settings/about_page.dart | 4 +- lib/screens/track_metadata_screen.dart | 14 ++- lib/services/platform_bridge.dart | 5 + lib/utils/mime_utils.dart | 24 +++++ 19 files changed, 428 insertions(+), 37 deletions(-) create mode 100644 go_backend/cancel.go create mode 100644 lib/utils/mime_utils.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index b52a0f13..9999deda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,5 @@ # Changelog -## [Unreleased] - ## [3.1.0] - 2026-01-19 ### Added @@ -22,13 +20,13 @@ - New `getAlbum()`, `getPlaylist()`, and `getArtist()` extension functions - New `ExtensionAlbumScreen`, `ExtensionPlaylistScreen`, and `ExtensionArtistScreen` for fetching content from extensions - YouTube Music extension updated with album/playlist/artist support - - See [Extension Development Guide](docs/EXTENSION_DEVELOPMENT.md#artist-support) for implementation details - **Odesli (song.link) Integration for YouTube Music Extension** - New `enrichTrack()` function to fetch ISRC and external service links - Uses Odesli API to convert YouTube Music tracks to Deezer/Tidal/Qobuz/Spotify - Enables built-in service fallback for high-quality audio downloads - Extension version updated to 1.4.0 with `api.song.link` and `odesli.io` network permissions +- **Download Cancel**: Canceling a download now stops in-flight built-in provider downloads (Tidal/Qobuz/Amazon) and clears backend progress tracking. ### Fixed @@ -49,6 +47,13 @@ - Fixed extension duplicate load error (skip silently instead of throwing error) - Fixed keyboard appearing when swiping between tabs (unfocus on page change) - Removed "Free"/"API Key" badges from search source selector +- Fixed cancel action briefly resuming downloads in the queue UI after ~1 second. +- Fixed cancelled downloads being marked as failed when the backend returns after cancellation. +- Fixed cancel triggering provider fallback (cancel now stops the download flow immediately). +- Fixed stale ISRC cache returning deleted files after cancel. +- Fixed search results mixing extension and built-in artists when using default provider. +- Fixed audio files opening with non-music apps by passing audio MIME type on open. +- Fixed album artist showing null/blank by normalizing empty metadata and using artist fallback for tags. - **Go Backend: Missing `item_type` and `album_type` fields** - Added `ItemType` and `AlbumType` fields to `ExtTrackMetadata` struct - Fixed `CustomSearchWithExtensionJSON` - now includes `item_type` and `album_type` in response 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 5673d009..07374cad 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -117,6 +117,13 @@ class MainActivity: FlutterActivity() { } result.success(null) } + "cancelDownload" -> { + val itemId = call.argument("item_id") ?: "" + withContext(Dispatchers.IO) { + Gobackend.cancelDownload(itemId) + } + result.success(null) + } "setDownloadDirectory" -> { val path = call.argument("path") ?: "" withContext(Dispatchers.IO) { diff --git a/go_backend/amazon.go b/go_backend/amazon.go index 35860f5f..39ebe11d 100644 --- a/go_backend/amazon.go +++ b/go_backend/amazon.go @@ -1,9 +1,11 @@ package gobackend import ( + "context" "bufio" "encoding/base64" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -346,13 +348,21 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, _ string) // DownloadFile downloads a file from URL with User-Agent and progress tracking func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error { + ctx := context.Background() + // Initialize item progress (required for all downloads) if itemID != "" { StartItemProgress(itemID) defer CompleteItemProgress(itemID) + ctx = initDownloadCancel(itemID) + defer clearDownloadCancel(itemID) } - req, err := http.NewRequest("GET", downloadURL, nil) + 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) } @@ -361,6 +371,9 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) resp, err := a.client.Do(req) if err != nil { + if isDownloadCancelled(itemID) { + return ErrDownloadCancelled + } return err } defer resp.Body.Close() @@ -400,6 +413,9 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) // Check for any errors if err != nil { os.Remove(outputPath) + if isDownloadCancelled(itemID) { + return ErrDownloadCancelled + } return fmt.Errorf("download interrupted: %w", err) } if flushErr != nil { @@ -527,6 +543,9 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) { // Download audio file with item ID for progress tracking if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil { + if errors.Is(err, ErrDownloadCancelled) { + return AmazonDownloadResult{}, ErrDownloadCancelled + } return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err) } diff --git a/go_backend/cancel.go b/go_backend/cancel.go new file mode 100644 index 00000000..cc72c05d --- /dev/null +++ b/go_backend/cancel.go @@ -0,0 +1,79 @@ +package gobackend + +import ( + "context" + "errors" + "sync" +) + +// ErrDownloadCancelled is returned when a download is cancelled by the user. +var ErrDownloadCancelled = errors.New("download cancelled") + +type cancelEntry struct { + cancel context.CancelFunc + canceled bool +} + +var ( + cancelMu sync.Mutex + cancelMap = make(map[string]*cancelEntry) +) + +func initDownloadCancel(itemID string) context.Context { + if itemID == "" { + return context.Background() + } + + cancelMu.Lock() + defer cancelMu.Unlock() + + ctx, cancel := context.WithCancel(context.Background()) + cancelMap[itemID] = &cancelEntry{ + cancel: cancel, + canceled: false, + } + return ctx +} + +func cancelDownload(itemID string) { + if itemID == "" { + return + } + + cancelMu.Lock() + entry, ok := cancelMap[itemID] + if ok { + entry.canceled = true + if entry.cancel != nil { + entry.cancel() + } + } else { + cancelMap[itemID] = &cancelEntry{canceled: true} + } + cancelMu.Unlock() + + // Hide progress for cancelled items. + RemoveItemProgress(itemID) +} + +func isDownloadCancelled(itemID string) bool { + if itemID == "" { + return false + } + + cancelMu.Lock() + entry, ok := cancelMap[itemID] + canceled := ok && entry.canceled + cancelMu.Unlock() + return canceled +} + +func clearDownloadCancel(itemID string) { + if itemID == "" { + return + } + + cancelMu.Lock() + delete(cancelMap, itemID) + cancelMu.Unlock() +} diff --git a/go_backend/duplicate.go b/go_backend/duplicate.go index a637c041..48b53299 100644 --- a/go_backend/duplicate.go +++ b/go_backend/duplicate.go @@ -103,6 +103,18 @@ func (idx *ISRCIndex) lookup(isrc string) (string, bool) { return path, exists } +// remove deletes an ISRC entry from the index (internal use) +func (idx *ISRCIndex) remove(isrc string) { + if isrc == "" { + return + } + + idx.mu.Lock() + defer idx.mu.Unlock() + + delete(idx.index, strings.ToUpper(isrc)) +} + // Lookup checks if an ISRC exists in the index (gomobile compatible) // Returns filepath if found, empty string if not found func (idx *ISRCIndex) Lookup(isrc string) (string, error) { @@ -138,7 +150,18 @@ func checkISRCExistsInternal(outputDir, isrc string) (string, bool) { // Use index for fast lookup idx := GetISRCIndex(outputDir) - return idx.lookup(isrc) + filePath, exists := idx.lookup(isrc) + if !exists { + return "", false + } + + if !CheckFileExists(filePath) { + // Stale index entry; remove it and return not found. + idx.remove(isrc) + return "", false + } + + return filePath, true } // CheckISRCExists is the exported version for gomobile (returns string, error) diff --git a/go_backend/exports.go b/go_backend/exports.go index e1d869b7..9c9c15ed 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -5,6 +5,7 @@ package gobackend import ( "context" "encoding/json" + "errors" "fmt" "strings" "time" @@ -405,7 +406,7 @@ func DownloadWithFallback(requestJSON string) (string, error) { DiscNumber: tidalResult.DiscNumber, ISRC: tidalResult.ISRC, } - } else { + } else if !errors.Is(tidalErr, ErrDownloadCancelled) { GoLog("[DownloadWithFallback] Tidal error: %v\n", tidalErr) } err = tidalErr @@ -424,7 +425,7 @@ func DownloadWithFallback(requestJSON string) (string, error) { DiscNumber: qobuzResult.DiscNumber, ISRC: qobuzResult.ISRC, } - } else { + } else if !errors.Is(qobuzErr, ErrDownloadCancelled) { GoLog("[DownloadWithFallback] Qobuz error: %v\n", qobuzErr) } err = qobuzErr @@ -443,12 +444,16 @@ func DownloadWithFallback(requestJSON string) (string, error) { DiscNumber: amazonResult.DiscNumber, ISRC: amazonResult.ISRC, } - } else { + } else if !errors.Is(amazonErr, ErrDownloadCancelled) { GoLog("[DownloadWithFallback] Amazon error: %v\n", amazonErr) } err = amazonErr } + if err != nil && errors.Is(err, ErrDownloadCancelled) { + return errorResponse("Download cancelled") + } + if err == nil { // Check if file already exists if len(result.FilePath) > 7 && result.FilePath[:7] == "EXISTS:" { @@ -542,6 +547,11 @@ func ClearItemProgress(itemID string) { RemoveItemProgress(itemID) } +// CancelDownload cancels an in-progress download for the given item. +func CancelDownload(itemID string) { + cancelDownload(itemID) +} + // CleanupConnections closes idle HTTP connections // Call this periodically during large batch downloads to prevent TCP exhaustion func CleanupConnections() { @@ -1031,6 +1041,8 @@ func errorResponse(msg string) (string, error) { strings.Contains(lowerMsg, "try using vpn") || strings.Contains(lowerMsg, "change dns") { errorType = "isp_blocked" + } else if strings.Contains(lowerMsg, "cancel") { + errorType = "cancelled" } else if strings.Contains(lowerMsg, "permission") || strings.Contains(lowerMsg, "operation not permitted") || strings.Contains(lowerMsg, "access denied") || diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index c983165e..57939053 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -3,6 +3,7 @@ package gobackend import ( "encoding/json" + "errors" "fmt" "path/filepath" "strings" @@ -835,6 +836,14 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro } if err != nil { + if errors.Is(err, ErrDownloadCancelled) { + return &DownloadResponse{ + Success: false, + Error: "Download cancelled", + ErrorType: "cancelled", + Service: req.Source, + }, nil + } lastErr = err } else if result.ErrorMessage != "" { lastErr = fmt.Errorf("%s", result.ErrorMessage) @@ -879,6 +888,14 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro return result, nil } if err != nil { + if errors.Is(err, ErrDownloadCancelled) { + return &DownloadResponse{ + Success: false, + Error: "Download cancelled", + ErrorType: "cancelled", + Service: providerID, + }, nil + } lastErr = err GoLog("[DownloadWithExtensionFallback] %s failed: %v\n", providerID, err) } @@ -964,6 +981,14 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro } if err != nil { + if errors.Is(err, ErrDownloadCancelled) { + return &DownloadResponse{ + Success: false, + Error: "Download cancelled", + ErrorType: "cancelled", + Service: providerID, + }, nil + } lastErr = err } else if result.ErrorMessage != "" { lastErr = fmt.Errorf("%s", result.ErrorMessage) diff --git a/go_backend/progress.go b/go_backend/progress.go index 1c95313e..aca7d070 100644 --- a/go_backend/progress.go +++ b/go_backend/progress.go @@ -240,6 +240,9 @@ func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID str // Write implements io.Writer with threshold-based progress updates and speed tracking func (pw *ItemProgressWriter) Write(p []byte) (int, error) { + if pw.itemID != "" && isDownloadCancelled(pw.itemID) { + return 0, ErrDownloadCancelled + } n, err := pw.writer.Write(p) if err != nil { return n, err diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index e5c3e3b4..350b54f0 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -1,9 +1,11 @@ package gobackend import ( + "context" "bufio" "encoding/base64" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -864,19 +866,30 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, // DownloadFile downloads a file from URL with User-Agent and progress tracking func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) error { + ctx := context.Background() + // Initialize item progress (required for all downloads) if itemID != "" { StartItemProgress(itemID) defer CompleteItemProgress(itemID) + ctx = initDownloadCancel(itemID) + defer clearDownloadCancel(itemID) } - req, err := http.NewRequest("GET", downloadURL, nil) + 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) } resp, err := DoRequestWithUserAgent(q.client, req) if err != nil { + if isDownloadCancelled(itemID) { + return ErrDownloadCancelled + } return err } defer resp.Body.Close() @@ -916,6 +929,9 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e // Check for any errors if err != nil { os.Remove(outputPath) + if isDownloadCancelled(itemID) { + return ErrDownloadCancelled + } return fmt.Errorf("download interrupted: %w", err) } if flushErr != nil { @@ -1095,6 +1111,9 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) { // Download audio file with item ID for progress tracking if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil { + if errors.Is(err, ErrDownloadCancelled) { + return QobuzDownloadResult{}, ErrDownloadCancelled + } return QobuzDownloadResult{}, fmt.Errorf("download failed: %w", err) } diff --git a/go_backend/tidal.go b/go_backend/tidal.go index 15a8f7ea..6373501b 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -1,10 +1,12 @@ package gobackend import ( + "context" "bufio" "encoding/base64" "encoding/json" "encoding/xml" + "errors" "fmt" "io" "net/http" @@ -886,29 +888,45 @@ func parseManifest(manifestB64 string) (directURL string, initURL string, mediaU // DownloadFile downloads a file from URL with progress tracking func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) error { + ctx := context.Background() + // Handle manifest-based download (DASH/BTS) if strings.HasPrefix(downloadURL, "MANIFEST:") { // Initialize progress tracking for manifest downloads if itemID != "" { StartItemProgress(itemID) defer CompleteItemProgress(itemID) + ctx = initDownloadCancel(itemID) + defer clearDownloadCancel(itemID) } - return t.downloadFromManifest(strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, itemID) + if isDownloadCancelled(itemID) { + return ErrDownloadCancelled + } + return t.downloadFromManifest(ctx, strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, itemID) } // Initialize item progress for direct downloads if itemID != "" { StartItemProgress(itemID) defer CompleteItemProgress(itemID) + ctx = initDownloadCancel(itemID) + defer clearDownloadCancel(itemID) } - req, err := http.NewRequest("GET", downloadURL, nil) + 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) } resp, err := DoRequestWithUserAgent(t.client, req) if err != nil { + if isDownloadCancelled(itemID) { + return ErrDownloadCancelled + } return err } defer resp.Body.Close() @@ -948,6 +966,9 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e // Check for any errors if err != nil { os.Remove(outputPath) + if isDownloadCancelled(itemID) { + return ErrDownloadCancelled + } return fmt.Errorf("download interrupted: %w", err) } if flushErr != nil { @@ -968,7 +989,7 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e return nil } -func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID string) error { +func (t *TidalDownloader) downloadFromManifest(ctx context.Context, manifestB64, outputPath, itemID string) error { fmt.Println("[Tidal] Parsing manifest...") directURL, initURL, mediaURLs, err := parseManifest(manifestB64) if err != nil { @@ -987,7 +1008,11 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s GoLog("[Tidal] BTS format - downloading from direct URL: %s...\n", directURL[:min(80, len(directURL))]) // Note: Progress tracking is initialized by the caller (DownloadFile) - req, err := http.NewRequest("GET", directURL, nil) + if isDownloadCancelled(itemID) { + return ErrDownloadCancelled + } + + req, err := http.NewRequestWithContext(ctx, "GET", directURL, nil) if err != nil { GoLog("[Tidal] BTS request creation failed: %v\n", err) return fmt.Errorf("failed to create request: %w", err) @@ -995,6 +1020,9 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s resp, err := client.Do(req) if err != nil { + if isDownloadCancelled(itemID) { + return ErrDownloadCancelled + } GoLog("[Tidal] BTS download failed: %v\n", err) return fmt.Errorf("failed to download file: %w", err) } @@ -1030,6 +1058,9 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s if err != nil { os.Remove(outputPath) + if isDownloadCancelled(itemID) { + return ErrDownloadCancelled + } return fmt.Errorf("download interrupted: %w", err) } if closeErr != nil { @@ -1062,10 +1093,25 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s // Download initialization segment GoLog("[Tidal] Downloading init segment...\n") - resp, err := client.Get(initURL) + if isDownloadCancelled(itemID) { + out.Close() + os.Remove(m4aPath) + return ErrDownloadCancelled + } + req, err := http.NewRequestWithContext(ctx, "GET", initURL, nil) if err != nil { out.Close() os.Remove(m4aPath) + GoLog("[Tidal] Init segment request failed: %v\n", err) + return fmt.Errorf("failed to create init segment request: %w", err) + } + resp, err := client.Do(req) + if err != nil { + out.Close() + os.Remove(m4aPath) + if isDownloadCancelled(itemID) { + return ErrDownloadCancelled + } GoLog("[Tidal] Init segment download failed: %v\n", err) return fmt.Errorf("failed to download init segment: %w", err) } @@ -1081,6 +1127,9 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s if err != nil { out.Close() os.Remove(m4aPath) + if isDownloadCancelled(itemID) { + return ErrDownloadCancelled + } GoLog("[Tidal] Init segment write failed: %v\n", err) return fmt.Errorf("failed to write init segment: %w", err) } @@ -1088,6 +1137,12 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s // Download media segments with progress totalSegments := len(mediaURLs) for i, mediaURL := range mediaURLs { + if isDownloadCancelled(itemID) { + out.Close() + os.Remove(m4aPath) + return ErrDownloadCancelled + } + if i%10 == 0 || i == totalSegments-1 { GoLog("[Tidal] Downloading segment %d/%d...\n", i+1, totalSegments) } @@ -1098,10 +1153,20 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s SetItemProgress(itemID, progress, 0, 0) } - resp, err := client.Get(mediaURL) + req, err := http.NewRequestWithContext(ctx, "GET", mediaURL, nil) if err != nil { out.Close() os.Remove(m4aPath) + GoLog("[Tidal] Segment %d request failed: %v\n", i+1, err) + return fmt.Errorf("failed to create segment %d request: %w", i+1, err) + } + resp, err := client.Do(req) + if err != nil { + out.Close() + os.Remove(m4aPath) + if isDownloadCancelled(itemID) { + return ErrDownloadCancelled + } GoLog("[Tidal] Segment %d download failed: %v\n", i+1, err) return fmt.Errorf("failed to download segment %d: %w", i+1, err) } @@ -1117,6 +1182,9 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s if err != nil { out.Close() os.Remove(m4aPath) + if isDownloadCancelled(itemID) { + return ErrDownloadCancelled + } GoLog("[Tidal] Segment %d write failed: %v\n", i+1, err) return fmt.Errorf("failed to write segment %d: %w", i+1, err) } @@ -1686,6 +1754,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) { }()) if err := downloader.DownloadFile(downloadInfo.URL, outputPath, req.ItemID); err != nil { + if errors.Is(err, ErrDownloadCancelled) { + return TidalDownloadResult{}, ErrDownloadCancelled + } GoLog("[Tidal] Download failed with error: %v\n", err) return TidalDownloadResult{}, fmt.Errorf("download failed: %w", err) } diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 22c9768a..cb01b712 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -120,6 +120,12 @@ import Gobackend // Import Go framework let itemId = args["item_id"] as! String GobackendClearItemProgress(itemId) return nil + + case "cancelDownload": + let args = call.arguments as! [String: Any] + let itemId = args["item_id"] as! String + GobackendCancelDownload(itemId) + return nil case "setDownloadDirectory": let args = call.arguments as! [String: Any] diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 03d6c808..3c98cdc6 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -18,6 +18,14 @@ import 'package:spotiflac_android/utils/logger.dart'; final _log = AppLogger('DownloadQueue'); final _historyLog = AppLogger('DownloadHistory'); +String? _normalizeOptionalString(String? value) { + if (value == null) return null; + final trimmed = value.trim(); + if (trimmed.isEmpty) return null; + if (trimmed.toLowerCase() == 'null') return null; + return trimmed; +} + // Download History Item model class DownloadHistoryItem { final String id; @@ -89,7 +97,7 @@ class DownloadHistoryItem { trackName: json['trackName'] as String, artistName: json['artistName'] as String, albumName: json['albumName'] as String, - albumArtist: json['albumArtist'] as String?, + albumArtist: _normalizeOptionalString(json['albumArtist'] as String?), coverUrl: json['coverUrl'] as String?, filePath: json['filePath'] as String, service: json['service'] as String, @@ -492,6 +500,20 @@ class DownloadQueueNotifier extends Notifier { for (final entry in items.entries) { final itemId = entry.key; + final localItem = state.items + .where((i) => i.id == itemId) + .firstOrNull; + if (localItem == null) { + continue; + } + if (localItem.status == DownloadStatus.skipped) { + PlatformBridge.clearItemProgress(itemId).catchError((_) {}); + continue; + } + if (localItem.status == DownloadStatus.completed || + localItem.status == DownloadStatus.failed) { + continue; + } final itemProgress = entry.value as Map; final bytesReceived = itemProgress['bytes_received'] as int? ?? 0; final bytesTotal = itemProgress['bytes_total'] as int? ?? 0; @@ -671,6 +693,7 @@ class DownloadQueueNotifier extends Notifier { /// Build output directory based on folder organization setting and separateSingles Future _buildOutputDir(Track track, String folderOrganization, {bool separateSingles = false, String albumFolderStructure = 'artist_album'}) async { String baseDir = state.outputDir; + final albumArtist = _normalizeOptionalString(track.albumArtist) ?? track.artistName; // If separateSingles is enabled, use Albums/Singles structure if (separateSingles) { @@ -688,7 +711,7 @@ class DownloadQueueNotifier extends Notifier { } else { // Albums folder structure based on setting final albumName = _sanitizeFolderName(track.albumName); - final artistName = _sanitizeFolderName(track.albumArtist ?? track.artistName); + final artistName = _sanitizeFolderName(albumArtist); final year = _extractYear(track.releaseDate); String albumPath; @@ -729,7 +752,7 @@ class DownloadQueueNotifier extends Notifier { String subPath = ''; switch (folderOrganization) { case 'artist': - final artistName = _sanitizeFolderName(track.albumArtist ?? track.artistName); + final artistName = _sanitizeFolderName(albumArtist); subPath = artistName; break; case 'album': @@ -737,7 +760,7 @@ class DownloadQueueNotifier extends Notifier { subPath = albumName; break; case 'artist_album': - final artistName = _sanitizeFolderName(track.albumArtist ?? track.artistName); + final artistName = _sanitizeFolderName(albumArtist); final albumName = _sanitizeFolderName(track.albumName); subPath = '$artistName${Platform.pathSeparator}$albumName'; break; @@ -874,6 +897,13 @@ class DownloadQueueNotifier extends Notifier { } void updateProgress(String id, double progress, {double? speedMBps}) { + final item = state.items.where((i) => i.id == id).firstOrNull; + if (item == null || + item.status == DownloadStatus.skipped || + item.status == DownloadStatus.completed || + item.status == DownloadStatus.failed) { + return; + } updateItemStatus( id, DownloadStatus.downloading, @@ -884,6 +914,8 @@ class DownloadQueueNotifier extends Notifier { void cancelItem(String id) { updateItemStatus(id, DownloadStatus.skipped); + PlatformBridge.cancelDownload(id).catchError((_) {}); + PlatformBridge.clearItemProgress(id).catchError((_) {}); } void clearCompleted() { @@ -1002,7 +1034,7 @@ class DownloadQueueNotifier extends Notifier { 'title': track.name, 'artist': track.artistName, 'album': track.albumName, - 'album_artist': track.albumArtist ?? track.artistName, + 'album_artist': _normalizeOptionalString(track.albumArtist) ?? track.artistName, 'track_number': track.trackNumber ?? 1, 'disc_number': track.discNumber ?? 1, 'isrc': track.isrc ?? '', @@ -1105,9 +1137,9 @@ class DownloadQueueNotifier extends Notifier { 'ALBUM': track.albumName, }; - if (track.albumArtist != null) { - metadata['ALBUMARTIST'] = track.albumArtist!; - } + final albumArtist = _normalizeOptionalString(track.albumArtist) ?? + track.artistName; + metadata['ALBUMARTIST'] = albumArtist; if (track.trackNumber != null) { metadata['TRACKNUMBER'] = track.trackNumber.toString(); @@ -1415,6 +1447,15 @@ class DownloadQueueNotifier extends Notifier { _log.d('Processing: ${item.track.name} by ${item.track.artistName}'); _log.d('Cover URL: ${item.track.coverUrl}'); + final currentItem = state.items.firstWhere( + (i) => i.id == item.id, + orElse: () => item, + ); + if (currentItem.status == DownloadStatus.skipped) { + _log.i('Download was cancelled before start, skipping'); + return; + } + // Set currentDownload for UI reference state = state.copyWith(currentDownload: item); @@ -1505,6 +1546,9 @@ class DownloadQueueNotifier extends Notifier { // Log cover URL for debugging CSV import issues _log.d('Track coverUrl after enrichment: ${trackToDownload.coverUrl}'); + final normalizedAlbumArtist = + _normalizeOptionalString(trackToDownload.albumArtist); + final outputDir = await _buildOutputDir( trackToDownload, settings.folderOrganization, @@ -1535,7 +1579,7 @@ class DownloadQueueNotifier extends Notifier { trackName: trackToDownload.name, artistName: trackToDownload.artistName, albumName: trackToDownload.albumName, - albumArtist: trackToDownload.albumArtist, + albumArtist: normalizedAlbumArtist, coverUrl: trackToDownload.coverUrl, outputDir: outputDir, filenameFormat: state.filenameFormat, @@ -1559,7 +1603,7 @@ class DownloadQueueNotifier extends Notifier { trackName: trackToDownload.name, artistName: trackToDownload.artistName, albumName: trackToDownload.albumName, - albumArtist: trackToDownload.albumArtist, + albumArtist: normalizedAlbumArtist, coverUrl: trackToDownload.coverUrl, outputDir: outputDir, filenameFormat: state.filenameFormat, @@ -1580,7 +1624,7 @@ class DownloadQueueNotifier extends Notifier { trackName: trackToDownload.name, artistName: trackToDownload.artistName, albumName: trackToDownload.albumName, - albumArtist: trackToDownload.albumArtist, + albumArtist: normalizedAlbumArtist, coverUrl: trackToDownload.coverUrl, outputDir: outputDir, filenameFormat: state.filenameFormat, @@ -1645,7 +1689,6 @@ class DownloadQueueNotifier extends Notifier { _log.i('Actual quality: $actualQuality'); } - // M4A files from Tidal DASH streams - try to convert to FLAC // M4A files from Tidal DASH streams - try to convert to FLAC if (filePath != null && filePath.endsWith('.m4a')) { _log.d( @@ -1715,7 +1758,7 @@ class DownloadQueueNotifier extends Notifier { name: trackToDownload.name, artistName: trackToDownload.artistName, albumName: backendAlbum ?? trackToDownload.albumName, - albumArtist: trackToDownload.albumArtist, + albumArtist: normalizedAlbumArtist, coverUrl: trackToDownload.coverUrl, duration: trackToDownload.duration, isrc: trackToDownload.isrc, @@ -1806,6 +1849,12 @@ class DownloadQueueNotifier extends Notifier { // Log cover URL for debugging _log.d('Saving to history - coverUrl: ${trackToDownload.coverUrl}'); + final historyAlbumArtist = + (normalizedAlbumArtist != null && + normalizedAlbumArtist != trackToDownload.artistName) + ? normalizedAlbumArtist + : null; + ref .read(downloadHistoryProvider.notifier) .addToHistory( @@ -1820,7 +1869,7 @@ class DownloadQueueNotifier extends Notifier { albumName: (backendAlbum != null && backendAlbum.isNotEmpty) ? backendAlbum : trackToDownload.albumName, - albumArtist: trackToDownload.albumArtist, + albumArtist: historyAlbumArtist, coverUrl: trackToDownload.coverUrl, filePath: filePath, service: result['service'] as String? ?? item.service, @@ -1849,8 +1898,22 @@ class DownloadQueueNotifier extends Notifier { removeItem(item.id); } } else { + final itemAfterFailure = state.items.firstWhere( + (i) => i.id == item.id, + orElse: () => item, + ); + if (itemAfterFailure.status == DownloadStatus.skipped) { + _log.i('Download was cancelled, skipping error handling'); + return; + } + final errorMsg = result['error'] as String? ?? 'Download failed'; final errorTypeStr = result['error_type'] as String? ?? 'unknown'; + if (errorTypeStr == 'cancelled') { + _log.i('Download was cancelled by backend, skipping error handling'); + updateItemStatus(item.id, DownloadStatus.skipped); + return; + } // Convert error type string to enum DownloadErrorType errorType; @@ -1894,6 +1957,15 @@ class DownloadQueueNotifier extends Notifier { } } } catch (e, stackTrace) { + final itemAfterError = state.items.firstWhere( + (i) => i.id == item.id, + orElse: () => item, + ); + if (itemAfterError.status == DownloadStatus.skipped) { + _log.i('Download was cancelled, skipping error handling'); + return; + } + _log.e('Exception: $e', e, stackTrace); String errorMsg = e.toString(); diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index 29e57250..272a8dc8 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -277,12 +277,19 @@ class TrackNotifier extends Notifier { final hasActiveMetadataExtensions = extensionState.extensions.any( (e) => e.enabled && e.hasMetadataProvider, ); - final useExtensions = settings.useExtensionProviders && hasActiveMetadataExtensions; + final searchProvider = settings.searchProvider; + final useExtensions = + settings.useExtensionProviders && + hasActiveMetadataExtensions && + searchProvider != null && + searchProvider.isNotEmpty; // Use Deezer or Spotify based on settings final source = metadataSource ?? 'deezer'; - _log.i('Search started: source=$source, query="$query", useExtensions=$useExtensions'); + _log.i( + 'Search started: source=$source, query="$query", useExtensions=$useExtensions', + ); Map results; List extensionTracks = []; diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 3380cda3..200d2cd8 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:open_filex/open_filex.dart'; +import 'package:spotiflac_android/utils/mime_utils.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; @@ -132,7 +133,8 @@ class _DownloadedAlbumScreenState extends ConsumerState { Future _openFile(String filePath) async { try { - await OpenFilex.open(filePath); + final mimeType = audioMimeTypeForPath(filePath); + await OpenFilex.open(filePath, type: mimeType); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index a3674035..c69fc1d1 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -4,6 +4,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:open_filex/open_filex.dart'; +import 'package:spotiflac_android/utils/mime_utils.dart'; import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; @@ -172,7 +173,8 @@ class _QueueTabState extends ConsumerState { Future _openFile(String filePath) async { final cleanPath = _cleanFilePath(filePath); try { - await OpenFilex.open(cleanPath); + final mimeType = audioMimeTypeForPath(cleanPath); + await OpenFilex.open(cleanPath, type: mimeType); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( diff --git a/lib/screens/settings/about_page.dart b/lib/screens/settings/about_page.dart index a17857f3..cc08f88a 100644 --- a/lib/screens/settings/about_page.dart +++ b/lib/screens/settings/about_page.dart @@ -98,9 +98,9 @@ class AboutPage extends StatelessWidget { child: SettingsGroup( children: [ _ContributorItem( - name: 'uimaxbai', + name: 'binimum', description: 'The creator of QQDL & HiFi API. Without this API, Tidal downloads wouldn\'t exist!', - githubUsername: 'uimaxbai', + githubUsername: 'binimum', showDivider: true, ), _ContributorItem( diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 72e52492..62c5d47c 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:open_filex/open_filex.dart'; +import 'package:spotiflac_android/utils/mime_utils.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:share_plus/share_plus.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; @@ -27,6 +28,14 @@ class _TrackMetadataScreenState extends ConsumerState { bool _lyricsLoading = false; String? _lyricsError; + String? _normalizeOptionalString(String? value) { + if (value == null) return null; + final trimmed = value.trim(); + if (trimmed.isEmpty) return null; + if (trimmed.toLowerCase() == 'null') return null; + return trimmed; + } + @override void initState() { super.initState(); @@ -68,7 +77,7 @@ class _TrackMetadataScreenState extends ConsumerState { String get trackName => item.trackName; String get artistName => item.artistName; String get albumName => item.albumName; - String? get albumArtist => item.albumArtist; + String? get albumArtist => _normalizeOptionalString(item.albumArtist); int? get trackNumber => item.trackNumber; int? get discNumber => item.discNumber; String? get releaseDate => item.releaseDate; @@ -970,7 +979,8 @@ class _TrackMetadataScreenState extends ConsumerState { Future _openFile(BuildContext context, String filePath) async { try { - final result = await OpenFilex.open(filePath); + final mimeType = audioMimeTypeForPath(filePath); + final result = await OpenFilex.open(filePath, type: mimeType); if (result.type != ResultType.done && context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Cannot open: ${result.message}')), diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 44246125..31de5a07 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -199,6 +199,11 @@ class PlatformBridge { await _channel.invokeMethod('clearItemProgress', {'item_id': itemId}); } + /// Cancel an in-progress download + static Future cancelDownload(String itemId) async { + await _channel.invokeMethod('cancelDownload', {'item_id': itemId}); + } + /// Set download directory static Future setDownloadDirectory(String path) async { await _channel.invokeMethod('setDownloadDirectory', {'path': path}); diff --git a/lib/utils/mime_utils.dart b/lib/utils/mime_utils.dart new file mode 100644 index 00000000..ecee23f4 --- /dev/null +++ b/lib/utils/mime_utils.dart @@ -0,0 +1,24 @@ +String audioMimeTypeForPath(String filePath) { + final dotIndex = filePath.lastIndexOf('.'); + if (dotIndex == -1 || dotIndex == filePath.length - 1) { + return 'audio/*'; + } + + final ext = filePath.substring(dotIndex + 1).toLowerCase(); + switch (ext) { + case 'flac': + return 'audio/flac'; + case 'm4a': + return 'audio/mp4'; + case 'mp3': + return 'audio/mpeg'; + case 'ogg': + return 'audio/ogg'; + case 'wav': + return 'audio/wav'; + case 'aac': + return 'audio/aac'; + default: + return 'audio/*'; + } +}