import 'dart:async'; import 'dart:io'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/notification_service.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/local_library_scan_prefs.dart'; import 'package:spotiflac_android/utils/progress_stream_poller.dart'; final _log = AppLogger('LocalLibrary'); const _excludedDownloadedCountKey = 'local_library_excluded_downloaded_count'; final _prefs = SharedPreferences.getInstance(); class LocalLibraryState { final bool isScanning; final bool scanIsFinalizing; final double scanProgress; final String? scanCurrentFile; final int scanTotalFiles; final int scannedFiles; final int scanErrorCount; final bool scanWasCancelled; final int totalCount; final int loadedIndexVersion; final DateTime? lastScannedAt; final List sources; final String? scanningSourceId; final int excludedDownloadedCount; final Set _trackKeySet; final Set _isrcSet; LocalLibraryState({ this.isScanning = false, this.scanIsFinalizing = false, this.scanProgress = 0, this.scanCurrentFile, this.scanTotalFiles = 0, this.scannedFiles = 0, this.scanErrorCount = 0, this.scanWasCancelled = false, this.totalCount = 0, this.loadedIndexVersion = 0, this.lastScannedAt, this.sources = const [], this.scanningSourceId, this.excludedDownloadedCount = 0, Set? trackKeySet, Set? isrcSet, }) : _trackKeySet = trackKeySet ?? const {}, _isrcSet = isrcSet ?? const {}; bool hasIsrc(String isrc) => _isrcSet.contains(isrc); bool hasTrack(String trackName, String artistName) { final key = LibraryDatabase.matchKeyFor(trackName, artistName); return _trackKeySet.contains(key); } bool existsInLibrary({String? isrc, String? trackName, String? artistName}) { if (isrc != null && isrc.isNotEmpty && hasIsrc(isrc)) { return true; } if (trackName != null && artistName != null) { return hasTrack(trackName, artistName); } return false; } LocalLibraryState copyWith({ bool? isScanning, bool? scanIsFinalizing, double? scanProgress, String? scanCurrentFile, int? scanTotalFiles, int? scannedFiles, int? scanErrorCount, bool? scanWasCancelled, int? totalCount, int? loadedIndexVersion, DateTime? lastScannedAt, bool clearLastScannedAt = false, List? sources, String? scanningSourceId, bool clearScanningSourceId = false, int? excludedDownloadedCount, Set? trackKeySet, Set? isrcSet, }) { return LocalLibraryState( isScanning: isScanning ?? this.isScanning, scanIsFinalizing: scanIsFinalizing ?? this.scanIsFinalizing, scanProgress: scanProgress ?? this.scanProgress, scanCurrentFile: scanCurrentFile ?? this.scanCurrentFile, scanTotalFiles: scanTotalFiles ?? this.scanTotalFiles, scannedFiles: scannedFiles ?? this.scannedFiles, scanErrorCount: scanErrorCount ?? this.scanErrorCount, scanWasCancelled: scanWasCancelled ?? this.scanWasCancelled, totalCount: totalCount ?? this.totalCount, loadedIndexVersion: loadedIndexVersion ?? this.loadedIndexVersion, lastScannedAt: clearLastScannedAt ? null : lastScannedAt ?? this.lastScannedAt, sources: sources ?? this.sources, scanningSourceId: clearScanningSourceId ? null : scanningSourceId ?? this.scanningSourceId, excludedDownloadedCount: excludedDownloadedCount ?? this.excludedDownloadedCount, trackKeySet: trackKeySet ?? _trackKeySet, isrcSet: isrcSet ?? _isrcSet, ); } } class LocalLibraryNotifier extends Notifier { final LibraryDatabase _db = LibraryDatabase.instance; final NotificationService _notificationService = NotificationService(); static const _progressPollingInterval = Duration(milliseconds: 350); static const _progressStreamBootstrapTimeout = Duration(milliseconds: 900); late final ProgressStreamPoller> _progressPoller = ProgressStreamPoller>( streamProvider: PlatformBridge.libraryScanProgressStream, pollProvider: PlatformBridge.getLibraryScanProgress, onProgress: _handleLibraryScanProgress, pollingInterval: _progressPollingInterval, bootstrapTimeout: _progressStreamBootstrapTimeout, onStreamProcessingError: (e) => _log.w('Library scan progress stream processing failed: $e'), onStreamFailed: (error) => _log.w( 'Library scan progress stream failed, fallback to polling: $error', ), onStreamTimeout: () => _log.w('Library scan progress stream timeout, fallback to polling'), onPollError: (e) => _log.w('Library scan progress polling failed: $e'), ); bool _isLoaded = false; bool _hasLoadedFromDatabase = false; Future? _loadFuture; bool _scanCancelRequested = false; bool _scanInProgress = false; StreamSubscription? _storageEventsSubscription; Timer? _storageEventDebounce; static const _scanNotificationHeartbeat = Duration(seconds: 4); int _lastScanNotificationPercent = -1; int _lastScanNotificationTotalFiles = -1; DateTime _lastScanNotificationAt = DateTime.fromMillisecondsSinceEpoch(0); @override LocalLibraryState build() { ref.onDispose(() { _progressPoller.stop(); _storageEventsSubscription?.cancel(); _storageEventDebounce?.cancel(); }); if (Platform.isAndroid) { _storageEventsSubscription = PlatformBridge.libraryStorageEvents().listen( (_) { _storageEventDebounce?.cancel(); _storageEventDebounce = Timer( const Duration(milliseconds: 900), () => refreshSourceAvailability(scanReconnected: true), ); }, onError: (Object e) => _log.w('Library storage event stream failed: $e'), ); } Future.microtask(_ensureLoadedFromDatabase); return LocalLibraryState(); } Future _ensureLoadedFromDatabase() { if (_hasLoadedFromDatabase) { return Future.value(); } return _loadFuture ??= _loadFromDatabase(); } Future _loadFromDatabase() async { if (_hasLoadedFromDatabase) return; if (_isLoaded) { return _loadFuture ?? Future.value(); } _isLoaded = true; try { await _migrateLegacySource(); final reconnectedSources = await _refreshSourceAvailabilityInDatabase(); final countFuture = _db.getCount(); final indexFuture = _db.getLookupIndex(); final sourcesFuture = _db.getSources(); final prefsFuture = _prefs; final count = await countFuture; final lookupIndex = await indexFuture; final sources = await sourcesFuture; DateTime? lastScannedAt; var excludedDownloadedCount = 0; try { final prefs = await prefsFuture; lastScannedAt = readLocalLibraryLastScannedAt(prefs); excludedDownloadedCount = prefs.getInt(_excludedDownloadedCountKey) ?? 0; } catch (e) { _log.w('Failed to load lastScannedAt: $e'); } state = state.copyWith( totalCount: count, loadedIndexVersion: state.loadedIndexVersion + 1, lastScannedAt: lastScannedAt, excludedDownloadedCount: excludedDownloadedCount, sources: sources, trackKeySet: lookupIndex.matchKeys, isrcSet: lookupIndex.isrcs, ); _log.i( 'Loaded local library summary: $count items, lastScannedAt: ' '$lastScannedAt, excludedDownloadedCount: $excludedDownloadedCount', ); _hasLoadedFromDatabase = true; if (reconnectedSources.isNotEmpty) { unawaited( Future.microtask( () => _scanSourcesSequentially(reconnectedSources), ), ); } } catch (e, stack) { _isLoaded = false; _log.e('Failed to load library from database: $e', e, stack); } finally { _loadFuture = null; } } Future reloadFromStorage() async { _isLoaded = false; _hasLoadedFromDatabase = false; _loadFuture = null; await _ensureLoadedFromDatabase(); } Future addSource({ required String path, required String displayName, String? bookmark, String? volumeId, bool isRemovable = false, }) async { final trimmedPath = path.trim(); if (trimmedPath.isEmpty) { throw const FormatException('Library folder path is empty'); } final existing = await _db.getSourceByPath(trimmedPath); if (existing != null) { await _db.updateSourceState( existing.id, enabled: true, available: true, lastSeenAt: DateTime.now(), ); await _refreshSummaryFromStorage(); return existing.copyWith( enabled: true, available: true, lastSeenAt: DateTime.now(), ); } final source = LocalLibrarySource( id: _newSourceId(trimmedPath), path: trimmedPath, displayName: displayName.trim().isEmpty ? _displayNameForPath(trimmedPath) : displayName.trim(), bookmark: bookmark?.trim().isEmpty == true ? null : bookmark, volumeId: volumeId?.trim().isEmpty == true ? null : volumeId, isRemovable: isRemovable || _looksLikeRemovablePath(trimmedPath), enabled: true, available: true, lastSeenAt: DateTime.now(), ); await _db.upsertSource(source); await _refreshSummaryFromStorage(); return source; } String _newSourceId(String path) { const offset = 0xcbf29ce484222325; const prime = 0x100000001b3; const mask = 0xffffffffffffffff; var hash = offset; for (final unit in path.codeUnits) { hash = ((hash ^ unit) * prime) & mask; } return 'source_${hash.toRadixString(16).padLeft(16, '0')}'; } Future setSourceEnabled(String sourceId, bool enabled) async { final source = state.sources .where((entry) => entry.id == sourceId) .firstOrNull; await _db.updateSourceState(sourceId, enabled: enabled); await _refreshSummaryFromStorage(); if (enabled && source?.available == true && source?.isIndexed == true && !_scanInProgress) { unawaited(startSourceScan(sourceId)); } } Future removeSource(String sourceId) async { await _db.removeSource(sourceId); await _refreshSummaryFromStorage(); } Future refreshSourceAvailability({bool scanReconnected = false}) async { await _ensureLoadedFromDatabase(); final before = await _db.getSources(); final reconnected = []; for (final source in before) { final available = await _isPathAvailable( source.path, bookmark: source.bookmark, ); if (available != source.available) { await _db.updateSourceState( source.id, available: available, lastSeenAt: available ? DateTime.now() : null, ); if (available && source.enabled && source.isIndexed) { reconnected.add(source.id); } } } await _refreshSummaryFromStorage(); // Existing rows become visible as soon as availability flips. The // incremental pass then verifies changes made while storage was detached // without re-reading metadata for unchanged files. if (scanReconnected && reconnected.isNotEmpty && !_scanInProgress) { unawaited(_scanSourcesSequentially(reconnected)); } } Future _scanSourcesSequentially( Iterable sourceIds, { bool forceFullScan = false, }) async { for (final sourceId in sourceIds) { await startSourceScan(sourceId, forceFullScan: forceFullScan); if (_scanCancelRequested) break; } } Future scanAllSources({bool forceFullScan = false}) async { await _ensureLoadedFromDatabase(); final sourceIds = state.sources .where((source) => source.enabled && source.available) .map((source) => source.id) .toList(growable: false); await _scanSourcesSequentially(sourceIds, forceFullScan: forceFullScan); } Future startSourceScan( String sourceId, { bool forceFullScan = false, }) async { await _ensureLoadedFromDatabase(); final source = (await _db.getSources()) .where((entry) => entry.id == sourceId) .firstOrNull; if (source == null || !source.enabled || !source.available) return; await startScan( source.path, forceFullScan: forceFullScan, iosBookmark: source.bookmark, sourceId: source.id, ); } Future _refreshSummaryFromStorage({ DateTime? lastScannedAt, int? excludedDownloadedCount, }) async { final countFuture = _db.getCount(); final indexFuture = _db.getLookupIndex(); final sourcesFuture = _db.getSources(); final count = await countFuture; final index = await indexFuture; final sources = await sourcesFuture; final latestSourceScan = sources .map((source) => source.lastScannedAt) .whereType() .fold( null, (latest, value) => latest == null || value.isAfter(latest) ? value : latest, ); state = state.copyWith( totalCount: count, loadedIndexVersion: state.loadedIndexVersion + 1, lastScannedAt: lastScannedAt ?? latestSourceScan, excludedDownloadedCount: excludedDownloadedCount, sources: sources, trackKeySet: index.matchKeys, isrcSet: index.isrcs, ); _hasLoadedFromDatabase = true; _isLoaded = true; } Future _migrateLegacySource() async { final settings = ref.read(settingsProvider); final path = settings.localLibraryPath.trim(); if (path.isEmpty) return; if (await _db.getSourceByPath(path) != null) return; DateTime? legacyLastScannedAt; try { legacyLastScannedAt = readLocalLibraryLastScannedAt(await _prefs); } catch (_) {} await _db.migrateLegacySource( path: path, displayName: _displayNameForPath(path), bookmark: settings.localLibraryBookmark.trim().isEmpty ? null : settings.localLibraryBookmark, isRemovable: _looksLikeRemovablePath(path), available: await _isPathAvailable( path, bookmark: settings.localLibraryBookmark, ), lastScannedAt: legacyLastScannedAt, ); } String _displayNameForPath(String path) { if (path.startsWith('content://')) { try { final decoded = Uri.decodeComponent(Uri.parse(path).pathSegments.last); final relative = decoded.contains(':') ? decoded.substring(decoded.indexOf(':') + 1) : decoded; if (relative.trim().isNotEmpty) { return relative.split('/').last; } } catch (_) {} return 'Music'; } final normalized = path .replaceAll('\\', '/') .replaceAll(RegExp(r'/+$'), ''); final name = normalized.split('/').last; return name.isEmpty ? path : name; } bool _looksLikeRemovablePath(String path) { if (path.startsWith('content://')) { try { final decoded = Uri.decodeComponent(Uri.parse(path).pathSegments.last); return !decoded.startsWith('primary:'); } catch (_) { return false; } } final normalized = path.replaceAll('\\', '/').toLowerCase(); return normalized.startsWith('/storage/') && !normalized.startsWith('/storage/emulated/'); } Future _isPathAvailable(String path, {String? bookmark}) async { if (Platform.isAndroid && path.startsWith('content://')) { return PlatformBridge.isSafTreeAccessible(path); } if (Platform.isIOS && bookmark != null && bookmark.trim().isNotEmpty) { final access = await PlatformBridge.startAccessingIosBookmark(bookmark); if (access == null) return false; try { return await Directory(access.path).exists(); } finally { await PlatformBridge.stopAccessingIosBookmark(access); } } return await Directory(path).exists(); } Future> _refreshSourceAvailabilityInDatabase() async { final sources = await _db.getSources(); final reconnected = []; for (final source in sources) { final available = await _isPathAvailable( source.path, bookmark: source.bookmark, ); if (available != source.available) { await _db.updateSourceState( source.id, available: available, lastSeenAt: available ? DateTime.now() : null, ); if (available && source.enabled && source.isIndexed) { reconnected.add(source.id); } } } return reconnected; } Future<({int inserted, int skipped})?> _replaceFromFullScanStream({ required String sourceId, required String folderPath, required bool isSaf, }) async { if (_scanCancelRequested) return null; final scanFile = isSaf ? await PlatformBridge.scanSafTreeToNDJSONFile( folderPath, isCancelled: () => _scanCancelRequested, ) : await PlatformBridge.scanLibraryFolderToNDJSONFile( folderPath, isCancelled: () => _scanCancelRequested, ); var ingested = false; try { if (_scanCancelRequested) return null; state = state.copyWith( scanIsFinalizing: true, scanProgress: state.scanProgress >= 99 ? state.scanProgress : 99, scanCurrentFile: null, ); Stream> validatedRows() async* { var decodedRows = 0; await for (final json in scanFile.rows()) { if (_scanCancelRequested) { throw StateError('Library scan cancelled during ingestion'); } decodedRows++; yield json; } if (decodedRows != scanFile.expectedCount) { throw FormatException( 'Library scan row count mismatch: decoded $decodedRows, ' 'expected ${scanFile.expectedCount}', ); } } final result = await _db.replaceSourceStream(sourceId, validatedRows()); _log.i( 'Stream-ingested ${result.inserted}/${scanFile.expectedCount} scan rows ' '(${result.skipped} downloads excluded)', ); ingested = true; return result; } finally { if (ingested) await scanFile.delete(); } } Future startScan( String folderPath, { bool forceFullScan = false, String? iosBookmark, String? sourceId, }) async { if (_scanInProgress || state.isScanning) { _log.w('Scan already in progress'); return; } var activeSourceId = sourceId; if (activeSourceId == null) { final existingSource = await _db.getSourceByPath(folderPath); activeSourceId = existingSource?.id; if (activeSourceId == null) { final source = await addSource( path: folderPath, displayName: _displayNameForPath(folderPath), bookmark: iosBookmark, isRemovable: _looksLikeRemovablePath(folderPath), ); activeSourceId = source.id; } } if (!await _isPathAvailable(folderPath, bookmark: iosBookmark)) { await _db.updateSourceState(activeSourceId, available: false); await _refreshSummaryFromStorage(); return; } _scanInProgress = true; _scanCancelRequested = false; try { final prefs = await _prefs; await prefs.setString(localLibraryActiveScanSourceKey, activeSourceId); } catch (e) { _log.w('Failed to persist active library scan marker: $e'); } _log.i( 'Starting library scan: $folderPath (incremental: ${!forceFullScan})', ); state = state.copyWith( isScanning: true, scanIsFinalizing: false, scanProgress: 0, scanCurrentFile: null, scanTotalFiles: 0, scannedFiles: 0, scanErrorCount: 0, scanWasCancelled: false, scanningSourceId: activeSourceId, ); _resetScanNotificationTracking(); if (_shouldShowScanProgressNotification( progress: 0, totalFiles: 0, isComplete: false, )) { await _showScanProgressNotification( progress: 0, scannedFiles: 0, totalFiles: 0, currentFile: null, ); } try { final appSupportDir = await getApplicationSupportDirectory(); final coverCacheDir = '${appSupportDir.path}/library_covers'; await PlatformBridge.setLibraryCoverCacheDir(coverCacheDir); _log.i('Cover cache directory set to: $coverCacheDir'); } catch (e) { _log.w('Failed to set cover cache directory: $e'); } _startProgressPolling(); String? resolvedPath; IosSecurityScopedAccess? securityAccess; if (Platform.isIOS && iosBookmark != null && iosBookmark.isNotEmpty) { securityAccess = await PlatformBridge.startAccessingIosBookmark( iosBookmark, ); resolvedPath = securityAccess?.path; if (securityAccess != null) { _log.i('Started iOS security-scoped access: $resolvedPath'); } else { _log.w( 'Failed to start iOS security-scoped access, ' 'falling back to original path', ); } } final effectiveFolderPath = resolvedPath ?? folderPath; try { final isSaf = effectiveFolderPath.startsWith('content://'); final useStreamingFullScan = forceFullScan || await _db.getSourceCount(activeSourceId) == 0; if (useStreamingFullScan) { final scanResult = await _replaceFromFullScanStream( sourceId: activeSourceId, folderPath: effectiveFolderPath, isSaf: isSaf, ); if (scanResult == null || _scanCancelRequested) { state = state.copyWith( isScanning: false, scanIsFinalizing: false, scanWasCancelled: true, ); await _showScanCancelledNotification(); return; } state = state.copyWith( scanIsFinalizing: true, scanProgress: state.scanProgress >= 99 ? state.scanProgress : 99, scanCurrentFile: null, ); final skippedDownloads = scanResult.skipped; if (skippedDownloads > 0) { _log.i('Skipped $skippedDownloads files already in download history'); } final now = DateTime.now(); await _db.updateSourceState( activeSourceId, available: true, lastSeenAt: now, lastScannedAt: now, clearLastScanError: true, ); try { final prefs = await SharedPreferences.getInstance(); await writeLocalLibraryLastScannedAt(prefs, now); await prefs.setInt(_excludedDownloadedCountKey, skippedDownloads); _log.d('Saved lastScannedAt: $now'); } catch (e) { _log.w('Failed to save lastScannedAt: $e'); } await _refreshSummaryFromStorage( lastScannedAt: now, excludedDownloadedCount: skippedDownloads, ); state = state.copyWith( isScanning: false, scanIsFinalizing: false, scanProgress: 100, lastScannedAt: now, scanWasCancelled: false, excludedDownloadedCount: skippedDownloads, ); await _pruneLibraryCoverCache(); _log.i( 'Full scan complete: ${state.totalCount} tracks found, ' '$skippedDownloads already in downloads', ); await _showScanCompleteNotification( totalTracks: state.totalCount, excludedDownloadedCount: skippedDownloads, errorCount: state.scanErrorCount, ); } else { final existingFiles = await _db.getFileModTimes( sourceId: activeSourceId, ); _log.i( 'Incremental scan: ${existingFiles.length} existing files in database', ); final backfilledModTimes = await _backfillLegacyFileModTimes( isSaf: isSaf, existingFiles: existingFiles, ); if (backfilledModTimes.isNotEmpty) { await _db.updateFileModTimes(backfilledModTimes); existingFiles.addAll(backfilledModTimes); _log.i('Backfilled ${backfilledModTimes.length} legacy mod times'); } final useSnapshotBridge = Platform.isAndroid && existingFiles.isNotEmpty; final snapshotPath = useSnapshotBridge ? await _db.writeFileModTimesSnapshot(existingFiles) : null; Map result; try { if (_scanCancelRequested) { throw StateError('Library scan cancelled before native scan'); } if (isSaf) { result = useSnapshotBridge && snapshotPath != null ? await PlatformBridge.scanSafTreeIncrementalFromSnapshot( effectiveFolderPath, snapshotPath, ) : await PlatformBridge.scanSafTreeIncremental( effectiveFolderPath, existingFiles, ); } else { result = useSnapshotBridge && snapshotPath != null ? await PlatformBridge.scanLibraryFolderIncrementalFromSnapshot( effectiveFolderPath, snapshotPath, ) : await PlatformBridge.scanLibraryFolderIncremental( effectiveFolderPath, existingFiles, ); } } finally { if (snapshotPath != null) { try { await File(snapshotPath).delete(); } catch (_) {} } } if (_scanCancelRequested) { state = state.copyWith( isScanning: false, scanIsFinalizing: false, scanWasCancelled: true, ); await _showScanCancelledNotification(); return; } state = state.copyWith( scanIsFinalizing: true, scanProgress: state.scanProgress >= 99 ? state.scanProgress : 99, scanCurrentFile: null, ); final scannedList = (result['files'] as List?) ?? (result['scanned'] as List?) ?? []; final deletedPaths = (result['removedUris'] as List?) ?.map((e) => e as String) .toList() ?? (result['deletedPaths'] as List?) ?.map((e) => e as String) .toList() ?? []; final skippedCount = result['skippedCount'] as int? ?? 0; final totalFiles = result['totalFiles'] as int? ?? 0; _log.i( 'Incremental result: ${scannedList.length} scanned, ' '$skippedCount skipped, ${deletedPaths.length} deleted, $totalFiles total', ); final removedDownloaded = await _db.deleteDownloadedRowsForSource( activeSourceId, ); if (removedDownloaded > 0) { _log.i( 'Removed $removedDownloaded downloaded tracks already present in ' 'the local Library index', ); } final updatedItems = []; var skippedDownloads = removedDownloaded; if (scannedList.isNotEmpty) { for (final json in scannedList) { final map = json as Map; final item = LocalLibraryItem.fromJson(map); updatedItems.add(item); } if (updatedItems.isNotEmpty) { final upsertResult = await _db.upsertBatchExcludingHistory( updatedItems.map((e) => e.toJson()).toList(), sourceId: activeSourceId, ); skippedDownloads += upsertResult.skipped; _log.i('Upserted ${upsertResult.upserted} items'); } if (skippedDownloads > 0) { _log.i( 'Skipped $skippedDownloads files already in download history', ); } } if (deletedPaths.isNotEmpty) { final deleteCount = await _db.deleteByPaths(deletedPaths); _log.i('Deleted $deleteCount items from database'); } final now = DateTime.now(); await _db.updateSourceState( activeSourceId, available: true, lastSeenAt: now, lastScannedAt: now, clearLastScanError: true, ); try { final prefs = await SharedPreferences.getInstance(); await writeLocalLibraryLastScannedAt(prefs, now); await prefs.setInt(_excludedDownloadedCountKey, skippedDownloads); _log.d('Saved lastScannedAt: $now'); } catch (e) { _log.w('Failed to save lastScannedAt: $e'); } await _refreshSummaryFromStorage( lastScannedAt: now, excludedDownloadedCount: skippedDownloads, ); state = state.copyWith( isScanning: false, scanIsFinalizing: false, scanProgress: 100, lastScannedAt: now, scanWasCancelled: false, excludedDownloadedCount: skippedDownloads, ); _log.i( 'Incremental scan complete: ${state.totalCount} total tracks ' '(${scannedList.length} new/updated, $skippedCount unchanged, ' '${deletedPaths.length} removed, $skippedDownloads already in downloads)', ); await _showScanCompleteNotification( totalTracks: state.totalCount, excludedDownloadedCount: skippedDownloads, errorCount: state.scanErrorCount, ); } } catch (e, stack) { if (_scanCancelRequested) { _log.i('Library scan cancelled'); state = state.copyWith( isScanning: false, scanIsFinalizing: false, scanWasCancelled: true, ); await _showScanCancelledNotification(); return; } _log.e('Library scan failed: $e', e, stack); await _db.updateSourceState(activeSourceId, lastScanError: e.toString()); await _refreshSummaryFromStorage(); state = state.copyWith( isScanning: false, scanIsFinalizing: false, scanWasCancelled: false, ); await _showScanFailedNotification(e.toString()); } finally { if (securityAccess != null) { await PlatformBridge.stopAccessingIosBookmark(securityAccess); _log.i('Stopped iOS security-scoped access'); } _stopProgressPolling(); _scanInProgress = false; try { final prefs = await _prefs; if (prefs.getString(localLibraryActiveScanSourceKey) == activeSourceId) { await prefs.remove(localLibraryActiveScanSourceKey); } } catch (e) { _log.w('Failed to clear active library scan marker: $e'); } state = state.copyWith(clearScanningSourceId: true); } } void _startProgressPolling() { final useStream = Platform.isAndroid || Platform.isIOS; _progressPoller.start(useStream: useStream); if (useStream) { Future.microtask( () => _progressPoller.pollOnce( (e) => _log.w('Initial library scan progress fetch failed: $e'), ), ); } } Future _handleLibraryScanProgress(Map progress) async { if (_scanCancelRequested) return; final nextProgress = (progress['progress_pct'] as num?)?.toDouble() ?? 0; final normalizedProgress = ((nextProgress * 10).round() / 10).clamp( 0.0, 100.0, ); final isComplete = progress['is_complete'] == true; final displayProgress = isComplete ? 99.0 : (normalizedProgress >= 100.0 ? 99.0 : normalizedProgress); final currentFile = progress['current_file'] as String?; final totalFiles = (progress['total_files'] as num?)?.toInt() ?? 0; final scannedFiles = (progress['scanned_files'] as num?)?.toInt() ?? 0; final errorCount = (progress['error_count'] as num?)?.toInt() ?? 0; final shouldUpdateState = state.scanProgress != displayProgress || state.scanIsFinalizing != isComplete || state.scanCurrentFile != currentFile || state.scanTotalFiles != totalFiles || state.scannedFiles != scannedFiles || state.scanErrorCount != errorCount; if (shouldUpdateState) { state = state.copyWith( scanIsFinalizing: isComplete, scanProgress: displayProgress, scanCurrentFile: isComplete ? null : currentFile, scanTotalFiles: totalFiles, scannedFiles: scannedFiles, scanErrorCount: errorCount, ); } if (_shouldShowScanProgressNotification( progress: normalizedProgress, totalFiles: totalFiles, isComplete: isComplete, )) { await _showScanProgressNotification( progress: normalizedProgress, scannedFiles: scannedFiles, totalFiles: totalFiles, currentFile: currentFile, ); } if (isComplete) { _stopProgressPolling(); } } void _stopProgressPolling() { _progressPoller.stop(); _resetScanNotificationTracking(); } void _resetScanNotificationTracking() { _lastScanNotificationPercent = -1; _lastScanNotificationTotalFiles = -1; _lastScanNotificationAt = DateTime.fromMillisecondsSinceEpoch(0); } bool _shouldShowScanProgressNotification({ required double progress, required int totalFiles, required bool isComplete, }) { final now = DateTime.now(); final percent = progress.round().clamp(0, 100); final percentChanged = percent != _lastScanNotificationPercent; final totalFilesChanged = totalFiles != _lastScanNotificationTotalFiles; final heartbeatDue = now.difference(_lastScanNotificationAt) >= _scanNotificationHeartbeat; if (!percentChanged && !totalFilesChanged && !isComplete && !heartbeatDue) { return false; } _lastScanNotificationPercent = percent; _lastScanNotificationTotalFiles = totalFiles; _lastScanNotificationAt = now; return true; } Future cancelScan() async { if (!state.isScanning) return; _log.i('Cancelling library scan'); _scanCancelRequested = true; await PlatformBridge.cancelLibraryScan(); state = state.copyWith(scanIsFinalizing: false, scanWasCancelled: true); _stopProgressPolling(); await _showScanCancelledNotification(); } Future _showScanProgressNotification({ required double progress, required int scannedFiles, required int totalFiles, required String? currentFile, }) async { try { await _notificationService.showLibraryScanProgress( progress: progress, scannedFiles: scannedFiles, totalFiles: totalFiles, currentFile: _shortenFileForNotification(currentFile), ); } catch (e) { _log.w('Failed to show scan progress notification: $e'); } } Future _showScanCompleteNotification({ required int totalTracks, required int excludedDownloadedCount, required int errorCount, }) async { try { await _notificationService.showLibraryScanComplete( totalTracks: totalTracks, excludedDownloadedCount: excludedDownloadedCount, errorCount: errorCount, ); } catch (e) { _log.w('Failed to show scan complete notification: $e'); } } Future _showScanFailedNotification(String message) async { try { await _notificationService.showLibraryScanFailed(message); } catch (e) { _log.w('Failed to show scan failure notification: $e'); } } Future _showScanCancelledNotification() async { try { await _notificationService.showLibraryScanCancelled(); } catch (e) { _log.w('Failed to show scan cancelled notification: $e'); } } String? _shortenFileForNotification(String? path) { final raw = path?.trim() ?? ''; if (raw.isEmpty) return null; var decoded = raw; try { decoded = Uri.decodeFull(raw); } catch (_) {} final slashIdx = decoded.lastIndexOf('/'); final backslashIdx = decoded.lastIndexOf('\\'); final cut = slashIdx > backslashIdx ? slashIdx : backslashIdx; if (cut >= 0 && cut < decoded.length - 1) { return decoded.substring(cut + 1); } return decoded; } Future cleanupMissingFiles({String? iosBookmark}) async { await refreshSourceAvailability(); var removed = 0; for (final source in state.sources) { if (!source.enabled || !source.available) continue; IosSecurityScopedAccess? securityAccess; try { if (Platform.isIOS && source.bookmark != null && source.bookmark!.isNotEmpty) { securityAccess = await PlatformBridge.startAccessingIosBookmark( source.bookmark!, ); if (securityAccess == null) continue; } removed += await _db.cleanupMissingFiles( sourceId: source.id, canDelete: () async { if (isContentUri(source.path)) { return PlatformBridge.isSafTreeAccessible(source.path); } // Recheck access after the file probes, before deleting a page. // An empty but readable source is valid; an offline one throws. await Directory( securityAccess?.path ?? source.path, ).list(followLinks: false).take(1).drain(); return true; }, ); } finally { if (securityAccess != null) { await PlatformBridge.stopAccessingIosBookmark(securityAccess); } } } if (removed > 0) await _refreshSummaryFromStorage(); return removed; } Future clearLibrary() async { await _db.clearAll(); try { final prefs = await SharedPreferences.getInstance(); await clearLocalLibraryLastScannedAt(prefs); await prefs.remove(_excludedDownloadedCountKey); } catch (e) { _log.w('Failed to clear lastScannedAt: $e'); } await _refreshSummaryFromStorage(); state = state.copyWith(clearLastScannedAt: true); _log.i('Library cleared'); } Future _pruneLibraryCoverCache() async { try { final appSupportDir = await getApplicationSupportDirectory(); final libraryCoverDir = Directory('${appSupportDir.path}/library_covers'); if (!await libraryCoverDir.exists()) { return; } final referencedCoverPaths = {}; var offset = 0; const pageSize = 500; while (true) { final page = await _db.getCoverPaths(limit: pageSize, offset: offset); if (page.isEmpty) break; referencedCoverPaths.addAll(page); if (page.length < pageSize) break; offset += pageSize; } var deletedCount = 0; await for (final entity in libraryCoverDir.list( recursive: true, followLinks: false, )) { if (entity is! File || referencedCoverPaths.contains(entity.path)) { continue; } try { await entity.delete(); deletedCount++; } catch (e) { _log.w( 'Failed deleting stale library cover cache ${entity.path}: $e', ); } } if (deletedCount > 0) { _log.i('Pruned $deletedCount stale library cover cache files'); } } catch (e) { _log.w('Failed pruning library cover cache: $e'); } } Future removeItem(String id) async { await _db.delete(id); await _refreshSummaryFromStorage(); } Future getById(String id) async { final json = await _db.getById(id); return json == null ? null : LocalLibraryItem.fromJson(json); } Future getByIsrcAsync(String isrc) async { final json = await _db.getByIsrc(isrc); return json == null ? null : LocalLibraryItem.fromJson(json); } Future findByTrackAndArtistAsync( String trackName, String artistName, ) async { final json = await _db.findFirstByTrackAndArtist(trackName, artistName); return json == null ? null : LocalLibraryItem.fromJson(json); } Future findExistingAsync({ String? id, String? isrc, String? trackName, String? artistName, }) async { if (id != null && id.isNotEmpty) { final byId = await getById(id); if (byId != null) return byId; } if (isrc != null && isrc.isNotEmpty) { final byIsrc = await getByIsrcAsync(isrc); if (byIsrc != null) return byIsrc; } if (trackName != null && artistName != null) { return findByTrackAndArtistAsync(trackName, artistName); } return null; } Future> _backfillLegacyFileModTimes({ required bool isSaf, required Map existingFiles, }) async { final legacyPaths = existingFiles.entries // Negative timestamps deliberately force a metadata-version rescan; // only a missing timestamp (zero) may be filled from the filesystem. .where((entry) => entry.value == 0) .map((entry) => entry.key) .toList(); if (legacyPaths.isEmpty) { return const {}; } if (isSaf) { final uris = legacyPaths .where((path) => path.startsWith('content://')) .toList(); if (uris.isEmpty) { return const {}; } const chunkSize = 500; final backfilled = {}; try { for (var i = 0; i < uris.length; i += chunkSize) { if (_scanCancelRequested) { break; } final end = (i + chunkSize < uris.length) ? i + chunkSize : uris.length; final chunk = uris.sublist(i, end); final chunkResult = await PlatformBridge.getSafFileModTimes(chunk); backfilled.addAll(chunkResult); } return backfilled; } catch (e) { _log.w('Failed to backfill SAF mod times: $e'); return const {}; } } final paths = legacyPaths .where((path) => !path.startsWith('content://')) .toList(growable: false); const chunkSize = 24; final backfilled = {}; for (var i = 0; i < paths.length; i += chunkSize) { if (_scanCancelRequested) { break; } 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; } } } return backfilled; } } final localLibraryProvider = NotifierProvider( LocalLibraryNotifier.new, ); class LocalLibraryCoverRequest { final String? isrc; final String trackName; final String artistName; const LocalLibraryCoverRequest({ this.isrc, required this.trackName, required this.artistName, }); @override bool operator ==(Object other) { return other is LocalLibraryCoverRequest && other.isrc == isrc && other.trackName == trackName && other.artistName == artistName; } @override int get hashCode => Object.hash(isrc, trackName, artistName); } class LocalLibraryCoverBatchRequest { final List tracks; const LocalLibraryCoverBatchRequest(this.tracks); @override bool operator ==(Object other) { if (other is! LocalLibraryCoverBatchRequest) return false; if (other.tracks.length != tracks.length) return false; for (var i = 0; i < tracks.length; i++) { if (other.tracks[i] != tracks[i]) return false; } return true; } @override int get hashCode => Object.hashAll(tracks); } String? _nonEmptyCoverPath(Map? json) { final coverPath = json?['coverPath'] as String?; final trimmed = coverPath?.trim(); return trimmed == null || trimmed.isEmpty ? null : trimmed; } final localLibraryCoverProvider = FutureProvider.autoDispose .family((ref, request) { ref.watch( localLibraryProvider.select((state) => state.loadedIndexVersion), ); return LibraryDatabase.instance .findExisting( isrc: request.isrc, trackName: request.trackName, artistName: request.artistName, ) .then(_nonEmptyCoverPath); }); final localLibraryFirstCoverProvider = FutureProvider.autoDispose .family((ref, request) async { ref.watch( localLibraryProvider.select((state) => state.loadedIndexVersion), ); final rows = await LibraryDatabase.instance.findExistingBatch([ for (final track in request.tracks) LocalLibraryBatchLookupRequest( isrc: track.isrc, trackName: track.trackName, artistName: track.artistName, ), ]); for (final row in rows) { final cover = _nonEmptyCoverPath(row); if (cover != null) return cover; } return null; });