perf(history): coalesce index bumps and batch per-row exists lookups

This commit is contained in:
zarzet
2026-07-26 22:25:04 +07:00
parent 1cda88cafc
commit cbee637501
5 changed files with 69 additions and 21 deletions
+41 -10
View File
@@ -363,9 +363,13 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
bool _isAudioMetadataBackfillInProgress = false;
bool _startupMaintenanceScheduled = false;
Future<void> _historyWriteChain = Future<void>.value();
Timer? _indexBumpTimer;
DateTime? _lastIndexBumpAt;
static const _indexBumpWindow = Duration(seconds: 1);
@override
DownloadHistoryState build() {
ref.onDispose(() => _indexBumpTimer?.cancel());
_loadFromDatabaseSync();
return DownloadHistoryState();
}
@@ -1162,22 +1166,49 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
await _db.upsert(resolved.item.toJson());
_putResolvedHistoryInMemory(resolved.item, resolved.existingId);
}
int? persistedCount;
try {
persistedCount = await _db.getCount();
} catch (error) {
_historyLog.w('History saved but count refresh failed: $error');
}
state = state.copyWith(
totalCount: persistedCount ?? state.totalCount,
loadedIndexVersion: state.loadedIndexVersion + 1,
);
_scheduleIndexBump();
} catch (e, stack) {
_historyLog.e('Failed to $action: $e', e, stack);
rethrow;
}
}
/// Coalesces the post-persist count refresh + index bump to at most one per
/// [_indexBumpWindow]. Every bump invalidates the DB-derived views (queue
/// union queries, grouped counts, per-row exists checks) across all
/// keep-alive tabs, so per-item bumps during a batch fan out into repeated
/// full-table work.
void _scheduleIndexBump() {
if (_indexBumpTimer != null) return;
final now = DateTime.now();
final sinceLast = _lastIndexBumpAt == null
? _indexBumpWindow
: now.difference(_lastIndexBumpAt!);
if (sinceLast >= _indexBumpWindow) {
_lastIndexBumpAt = now;
unawaited(_bumpIndexNow());
return;
}
_indexBumpTimer = Timer(_indexBumpWindow - sinceLast, () {
_indexBumpTimer = null;
_lastIndexBumpAt = DateTime.now();
unawaited(_bumpIndexNow());
});
}
Future<void> _bumpIndexNow() async {
int? persistedCount;
try {
persistedCount = await _db.getCount();
} catch (error) {
_historyLog.w('History saved but count refresh failed: $error');
}
state = state.copyWith(
totalCount: persistedCount ?? state.totalCount,
loadedIndexVersion: state.loadedIndexVersion + 1,
);
}
DownloadHistoryItem _putInMemoryTrackVariant(DownloadHistoryItem item) {
final isReplacement = state.items.any((existing) => existing.id == item.id);
final items = [
+6 -4
View File
@@ -1031,14 +1031,16 @@ final localLibraryFirstCoverProvider = FutureProvider.autoDispose
ref.watch(
localLibraryProvider.select((state) => state.loadedIndexVersion),
);
for (final track in request.tracks) {
final cover = _nonEmptyCoverPath(
await LibraryDatabase.instance.findExisting(
final rows = await LibraryDatabase.instance.findExistingBatch([
for (final track in request.tracks)
LocalLibraryBatchLookupRequest(
isrc: track.isrc,
trackName: track.trackName,
artistName: track.artistName,
),
);
]);
for (final row in rows) {
final cover = _nonEmptyCoverPath(row);
if (cover != null) return cover;
}
return null;
+13
View File
@@ -2735,6 +2735,16 @@ class _HomeTabState extends ConsumerState<HomeTab>
}
if (sortedTracks.isNotEmpty) {
final historyLookups = sortedTracks
.map(historyLookupForTrack)
.toList(growable: false);
final existingHistoryKeys = ref
.watch(
downloadHistoryBatchExistsProvider(
HistoryBatchLookupRequest(historyLookups),
),
)
.maybeWhen(data: (keys) => keys, orElse: () => const <String>{});
slivers.addAll(
_buildVirtualizedResultSection(
title: context.l10n.searchSongs,
@@ -2753,6 +2763,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
searchExtensionId: searchExtensionId,
showLocalLibraryIndicator: showLocalLibraryIndicator,
thumbnailSizesByExtensionId: thumbnailSizesByExtensionId,
isInHistory: existingHistoryKeys.contains(
historyLookups[index].lookupKey,
),
),
),
);
+5 -5
View File
@@ -197,6 +197,10 @@ class _TrackItemWithStatus extends ConsumerWidget {
final bool showLocalLibraryIndicator;
final Map<String, (double, double)> thumbnailSizesByExtensionId;
/// Resolved by the result page via one batch lookup instead of a per-row
/// exists query (which in SAF mode also costs a bridge call per row).
final bool isInHistory;
const _TrackItemWithStatus({
super.key,
required this.track,
@@ -206,6 +210,7 @@ class _TrackItemWithStatus extends ConsumerWidget {
required this.searchExtensionId,
required this.showLocalLibraryIndicator,
required this.thumbnailSizesByExtensionId,
required this.isInHistory,
});
@override
@@ -218,11 +223,6 @@ class _TrackItemWithStatus extends ConsumerWidget {
),
);
final historyLookup = historyLookupForTrack(track);
final isInHistory = ref
.watch(downloadHistoryExistsProvider(historyLookup))
.maybeWhen(data: (exists) => exists, orElse: () => false);
final isInLocalLibrary = showLocalLibraryIndicator
? ref.watch(
localLibraryProvider.select(
+4 -2
View File
@@ -626,11 +626,13 @@ class HistoryDatabase {
String trackName,
String artistName,
) async {
final key = matchKeyFor(trackName, artistName);
if (key.isEmpty) return null;
final db = await database;
final rows = await db.query(
'history',
where: 'LOWER(track_name) = ? AND LOWER(artist_name) = ?',
whereArgs: [trackName.toLowerCase(), artistName.toLowerCase()],
where: 'match_key = ?',
whereArgs: [key],
orderBy: 'downloaded_at DESC',
limit: 1,
);