aves_mio0.31/lib/model/source/media_store_source.dart
2026-07-18 13:39:22 +02:00

785 lines
20 KiB
Dart

// 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';
import 'package:aves/remote/remote_origin.dart';
// Temporaneo: origine remota come nel tuo corrente.
class MediaStoreSource extends CollectionSource {
final Debouncer _changeDebouncer = Debouncer(
delay: ADurations.mediaContentChangeDebounceDelay,
);
final Set<String> _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<void> 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<StoredAlbumFilter>()
.map((v) => v.album)
.toSet(),
);
await updateGeneration();
unawaited(
_loadEntries(
analysisController: analysisController,
loadTopEntriesFirst: loadTopEntriesFirst,
),
);
}
Future<void> _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<void> _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<StoredAlbumFilter>();
final scopeDirectory =
scopeAlbumFilters != null && scopeAlbumFilters.length == 1
? scopeAlbumFilters.first.album
: null;
final topEntries = <AvesEntry>{};
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 != RemoteOrigin.value)
.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<void> _loadNewEntries({
required AnalysisController? analysisController,
required String? directory,
required Set<AvesEntry> knownLiveEntries,
required Map<int?, int?> knownDateByContentId,
}) async {
unawaited(reportService.log('$runtimeType load (new) start'));
final stopwatch = Stopwatch()..start();
final newEntries = <AvesEntry>{};
final folderSet = <String>{};
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 != RemoteOrigin.value)
.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<void>();
late final StreamSubscription<AvesEntry> 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<AvesEntry>? 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<Set<String>> refreshUris(
Set<String> 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 = <String>{};
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 != RemoteOrigin.value) {
localObsoleteUris.add(uri);
}
} else {
if (existingEntry.origin != RemoteOrigin.value) {
localObsoleteUris.add(uri);
}
}
}
if (localObsoleteUris.isNotEmpty) {
await removeEntries(
localObsoleteUris,
includeTrash: false,
);
}
for (final contentId in obsoleteContentIds) {
changedUriByContentId.remove(contentId);
}
final tempUris = <String>{};
final newEntries = <AvesEntry>{};
final entriesToRefresh = <AvesEntry>{};
final existingDirectories = <String>{};
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<void> checkForChanges() async {
final sinceGeneration = _lastGeneration;
if (sinceGeneration != null) {
_changedUris.addAll(
await mediaStoreService.getChangedUris(sinceGeneration),
);
onStoreChanged(null);
}
await updateGeneration();
}
Future<void> updateGeneration() async {
_lastGeneration = await mediaStoreService.getGeneration();
}
Future<void> _loadVaultEntries(String? directory) async {
final entries = await localMediaDb.loadEntries(
origin: EntryOrigins.vault,
directory: directory,
);
await mergeEntriesInMemory(
entries,
reason: 'loadVaultEntriesInMemory',
notify: false,
updateFolders: true,
);
}
Future<void> _refreshVaultEntries({
required Set<String> changedUris,
required Set<AvesEntry> newEntries,
required Set<AvesEntry> entriesToRefresh,
required Set<String> 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,
),
);
}
}
}
}
}