diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 606bff6b..e63c276d 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -519,31 +519,36 @@ class DownloadHistoryNotifier extends Notifier { final entries = await _db.getAllEntriesWithPaths(); final orphanedIds = []; + final pathById = {}; + 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?>( + 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] ?? ''})', + ); } } diff --git a/lib/providers/local_library_provider.dart b/lib/providers/local_library_provider.dart index e02e0d32..fcaa0f1c 100644 --- a/lib/providers/local_library_provider.dart +++ b/lib/providers/local_library_provider.dart @@ -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 items; @@ -26,6 +27,7 @@ class LocalLibraryState { final int excludedDownloadedCount; final Set _trackKeySet; final Map _byIsrc; + final Map _byTrackKey; LocalLibraryState({ this.items = const [], @@ -40,6 +42,7 @@ class LocalLibraryState { this.excludedDownloadedCount = 0, Set? trackKeySet, Map? byIsrc, + Map? 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 { _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 { } } + final paths = legacyPaths + .where((path) => !path.startsWith('content://')) + .toList(growable: false); + const chunkSize = 24; final backfilled = {}; - 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?>( + 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; } diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index a36d85ca..4f1270f3 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -37,6 +37,14 @@ class _DownloadedAlbumScreenState extends ConsumerState { bool _embeddedCoverRefreshScheduled = false; List? _albumTracksSourceCache; List? _albumTracksCache; + List? _discGroupingSourceCache; + Map>? _discGroupingCache; + List? _sortedDiscNumbersCache; + List? _commonQualitySourceCache; + String? _commonQualityCache; + List? _embeddedCoverSourceCache; + String? _embeddedCoverPathCache; + bool _embeddedCoverPathResolved = false; String get _albumLookupKey => '${widget.albumName.toLowerCase()}|${widget.artistName.toLowerCase()}'; @@ -61,6 +69,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { oldWidget.artistName != widget.artistName) { _albumTracksSourceCache = null; _albumTracksCache = null; + _invalidateDerivedTrackCaches(); } } @@ -104,20 +113,45 @@ class _DownloadedAlbumScreenState extends ConsumerState { _albumTracksSourceCache = allItems; _albumTracksCache = tracks; + _invalidateDerivedTrackCaches(); return tracks; } - Map> _groupTracksByDisc( + void _invalidateDerivedTrackCaches() { + _discGroupingSourceCache = null; + _discGroupingCache = null; + _sortedDiscNumbersCache = null; + _commonQualitySourceCache = null; + _commonQualityCache = null; + _embeddedCoverSourceCache = null; + _embeddedCoverPathCache = null; + _embeddedCoverPathResolved = false; + } + + Map> _getDiscGroups( List tracks, ) { + final cached = _discGroupingCache; + if (cached != null && identical(tracks, _discGroupingSourceCache)) { + return cached; + } + final discMap = >{}; 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 _getSortedDiscNumbers(List tracks) { + _getDiscGroups(tracks); + return _sortedDiscNumbersCache ?? const []; + } + void _enterSelectionMode(String itemId) { HapticFeedback.mediumImpact(); setState(() { @@ -178,10 +212,11 @@ class _DownloadedAlbumScreenState extends ConsumerState { 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 { 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 { } String? _resolveAlbumEmbeddedCoverPath(List 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 { ColorScheme colorScheme, List tracks, ) { + final commonQuality = _getCommonQuality(tracks); + return SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.all(16), @@ -604,22 +655,22 @@ class _DownloadedAlbumScreenState extends ConsumerState { ), ), 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 { } String? _getCommonQuality(List 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 { ColorScheme colorScheme, List tracks, ) { - final discMap = _groupTracksByDisc(tracks); + final discMap = _getDiscGroups(tracks); if (discMap.length <= 1) { return SliverList( @@ -703,7 +772,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { ); } - final discNumbers = discMap.keys.toList()..sort(); + final discNumbers = _getSortedDiscNumbers(tracks); final List children = []; for (final discNumber in discNumbers) { diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index e9cbcdaf..529ad159 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -36,6 +36,7 @@ class _LocalAlbumScreenState extends ConsumerState { late Map> _discGroupsCache; late List _sortedDiscNumbersCache; late bool _hasMultipleDiscsCache; + String? _commonQualityCache; @override void initState() { @@ -87,6 +88,7 @@ class _LocalAlbumScreenState extends ConsumerState { _discGroupsCache = _groupTracksByDisc(_sortedTracksCache); _sortedDiscNumbersCache = _discGroupsCache.keys.toList()..sort(); _hasMultipleDiscsCache = _discGroupsCache.length > 1; + _commonQualityCache = _computeCommonQuality(_sortedTracksCache); } Map> _groupTracksByDisc( @@ -160,15 +162,16 @@ class _LocalAlbumScreenState extends ConsumerState { 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 { ColorScheme colorScheme, List tracks, ) { + final commonQuality = _commonQualityCache; + return SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.all(16), @@ -519,22 +524,22 @@ class _LocalAlbumScreenState extends ConsumerState { ), 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 { ); } - String? _getCommonQuality(List tracks) { + String? _computeCommonQuality(List tracks) { if (tracks.isEmpty) return null; final first = tracks.first; if (first.bitDepth == null || first.sampleRate == null) return null; diff --git a/lib/screens/settings/cache_management_page.dart b/lib/screens/settings/cache_management_page.dart index b39bdf9b..c01c685f 100644 --- a/lib/screens/settings/cache_management_page.dart +++ b/lib/screens/settings/cache_management_page.dart @@ -60,19 +60,30 @@ class _CacheManagementPageState extends ConsumerState { } 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 { } 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 { return _DirectoryStats(fileCount: fileCount, totalSizeBytes: totalSize); } + Future _getTrackCacheSizeSafe() async { + try { + return await PlatformBridge.getTrackCacheSize(); + } catch (_) { + return 0; + } + } + Future _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 = []; + 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 { 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 { 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, diff --git a/lib/services/downloaded_embedded_cover_resolver.dart b/lib/services/downloaded_embedded_cover_resolver.dart index 0f40bd02..3f598c2f 100644 --- a/lib/services/downloaded_embedded_cover_resolver.dart +++ b/lib/services/downloaded_embedded_cover_resolver.dart @@ -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 _cache = LinkedHashMap(); @@ -28,6 +29,8 @@ class DownloadedEmbeddedCoverResolver { static final Set _pendingModCheck = {}; static final Set _failedExtract = {}; static final Map _lastModCheckMillis = {}; + static final Map _lastPreviewExistsCheckMillis = + {}; 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) { diff --git a/lib/services/history_database.dart b/lib/services/history_database.dart index 1e9f91d7..b20dd7bc 100644 --- a/lib/services/history_database.dart +++ b/lib/services/history_database.dart @@ -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; } } diff --git a/lib/services/library_database.dart b/lib/services/library_database.dart index a45c4769..c15b0c99 100644 --- a/lib/services/library_database.dart +++ b/lib/services/library_database.dart @@ -229,6 +229,7 @@ class LibraryDatabase { } Future upsertBatch(List> items) async { + if (items.isEmpty) return; final db = await database; final batch = db.batch(); @@ -350,16 +351,46 @@ class LibraryDatabase { Future 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 = []; + 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>( + 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 deleteByPaths(List 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; } }