import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/models/albums/album_add_asset_response.model.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/providers/api.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/services/user.service.dart'; import 'package:isar/isar.dart'; import 'package:logging/logging.dart'; import 'package:openapi/api.dart'; import 'package:photo_manager/photo_manager.dart'; final albumServiceProvider = Provider( (ref) => AlbumService( ref.watch(apiServiceProvider), ref.watch(userServiceProvider), ref.watch(syncServiceProvider), ref.watch(dbProvider), ), ); class AlbumService { final ApiService _apiService; final UserService _userService; final SyncService _syncService; final Isar _db; final Logger _log = Logger('AlbumService'); Completer _localCompleter = Completer()..complete(false); Completer _remoteCompleter = Completer()..complete(false); AlbumService( this._apiService, this._userService, this._syncService, this._db, ); /// 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 onDevice = await PhotoManager.getAssetPathList( hasAll: true, filterOption: FilterOptionGroup(containsPathModified: true), ); _log.info("Found ${onDevice.length} device albums"); changes = await _syncService.syncLocalAlbumAssetsToDb(onDevice); _log.info("Syncing completed. Changes: $changes"); } finally { _localCompleter.complete(changes); } debugPrint("refreshDeviceAlbums took ${sw.elapsedMilliseconds}ms"); return changes; } /// Checks remote albums (owned if `isShared` is false) for changes, /// updates the local database and returns `true` if there were any changes Future refreshRemoteAlbums({required bool isShared}) 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? serverAlbums = await _apiService.albumsApi .getAllAlbums(shared: isShared ? true : null); if (serverAlbums == null) { return false; } changes = await _syncService.syncRemoteAlbumsToDb( serverAlbums, isShared: isShared, loadDetails: (dto) async => dto.assetCount == dto.assets.length ? dto : (await _apiService.albumsApi.getAlbumInfo(dto.id)) ?? dto, ); } finally { _remoteCompleter.complete(changes); } debugPrint("refreshRemoteAlbums took ${sw.elapsedMilliseconds}ms"); return changes; } Future createAlbum( String albumName, Iterable assets, [ Iterable sharedUsers = const [], ]) async { try { AlbumResponseDto? remote = await _apiService.albumsApi.createAlbum( CreateAlbumDto( albumName: albumName, assetIds: assets.map((asset) => asset.remoteId!).toList(), albumUsers: sharedUsers .map( (e) => AlbumUserCreateDto( userId: e.id, role: AlbumUserRole.editor, ), ) .toList(), ), ); if (remote != null) { Album album = await Album.remote(remote); await _db.writeTxn(() => _db.albums.store(album)); return album; } } catch (e) { debugPrint("Error createSharedAlbum ${e.toString()}"); } return null; } /* * 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 _db.albums.filter().nameEqualTo(proposedName).findFirst()) { return proposedName; } } } Future createAlbumWithGeneratedName( Iterable assets, ) async { return createAlbum( await _getNextAlbumName(), assets, [], ); } Future addAdditionalAssetToAlbum( Iterable assets, Album album, ) async { try { var response = await _apiService.albumsApi.addAssetsToAlbum( album.remoteId!, BulkIdsDto(ids: assets.map((asset) => asset.remoteId!).toList()), ); if (response != null) { List successAssets = []; List duplicatedAssets = []; for (final result in response) { if (result.success) { successAssets .add(assets.firstWhere((asset) => asset.remoteId == result.id)); } else if (!result.success && result.error == BulkIdResponseDtoErrorEnum.duplicate) { duplicatedAssets.add(result.id); } } await _updateAssets(album.id, add: successAssets); return AlbumAddAssetsResponse( alreadyInAlbum: duplicatedAssets, successfullyAdded: successAssets.length, ); } } catch (e) { debugPrint("Error addAdditionalAssetToAlbum ${e.toString()}"); } return null; } Future _updateAssets( int albumId, { Iterable add = const [], Iterable remove = const [], }) { return _db.writeTxn(() async { final album = await _db.albums.get(albumId); if (album == null) return; await album.assets.update(link: add, unlink: remove); album.startDate = await album.assets.filter().fileCreatedAtProperty().min(); album.endDate = await album.assets.filter().fileCreatedAtProperty().max(); album.lastModifiedAssetTimestamp = await album.assets.filter().updatedAtProperty().max(); await _db.albums.put(album); }); } Future addAdditionalUserToAlbum( List sharedUserIds, Album album, ) async { try { final List albumUsers = sharedUserIds .map((userId) => AlbumUserAddDto(userId: userId)) .toList(); final result = await _apiService.albumsApi.addUsersToAlbum( album.remoteId!, AddUsersDto(albumUsers: albumUsers), ); if (result != null) { album.sharedUsers .addAll((await _db.users.getAllById(sharedUserIds)).cast()); album.shared = result.shared; await _db.writeTxn(() async { await _db.albums.put(album); await album.sharedUsers.save(); }); return true; } } catch (e) { debugPrint("Error addAdditionalUserToAlbum ${e.toString()}"); } return false; } Future setActivityEnabled(Album album, bool enabled) async { try { final result = await _apiService.albumsApi.updateAlbumInfo( album.remoteId!, UpdateAlbumDto(isActivityEnabled: enabled), ); if (result != null) { album.activityEnabled = enabled; await _db.writeTxn(() => _db.albums.put(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 _apiService.albumsApi.deleteAlbum(album.remoteId!); } if (album.shared) { final foreignAssets = await album.assets.filter().not().ownerIdEqualTo(userId).findAll(); await _db.writeTxn(() => _db.albums.delete(album.id)); final List albums = await _db.albums.filter().sharedEqualTo(true).findAll(); final List existing = []; for (Album a in albums) { existing.addAll( await a.assets.filter().not().ownerIdEqualTo(userId).findAll(), ); } final List idsToRemove = _syncService.sharedAssetsToRemove(foreignAssets, existing); if (idsToRemove.isNotEmpty) { await _db.writeTxn(() => _db.assets.deleteAll(idsToRemove)); } } else { await _db.writeTxn(() => _db.albums.delete(album.id)); } return true; } catch (e) { debugPrint("Error deleteAlbum ${e.toString()}"); } return false; } Future leaveAlbum(Album album) async { try { await _apiService.albumsApi.removeUserFromAlbum(album.remoteId!, "me"); return true; } catch (e) { debugPrint("Error leaveAlbum ${e.toString()}"); return false; } } Future removeAssetFromAlbum( Album album, Iterable assets, ) async { try { final response = await _apiService.albumsApi.removeAssetFromAlbum( album.remoteId!, BulkIdsDto( ids: assets.map((asset) => asset.remoteId!).toList(), ), ); if (response != null) { final toRemove = response.every((e) => e.success) ? assets : response .where((e) => e.success) .map((e) => assets.firstWhere((a) => a.remoteId == e.id)); await _updateAssets(album.id, remove: toRemove); return true; } } catch (e) { debugPrint("Error removeAssetFromAlbum ${e.toString()}"); } return false; } Future removeUserFromAlbum( Album album, User user, ) async { try { await _apiService.albumsApi.removeUserFromAlbum( album.remoteId!, user.id, ); album.sharedUsers.remove(user); await _db.writeTxn(() async { await album.sharedUsers.update(unlink: [user]); final a = await _db.albums.get(album.id); // trigger watcher await _db.albums.put(a!); }); return true; } catch (e) { debugPrint("Error removeUserFromAlbum ${e.toString()}"); return false; } } Future changeTitleAlbum( Album album, String newAlbumTitle, ) async { try { await _apiService.albumsApi.updateAlbumInfo( album.remoteId!, UpdateAlbumDto( albumName: newAlbumTitle, ), ); album.name = newAlbumTitle; await _db.writeTxn(() => _db.albums.put(album)); return true; } catch (e) { debugPrint("Error changeTitleAlbum ${e.toString()}"); return false; } } }