1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2024-12-29 15:11:58 +00:00

refactor(mobile): album api repository for album service (#12791)

* refactor(mobile): album api repository for album service
This commit is contained in:
Fynn Petersen-Frey 2024-09-20 15:32:37 +02:00 committed by GitHub
parent 94fc1f213a
commit 3868736799
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 751 additions and 223 deletions

View file

@ -46,21 +46,46 @@ custom_lint:
- avoid_public_notifier_properties: false - avoid_public_notifier_properties: false
- avoid_manual_providers_as_generated_provider_dependency: false - avoid_manual_providers_as_generated_provider_dependency: false
- unsupported_provider_value: false - unsupported_provider_value: false
- photo_manager: - import_rule_photo_manager:
exclude: message: photo_manager must only be used in MediaRepositories
restrict: package:photo_manager
allowed:
# required / wanted # required / wanted
- album_media.repository.dart - 'lib/repositories/{album,asset,file}_media.repository.dart'
- asset_media.repository.dart
- file_media.repository.dart
# acceptable exceptions for the time being # acceptable exceptions for the time being
- asset.entity.dart # to provide local AssetEntity for now - lib/entities/asset.entity.dart # to provide local AssetEntity for now
- immich_local_image_provider.dart # accesses thumbnails via PhotoManager - lib/providers/image/immich_local_{image,thumbnail}_provider.dart # accesses thumbnails via PhotoManager
- immich_local_thumbnail_provider.dart # accesses thumbnails via PhotoManager
# refactor to make the providers and services testable # refactor to make the providers and services testable
- backup.provider.dart # uses only PMProgressHandler - lib/providers/backup/{backup,manual_upload}.provider.dart # uses only PMProgressHandler
- manual_upload.provider.dart # uses only PMProgressHandler - lib/services/{background,backup}.service.dart # uses only PMProgressHandler
- background.service.dart # uses only PMProgressHandler - import_rule_openapi:
- backup.service.dart # uses only PMProgressHandler message: openapi must only be used through ApiRepositories
restrict: package:openapi
allowed:
# requried / wanted
- lib/repositories/album_api.repository.dart
# acceptable exceptions for the time being
- lib/entities/{album,asset,exif_info,user}.entity.dart # to convert DTOs to entities
- lib/utils/{image_url_builder,openapi_patching}.dart # utils are fine
- test/modules/utils/openapi_patching_test.dart # filename is self-explanatory...
# refactor
- lib/models/activities/activity.model.dart
- lib/models/map/map_marker.model.dart
- lib/models/search/search_filter.model.dart
- lib/models/server_info/server_{config,disk_info,features,version}.model.dart
- lib/models/shared_link/shared_link.model.dart
- lib/pages/search/search_input.page.dart
- lib/providers/asset_viewer/asset_people.provider.dart
- lib/providers/authentication.provider.dart
- lib/providers/image/immich_remote_{image,thumbnail}_provider.dart
- lib/providers/map/map_state.provider.dart
- lib/providers/search/{people,search,search_filter}.provider.dart
- lib/providers/websocket.provider.dart
- lib/routing/auth_guard.dart
- lib/services/{activity,api,asset,asset_description,backup,memory,oauth,partner,person,search,shared_link,stack,trash,user}.service.dart
- lib/widgets/album/album_thumbnail_listtile.dart
- lib/widgets/forms/login/login_form.dart
- lib/widgets/search/search_filter/{camera_picker,location_picker,people_picker}.dart
dart_code_metrics: dart_code_metrics:
metrics: metrics:

View file

@ -1,48 +1,85 @@
import 'dart:collection';
import 'package:analyzer/error/listener.dart'; import 'package:analyzer/error/listener.dart';
import 'package:analyzer/error/error.dart' show ErrorSeverity; import 'package:analyzer/error/error.dart' show ErrorSeverity;
import 'package:custom_lint_builder/custom_lint_builder.dart'; import 'package:custom_lint_builder/custom_lint_builder.dart';
// ignore: depend_on_referenced_packages
import 'package:glob/glob.dart';
PluginBase createPlugin() => ImmichLinter(); PluginBase createPlugin() => ImmichLinter();
class ImmichLinter extends PluginBase { class ImmichLinter extends PluginBase {
@override @override
List<LintRule> getLintRules(CustomLintConfigs configs) => [ List<LintRule> getLintRules(CustomLintConfigs configs) {
PhotoManagerRule(configs.rules[PhotoManagerRule._code.name]), final List<LintRule> rules = [];
]; for (final entry in configs.rules.entries) {
} if (entry.value.enabled && entry.key.startsWith("import_rule_")) {
final code = makeCode(entry.key, entry.value);
class PhotoManagerRule extends DartLintRule { final allowedPaths = getStrings(entry.value, "allowed");
PhotoManagerRule(LintOptions? options) : super(code: _code) { final forbiddenPaths = getStrings(entry.value, "forbidden");
final excludeOption = options?.json["exclude"]; final restrict = getStrings(entry.value, "restrict");
if (excludeOption is String) { rules.add(ImportRule(code, buildGlob(allowedPaths),
_excludePaths.add(excludeOption); buildGlob(forbiddenPaths), restrict));
} else if (excludeOption is List) {
_excludePaths.addAll(excludeOption.map((option) => option));
} }
} }
return rules;
}
final Set<String> _excludePaths = HashSet(); static makeCode(String name, LintOptions options) => LintCode(
name: name,
static const _code = LintCode( problemMessage: options.json["message"] as String,
name: 'photo_manager',
problemMessage:
'photo_manager library must only be used in MediaRepository',
errorSeverity: ErrorSeverity.WARNING, errorSeverity: ErrorSeverity.WARNING,
); );
static List<String> getStrings(LintOptions options, String field) {
final List<String> result = [];
final excludeOption = options.json[field];
if (excludeOption is String) {
result.add(excludeOption);
} else if (excludeOption is List) {
result.addAll(excludeOption.map((option) => option));
}
return result;
}
Glob? buildGlob(List<String> globs) {
if (globs.isEmpty) return null;
if (globs.length == 1) return Glob(globs[0], caseSensitive: true);
return Glob("{${globs.join(",")}}", caseSensitive: true);
}
}
// ignore: must_be_immutable
class ImportRule extends DartLintRule {
ImportRule(LintCode code, this._allowed, this._forbidden, this._restrict)
: super(code: code);
final Glob? _allowed;
final Glob? _forbidden;
final List<String> _restrict;
int _rootOffset = -1;
@override @override
void run( void run(
CustomLintResolver resolver, CustomLintResolver resolver,
ErrorReporter reporter, ErrorReporter reporter,
CustomLintContext context, CustomLintContext context,
) { ) {
if (_excludePaths.contains(resolver.source.shortName)) return; if (_rootOffset == -1) {
const project = "/immich/mobile/";
_rootOffset = resolver.path.indexOf(project) + project.length;
}
final path = resolver.path.substring(_rootOffset);
if ((_allowed != null && _allowed!.matches(path)) &&
(_forbidden == null || !_forbidden!.matches(path))) return;
context.registry.addImportDirective((node) { context.registry.addImportDirective((node) {
if (node.uri.stringValue?.startsWith("package:photo_manager") == true) { final uri = node.uri.stringValue;
if (uri == null) return;
for (final restricted in _restrict) {
if (uri.startsWith(restricted) == true) {
reporter.atNode(node, code); reporter.atNode(node, code);
return;
}
} }
}); });
} }

View file

@ -159,7 +159,7 @@ packages:
source: hosted source: hosted
version: "2.4.4" version: "2.4.4"
glob: glob:
dependency: transitive dependency: "direct main"
description: description:
name: glob name: glob
sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63"

View file

@ -8,6 +8,7 @@ dependencies:
analyzer: ^6.8.0 analyzer: ^6.8.0
analyzer_plugin: ^0.11.3 analyzer_plugin: ^0.11.3
custom_lint_builder: ^0.6.4 custom_lint_builder: ^0.6.4
glob: ^2.1.2
dev_dependencies: dev_dependencies:
lints: ^4.0.0 lints: ^4.0.0

View file

@ -3,6 +3,8 @@ import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/utils/datetime_comparison.dart'; import 'package:immich_mobile/utils/datetime_comparison.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
// ignore: implementation_imports
import 'package:isar/src/common/isar_links_common.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
part 'album.entity.g.dart'; part 'album.entity.g.dart';
@ -23,6 +25,7 @@ class Album {
required this.activityEnabled, required this.activityEnabled,
}); });
// fields stored in DB
Id id = Isar.autoIncrement; Id id = Isar.autoIncrement;
@Index(unique: false, replace: false, type: IndexType.hash) @Index(unique: false, replace: false, type: IndexType.hash)
String? remoteId; String? remoteId;
@ -41,9 +44,17 @@ class Album {
final IsarLinks<User> sharedUsers = IsarLinks<User>(); final IsarLinks<User> sharedUsers = IsarLinks<User>();
final IsarLinks<Asset> assets = IsarLinks<Asset>(); final IsarLinks<Asset> assets = IsarLinks<Asset>();
// transient fields
@ignore @ignore
bool isAll = false; bool isAll = false;
@ignore
String? remoteThumbnailAssetId;
@ignore
int remoteAssetCount = 0;
// getters
@ignore @ignore
bool get isRemote => remoteId != null; bool get isRemote => remoteId != null;
@ -74,6 +85,18 @@ class Album {
@ignore @ignore
String get eTagKeyAssetCount => "device-album-$localId-asset-count"; String get eTagKeyAssetCount => "device-album-$localId-asset-count";
// the following getter are needed because Isar links do not make data
// accessible in an object freshly created (not loaded from DB)
@ignore
Iterable<User> get remoteUsers => sharedUsers.isEmpty
? (sharedUsers as IsarLinksCommon<User>).addedObjects
: sharedUsers;
@ignore
Iterable<Asset> get remoteAssets =>
assets.isEmpty ? (assets as IsarLinksCommon<Asset>).addedObjects : assets;
@override @override
bool operator ==(other) { bool operator ==(other) {
if (other is! Album) return false; if (other is! Album) return false;
@ -129,6 +152,7 @@ class Album {
endDate: dto.endDate, endDate: dto.endDate,
activityEnabled: dto.isActivityEnabled, activityEnabled: dto.isActivityEnabled,
); );
a.remoteAssetCount = dto.assetCount;
a.owner.value = await db.users.getById(dto.ownerId); a.owner.value = await db.users.getById(dto.ownerId);
if (dto.albumThumbnailAssetId != null) { if (dto.albumThumbnailAssetId != null) {
a.thumbnail.value = await db.assets a.thumbnail.value = await db.assets
@ -164,7 +188,3 @@ extension AssetsHelper on IsarCollection<Album> {
return a; return a;
} }
} }
extension AlbumResponseDtoHelper on AlbumResponseDto {
List<Asset> getAssets() => assets.map(Asset.remote).toList();
}

View file

@ -0,0 +1,40 @@
import 'package:immich_mobile/entities/album.entity.dart';
abstract interface class IAlbumApiRepository {
Future<Album> get(String id);
Future<List<Album>> getAll({bool? shared});
Future<Album> create(
String name, {
required Iterable<String> assetIds,
Iterable<String> sharedUserIds = const [],
});
Future<Album> update(
String albumId, {
String? name,
String? thumbnailAssetId,
String? description,
bool? activityEnabled,
});
Future<void> delete(String albumId);
Future<({List<String> added, List<String> duplicates})> addAssets(
String albumId,
Iterable<String> assetIds,
);
Future<({List<String> removed, List<String> failed})> removeAssets(
String albumId,
Iterable<String> assetIds,
);
Future<Album> addUsers(
String albumId,
Iterable<String> userIds,
);
Future<void> removeUser(String albumId, {required String userId});
}

View file

@ -3,6 +3,8 @@ import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart';
abstract interface class IAssetRepository { abstract interface class IAssetRepository {
Future<Asset?> getByRemoteId(String id);
Future<List<Asset>> getAllByRemoteId(Iterable<String> ids);
Future<List<Asset>> getByAlbum(Album album, {User? notOwnedBy}); Future<List<Asset>> getByAlbum(Album album, {User? notOwnedBy});
Future<void> deleteById(List<int> ids); Future<void> deleteById(List<int> ids);
} }

View file

@ -2,4 +2,5 @@ import 'package:immich_mobile/entities/user.entity.dart';
abstract interface class IUserRepository { abstract interface class IUserRepository {
Future<List<User>> getByIds(List<String> ids); Future<List<User>> getByIds(List<String> ids);
Future<User?> get(String id);
} }

View file

@ -0,0 +1,172 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/errors.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/interfaces/album_api.interface.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:openapi/api.dart';
final albumApiRepositoryProvider = Provider(
(ref) => AlbumApiRepository(ref.watch(apiServiceProvider).albumsApi),
);
class AlbumApiRepository implements IAlbumApiRepository {
final AlbumsApi _api;
AlbumApiRepository(this._api);
@override
Future<Album> get(String id) async {
final dto = await _checkNull(_api.getAlbumInfo(id));
return _toAlbum(dto);
}
@override
Future<List<Album>> getAll({bool? shared}) async {
final dtos = await _checkNull(_api.getAllAlbums(shared: shared));
return dtos.map(_toAlbum).toList().cast();
}
@override
Future<Album> create(
String name, {
required Iterable<String> assetIds,
Iterable<String> sharedUserIds = const [],
}) async {
final users = sharedUserIds.map(
(id) => AlbumUserCreateDto(userId: id, role: AlbumUserRole.editor),
);
final responseDto = await _checkNull(
_api.createAlbum(
CreateAlbumDto(
albumName: name,
assetIds: assetIds.toList(),
albumUsers: users.toList(),
),
),
);
return _toAlbum(responseDto);
}
@override
Future<Album> update(
String albumId, {
String? name,
String? thumbnailAssetId,
String? description,
bool? activityEnabled,
}) async {
final response = await _checkNull(
_api.updateAlbumInfo(
albumId,
UpdateAlbumDto(
albumName: name,
albumThumbnailAssetId: thumbnailAssetId,
description: description,
isActivityEnabled: activityEnabled,
),
),
);
return _toAlbum(response);
}
@override
Future<void> delete(String albumId) {
return _api.deleteAlbum(albumId);
}
@override
Future<({List<String> added, List<String> duplicates})> addAssets(
String albumId,
Iterable<String> assetIds,
) async {
final response = await _checkNull(
_api.addAssetsToAlbum(
albumId,
BulkIdsDto(ids: assetIds.toList()),
),
);
final List<String> added = [];
final List<String> duplicates = [];
for (final result in response) {
if (result.success) {
added.add(result.id);
} else if (result.error == BulkIdResponseDtoErrorEnum.duplicate) {
duplicates.add(result.id);
}
}
return (added: added, duplicates: duplicates);
}
@override
Future<({List<String> removed, List<String> failed})> removeAssets(
String albumId,
Iterable<String> assetIds,
) async {
final response = await _checkNull(
_api.removeAssetFromAlbum(
albumId,
BulkIdsDto(ids: assetIds.toList()),
),
);
final List<String> removed = [], failed = [];
for (final dto in response) {
if (dto.success) {
removed.add(dto.id);
} else {
failed.add(dto.id);
}
}
return (removed: removed, failed: failed);
}
@override
Future<Album> addUsers(String albumId, Iterable<String> userIds) async {
final albumUsers =
userIds.map((userId) => AlbumUserAddDto(userId: userId)).toList();
final response = await _checkNull(
_api.addUsersToAlbum(
albumId,
AddUsersDto(albumUsers: albumUsers),
),
);
return _toAlbum(response);
}
@override
Future<void> removeUser(String albumId, {required String userId}) {
return _api.removeUserFromAlbum(albumId, userId);
}
static Future<T> _checkNull<T>(Future<T?> future) async {
final response = await future;
if (response == null) throw NoResponseDtoError();
return response;
}
static Album _toAlbum(AlbumResponseDto dto) {
final Album album = Album(
remoteId: dto.id,
name: dto.albumName,
createdAt: dto.createdAt,
modifiedAt: dto.updatedAt,
lastModifiedAssetTimestamp: dto.lastModifiedAssetTimestamp,
shared: dto.shared,
startDate: dto.startDate,
endDate: dto.endDate,
activityEnabled: dto.isActivityEnabled,
);
album.remoteAssetCount = dto.assetCount;
album.owner.value = User.fromSimpleUserDto(dto.owner);
album.remoteThumbnailAssetId = dto.albumThumbnailAssetId;
final users = dto.albumUsers
.map((albumUser) => User.fromSimpleUserDto(albumUser.user));
album.sharedUsers.addAll(users);
final assets = dto.assets.map(Asset.remote).toList();
album.assets.addAll(assets);
return album;
}
}

View file

@ -28,4 +28,11 @@ class AssetRepository implements IAssetRepository {
@override @override
Future<void> deleteById(List<int> ids) => Future<void> deleteById(List<int> ids) =>
_db.writeTxn(() => _db.assets.deleteAll(ids)); _db.writeTxn(() => _db.assets.deleteAll(ids));
@override
Future<Asset?> getByRemoteId(String id) => _db.assets.getByRemoteId(id);
@override
Future<List<Asset>> getAllByRemoteId(Iterable<String> ids) =>
_db.assets.getAllByRemoteId(ids);
} }

View file

@ -17,4 +17,7 @@ class UserRepository implements IUserRepository {
@override @override
Future<List<User>> getByIds(List<String> ids) async => Future<List<User>> getByIds(List<String> ids) async =>
(await _db.users.getAllById(ids)).cast(); (await _db.users.getAllById(ids)).cast();
@override
Future<User?> get(String id) => _db.users.getById(id);
} }

