Files
SpotiFLAC-Mobile/lib/providers/local_library_provider.dart
T

1455 lines
46 KiB
Dart

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