import 'dart:async'; import 'package:collection/collection.dart'; import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart'; import 'package:immich_mobile/repositories/drift_album_api_repository.dart'; import 'package:immich_mobile/services/foreground_upload.service.dart'; import 'package:logging/logging.dart'; /// Categorizes a heterogeneous asset selection into the candidates that can /// be added to an album immediately (already on the server) and the local-only /// candidates that must be uploaded first. class AlbumAssetCandidates { final List remoteAssetIds; final List localAssetsToUpload; const AlbumAssetCandidates({required this.remoteAssetIds, required this.localAssetsToUpload}); } class RemoteAlbumService { static final _logger = Logger('RemoteAlbumService'); final DriftRemoteAlbumRepository _repository; final DriftAlbumApiRepository _albumApiRepository; final ForegroundUploadService _uploadService; const RemoteAlbumService(this._repository, this._albumApiRepository, this._uploadService); /// Categorizes a heterogeneous asset selection into already-on-server IDs /// and local assets that still need to be uploaded. static AlbumAssetCandidates categorizeCandidates(Iterable assets) { final remoteIds = []; final localToUpload = []; for (final asset in assets) { if (asset is RemoteAsset) { remoteIds.add(asset.id); } else if (asset is LocalAsset) { final remoteId = asset.remoteId; if (remoteId != null) { remoteIds.add(remoteId); } else { localToUpload.add(asset); } } } return AlbumAssetCandidates(remoteAssetIds: remoteIds, localAssetsToUpload: localToUpload); } Stream watchAlbum(String albumId) { return _repository.watchAlbum(albumId); } Future> getAll() { return _repository.getAll(); } Future get(String albumId) { return _repository.get(albumId); } Future> sortAlbums( List albums, AlbumSortMode sortMode, { bool isReverse = false, }) async { // list of albums sorted ascendingly according to the selected sort mode final List sorted = switch (sortMode) { AlbumSortMode.created => albums.sortedBy((album) => album.createdAt), AlbumSortMode.title => albums.sortedBy((album) => album.name), AlbumSortMode.lastModified => albums.sortedBy((album) => album.updatedAt), AlbumSortMode.assetCount => albums.sortedBy((album) => album.assetCount), AlbumSortMode.mostRecent => await _sortByAssetDate(albums, aggregation: AssetDateAggregation.end), AlbumSortMode.mostOldest => await _sortByAssetDate(albums, aggregation: AssetDateAggregation.start), }; final effectiveOrder = isReverse ? sortMode.defaultOrder.reverse() : sortMode.defaultOrder; return (effectiveOrder == SortOrder.asc ? sorted : sorted.reversed).toList(); } List searchAlbums( List albums, String query, String? userId, [ QuickFilterMode filterMode = QuickFilterMode.all, ]) { final lowerQuery = query.toLowerCase(); List filtered = albums; // Apply text search filter if (query.isNotEmpty) { filtered = filtered .where( (album) => album.name.toLowerCase().contains(lowerQuery) || album.description.toLowerCase().contains(lowerQuery), ) .toList(); } if (userId != null) { switch (filterMode) { case QuickFilterMode.myAlbums: filtered = filtered.where((album) => album.ownerId == userId).toList(); break; case QuickFilterMode.sharedWithMe: filtered = filtered.where((album) => album.ownerId != userId).toList(); break; case QuickFilterMode.all: break; } } return filtered; } Future createAlbum({ required String title, required UserDto owner, required List assetIds, String? description, }) async { final album = await _albumApiRepository.createDriftAlbum( title, owner, description: description, assetIds: assetIds, ); await _repository.create(album, assetIds); return album; } Future updateAlbum( String albumId, { String? name, String? description, String? thumbnailAssetId, bool? isActivityEnabled, AlbumAssetOrder? order, }) async { final owner = await _repository.getOwner(albumId); final updatedAlbum = await _albumApiRepository.updateAlbum( albumId, owner, name: name, description: description, thumbnailAssetId: thumbnailAssetId, isActivityEnabled: isActivityEnabled, order: order, ); // Update the local database await _repository.update(updatedAlbum); return updatedAlbum; } FutureOr<(DateTime, DateTime)> getDateRange(String albumId) { return _repository.getDateRange(albumId); } Future> getSharedUsers(String albumId) { return _repository.getSharedUsers(albumId); } Future getUserRole(String albumId, String userId) { return _repository.getUserRole(albumId, userId); } Future> getAssets(String albumId) { return _repository.getAssets(albumId); } Future addAssets({required String albumId, required List assetIds}) async { final album = await _albumApiRepository.addAssets(albumId, assetIds); await _repository.addAssets(albumId, album.added); return album.added.length; } /// !TODO The name here is not clear as we have addAssets method above, /// which is only add remote assets to album, for the next PR, we will allow /// adding local assets from album from the timeline as well with this flow. /// So saving that for the next refactor Future addAssetsToAlbum({ required String albumId, required UserDto uploader, required AlbumAssetCandidates candidates, UploadCallbacks uploadCallbacks = const UploadCallbacks(), Completer? cancelToken, }) async { int addedCount = 0; if (candidates.remoteAssetIds.isNotEmpty) { addedCount += await addAssets(albumId: albumId, assetIds: candidates.remoteAssetIds); } if (candidates.localAssetsToUpload.isNotEmpty) { addedCount += await _uploadAndAddLocals( albumId, uploader, candidates.localAssetsToUpload, uploadCallbacks, cancelToken, ); } return addedCount; } Future _uploadAndAddLocals( String albumId, UserDto uploader, List localAssets, UploadCallbacks userCallbacks, Completer? cancelToken, ) async { int addedCount = 0; final pendingAdds = >[]; final localById = {for (final a in localAssets) a.id: a}; final wrappedCallbacks = UploadCallbacks( onProgress: (localId, filename, bytes, totalBytes) => _runUploadCallback( 'Upload progress callback failed for $localId', () => userCallbacks.onProgress?.call(localId, filename, bytes, totalBytes), ), onICloudProgress: (localId, progress) => _runUploadCallback( 'iCloud progress callback failed for $localId', () => userCallbacks.onICloudProgress?.call(localId, progress), ), onError: (localId, errorMessage) => _runUploadCallback( 'Upload error callback failed for $localId', () => userCallbacks.onError?.call(localId, errorMessage), ), onSuccess: (localId, remoteId) { _runUploadCallback( 'Upload success callback failed for $localId', () => userCallbacks.onSuccess?.call(localId, remoteId), ); final source = localById[localId]; if (source == null) { _logger.warning('Upload success for $localId but source LocalAsset missing; skipping album link'); return; } pendingAdds.add( linkUploadedAssetToAlbum(albumId, remoteId, uploader, source) .then((added) { addedCount += added; }) .catchError((Object error, StackTrace stack) { _logger.warning('Failed to add uploaded asset $remoteId to album $albumId', error, stack); }), ); }, ); await _uploadService.uploadManual(localAssets, callbacks: wrappedCallbacks, cancelToken: cancelToken); await Future.wait(pendingAdds); return addedCount; } void _runUploadCallback(String message, void Function() callback) { try { callback(); } catch (error, stack) { _logger.warning(message, error, stack); } } /// Links a freshly-uploaded asset to an album, ensuring the local DB /// reflects the change without waiting for the next sync. We call the API /// (server is the source of truth), then upsert a placeholder /// `remote_asset_entity` row from the local source so the FK-protected /// junction insert succeeds. Sync overwrites the placeholder later with /// the authoritative server data. Future linkUploadedAssetToAlbum(String albumId, String remoteId, UserDto uploader, LocalAsset source) async { final result = await _albumApiRepository.addAssets(albumId, [remoteId]); if (result.added.isEmpty) { return 0; } await _repository.upsertRemoteAssetStub(remoteId: remoteId, ownerId: uploader.id, source: source); await _repository.addAssets(albumId, result.added); return result.added.length; } Future deleteAlbum(String albumId) async { await _albumApiRepository.deleteAlbum(albumId); await _repository.deleteAlbum(albumId); } Future addUsers({required String albumId, required List userIds}) async { await _albumApiRepository.addUsers(albumId, userIds); return _repository.addUsers(albumId, userIds); } Future removeUser(String albumId, {required String userId}) async { await _albumApiRepository.removeUser(albumId, userId: userId); return _repository.removeUser(albumId, userId: userId); } Future setActivityStatus(String albumId, bool enabled) async { await _albumApiRepository.setActivityStatus(albumId, enabled); return _repository.setActivityStatus(albumId, enabled); } Future getCount() { return _repository.getCount(); } Future> getAlbumsContainingAsset(String assetId) { return _repository.getAlbumsContainingAsset(assetId); } Future> _sortByAssetDate( List albums, { required AssetDateAggregation aggregation, }) async { if (albums.isEmpty) { return []; } final albumIds = albums.map((e) => e.id).toList(); final sortedIds = await _repository.getSortedAlbumIds(albumIds, aggregation: aggregation); final albumMap = Map.fromEntries(albums.map((a) => MapEntry(a.id, a))); final sortedAlbums = sortedIds.map((id) => albumMap[id]).whereType().toList(); if (sortedAlbums.length < albums.length) { final returnedIdSet = sortedIds.toSet(); final emptyAlbums = albums.where((a) => !returnedIdSet.contains(a.id)); sortedAlbums.addAll(emptyAlbums); } return sortedAlbums; } }