From ff7afa5b3c301d3cce28f70bd89266faabae4138 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:09:43 +0700 Subject: [PATCH] refactor(library): split local_library_provider.dart into source, scan and cover parts The provider file mixed source management, the scan pipeline and the cover lookup providers. Move the private notifier methods into extensions in two parts (_LocalLibrarySources, _LocalLibraryScan) and the cover request types and providers into a third part. The moves are verbatim except for one reference to the static heartbeat constant, which an extension must qualify with the class name. Public notifier methods, which test subclasses override, stay on the class in the library file. --- lib/providers/local_library_provider.dart | 1211 +---------------- .../local_library_provider_covers.dart | 83 ++ .../local_library_provider_scan.dart | 851 ++++++++++++ .../local_library_provider_sources.dart | 286 ++++ 4 files changed, 1224 insertions(+), 1207 deletions(-) create mode 100644 lib/providers/local_library_provider_covers.dart create mode 100644 lib/providers/local_library_provider_scan.dart create mode 100644 lib/providers/local_library_provider_sources.dart 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, + ); + } +}