diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index 6d10c4eb2ac59..f69d1dbb9180e 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 0000000000000..11efd36f84a35 --- /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 0000000000000..531c5c944adca --- /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 a6fd8db23e431..8b342dd45f08e 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 2e45d87c8dcca..0960978a47a58 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 3abe0d80788a5..257dbdbaa2ff9 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 f74118b3d97af..3392ed51833dd 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 9363c7f9bd204..31c48c4503012 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 3dc3a41beb2e6..715dcc47a5890 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 ad445090a25ec..1a97eef9f82a7 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 38780bd67a98f..5e9cee604063b 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -182,8 +182,6 @@ Class | Method | HTTP request | Description - [APIKeyCreateResponseDto](doc//APIKeyCreateResponseDto.md) - [APIKeyResponseDto](doc//APIKeyResponseDto.md) - [APIKeyUpdateDto](doc//APIKeyUpdateDto.md) - - [AddAssetsDto](doc//AddAssetsDto.md) - - [AddAssetsResponseDto](doc//AddAssetsResponseDto.md) - [AddUsersDto](doc//AddUsersDto.md) - [AdminSignupResponseDto](doc//AdminSignupResponseDto.md) - [AlbumCountResponseDto](doc//AlbumCountResponseDto.md) @@ -204,6 +202,7 @@ Class | Method | HTTP request | Description - [AudioCodec](doc//AudioCodec.md) - [AuthDeviceResponseDto](doc//AuthDeviceResponseDto.md) - [BulkIdResponseDto](doc//BulkIdResponseDto.md) + - [BulkIdsDto](doc//BulkIdsDto.md) - [ChangePasswordDto](doc//ChangePasswordDto.md) - [CheckDuplicateAssetDto](doc//CheckDuplicateAssetDto.md) - [CheckDuplicateAssetResponseDto](doc//CheckDuplicateAssetResponseDto.md) @@ -245,7 +244,6 @@ Class | Method | HTTP request | Description - [PersonResponseDto](doc//PersonResponseDto.md) - [PersonUpdateDto](doc//PersonUpdateDto.md) - [QueueStatusDto](doc//QueueStatusDto.md) - - [RemoveAssetsDto](doc//RemoveAssetsDto.md) - [SearchAlbumResponseDto](doc//SearchAlbumResponseDto.md) - [SearchAssetDto](doc//SearchAssetDto.md) - [SearchAssetResponseDto](doc//SearchAssetResponseDto.md) diff --git a/mobile/openapi/doc/AddAssetsResponseDto.md b/mobile/openapi/doc/AddAssetsResponseDto.md deleted file mode 100644 index 3a8d747ea9309..0000000000000 --- a/mobile/openapi/doc/AddAssetsResponseDto.md +++ /dev/null @@ -1,17 +0,0 @@ -# openapi.model.AddAssetsResponseDto - -## Load the model package -```dart -import 'package:openapi/api.dart'; -``` - -## Properties -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**album** | [**AlbumResponseDto**](AlbumResponseDto.md) | | [optional] -**alreadyInAlbum** | **List** | | [default to const []] -**successfullyAdded** | **int** | | - -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/mobile/openapi/doc/AlbumApi.md b/mobile/openapi/doc/AlbumApi.md index 47c418096854e..b12e68c8ef993 100644 --- a/mobile/openapi/doc/AlbumApi.md +++ b/mobile/openapi/doc/AlbumApi.md @@ -22,7 +22,7 @@ Method | HTTP request | Description # **addAssetsToAlbum** -> AddAssetsResponseDto addAssetsToAlbum(id, addAssetsDto, key) +> List addAssetsToAlbum(id, bulkIdsDto, key) @@ -46,11 +46,11 @@ import 'package:openapi/api.dart'; final api_instance = AlbumApi(); final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | -final addAssetsDto = AddAssetsDto(); // AddAssetsDto | +final bulkIdsDto = BulkIdsDto(); // BulkIdsDto | final key = key_example; // String | try { - final result = api_instance.addAssetsToAlbum(id, addAssetsDto, key); + final result = api_instance.addAssetsToAlbum(id, bulkIdsDto, key); print(result); } catch (e) { print('Exception when calling AlbumApi->addAssetsToAlbum: $e\n'); @@ -62,12 +62,12 @@ try { Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- **id** | **String**| | - **addAssetsDto** | [**AddAssetsDto**](AddAssetsDto.md)| | + **bulkIdsDto** | [**BulkIdsDto**](BulkIdsDto.md)| | **key** | **String**| | [optional] ### Return type -[**AddAssetsResponseDto**](AddAssetsResponseDto.md) +[**List**](BulkIdResponseDto.md) ### Authorization @@ -412,7 +412,7 @@ Name | Type | Description | Notes [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) # **removeAssetFromAlbum** -> AlbumResponseDto removeAssetFromAlbum(id, removeAssetsDto) +> List removeAssetFromAlbum(id, bulkIdsDto) @@ -436,10 +436,10 @@ import 'package:openapi/api.dart'; final api_instance = AlbumApi(); final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | -final removeAssetsDto = RemoveAssetsDto(); // RemoveAssetsDto | +final bulkIdsDto = BulkIdsDto(); // BulkIdsDto | try { - final result = api_instance.removeAssetFromAlbum(id, removeAssetsDto); + final result = api_instance.removeAssetFromAlbum(id, bulkIdsDto); print(result); } catch (e) { print('Exception when calling AlbumApi->removeAssetFromAlbum: $e\n'); @@ -451,11 +451,11 @@ try { Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- **id** | **String**| | - **removeAssetsDto** | [**RemoveAssetsDto**](RemoveAssetsDto.md)| | + **bulkIdsDto** | [**BulkIdsDto**](BulkIdsDto.md)| | ### Return type -[**AlbumResponseDto**](AlbumResponseDto.md) +[**List**](BulkIdResponseDto.md) ### Authorization 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 b74211d6b7c61..71f1badd605ae 100644 --- a/mobile/openapi/doc/AddAssetsDto.md +++ b/mobile/openapi/doc/BulkIdsDto.md @@ -1,4 +1,4 @@ -# openapi.model.AddAssetsDto +# openapi.model.BulkIdsDto ## Load the model package ```dart @@ -8,7 +8,7 @@ import 'package:openapi/api.dart'; ## Properties Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- -**assetIds** | **List** | | [default to const []] +**ids** | **List** | | [default to const []] [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/mobile/openapi/doc/RemoveAssetsDto.md b/mobile/openapi/doc/RemoveAssetsDto.md deleted file mode 100644 index d2ab8473247b4..0000000000000 --- a/mobile/openapi/doc/RemoveAssetsDto.md +++ /dev/null @@ -1,15 +0,0 @@ -# openapi.model.RemoveAssetsDto - -## Load the model package -```dart -import 'package:openapi/api.dart'; -``` - -## Properties -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**assetIds** | **List** | | [default to const []] - -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 36415d353ae97..5a9a1db16396e 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -47,8 +47,6 @@ part 'model/api_key_create_dto.dart'; part 'model/api_key_create_response_dto.dart'; part 'model/api_key_response_dto.dart'; part 'model/api_key_update_dto.dart'; -part 'model/add_assets_dto.dart'; -part 'model/add_assets_response_dto.dart'; part 'model/add_users_dto.dart'; part 'model/admin_signup_response_dto.dart'; part 'model/album_count_response_dto.dart'; @@ -69,6 +67,7 @@ part 'model/asset_type_enum.dart'; part 'model/audio_codec.dart'; part 'model/auth_device_response_dto.dart'; part 'model/bulk_id_response_dto.dart'; +part 'model/bulk_ids_dto.dart'; part 'model/change_password_dto.dart'; part 'model/check_duplicate_asset_dto.dart'; part 'model/check_duplicate_asset_response_dto.dart'; @@ -110,7 +109,6 @@ part 'model/people_update_item.dart'; part 'model/person_response_dto.dart'; part 'model/person_update_dto.dart'; part 'model/queue_status_dto.dart'; -part 'model/remove_assets_dto.dart'; part 'model/search_album_response_dto.dart'; part 'model/search_asset_dto.dart'; part 'model/search_asset_response_dto.dart'; diff --git a/mobile/openapi/lib/api/album_api.dart b/mobile/openapi/lib/api/album_api.dart index 1f5bd7b58ed23..39b44e9becf08 100644 --- a/mobile/openapi/lib/api/album_api.dart +++ b/mobile/openapi/lib/api/album_api.dart @@ -21,16 +21,16 @@ class AlbumApi { /// /// * [String] id (required): /// - /// * [AddAssetsDto] addAssetsDto (required): + /// * [BulkIdsDto] bulkIdsDto (required): /// /// * [String] key: - Future addAssetsToAlbumWithHttpInfo(String id, AddAssetsDto addAssetsDto, { String? key, }) async { + Future addAssetsToAlbumWithHttpInfo(String id, BulkIdsDto bulkIdsDto, { String? key, }) async { // ignore: prefer_const_declarations final path = r'/album/{id}/assets' .replaceAll('{id}', id); // ignore: prefer_final_locals - Object? postBody = addAssetsDto; + Object? postBody = bulkIdsDto; final queryParams = []; final headerParams = {}; @@ -58,11 +58,11 @@ class AlbumApi { /// /// * [String] id (required): /// - /// * [AddAssetsDto] addAssetsDto (required): + /// * [BulkIdsDto] bulkIdsDto (required): /// /// * [String] key: - Future addAssetsToAlbum(String id, AddAssetsDto addAssetsDto, { String? key, }) async { - final response = await addAssetsToAlbumWithHttpInfo(id, addAssetsDto, key: key, ); + Future?> addAssetsToAlbum(String id, BulkIdsDto bulkIdsDto, { String? key, }) async { + final response = await addAssetsToAlbumWithHttpInfo(id, bulkIdsDto, key: key, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -70,8 +70,11 @@ class AlbumApi { // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" // FormatException when trying to decode an empty string. if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AddAssetsResponseDto',) as AddAssetsResponseDto; - + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(); + } return null; } @@ -380,14 +383,14 @@ class AlbumApi { /// /// * [String] id (required): /// - /// * [RemoveAssetsDto] removeAssetsDto (required): - Future removeAssetFromAlbumWithHttpInfo(String id, RemoveAssetsDto removeAssetsDto,) async { + /// * [BulkIdsDto] bulkIdsDto (required): + Future removeAssetFromAlbumWithHttpInfo(String id, BulkIdsDto bulkIdsDto,) async { // ignore: prefer_const_declarations final path = r'/album/{id}/assets' .replaceAll('{id}', id); // ignore: prefer_final_locals - Object? postBody = removeAssetsDto; + Object? postBody = bulkIdsDto; final queryParams = []; final headerParams = {}; @@ -411,9 +414,9 @@ class AlbumApi { /// /// * [String] id (required): /// - /// * [RemoveAssetsDto] removeAssetsDto (required): - Future removeAssetFromAlbum(String id, RemoveAssetsDto removeAssetsDto,) async { - final response = await removeAssetFromAlbumWithHttpInfo(id, removeAssetsDto,); + /// * [BulkIdsDto] bulkIdsDto (required): + Future?> removeAssetFromAlbum(String id, BulkIdsDto bulkIdsDto,) async { + final response = await removeAssetFromAlbumWithHttpInfo(id, bulkIdsDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -421,8 +424,11 @@ class AlbumApi { // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" // FormatException when trying to decode an empty string. if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AlbumResponseDto',) as AlbumResponseDto; - + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(); + } return null; } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 000629e1c8072..daf38f5ac20a5 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -189,10 +189,6 @@ class ApiClient { return APIKeyResponseDto.fromJson(value); case 'APIKeyUpdateDto': return APIKeyUpdateDto.fromJson(value); - case 'AddAssetsDto': - return AddAssetsDto.fromJson(value); - case 'AddAssetsResponseDto': - return AddAssetsResponseDto.fromJson(value); case 'AddUsersDto': return AddUsersDto.fromJson(value); case 'AdminSignupResponseDto': @@ -233,6 +229,8 @@ class ApiClient { return AuthDeviceResponseDto.fromJson(value); case 'BulkIdResponseDto': return BulkIdResponseDto.fromJson(value); + case 'BulkIdsDto': + return BulkIdsDto.fromJson(value); case 'ChangePasswordDto': return ChangePasswordDto.fromJson(value); case 'CheckDuplicateAssetDto': @@ -315,8 +313,6 @@ class ApiClient { return PersonUpdateDto.fromJson(value); case 'QueueStatusDto': return QueueStatusDto.fromJson(value); - case 'RemoveAssetsDto': - return RemoveAssetsDto.fromJson(value); case 'SearchAlbumResponseDto': return SearchAlbumResponseDto.fromJson(value); case 'SearchAssetDto': 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 a5b293e1fcad0..0000000000000 --- a/mobile/openapi/lib/model/add_assets_response_dto.dart +++ /dev/null @@ -1,125 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.12 - -// ignore_for_file: unused_element, unused_import -// ignore_for_file: always_put_required_named_parameters_first -// ignore_for_file: constant_identifier_names -// ignore_for_file: lines_longer_than_80_chars - -part of openapi.api; - -class AddAssetsResponseDto { - /// Returns a new [AddAssetsResponseDto] instance. - AddAssetsResponseDto({ - this.album, - this.alreadyInAlbum = const [], - required this.successfullyAdded, - }); - - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// - AlbumResponseDto? album; - - List alreadyInAlbum; - - int successfullyAdded; - - @override - bool operator ==(Object other) => identical(this, other) || other is AddAssetsResponseDto && - other.album == album && - other.alreadyInAlbum == alreadyInAlbum && - other.successfullyAdded == successfullyAdded; - - @override - int get hashCode => - // ignore: unnecessary_parenthesis - (album == null ? 0 : album!.hashCode) + - (alreadyInAlbum.hashCode) + - (successfullyAdded.hashCode); - - @override - String toString() => 'AddAssetsResponseDto[album=$album, alreadyInAlbum=$alreadyInAlbum, successfullyAdded=$successfullyAdded]'; - - Map toJson() { - final json = {}; - if (this.album != null) { - json[r'album'] = this.album; - } else { - // json[r'album'] = null; - } - json[r'alreadyInAlbum'] = this.alreadyInAlbum; - json[r'successfullyAdded'] = this.successfullyAdded; - return json; - } - - /// Returns a new [AddAssetsResponseDto] instance and imports its values from - /// [value] if it's a [Map], null otherwise. - // ignore: prefer_constructors_over_static_methods - static AddAssetsResponseDto? fromJson(dynamic value) { - if (value is Map) { - final json = value.cast(); - - return AddAssetsResponseDto( - album: AlbumResponseDto.fromJson(json[r'album']), - alreadyInAlbum: json[r'alreadyInAlbum'] is Iterable - ? (json[r'alreadyInAlbum'] as Iterable).cast().toList(growable: false) - : const [], - successfullyAdded: mapValueOfType(json, r'successfullyAdded')!, - ); - } - return null; - } - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = AddAssetsResponseDto.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } - - static Map mapFromJson(dynamic json) { - final map = {}; - if (json is Map && json.isNotEmpty) { - json = json.cast(); // ignore: parameter_assignments - for (final entry in json.entries) { - final value = AddAssetsResponseDto.fromJson(entry.value); - if (value != null) { - map[entry.key] = value; - } - } - } - return map; - } - - // maps a json object with a list of AddAssetsResponseDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; - if (json is Map && json.isNotEmpty) { - // ignore: parameter_assignments - json = json.cast(); - for (final entry in json.entries) { - map[entry.key] = AddAssetsResponseDto.listFromJson(entry.value, growable: growable,); - } - } - return map; - } - - /// The list of required keys that must be present in a JSON. - static const requiredKeys = { - 'alreadyInAlbum', - 'successfullyAdded', - }; -} - 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 dd8ca47694071..c1cbb2cb341d5 100644 --- a/mobile/openapi/lib/model/add_assets_dto.dart +++ b/mobile/openapi/lib/model/bulk_ids_dto.dart @@ -10,53 +10,53 @@ part of openapi.api; -class AddAssetsDto { - /// Returns a new [AddAssetsDto] instance. - AddAssetsDto({ - this.assetIds = const [], +class BulkIdsDto { + /// Returns a new [BulkIdsDto] instance. + BulkIdsDto({ + this.ids = const [], }); - List assetIds; + List ids; @override - bool operator ==(Object other) => identical(this, other) || other is AddAssetsDto && - other.assetIds == assetIds; + bool operator ==(Object other) => identical(this, other) || other is BulkIdsDto && + other.ids == ids; @override int get hashCode => // ignore: unnecessary_parenthesis - (assetIds.hashCode); + (ids.hashCode); @override - String toString() => 'AddAssetsDto[assetIds=$assetIds]'; + String toString() => 'BulkIdsDto[ids=$ids]'; Map toJson() { final json = {}; - json[r'assetIds'] = this.assetIds; + json[r'ids'] = this.ids; return json; } - /// Returns a new [AddAssetsDto] instance and imports its values from + /// Returns a new [BulkIdsDto] instance and imports its values from /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods - static AddAssetsDto? fromJson(dynamic value) { + static BulkIdsDto? fromJson(dynamic value) { if (value is Map) { final json = value.cast(); - return AddAssetsDto( - assetIds: json[r'assetIds'] is Iterable - ? (json[r'assetIds'] as Iterable).cast().toList(growable: false) + return BulkIdsDto( + ids: json[r'ids'] is Iterable + ? (json[r'ids'] as Iterable).cast().toList(growable: false) : const [], ); } return null; } - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = AddAssetsDto.fromJson(row); + final value = BulkIdsDto.fromJson(row); if (value != null) { result.add(value); } @@ -65,12 +65,12 @@ class AddAssetsDto { return result.toList(growable: growable); } - static Map mapFromJson(dynamic json) { - final map = {}; + static Map mapFromJson(dynamic json) { + final map = {}; if (json is Map && json.isNotEmpty) { json = json.cast(); // ignore: parameter_assignments for (final entry in json.entries) { - final value = AddAssetsDto.fromJson(entry.value); + final value = BulkIdsDto.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -79,14 +79,14 @@ class AddAssetsDto { return map; } - // maps a json object with a list of AddAssetsDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; + // maps a json object with a list of BulkIdsDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; if (json is Map && json.isNotEmpty) { // ignore: parameter_assignments json = json.cast(); for (final entry in json.entries) { - map[entry.key] = AddAssetsDto.listFromJson(entry.value, growable: growable,); + map[entry.key] = BulkIdsDto.listFromJson(entry.value, growable: growable,); } } return map; @@ -94,7 +94,7 @@ class AddAssetsDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { - 'assetIds', + 'ids', }; } 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 c53e6d0ad29d1..0000000000000 --- a/mobile/openapi/lib/model/remove_assets_dto.dart +++ /dev/null @@ -1,100 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.12 - -// ignore_for_file: unused_element, unused_import -// ignore_for_file: always_put_required_named_parameters_first -// ignore_for_file: constant_identifier_names -// ignore_for_file: lines_longer_than_80_chars - -part of openapi.api; - -class RemoveAssetsDto { - /// Returns a new [RemoveAssetsDto] instance. - RemoveAssetsDto({ - this.assetIds = const [], - }); - - List assetIds; - - @override - bool operator ==(Object other) => identical(this, other) || other is RemoveAssetsDto && - other.assetIds == assetIds; - - @override - int get hashCode => - // ignore: unnecessary_parenthesis - (assetIds.hashCode); - - @override - String toString() => 'RemoveAssetsDto[assetIds=$assetIds]'; - - Map toJson() { - final json = {}; - json[r'assetIds'] = this.assetIds; - return json; - } - - /// Returns a new [RemoveAssetsDto] instance and imports its values from - /// [value] if it's a [Map], null otherwise. - // ignore: prefer_constructors_over_static_methods - static RemoveAssetsDto? fromJson(dynamic value) { - if (value is Map) { - final json = value.cast(); - - return RemoveAssetsDto( - assetIds: json[r'assetIds'] is Iterable - ? (json[r'assetIds'] as Iterable).cast().toList(growable: false) - : const [], - ); - } - return null; - } - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = RemoveAssetsDto.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } - - static Map mapFromJson(dynamic json) { - final map = {}; - if (json is Map && json.isNotEmpty) { - json = json.cast(); // ignore: parameter_assignments - for (final entry in json.entries) { - final value = RemoveAssetsDto.fromJson(entry.value); - if (value != null) { - map[entry.key] = value; - } - } - } - return map; - } - - // maps a json object with a list of RemoveAssetsDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; - if (json is Map && json.isNotEmpty) { - // ignore: parameter_assignments - json = json.cast(); - for (final entry in json.entries) { - map[entry.key] = RemoveAssetsDto.listFromJson(entry.value, growable: growable,); - } - } - return map; - } - - /// The list of required keys that must be present in a JSON. - static const requiredKeys = { - 'assetIds', - }; -} - 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 5dacd3943b73d..0000000000000 --- a/mobile/openapi/test/add_assets_response_dto_test.dart +++ /dev/null @@ -1,37 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.12 - -// ignore_for_file: unused_element, unused_import -// ignore_for_file: always_put_required_named_parameters_first -// ignore_for_file: constant_identifier_names -// ignore_for_file: lines_longer_than_80_chars - -import 'package:openapi/api.dart'; -import 'package:test/test.dart'; - -// tests for AddAssetsResponseDto -void main() { - // final instance = AddAssetsResponseDto(); - - group('test AddAssetsResponseDto', () { - // AlbumResponseDto album - test('to test the property `album`', () async { - // TODO - }); - - // List alreadyInAlbum (default value: const []) - test('to test the property `alreadyInAlbum`', () async { - // TODO - }); - - // int successfullyAdded - test('to test the property `successfullyAdded`', () async { - // TODO - }); - - - }); - -} diff --git a/mobile/openapi/test/album_api_test.dart b/mobile/openapi/test/album_api_test.dart index 5c2331fa903e7..085b7f0ff4dda 100644 --- a/mobile/openapi/test/album_api_test.dart +++ b/mobile/openapi/test/album_api_test.dart @@ -17,7 +17,7 @@ void main() { // final instance = AlbumApi(); group('tests for AlbumApi', () { - //Future addAssetsToAlbum(String id, AddAssetsDto addAssetsDto, { String key }) async + //Future> addAssetsToAlbum(String id, BulkIdsDto bulkIdsDto, { String key }) async test('test addAssetsToAlbum', () async { // TODO }); @@ -52,7 +52,7 @@ void main() { // TODO }); - //Future removeAssetFromAlbum(String id, RemoveAssetsDto removeAssetsDto) async + //Future> removeAssetFromAlbum(String id, BulkIdsDto bulkIdsDto) async test('test removeAssetFromAlbum', () async { // TODO }); 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 ec660c4000567..22b11ec8f06d6 100644 --- a/mobile/openapi/test/add_assets_dto_test.dart +++ b/mobile/openapi/test/bulk_ids_dto_test.dart @@ -11,13 +11,13 @@ import 'package:openapi/api.dart'; import 'package:test/test.dart'; -// tests for AddAssetsDto +// tests for BulkIdsDto void main() { - // final instance = AddAssetsDto(); + // final instance = BulkIdsDto(); - group('test AddAssetsDto', () { - // List assetIds (default value: const []) - test('to test the property `assetIds`', () async { + group('test BulkIdsDto', () { + // List ids (default value: const []) + test('to test the property `ids`', () async { // TODO }); 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 4e5b48bcbcf46..0000000000000 --- a/mobile/openapi/test/remove_assets_dto_test.dart +++ /dev/null @@ -1,27 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.12 - -// ignore_for_file: unused_element, unused_import -// ignore_for_file: always_put_required_named_parameters_first -// ignore_for_file: constant_identifier_names -// ignore_for_file: lines_longer_than_80_chars - -import 'package:openapi/api.dart'; -import 'package:test/test.dart'; - -// tests for RemoveAssetsDto -void main() { - // final instance = RemoveAssetsDto(); - - group('test RemoveAssetsDto', () { - // List assetIds (default value: const []) - test('to test the property `assetIds`', () async { - // TODO - }); - - - }); - -} diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 28db8ca291cc2..3fae5cbf42d51 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 7ecaf97e13594..d8156123658e0 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 e9f2b4c368528..811b85ec9adb4 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 d4eeca7fc4a62..b6c6204215a92 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 945516e2a7aab..246a53047a36f 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 81672564af436..9bb6a5b368d37 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 200b7d09702c9..0000000000000 --- 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 ba6f195a244df..0000000000000 --- 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 e241f96359301..0000000000000 --- 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 98edf6c8f2e3f..0000000000000 --- 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 cb433bcbd622e..0000000000000 --- 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 9f66fab3d870b..0000000000000 --- 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 3ff76a8227555..0000000000000 --- 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 a663b0254d561..0000000000000 --- 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 2580d5478e7fd..0000000000000 --- 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 bb32db50248b5..1067485ac0410 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 6da64dd1ee2dd..889a025a4ce04 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 bc3cc8adc212d..850b11f267d4c 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 6762950d3172e..e86d949768d37 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 662b183d31515..8656fa64a1daa 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 453e9ae84e4af..d94ecd5cb28c3 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 2c6fa6232c04d..41cdb7fb0709f 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 094ed09269d52..d41419ffc793a 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 90e2ceff45f13..a205d839270d1 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 1a553e031f0c1..9067cc6e3f485 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 0be85fa89bb73..cd8c87d3125e5 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) => {