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
+27 -22
View File
@@ -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] ?? ''})',
);
}
}
+41 -13
View File
@@ -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;
}
+84 -15
View File
@@ -37,6 +37,14 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
bool _embeddedCoverRefreshScheduled = false;
List<DownloadHistoryItem>? _albumTracksSourceCache;
List<DownloadHistoryItem>? _albumTracksCache;
List<DownloadHistoryItem>? _discGroupingSourceCache;
Map<int, List<DownloadHistoryItem>>? _discGroupingCache;
List<int>? _sortedDiscNumbersCache;
List<DownloadHistoryItem>? _commonQualitySourceCache;
String? _commonQualityCache;
List<DownloadHistoryItem>? _embeddedCoverSourceCache;
String? _embeddedCoverPathCache;
bool _embeddedCoverPathResolved = false;
String get _albumLookupKey =>
'${widget.albumName.toLowerCase()}|${widget.artistName.toLowerCase()}';
@@ -61,6 +69,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
oldWidget.artistName != widget.artistName) {
_albumTracksSourceCache = null;
_albumTracksCache = null;
_invalidateDerivedTrackCaches();
}
}
@@ -104,20 +113,45 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
_albumTracksSourceCache = allItems;
_albumTracksCache = tracks;
_invalidateDerivedTrackCaches();
return tracks;
}
Map<int, List<DownloadHistoryItem>> _groupTracksByDisc(
void _invalidateDerivedTrackCaches() {
_discGroupingSourceCache = null;
_discGroupingCache = null;
_sortedDiscNumbersCache = null;
_commonQualitySourceCache = null;
_commonQualityCache = null;
_embeddedCoverSourceCache = null;
_embeddedCoverPathCache = null;
_embeddedCoverPathResolved = false;
}
Map<int, List<DownloadHistoryItem>> _getDiscGroups(
List<DownloadHistoryItem> tracks,
) {
final cached = _discGroupingCache;
if (cached != null && identical(tracks, _discGroupingSourceCache)) {
return cached;
}
final discMap = <int, List<DownloadHistoryItem>>{};
for (final track in tracks) {
final discNumber = track.discNumber ?? 1;
discMap.putIfAbsent(discNumber, () => []).add(track);
}
_discGroupingSourceCache = tracks;
_discGroupingCache = discMap;
_sortedDiscNumbersCache = discMap.keys.toList()..sort();
return discMap;
}
List<int> _getSortedDiscNumbers(List<DownloadHistoryItem> tracks) {
_getDiscGroups(tracks);
return _sortedDiscNumbersCache ?? const [];
}
void _enterSelectionMode(String itemId) {
HapticFeedback.mediumImpact();
setState(() {
@@ -178,10 +212,11 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
if (confirmed == true && mounted) {
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
final idsToDelete = _selectedIds.toList();
final tracksById = {for (final track in currentTracks) track.id: track};
int deletedCount = 0;
for (final id in idsToDelete) {
final item = currentTracks.where((e) => e.id == id).firstOrNull;
final item = tracksById[id];
if (item != null) {
try {
await deleteFile(item.filePath);
@@ -220,6 +255,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
void _onEmbeddedCoverChanged() {
if (!mounted || _embeddedCoverRefreshScheduled) return;
_embeddedCoverRefreshScheduled = true;
_embeddedCoverPathResolved = false;
WidgetsBinding.instance.addPostFrameCallback((_) {
_embeddedCoverRefreshScheduled = false;
if (mounted) {
@@ -346,11 +382,24 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
}
String? _resolveAlbumEmbeddedCoverPath(List<DownloadHistoryItem> tracks) {
if (tracks.isEmpty) return null;
return DownloadedEmbeddedCoverResolver.resolve(
if (_embeddedCoverPathResolved &&
identical(tracks, _embeddedCoverSourceCache)) {
return _embeddedCoverPathCache;
}
_embeddedCoverSourceCache = tracks;
_embeddedCoverPathResolved = true;
if (tracks.isEmpty) {
_embeddedCoverPathCache = null;
return null;
}
_embeddedCoverPathCache = DownloadedEmbeddedCoverResolver.resolve(
tracks.first.filePath,
onChanged: _onEmbeddedCoverChanged,
);
return _embeddedCoverPathCache;
}
Widget _buildAppBar(
@@ -541,6 +590,8 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
ColorScheme colorScheme,
List<DownloadHistoryItem> tracks,
) {
final commonQuality = _getCommonQuality(tracks);
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
@@ -604,22 +655,22 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
),
),
const SizedBox(width: 8),
if (_getCommonQuality(tracks) != null)
if (commonQuality != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: _getCommonQuality(tracks)!.startsWith('24')
color: commonQuality.startsWith('24')
? colorScheme.tertiaryContainer
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Text(
_getCommonQuality(tracks)!,
commonQuality,
style: TextStyle(
color: _getCommonQuality(tracks)!.startsWith('24')
color: commonQuality.startsWith('24')
? colorScheme.onTertiaryContainer
: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
@@ -638,12 +689,30 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
}
String? _getCommonQuality(List<DownloadHistoryItem> tracks) {
if (tracks.isEmpty) return null;
final firstQuality = tracks.first.quality;
if (firstQuality == null) return null;
for (final track in tracks) {
if (track.quality != firstQuality) return null;
if (identical(tracks, _commonQualitySourceCache)) {
return _commonQualityCache;
}
if (tracks.isEmpty) {
_commonQualitySourceCache = tracks;
_commonQualityCache = null;
return null;
}
final firstQuality = tracks.first.quality;
if (firstQuality == null) {
_commonQualitySourceCache = tracks;
_commonQualityCache = null;
return null;
}
for (final track in tracks) {
if (track.quality != firstQuality) {
_commonQualitySourceCache = tracks;
_commonQualityCache = null;
return null;
}
}
_commonQualitySourceCache = tracks;
_commonQualityCache = firstQuality;
return firstQuality;
}
@@ -689,7 +758,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
ColorScheme colorScheme,
List<DownloadHistoryItem> tracks,
) {
final discMap = _groupTracksByDisc(tracks);
final discMap = _getDiscGroups(tracks);
if (discMap.length <= 1) {
return SliverList(
@@ -703,7 +772,7 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen> {
);
}
final discNumbers = discMap.keys.toList()..sort();
final discNumbers = _getSortedDiscNumbers(tracks);
final List<Widget> children = [];
for (final discNumber in discNumbers) {
+12 -7
View File
@@ -36,6 +36,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
late Map<int, List<LocalLibraryItem>> _discGroupsCache;
late List<int> _sortedDiscNumbersCache;
late bool _hasMultipleDiscsCache;
String? _commonQualityCache;
@override
void initState() {
@@ -87,6 +88,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
_discGroupsCache = _groupTracksByDisc(_sortedTracksCache);
_sortedDiscNumbersCache = _discGroupsCache.keys.toList()..sort();
_hasMultipleDiscsCache = _discGroupsCache.length > 1;
_commonQualityCache = _computeCommonQuality(_sortedTracksCache);
}
Map<int, List<LocalLibraryItem>> _groupTracksByDisc(
@@ -160,15 +162,16 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
if (confirmed == true && mounted) {
final libraryNotifier = ref.read(localLibraryProvider.notifier);
final idsToDelete = _selectedIds.toList();
final tracksById = {for (final track in currentTracks) track.id: track};
int deletedCount = 0;
for (final id in idsToDelete) {
final item = currentTracks.where((e) => e.id == id).firstOrNull;
final item = tracksById[id];
if (item != null) {
try {
await deleteFile(item.filePath);
} catch (_) {}
libraryNotifier.removeItem(id);
await libraryNotifier.removeItem(id);
deletedCount++;
}
}
@@ -425,6 +428,8 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
ColorScheme colorScheme,
List<LocalLibraryItem> tracks,
) {
final commonQuality = _commonQualityCache;
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
@@ -519,22 +524,22 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
),
const SizedBox(width: 8),
// Quality badge if all tracks have the same quality
if (_getCommonQuality(tracks) != null)
if (commonQuality != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: _getCommonQuality(tracks)!.contains('24')
color: commonQuality.contains('24')
? colorScheme.primaryContainer
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Text(
_getCommonQuality(tracks)!,
commonQuality,
style: TextStyle(
color: _getCommonQuality(tracks)!.contains('24')
color: commonQuality.contains('24')
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
@@ -552,7 +557,7 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen> {
);
}
String? _getCommonQuality(List<LocalLibraryItem> tracks) {
String? _computeCommonQuality(List<LocalLibraryItem> tracks) {
if (tracks.isEmpty) return null;
final first = tracks.first;
if (first.bitDepth == null || first.sampleRate == null) return null;
+55 -25
View File
@@ -60,19 +60,30 @@ class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
}
Future<_CacheOverview> _buildOverview() async {
final appCacheDir = await getApplicationCacheDirectory();
final tempDir = await getTemporaryDirectory();
final appCacheDirFuture = getApplicationCacheDirectory();
final tempDirFuture = getTemporaryDirectory();
final appSupportDirFuture = getApplicationSupportDirectory();
final coverStatsFuture = CoverCacheManager.getStats();
final prefsFuture = SharedPreferences.getInstance();
final trackCacheEntriesFuture = _getTrackCacheSizeSafe();
final appCacheDir = await appCacheDirFuture;
final tempDir = await tempDirFuture;
final appCachePath = p.normalize(appCacheDir.path);
final tempPath = p.normalize(tempDir.path);
final tempIsSameAsAppCache = appCachePath == tempPath;
final appCacheStats = await _scanDirectory(Directory(appCachePath));
final tempStats = tempIsSameAsAppCache
? null
: await _scanDirectory(Directory(tempPath));
final coverStats = await CoverCacheManager.getStats();
final appCacheStatsFuture = _scanDirectory(Directory(appCachePath));
final tempStatsFuture = tempIsSameAsAppCache
? Future<_DirectoryStats?>.value(null)
: _scanDirectory(Directory(tempPath));
final prefs = await SharedPreferences.getInstance();
final appSupportDir = await appSupportDirFuture;
final libraryCoverStatsFuture = _scanDirectory(
Directory('${appSupportDir.path}/library_covers'),
);
final prefs = await prefsFuture;
final explorePayload = prefs.getString(_exploreCacheKey);
final exploreTs = prefs.getInt(_exploreCacheTsKey);
var exploreBytes = 0;
@@ -84,16 +95,11 @@ class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
}
final hasExploreCache = exploreBytes > 0;
int trackCacheEntries;
try {
trackCacheEntries = await PlatformBridge.getTrackCacheSize();
} catch (_) {
trackCacheEntries = 0;
}
final appSupportDir = await getApplicationSupportDirectory();
final libraryCoverDir = Directory('${appSupportDir.path}/library_covers');
final libraryCoverStats = await _scanDirectory(libraryCoverDir);
final appCacheStats = await appCacheStatsFuture;
final tempStats = await tempStatsFuture;
final coverStats = await coverStatsFuture;
final libraryCoverStats = await libraryCoverStatsFuture;
final trackCacheEntries = await trackCacheEntriesFuture;
return _CacheOverview(
appCachePath: appCachePath,
@@ -132,16 +138,37 @@ class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
return _DirectoryStats(fileCount: fileCount, totalSizeBytes: totalSize);
}
Future<int> _getTrackCacheSizeSafe() async {
try {
return await PlatformBridge.getTrackCacheSize();
} catch (_) {
return 0;
}
}
Future<void> _clearDirectoryContents(String path) async {
final directory = Directory(path);
if (!await directory.exists()) return;
try {
final entities = directory.listSync(followLinks: false);
for (final entity in entities) {
try {
await entity.delete(recursive: true);
} catch (_) {}
final entities = <FileSystemEntity>[];
await for (final entity in directory.list(followLinks: false)) {
entities.add(entity);
}
const deleteChunkSize = 24;
for (var i = 0; i < entities.length; i += deleteChunkSize) {
final end = (i + deleteChunkSize < entities.length)
? i + deleteChunkSize
: entities.length;
final chunk = entities.sublist(i, end);
await Future.wait(
chunk.map((entity) async {
try {
await entity.delete(recursive: true);
} catch (_) {}
}),
);
}
} catch (_) {}
@@ -583,7 +610,9 @@ class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
subtitle: _buildSubtitle(
context.l10n.cacheTrackLookupDesc,
overview.trackCacheEntries > 0
? context.l10n.cacheEntries(overview.trackCacheEntries)
? context.l10n.cacheEntries(
overview.trackCacheEntries,
)
: context.l10n.cacheNoData,
),
trailing: _buildClearTrailing(
@@ -611,7 +640,8 @@ class _CacheManagementPageState extends ConsumerState<CacheManagementPage> {
SettingsItem(
icon: Icons.cleaning_services_outlined,
title: context.l10n.cacheCleanupUnused,
subtitle: '${context.l10n.cacheCleanupUnusedDesc}\n${context.l10n.cacheCleanupUnusedSubtitle}',
subtitle:
'${context.l10n.cacheCleanupUnusedDesc}\n${context.l10n.cacheCleanupUnusedSubtitle}',
trailing: _buildClearTrailing(
'cleanup_unused',
_cleanupUnusedData,
@@ -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;
}
}