diff --git a/go_backend/library_scan.go b/go_backend/library_scan.go index 673c8870..e1752d9a 100644 --- a/go_backend/library_scan.go +++ b/go_backend/library_scan.go @@ -90,7 +90,7 @@ type scannedCueFileInfo struct { func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]libraryAudioFileInfo, error) { var files []libraryAudioFileInfo - err := filepath.Walk(folderPath, func(path string, info os.FileInfo, err error) error { + err := filepath.WalkDir(folderPath, func(path string, entry os.DirEntry, err error) error { if err != nil { return nil } @@ -101,7 +101,7 @@ func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]li default: } - if info.IsDir() { + if entry.IsDir() { return nil } @@ -110,6 +110,11 @@ func collectLibraryAudioFiles(folderPath string, cancelCh <-chan struct{}) ([]li return nil } + info, err := entry.Info() + if err != nil { + return nil + } + files = append(files, libraryAudioFileInfo{ path: path, modTime: info.ModTime().UnixMilli(), diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index dad08bae..5366fc1b 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -1293,19 +1293,11 @@ class DownloadQueueState { ); } - int get queuedCount => items - .where( - (i) => - i.status == DownloadStatus.queued || - i.status == DownloadStatus.downloading, - ) - .length; - int get completedCount => - items.where((i) => i.status == DownloadStatus.completed).length; - int get failedCount => - items.where((i) => i.status == DownloadStatus.failed).length; + int get queuedCount => items.isEmpty ? 0 : lookup.queuedCount; + int get completedCount => items.isEmpty ? 0 : lookup.completedCount; + int get failedCount => items.isEmpty ? 0 : lookup.failedCount; int get activeDownloadsCount => - items.where((i) => i.status == DownloadStatus.downloading).length; + items.isEmpty ? 0 : lookup.activeDownloadsCount; } class _ProgressUpdate { @@ -6419,18 +6411,30 @@ class DownloadQueueLookup { final Map byItemId; final Map indexByItemId; final List itemIds; + final int queuedCount; + final int completedCount; + final int failedCount; + final int activeDownloadsCount; const DownloadQueueLookup.empty() : byTrackId = const {}, byItemId = const {}, indexByItemId = const {}, - itemIds = const []; + itemIds = const [], + queuedCount = 0, + completedCount = 0, + failedCount = 0, + activeDownloadsCount = 0; DownloadQueueLookup._({ required this.byTrackId, required this.byItemId, required this.indexByItemId, required this.itemIds, + required this.queuedCount, + required this.completedCount, + required this.failedCount, + required this.activeDownloadsCount, }); factory DownloadQueueLookup.fromItems(List items) { @@ -6438,21 +6442,47 @@ class DownloadQueueLookup { final byItemId = {}; final indexByItemId = {}; final itemIds = []; + var queuedCount = 0; + var completedCount = 0; + var failedCount = 0; + var activeDownloadsCount = 0; for (var index = 0; index < items.length; index++) { final item = items[index]; byTrackId.putIfAbsent(item.track.id, () => item); byItemId[item.id] = item; indexByItemId[item.id] = index; itemIds.add(item.id); + if (_countsAsQueued(item.status)) queuedCount++; + if (item.status == DownloadStatus.completed) completedCount++; + if (item.status == DownloadStatus.failed) failedCount++; + if (item.status == DownloadStatus.downloading) activeDownloadsCount++; } return DownloadQueueLookup._( byTrackId: byTrackId, byItemId: byItemId, indexByItemId: indexByItemId, itemIds: itemIds, + queuedCount: queuedCount, + completedCount: completedCount, + failedCount: failedCount, + activeDownloadsCount: activeDownloadsCount, ); } + static bool _countsAsQueued(DownloadStatus status) => + status == DownloadStatus.queued || status == DownloadStatus.downloading; + + static int _deltaForStatus({ + required DownloadStatus previous, + required DownloadStatus next, + required bool Function(DownloadStatus status) predicate, + }) { + final had = predicate(previous); + final has = predicate(next); + if (had == has) return 0; + return has ? 1 : -1; + } + DownloadQueueLookup updatedForIndices({ required List previousItems, required List nextItems, @@ -6473,7 +6503,11 @@ class DownloadQueueLookup { } if (normalizedChanged.isEmpty) return this; - final nextByItemId = Map.from(byItemId); + var nextQueuedCount = queuedCount; + var nextCompletedCount = completedCount; + var nextFailedCount = failedCount; + var nextActiveDownloadsCount = activeDownloadsCount; + Map? nextByItemId; Map? nextByTrackId; for (final index in normalizedChanged) { @@ -6483,18 +6517,43 @@ class DownloadQueueLookup { return DownloadQueueLookup.fromItems(nextItems); } + nextByItemId ??= Map.from(byItemId); nextByItemId[next.id] = next; if (byTrackId[next.track.id]?.id == previous.id) { nextByTrackId ??= Map.from(byTrackId); nextByTrackId[next.track.id] = next; } + nextQueuedCount += _deltaForStatus( + previous: previous.status, + next: next.status, + predicate: _countsAsQueued, + ); + nextCompletedCount += _deltaForStatus( + previous: previous.status, + next: next.status, + predicate: (status) => status == DownloadStatus.completed, + ); + nextFailedCount += _deltaForStatus( + previous: previous.status, + next: next.status, + predicate: (status) => status == DownloadStatus.failed, + ); + nextActiveDownloadsCount += _deltaForStatus( + previous: previous.status, + next: next.status, + predicate: (status) => status == DownloadStatus.downloading, + ); } return DownloadQueueLookup._( byTrackId: nextByTrackId ?? byTrackId, - byItemId: nextByItemId, + byItemId: nextByItemId ?? byItemId, indexByItemId: indexByItemId, itemIds: itemIds, + queuedCount: nextQueuedCount, + completedCount: nextCompletedCount, + failedCount: nextFailedCount, + activeDownloadsCount: nextActiveDownloadsCount, ); } } diff --git a/lib/providers/local_library_provider.dart b/lib/providers/local_library_provider.dart index 8328f10c..83dc0a06 100644 --- a/lib/providers/local_library_provider.dart +++ b/lib/providers/local_library_provider.dart @@ -130,6 +130,8 @@ class LocalLibraryNotifier extends Notifier { Timer? _progressStreamBootstrapTimer; StreamSubscription>? _progressStreamSub; bool _isLoaded = false; + bool _hasLoadedFromDatabase = false; + Future? _loadFuture; bool _scanCancelRequested = false; int _progressPollingErrorCount = 0; bool _isProgressPollingInFlight = false; @@ -148,14 +150,22 @@ class LocalLibraryNotifier extends Notifier { _progressStreamSub?.cancel(); }); - Future.microtask(() async { - await _loadFromDatabase(); - }); + Future.microtask(_ensureLoadedFromDatabase); return LocalLibraryState(); } + Future _ensureLoadedFromDatabase() { + if (_hasLoadedFromDatabase) { + return Future.value(); + } + return _loadFuture ??= _loadFromDatabase(); + } + Future _loadFromDatabase() async { - if (_isLoaded) return; + if (_hasLoadedFromDatabase) return; + if (_isLoaded) { + return _loadFuture ?? Future.value(); + } _isLoaded = true; try { @@ -186,14 +196,20 @@ class LocalLibraryNotifier extends Notifier { 'Loaded ${items.length} items from library database, lastScannedAt: ' '$lastScannedAt, excludedDownloadedCount: $excludedDownloadedCount', ); + _hasLoadedFromDatabase = true; } catch (e, stack) { + _isLoaded = false; _log.e('Failed to load library from database: $e', e, stack); + } finally { + _loadFuture = null; } } Future reloadFromStorage() async { _isLoaded = false; - await _loadFromDatabase(); + _hasLoadedFromDatabase = false; + _loadFuture = null; + await _ensureLoadedFromDatabase(); } bool _isDownloadedPath(String? filePath, Set downloadedPathKeys) { @@ -209,6 +225,31 @@ class LocalLibraryNotifier extends Notifier { return false; } + Future> _currentItemsByPathForIncrementalScan( + Map existingFiles, + ) async { + await _ensureLoadedFromDatabase(); + + final loadedItems = state.items; + if (loadedItems.isNotEmpty || existingFiles.isEmpty) { + return { + for (final item in loadedItems) item.filePath: item, + }; + } + + // Rare fallback: if provider state failed to warm while the database has + // rows, preserve correctness instead of applying a diff to an empty base. + _log.w( + 'Library state is empty while database has ${existingFiles.length} files; ' + 'loading incremental scan baseline from database', + ); + final existingJson = await _db.getAll(); + return { + for (final item in existingJson.map(LocalLibraryItem.fromJson)) + item.filePath: item, + }; + } + Future startScan( String folderPath, { bool forceFullScan = false, @@ -456,11 +497,9 @@ class LocalLibraryNotifier extends Notifier { '$skippedCount skipped, ${deletedPaths.length} deleted, $totalFiles total', ); - final existingJson = await _db.getAll(); - final currentByPath = { - for (final item in existingJson.map(LocalLibraryItem.fromJson)) - item.filePath: item, - }; + final currentByPath = await _currentItemsByPathForIncrementalScan( + existingFiles, + ); final existingDownloadedPaths = []; currentByPath.removeWhere((path, _) { final shouldExclude = _isDownloadedPath(path, downloadedPathKeys); diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index c26ad1b3..6716dbd5 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/services/download_request_payload.dart'; @@ -8,6 +9,8 @@ import 'package:spotiflac_android/utils/logger.dart'; final _log = AppLogger('PlatformBridge'); +Object? _decodeJsonInBackground(String json) => jsonDecode(json); + class _BridgeCacheEntry { final Map value; final DateTime expiresAt; @@ -32,6 +35,7 @@ class _BridgeInFlight { class PlatformBridge { static const _channel = MethodChannel('com.zarz.spotiflac/backend'); static const _jsonResultFileKey = '__json_file'; + static const _backgroundJsonDecodeThresholdBytes = 128 * 1024; static const _metadataCacheTtl = Duration(minutes: 20); static const _availabilityCacheTtl = Duration(minutes: 15); static const _bridgeCacheMaxEntries = 256; @@ -1568,16 +1572,27 @@ class PlatformBridge { try { final contents = await file.readAsString(); if (contents.isEmpty) return null; - return jsonDecode(contents); + return _decodeJsonStringAsync(contents); } finally { try { await file.delete(); } catch (_) {} } } + if (result is String && + result.length >= _backgroundJsonDecodeThresholdBytes) { + return _decodeJsonStringAsync(result); + } return _decodeJsonResult(result); } + static Future _decodeJsonStringAsync(String json) { + if (json.length < _backgroundJsonDecodeThresholdBytes) { + return Future.value(jsonDecode(json)); + } + return compute(_decodeJsonInBackground, json); + } + static Map _decodeRequiredMapResult( dynamic result, String method,