1
0
mirror of https://github.com/immich-app/immich.git synced 2024-11-28 09:33:27 +02:00
immich/mobile/lib/services/album.service.dart
2024-10-10 15:44:14 +07:00

436 lines
14 KiB
Dart

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<bool> _localCompleter = Completer()..complete(false);
Completer<bool> _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<bool> 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<String> excludedIds = await _backupAlbumRepository
.getIdsBySelection(BackupSelection.exclude);
final List<String> 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<Album> onDevice = await _albumMediaRepository.getAll();
_log.info("Found ${onDevice.length} device albums");
Set<String>? 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<Set<String>> _loadExcludedAssetIds(
List<Album> albums,
List<String> excludedAlbumIds,
) async {
final Set<String> result = HashSet<String>();
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<bool> 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<Album> sharedAlbum =
await _albumApiRepository.getAll(shared: true);
final List<Album> ownedAlbum =
await _albumApiRepository.getAll(shared: null);
final albums = HashSet<Album>(
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<Album?> createAlbum(
String albumName,
Iterable<Asset> assets, [
Iterable<User> 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<String> _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<Album?> createAlbumWithGeneratedName(
Iterable<Asset> assets,
) async {
return createAlbum(
await _getNextAlbumName(),
assets,
[],
);
}
Future<AlbumAddAssetsResponse?> addAssets(
Album album,
Iterable<Asset> assets,
) async {
try {
final result = await _albumApiRepository.addAssets(
album.remoteId!,
assets.map((asset) => asset.remoteId!),
);
final List<Asset> 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<void> _updateAssets(
int albumId, {
List<Asset> add = const [],
List<Asset> 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<bool> 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<bool> 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<Album> albums = await _albumRepository.getAll(shared: true);
final List<Asset> existing = [];
for (Album album in albums) {
existing.addAll(
await _assetRepository.getByAlbum(album, notOwnedBy: [userId]),
);
}
final List<int> 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<bool> leaveAlbum(Album album) async {
try {
await _albumApiRepository.removeUser(album.remoteId!, userId: "me");
return true;
} catch (e) {
debugPrint("Error leaveAlbum ${e.toString()}");
return false;
}
}
Future<bool> removeAsset(
Album album,
Iterable<Asset> 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<bool> 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<bool> addUsers(
Album album,
List<String> 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<bool> 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<Album?> getAlbumByName(String name, bool remoteOnly) =>
_albumRepository.getByName(name, remote: remoteOnly ? true : null);
///
/// Add the uploaded asset to the selected albums
///
Future<void> syncUploadAlbums(
List<String> albumNames,
List<String> 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<List<Album>> getAll() async {
return _albumRepository.getAll(remote: true);
}
Future<List<Album>> search(
String searchTerm,
QuickFilterMode filterMode,
) async {
return _albumRepository.search(searchTerm, filterMode);
}
}