diff --git a/lib/providers/local_library_provider.dart b/lib/providers/local_library_provider.dart index 88402fcc..2b308b82 100644 --- a/lib/providers/local_library_provider.dart +++ b/lib/providers/local_library_provider.dart @@ -14,6 +14,10 @@ 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'; +part 'local_library_provider_sources.dart'; +part 'local_library_provider_scan.dart'; +part 'local_library_provider_covers.dart'; + final _log = AppLogger('LocalLibrary'); const _excludedDownloadedCountKey = 'local_library_excluded_downloaded_count'; @@ -382,17 +386,6 @@ class LocalLibraryNotifier extends Notifier { 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) @@ -424,37 +417,6 @@ class LocalLibraryNotifier extends Notifier { }); } - Future _refreshSourceAvailability() async { - await _ensureLoadedFromDatabase(); - final reconnected = {}; - while (ref.mounted) { - final result = await _refreshSourceAvailabilityInDatabase(); - if (!ref.mounted) return; - reconnected.addAll(result.reconnected); - if (result.changed) await _refreshSummaryFromStorage(); - if (result.requestVersion == _availabilityRequestVersion) break; - } - - // 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 (ref.mounted && _scanReconnectedOnRefresh && reconnected.isNotEmpty) { - _pendingReconnectScans.addAll(reconnected); - unawaited(_drainReconnectScans()); - } - } - - Future _scanSourcesSequentially( - Iterable sourceIds, { - bool forceFullScan = false, - }) async { - for (final sourceId in sourceIds) { - if (!ref.mounted) break; - await startSourceScan(sourceId, forceFullScan: forceFullScan); - if (_scanCancelRequested) break; - } - } - Future scanAllSources({bool forceFullScan = false}) async { await _ensureLoadedFromDatabase(); final sourceIds = state.sources @@ -491,328 +453,6 @@ class LocalLibraryNotifier extends Notifier { } } - Future _sourceForScan(String sourceId) async { - if (!ref.mounted) return null; - await _ensureLoadedFromDatabase(); - if (!ref.mounted) return null; - while (ref.mounted) { - final sourceVersion = _sourceVersion; - final source = (await _db.getSources()) - .where((entry) => entry.id == sourceId) - .firstOrNull; - if (!ref.mounted) return null; - if (sourceVersion != _sourceVersion) continue; - return source?.enabled == true && source?.available == true - ? source - : null; - } - return null; - } - - Future _drainReconnectScans() async { - if (_drainingReconnectScans || !ref.mounted) return; - _drainingReconnectScans = true; - try { - while (ref.mounted && _pendingReconnectScans.isNotEmpty) { - final active = _scanCompletion; - if (active != null) await active; - if (!ref.mounted || _pendingReconnectScans.isEmpty) break; - final sourceId = _pendingReconnectScans.first; - await startSourceScan(sourceId); - _pendingReconnectScans.remove(sourceId); - } - } catch (error, stack) { - _log.e('Failed to scan reconnected library source: $error', error, stack); - } finally { - _drainingReconnectScans = false; - } - } - - Future _refreshSummaryFromStorage({ - DateTime? lastScannedAt, - int? excludedDownloadedCount, - }) async { - final sourceVersion = ++_sourceVersion; - final sources = await _db.getSources(); - if (!ref.mounted || sourceVersion != _sourceVersion) return; - state = state.copyWith(sources: sources); - final summary = await Future.wait([ - _db.getCount(), - _db.getLookupIndex(), - ]); - final count = summary[0] as int; - final index = summary[1] as LocalLibraryLookupIndex; - if (!ref.mounted || sourceVersion != _sourceVersion) return; - final latestSourceScan = sources - .map((source) => source.lastScannedAt) - .whereType() - .fold( - null, - (latest, value) => - latest == null || value.isAfter(latest) ? value : latest, - ); - state = state.copyWith( - loadFailed: false, - 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 (_) {} - _sourceVersion++; - 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 (isContentUri(path)) { - 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 (isContentUri(path)) { - 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 { - return await _probePathAvailability(path, bookmark: bookmark) ?? true; - } - - Future _probePathAvailability(String path, {String? bookmark}) async { - // Network outages are transient. Keep the indexed collection visible; - // opening/scanning a source reports connectivity errors without dropping it. - if (path.startsWith('network://')) return true; - try { - if (_availabilityProbe != null) { - return await _availabilityProbe(path, bookmark: bookmark); - } - if (Platform.isAndroid && isContentUri(path)) { - return await PlatformBridge.probeSafTreeReadAccess(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(); - } catch (error) { - // A busy provider or a filesystem exception cannot prove detachment. - _log.w('Library source access probe failed: $error'); - return null; - } - } - - Future<({List reconnected, bool changed, int requestVersion})> - _refreshSourceAvailabilityInDatabase() => - _availabilityCheckFuture ??= _checkSourceAvailability().whenComplete(() { - _availabilityCheckFuture = null; - }); - - Future<({List reconnected, bool changed, int requestVersion})> - _checkSourceAvailability() async { - final reconnected = {}; - var changed = false; - while (ref.mounted) { - final sourceVersion = _sourceVersion; - final requestVersion = _availabilityRequestVersion; - final sources = await _db.getSources(); - var inconclusive = false; - for (final source in sources) { - final available = await _probePathAvailability( - source.path, - bookmark: source.bookmark, - ); - if (!ref.mounted || - sourceVersion != _sourceVersion || - requestVersion != _availabilityRequestVersion) { - break; - } - if (available == null) { - inconclusive = true; - _log.w( - 'Library source access inconclusive; retaining status: ${source.id}', - ); - continue; - } - if (available != source.available) { - await _db.updateSourceState( - source.id, - available: available, - lastSeenAt: available ? DateTime.now() : null, - ); - changed = true; - if (available && source.enabled && source.isIndexed) { - reconnected.add(source.id); - } - } - } - if (!ref.mounted) break; - // A folder edit or a newer storage event supersedes pending probe - // results. Re-read the source snapshot before applying them. - if (sourceVersion != _sourceVersion || - requestVersion != _availabilityRequestVersion) { - continue; - } - _availabilityRetry?.cancel(); - if (inconclusive && _availabilityRetryCount < 3) { - _availabilityRetryCount++; - _availabilityRetry = Timer(const Duration(seconds: 2), () { - unawaited(refreshSourceAvailability(scanReconnected: true)); - }); - } else if (!inconclusive) { - _availabilityRetryCount = 0; - } - return ( - reconnected: reconnected.toList(growable: false), - changed: changed, - requestVersion: requestVersion, - ); - } - return ( - reconnected: reconnected.toList(growable: false), - changed: changed, - requestVersion: _availabilityRequestVersion, - ); - } - - Future<({int inserted, int skipped})?> _replaceFromFullScanStream({ - required String sourceId, - required String folderPath, - required bool isSaf, - required bool forceFullScan, - }) async { - if (_scanCancelRequested) return null; - final scanFile = folderPath.startsWith('network://') - ? await NetworkLibraryScanner().scan( - folderPath, - checkpoint: () async { - while (_scanPauseRequested && - !_scanCancelRequested && - ref.mounted) { - await Future.delayed(const Duration(milliseconds: 100)); - } - if (_scanCancelRequested || !ref.mounted) { - throw StateError('Network library scan cancelled'); - } - }, - onProgress: (processed, errors, name) { - if (!ref.mounted) return; - state = state.copyWith( - scannedFiles: processed, - scanErrorCount: errors, - scanCurrentFile: name, - ); - if (_shouldShowScanProgressNotification( - progress: 0, - totalFiles: 0, - isComplete: false, - )) { - unawaited( - _showScanProgressNotification( - progress: 0, - scannedFiles: processed, - totalFiles: 0, - currentFile: name, - ), - ); - } - }, - ) - : isSaf - ? await PlatformBridge.scanSafTreeToNDJSONFile( - folderPath, - forceFullScan: forceFullScan, - isCancelled: () => _scanCancelRequested, - ) - : await PlatformBridge.scanLibraryFolderToNDJSONFile( - folderPath, - forceFullScan: forceFullScan, - isCancelled: () => _scanCancelRequested, - ); - var ingested = false; - try { - if (_scanCancelRequested) return null; - await _endPauseForFinalization(); - state = state.copyWith( - scanIsFinalizing: true, - scanProgress: state.scanProgress >= 99 ? state.scanProgress : 99, - scanCurrentFile: null, - scannedFiles: scanFile.expectedCount, - scanErrorCount: scanFile.errorCount, - ); - final result = await _db.replaceSourceScanFile( - sourceId, - scanFile, - requestId: _nativeLibraryRequestId ?? 'desktop-library-scan', - isCancelled: () => _scanCancelRequested || !ref.mounted, - ); - _log.i( - 'Stream-ingested ${result.inserted}/${scanFile.expectedCount} scan rows ' - '(${result.skipped} downloads excluded)', - ); - ingested = true; - return result; - } finally { - if (ingested || folderPath.startsWith('network://')) { - await scanFile.delete(); - } - } - } - Future startScan( String folderPath, { bool forceFullScan = false, @@ -857,557 +497,6 @@ class LocalLibraryNotifier extends Notifier { } } - Future _runScan( - String folderPath, { - required bool forceFullScan, - required String? iosBookmark, - required String? sourceId, - }) async { - var activeSourceId = sourceId; - IosSecurityScopedAccess? securityAccess; - try { - if (activeSourceId == null) { - final existingSource = await _db.getSourceByPath(folderPath); - if (!ref.mounted || _scanCancelRequested) return; - activeSourceId = existingSource?.id; - if (activeSourceId == null) { - final source = await addSource( - path: folderPath, - displayName: _displayNameForPath(folderPath), - bookmark: iosBookmark, - isRemovable: _looksLikeRemovablePath(folderPath), - ); - activeSourceId = source.id; - } - } - - final available = await _isPathAvailable( - folderPath, - bookmark: iosBookmark, - ); - if (!ref.mounted || _scanCancelRequested) return; - if (!available) { - _sourceVersion++; - await _db.updateSourceState(activeSourceId, available: false); - await _refreshSummaryFromStorage(); - return; - } - if (await _sourceForScan(activeSourceId) == null || - !ref.mounted || - _scanCancelRequested) { - return; - } - - // A pause left behind by an interrupted Dart session would hold the new - // native scan forever; clear it before the pause control becomes visible. - await _releaseNativeScanPause(); - try { - final prefs = await _prefs; - await prefs.setString(localLibraryActiveScanSourceKey, activeSourceId); - } catch (e) { - _log.w('Failed to persist active library scan marker: $e'); - } - if (!ref.mounted || _scanCancelRequested) return; - _log.i( - 'Starting library scan: $folderPath (incremental: ${!forceFullScan})', - ); - state = state.copyWith( - isScanning: true, - scanIsFinalizing: false, - scanIsPaused: false, - scanProgress: 0, - scanCurrentFile: null, - scanTotalFiles: 0, - scannedFiles: 0, - scanErrorCount: 0, - scanWasCancelled: false, - scanningSourceId: activeSourceId, - ); - _resetScanNotificationTracking(); - // Hold before the first progress notification so Android shows a single - // foreground-service notification; released in the final cleanup below. - await _notificationService.beginLibraryScanWork(); - 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'); - } - if (!ref.mounted || _scanCancelRequested) return; - - if (!folderPath.startsWith('network://')) _startProgressPolling(); - - String? resolvedPath; - 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', - ); - } - } - if (!ref.mounted || _scanCancelRequested) return; - final effectiveFolderPath = resolvedPath ?? folderPath; - - final isSaf = isContentUri(effectiveFolderPath); - - final useStreamingFullScan = - effectiveFolderPath.startsWith('network://') || - forceFullScan || - await _db.getSourceCount(activeSourceId) == 0; - if (useStreamingFullScan) { - if (await _sourceForScan(activeSourceId) == null || - !ref.mounted || - _scanCancelRequested) { - return; - } - final scanResult = await _replaceFromFullScanStream( - sourceId: activeSourceId, - folderPath: effectiveFolderPath, - isSaf: isSaf, - forceFullScan: forceFullScan, - ); - if (!ref.mounted) return; - if (scanResult == null || _scanCancelRequested) { - if (scanResult != null) { - await _refreshSummaryFromStorage( - excludedDownloadedCount: scanResult.skipped, - ); - } - if (!ref.mounted) return; - 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'); - } - - await _finishScan( - sourceId: activeSourceId, - skippedDownloads: skippedDownloads, - pruneCoverCache: true, - summary: () => - 'Full scan complete: ${state.totalCount} tracks found, ' - '$skippedDownloads already in downloads, ' - '${state.scanErrorCount} errors', - ); - } else { - final nativeIngestion = PlatformBridge.supportsCoreBackend; - final existingFiles = nativeIngestion - ? const {} - : await _db.getFileModTimes(sourceId: activeSourceId); - if (!nativeIngestion) { - _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 snapshotPath = nativeIngestion - ? await _db.writeNativeFileModTimesSnapshot( - activeSourceId, - requestId: _nativeLibraryRequestId!, - isCancelled: () => _scanCancelRequested || !ref.mounted, - ) - : null; - - Map result; - try { - if (await _sourceForScan(activeSourceId) == null || - _scanCancelRequested || - !ref.mounted) { - return; - } - if (nativeIngestion) { - result = await _db.scanIncrementalNative( - activeSourceId, - effectiveFolderPath, - snapshotPath!, - isSaf: isSaf, - requestId: _nativeLibraryRequestId!, - isCancelled: () => _scanCancelRequested || !ref.mounted, - ); - } else if (isSaf) { - result = await PlatformBridge.scanSafTreeIncremental( - effectiveFolderPath, - existingFiles, - ); - } else { - result = await PlatformBridge.scanLibraryFolderIncremental( - effectiveFolderPath, - existingFiles, - ); - } - } finally { - if (snapshotPath != null) { - try { - await File(snapshotPath).delete(); - } catch (_) {} - } - } - - if (!ref.mounted) return; - if (_scanCancelRequested || result['cancelled'] == true) { - if (result['committed'] == true) { - await _refreshSummaryFromStorage( - excludedDownloadedCount: (result['skipped'] as num?)?.toInt(), - ); - } - if (!ref.mounted) return; - state = state.copyWith( - isScanning: false, - scanIsFinalizing: false, - scanWasCancelled: true, - ); - await _showScanCancelledNotification(); - return; - } - - await _endPauseForFinalization(); - 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; - final scannedCount = nativeIngestion - ? (result['scanned_count'] as num).toInt() - : scannedList.length; - final deletedCount = nativeIngestion - ? (result['deleted_count'] as num).toInt() - : deletedPaths.length; - if (nativeIngestion && result['committed'] != true) { - throw StateError('Native Library scan did not commit its index'); - } - - _log.i( - 'Incremental result: $scannedCount scanned, ' - '$skippedCount skipped, $deletedCount deleted, $totalFiles total', - ); - - final removedDownloaded = nativeIngestion - ? 0 - : await _db.deleteDownloadedRowsForSource(activeSourceId); - if (removedDownloaded > 0) { - _log.i( - 'Removed $removedDownloaded downloaded tracks already present in ' - 'the local Library index', - ); - } - - final updatedItems = []; - var skippedDownloads = nativeIngestion - ? (result['skipped'] as num).toInt() - : removedDownloaded; - if (!nativeIngestion && 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 (!nativeIngestion && deletedPaths.isNotEmpty) { - final deleteCount = await _db.deleteByPaths(deletedPaths); - _log.i('Deleted $deleteCount items from database'); - } - - await _finishScan( - sourceId: activeSourceId, - skippedDownloads: skippedDownloads, - pruneCoverCache: false, - summary: () => - 'Incremental scan complete: ${state.totalCount} total tracks ' - '($scannedCount new/updated, $skippedCount unchanged, ' - '$deletedCount removed, $skippedDownloads already in downloads, ' - '${state.scanErrorCount} errors)', - ); - } - } catch (e, stack) { - if (!ref.mounted) return; - 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); - if (activeSourceId != null) { - await _db.updateSourceState( - activeSourceId, - lastScanError: e.toString(), - ); - await _refreshSummaryFromStorage(); - } - if (!ref.mounted) return; - state = state.copyWith( - isScanning: false, - scanIsFinalizing: false, - scanWasCancelled: false, - ); - await _showScanFailedNotification(e.toString()); - } finally { - await _notificationService.endBackgroundWork( - NotificationService.libraryScanWorkKind, - ); - if (securityAccess != null) { - await PlatformBridge.stopAccessingIosBookmark(securityAccess); - _log.i('Stopped iOS security-scoped access'); - } - _stopProgressPolling(); - if (_scanPauseRequested) { - _scanPauseRequested = false; - await _releaseNativeScanPause(); - } - 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'); - } - } - } - - /// Shared completion tail of a successful full or incremental scan. - /// [summary] is evaluated after the final state update so it reads the - /// refreshed totals. - Future _finishScan({ - required String sourceId, - required int skippedDownloads, - required bool pruneCoverCache, - required String Function() summary, - }) async { - final now = DateTime.now(); - _sourceVersion++; - await _db.updateSourceState( - sourceId, - 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, - ); - if (!ref.mounted) return; - state = state.copyWith( - isScanning: false, - scanIsFinalizing: false, - scanProgress: 100, - lastScannedAt: now, - scanWasCancelled: false, - excludedDownloadedCount: skippedDownloads, - ); - if (pruneCoverCache) await _pruneLibraryCoverCache(); - - final message = summary(); - if (state.scanErrorCount > 0) { - _log.e(message); - } else { - _log.i(message); - } - await _showScanCompleteNotification( - totalTracks: state.totalCount, - excludedDownloadedCount: skippedDownloads, - errorCount: state.scanErrorCount, - ); - } - - 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 { - // A newly subscribed native stream may still contain the previous scan's - // terminal snapshot (including the initial idle snapshot). Only the scan - // method's result can start finalization; never stop polling on that replay. - if (_scanCancelRequested || - !state.isScanning || - state.scanIsFinalizing || - progress['is_complete'] == true) { - return; - } - final nextProgress = (progress['progress_pct'] as num?)?.toDouble() ?? 0; - final normalizedProgress = ((nextProgress * 10).round() / 10).clamp( - 0.0, - 100.0, - ); - final displayProgress = 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.scanCurrentFile != currentFile || - state.scanTotalFiles != totalFiles || - state.scannedFiles != scannedFiles || - state.scanErrorCount != errorCount; - - if (shouldUpdateState) { - state = state.copyWith( - scanProgress: displayProgress, - scanCurrentFile: currentFile, - scanTotalFiles: totalFiles, - scannedFiles: scannedFiles, - scanErrorCount: errorCount, - ); - } - - // Files already in flight still finish after a pause; count them, but - // keep the paused notice instead of reposting progress. - if (!state.scanIsPaused && - _shouldShowScanProgressNotification( - progress: normalizedProgress, - totalFiles: totalFiles, - isComplete: false, - )) { - await _showScanProgressNotification( - progress: normalizedProgress, - scannedFiles: scannedFiles, - totalFiles: totalFiles, - currentFile: currentFile, - ); - } - } - - 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; - } - - bool get _isScanningNetwork => state.sources.any( - (source) => - source.id == state.scanningSourceId && - source.path.startsWith('network://'), - ); - Future cancelScan() async { if (!ref.mounted || (!_scanInProgress && !state.isScanning)) return; @@ -1481,111 +570,6 @@ class LocalLibraryNotifier extends Notifier { await _releaseNativeScanPause(); } - Future _releaseNativeScanPause() async { - try { - await PlatformBridge.resumeLibraryScan(); - } catch (e) { - _log.w('Failed to release library scan pause: $e'); - } - } - - /// The native scan can finish while paused when the pause lands after its - /// last file. Ingestion then runs normally, so restore background work. - Future _endPauseForFinalization() async { - if (!_scanPauseRequested) return; - _scanPauseRequested = false; - state = state.copyWith(scanIsPaused: false); - await _notificationService.beginLibraryScanWork(); - await _cancelScanPausedNotification(); - await _releaseNativeScanPause(); - } - - Future _showScanPausedNotification(int scannedFiles) async { - try { - await _notificationService.showLibraryScanPaused( - scannedFiles: scannedFiles, - ); - } catch (e) { - _log.w('Failed to show scan paused notification: $e'); - } - } - - Future _cancelScanPausedNotification() async { - try { - await _notificationService.cancelLibraryScanNotification(); - } catch (e) { - _log.w('Failed to clear scan paused notification: $e'); - } - } - - 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; @@ -1643,39 +627,6 @@ class LocalLibraryNotifier extends Notifier { _log.i('Library cleared'); } - Future _pruneLibraryCoverCache() async { - try { - final appSupportDir = await getApplicationSupportDirectory(); - final libraryCoverDir = Directory('${appSupportDir.path}/library_covers'); - if (!await libraryCoverDir.exists()) { - return; - } - - if (!ref.mounted || _scanCancelRequested) return; - final libraryDatabase = await _db.database; - if (!ref.mounted || _scanCancelRequested) return; - - final deletedCount = await pruneUnreferencedLibraryCovers( - libraryCoverDir, - const {}, - libraryDatabase: libraryDatabase, - requestId: _nativeLibraryRequestId, - canPrune: () => ref.mounted && !_scanCancelRequested, - // Avoid removing covers being written by a background producer before - // its Library transaction publishes the reference. The scan guard is - // still held here; the source sqflite transaction holds the writer - // barrier while Rust reads its detached, private cover projection. - minimumAge: const Duration(minutes: 1), - ); - - if (deletedCount > 0) { - _log.i('Pruned $deletedCount stale library cover cache files'); - } - } catch (e) { - _log.w('Failed pruning library cover cache: $e'); - } - } - Future removePhysicalFiles(Iterable filePaths) async { await removeItems(await _db.getPhysicalFileIds(filePaths)); } @@ -1727,163 +678,9 @@ class LocalLibraryNotifier extends Notifier { } 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) => isContentUri(path)).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) => !isContentUri(path)) - .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; - }); diff --git a/lib/providers/local_library_provider_covers.dart b/lib/providers/local_library_provider_covers.dart new file mode 100644 index 00000000..96bdd1a3 --- /dev/null +++ b/lib/providers/local_library_provider_covers.dart @@ -0,0 +1,83 @@ +part of 'local_library_provider.dart'; + +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; + }); diff --git a/lib/providers/local_library_provider_scan.dart b/lib/providers/local_library_provider_scan.dart new file mode 100644 index 00000000..dfb04321 --- /dev/null +++ b/lib/providers/local_library_provider_scan.dart @@ -0,0 +1,851 @@ +// ignore_for_file: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member +part of 'local_library_provider.dart'; + +/// Scan execution, progress polling, pause handling and scan notifications. +extension _LocalLibraryScan on LocalLibraryNotifier { + Future<({int inserted, int skipped})?> _replaceFromFullScanStream({ + required String sourceId, + required String folderPath, + required bool isSaf, + required bool forceFullScan, + }) async { + if (_scanCancelRequested) return null; + final scanFile = folderPath.startsWith('network://') + ? await NetworkLibraryScanner().scan( + folderPath, + checkpoint: () async { + while (_scanPauseRequested && + !_scanCancelRequested && + ref.mounted) { + await Future.delayed(const Duration(milliseconds: 100)); + } + if (_scanCancelRequested || !ref.mounted) { + throw StateError('Network library scan cancelled'); + } + }, + onProgress: (processed, errors, name) { + if (!ref.mounted) return; + state = state.copyWith( + scannedFiles: processed, + scanErrorCount: errors, + scanCurrentFile: name, + ); + if (_shouldShowScanProgressNotification( + progress: 0, + totalFiles: 0, + isComplete: false, + )) { + unawaited( + _showScanProgressNotification( + progress: 0, + scannedFiles: processed, + totalFiles: 0, + currentFile: name, + ), + ); + } + }, + ) + : isSaf + ? await PlatformBridge.scanSafTreeToNDJSONFile( + folderPath, + forceFullScan: forceFullScan, + isCancelled: () => _scanCancelRequested, + ) + : await PlatformBridge.scanLibraryFolderToNDJSONFile( + folderPath, + forceFullScan: forceFullScan, + isCancelled: () => _scanCancelRequested, + ); + var ingested = false; + try { + if (_scanCancelRequested) return null; + await _endPauseForFinalization(); + state = state.copyWith( + scanIsFinalizing: true, + scanProgress: state.scanProgress >= 99 ? state.scanProgress : 99, + scanCurrentFile: null, + scannedFiles: scanFile.expectedCount, + scanErrorCount: scanFile.errorCount, + ); + final result = await _db.replaceSourceScanFile( + sourceId, + scanFile, + requestId: _nativeLibraryRequestId ?? 'desktop-library-scan', + isCancelled: () => _scanCancelRequested || !ref.mounted, + ); + _log.i( + 'Stream-ingested ${result.inserted}/${scanFile.expectedCount} scan rows ' + '(${result.skipped} downloads excluded)', + ); + ingested = true; + return result; + } finally { + if (ingested || folderPath.startsWith('network://')) { + await scanFile.delete(); + } + } + } + + Future _runScan( + String folderPath, { + required bool forceFullScan, + required String? iosBookmark, + required String? sourceId, + }) async { + var activeSourceId = sourceId; + IosSecurityScopedAccess? securityAccess; + try { + if (activeSourceId == null) { + final existingSource = await _db.getSourceByPath(folderPath); + if (!ref.mounted || _scanCancelRequested) return; + activeSourceId = existingSource?.id; + if (activeSourceId == null) { + final source = await addSource( + path: folderPath, + displayName: _displayNameForPath(folderPath), + bookmark: iosBookmark, + isRemovable: _looksLikeRemovablePath(folderPath), + ); + activeSourceId = source.id; + } + } + + final available = await _isPathAvailable( + folderPath, + bookmark: iosBookmark, + ); + if (!ref.mounted || _scanCancelRequested) return; + if (!available) { + _sourceVersion++; + await _db.updateSourceState(activeSourceId, available: false); + await _refreshSummaryFromStorage(); + return; + } + if (await _sourceForScan(activeSourceId) == null || + !ref.mounted || + _scanCancelRequested) { + return; + } + + // A pause left behind by an interrupted Dart session would hold the new + // native scan forever; clear it before the pause control becomes visible. + await _releaseNativeScanPause(); + try { + final prefs = await _prefs; + await prefs.setString(localLibraryActiveScanSourceKey, activeSourceId); + } catch (e) { + _log.w('Failed to persist active library scan marker: $e'); + } + if (!ref.mounted || _scanCancelRequested) return; + _log.i( + 'Starting library scan: $folderPath (incremental: ${!forceFullScan})', + ); + state = state.copyWith( + isScanning: true, + scanIsFinalizing: false, + scanIsPaused: false, + scanProgress: 0, + scanCurrentFile: null, + scanTotalFiles: 0, + scannedFiles: 0, + scanErrorCount: 0, + scanWasCancelled: false, + scanningSourceId: activeSourceId, + ); + _resetScanNotificationTracking(); + // Hold before the first progress notification so Android shows a single + // foreground-service notification; released in the final cleanup below. + await _notificationService.beginLibraryScanWork(); + 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'); + } + if (!ref.mounted || _scanCancelRequested) return; + + if (!folderPath.startsWith('network://')) _startProgressPolling(); + + String? resolvedPath; + 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', + ); + } + } + if (!ref.mounted || _scanCancelRequested) return; + final effectiveFolderPath = resolvedPath ?? folderPath; + + final isSaf = isContentUri(effectiveFolderPath); + + final useStreamingFullScan = + effectiveFolderPath.startsWith('network://') || + forceFullScan || + await _db.getSourceCount(activeSourceId) == 0; + if (useStreamingFullScan) { + if (await _sourceForScan(activeSourceId) == null || + !ref.mounted || + _scanCancelRequested) { + return; + } + final scanResult = await _replaceFromFullScanStream( + sourceId: activeSourceId, + folderPath: effectiveFolderPath, + isSaf: isSaf, + forceFullScan: forceFullScan, + ); + if (!ref.mounted) return; + if (scanResult == null || _scanCancelRequested) { + if (scanResult != null) { + await _refreshSummaryFromStorage( + excludedDownloadedCount: scanResult.skipped, + ); + } + if (!ref.mounted) return; + 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'); + } + + await _finishScan( + sourceId: activeSourceId, + skippedDownloads: skippedDownloads, + pruneCoverCache: true, + summary: () => + 'Full scan complete: ${state.totalCount} tracks found, ' + '$skippedDownloads already in downloads, ' + '${state.scanErrorCount} errors', + ); + } else { + final nativeIngestion = PlatformBridge.supportsCoreBackend; + final existingFiles = nativeIngestion + ? const {} + : await _db.getFileModTimes(sourceId: activeSourceId); + if (!nativeIngestion) { + _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 snapshotPath = nativeIngestion + ? await _db.writeNativeFileModTimesSnapshot( + activeSourceId, + requestId: _nativeLibraryRequestId!, + isCancelled: () => _scanCancelRequested || !ref.mounted, + ) + : null; + + Map result; + try { + if (await _sourceForScan(activeSourceId) == null || + _scanCancelRequested || + !ref.mounted) { + return; + } + if (nativeIngestion) { + result = await _db.scanIncrementalNative( + activeSourceId, + effectiveFolderPath, + snapshotPath!, + isSaf: isSaf, + requestId: _nativeLibraryRequestId!, + isCancelled: () => _scanCancelRequested || !ref.mounted, + ); + } else if (isSaf) { + result = await PlatformBridge.scanSafTreeIncremental( + effectiveFolderPath, + existingFiles, + ); + } else { + result = await PlatformBridge.scanLibraryFolderIncremental( + effectiveFolderPath, + existingFiles, + ); + } + } finally { + if (snapshotPath != null) { + try { + await File(snapshotPath).delete(); + } catch (_) {} + } + } + + if (!ref.mounted) return; + if (_scanCancelRequested || result['cancelled'] == true) { + if (result['committed'] == true) { + await _refreshSummaryFromStorage( + excludedDownloadedCount: (result['skipped'] as num?)?.toInt(), + ); + } + if (!ref.mounted) return; + state = state.copyWith( + isScanning: false, + scanIsFinalizing: false, + scanWasCancelled: true, + ); + await _showScanCancelledNotification(); + return; + } + + await _endPauseForFinalization(); + 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; + final scannedCount = nativeIngestion + ? (result['scanned_count'] as num).toInt() + : scannedList.length; + final deletedCount = nativeIngestion + ? (result['deleted_count'] as num).toInt() + : deletedPaths.length; + if (nativeIngestion && result['committed'] != true) { + throw StateError('Native Library scan did not commit its index'); + } + + _log.i( + 'Incremental result: $scannedCount scanned, ' + '$skippedCount skipped, $deletedCount deleted, $totalFiles total', + ); + + final removedDownloaded = nativeIngestion + ? 0 + : await _db.deleteDownloadedRowsForSource(activeSourceId); + if (removedDownloaded > 0) { + _log.i( + 'Removed $removedDownloaded downloaded tracks already present in ' + 'the local Library index', + ); + } + + final updatedItems = []; + var skippedDownloads = nativeIngestion + ? (result['skipped'] as num).toInt() + : removedDownloaded; + if (!nativeIngestion && 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 (!nativeIngestion && deletedPaths.isNotEmpty) { + final deleteCount = await _db.deleteByPaths(deletedPaths); + _log.i('Deleted $deleteCount items from database'); + } + + await _finishScan( + sourceId: activeSourceId, + skippedDownloads: skippedDownloads, + pruneCoverCache: false, + summary: () => + 'Incremental scan complete: ${state.totalCount} total tracks ' + '($scannedCount new/updated, $skippedCount unchanged, ' + '$deletedCount removed, $skippedDownloads already in downloads, ' + '${state.scanErrorCount} errors)', + ); + } + } catch (e, stack) { + if (!ref.mounted) return; + 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); + if (activeSourceId != null) { + await _db.updateSourceState( + activeSourceId, + lastScanError: e.toString(), + ); + await _refreshSummaryFromStorage(); + } + if (!ref.mounted) return; + state = state.copyWith( + isScanning: false, + scanIsFinalizing: false, + scanWasCancelled: false, + ); + await _showScanFailedNotification(e.toString()); + } finally { + await _notificationService.endBackgroundWork( + NotificationService.libraryScanWorkKind, + ); + if (securityAccess != null) { + await PlatformBridge.stopAccessingIosBookmark(securityAccess); + _log.i('Stopped iOS security-scoped access'); + } + _stopProgressPolling(); + if (_scanPauseRequested) { + _scanPauseRequested = false; + await _releaseNativeScanPause(); + } + 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'); + } + } + } + + /// Shared completion tail of a successful full or incremental scan. + /// [summary] is evaluated after the final state update so it reads the + /// refreshed totals. + Future _finishScan({ + required String sourceId, + required int skippedDownloads, + required bool pruneCoverCache, + required String Function() summary, + }) async { + final now = DateTime.now(); + _sourceVersion++; + await _db.updateSourceState( + sourceId, + 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, + ); + if (!ref.mounted) return; + state = state.copyWith( + isScanning: false, + scanIsFinalizing: false, + scanProgress: 100, + lastScannedAt: now, + scanWasCancelled: false, + excludedDownloadedCount: skippedDownloads, + ); + if (pruneCoverCache) await _pruneLibraryCoverCache(); + + final message = summary(); + if (state.scanErrorCount > 0) { + _log.e(message); + } else { + _log.i(message); + } + await _showScanCompleteNotification( + totalTracks: state.totalCount, + excludedDownloadedCount: skippedDownloads, + errorCount: state.scanErrorCount, + ); + } + + 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 { + // A newly subscribed native stream may still contain the previous scan's + // terminal snapshot (including the initial idle snapshot). Only the scan + // method's result can start finalization; never stop polling on that replay. + if (_scanCancelRequested || + !state.isScanning || + state.scanIsFinalizing || + progress['is_complete'] == true) { + return; + } + final nextProgress = (progress['progress_pct'] as num?)?.toDouble() ?? 0; + final normalizedProgress = ((nextProgress * 10).round() / 10).clamp( + 0.0, + 100.0, + ); + final displayProgress = 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.scanCurrentFile != currentFile || + state.scanTotalFiles != totalFiles || + state.scannedFiles != scannedFiles || + state.scanErrorCount != errorCount; + + if (shouldUpdateState) { + state = state.copyWith( + scanProgress: displayProgress, + scanCurrentFile: currentFile, + scanTotalFiles: totalFiles, + scannedFiles: scannedFiles, + scanErrorCount: errorCount, + ); + } + + // Files already in flight still finish after a pause; count them, but + // keep the paused notice instead of reposting progress. + if (!state.scanIsPaused && + _shouldShowScanProgressNotification( + progress: normalizedProgress, + totalFiles: totalFiles, + isComplete: false, + )) { + await _showScanProgressNotification( + progress: normalizedProgress, + scannedFiles: scannedFiles, + totalFiles: totalFiles, + currentFile: currentFile, + ); + } + } + + 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) >= + LocalLibraryNotifier._scanNotificationHeartbeat; + + if (!percentChanged && !totalFilesChanged && !isComplete && !heartbeatDue) { + return false; + } + + _lastScanNotificationPercent = percent; + _lastScanNotificationTotalFiles = totalFiles; + _lastScanNotificationAt = now; + return true; + } + + bool get _isScanningNetwork => state.sources.any( + (source) => + source.id == state.scanningSourceId && + source.path.startsWith('network://'), + ); + + Future _releaseNativeScanPause() async { + try { + await PlatformBridge.resumeLibraryScan(); + } catch (e) { + _log.w('Failed to release library scan pause: $e'); + } + } + + /// The native scan can finish while paused when the pause lands after its + /// last file. Ingestion then runs normally, so restore background work. + Future _endPauseForFinalization() async { + if (!_scanPauseRequested) return; + _scanPauseRequested = false; + state = state.copyWith(scanIsPaused: false); + await _notificationService.beginLibraryScanWork(); + await _cancelScanPausedNotification(); + await _releaseNativeScanPause(); + } + + Future _showScanPausedNotification(int scannedFiles) async { + try { + await _notificationService.showLibraryScanPaused( + scannedFiles: scannedFiles, + ); + } catch (e) { + _log.w('Failed to show scan paused notification: $e'); + } + } + + Future _cancelScanPausedNotification() async { + try { + await _notificationService.cancelLibraryScanNotification(); + } catch (e) { + _log.w('Failed to clear scan paused notification: $e'); + } + } + + 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 _pruneLibraryCoverCache() async { + try { + final appSupportDir = await getApplicationSupportDirectory(); + final libraryCoverDir = Directory('${appSupportDir.path}/library_covers'); + if (!await libraryCoverDir.exists()) { + return; + } + + if (!ref.mounted || _scanCancelRequested) return; + final libraryDatabase = await _db.database; + if (!ref.mounted || _scanCancelRequested) return; + + final deletedCount = await pruneUnreferencedLibraryCovers( + libraryCoverDir, + const {}, + libraryDatabase: libraryDatabase, + requestId: _nativeLibraryRequestId, + canPrune: () => ref.mounted && !_scanCancelRequested, + // Avoid removing covers being written by a background producer before + // its Library transaction publishes the reference. The scan guard is + // still held here; the source sqflite transaction holds the writer + // barrier while Rust reads its detached, private cover projection. + minimumAge: const Duration(minutes: 1), + ); + + if (deletedCount > 0) { + _log.i('Pruned $deletedCount stale library cover cache files'); + } + } catch (e) { + _log.w('Failed pruning library cover cache: $e'); + } + } + + 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) => isContentUri(path)).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) => !isContentUri(path)) + .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; + } +} diff --git a/lib/providers/local_library_provider_sources.dart b/lib/providers/local_library_provider_sources.dart new file mode 100644 index 00000000..dc81a874 --- /dev/null +++ b/lib/providers/local_library_provider_sources.dart @@ -0,0 +1,286 @@ +// ignore_for_file: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member +part of 'local_library_provider.dart'; + +/// Source identity, availability probing, reconnect scans and the summary +/// refresh that follows a source change. +extension _LocalLibrarySources on LocalLibraryNotifier { + 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 _refreshSourceAvailability() async { + await _ensureLoadedFromDatabase(); + final reconnected = {}; + while (ref.mounted) { + final result = await _refreshSourceAvailabilityInDatabase(); + if (!ref.mounted) return; + reconnected.addAll(result.reconnected); + if (result.changed) await _refreshSummaryFromStorage(); + if (result.requestVersion == _availabilityRequestVersion) break; + } + + // 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 (ref.mounted && _scanReconnectedOnRefresh && reconnected.isNotEmpty) { + _pendingReconnectScans.addAll(reconnected); + unawaited(_drainReconnectScans()); + } + } + + Future _scanSourcesSequentially( + Iterable sourceIds, { + bool forceFullScan = false, + }) async { + for (final sourceId in sourceIds) { + if (!ref.mounted) break; + await startSourceScan(sourceId, forceFullScan: forceFullScan); + if (_scanCancelRequested) break; + } + } + + Future _sourceForScan(String sourceId) async { + if (!ref.mounted) return null; + await _ensureLoadedFromDatabase(); + if (!ref.mounted) return null; + while (ref.mounted) { + final sourceVersion = _sourceVersion; + final source = (await _db.getSources()) + .where((entry) => entry.id == sourceId) + .firstOrNull; + if (!ref.mounted) return null; + if (sourceVersion != _sourceVersion) continue; + return source?.enabled == true && source?.available == true + ? source + : null; + } + return null; + } + + Future _drainReconnectScans() async { + if (_drainingReconnectScans || !ref.mounted) return; + _drainingReconnectScans = true; + try { + while (ref.mounted && _pendingReconnectScans.isNotEmpty) { + final active = _scanCompletion; + if (active != null) await active; + if (!ref.mounted || _pendingReconnectScans.isEmpty) break; + final sourceId = _pendingReconnectScans.first; + await startSourceScan(sourceId); + _pendingReconnectScans.remove(sourceId); + } + } catch (error, stack) { + _log.e('Failed to scan reconnected library source: $error', error, stack); + } finally { + _drainingReconnectScans = false; + } + } + + Future _refreshSummaryFromStorage({ + DateTime? lastScannedAt, + int? excludedDownloadedCount, + }) async { + final sourceVersion = ++_sourceVersion; + final sources = await _db.getSources(); + if (!ref.mounted || sourceVersion != _sourceVersion) return; + state = state.copyWith(sources: sources); + final summary = await Future.wait([ + _db.getCount(), + _db.getLookupIndex(), + ]); + final count = summary[0] as int; + final index = summary[1] as LocalLibraryLookupIndex; + if (!ref.mounted || sourceVersion != _sourceVersion) return; + final latestSourceScan = sources + .map((source) => source.lastScannedAt) + .whereType() + .fold( + null, + (latest, value) => + latest == null || value.isAfter(latest) ? value : latest, + ); + state = state.copyWith( + loadFailed: false, + 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 (_) {} + _sourceVersion++; + 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 (isContentUri(path)) { + 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 (isContentUri(path)) { + 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 { + return await _probePathAvailability(path, bookmark: bookmark) ?? true; + } + + Future _probePathAvailability(String path, {String? bookmark}) async { + // Network outages are transient. Keep the indexed collection visible; + // opening/scanning a source reports connectivity errors without dropping it. + if (path.startsWith('network://')) return true; + try { + if (_availabilityProbe != null) { + return await _availabilityProbe(path, bookmark: bookmark); + } + if (Platform.isAndroid && isContentUri(path)) { + return await PlatformBridge.probeSafTreeReadAccess(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(); + } catch (error) { + // A busy provider or a filesystem exception cannot prove detachment. + _log.w('Library source access probe failed: $error'); + return null; + } + } + + Future<({List reconnected, bool changed, int requestVersion})> + _refreshSourceAvailabilityInDatabase() => + _availabilityCheckFuture ??= _checkSourceAvailability().whenComplete(() { + _availabilityCheckFuture = null; + }); + + Future<({List reconnected, bool changed, int requestVersion})> + _checkSourceAvailability() async { + final reconnected = {}; + var changed = false; + while (ref.mounted) { + final sourceVersion = _sourceVersion; + final requestVersion = _availabilityRequestVersion; + final sources = await _db.getSources(); + var inconclusive = false; + for (final source in sources) { + final available = await _probePathAvailability( + source.path, + bookmark: source.bookmark, + ); + if (!ref.mounted || + sourceVersion != _sourceVersion || + requestVersion != _availabilityRequestVersion) { + break; + } + if (available == null) { + inconclusive = true; + _log.w( + 'Library source access inconclusive; retaining status: ${source.id}', + ); + continue; + } + if (available != source.available) { + await _db.updateSourceState( + source.id, + available: available, + lastSeenAt: available ? DateTime.now() : null, + ); + changed = true; + if (available && source.enabled && source.isIndexed) { + reconnected.add(source.id); + } + } + } + if (!ref.mounted) break; + // A folder edit or a newer storage event supersedes pending probe + // results. Re-read the source snapshot before applying them. + if (sourceVersion != _sourceVersion || + requestVersion != _availabilityRequestVersion) { + continue; + } + _availabilityRetry?.cancel(); + if (inconclusive && _availabilityRetryCount < 3) { + _availabilityRetryCount++; + _availabilityRetry = Timer(const Duration(seconds: 2), () { + unawaited(refreshSourceAvailability(scanReconnected: true)); + }); + } else if (!inconclusive) { + _availabilityRetryCount = 0; + } + return ( + reconnected: reconnected.toList(growable: false), + changed: changed, + requestVersion: requestVersion, + ); + } + return ( + reconnected: reconnected.toList(growable: false), + changed: changed, + requestVersion: _availabilityRequestVersion, + ); + } +}