mirror of
https://github.com/immich-app/immich.git
synced 2024-11-28 09:33:27 +02:00
436 lines
14 KiB
Dart
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);
|
|
}
|
|
}
|