mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-14 03:31:06 +02:00
perf: parallel I/O, caching, and chunked DB operations (batch 3)
- Orphan cleanup: parallel file existence checks (chunk 16) - LocalLibraryState: O(1) findByTrackAndArtist via _byTrackKey map - Local library load: parallel DB + SharedPreferences fetch - Legacy mod-time backfill: chunked parallel File.stat (chunk 24) - Downloaded album screen: cache disc groups, quality, cover path - Local album screen: cache common quality, map-based batch delete - Cache management: parallel async init, chunked directory cleanup - Cover resolver: throttled preview exists check (2.2s interval) - History/Library DB: chunked SQL DELETE (500 per batch) - Batch delete screens: O(1) item lookup via tracksById map
This commit is contained in:
1 parent
986f5eafc8
commit
9847594ca1
8 files changed
+304
-106
No files matched your search
@@ -519,31 +519,36 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
|
||||
|
||||
final entries = await _db.getAllEntriesWithPaths();
|
||||
final orphanedIds = <String>[];
|
||||
final pathById = <String, String>{};
|
||||
const checkChunkSize = 16;
|
||||
|
||||
for (final entry in entries) {
|
||||
final id = entry['id'] as String;
|
||||
final filePath = entry['file_path'] as String?;
|
||||
for (var i = 0; i < entries.length; i += checkChunkSize) {
|
||||
final end = (i + checkChunkSize < entries.length)
|
||||
? i + checkChunkSize
|
||||
: entries.length;
|
||||
final chunk = entries.sublist(i, end);
|
||||
|
||||
if (filePath == null || filePath.isEmpty) continue;
|
||||
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?;
|
||||
if (filePath == null || filePath.isEmpty) return null;
|
||||
pathById[id] = filePath;
|
||||
try {
|
||||
return MapEntry(id, await fileExists(filePath));
|
||||
} catch (e) {
|
||||
_historyLog.w('Error checking file existence for $id: $e');
|
||||
return MapEntry(id, false);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
bool exists = false;
|
||||
|
||||
if (filePath.startsWith('content://')) {
|
||||
// SAF path - check via platform bridge
|
||||
try {
|
||||
exists = await PlatformBridge.safExists(filePath);
|
||||
} catch (e) {
|
||||
_historyLog.w('Error checking SAF file existence: $e');
|
||||
exists = false;
|
||||
}
|
||||
} else {
|
||||
// Regular file path
|
||||
exists = File(filePath).existsSync();
|
||||
}
|
||||
|
||||
if (!exists) {
|
||||
orphanedIds.add(id);
|
||||
_historyLog.d('Found orphaned entry: $id ($filePath)');
|
||||
for (final check in checks) {
|
||||
if (check == null || check.value) continue;
|
||||
orphanedIds.add(check.key);
|
||||
_historyLog.d(
|
||||
'Found orphaned entry: ${check.key} (${pathById[check.key] ?? ''})',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ final _log = AppLogger('LocalLibrary');
|
||||
|
||||
const _lastScannedAtKey = 'local_library_last_scanned_at';
|
||||
const _excludedDownloadedCountKey = 'local_library_excluded_downloaded_count';
|
||||
final _prefs = SharedPreferences.getInstance();
|
||||
|
||||
class LocalLibraryState {
|
||||
final List<LocalLibraryItem> items;
|
||||
@@ -26,6 +27,7 @@ class LocalLibraryState {
|
||||
final int excludedDownloadedCount;
|
||||
final Set<String> _trackKeySet;
|
||||
final Map<String, LocalLibraryItem> _byIsrc;
|
||||
final Map<String, LocalLibraryItem> _byTrackKey;
|
||||
|
||||
LocalLibraryState({
|
||||
this.items = const [],
|
||||
@@ -40,6 +42,7 @@ class LocalLibraryState {
|
||||
this.excludedDownloadedCount = 0,
|
||||
Set<String>? trackKeySet,
|
||||
Map<String, LocalLibraryItem>? byIsrc,
|
||||
Map<String, LocalLibraryItem>? byTrackKey,
|
||||
}) : _trackKeySet = trackKeySet ?? items.map((item) => item.matchKey).toSet(),
|
||||
_byIsrc =
|
||||
byIsrc ??
|
||||
@@ -47,7 +50,10 @@ class LocalLibraryState {
|
||||
items
|
||||
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
|
||||
.map((item) => MapEntry(item.isrc!, item)),
|
||||
);
|
||||
),
|
||||
_byTrackKey =
|
||||
byTrackKey ??
|
||||
Map.fromEntries(items.map((item) => MapEntry(item.matchKey, item)));
|
||||
|
||||
bool hasIsrc(String isrc) => _byIsrc.containsKey(isrc);
|
||||
|
||||
@@ -60,7 +66,7 @@ class LocalLibraryState {
|
||||
|
||||
LocalLibraryItem? findByTrackAndArtist(String trackName, String artistName) {
|
||||
final key = '${trackName.toLowerCase()}|${artistName.toLowerCase()}';
|
||||
return items.where((item) => item.matchKey == key).firstOrNull;
|
||||
return _byTrackKey[key];
|
||||
}
|
||||
|
||||
bool existsInLibrary({String? isrc, String? trackName, String? artistName}) {
|
||||
@@ -102,6 +108,7 @@ class LocalLibraryState {
|
||||
excludedDownloadedCount ?? this.excludedDownloadedCount,
|
||||
trackKeySet: keepDerivedIndex ? _trackKeySet : null,
|
||||
byIsrc: keepDerivedIndex ? _byIsrc : null,
|
||||
byTrackKey: keepDerivedIndex ? _byTrackKey : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -133,13 +140,17 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
_isLoaded = true;
|
||||
|
||||
try {
|
||||
final jsonList = await _db.getAll();
|
||||
final items = jsonList.map((e) => LocalLibraryItem.fromJson(e)).toList();
|
||||
final dbItemsFuture = _db.getAll();
|
||||
final prefsFuture = _prefs;
|
||||
final jsonList = await dbItemsFuture;
|
||||
final items = jsonList
|
||||
.map((e) => LocalLibraryItem.fromJson(e))
|
||||
.toList(growable: false);
|
||||
|
||||
DateTime? lastScannedAt;
|
||||
var excludedDownloadedCount = 0;
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final prefs = await prefsFuture;
|
||||
final lastScannedAtStr = prefs.getString(_lastScannedAtKey);
|
||||
if (lastScannedAtStr != null && lastScannedAtStr.isNotEmpty) {
|
||||
lastScannedAt = DateTime.tryParse(lastScannedAtStr);
|
||||
@@ -589,17 +600,34 @@ class LocalLibraryNotifier extends Notifier<LocalLibraryState> {
|
||||
}
|
||||
}
|
||||
|
||||
final paths = legacyPaths
|
||||
.where((path) => !path.startsWith('content://'))
|
||||
.toList(growable: false);
|
||||
const chunkSize = 24;
|
||||
final backfilled = <String, int>{};
|
||||
for (final path in legacyPaths) {
|
||||
if (_scanCancelRequested || path.startsWith('content://')) {
|
||||
continue;
|
||||
|
||||
for (var i = 0; i < paths.length; i += chunkSize) {
|
||||
if (_scanCancelRequested) {
|
||||
break;
|
||||
}
|
||||
try {
|
||||
final stat = await File(path).stat();
|
||||
if (stat.type == FileSystemEntityType.file) {
|
||||
backfilled[path] = stat.modified.millisecondsSinceEpoch;
|
||||
final end = (i + chunkSize < paths.length) ? i + chunkSize : paths.length;
|
||||
final chunk = paths.sublist(i, end);
|
||||
final chunkEntries = await Future.wait<MapEntry<String, int>?>(
|
||||
chunk.map((path) async {
|
||||
try {
|
||||
final stat = await File(path).stat();
|
||||
if (stat.type == FileSystemEntityType.file) {
|
||||
return MapEntry(path, stat.modified.millisecondsSinceEpoch);
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}),
|
||||
);
|
||||
for (final entry in chunkEntries) {
|
||||
if (entry != null) {
|
||||
backfilled[entry.key] = entry.value;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
return backfilled;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user