fix(history): serialize mutations and preserve newer file metadata

This commit is contained in:
zarzet committed 2026-10-06 21:59:53 +07:00
1 parent 1ad2b7a51d
commit cd313e1bfd
7 files changed
+642 -133

No files matched your search

@@ -1,4 +1,4 @@
part of 'download_history_provider.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
class DownloadHistoryItem {
final String id;
+150 -107
View File
@@ -3,9 +3,11 @@ import 'dart:math';
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/models/download_history.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/history_database.dart';
import 'package:spotiflac_android/services/history_maintenance.dart';
import 'package:spotiflac_android/utils/logger.dart' hide log;
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
@@ -14,7 +16,8 @@ import 'package:spotiflac_android/utils/int_utils.dart';
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
import 'package:spotiflac_android/utils/path_match_keys.dart';
part 'download_history_models.dart';
export 'package:spotiflac_android/models/download_history.dart';
part 'download_history_provider_maintenance.dart';
final _historyLog = AppLogger('DownloadHistory');
@@ -325,6 +328,25 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
return pending;
}
/// Background probes return sparse changes; this owner reconciles them
/// against the current stored record before publishing their result.
Future<void> applyMaintenanceUpdates(
Iterable<HistoryMaintenanceUpdate> updates,
) => _enqueueHistoryWrite(() async {
final merged = <DownloadHistoryItem>[];
for (final update in updates) {
final json = await _db.getById(update.original.id);
if (json == null) continue;
final item = update.mergeInto(DownloadHistoryItem.fromJson(json));
if (item != null) merged.add(item);
}
final persistedIds = await _db.updateExistingBatch(
merged.map((item) => item.toJson()).toList(growable: false),
);
if (!ref.mounted || persistedIds.isEmpty) return;
state = mergeHistoryMaintenanceUpdates(state, merged, persistedIds);
});
Future<void> _persistHistoryItem(
DownloadHistoryItem item,
String action, {
@@ -369,7 +391,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
});
}
Future<void> _bumpIndexNow() async {
Future<void> _bumpIndexNow() => _enqueueHistoryWrite(() async {
int? persistedCount;
try {
persistedCount = await _db.getCount();
@@ -380,7 +402,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
totalCount: persistedCount ?? state.totalCount,
loadedIndexVersion: state.loadedIndexVersion + 1,
);
}
});
DownloadHistoryItem _putInMemoryTrackVariant(DownloadHistoryItem item) {
final isReplacement = state.items.any((existing) => existing.id == item.id);
@@ -401,25 +423,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
return item;
}
void removeFromHistory(String id) {
state = state.copyWith(
items: state.items.where((item) => item.id != id).toList(),
totalCount: state.totalCount > 0
? state.totalCount - 1
: state.totalCount,
lookupItems: state.lookupItems
.where((item) => item.id != id)
.toList(growable: false),
);
_db
.deleteById(id)
.catchError((Object e) {
_historyLog.e('Failed to delete from database: $e');
})
.then((_) {
_bumpHistoryRevision();
});
}
Future<void> removeFromHistory(String id) => removeManyFromHistory([id]);
Future<void> removePhysicalFiles(Iterable<String> filePaths) {
final paths = filePaths.toList();
@@ -431,8 +435,11 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
}
Future<void> removeManyFromHistory(Iterable<String> ids) async {
final requestedIds = ids.toList(growable: false);
try {
await _removePersistedHistoryItems(ids);
await _enqueueHistoryWrite(
() => _removePersistedHistoryItems(requestedIds),
);
} catch (error, stack) {
_historyLog.e('Failed to delete from database: $error', error, stack);
}
@@ -564,7 +571,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
int? lyricsMetadataScanVersion,
bool? hasReplayGain,
int? replayGainMetadataScanVersion,
}) async {
}) => _enqueueHistoryWrite(() async {
final target = await _historyItemForUpdate(id);
if (target == null) {
_historyLog.w(
@@ -614,6 +621,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
return;
}
await _db.upsert(updated.toJson());
final updatedItems = target.index >= 0
? ([...state.items]..[target.index] = updated)
: state.items;
@@ -621,12 +629,11 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
items: updatedItems,
lookupItems: _lookupItemsWithUpdates([updated]),
);
await _db.upsert(updated.toJson());
// Swiping through older tracks can backfill several quality records in a
// short burst. Coalesce their DB-derived Library refreshes just like
// download completion writes instead of rebuilding every view per swipe.
_scheduleIndexBump();
}
});
Future<void> updateMetadataForItem({
required String id,
@@ -647,7 +654,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
bool? explicit,
bool? hasLyrics,
int? lyricsMetadataScanVersion,
}) async {
}) => _enqueueHistoryWrite(() async {
final target = await _historyItemForUpdate(id);
if (target == null) {
_historyLog.w('Cannot update metadata for missing history item: $id');
@@ -675,6 +682,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
lyricsMetadataScanVersion: lyricsMetadataScanVersion,
);
await _db.upsert(updated.toJson());
final updatedItems = target.index >= 0
? ([...state.items]..[target.index] = updated)
: state.items;
@@ -682,9 +690,8 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
items: updatedItems,
lookupItems: _lookupItemsWithUpdates([updated]),
);
await _db.upsert(updated.toJson());
_bumpHistoryRevision();
}
});
static const _audioExtensions = [
'.flac',
@@ -880,10 +887,9 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
return;
}
final deletedSet = deletedIds.toSet();
final updatedItems = <DownloadHistoryItem>[];
for (final item in state.items) {
DownloadHistoryItem? updateItem(DownloadHistoryItem item) {
if (deletedSet.contains(item.id)) {
continue;
return null;
}
final replacementPath = replacementPaths[item.id];
final replacementFileName = replacementFileNames[item.id];
@@ -894,28 +900,96 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
replacementFileName != item.safFileName) ||
(replacementRelativeDir != null &&
replacementRelativeDir != item.safRelativeDir))) {
updatedItems.add(
item.copyWith(
filePath: replacementPath,
safFileName: replacementFileName,
safRelativeDir: replacementRelativeDir,
),
return item.copyWith(
filePath: replacementPath,
safFileName: replacementFileName,
safRelativeDir: replacementRelativeDir,
);
} else {
updatedItems.add(item);
}
return item;
}
state = state.copyWith(
items: updatedItems,
items: state.items
.map(updateItem)
.whereType<DownloadHistoryItem>()
.toList(),
loadedIndexVersion: state.loadedIndexVersion + 1,
lookupItems: _lookupItemsWithUpdates(
updatedItems,
deletedIds: deletedSet,
),
lookupItems: state.lookupItems
.map(updateItem)
.whereType<DownloadHistoryItem>()
.toList(growable: false),
totalCount: max(0, state.totalCount - deletedSet.length),
);
}
Future<({int deleted, int repaired})> _commitInspectedHistoryChanges({
required List<Map<String, dynamic>> entries,
required List<String> orphanedIds,
required Map<String, String> replacementPaths,
required Map<String, String> replacementFileNames,
required Map<String, String> replacementRelativeDirs,
}) async {
var outcome = (deleted: 0, repaired: 0);
await _enqueueHistoryWrite(() async {
final byId = {for (final entry in entries) entry['id'] as String: entry};
final deletions = <Map<String, dynamic>>[];
final repairedPaths = <String, String>{};
final repairedNames = <String, String>{};
final repairedDirs = <String, String>{};
for (final id in {...orphanedIds, ...replacementPaths.keys}) {
final entry = byId[id];
final json = await _db.getById(id);
if (entry == null || json == null) continue;
final current = DownloadHistoryItem.fromJson(json);
final inspectedAt = DateTime.tryParse(
entry['downloaded_at']?.toString() ?? '',
);
if (current.filePath != entry['file_path'] ||
current.storageMode != entry['storage_mode'] ||
current.downloadTreeUri != entry['download_tree_uri'] ||
(inspectedAt != null &&
!current.downloadedAt.isAtSameMomentAs(inspectedAt))) {
continue;
}
final replacement = replacementPaths[id];
if (replacement == null) {
deletions.add(entry);
continue;
}
final name = current.safFileName == entry['saf_file_name']
? replacementFileNames[id]
: null;
final relativeDir = current.safRelativeDir == entry['saf_relative_dir']
? replacementRelativeDirs[id]
: null;
final repaired = await _db.updateFilePath(
id,
replacement,
newSafFileName: name,
newSafRelativeDir: relativeDir,
expectedFilePath: entry['file_path'] as String,
expectedDownloadedAt: entry['downloaded_at'] as String?,
);
if (!repaired) continue;
repairedPaths[id] = replacement;
if (name != null) repairedNames[id] = name;
if (relativeDir != null) repairedDirs[id] = relativeDir;
}
final deletedIds = deletions.isEmpty
? <String>[]
: await _db.deleteInspectedEntries(deletions);
_applyHistoryPathAndDeletionChanges(
deletedIds: deletedIds,
replacementPaths: repairedPaths,
replacementFileNames: repairedNames,
replacementRelativeDirs: repairedDirs,
);
outcome = (deleted: deletedIds.length, repaired: repairedPaths.length);
});
return outcome;
}
Future<int> _cleanupOrphanedDownloadsIncremental({
required int maxItems,
required SharedPreferences prefs,
@@ -955,22 +1029,13 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
}
}
for (final replacement in result.replacementPaths.entries) {
await _db.updateFilePath(
replacement.key,
replacement.value,
newSafFileName: result.replacementFileNames[replacement.key],
newSafRelativeDir: result.replacementRelativeDirs[replacement.key],
);
await prefs.remove('$_startupOrphanSuspectPrefix${replacement.key}');
}
final confirmedOrphanIds = decision.confirmedIds.toList(growable: false);
final deletedCount = confirmedOrphanIds.isEmpty
? 0
: await _db.deleteByIds(confirmedOrphanIds);
_applyHistoryPathAndDeletionChanges(
deletedIds: confirmedOrphanIds,
final committed = await _commitInspectedHistoryChanges(
entries: entries,
orphanedIds: confirmedOrphanIds,
replacementPaths: result.replacementPaths,
replacementFileNames: result.replacementFileNames,
replacementRelativeDirs: result.replacementRelativeDirs,
@@ -985,12 +1050,12 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
await prefs.setInt(_startupOrphanCursorKey, nextCursor);
}
if (deletedCount > 0 || result.replacementPaths.isNotEmpty) {
if (committed.deleted > 0 || committed.repaired > 0) {
_historyLog.i(
'Startup orphan cleanup pass: removed=$deletedCount, repaired=${result.replacementPaths.length}, checked=${entries.length}',
'Startup orphan cleanup pass: removed=${committed.deleted}, repaired=${committed.repaired}, checked=${entries.length}',
);
}
return deletedCount;
return committed.deleted;
}
Future<int> cleanupOrphanedDownloads() async {
@@ -999,6 +1064,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
final replacementPaths = <String, String>{};
final replacementFileNames = <String, String>{};
final replacementRelativeDirs = <String, String>{};
final inspectedEntries = <Map<String, dynamic>>[];
const pageSize = 256;
var offset = 0;
@@ -1011,6 +1077,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
break;
}
inspectedEntries.addAll(entries);
final result = await _inspectOrphanedEntries(entries);
orphanedIds.addAll(result.orphanedIds);
replacementPaths.addAll(result.replacementPaths);
@@ -1026,75 +1093,51 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
offset += entries.length;
}
for (final replacement in replacementPaths.entries) {
await _db.updateFilePath(
replacement.key,
replacement.value,
newSafFileName: replacementFileNames[replacement.key],
newSafRelativeDir: replacementRelativeDirs[replacement.key],
);
}
if (orphanedIds.isEmpty && replacementPaths.isEmpty) {
_historyLog.i('No orphaned entries found');
return 0;
}
final deletedCount = orphanedIds.isEmpty
? 0
: await _db.deleteByIds(orphanedIds);
_applyHistoryPathAndDeletionChanges(
deletedIds: orphanedIds,
final committed = await _commitInspectedHistoryChanges(
entries: inspectedEntries,
orphanedIds: orphanedIds,
replacementPaths: replacementPaths,
replacementFileNames: replacementFileNames,
replacementRelativeDirs: replacementRelativeDirs,
);
_historyLog.i(
'Cleaned up $deletedCount orphaned entries and repaired ${replacementPaths.length} paths',
'Cleaned up ${committed.deleted} orphaned entries and repaired ${committed.repaired} paths',
);
return deletedCount;
return committed.deleted;
}
void clearHistory() {
state = DownloadHistoryState(loadedIndexVersion: state.loadedIndexVersion);
_db
.clearAll()
.then((_) {
_bumpHistoryRevision();
})
.catchError((Object e) {
_historyLog.e('Failed to clear database: $e');
});
Future<void> clearHistory() async {
try {
await _enqueueHistoryWrite(() async {
await _db.clearAll();
state = DownloadHistoryState(
loadedIndexVersion: state.loadedIndexVersion + 1,
);
});
} catch (error, stack) {
_historyLog.e('Failed to clear database: $error', error, stack);
}
}
/// Replaces all download history with [items] (each in the
/// [DownloadHistoryItem.toJson] shape) from a restored backup, then reloads
/// the in-memory state from storage.
Future<void> restoreFromBackup(List<Map<String, dynamic>> items) async {
await _db.clearAll();
if (items.isNotEmpty) {
await _db.upsertBatch(items);
}
await reloadFromStorage();
}
Future<void> restoreFromBackup(List<Map<String, dynamic>> items) =>
restoreFromBackupStream(Stream.fromIterable(items));
/// Restores a large v2 backup without retaining the complete history in
/// Dart memory. SQLite writes are grouped to keep JNI/channel overhead low.
Future<void> restoreFromBackupStream(
Stream<Map<String, dynamic>> items,
) async {
await _db.clearAll();
var batch = <Map<String, dynamic>>[];
await for (final item in items) {
batch.add(item);
if (batch.length < 500) continue;
await _db.upsertBatch(batch);
batch = <Map<String, dynamic>>[];
}
if (batch.isNotEmpty) await _db.upsertBatch(batch);
await reloadFromStorage();
}
Future<void> restoreFromBackupStream(Stream<Map<String, dynamic>> items) =>
_enqueueHistoryWrite(() async {
await _db.replaceAll(items);
await _loadFromDatabase();
});
}
final downloadHistoryProvider =
@@ -4,17 +4,6 @@ part of 'download_history_provider.dart';
/// Startup maintenance: SAF repair, orphan cleanup, and audio-metadata
/// backfill that run staggered after the initial history load.
extension _HistoryStartupMaintenance on DownloadHistoryNotifier {
Future<void> _commitMaintenanceUpdates(List<Map<String, dynamic>> updates) =>
_enqueueHistoryWrite(() async {
final persistedIds = await _db.updateExistingBatch(updates);
if (!ref.mounted || persistedIds.isEmpty) return;
state = mergeHistoryMaintenanceUpdates(
state,
updates.map(DownloadHistoryItem.fromJson),
persistedIds,
);
});
void _scheduleStartupMaintenance(List<DownloadHistoryItem> initialItems) {
if (_startupMaintenanceScheduled) {
return;
@@ -170,7 +159,7 @@ extension _HistoryStartupMaintenance on DownloadHistoryNotifier {
return;
}
final persistedUpdates = <Map<String, dynamic>>[];
final persistedUpdates = <HistoryMaintenanceUpdate>[];
var changed = false;
var repairedCount = 0;
var verifiedCount = 0;
@@ -234,11 +223,13 @@ extension _HistoryStartupMaintenance on DownloadHistoryNotifier {
} else {
repairedCount++;
}
persistedUpdates.add(updated.toJson());
persistedUpdates.add(
HistoryMaintenanceUpdate(original: item, updated: updated),
);
}
if (changed) {
await _commitMaintenanceUpdates(persistedUpdates);
await applyMaintenanceUpdates(persistedUpdates);
_historyLog.i(
'SAF repair pass: verified=$verifiedCount, repaired=$repairedCount, checked=${selectedIndexes.length}',
);
@@ -562,7 +553,7 @@ extension _HistoryStartupMaintenance on DownloadHistoryNotifier {
return;
}
final persistedUpdates = <Map<String, dynamic>>[];
final persistedUpdates = <HistoryMaintenanceUpdate>[];
var refreshedCount = 0;
for (final index in selectedIndexes) {
@@ -693,12 +684,14 @@ extension _HistoryStartupMaintenance on DownloadHistoryNotifier {
hasReplayGain: resolvedHasReplayGain,
replayGainMetadataScanVersion: resolvedReplayGainScanVersion,
);
persistedUpdates.add(updated.toJson());
persistedUpdates.add(
HistoryMaintenanceUpdate(original: item, updated: updated),
);
refreshedCount++;
}
if (persistedUpdates.isNotEmpty) {
await _commitMaintenanceUpdates(persistedUpdates);
await applyMaintenanceUpdates(persistedUpdates);
}
await _writeStartupCursor(
+1 -1
View File
@@ -1,4 +1,4 @@
import 'package:spotiflac_android/providers/download_history_provider.dart';
import 'package:spotiflac_android/models/download_history.dart';
import 'package:spotiflac_android/services/history_database.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/audio_conversion_utils.dart';
+49 -3
View File
@@ -810,6 +810,9 @@ class HistoryDatabase {
});
}
Future<void> replaceAll(Stream<Map<String, dynamic>> items) async =>
replaceHistoryRows(await database, items.map(_jsonToDbRow));
Future<Set<String>> updateExistingBatch(
List<Map<String, dynamic>> items,
) async {
@@ -1155,7 +1158,7 @@ class HistoryDatabase {
_searchFtsAvailable = null;
}
Future<void> updateFilePath(
Future<bool> updateFilePath(
String id,
String newFilePath, {
String? newSafFileName,
@@ -1166,6 +1169,8 @@ class HistoryDatabase {
int? newBitrate,
String? newFormat,
bool clearAudioSpecs = false,
String? expectedFilePath,
String? expectedDownloadedAt,
}) async {
final db = await database;
final values = <String, dynamic>{'file_path': newFilePath};
@@ -1198,11 +1203,21 @@ class HistoryDatabase {
values['sample_rate'] = newSampleRate;
}
}
await db.transaction((txn) async {
await txn.update('history', values, where: 'id = ?', whereArgs: [id]);
return db.transaction((txn) async {
final changed = await txn.update(
'history',
values,
where:
'id = ?'
'${expectedFilePath == null ? '' : ' AND file_path = ?'}'
'${expectedDownloadedAt == null ? '' : ' AND downloaded_at = ?'}',
whereArgs: [id, ?expectedFilePath, ?expectedDownloadedAt],
);
if (changed == 0) return false;
final batch = txn.batch();
_putPathKeysInBatch(batch, id, newFilePath);
await batch.commit(noResult: true);
return true;
});
}
@@ -1259,6 +1274,7 @@ class HistoryDatabase {
'download_tree_uri',
'saf_relative_dir',
'saf_file_name',
'downloaded_at',
],
where: 'file_path IS NOT NULL AND file_path != ""',
orderBy: 'sort_added DESC, id DESC',
@@ -1268,6 +1284,36 @@ class HistoryDatabase {
return rows.map((r) => Map<String, dynamic>.from(r)).toList();
}
/// A delayed absence check must not delete a replacement downloaded after
/// the inspected snapshot, even when it reused the same ID and path.
Future<List<String>> deleteInspectedEntries(
List<Map<String, dynamic>> entries,
) async {
final db = await database;
return db.transaction((txn) async {
final deletedIds = <String>[];
for (final entry in entries) {
final id = entry['id'] as String;
final downloadedAt = entry['downloaded_at'] as String?;
final count = await txn.delete(
'history',
where:
'id = ? AND file_path = ?'
'${downloadedAt == null ? '' : ' AND downloaded_at = ?'}',
whereArgs: [id, entry['file_path'], ?downloadedAt],
);
if (count == 0) continue;
await txn.delete(
'history_path_keys',
where: 'item_id = ?',
whereArgs: [id],
);
deletedIds.add(id);
}
return deletedIds;
});
}
Future<int> deleteByIds(List<String> ids) async {
if (ids.isEmpty) return 0;
+71
View File
@@ -1,6 +1,77 @@
import 'package:sqflite/sqflite.dart';
import 'package:spotiflac_android/models/download_history.dart';
import 'package:spotiflac_android/services/sqlite_helpers.dart' as sqlite;
/// A delayed probe owns only fields it changed on the file it inspected.
/// Newer user edits and results for a replacement file remain authoritative.
class HistoryMaintenanceUpdate {
const HistoryMaintenanceUpdate({
required this.original,
required this.updated,
});
final DownloadHistoryItem original;
final DownloadHistoryItem updated;
DownloadHistoryItem? mergeInto(DownloadHistoryItem current) {
if (current.id != original.id ||
current.filePath != original.filePath ||
!current.downloadedAt.isAtSameMomentAs(original.downloadedAt)) {
return null;
}
final before = original.toJson();
final next = current.toJson();
final keepLyrics =
current.lyricsMetadataScanVersion != original.lyricsMetadataScanVersion;
final keepReplayGain =
current.replayGainMetadataScanVersion !=
original.replayGainMetadataScanVersion;
for (final field in updated.toJson().entries) {
if (field.value == before[field.key] ||
next[field.key] != before[field.key]) {
continue;
}
if (keepLyrics &&
(field.key == 'hasLyrics' ||
field.key == 'lyricsMetadataScanVersion')) {
continue;
}
if (keepReplayGain &&
(field.key == 'hasReplayGain' ||
field.key == 'replayGainMetadataScanVersion')) {
continue;
}
next[field.key] = field.value;
}
return DownloadHistoryItem.fromJson(next);
}
}
/// Streaming restore is atomic, including failures after a flushed batch.
Future<void> replaceHistoryRows(
Database db,
Stream<Map<String, dynamic>> rows,
) => db.transaction((txn) async {
await txn.delete('history_path_keys');
await txn.delete('history');
var batch = txn.batch();
var pending = 0;
await for (final row in rows) {
batch.insert('history', row, conflictAlgorithm: ConflictAlgorithm.replace);
sqlite.putPathKeysInBatch(
batch,
'history_path_keys',
row['id'] as String,
row['file_path'] as String?,
);
if (++pending < 500) continue;
await batch.commit(noResult: true);
batch = txn.batch();
pending = 0;
}
if (pending > 0) await batch.commit(noResult: true);
});
/// Called inside a transaction. Delayed metadata/SAF probes must never insert
/// rows deleted by orphan cleanup while the probe was running.
Future<Set<String>> updateExistingHistoryRows(
+360 -4
View File
@@ -10,6 +10,8 @@ import 'package:spotiflac_android/providers/download_history_provider.dart';
import 'package:spotiflac_android/services/history_database.dart';
import 'package:spotiflac_android/services/history_maintenance.dart';
import 'support/sqlite_process_database.dart';
class _ReloadDatabase implements HistoryDatabase {
_ReloadDatabase(this.rows);
@@ -45,13 +47,21 @@ class _ReloadDatabase implements HistoryDatabase {
return previousCount - rows.length;
}
@override
Future<void> clearAll() async {
if (failDeletion) throw StateError('database write failed');
rows.clear();
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _HistoryNotifier extends DownloadHistoryNotifier {
_HistoryNotifier(_ReloadDatabase database)
: _initialItems = database.rows.values.toList(),
_HistoryNotifier(
HistoryDatabase database,
Iterable<DownloadHistoryItem> items,
) : _initialItems = items.toList(),
super(database: database);
final List<DownloadHistoryItem> _initialItems;
@@ -63,6 +73,83 @@ class _HistoryNotifier extends DownloadHistoryNotifier {
);
}
class _MutationDatabase implements HistoryDatabase {
_MutationDatabase(DownloadHistoryItem item) : rows = {item.id: item};
final Map<String, DownloadHistoryItem> rows;
final writeStarted = Completer<void>();
final releaseWrite = Completer<void>();
bool failWrite = false;
@override
Future<Map<String, dynamic>?> getById(String id) async => rows[id]?.toJson();
@override
Future<void> upsert(Map<String, dynamic> json) async {
if (!writeStarted.isCompleted) writeStarted.complete();
await releaseWrite.future;
if (failWrite) throw StateError('database write failed');
final item = DownloadHistoryItem.fromJson(json);
rows[item.id] = item;
}
@override
Future<int> deleteByIds(List<String> ids) async {
final previousCount = rows.length;
rows.removeWhere((id, _) => ids.contains(id));
return previousCount - rows.length;
}
@override
Future<int> getCount() async => rows.length;
@override
Future<Set<String>> updateExistingBatch(
List<Map<String, dynamic>> items,
) async {
final changedIds = <String>{};
for (final json in items) {
final id = json['id'] as String;
if (!rows.containsKey(id)) continue;
rows[id] = DownloadHistoryItem.fromJson(json);
changedIds.add(id);
}
return changedIds;
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _InspectionDatabase extends _MutationDatabase {
_InspectionDatabase(super.item);
final snapshotRead = Completer<void>();
final releaseSnapshot = Completer<void>();
@override
Future<List<Map<String, dynamic>>> getEntriesWithPathsPage({
required int limit,
int offset = 0,
}) async {
final snapshot = [
for (final item in rows.values)
{
'id': item.id,
'file_path': item.filePath,
'storage_mode': item.storageMode,
'download_tree_uri': item.downloadTreeUri,
'saf_relative_dir': item.safRelativeDir,
'saf_file_name': item.safFileName,
'downloaded_at': item.downloadedAt.toUtc().toIso8601String(),
},
];
if (!snapshotRead.isCompleted) snapshotRead.complete();
await releaseSnapshot.future;
return snapshot;
}
}
class _HistoryRows implements DatabaseExecutor {
final statements = <Map<String, Object?>>[];
final _keys = _PathKeys();
@@ -146,7 +233,9 @@ void main() {
final db = _ReloadDatabase({item.id: item});
final container = ProviderContainer(
overrides: [
downloadHistoryProvider.overrideWith(() => _HistoryNotifier(db)),
downloadHistoryProvider.overrideWith(
() => _HistoryNotifier(db, db.rows.values),
),
],
);
addTearDown(container.dispose);
@@ -174,7 +263,9 @@ void main() {
final db = _ReloadDatabase({item.id: item})..failDeletion = true;
final container = ProviderContainer(
overrides: [
downloadHistoryProvider.overrideWith(() => _HistoryNotifier(db)),
downloadHistoryProvider.overrideWith(
() => _HistoryNotifier(db, db.rows.values),
),
],
);
addTearDown(container.dispose);
@@ -190,6 +281,271 @@ void main() {
},
);
for (final deletion in ['single', 'many', 'clear']) {
Future<void> remove(DownloadHistoryNotifier notifier, String id) =>
switch (deletion) {
'single' => notifier.removeFromHistory(id),
'many' => notifier.removeManyFromHistory([id]),
_ => notifier.clearHistory(),
};
test('$deletion deletion waits for an older reload', () async {
final item = _track('removed');
final db = _ReloadDatabase({item.id: item});
final container = ProviderContainer(
overrides: [
downloadHistoryProvider.overrideWith(
() => _HistoryNotifier(db, db.rows.values),
),
],
);
addTearDown(container.dispose);
final notifier = container.read(downloadHistoryProvider.notifier);
final reload = notifier.reloadFromStorage();
await db.snapshotRead.future;
final deletion = remove(notifier, item.id);
await Future<void>.delayed(Duration.zero);
expect(db.rows.keys, [item.id]);
db.count.complete(1);
await Future.wait([reload, deletion]);
final state = container.read(downloadHistoryProvider);
expect(db.rows, isEmpty);
expect(state.items, isEmpty);
expect(state.lookupItems, isEmpty);
expect(state.totalCount, 0);
});
test('failed $deletion deletion keeps the visible record', () async {
final item = _track('retained');
final db = _ReloadDatabase({item.id: item})..failDeletion = true;
final container = ProviderContainer(
overrides: [
downloadHistoryProvider.overrideWith(
() => _HistoryNotifier(db, db.rows.values),
),
],
);
addTearDown(container.dispose);
await remove(container.read(downloadHistoryProvider.notifier), item.id);
final state = container.read(downloadHistoryProvider);
expect(db.rows.keys, [item.id]);
expect(state.items, [item]);
expect(state.totalCount, 1);
expect(state.loadedIndexVersion, 0);
});
}
for (final audioOnly in [false, true]) {
Future<void> update(DownloadHistoryNotifier notifier, String id) =>
audioOnly
? notifier.updateAudioMetadataForItem(id: id, quality: '24-bit/96kHz')
: notifier.updateMetadataForItem(
id: id,
trackName: 'Updated',
artistName: 'Artist',
albumName: 'Album',
);
test('failed metadata save keeps state (audio: $audioOnly)', () async {
final item = _track('retained');
final db = _MutationDatabase(item)..failWrite = true;
db.releaseWrite.complete();
final container = ProviderContainer(
overrides: [
downloadHistoryProvider.overrideWith(
() => _HistoryNotifier(db, db.rows.values),
),
],
);
addTearDown(container.dispose);
await expectLater(
update(container.read(downloadHistoryProvider.notifier), item.id),
throwsStateError,
);
final state = container.read(downloadHistoryProvider);
expect(db.rows.values, [item]);
expect(state.items, [item]);
expect(state.lookupItems, [item]);
expect(state.loadedIndexVersion, 0);
});
test(
'metadata cannot revive a deleted record (audio: $audioOnly)',
() async {
final item = _track('removed');
final db = _MutationDatabase(item);
final container = ProviderContainer(
overrides: [
downloadHistoryProvider.overrideWith(
() => _HistoryNotifier(db, db.rows.values),
),
],
);
addTearDown(container.dispose);
final notifier = container.read(downloadHistoryProvider.notifier);
final updating = update(notifier, item.id);
await db.writeStarted.future;
final deleting = notifier.removeFromHistory(item.id);
await Future<void>.delayed(Duration.zero);
expect(db.rows.keys, [item.id]);
expect(container.read(downloadHistoryProvider).items, [item]);
db.releaseWrite.complete();
await Future.wait([updating, deleting]);
final state = container.read(downloadHistoryProvider);
expect(db.rows, isEmpty);
expect(state.items, isEmpty);
expect(state.lookupItems, isEmpty);
expect(state.totalCount, 0);
},
);
}
test('delayed maintenance preserves newer metadata edits', () async {
final original = _track('kept').copyWith(composer: 'Old composer');
final db = _MutationDatabase(original);
db.releaseWrite.complete();
final container = ProviderContainer(
overrides: [
downloadHistoryProvider.overrideWith(
() => _HistoryNotifier(db, db.rows.values),
),
],
);
addTearDown(container.dispose);
final notifier = container.read(downloadHistoryProvider.notifier);
await notifier.updateMetadataForItem(
id: original.id,
trackName: 'User title',
artistName: original.artistName,
albumName: original.albumName,
composer: 'User composer',
);
await notifier.applyMaintenanceUpdates([
HistoryMaintenanceUpdate(
original: original,
updated: original.copyWith(
quality: '24-bit/96kHz',
composer: 'Probed composer',
filePath: '/music/repaired.flac',
),
),
]);
final stored = db.rows[original.id]!;
final visible = container.read(downloadHistoryProvider).items.single;
for (final item in [stored, visible]) {
expect(item.trackName, 'User title');
expect(item.composer, 'User composer');
expect(item.quality, '24-bit/96kHz');
expect(item.filePath, '/music/repaired.flac');
}
});
test('newer tag scan results survive an older metadata probe', () {
final original = _track('kept');
final current = original.copyWith(
lyricsMetadataScanVersion: 2,
replayGainMetadataScanVersion: 2,
);
final merged = HistoryMaintenanceUpdate(
original: original,
updated: original.copyWith(
hasLyrics: true,
lyricsMetadataScanVersion: 1,
hasReplayGain: true,
replayGainMetadataScanVersion: 1,
),
).mergeInto(current)!;
expect(merged.hasLyrics, isFalse);
expect(merged.hasReplayGain, isFalse);
expect(merged.lyricsMetadataScanVersion, 2);
expect(merged.replayGainMetadataScanVersion, 2);
});
for (final replacedPath in [false, true]) {
test('old orphan inspection keeps a replacement ($replacedPath)', () async {
final original = _track('replaced');
final db = _InspectionDatabase(original);
db.releaseWrite.complete();
final container = ProviderContainer(
overrides: [
downloadHistoryProvider.overrideWith(
() => _HistoryNotifier(db, db.rows.values),
),
],
);
addTearDown(container.dispose);
final notifier = container.read(downloadHistoryProvider.notifier);
final cleanup = notifier.cleanupOrphanedDownloads();
await db.snapshotRead.future;
final replacement = DownloadHistoryItem.fromJson({
...original.toJson(),
'filePath': replacedPath ? '/music/new-file.flac' : original.filePath,
'downloadedAt': original.downloadedAt
.add(const Duration(days: 1))
.toUtc()
.toIso8601String(),
});
await notifier.addToHistory(replacement);
db.releaseSnapshot.complete();
expect(await cleanup, 0);
expect(db.rows[original.id]!.filePath, replacement.filePath);
expect(
container.read(downloadHistoryProvider).items.single.filePath,
replacement.filePath,
);
expect(container.read(downloadHistoryProvider).totalCount, 1);
});
}
test('atomic streaming restore rolls back a late stream failure', () async {
final db = await SqliteProcessDatabase.open();
addTearDown(db.close);
await db.execute(
'CREATE TABLE history(id TEXT PRIMARY KEY, file_path TEXT NOT NULL)',
);
await db.execute(
'CREATE TABLE history_path_keys(item_id TEXT, path_key TEXT, '
'PRIMARY KEY(item_id, path_key))',
);
await db.insert('history', {
'id': 'previous',
'file_path': '/music/old.flac',
});
await db.insert('history_path_keys', {
'item_id': 'previous',
'path_key': 'old-key',
});
Stream<Map<String, dynamic>> brokenBackup() async* {
for (var i = 0; i < 501; i++) {
yield {'id': '$i', 'file_path': '/music/$i.flac'};
}
throw const FormatException('truncated backup');
}
await expectLater(
replaceHistoryRows(db, brokenBackup()),
throwsFormatException,
);
expect(await db.query('history'), [
{'id': 'previous', 'file_path': '/music/old.flac'},
]);
expect(await db.query('history_path_keys'), [
{'item_id': 'previous', 'path_key': 'old-key'},
]);
await replaceHistoryRows(
db,
Stream.fromIterable([
{'id': 'restored', 'file_path': '/music/restored.flac'},
]),
);
expect(await db.query('history'), [
{'id': 'restored', 'file_path': '/music/restored.flac'},
]);
final keys = await db.query('history_path_keys');
expect(keys, isNotEmpty);
expect(keys.every((row) => row['item_id'] == 'restored'), isTrue);
});
test(
'late maintenance cannot recreate deleted rows or their path keys',
() async {