mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-21 07:26:51 +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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user