import 'dart:async'; import 'dart:collection'; import 'dart:io'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/interfaces/album.interface.dart'; import 'package:immich_mobile/interfaces/album_api.interface.dart'; import 'package:immich_mobile/interfaces/album_media.interface.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/backup.interface.dart'; import 'package:immich_mobile/models/albums/album_add_asset_response.model.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/models/albums/album_search.model.dart'; import 'package:immich_mobile/repositories/album.repository.dart'; import 'package:immich_mobile/repositories/album_api.repository.dart'; import 'package:immich_mobile/repositories/asset.repository.dart'; import 'package:immich_mobile/repositories/backup.repository.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart'; import 'package:immich_mobile/services/entity.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/services/user.service.dart'; import 'package:logging/logging.dart'; final albumServiceProvider = Provider( (ref) => AlbumService( ref.watch(userServiceProvider), ref.watch(syncServiceProvider), ref.watch(entityServiceProvider), ref.watch(albumRepositoryProvider), ref.watch(assetRepositoryProvider), ref.watch(backupRepositoryProvider), ref.watch(albumMediaRepositoryProvider), ref.watch(albumApiRepositoryProvider), ), ); class AlbumService { final UserService _userService; final SyncService _syncService; final EntityService _entityService; final IAlbumRepository _albumRepository; final IAssetRepository _assetRepository; final IBackupRepository _backupAlbumRepository; final IAlbumMediaRepository _albumMediaRepository; final IAlbumApiRepository _albumApiRepository; final Logger _log = Logger('AlbumService'); Completer _localCompleter = Completer()..complete(false); Completer _remoteCompleter = Completer()..complete(false); AlbumService( this._userService, this._syncService, this._entityService, this._albumRepository, this._assetRepository, this._backupAlbumRepository, this._albumMediaRepository, this._albumApiRepository, ); /// Checks all selected device albums for changes of albums and their assets /// Updates the local database and returns `true` if there were any changes Future refreshDeviceAlbums() async { if (!_localCompleter.isCompleted) { // guard against concurrent calls _log.info("refreshDeviceAlbums is already in progress"); return _localCompleter.future; } _localCompleter = Completer(); final Stopwatch sw = Stopwatch()..start(); bool changes = false; try { final List excludedIds = await _backupAlbumRepository .getIdsBySelection(BackupSelection.exclude); final List selectedIds = await _backupAlbumRepository .getIdsBySelection(BackupSelection.select); if (selectedIds.isEmpty) { final numLocal = await _albumRepository.count(local: true); if (numLocal > 0) { _syncService.removeAllLocalAlbumsAndAssets(); } return false; } final List onDevice = await _albumMediaRepository.getAll(); _log.info("Found ${onDevice.length} device albums"); Set? excludedAssets; if (excludedIds.isNotEmpty) { if (Platform.isIOS) { // iOS and Android device album working principle differ significantly // on iOS, an asset can be in multiple albums // on Android, an asset can only be in exactly one album (folder!) at the same time // thus, on Android, excluding an album can be done by ignoring that album // however, on iOS, it it necessary to load the assets from all excluded // albums and check every asset from any selected album against the set // of excluded assets excludedAssets = await _loadExcludedAssetIds(onDevice, excludedIds); _log.info("Found ${excludedAssets.length} assets to exclude"); } // remove all excluded albums onDevice.removeWhere((e) => excludedIds.contains(e.localId)); _log.info( "Ignoring ${excludedIds.length} excluded albums resulting in ${onDevice.length} device albums", ); } final hasAll = selectedIds .map( (id) => onDevice.firstWhereOrNull((album) => album.localId == id), ) .whereNotNull() .any((a) => a.isAll); if (hasAll) { if (Platform.isAndroid) { // remove the virtual "Recent" album and keep and individual albums // on Android, the virtual "Recent" `lastModified` value is always null onDevice.removeWhere((e) => e.isAll); _log.info("'Recents' is selected, keeping all individual albums"); } } else { // keep only the explicitly selected albums onDevice.removeWhere((e) => !selectedIds.contains(e.localId)); _log.info("'Recents' is not selected, keeping only selected albums"); } changes = await _syncService.syncLocalAlbumAssetsToDb(onDevice, excludedAssets); _log.info("Syncing completed. Changes: $changes"); } finally { _localCompleter.complete(changes); } debugPrint("refreshDeviceAlbums took ${sw.elapsedMilliseconds}ms"); return changes; } Future> _loadExcludedAssetIds( List albums, List excludedAlbumIds, ) async { final Set result = HashSet(); for (Album album in albums) { if (excludedAlbumIds.contains(album.localId)) { final assetIds = await _albumMediaRepository.getAssetIds(album.localId!); result.addAll(assetIds); } } return result; } /// Checks remote albums (owned if `isShared` is false) for changes, /// updates the local database and returns `true` if there were any changes Future refreshRemoteAlbums() async { if (!_remoteCompleter.isCompleted) { // guard against concurrent calls return _remoteCompleter.future; } _remoteCompleter = Completer(); final Stopwatch sw = Stopwatch()..start(); bool changes = false; try { await _userService.refreshUsers(); final List sharedAlbum = await _albumApiRepository.getAll(shared: true); final List ownedAlbum = await _albumApiRepository.getAll(shared: null); final albums = HashSet( equals: (a, b) => a.remoteId == b.remoteId, hashCode: (a) => a.remoteId.hashCode, ); albums.addAll(sharedAlbum); albums.addAll(ownedAlbum); changes = await _syncService.syncRemoteAlbumsToDb(albums.toList()); } finally { _remoteCompleter.complete(changes); } debugPrint("refreshRemoteAlbums took ${sw.elapsedMilliseconds}ms"); return changes; } Future createAlbum( String albumName, Iterable assets, [ Iterable sharedUsers = const [], ]) async { final Album album = await _albumApiRepository.create( albumName, assetIds: assets.map((asset) => asset.remoteId!), sharedUserIds: sharedUsers.map((user) => user.id), ); await _entityService.fillAlbumWithDatabaseEntities(album); return _albumRepository.create(album); } /* * Creates names like Untitled, Untitled (1), Untitled (2), ... */ Future _getNextAlbumName() async { const baseName = "Untitled"; for (int round = 0;; round++) { final proposedName = "$baseName${round == 0 ? "" : " ($round)"}"; if (null == await _albumRepository.getByName(proposedName)) { return proposedName; } } } Future createAlbumWithGeneratedName( Iterable assets, ) async { return createAlbum( await _getNextAlbumName(), assets, [], ); } Future addAssets( Album album, Iterable assets, ) async { try { final result = await _albumApiRepository.addAssets( album.remoteId!, assets.map((asset) => asset.remoteId!), ); final List addedAssets = result.added .map((id) => assets.firstWhere((asset) => asset.remoteId == id)) .toList(); await _updateAssets(album.id, add: addedAssets); return AlbumAddAssetsResponse( alreadyInAlbum: result.duplicates, successfullyAdded: addedAssets.length, ); } catch (e) { debugPrint("Error addAssets ${e.toString()}"); } return null; } Future _updateAssets( int albumId, { List add = const [], List remove = const [], }) => _albumRepository.transaction(() async { final album = await _albumRepository.get(albumId); if (album == null) return; await _albumRepository.addAssets(album, add); await _albumRepository.removeAssets(album, remove); await _albumRepository.recalculateMetadata(album); await _albumRepository.update(album); }); Future setActivityStatus(Album album, bool enabled) async { try { final updatedAlbum = await _albumApiRepository.update( album.remoteId!, activityEnabled: enabled, ); album.activityEnabled = updatedAlbum.activityEnabled; await _albumRepository.update(album); return true; } catch (e) { debugPrint("Error setActivityEnabled ${e.toString()}"); } return false; } Future deleteAlbum(Album album) async { try { final userId = Store.get(StoreKey.currentUser).isarId; if (album.owner.value?.isarId == userId) { await _albumApiRepository.delete(album.remoteId!); } if (album.shared) { final foreignAssets = await _assetRepository.getByAlbum(album, notOwnedBy: [userId]); await _albumRepository.delete(album.id); final List albums = await _albumRepository.getAll(shared: true); final List existing = []; for (Album album in albums) { existing.addAll( await _assetRepository.getByAlbum(album, notOwnedBy: [userId]), ); } final List idsToRemove = _syncService.sharedAssetsToRemove(foreignAssets, existing); if (idsToRemove.isNotEmpty) { await _assetRepository.deleteById(idsToRemove); } } else { await _albumRepository.delete(album.id); } return true; } catch (e) { debugPrint("Error deleteAlbum ${e.toString()}"); } return false; } Future leaveAlbum(Album album) async { try { await _albumApiRepository.removeUser(album.remoteId!, userId: "me"); return true; } catch (e) { debugPrint("Error leaveAlbum ${e.toString()}"); return false; } } Future removeAsset( Album album, Iterable assets, ) async { try { final result = await _albumApiRepository.removeAssets( album.remoteId!, assets.map((asset) => asset.remoteId!), ); final toRemove = result.removed .map((id) => assets.firstWhere((asset) => asset.remoteId == id)); await _updateAssets(album.id, remove: toRemove.toList()); return true; } catch (e) { debugPrint("Error removeAssetFromAlbum ${e.toString()}"); } return false; } Future removeUser( Album album, User user, ) async { try { await _albumApiRepository.removeUser( album.remoteId!, userId: user.id, ); album.sharedUsers.remove(user); await _albumRepository.removeUsers(album, [user]); final a = await _albumRepository.get(album.id); // trigger watcher await _albumRepository.update(a!); return true; } catch (error) { debugPrint("Error removeUser ${error.toString()}"); return false; } } Future addUsers( Album album, List userIds, ) async { try { final updatedAlbum = await _albumApiRepository.addUsers(album.remoteId!, userIds); album.sharedUsers.addAll(updatedAlbum.remoteUsers); album.shared = true; await _albumRepository.addUsers(album, album.sharedUsers.toList()); await _albumRepository.update(album); return true; } catch (error) { debugPrint("Error addUsers ${error.toString()}"); } return false; } Future changeTitleAlbum( Album album, String newAlbumTitle, ) async { try { final updatedAlbum = await _albumApiRepository.update( album.remoteId!, name: newAlbumTitle, ); album.name = updatedAlbum.name; await _albumRepository.update(album); return true; } catch (e) { debugPrint("Error changeTitleAlbum ${e.toString()}"); return false; } } Future getAlbumByName(String name, bool remoteOnly) => _albumRepository.getByName(name, remote: remoteOnly ? true : null); /// /// Add the uploaded asset to the selected albums /// Future syncUploadAlbums( List albumNames, List assetIds, ) async { for (final albumName in albumNames) { Album? album = await getAlbumByName(albumName, true); album ??= await createAlbum(albumName, []); if (album != null && album.remoteId != null) { await _albumApiRepository.addAssets(album.remoteId!, assetIds); } } } Future> getAll() async { return _albumRepository.getAll(remote: true); } Future> search( String searchTerm, QuickFilterMode filterMode, ) async { return _albumRepository.search(searchTerm, filterMode); } }