fix(library): preserve downloaded file records

This commit is contained in:
zarzet
2026-07-23 17:40:09 +07:00
parent 3f487c03cc
commit c35b11a155
3 changed files with 345 additions and 46 deletions
+187 -46
View File
@@ -11,9 +11,39 @@ import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/utils/audio_format_utils.dart';
import 'package:spotiflac_android/utils/int_utils.dart';
import 'package:spotiflac_android/utils/path_match_keys.dart';
final _historyLog = AppLogger('DownloadHistory');
typedef StartupOrphanDecision = ({
Set<String> confirmedIds,
Set<String> pendingIds,
});
StartupOrphanDecision reconcileStartupOrphanSuspects({
required Set<String> checkedIds,
required Set<String> missingIds,
required Set<String> previousSuspectIds,
}) {
final currentMissing = missingIds.intersection(checkedIds);
return (
confirmedIds: currentMissing.intersection(previousSuspectIds),
pendingIds: currentMissing.difference(previousSuspectIds),
);
}
bool historyItemsReferToSameStoredFile(
DownloadHistoryItem first,
DownloadHistoryItem second,
) {
if (first.id == second.id) return true;
final firstKeys = buildPathMatchKeys(first.filePath);
if (firstKeys.isEmpty) return false;
return buildPathMatchKeys(
second.filePath,
).any((key) => firstKeys.contains(key));
}
class DownloadHistoryItem {
final String id;
final String trackName;
@@ -216,6 +246,20 @@ class DownloadHistoryItem {
}
}
Map<String, DownloadHistoryItem> _indexRecentHistoryItems(
Iterable<DownloadHistoryItem> items,
String? Function(DownloadHistoryItem item) keyOf,
) {
final index = <String, DownloadHistoryItem>{};
for (final item in items) {
final key = keyOf(item);
if (key != null && key.isNotEmpty) {
index.putIfAbsent(key, () => item);
}
}
return index;
}
class DownloadHistoryState {
final List<DownloadHistoryItem> items;
final int totalCount;
@@ -231,27 +275,17 @@ class DownloadHistoryState {
this.loadedIndexVersion = 0,
List<DownloadHistoryItem>? lookupItems,
}) : _lookupItems = List.unmodifiable(lookupItems ?? items),
_bySpotifyId = Map.fromEntries(
(lookupItems ?? items)
.where(
(item) => item.spotifyId != null && item.spotifyId!.isNotEmpty,
)
.map((item) => MapEntry(item.spotifyId!, item)),
_bySpotifyId = _indexRecentHistoryItems(
lookupItems ?? items,
(item) => item.spotifyId,
),
_byIsrc = Map.fromEntries(
(lookupItems ?? items)
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
.map((item) => MapEntry(item.isrc!, item)),
_byIsrc = _indexRecentHistoryItems(
lookupItems ?? items,
(item) => item.isrc,
),
_byTrackArtistKey = Map.fromEntries(
(lookupItems ?? items)
.map(
(item) => MapEntry(
_trackArtistKey(item.trackName, item.artistName),
item,
),
)
.where((entry) => entry.key.isNotEmpty),
_byTrackArtistKey = _indexRecentHistoryItems(
lookupItems ?? items,
(item) => _trackArtistKey(item.trackName, item.artistName),
);
static String _trackArtistKey(String trackName, String artistName) {
@@ -306,6 +340,8 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
'history_startup_saf_repair_cursor_v1';
static const _startupOrphanCursorKey = 'history_startup_orphan_cursor_v1';
static const _startupOrphanSuspectPrefix =
'history_startup_orphan_suspect_v2_';
static const _legacyStartupOrphanSuspectPrefix =
'history_startup_orphan_suspect_v1_';
static const _startupAudioCursorKey = 'history_startup_audio_cursor_v1';
static const _audioProbeFailedPathsKey =
@@ -971,26 +1007,22 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
DownloadHistoryItem item,
) async {
DownloadHistoryItem? existing;
if (item.spotifyId != null && item.spotifyId!.isNotEmpty) {
existing = state.getBySpotifyId(item.spotifyId!);
}
if (existing == null && item.isrc != null && item.isrc!.isNotEmpty) {
existing = state.getByIsrc(item.isrc!);
for (final candidate in state.lookupItems) {
if (historyItemsReferToSameStoredFile(candidate, item)) {
existing = candidate;
break;
}
}
if (existing == null) {
final json = await _db.findExisting(
spotifyId: item.spotifyId,
isrc: item.isrc,
);
final json = await _db.getById(item.id);
if (json != null) {
existing = DownloadHistoryItem.fromJson(json);
}
}
if (existing == null) {
final json = await _db.findByTrackAndArtist(
item.trackName,
item.artistName,
);
final json = await _db.findByFilePath(item.filePath);
if (json != null) {
existing = DownloadHistoryItem.fromJson(json);
}
@@ -1457,12 +1489,16 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
({
List<String> orphanedIds,
Map<String, String> replacementPaths,
Map<String, String> replacementFileNames,
Map<String, String> replacementRelativeDirs,
Map<String, String> pathById,
})
>
_inspectOrphanedEntries(List<Map<String, dynamic>> entries) async {
final orphanedIds = <String>[];
final replacementPaths = <String, String>{};
final replacementFileNames = <String, String>{};
final replacementRelativeDirs = <String, String>{};
final pathById = <String, String>{};
const checkChunkSize = 16;
@@ -1472,7 +1508,7 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
: entries.length;
final chunk = entries.sublist(i, end);
final checks = await Future.wait<MapEntry<String, bool>?>(
final checks = await Future.wait<MapEntry<String, bool?>?>(
chunk.map((entry) async {
final id = entry['id'] as String;
final filePath = entry['file_path'] as String?;
@@ -1481,6 +1517,63 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
try {
if (await fileExists(filePath)) return MapEntry(id, true);
if (entry['storage_mode'] == 'saf') {
final treeUri = (entry['download_tree_uri'] as String? ?? '')
.trim();
var fileName = (entry['saf_file_name'] as String? ?? '').trim();
if (fileName.isEmpty && isContentUri(filePath)) {
fileName = _fileNameFromUri(filePath);
}
if (treeUri.isEmpty || fileName.isEmpty) {
return MapEntry(id, null);
}
bool treeAccessible;
try {
treeAccessible = await PlatformBridge.validateSafTreeAccess(
treeUri,
);
} catch (error) {
_historyLog.w(
'Unable to verify SAF tree while checking $id: $error',
);
return MapEntry(id, null);
}
if (!treeAccessible) {
return MapEntry(id, null);
}
for (final candidate in _conversionRenameCandidates(
fileName,
includeAlternateExtensions: true,
)) {
try {
final resolved = await PlatformBridge.resolveSafFile(
treeUri: treeUri,
relativeDir: entry['saf_relative_dir'] as String? ?? '',
fileName: candidate,
);
final uri = (resolved['uri'] as String? ?? '').trim();
if (uri.isEmpty) continue;
replacementPaths[id] = uri;
replacementFileNames[id] = candidate;
final relativeDir =
(resolved['relative_dir'] as String? ?? '').trim();
if (relativeDir.isNotEmpty) {
replacementRelativeDirs[id] = relativeDir;
}
pathById[id] = uri;
return MapEntry(id, true);
} catch (error) {
_historyLog.w(
'Unable to resolve SAF file while checking $id: $error',
);
return MapEntry(id, null);
}
}
return MapEntry(id, false);
}
final sibling = await _findConvertedSibling(filePath);
if (sibling != null) {
_historyLog.i(
@@ -1494,13 +1587,13 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
return MapEntry(id, false);
} catch (e) {
_historyLog.w('Error checking file existence for $id: $e');
return MapEntry(id, false);
return MapEntry(id, null);
}
}),
);
for (final check in checks) {
if (check == null || check.value) continue;
if (check == null || check.value != false) continue;
orphanedIds.add(check.key);
_historyLog.d(
'Found orphaned entry: ${check.key} (${pathById[check.key] ?? ''})',
@@ -1511,6 +1604,8 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
return (
orphanedIds: orphanedIds,
replacementPaths: replacementPaths,
replacementFileNames: replacementFileNames,
replacementRelativeDirs: replacementRelativeDirs,
pathById: pathById,
);
}
@@ -1518,6 +1613,8 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
void _applyHistoryPathAndDeletionChanges({
required List<String> deletedIds,
required Map<String, String> replacementPaths,
Map<String, String> replacementFileNames = const {},
Map<String, String> replacementRelativeDirs = const {},
}) {
if (deletedIds.isEmpty && replacementPaths.isEmpty) {
return;
@@ -1529,8 +1626,21 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
continue;
}
final replacementPath = replacementPaths[item.id];
if (replacementPath != null && replacementPath != item.filePath) {
updatedItems.add(item.copyWith(filePath: replacementPath));
final replacementFileName = replacementFileNames[item.id];
final replacementRelativeDir = replacementRelativeDirs[item.id];
if (replacementPath != null &&
(replacementPath != item.filePath ||
(replacementFileName != null &&
replacementFileName != item.safFileName) ||
(replacementRelativeDir != null &&
replacementRelativeDir != item.safRelativeDir))) {
updatedItems.add(
item.copyWith(
filePath: replacementPath,
safFileName: replacementFileName,
safRelativeDir: replacementRelativeDir,
),
);
} else {
updatedItems.add(item);
}
@@ -1562,24 +1672,39 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
}
final result = await _inspectOrphanedEntries(entries);
final confirmedOrphanIds = <String>[];
for (final id in result.orphanedIds) {
final checkedIds = result.pathById.keys.toSet();
final previousSuspectIds = <String>{
for (final id in checkedIds)
if (prefs.getBool('$_startupOrphanSuspectPrefix$id') == true) id,
};
final decision = reconcileStartupOrphanSuspects(
checkedIds: checkedIds,
missingIds: result.orphanedIds.toSet(),
previousSuspectIds: previousSuspectIds,
);
for (final id in checkedIds) {
final key = '$_startupOrphanSuspectPrefix$id';
if (prefs.getBool(key) == true) {
confirmedOrphanIds.add(id);
await prefs.remove(key);
} else {
await prefs.remove('$_legacyStartupOrphanSuspectPrefix$id');
if (decision.pendingIds.contains(id)) {
await prefs.setBool(key, true);
_historyLog.d(
'Deferring orphan removal until next pass: $id (${result.pathById[id] ?? ''})',
);
} else {
await prefs.remove(key);
}
}
for (final replacement in result.replacementPaths.entries) {
await _db.updateFilePath(replacement.key, replacement.value);
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);
@@ -1587,6 +1712,8 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
_applyHistoryPathAndDeletionChanges(
deletedIds: confirmedOrphanIds,
replacementPaths: result.replacementPaths,
replacementFileNames: result.replacementFileNames,
replacementRelativeDirs: result.replacementRelativeDirs,
);
if (entries.length < maxItems) {
@@ -1610,6 +1737,8 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
_historyLog.i('Starting orphaned downloads cleanup...');
final orphanedIds = <String>[];
final replacementPaths = <String, String>{};
final replacementFileNames = <String, String>{};
final replacementRelativeDirs = <String, String>{};
const pageSize = 256;
var offset = 0;
@@ -1625,15 +1754,25 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
final result = await _inspectOrphanedEntries(entries);
orphanedIds.addAll(result.orphanedIds);
replacementPaths.addAll(result.replacementPaths);
replacementFileNames.addAll(result.replacementFileNames);
replacementRelativeDirs.addAll(result.replacementRelativeDirs);
if (entries.length < pageSize) {
break;
}
offset += entries.length - result.orphanedIds.length;
// Deletions are applied only after inspection finishes, so advance by
// the full page. Subtracting missing rows here can repeatedly fetch the
// same page forever when an entire page is orphaned.
offset += entries.length;
}
for (final replacement in replacementPaths.entries) {
await _db.updateFilePath(replacement.key, replacement.value);
await _db.updateFilePath(
replacement.key,
replacement.value,
newSafFileName: replacementFileNames[replacement.key],
newSafRelativeDir: replacementRelativeDirs[replacement.key],
);
}
if (orphanedIds.isEmpty && replacementPaths.isEmpty) {
@@ -1647,6 +1786,8 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
_applyHistoryPathAndDeletionChanges(
deletedIds: orphanedIds,
replacementPaths: replacementPaths,
replacementFileNames: replacementFileNames,
replacementRelativeDirs: replacementRelativeDirs,
);
_historyLog.i(
+25
View File
@@ -5,6 +5,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/services/sqlite_helpers.dart' as sqlite;
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/utils/path_match_keys.dart';
final _log = AppLogger('HistoryDatabase');
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
@@ -648,12 +649,31 @@ class HistoryDatabase {
return _dbRowToJson(rows.first);
}
Future<Map<String, dynamic>?> findByFilePath(String filePath) async {
final pathKeys = buildPathMatchKeys(filePath).toList(growable: false);
if (pathKeys.isEmpty) return null;
final db = await database;
final placeholders = List.filled(pathKeys.length, '?').join(',');
final rows = await db.rawQuery('''
SELECT h.*
FROM history h
JOIN history_path_keys hpk ON hpk.item_id = h.id
WHERE hpk.path_key IN ($placeholders)
ORDER BY h.downloaded_at DESC
LIMIT 1
''', pathKeys);
if (rows.isEmpty) return null;
return _dbRowToJson(rows.first);
}
Future<Map<String, dynamic>?> getBySpotifyId(String spotifyId) async {
final db = await database;
final rows = await db.query(
'history',
where: 'spotify_id = ?',
whereArgs: [spotifyId],
orderBy: 'downloaded_at DESC',
limit: 1,
);
if (rows.isEmpty) return null;
@@ -666,6 +686,7 @@ class HistoryDatabase {
'history',
where: 'isrc = ?',
whereArgs: [isrc],
orderBy: 'downloaded_at DESC',
limit: 1,
);
if (rows.isEmpty) return null;
@@ -971,6 +992,7 @@ class HistoryDatabase {
String id,
String newFilePath, {
String? newSafFileName,
String? newSafRelativeDir,
String? newQuality,
int? newBitDepth,
int? newSampleRate,
@@ -983,6 +1005,9 @@ class HistoryDatabase {
if (newSafFileName != null) {
values['saf_file_name'] = newSafFileName;
}
if (newSafRelativeDir != null) {
values['saf_relative_dir'] = newSafRelativeDir;
}
if (newQuality != null) {
values['quality'] = newQuality;
}
+133
View File
@@ -0,0 +1,133 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/providers/download_history_provider.dart';
DownloadHistoryItem _historyItem({
required String id,
required String filePath,
required DateTime downloadedAt,
String albumName = 'Album',
}) {
return DownloadHistoryItem(
id: id,
trackName: 'Same Song',
artistName: 'Same Artist',
albumName: albumName,
filePath: filePath,
service: 'test',
downloadedAt: downloadedAt,
spotifyId: 'spotify:track:same',
isrc: 'SAMEISRC',
);
}
void main() {
group('download history identity', () {
test('same track metadata does not merge files from different albums', () {
final first = _historyItem(
id: 'first',
filePath: '/music/First Album/Same Song.flac',
downloadedAt: DateTime.utc(2026, 7, 22),
albumName: 'First Album',
);
final second = _historyItem(
id: 'second',
filePath: '/music/Second Album/Same Song.flac',
downloadedAt: DateTime.utc(2026, 7, 23),
albumName: 'Second Album',
);
expect(historyItemsReferToSameStoredFile(first, second), isFalse);
});
test('equivalent paths still update one stored file record', () {
final original = _historyItem(
id: 'original',
filePath: '/music/Album/Same Song.flac',
downloadedAt: DateTime.utc(2026, 7, 22),
);
final converted = _historyItem(
id: 'converted',
filePath: '/music/Album/Same Song.opus',
downloadedAt: DateTime.utc(2026, 7, 23),
);
expect(historyItemsReferToSameStoredFile(original, converted), isTrue);
});
test('duplicate track identities keep the newest lookup item', () {
final newest = _historyItem(
id: 'newest',
filePath: '/music/New Album/Same Song.flac',
downloadedAt: DateTime.utc(2026, 7, 23),
albumName: 'New Album',
);
final older = _historyItem(
id: 'older',
filePath: '/music/Old Album/Same Song.flac',
downloadedAt: DateTime.utc(2026, 7, 22),
albumName: 'Old Album',
);
final state = DownloadHistoryState(
items: [newest, older],
lookupItems: [newest, older],
totalCount: 2,
);
expect(state.getBySpotifyId('spotify:track:same')?.id, 'newest');
expect(state.getByIsrc('SAMEISRC')?.id, 'newest');
expect(
state.findByTrackAndArtist('Same Song', 'Same Artist')?.id,
'newest',
);
expect(state.items, hasLength(2));
});
});
group('startup orphan reconciliation', () {
test('requires consecutive missing checks before confirmation', () {
final firstMissing = reconcileStartupOrphanSuspects(
checkedIds: {'track'},
missingIds: {'track'},
previousSuspectIds: {},
);
expect(firstMissing.confirmedIds, isEmpty);
expect(firstMissing.pendingIds, {'track'});
final recovered = reconcileStartupOrphanSuspects(
checkedIds: {'track'},
missingIds: {},
previousSuspectIds: firstMissing.pendingIds,
);
expect(recovered.confirmedIds, isEmpty);
expect(recovered.pendingIds, isEmpty);
final missingAgain = reconcileStartupOrphanSuspects(
checkedIds: {'track'},
missingIds: {'track'},
previousSuspectIds: recovered.pendingIds,
);
expect(missingAgain.confirmedIds, isEmpty);
expect(missingAgain.pendingIds, {'track'});
final consecutiveMissing = reconcileStartupOrphanSuspects(
checkedIds: {'track'},
missingIds: {'track'},
previousSuspectIds: missingAgain.pendingIds,
);
expect(consecutiveMissing.confirmedIds, {'track'});
expect(consecutiveMissing.pendingIds, isEmpty);
});
test('ignores unchecked stale suspects', () {
final decision = reconcileStartupOrphanSuspects(
checkedIds: {'checked'},
missingIds: {'unchecked'},
previousSuspectIds: {'unchecked'},
);
expect(decision.confirmedIds, isEmpty);
expect(decision.pendingIds, isEmpty);
});
});
}