diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index 6d10c4eb2a..f69d1dbb91 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -99,44 +99,6 @@ export interface APIKeyUpdateDto { */ 'name': string; } -/** - * - * @export - * @interface AddAssetsDto - */ -export interface AddAssetsDto { - /** - * - * @type {Array} - * @memberof AddAssetsDto - */ - 'assetIds': Array; -} -/** - * - * @export - * @interface AddAssetsResponseDto - */ -export interface AddAssetsResponseDto { - /** - * - * @type {AlbumResponseDto} - * @memberof AddAssetsResponseDto - */ - 'album'?: AlbumResponseDto; - /** - * - * @type {Array} - * @memberof AddAssetsResponseDto - */ - 'alreadyInAlbum': Array; - /** - * - * @type {number} - * @memberof AddAssetsResponseDto - */ - 'successfullyAdded': number; -} /** * * @export @@ -821,6 +783,19 @@ export const BulkIdResponseDtoErrorEnum = { export type BulkIdResponseDtoErrorEnum = typeof BulkIdResponseDtoErrorEnum[keyof typeof BulkIdResponseDtoErrorEnum]; +/** + * + * @export + * @interface BulkIdsDto + */ +export interface BulkIdsDto { + /** + * + * @type {Array} + * @memberof BulkIdsDto + */ + 'ids': Array; +} /** * * @export @@ -1927,19 +1902,6 @@ export interface QueueStatusDto { */ 'isPaused': boolean; } -/** - * - * @export - * @interface RemoveAssetsDto - */ -export interface RemoveAssetsDto { - /** - * - * @type {Array} - * @memberof RemoveAssetsDto - */ - 'assetIds': Array; -} /** * * @export @@ -3678,16 +3640,16 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration /** * * @param {string} id - * @param {AddAssetsDto} addAssetsDto + * @param {BulkIdsDto} bulkIdsDto * @param {string} [key] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - addAssetsToAlbum: async (id: string, addAssetsDto: AddAssetsDto, key?: string, options: AxiosRequestConfig = {}): Promise => { + addAssetsToAlbum: async (id: string, bulkIdsDto: BulkIdsDto, key?: string, options: AxiosRequestConfig = {}): Promise => { // verify required parameter 'id' is not null or undefined assertParamExists('addAssetsToAlbum', 'id', id) - // verify required parameter 'addAssetsDto' is not null or undefined - assertParamExists('addAssetsToAlbum', 'addAssetsDto', addAssetsDto) + // verify required parameter 'bulkIdsDto' is not null or undefined + assertParamExists('addAssetsToAlbum', 'bulkIdsDto', bulkIdsDto) const localVarPath = `/album/{id}/assets` .replace(`{${"id"}}`, encodeURIComponent(String(id))); // use dummy base URL string because the URL constructor only accepts absolute URLs. @@ -3721,7 +3683,7 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - localVarRequestOptions.data = serializeDataIfNeeded(addAssetsDto, localVarRequestOptions, configuration) + localVarRequestOptions.data = serializeDataIfNeeded(bulkIdsDto, localVarRequestOptions, configuration) return { url: toPathString(localVarUrlObj), @@ -3998,15 +3960,15 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration /** * * @param {string} id - * @param {RemoveAssetsDto} removeAssetsDto + * @param {BulkIdsDto} bulkIdsDto * @param {*} [options] Override http request option. * @throws {RequiredError} */ - removeAssetFromAlbum: async (id: string, removeAssetsDto: RemoveAssetsDto, options: AxiosRequestConfig = {}): Promise => { + removeAssetFromAlbum: async (id: string, bulkIdsDto: BulkIdsDto, options: AxiosRequestConfig = {}): Promise => { // verify required parameter 'id' is not null or undefined assertParamExists('removeAssetFromAlbum', 'id', id) - // verify required parameter 'removeAssetsDto' is not null or undefined - assertParamExists('removeAssetFromAlbum', 'removeAssetsDto', removeAssetsDto) + // verify required parameter 'bulkIdsDto' is not null or undefined + assertParamExists('removeAssetFromAlbum', 'bulkIdsDto', bulkIdsDto) const localVarPath = `/album/{id}/assets` .replace(`{${"id"}}`, encodeURIComponent(String(id))); // use dummy base URL string because the URL constructor only accepts absolute URLs. @@ -4036,7 +3998,7 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - localVarRequestOptions.data = serializeDataIfNeeded(removeAssetsDto, localVarRequestOptions, configuration) + localVarRequestOptions.data = serializeDataIfNeeded(bulkIdsDto, localVarRequestOptions, configuration) return { url: toPathString(localVarUrlObj), @@ -4150,13 +4112,13 @@ export const AlbumApiFp = function(configuration?: Configuration) { /** * * @param {string} id - * @param {AddAssetsDto} addAssetsDto + * @param {BulkIdsDto} bulkIdsDto * @param {string} [key] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async addAssetsToAlbum(id: string, addAssetsDto: AddAssetsDto, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.addAssetsToAlbum(id, addAssetsDto, key, options); + async addAssetsToAlbum(id: string, bulkIdsDto: BulkIdsDto, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.addAssetsToAlbum(id, bulkIdsDto, key, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** @@ -4224,12 +4186,12 @@ export const AlbumApiFp = function(configuration?: Configuration) { /** * * @param {string} id - * @param {RemoveAssetsDto} removeAssetsDto + * @param {BulkIdsDto} bulkIdsDto * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async removeAssetFromAlbum(id: string, removeAssetsDto: RemoveAssetsDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.removeAssetFromAlbum(id, removeAssetsDto, options); + async removeAssetFromAlbum(id: string, bulkIdsDto: BulkIdsDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.removeAssetFromAlbum(id, bulkIdsDto, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** @@ -4270,8 +4232,8 @@ export const AlbumApiFactory = function (configuration?: Configuration, basePath * @param {*} [options] Override http request option. * @throws {RequiredError} */ - addAssetsToAlbum(requestParameters: AlbumApiAddAssetsToAlbumRequest, options?: AxiosRequestConfig): AxiosPromise { - return localVarFp.addAssetsToAlbum(requestParameters.id, requestParameters.addAssetsDto, requestParameters.key, options).then((request) => request(axios, basePath)); + addAssetsToAlbum(requestParameters: AlbumApiAddAssetsToAlbumRequest, options?: AxiosRequestConfig): AxiosPromise> { + return localVarFp.addAssetsToAlbum(requestParameters.id, requestParameters.bulkIdsDto, requestParameters.key, options).then((request) => request(axios, basePath)); }, /** * @@ -4332,8 +4294,8 @@ export const AlbumApiFactory = function (configuration?: Configuration, basePath * @param {*} [options] Override http request option. * @throws {RequiredError} */ - removeAssetFromAlbum(requestParameters: AlbumApiRemoveAssetFromAlbumRequest, options?: AxiosRequestConfig): AxiosPromise { - return localVarFp.removeAssetFromAlbum(requestParameters.id, requestParameters.removeAssetsDto, options).then((request) => request(axios, basePath)); + removeAssetFromAlbum(requestParameters: AlbumApiRemoveAssetFromAlbumRequest, options?: AxiosRequestConfig): AxiosPromise> { + return localVarFp.removeAssetFromAlbum(requestParameters.id, requestParameters.bulkIdsDto, options).then((request) => request(axios, basePath)); }, /** * @@ -4371,10 +4333,10 @@ export interface AlbumApiAddAssetsToAlbumRequest { /** * - * @type {AddAssetsDto} + * @type {BulkIdsDto} * @memberof AlbumApiAddAssetsToAlbum */ - readonly addAssetsDto: AddAssetsDto + readonly bulkIdsDto: BulkIdsDto /** * @@ -4490,10 +4452,10 @@ export interface AlbumApiRemoveAssetFromAlbumRequest { /** * - * @type {RemoveAssetsDto} + * @type {BulkIdsDto} * @memberof AlbumApiRemoveAssetFromAlbum */ - readonly removeAssetsDto: RemoveAssetsDto + readonly bulkIdsDto: BulkIdsDto } /** @@ -4553,7 +4515,7 @@ export class AlbumApi extends BaseAPI { * @memberof AlbumApi */ public addAssetsToAlbum(requestParameters: AlbumApiAddAssetsToAlbumRequest, options?: AxiosRequestConfig) { - return AlbumApiFp(this.configuration).addAssetsToAlbum(requestParameters.id, requestParameters.addAssetsDto, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); + return AlbumApiFp(this.configuration).addAssetsToAlbum(requestParameters.id, requestParameters.bulkIdsDto, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); } /** @@ -4629,7 +4591,7 @@ export class AlbumApi extends BaseAPI { * @memberof AlbumApi */ public removeAssetFromAlbum(requestParameters: AlbumApiRemoveAssetFromAlbumRequest, options?: AxiosRequestConfig) { - return AlbumApiFp(this.configuration).removeAssetFromAlbum(requestParameters.id, requestParameters.removeAssetsDto, options).then((request) => request(this.axios, this.basePath)); + return AlbumApiFp(this.configuration).removeAssetFromAlbum(requestParameters.id, requestParameters.bulkIdsDto, options).then((request) => request(this.axios, this.basePath)); } /** diff --git a/mobile/lib/modules/album/models/add_asset_response.model.dart b/mobile/lib/modules/album/models/add_asset_response.model.dart new file mode 100644 index 0000000000..11efd36f84 --- /dev/null +++ b/mobile/lib/modules/album/models/add_asset_response.model.dart @@ -0,0 +1,49 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first +import 'dart:convert'; + +import 'package:collection/collection.dart'; + +class AddAssetsResponse { + List alreadyInAlbum; + int successfullyAdded; + + AddAssetsResponse({ + required this.alreadyInAlbum, + required this.successfullyAdded, + }); + + AddAssetsResponse copyWith({ + List? alreadyInAlbum, + int? successfullyAdded, + }) { + return AddAssetsResponse( + alreadyInAlbum: alreadyInAlbum ?? this.alreadyInAlbum, + successfullyAdded: successfullyAdded ?? this.successfullyAdded, + ); + } + + Map toMap() { + return { + 'alreadyInAlbum': alreadyInAlbum, + 'successfullyAdded': successfullyAdded, + }; + } + + String toJson() => json.encode(toMap()); + + @override + String toString() => + 'AddAssetsResponse(alreadyInAlbum: $alreadyInAlbum, successfullyAdded: $successfullyAdded)'; + + @override + bool operator ==(covariant AddAssetsResponse other) { + if (identical(this, other)) return true; + final listEquals = const DeepCollectionEquality().equals; + + return listEquals(other.alreadyInAlbum, alreadyInAlbum) && + other.successfullyAdded == successfullyAdded; + } + + @override + int get hashCode => alreadyInAlbum.hashCode ^ successfullyAdded.hashCode; +} diff --git a/mobile/lib/modules/album/providers/album_detail.provider.dart b/mobile/lib/modules/album/providers/album_detail.provider.dart new file mode 100644 index 0000000000..531c5c944a --- /dev/null +++ b/mobile/lib/modules/album/providers/album_detail.provider.dart @@ -0,0 +1,21 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/album/services/album.service.dart'; +import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart'; +import 'package:immich_mobile/shared/models/album.dart'; +import 'package:immich_mobile/shared/providers/user.provider.dart'; + +final albumDetailProvider = + StreamProvider.family((ref, albumId) async* { + final user = ref.watch(currentUserProvider); + if (user == null) return; + final AlbumService service = ref.watch(albumServiceProvider); + + await for (final a in service.watchAlbum(albumId)) { + if (a == null) { + throw Exception("Album with ID=$albumId does not exist anymore!"); + } + await for (final _ in a.watchRenderList(GroupAssetsBy.none)) { + yield a; + } + } +}); diff --git a/mobile/lib/modules/album/providers/shared_album.provider.dart b/mobile/lib/modules/album/providers/shared_album.provider.dart index a6fd8db23e..8b342dd45f 100644 --- a/mobile/lib/modules/album/providers/shared_album.provider.dart +++ b/mobile/lib/modules/album/providers/shared_album.provider.dart @@ -3,12 +3,10 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/album/services/album.service.dart'; -import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/user.dart'; import 'package:immich_mobile/shared/providers/db.provider.dart'; -import 'package:immich_mobile/shared/providers/user.provider.dart'; import 'package:isar/isar.dart'; class SharedAlbumNotifier extends StateNotifier> { @@ -72,19 +70,3 @@ final sharedAlbumProvider = ref.watch(dbProvider), ); }); - -final sharedAlbumDetailProvider = - StreamProvider.family((ref, albumId) async* { - final user = ref.watch(currentUserProvider); - if (user == null) return; - final AlbumService sharedAlbumService = ref.watch(albumServiceProvider); - - await for (final a in sharedAlbumService.watchAlbum(albumId)) { - if (a == null) { - throw Exception("Album with ID=$albumId does not exist anymore!"); - } - await for (final _ in a.watchRenderList(GroupAssetsBy.none)) { - yield a; - } - } -}); diff --git a/mobile/lib/modules/album/services/album.service.dart b/mobile/lib/modules/album/services/album.service.dart index 2e45d87c8d..0960978a47 100644 --- a/mobile/lib/modules/album/services/album.service.dart +++ b/mobile/lib/modules/album/services/album.service.dart @@ -5,6 +5,7 @@ import 'dart:io'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/album/models/add_asset_response.model.dart'; import 'package:immich_mobile/modules/backup/models/backup_album.model.dart'; import 'package:immich_mobile/modules/backup/services/backup.service.dart'; import 'package:immich_mobile/shared/models/album.dart'; @@ -219,24 +220,43 @@ class AlbumService { yield* _db.albums.watchObject(albumId); } - Future addAdditionalAssetToAlbum( + Future addAdditionalAssetToAlbum( Iterable assets, Album album, ) async { try { - var result = await _apiService.albumApi.addAssetsToAlbum( + var response = await _apiService.albumApi.addAssetsToAlbum( album.remoteId!, - AddAssetsDto(assetIds: assets.map((asset) => asset.remoteId!).toList()), + BulkIdsDto(ids: assets.map((asset) => asset.remoteId!).toList()), ); - if (result != null && result.successfullyAdded > 0) { - album.assets.addAll(assets); + + 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); + } + } + + album.assets.addAll(successAssets); await _db.writeTxn(() => album.assets.save()); + + return AddAssetsResponse( + alreadyInAlbum: duplicatedAssets, + successfullyAdded: successAssets.length, + ); } - return result; } catch (e) { debugPrint("Error addAdditionalAssetToAlbum ${e.toString()}"); return null; } + return null; } Future addAdditionalUserToAlbum( @@ -314,8 +334,8 @@ class AlbumService { try { await _apiService.albumApi.removeAssetFromAlbum( album.remoteId!, - RemoveAssetsDto( - assetIds: assets.map((e) => e.remoteId!).toList(growable: false), + BulkIdsDto( + ids: assets.map((asset) => asset.remoteId!).toList(), ), ); album.assets.removeAll(assets); diff --git a/mobile/lib/modules/album/ui/add_to_album_bottom_sheet.dart b/mobile/lib/modules/album/ui/add_to_album_bottom_sheet.dart index 3abe0d8078..257dbdbaa2 100644 --- a/mobile/lib/modules/album/ui/add_to_album_bottom_sheet.dart +++ b/mobile/lib/modules/album/ui/add_to_album_bottom_sheet.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/album/providers/album.provider.dart'; +import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart'; import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart'; import 'package:immich_mobile/modules/album/services/album.service.dart'; import 'package:immich_mobile/modules/album/ui/add_to_album_sliverlist.dart'; @@ -63,9 +64,7 @@ class AddToAlbumBottomSheet extends HookConsumerWidget { } } - ref.read(albumProvider.notifier).getAllAlbums(); - ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums(); - + ref.invalidate(albumDetailProvider(album.id)); Navigator.pop(context); } diff --git a/mobile/lib/modules/album/ui/album_viewer_appbar.dart b/mobile/lib/modules/album/ui/album_viewer_appbar.dart index f74118b3d9..3392ed5183 100644 --- a/mobile/lib/modules/album/ui/album_viewer_appbar.dart +++ b/mobile/lib/modules/album/ui/album_viewer_appbar.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/album/providers/album.provider.dart'; +import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart'; import 'package:immich_mobile/modules/album/providers/album_viewer.provider.dart'; import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -99,7 +100,7 @@ class AlbumViewerAppbar extends HookConsumerWidget Navigator.pop(context); selectionDisabled(); ref.watch(albumProvider.notifier).getAllAlbums(); - ref.invalidate(sharedAlbumDetailProvider(album.id)); + ref.invalidate(albumDetailProvider(album.id)); } else { Navigator.pop(context); ImmichToast.show( diff --git a/mobile/lib/modules/album/views/album_viewer_page.dart b/mobile/lib/modules/album/views/album_viewer_page.dart index 9363c7f9bd..31c48c4503 100644 --- a/mobile/lib/modules/album/views/album_viewer_page.dart +++ b/mobile/lib/modules/album/views/album_viewer_page.dart @@ -6,13 +6,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart'; -import 'package:immich_mobile/modules/album/providers/album.provider.dart'; +import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart'; import 'package:immich_mobile/modules/album/services/album.service.dart'; import 'package:immich_mobile/modules/album/ui/album_action_outlined_button.dart'; import 'package:immich_mobile/modules/album/ui/album_viewer_editable_title.dart'; import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; -import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart'; import 'package:immich_mobile/modules/album/ui/album_viewer_appbar.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/shared/models/album.dart'; @@ -28,11 +27,20 @@ class AlbumViewerPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { FocusNode titleFocusNode = useFocusNode(); - final album = ref.watch(sharedAlbumDetailProvider(albumId)); + final album = ref.watch(albumDetailProvider(albumId)); final userId = ref.watch(authenticationProvider).userId; final selection = useState>({}); final multiSelectEnabled = useState(false); + useEffect( + () { + // Fetch album updates, e.g., cover image + ref.invalidate(albumDetailProvider(albumId)); + return null; + }, + [], + ); + Future onWillPop() async { if (multiSelectEnabled.value) { selection.value = {}; @@ -77,8 +85,7 @@ class AlbumViewerPage extends HookConsumerWidget { if (addAssetsResult != null && addAssetsResult.successfullyAdded > 0) { - ref.watch(albumProvider.notifier).getAllAlbums(); - ref.invalidate(sharedAlbumDetailProvider(albumId)); + ref.invalidate(albumDetailProvider(albumId)); } ImmichLoadingOverlayController.appLoader.hide(); @@ -100,7 +107,7 @@ class AlbumViewerPage extends HookConsumerWidget { .addAdditionalUserToAlbum(sharedUserIds, album); if (isSuccess) { - ref.invalidate(sharedAlbumDetailProvider(album.id)); + ref.invalidate(albumDetailProvider(album.id)); } ImmichLoadingOverlayController.appLoader.hide(); diff --git a/mobile/lib/modules/home/views/home_page.dart b/mobile/lib/modules/home/views/home_page.dart index 3dc3a41beb..715dcc47a5 100644 --- a/mobile/lib/modules/home/views/home_page.dart +++ b/mobile/lib/modules/home/views/home_page.dart @@ -8,6 +8,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/album/providers/album.provider.dart'; +import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart'; import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart'; import 'package:immich_mobile/modules/album/services/album.service.dart'; import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart'; @@ -208,6 +209,9 @@ class HomePage extends HookConsumerWidget { ), toastType: ToastType.success, ); + + ref.watch(albumProvider.notifier).getAllAlbums(); + ref.invalidate(albumDetailProvider(album.id)); } } } finally { diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index ad445090a2..1a97eef9f8 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -8,8 +8,6 @@ doc/APIKeyCreateDto.md doc/APIKeyCreateResponseDto.md doc/APIKeyResponseDto.md doc/APIKeyUpdateDto.md -doc/AddAssetsDto.md -doc/AddAssetsResponseDto.md doc/AddUsersDto.md doc/AdminSignupResponseDto.md doc/AlbumApi.md @@ -33,6 +31,7 @@ doc/AudioCodec.md doc/AuthDeviceResponseDto.md doc/AuthenticationApi.md doc/BulkIdResponseDto.md +doc/BulkIdsDto.md doc/ChangePasswordDto.md doc/CheckDuplicateAssetDto.md doc/CheckDuplicateAssetResponseDto.md @@ -78,7 +77,6 @@ doc/PersonApi.md doc/PersonResponseDto.md doc/PersonUpdateDto.md doc/QueueStatusDto.md -doc/RemoveAssetsDto.md doc/SearchAlbumResponseDto.md doc/SearchApi.md doc/SearchAssetDto.md @@ -150,8 +148,6 @@ lib/auth/authentication.dart lib/auth/http_basic_auth.dart lib/auth/http_bearer_auth.dart lib/auth/oauth.dart -lib/model/add_assets_dto.dart -lib/model/add_assets_response_dto.dart lib/model/add_users_dto.dart lib/model/admin_signup_response_dto.dart lib/model/album_count_response_dto.dart @@ -176,6 +172,7 @@ lib/model/asset_type_enum.dart lib/model/audio_codec.dart lib/model/auth_device_response_dto.dart lib/model/bulk_id_response_dto.dart +lib/model/bulk_ids_dto.dart lib/model/change_password_dto.dart lib/model/check_duplicate_asset_dto.dart lib/model/check_duplicate_asset_response_dto.dart @@ -217,7 +214,6 @@ lib/model/people_update_item.dart lib/model/person_response_dto.dart lib/model/person_update_dto.dart lib/model/queue_status_dto.dart -lib/model/remove_assets_dto.dart lib/model/search_album_response_dto.dart lib/model/search_asset_dto.dart lib/model/search_asset_response_dto.dart @@ -260,8 +256,6 @@ lib/model/user_response_dto.dart lib/model/validate_access_token_response_dto.dart lib/model/video_codec.dart pubspec.yaml -test/add_assets_dto_test.dart -test/add_assets_response_dto_test.dart test/add_users_dto_test.dart test/admin_signup_response_dto_test.dart test/album_api_test.dart @@ -290,6 +284,7 @@ test/audio_codec_test.dart test/auth_device_response_dto_test.dart test/authentication_api_test.dart test/bulk_id_response_dto_test.dart +test/bulk_ids_dto_test.dart test/change_password_dto_test.dart test/check_duplicate_asset_dto_test.dart test/check_duplicate_asset_response_dto_test.dart @@ -335,7 +330,6 @@ test/person_api_test.dart test/person_response_dto_test.dart test/person_update_dto_test.dart test/queue_status_dto_test.dart -test/remove_assets_dto_test.dart test/search_album_response_dto_test.dart test/search_api_test.dart test/search_asset_dto_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 38780bd67a..5e9cee6040 100644 Binary files a/mobile/openapi/README.md and b/mobile/openapi/README.md differ diff --git a/mobile/openapi/doc/AddAssetsResponseDto.md b/mobile/openapi/doc/AddAssetsResponseDto.md deleted file mode 100644 index 3a8d747ea9..0000000000 Binary files a/mobile/openapi/doc/AddAssetsResponseDto.md and /dev/null differ diff --git a/mobile/openapi/doc/AlbumApi.md b/mobile/openapi/doc/AlbumApi.md index 47c4180968..b12e68c8ef 100644 Binary files a/mobile/openapi/doc/AlbumApi.md and b/mobile/openapi/doc/AlbumApi.md differ diff --git a/mobile/openapi/doc/AddAssetsDto.md b/mobile/openapi/doc/BulkIdsDto.md similarity index 79% rename from mobile/openapi/doc/AddAssetsDto.md rename to mobile/openapi/doc/BulkIdsDto.md index b74211d6b7..71f1badd60 100644 Binary files a/mobile/openapi/doc/AddAssetsDto.md and b/mobile/openapi/doc/BulkIdsDto.md differ diff --git a/mobile/openapi/doc/RemoveAssetsDto.md b/mobile/openapi/doc/RemoveAssetsDto.md deleted file mode 100644 index d2ab847324..0000000000 Binary files a/mobile/openapi/doc/RemoveAssetsDto.md and /dev/null differ diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 36415d353a..5a9a1db163 100644 Binary files a/mobile/openapi/lib/api.dart and b/mobile/openapi/lib/api.dart differ diff --git a/mobile/openapi/lib/api/album_api.dart b/mobile/openapi/lib/api/album_api.dart index 1f5bd7b58e..39b44e9bec 100644 Binary files a/mobile/openapi/lib/api/album_api.dart and b/mobile/openapi/lib/api/album_api.dart differ diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 000629e1c8..daf38f5ac2 100644 Binary files a/mobile/openapi/lib/api_client.dart and b/mobile/openapi/lib/api_client.dart differ diff --git a/mobile/openapi/lib/model/add_assets_response_dto.dart b/mobile/openapi/lib/model/add_assets_response_dto.dart deleted file mode 100644 index a5b293e1fc..0000000000 Binary files a/mobile/openapi/lib/model/add_assets_response_dto.dart and /dev/null differ diff --git a/mobile/openapi/lib/model/add_assets_dto.dart b/mobile/openapi/lib/model/bulk_ids_dto.dart similarity index 55% rename from mobile/openapi/lib/model/add_assets_dto.dart rename to mobile/openapi/lib/model/bulk_ids_dto.dart index dd8ca47694..c1cbb2cb34 100644 Binary files a/mobile/openapi/lib/model/add_assets_dto.dart and b/mobile/openapi/lib/model/bulk_ids_dto.dart differ diff --git a/mobile/openapi/lib/model/remove_assets_dto.dart b/mobile/openapi/lib/model/remove_assets_dto.dart deleted file mode 100644 index c53e6d0ad2..0000000000 Binary files a/mobile/openapi/lib/model/remove_assets_dto.dart and /dev/null differ diff --git a/mobile/openapi/test/add_assets_response_dto_test.dart b/mobile/openapi/test/add_assets_response_dto_test.dart deleted file mode 100644 index 5dacd3943b..0000000000 Binary files a/mobile/openapi/test/add_assets_response_dto_test.dart and /dev/null differ diff --git a/mobile/openapi/test/album_api_test.dart b/mobile/openapi/test/album_api_test.dart index 5c2331fa90..085b7f0ff4 100644 Binary files a/mobile/openapi/test/album_api_test.dart and b/mobile/openapi/test/album_api_test.dart differ diff --git a/mobile/openapi/test/add_assets_dto_test.dart b/mobile/openapi/test/bulk_ids_dto_test.dart similarity index 64% rename from mobile/openapi/test/add_assets_dto_test.dart rename to mobile/openapi/test/bulk_ids_dto_test.dart index ec660c4000..22b11ec8f0 100644 Binary files a/mobile/openapi/test/add_assets_dto_test.dart and b/mobile/openapi/test/bulk_ids_dto_test.dart differ diff --git a/mobile/openapi/test/remove_assets_dto_test.dart b/mobile/openapi/test/remove_assets_dto_test.dart deleted file mode 100644 index 4e5b48bcbc..0000000000 Binary files a/mobile/openapi/test/remove_assets_dto_test.dart and /dev/null differ diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 28db8ca291..3fae5cbf42 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -278,7 +278,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RemoveAssetsDto" + "$ref": "#/components/schemas/BulkIdsDto" } } }, @@ -289,7 +289,10 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AlbumResponseDto" + "items": { + "$ref": "#/components/schemas/BulkIdResponseDto" + }, + "type": "array" } } }, @@ -336,7 +339,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AddAssetsDto" + "$ref": "#/components/schemas/BulkIdsDto" } } }, @@ -347,7 +350,10 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AddAssetsResponseDto" + "items": { + "$ref": "#/components/schemas/BulkIdResponseDto" + }, + "type": "array" } } }, @@ -4535,42 +4541,6 @@ ], "type": "object" }, - "AddAssetsDto": { - "properties": { - "assetIds": { - "items": { - "format": "uuid", - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "assetIds" - ], - "type": "object" - }, - "AddAssetsResponseDto": { - "properties": { - "album": { - "$ref": "#/components/schemas/AlbumResponseDto" - }, - "alreadyInAlbum": { - "items": { - "type": "string" - }, - "type": "array" - }, - "successfullyAdded": { - "type": "integer" - } - }, - "required": [ - "successfullyAdded", - "alreadyInAlbum" - ], - "type": "object" - }, "AddUsersDto": { "properties": { "sharedUserIds": { @@ -5093,6 +5063,21 @@ ], "type": "object" }, + "BulkIdsDto": { + "properties": { + "ids": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "ids" + ], + "type": "object" + }, "ChangePasswordDto": { "properties": { "newPassword": { @@ -6055,21 +6040,6 @@ ], "type": "object" }, - "RemoveAssetsDto": { - "properties": { - "assetIds": { - "items": { - "format": "uuid", - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "assetIds" - ], - "type": "object" - }, "SearchAlbumResponseDto": { "properties": { "count": { diff --git a/server/src/domain/access/access.core.ts b/server/src/domain/access/access.core.ts index 7ecaf97e13..d815612365 100644 --- a/server/src/domain/access/access.core.ts +++ b/server/src/domain/access/access.core.ts @@ -12,9 +12,10 @@ export enum Permission { ASSET_DOWNLOAD = 'asset.download', // ALBUM_CREATE = 'album.create', - // ALBUM_READ = 'album.read', + ALBUM_READ = 'album.read', ALBUM_UPDATE = 'album.update', ALBUM_DELETE = 'album.delete', + ALBUM_REMOVE_ASSET = 'album.removeAsset', ALBUM_SHARE = 'album.share', ALBUM_DOWNLOAD = 'album.download', @@ -39,6 +40,16 @@ export class AccessCore { } } + async hasAny(authUser: AuthUserDto, permissions: Array<{ permission: Permission; id: string }>) { + for (const { permission, id } of permissions) { + const hasAccess = await this.hasPermission(authUser, permission, id); + if (hasAccess) { + return true; + } + } + return false; + } + async hasPermission(authUser: AuthUserDto, permission: Permission, ids: string[] | string) { ids = Array.isArray(ids) ? ids : [ids]; @@ -76,12 +87,11 @@ export class AccessCore { // TODO: fix this to not use authUser.id for shared link access control return this.repository.asset.hasOwnerAccess(authUser.id, id); - case Permission.ALBUM_DOWNLOAD: { - return !!authUser.isAllowDownload && (await this.repository.album.hasSharedLinkAccess(sharedLinkId, id)); - } + case Permission.ALBUM_READ: + return this.repository.album.hasSharedLinkAccess(sharedLinkId, id); - // case Permission.ALBUM_READ: - // return this.repository.album.hasSharedLinkAccess(sharedLinkId, id); + case Permission.ALBUM_DOWNLOAD: + return !!authUser.isAllowDownload && (await this.repository.album.hasSharedLinkAccess(sharedLinkId, id)); default: return false; @@ -122,8 +132,11 @@ export class AccessCore { (await this.repository.asset.hasPartnerAccess(authUser.id, id)) ); - // case Permission.ALBUM_READ: - // return this.repository.album.hasOwnerAccess(authUser.id, id); + case Permission.ALBUM_READ: + return ( + (await this.repository.album.hasOwnerAccess(authUser.id, id)) || + (await this.repository.album.hasSharedAlbumAccess(authUser.id, id)) + ); case Permission.ALBUM_UPDATE: return this.repository.album.hasOwnerAccess(authUser.id, id); @@ -140,13 +153,17 @@ export class AccessCore { (await this.repository.album.hasSharedAlbumAccess(authUser.id, id)) ); + case Permission.ALBUM_REMOVE_ASSET: + return this.repository.album.hasOwnerAccess(authUser.id, id); + case Permission.LIBRARY_READ: return authUser.id === id || (await this.repository.library.hasPartnerAccess(authUser.id, id)); case Permission.LIBRARY_DOWNLOAD: return authUser.id === id; - } - return false; + default: + return false; + } } } diff --git a/server/src/domain/album/album.repository.ts b/server/src/domain/album/album.repository.ts index e9f2b4c368..811b85ec9a 100644 --- a/server/src/domain/album/album.repository.ts +++ b/server/src/domain/album/album.repository.ts @@ -8,6 +8,7 @@ export interface AlbumAssetCount { } export interface IAlbumRepository { + getById(id: string): Promise; getByIds(ids: string[]): Promise; getByAssetId(ownerId: string, assetId: string): Promise; hasAsset(id: string, assetId: string): Promise; @@ -21,4 +22,5 @@ export interface IAlbumRepository { create(album: Partial): Promise; update(album: Partial): Promise; delete(album: AlbumEntity): Promise; + updateThumbnails(): Promise; } diff --git a/server/src/domain/album/album.service.spec.ts b/server/src/domain/album/album.service.spec.ts index d4eeca7fc4..b6c6204215 100644 --- a/server/src/domain/album/album.service.spec.ts +++ b/server/src/domain/album/album.service.spec.ts @@ -1,6 +1,7 @@ import { BadRequestException } from '@nestjs/common'; import { albumStub, + assetStub, authStub, IAccessRepositoryMock, newAccessRepositoryMock, @@ -11,7 +12,7 @@ import { userStub, } from '@test'; import _ from 'lodash'; -import { IAssetRepository } from '../asset'; +import { BulkIdErrorReason, IAssetRepository } from '../asset'; import { IJobRepository, JobName } from '../job'; import { IUserRepository } from '../user'; import { IAlbumRepository } from './album.repository'; @@ -202,7 +203,7 @@ describe(AlbumService.name, () => { describe('update', () => { it('should prevent updating an album that does not exist', async () => { - albumMock.getByIds.mockResolvedValue([]); + albumMock.getById.mockResolvedValue(null); await expect( sut.update(authStub.user1, 'invalid-id', { @@ -224,7 +225,7 @@ describe(AlbumService.name, () => { it('should require a valid thumbnail asset id', async () => { accessMock.album.hasOwnerAccess.mockResolvedValue(true); - albumMock.getByIds.mockResolvedValue([albumStub.oneAsset]); + albumMock.getById.mockResolvedValue(albumStub.oneAsset); albumMock.update.mockResolvedValue(albumStub.oneAsset); albumMock.hasAsset.mockResolvedValue(false); @@ -241,7 +242,7 @@ describe(AlbumService.name, () => { it('should allow the owner to update the album', async () => { accessMock.album.hasOwnerAccess.mockResolvedValue(true); - albumMock.getByIds.mockResolvedValue([albumStub.oneAsset]); + albumMock.getById.mockResolvedValue(albumStub.oneAsset); albumMock.update.mockResolvedValue(albumStub.oneAsset); await sut.update(authStub.admin, albumStub.oneAsset.id, { @@ -263,7 +264,7 @@ describe(AlbumService.name, () => { describe('delete', () => { it('should throw an error for an album not found', async () => { accessMock.album.hasOwnerAccess.mockResolvedValue(true); - albumMock.getByIds.mockResolvedValue([]); + albumMock.getById.mockResolvedValue(null); await expect(sut.delete(authStub.admin, albumStub.sharedWithAdmin.id)).rejects.toBeInstanceOf( BadRequestException, @@ -274,7 +275,7 @@ describe(AlbumService.name, () => { it('should not let a shared user delete the album', async () => { accessMock.album.hasOwnerAccess.mockResolvedValue(false); - albumMock.getByIds.mockResolvedValue([albumStub.sharedWithAdmin]); + albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin); await expect(sut.delete(authStub.admin, albumStub.sharedWithAdmin.id)).rejects.toBeInstanceOf( BadRequestException, @@ -285,7 +286,7 @@ describe(AlbumService.name, () => { it('should let the owner delete an album', async () => { accessMock.album.hasOwnerAccess.mockResolvedValue(true); - albumMock.getByIds.mockResolvedValue([albumStub.empty]); + albumMock.getById.mockResolvedValue(albumStub.empty); await sut.delete(authStub.admin, albumStub.empty.id); @@ -305,7 +306,7 @@ describe(AlbumService.name, () => { it('should throw an error if the userId is already added', async () => { accessMock.album.hasOwnerAccess.mockResolvedValue(true); - albumMock.getByIds.mockResolvedValue([albumStub.sharedWithAdmin]); + albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin); await expect( sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { sharedUserIds: [authStub.admin.id] }), ).rejects.toBeInstanceOf(BadRequestException); @@ -314,7 +315,7 @@ describe(AlbumService.name, () => { it('should throw an error if the userId does not exist', async () => { accessMock.album.hasOwnerAccess.mockResolvedValue(true); - albumMock.getByIds.mockResolvedValue([albumStub.sharedWithAdmin]); + albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin); userMock.get.mockResolvedValue(null); await expect( sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { sharedUserIds: ['user-3'] }), @@ -324,7 +325,7 @@ describe(AlbumService.name, () => { it('should add valid shared users', async () => { accessMock.album.hasOwnerAccess.mockResolvedValue(true); - albumMock.getByIds.mockResolvedValue([_.cloneDeep(albumStub.sharedWithAdmin)]); + albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithAdmin)); albumMock.update.mockResolvedValue(albumStub.sharedWithAdmin); userMock.get.mockResolvedValue(userStub.user2); await sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { sharedUserIds: [authStub.user2.id] }); @@ -339,14 +340,14 @@ describe(AlbumService.name, () => { describe('removeUser', () => { it('should require a valid album id', async () => { accessMock.album.hasOwnerAccess.mockResolvedValue(true); - albumMock.getByIds.mockResolvedValue([]); + albumMock.getById.mockResolvedValue(null); await expect(sut.removeUser(authStub.admin, 'album-1', 'user-1')).rejects.toBeInstanceOf(BadRequestException); expect(albumMock.update).not.toHaveBeenCalled(); }); it('should remove a shared user from an owned album', async () => { accessMock.album.hasOwnerAccess.mockResolvedValue(true); - albumMock.getByIds.mockResolvedValue([albumStub.sharedWithUser]); + albumMock.getById.mockResolvedValue(albumStub.sharedWithUser); await expect( sut.removeUser(authStub.admin, albumStub.sharedWithUser.id, userStub.user1.id), @@ -362,7 +363,7 @@ describe(AlbumService.name, () => { it('should prevent removing a shared user from a not-owned album (shared with auth user)', async () => { accessMock.album.hasOwnerAccess.mockResolvedValue(false); - albumMock.getByIds.mockResolvedValue([albumStub.sharedWithMultiple]); + albumMock.getById.mockResolvedValue(albumStub.sharedWithMultiple); await expect( sut.removeUser(authStub.user1, albumStub.sharedWithMultiple.id, authStub.user2.id), @@ -373,7 +374,7 @@ describe(AlbumService.name, () => { }); it('should allow a shared user to remove themselves', async () => { - albumMock.getByIds.mockResolvedValue([albumStub.sharedWithUser]); + albumMock.getById.mockResolvedValue(albumStub.sharedWithUser); await sut.removeUser(authStub.user1, albumStub.sharedWithUser.id, authStub.user1.id); @@ -386,7 +387,7 @@ describe(AlbumService.name, () => { }); it('should allow a shared user to remove themselves using "me"', async () => { - albumMock.getByIds.mockResolvedValue([albumStub.sharedWithUser]); + albumMock.getById.mockResolvedValue(albumStub.sharedWithUser); await sut.removeUser(authStub.user1, albumStub.sharedWithUser.id, 'me'); @@ -399,7 +400,7 @@ describe(AlbumService.name, () => { }); it('should not allow the owner to be removed', async () => { - albumMock.getByIds.mockResolvedValue([albumStub.empty]); + albumMock.getById.mockResolvedValue(albumStub.empty); await expect(sut.removeUser(authStub.admin, albumStub.empty.id, authStub.admin.id)).rejects.toBeInstanceOf( BadRequestException, @@ -409,7 +410,7 @@ describe(AlbumService.name, () => { }); it('should throw an error for a user not in the album', async () => { - albumMock.getByIds.mockResolvedValue([albumStub.empty]); + albumMock.getById.mockResolvedValue(albumStub.empty); await expect(sut.removeUser(authStub.admin, albumStub.empty.id, 'user-3')).rejects.toBeInstanceOf( BadRequestException, @@ -418,4 +419,301 @@ describe(AlbumService.name, () => { expect(albumMock.update).not.toHaveBeenCalled(); }); }); + + describe('getAlbumInfo', () => { + it('should get a shared album', async () => { + albumMock.getById.mockResolvedValue(albumStub.oneAsset); + accessMock.album.hasOwnerAccess.mockResolvedValue(true); + + await sut.get(authStub.admin, albumStub.oneAsset.id); + + expect(albumMock.getById).toHaveBeenCalledWith(albumStub.oneAsset.id); + expect(accessMock.album.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, albumStub.oneAsset.id); + }); + + it('should get a shared album via a shared link', async () => { + albumMock.getById.mockResolvedValue(albumStub.oneAsset); + accessMock.album.hasSharedLinkAccess.mockResolvedValue(true); + + await sut.get(authStub.adminSharedLink, 'album-123'); + + expect(albumMock.getById).toHaveBeenCalledWith('album-123'); + expect(accessMock.album.hasSharedLinkAccess).toHaveBeenCalledWith( + authStub.adminSharedLink.sharedLinkId, + 'album-123', + ); + }); + + it('should get a shared album via shared with user', async () => { + albumMock.getById.mockResolvedValue(albumStub.oneAsset); + accessMock.album.hasSharedAlbumAccess.mockResolvedValue(true); + + await sut.get(authStub.user1, 'album-123'); + + expect(albumMock.getById).toHaveBeenCalledWith('album-123'); + expect(accessMock.album.hasSharedAlbumAccess).toHaveBeenCalledWith(authStub.user1.id, 'album-123'); + }); + + it('should throw an error for no access', async () => { + accessMock.album.hasOwnerAccess.mockResolvedValue(false); + accessMock.album.hasSharedAlbumAccess.mockResolvedValue(false); + + await expect(sut.get(authStub.admin, 'album-123')).rejects.toBeInstanceOf(BadRequestException); + + expect(accessMock.album.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'album-123'); + expect(accessMock.album.hasSharedAlbumAccess).toHaveBeenCalledWith(authStub.admin.id, 'album-123'); + }); + }); + + describe('addAssets', () => { + it('should allow the owner to add assets', async () => { + accessMock.album.hasOwnerAccess.mockResolvedValue(true); + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); + + await expect( + sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), + ).resolves.toEqual([ + { success: true, id: 'asset-1' }, + { success: true, id: 'asset-2' }, + { success: true, id: 'asset-3' }, + ]); + + expect(albumMock.update).toHaveBeenCalledWith({ + id: 'album-123', + updatedAt: expect.any(Date), + assets: [assetStub.image, { id: 'asset-1' }, { id: 'asset-2' }, { id: 'asset-3' }], + albumThumbnailAssetId: 'asset-1', + }); + }); + + it('should not set the thumbnail if the album has one already', async () => { + accessMock.album.hasOwnerAccess.mockResolvedValue(true); + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + albumMock.getById.mockResolvedValue(_.cloneDeep({ ...albumStub.empty, albumThumbnailAssetId: 'asset-id' })); + + await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1'] })).resolves.toEqual([ + { success: true, id: 'asset-1' }, + ]); + + expect(albumMock.update).toHaveBeenCalledWith({ + id: 'album-123', + updatedAt: expect.any(Date), + assets: [{ id: 'asset-1' }], + albumThumbnailAssetId: 'asset-id', + }); + }); + + it('should allow a shared user to add assets', async () => { + accessMock.album.hasOwnerAccess.mockResolvedValue(false); + accessMock.album.hasSharedAlbumAccess.mockResolvedValue(true); + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithUser)); + + await expect( + sut.addAssets(authStub.user1, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), + ).resolves.toEqual([ + { success: true, id: 'asset-1' }, + { success: true, id: 'asset-2' }, + { success: true, id: 'asset-3' }, + ]); + + expect(albumMock.update).toHaveBeenCalledWith({ + id: 'album-123', + updatedAt: expect.any(Date), + assets: [{ id: 'asset-1' }, { id: 'asset-2' }, { id: 'asset-3' }], + albumThumbnailAssetId: 'asset-1', + }); + }); + + it('should allow a shared link user to add assets', async () => { + accessMock.album.hasOwnerAccess.mockResolvedValue(false); + accessMock.album.hasSharedAlbumAccess.mockResolvedValue(false); + accessMock.album.hasSharedLinkAccess.mockResolvedValue(true); + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); + + await expect( + sut.addAssets(authStub.adminSharedLink, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), + ).resolves.toEqual([ + { success: true, id: 'asset-1' }, + { success: true, id: 'asset-2' }, + { success: true, id: 'asset-3' }, + ]); + + expect(albumMock.update).toHaveBeenCalledWith({ + id: 'album-123', + updatedAt: expect.any(Date), + assets: [assetStub.image, { id: 'asset-1' }, { id: 'asset-2' }, { id: 'asset-3' }], + albumThumbnailAssetId: 'asset-1', + }); + + expect(accessMock.album.hasSharedLinkAccess).toHaveBeenCalledWith( + authStub.adminSharedLink.sharedLinkId, + 'album-123', + ); + }); + + it('should allow adding assets shared via partner sharing', async () => { + accessMock.album.hasOwnerAccess.mockResolvedValue(true); + accessMock.asset.hasOwnerAccess.mockResolvedValue(false); + accessMock.asset.hasPartnerAccess.mockResolvedValue(true); + albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); + + await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1'] })).resolves.toEqual([ + { success: true, id: 'asset-1' }, + ]); + + expect(albumMock.update).toHaveBeenCalledWith({ + id: 'album-123', + updatedAt: expect.any(Date), + assets: [assetStub.image, { id: 'asset-1' }], + albumThumbnailAssetId: 'asset-1', + }); + + expect(accessMock.asset.hasPartnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'asset-1'); + }); + + it('should skip duplicate assets', async () => { + accessMock.album.hasOwnerAccess.mockResolvedValue(true); + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); + + await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ + { success: false, id: 'asset-id', error: BulkIdErrorReason.DUPLICATE }, + ]); + + expect(albumMock.update).not.toHaveBeenCalled(); + }); + + it('should skip assets not shared with user', async () => { + accessMock.album.hasOwnerAccess.mockResolvedValue(true); + accessMock.asset.hasOwnerAccess.mockResolvedValue(false); + accessMock.asset.hasPartnerAccess.mockResolvedValue(false); + albumMock.getById.mockResolvedValue(albumStub.oneAsset); + + await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1'] })).resolves.toEqual([ + { success: false, id: 'asset-1', error: BulkIdErrorReason.NO_PERMISSION }, + ]); + + expect(accessMock.asset.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'asset-1'); + expect(accessMock.asset.hasPartnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'asset-1'); + }); + + it('should not allow unauthorized access to the album', async () => { + accessMock.album.hasOwnerAccess.mockResolvedValue(false); + accessMock.album.hasSharedAlbumAccess.mockResolvedValue(false); + albumMock.getById.mockResolvedValue(albumStub.oneAsset); + + await expect( + sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(accessMock.album.hasOwnerAccess).toHaveBeenCalled(); + expect(accessMock.album.hasSharedAlbumAccess).toHaveBeenCalled(); + }); + + it('should not allow unauthorized shared link access to the album', async () => { + accessMock.album.hasSharedLinkAccess.mockResolvedValue(false); + albumMock.getById.mockResolvedValue(albumStub.oneAsset); + + await expect( + sut.addAssets(authStub.adminSharedLink, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(accessMock.album.hasSharedLinkAccess).toHaveBeenCalled(); + }); + }); + + describe('removeAssets', () => { + it('should allow the owner to remove assets', async () => { + accessMock.album.hasOwnerAccess.mockResolvedValue(true); + albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); + + await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ + { success: true, id: 'asset-id' }, + ]); + + expect(albumMock.update).toHaveBeenCalledWith({ + id: 'album-123', + updatedAt: expect.any(Date), + assets: [], + albumThumbnailAssetId: null, + }); + }); + + it('should skip assets not in the album', async () => { + accessMock.album.hasOwnerAccess.mockResolvedValue(true); + albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.empty)); + + await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ + { success: false, id: 'asset-id', error: BulkIdErrorReason.NOT_FOUND }, + ]); + + expect(albumMock.update).not.toHaveBeenCalled(); + }); + + it('should skip assets without user permission to remove', async () => { + accessMock.album.hasSharedAlbumAccess.mockResolvedValue(true); + albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); + + await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ + { success: false, id: 'asset-id', error: BulkIdErrorReason.NO_PERMISSION }, + ]); + + expect(albumMock.update).not.toHaveBeenCalled(); + }); + + it('should reset the thumbnail if it is removed', async () => { + accessMock.album.hasOwnerAccess.mockResolvedValue(true); + albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.twoAssets)); + + await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ + { success: true, id: 'asset-id' }, + ]); + + expect(albumMock.update).toHaveBeenCalledWith({ + id: 'album-123', + updatedAt: expect.any(Date), + assets: [assetStub.withLocation], + albumThumbnailAssetId: assetStub.withLocation.id, + }); + }); + }); + + // // it('removes assets from shared album (shared with auth user)', async () => { + // // const albumEntity = _getOwnedSharedAlbum(); + // // albumRepositoryMock.get.mockImplementation(() => Promise.resolve(albumEntity)); + // // albumRepositoryMock.removeAssets.mockImplementation(() => Promise.resolve(albumEntity)); + + // // await expect( + // // sut.removeAssetsFromAlbum( + // // authUser, + // // { + // // ids: ['1'], + // // }, + // // albumEntity.id, + // // ), + // // ).resolves.toBeUndefined(); + // // expect(albumRepositoryMock.removeAssets).toHaveBeenCalledTimes(1); + // // expect(albumRepositoryMock.removeAssets).toHaveBeenCalledWith(albumEntity, { + // // ids: ['1'], + // // }); + // // }); + + // it('prevents removing assets from a not owned / shared album', async () => { + // const albumEntity = _getNotOwnedNotSharedAlbum(); + + // const albumResponse: AddAssetsResponseDto = { + // alreadyInAlbum: [], + // successfullyAdded: 1, + // }; + + // const albumId = albumEntity.id; + + // albumRepositoryMock.get.mockImplementation(() => Promise.resolve(albumEntity)); + // albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve(albumResponse)); + + // await expect(sut.removeAssets(authUser, albumId, { ids: ['1'] })).rejects.toBeInstanceOf(ForbiddenException); + // }); }); diff --git a/server/src/domain/album/album.service.ts b/server/src/domain/album/album.service.ts index 945516e2a7..246a53047a 100644 --- a/server/src/domain/album/album.service.ts +++ b/server/src/domain/album/album.service.ts @@ -1,8 +1,8 @@ import { AlbumEntity, AssetEntity, UserEntity } from '@app/infra/entities'; import { BadRequestException, Inject, Injectable } from '@nestjs/common'; -import { IAssetRepository, mapAsset } from '../asset'; +import { AccessCore, IAccessRepository, Permission } from '../access'; +import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto, IAssetRepository, mapAsset } from '../asset'; import { AuthUserDto } from '../auth'; -import { AccessCore, IAccessRepository, Permission } from '../index'; import { IJobRepository, JobName } from '../job'; import { IUserRepository } from '../user'; import { AlbumCountResponseDto, AlbumResponseDto, mapAlbum } from './album-response.dto'; @@ -37,7 +37,11 @@ export class AlbumService { } async getAll({ id: ownerId }: AuthUserDto, { assetId, shared }: GetAlbumsDto): Promise { - await this.updateInvalidThumbnails(); + const invalidAlbumIds = await this.albumRepository.getInvalidThumbnail(); + for (const albumId of invalidAlbumIds) { + const newThumbnail = await this.assetRepository.getFirstAssetForAlbumId(albumId); + await this.albumRepository.update({ id: albumId, albumThumbnailAsset: newThumbnail }); + } let albums: AlbumEntity[]; if (assetId) { @@ -73,15 +77,10 @@ export class AlbumService { ); } - private async updateInvalidThumbnails(): Promise { - const invalidAlbumIds = await this.albumRepository.getInvalidThumbnail(); - - for (const albumId of invalidAlbumIds) { - const newThumbnail = await this.assetRepository.getFirstAssetForAlbumId(albumId); - await this.albumRepository.update({ id: albumId, albumThumbnailAsset: newThumbnail }); - } - - return invalidAlbumIds.length; + async get(authUser: AuthUserDto, id: string) { + await this.access.requirePermission(authUser, Permission.ALBUM_READ, id); + await this.albumRepository.updateThumbnails(); + return mapAlbum(await this.findOrFail(id)); } async create(authUser: AuthUserDto, dto: CreateAlbumDto): Promise { @@ -107,7 +106,7 @@ export class AlbumService { async update(authUser: AuthUserDto, id: string, dto: UpdateAlbumDto): Promise { await this.access.requirePermission(authUser, Permission.ALBUM_UPDATE, id); - const album = await this.get(id); + const album = await this.findOrFail(id); if (dto.albumThumbnailAssetId) { const valid = await this.albumRepository.hasAsset(id, dto.albumThumbnailAssetId); @@ -130,7 +129,7 @@ export class AlbumService { async delete(authUser: AuthUserDto, id: string): Promise { await this.access.requirePermission(authUser, Permission.ALBUM_DELETE, id); - const [album] = await this.albumRepository.getByIds([id]); + const album = await this.albumRepository.getById(id); if (!album) { throw new BadRequestException('Album not found'); } @@ -139,10 +138,88 @@ export class AlbumService { await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ALBUM, data: { ids: [id] } }); } + async addAssets(authUser: AuthUserDto, id: string, dto: BulkIdsDto): Promise { + const album = await this.findOrFail(id); + + await this.access.requirePermission(authUser, Permission.ALBUM_READ, id); + + const results: BulkIdResponseDto[] = []; + for (const id of dto.ids) { + const hasAsset = album.assets.find((asset) => asset.id === id); + if (hasAsset) { + results.push({ id, success: false, error: BulkIdErrorReason.DUPLICATE }); + continue; + } + + const hasAccess = await this.access.hasPermission(authUser, Permission.ASSET_SHARE, id); + if (!hasAccess) { + results.push({ id, success: false, error: BulkIdErrorReason.NO_PERMISSION }); + continue; + } + + results.push({ id, success: true }); + album.assets.push({ id } as AssetEntity); + } + + const newAsset = results.find(({ success }) => success); + if (newAsset) { + await this.albumRepository.update({ + id, + assets: album.assets, + updatedAt: new Date(), + albumThumbnailAssetId: album.albumThumbnailAssetId ?? newAsset.id, + }); + } + + return results; + } + + async removeAssets(authUser: AuthUserDto, id: string, dto: BulkIdsDto): Promise { + const album = await this.findOrFail(id); + + await this.access.requirePermission(authUser, Permission.ALBUM_READ, id); + + const results: BulkIdResponseDto[] = []; + for (const id of dto.ids) { + const hasAsset = album.assets.find((asset) => asset.id === id); + if (!hasAsset) { + results.push({ id, success: false, error: BulkIdErrorReason.NOT_FOUND }); + continue; + } + + const hasAccess = await this.access.hasAny(authUser, [ + { permission: Permission.ALBUM_REMOVE_ASSET, id }, + { permission: Permission.ASSET_SHARE, id }, + ]); + if (!hasAccess) { + results.push({ id, success: false, error: BulkIdErrorReason.NO_PERMISSION }); + continue; + } + + results.push({ id, success: true }); + album.assets = album.assets.filter((asset) => asset.id !== id); + if (album.albumThumbnailAssetId === id) { + album.albumThumbnailAssetId = null; + } + } + + const hasSuccess = results.find(({ success }) => success); + if (hasSuccess) { + await this.albumRepository.update({ + id, + assets: album.assets, + updatedAt: new Date(), + albumThumbnailAssetId: album.albumThumbnailAssetId || album.assets[0]?.id || null, + }); + } + + return results; + } + async addUsers(authUser: AuthUserDto, id: string, dto: AddUsersDto) { await this.access.requirePermission(authUser, Permission.ALBUM_SHARE, id); - const album = await this.get(id); + const album = await this.findOrFail(id); for (const userId of dto.sharedUserIds) { const exists = album.sharedUsers.find((user) => user.id === userId); @@ -172,7 +249,7 @@ export class AlbumService { userId = authUser.id; } - const album = await this.get(id); + const album = await this.findOrFail(id); if (album.ownerId === userId) { throw new BadRequestException('Cannot remove album owner'); @@ -195,8 +272,8 @@ export class AlbumService { }); } - private async get(id: string) { - const [album] = await this.albumRepository.getByIds([id]); + private async findOrFail(id: string) { + const album = await this.albumRepository.getById(id); if (!album) { throw new BadRequestException('Album not found'); } diff --git a/server/src/domain/asset/response-dto/asset-ids-response.dto.ts b/server/src/domain/asset/response-dto/asset-ids-response.dto.ts index 81672564af..9bb6a5b368 100644 --- a/server/src/domain/asset/response-dto/asset-ids-response.dto.ts +++ b/server/src/domain/asset/response-dto/asset-ids-response.dto.ts @@ -1,3 +1,5 @@ +import { ValidateUUID } from '../../domain.util'; + /** @deprecated Use `BulkIdResponseDto` instead */ export enum AssetIdErrorReason { DUPLICATE = 'duplicate', @@ -19,6 +21,11 @@ export enum BulkIdErrorReason { UNKNOWN = 'unknown', } +export class BulkIdsDto { + @ValidateUUID({ each: true }) + ids!: string[]; +} + export class BulkIdResponseDto { id!: string; success!: boolean; diff --git a/server/src/immich/api-v1/album/album-repository.ts b/server/src/immich/api-v1/album/album-repository.ts deleted file mode 100644 index 200b7d0970..0000000000 --- a/server/src/immich/api-v1/album/album-repository.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { dataSource } from '@app/infra/database.config'; -import { AlbumEntity, AssetEntity } from '@app/infra/entities'; -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { AddAssetsDto } from './dto/add-assets.dto'; -import { RemoveAssetsDto } from './dto/remove-assets.dto'; -import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto'; - -export interface IAlbumRepository { - get(albumId: string): Promise; - removeAssets(album: AlbumEntity, removeAssets: RemoveAssetsDto): Promise; - addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise; - updateThumbnails(): Promise; -} - -export const IAlbumRepository = 'IAlbumRepository'; - -@Injectable() -export class AlbumRepository implements IAlbumRepository { - constructor( - @InjectRepository(AlbumEntity) private albumRepository: Repository, - @InjectRepository(AssetEntity) private assetRepository: Repository, - ) {} - - async get(albumId: string): Promise { - return this.albumRepository.findOne({ - where: { id: albumId }, - relations: { - owner: true, - sharedUsers: true, - assets: { - exifInfo: true, - }, - sharedLinks: true, - }, - order: { - assets: { - fileCreatedAt: 'DESC', - }, - }, - }); - } - - async removeAssets(album: AlbumEntity, removeAssetsDto: RemoveAssetsDto): Promise { - const assetCount = album.assets.length; - - album.assets = album.assets.filter((asset) => { - return !removeAssetsDto.assetIds.includes(asset.id); - }); - - const numRemovedAssets = assetCount - album.assets.length; - if (numRemovedAssets > 0) { - album.updatedAt = new Date(); - } - await this.albumRepository.save(album, {}); - - return numRemovedAssets; - } - - async addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise { - const alreadyExisting: string[] = []; - - for (const assetId of addAssetsDto.assetIds) { - // Album already contains that asset - if (album.assets?.some((a) => a.id === assetId)) { - alreadyExisting.push(assetId); - continue; - } - - album.assets.push({ id: assetId } as AssetEntity); - } - - // Add album thumbnail if not exist. - if (!album.albumThumbnailAssetId && album.assets.length > 0) { - album.albumThumbnailAssetId = album.assets[0].id; - } - - const successfullyAdded = addAssetsDto.assetIds.length - alreadyExisting.length; - if (successfullyAdded > 0) { - album.updatedAt = new Date(); - } - await this.albumRepository.save(album); - - return { - successfullyAdded, - alreadyInAlbum: alreadyExisting, - }; - } - - /** - * Makes sure all thumbnails for albums are updated by: - * - Removing thumbnails from albums without assets - * - Removing references of thumbnails to assets outside the album - * - Setting a thumbnail when none is set and the album contains assets - * - * @returns Amount of updated album thumbnails or undefined when unknown - */ - async updateThumbnails(): Promise { - // Subquery for getting a new thumbnail. - const newThumbnail = this.assetRepository - .createQueryBuilder('assets') - .select('albums_assets2.assetsId') - .addFrom('albums_assets_assets', 'albums_assets2') - .where('albums_assets2.assetsId = assets.id') - .andWhere('albums_assets2.albumsId = "albums"."id"') // Reference to albums.id outside this query - .orderBy('assets.fileCreatedAt', 'DESC') - .limit(1); - - // Using dataSource, because there is no direct access to albums_assets_assets. - const albumHasAssets = dataSource - .createQueryBuilder() - .select('1') - .from('albums_assets_assets', 'albums_assets') - .where('"albums"."id" = "albums_assets"."albumsId"'); - - const albumContainsThumbnail = albumHasAssets - .clone() - .andWhere('"albums"."albumThumbnailAssetId" = "albums_assets"."assetsId"'); - - const updateAlbums = this.albumRepository - .createQueryBuilder('albums') - .update(AlbumEntity) - .set({ albumThumbnailAssetId: () => `(${newThumbnail.getQuery()})` }) - .where(`"albums"."albumThumbnailAssetId" IS NULL AND EXISTS (${albumHasAssets.getQuery()})`) - .orWhere(`"albums"."albumThumbnailAssetId" IS NOT NULL AND NOT EXISTS (${albumContainsThumbnail.getQuery()})`); - - const result = await updateAlbums.execute(); - - return result.affected; - } -} diff --git a/server/src/immich/api-v1/album/album.controller.ts b/server/src/immich/api-v1/album/album.controller.ts deleted file mode 100644 index ba6f195a24..0000000000 --- a/server/src/immich/api-v1/album/album.controller.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { AlbumResponseDto, AuthUserDto } from '@app/domain'; -import { Body, Controller, Delete, Get, Param, Put } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; -import { Authenticated, AuthUser, SharedLinkRoute } from '../../app.guard'; -import { UseValidation } from '../../app.utils'; -import { UUIDParamDto } from '../../controllers/dto/uuid-param.dto'; -import { AlbumService } from './album.service'; -import { AddAssetsDto } from './dto/add-assets.dto'; -import { RemoveAssetsDto } from './dto/remove-assets.dto'; -import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto'; - -@ApiTags('Album') -@Controller('album') -@Authenticated() -@UseValidation() -export class AlbumController { - constructor(private service: AlbumService) {} - - @SharedLinkRoute() - @Put(':id/assets') - addAssetsToAlbum( - @AuthUser() authUser: AuthUserDto, - @Param() { id }: UUIDParamDto, - @Body() dto: AddAssetsDto, - ): Promise { - // TODO: Handle nonexistent assetIds. - // TODO: Disallow adding assets of another user to an album. - return this.service.addAssets(authUser, id, dto); - } - - @SharedLinkRoute() - @Get(':id') - getAlbumInfo(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto) { - return this.service.get(authUser, id); - } - - @Delete(':id/assets') - removeAssetFromAlbum( - @AuthUser() authUser: AuthUserDto, - @Body() dto: RemoveAssetsDto, - @Param() { id }: UUIDParamDto, - ): Promise { - return this.service.removeAssets(authUser, id, dto); - } -} diff --git a/server/src/immich/api-v1/album/album.module.ts b/server/src/immich/api-v1/album/album.module.ts deleted file mode 100644 index e241f96359..0000000000 --- a/server/src/immich/api-v1/album/album.module.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { AlbumEntity, AssetEntity } from '@app/infra/entities'; -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { AlbumRepository, IAlbumRepository } from './album-repository'; -import { AlbumController } from './album.controller'; -import { AlbumService } from './album.service'; - -@Module({ - imports: [TypeOrmModule.forFeature([AlbumEntity, AssetEntity])], - controllers: [AlbumController], - providers: [AlbumService, { provide: IAlbumRepository, useClass: AlbumRepository }], -}) -export class AlbumModule {} diff --git a/server/src/immich/api-v1/album/album.service.spec.ts b/server/src/immich/api-v1/album/album.service.spec.ts deleted file mode 100644 index 98edf6c8f2..0000000000 --- a/server/src/immich/api-v1/album/album.service.spec.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { AlbumResponseDto, AuthUserDto, mapUser } from '@app/domain'; -import { AlbumEntity, UserEntity } from '@app/infra/entities'; -import { ForbiddenException, NotFoundException } from '@nestjs/common'; -import { userStub } from '@test'; -import { IAlbumRepository } from './album-repository'; -import { AlbumService } from './album.service'; -import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto'; - -describe('Album service', () => { - let sut: AlbumService; - let albumRepositoryMock: jest.Mocked; - - const authUser: AuthUserDto = Object.freeze({ - id: '1111', - email: 'auth@test.com', - isAdmin: false, - }); - - const albumOwner: UserEntity = Object.freeze({ - ...authUser, - firstName: 'auth', - lastName: 'user', - createdAt: new Date('2022-06-19T23:41:36.910Z'), - deletedAt: null, - updatedAt: new Date('2022-06-19T23:41:36.910Z'), - profileImagePath: '', - shouldChangePassword: false, - oauthId: '', - tags: [], - assets: [], - storageLabel: null, - externalPath: null, - }); - const albumId = 'f19ab956-4761-41ea-a5d6-bae948308d58'; - const sharedAlbumOwnerId = '2222'; - const sharedAlbumSharedAlsoWithId = '3333'; - - const _getOwnedAlbum = () => { - const albumEntity = new AlbumEntity(); - albumEntity.ownerId = albumOwner.id; - albumEntity.owner = albumOwner; - albumEntity.id = albumId; - albumEntity.albumName = 'name'; - albumEntity.createdAt = new Date('2022-06-19T23:41:36.910Z'); - albumEntity.updatedAt = new Date('2022-06-19T23:41:36.910Z'); - albumEntity.sharedUsers = []; - albumEntity.assets = []; - albumEntity.albumThumbnailAssetId = null; - albumEntity.sharedLinks = []; - return albumEntity; - }; - - const _getSharedWithAuthUserAlbum = () => { - const albumEntity = new AlbumEntity(); - albumEntity.ownerId = sharedAlbumOwnerId; - albumEntity.owner = albumOwner; - albumEntity.id = albumId; - albumEntity.albumName = 'name'; - albumEntity.createdAt = new Date('2022-06-19T23:41:36.910Z'); - albumEntity.assets = []; - albumEntity.albumThumbnailAssetId = null; - albumEntity.sharedUsers = [ - { - ...userStub.user1, - id: authUser.id, - }, - { - ...userStub.user1, - id: sharedAlbumSharedAlsoWithId, - }, - ]; - albumEntity.sharedLinks = []; - - return albumEntity; - }; - - const _getNotOwnedNotSharedAlbum = () => { - const albumEntity = new AlbumEntity(); - albumEntity.ownerId = '5555'; - albumEntity.id = albumId; - albumEntity.albumName = 'name'; - albumEntity.createdAt = new Date('2022-06-19T23:41:36.910Z'); - albumEntity.sharedUsers = []; - albumEntity.assets = []; - albumEntity.albumThumbnailAssetId = null; - - return albumEntity; - }; - - beforeAll(() => { - albumRepositoryMock = { - addAssets: jest.fn(), - get: jest.fn(), - removeAssets: jest.fn(), - updateThumbnails: jest.fn(), - }; - - sut = new AlbumService(albumRepositoryMock); - }); - - it('gets an owned album', async () => { - const albumId = 'f19ab956-4761-41ea-a5d6-bae948308d58'; - - const albumEntity = _getOwnedAlbum(); - albumRepositoryMock.get.mockImplementation(() => Promise.resolve(albumEntity)); - - const expectedResult: AlbumResponseDto = { - ownerId: albumOwner.id, - owner: mapUser(albumOwner), - id: albumId, - albumName: 'name', - createdAt: new Date('2022-06-19T23:41:36.910Z'), - updatedAt: new Date('2022-06-19T23:41:36.910Z'), - sharedUsers: [], - assets: [], - albumThumbnailAssetId: null, - shared: false, - assetCount: 0, - }; - await expect(sut.get(authUser, albumId)).resolves.toEqual(expectedResult); - }); - - it('gets a shared album', async () => { - const albumEntity = _getSharedWithAuthUserAlbum(); - albumRepositoryMock.get.mockImplementation(() => Promise.resolve(albumEntity)); - - const result = await sut.get(authUser, albumId); - expect(result.id).toEqual(albumId); - expect(result.ownerId).toEqual(sharedAlbumOwnerId); - expect(result.shared).toEqual(true); - expect(result.sharedUsers).toHaveLength(2); - expect(result.sharedUsers[0].id).toEqual(authUser.id); - expect(result.sharedUsers[1].id).toEqual(sharedAlbumSharedAlsoWithId); - }); - - it('prevents retrieving an album that is not owned or shared', async () => { - const albumEntity = _getNotOwnedNotSharedAlbum(); - const albumId = albumEntity.id; - - albumRepositoryMock.get.mockImplementation(() => Promise.resolve(albumEntity)); - await expect(sut.get(authUser, albumId)).rejects.toBeInstanceOf(ForbiddenException); - }); - - it('throws a not found exception if the album is not found', async () => { - albumRepositoryMock.get.mockImplementation(() => Promise.resolve(null)); - await expect(sut.get(authUser, '0002')).rejects.toBeInstanceOf(NotFoundException); - }); - - it('adds assets to owned album', async () => { - const albumEntity = _getOwnedAlbum(); - - const albumResponse: AddAssetsResponseDto = { - alreadyInAlbum: [], - successfullyAdded: 1, - }; - - const albumId = albumEntity.id; - - albumRepositoryMock.get.mockImplementation(() => Promise.resolve(albumEntity)); - albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve(albumResponse)); - - const result = (await sut.addAssets(authUser, albumId, { assetIds: ['1'] })) as AddAssetsResponseDto; - - // TODO: stub and expect album rendered - expect(result.album?.id).toEqual(albumId); - }); - - it('adds assets to shared album (shared with auth user)', async () => { - const albumEntity = _getSharedWithAuthUserAlbum(); - - const albumResponse: AddAssetsResponseDto = { - alreadyInAlbum: [], - successfullyAdded: 1, - }; - - const albumId = albumEntity.id; - - albumRepositoryMock.get.mockImplementation(() => Promise.resolve(albumEntity)); - albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve(albumResponse)); - - const result = (await sut.addAssets(authUser, albumId, { assetIds: ['1'] })) as AddAssetsResponseDto; - - // TODO: stub and expect album rendered - expect(result.album?.id).toEqual(albumId); - }); - - it('prevents adding assets to a not owned / shared album', async () => { - const albumEntity = _getNotOwnedNotSharedAlbum(); - - const albumResponse: AddAssetsResponseDto = { - alreadyInAlbum: [], - successfullyAdded: 1, - }; - - const albumId = albumEntity.id; - - albumRepositoryMock.get.mockImplementation(() => Promise.resolve(albumEntity)); - albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve(albumResponse)); - - await expect(sut.addAssets(authUser, albumId, { assetIds: ['1'] })).rejects.toBeInstanceOf(ForbiddenException); - }); - - // it('removes assets from owned album', async () => { - // const albumEntity = _getOwnedAlbum(); - // albumRepositoryMock.get.mockImplementation(() => Promise.resolve(albumEntity)); - // albumRepositoryMock.removeAssets.mockImplementation(() => Promise.resolve(albumEntity)); - - // await expect( - // sut.removeAssetsFromAlbum( - // authUser, - // { - // assetIds: ['f19ab956-4761-41ea-a5d6-bae948308d60'], - // }, - // albumEntity.id, - // ), - // ).resolves.toBeUndefined(); - // expect(albumRepositoryMock.removeAssets).toHaveBeenCalledTimes(1); - // expect(albumRepositoryMock.removeAssets).toHaveBeenCalledWith(albumEntity, { - // assetIds: ['f19ab956-4761-41ea-a5d6-bae948308d60'], - // }); - // }); - - // it('removes assets from shared album (shared with auth user)', async () => { - // const albumEntity = _getOwnedSharedAlbum(); - // albumRepositoryMock.get.mockImplementation(() => Promise.resolve(albumEntity)); - // albumRepositoryMock.removeAssets.mockImplementation(() => Promise.resolve(albumEntity)); - - // await expect( - // sut.removeAssetsFromAlbum( - // authUser, - // { - // assetIds: ['1'], - // }, - // albumEntity.id, - // ), - // ).resolves.toBeUndefined(); - // expect(albumRepositoryMock.removeAssets).toHaveBeenCalledTimes(1); - // expect(albumRepositoryMock.removeAssets).toHaveBeenCalledWith(albumEntity, { - // assetIds: ['1'], - // }); - // }); - - it('prevents removing assets from a not owned / shared album', async () => { - const albumEntity = _getNotOwnedNotSharedAlbum(); - - const albumResponse: AddAssetsResponseDto = { - alreadyInAlbum: [], - successfullyAdded: 1, - }; - - const albumId = albumEntity.id; - - albumRepositoryMock.get.mockImplementation(() => Promise.resolve(albumEntity)); - albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve(albumResponse)); - - await expect(sut.removeAssets(authUser, albumId, { assetIds: ['1'] })).rejects.toBeInstanceOf(ForbiddenException); - }); -}); diff --git a/server/src/immich/api-v1/album/album.service.ts b/server/src/immich/api-v1/album/album.service.ts deleted file mode 100644 index cb433bcbd6..0000000000 --- a/server/src/immich/api-v1/album/album.service.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { AlbumResponseDto, AuthUserDto, mapAlbum } from '@app/domain'; -import { AlbumEntity } from '@app/infra/entities'; -import { BadRequestException, ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { IAlbumRepository } from './album-repository'; -import { AddAssetsDto } from './dto/add-assets.dto'; -import { RemoveAssetsDto } from './dto/remove-assets.dto'; -import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto'; - -@Injectable() -export class AlbumService { - private logger = new Logger(AlbumService.name); - - constructor(@Inject(IAlbumRepository) private repository: IAlbumRepository) {} - - private async _getAlbum({ - authUser, - albumId, - validateIsOwner = true, - }: { - authUser: AuthUserDto; - albumId: string; - validateIsOwner?: boolean; - }): Promise { - await this.repository.updateThumbnails(); - - const album = await this.repository.get(albumId); - if (!album) { - throw new NotFoundException('Album Not Found'); - } - const isOwner = album.ownerId == authUser.id; - - if (validateIsOwner && !isOwner) { - throw new ForbiddenException('Unauthorized Album Access'); - } else if (!isOwner && !album.sharedUsers?.some((user) => user.id == authUser.id)) { - throw new ForbiddenException('Unauthorized Album Access'); - } - return album; - } - - async get(authUser: AuthUserDto, albumId: string): Promise { - const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false }); - return mapAlbum(album); - } - - async removeAssets(authUser: AuthUserDto, albumId: string, dto: RemoveAssetsDto): Promise { - const album = await this._getAlbum({ authUser, albumId }); - const deletedCount = await this.repository.removeAssets(album, dto); - const newAlbum = await this._getAlbum({ authUser, albumId }); - - if (deletedCount !== dto.assetIds.length) { - throw new BadRequestException('Some assets were not found in the album'); - } - - return mapAlbum(newAlbum); - } - - async addAssets(authUser: AuthUserDto, albumId: string, dto: AddAssetsDto): Promise { - if (authUser.isPublicUser && !authUser.isAllowUpload) { - this.logger.warn('Deny public user attempt to add asset to album'); - throw new ForbiddenException('Public user is not allowed to upload'); - } - - const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false }); - const result = await this.repository.addAssets(album, dto); - const newAlbum = await this._getAlbum({ authUser, albumId, validateIsOwner: false }); - - return { - ...result, - album: mapAlbum(newAlbum), - }; - } -} diff --git a/server/src/immich/api-v1/album/dto/add-assets.dto.ts b/server/src/immich/api-v1/album/dto/add-assets.dto.ts deleted file mode 100644 index 9f66fab3d8..0000000000 --- a/server/src/immich/api-v1/album/dto/add-assets.dto.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { ValidateUUID } from '@app/domain'; - -export class AddAssetsDto { - @ValidateUUID({ each: true }) - assetIds!: string[]; -} diff --git a/server/src/immich/api-v1/album/dto/add-users.dto.ts b/server/src/immich/api-v1/album/dto/add-users.dto.ts deleted file mode 100644 index 3ff76a8227..0000000000 --- a/server/src/immich/api-v1/album/dto/add-users.dto.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { ValidateUUID } from '@app/domain'; - -export class AddUsersDto { - @ValidateUUID({ each: true }) - sharedUserIds!: string[]; -} diff --git a/server/src/immich/api-v1/album/dto/remove-assets.dto.ts b/server/src/immich/api-v1/album/dto/remove-assets.dto.ts deleted file mode 100644 index a663b0254d..0000000000 --- a/server/src/immich/api-v1/album/dto/remove-assets.dto.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { ValidateUUID } from '@app/domain'; - -export class RemoveAssetsDto { - @ValidateUUID({ each: true }) - assetIds!: string[]; -} diff --git a/server/src/immich/api-v1/album/response-dto/add-assets-response.dto.ts b/server/src/immich/api-v1/album/response-dto/add-assets-response.dto.ts deleted file mode 100644 index 2580d5478e..0000000000 --- a/server/src/immich/api-v1/album/response-dto/add-assets-response.dto.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { AlbumResponseDto } from '@app/domain'; -import { ApiProperty } from '@nestjs/swagger'; - -export class AddAssetsResponseDto { - @ApiProperty({ type: 'integer' }) - successfullyAdded!: number; - - @ApiProperty() - alreadyInAlbum!: string[]; - - @ApiProperty() - album?: AlbumResponseDto; -} diff --git a/server/src/immich/app.module.ts b/server/src/immich/app.module.ts index bb32db5024..1067485ac0 100644 --- a/server/src/immich/app.module.ts +++ b/server/src/immich/app.module.ts @@ -5,7 +5,6 @@ import { Module } from '@nestjs/common'; import { APP_GUARD } from '@nestjs/core'; import { ScheduleModule } from '@nestjs/schedule'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { AlbumModule } from './api-v1/album/album.module'; import { AssetRepository, IAssetRepository } from './api-v1/asset/asset-repository'; import { AssetController as AssetControllerV1 } from './api-v1/asset/asset.controller'; import { AssetService } from './api-v1/asset/asset.service'; @@ -34,7 +33,6 @@ import { imports: [ // DomainModule.register({ imports: [InfraModule] }), - AlbumModule, ScheduleModule.forRoot(), TypeOrmModule.forFeature([AssetEntity, ExifEntity]), ], diff --git a/server/src/immich/controllers/album.controller.ts b/server/src/immich/controllers/album.controller.ts index 6da64dd1ee..889a025a4c 100644 --- a/server/src/immich/controllers/album.controller.ts +++ b/server/src/immich/controllers/album.controller.ts @@ -3,6 +3,8 @@ import { AlbumCountResponseDto, AlbumService, AuthUserDto, + BulkIdResponseDto, + BulkIdsDto, CreateAlbumDto, UpdateAlbumDto, } from '@app/domain'; @@ -10,7 +12,7 @@ import { GetAlbumsDto } from '@app/domain/album/dto/get-albums.dto'; import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { ParseMeUUIDPipe } from '../api-v1/validation/parse-me-uuid-pipe'; -import { Authenticated, AuthUser } from '../app.guard'; +import { Authenticated, AuthUser, SharedLinkRoute } from '../app.guard'; import { UseValidation } from '../app.utils'; import { UUIDParamDto } from './dto/uuid-param.dto'; @@ -36,6 +38,12 @@ export class AlbumController { return this.service.create(authUser, dto); } + @SharedLinkRoute() + @Get(':id') + getAlbumInfo(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto) { + return this.service.get(authUser, id); + } + @Patch(':id') updateAlbumInfo(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto, @Body() dto: UpdateAlbumDto) { return this.service.update(authUser, id, dto); @@ -46,6 +54,25 @@ export class AlbumController { return this.service.delete(authUser, id); } + @SharedLinkRoute() + @Put(':id/assets') + addAssetsToAlbum( + @AuthUser() authUser: AuthUserDto, + @Param() { id }: UUIDParamDto, + @Body() dto: BulkIdsDto, + ): Promise { + return this.service.addAssets(authUser, id, dto); + } + + @Delete(':id/assets') + removeAssetFromAlbum( + @AuthUser() authUser: AuthUserDto, + @Body() dto: BulkIdsDto, + @Param() { id }: UUIDParamDto, + ): Promise { + return this.service.removeAssets(authUser, id, dto); + } + @Put(':id/users') addUsersToAlbum(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto, @Body() dto: AddUsersDto) { return this.service.addUsers(authUser, id, dto); diff --git a/server/src/infra/repositories/album.repository.ts b/server/src/infra/repositories/album.repository.ts index bc3cc8adc2..850b11f267 100644 --- a/server/src/infra/repositories/album.repository.ts +++ b/server/src/infra/repositories/album.repository.ts @@ -3,11 +3,35 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { In, IsNull, Not, Repository } from 'typeorm'; import { dataSource } from '../database.config'; -import { AlbumEntity } from '../entities'; +import { AlbumEntity, AssetEntity } from '../entities'; @Injectable() export class AlbumRepository implements IAlbumRepository { - constructor(@InjectRepository(AlbumEntity) private repository: Repository) {} + constructor( + @InjectRepository(AssetEntity) private assetRepository: Repository, + @InjectRepository(AlbumEntity) private repository: Repository, + ) {} + + getById(id: string): Promise { + return this.repository.findOne({ + where: { + id, + }, + relations: { + owner: true, + sharedUsers: true, + assets: { + exifInfo: true, + }, + sharedLinks: true, + }, + order: { + assets: { + fileCreatedAt: 'DESC', + }, + }, + }); + } getByIds(ids: string[]): Promise { return this.repository.find({ @@ -161,4 +185,46 @@ export class AlbumRepository implements IAlbumRepository { }, }); } + + /** + * Makes sure all thumbnails for albums are updated by: + * - Removing thumbnails from albums without assets + * - Removing references of thumbnails to assets outside the album + * - Setting a thumbnail when none is set and the album contains assets + * + * @returns Amount of updated album thumbnails or undefined when unknown + */ + async updateThumbnails(): Promise { + // Subquery for getting a new thumbnail. + const newThumbnail = this.assetRepository + .createQueryBuilder('assets') + .select('albums_assets2.assetsId') + .addFrom('albums_assets_assets', 'albums_assets2') + .where('albums_assets2.assetsId = assets.id') + .andWhere('albums_assets2.albumsId = "albums"."id"') // Reference to albums.id outside this query + .orderBy('assets.fileCreatedAt', 'DESC') + .limit(1); + + // Using dataSource, because there is no direct access to albums_assets_assets. + const albumHasAssets = dataSource + .createQueryBuilder() + .select('1') + .from('albums_assets_assets', 'albums_assets') + .where('"albums"."id" = "albums_assets"."albumsId"'); + + const albumContainsThumbnail = albumHasAssets + .clone() + .andWhere('"albums"."albumThumbnailAssetId" = "albums_assets"."assetsId"'); + + const updateAlbums = this.repository + .createQueryBuilder('albums') + .update(AlbumEntity) + .set({ albumThumbnailAssetId: () => `(${newThumbnail.getQuery()})` }) + .where(`"albums"."albumThumbnailAssetId" IS NULL AND EXISTS (${albumHasAssets.getQuery()})`) + .orWhere(`"albums"."albumThumbnailAssetId" IS NOT NULL AND NOT EXISTS (${albumContainsThumbnail.getQuery()})`); + + const result = await updateAlbums.execute(); + + return result.affected; + } } diff --git a/server/test/fixtures/album.stub.ts b/server/test/fixtures/album.stub.ts index 6762950d31..e86d949768 100644 --- a/server/test/fixtures/album.stub.ts +++ b/server/test/fixtures/album.stub.ts @@ -69,6 +69,19 @@ export const albumStub = { sharedLinks: [], sharedUsers: [], }), + twoAssets: Object.freeze({ + id: 'album-4a', + albumName: 'Album with two assets', + ownerId: authStub.admin.id, + owner: userStub.admin, + assets: [assetStub.image, assetStub.withLocation], + albumThumbnailAsset: assetStub.image, + albumThumbnailAssetId: assetStub.image.id, + createdAt: new Date(), + updatedAt: new Date(), + sharedLinks: [], + sharedUsers: [], + }), emptyWithInvalidThumbnail: Object.freeze({ id: 'album-5', albumName: 'Empty album with invalid thumbnail', diff --git a/server/test/repositories/album.repository.mock.ts b/server/test/repositories/album.repository.mock.ts index 662b183d31..8656fa64a1 100644 --- a/server/test/repositories/album.repository.mock.ts +++ b/server/test/repositories/album.repository.mock.ts @@ -2,6 +2,7 @@ import { IAlbumRepository } from '@app/domain'; export const newAlbumRepositoryMock = (): jest.Mocked => { return { + getById: jest.fn(), getByIds: jest.fn(), getByAssetId: jest.fn(), getAssetCountForIds: jest.fn(), @@ -15,5 +16,6 @@ export const newAlbumRepositoryMock = (): jest.Mocked => { create: jest.fn(), update: jest.fn(), delete: jest.fn(), + updateThumbnails: jest.fn(), }; }; diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 453e9ae84e..d94ecd5cb2 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -99,44 +99,6 @@ export interface APIKeyUpdateDto { */ 'name': string; } -/** - * - * @export - * @interface AddAssetsDto - */ -export interface AddAssetsDto { - /** - * - * @type {Array} - * @memberof AddAssetsDto - */ - 'assetIds': Array; -} -/** - * - * @export - * @interface AddAssetsResponseDto - */ -export interface AddAssetsResponseDto { - /** - * - * @type {AlbumResponseDto} - * @memberof AddAssetsResponseDto - */ - 'album'?: AlbumResponseDto; - /** - * - * @type {Array} - * @memberof AddAssetsResponseDto - */ - 'alreadyInAlbum': Array; - /** - * - * @type {number} - * @memberof AddAssetsResponseDto - */ - 'successfullyAdded': number; -} /** * * @export @@ -821,6 +783,19 @@ export const BulkIdResponseDtoErrorEnum = { export type BulkIdResponseDtoErrorEnum = typeof BulkIdResponseDtoErrorEnum[keyof typeof BulkIdResponseDtoErrorEnum]; +/** + * + * @export + * @interface BulkIdsDto + */ +export interface BulkIdsDto { + /** + * + * @type {Array} + * @memberof BulkIdsDto + */ + 'ids': Array; +} /** * * @export @@ -1927,19 +1902,6 @@ export interface QueueStatusDto { */ 'isPaused': boolean; } -/** - * - * @export - * @interface RemoveAssetsDto - */ -export interface RemoveAssetsDto { - /** - * - * @type {Array} - * @memberof RemoveAssetsDto - */ - 'assetIds': Array; -} /** * * @export @@ -3679,16 +3641,16 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration /** * * @param {string} id - * @param {AddAssetsDto} addAssetsDto + * @param {BulkIdsDto} bulkIdsDto * @param {string} [key] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - addAssetsToAlbum: async (id: string, addAssetsDto: AddAssetsDto, key?: string, options: AxiosRequestConfig = {}): Promise => { + addAssetsToAlbum: async (id: string, bulkIdsDto: BulkIdsDto, key?: string, options: AxiosRequestConfig = {}): Promise => { // verify required parameter 'id' is not null or undefined assertParamExists('addAssetsToAlbum', 'id', id) - // verify required parameter 'addAssetsDto' is not null or undefined - assertParamExists('addAssetsToAlbum', 'addAssetsDto', addAssetsDto) + // verify required parameter 'bulkIdsDto' is not null or undefined + assertParamExists('addAssetsToAlbum', 'bulkIdsDto', bulkIdsDto) const localVarPath = `/album/{id}/assets` .replace(`{${"id"}}`, encodeURIComponent(String(id))); // use dummy base URL string because the URL constructor only accepts absolute URLs. @@ -3722,7 +3684,7 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - localVarRequestOptions.data = serializeDataIfNeeded(addAssetsDto, localVarRequestOptions, configuration) + localVarRequestOptions.data = serializeDataIfNeeded(bulkIdsDto, localVarRequestOptions, configuration) return { url: toPathString(localVarUrlObj), @@ -3999,15 +3961,15 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration /** * * @param {string} id - * @param {RemoveAssetsDto} removeAssetsDto + * @param {BulkIdsDto} bulkIdsDto * @param {*} [options] Override http request option. * @throws {RequiredError} */ - removeAssetFromAlbum: async (id: string, removeAssetsDto: RemoveAssetsDto, options: AxiosRequestConfig = {}): Promise => { + removeAssetFromAlbum: async (id: string, bulkIdsDto: BulkIdsDto, options: AxiosRequestConfig = {}): Promise => { // verify required parameter 'id' is not null or undefined assertParamExists('removeAssetFromAlbum', 'id', id) - // verify required parameter 'removeAssetsDto' is not null or undefined - assertParamExists('removeAssetFromAlbum', 'removeAssetsDto', removeAssetsDto) + // verify required parameter 'bulkIdsDto' is not null or undefined + assertParamExists('removeAssetFromAlbum', 'bulkIdsDto', bulkIdsDto) const localVarPath = `/album/{id}/assets` .replace(`{${"id"}}`, encodeURIComponent(String(id))); // use dummy base URL string because the URL constructor only accepts absolute URLs. @@ -4037,7 +3999,7 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - localVarRequestOptions.data = serializeDataIfNeeded(removeAssetsDto, localVarRequestOptions, configuration) + localVarRequestOptions.data = serializeDataIfNeeded(bulkIdsDto, localVarRequestOptions, configuration) return { url: toPathString(localVarUrlObj), @@ -4151,13 +4113,13 @@ export const AlbumApiFp = function(configuration?: Configuration) { /** * * @param {string} id - * @param {AddAssetsDto} addAssetsDto + * @param {BulkIdsDto} bulkIdsDto * @param {string} [key] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async addAssetsToAlbum(id: string, addAssetsDto: AddAssetsDto, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.addAssetsToAlbum(id, addAssetsDto, key, options); + async addAssetsToAlbum(id: string, bulkIdsDto: BulkIdsDto, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.addAssetsToAlbum(id, bulkIdsDto, key, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** @@ -4225,12 +4187,12 @@ export const AlbumApiFp = function(configuration?: Configuration) { /** * * @param {string} id - * @param {RemoveAssetsDto} removeAssetsDto + * @param {BulkIdsDto} bulkIdsDto * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async removeAssetFromAlbum(id: string, removeAssetsDto: RemoveAssetsDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.removeAssetFromAlbum(id, removeAssetsDto, options); + async removeAssetFromAlbum(id: string, bulkIdsDto: BulkIdsDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.removeAssetFromAlbum(id, bulkIdsDto, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** @@ -4268,13 +4230,13 @@ export const AlbumApiFactory = function (configuration?: Configuration, basePath /** * * @param {string} id - * @param {AddAssetsDto} addAssetsDto + * @param {BulkIdsDto} bulkIdsDto * @param {string} [key] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - addAssetsToAlbum(id: string, addAssetsDto: AddAssetsDto, key?: string, options?: any): AxiosPromise { - return localVarFp.addAssetsToAlbum(id, addAssetsDto, key, options).then((request) => request(axios, basePath)); + addAssetsToAlbum(id: string, bulkIdsDto: BulkIdsDto, key?: string, options?: any): AxiosPromise> { + return localVarFp.addAssetsToAlbum(id, bulkIdsDto, key, options).then((request) => request(axios, basePath)); }, /** * @@ -4335,12 +4297,12 @@ export const AlbumApiFactory = function (configuration?: Configuration, basePath /** * * @param {string} id - * @param {RemoveAssetsDto} removeAssetsDto + * @param {BulkIdsDto} bulkIdsDto * @param {*} [options] Override http request option. * @throws {RequiredError} */ - removeAssetFromAlbum(id: string, removeAssetsDto: RemoveAssetsDto, options?: any): AxiosPromise { - return localVarFp.removeAssetFromAlbum(id, removeAssetsDto, options).then((request) => request(axios, basePath)); + removeAssetFromAlbum(id: string, bulkIdsDto: BulkIdsDto, options?: any): AxiosPromise> { + return localVarFp.removeAssetFromAlbum(id, bulkIdsDto, options).then((request) => request(axios, basePath)); }, /** * @@ -4380,10 +4342,10 @@ export interface AlbumApiAddAssetsToAlbumRequest { /** * - * @type {AddAssetsDto} + * @type {BulkIdsDto} * @memberof AlbumApiAddAssetsToAlbum */ - readonly addAssetsDto: AddAssetsDto + readonly bulkIdsDto: BulkIdsDto /** * @@ -4499,10 +4461,10 @@ export interface AlbumApiRemoveAssetFromAlbumRequest { /** * - * @type {RemoveAssetsDto} + * @type {BulkIdsDto} * @memberof AlbumApiRemoveAssetFromAlbum */ - readonly removeAssetsDto: RemoveAssetsDto + readonly bulkIdsDto: BulkIdsDto } /** @@ -4562,7 +4524,7 @@ export class AlbumApi extends BaseAPI { * @memberof AlbumApi */ public addAssetsToAlbum(requestParameters: AlbumApiAddAssetsToAlbumRequest, options?: AxiosRequestConfig) { - return AlbumApiFp(this.configuration).addAssetsToAlbum(requestParameters.id, requestParameters.addAssetsDto, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); + return AlbumApiFp(this.configuration).addAssetsToAlbum(requestParameters.id, requestParameters.bulkIdsDto, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); } /** @@ -4638,7 +4600,7 @@ export class AlbumApi extends BaseAPI { * @memberof AlbumApi */ public removeAssetFromAlbum(requestParameters: AlbumApiRemoveAssetFromAlbumRequest, options?: AxiosRequestConfig) { - return AlbumApiFp(this.configuration).removeAssetFromAlbum(requestParameters.id, requestParameters.removeAssetsDto, options).then((request) => request(this.axios, this.basePath)); + return AlbumApiFp(this.configuration).removeAssetFromAlbum(requestParameters.id, requestParameters.bulkIdsDto, options).then((request) => request(this.axios, this.basePath)); } /** diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte index 2c6fa6232c..41cdb7fb07 100644 --- a/web/src/lib/components/album-page/album-viewer.svelte +++ b/web/src/lib/components/album-page/album-viewer.svelte @@ -92,6 +92,7 @@ let multiSelectAsset: Set = new Set(); $: isMultiSelectionMode = multiSelectAsset.size > 0; + $: isMultiSelectionUserOwned = Array.from(multiSelectAsset).every((asset) => asset.ownerId === currentUser?.id); afterNavigate(({ from }) => { backUrl = from?.url.pathname ?? '/albums'; @@ -182,24 +183,24 @@ const createAlbumHandler = async (event: CustomEvent) => { const { assets }: { assets: AssetResponseDto[] } = event.detail; try { - const { data } = await api.albumApi.addAssetsToAlbum({ + const { data: results } = await api.albumApi.addAssetsToAlbum({ id: album.id, - addAssetsDto: { - assetIds: assets.map((a) => a.id), - }, + bulkIdsDto: { ids: assets.map((a) => a.id) }, key: sharedLink?.key, }); - if (data.album) { - album = data.album; - } + const count = results.filter(({ success }) => success).length; + notificationController.show({ + type: NotificationType.Info, + message: `Added ${count} asset${count === 1 ? '' : 's'}`, + }); + + const { data } = await api.albumApi.getAlbumInfo({ id: album.id }); + album = data; + isShowAssetSelection = false; } catch (e) { - console.error('Error [createAlbumHandler] ', e); - notificationController.show({ - type: NotificationType.Error, - message: 'Error creating album, check console for more details', - }); + handleError(e, 'Error creating album'); } }; @@ -307,7 +308,7 @@ {#if sharedLink?.allowDownload || !isPublicShared} {/if} - {#if isOwned} + {#if isOwned || isMultiSelectionUserOwned} {/if} diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 094ed09269..d41419ffc7 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -189,11 +189,8 @@ isShowAlbumPicker = false; const album = event.detail.album; - addAssetsToAlbum(album.id, [asset.id]).then((dto) => { - if (dto.successfullyAdded === 1 && dto.album) { - appearsInAlbums = [...appearsInAlbums, dto.album]; - } - }); + await addAssetsToAlbum(album.id, [asset.id]); + await getAllAlbums(); }; const disableKeyDownEvent = () => { diff --git a/web/src/lib/components/photos-page/actions/add-to-album.svelte b/web/src/lib/components/photos-page/actions/add-to-album.svelte index 90e2ceff45..a205d83927 100644 --- a/web/src/lib/components/photos-page/actions/add-to-album.svelte +++ b/web/src/lib/components/photos-page/actions/add-to-album.svelte @@ -44,10 +44,9 @@ const handleAddToAlbum = async (event: CustomEvent<{ album: AlbumResponseDto }>) => { showAlbumPicker = false; const album = event.detail.album; - const assetIds = Array.from(getAssets()).map((asset) => asset.id); - - addAssetsToAlbum(album.id, assetIds).then(clearSelect); + await addAssetsToAlbum(album.id, assetIds); + clearSelect(); }; diff --git a/web/src/lib/components/photos-page/actions/remove-from-album.svelte b/web/src/lib/components/photos-page/actions/remove-from-album.svelte index 1a553e031f..9067cc6e3f 100644 --- a/web/src/lib/components/photos-page/actions/remove-from-album.svelte +++ b/web/src/lib/components/photos-page/actions/remove-from-album.svelte @@ -17,14 +17,20 @@ const removeFromAlbum = async () => { try { - const { data } = await api.albumApi.removeAssetFromAlbum({ + const { data: results } = await api.albumApi.removeAssetFromAlbum({ id: album.id, - removeAssetsDto: { - assetIds: Array.from(getAssets()).map((a) => a.id), - }, + bulkIdsDto: { ids: Array.from(getAssets()).map((a) => a.id) }, }); + const { data } = await api.albumApi.getAlbumInfo({ id: album.id }); album = data; + + const count = results.filter(({ success }) => success).length; + notificationController.show({ + type: NotificationType.Info, + message: `Removed ${count} asset${count === 1 ? '' : 's'}`, + }); + clearSelect(); } catch (e) { console.error('Error [album-viewer] [removeAssetFromAlbum]', e); diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index 0be85fa89b..cd8c87d312 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -1,23 +1,24 @@ import { notificationController, NotificationType } from '$lib/components/shared-components/notification/notification'; import { downloadManager } from '$lib/stores/download'; -import { AddAssetsResponseDto, api, AssetApiGetDownloadInfoRequest, AssetResponseDto, DownloadResponseDto } from '@api'; +import { api, AssetApiGetDownloadInfoRequest, BulkIdResponseDto, AssetResponseDto, DownloadResponseDto } from '@api'; import { handleError } from './handle-error'; export const addAssetsToAlbum = async ( albumId: string, assetIds: Array, key: string | undefined = undefined, -): Promise => - api.albumApi.addAssetsToAlbum({ id: albumId, addAssetsDto: { assetIds }, key }).then(({ data: dto }) => { - if (dto.successfullyAdded > 0) { +): Promise => + api.albumApi.addAssetsToAlbum({ id: albumId, bulkIdsDto: { ids: assetIds }, key }).then(({ data: results }) => { + const count = results.filter(({ success }) => success).length; + if (count > 0) { // This might be 0 if the user tries to add an asset that is already in the album notificationController.show({ - message: `Added ${dto.successfullyAdded} to ${dto.album?.albumName}`, type: NotificationType.Info, + message: `Added ${count} asset${count === 1 ? '' : 's'}`, }); } - return dto; + return results; }); const downloadBlob = (data: Blob, filename: string) => {