View file

@ -6,63 +6,61 @@ import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/interfaces/album.interface.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/album_media.interface.dart';
import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart';
import 'package:immich_mobile/interfaces/backup.interface.dart'; import 'package:immich_mobile/interfaces/backup.interface.dart';
import 'package:immich_mobile/interfaces/user.interface.dart';
import 'package:immich_mobile/models/albums/album_add_asset_response.model.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/backup_album.entity.dart';
import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/repositories/album.repository.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/asset.repository.dart';
import 'package:immich_mobile/repositories/backup.repository.dart'; import 'package:immich_mobile/repositories/backup.repository.dart';
import 'package:immich_mobile/repositories/album_media.repository.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart';
import 'package:immich_mobile/repositories/user.repository.dart'; import 'package:immich_mobile/services/entity.service.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/services/sync.service.dart';
import 'package:immich_mobile/services/user.service.dart'; import 'package:immich_mobile/services/user.service.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
final albumServiceProvider = Provider( final albumServiceProvider = Provider(
(ref) => AlbumService( (ref) => AlbumService(
ref.watch(apiServiceProvider),
ref.watch(userServiceProvider), ref.watch(userServiceProvider),
ref.watch(syncServiceProvider), ref.watch(syncServiceProvider),
ref.watch(entityServiceProvider),
ref.watch(albumRepositoryProvider), ref.watch(albumRepositoryProvider),
ref.watch(assetRepositoryProvider), ref.watch(assetRepositoryProvider),
ref.watch(userRepositoryProvider),
ref.watch(backupRepositoryProvider), ref.watch(backupRepositoryProvider),
ref.watch(albumMediaRepositoryProvider), ref.watch(albumMediaRepositoryProvider),
ref.watch(albumApiRepositoryProvider),
), ),
); );
class AlbumService { class AlbumService {
final ApiService _apiService;
final UserService _userService; final UserService _userService;
final SyncService _syncService; final SyncService _syncService;
final EntityService _entityService;
final IAlbumRepository _albumRepository; final IAlbumRepository _albumRepository;
final IAssetRepository _assetRepository; final IAssetRepository _assetRepository;
final IUserRepository _userRepository;
final IBackupRepository _backupAlbumRepository; final IBackupRepository _backupAlbumRepository;
final IAlbumMediaRepository _albumMediaRepository; final IAlbumMediaRepository _albumMediaRepository;
final IAlbumApiRepository _albumApiRepository;
final Logger _log = Logger('AlbumService'); final Logger _log = Logger('AlbumService');
Completer<bool> _localCompleter = Completer()..complete(false); Completer<bool> _localCompleter = Completer()..complete(false);
Completer<bool> _remoteCompleter = Completer()..complete(false); Completer<bool> _remoteCompleter = Completer()..complete(false);
AlbumService( AlbumService(
this._apiService,
this._userService, this._userService,
this._syncService, this._syncService,
this._entityService,
this._albumRepository, this._albumRepository,
this._assetRepository, this._assetRepository,
this._userRepository,
this._backupAlbumRepository, this._backupAlbumRepository,
this._albumMediaRepository, this._albumMediaRepository,
this._albumApiRepository,
); );
/// Checks all selected device albums for changes of albums and their assets /// Checks all selected device albums for changes of albums and their assets
@ -164,17 +162,11 @@ class AlbumService {
bool changes = false; bool changes = false;
try { try {
await _userService.refreshUsers(); await _userService.refreshUsers();
final List<AlbumResponseDto>? serverAlbums = await _apiService.albumsApi final List<Album> serverAlbums =
.getAllAlbums(shared: isShared ? true : null); await _albumApiRepository.getAll(shared: isShared ? true : null);
if (serverAlbums == null) {
return false;
}
changes = await _syncService.syncRemoteAlbumsToDb( changes = await _syncService.syncRemoteAlbumsToDb(
serverAlbums, serverAlbums,
isShared: isShared, isShared: isShared,
loadDetails: (dto) async => dto.assetCount == dto.assets.length
? dto
: (await _apiService.albumsApi.getAlbumInfo(dto.id)) ?? dto,
); );
} finally { } finally {
_remoteCompleter.complete(changes); _remoteCompleter.complete(changes);
@ -188,30 +180,13 @@ class AlbumService {
Iterable<Asset> assets, [ Iterable<Asset> assets, [
Iterable<User> sharedUsers = const [], Iterable<User> sharedUsers = const [],
]) async { ]) async {
try { final Album album = await _albumApiRepository.create(
AlbumResponseDto? remote = await _apiService.albumsApi.createAlbum( albumName,
CreateAlbumDto( assetIds: assets.map((asset) => asset.remoteId!),
albumName: albumName, sharedUserIds: sharedUsers.map((user) => user.id),
assetIds: assets.map((asset) => asset.remoteId!).toList(),
albumUsers: sharedUsers
.map(
(e) => AlbumUserCreateDto(
userId: e.id,
role: AlbumUserRole.editor,
),
)
.toList(),
),
); );
if (remote != null) { await _entityService.fillAlbumWithDatabaseEntities(album);
final Album album = await Album.remote(remote); return _albumRepository.create(album);
await _albumRepository.create(album);
return album;
}
} catch (e) {
debugPrint("Error createSharedAlbum ${e.toString()}");
}
return null;
} }
/* /*
@ -243,32 +218,21 @@ class AlbumService {
Album album, Album album,
) async { ) async {
try { try {
var response = await _apiService.albumsApi.addAssetsToAlbum( final result = await _albumApiRepository.addAssets(
album.remoteId!, album.remoteId!,
BulkIdsDto(ids: assets.map((asset) => asset.remoteId!).toList()), assets.map((asset) => asset.remoteId!),
); );
if (response != null) { final List<Asset> addedAssets = result.added
List<Asset> successAssets = []; .map((id) => assets.firstWhere((asset) => asset.remoteId == id))
List<String> duplicatedAssets = []; .toList();
for (final result in response) { await _updateAssets(album.id, add: addedAssets);
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( return AlbumAddAssetsResponse(
alreadyInAlbum: duplicatedAssets, alreadyInAlbum: result.duplicates,
successfullyAdded: successAssets.length, successfullyAdded: addedAssets.length,
); );
}
} catch (e) { } catch (e) {
debugPrint("Error addAdditionalAssetToAlbum ${e.toString()}"); debugPrint("Error addAdditionalAssetToAlbum ${e.toString()}");
} }
@ -293,20 +257,11 @@ class AlbumService {
Album album, Album album,
) async { ) async {
try { try {
final List<AlbumUserAddDto> albumUsers = sharedUserIds final updatedAlbum =
.map((userId) => AlbumUserAddDto(userId: userId)) await _albumApiRepository.addUsers(album.remoteId!, sharedUserIds);
.toList(); await _entityService.fillAlbumWithDatabaseEntities(updatedAlbum);
await _albumRepository.update(updatedAlbum);
final result = await _apiService.albumsApi.addUsersToAlbum(
album.remoteId!,
AddUsersDto(albumUsers: albumUsers),
);
if (result != null) {
album.sharedUsers.addAll(await _userRepository.getByIds(sharedUserIds));
album.shared = result.shared;
await _albumRepository.update(album);
return true; return true;
}
} catch (e) { } catch (e) {
debugPrint("Error addAdditionalUserToAlbum ${e.toString()}"); debugPrint("Error addAdditionalUserToAlbum ${e.toString()}");
} }
@ -315,15 +270,13 @@ class AlbumService {
Future<bool> setActivityEnabled(Album album, bool enabled) async { Future<bool> setActivityEnabled(Album album, bool enabled) async {
try { try {
final result = await _apiService.albumsApi.updateAlbumInfo( final updatedAlbum = await _albumApiRepository.update(
album.remoteId!, album.remoteId!,
UpdateAlbumDto(isActivityEnabled: enabled), activityEnabled: enabled,
); );
if (result != null) { await _entityService.fillAlbumWithDatabaseEntities(updatedAlbum);
album.activityEnabled = enabled; await _albumRepository.update(updatedAlbum);
await _albumRepository.update(album);
return true; return true;
}
} catch (e) { } catch (e) {
debugPrint("Error setActivityEnabled ${e.toString()}"); debugPrint("Error setActivityEnabled ${e.toString()}");
} }
@ -334,7 +287,7 @@ class AlbumService {
try { try {
final user = Store.get(StoreKey.currentUser); final user = Store.get(StoreKey.currentUser);
if (album.owner.value?.isarId == user.isarId) { if (album.owner.value?.isarId == user.isarId) {
await _apiService.albumsApi.deleteAlbum(album.remoteId!); await _albumApiRepository.delete(album.remoteId!);
} }
if (album.shared) { if (album.shared) {
final foreignAssets = final foreignAssets =
@ -365,7 +318,7 @@ class AlbumService {
Future<bool> leaveAlbum(Album album) async { Future<bool> leaveAlbum(Album album) async {
try { try {
await _apiService.albumsApi.removeUserFromAlbum(album.remoteId!, "me"); await _albumApiRepository.removeUser(album.remoteId!, userId: "me");
return true; return true;
} catch (e) { } catch (e) {
debugPrint("Error leaveAlbum ${e.toString()}"); debugPrint("Error leaveAlbum ${e.toString()}");
@ -378,21 +331,14 @@ class AlbumService {
Iterable<Asset> assets, Iterable<Asset> assets,
) async { ) async {
try { try {
final response = await _apiService.albumsApi.removeAssetFromAlbum( final result = await _albumApiRepository.removeAssets(
album.remoteId!, album.remoteId!,
BulkIdsDto( assets.map((asset) => asset.remoteId!),
ids: assets.map((asset) => asset.remoteId!).toList(),
),
); );
if (response != null) { final toRemove = result.removed
final toRemove = response.every((e) => e.success) .map((id) => assets.firstWhere((asset) => asset.remoteId == id));
? assets
: response
.where((e) => e.success)
.map((e) => assets.firstWhere((a) => a.remoteId == e.id));
await _updateAssets(album.id, remove: toRemove.toList()); await _updateAssets(album.id, remove: toRemove.toList());
return true; return true;
}
} catch (e) { } catch (e) {
debugPrint("Error removeAssetFromAlbum ${e.toString()}"); debugPrint("Error removeAssetFromAlbum ${e.toString()}");
} }
@ -404,9 +350,9 @@ class AlbumService {
User user, User user,
) async { ) async {
try { try {
await _apiService.albumsApi.removeUserFromAlbum( await _albumApiRepository.removeUser(
album.remoteId!, album.remoteId!,
user.id, userId: user.id,
); );
album.sharedUsers.remove(user); album.sharedUsers.remove(user);
@ -427,15 +373,12 @@ class AlbumService {
String newAlbumTitle, String newAlbumTitle,
) async { ) async {
try { try {
await _apiService.albumsApi.updateAlbumInfo( album = await _albumApiRepository.update(
album.remoteId!, album.remoteId!,
UpdateAlbumDto( name: newAlbumTitle,
albumName: newAlbumTitle,
),
); );
album.name = newAlbumTitle; await _entityService.fillAlbumWithDatabaseEntities(album);
await _albumRepository.update(album); await _albumRepository.update(album);
return true; return true;
} catch (e) { } catch (e) {
debugPrint("Error changeTitleAlbum ${e.toString()}"); debugPrint("Error changeTitleAlbum ${e.toString()}");
@ -456,12 +399,8 @@ class AlbumService {
for (final albumName in albumNames) { for (final albumName in albumNames) {
Album? album = await getAlbumByName(albumName, true); Album? album = await getAlbumByName(albumName, true);
album ??= await createAlbum(albumName, []); album ??= await createAlbum(albumName, []);
if (album != null && album.remoteId != null) { if (album != null && album.remoteId != null) {
await _apiService.albumsApi.addAssetsToAlbum( await _albumApiRepository.addAssets(album.remoteId!, assetIds);
album.remoteId!,
BulkIdsDto(ids: assetIds),
);
} }
} }
} }

View file

@ -13,12 +13,14 @@ import 'package:immich_mobile/main.dart';
import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; import 'package:immich_mobile/models/backup/success_upload_asset.model.dart';
import 'package:immich_mobile/repositories/album.repository.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/asset.repository.dart';
import 'package:immich_mobile/repositories/backup.repository.dart'; import 'package:immich_mobile/repositories/backup.repository.dart';
import 'package:immich_mobile/repositories/album_media.repository.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart';
import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart';
import 'package:immich_mobile/repositories/user.repository.dart'; import 'package:immich_mobile/repositories/user.repository.dart';
import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/album.service.dart';
import 'package:immich_mobile/services/entity.service.dart';
import 'package:immich_mobile/services/hash.service.dart'; import 'package:immich_mobile/services/hash.service.dart';
import 'package:immich_mobile/services/localization.service.dart'; import 'package:immich_mobile/services/localization.service.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/backup_album.entity.dart';
@ -363,23 +365,33 @@ class BackgroundService {
PartnerService partnerService = PartnerService(apiService, db); PartnerService partnerService = PartnerService(apiService, db);
AlbumRepository albumRepository = AlbumRepository(db); AlbumRepository albumRepository = AlbumRepository(db);
AssetRepository assetRepository = AssetRepository(db); AssetRepository assetRepository = AssetRepository(db);
UserRepository userRepository = UserRepository(db);
BackupRepository backupAlbumRepository = BackupRepository(db); BackupRepository backupAlbumRepository = BackupRepository(db);
AlbumMediaRepository albumMediaRepository = AlbumMediaRepository(); AlbumMediaRepository albumMediaRepository = AlbumMediaRepository();
FileMediaRepository fileMediaRepository = FileMediaRepository(); FileMediaRepository fileMediaRepository = FileMediaRepository();
UserRepository userRepository = UserRepository(db);
AlbumApiRepository albumApiRepository =
AlbumApiRepository(apiService.albumsApi);
HashService hashService = HashService(db, this, albumMediaRepository); HashService hashService = HashService(db, this, albumMediaRepository);
SyncService syncSerive = SyncService(db, hashService, albumMediaRepository); EntityService entityService =
EntityService(assetRepository, userRepository);
SyncService syncSerive = SyncService(
db,
hashService,
entityService,
albumMediaRepository,
albumApiRepository,
);
UserService userService = UserService userService =
UserService(apiService, db, syncSerive, partnerService); UserService(apiService, db, syncSerive, partnerService);
AlbumService albumService = AlbumService( AlbumService albumService = AlbumService(
apiService,
userService, userService,
syncSerive, syncSerive,
entityService,
albumRepository, albumRepository,
assetRepository, assetRepository,
userRepository,
backupAlbumRepository, backupAlbumRepository,
albumMediaRepository, albumMediaRepository,
albumApiRepository,
); );
BackupService backupService = BackupService( BackupService backupService = BackupService(
apiService, apiService,

View file

@ -0,0 +1,52 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/interfaces/asset.interface.dart';
import 'package:immich_mobile/interfaces/user.interface.dart';
import 'package:immich_mobile/repositories/asset.repository.dart';
import 'package:immich_mobile/repositories/user.repository.dart';
class EntityService {
final IAssetRepository _assetRepository;
final IUserRepository _userRepository;
EntityService(
this._assetRepository,
this._userRepository,
);
Future<Album> fillAlbumWithDatabaseEntities(Album album) async {
final ownerId = album.ownerId;
if (ownerId != null) {
// replace owner with user from database
album.owner.value = await _userRepository.get(ownerId);
}
final thumbnailAssetId =
album.remoteThumbnailAssetId ?? album.thumbnail.value?.remoteId;
if (thumbnailAssetId != null) {
// set thumbnail with asset from database
album.thumbnail.value =
await _assetRepository.getByRemoteId(thumbnailAssetId);
}
if (album.remoteUsers.isNotEmpty) {
// replace all users with users from database
final users = await _userRepository
.getByIds(album.remoteUsers.map((user) => user.id).toList());
album.sharedUsers.clear();
album.sharedUsers.addAll(users);
}
if (album.remoteAssets.isNotEmpty) {
// replace all assets with assets from database
final assets = await _assetRepository
.getAllByRemoteId(album.remoteAssets.map((asset) => asset.remoteId!));
album.assets.clear();
album.assets.addAll(assets);
}
return album;
}
}
final entityServiceProvider = Provider(
(ref) => EntityService(
ref.watch(assetRepositoryProvider),
ref.watch(userRepositoryProvider),
),
);

View file

@ -8,9 +8,12 @@ import 'package:immich_mobile/entities/etag.entity.dart';
import 'package:immich_mobile/entities/exif_info.entity.dart'; import 'package:immich_mobile/entities/exif_info.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/interfaces/album_api.interface.dart';
import 'package:immich_mobile/interfaces/album_media.interface.dart'; import 'package:immich_mobile/interfaces/album_media.interface.dart';
import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/repositories/album_api.repository.dart';
import 'package:immich_mobile/repositories/album_media.repository.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart';
import 'package:immich_mobile/services/entity.service.dart';
import 'package:immich_mobile/services/hash.service.dart'; import 'package:immich_mobile/services/hash.service.dart';
import 'package:immich_mobile/utils/async_mutex.dart'; import 'package:immich_mobile/utils/async_mutex.dart';
import 'package:immich_mobile/extensions/collection_extensions.dart'; import 'package:immich_mobile/extensions/collection_extensions.dart';
@ -18,24 +21,33 @@ import 'package:immich_mobile/utils/datetime_comparison.dart';
import 'package:immich_mobile/utils/diff.dart'; import 'package:immich_mobile/utils/diff.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
final syncServiceProvider = Provider( final syncServiceProvider = Provider(
(ref) => SyncService( (ref) => SyncService(
ref.watch(dbProvider), ref.watch(dbProvider),
ref.watch(hashServiceProvider), ref.watch(hashServiceProvider),
ref.watch(entityServiceProvider),
ref.watch(albumMediaRepositoryProvider), ref.watch(albumMediaRepositoryProvider),
ref.watch(albumApiRepositoryProvider),
), ),
); );
class SyncService { class SyncService {
final Isar _db; final Isar _db;
final HashService _hashService; final HashService _hashService;
final EntityService _entityService;
final IAlbumMediaRepository _albumMediaRepository; final IAlbumMediaRepository _albumMediaRepository;
final IAlbumApiRepository _albumApiRepository;
final AsyncMutex _lock = AsyncMutex(); final AsyncMutex _lock = AsyncMutex();
final Logger _log = Logger('SyncService'); final Logger _log = Logger('SyncService');
SyncService(this._db, this._hashService, this._albumMediaRepository); SyncService(
this._db,
this._hashService,
this._entityService,
this._albumMediaRepository,
this._albumApiRepository,
);
// public methods: // public methods:
@ -65,11 +77,10 @@ class SyncService {
/// Syncs remote albums to the database /// Syncs remote albums to the database
/// returns `true` if there were any changes /// returns `true` if there were any changes
Future<bool> syncRemoteAlbumsToDb( Future<bool> syncRemoteAlbumsToDb(
List<AlbumResponseDto> remote, { List<Album> remote, {
required bool isShared, required bool isShared,
required FutureOr<AlbumResponseDto> Function(AlbumResponseDto) loadDetails,
}) => }) =>
_lock.run(() => _syncRemoteAlbumsToDb(remote, isShared, loadDetails)); _lock.run(() => _syncRemoteAlbumsToDb(remote, isShared));
/// Syncs all device albums and their assets to the database /// Syncs all device albums and their assets to the database
/// Returns `true` if there were any changes /// Returns `true` if there were any changes
@ -289,11 +300,10 @@ class SyncService {
/// Syncs remote albums to the database /// Syncs remote albums to the database
/// returns `true` if there were any changes /// returns `true` if there were any changes
Future<bool> _syncRemoteAlbumsToDb( Future<bool> _syncRemoteAlbumsToDb(
List<AlbumResponseDto> remote, List<Album> remoteAlbums,
bool isShared, bool isShared,
FutureOr<AlbumResponseDto> Function(AlbumResponseDto) loadDetails,
) async { ) async {
remote.sortBy((e) => e.id); remoteAlbums.sortBy((e) => e.remoteId!);
final baseQuery = _db.albums.where().remoteIdIsNotNull().filter(); final baseQuery = _db.albums.where().remoteIdIsNotNull().filter();
final QueryBuilder<Album, Album, QAfterFilterCondition> query; final QueryBuilder<Album, Album, QAfterFilterCondition> query;
@ -310,14 +320,14 @@ class SyncService {
final List<Asset> existing = []; final List<Asset> existing = [];
final bool changes = await diffSortedLists( final bool changes = await diffSortedLists(
remote, remoteAlbums,
dbAlbums, dbAlbums,
compare: (AlbumResponseDto a, Album b) => a.id.compareTo(b.remoteId!), compare: (remoteAlbum, dbAlbum) =>
both: (AlbumResponseDto a, Album b) => remoteAlbum.remoteId!.compareTo(dbAlbum.remoteId!),
_syncRemoteAlbum(a, b, toDelete, existing, loadDetails), both: (remoteAlbum, dbAlbum) =>
onlyFirst: (AlbumResponseDto a) => _syncRemoteAlbum(remoteAlbum, dbAlbum, toDelete, existing),
_addAlbumFromServer(a, existing, loadDetails), onlyFirst: (remoteAlbum) => _addAlbumFromServer(remoteAlbum, existing),
onlySecond: (Album a) => _removeAlbumFromDb(a, toDelete), onlySecond: (dbAlbum) => _removeAlbumFromDb(dbAlbum, toDelete),
); );
if (isShared && toDelete.isNotEmpty) { if (isShared && toDelete.isNotEmpty) {
@ -338,26 +348,22 @@ class SyncService {
/// syncing changes from local back to server) /// syncing changes from local back to server)
/// accumulates /// accumulates
Future<bool> _syncRemoteAlbum( Future<bool> _syncRemoteAlbum(
AlbumResponseDto dto, Album dto,
Album album, Album album,
List<Asset> deleteCandidates, List<Asset> deleteCandidates,
List<Asset> existing, List<Asset> existing,
FutureOr<AlbumResponseDto> Function(AlbumResponseDto) loadDetails,
) async { ) async {
if (!_hasAlbumResponseDtoChanged(dto, album)) { if (!_hasRemoteAlbumChanged(dto, album)) {
return false; return false;
} }
// loadDetails (/api/album/:id) will not include lastModifiedAssetTimestamp, // loadDetails (/api/album/:id) will not include lastModifiedAssetTimestamp,
// i.e. it will always be null. Save it here. // i.e. it will always be null. Save it here.
final originalDto = dto; final originalDto = dto;
dto = await loadDetails(dto); dto = await _albumApiRepository.get(dto.remoteId!);
if (dto.assetCount != dto.assets.length) {
return false;
}
final assetsInDb = final assetsInDb =
await album.assets.filter().sortByOwnerId().thenByChecksum().findAll(); await album.assets.filter().sortByOwnerId().thenByChecksum().findAll();
assert(assetsInDb.isSorted(Asset.compareByOwnerChecksum), "inDb unsorted!"); assert(assetsInDb.isSorted(Asset.compareByOwnerChecksum), "inDb unsorted!");
final List<Asset> assetsOnRemote = dto.getAssets(); final List<Asset> assetsOnRemote = dto.remoteAssets.toList();
assetsOnRemote.sort(Asset.compareByOwnerChecksum); assetsOnRemote.sort(Asset.compareByOwnerChecksum);
final (toAdd, toUpdate, toUnlink) = _diffAssets( final (toAdd, toUpdate, toUnlink) = _diffAssets(
assetsOnRemote, assetsOnRemote,
@ -368,15 +374,16 @@ class SyncService {
// update shared users // update shared users
final List<User> sharedUsers = album.sharedUsers.toList(growable: false); final List<User> sharedUsers = album.sharedUsers.toList(growable: false);
sharedUsers.sort((a, b) => a.id.compareTo(b.id)); sharedUsers.sort((a, b) => a.id.compareTo(b.id));
dto.albumUsers.sort((a, b) => a.user.id.compareTo(b.user.id)); final List<User> users = dto.remoteUsers.toList()
..sort((a, b) => a.id.compareTo(b.id));
final List<String> userIdsToAdd = []; final List<String> userIdsToAdd = [];
final List<User> usersToUnlink = []; final List<User> usersToUnlink = [];
diffSortedListsSync( diffSortedListsSync(
dto.albumUsers, users,
sharedUsers, sharedUsers,
compare: (AlbumUserResponseDto a, User b) => a.user.id.compareTo(b.id), compare: (User a, User b) => a.id.compareTo(b.id),
both: (a, b) => false, both: (a, b) => false,
onlyFirst: (AlbumUserResponseDto a) => userIdsToAdd.add(a.user.id), onlyFirst: (User a) => userIdsToAdd.add(a.id),
onlySecond: (User a) => usersToUnlink.add(a), onlySecond: (User a) => usersToUnlink.add(a),
); );
@ -386,19 +393,19 @@ class SyncService {
final assetsToLink = existingInDb + updated; final assetsToLink = existingInDb + updated;
final usersToLink = (await _db.users.getAllById(userIdsToAdd)).cast<User>(); final usersToLink = (await _db.users.getAllById(userIdsToAdd)).cast<User>();
album.name = dto.albumName; album.name = dto.name;
album.shared = dto.shared; album.shared = dto.shared;
album.createdAt = dto.createdAt; album.createdAt = dto.createdAt;
album.modifiedAt = dto.updatedAt; album.modifiedAt = dto.modifiedAt;
album.startDate = dto.startDate; album.startDate = dto.startDate;
album.endDate = dto.endDate; album.endDate = dto.endDate;
album.lastModifiedAssetTimestamp = originalDto.lastModifiedAssetTimestamp; album.lastModifiedAssetTimestamp = originalDto.lastModifiedAssetTimestamp;
album.shared = dto.shared; album.shared = dto.shared;
album.activityEnabled = dto.isActivityEnabled; album.activityEnabled = dto.activityEnabled;
if (album.thumbnail.value?.remoteId != dto.albumThumbnailAssetId) { if (album.thumbnail.value?.remoteId != dto.remoteThumbnailAssetId) {
album.thumbnail.value = await _db.assets album.thumbnail.value = await _db.assets
.where() .where()
.remoteIdEqualTo(dto.albumThumbnailAssetId) .remoteIdEqualTo(dto.remoteThumbnailAssetId)
.findFirst(); .findFirst();
} }
@ -434,27 +441,26 @@ class SyncService {
/// (shared) assets to the database beforehand /// (shared) assets to the database beforehand
/// accumulates assets already existing in the database /// accumulates assets already existing in the database
Future<void> _addAlbumFromServer( Future<void> _addAlbumFromServer(
AlbumResponseDto dto, Album album,
List<Asset> existing, List<Asset> existing,
FutureOr<AlbumResponseDto> Function(AlbumResponseDto) loadDetails,
) async { ) async {
if (dto.assetCount != dto.assets.length) { if (album.remoteAssetCount != album.remoteAssets.length) {
dto = await loadDetails(dto); album = await _albumApiRepository.get(album.remoteId!);
} }
if (dto.assetCount == dto.assets.length) { if (album.remoteAssetCount == album.remoteAssets.length) {
// in case an album contains assets not yet present in local DB: // in case an album contains assets not yet present in local DB:
// put missing album assets into local DB // put missing album assets into local DB
final (existingInDb, updated) = final (existingInDb, updated) =
await _linkWithExistingFromDb(dto.getAssets()); await _linkWithExistingFromDb(album.remoteAssets.toList());
existing.addAll(existingInDb); existing.addAll(existingInDb);
await upsertAssetsWithExif(updated); await upsertAssetsWithExif(updated);
final Album a = await Album.remote(dto); await _entityService.fillAlbumWithDatabaseEntities(album);
await _db.writeTxn(() => _db.albums.store(a)); await _db.writeTxn(() => _db.albums.store(album));
} else { } else {
_log.warning( _log.warning(
"Failed to add album from server: assetCount ${dto.assetCount} != " "Failed to add album from server: assetCount ${album.remoteAssetCount} != "
"asset array length ${dto.assets.length} for album ${dto.albumName}"); "asset array length ${album.remoteAssets.length} for album ${album.name}");
} }
} }
@ -919,17 +925,17 @@ class SyncService {
} }
/// returns `true` if the albums differ on the surface /// returns `true` if the albums differ on the surface
bool _hasAlbumResponseDtoChanged(AlbumResponseDto dto, Album a) { bool _hasRemoteAlbumChanged(Album remoteAlbum, Album dbAlbum) {
return dto.assetCount != a.assetCount || return remoteAlbum.remoteAssetCount != dbAlbum.assetCount ||
dto.albumName != a.name || remoteAlbum.name != dbAlbum.name ||
dto.albumThumbnailAssetId != a.thumbnail.value?.remoteId || remoteAlbum.remoteThumbnailAssetId != dbAlbum.thumbnail.value?.remoteId ||
dto.shared != a.shared || remoteAlbum.shared != dbAlbum.shared ||
dto.albumUsers.length != a.sharedUsers.length || remoteAlbum.remoteUsers.length != dbAlbum.sharedUsers.length ||
!dto.updatedAt.isAtSameMomentAs(a.modifiedAt) || !remoteAlbum.modifiedAt.isAtSameMomentAs(dbAlbum.modifiedAt) ||
!isAtSameMomentAs(dto.startDate, a.startDate) || !isAtSameMomentAs(remoteAlbum.startDate, dbAlbum.startDate) ||
!isAtSameMomentAs(dto.endDate, a.endDate) || !isAtSameMomentAs(remoteAlbum.endDate, dbAlbum.endDate) ||
!isAtSameMomentAs( !isAtSameMomentAs(
dto.lastModifiedAssetTimestamp, remoteAlbum.lastModifiedAssetTimestamp,
a.lastModifiedAssetTimestamp, dbAlbum.lastModifiedAssetTimestamp,
); );
} }

View file

@ -39,8 +39,10 @@ void main() {
group('Test SyncService grouped', () { group('Test SyncService grouped', () {
late final Isar db; late final Isar db;
final MockHashService hs = MockHashService(); final MockHashService hs = MockHashService();
final MockEntityService entityService = MockEntityService();
final MockAlbumMediaRepository albumMediaRepository = final MockAlbumMediaRepository albumMediaRepository =
MockAlbumMediaRepository(); MockAlbumMediaRepository();
final MockAlbumApiRepository albumApiRepository = MockAlbumApiRepository();
final owner = User( final owner = User(
id: "1", id: "1",
updatedAt: DateTime.now(), updatedAt: DateTime.now(),
@ -48,6 +50,7 @@ void main() {
name: "first last", name: "first last",
isAdmin: false, isAdmin: false,
); );
late SyncService s;
setUpAll(() async { setUpAll(() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
db = await TestUtils.initIsar(); db = await TestUtils.initIsar();
@ -68,9 +71,15 @@ void main() {
db.assets.clearSync(); db.assets.clearSync();
db.assets.putAllSync(initialAssets); db.assets.putAllSync(initialAssets);
}); });
s = SyncService(
db,
hs,
entityService,
albumMediaRepository,
albumApiRepository,
);
}); });
test('test inserting existing assets', () async { test('test inserting existing assets', () async {
SyncService s = SyncService(db, hs, albumMediaRepository);
final List<Asset> remoteAssets = [ final List<Asset> remoteAssets = [
makeAsset(checksum: "a", remoteId: "0-1"), makeAsset(checksum: "a", remoteId: "0-1"),
makeAsset(checksum: "b", remoteId: "2-1"), makeAsset(checksum: "b", remoteId: "2-1"),
@ -88,7 +97,6 @@ void main() {
}); });
test('test inserting new assets', () async { test('test inserting new assets', () async {
SyncService s = SyncService(db, hs, albumMediaRepository);
final List<Asset> remoteAssets = [ final List<Asset> remoteAssets = [
makeAsset(checksum: "a", remoteId: "0-1"), makeAsset(checksum: "a", remoteId: "0-1"),
makeAsset(checksum: "b", remoteId: "2-1"), makeAsset(checksum: "b", remoteId: "2-1"),
@ -109,7 +117,6 @@ void main() {
}); });
test('test syncing duplicate assets', () async { test('test syncing duplicate assets', () async {
SyncService s = SyncService(db, hs, albumMediaRepository);
final List<Asset> remoteAssets = [ final List<Asset> remoteAssets = [
makeAsset(checksum: "a", remoteId: "0-1"), makeAsset(checksum: "a", remoteId: "0-1"),
makeAsset(checksum: "b", remoteId: "1-1"), makeAsset(checksum: "b", remoteId: "1-1"),
@ -157,7 +164,6 @@ void main() {
}); });
test('test efficient sync', () async { test('test efficient sync', () async {
SyncService s = SyncService(db, hs, albumMediaRepository);
final List<Asset> toUpsert = [ final List<Asset> toUpsert = [
makeAsset(checksum: "a", remoteId: "0-1"), // changed makeAsset(checksum: "a", remoteId: "0-1"), // changed
makeAsset(checksum: "f", remoteId: "0-2"), // new makeAsset(checksum: "f", remoteId: "0-2"), // new

View file

@ -1,4 +1,5 @@
import 'package:immich_mobile/interfaces/album.interface.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/album_media.interface.dart';
import 'package:immich_mobile/interfaces/asset.interface.dart'; import 'package:immich_mobile/interfaces/asset.interface.dart';
import 'package:immich_mobile/interfaces/asset_media.interface.dart'; import 'package:immich_mobile/interfaces/asset_media.interface.dart';
@ -20,3 +21,5 @@ class MockAlbumMediaRepository extends Mock implements IAlbumMediaRepository {}
class MockAssetMediaRepository extends Mock implements IAssetMediaRepository {} class MockAssetMediaRepository extends Mock implements IAssetMediaRepository {}
class MockFileMediaRepository extends Mock implements IFileMediaRepository {} class MockFileMediaRepository extends Mock implements IFileMediaRepository {}
class MockAlbumApiRepository extends Mock implements IAlbumApiRepository {}

View file

@ -1,4 +1,5 @@
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/entity.service.dart';
import 'package:immich_mobile/services/hash.service.dart'; import 'package:immich_mobile/services/hash.service.dart';
import 'package:immich_mobile/services/sync.service.dart'; import 'package:immich_mobile/services/sync.service.dart';
import 'package:immich_mobile/services/user.service.dart'; import 'package:immich_mobile/services/user.service.dart';
@ -11,3 +12,5 @@ class MockUserService extends Mock implements UserService {}
class MockSyncService extends Mock implements SyncService {} class MockSyncService extends Mock implements SyncService {}
class MockHashService extends Mock implements HashService {} class MockHashService extends Mock implements HashService {}
class MockEntityService extends Mock implements EntityService {}

View file

@ -3,39 +3,41 @@ import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/album.service.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
import '../fixtures/album.stub.dart'; import '../fixtures/album.stub.dart';
import '../fixtures/asset.stub.dart';
import '../fixtures/user.stub.dart';
import '../repository.mocks.dart'; import '../repository.mocks.dart';
import '../service.mocks.dart'; import '../service.mocks.dart';
void main() { void main() {
late AlbumService sut; late AlbumService sut;
late MockApiService apiService;
late MockUserService userService; late MockUserService userService;
late MockSyncService syncService; late MockSyncService syncService;
late MockEntityService entityService;
late MockAlbumRepository albumRepository; late MockAlbumRepository albumRepository;
late MockAssetRepository assetRepository; late MockAssetRepository assetRepository;
late MockUserRepository userRepository;
late MockBackupRepository backupRepository; late MockBackupRepository backupRepository;
late MockAlbumMediaRepository albumMediaRepository; late MockAlbumMediaRepository albumMediaRepository;
late MockAlbumApiRepository albumApiRepository;
setUp(() { setUp(() {
apiService = MockApiService();
userService = MockUserService(); userService = MockUserService();
syncService = MockSyncService(); syncService = MockSyncService();
entityService = MockEntityService();
albumRepository = MockAlbumRepository(); albumRepository = MockAlbumRepository();
assetRepository = MockAssetRepository(); assetRepository = MockAssetRepository();
userRepository = MockUserRepository();
backupRepository = MockBackupRepository(); backupRepository = MockBackupRepository();
albumMediaRepository = MockAlbumMediaRepository(); albumMediaRepository = MockAlbumMediaRepository();
albumApiRepository = MockAlbumApiRepository();
sut = AlbumService( sut = AlbumService(
apiService,
userService, userService,
syncService, syncService,
entityService,
albumRepository, albumRepository,
assetRepository, assetRepository,
userRepository,
backupRepository, backupRepository,
albumMediaRepository, albumMediaRepository,
albumApiRepository,
); );
}); });
@ -70,4 +72,125 @@ void main() {
verifyNoMoreInteractions(syncService); verifyNoMoreInteractions(syncService);
}); });
}); });
group('refreshRemoteAlbums', () {
test('isShared: false', () async {
when(() => userService.refreshUsers()).thenAnswer((_) async => true);
when(() => albumApiRepository.getAll(shared: null))
.thenAnswer((_) async => [AlbumStub.oneAsset, AlbumStub.twoAsset]);
when(
() => syncService.syncRemoteAlbumsToDb(
[AlbumStub.oneAsset, AlbumStub.twoAsset],
isShared: false,
),
).thenAnswer((_) async => true);
final result = await sut.refreshRemoteAlbums(isShared: false);
expect(result, true);
verify(() => userService.refreshUsers()).called(1);
verify(() => albumApiRepository.getAll(shared: null)).called(1);
verify(
() => syncService.syncRemoteAlbumsToDb(
[AlbumStub.oneAsset, AlbumStub.twoAsset],
isShared: false,
),
).called(1);
verifyNoMoreInteractions(userService);
verifyNoMoreInteractions(albumApiRepository);
verifyNoMoreInteractions(syncService);
});
});
group('createAlbum', () {
test('shared with assets', () async {
when(
() => albumApiRepository.create(
"name",
assetIds: any(named: "assetIds"),
sharedUserIds: any(named: "sharedUserIds"),
),
).thenAnswer((_) async => AlbumStub.oneAsset);
when(
() => entityService.fillAlbumWithDatabaseEntities(AlbumStub.oneAsset),
).thenAnswer((_) async => AlbumStub.oneAsset);
when(
() => albumRepository.create(AlbumStub.oneAsset),
).thenAnswer((_) async => AlbumStub.twoAsset);
final result =
await sut.createAlbum("name", [AssetStub.image1], [UserStub.user1]);
expect(result, AlbumStub.twoAsset);
verify(
() => albumApiRepository.create(
"name",
assetIds: [AssetStub.image1.remoteId!],
sharedUserIds: [UserStub.user1.id],
),
).called(1);
verify(
() => entityService.fillAlbumWithDatabaseEntities(AlbumStub.oneAsset),
).called(1);
});
});
group('addAdditionalAssetToAlbum', () {
test('one added, one duplicate', () async {
when(
() => albumApiRepository.addAssets(AlbumStub.oneAsset.remoteId!, any()),
).thenAnswer(
(_) async => (
added: [AssetStub.image2.remoteId!],
duplicates: [AssetStub.image1.remoteId!]
),
);
when(
() => albumRepository.getById(AlbumStub.oneAsset.id),
).thenAnswer((_) async => AlbumStub.oneAsset);
when(
() => albumRepository.addAssets(AlbumStub.oneAsset, [AssetStub.image2]),
).thenAnswer((_) async {});
when(
() => albumRepository.removeAssets(AlbumStub.oneAsset, []),
).thenAnswer((_) async {});
when(
() => albumRepository.recalculateMetadata(AlbumStub.oneAsset),
).thenAnswer((_) async => AlbumStub.oneAsset);
when(
() => albumRepository.update(AlbumStub.oneAsset),
).thenAnswer((_) async => AlbumStub.oneAsset);
final result = await sut.addAdditionalAssetToAlbum(
[AssetStub.image1, AssetStub.image2],
AlbumStub.oneAsset,
);
expect(result != null, true);
expect(result!.alreadyInAlbum, [AssetStub.image1.remoteId!]);
expect(result.successfullyAdded, 1);
});
});
group('addAdditionalUserToAlbum', () {
test('one added', () async {
when(
() =>
albumApiRepository.addUsers(AlbumStub.emptyAlbum.remoteId!, any()),
).thenAnswer(
(_) async => AlbumStub.sharedWithUser,
);
when(
() => entityService
.fillAlbumWithDatabaseEntities(AlbumStub.sharedWithUser),
).thenAnswer((_) async => AlbumStub.sharedWithUser);
when(
() => albumRepository.update(AlbumStub.sharedWithUser),
).thenAnswer((_) async => AlbumStub.sharedWithUser);
final result = await sut.addAdditionalUserToAlbum(
[UserStub.user2.id],
AlbumStub.emptyAlbum,
);
expect(result, true);
});
});
} }

View file

@ -0,0 +1,76 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/services/entity.service.dart';
import 'package:mocktail/mocktail.dart';
import '../fixtures/asset.stub.dart';
import '../fixtures/user.stub.dart';
import '../repository.mocks.dart';
void main() {
late EntityService sut;
late MockAssetRepository assetRepository;
late MockUserRepository userRepository;
setUp(() {
assetRepository = MockAssetRepository();
userRepository = MockUserRepository();
sut = EntityService(assetRepository, userRepository);
});
group('fillAlbumWithDatabaseEntities', () {
test('remote album with owner, thumbnail, sharedUsers and assets',
() async {
final Album album = Album(
name: "album-with-two-assets-and-two-users",
localId: "album-with-two-assets-and-two-users-local",
remoteId: "album-with-two-assets-and-two-users-remote",
createdAt: DateTime(2001),
modifiedAt: DateTime(2010),
shared: true,
activityEnabled: true,
startDate: DateTime(2019),
endDate: DateTime(2020),
)
..remoteThumbnailAssetId = AssetStub.image1.remoteId
..assets.addAll([AssetStub.image1, AssetStub.image1])
..owner.value = UserStub.user1
..sharedUsers.addAll([UserStub.admin, UserStub.admin]);
when(() => userRepository.get(album.ownerId!))
.thenAnswer((_) async => UserStub.admin);
when(() => assetRepository.getByRemoteId(AssetStub.image1.remoteId!))
.thenAnswer((_) async => AssetStub.image1);
when(() => userRepository.getByIds(any()))
.thenAnswer((_) async => [UserStub.user1, UserStub.user2]);
when(() => assetRepository.getAllByRemoteId(any()))
.thenAnswer((_) async => [AssetStub.image1, AssetStub.image2]);
await sut.fillAlbumWithDatabaseEntities(album);
expect(album.owner.value, UserStub.admin);
expect(album.thumbnail.value, AssetStub.image1);
expect(album.remoteUsers.toSet(), {UserStub.user1, UserStub.user2});
expect(album.remoteAssets.toSet(), {AssetStub.image1, AssetStub.image2});
});
test('remote album without any info', () async {
makeEmptyAlbum() => Album(
name: "album-without-info",
localId: "album-without-info-local",
remoteId: "album-without-info-remote",
createdAt: DateTime(2001),
modifiedAt: DateTime(2010),
shared: false,
activityEnabled: false,
);
final album = makeEmptyAlbum();
await sut.fillAlbumWithDatabaseEntities(album);
verifyNoMoreInteractions(assetRepository);
verifyNoMoreInteractions(userRepository);
expect(album, makeEmptyAlbum());
});
});
}