From ca136b8e17c513d70b1004bf22af8eda284b76b1 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sat, 7 Feb 2026 13:11:23 +0700 Subject: [PATCH] fix: stabilize incremental library scan and fold 3.5.1 into 3.5.0 --- CHANGELOG.md | 13 + .../kotlin/com/zarz/spotiflac/MainActivity.kt | 253 +- go_backend/library_scan.go | 194 ++ lib/providers/local_library_provider.dart | 247 +- lib/services/library_database.dart | 69 +- lib/services/platform_bridge.dart | 2158 +++++++++-------- 6 files changed, 1833 insertions(+), 1101 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2875edd..4ff82de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,10 @@ - New settings fields for storage mode + SAF tree URI - SAF platform bridge methods: pick tree, stat/exists/delete, open content URI, copy to temp, write back to SAF - SAF library scan mode (DocumentFile traversal + metadata read) +- Incremental library scanning for filesystem and SAF paths (only scans new/modified files and detects removed files) +- Force Full Scan action in Library Settings to rescan all files on demand +- Downloaded files are now excluded from Local Library scan results to prevent duplicate entries +- Legacy library rows now support `file_mod_time` backfill before incremental scans (faster follow-up scans after upgrade) - Library UI toggle to show SAF-repaired history items - Scan cancelled banner + retry action for library scans - Android DocumentFile dependency for SAF operations @@ -24,6 +28,7 @@ - Donate page in Settings with Ko-fi and Buy Me a Coffee links - Per-App Language support on Android 13+ (locale_config.xml) - Interactive tutorial with working search bar simulation and clickable download buttons +- Tutorial completion state is persisted after onboarding - Visual feedback animations for page transitions, entrance effects, and feature lists - New dedicated welcome step in setup wizard with improved branding @@ -33,6 +38,10 @@ - Tidal/Qobuz/Amazon/Extension downloads use SAF-aware output when enabled - Post-processing hooks run for SAF content URIs (via temp file bridge) - File operations in Library/Queue/Track screens now SAF-aware (`open`, `exists`, `delete`, `stat`) +- Local Library scan defaults to incremental mode; full rescan is available via Force Full Scan +- Local library database upgraded to schema v3 with `file_mod_time` tracking for incremental scan cache +- Platform channels expanded with incremental scan APIs (`scanLibraryFolderIncremental`) on Android and iOS +- Android platform channel adds `getSafFileModTimes` for SAF legacy cache backfill - Android build tooling upgraded to Gradle 9.3.1 (wrapper) - Android build path validated with Java 25 (Gradle/Kotlin/assemble debug) - SAF tree picker flow in `MainActivity` migrated to Activity Result API (`registerForActivityResult`) @@ -61,6 +70,10 @@ - Library scan hero card showing 0 tracks during scan (now shows scanned file count in real-time) - Library folder picker no longer requires MANAGE_EXTERNAL_STORAGE on Android 10+ (uses SAF tree picker) - One-time SAF migration prompt for users updating from pre-SAF versions +- Fixed `fileModTime` propagation across Go/Android/Dart so incremental scan cache is stored and reused correctly +- Fixed SAF incremental scan key mismatch (`lastModified` vs `fileModTime`) and normalized result fields (`skippedCount`, `totalFiles`) +- Fixed incremental scan progress when all files are skipped (`scanned_files` now reaches total files) +- Removed duplicate `"removeExtension"` branch in Android method channel handler (eliminates Kotlin duplicate-branch warning) --- 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 8c8c4ca..a43f5a0 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -606,7 +606,9 @@ class MainActivity: FlutterFragmentActivity() { val metadataJson = Gobackend.readAudioMetadataJSON(tempPath) if (metadataJson.isNotBlank()) { val obj = JSONObject(metadataJson) + val lastModified = doc.lastModified() obj.put("filePath", doc.uri.toString()) + obj.put("fileModTime", lastModified) results.put(obj) } else { errors++ @@ -637,6 +639,226 @@ class MainActivity: FlutterFragmentActivity() { return results.toString() } + /** + * Incremental SAF tree scan - only scans new or modified files. + * @param treeUriStr The SAF tree URI to scan + * @param existingFilesJson JSON object mapping file URI -> lastModified timestamp + * @return JSON object with new/changed files and removed URIs + */ + private fun scanSafTreeIncremental(treeUriStr: String, existingFilesJson: String): String { + if (treeUriStr.isBlank()) { + val result = JSONObject() + result.put("files", JSONArray()) + result.put("removedUris", JSONArray()) + result.put("skippedCount", 0) + result.put("totalFiles", 0) + return result.toString() + } + + val treeUri = Uri.parse(treeUriStr) + val root = DocumentFile.fromTreeUri(this, treeUri) ?: run { + val result = JSONObject() + result.put("files", JSONArray()) + result.put("removedUris", JSONArray()) + result.put("skippedCount", 0) + result.put("totalFiles", 0) + return result.toString() + } + + // Parse existing files map: URI -> lastModified + val existingFiles = mutableMapOf() + try { + val obj = JSONObject(existingFilesJson) + val keys = obj.keys() + while (keys.hasNext()) { + val key = keys.next() + existingFiles[key] = obj.optLong(key, 0) + } + } catch (_: Exception) {} + + resetSafScanProgress() + safScanCancel = false + safScanActive = true + + val supportedExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg") + val audioFiles = mutableListOf>() // doc, path, lastModified + val currentUris = mutableSetOf() + + // Collect all audio files with lastModified + val queue: ArrayDeque> = ArrayDeque() + queue.add(root to "") + + while (queue.isNotEmpty()) { + if (safScanCancel) { + updateSafScanProgress { it.isComplete = true } + val result = JSONObject() + result.put("files", JSONArray()) + result.put("removedUris", JSONArray()) + result.put("skippedCount", 0) + result.put("totalFiles", 0) + result.put("cancelled", true) + return result.toString() + } + + val (dir, path) = queue.removeFirst() + for (child in dir.listFiles()) { + if (safScanCancel) { + updateSafScanProgress { it.isComplete = true } + val result = JSONObject() + result.put("files", JSONArray()) + result.put("removedUris", JSONArray()) + result.put("skippedCount", 0) + result.put("totalFiles", 0) + result.put("cancelled", true) + return result.toString() + } + + if (child.isDirectory) { + val childName = child.name ?: continue + val childPath = if (path.isBlank()) childName else "$path/$childName" + queue.add(child to childPath) + } else if (child.isFile) { + val name = child.name ?: continue + val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) + if (ext.isNotBlank() && supportedExt.contains(".$ext")) { + val uriStr = child.uri.toString() + val lastModified = child.lastModified() + currentUris.add(uriStr) + + // Check if file is new or modified + val existingModified = existingFiles[uriStr] + if (existingModified == null || existingModified != lastModified) { + audioFiles.add(Triple(child, path, lastModified)) + } + } + } + } + } + + // Find removed files (in existing but not in current) + val removedUris = existingFiles.keys.filter { !currentUris.contains(it) } + val totalFiles = currentUris.size + val skippedCount = (totalFiles - audioFiles.size).coerceAtLeast(0) + + updateSafScanProgress { + it.totalFiles = totalFiles + } + + if (audioFiles.isEmpty()) { + updateSafScanProgress { + it.isComplete = true + it.scannedFiles = totalFiles + it.progressPct = 100.0 + } + val result = JSONObject() + result.put("files", JSONArray()) + result.put("removedUris", JSONArray(removedUris)) + result.put("skippedCount", skippedCount) + result.put("totalFiles", totalFiles) + return result.toString() + } + + val results = JSONArray() + var scanned = 0 + var errors = 0 + + for ((doc, _, lastModified) in audioFiles) { + if (safScanCancel) { + updateSafScanProgress { it.isComplete = true } + val result = JSONObject() + result.put("files", JSONArray()) + result.put("removedUris", JSONArray()) + result.put("skippedCount", skippedCount) + result.put("totalFiles", totalFiles) + result.put("cancelled", true) + return result.toString() + } + + val name = doc.name ?: "" + updateSafScanProgress { + it.currentFile = name + } + + val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT) + val fallbackExt = if (ext.isNotBlank()) ".${ext}" else null + val tempPath = copyUriToTemp(doc.uri, fallbackExt) + if (tempPath == null) { + errors++ + } else { + try { + val metadataJson = Gobackend.readAudioMetadataJSON(tempPath) + if (metadataJson.isNotBlank()) { + val obj = JSONObject(metadataJson) + obj.put("filePath", doc.uri.toString()) + obj.put("fileModTime", lastModified) + obj.put("lastModified", lastModified) + results.put(obj) + } else { + errors++ + } + } catch (_: Exception) { + errors++ + } finally { + try { + File(tempPath).delete() + } catch (_: Exception) {} + } + } + + scanned++ + val processed = skippedCount + scanned + val pct = if (totalFiles > 0) { + processed.toDouble() / totalFiles.toDouble() * 100.0 + } else { + 100.0 + } + updateSafScanProgress { + it.scannedFiles = processed + it.errorCount = errors + it.progressPct = pct + } + } + + updateSafScanProgress { + it.isComplete = true + it.progressPct = 100.0 + } + + val result = JSONObject() + result.put("files", results) + result.put("removedUris", JSONArray(removedUris)) + result.put("skippedCount", skippedCount) + result.put("totalFiles", totalFiles) + return result.toString() + } + + /** + * Resolve SAF file last-modified values for a list of content URIs. + * Returns JSON object mapping uri -> lastModified (unix millis). + */ + private fun getSafFileModTimes(urisJson: String): String { + val result = JSONObject() + val uris = try { + JSONArray(urisJson) + } catch (_: Exception) { + JSONArray() + } + + for (i in 0 until uris.length()) { + val uriStr = uris.optString(i, "") + if (uriStr.isBlank()) continue + try { + val uri = Uri.parse(uriStr) + val doc = DocumentFile.fromSingleUri(this, uri) + if (doc != null && doc.exists()) { + result.put(uriStr, doc.lastModified()) + } + } catch (_: Exception) {} + } + + return result.toString() + } + private fun runPostProcessingSaf(fileUriStr: String, metadataJson: String): String { val uri = Uri.parse(fileUriStr) val doc = DocumentFile.fromSingleUri(this, uri) @@ -1476,13 +1698,6 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } - "removeExtension" -> { - val extensionId = call.argument("extension_id") ?: "" - withContext(Dispatchers.IO) { - Gobackend.removeExtensionByID(extensionId) - } - result.success(null) - } "cleanupExtensions" -> { withContext(Dispatchers.IO) { Gobackend.cleanupExtensions() @@ -1746,6 +1961,15 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } + "scanLibraryFolderIncremental" -> { + val folderPath = call.argument("folder_path") ?: "" + val existingFiles = call.argument("existing_files") ?: "{}" + val response = withContext(Dispatchers.IO) { + safScanActive = false + Gobackend.scanLibraryFolderIncrementalJSON(folderPath, existingFiles) + } + result.success(response) + } "scanSafTree" -> { val treeUri = call.argument("tree_uri") ?: "" val response = withContext(Dispatchers.IO) { @@ -1753,6 +1977,21 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } + "scanSafTreeIncremental" -> { + val treeUri = call.argument("tree_uri") ?: "" + val existingFiles = call.argument("existing_files") ?: "{}" + val response = withContext(Dispatchers.IO) { + scanSafTreeIncremental(treeUri, existingFiles) + } + result.success(response) + } + "getSafFileModTimes" -> { + val uris = call.argument("uris") ?: "[]" + val response = withContext(Dispatchers.IO) { + getSafFileModTimes(uris) + } + result.success(response) + } "getLibraryScanProgress" -> { val response = withContext(Dispatchers.IO) { if (safScanActive) { diff --git a/go_backend/library_scan.go b/go_backend/library_scan.go index a0ceecf..7ed3fee 100644 --- a/go_backend/library_scan.go +++ b/go_backend/library_scan.go @@ -20,6 +20,7 @@ type LibraryScanResult struct { FilePath string `json:"filePath"` CoverPath string `json:"coverPath,omitempty"` ScannedAt string `json:"scannedAt"` + FileModTime int64 `json:"fileModTime,omitempty"` // Unix timestamp in milliseconds ISRC string `json:"isrc,omitempty"` TrackNumber int `json:"trackNumber,omitempty"` DiscNumber int `json:"discNumber,omitempty"` @@ -40,6 +41,14 @@ type LibraryScanProgress struct { IsComplete bool `json:"is_complete"` } +// IncrementalScanResult contains results of an incremental library scan +type IncrementalScanResult struct { + Scanned []LibraryScanResult `json:"scanned"` // New or updated files + DeletedPaths []string `json:"deletedPaths"` // Files that no longer exist + SkippedCount int `json:"skippedCount"` // Files that were unchanged + TotalFiles int `json:"totalFiles"` // Total files in folder +} + var ( libraryScanProgress LibraryScanProgress libraryScanProgressMu sync.RWMutex @@ -179,6 +188,11 @@ func scanAudioFile(filePath, scanTime string) (*LibraryScanResult, error) { Format: strings.TrimPrefix(ext, "."), } + // Get file modification time + if info, err := os.Stat(filePath); err == nil { + result.FileModTime = info.ModTime().UnixMilli() + } + libraryCoverCacheMu.RLock() coverCacheDir := libraryCoverCacheDir libraryCoverCacheMu.RUnlock() @@ -413,3 +427,183 @@ func ReadAudioMetadata(filePath string) (string, error) { return string(jsonBytes), nil } + +// ScanLibraryFolderIncremental performs an incremental scan of the library folder +// existingFilesJSON is a JSON object mapping filePath -> modTime (unix millis) +// Only files that are new or have changed modification time will be scanned +func ScanLibraryFolderIncremental(folderPath, existingFilesJSON string) (string, error) { + if folderPath == "" { + return "{}", fmt.Errorf("folder path is empty") + } + + info, err := os.Stat(folderPath) + if err != nil { + return "{}", fmt.Errorf("folder not found: %w", err) + } + if !info.IsDir() { + return "{}", fmt.Errorf("path is not a folder: %s", folderPath) + } + + // Parse existing files map + existingFiles := make(map[string]int64) + if existingFilesJSON != "" && existingFilesJSON != "{}" { + if err := json.Unmarshal([]byte(existingFilesJSON), &existingFiles); err != nil { + GoLog("[LibraryScan] Warning: failed to parse existing files JSON: %v\n", err) + } + } + + GoLog("[LibraryScan] Incremental scan starting, %d existing files in database\n", len(existingFiles)) + + // Reset progress + libraryScanProgressMu.Lock() + libraryScanProgress = LibraryScanProgress{} + libraryScanProgressMu.Unlock() + + // Setup cancellation + libraryScanCancelMu.Lock() + if libraryScanCancel != nil { + close(libraryScanCancel) + } + libraryScanCancel = make(chan struct{}) + cancelCh := libraryScanCancel + libraryScanCancelMu.Unlock() + + // Collect all audio files with their mod times + type fileInfo struct { + path string + modTime int64 + } + var currentFiles []fileInfo + currentPathSet := make(map[string]bool) + + err = filepath.Walk(folderPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + + select { + case <-cancelCh: + return fmt.Errorf("scan cancelled") + default: + } + + if !info.IsDir() { + ext := strings.ToLower(filepath.Ext(path)) + if supportedAudioFormats[ext] { + currentFiles = append(currentFiles, fileInfo{ + path: path, + modTime: info.ModTime().UnixMilli(), + }) + currentPathSet[path] = true + } + } + return nil + }) + + if err != nil { + return "{}", err + } + + totalFiles := len(currentFiles) + libraryScanProgressMu.Lock() + libraryScanProgress.TotalFiles = totalFiles + libraryScanProgressMu.Unlock() + + // Find files to scan (new or modified) + var filesToScan []fileInfo + skippedCount := 0 + + for _, f := range currentFiles { + existingModTime, exists := existingFiles[f.path] + if !exists { + // New file + filesToScan = append(filesToScan, f) + } else if f.modTime != existingModTime { + // Modified file + filesToScan = append(filesToScan, f) + } else { + // Unchanged file - skip + skippedCount++ + } + } + + // Find deleted files + var deletedPaths []string + for existingPath := range existingFiles { + if !currentPathSet[existingPath] { + deletedPaths = append(deletedPaths, existingPath) + } + } + + GoLog("[LibraryScan] Incremental: %d to scan, %d skipped, %d deleted\n", + len(filesToScan), skippedCount, len(deletedPaths)) + + if len(filesToScan) == 0 { + libraryScanProgressMu.Lock() + libraryScanProgress.ScannedFiles = totalFiles + libraryScanProgress.IsComplete = true + libraryScanProgress.ProgressPct = 100 + libraryScanProgressMu.Unlock() + + result := IncrementalScanResult{ + Scanned: []LibraryScanResult{}, + DeletedPaths: deletedPaths, + SkippedCount: skippedCount, + TotalFiles: totalFiles, + } + jsonBytes, _ := json.Marshal(result) + return string(jsonBytes), nil + } + + // Scan the files that need scanning + results := make([]LibraryScanResult, 0, len(filesToScan)) + scanTime := time.Now().UTC().Format(time.RFC3339) + errorCount := 0 + + for i, f := range filesToScan { + select { + case <-cancelCh: + return "{}", fmt.Errorf("scan cancelled") + default: + } + + libraryScanProgressMu.Lock() + libraryScanProgress.ScannedFiles = skippedCount + i + 1 + libraryScanProgress.CurrentFile = filepath.Base(f.path) + libraryScanProgress.ProgressPct = float64(skippedCount+i+1) / float64(totalFiles) * 100 + libraryScanProgressMu.Unlock() + + result, err := scanAudioFile(f.path, scanTime) + if err != nil { + errorCount++ + GoLog("[LibraryScan] Error scanning %s: %v\n", f.path, err) + continue + } + + results = append(results, *result) + } + + libraryScanProgressMu.Lock() + libraryScanProgress.ErrorCount = errorCount + libraryScanProgress.IsComplete = true + libraryScanProgress.ScannedFiles = totalFiles + libraryScanProgress.ProgressPct = 100 + libraryScanProgressMu.Unlock() + + GoLog("[LibraryScan] Incremental scan complete: %d scanned, %d skipped, %d deleted, %d errors\n", + len(results), skippedCount, len(deletedPaths), errorCount) + + scanResult := IncrementalScanResult{ + Scanned: results, + DeletedPaths: deletedPaths, + SkippedCount: skippedCount, + TotalFiles: totalFiles, + } + + jsonBytes, err := json.Marshal(scanResult) + if err != nil { + return "{}", fmt.Errorf("failed to marshal results: %w", err) + } + + return string(jsonBytes), nil +} diff --git a/lib/providers/local_library_provider.dart b/lib/providers/local_library_provider.dart index e8fdad0..b5cc52b 100644 --- a/lib/providers/local_library_provider.dart +++ b/lib/providers/local_library_provider.dart @@ -1,7 +1,9 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotiflac_android/services/history_database.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/logger.dart'; @@ -96,6 +98,7 @@ class LocalLibraryState { class LocalLibraryNotifier extends Notifier { final LibraryDatabase _db = LibraryDatabase.instance; + final HistoryDatabase _historyDb = HistoryDatabase.instance; Timer? _progressTimer; bool _isLoaded = false; bool _scanCancelRequested = false; @@ -145,14 +148,14 @@ class LocalLibraryNotifier extends Notifier { await _loadFromDatabase(); } - Future startScan(String folderPath) async { + Future startScan(String folderPath, {bool forceFullScan = false}) async { if (state.isScanning) { _log.w('Scan already in progress'); return; } _scanCancelRequested = false; - _log.i('Starting library scan: $folderPath'); + _log.i('Starting library scan: $folderPath (incremental: ${!forceFullScan})'); state = state.copyWith( isScanning: true, scanProgress: 0, @@ -176,40 +179,163 @@ class LocalLibraryNotifier extends Notifier { try { final isSaf = folderPath.startsWith('content://'); - final results = isSaf - ? await PlatformBridge.scanSafTree(folderPath) - : await PlatformBridge.scanLibraryFolder(folderPath); - if (_scanCancelRequested) { - state = state.copyWith(isScanning: false, scanWasCancelled: true); - return; - } - final items = []; - for (final json in results) { - final item = LocalLibraryItem.fromJson(json); - items.add(item); + // Get all file paths from download history to exclude them + final downloadedPaths = await _historyDb.getAllFilePaths(); + _log.i('Excluding ${downloadedPaths.length} downloaded files from library scan'); + + if (forceFullScan) { + // Full scan path - ignores existing data + final results = isSaf + ? await PlatformBridge.scanSafTree(folderPath) + : await PlatformBridge.scanLibraryFolder(folderPath); + if (_scanCancelRequested) { + state = state.copyWith(isScanning: false, scanWasCancelled: true); + return; + } + + final items = []; + int skippedDownloads = 0; + for (final json in results) { + final filePath = json['filePath'] as String?; + // Skip files that are already in download history + if (filePath != null && downloadedPaths.contains(filePath)) { + skippedDownloads++; + continue; + } + final item = LocalLibraryItem.fromJson(json); + items.add(item); + } + + if (skippedDownloads > 0) { + _log.i('Skipped $skippedDownloads files already in download history'); + } + + await _db.upsertBatch(items.map((e) => e.toJson()).toList()); + + final now = DateTime.now(); + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_lastScannedAtKey, now.toIso8601String()); + _log.d('Saved lastScannedAt: $now'); + } catch (e) { + _log.w('Failed to save lastScannedAt: $e'); + } + + state = state.copyWith( + items: items, + isScanning: false, + scanProgress: 100, + lastScannedAt: now, + scanWasCancelled: false, + ); + + _log.i('Full scan complete: ${items.length} tracks found'); + } else { + // Incremental scan path - only scans new/modified files + final existingFiles = await _db.getFileModTimes(); + _log.i('Incremental scan: ${existingFiles.length} existing files in database'); + + final backfilledModTimes = await _backfillLegacyFileModTimes( + isSaf: isSaf, + existingFiles: existingFiles, + ); + if (backfilledModTimes.isNotEmpty) { + await _db.updateFileModTimes(backfilledModTimes); + existingFiles.addAll(backfilledModTimes); + _log.i('Backfilled ${backfilledModTimes.length} legacy mod times'); + } + + // Use appropriate incremental scan method based on SAF or not + final Map result; + if (isSaf) { + result = await PlatformBridge.scanSafTreeIncremental( + folderPath, + existingFiles, + ); + } else { + result = await PlatformBridge.scanLibraryFolderIncremental( + folderPath, + existingFiles, + ); + } + + if (_scanCancelRequested) { + state = state.copyWith(isScanning: false, scanWasCancelled: true); + return; + } + + // Parse incremental scan result + // SAF returns 'files' and 'removedUris', non-SAF returns 'scanned' and 'deletedPaths' + final scannedList = (result['files'] as List?) + ?? (result['scanned'] as List?) + ?? []; + final deletedPaths = (result['removedUris'] as List?) + ?.map((e) => e as String) + .toList() + ?? (result['deletedPaths'] as List?) + ?.map((e) => e as String) + .toList() + ?? []; + final skippedCount = result['skippedCount'] as int? ?? 0; + final totalFiles = result['totalFiles'] as int? ?? 0; + + _log.i('Incremental result: ${scannedList.length} scanned, ' + '$skippedCount skipped, ${deletedPaths.length} deleted, $totalFiles total'); + + // Upsert new/modified items (excluding downloaded files) + if (scannedList.isNotEmpty) { + final items = []; + int skippedDownloads = 0; + for (final json in scannedList) { + final map = json as Map; + final filePath = map['filePath'] as String?; + // Skip files that are already in download history + if (filePath != null && downloadedPaths.contains(filePath)) { + skippedDownloads++; + continue; + } + items.add(LocalLibraryItem.fromJson(map)); + } + if (items.isNotEmpty) { + await _db.upsertBatch(items.map((e) => e.toJson()).toList()); + _log.i('Upserted ${items.length} items'); + } + if (skippedDownloads > 0) { + _log.i('Skipped $skippedDownloads files already in download history'); + } + } + + // Delete removed items + if (deletedPaths.isNotEmpty) { + final deleteCount = await _db.deleteByPaths(deletedPaths); + _log.i('Deleted $deleteCount items from database'); + } + + // Reload all items from database to get complete list + final allItems = await _db.getAll(); + final items = allItems.map((e) => LocalLibraryItem.fromJson(e)).toList(); + + final now = DateTime.now(); + try { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_lastScannedAtKey, now.toIso8601String()); + _log.d('Saved lastScannedAt: $now'); + } catch (e) { + _log.w('Failed to save lastScannedAt: $e'); + } + + state = state.copyWith( + items: items, + isScanning: false, + scanProgress: 100, + lastScannedAt: now, + scanWasCancelled: false, + ); + + _log.i('Incremental scan complete: ${items.length} total tracks ' + '(${scannedList.length} new/updated, $skippedCount unchanged, ${deletedPaths.length} removed)'); } - - await _db.upsertBatch(items.map((e) => e.toJson()).toList()); - - final now = DateTime.now(); - try { - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_lastScannedAtKey, now.toIso8601String()); - _log.d('Saved lastScannedAt: $now'); - } catch (e) { - _log.w('Failed to save lastScannedAt: $e'); - } - - state = state.copyWith( - items: items, - isScanning: false, - scanProgress: 100, - lastScannedAt: now, - scanWasCancelled: false, - ); - - _log.i('Scan complete: ${items.length} tracks found'); } catch (e, stack) { _log.e('Library scan failed: $e', e, stack); state = state.copyWith(isScanning: false, scanWasCancelled: false); @@ -316,6 +442,59 @@ class LocalLibraryNotifier extends Notifier { Future getCount() async { return await _db.getCount(); } + + Future> _backfillLegacyFileModTimes({ + required bool isSaf, + required Map existingFiles, + }) async { + final legacyPaths = existingFiles.entries + .where((entry) => entry.value <= 0) + .map((entry) => entry.key) + .toList(); + if (legacyPaths.isEmpty) { + return const {}; + } + + if (isSaf) { + final uris = legacyPaths + .where((path) => path.startsWith('content://')) + .toList(); + if (uris.isEmpty) { + return const {}; + } + const chunkSize = 500; + final backfilled = {}; + try { + for (var i = 0; i < uris.length; i += chunkSize) { + if (_scanCancelRequested) { + break; + } + final end = (i + chunkSize < uris.length) ? i + chunkSize : uris.length; + final chunk = uris.sublist(i, end); + final chunkResult = await PlatformBridge.getSafFileModTimes(chunk); + backfilled.addAll(chunkResult); + } + return backfilled; + } catch (e) { + _log.w('Failed to backfill SAF mod times: $e'); + return const {}; + } + } + + final backfilled = {}; + for (final path in legacyPaths) { + if (_scanCancelRequested || path.startsWith('content://')) { + continue; + } + try { + final stat = await File(path).stat(); + if (stat.type == FileSystemEntityType.file) { + backfilled[path] = stat.modified.millisecondsSinceEpoch; + } + } catch (_) {} + } + return backfilled; + } } final localLibraryProvider = diff --git a/lib/services/library_database.dart b/lib/services/library_database.dart index 71ec4a3..a45c476 100644 --- a/lib/services/library_database.dart +++ b/lib/services/library_database.dart @@ -15,6 +15,7 @@ class LocalLibraryItem { final String filePath; final String? coverPath; final DateTime scannedAt; + final int? fileModTime; final String? isrc; final int? trackNumber; final int? discNumber; @@ -34,6 +35,7 @@ class LocalLibraryItem { required this.filePath, this.coverPath, required this.scannedAt, + this.fileModTime, this.isrc, this.trackNumber, this.discNumber, @@ -54,6 +56,7 @@ class LocalLibraryItem { 'filePath': filePath, 'coverPath': coverPath, 'scannedAt': scannedAt.toIso8601String(), + 'fileModTime': fileModTime, 'isrc': isrc, 'trackNumber': trackNumber, 'discNumber': discNumber, @@ -75,6 +78,7 @@ class LocalLibraryItem { filePath: json['filePath'] as String, coverPath: json['coverPath'] as String?, scannedAt: DateTime.parse(json['scannedAt'] as String), + fileModTime: (json['fileModTime'] as num?)?.toInt(), isrc: json['isrc'] as String?, trackNumber: json['trackNumber'] as int?, discNumber: json['discNumber'] as int?, @@ -111,7 +115,7 @@ class LibraryDatabase { return await openDatabase( path, - version: 2, // Bumped version for cover_path migration + version: 3, // Bumped version for file_mod_time migration onCreate: _createDB, onUpgrade: _upgradeDB, ); @@ -130,6 +134,7 @@ class LibraryDatabase { file_path TEXT NOT NULL UNIQUE, cover_path TEXT, scanned_at TEXT NOT NULL, + file_mod_time INTEGER, isrc TEXT, track_number INTEGER, disc_number INTEGER, @@ -158,6 +163,12 @@ class LibraryDatabase { await db.execute('ALTER TABLE library ADD COLUMN cover_path TEXT'); _log.i('Added cover_path column'); } + + if (oldVersion < 3) { + // Add file_mod_time column for incremental scanning + await db.execute('ALTER TABLE library ADD COLUMN file_mod_time INTEGER'); + _log.i('Added file_mod_time column for incremental scanning'); + } } Map _jsonToDbRow(Map json) { @@ -170,6 +181,7 @@ class LibraryDatabase { 'file_path': json['filePath'], 'cover_path': json['coverPath'], 'scanned_at': json['scannedAt'], + 'file_mod_time': json['fileModTime'], 'isrc': json['isrc'], 'track_number': json['trackNumber'], 'disc_number': json['discNumber'], @@ -192,6 +204,7 @@ class LibraryDatabase { 'filePath': row['file_path'], 'coverPath': row['cover_path'], 'scannedAt': row['scanned_at'], + 'fileModTime': row['file_mod_time'], 'isrc': row['isrc'], 'trackNumber': row['track_number'], 'discNumber': row['disc_number'], @@ -383,4 +396,58 @@ class LibraryDatabase { await db.close(); _database = null; } + + /// Get all file paths with their modification times for incremental scanning + /// Returns a map of filePath -> fileModTime (unix timestamp in milliseconds) + Future> getFileModTimes() async { + final db = await database; + final rows = await db.rawQuery( + 'SELECT file_path, COALESCE(file_mod_time, 0) AS file_mod_time FROM library' + ); + final result = {}; + for (final row in rows) { + final path = row['file_path'] as String; + final modTime = (row['file_mod_time'] as num?)?.toInt() ?? 0; + result[path] = modTime; + } + return result; + } + + /// Update file_mod_time for existing rows using file_path as key. + Future updateFileModTimes(Map fileModTimes) async { + if (fileModTimes.isEmpty) return; + final db = await database; + final batch = db.batch(); + for (final entry in fileModTimes.entries) { + batch.update( + 'library', + {'file_mod_time': entry.value}, + where: 'file_path = ?', + whereArgs: [entry.key], + ); + } + await batch.commit(noResult: true); + } + + /// Get all file paths in the library (for detecting deleted files) + Future> getAllFilePaths() async { + final db = await database; + final rows = await db.rawQuery('SELECT file_path FROM library'); + return rows.map((r) => r['file_path'] as String).toSet(); + } + + /// Delete multiple items by their file paths + Future deleteByPaths(List filePaths) async { + if (filePaths.isEmpty) return 0; + final db = await database; + final placeholders = List.filled(filePaths.length, '?').join(','); + final result = await db.rawDelete( + 'DELETE FROM library WHERE file_path IN ($placeholders)', + filePaths, + ); + if (result > 0) { + _log.i('Deleted $result items from library'); + } + return result; + } } diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index d24a5a0..e5e8f66 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -1,959 +1,959 @@ -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> readFileMetadata(String filePath) async { - final result = await _channel.invokeMethod('readFileMetadata', { - 'file_path': filePath, - }); - return jsonDecode(result as String) as Map; - } - - 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 +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> readFileMetadata(String filePath) async { + final result = await _channel.invokeMethod('readFileMetadata', { + 'file_path': filePath, + }); + return jsonDecode(result as String) as Map; + } + + 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'); @@ -964,6 +964,22 @@ static Future> downloadWithExtensions({ 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', { @@ -973,108 +989,132 @@ static Future> downloadWithExtensions({ 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'); - } -} + 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'); + } +}