From b15bb2673cb8b9ce34adf70a35444fded84b63bf Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:13:17 +0700 Subject: [PATCH] fix(library): index native completions and refresh background downloads --- lib/main.dart | 7 ++ lib/providers/download_history_provider.dart | 10 ++- lib/providers/download_queue_provider.dart | 1 + ...download_queue_provider_native_worker.dart | 42 +++------- lib/services/native_download_history.dart | 26 ++++++ test/native_download_history_test.dart | 81 +++++++++++++++++++ 6 files changed, 132 insertions(+), 35 deletions(-) create mode 100644 lib/services/native_download_history.dart create mode 100644 test/native_download_history_test.dart diff --git a/lib/main.dart b/lib/main.dart index 2ccf3f4a..9e266643 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -394,6 +394,13 @@ class _EagerInitializationState extends ConsumerState if (state == AppLifecycleState.resumed) { unawaited(_consumeVerificationNotification()); CoverCacheManager.scheduleMaintenance(); + // Native downloads can finish while Flutter is suspended. Refresh the + // persisted history even when no queued item remains to reconcile. + if (ref.exists(downloadHistoryProvider)) { + unawaited( + ref.read(downloadHistoryProvider.notifier).reloadFromStorage(), + ); + } _maybeAutoScanLocalLibrary(); if (ref.exists(localLibraryProvider)) { unawaited( diff --git a/lib/providers/download_history_provider.dart b/lib/providers/download_history_provider.dart index caa2deab..b4fc3136 100644 --- a/lib/providers/download_history_provider.dart +++ b/lib/providers/download_history_provider.dart @@ -82,6 +82,7 @@ class DownloadHistoryNotifier extends Notifier { bool _isAudioMetadataBackfillInProgress = false; bool _startupMaintenanceScheduled = false; Future _historyWriteChain = Future.value(); + Future? _reloadFuture; Timer? _indexBumpTimer; DateTime? _lastIndexBumpAt; static const _indexBumpWindow = Duration(seconds: 1); @@ -97,7 +98,7 @@ class DownloadHistoryNotifier extends Notifier { if (_isLoaded) return; _isLoaded = true; Future.microtask(() async { - await _loadFromDatabase(); + await reloadFromStorage(); }); } @@ -137,9 +138,10 @@ class DownloadHistoryNotifier extends Notifier { } } - Future reloadFromStorage() async { - await _loadFromDatabase(); - } + Future reloadFromStorage() => _reloadFuture ??= + _enqueueHistoryWrite(_loadFromDatabase).whenComplete(() { + _reloadFuture = null; + }); void _bumpHistoryRevision() { state = state.copyWith(loadedIndexVersion: state.loadedIndexVersion + 1); diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index a2210548..e0858a0f 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -43,6 +43,7 @@ import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/utils/progress_stream_poller.dart'; import 'package:spotiflac_android/providers/download_history_provider.dart'; +import 'package:spotiflac_android/services/native_download_history.dart'; export 'package:spotiflac_android/providers/download_history_provider.dart'; export 'package:spotiflac_android/providers/download_queue_state.dart'; diff --git a/lib/providers/download_queue_provider_native_worker.dart b/lib/providers/download_queue_provider_native_worker.dart index d64a064a..f7158158 100644 --- a/lib/providers/download_queue_provider_native_worker.dart +++ b/lib/providers/download_queue_provider_native_worker.dart @@ -1261,40 +1261,20 @@ extension _DownloadQueueNativeWorker on DownloadQueueNotifier { await persistBeforePublishingDownloadCompletion( persist: () async { if (!settings.saveDownloadHistory) return; - final historyItem = result['history_item']; - if (historyItem is Map) { - try { - await ref - .read(downloadHistoryProvider.notifier) - .adoptNativeHistoryItem( - DownloadHistoryItem.fromJson( - Map.from(historyItem), - ), - preserveTrackVariant: item.preserveQualityVariant, - ); - } catch (e) { - _log.w('Failed to adopt native history item: $e'); - await _persistNativeFinalizedHistoryFallback( - context, - result, - nativeFinalizedFilePath, - ); - } - } else if (result['history_written'] == true || - result['already_exists'] == true) { - await ref - .read(downloadHistoryProvider.notifier) - .reloadFromStorage(); - } else { - _log.w( - 'Native finalizer completed without history; persisting Dart fallback', - ); - await _persistNativeFinalizedHistoryFallback( + final history = ref.read(downloadHistoryProvider.notifier); + await reconcileNativeDownloadHistory( + result: result, + adopt: (json) => history.adoptNativeHistoryItem( + DownloadHistoryItem.fromJson(json), + preserveTrackVariant: item.preserveQualityVariant, + ), + reload: history.reloadFromStorage, + persistFallback: () => _persistNativeFinalizedHistoryFallback( context, result, nativeFinalizedFilePath, - ); - } + ), + ); }, publish: () { _completedInSession++; diff --git a/lib/services/native_download_history.dart b/lib/services/native_download_history.dart new file mode 100644 index 00000000..1856cdd6 --- /dev/null +++ b/lib/services/native_download_history.dart @@ -0,0 +1,26 @@ +import 'package:spotiflac_android/utils/logger.dart'; + +/// A completed file and a persisted Library entry are separate guarantees. +/// In particular, `already_exists` does not mean history was ever saved. +Future reconcileNativeDownloadHistory({ + required Map result, + required Future Function(Map) adopt, + required Future Function() reload, + required Future Function() persistFallback, +}) async { + final historyItem = result['history_item']; + if (historyItem is Map) { + try { + await adopt(Map.from(historyItem)); + return; + } catch (error) { + AppLogger('DownloadHistory').w( + 'Failed to adopt native history item, retrying from download result: $error', + ); + } + } else if (result['history_written'] == true) { + await reload(); + return; + } + await persistFallback(); +} diff --git a/test/native_download_history_test.dart b/test/native_download_history_test.dart new file mode 100644 index 00000000..2135775b --- /dev/null +++ b/test/native_download_history_test.dart @@ -0,0 +1,81 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/providers/download_queue_provider.dart'; +import 'package:spotiflac_android/services/native_download_history.dart'; + +void main() { + for (final alreadyExists in [false, true]) { + test( + 'unindexed completed file is persisted before publication (exists: $alreadyExists)', + () async { + final saved = Completer(); + final events = []; + final completion = persistBeforePublishingDownloadCompletion( + persist: () => reconcileNativeDownloadHistory( + result: {'already_exists': alreadyExists, 'history_written': false}, + adopt: (_) async => fail('No history item was supplied'), + reload: () async => fail('Reload cannot index an unpersisted file'), + persistFallback: () async { + events.add('persist'); + await saved.future; + }, + ), + publish: () => events.add('publish'), + ); + await Future.delayed(Duration.zero); + expect(events, ['persist']); + saved.complete(); + await completion; + expect(events, ['persist', 'publish']); + }, + ); + } + + test( + 'native history is adopted without a second fallback insertion', + () async { + await reconcileNativeDownloadHistory( + result: { + 'history_item': {'id': 'native-row'}, + 'history_written': true, + }, + adopt: (json) async => expect(json['id'], 'native-row'), + reload: () async => fail('Native row should be adopted directly'), + persistFallback: () async => fail('No duplicate fallback'), + ); + }, + ); + + test('confirmed native write reloads the Library', () async { + var refreshed = false; + await reconcileNativeDownloadHistory( + result: {'history_written': true}, + adopt: (_) async => fail('No row supplied'), + reload: () async => refreshed = true, + persistFallback: () async => fail('Native history already persisted'), + ); + expect(refreshed, isTrue); + }); + + test( + 'bad native history falls back and persistence errors prevent completion', + () async { + var published = false; + await expectLater( + persistBeforePublishingDownloadCompletion( + persist: () => reconcileNativeDownloadHistory( + result: {'history_item': {}}, + adopt: (_) async => throw const FormatException('incomplete row'), + reload: () async => fail('Unconfirmed history write'), + persistFallback: () async => + throw StateError('storage unavailable'), + ), + publish: () => published = true, + ), + throwsStateError, + ); + expect(published, isFalse); + }, + ); +}