From 05d25d4d7ca278f81154bfd1a852bbd8aeea1c0f Mon Sep 17 00:00:00 2001 From: zarzet Date: Tue, 10 Feb 2026 10:11:02 +0700 Subject: [PATCH] v3.6.1: fix lyrics_mode, notification v20, SAF duplicate, primary artist setting, unified download strategy --- CHANGELOG.md | 16 + .../kotlin/com/zarz/spotiflac/MainActivity.kt | 9 + go_backend/exports.go | 205 +- lib/constants/app_info.dart | 4 +- lib/providers/download_queue_provider.dart | 118 +- lib/services/download_request_payload.dart | 154 + lib/services/notification_service.dart | 62 +- lib/services/platform_bridge.dart | 2656 +++++++++-------- pubspec.lock | 64 +- pubspec.yaml | 2 +- 10 files changed, 1811 insertions(+), 1479 deletions(-) create mode 100644 lib/services/download_request_payload.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index cdb01dd3..de083e93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## [3.6.1] - 2026-02-10 + +### Added + +- "Use Primary Artist Only" setting: strips featured artists from folder names (e.g. "Justin Bieber, Quavo" becomes "Justin Bieber") for cleaner folder organization + - Supports separators: `, ` `;` `&` `feat.` `ft.` `featuring` `with` `x` + - Available in Settings > Download > below "Use Album Artist for folders" + +### Fixed + +- Fixed lyrics mode "External .LRC" still embedding lyrics into metadata - `lyrics_mode` was not being sent to Go backend for single-service downloads and YouTube provider, causing Go to default to "embed" +- Fixed `flutter_local_notifications` v20 breaking changes - migrated all `initialize()`, `show()`, and `cancel()` calls from positional parameters to named parameters +- Fixed SAF duplicate folder bug: concurrent batch downloads creating empty folders with `(1)`, `(2)`, `(3)` suffixes - added synchronized lock to `ensureDocumentDir` in Kotlin with duplicate detection and cleanup + +--- + ## [3.6.0] - 2026-02-09 ### Highlights 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 d0f27b65..52615fbe 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -1312,6 +1312,15 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } + "downloadByStrategy" -> { + val requestJson = call.arguments as String + val response = withContext(Dispatchers.IO) { + handleSafDownload(requestJson) { json -> + Gobackend.downloadByStrategy(json) + } + } + result.success(response) + } "getDownloadProgress" -> { val response = withContext(Dispatchers.IO) { Gobackend.getDownloadProgress() diff --git a/go_backend/exports.go b/go_backend/exports.go index a30e1628..b6b7a630 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -150,6 +150,8 @@ type DownloadRequest struct { QobuzID string `json:"qobuz_id,omitempty"` DeezerID string `json:"deezer_id,omitempty"` LyricsMode string `json:"lyrics_mode,omitempty"` + UseExtensions bool `json:"use_extensions,omitempty"` + UseFallback bool `json:"use_fallback,omitempty"` } type DownloadResponse struct { @@ -192,6 +194,73 @@ type DownloadResult struct { LyricsLRC string } +func buildDownloadSuccessResponse( + req DownloadRequest, + result DownloadResult, + service string, + message string, + filePath string, + alreadyExists bool, +) DownloadResponse { + title := result.Title + if title == "" { + title = req.TrackName + } + + artist := result.Artist + if artist == "" { + artist = req.ArtistName + } + + album := result.Album + if album == "" { + album = req.AlbumName + } + + releaseDate := result.ReleaseDate + if releaseDate == "" { + releaseDate = req.ReleaseDate + } + + trackNumber := result.TrackNumber + if trackNumber == 0 { + trackNumber = req.TrackNumber + } + + discNumber := result.DiscNumber + if discNumber == 0 { + discNumber = req.DiscNumber + } + + isrc := result.ISRC + if isrc == "" { + isrc = req.ISRC + } + + return DownloadResponse{ + Success: true, + Message: message, + FilePath: filePath, + AlreadyExists: alreadyExists, + ActualBitDepth: result.BitDepth, + ActualSampleRate: result.SampleRate, + Service: service, + Title: title, + Artist: artist, + Album: album, + AlbumArtist: req.AlbumArtist, + ReleaseDate: releaseDate, + TrackNumber: trackNumber, + DiscNumber: discNumber, + ISRC: isrc, + CoverURL: req.CoverURL, + Genre: req.Genre, + Label: req.Label, + Copyright: req.Copyright, + LyricsLRC: result.LyricsLRC, + } +} + func DownloadTrack(requestJSON string) (string, error) { var req DownloadRequest if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { @@ -301,22 +370,14 @@ func DownloadTrack(requestJSON string) (string, error) { result.BitDepth = quality.BitDepth result.SampleRate = quality.SampleRate } - resp := DownloadResponse{ - Success: true, - Message: "File already exists", - FilePath: actualPath, - AlreadyExists: true, - ActualBitDepth: result.BitDepth, - ActualSampleRate: result.SampleRate, - Service: req.Service, - Title: result.Title, - Artist: result.Artist, - Album: result.Album, - ReleaseDate: result.ReleaseDate, - TrackNumber: result.TrackNumber, - DiscNumber: result.DiscNumber, - ISRC: result.ISRC, - } + resp := buildDownloadSuccessResponse( + req, + result, + req.Service, + "File already exists", + actualPath, + true, + ) jsonBytes, _ := json.Marshal(resp) return string(jsonBytes), nil } @@ -330,27 +391,54 @@ func DownloadTrack(requestJSON string) (string, error) { GoLog("[Download] Could not read quality from file: %v\n", qErr) } - resp := DownloadResponse{ - Success: true, - Message: "Download complete", - FilePath: result.FilePath, - ActualBitDepth: result.BitDepth, - ActualSampleRate: result.SampleRate, - Service: req.Service, - Title: result.Title, - Artist: result.Artist, - Album: result.Album, - ReleaseDate: result.ReleaseDate, - TrackNumber: result.TrackNumber, - DiscNumber: result.DiscNumber, - ISRC: result.ISRC, - LyricsLRC: result.LyricsLRC, - } + resp := buildDownloadSuccessResponse( + req, + result, + req.Service, + "Download complete", + result.FilePath, + false, + ) jsonBytes, _ := json.Marshal(resp) return string(jsonBytes), nil } +// DownloadByStrategy routes a unified download request to the appropriate flow. +// Routing priority: YouTube service > extension fallback > built-in fallback > direct service. +func DownloadByStrategy(requestJSON string) (string, error) { + var req DownloadRequest + if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { + return errorResponse("Invalid request: " + err.Error()) + } + + service := strings.TrimSpace(strings.ToLower(req.Service)) + req.Service = service + normalizedBytes, err := json.Marshal(req) + if err != nil { + return errorResponse("Invalid request: " + err.Error()) + } + normalizedJSON := string(normalizedBytes) + + if service == "youtube" { + return DownloadFromYouTube(normalizedJSON) + } + + if req.UseExtensions { + resp, err := DownloadWithExtensionsJSON(normalizedJSON) + if err != nil { + return errorResponse(err.Error()) + } + return resp, nil + } + + if req.UseFallback { + return DownloadWithFallback(normalizedJSON) + } + + return DownloadTrack(normalizedJSON) +} + func DownloadWithFallback(requestJSON string) (string, error) { var req DownloadRequest if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { @@ -470,23 +558,14 @@ func DownloadWithFallback(requestJSON string) (string, error) { result.BitDepth = quality.BitDepth result.SampleRate = quality.SampleRate } - resp := DownloadResponse{ - Success: true, - Message: "File already exists", - FilePath: actualPath, - AlreadyExists: true, - ActualBitDepth: result.BitDepth, - ActualSampleRate: result.SampleRate, - Service: service, - Title: result.Title, - Artist: result.Artist, - Album: result.Album, - ReleaseDate: result.ReleaseDate, - TrackNumber: result.TrackNumber, - DiscNumber: result.DiscNumber, - ISRC: result.ISRC, - LyricsLRC: result.LyricsLRC, - } + resp := buildDownloadSuccessResponse( + req, + result, + service, + "File already exists", + actualPath, + true, + ) jsonBytes, _ := json.Marshal(resp) return string(jsonBytes), nil } @@ -500,22 +579,14 @@ func DownloadWithFallback(requestJSON string) (string, error) { GoLog("[Download] Could not read quality from file: %v\n", qErr) } - resp := DownloadResponse{ - Success: true, - Message: "Downloaded from " + service, - FilePath: result.FilePath, - ActualBitDepth: result.BitDepth, - ActualSampleRate: result.SampleRate, - Service: service, - Title: result.Title, - Artist: result.Artist, - Album: result.Album, - ReleaseDate: result.ReleaseDate, - TrackNumber: result.TrackNumber, - DiscNumber: result.DiscNumber, - ISRC: result.ISRC, - LyricsLRC: result.LyricsLRC, - } + resp := buildDownloadSuccessResponse( + req, + result, + service, + "Downloaded from "+service, + result.FilePath, + false, + ) jsonBytes, _ := json.Marshal(resp) return string(jsonBytes), nil } @@ -1266,6 +1337,10 @@ func DownloadFromYouTube(requestJSON string) (string, error) { DiscNumber: youtubeResult.DiscNumber, ISRC: youtubeResult.ISRC, LyricsLRC: youtubeResult.LyricsLRC, + CoverURL: req.CoverURL, + Genre: req.Genre, + Label: req.Label, + Copyright: req.Copyright, } jsonBytes, _ := json.Marshal(resp) diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index 7dbe289b..75306c04 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -1,8 +1,8 @@ /// App version and info constants /// Update version here only - all other files will reference this class AppInfo { - static const String version = '3.6.0'; - static const String buildNumber = '77'; + static const String version = '3.6.1'; + static const String buildNumber = '78'; static const String fullVersion = '$version+$buildNumber'; diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 0a634496..b65f77ac 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -12,6 +12,7 @@ import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/services/download_request_payload.dart'; import 'package:spotiflac_android/services/ffmpeg_service.dart'; import 'package:spotiflac_android/services/notification_service.dart'; import 'package:spotiflac_android/services/history_database.dart'; @@ -2756,129 +2757,64 @@ class DownloadQueueNotifier extends Notifier { final relativeDir = useSaf ? outputDir : ''; final fileName = useSaf ? (safFileName ?? '') : ''; final outputExt = useSaf ? safOutputExt : ''; + final isYouTube = item.service == 'youtube'; + final shouldUseExtensions = !isYouTube && useExtensions; + final shouldUseFallback = + !isYouTube && !shouldUseExtensions && state.autoFallback; - // YouTube provider - lossy only, bypasses fallback chain - if (item.service == 'youtube') { + if (isYouTube) { _log.d('Using YouTube/Cobalt provider for download'); _log.d('Quality: $quality (lossy only)'); - _log.d('Output dir: $outputDir'); - return PlatformBridge.downloadFromYouTube( - trackName: trackToDownload.name, - artistName: trackToDownload.artistName, - albumName: trackToDownload.albumName, - albumArtist: normalizedAlbumArtist, - coverUrl: trackToDownload.coverUrl, - outputDir: outputDir, - filenameFormat: state.filenameFormat, - quality: quality, - trackNumber: trackToDownload.trackNumber ?? 1, - discNumber: trackToDownload.discNumber ?? 1, - releaseDate: trackToDownload.releaseDate, - itemId: item.id, - durationMs: trackToDownload.duration, - isrc: trackToDownload.isrc, - spotifyId: trackToDownload.id, - deezerId: deezerTrackId, - storageMode: storageMode, - safTreeUri: treeUri, - safRelativeDir: relativeDir, - safFileName: fileName, - safOutputExt: outputExt, - ); - } - - if (useExtensions) { + } else if (shouldUseExtensions) { _log.d('Using extension providers for download'); _log.d( 'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}', ); - _log.d('Output dir: $outputDir'); - return PlatformBridge.downloadWithExtensions( - isrc: trackToDownload.isrc ?? '', - spotifyId: trackToDownload.id, - trackName: trackToDownload.name, - artistName: trackToDownload.artistName, - albumName: trackToDownload.albumName, - albumArtist: normalizedAlbumArtist, - coverUrl: trackToDownload.coverUrl, - outputDir: outputDir, - filenameFormat: state.filenameFormat, - quality: quality, - trackNumber: trackToDownload.trackNumber ?? 1, - discNumber: trackToDownload.discNumber ?? 1, - releaseDate: trackToDownload.releaseDate, - itemId: item.id, - durationMs: trackToDownload.duration, - source: trackToDownload.source, - genre: genre, - label: label, - lyricsMode: settings.lyricsMode, - preferredService: item.service, - storageMode: storageMode, - safTreeUri: treeUri, - safRelativeDir: relativeDir, - safFileName: fileName, - safOutputExt: outputExt, - ); - } - - if (state.autoFallback) { + } else if (shouldUseFallback) { _log.d('Using auto-fallback mode'); _log.d( 'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}', ); - _log.d('Output dir: $outputDir'); - return PlatformBridge.downloadWithFallback( - isrc: trackToDownload.isrc ?? '', - spotifyId: trackToDownload.id, - trackName: trackToDownload.name, - artistName: trackToDownload.artistName, - albumName: trackToDownload.albumName, - albumArtist: normalizedAlbumArtist, - coverUrl: trackToDownload.coverUrl, - outputDir: outputDir, - filenameFormat: state.filenameFormat, - quality: quality, - trackNumber: trackToDownload.trackNumber ?? 1, - discNumber: trackToDownload.discNumber ?? 1, - releaseDate: trackToDownload.releaseDate, - preferredService: item.service, - itemId: item.id, - durationMs: trackToDownload.duration, - genre: genre, - label: label, - lyricsMode: settings.lyricsMode, - storageMode: storageMode, - safTreeUri: treeUri, - safRelativeDir: relativeDir, - safFileName: fileName, - safOutputExt: outputExt, - ); } + _log.d('Output dir: $outputDir'); - return PlatformBridge.downloadTrack( + final payload = DownloadRequestPayload( isrc: trackToDownload.isrc ?? '', service: item.service, spotifyId: trackToDownload.id, trackName: trackToDownload.name, artistName: trackToDownload.artistName, albumName: trackToDownload.albumName, - albumArtist: normalizedAlbumArtist, - coverUrl: trackToDownload.coverUrl, + albumArtist: normalizedAlbumArtist ?? trackToDownload.artistName, + coverUrl: trackToDownload.coverUrl ?? '', outputDir: outputDir, filenameFormat: state.filenameFormat, quality: quality, + // Keep prior behavior: non-YouTube paths were implicitly true. + embedLyrics: isYouTube ? settings.embedLyrics : true, + embedMaxQualityCover: settings.maxQualityCover, trackNumber: trackToDownload.trackNumber ?? 1, discNumber: trackToDownload.discNumber ?? 1, - releaseDate: trackToDownload.releaseDate, + releaseDate: trackToDownload.releaseDate ?? '', itemId: item.id, durationMs: trackToDownload.duration, + source: trackToDownload.source ?? '', + genre: genre ?? '', + label: label ?? '', + deezerId: deezerTrackId ?? '', + lyricsMode: settings.lyricsMode, storageMode: storageMode, safTreeUri: treeUri, safRelativeDir: relativeDir, safFileName: fileName, safOutputExt: outputExt, ); + + return PlatformBridge.downloadByStrategy( + payload: payload, + useExtensions: shouldUseExtensions, + useFallback: shouldUseFallback, + ); } result = await runDownload( diff --git a/lib/services/download_request_payload.dart b/lib/services/download_request_payload.dart new file mode 100644 index 00000000..bff0a941 --- /dev/null +++ b/lib/services/download_request_payload.dart @@ -0,0 +1,154 @@ +class DownloadRequestPayload { + final String isrc; + final String service; + final String spotifyId; + final String trackName; + final String artistName; + final String albumName; + final String albumArtist; + final String coverUrl; + final String outputDir; + final String filenameFormat; + final String quality; + final bool embedLyrics; + final bool embedMaxQualityCover; + final int trackNumber; + final int discNumber; + final int totalTracks; + final String releaseDate; + final String itemId; + final int durationMs; + final String source; + final String genre; + final String label; + final String copyright; + final String tidalId; + final String qobuzId; + final String deezerId; + final String lyricsMode; + final bool useExtensions; + final bool useFallback; + final String storageMode; + final String safTreeUri; + final String safRelativeDir; + final String safFileName; + final String safOutputExt; + + const DownloadRequestPayload({ + this.isrc = '', + this.service = '', + this.spotifyId = '', + required this.trackName, + required this.artistName, + required this.albumName, + this.albumArtist = '', + this.coverUrl = '', + required this.outputDir, + required this.filenameFormat, + this.quality = 'LOSSLESS', + this.embedLyrics = true, + this.embedMaxQualityCover = true, + this.trackNumber = 1, + this.discNumber = 1, + this.totalTracks = 1, + this.releaseDate = '', + this.itemId = '', + this.durationMs = 0, + this.source = '', + this.genre = '', + this.label = '', + this.copyright = '', + this.tidalId = '', + this.qobuzId = '', + this.deezerId = '', + this.lyricsMode = 'embed', + this.useExtensions = false, + this.useFallback = false, + this.storageMode = 'app', + this.safTreeUri = '', + this.safRelativeDir = '', + this.safFileName = '', + this.safOutputExt = '', + }); + + Map toJson() { + return { + 'isrc': isrc, + 'service': service, + 'spotify_id': spotifyId, + 'track_name': trackName, + 'artist_name': artistName, + 'album_name': albumName, + 'album_artist': albumArtist, + 'cover_url': coverUrl, + 'output_dir': outputDir, + 'filename_format': filenameFormat, + 'quality': quality, + 'embed_lyrics': embedLyrics, + 'embed_max_quality_cover': embedMaxQualityCover, + 'track_number': trackNumber, + 'disc_number': discNumber, + 'total_tracks': totalTracks, + 'release_date': releaseDate, + 'item_id': itemId, + 'duration_ms': durationMs, + 'source': source, + 'genre': genre, + 'label': label, + 'copyright': copyright, + 'tidal_id': tidalId, + 'qobuz_id': qobuzId, + 'deezer_id': deezerId, + 'lyrics_mode': lyricsMode, + 'use_extensions': useExtensions, + 'use_fallback': useFallback, + 'storage_mode': storageMode, + 'saf_tree_uri': safTreeUri, + 'saf_relative_dir': safRelativeDir, + 'saf_file_name': safFileName, + 'saf_output_ext': safOutputExt, + }; + } + + DownloadRequestPayload withStrategy({ + bool? useExtensions, + bool? useFallback, + }) { + return DownloadRequestPayload( + isrc: isrc, + service: service, + spotifyId: spotifyId, + trackName: trackName, + artistName: artistName, + albumName: albumName, + albumArtist: albumArtist, + coverUrl: coverUrl, + outputDir: outputDir, + filenameFormat: filenameFormat, + quality: quality, + embedLyrics: embedLyrics, + embedMaxQualityCover: embedMaxQualityCover, + trackNumber: trackNumber, + discNumber: discNumber, + totalTracks: totalTracks, + releaseDate: releaseDate, + itemId: itemId, + durationMs: durationMs, + source: source, + genre: genre, + label: label, + copyright: copyright, + tidalId: tidalId, + qobuzId: qobuzId, + deezerId: deezerId, + lyricsMode: lyricsMode, + useExtensions: useExtensions ?? this.useExtensions, + useFallback: useFallback ?? this.useFallback, + storageMode: storageMode, + safTreeUri: safTreeUri, + safRelativeDir: safRelativeDir, + safFileName: safFileName, + safOutputExt: safOutputExt, + ); + } +} diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index 13d84e72..430c779c 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -30,7 +30,7 @@ class NotificationService { iOS: iosSettings, ); - await _notifications.initialize(initSettings); + await _notifications.initialize(settings: initSettings); if (Platform.isAndroid) { await _notifications @@ -90,10 +90,10 @@ class NotificationService { ); await _notifications.show( - downloadProgressId, - 'Downloading $trackName', - '$artistName • $percentage%', - details, + id: downloadProgressId, + title: 'Downloading $trackName', + body: '$artistName • $percentage%', + notificationDetails: details, ); } @@ -133,10 +133,10 @@ class NotificationService { ); await _notifications.show( - downloadProgressId, - 'Finalizing $trackName', - '$artistName • Embedding metadata...', - details, + id: downloadProgressId, + title: 'Finalizing $trackName', + body: '$artistName • Embedding metadata...', + notificationDetails: details, ); } @@ -183,10 +183,10 @@ class NotificationService { ); await _notifications.show( - downloadProgressId, - title, - '$trackName - $artistName', - details, + id: downloadProgressId, + title: title, + body: '$trackName - $artistName', + notificationDetails: details, ); } @@ -223,15 +223,15 @@ class NotificationService { ); await _notifications.show( - downloadProgressId, - title, - '$completedCount tracks downloaded successfully', - details, + id: downloadProgressId, + title: title, + body: '$completedCount tracks downloaded successfully', + notificationDetails: details, ); } Future cancelDownloadNotification() async { - await _notifications.cancel(downloadProgressId); + await _notifications.cancel(id: downloadProgressId); } Future showUpdateDownloadProgress({ @@ -274,10 +274,10 @@ class NotificationService { ); await _notifications.show( - updateDownloadId, - 'Downloading SpotiFLAC v$version', - '$receivedMB / $totalMB MB • $percentage%', - details, + id: updateDownloadId, + title: 'Downloading SpotiFLAC v$version', + body: '$receivedMB / $totalMB MB • $percentage%', + notificationDetails: details, ); } @@ -307,10 +307,10 @@ class NotificationService { ); await _notifications.show( - updateDownloadId, - 'Update Ready', - 'SpotiFLAC v$version downloaded. Tap to install.', - details, + id: updateDownloadId, + title: 'Update Ready', + body: 'SpotiFLAC v$version downloaded. Tap to install.', + notificationDetails: details, ); } @@ -339,14 +339,14 @@ class NotificationService { ); await _notifications.show( - updateDownloadId, - 'Update Failed', - 'Could not download update. Try again later.', - details, + id: updateDownloadId, + title: 'Update Failed', + body: 'Could not download update. Try again later.', + notificationDetails: details, ); } Future cancelUpdateNotification() async { - await _notifications.cancel(updateDownloadId); + await _notifications.cancel(id: updateDownloadId); } } diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 7bcae04a..74bcaed0 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -1,1253 +1,1403 @@ -import 'dart:convert'; -import 'package:flutter/services.dart'; -import 'package:spotiflac_android/utils/logger.dart'; - -final _log = AppLogger('PlatformBridge'); - -class PlatformBridge { - static const _channel = MethodChannel('com.zarz.spotiflac/backend'); - - static Future> parseSpotifyUrl(String url) async { - _log.d('parseSpotifyUrl: $url'); - final result = await _channel.invokeMethod('parseSpotifyUrl', {'url': url}); - return jsonDecode(result as String) as Map; - } - - static Future> getSpotifyMetadata(String url) async { - _log.d('getSpotifyMetadata: $url'); - final result = await _channel.invokeMethod('getSpotifyMetadata', {'url': url}); - return jsonDecode(result as String) as Map; - } - - static Future> searchSpotify(String query, {int limit = 10}) async { - _log.d('searchSpotify: "$query" (limit: $limit)'); - final result = await _channel.invokeMethod('searchSpotify', { - 'query': query, - 'limit': limit, - }); - return jsonDecode(result as String) as Map; - } - - static Future> searchSpotifyAll(String query, {int trackLimit = 15, int artistLimit = 3}) async { - _log.d('searchSpotifyAll: "$query"'); - final result = await _channel.invokeMethod('searchSpotifyAll', { - 'query': query, - 'track_limit': trackLimit, - 'artist_limit': artistLimit, - }); - return jsonDecode(result as String) as Map; - } - - static Future> checkAvailability(String spotifyId, String isrc) async { - _log.d('checkAvailability: $spotifyId (ISRC: $isrc)'); - final result = await _channel.invokeMethod('checkAvailability', { - 'spotify_id': spotifyId, - 'isrc': isrc, - }); - return jsonDecode(result as String) as Map; - } - - static Future> downloadTrack({ - required String isrc, - required String service, - required String spotifyId, - required String trackName, - required String artistName, - required String albumName, - String? albumArtist, - String? coverUrl, - required String outputDir, - required String filenameFormat, - String quality = 'LOSSLESS', - bool embedLyrics = true, - bool embedMaxQualityCover = true, - int trackNumber = 1, - int discNumber = 1, - int totalTracks = 1, - String? releaseDate, - String? itemId, - int durationMs = 0, - String storageMode = 'app', - String safTreeUri = '', - String safRelativeDir = '', - String safFileName = '', - String safOutputExt = '', - }) async { - _log.i('downloadTrack: "$trackName" by $artistName via $service'); - final request = jsonEncode({ - 'isrc': isrc, - 'service': service, - 'spotify_id': spotifyId, - 'track_name': trackName, - 'artist_name': artistName, - 'album_name': albumName, - 'album_artist': albumArtist ?? artistName, - 'cover_url': coverUrl, - 'output_dir': outputDir, - 'filename_format': filenameFormat, - 'quality': quality, - 'embed_lyrics': embedLyrics, - 'embed_max_quality_cover': embedMaxQualityCover, - 'track_number': trackNumber, - 'disc_number': discNumber, - 'total_tracks': totalTracks, - 'release_date': releaseDate ?? '', - 'item_id': itemId ?? '', - 'duration_ms': durationMs, - 'storage_mode': storageMode, - 'saf_tree_uri': safTreeUri, - 'saf_relative_dir': safRelativeDir, - 'saf_file_name': safFileName, - 'saf_output_ext': safOutputExt, - }); - - final result = await _channel.invokeMethod('downloadTrack', request); - final response = jsonDecode(result as String) as Map; - if (response['success'] == true) { - _log.i('Download success: ${response['file_path']}'); - } else { - _log.w('Download failed: ${response['error']}'); - } - return response; - } - - static Future> downloadWithFallback({ - required String isrc, - required String spotifyId, - required String trackName, - required String artistName, - required String albumName, - String? albumArtist, - String? coverUrl, - required String outputDir, - required String filenameFormat, - String quality = 'LOSSLESS', - bool embedLyrics = true, - bool embedMaxQualityCover = true, - int trackNumber = 1, - int discNumber = 1, - int totalTracks = 1, - String? releaseDate, - String preferredService = 'tidal', - String? itemId, - int durationMs = 0, - String? genre, - String? label, - String? copyright, - String lyricsMode = 'embed', - String storageMode = 'app', - String safTreeUri = '', - String safRelativeDir = '', - String safFileName = '', - String safOutputExt = '', - }) async { - _log.i('downloadWithFallback: "$trackName" by $artistName (preferred: $preferredService)'); - final request = jsonEncode({ - 'isrc': isrc, - 'service': preferredService, - 'spotify_id': spotifyId, - 'track_name': trackName, - 'artist_name': artistName, - 'album_name': albumName, - 'album_artist': albumArtist ?? artistName, - 'cover_url': coverUrl, - 'output_dir': outputDir, - 'filename_format': filenameFormat, - 'quality': quality, - 'embed_lyrics': embedLyrics, - 'embed_max_quality_cover': embedMaxQualityCover, - 'track_number': trackNumber, - 'disc_number': discNumber, - 'total_tracks': totalTracks, - 'release_date': releaseDate ?? '', - 'item_id': itemId ?? '', - 'duration_ms': durationMs, - 'genre': genre ?? '', - 'label': label ?? '', - 'copyright': copyright ?? '', - 'lyrics_mode': lyricsMode, - 'storage_mode': storageMode, - 'saf_tree_uri': safTreeUri, - 'saf_relative_dir': safRelativeDir, - 'saf_file_name': safFileName, - 'saf_output_ext': safOutputExt, - }); - - final result = await _channel.invokeMethod('downloadWithFallback', request); - final response = jsonDecode(result as String) as Map; - if (response['success'] == true) { - final service = response['service'] ?? 'unknown'; - final filePath = response['file_path'] ?? ''; - final bitDepth = response['actual_bit_depth']; - final sampleRate = response['actual_sample_rate']; - final qualityStr = bitDepth != null && sampleRate != null - ? ' ($bitDepth-bit/${(sampleRate / 1000).toStringAsFixed(1)}kHz)' - : ''; - _log.i('Download success via $service$qualityStr: $filePath'); - } else { - final error = response['error'] ?? 'Unknown error'; - final errorType = response['error_type'] ?? ''; - _log.e('Download failed: $error (type: $errorType)'); - } - return response; - } - - static Future> getDownloadProgress() async { - final result = await _channel.invokeMethod('getDownloadProgress'); - return jsonDecode(result as String) as Map; - } - - static Future> getAllDownloadProgress() async { - final result = await _channel.invokeMethod('getAllDownloadProgress'); - return jsonDecode(result as String) as Map; - } - - static Future initItemProgress(String itemId) async { - await _channel.invokeMethod('initItemProgress', {'item_id': itemId}); - } - - static Future finishItemProgress(String itemId) async { - await _channel.invokeMethod('finishItemProgress', {'item_id': itemId}); - } - - static Future clearItemProgress(String itemId) async { - await _channel.invokeMethod('clearItemProgress', {'item_id': itemId}); - } - - static Future cancelDownload(String itemId) async { - await _channel.invokeMethod('cancelDownload', {'item_id': itemId}); - } - - static Future setDownloadDirectory(String path) async { - await _channel.invokeMethod('setDownloadDirectory', {'path': path}); - } - - static Future> checkDuplicate(String outputDir, String isrc) async { - final result = await _channel.invokeMethod('checkDuplicate', { - 'output_dir': outputDir, - 'isrc': isrc, - }); - return jsonDecode(result as String) as Map; - } - - static Future buildFilename(String template, Map metadata) async { - final result = await _channel.invokeMethod('buildFilename', { - 'template': template, - 'metadata': jsonEncode(metadata), - }); - return result as String; - } - - static Future sanitizeFilename(String filename) async { - final result = await _channel.invokeMethod('sanitizeFilename', { - 'filename': filename, - }); - return result as String; - } - - static Future?> pickSafTree() async { - final result = await _channel.invokeMethod('pickSafTree'); - if (result == null) return null; - return jsonDecode(result as String) as Map; - } - - static Future safExists(String uri) async { - final result = await _channel.invokeMethod('safExists', {'uri': uri}); - return result as bool; - } - - static Future safDelete(String uri) async { - final result = await _channel.invokeMethod('safDelete', {'uri': uri}); - return result as bool; - } - - static Future> safStat(String uri) async { - final result = await _channel.invokeMethod('safStat', {'uri': uri}); - return jsonDecode(result as String) as Map; - } - - static Future> resolveSafFile({ - required String treeUri, - required String fileName, - String relativeDir = '', - }) async { - final result = await _channel.invokeMethod('resolveSafFile', { - 'tree_uri': treeUri, - 'relative_dir': relativeDir, - 'file_name': fileName, - }); - return jsonDecode(result as String) as Map; - } - - static Future copyContentUriToTemp(String uri) async { - final result = await _channel.invokeMethod('safCopyToTemp', {'uri': uri}); - return result as String?; - } - - static Future replaceContentUriFromPath( - String uri, - String srcPath, - ) async { - final result = await _channel.invokeMethod('safReplaceFromPath', { - 'uri': uri, - 'src_path': srcPath, - }); - return result as bool; - } - - static Future createSafFileFromPath({ - required String treeUri, - required String relativeDir, - required String fileName, - required String mimeType, - required String srcPath, - }) async { - final result = await _channel.invokeMethod('safCreateFromPath', { - 'tree_uri': treeUri, - 'relative_dir': relativeDir, - 'file_name': fileName, - 'mime_type': mimeType, - 'src_path': srcPath, - }); - return result as String?; - } - - static Future openContentUri(String uri, {String mimeType = ''}) async { - await _channel.invokeMethod('openContentUri', { - 'uri': uri, - 'mime_type': mimeType, - }); - } - - static Future shareContentUri(String uri, {String title = ''}) async { - final result = await _channel.invokeMethod('shareContentUri', { - 'uri': uri, - 'title': title, - }); - return result as bool? ?? false; - } - - static Future> fetchLyrics( - String spotifyId, - String trackName, - String artistName, { - int durationMs = 0, - }) async { - final result = await _channel.invokeMethod('fetchLyrics', { - 'spotify_id': spotifyId, - 'track_name': trackName, - 'artist_name': artistName, - 'duration_ms': durationMs, - }); - return jsonDecode(result as String) as Map; - } - - static Future getLyricsLRC( - String spotifyId, - String trackName, - String artistName, { - String? filePath, - int durationMs = 0, - }) async { - final result = await _channel.invokeMethod('getLyricsLRC', { - 'spotify_id': spotifyId, - 'track_name': trackName, - 'artist_name': artistName, - 'file_path': filePath ?? '', - 'duration_ms': durationMs, - }); - return result as String; - } - - static Future> embedLyricsToFile( - String filePath, - String lyrics, - ) async { - final result = await _channel.invokeMethod('embedLyricsToFile', { - 'file_path': filePath, - 'lyrics': lyrics, - }); - return jsonDecode(result as String) as Map; - } - - static Future cleanupConnections() async { - await _channel.invokeMethod('cleanupConnections'); - } - - static Future> downloadCoverToFile( - String coverUrl, - String outputPath, { - bool maxQuality = true, - }) async { - final result = await _channel.invokeMethod('downloadCoverToFile', { - 'cover_url': coverUrl, - 'output_path': outputPath, - 'max_quality': maxQuality, - }); - return jsonDecode(result as String) as Map; - } - - static Future> extractCoverToFile( - String audioPath, - String outputPath, - ) async { - final result = await _channel.invokeMethod('extractCoverToFile', { - 'audio_path': audioPath, - 'output_path': outputPath, - }); - return jsonDecode(result as String) as Map; - } - - static Future> fetchAndSaveLyrics({ - required String trackName, - required String artistName, - required String spotifyId, - required int durationMs, - required String outputPath, - }) async { - final result = await _channel.invokeMethod('fetchAndSaveLyrics', { - 'track_name': trackName, - 'artist_name': artistName, - 'spotify_id': spotifyId, - 'duration_ms': durationMs, - 'output_path': outputPath, - }); - return jsonDecode(result as String) as Map; - } - - static Future> reEnrichFile(Map request) async { - final requestJSON = jsonEncode(request); - final result = await _channel.invokeMethod('reEnrichFile', { - 'request_json': requestJSON, - }); - return jsonDecode(result as String) as Map; - } - - static Future> readFileMetadata(String filePath) async { - final result = await _channel.invokeMethod('readFileMetadata', { - 'file_path': filePath, - }); - return jsonDecode(result as String) as Map; - } - - static Future> editFileMetadata( - String filePath, - Map metadata, - ) async { - final metadataJSON = jsonEncode(metadata); - final result = await _channel.invokeMethod('editFileMetadata', { - 'file_path': filePath, - 'metadata_json': metadataJSON, - }); - return jsonDecode(result as String) as Map; - } - - static Future writeTempToSaf(String tempPath, String safUri) async { - final result = await _channel.invokeMethod('writeTempToSaf', { - 'temp_path': tempPath, - 'saf_uri': safUri, - }); - final map = jsonDecode(result as String) as Map; - return map['success'] == true; - } - - static Future startDownloadService({ - String trackName = '', - String artistName = '', - int queueCount = 0, - }) async { - await _channel.invokeMethod('startDownloadService', { - 'track_name': trackName, - 'artist_name': artistName, - 'queue_count': queueCount, - }); - } - - static Future stopDownloadService() async { - await _channel.invokeMethod('stopDownloadService'); - } - - static Future updateDownloadServiceProgress({ - required String trackName, - required String artistName, - required int progress, - required int total, - required int queueCount, - }) async { - await _channel.invokeMethod('updateDownloadServiceProgress', { - 'track_name': trackName, - 'artist_name': artistName, - 'progress': progress, - 'total': total, - 'queue_count': queueCount, - }); - } - - static Future isDownloadServiceRunning() async { - final result = await _channel.invokeMethod('isDownloadServiceRunning'); - return result as bool; - } - - static Future setSpotifyCredentials(String clientId, String clientSecret) async { - await _channel.invokeMethod('setSpotifyCredentials', { - 'client_id': clientId, - 'client_secret': clientSecret, - }); - } - - static Future hasSpotifyCredentials() async { - final result = await _channel.invokeMethod('hasSpotifyCredentials'); - return result as bool; - } - - static Future preWarmTrackCache(List> tracks) async { - final tracksJson = jsonEncode(tracks); - await _channel.invokeMethod('preWarmTrackCache', {'tracks': tracksJson}); - } - - static Future getTrackCacheSize() async { - final result = await _channel.invokeMethod('getTrackCacheSize'); - return result as int; - } - - static Future clearTrackCache() async { - await _channel.invokeMethod('clearTrackCache'); - } - - static Future> searchDeezerAll(String query, {int trackLimit = 15, int artistLimit = 2, String? filter}) async { - final result = await _channel.invokeMethod('searchDeezerAll', { - 'query': query, - 'track_limit': trackLimit, - 'artist_limit': artistLimit, - 'filter': filter ?? '', - }); - return jsonDecode(result as String) as Map; - } - - static Future> getDeezerMetadata(String resourceType, String resourceId) async { - final result = await _channel.invokeMethod('getDeezerMetadata', { - 'resource_type': resourceType, - 'resource_id': resourceId, - }); - if (result == null) { - throw Exception('getDeezerMetadata returned null for $resourceType:$resourceId'); - } - return jsonDecode(result as String) as Map; - } - - static Future> parseDeezerUrl(String url) async { - final result = await _channel.invokeMethod('parseDeezerUrl', {'url': url}); - return jsonDecode(result as String) as Map; - } - - static Future> parseTidalUrl(String url) async { - final result = await _channel.invokeMethod('parseTidalUrl', {'url': url}); - return jsonDecode(result as String) as Map; - } - - static Future> convertTidalToSpotifyDeezer(String tidalUrl) async { - final result = await _channel.invokeMethod('convertTidalToSpotifyDeezer', {'url': tidalUrl}); - return jsonDecode(result as String) as Map; - } - - static Future> searchDeezerByISRC(String isrc) async { - final result = await _channel.invokeMethod('searchDeezerByISRC', {'isrc': isrc}); - return jsonDecode(result as String) as Map; - } - - static Future?> getDeezerExtendedMetadata(String trackId) async { - try { - final result = await _channel.invokeMethod('getDeezerExtendedMetadata', { - 'track_id': trackId, - }); - if (result == null) return null; - final data = jsonDecode(result as String) as Map; - return { - 'genre': data['genre'] as String? ?? '', - 'label': data['label'] as String? ?? '', - }; - } catch (e) { - _log.w('Failed to get Deezer extended metadata for $trackId: $e'); - return null; - } - } - - static Future> convertSpotifyToDeezer(String resourceType, String spotifyId) async { - final result = await _channel.invokeMethod('convertSpotifyToDeezer', { - 'resource_type': resourceType, - 'spotify_id': spotifyId, - }); - return jsonDecode(result as String) as Map; - } - - static Future> getSpotifyMetadataWithFallback(String url) async { - final result = await _channel.invokeMethod('getSpotifyMetadataWithFallback', {'url': url}); - return jsonDecode(result as String) as Map; - } - - static Future>> getGoLogs() async { - final result = await _channel.invokeMethod('getLogs'); - final logs = jsonDecode(result as String) as List; - return logs.map((e) => e as Map).toList(); - } - - static Future> getGoLogsSince(int index) async { - final result = await _channel.invokeMethod('getLogsSince', {'index': index}); - return jsonDecode(result as String) as Map; - } - - static Future clearGoLogs() async { - await _channel.invokeMethod('clearLogs'); - } - - static Future getGoLogCount() async { - final result = await _channel.invokeMethod('getLogCount'); - return result as int; - } - - static Future setGoLoggingEnabled(bool enabled) async { - await _channel.invokeMethod('setLoggingEnabled', {'enabled': enabled}); - } - - - static Future initExtensionSystem(String extensionsDir, String dataDir) async { - _log.d('initExtensionSystem: $extensionsDir, $dataDir'); - await _channel.invokeMethod('initExtensionSystem', { - 'extensions_dir': extensionsDir, - 'data_dir': dataDir, - }); - } - - static Future> loadExtensionsFromDir(String dirPath) async { - _log.d('loadExtensionsFromDir: $dirPath'); - final result = await _channel.invokeMethod('loadExtensionsFromDir', { - 'dir_path': dirPath, - }); - return jsonDecode(result as String) as Map; - } - - static Future> loadExtensionFromPath(String filePath) async { - _log.d('loadExtensionFromPath: $filePath'); - final result = await _channel.invokeMethod('loadExtensionFromPath', { - 'file_path': filePath, - }); - return jsonDecode(result as String) as Map; - } - - static Future unloadExtension(String extensionId) async { - _log.d('unloadExtension: $extensionId'); - await _channel.invokeMethod('unloadExtension', { - 'extension_id': extensionId, - }); - } - - static Future removeExtension(String extensionId) async { - _log.d('removeExtension: $extensionId'); - await _channel.invokeMethod('removeExtension', { - 'extension_id': extensionId, - }); - } - - static Future> upgradeExtension(String filePath) async { - _log.d('upgradeExtension: $filePath'); - final result = await _channel.invokeMethod('upgradeExtension', { - 'file_path': filePath, - }); - return jsonDecode(result as String) as Map; - } - - static Future> checkExtensionUpgrade(String filePath) async { - _log.d('checkExtensionUpgrade: $filePath'); - final result = await _channel.invokeMethod('checkExtensionUpgrade', { - 'file_path': filePath, - }); - return jsonDecode(result as String) as Map; - } - - static Future>> getInstalledExtensions() async { - final result = await _channel.invokeMethod('getInstalledExtensions'); - final list = jsonDecode(result as String) as List; - return list.map((e) => e as Map).toList(); - } - - static Future setExtensionEnabled(String extensionId, bool enabled) async { - _log.d('setExtensionEnabled: $extensionId = $enabled'); - await _channel.invokeMethod('setExtensionEnabled', { - 'extension_id': extensionId, - 'enabled': enabled, - }); - } - - static Future setProviderPriority(List providerIds) async { - _log.d('setProviderPriority: $providerIds'); - await _channel.invokeMethod('setProviderPriority', { - 'priority': jsonEncode(providerIds), - }); - } - - static Future> getProviderPriority() async { - final result = await _channel.invokeMethod('getProviderPriority'); - final list = jsonDecode(result as String) as List; - return list.map((e) => e as String).toList(); - } - - static Future setMetadataProviderPriority(List providerIds) async { - _log.d('setMetadataProviderPriority: $providerIds'); - await _channel.invokeMethod('setMetadataProviderPriority', { - 'priority': jsonEncode(providerIds), - }); - } - - static Future> getMetadataProviderPriority() async { - final result = await _channel.invokeMethod('getMetadataProviderPriority'); - final list = jsonDecode(result as String) as List; - return list.map((e) => e as String).toList(); - } - - static Future> getExtensionSettings(String extensionId) async { - final result = await _channel.invokeMethod('getExtensionSettings', { - 'extension_id': extensionId, - }); - return jsonDecode(result as String) as Map; - } - - static Future setExtensionSettings(String extensionId, Map settings) async { - _log.d('setExtensionSettings: $extensionId'); - await _channel.invokeMethod('setExtensionSettings', { - 'extension_id': extensionId, - 'settings': jsonEncode(settings), - }); - } - - static Future> invokeExtensionAction(String extensionId, String actionName) async { - _log.d('invokeExtensionAction: $extensionId.$actionName'); - final result = await _channel.invokeMethod('invokeExtensionAction', { - 'extension_id': extensionId, - 'action': actionName, - }); - if (result == null || (result as String).isEmpty) { - return {'success': true}; - } - return jsonDecode(result) as Map; - } - - static Future>> searchTracksWithExtensions(String query, {int limit = 20}) async { - _log.d('searchTracksWithExtensions: "$query"'); - final result = await _channel.invokeMethod('searchTracksWithExtensions', { - 'query': query, - 'limit': limit, - }); - final list = jsonDecode(result as String) as List; - return list.map((e) => e as Map).toList(); - } - -static Future> downloadWithExtensions({ - required String isrc, - required String spotifyId, - required String trackName, - required String artistName, - required String albumName, - String? albumArtist, - String? coverUrl, - required String outputDir, - required String filenameFormat, - String quality = 'LOSSLESS', - bool embedLyrics = true, - bool embedMaxQualityCover = true, - int trackNumber = 1, - int discNumber = 1, - int totalTracks = 1, - String? releaseDate, - String? itemId, - int durationMs = 0, - String? source, - String? genre, - String? label, - String lyricsMode = 'embed', - String? preferredService, - String storageMode = 'app', - String safTreeUri = '', - String safRelativeDir = '', - String safFileName = '', - String safOutputExt = '', - }) async { - _log.i('downloadWithExtensions: "$trackName" by $artistName${source != null ? ' (source: $source)' : ''}${preferredService != null ? ' (service: $preferredService)' : ''}'); - final request = jsonEncode({ - 'isrc': isrc, - 'spotify_id': spotifyId, - 'track_name': trackName, - 'artist_name': artistName, - 'album_name': albumName, - 'album_artist': albumArtist ?? artistName, - 'cover_url': coverUrl, - 'output_dir': outputDir, - 'filename_format': filenameFormat, - 'quality': quality, - 'embed_lyrics': embedLyrics, - 'embed_max_quality_cover': embedMaxQualityCover, - 'track_number': trackNumber, - 'disc_number': discNumber, - 'total_tracks': totalTracks, - 'release_date': releaseDate ?? '', - 'item_id': itemId ?? '', - 'duration_ms': durationMs, - 'source': source ?? '', - 'genre': genre ?? '', - 'label': label ?? '', - 'lyrics_mode': lyricsMode, - 'service': preferredService ?? '', - 'storage_mode': storageMode, - 'saf_tree_uri': safTreeUri, - 'saf_relative_dir': safRelativeDir, - 'saf_file_name': safFileName, - 'saf_output_ext': safOutputExt, - }); - - final result = await _channel.invokeMethod('downloadWithExtensions', request); - return jsonDecode(result as String) as Map; - } - - static Future cleanupExtensions() async { - _log.d('cleanupExtensions'); - await _channel.invokeMethod('cleanupExtensions'); - } - - static Future?> getExtensionPendingAuth(String extensionId) async { - final result = await _channel.invokeMethod('getExtensionPendingAuth', { - 'extension_id': extensionId, - }); - if (result == null) return null; - return jsonDecode(result as String) as Map; - } - - static Future setExtensionAuthCode(String extensionId, String authCode) async { - _log.d('setExtensionAuthCode: $extensionId'); - await _channel.invokeMethod('setExtensionAuthCode', { - 'extension_id': extensionId, - 'auth_code': authCode, - }); - } - - static Future setExtensionTokens( - String extensionId, { - required String accessToken, - String? refreshToken, - int? expiresIn, - }) async { - _log.d('setExtensionTokens: $extensionId'); - await _channel.invokeMethod('setExtensionTokens', { - 'extension_id': extensionId, - 'access_token': accessToken, - 'refresh_token': refreshToken ?? '', - 'expires_in': expiresIn ?? 0, - }); - } - - static Future clearExtensionPendingAuth(String extensionId) async { - await _channel.invokeMethod('clearExtensionPendingAuth', { - 'extension_id': extensionId, - }); - } - - static Future isExtensionAuthenticated(String extensionId) async { - final result = await _channel.invokeMethod('isExtensionAuthenticated', { - 'extension_id': extensionId, - }); - return result as bool; - } - - static Future>> getAllPendingAuthRequests() async { - final result = await _channel.invokeMethod('getAllPendingAuthRequests'); - final list = jsonDecode(result as String) as List; - return list.map((e) => e as Map).toList(); - } - - static Future?> getPendingFFmpegCommand(String commandId) async { - final result = await _channel.invokeMethod('getPendingFFmpegCommand', { - 'command_id': commandId, - }); - if (result == null) return null; - return jsonDecode(result as String) as Map; - } - - static Future setFFmpegCommandResult( - String commandId, { - required bool success, - String output = '', - String error = '', - }) async { - await _channel.invokeMethod('setFFmpegCommandResult', { - 'command_id': commandId, - 'success': success, - 'output': output, - 'error': error, - }); - } - - static Future>> getAllPendingFFmpegCommands() async { - final result = await _channel.invokeMethod('getAllPendingFFmpegCommands'); - final list = jsonDecode(result as String) as List; - return list.map((e) => e as Map).toList(); - } - - static Future>> customSearchWithExtension( - String extensionId, - String query, { - Map? options, - }) async { - final result = await _channel.invokeMethod('customSearchWithExtension', { - 'extension_id': extensionId, - 'query': query, - 'options': options != null ? jsonEncode(options) : '', - }); - final list = jsonDecode(result as String) as List; - return list.map((e) => e as Map).toList(); - } - - static Future>> getSearchProviders() async { - final result = await _channel.invokeMethod('getSearchProviders'); - final list = jsonDecode(result as String) as List; - return list.map((e) => e as Map).toList(); - } - - static Future?> handleURLWithExtension(String url) async { - try { - final result = await _channel.invokeMethod('handleURLWithExtension', { - 'url': url, - }); - if (result == null || result == '') return null; - return jsonDecode(result as String) as Map; - } catch (e) { - return null; - } - } - - static Future findURLHandler(String url) async { - final result = await _channel.invokeMethod('findURLHandler', { - 'url': url, - }); - if (result == null || result == '') return null; - return result as String; - } - - static Future>> getURLHandlers() async { - final result = await _channel.invokeMethod('getURLHandlers'); - final list = jsonDecode(result as String) as List; - return list.map((e) => e as Map).toList(); - } - - static Future?> getAlbumWithExtension( - String extensionId, - String albumId, - ) async { - try { - final result = await _channel.invokeMethod('getAlbumWithExtension', { - 'extension_id': extensionId, - 'album_id': albumId, - }); - if (result == null || result == '') return null; - return jsonDecode(result as String) as Map; - } catch (e) { - _log.e('getAlbumWithExtension failed: $e'); - return null; - } - } - - static Future?> getPlaylistWithExtension( - String extensionId, - String playlistId, - ) async { - try { - final result = await _channel.invokeMethod('getPlaylistWithExtension', { - 'extension_id': extensionId, - 'playlist_id': playlistId, - }); - if (result == null || result == '') return null; - return jsonDecode(result as String) as Map; - } catch (e) { - _log.e('getPlaylistWithExtension failed: $e'); - return null; - } - } - - static Future?> getArtistWithExtension( - String extensionId, - String artistId, - ) async { - try { - final result = await _channel.invokeMethod('getArtistWithExtension', { - 'extension_id': extensionId, - 'artist_id': artistId, - }); - if (result == null || result == '') return null; - return jsonDecode(result as String) as Map; - } catch (e) { - _log.e('getArtistWithExtension failed: $e'); - return null; - } - } - - static Future?> getExtensionHomeFeed(String extensionId) async { - try { - final result = await _channel.invokeMethod('getExtensionHomeFeed', { - 'extension_id': extensionId, - }); - if (result == null || result == '') return null; - return jsonDecode(result as String) as Map; - } catch (e) { - _log.e('getExtensionHomeFeed failed: $e'); - return null; - } - } - - static Future?> getExtensionBrowseCategories(String extensionId) async { - try { - final result = await _channel.invokeMethod('getExtensionBrowseCategories', { - 'extension_id': extensionId, - }); - if (result == null || result == '') return null; - return jsonDecode(result as String) as Map; - } catch (e) { - _log.e('getExtensionBrowseCategories failed: $e'); - return null; - } - } - - // ==================== LOCAL LIBRARY SCANNING ==================== - - /// Set the directory for caching extracted cover art - static Future setLibraryCoverCacheDir(String cacheDir) async { - _log.i('setLibraryCoverCacheDir: $cacheDir'); - await _channel.invokeMethod('setLibraryCoverCacheDir', { - 'cache_dir': cacheDir, - }); - } - -/// Scan a folder for audio files and read their metadata - /// Returns a list of track metadata - static Future>> scanLibraryFolder(String folderPath) async { - _log.i('scanLibraryFolder: $folderPath'); - final result = await _channel.invokeMethod('scanLibraryFolder', { - 'folder_path': folderPath, - }); - final list = jsonDecode(result as String) as List; - return list.map((e) => e as Map).toList(); - } - - /// Perform an incremental scan of the library folder - /// Only scans files that are new or have changed since last scan - /// [existingFiles] is a map of filePath -> modTime (unix millis) - /// Returns IncrementalScanResult with scanned items, deleted paths, and skip count - static Future> scanLibraryFolderIncremental( - String folderPath, - Map existingFiles, - ) async { - _log.i('scanLibraryFolderIncremental: $folderPath (${existingFiles.length} existing files)'); - final result = await _channel.invokeMethod('scanLibraryFolderIncremental', { - 'folder_path': folderPath, - 'existing_files': jsonEncode(existingFiles), - }); - return jsonDecode(result as String) as Map; - } - - static Future>> scanSafTree(String treeUri) async { - _log.i('scanSafTree: $treeUri'); - final result = await _channel.invokeMethod('scanSafTree', { - 'tree_uri': treeUri, - }); - final list = jsonDecode(result as String) as List; - return list.map((e) => e as Map).toList(); - } - - /// Incremental SAF tree scan - only scans new or modified files - /// Returns a map with 'files' (new/changed) and 'removedUris' (deleted files) - static Future> scanSafTreeIncremental( - String treeUri, - Map existingFiles, - ) async { - _log.i('scanSafTreeIncremental: $treeUri (${existingFiles.length} existing files)'); - final result = await _channel.invokeMethod('scanSafTreeIncremental', { - 'tree_uri': treeUri, - 'existing_files': jsonEncode(existingFiles), - }); - return jsonDecode(result as String) as Map; - } - - /// Get last-modified timestamps for a list of SAF file URIs. - /// Returns map uri -> modTime (unix millis), only for files that still exist. - static Future> getSafFileModTimes(List uris) async { - final result = await _channel.invokeMethod('getSafFileModTimes', { - 'uris': jsonEncode(uris), - }); - final map = jsonDecode(result as String) as Map; - return map.map((key, value) => MapEntry(key, (value as num).toInt())); - } - - /// Get current library scan progress - static Future> getLibraryScanProgress() async { - final result = await _channel.invokeMethod('getLibraryScanProgress'); - return jsonDecode(result as String) as Map; - } - - /// Cancel ongoing library scan - static Future cancelLibraryScan() async { - await _channel.invokeMethod('cancelLibraryScan'); - } - - /// Read metadata from a single audio file - static Future?> readAudioMetadata(String filePath) async { - try { - final result = await _channel.invokeMethod('readAudioMetadata', { - 'file_path': filePath, - }); - if (result == null || result == '') return null; - return jsonDecode(result as String) as Map; - } catch (e) { - _log.w('Failed to read audio metadata: $e'); - return null; - } - } - - - static Future> runPostProcessing( - String filePath, { - Map? metadata, - }) async { - final result = await _channel.invokeMethod('runPostProcessing', { - 'file_path': filePath, - 'metadata': metadata != null ? jsonEncode(metadata) : '', - }); - return jsonDecode(result as String) as Map; - } - - static Future> runPostProcessingV2( - String filePath, { - Map? metadata, - }) async { - final input = {}; - if (filePath.startsWith('content://')) { - input['uri'] = filePath; - } else { - input['path'] = filePath; - } - final result = await _channel.invokeMethod('runPostProcessingV2', { - 'input': jsonEncode(input), - 'metadata': metadata != null ? jsonEncode(metadata) : '', - }); - return jsonDecode(result as String) as Map; - } - - static Future>> getPostProcessingProviders() async { - final result = await _channel.invokeMethod('getPostProcessingProviders'); - final list = jsonDecode(result as String) as List; - return list.map((e) => e as Map).toList(); - } - - - static Future initExtensionStore(String cacheDir) async { - _log.d('initExtensionStore: $cacheDir'); - await _channel.invokeMethod('initExtensionStore', {'cache_dir': cacheDir}); - } - - static Future>> getStoreExtensions({bool forceRefresh = false}) async { - _log.d('getStoreExtensions (forceRefresh: $forceRefresh)'); - final result = await _channel.invokeMethod('getStoreExtensions', { - 'force_refresh': forceRefresh, - }); - final list = jsonDecode(result as String) as List; - return list.map((e) => e as Map).toList(); - } - - static Future>> searchStoreExtensions(String query, {String? category}) async { - _log.d('searchStoreExtensions: "$query" (category: $category)'); - final result = await _channel.invokeMethod('searchStoreExtensions', { - 'query': query, - 'category': category ?? '', - }); - final list = jsonDecode(result as String) as List; - return list.map((e) => e as Map).toList(); - } - - static Future> getStoreCategories() async { - final result = await _channel.invokeMethod('getStoreCategories'); - final list = jsonDecode(result as String) as List; - return list.cast(); - } - - static Future downloadStoreExtension(String extensionId, String destDir) async { - _log.i('downloadStoreExtension: $extensionId to $destDir'); - final result = await _channel.invokeMethod('downloadStoreExtension', { - 'extension_id': extensionId, - 'dest_dir': destDir, - }); - return result as String; - } - - static Future clearStoreCache() async { - _log.d('clearStoreCache'); - await _channel.invokeMethod('clearStoreCache'); - } - - // ==================== YOUTUBE / COBALT ==================== - - /// Download a track from YouTube using the Cobalt API. - /// YouTube is a lossy-only provider (Opus 256kbps or MP3 320kbps). - /// It does NOT participate in the lossless fallback chain. - static Future> downloadFromYouTube({ - required String trackName, - required String artistName, - required String albumName, - String? albumArtist, - String? coverUrl, - required String outputDir, - required String filenameFormat, - String quality = 'opus_256', - int trackNumber = 1, - int discNumber = 1, - String? releaseDate, - String? itemId, - int durationMs = 0, - String? isrc, - String? spotifyId, - String? deezerId, - String storageMode = 'app', - String safTreeUri = '', - String safRelativeDir = '', - String safFileName = '', - String safOutputExt = '', - }) async { - _log.i('downloadFromYouTube: "$trackName" by $artistName (quality: $quality)'); - final request = jsonEncode({ - 'track_name': trackName, - 'artist_name': artistName, - 'album_name': albumName, - 'album_artist': albumArtist ?? artistName, - 'cover_url': coverUrl, - 'output_dir': outputDir, - 'filename_format': filenameFormat, - 'quality': quality, - 'track_number': trackNumber, - 'disc_number': discNumber, - 'release_date': releaseDate ?? '', - 'item_id': itemId ?? '', - 'duration_ms': durationMs, - 'isrc': isrc ?? '', - 'spotify_id': spotifyId ?? '', - 'deezer_id': deezerId ?? '', - 'storage_mode': storageMode, - 'saf_tree_uri': safTreeUri, - 'saf_relative_dir': safRelativeDir, - 'saf_file_name': safFileName, - 'saf_output_ext': safOutputExt, - }); - - final result = await _channel.invokeMethod('downloadFromYouTube', request); - final response = jsonDecode(result as String) as Map; - if (response['success'] == true) { - _log.i('YouTube download success: ${response['file_path']}'); - } else { - _log.w('YouTube download failed: ${response['error']}'); - } - return response; - } -} +import 'dart:convert'; +import 'package:flutter/services.dart'; +import 'package:spotiflac_android/services/download_request_payload.dart'; +import 'package:spotiflac_android/utils/logger.dart'; + +final _log = AppLogger('PlatformBridge'); + +class PlatformBridge { + static const _channel = MethodChannel('com.zarz.spotiflac/backend'); + + static Future> parseSpotifyUrl(String url) async { + _log.d('parseSpotifyUrl: $url'); + final result = await _channel.invokeMethod('parseSpotifyUrl', {'url': url}); + return jsonDecode(result as String) as Map; + } + + static Future> getSpotifyMetadata(String url) async { + _log.d('getSpotifyMetadata: $url'); + final result = await _channel.invokeMethod('getSpotifyMetadata', { + 'url': url, + }); + return jsonDecode(result as String) as Map; + } + + static Future> searchSpotify( + String query, { + int limit = 10, + }) async { + _log.d('searchSpotify: "$query" (limit: $limit)'); + final result = await _channel.invokeMethod('searchSpotify', { + 'query': query, + 'limit': limit, + }); + return jsonDecode(result as String) as Map; + } + + static Future> searchSpotifyAll( + String query, { + int trackLimit = 15, + int artistLimit = 3, + }) async { + _log.d('searchSpotifyAll: "$query"'); + final result = await _channel.invokeMethod('searchSpotifyAll', { + 'query': query, + 'track_limit': trackLimit, + 'artist_limit': artistLimit, + }); + return jsonDecode(result as String) as Map; + } + + static Future> checkAvailability( + String spotifyId, + String isrc, + ) async { + _log.d('checkAvailability: $spotifyId (ISRC: $isrc)'); + final result = await _channel.invokeMethod('checkAvailability', { + 'spotify_id': spotifyId, + 'isrc': isrc, + }); + return jsonDecode(result as String) as Map; + } + + static Future> _invokeDownloadMethod( + String method, + DownloadRequestPayload payload, + ) async { + final request = jsonEncode(payload.toJson()); + final result = await _channel.invokeMethod(method, request); + return jsonDecode(result as String) as Map; + } + + static Future> downloadByStrategy({ + required DownloadRequestPayload payload, + bool? useExtensions, + bool? useFallback, + }) async { + final routedPayload = payload.withStrategy( + useExtensions: useExtensions, + useFallback: useFallback, + ); + _log.i( + 'downloadByStrategy: "${payload.trackName}" by ${payload.artistName} ' + '(service: ${payload.service}, ext: ${routedPayload.useExtensions}, fallback: ${routedPayload.useFallback})', + ); + final response = await _invokeDownloadMethod( + 'downloadByStrategy', + routedPayload, + ); + if (response['success'] == true) { + final service = response['service'] ?? payload.service; + final filePath = response['file_path'] ?? ''; + final bitDepth = response['actual_bit_depth']; + final sampleRate = response['actual_sample_rate']; + final qualityStr = bitDepth != null && sampleRate != null + ? ' ($bitDepth-bit/${(sampleRate / 1000).toStringAsFixed(1)}kHz)' + : ''; + _log.i('Download success via $service$qualityStr: $filePath'); + } else { + final error = response['error'] ?? 'Unknown error'; + final errorType = response['error_type'] ?? ''; + _log.e('Download failed: $error (type: $errorType)'); + } + return response; + } + + @Deprecated('Use downloadByStrategy with DownloadRequestPayload.') + static Future> downloadTrack({ + required String isrc, + required String service, + required String spotifyId, + required String trackName, + required String artistName, + required String albumName, + String? albumArtist, + String? coverUrl, + required String outputDir, + required String filenameFormat, + String quality = 'LOSSLESS', + bool embedLyrics = true, + bool embedMaxQualityCover = true, + int trackNumber = 1, + int discNumber = 1, + int totalTracks = 1, + String? releaseDate, + String? itemId, + int durationMs = 0, + String? source, + String? genre, + String? label, + String? copyright, + String lyricsMode = 'embed', + String storageMode = 'app', + String safTreeUri = '', + String safRelativeDir = '', + String safFileName = '', + String safOutputExt = '', + }) async { + final payload = DownloadRequestPayload( + isrc: isrc, + service: service, + spotifyId: spotifyId, + trackName: trackName, + artistName: artistName, + albumName: albumName, + albumArtist: albumArtist ?? artistName, + coverUrl: coverUrl ?? '', + outputDir: outputDir, + filenameFormat: filenameFormat, + quality: quality, + embedLyrics: embedLyrics, + embedMaxQualityCover: embedMaxQualityCover, + trackNumber: trackNumber, + discNumber: discNumber, + totalTracks: totalTracks, + releaseDate: releaseDate ?? '', + itemId: itemId ?? '', + durationMs: durationMs, + source: source ?? '', + genre: genre ?? '', + label: label ?? '', + copyright: copyright ?? '', + lyricsMode: lyricsMode, + storageMode: storageMode, + safTreeUri: safTreeUri, + safRelativeDir: safRelativeDir, + safFileName: safFileName, + safOutputExt: safOutputExt, + ); + + return downloadByStrategy(payload: payload); + } + + @Deprecated('Use downloadByStrategy with DownloadRequestPayload.') + static Future> downloadWithFallback({ + required String isrc, + required String spotifyId, + required String trackName, + required String artistName, + required String albumName, + String? albumArtist, + String? coverUrl, + required String outputDir, + required String filenameFormat, + String quality = 'LOSSLESS', + bool embedLyrics = true, + bool embedMaxQualityCover = true, + int trackNumber = 1, + int discNumber = 1, + int totalTracks = 1, + String? releaseDate, + String preferredService = 'tidal', + String? itemId, + int durationMs = 0, + String? genre, + String? label, + String? copyright, + String lyricsMode = 'embed', + String storageMode = 'app', + String safTreeUri = '', + String safRelativeDir = '', + String safFileName = '', + String safOutputExt = '', + }) async { + final payload = DownloadRequestPayload( + isrc: isrc, + service: preferredService, + spotifyId: spotifyId, + trackName: trackName, + artistName: artistName, + albumName: albumName, + albumArtist: albumArtist ?? artistName, + coverUrl: coverUrl ?? '', + outputDir: outputDir, + filenameFormat: filenameFormat, + quality: quality, + embedLyrics: embedLyrics, + embedMaxQualityCover: embedMaxQualityCover, + trackNumber: trackNumber, + discNumber: discNumber, + totalTracks: totalTracks, + releaseDate: releaseDate ?? '', + itemId: itemId ?? '', + durationMs: durationMs, + genre: genre ?? '', + label: label ?? '', + copyright: copyright ?? '', + lyricsMode: lyricsMode, + storageMode: storageMode, + safTreeUri: safTreeUri, + safRelativeDir: safRelativeDir, + safFileName: safFileName, + safOutputExt: safOutputExt, + ); + + return downloadByStrategy(payload: payload, useFallback: true); + } + + static Future> getDownloadProgress() async { + final result = await _channel.invokeMethod('getDownloadProgress'); + return jsonDecode(result as String) as Map; + } + + static Future> getAllDownloadProgress() async { + final result = await _channel.invokeMethod('getAllDownloadProgress'); + return jsonDecode(result as String) as Map; + } + + static Future initItemProgress(String itemId) async { + await _channel.invokeMethod('initItemProgress', {'item_id': itemId}); + } + + static Future finishItemProgress(String itemId) async { + await _channel.invokeMethod('finishItemProgress', {'item_id': itemId}); + } + + static Future clearItemProgress(String itemId) async { + await _channel.invokeMethod('clearItemProgress', {'item_id': itemId}); + } + + static Future cancelDownload(String itemId) async { + await _channel.invokeMethod('cancelDownload', {'item_id': itemId}); + } + + static Future setDownloadDirectory(String path) async { + await _channel.invokeMethod('setDownloadDirectory', {'path': path}); + } + + static Future> checkDuplicate( + String outputDir, + String isrc, + ) async { + final result = await _channel.invokeMethod('checkDuplicate', { + 'output_dir': outputDir, + 'isrc': isrc, + }); + return jsonDecode(result as String) as Map; + } + + static Future buildFilename( + String template, + Map metadata, + ) async { + final result = await _channel.invokeMethod('buildFilename', { + 'template': template, + 'metadata': jsonEncode(metadata), + }); + return result as String; + } + + static Future sanitizeFilename(String filename) async { + final result = await _channel.invokeMethod('sanitizeFilename', { + 'filename': filename, + }); + return result as String; + } + + static Future?> pickSafTree() async { + final result = await _channel.invokeMethod('pickSafTree'); + if (result == null) return null; + return jsonDecode(result as String) as Map; + } + + static Future safExists(String uri) async { + final result = await _channel.invokeMethod('safExists', {'uri': uri}); + return result as bool; + } + + static Future safDelete(String uri) async { + final result = await _channel.invokeMethod('safDelete', {'uri': uri}); + return result as bool; + } + + static Future> safStat(String uri) async { + final result = await _channel.invokeMethod('safStat', {'uri': uri}); + return jsonDecode(result as String) as Map; + } + + static Future> resolveSafFile({ + required String treeUri, + required String fileName, + String relativeDir = '', + }) async { + final result = await _channel.invokeMethod('resolveSafFile', { + 'tree_uri': treeUri, + 'relative_dir': relativeDir, + 'file_name': fileName, + }); + return jsonDecode(result as String) as Map; + } + + static Future copyContentUriToTemp(String uri) async { + final result = await _channel.invokeMethod('safCopyToTemp', {'uri': uri}); + return result as String?; + } + + static Future replaceContentUriFromPath( + String uri, + String srcPath, + ) async { + final result = await _channel.invokeMethod('safReplaceFromPath', { + 'uri': uri, + 'src_path': srcPath, + }); + return result as bool; + } + + static Future createSafFileFromPath({ + required String treeUri, + required String relativeDir, + required String fileName, + required String mimeType, + required String srcPath, + }) async { + final result = await _channel.invokeMethod('safCreateFromPath', { + 'tree_uri': treeUri, + 'relative_dir': relativeDir, + 'file_name': fileName, + 'mime_type': mimeType, + 'src_path': srcPath, + }); + return result as String?; + } + + static Future openContentUri(String uri, {String mimeType = ''}) async { + await _channel.invokeMethod('openContentUri', { + 'uri': uri, + 'mime_type': mimeType, + }); + } + + static Future shareContentUri(String uri, {String title = ''}) async { + final result = await _channel.invokeMethod('shareContentUri', { + 'uri': uri, + 'title': title, + }); + return result as bool? ?? false; + } + + static Future> fetchLyrics( + String spotifyId, + String trackName, + String artistName, { + int durationMs = 0, + }) async { + final result = await _channel.invokeMethod('fetchLyrics', { + 'spotify_id': spotifyId, + 'track_name': trackName, + 'artist_name': artistName, + 'duration_ms': durationMs, + }); + return jsonDecode(result as String) as Map; + } + + static Future getLyricsLRC( + String spotifyId, + String trackName, + String artistName, { + String? filePath, + int durationMs = 0, + }) async { + final result = await _channel.invokeMethod('getLyricsLRC', { + 'spotify_id': spotifyId, + 'track_name': trackName, + 'artist_name': artistName, + 'file_path': filePath ?? '', + 'duration_ms': durationMs, + }); + return result as String; + } + + static Future> embedLyricsToFile( + String filePath, + String lyrics, + ) async { + final result = await _channel.invokeMethod('embedLyricsToFile', { + 'file_path': filePath, + 'lyrics': lyrics, + }); + return jsonDecode(result as String) as Map; + } + + static Future cleanupConnections() async { + await _channel.invokeMethod('cleanupConnections'); + } + + static Future> downloadCoverToFile( + String coverUrl, + String outputPath, { + bool maxQuality = true, + }) async { + final result = await _channel.invokeMethod('downloadCoverToFile', { + 'cover_url': coverUrl, + 'output_path': outputPath, + 'max_quality': maxQuality, + }); + return jsonDecode(result as String) as Map; + } + + static Future> extractCoverToFile( + String audioPath, + String outputPath, + ) async { + final result = await _channel.invokeMethod('extractCoverToFile', { + 'audio_path': audioPath, + 'output_path': outputPath, + }); + return jsonDecode(result as String) as Map; + } + + static Future> fetchAndSaveLyrics({ + required String trackName, + required String artistName, + required String spotifyId, + required int durationMs, + required String outputPath, + }) async { + final result = await _channel.invokeMethod('fetchAndSaveLyrics', { + 'track_name': trackName, + 'artist_name': artistName, + 'spotify_id': spotifyId, + 'duration_ms': durationMs, + 'output_path': outputPath, + }); + return jsonDecode(result as String) as Map; + } + + static Future> reEnrichFile( + Map request, + ) async { + final requestJSON = jsonEncode(request); + final result = await _channel.invokeMethod('reEnrichFile', { + 'request_json': requestJSON, + }); + return jsonDecode(result as String) as Map; + } + + static Future> readFileMetadata(String filePath) async { + final result = await _channel.invokeMethod('readFileMetadata', { + 'file_path': filePath, + }); + return jsonDecode(result as String) as Map; + } + + static Future> editFileMetadata( + String filePath, + Map metadata, + ) async { + final metadataJSON = jsonEncode(metadata); + final result = await _channel.invokeMethod('editFileMetadata', { + 'file_path': filePath, + 'metadata_json': metadataJSON, + }); + return jsonDecode(result as String) as Map; + } + + static Future writeTempToSaf(String tempPath, String safUri) async { + final result = await _channel.invokeMethod('writeTempToSaf', { + 'temp_path': tempPath, + 'saf_uri': safUri, + }); + final map = jsonDecode(result as String) as Map; + return map['success'] == true; + } + + static Future startDownloadService({ + String trackName = '', + String artistName = '', + int queueCount = 0, + }) async { + await _channel.invokeMethod('startDownloadService', { + 'track_name': trackName, + 'artist_name': artistName, + 'queue_count': queueCount, + }); + } + + static Future stopDownloadService() async { + await _channel.invokeMethod('stopDownloadService'); + } + + static Future updateDownloadServiceProgress({ + required String trackName, + required String artistName, + required int progress, + required int total, + required int queueCount, + }) async { + await _channel.invokeMethod('updateDownloadServiceProgress', { + 'track_name': trackName, + 'artist_name': artistName, + 'progress': progress, + 'total': total, + 'queue_count': queueCount, + }); + } + + static Future isDownloadServiceRunning() async { + final result = await _channel.invokeMethod('isDownloadServiceRunning'); + return result as bool; + } + + static Future setSpotifyCredentials( + String clientId, + String clientSecret, + ) async { + await _channel.invokeMethod('setSpotifyCredentials', { + 'client_id': clientId, + 'client_secret': clientSecret, + }); + } + + static Future hasSpotifyCredentials() async { + final result = await _channel.invokeMethod('hasSpotifyCredentials'); + return result as bool; + } + + static Future preWarmTrackCache( + List> tracks, + ) async { + final tracksJson = jsonEncode(tracks); + await _channel.invokeMethod('preWarmTrackCache', {'tracks': tracksJson}); + } + + static Future getTrackCacheSize() async { + final result = await _channel.invokeMethod('getTrackCacheSize'); + return result as int; + } + + static Future clearTrackCache() async { + await _channel.invokeMethod('clearTrackCache'); + } + + static Future> searchDeezerAll( + String query, { + int trackLimit = 15, + int artistLimit = 2, + String? filter, + }) async { + final result = await _channel.invokeMethod('searchDeezerAll', { + 'query': query, + 'track_limit': trackLimit, + 'artist_limit': artistLimit, + 'filter': filter ?? '', + }); + return jsonDecode(result as String) as Map; + } + + static Future> getDeezerMetadata( + String resourceType, + String resourceId, + ) async { + final result = await _channel.invokeMethod('getDeezerMetadata', { + 'resource_type': resourceType, + 'resource_id': resourceId, + }); + if (result == null) { + throw Exception( + 'getDeezerMetadata returned null for $resourceType:$resourceId', + ); + } + return jsonDecode(result as String) as Map; + } + + static Future> parseDeezerUrl(String url) async { + final result = await _channel.invokeMethod('parseDeezerUrl', {'url': url}); + return jsonDecode(result as String) as Map; + } + + static Future> parseTidalUrl(String url) async { + final result = await _channel.invokeMethod('parseTidalUrl', {'url': url}); + return jsonDecode(result as String) as Map; + } + + static Future> convertTidalToSpotifyDeezer( + String tidalUrl, + ) async { + final result = await _channel.invokeMethod('convertTidalToSpotifyDeezer', { + 'url': tidalUrl, + }); + return jsonDecode(result as String) as Map; + } + + static Future> searchDeezerByISRC(String isrc) async { + final result = await _channel.invokeMethod('searchDeezerByISRC', { + 'isrc': isrc, + }); + return jsonDecode(result as String) as Map; + } + + static Future?> getDeezerExtendedMetadata( + String trackId, + ) async { + try { + final result = await _channel.invokeMethod('getDeezerExtendedMetadata', { + 'track_id': trackId, + }); + if (result == null) return null; + final data = jsonDecode(result as String) as Map; + return { + 'genre': data['genre'] as String? ?? '', + 'label': data['label'] as String? ?? '', + }; + } catch (e) { + _log.w('Failed to get Deezer extended metadata for $trackId: $e'); + return null; + } + } + + static Future> convertSpotifyToDeezer( + String resourceType, + String spotifyId, + ) async { + final result = await _channel.invokeMethod('convertSpotifyToDeezer', { + 'resource_type': resourceType, + 'spotify_id': spotifyId, + }); + return jsonDecode(result as String) as Map; + } + + static Future> getSpotifyMetadataWithFallback( + String url, + ) async { + final result = await _channel.invokeMethod( + 'getSpotifyMetadataWithFallback', + {'url': url}, + ); + return jsonDecode(result as String) as Map; + } + + static Future>> getGoLogs() async { + final result = await _channel.invokeMethod('getLogs'); + final logs = jsonDecode(result as String) as List; + return logs.map((e) => e as Map).toList(); + } + + static Future> getGoLogsSince(int index) async { + final result = await _channel.invokeMethod('getLogsSince', { + 'index': index, + }); + return jsonDecode(result as String) as Map; + } + + static Future clearGoLogs() async { + await _channel.invokeMethod('clearLogs'); + } + + static Future getGoLogCount() async { + final result = await _channel.invokeMethod('getLogCount'); + return result as int; + } + + static Future setGoLoggingEnabled(bool enabled) async { + await _channel.invokeMethod('setLoggingEnabled', {'enabled': enabled}); + } + + static Future initExtensionSystem( + String extensionsDir, + String dataDir, + ) async { + _log.d('initExtensionSystem: $extensionsDir, $dataDir'); + await _channel.invokeMethod('initExtensionSystem', { + 'extensions_dir': extensionsDir, + 'data_dir': dataDir, + }); + } + + static Future> loadExtensionsFromDir( + String dirPath, + ) async { + _log.d('loadExtensionsFromDir: $dirPath'); + final result = await _channel.invokeMethod('loadExtensionsFromDir', { + 'dir_path': dirPath, + }); + return jsonDecode(result as String) as Map; + } + + static Future> loadExtensionFromPath( + String filePath, + ) async { + _log.d('loadExtensionFromPath: $filePath'); + final result = await _channel.invokeMethod('loadExtensionFromPath', { + 'file_path': filePath, + }); + return jsonDecode(result as String) as Map; + } + + static Future unloadExtension(String extensionId) async { + _log.d('unloadExtension: $extensionId'); + await _channel.invokeMethod('unloadExtension', { + 'extension_id': extensionId, + }); + } + + static Future removeExtension(String extensionId) async { + _log.d('removeExtension: $extensionId'); + await _channel.invokeMethod('removeExtension', { + 'extension_id': extensionId, + }); + } + + static Future> upgradeExtension(String filePath) async { + _log.d('upgradeExtension: $filePath'); + final result = await _channel.invokeMethod('upgradeExtension', { + 'file_path': filePath, + }); + return jsonDecode(result as String) as Map; + } + + static Future> checkExtensionUpgrade( + String filePath, + ) async { + _log.d('checkExtensionUpgrade: $filePath'); + final result = await _channel.invokeMethod('checkExtensionUpgrade', { + 'file_path': filePath, + }); + return jsonDecode(result as String) as Map; + } + + static Future>> getInstalledExtensions() async { + final result = await _channel.invokeMethod('getInstalledExtensions'); + final list = jsonDecode(result as String) as List; + return list.map((e) => e as Map).toList(); + } + + static Future setExtensionEnabled( + String extensionId, + bool enabled, + ) async { + _log.d('setExtensionEnabled: $extensionId = $enabled'); + await _channel.invokeMethod('setExtensionEnabled', { + 'extension_id': extensionId, + 'enabled': enabled, + }); + } + + static Future setProviderPriority(List providerIds) async { + _log.d('setProviderPriority: $providerIds'); + await _channel.invokeMethod('setProviderPriority', { + 'priority': jsonEncode(providerIds), + }); + } + + static Future> getProviderPriority() async { + final result = await _channel.invokeMethod('getProviderPriority'); + final list = jsonDecode(result as String) as List; + return list.map((e) => e as String).toList(); + } + + static Future setMetadataProviderPriority( + List providerIds, + ) async { + _log.d('setMetadataProviderPriority: $providerIds'); + await _channel.invokeMethod('setMetadataProviderPriority', { + 'priority': jsonEncode(providerIds), + }); + } + + static Future> getMetadataProviderPriority() async { + final result = await _channel.invokeMethod('getMetadataProviderPriority'); + final list = jsonDecode(result as String) as List; + return list.map((e) => e as String).toList(); + } + + static Future> getExtensionSettings( + String extensionId, + ) async { + final result = await _channel.invokeMethod('getExtensionSettings', { + 'extension_id': extensionId, + }); + return jsonDecode(result as String) as Map; + } + + static Future setExtensionSettings( + String extensionId, + Map settings, + ) async { + _log.d('setExtensionSettings: $extensionId'); + await _channel.invokeMethod('setExtensionSettings', { + 'extension_id': extensionId, + 'settings': jsonEncode(settings), + }); + } + + static Future> invokeExtensionAction( + String extensionId, + String actionName, + ) async { + _log.d('invokeExtensionAction: $extensionId.$actionName'); + final result = await _channel.invokeMethod('invokeExtensionAction', { + 'extension_id': extensionId, + 'action': actionName, + }); + if (result == null || (result as String).isEmpty) { + return {'success': true}; + } + return jsonDecode(result) as Map; + } + + static Future>> searchTracksWithExtensions( + String query, { + int limit = 20, + }) async { + _log.d('searchTracksWithExtensions: "$query"'); + final result = await _channel.invokeMethod('searchTracksWithExtensions', { + 'query': query, + 'limit': limit, + }); + final list = jsonDecode(result as String) as List; + return list.map((e) => e as Map).toList(); + } + + @Deprecated('Use downloadByStrategy with DownloadRequestPayload.') + static Future> downloadWithExtensions({ + required String isrc, + required String spotifyId, + required String trackName, + required String artistName, + required String albumName, + String? albumArtist, + String? coverUrl, + required String outputDir, + required String filenameFormat, + String quality = 'LOSSLESS', + bool embedLyrics = true, + bool embedMaxQualityCover = true, + int trackNumber = 1, + int discNumber = 1, + int totalTracks = 1, + String? releaseDate, + String? itemId, + int durationMs = 0, + String? source, + String? genre, + String? label, + String? copyright, + String? tidalId, + String? qobuzId, + String? deezerId, + String lyricsMode = 'embed', + String? preferredService, + String storageMode = 'app', + String safTreeUri = '', + String safRelativeDir = '', + String safFileName = '', + String safOutputExt = '', + }) async { + final payload = DownloadRequestPayload( + isrc: isrc, + service: preferredService ?? '', + spotifyId: spotifyId, + trackName: trackName, + artistName: artistName, + albumName: albumName, + albumArtist: albumArtist ?? artistName, + coverUrl: coverUrl ?? '', + outputDir: outputDir, + filenameFormat: filenameFormat, + quality: quality, + embedLyrics: embedLyrics, + embedMaxQualityCover: embedMaxQualityCover, + trackNumber: trackNumber, + discNumber: discNumber, + totalTracks: totalTracks, + releaseDate: releaseDate ?? '', + itemId: itemId ?? '', + durationMs: durationMs, + source: source ?? '', + genre: genre ?? '', + label: label ?? '', + copyright: copyright ?? '', + tidalId: tidalId ?? '', + qobuzId: qobuzId ?? '', + deezerId: deezerId ?? '', + lyricsMode: lyricsMode, + storageMode: storageMode, + safTreeUri: safTreeUri, + safRelativeDir: safRelativeDir, + safFileName: safFileName, + safOutputExt: safOutputExt, + ); + + return downloadByStrategy(payload: payload, useExtensions: true); + } + + static Future cleanupExtensions() async { + _log.d('cleanupExtensions'); + await _channel.invokeMethod('cleanupExtensions'); + } + + static Future?> getExtensionPendingAuth( + String extensionId, + ) async { + final result = await _channel.invokeMethod('getExtensionPendingAuth', { + 'extension_id': extensionId, + }); + if (result == null) return null; + return jsonDecode(result as String) as Map; + } + + static Future setExtensionAuthCode( + String extensionId, + String authCode, + ) async { + _log.d('setExtensionAuthCode: $extensionId'); + await _channel.invokeMethod('setExtensionAuthCode', { + 'extension_id': extensionId, + 'auth_code': authCode, + }); + } + + static Future setExtensionTokens( + String extensionId, { + required String accessToken, + String? refreshToken, + int? expiresIn, + }) async { + _log.d('setExtensionTokens: $extensionId'); + await _channel.invokeMethod('setExtensionTokens', { + 'extension_id': extensionId, + 'access_token': accessToken, + 'refresh_token': refreshToken ?? '', + 'expires_in': expiresIn ?? 0, + }); + } + + static Future clearExtensionPendingAuth(String extensionId) async { + await _channel.invokeMethod('clearExtensionPendingAuth', { + 'extension_id': extensionId, + }); + } + + static Future isExtensionAuthenticated(String extensionId) async { + final result = await _channel.invokeMethod('isExtensionAuthenticated', { + 'extension_id': extensionId, + }); + return result as bool; + } + + static Future>> getAllPendingAuthRequests() async { + final result = await _channel.invokeMethod('getAllPendingAuthRequests'); + final list = jsonDecode(result as String) as List; + return list.map((e) => e as Map).toList(); + } + + static Future?> getPendingFFmpegCommand( + String commandId, + ) async { + final result = await _channel.invokeMethod('getPendingFFmpegCommand', { + 'command_id': commandId, + }); + if (result == null) return null; + return jsonDecode(result as String) as Map; + } + + static Future setFFmpegCommandResult( + String commandId, { + required bool success, + String output = '', + String error = '', + }) async { + await _channel.invokeMethod('setFFmpegCommandResult', { + 'command_id': commandId, + 'success': success, + 'output': output, + 'error': error, + }); + } + + static Future>> + getAllPendingFFmpegCommands() async { + final result = await _channel.invokeMethod('getAllPendingFFmpegCommands'); + final list = jsonDecode(result as String) as List; + return list.map((e) => e as Map).toList(); + } + + static Future>> customSearchWithExtension( + String extensionId, + String query, { + Map? options, + }) async { + final result = await _channel.invokeMethod('customSearchWithExtension', { + 'extension_id': extensionId, + 'query': query, + 'options': options != null ? jsonEncode(options) : '', + }); + final list = jsonDecode(result as String) as List; + return list.map((e) => e as Map).toList(); + } + + static Future>> getSearchProviders() async { + final result = await _channel.invokeMethod('getSearchProviders'); + final list = jsonDecode(result as String) as List; + return list.map((e) => e as Map).toList(); + } + + static Future?> handleURLWithExtension( + String url, + ) async { + try { + final result = await _channel.invokeMethod('handleURLWithExtension', { + 'url': url, + }); + if (result == null || result == '') return null; + return jsonDecode(result as String) as Map; + } catch (e) { + return null; + } + } + + static Future findURLHandler(String url) async { + final result = await _channel.invokeMethod('findURLHandler', {'url': url}); + if (result == null || result == '') return null; + return result as String; + } + + static Future>> getURLHandlers() async { + final result = await _channel.invokeMethod('getURLHandlers'); + final list = jsonDecode(result as String) as List; + return list.map((e) => e as Map).toList(); + } + + static Future?> getAlbumWithExtension( + String extensionId, + String albumId, + ) async { + try { + final result = await _channel.invokeMethod('getAlbumWithExtension', { + 'extension_id': extensionId, + 'album_id': albumId, + }); + if (result == null || result == '') return null; + return jsonDecode(result as String) as Map; + } catch (e) { + _log.e('getAlbumWithExtension failed: $e'); + return null; + } + } + + static Future?> getPlaylistWithExtension( + String extensionId, + String playlistId, + ) async { + try { + final result = await _channel.invokeMethod('getPlaylistWithExtension', { + 'extension_id': extensionId, + 'playlist_id': playlistId, + }); + if (result == null || result == '') return null; + return jsonDecode(result as String) as Map; + } catch (e) { + _log.e('getPlaylistWithExtension failed: $e'); + return null; + } + } + + static Future?> getArtistWithExtension( + String extensionId, + String artistId, + ) async { + try { + final result = await _channel.invokeMethod('getArtistWithExtension', { + 'extension_id': extensionId, + 'artist_id': artistId, + }); + if (result == null || result == '') return null; + return jsonDecode(result as String) as Map; + } catch (e) { + _log.e('getArtistWithExtension failed: $e'); + return null; + } + } + + static Future?> getExtensionHomeFeed( + String extensionId, + ) async { + try { + final result = await _channel.invokeMethod('getExtensionHomeFeed', { + 'extension_id': extensionId, + }); + if (result == null || result == '') return null; + return jsonDecode(result as String) as Map; + } catch (e) { + _log.e('getExtensionHomeFeed failed: $e'); + return null; + } + } + + static Future?> getExtensionBrowseCategories( + String extensionId, + ) async { + try { + final result = await _channel.invokeMethod( + 'getExtensionBrowseCategories', + {'extension_id': extensionId}, + ); + if (result == null || result == '') return null; + return jsonDecode(result as String) as Map; + } catch (e) { + _log.e('getExtensionBrowseCategories failed: $e'); + return null; + } + } + + // ==================== LOCAL LIBRARY SCANNING ==================== + + /// Set the directory for caching extracted cover art + static Future setLibraryCoverCacheDir(String cacheDir) async { + _log.i('setLibraryCoverCacheDir: $cacheDir'); + await _channel.invokeMethod('setLibraryCoverCacheDir', { + 'cache_dir': cacheDir, + }); + } + + /// Scan a folder for audio files and read their metadata + /// Returns a list of track metadata + static Future>> scanLibraryFolder( + String folderPath, + ) async { + _log.i('scanLibraryFolder: $folderPath'); + final result = await _channel.invokeMethod('scanLibraryFolder', { + 'folder_path': folderPath, + }); + final list = jsonDecode(result as String) as List; + return list.map((e) => e as Map).toList(); + } + + /// Perform an incremental scan of the library folder + /// Only scans files that are new or have changed since last scan + /// [existingFiles] is a map of filePath -> modTime (unix millis) + /// Returns IncrementalScanResult with scanned items, deleted paths, and skip count + static Future> scanLibraryFolderIncremental( + String folderPath, + Map existingFiles, + ) async { + _log.i( + 'scanLibraryFolderIncremental: $folderPath (${existingFiles.length} existing files)', + ); + final result = await _channel.invokeMethod('scanLibraryFolderIncremental', { + 'folder_path': folderPath, + 'existing_files': jsonEncode(existingFiles), + }); + return jsonDecode(result as String) as Map; + } + + static Future>> scanSafTree(String treeUri) async { + _log.i('scanSafTree: $treeUri'); + final result = await _channel.invokeMethod('scanSafTree', { + 'tree_uri': treeUri, + }); + final list = jsonDecode(result as String) as List; + return list.map((e) => e as Map).toList(); + } + + /// Incremental SAF tree scan - only scans new or modified files + /// Returns a map with 'files' (new/changed) and 'removedUris' (deleted files) + static Future> scanSafTreeIncremental( + String treeUri, + Map existingFiles, + ) async { + _log.i( + 'scanSafTreeIncremental: $treeUri (${existingFiles.length} existing files)', + ); + final result = await _channel.invokeMethod('scanSafTreeIncremental', { + 'tree_uri': treeUri, + 'existing_files': jsonEncode(existingFiles), + }); + return jsonDecode(result as String) as Map; + } + + /// Get last-modified timestamps for a list of SAF file URIs. + /// Returns map uri -> modTime (unix millis), only for files that still exist. + static Future> getSafFileModTimes(List uris) async { + final result = await _channel.invokeMethod('getSafFileModTimes', { + 'uris': jsonEncode(uris), + }); + final map = jsonDecode(result as String) as Map; + return map.map((key, value) => MapEntry(key, (value as num).toInt())); + } + + /// Get current library scan progress + static Future> getLibraryScanProgress() async { + final result = await _channel.invokeMethod('getLibraryScanProgress'); + return jsonDecode(result as String) as Map; + } + + /// Cancel ongoing library scan + static Future cancelLibraryScan() async { + await _channel.invokeMethod('cancelLibraryScan'); + } + + /// Read metadata from a single audio file + static Future?> readAudioMetadata( + String filePath, + ) async { + try { + final result = await _channel.invokeMethod('readAudioMetadata', { + 'file_path': filePath, + }); + if (result == null || result == '') return null; + return jsonDecode(result as String) as Map; + } catch (e) { + _log.w('Failed to read audio metadata: $e'); + return null; + } + } + + static Future> runPostProcessing( + String filePath, { + Map? metadata, + }) async { + final result = await _channel.invokeMethod('runPostProcessing', { + 'file_path': filePath, + 'metadata': metadata != null ? jsonEncode(metadata) : '', + }); + return jsonDecode(result as String) as Map; + } + + static Future> runPostProcessingV2( + String filePath, { + Map? metadata, + }) async { + final input = {}; + if (filePath.startsWith('content://')) { + input['uri'] = filePath; + } else { + input['path'] = filePath; + } + final result = await _channel.invokeMethod('runPostProcessingV2', { + 'input': jsonEncode(input), + 'metadata': metadata != null ? jsonEncode(metadata) : '', + }); + return jsonDecode(result as String) as Map; + } + + static Future>> getPostProcessingProviders() async { + final result = await _channel.invokeMethod('getPostProcessingProviders'); + final list = jsonDecode(result as String) as List; + return list.map((e) => e as Map).toList(); + } + + static Future initExtensionStore(String cacheDir) async { + _log.d('initExtensionStore: $cacheDir'); + await _channel.invokeMethod('initExtensionStore', {'cache_dir': cacheDir}); + } + + static Future>> getStoreExtensions({ + bool forceRefresh = false, + }) async { + _log.d('getStoreExtensions (forceRefresh: $forceRefresh)'); + final result = await _channel.invokeMethod('getStoreExtensions', { + 'force_refresh': forceRefresh, + }); + final list = jsonDecode(result as String) as List; + return list.map((e) => e as Map).toList(); + } + + static Future>> searchStoreExtensions( + String query, { + String? category, + }) async { + _log.d('searchStoreExtensions: "$query" (category: $category)'); + final result = await _channel.invokeMethod('searchStoreExtensions', { + 'query': query, + 'category': category ?? '', + }); + final list = jsonDecode(result as String) as List; + return list.map((e) => e as Map).toList(); + } + + static Future> getStoreCategories() async { + final result = await _channel.invokeMethod('getStoreCategories'); + final list = jsonDecode(result as String) as List; + return list.cast(); + } + + static Future downloadStoreExtension( + String extensionId, + String destDir, + ) async { + _log.i('downloadStoreExtension: $extensionId to $destDir'); + final result = await _channel.invokeMethod('downloadStoreExtension', { + 'extension_id': extensionId, + 'dest_dir': destDir, + }); + return result as String; + } + + static Future clearStoreCache() async { + _log.d('clearStoreCache'); + await _channel.invokeMethod('clearStoreCache'); + } + + // ==================== YOUTUBE / COBALT ==================== + + /// Download a track from YouTube using the Cobalt API. + /// YouTube is a lossy-only provider (Opus 256kbps or MP3 320kbps). + /// It does NOT participate in the lossless fallback chain. + @Deprecated('Use downloadByStrategy with DownloadRequestPayload.') + static Future> downloadFromYouTube({ + required String trackName, + required String artistName, + required String albumName, + String? albumArtist, + String? coverUrl, + required String outputDir, + required String filenameFormat, + String quality = 'opus_256', + int trackNumber = 1, + int discNumber = 1, + String? releaseDate, + String? itemId, + int durationMs = 0, + String? isrc, + String? spotifyId, + String? deezerId, + bool embedLyrics = true, + bool embedMaxQualityCover = true, + int totalTracks = 1, + String? genre, + String? label, + String? copyright, + String lyricsMode = 'embed', + String storageMode = 'app', + String safTreeUri = '', + String safRelativeDir = '', + String safFileName = '', + String safOutputExt = '', + }) async { + final payload = DownloadRequestPayload( + isrc: isrc ?? '', + service: 'youtube', + spotifyId: spotifyId ?? '', + trackName: trackName, + artistName: artistName, + albumName: albumName, + albumArtist: albumArtist ?? artistName, + coverUrl: coverUrl ?? '', + outputDir: outputDir, + filenameFormat: filenameFormat, + quality: quality, + embedLyrics: embedLyrics, + embedMaxQualityCover: embedMaxQualityCover, + trackNumber: trackNumber, + discNumber: discNumber, + totalTracks: totalTracks, + releaseDate: releaseDate ?? '', + itemId: itemId ?? '', + durationMs: durationMs, + deezerId: deezerId ?? '', + genre: genre ?? '', + label: label ?? '', + copyright: copyright ?? '', + lyricsMode: lyricsMode, + storageMode: storageMode, + safTreeUri: safTreeUri, + safRelativeDir: safRelativeDir, + safFileName: safFileName, + safOutputExt: safOutputExt, + ); + + return downloadByStrategy(payload: payload); + } +} diff --git a/pubspec.lock b/pubspec.lock index 59901618..a250b404 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -189,10 +189,10 @@ packages: dependency: "direct main" description: name: connectivity_plus - sha256: b5e72753cf63becce2c61fd04dfe0f1c430cc5278b53a1342dc5ad839eab29ec + sha256: "33bae12a398f841c6cda09d1064212957265869104c478e5ad51e2fb26c3973c" url: "https://pub.dev" source: hosted - version: "6.1.5" + version: "7.0.0" connectivity_plus_platform_interface: dependency: transitive description: @@ -386,34 +386,34 @@ packages: dependency: "direct main" description: name: flutter_local_notifications - sha256: "19ffb0a8bb7407875555e5e98d7343a633bb73707bae6c6a5f37c90014077875" + sha256: "76cd20bcfa72fabe50ea27eeaf165527f446f55d3033021462084b87805b4cac" url: "https://pub.dev" source: hosted - version: "19.5.0" + version: "20.0.0" flutter_local_notifications_linux: dependency: transitive description: name: flutter_local_notifications_linux - sha256: e3c277b2daab8e36ac5a6820536668d07e83851aeeb79c446e525a70710770a5 + sha256: dce0116868cedd2cdf768af0365fc37ff1cbef7c02c4f51d0587482e625868d0 url: "https://pub.dev" source: hosted - version: "6.0.0" + version: "7.0.0" flutter_local_notifications_platform_interface: dependency: transitive description: name: flutter_local_notifications_platform_interface - sha256: "277d25d960c15674ce78ca97f57d0bae2ee401c844b6ac80fcd972a9c99d09fe" + sha256: "23de31678a48c084169d7ae95866df9de5c9d2a44be3e5915a2ff067aeeba899" url: "https://pub.dev" source: hosted - version: "9.1.0" + version: "10.0.0" flutter_local_notifications_windows: dependency: transitive description: name: flutter_local_notifications_windows - sha256: "8d658f0d367c48bd420e7cf2d26655e2d1130147bca1eea917e576ca76668aaf" + sha256: "7ddd964fa85b6a23e96956c5b63ef55cdb9e5947b71b95712204db42ad46da61" url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "2.0.0" flutter_localizations: dependency: "direct main" description: flutter @@ -439,50 +439,50 @@ packages: dependency: "direct main" description: name: flutter_secure_storage - sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + sha256: da922f2aab2d733db7e011a6bcc4a825b844892d4edd6df83ff156b09a9b2e40 url: "https://pub.dev" source: hosted - version: "9.2.4" + version: "10.0.0" + flutter_secure_storage_darwin: + dependency: transitive + description: + name: flutter_secure_storage_darwin + sha256: "8878c25136a79def1668c75985e8e193d9d7d095453ec28730da0315dc69aee3" + url: "https://pub.dev" + source: hosted + version: "0.2.0" flutter_secure_storage_linux: dependency: transitive description: name: flutter_secure_storage_linux - sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + sha256: "2b5c76dce569ab752d55a1cee6a2242bcc11fdba927078fb88c503f150767cda" url: "https://pub.dev" source: hosted - version: "1.2.3" - flutter_secure_storage_macos: - dependency: transitive - description: - name: flutter_secure_storage_macos - sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" - url: "https://pub.dev" - source: hosted - version: "3.1.3" + version: "3.0.0" flutter_secure_storage_platform_interface: dependency: transitive description: name: flutter_secure_storage_platform_interface - sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6" url: "https://pub.dev" source: hosted - version: "1.1.2" + version: "2.0.1" flutter_secure_storage_web: dependency: transitive description: name: flutter_secure_storage_web - sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + sha256: "6a1137df62b84b54261dca582c1c09ea72f4f9a4b2fcee21b025964132d5d0c3" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "2.1.0" flutter_secure_storage_windows: dependency: transitive description: name: flutter_secure_storage_windows - sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613" url: "https://pub.dev" source: hosted - version: "3.1.2" + version: "4.1.0" flutter_svg: dependency: "direct main" description: @@ -741,14 +741,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" - palette_generator: - dependency: "direct main" - description: - name: palette_generator - sha256: "4420f7ccc3f0a4a906144e73f8b6267cd940b64f57a7262e95cb8cec3a8ae0ed" - url: "https://pub.dev" - source: hosted - version: "0.3.3+7" path: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 3073f601..c7ce0a98 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: "none" -version: 3.6.0+77 +version: 3.6.1+78 environment: sdk: ^3.10.0