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:
zarzet
2026-02-11 02:40:09 +07:00
parent 986f5eafc8
commit 9847594ca1
8 changed files with 304 additions and 106 deletions
@@ -21,6 +21,7 @@ class _EmbeddedCoverCacheEntry {
class DownloadedEmbeddedCoverResolver {
static const int _maxCacheEntries = 160;
static const int _minModCheckIntervalMs = 1200;
static const int _minPreviewExistsCheckIntervalMs = 2200;
static final LinkedHashMap<String, _EmbeddedCoverCacheEntry> _cache =
LinkedHashMap<String, _EmbeddedCoverCacheEntry>();
@@ -28,6 +29,8 @@ class DownloadedEmbeddedCoverResolver {
static final Set<String> _pendingModCheck = <String>{};
static final Set<String> _failedExtract = <String>{};
static final Map<String, int> _lastModCheckMillis = <String, int>{};
static final Map<String, int> _lastPreviewExistsCheckMillis =
<String, int>{};
static String cleanFilePath(String? filePath) {
if (filePath == null) return '';
@@ -64,12 +67,21 @@ class DownloadedEmbeddedCoverResolver {
final cached = _cache[cleanPath];
if (cached != null) {
if (File(cached.previewPath).existsSync()) {
final now = DateTime.now().millisecondsSinceEpoch;
final lastPreviewCheck = _lastPreviewExistsCheckMillis[cleanPath] ?? 0;
final shouldVerifyExists =
now - lastPreviewCheck >= _minPreviewExistsCheckIntervalMs;
if (!shouldVerifyExists || File(cached.previewPath).existsSync()) {
if (shouldVerifyExists) {
_lastPreviewExistsCheckMillis[cleanPath] = now;
}
_touch(cleanPath, cached);
_scheduleModCheck(cleanPath, onChanged: onChanged);
return cached.previewPath;
}
_cache.remove(cleanPath);
_lastPreviewExistsCheckMillis.remove(cleanPath);
_cleanupTempCoverPathSync(cached.previewPath);
}
@@ -107,6 +119,7 @@ class DownloadedEmbeddedCoverResolver {
_pendingModCheck.remove(cleanPath);
_failedExtract.remove(cleanPath);
_lastModCheckMillis.remove(cleanPath);
_lastPreviewExistsCheckMillis.remove(cleanPath);
if (cached != null) {
_cleanupTempCoverPathSync(cached.previewPath);
}
@@ -129,6 +142,7 @@ class DownloadedEmbeddedCoverResolver {
_pendingModCheck.remove(oldestKey);
_failedExtract.remove(oldestKey);
_lastModCheckMillis.remove(oldestKey);
_lastPreviewExistsCheckMillis.remove(oldestKey);
}
}
@@ -204,6 +218,8 @@ class DownloadedEmbeddedCoverResolver {
);
_touch(cleanPath, next);
_failedExtract.remove(cleanPath);
_lastPreviewExistsCheckMillis[cleanPath] =
DateTime.now().millisecondsSinceEpoch;
_trimCacheIfNeeded();
if (previous != null && previous.previewPath != outputPath) {
+13 -7
View File
@@ -525,12 +525,18 @@ class HistoryDatabase {
if (ids.isEmpty) return 0;
final db = await database;
final placeholders = List.filled(ids.length, '?').join(',');
final count = await db.rawDelete(
'DELETE FROM history WHERE id IN ($placeholders)',
ids,
);
_log.i('Deleted $count orphaned entries');
return count;
var totalDeleted = 0;
const chunkSize = 500;
for (var i = 0; i < ids.length; i += chunkSize) {
final end = (i + chunkSize < ids.length) ? i + chunkSize : ids.length;
final chunk = ids.sublist(i, end);
final placeholders = List.filled(chunk.length, '?').join(',');
totalDeleted += await db.rawDelete(
'DELETE FROM history WHERE id IN ($placeholders)',
chunk,
);
}
_log.i('Deleted $totalDeleted orphaned entries');
return totalDeleted;
}
}
+55 -16
View File
@@ -229,6 +229,7 @@ class LibraryDatabase {
}
Future<void> upsertBatch(List<Map<String, dynamic>> items) async {
if (items.isEmpty) return;
final db = await database;
final batch = db.batch();
@@ -350,16 +351,46 @@ class LibraryDatabase {
Future<int> cleanupMissingFiles() async {
final db = await database;
final rows = await db.query('library', columns: ['id', 'file_path']);
int removed = 0;
for (final row in rows) {
final filePath = row['file_path'] as String;
if (!await fileExists(filePath)) {
await db.delete('library', where: 'id = ?', whereArgs: [row['id']]);
removed++;
final missingIds = <String>[];
const checkChunkSize = 16;
for (var i = 0; i < rows.length; i += checkChunkSize) {
final end = (i + checkChunkSize < rows.length)
? i + checkChunkSize
: rows.length;
final chunk = rows.sublist(i, end);
final checks = await Future.wait<MapEntry<String, bool>>(
chunk.map((row) async {
final id = row['id'] as String;
final filePath = row['file_path'] as String;
return MapEntry(id, await fileExists(filePath));
}),
);
for (final check in checks) {
if (!check.value) {
missingIds.add(check.key);
}
}
}
if (missingIds.isEmpty) {
return 0;
}
var removed = 0;
const deleteChunkSize = 500;
for (var i = 0; i < missingIds.length; i += deleteChunkSize) {
final end = (i + deleteChunkSize < missingIds.length)
? i + deleteChunkSize
: missingIds.length;
final idChunk = missingIds.sublist(i, end);
final placeholders = List.filled(idChunk.length, '?').join(',');
removed += await db.rawDelete(
'DELETE FROM library WHERE id IN ($placeholders)',
idChunk,
);
}
if (removed > 0) {
_log.i('Cleaned up $removed missing files from library');
}
@@ -440,14 +471,22 @@ class LibraryDatabase {
Future<int> deleteByPaths(List<String> filePaths) async {
if (filePaths.isEmpty) return 0;
final db = await database;
final placeholders = List.filled(filePaths.length, '?').join(',');
final result = await db.rawDelete(
'DELETE FROM library WHERE file_path IN ($placeholders)',
filePaths,
);
if (result > 0) {
_log.i('Deleted $result items from library');
var totalDeleted = 0;
const chunkSize = 500;
for (var i = 0; i < filePaths.length; i += chunkSize) {
final end = (i + chunkSize < filePaths.length)
? i + chunkSize
: filePaths.length;
final chunk = filePaths.sublist(i, end);
final placeholders = List.filled(chunk.length, '?').join(',');
totalDeleted += await db.rawDelete(
'DELETE FROM library WHERE file_path IN ($placeholders)',
chunk,
);
}
return result;
if (totalDeleted > 0) {
_log.i('Deleted $totalDeleted items from library');
}
return totalDeleted;
}
}