// lib/model/source/media_store_source.dart import 'dart:async'; import 'package:aves/model/covers.dart'; import 'package:aves/model/dynamic_albums.dart'; import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/entry/origins.dart'; import 'package:aves/model/favourites.dart'; import 'package:aves/model/filters/covered/stored_album.dart'; import 'package:aves/model/grouping/common.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/analysis_controller.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/vaults/vaults.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/debouncer.dart'; import 'package:aves_model/aves_model.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; // Temporaneo: origine remota come nel tuo corrente. const int ORIGIN_REMOTE = 1; class MediaStoreSource extends CollectionSource { final Debouncer _changeDebouncer = Debouncer( delay: ADurations.mediaContentChangeDebounceDelay, ); final Set _changedUris = {}; int? _lastGeneration; SourceScope _loadedScope, _targetScope; bool _canAnalyze = true; // Evita caricamenti paralleli di _loadEntries. bool _loadingEntries = false; @override set canAnalyze(bool enabled) => _canAnalyze = enabled; @override SourceScope get loadedScope => _loadedScope; @override SourceScope get targetScope => _targetScope; @override Future init({ required SourceScope scope, AnalysisController? analysisController, bool loadTopEntriesFirst = false, }) async { _targetScope = scope; await reportService.log('$runtimeType init target scope=$scope'); await _loadEssentials(); addDirectories( albums: settings.pinnedFilters .whereType() .map((v) => v.album) .toSet(), ); await updateGeneration(); unawaited( _loadEntries( analysisController: analysisController, loadTopEntriesFirst: loadTopEntriesFirst, ), ); } Future _loadEssentials() async { final stopwatch = Stopwatch()..start(); state = SourceState.loading; await localMediaDb.init(); debugPrint( '[MediaStoreSource] localMediaDb instance hashCode after init = ${localMediaDb.hashCode}', ); await vaults.init(); await favourites.init(); albumGrouping.init(); albumGrouping.setGroups(settings.albumGroups); albumGrouping.registerSource(this); tagGrouping.init(); tagGrouping.setGroups(settings.tagGroups); tagGrouping.registerSource(this); await covers.init(); await dynamicAlbums.init(); final deviceOffset = DateTime.now().timeZoneOffset.inMilliseconds; final catalogOffset = settings.catalogTimeZoneOffsetMillis; if (deviceOffset != catalogOffset) { unawaited( reportService.log( 'Time zone offset change: $catalogOffset -> $deviceOffset. ' 'Clear catalog metadata to get correct date/times.', ), ); await localMediaDb.clearDates(); await localMediaDb.clearCatalogMetadata(); settings.catalogTimeZoneOffsetMillis = deviceOffset; } debugPrint('[MediaStoreSource] calling loadDates()...'); await loadDates(); debugPrint( '[MediaStoreSource] localMediaDb instance hashCode before loadDates = ${localMediaDb.hashCode}', ); debugPrint( '[MediaStoreSource] BEFORE loadDates: rawDb is null? ${localMediaDb.rawDb == null}', ); debugPrint('[MediaStoreSource] loadDates() DONE'); debugPrint( '$runtimeType load essentials complete in ${stopwatch.elapsed.inMilliseconds}ms', ); } Future _loadEntries({ AnalysisController? analysisController, required bool loadTopEntriesFirst, }) async { if (_loadingEntries) { debugPrint('[MediaStoreSource] _loadEntries skipped: already running'); return; } _loadingEntries = true; try { unawaited(reportService.log('$runtimeType load (known) start')); final stopwatch = Stopwatch()..start(); state = SourceState.loading; // Come Aves originale: pulizia solo in memoria, NON nel DB. clearEntries(); final scopeAlbumFilters = _targetScope?.whereType(); final scopeDirectory = scopeAlbumFilters != null && scopeAlbumFilters.length == 1 ? scopeAlbumFilters.first.album : null; final topEntries = {}; if (loadTopEntriesFirst) { final topIds = settings.topEntryIds?.toSet(); if (topIds != null) { debugPrint( '$runtimeType load ${stopwatch.elapsed} load ${topIds.length} top entries', ); topEntries.addAll(await localMediaDb.loadEntriesById(topIds)); await replaceEntriesInMemory( topEntries, reason: 'loadTopEntriesInMemory', notify: true, updateFolders: false, ); } } debugPrint('$runtimeType load ${stopwatch.elapsed} fetch known entries'); final knownEntries = await localMediaDb.loadEntries( origin: EntryOrigins.mediaStoreContent, directory: scopeDirectory, ); final knownLiveEntries = knownEntries.where((entry) => !entry.trashed).toSet(); final isLargeCollection = knownEntries.length > 80000; if (isLargeCollection && settings.isErrorReportingAllowed) { settings.isErrorReportingAllowed = false; } unawaited( reportService.setCustomKey('is_large_collection', isLargeCollection), ); unawaited( reportService.log('$runtimeType found ${knownEntries.length} known entries'), ); debugPrint('$runtimeType load ${stopwatch.elapsed} check obsolete entries'); final knownDateByContentId = Map.fromEntries( knownLiveEntries.map( (entry) => MapEntry( entry.contentId, entry.dateModifiedMillis, ), ), ); final knownContentIds = knownDateByContentId.keys.toList(); final removedContentIds = (await mediaStoreService.checkObsoleteContentIds(knownContentIds)) .toSet(); debugPrint( '[media_store_source] removedContentIds count=${removedContentIds.length} ' 'sample=${removedContentIds.take(10)}', ); if (topEntries.isNotEmpty) { final removedTopEntries = topEntries.where( (entry) => removedContentIds.contains(entry.contentId), ); await removeEntries( removedTopEntries.map((entry) => entry.uri).toSet(), includeTrash: false, ); } final removedEntries = knownEntries .where((entry) => removedContentIds.contains(entry.contentId)) .toSet(); knownEntries.removeAll(removedEntries); // Rimuovi solo locali, non remote. final localRemovedEntries = removedEntries .where((entry) => entry.origin != ORIGIN_REMOTE) .toSet(); debugPrint( '[media_store_source] removedEntries total=${removedEntries.length} ' 'localRemovedEntries=${localRemovedEntries.length}', ); if (localRemovedEntries.isNotEmpty) { await localMediaDb.removeIds( localRemovedEntries.map((entry) => entry.id).toSet(), ); } // Show known entries. debugPrint('$runtimeType load ${stopwatch.elapsed} load known entries in memory'); await replaceEntriesInMemory( knownEntries, reason: 'loadKnownEntriesInMemory', notify: false, updateFolders: true, ); await _loadVaultEntries(scopeDirectory); debugPrint('$runtimeType load ${stopwatch.elapsed} load metadata'); if (scopeDirectory != null) { final ids = knownLiveEntries.map((entry) => entry.id).toSet(); await loadCatalogMetadata(ids: ids); await loadAddresses(ids: ids); } else { await loadCatalogMetadata(); await loadAddresses(); await loadTrashDetails(); unawaited( deleteExpiredTrash().then( (deletedUris) { if (deletedUris.isNotEmpty) { debugPrint( 'evicted ${deletedUris.length} expired items from the trash', ); removeEntries( deletedUris, includeTrash: true, ); } }, ), ); } updateDerivedFilters(); _loadedScope = _targetScope; if (_canAnalyze) { await _loadNewEntries( analysisController: analysisController, directory: scopeDirectory, knownLiveEntries: knownLiveEntries, knownDateByContentId: knownDateByContentId, ); } else { state = SourceState.ready; } } finally { _loadingEntries = false; } } Future _loadNewEntries({ required AnalysisController? analysisController, required String? directory, required Set knownLiveEntries, required Map knownDateByContentId, }) async { unawaited(reportService.log('$runtimeType load (new) start')); final stopwatch = Stopwatch()..start(); final newEntries = {}; final folderSet = {}; if (directory == null) { newEntries.addAll(await recoverUntrackedTrashItems()); } debugPrint('$runtimeType load ${stopwatch.elapsed} check obsolete paths'); // Escludi remote. final knownPathByContentId = Map.fromEntries( knownLiveEntries .where((entry) => entry.origin != ORIGIN_REMOTE) .map( (entry) => MapEntry( entry.contentId, entry.path, ), ), ); final movedContentIds = (await mediaStoreService.checkObsoletePaths(knownPathByContentId)) .toSet(); for (final contentId in movedContentIds) { knownDateByContentId[contentId] = 0; } debugPrint('$runtimeType load ${stopwatch.elapsed} fetch new entries'); final knownContentIds = knownDateByContentId.keys.toSet(); final completer = Completer(); late final StreamSubscription subscription; subscription = mediaStoreService .getEntries( knownDateByContentId, directory: directory, ) .listen( (entry) { final dir = entry.directory; if (dir != null) { folderSet.add(dir); } final contentId = entry.contentId; final existingEntry = knownContentIds.contains(contentId) ? knownLiveEntries.firstWhereOrNull( (entry) => entry.contentId == contentId, ) : null; entry.id = existingEntry?.id ?? localMediaDb.nextId; newEntries.add(entry); setProgress( done: newEntries.length, total: 0, ); }, onError: (Object e, StackTrace st) async { await subscription.cancel(); if (!completer.isCompleted) { completer.completeError(e, st); } }, onDone: () async { try { if (newEntries.isNotEmpty) { await localMediaDb.insertEntries(newEntries); final duplicates = await localMediaDb.searchLiveDuplicates( EntryOrigins.mediaStoreContent, newEntries, ); if (duplicates.isNotEmpty) { await localMediaDb.removeIds( duplicates.map((v) => v.id).toSet(), ); for (final duplicate in duplicates) { final duplicateId = duplicate.id; newEntries.removeWhere((v) => v.id == duplicateId); } } await mergeEntriesInMemory( newEntries, reason: 'loadNewEntriesInMemory', notify: true, updateFolders: true, ); invalidateAlbumFilterSummary(); } Set? analysisEntries; final analysisIds = analysisController?.entryIds; if (analysisIds != null) { analysisEntries = allEntries .where((entry) => analysisIds.contains(entry.id)) .toSet(); } await analyze( analysisController, entries: analysisEntries, ); // Comportamento incrementale, non distruttivo. if (folderSet.isEmpty) { debugPrint('[folders] Nessuna nuova cartella → DB invariato'); state = SourceState.ready; if (!completer.isCompleted) { completer.complete(); } return; } final existing = await localMediaDb.getLocalFolderPaths(); final newOnes = folderSet.difference(existing.toSet()); if (newOnes.isEmpty) { debugPrint('[folders] Nessuna cartella nuova → DB invariato'); state = SourceState.ready; if (!completer.isCompleted) { completer.complete(); } return; } debugPrint('[folders] Aggiungo ${newOnes.length} nuove cartelle'); await updateDirectories(); notifyAlbumsChanged(); state = SourceState.ready; if (!completer.isCompleted) { completer.complete(); } } catch (e, st) { if (!completer.isCompleted) { completer.completeError(e, st); } } }, cancelOnError: true, ); await completer.future; } @override Future> refreshUris( Set changedUris, { AnalysisController? analysisController, }) async { if (!canRefresh || !isReady) return changedUris; state = SourceState.loading; final changedUriByContentId = Map.fromEntries( changedUris.map((uri) { final pathSegments = Uri.parse(uri).pathSegments; if (pathSegments.isEmpty) return null; final idString = pathSegments.last; final contentId = int.tryParse(idString); if (contentId == null) return null; return MapEntry(contentId, uri); }).nonNulls, ); final obsoleteContentIds = (await mediaStoreService.checkObsoleteContentIds( changedUriByContentId.keys.toList(), )) .toSet(); final obsoleteUris = obsoleteContentIds .map((contentId) => changedUriByContentId[contentId]) .nonNulls .toSet(); // Rimuovi solo locali. final localObsoleteUris = {}; for (final uri in obsoleteUris) { final existingEntry = allEntries.firstWhereOrNull( (entry) => entry.uri == uri, ); if (existingEntry == null) { final sourceEntry = await mediaFetchService.getEntry( uri, null, allowUnsized: true, ); if (sourceEntry != null && sourceEntry.origin != ORIGIN_REMOTE) { localObsoleteUris.add(uri); } } else { if (existingEntry.origin != ORIGIN_REMOTE) { localObsoleteUris.add(uri); } } } if (localObsoleteUris.isNotEmpty) { await removeEntries( localObsoleteUris, includeTrash: false, ); } for (final contentId in obsoleteContentIds) { changedUriByContentId.remove(contentId); } final tempUris = {}; final newEntries = {}; final entriesToRefresh = {}; final existingDirectories = {}; for (final kv in changedUriByContentId.entries) { final contentId = kv.key; final uri = kv.value; final sourceEntry = await mediaFetchService.getEntry( uri, null, ); if (sourceEntry != null) { final existingEntry = allEntries.firstWhereOrNull( (entry) => entry.contentId == contentId, ); if (existingEntry == null || (sourceEntry.dateModifiedMillis ?? 0) > (existingEntry.dateModifiedMillis ?? 0) || sourceEntry.path != existingEntry.path) { final newPath = sourceEntry.path; final volume = newPath != null ? androidFileUtils.getStorageVolume(newPath) : null; if (volume != null) { if (existingEntry != null) { entriesToRefresh.add(existingEntry); } else if (_canAnalyze) { sourceEntry.id = localMediaDb.nextId; newEntries.add(sourceEntry); } final existingDirectory = existingEntry?.directory; if (existingDirectory != null) { existingDirectories.add(existingDirectory); } } else { tempUris.add(uri); } } } } await _refreshVaultEntries( changedUris: changedUris.where(vaults.isVaultEntryUri).toSet(), newEntries: newEntries, entriesToRefresh: entriesToRefresh, existingDirectories: existingDirectories, ); invalidateAlbumFilterSummary( directories: existingDirectories, ); if (newEntries.isNotEmpty) { await localMediaDb.insertEntries(newEntries); final duplicates = await localMediaDb.searchLiveDuplicates( EntryOrigins.mediaStoreContent, newEntries, ); if (duplicates.isNotEmpty) { await localMediaDb.removeIds( duplicates.map((v) => v.id).toSet(), ); for (final duplicate in duplicates) { final duplicateId = duplicate.id; newEntries.removeWhere((v) => v.id == duplicateId); tempUris.add(duplicate.uri); } } await mergeEntriesInMemory( newEntries, reason: 'refreshUrisNewEntriesInMemory', notify: true, updateFolders: true, ); await analyze( analysisController, entries: newEntries, ); } if (entriesToRefresh.isNotEmpty) { await refreshEntries( entriesToRefresh, EntryDataType.values.toSet(), ); } state = SourceState.ready; cleanEmptyAlbums(); await updateDirectories(); notifyAlbumsChanged(); return tempUris; } void onStoreChanged(String? uri) { if (uri != null) _changedUris.add(uri); if (_changedUris.isNotEmpty) { _changeDebouncer(() async { final todo = _changedUris.toSet(); _changedUris.clear(); final tempUris = await refreshUris(todo); if (tempUris.isNotEmpty) { _changedUris.addAll(tempUris); onStoreChanged(null); } }); } } Future checkForChanges() async { final sinceGeneration = _lastGeneration; if (sinceGeneration != null) { _changedUris.addAll( await mediaStoreService.getChangedUris(sinceGeneration), ); onStoreChanged(null); } await updateGeneration(); } Future updateGeneration() async { _lastGeneration = await mediaStoreService.getGeneration(); } Future _loadVaultEntries(String? directory) async { final entries = await localMediaDb.loadEntries( origin: EntryOrigins.vault, directory: directory, ); await mergeEntriesInMemory( entries, reason: 'loadVaultEntriesInMemory', notify: false, updateFolders: true, ); } Future _refreshVaultEntries({ required Set changedUris, required Set newEntries, required Set entriesToRefresh, required Set existingDirectories, }) async { for (final uri in changedUris) { final existingEntry = allEntries.firstWhereOrNull( (entry) => entry.uri == uri, ); if (existingEntry != null) { entriesToRefresh.add(existingEntry); final existingDirectory = existingEntry.directory; if (existingDirectory != null) { existingDirectories.add(existingDirectory); } } else { final sourceEntry = await mediaFetchService.getEntry( uri, null, allowUnsized: true, ); if (sourceEntry != null) { newEntries.add( sourceEntry.copyWith( id: localMediaDb.nextId, origin: EntryOrigins.vault, ), ); } } } } }