diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 2d404ba2..fd139dbd 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -153,6 +153,8 @@ class _ProgressUpdate { class DownloadQueueNotifier extends Notifier { Timer? _queuePersistDebounce; + Future _queuePersistenceWrite = Future.value(); + final Map _persistedQueueJsonById = {}; StreamSubscription>? _connectivitySub; int _downloadCount = 0; static const _cleanupInterval = 50; @@ -307,9 +309,11 @@ class DownloadQueueNotifier extends Notifier { /// teardown, and a pending debounce timer would silently drop the most /// recent queue mutations. Future flushQueuePersistence() async { - if (_queuePersistDebounce?.isActive != true) return; - _queuePersistDebounce?.cancel(); - await _flushQueueToStorage(); + if (_queuePersistDebounce?.isActive == true) { + _queuePersistDebounce?.cancel(); + await _flushQueueToStorage(); + } + await _queuePersistenceWrite; } Future _loadQueueFromStorage() async { @@ -319,6 +323,18 @@ class DownloadQueueNotifier extends Notifier { try { await _appStateDb.migrateQueueFromSharedPreferences(); final rows = await _appStateDb.getPendingDownloadQueueRows(); + _persistedQueueJsonById + ..clear() + ..addEntries( + rows + .map( + (row) => MapEntry( + row['id']?.toString() ?? '', + row['item_json']?.toString() ?? '', + ), + ) + .where((entry) => entry.key.isNotEmpty), + ); if (rows.isEmpty) { _log.d('No queue found in storage'); return; @@ -352,11 +368,13 @@ class DownloadQueueNotifier extends Notifier { if (pendingItems.isEmpty) { _log.d('No pending items to restore'); await _appStateDb.replacePendingDownloadQueueRows(const []); + _persistedQueueJsonById.clear(); return; } final normalizedPendingItems = _normalizeRestoredQueueIds(pendingItems); state = state.copyWith(items: normalizedPendingItems); + _saveQueueToStorage(); _log.i( 'Restored ${normalizedPendingItems.length} pending items from storage', ); @@ -533,6 +551,12 @@ class DownloadQueueNotifier extends Notifier { } Future _flushQueueToStorage() async { + final operation = _queuePersistenceWrite.then((_) => _writeQueueChanges()); + _queuePersistenceWrite = operation.catchError((_) {}); + await operation; + } + + Future _writeQueueChanges() async { try { final pendingItems = state.items .where( @@ -541,27 +565,38 @@ class DownloadQueueNotifier extends Notifier { item.status == DownloadStatus.downloading || item.status == DownloadStatus.finalizing, ) - .toList(); - - if (pendingItems.isEmpty) { - await _appStateDb.replacePendingDownloadQueueRows(const []); - _log.d('Cleared queue storage (no pending items)'); - } else { - final nowIso = DateTime.now().toIso8601String(); - final rows = pendingItems - .map( - (item) => { - 'id': item.id, - 'item_json': jsonEncode(item.toJson()), - 'status': item.status.name, - 'created_at': item.createdAt.toIso8601String(), - 'updated_at': nowIso, - }, - ) - .toList(growable: false); - await _appStateDb.replacePendingDownloadQueueRows(rows); - _log.d('Saved ${pendingItems.length} pending items to storage'); + .toList(growable: false); + final nowIso = DateTime.now().toIso8601String(); + final currentJsonById = {}; + final upserts = >[]; + for (final item in pendingItems) { + final itemJson = jsonEncode(item.toJson()); + currentJsonById[item.id] = itemJson; + if (_persistedQueueJsonById[item.id] == itemJson) continue; + upserts.add({ + 'id': item.id, + 'item_json': itemJson, + 'status': item.status.name, + 'created_at': item.createdAt.toIso8601String(), + 'updated_at': nowIso, + }); } + final deletedIds = _persistedQueueJsonById.keys + .where((id) => !currentJsonById.containsKey(id)) + .toList(growable: false); + if (upserts.isEmpty && deletedIds.isEmpty) return; + + await _appStateDb.applyPendingDownloadQueueChanges( + upserts: upserts, + deletedIds: deletedIds, + ); + _persistedQueueJsonById + ..clear() + ..addAll(currentJsonById); + _log.d( + 'Persisted ${upserts.length} changed and removed ' + '${deletedIds.length} queue items', + ); } catch (e) { _log.e('Failed to save queue to storage: $e'); } @@ -608,27 +643,28 @@ class DownloadQueueNotifier extends Notifier { : const {}; final currentItems = state.items; final lookup = state.lookup; - int queuedCount = 0; - int downloadingCount = 0; + final queuedCount = lookup.queuedCount; + final downloadingCount = lookup.activeDownloadsCount; DownloadItem? firstDownloading; - bool hasFinalizingItem = false; + bool hasFinalizingItem = lookup.finalizingCount > 0; String? finalizingTrackName; String? finalizingArtistName; - for (int i = 0; i < currentItems.length; i++) { - final item = currentItems[i]; - if (item.status == DownloadStatus.downloading) { - downloadingCount++; - firstDownloading ??= item; - } - if (item.status == DownloadStatus.queued || - item.status == DownloadStatus.downloading || - item.status == DownloadStatus.finalizing) { - queuedCount++; - } - if (item.status == DownloadStatus.finalizing && !hasFinalizingItem) { - hasFinalizingItem = true; - finalizingTrackName = item.track.name; - finalizingArtistName = item.track.artistName; + if (downloadingCount > 0 || hasFinalizingItem) { + for (final item in currentItems) { + if (firstDownloading == null && + item.status == DownloadStatus.downloading) { + firstDownloading = item; + } + if (finalizingTrackName == null && + item.status == DownloadStatus.finalizing) { + hasFinalizingItem = true; + finalizingTrackName = item.track.name; + finalizingArtistName = item.track.artistName; + } + if ((downloadingCount == 0 || firstDownloading != null) && + (!hasFinalizingItem || finalizingTrackName != null)) { + break; + } } } final progressUpdates = {}; @@ -1278,18 +1314,18 @@ class DownloadQueueNotifier extends Notifier { final updatedItems = List.from(items); updatedItems[index] = next; - state = state.copyWith(items: updatedItems); + state = state.copyWith( + items: updatedItems, + lookup: state.lookup.updatedForIndices( + previousItems: items, + nextItems: updatedItems, + changedIndices: [index], + ), + ); if (Platform.isAndroid && status == DownloadStatus.finalizing) { PlatformBridge.clearItemProgress(id).catchError((_) {}); - final queueCount = updatedItems - .where( - (entry) => - entry.status == DownloadStatus.queued || - entry.status == DownloadStatus.downloading || - entry.status == DownloadStatus.finalizing, - ) - .length; + final queueCount = state.lookup.queuedCount; _maybeUpdateAndroidDownloadService( trackName: next.track.name, artistName: _notificationService.embeddingMetadataLabel, @@ -2592,12 +2628,13 @@ class DownloadQueueNotifier extends Notifier { // copy is just echoed back in the response), so this lookup overlaps // with the download instead of delaying its start; it is awaited right // after the download returns. - final extendedMetadataFuture = _loadExtendedMetadataForDeezerId( - deezerTrackId, - ).catchError((Object e) { - _log.w('Extended metadata lookup failed: $e'); - return null; - }); + final extendedMetadataFuture = + _loadExtendedMetadataForDeezerId(deezerTrackId).catchError(( + Object e, + ) { + _log.w('Extended metadata lookup failed: $e'); + return null; + }); Map result; @@ -3933,6 +3970,7 @@ class DownloadQueueLookup { final int completedCount; final int failedCount; final int activeDownloadsCount; + final int finalizingCount; const DownloadQueueLookup.empty() : byTrackId = const {}, @@ -3942,21 +3980,20 @@ class DownloadQueueLookup { queuedCount = 0, completedCount = 0, failedCount = 0, - activeDownloadsCount = 0; + activeDownloadsCount = 0, + finalizingCount = 0; DownloadQueueLookup._({ - required Map byTrackId, - required Map byItemId, - required Map indexByItemId, - required List itemIds, + required this.byTrackId, + required this.byItemId, + required this.indexByItemId, + required this.itemIds, required this.queuedCount, required this.completedCount, required this.failedCount, required this.activeDownloadsCount, - }) : byTrackId = Map.unmodifiable(byTrackId), - byItemId = Map.unmodifiable(byItemId), - indexByItemId = Map.unmodifiable(indexByItemId), - itemIds = List.unmodifiable(itemIds); + required this.finalizingCount, + }); factory DownloadQueueLookup.fromItems(List items) { final byTrackId = {}; @@ -3967,6 +4004,7 @@ class DownloadQueueLookup { var completedCount = 0; var failedCount = 0; var activeDownloadsCount = 0; + var finalizingCount = 0; for (var index = 0; index < items.length; index++) { final item = items[index]; byTrackId.putIfAbsent(item.track.id, () => item); @@ -3977,16 +4015,18 @@ class DownloadQueueLookup { if (item.status == DownloadStatus.completed) completedCount++; if (item.status == DownloadStatus.failed) failedCount++; if (item.status == DownloadStatus.downloading) activeDownloadsCount++; + if (item.status == DownloadStatus.finalizing) finalizingCount++; } return DownloadQueueLookup._( - byTrackId: byTrackId, - byItemId: byItemId, - indexByItemId: indexByItemId, - itemIds: itemIds, + byTrackId: Map.unmodifiable(byTrackId), + byItemId: Map.unmodifiable(byItemId), + indexByItemId: Map.unmodifiable(indexByItemId), + itemIds: List.unmodifiable(itemIds), queuedCount: queuedCount, completedCount: completedCount, failedCount: failedCount, activeDownloadsCount: activeDownloadsCount, + finalizingCount: finalizingCount, ); } @@ -4030,6 +4070,7 @@ class DownloadQueueLookup { var nextCompletedCount = completedCount; var nextFailedCount = failedCount; var nextActiveDownloadsCount = activeDownloadsCount; + var nextFinalizingCount = finalizingCount; Map? nextByItemId; Map? nextByTrackId; @@ -4066,17 +4107,27 @@ class DownloadQueueLookup { next: next.status, predicate: (status) => status == DownloadStatus.downloading, ); + nextFinalizingCount += _deltaForStatus( + previous: previous.status, + next: next.status, + predicate: (status) => status == DownloadStatus.finalizing, + ); } return DownloadQueueLookup._( - byTrackId: nextByTrackId ?? byTrackId, - byItemId: nextByItemId ?? byItemId, + byTrackId: nextByTrackId == null + ? byTrackId + : Map.unmodifiable(nextByTrackId), + byItemId: nextByItemId == null + ? byItemId + : Map.unmodifiable(nextByItemId), indexByItemId: indexByItemId, itemIds: itemIds, queuedCount: nextQueuedCount, completedCount: nextCompletedCount, failedCount: nextFailedCount, activeDownloadsCount: nextActiveDownloadsCount, + finalizingCount: nextFinalizingCount, ); } } diff --git a/lib/services/app_state_database.dart b/lib/services/app_state_database.dart index 471c251c..7cb23cd2 100644 --- a/lib/services/app_state_database.dart +++ b/lib/services/app_state_database.dart @@ -225,8 +225,8 @@ class AppStateDatabase { final db = await database; return db.query( _queueTable, - where: 'status = ? OR status = ?', - whereArgs: ['queued', 'downloading'], + where: 'status IN (?, ?, ?)', + whereArgs: ['queued', 'downloading', 'finalizing'], orderBy: 'created_at ASC, rowid ASC', ); } @@ -251,6 +251,28 @@ class AppStateDatabase { }); } + Future applyPendingDownloadQueueChanges({ + required List> upserts, + required List deletedIds, + }) async { + if (upserts.isEmpty && deletedIds.isEmpty) return; + final db = await database; + await db.transaction((txn) async { + final batch = txn.batch(); + for (final id in deletedIds) { + batch.delete(_queueTable, where: 'id = ?', whereArgs: [id]); + } + for (final row in upserts) { + batch.insert( + _queueTable, + row, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + await batch.commit(noResult: true); + }); + } + Future>> getRecentAccessRows({int? limit}) async { final db = await database; return db.query( diff --git a/test/models_and_utils_test.dart b/test/models_and_utils_test.dart index 486eecbe..da8e27a1 100644 --- a/test/models_and_utils_test.dart +++ b/test/models_and_utils_test.dart @@ -5,6 +5,7 @@ import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/models/theme_settings.dart'; import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; +import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/services/app_remote_config_service.dart'; import 'package:spotiflac_android/services/download_request_payload.dart'; import 'package:spotiflac_android/utils/artist_utils.dart'; @@ -220,6 +221,48 @@ void main() { }); }); + group('Download queue lookup', () { + test('updates counters while sharing unchanged index structures', () { + DownloadItem item(String id, DownloadStatus status) => DownloadItem( + id: id, + track: Track( + id: 'track-$id', + name: 'Song $id', + artistName: 'Artist', + albumName: 'Album', + duration: 1000, + ), + service: 'extension.test', + createdAt: DateTime.utc(2026), + status: status, + ); + + final previous = [ + item('queued', DownloadStatus.queued), + item('active', DownloadStatus.downloading), + item('finalizing', DownloadStatus.finalizing), + ]; + final lookup = DownloadQueueLookup.fromItems(previous); + expect(lookup.queuedCount, 3); + expect(lookup.activeDownloadsCount, 1); + expect(lookup.finalizingCount, 1); + + final next = List.from(previous); + next[2] = previous[2].copyWith(status: DownloadStatus.completed); + final updated = lookup.updatedForIndices( + previousItems: previous, + nextItems: next, + changedIndices: const [2], + ); + + expect(updated.queuedCount, 2); + expect(updated.completedCount, 1); + expect(updated.finalizingCount, 0); + expect(identical(updated.indexByItemId, lookup.indexByItemId), isTrue); + expect(identical(updated.itemIds, lookup.itemIds), isTrue); + }); + }); + group('AppSettings', () { test('provides stable defaults', () { const settings = AppSettings();