From b9cda591725f8e5d68ec2f8f82ac8042e751e5c6 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Tue, 1 Aug 2023 21:29:14 -0400 Subject: [PATCH] refactor(server)!: add/remove album assets (#3109) * refactor: add/remove album assets * chore: open api * feat: remove owned assets from album * refactor: move to bulk id req/res dto * chore: open api * chore: merge main * dev: mobile work * fix: adding asset from web not sync with mobile * remove print statement --------- Co-authored-by: Alex Tran --- cli/src/api/open-api/api.ts | 116 ++---- .../models/add_asset_response.model.dart | 49 +++ .../providers/album_detail.provider.dart | 21 ++ .../providers/shared_album.provider.dart | 18 - .../modules/album/services/album.service.dart | 36 +- .../album/ui/add_to_album_bottom_sheet.dart | 5 +- .../modules/album/ui/album_viewer_appbar.dart | 3 +- .../album/views/album_viewer_page.dart | 19 +- mobile/lib/modules/home/views/home_page.dart | 4 + mobile/openapi/.openapi-generator/FILES | 12 +- mobile/openapi/README.md | Bin 18362 -> 18256 bytes mobile/openapi/doc/AddAssetsResponseDto.md | Bin 562 -> 0 bytes mobile/openapi/doc/AlbumApi.md | Bin 21326 -> 21288 bytes .../doc/{AddAssetsDto.md => BulkIdsDto.md} | Bin 437 -> 430 bytes mobile/openapi/doc/RemoveAssetsDto.md | Bin 440 -> 0 bytes mobile/openapi/lib/api.dart | Bin 5978 -> 5896 bytes mobile/openapi/lib/api/album_api.dart | Bin 16818 -> 16922 bytes mobile/openapi/lib/api_client.dart | Bin 18687 -> 18509 bytes .../lib/model/add_assets_response_dto.dart | Bin 4070 -> 0 bytes ...{add_assets_dto.dart => bulk_ids_dto.dart} | Bin 2845 -> 2744 bytes .../openapi/lib/model/remove_assets_dto.dart | Bin 2899 -> 0 bytes .../test/add_assets_response_dto_test.dart | Bin 849 -> 0 bytes mobile/openapi/test/album_api_test.dart | Bin 1925 -> 1921 bytes ...s_dto_test.dart => bulk_ids_dto_test.dart} | Bin 592 -> 576 bytes .../openapi/test/remove_assets_dto_test.dart | Bin 601 -> 0 bytes server/immich-openapi-specs.json | 80 ++--- server/src/domain/access/access.core.ts | 37 +- server/src/domain/album/album.repository.ts | 2 + server/src/domain/album/album.service.spec.ts | 332 +++++++++++++++++- server/src/domain/album/album.service.ts | 113 +++++- .../response-dto/asset-ids-response.dto.ts | 7 + .../immich/api-v1/album/album-repository.ts | 132 ------- .../immich/api-v1/album/album.controller.ts | 45 --- .../src/immich/api-v1/album/album.module.ts | 13 - .../immich/api-v1/album/album.service.spec.ts | 258 -------------- .../src/immich/api-v1/album/album.service.ts | 72 ---- .../immich/api-v1/album/dto/add-assets.dto.ts | 6 - .../immich/api-v1/album/dto/add-users.dto.ts | 6 - .../api-v1/album/dto/remove-assets.dto.ts | 6 - .../response-dto/add-assets-response.dto.ts | 13 - server/src/immich/app.module.ts | 2 - .../immich/controllers/album.controller.ts | 29 +- .../infra/repositories/album.repository.ts | 70 +++- server/test/fixtures/album.stub.ts | 13 + .../repositories/album.repository.mock.ts | 2 + web/src/api/open-api/api.ts | 120 +++---- .../components/album-page/album-viewer.svelte | 27 +- .../asset-viewer/asset-viewer.svelte | 7 +- .../photos-page/actions/add-to-album.svelte | 5 +- .../actions/remove-from-album.svelte | 14 +- web/src/lib/utils/asset-utils.ts | 13 +- 51 files changed, 819 insertions(+), 888 deletions(-) create mode 100644 mobile/lib/modules/album/models/add_asset_response.model.dart create mode 100644 mobile/lib/modules/album/providers/album_detail.provider.dart delete mode 100644 mobile/openapi/doc/AddAssetsResponseDto.md rename mobile/openapi/doc/{AddAssetsDto.md => BulkIdsDto.md} (79%) delete mode 100644 mobile/openapi/doc/RemoveAssetsDto.md delete mode 100644 mobile/openapi/lib/model/add_assets_response_dto.dart rename mobile/openapi/lib/model/{add_assets_dto.dart => bulk_ids_dto.dart} (55%) delete mode 100644 mobile/openapi/lib/model/remove_assets_dto.dart delete mode 100644 mobile/openapi/test/add_assets_response_dto_test.dart rename mobile/openapi/test/{add_assets_dto_test.dart => bulk_ids_dto_test.dart} (64%) delete mode 100644 mobile/openapi/test/remove_assets_dto_test.dart delete mode 100644 server/src/immich/api-v1/album/album-repository.ts delete mode 100644 server/src/immich/api-v1/album/album.controller.ts delete mode 100644 server/src/immich/api-v1/album/album.module.ts delete mode 100644 server/src/immich/api-v1/album/album.service.spec.ts delete mode 100644 server/src/immich/api-v1/album/album.service.ts delete mode 100644 server/src/immich/api-v1/album/dto/add-assets.dto.ts delete mode 100644 server/src/immich/api-v1/album/dto/add-users.dto.ts delete mode 100644 server/src/immich/api-v1/album/dto/remove-assets.dto.ts delete mode 100644 server/src/immich/api-v1/album/response-dto/add-assets-response.dto.ts diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index 6d10c4eb2a..f69d1dbb91 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -99,44 +99,6 @@ export interface APIKeyUpdateDto { */ 'name': string; } -/** - * - * @export - * @interface AddAssetsDto - */ -export interface AddAssetsDto { - /** - * - * @type {Array} - * @memberof AddAssetsDto - */ - 'assetIds': Array; -} -/** - * - * @export - * @interface AddAssetsResponseDto - */ -export interface AddAssetsResponseDto { - /** - * - * @type {AlbumResponseDto} - * @memberof AddAssetsResponseDto - */ - 'album'?: AlbumResponseDto; - /** - * - * @type {Array} - * @memberof AddAssetsResponseDto - */ - 'alreadyInAlbum': Array; - /** - * - * @type {number} - * @memberof AddAssetsResponseDto - */ - 'successfullyAdded': number; -} /** * * @export @@ -821,6 +783,19 @@ export const BulkIdResponseDtoErrorEnum = { export type BulkIdResponseDtoErrorEnum = typeof BulkIdResponseDtoErrorEnum[keyof typeof BulkIdResponseDtoErrorEnum]; +/** + * + * @export + * @interface BulkIdsDto + */ +export interface BulkIdsDto { + /** + * + * @type {Array} + * @memberof BulkIdsDto + */ + 'ids': Array; +} /** * * @export @@ -1927,19 +1902,6 @@ export interface QueueStatusDto { */ 'isPaused': boolean; } -/** - * - * @export - * @interface RemoveAssetsDto - */ -export interface RemoveAssetsDto { - /** - * - * @type {Array} - * @memberof RemoveAssetsDto - */ - 'assetIds': Array; -} /** * * @export @@ -3678,16 +3640,16 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration /** * * @param {string} id - * @param {AddAssetsDto} addAssetsDto + * @param {BulkIdsDto} bulkIdsDto * @param {string} [key] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - addAssetsToAlbum: async (id: string, addAssetsDto: AddAssetsDto, key?: string, options: AxiosRequestConfig = {}): Promise => { + addAssetsToAlbum: async (id: string, bulkIdsDto: BulkIdsDto, key?: string, options: AxiosRequestConfig = {}): Promise => { // verify required parameter 'id' is not null or undefined assertParamExists('addAssetsToAlbum', 'id', id) - // verify required parameter 'addAssetsDto' is not null or undefined - assertParamExists('addAssetsToAlbum', 'addAssetsDto', addAssetsDto) + // verify required parameter 'bulkIdsDto' is not null or undefined + assertParamExists('addAssetsToAlbum', 'bulkIdsDto', bulkIdsDto) const localVarPath = `/album/{id}/assets` .replace(`{${"id"}}`, encodeURIComponent(String(id))); // use dummy base URL string because the URL constructor only accepts absolute URLs. @@ -3721,7 +3683,7 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - localVarRequestOptions.data = serializeDataIfNeeded(addAssetsDto, localVarRequestOptions, configuration) + localVarRequestOptions.data = serializeDataIfNeeded(bulkIdsDto, localVarRequestOptions, configuration) return { url: toPathString(localVarUrlObj), @@ -3998,15 +3960,15 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration /** * * @param {string} id - * @param {RemoveAssetsDto} removeAssetsDto + * @param {BulkIdsDto} bulkIdsDto * @param {*} [options] Override http request option. * @throws {RequiredError} */ - removeAssetFromAlbum: async (id: string, removeAssetsDto: RemoveAssetsDto, options: AxiosRequestConfig = {}): Promise => { + removeAssetFromAlbum: async (id: string, bulkIdsDto: BulkIdsDto, options: AxiosRequestConfig = {}): Promise => { // verify required parameter 'id' is not null or undefined assertParamExists('removeAssetFromAlbum', 'id', id) - // verify required parameter 'removeAssetsDto' is not null or undefined - assertParamExists('removeAssetFromAlbum', 'removeAssetsDto', removeAssetsDto) + // verify required parameter 'bulkIdsDto' is not null or undefined + assertParamExists('removeAssetFromAlbum', 'bulkIdsDto', bulkIdsDto) const localVarPath = `/album/{id}/assets` .replace(`{${"id"}}`, encodeURIComponent(String(id))); // use dummy base URL string because the URL constructor only accepts absolute URLs. @@ -4036,7 +3998,7 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - localVarRequestOptions.data = serializeDataIfNeeded(removeAssetsDto, localVarRequestOptions, configuration) + localVarRequestOptions.data = serializeDataIfNeeded(bulkIdsDto, localVarRequestOptions, configuration) return { url: toPathString(localVarUrlObj), @@ -4150,13 +4112,13 @@ export const AlbumApiFp = function(configuration?: Configuration) { /** * * @param {string} id - * @param {AddAssetsDto} addAssetsDto + * @param {BulkIdsDto} bulkIdsDto * @param {string} [key] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async addAssetsToAlbum(id: string, addAssetsDto: AddAssetsDto, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.addAssetsToAlbum(id, addAssetsDto, key, options); + async addAssetsToAlbum(id: string, bulkIdsDto: BulkIdsDto, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.addAssetsToAlbum(id, bulkIdsDto, key, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** @@ -4224,12 +4186,12 @@ export const AlbumApiFp = function(configuration?: Configuration) { /** * * @param {string} id - * @param {RemoveAssetsDto} removeAssetsDto + * @param {BulkIdsDto} bulkIdsDto * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async removeAssetFromAlbum(id: string, removeAssetsDto: RemoveAssetsDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.removeAssetFromAlbum(id, removeAssetsDto, options); + async removeAssetFromAlbum(id: string, bulkIdsDto: BulkIdsDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.removeAssetFromAlbum(id, bulkIdsDto, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** @@ -4270,8 +4232,8 @@ export const AlbumApiFactory = function (configuration?: Configuration, basePath * @param {*} [options] Override http request option. * @throws {RequiredError} */ - addAssetsToAlbum(requestParameters: AlbumApiAddAssetsToAlbumRequest, options?: AxiosRequestConfig): AxiosPromise { - return localVarFp.addAssetsToAlbum(requestParameters.id, requestParameters.addAssetsDto, requestParameters.key, options).then((request) => request(axios, basePath)); + addAssetsToAlbum(requestParameters: AlbumApiAddAssetsToAlbumRequest, options?: AxiosRequestConfig): AxiosPromise> { + return localVarFp.addAssetsToAlbum(requestParameters.id, requestParameters.bulkIdsDto, requestParameters.key, options).then((request) => request(axios, basePath)); }, /** * @@ -4332,8 +4294,8 @@ export const AlbumApiFactory = function (configuration?: Configuration, basePath * @param {*} [options] Override http request option. * @throws {RequiredError} */ - removeAssetFromAlbum(requestParameters: AlbumApiRemoveAssetFromAlbumRequest, options?: AxiosRequestConfig): AxiosPromise { - return localVarFp.removeAssetFromAlbum(requestParameters.id, requestParameters.removeAssetsDto, options).then((request) => request(axios, basePath)); + removeAssetFromAlbum(requestParameters: AlbumApiRemoveAssetFromAlbumRequest, options?: AxiosRequestConfig): AxiosPromise> { + return localVarFp.removeAssetFromAlbum(requestParameters.id, requestParameters.bulkIdsDto, options).then((request) => request(axios, basePath)); }, /** * @@ -4371,10 +4333,10 @@ export interface AlbumApiAddAssetsToAlbumRequest { /** * - * @type {AddAssetsDto} + * @type {BulkIdsDto} * @memberof AlbumApiAddAssetsToAlbum */ - readonly addAssetsDto: AddAssetsDto + readonly bulkIdsDto: BulkIdsDto /** * @@ -4490,10 +4452,10 @@ export interface AlbumApiRemoveAssetFromAlbumRequest { /** * - * @type {RemoveAssetsDto} + * @type {BulkIdsDto} * @memberof AlbumApiRemoveAssetFromAlbum */ - readonly removeAssetsDto: RemoveAssetsDto + readonly bulkIdsDto: BulkIdsDto } /** @@ -4553,7 +4515,7 @@ export class AlbumApi extends BaseAPI { * @memberof AlbumApi */ public addAssetsToAlbum(requestParameters: AlbumApiAddAssetsToAlbumRequest, options?: AxiosRequestConfig) { - return AlbumApiFp(this.configuration).addAssetsToAlbum(requestParameters.id, requestParameters.addAssetsDto, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); + return AlbumApiFp(this.configuration).addAssetsToAlbum(requestParameters.id, requestParameters.bulkIdsDto, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); } /** @@ -4629,7 +4591,7 @@ export class AlbumApi extends BaseAPI { * @memberof AlbumApi */ public removeAssetFromAlbum(requestParameters: AlbumApiRemoveAssetFromAlbumRequest, options?: AxiosRequestConfig) { - return AlbumApiFp(this.configuration).removeAssetFromAlbum(requestParameters.id, requestParameters.removeAssetsDto, options).then((request) => request(this.axios, this.basePath)); + return AlbumApiFp(this.configuration).removeAssetFromAlbum(requestParameters.id, requestParameters.bulkIdsDto, options).then((request) => request(this.axios, this.basePath)); } /** diff --git a/mobile/lib/modules/album/models/add_asset_response.model.dart b/mobile/lib/modules/album/models/add_asset_response.model.dart new file mode 100644 index 0000000000..11efd36f84 --- /dev/null +++ b/mobile/lib/modules/album/models/add_asset_response.model.dart @@ -0,0 +1,49 @@ +// ignore_for_file: public_member_api_docs, sort_constructors_first +import 'dart:convert'; + +import 'package:collection/collection.dart'; + +class AddAssetsResponse { + List alreadyInAlbum; + int successfullyAdded; + + AddAssetsResponse({ + required this.alreadyInAlbum, + required this.successfullyAdded, + }); + + AddAssetsResponse copyWith({ + List? alreadyInAlbum, + int? successfullyAdded, + }) { + return AddAssetsResponse( + alreadyInAlbum: alreadyInAlbum ?? this.alreadyInAlbum, + successfullyAdded: successfullyAdded ?? this.successfullyAdded, + ); + } + + Map toMap() { + return { + 'alreadyInAlbum': alreadyInAlbum, + 'successfullyAdded': successfullyAdded, + }; + } + + String toJson() => json.encode(toMap()); + + @override + String toString() => + 'AddAssetsResponse(alreadyInAlbum: $alreadyInAlbum, successfullyAdded: $successfullyAdded)'; + + @override + bool operator ==(covariant AddAssetsResponse other) { + if (identical(this, other)) return true; + final listEquals = const DeepCollectionEquality().equals; + + return listEquals(other.alreadyInAlbum, alreadyInAlbum) && + other.successfullyAdded == successfullyAdded; + } + + @override + int get hashCode => alreadyInAlbum.hashCode ^ successfullyAdded.hashCode; +} diff --git a/mobile/lib/modules/album/providers/album_detail.provider.dart b/mobile/lib/modules/album/providers/album_detail.provider.dart new file mode 100644 index 0000000000..531c5c944a --- /dev/null +++ b/mobile/lib/modules/album/providers/album_detail.provider.dart @@ -0,0 +1,21 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/album/services/album.service.dart'; +import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart'; +import 'package:immich_mobile/shared/models/album.dart'; +import 'package:immich_mobile/shared/providers/user.provider.dart'; + +final albumDetailProvider = + StreamProvider.family((ref, albumId) async* { + final user = ref.watch(currentUserProvider); + if (user == null) return; + final AlbumService service = ref.watch(albumServiceProvider); + + await for (final a in service.watchAlbum(albumId)) { + if (a == null) { + throw Exception("Album with ID=$albumId does not exist anymore!"); + } + await for (final _ in a.watchRenderList(GroupAssetsBy.none)) { + yield a; + } + } +}); diff --git a/mobile/lib/modules/album/providers/shared_album.provider.dart b/mobile/lib/modules/album/providers/shared_album.provider.dart index a6fd8db23e..8b342dd45f 100644 --- a/mobile/lib/modules/album/providers/shared_album.provider.dart +++ b/mobile/lib/modules/album/providers/shared_album.provider.dart @@ -3,12 +3,10 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/album/services/album.service.dart'; -import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/user.dart'; import 'package:immich_mobile/shared/providers/db.provider.dart'; -import 'package:immich_mobile/shared/providers/user.provider.dart'; import 'package:isar/isar.dart'; class SharedAlbumNotifier extends StateNotifier> { @@ -72,19 +70,3 @@ final sharedAlbumProvider = ref.watch(dbProvider), ); }); - -final sharedAlbumDetailProvider = - StreamProvider.family((ref, albumId) async* { - final user = ref.watch(currentUserProvider); - if (user == null) return; - final AlbumService sharedAlbumService = ref.watch(albumServiceProvider); - - await for (final a in sharedAlbumService.watchAlbum(albumId)) { - if (a == null) { - throw Exception("Album with ID=$albumId does not exist anymore!"); - } - await for (final _ in a.watchRenderList(GroupAssetsBy.none)) { - yield a; - } - } -}); diff --git a/mobile/lib/modules/album/services/album.service.dart b/mobile/lib/modules/album/services/album.service.dart index 2e45d87c8d..0960978a47 100644 --- a/mobile/lib/modules/album/services/album.service.dart +++ b/mobile/lib/modules/album/services/album.service.dart @@ -5,6 +5,7 @@ import 'dart:io'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/modules/album/models/add_asset_response.model.dart'; import 'package:immich_mobile/modules/backup/models/backup_album.model.dart'; import 'package:immich_mobile/modules/backup/services/backup.service.dart'; import 'package:immich_mobile/shared/models/album.dart'; @@ -219,24 +220,43 @@ class AlbumService { yield* _db.albums.watchObject(albumId); } - Future addAdditionalAssetToAlbum( + Future addAdditionalAssetToAlbum( Iterable assets, Album album, ) async { try { - var result = await _apiService.albumApi.addAssetsToAlbum( + var response = await _apiService.albumApi.addAssetsToAlbum( album.remoteId!, - AddAssetsDto(assetIds: assets.map((asset) => asset.remoteId!).toList()), + BulkIdsDto(ids: assets.map((asset) => asset.remoteId!).toList()), ); - if (result != null && result.successfullyAdded > 0) { - album.assets.addAll(assets); + + if (response != null) { + List successAssets = []; + List duplicatedAssets = []; + + for (final result in response) { + if (result.success) { + successAssets + .add(assets.firstWhere((asset) => asset.remoteId == result.id)); + } else if (!result.success && + result.error == BulkIdResponseDtoErrorEnum.duplicate) { + duplicatedAssets.add(result.id); + } + } + + album.assets.addAll(successAssets); await _db.writeTxn(() => album.assets.save()); + + return AddAssetsResponse( + alreadyInAlbum: duplicatedAssets, + successfullyAdded: successAssets.length, + ); } - return result; } catch (e) { debugPrint("Error addAdditionalAssetToAlbum ${e.toString()}"); return null; } + return null; } Future addAdditionalUserToAlbum( @@ -314,8 +334,8 @@ class AlbumService { try { await _apiService.albumApi.removeAssetFromAlbum( album.remoteId!, - RemoveAssetsDto( - assetIds: assets.map((e) => e.remoteId!).toList(growable: false), + BulkIdsDto( + ids: assets.map((asset) => asset.remoteId!).toList(), ), ); album.assets.removeAll(assets); diff --git a/mobile/lib/modules/album/ui/add_to_album_bottom_sheet.dart b/mobile/lib/modules/album/ui/add_to_album_bottom_sheet.dart index 3abe0d8078..257dbdbaa2 100644 --- a/mobile/lib/modules/album/ui/add_to_album_bottom_sheet.dart +++ b/mobile/lib/modules/album/ui/add_to_album_bottom_sheet.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/album/providers/album.provider.dart'; +import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart'; import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart'; import 'package:immich_mobile/modules/album/services/album.service.dart'; import 'package:immich_mobile/modules/album/ui/add_to_album_sliverlist.dart'; @@ -63,9 +64,7 @@ class AddToAlbumBottomSheet extends HookConsumerWidget { } } - ref.read(albumProvider.notifier).getAllAlbums(); - ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums(); - + ref.invalidate(albumDetailProvider(album.id)); Navigator.pop(context); } diff --git a/mobile/lib/modules/album/ui/album_viewer_appbar.dart b/mobile/lib/modules/album/ui/album_viewer_appbar.dart index f74118b3d9..3392ed5183 100644 --- a/mobile/lib/modules/album/ui/album_viewer_appbar.dart +++ b/mobile/lib/modules/album/ui/album_viewer_appbar.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/album/providers/album.provider.dart'; +import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart'; import 'package:immich_mobile/modules/album/providers/album_viewer.provider.dart'; import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart'; import 'package:immich_mobile/routing/router.dart'; @@ -99,7 +100,7 @@ class AlbumViewerAppbar extends HookConsumerWidget Navigator.pop(context); selectionDisabled(); ref.watch(albumProvider.notifier).getAllAlbums(); - ref.invalidate(sharedAlbumDetailProvider(album.id)); + ref.invalidate(albumDetailProvider(album.id)); } else { Navigator.pop(context); ImmichToast.show( diff --git a/mobile/lib/modules/album/views/album_viewer_page.dart b/mobile/lib/modules/album/views/album_viewer_page.dart index 9363c7f9bd..31c48c4503 100644 --- a/mobile/lib/modules/album/views/album_viewer_page.dart +++ b/mobile/lib/modules/album/views/album_viewer_page.dart @@ -6,13 +6,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/album/models/asset_selection_page_result.model.dart'; -import 'package:immich_mobile/modules/album/providers/album.provider.dart'; +import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart'; import 'package:immich_mobile/modules/album/services/album.service.dart'; import 'package:immich_mobile/modules/album/ui/album_action_outlined_button.dart'; import 'package:immich_mobile/modules/album/ui/album_viewer_editable_title.dart'; import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; -import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart'; import 'package:immich_mobile/modules/album/ui/album_viewer_appbar.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/shared/models/album.dart'; @@ -28,11 +27,20 @@ class AlbumViewerPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { FocusNode titleFocusNode = useFocusNode(); - final album = ref.watch(sharedAlbumDetailProvider(albumId)); + final album = ref.watch(albumDetailProvider(albumId)); final userId = ref.watch(authenticationProvider).userId; final selection = useState>({}); final multiSelectEnabled = useState(false); + useEffect( + () { + // Fetch album updates, e.g., cover image + ref.invalidate(albumDetailProvider(albumId)); + return null; + }, + [], + ); + Future onWillPop() async { if (multiSelectEnabled.value) { selection.value = {}; @@ -77,8 +85,7 @@ class AlbumViewerPage extends HookConsumerWidget { if (addAssetsResult != null && addAssetsResult.successfullyAdded > 0) { - ref.watch(albumProvider.notifier).getAllAlbums(); - ref.invalidate(sharedAlbumDetailProvider(albumId)); + ref.invalidate(albumDetailProvider(albumId)); } ImmichLoadingOverlayController.appLoader.hide(); @@ -100,7 +107,7 @@ class AlbumViewerPage extends HookConsumerWidget { .addAdditionalUserToAlbum(sharedUserIds, album); if (isSuccess) { - ref.invalidate(sharedAlbumDetailProvider(album.id)); + ref.invalidate(albumDetailProvider(album.id)); } ImmichLoadingOverlayController.appLoader.hide(); diff --git a/mobile/lib/modules/home/views/home_page.dart b/mobile/lib/modules/home/views/home_page.dart index 3dc3a41beb..715dcc47a5 100644 --- a/mobile/lib/modules/home/views/home_page.dart +++ b/mobile/lib/modules/home/views/home_page.dart @@ -8,6 +8,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/modules/album/providers/album.provider.dart'; +import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart'; import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart'; import 'package:immich_mobile/modules/album/services/album.service.dart'; import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart'; @@ -208,6 +209,9 @@ class HomePage extends HookConsumerWidget { ), toastType: ToastType.success, ); + + ref.watch(albumProvider.notifier).getAllAlbums(); + ref.invalidate(albumDetailProvider(album.id)); } } } finally { diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index ad445090a2..1a97eef9f8 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -8,8 +8,6 @@ doc/APIKeyCreateDto.md doc/APIKeyCreateResponseDto.md doc/APIKeyResponseDto.md doc/APIKeyUpdateDto.md -doc/AddAssetsDto.md -doc/AddAssetsResponseDto.md doc/AddUsersDto.md doc/AdminSignupResponseDto.md doc/AlbumApi.md @@ -33,6 +31,7 @@ doc/AudioCodec.md doc/AuthDeviceResponseDto.md doc/AuthenticationApi.md doc/BulkIdResponseDto.md +doc/BulkIdsDto.md doc/ChangePasswordDto.md doc/CheckDuplicateAssetDto.md doc/CheckDuplicateAssetResponseDto.md @@ -78,7 +77,6 @@ doc/PersonApi.md doc/PersonResponseDto.md doc/PersonUpdateDto.md doc/QueueStatusDto.md -doc/RemoveAssetsDto.md doc/SearchAlbumResponseDto.md doc/SearchApi.md doc/SearchAssetDto.md @@ -150,8 +148,6 @@ lib/auth/authentication.dart lib/auth/http_basic_auth.dart lib/auth/http_bearer_auth.dart lib/auth/oauth.dart -lib/model/add_assets_dto.dart -lib/model/add_assets_response_dto.dart lib/model/add_users_dto.dart lib/model/admin_signup_response_dto.dart lib/model/album_count_response_dto.dart @@ -176,6 +172,7 @@ lib/model/asset_type_enum.dart lib/model/audio_codec.dart lib/model/auth_device_response_dto.dart lib/model/bulk_id_response_dto.dart +lib/model/bulk_ids_dto.dart lib/model/change_password_dto.dart lib/model/check_duplicate_asset_dto.dart lib/model/check_duplicate_asset_response_dto.dart @@ -217,7 +214,6 @@ lib/model/people_update_item.dart lib/model/person_response_dto.dart lib/model/person_update_dto.dart lib/model/queue_status_dto.dart -lib/model/remove_assets_dto.dart lib/model/search_album_response_dto.dart lib/model/search_asset_dto.dart lib/model/search_asset_response_dto.dart @@ -260,8 +256,6 @@ lib/model/user_response_dto.dart lib/model/validate_access_token_response_dto.dart lib/model/video_codec.dart pubspec.yaml -test/add_assets_dto_test.dart -test/add_assets_response_dto_test.dart test/add_users_dto_test.dart test/admin_signup_response_dto_test.dart test/album_api_test.dart @@ -290,6 +284,7 @@ test/audio_codec_test.dart test/auth_device_response_dto_test.dart test/authentication_api_test.dart test/bulk_id_response_dto_test.dart +test/bulk_ids_dto_test.dart test/change_password_dto_test.dart test/check_duplicate_asset_dto_test.dart test/check_duplicate_asset_response_dto_test.dart @@ -335,7 +330,6 @@ test/person_api_test.dart test/person_response_dto_test.dart test/person_update_dto_test.dart test/queue_status_dto_test.dart -test/remove_assets_dto_test.dart test/search_album_response_dto_test.dart test/search_api_test.dart test/search_asset_dto_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 38780bd67a98fa1123f925a0b911891ecba5a250..5e9cee604063ba08bc0a414c3edbdfabbf01bfe9 100644 GIT binary patch delta 49 zcmdnh&v>Daal=}J&Af)0ED}zoIoX~m#V#fJu^K7)$@=B6UeaDm(xR73MiYAwWu0k|WaI&qL1P4rV l@_a+F&8rQvSvFgldT=NPrRL_BrNXsAO+pggTxxrp699uCDaZf- diff --git a/mobile/openapi/doc/AddAssetsResponseDto.md b/mobile/openapi/doc/AddAssetsResponseDto.md deleted file mode 100644 index 3a8d747ea93090051d7cfbb8f7778014b6760675..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 562 zcma)3!D<3A5WVLs2KJx^cD=Wyutg7oirC(k1=G!FLwA#q%pQb(d?%@;)=Qg9n3wn7 zOx_gWJX+;VH8_oZRcl?xh!po2y|WP)o za>4H|uJh!a2$TCn%}%?(kAVzEu~h@Y2fUnpx)vC_U_2RTx!O8nMtOA@ysFCbuUbf{ z`qy!gl9`Q^bt#&nue+*-^^1iS+PH!PwJdhLTL< zQH?zTIp{b*f~IZRAdX#!F&@XhKXb^aFCNCyw^WFxdE#7?bd$CLJvV6EX;sbl%X+a{ u@^poE-8i5nm2xVNE|hsAaRxu?dbj$gmiJSl)!y+Ozbw8Q-ZY;HAwB{2RI$DQ diff --git a/mobile/openapi/doc/AlbumApi.md b/mobile/openapi/doc/AlbumApi.md index 47c418096854ef806172efe867a3313b99429ab9..b12e68c8ef993b9f4e7f618f78293b85428ff415 100644 GIT binary patch delta 398 zcmX@NjB&*>#tr|OjeRnUOKhAMhM^7D#QT}txp6cSTX9E*!nONvAC9dnXO zb2TzkbQF@n>NXp)WU;6~*u_A_3bqOm13(-NO=|^xeW<|Xqa0amP)+8X9E@rRO~c+ol2ye?FIEG ln>$EyA>2CIfiqz8dWX&G2qApVFP>Z(s6?#$3!U%s0|2`oi%kFk delta 416 zcmZ3njPcwu#tr|OtsPTR9E*!nONxV1iwpAeic?)m@)Z(cf+6{iIZ36t8ks3N2tl9< z9fj=F%FT_=i7Z-hwIGeQ3UKp)Tn$ZY1$}+E(BuOgSsXCsn+-TQ7r(Ss}k5H7~IsQ!h6^B{fISF(t*ZxHz?BqS$`6L=eMs;LQ4IGg_OGNur^7` zES7R(6W>2^wEpxkQJHNG9`$)-V1j{sVraWgh_-#qD;9EFI)O1;yY8m07l+ld*{*75 rDnrBBp=Vvbtd11bR8E@1w|lu?|I?eRQs_+}>-k5-*T7%H=R$~29}S5I diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 36415d353ae9746d6bccf935c625e7fcd1dd4b61..5a9a1db16396e2ee210643d4834ad1f8059a420f 100644 GIT binary patch delta 34 qcmcbm*P*vTg>`cwYZ5biQfW?hd}hkzLQd(;S2@bqHW%=}Vg~@%>|w$r`%)(R+h;*oa&nuSkss{FXO0SV=GF{%`Z!xY{w}-SwVn(vnu~% Fb^v&b4#WTe diff --git a/mobile/openapi/lib/api/album_api.dart b/mobile/openapi/lib/api/album_api.dart index 1f5bd7b58ed238923fb877f3c7e53aeb4b9d5ce1..39b44e9becf08f0c7b06d77ab52de5bd06c531f1 100644 GIT binary patch delta 397 zcmdng%s8usaf3aRkW*<+wr5JQOG$pLLK2ug`5vp;- zPJW?V#RkzlIbBZ;#+;-VK6w|Tz~&OpzpB{m?bQ#!Vz!{MFq-2x?=iTD)#1JRQW7{E mZa;awzS!hfMm$(;@aKjE3n)x=HeY1BhuaHToB7O^a038dwUHYD delta 455 zcmbQ$!nmoKaf3aRm}5$cV{vh6NwG^weylr3drge4R&%P@i#v(xX4JH4QqvkCkGjeOy)M`Az*{>-Pfk=;-E5$2&NtcLgl+SFLkDpH D{6P{Y delta 90 zcmX>*f${%D#tr(?lNnWH*&S0-9E*!nCx6sZWd^b*C#t9-#1!;4>q 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 a5b293e1fcad05eb24a0abfc17bbe2448eb6bab0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4070 zcmbVPU2hXP6n)RHxJ8I0+973mS_P6)(XuKF%?i3Jt)gn=%y^oCi5+ZDqp0-1@45C& zGGPYjE)r>C-|usLuYG*nJ3gkXoS;&=CE~9rBLy2 z&*Ee$jP3Q{&XLYYucZ>}e1!imdcC9&*3xO3o?0uN{UGfc9_5+Sbk`#~MiD+pR~ltW zkdhmkcgbE;?!!#v2ufWN1GYxwmbo2?Vo|Q5=5HfKy1h`Ryg?K4Nz?o~$kb&E%C<}r zX>C>(#TJfa%0l-;q~fCupY&%Tg_WeV187%pN^66}joXr4>as{_DQ+b#q*M@6PZi9G zMIn#qW3CbjSCZ^nCMb9!T&@){{+01mOUtH_bW)V5L}gN$iL!8XD~eLmptQNV@kSn1 zJL5;Y2!;_Y^+w)G6M2yvsiYB3rj>cCOM?&+4a=+2I?6;*&_X1i38)}$whb_R{#2Rx2ykXf3u<<$~Nu2Q=;SH3L1F`UrPD zhg*%3%yeP4+$wPTm(u3eiw%6~*%h;ZPUsnpLpDdF+T@Uasb!n-xA$Ao?nPR02aG|J z@MYjp>37w9?n9hB_J5*pNWsg^prUrkCXZWjUH5%F@5Q>}T14p}*o?U}5Ubur)_3UE& zpaPh}NgTI2=7{U%<78s)i8Ty!j?Et)i}9XMO;P}KWO9L<1COkoSJpqF!Or$KDq^oP zY^FIK3`b6L2Or!Ryy7RN8&JQQqCm7w=TnJ1s z((({T$LMr0zYZ}nq1Y^=z~Nwmc%VkPz0~e}wRYRuE;xo!HmJ<&4Ub+{-S5{d3=Exg zGJH=Q08|xSvTfHCoXjr zLU|z7Y}TEonw`6b0RDf7fR~#DguZvY(ZlvlslJ!TxEkXHz&HIcb`Q|Rs@c|fj`&wD zZJiO9KV+M_e&8qv@B@&c+87565Vve!DIG3do7^q2 z#Ak|M4P9tF4WiJjCIVj{aTn%?1}x$vS7-KpV`6{*{7zYkw>!D=&@&7hO&0yY-T&dz z18MRQ%2my^(I0-wDCBksco!-ohHO{D2C*WlkMMZ+E!wdykmaBgz+HnR$9PA{9Upd& zGEmRrUL*iHzf~5Jhq{GZJ{p4Few|Cwcrw}m 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 dd8ca476940717aa8ca83645f8a9453ccfd1ce39..c1cbb2cb341d56177edbab4ee4c65c057ebeab96 100644 GIT binary patch delta 361 zcmbO$wnKD-IwPA?X->9h%4B^;8GQ(|*rg;tRv|O5xFj(zIaQBKK>;DIQ4QoOD3oMm z7VBkBZe~p|ld&K|Z8T8U zRwZ-t9i|vQAX7b7!4_t80JA>KW$nx}U`%zE3M8vEAQl1L1JrD#kX4+Y7hR;DIr$8W zI?$@0EG;l?t*n7C<~`Od7&Cxv9gNAwZVh94vhRm61vu8jn8!KnU`$@lcy5Fj>?ZrN an@(QG*$j?^ zvZT08S_8HvYB-m3&I~mk55{A-_~&l^^6%Nr?Dpbrb_rM4A7&w3&f#W$2OsB`*H{0( zKr^y@lQCuDSJ5A@26QXdrBpmwNtLWb!6#6amF8K(3%=o{3H`m;Y^5?Mcd%l|_Bz>C zCQlt*aadl--p0Fw(HVF+%nGYv7=dSI~(@#$fN2>^T$+Whs&C{eDb;7#q5 zH<*-CSUFnVOIc9viW#XOj)VFAXP#n3Cg*Ad@e~|FDJudB8u};SzPT$9kLx@;vq?IG zR+{nm^AM$hH8+qmoxhbC$H>-4qZSfL$y2U1Q+pyAdCR#L+L{Hvz{oprAhb($Sb&l4 zBCypBd-yKAqxd&}wH#X1KC*2MX9(FIwI5r#`N?@;OtmDP#0riBC1`ZM|gR%YOuNP{pMzOtg?8dgefYF7)pgpymK!H^`m zz(SWHlvRPkAQkL{wn}VKu~mG9tzcEBsuZQ86B1Q|O=U!yVE^SZBWm12U&XI&3KqH= zN|&0)5wi(wVx%t2$FBo)5Vj^!${L{`nF$>K)GIZLJ)#3l&V4)!lM}RH_-h;`?7pC8 zIeuP4KrDATWJlvsJF96hij1Uw4Ax5S$k_x|tk8VaaXRU=F$KdS=pF;-Mh!K* zw%2td9n6aBsxTzwzVF4-Wky7iN8*W+fU`4; zVkGp9G_%dt?7d6m4kt`ddzL$#a@O?t+_USLkNcvDNVc{X1hGbj&^e0bMQml;_DJ+o z5fRHWC#lv>hpWf8gL1v=5p5na>%}%?O+kA+^wEM1+nyxCXX-#ftM=IFd-3C=jX6gB zA>@{u*irJmKEaVvpU4wv4$lO;0*`4rj@^Xq(1)vP?+J|PPH^puAx>ODPZDYUMAocD z%M*kmObfU13*Xb^wELYNDfSOo#jQ>2VAXg_6VHMq-MP0DCdVae9Xi!}9K3W^xT&6{ zcmSX^z1@&AY||5JIN6+2vZp5}b@AK=MIUX&oR0J>x_PD~o+Gb%#I>6LN%de^U50x! zL_|*mTuVeD`2L(US7DfNiD-~68THtzz{2aCq>;J5E<=K~xh{{wBOsLB8U 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 5dacd3943b73d860db6c8c63b9031efb52336133..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 849 zcmb7COKaRP5Wf3YOi#ND+qliCBu$CIo3fC`kZunp6i2bfUQt<6HByQx`R^TR8wkXZ z^q|Lx?=_OjvM5WazU`W~pEp;V>uR^D;bMEaSwr2xRkMRHO})MN{)S-*c`k(yXU9kH zkBd~Q!B~%NZ++V`9MOdJW35JKeVAnk{aZ}bx)@rIzoPO?H)7=33D0Xl zPb}_fp59qQL731g$%d*|=rf&W&9pK|t+r-hSlA2Get6e*d*P`l4jEzLME6q+cw!wI z;Z(`vD0RI!eFh0gp-j^wAep9`fUg89DJvojbdBUVC)U9h_f|<5g)++({3!sK(JLb~ zsN6`0a5hU^u1*Vv58g(%TqKiO*kTR;gtK&|@8bBx9z;4|9mqLZ$mR`%J%aPxvkw#8 zX4LILOVDJx95t~x?yjkutnhG%ln*Zzg^yo@S7!LcCLV>HwkF%dQerQn4sb7YI|CIH2*L!v%vJ@I3pe9`b3<8_{_n=j*`_zN=)5_bRq diff --git a/mobile/openapi/test/album_api_test.dart b/mobile/openapi/test/album_api_test.dart index 5c2331fa903e74788014ff6827074c541a0ce556..085b7f0ff4dda0fb9b5fdcbd986c8acd05ba6048 100644 GIT binary patch delta 114 zcmZqWZ{*)_l2P0zv$({@sWd0sGbJdsxFA2TIMt;j-){0F(svr}Tnwwvi3NZqtvIvPkIe=Me@_aV7%`$AMi~tvgC&2&! 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 ec660c40005672f54086748b9e8bfc869501936f..22b11ec8f06d67c73e96b020b36514ca66e48deb 100644 GIT binary patch delta 70 zcmcb>a)4z+EF+s!X->9h%H&i=O=SqP*rg<2L(`gzOF<#MD8IBoL%k%mxI_V}ZY!e# PW9H=BjJ7~hj>!lBDjpU` delta 86 zcmX@Wa)D(-EF*_wN{VA~acarrWJXPO7^m2!Bws_*nu|+8A-yQSv_M0>B(=Ci0VX|p VJ);6!B1o6#@h4SCgAQwxmOXw>PED%R+HlKjjFgAR)~tm({lWU$kzyh+%4 zF#QK%@|=dS0|e91dytdB1GRQTGw+Fg#x~hlT|$f63`g*p00_9!1{=_Ehl1eZFK0NK zCW5S;?YrS1NdH9)#_%f_1#h+I^G9~t)R%z4dQo~9mQ-QiFl?~t<(uNw;N^8B+7Kre qaT^QZH9_ZOd&gT??Icz|KIpfmh{Fe>EU?V8Jc@jUNu`SUEBOY_AI4w+ diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 28db8ca291..3fae5cbf42 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -278,7 +278,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RemoveAssetsDto" + "$ref": "#/components/schemas/BulkIdsDto" } } }, @@ -289,7 +289,10 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AlbumResponseDto" + "items": { + "$ref": "#/components/schemas/BulkIdResponseDto" + }, + "type": "array" } } }, @@ -336,7 +339,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AddAssetsDto" + "$ref": "#/components/schemas/BulkIdsDto" } } }, @@ -347,7 +350,10 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AddAssetsResponseDto" + "items": { + "$ref": "#/components/schemas/BulkIdResponseDto" + }, + "type": "array" } } }, @@ -4535,42 +4541,6 @@ ], "type": "object" }, - "AddAssetsDto": { - "properties": { - "assetIds": { - "items": { - "format": "uuid", - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "assetIds" - ], - "type": "object" - }, - "AddAssetsResponseDto": { - "properties": { - "album": { - "$ref": "#/components/schemas/AlbumResponseDto" - }, - "alreadyInAlbum": { - "items": { - "type": "string" - }, - "type": "array" - }, - "successfullyAdded": { - "type": "integer" - } - }, - "required": [ - "successfullyAdded", - "alreadyInAlbum" - ], - "type": "object" - }, "AddUsersDto": { "properties": { "sharedUserIds": { @@ -5093,6 +5063,21 @@ ], "type": "object" }, + "BulkIdsDto": { + "properties": { + "ids": { + "items": { + "format": "uuid", + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "ids" + ], + "type": "object" + }, "ChangePasswordDto": { "properties": { "newPassword": { @@ -6055,21 +6040,6 @@ ], "type": "object" }, - "RemoveAssetsDto": { - "properties": { - "assetIds": { - "items": { - "format": "uuid", - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "assetIds" - ], - "type": "object" - }, "SearchAlbumResponseDto": { "properties": { "count": { diff --git a/server/src/domain/access/access.core.ts b/server/src/domain/access/access.core.ts index 7ecaf97e13..d815612365 100644 --- a/server/src/domain/access/access.core.ts +++ b/server/src/domain/access/access.core.ts @@ -12,9 +12,10 @@ export enum Permission { ASSET_DOWNLOAD = 'asset.download', // ALBUM_CREATE = 'album.create', - // ALBUM_READ = 'album.read', + ALBUM_READ = 'album.read', ALBUM_UPDATE = 'album.update', ALBUM_DELETE = 'album.delete', + ALBUM_REMOVE_ASSET = 'album.removeAsset', ALBUM_SHARE = 'album.share', ALBUM_DOWNLOAD = 'album.download', @@ -39,6 +40,16 @@ export class AccessCore { } } + async hasAny(authUser: AuthUserDto, permissions: Array<{ permission: Permission; id: string }>) { + for (const { permission, id } of permissions) { + const hasAccess = await this.hasPermission(authUser, permission, id); + if (hasAccess) { + return true; + } + } + return false; + } + async hasPermission(authUser: AuthUserDto, permission: Permission, ids: string[] | string) { ids = Array.isArray(ids) ? ids : [ids]; @@ -76,12 +87,11 @@ export class AccessCore { // TODO: fix this to not use authUser.id for shared link access control return this.repository.asset.hasOwnerAccess(authUser.id, id); - case Permission.ALBUM_DOWNLOAD: { - return !!authUser.isAllowDownload && (await this.repository.album.hasSharedLinkAccess(sharedLinkId, id)); - } + case Permission.ALBUM_READ: + return this.repository.album.hasSharedLinkAccess(sharedLinkId, id); - // case Permission.ALBUM_READ: - // return this.repository.album.hasSharedLinkAccess(sharedLinkId, id); + case Permission.ALBUM_DOWNLOAD: + return !!authUser.isAllowDownload && (await this.repository.album.hasSharedLinkAccess(sharedLinkId, id)); default: return false; @@ -122,8 +132,11 @@ export class AccessCore { (await this.repository.asset.hasPartnerAccess(authUser.id, id)) ); - // case Permission.ALBUM_READ: - // return this.repository.album.hasOwnerAccess(authUser.id, id); + case Permission.ALBUM_READ: + return ( + (await this.repository.album.hasOwnerAccess(authUser.id, id)) || + (await this.repository.album.hasSharedAlbumAccess(authUser.id, id)) + ); case Permission.ALBUM_UPDATE: return this.repository.album.hasOwnerAccess(authUser.id, id); @@ -140,13 +153,17 @@ export class AccessCore { (await this.repository.album.hasSharedAlbumAccess(authUser.id, id)) ); + case Permission.ALBUM_REMOVE_ASSET: + return this.repository.album.hasOwnerAccess(authUser.id, id); + case Permission.LIBRARY_READ: return authUser.id === id || (await this.repository.library.hasPartnerAccess(authUser.id, id)); case Permission.LIBRARY_DOWNLOAD: return authUser.id === id; - } - return false; + default: + return false; + } } } diff --git a/server/src/domain/album/album.repository.ts b/server/src/domain/album/album.repository.ts index e9f2b4c368..811b85ec9a 100644 --- a/server/src/domain/album/album.repository.ts +++ b/server/src/domain/album/album.repository.ts @@ -8,6 +8,7 @@ export interface AlbumAssetCount { } export interface IAlbumRepository { + getById(id: string): Promise; getByIds(ids: string[]): Promise; getByAssetId(ownerId: string, assetId: string): Promise; hasAsset(id: string, assetId: string): Promise; @@ -21,4 +22,5 @@ export interface IAlbumRepository { create(album: Partial): Promise; update(album: Partial): Promise; delete(album: AlbumEntity): Promise; + updateThumbnails(): Promise; } diff --git a/server/src/domain/album/album.service.spec.ts b/server/src/domain/album/album.service.spec.ts index d4eeca7fc4..b6c6204215 100644 --- a/server/src/domain/album/album.service.spec.ts +++ b/server/src/domain/album/album.service.spec.ts @@ -1,6 +1,7 @@ import { BadRequestException } from '@nestjs/common'; import { albumStub, + assetStub, authStub, IAccessRepositoryMock, newAccessRepositoryMock, @@ -11,7 +12,7 @@ import { userStub, } from '@test'; import _ from 'lodash'; -import { IAssetRepository } from '../asset'; +import { BulkIdErrorReason, IAssetRepository } from '../asset'; import { IJobRepository, JobName } from '../job'; import { IUserRepository } from '../user'; import { IAlbumRepository } from './album.repository'; @@ -202,7 +203,7 @@ describe(AlbumService.name, () => { describe('update', () => { it('should prevent updating an album that does not exist', async () => { - albumMock.getByIds.mockResolvedValue([]); + albumMock.getById.mockResolvedValue(null); await expect( sut.update(authStub.user1, 'invalid-id', { @@ -224,7 +225,7 @@ describe(AlbumService.name, () => { it('should require a valid thumbnail asset id', async () => { accessMock.album.hasOwnerAccess.mockResolvedValue(true); - albumMock.getByIds.mockResolvedValue([albumStub.oneAsset]); + albumMock.getById.mockResolvedValue(albumStub.oneAsset); albumMock.update.mockResolvedValue(albumStub.oneAsset); albumMock.hasAsset.mockResolvedValue(false); @@ -241,7 +242,7 @@ describe(AlbumService.name, () => { it('should allow the owner to update the album', async () => { accessMock.album.hasOwnerAccess.mockResolvedValue(true); - albumMock.getByIds.mockResolvedValue([albumStub.oneAsset]); + albumMock.getById.mockResolvedValue(albumStub.oneAsset); albumMock.update.mockResolvedValue(albumStub.oneAsset); await sut.update(authStub.admin, albumStub.oneAsset.id, { @@ -263,7 +264,7 @@ describe(AlbumService.name, () => { describe('delete', () => { it('should throw an error for an album not found', async () => { accessMock.album.hasOwnerAccess.mockResolvedValue(true); - albumMock.getByIds.mockResolvedValue([]); + albumMock.getById.mockResolvedValue(null); await expect(sut.delete(authStub.admin, albumStub.sharedWithAdmin.id)).rejects.toBeInstanceOf( BadRequestException, @@ -274,7 +275,7 @@ describe(AlbumService.name, () => { it('should not let a shared user delete the album', async () => { accessMock.album.hasOwnerAccess.mockResolvedValue(false); - albumMock.getByIds.mockResolvedValue([albumStub.sharedWithAdmin]); + albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin); await expect(sut.delete(authStub.admin, albumStub.sharedWithAdmin.id)).rejects.toBeInstanceOf( BadRequestException, @@ -285,7 +286,7 @@ describe(AlbumService.name, () => { it('should let the owner delete an album', async () => { accessMock.album.hasOwnerAccess.mockResolvedValue(true); - albumMock.getByIds.mockResolvedValue([albumStub.empty]); + albumMock.getById.mockResolvedValue(albumStub.empty); await sut.delete(authStub.admin, albumStub.empty.id); @@ -305,7 +306,7 @@ describe(AlbumService.name, () => { it('should throw an error if the userId is already added', async () => { accessMock.album.hasOwnerAccess.mockResolvedValue(true); - albumMock.getByIds.mockResolvedValue([albumStub.sharedWithAdmin]); + albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin); await expect( sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { sharedUserIds: [authStub.admin.id] }), ).rejects.toBeInstanceOf(BadRequestException); @@ -314,7 +315,7 @@ describe(AlbumService.name, () => { it('should throw an error if the userId does not exist', async () => { accessMock.album.hasOwnerAccess.mockResolvedValue(true); - albumMock.getByIds.mockResolvedValue([albumStub.sharedWithAdmin]); + albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin); userMock.get.mockResolvedValue(null); await expect( sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { sharedUserIds: ['user-3'] }), @@ -324,7 +325,7 @@ describe(AlbumService.name, () => { it('should add valid shared users', async () => { accessMock.album.hasOwnerAccess.mockResolvedValue(true); - albumMock.getByIds.mockResolvedValue([_.cloneDeep(albumStub.sharedWithAdmin)]); + albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithAdmin)); albumMock.update.mockResolvedValue(albumStub.sharedWithAdmin); userMock.get.mockResolvedValue(userStub.user2); await sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { sharedUserIds: [authStub.user2.id] }); @@ -339,14 +340,14 @@ describe(AlbumService.name, () => { describe('removeUser', () => { it('should require a valid album id', async () => { accessMock.album.hasOwnerAccess.mockResolvedValue(true); - albumMock.getByIds.mockResolvedValue([]); + albumMock.getById.mockResolvedValue(null); await expect(sut.removeUser(authStub.admin, 'album-1', 'user-1')).rejects.toBeInstanceOf(BadRequestException); expect(albumMock.update).not.toHaveBeenCalled(); }); it('should remove a shared user from an owned album', async () => { accessMock.album.hasOwnerAccess.mockResolvedValue(true); - albumMock.getByIds.mockResolvedValue([albumStub.sharedWithUser]); + albumMock.getById.mockResolvedValue(albumStub.sharedWithUser); await expect( sut.removeUser(authStub.admin, albumStub.sharedWithUser.id, userStub.user1.id), @@ -362,7 +363,7 @@ describe(AlbumService.name, () => { it('should prevent removing a shared user from a not-owned album (shared with auth user)', async () => { accessMock.album.hasOwnerAccess.mockResolvedValue(false); - albumMock.getByIds.mockResolvedValue([albumStub.sharedWithMultiple]); + albumMock.getById.mockResolvedValue(albumStub.sharedWithMultiple); await expect( sut.removeUser(authStub.user1, albumStub.sharedWithMultiple.id, authStub.user2.id), @@ -373,7 +374,7 @@ describe(AlbumService.name, () => { }); it('should allow a shared user to remove themselves', async () => { - albumMock.getByIds.mockResolvedValue([albumStub.sharedWithUser]); + albumMock.getById.mockResolvedValue(albumStub.sharedWithUser); await sut.removeUser(authStub.user1, albumStub.sharedWithUser.id, authStub.user1.id); @@ -386,7 +387,7 @@ describe(AlbumService.name, () => { }); it('should allow a shared user to remove themselves using "me"', async () => { - albumMock.getByIds.mockResolvedValue([albumStub.sharedWithUser]); + albumMock.getById.mockResolvedValue(albumStub.sharedWithUser); await sut.removeUser(authStub.user1, albumStub.sharedWithUser.id, 'me'); @@ -399,7 +400,7 @@ describe(AlbumService.name, () => { }); it('should not allow the owner to be removed', async () => { - albumMock.getByIds.mockResolvedValue([albumStub.empty]); + albumMock.getById.mockResolvedValue(albumStub.empty); await expect(sut.removeUser(authStub.admin, albumStub.empty.id, authStub.admin.id)).rejects.toBeInstanceOf( BadRequestException, @@ -409,7 +410,7 @@ describe(AlbumService.name, () => { }); it('should throw an error for a user not in the album', async () => { - albumMock.getByIds.mockResolvedValue([albumStub.empty]); + albumMock.getById.mockResolvedValue(albumStub.empty); await expect(sut.removeUser(authStub.admin, albumStub.empty.id, 'user-3')).rejects.toBeInstanceOf( BadRequestException, @@ -418,4 +419,301 @@ describe(AlbumService.name, () => { expect(albumMock.update).not.toHaveBeenCalled(); }); }); + + describe('getAlbumInfo', () => { + it('should get a shared album', async () => { + albumMock.getById.mockResolvedValue(albumStub.oneAsset); + accessMock.album.hasOwnerAccess.mockResolvedValue(true); + + await sut.get(authStub.admin, albumStub.oneAsset.id); + + expect(albumMock.getById).toHaveBeenCalledWith(albumStub.oneAsset.id); + expect(accessMock.album.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, albumStub.oneAsset.id); + }); + + it('should get a shared album via a shared link', async () => { + albumMock.getById.mockResolvedValue(albumStub.oneAsset); + accessMock.album.hasSharedLinkAccess.mockResolvedValue(true); + + await sut.get(authStub.adminSharedLink, 'album-123'); + + expect(albumMock.getById).toHaveBeenCalledWith('album-123'); + expect(accessMock.album.hasSharedLinkAccess).toHaveBeenCalledWith( + authStub.adminSharedLink.sharedLinkId, + 'album-123', + ); + }); + + it('should get a shared album via shared with user', async () => { + albumMock.getById.mockResolvedValue(albumStub.oneAsset); + accessMock.album.hasSharedAlbumAccess.mockResolvedValue(true); + + await sut.get(authStub.user1, 'album-123'); + + expect(albumMock.getById).toHaveBeenCalledWith('album-123'); + expect(accessMock.album.hasSharedAlbumAccess).toHaveBeenCalledWith(authStub.user1.id, 'album-123'); + }); + + it('should throw an error for no access', async () => { + accessMock.album.hasOwnerAccess.mockResolvedValue(false); + accessMock.album.hasSharedAlbumAccess.mockResolvedValue(false); + + await expect(sut.get(authStub.admin, 'album-123')).rejects.toBeInstanceOf(BadRequestException); + + expect(accessMock.album.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'album-123'); + expect(accessMock.album.hasSharedAlbumAccess).toHaveBeenCalledWith(authStub.admin.id, 'album-123'); + }); + }); + + describe('addAssets', () => { + it('should allow the owner to add assets', async () => { + accessMock.album.hasOwnerAccess.mockResolvedValue(true); + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); + + await expect( + sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), + ).resolves.toEqual([ + { success: true, id: 'asset-1' }, + { success: true, id: 'asset-2' }, + { success: true, id: 'asset-3' }, + ]); + + expect(albumMock.update).toHaveBeenCalledWith({ + id: 'album-123', + updatedAt: expect.any(Date), + assets: [assetStub.image, { id: 'asset-1' }, { id: 'asset-2' }, { id: 'asset-3' }], + albumThumbnailAssetId: 'asset-1', + }); + }); + + it('should not set the thumbnail if the album has one already', async () => { + accessMock.album.hasOwnerAccess.mockResolvedValue(true); + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + albumMock.getById.mockResolvedValue(_.cloneDeep({ ...albumStub.empty, albumThumbnailAssetId: 'asset-id' })); + + await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1'] })).resolves.toEqual([ + { success: true, id: 'asset-1' }, + ]); + + expect(albumMock.update).toHaveBeenCalledWith({ + id: 'album-123', + updatedAt: expect.any(Date), + assets: [{ id: 'asset-1' }], + albumThumbnailAssetId: 'asset-id', + }); + }); + + it('should allow a shared user to add assets', async () => { + accessMock.album.hasOwnerAccess.mockResolvedValue(false); + accessMock.album.hasSharedAlbumAccess.mockResolvedValue(true); + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithUser)); + + await expect( + sut.addAssets(authStub.user1, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), + ).resolves.toEqual([ + { success: true, id: 'asset-1' }, + { success: true, id: 'asset-2' }, + { success: true, id: 'asset-3' }, + ]); + + expect(albumMock.update).toHaveBeenCalledWith({ + id: 'album-123', + updatedAt: expect.any(Date), + assets: [{ id: 'asset-1' }, { id: 'asset-2' }, { id: 'asset-3' }], + albumThumbnailAssetId: 'asset-1', + }); + }); + + it('should allow a shared link user to add assets', async () => { + accessMock.album.hasOwnerAccess.mockResolvedValue(false); + accessMock.album.hasSharedAlbumAccess.mockResolvedValue(false); + accessMock.album.hasSharedLinkAccess.mockResolvedValue(true); + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); + + await expect( + sut.addAssets(authStub.adminSharedLink, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), + ).resolves.toEqual([ + { success: true, id: 'asset-1' }, + { success: true, id: 'asset-2' }, + { success: true, id: 'asset-3' }, + ]); + + expect(albumMock.update).toHaveBeenCalledWith({ + id: 'album-123', + updatedAt: expect.any(Date), + assets: [assetStub.image, { id: 'asset-1' }, { id: 'asset-2' }, { id: 'asset-3' }], + albumThumbnailAssetId: 'asset-1', + }); + + expect(accessMock.album.hasSharedLinkAccess).toHaveBeenCalledWith( + authStub.adminSharedLink.sharedLinkId, + 'album-123', + ); + }); + + it('should allow adding assets shared via partner sharing', async () => { + accessMock.album.hasOwnerAccess.mockResolvedValue(true); + accessMock.asset.hasOwnerAccess.mockResolvedValue(false); + accessMock.asset.hasPartnerAccess.mockResolvedValue(true); + albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); + + await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1'] })).resolves.toEqual([ + { success: true, id: 'asset-1' }, + ]); + + expect(albumMock.update).toHaveBeenCalledWith({ + id: 'album-123', + updatedAt: expect.any(Date), + assets: [assetStub.image, { id: 'asset-1' }], + albumThumbnailAssetId: 'asset-1', + }); + + expect(accessMock.asset.hasPartnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'asset-1'); + }); + + it('should skip duplicate assets', async () => { + accessMock.album.hasOwnerAccess.mockResolvedValue(true); + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); + + await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ + { success: false, id: 'asset-id', error: BulkIdErrorReason.DUPLICATE }, + ]); + + expect(albumMock.update).not.toHaveBeenCalled(); + }); + + it('should skip assets not shared with user', async () => { + accessMock.album.hasOwnerAccess.mockResolvedValue(true); + accessMock.asset.hasOwnerAccess.mockResolvedValue(false); + accessMock.asset.hasPartnerAccess.mockResolvedValue(false); + albumMock.getById.mockResolvedValue(albumStub.oneAsset); + + await expect(sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1'] })).resolves.toEqual([ + { success: false, id: 'asset-1', error: BulkIdErrorReason.NO_PERMISSION }, + ]); + + expect(accessMock.asset.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'asset-1'); + expect(accessMock.asset.hasPartnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'asset-1'); + }); + + it('should not allow unauthorized access to the album', async () => { + accessMock.album.hasOwnerAccess.mockResolvedValue(false); + accessMock.album.hasSharedAlbumAccess.mockResolvedValue(false); + albumMock.getById.mockResolvedValue(albumStub.oneAsset); + + await expect( + sut.addAssets(authStub.admin, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(accessMock.album.hasOwnerAccess).toHaveBeenCalled(); + expect(accessMock.album.hasSharedAlbumAccess).toHaveBeenCalled(); + }); + + it('should not allow unauthorized shared link access to the album', async () => { + accessMock.album.hasSharedLinkAccess.mockResolvedValue(false); + albumMock.getById.mockResolvedValue(albumStub.oneAsset); + + await expect( + sut.addAssets(authStub.adminSharedLink, 'album-123', { ids: ['asset-1', 'asset-2', 'asset-3'] }), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(accessMock.album.hasSharedLinkAccess).toHaveBeenCalled(); + }); + }); + + describe('removeAssets', () => { + it('should allow the owner to remove assets', async () => { + accessMock.album.hasOwnerAccess.mockResolvedValue(true); + albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); + + await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ + { success: true, id: 'asset-id' }, + ]); + + expect(albumMock.update).toHaveBeenCalledWith({ + id: 'album-123', + updatedAt: expect.any(Date), + assets: [], + albumThumbnailAssetId: null, + }); + }); + + it('should skip assets not in the album', async () => { + accessMock.album.hasOwnerAccess.mockResolvedValue(true); + albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.empty)); + + await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ + { success: false, id: 'asset-id', error: BulkIdErrorReason.NOT_FOUND }, + ]); + + expect(albumMock.update).not.toHaveBeenCalled(); + }); + + it('should skip assets without user permission to remove', async () => { + accessMock.album.hasSharedAlbumAccess.mockResolvedValue(true); + albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.oneAsset)); + + await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ + { success: false, id: 'asset-id', error: BulkIdErrorReason.NO_PERMISSION }, + ]); + + expect(albumMock.update).not.toHaveBeenCalled(); + }); + + it('should reset the thumbnail if it is removed', async () => { + accessMock.album.hasOwnerAccess.mockResolvedValue(true); + albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.twoAssets)); + + await expect(sut.removeAssets(authStub.admin, 'album-123', { ids: ['asset-id'] })).resolves.toEqual([ + { success: true, id: 'asset-id' }, + ]); + + expect(albumMock.update).toHaveBeenCalledWith({ + id: 'album-123', + updatedAt: expect.any(Date), + assets: [assetStub.withLocation], + albumThumbnailAssetId: assetStub.withLocation.id, + }); + }); + }); + + // // it('removes assets from shared album (shared with auth user)', async () => { + // // const albumEntity = _getOwnedSharedAlbum(); + // // albumRepositoryMock.get.mockImplementation(() => Promise.resolve(albumEntity)); + // // albumRepositoryMock.removeAssets.mockImplementation(() => Promise.resolve(albumEntity)); + + // // await expect( + // // sut.removeAssetsFromAlbum( + // // authUser, + // // { + // // ids: ['1'], + // // }, + // // albumEntity.id, + // // ), + // // ).resolves.toBeUndefined(); + // // expect(albumRepositoryMock.removeAssets).toHaveBeenCalledTimes(1); + // // expect(albumRepositoryMock.removeAssets).toHaveBeenCalledWith(albumEntity, { + // // ids: ['1'], + // // }); + // // }); + + // it('prevents removing assets from a not owned / shared album', async () => { + // const albumEntity = _getNotOwnedNotSharedAlbum(); + + // const albumResponse: AddAssetsResponseDto = { + // alreadyInAlbum: [], + // successfullyAdded: 1, + // }; + + // const albumId = albumEntity.id; + + // albumRepositoryMock.get.mockImplementation(() => Promise.resolve(albumEntity)); + // albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve(albumResponse)); + + // await expect(sut.removeAssets(authUser, albumId, { ids: ['1'] })).rejects.toBeInstanceOf(ForbiddenException); + // }); }); diff --git a/server/src/domain/album/album.service.ts b/server/src/domain/album/album.service.ts index 945516e2a7..246a53047a 100644 --- a/server/src/domain/album/album.service.ts +++ b/server/src/domain/album/album.service.ts @@ -1,8 +1,8 @@ import { AlbumEntity, AssetEntity, UserEntity } from '@app/infra/entities'; import { BadRequestException, Inject, Injectable } from '@nestjs/common'; -import { IAssetRepository, mapAsset } from '../asset'; +import { AccessCore, IAccessRepository, Permission } from '../access'; +import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto, IAssetRepository, mapAsset } from '../asset'; import { AuthUserDto } from '../auth'; -import { AccessCore, IAccessRepository, Permission } from '../index'; import { IJobRepository, JobName } from '../job'; import { IUserRepository } from '../user'; import { AlbumCountResponseDto, AlbumResponseDto, mapAlbum } from './album-response.dto'; @@ -37,7 +37,11 @@ export class AlbumService { } async getAll({ id: ownerId }: AuthUserDto, { assetId, shared }: GetAlbumsDto): Promise { - await this.updateInvalidThumbnails(); + const invalidAlbumIds = await this.albumRepository.getInvalidThumbnail(); + for (const albumId of invalidAlbumIds) { + const newThumbnail = await this.assetRepository.getFirstAssetForAlbumId(albumId); + await this.albumRepository.update({ id: albumId, albumThumbnailAsset: newThumbnail }); + } let albums: AlbumEntity[]; if (assetId) { @@ -73,15 +77,10 @@ export class AlbumService { ); } - private async updateInvalidThumbnails(): Promise { - const invalidAlbumIds = await this.albumRepository.getInvalidThumbnail(); - - for (const albumId of invalidAlbumIds) { - const newThumbnail = await this.assetRepository.getFirstAssetForAlbumId(albumId); - await this.albumRepository.update({ id: albumId, albumThumbnailAsset: newThumbnail }); - } - - return invalidAlbumIds.length; + async get(authUser: AuthUserDto, id: string) { + await this.access.requirePermission(authUser, Permission.ALBUM_READ, id); + await this.albumRepository.updateThumbnails(); + return mapAlbum(await this.findOrFail(id)); } async create(authUser: AuthUserDto, dto: CreateAlbumDto): Promise { @@ -107,7 +106,7 @@ export class AlbumService { async update(authUser: AuthUserDto, id: string, dto: UpdateAlbumDto): Promise { await this.access.requirePermission(authUser, Permission.ALBUM_UPDATE, id); - const album = await this.get(id); + const album = await this.findOrFail(id); if (dto.albumThumbnailAssetId) { const valid = await this.albumRepository.hasAsset(id, dto.albumThumbnailAssetId); @@ -130,7 +129,7 @@ export class AlbumService { async delete(authUser: AuthUserDto, id: string): Promise { await this.access.requirePermission(authUser, Permission.ALBUM_DELETE, id); - const [album] = await this.albumRepository.getByIds([id]); + const album = await this.albumRepository.getById(id); if (!album) { throw new BadRequestException('Album not found'); } @@ -139,10 +138,88 @@ export class AlbumService { await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ALBUM, data: { ids: [id] } }); } + async addAssets(authUser: AuthUserDto, id: string, dto: BulkIdsDto): Promise { + const album = await this.findOrFail(id); + + await this.access.requirePermission(authUser, Permission.ALBUM_READ, id); + + const results: BulkIdResponseDto[] = []; + for (const id of dto.ids) { + const hasAsset = album.assets.find((asset) => asset.id === id); + if (hasAsset) { + results.push({ id, success: false, error: BulkIdErrorReason.DUPLICATE }); + continue; + } + + const hasAccess = await this.access.hasPermission(authUser, Permission.ASSET_SHARE, id); + if (!hasAccess) { + results.push({ id, success: false, error: BulkIdErrorReason.NO_PERMISSION }); + continue; + } + + results.push({ id, success: true }); + album.assets.push({ id } as AssetEntity); + } + + const newAsset = results.find(({ success }) => success); + if (newAsset) { + await this.albumRepository.update({ + id, + assets: album.assets, + updatedAt: new Date(), + albumThumbnailAssetId: album.albumThumbnailAssetId ?? newAsset.id, + }); + } + + return results; + } + + async removeAssets(authUser: AuthUserDto, id: string, dto: BulkIdsDto): Promise { + const album = await this.findOrFail(id); + + await this.access.requirePermission(authUser, Permission.ALBUM_READ, id); + + const results: BulkIdResponseDto[] = []; + for (const id of dto.ids) { + const hasAsset = album.assets.find((asset) => asset.id === id); + if (!hasAsset) { + results.push({ id, success: false, error: BulkIdErrorReason.NOT_FOUND }); + continue; + } + + const hasAccess = await this.access.hasAny(authUser, [ + { permission: Permission.ALBUM_REMOVE_ASSET, id }, + { permission: Permission.ASSET_SHARE, id }, + ]); + if (!hasAccess) { + results.push({ id, success: false, error: BulkIdErrorReason.NO_PERMISSION }); + continue; + } + + results.push({ id, success: true }); + album.assets = album.assets.filter((asset) => asset.id !== id); + if (album.albumThumbnailAssetId === id) { + album.albumThumbnailAssetId = null; + } + } + + const hasSuccess = results.find(({ success }) => success); + if (hasSuccess) { + await this.albumRepository.update({ + id, + assets: album.assets, + updatedAt: new Date(), + albumThumbnailAssetId: album.albumThumbnailAssetId || album.assets[0]?.id || null, + }); + } + + return results; + } + async addUsers(authUser: AuthUserDto, id: string, dto: AddUsersDto) { await this.access.requirePermission(authUser, Permission.ALBUM_SHARE, id); - const album = await this.get(id); + const album = await this.findOrFail(id); for (const userId of dto.sharedUserIds) { const exists = album.sharedUsers.find((user) => user.id === userId); @@ -172,7 +249,7 @@ export class AlbumService { userId = authUser.id; } - const album = await this.get(id); + const album = await this.findOrFail(id); if (album.ownerId === userId) { throw new BadRequestException('Cannot remove album owner'); @@ -195,8 +272,8 @@ export class AlbumService { }); } - private async get(id: string) { - const [album] = await this.albumRepository.getByIds([id]); + private async findOrFail(id: string) { + const album = await this.albumRepository.getById(id); if (!album) { throw new BadRequestException('Album not found'); } diff --git a/server/src/domain/asset/response-dto/asset-ids-response.dto.ts b/server/src/domain/asset/response-dto/asset-ids-response.dto.ts index 81672564af..9bb6a5b368 100644 --- a/server/src/domain/asset/response-dto/asset-ids-response.dto.ts +++ b/server/src/domain/asset/response-dto/asset-ids-response.dto.ts @@ -1,3 +1,5 @@ +import { ValidateUUID } from '../../domain.util'; + /** @deprecated Use `BulkIdResponseDto` instead */ export enum AssetIdErrorReason { DUPLICATE = 'duplicate', @@ -19,6 +21,11 @@ export enum BulkIdErrorReason { UNKNOWN = 'unknown', } +export class BulkIdsDto { + @ValidateUUID({ each: true }) + ids!: string[]; +} + export class BulkIdResponseDto { id!: string; success!: boolean; diff --git a/server/src/immich/api-v1/album/album-repository.ts b/server/src/immich/api-v1/album/album-repository.ts deleted file mode 100644 index 200b7d0970..0000000000 --- a/server/src/immich/api-v1/album/album-repository.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { dataSource } from '@app/infra/database.config'; -import { AlbumEntity, AssetEntity } from '@app/infra/entities'; -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; -import { AddAssetsDto } from './dto/add-assets.dto'; -import { RemoveAssetsDto } from './dto/remove-assets.dto'; -import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto'; - -export interface IAlbumRepository { - get(albumId: string): Promise; - removeAssets(album: AlbumEntity, removeAssets: RemoveAssetsDto): Promise; - addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise; - updateThumbnails(): Promise; -} - -export const IAlbumRepository = 'IAlbumRepository'; - -@Injectable() -export class AlbumRepository implements IAlbumRepository { - constructor( - @InjectRepository(AlbumEntity) private albumRepository: Repository, - @InjectRepository(AssetEntity) private assetRepository: Repository, - ) {} - - async get(albumId: string): Promise { - return this.albumRepository.findOne({ - where: { id: albumId }, - relations: { - owner: true, - sharedUsers: true, - assets: { - exifInfo: true, - }, - sharedLinks: true, - }, - order: { - assets: { - fileCreatedAt: 'DESC', - }, - }, - }); - } - - async removeAssets(album: AlbumEntity, removeAssetsDto: RemoveAssetsDto): Promise { - const assetCount = album.assets.length; - - album.assets = album.assets.filter((asset) => { - return !removeAssetsDto.assetIds.includes(asset.id); - }); - - const numRemovedAssets = assetCount - album.assets.length; - if (numRemovedAssets > 0) { - album.updatedAt = new Date(); - } - await this.albumRepository.save(album, {}); - - return numRemovedAssets; - } - - async addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise { - const alreadyExisting: string[] = []; - - for (const assetId of addAssetsDto.assetIds) { - // Album already contains that asset - if (album.assets?.some((a) => a.id === assetId)) { - alreadyExisting.push(assetId); - continue; - } - - album.assets.push({ id: assetId } as AssetEntity); - } - - // Add album thumbnail if not exist. - if (!album.albumThumbnailAssetId && album.assets.length > 0) { - album.albumThumbnailAssetId = album.assets[0].id; - } - - const successfullyAdded = addAssetsDto.assetIds.length - alreadyExisting.length; - if (successfullyAdded > 0) { - album.updatedAt = new Date(); - } - await this.albumRepository.save(album); - - return { - successfullyAdded, - alreadyInAlbum: alreadyExisting, - }; - } - - /** - * Makes sure all thumbnails for albums are updated by: - * - Removing thumbnails from albums without assets - * - Removing references of thumbnails to assets outside the album - * - Setting a thumbnail when none is set and the album contains assets - * - * @returns Amount of updated album thumbnails or undefined when unknown - */ - async updateThumbnails(): Promise { - // Subquery for getting a new thumbnail. - const newThumbnail = this.assetRepository - .createQueryBuilder('assets') - .select('albums_assets2.assetsId') - .addFrom('albums_assets_assets', 'albums_assets2') - .where('albums_assets2.assetsId = assets.id') - .andWhere('albums_assets2.albumsId = "albums"."id"') // Reference to albums.id outside this query - .orderBy('assets.fileCreatedAt', 'DESC') - .limit(1); - - // Using dataSource, because there is no direct access to albums_assets_assets. - const albumHasAssets = dataSource - .createQueryBuilder() - .select('1') - .from('albums_assets_assets', 'albums_assets') - .where('"albums"."id" = "albums_assets"."albumsId"'); - - const albumContainsThumbnail = albumHasAssets - .clone() - .andWhere('"albums"."albumThumbnailAssetId" = "albums_assets"."assetsId"'); - - const updateAlbums = this.albumRepository - .createQueryBuilder('albums') - .update(AlbumEntity) - .set({ albumThumbnailAssetId: () => `(${newThumbnail.getQuery()})` }) - .where(`"albums"."albumThumbnailAssetId" IS NULL AND EXISTS (${albumHasAssets.getQuery()})`) - .orWhere(`"albums"."albumThumbnailAssetId" IS NOT NULL AND NOT EXISTS (${albumContainsThumbnail.getQuery()})`); - - const result = await updateAlbums.execute(); - - return result.affected; - } -} diff --git a/server/src/immich/api-v1/album/album.controller.ts b/server/src/immich/api-v1/album/album.controller.ts deleted file mode 100644 index ba6f195a24..0000000000 --- a/server/src/immich/api-v1/album/album.controller.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { AlbumResponseDto, AuthUserDto } from '@app/domain'; -import { Body, Controller, Delete, Get, Param, Put } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; -import { Authenticated, AuthUser, SharedLinkRoute } from '../../app.guard'; -import { UseValidation } from '../../app.utils'; -import { UUIDParamDto } from '../../controllers/dto/uuid-param.dto'; -import { AlbumService } from './album.service'; -import { AddAssetsDto } from './dto/add-assets.dto'; -import { RemoveAssetsDto } from './dto/remove-assets.dto'; -import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto'; - -@ApiTags('Album') -@Controller('album') -@Authenticated() -@UseValidation() -export class AlbumController { - constructor(private service: AlbumService) {} - - @SharedLinkRoute() - @Put(':id/assets') - addAssetsToAlbum( - @AuthUser() authUser: AuthUserDto, - @Param() { id }: UUIDParamDto, - @Body() dto: AddAssetsDto, - ): Promise { - // TODO: Handle nonexistent assetIds. - // TODO: Disallow adding assets of another user to an album. - return this.service.addAssets(authUser, id, dto); - } - - @SharedLinkRoute() - @Get(':id') - getAlbumInfo(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto) { - return this.service.get(authUser, id); - } - - @Delete(':id/assets') - removeAssetFromAlbum( - @AuthUser() authUser: AuthUserDto, - @Body() dto: RemoveAssetsDto, - @Param() { id }: UUIDParamDto, - ): Promise { - return this.service.removeAssets(authUser, id, dto); - } -} diff --git a/server/src/immich/api-v1/album/album.module.ts b/server/src/immich/api-v1/album/album.module.ts deleted file mode 100644 index e241f96359..0000000000 --- a/server/src/immich/api-v1/album/album.module.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { AlbumEntity, AssetEntity } from '@app/infra/entities'; -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { AlbumRepository, IAlbumRepository } from './album-repository'; -import { AlbumController } from './album.controller'; -import { AlbumService } from './album.service'; - -@Module({ - imports: [TypeOrmModule.forFeature([AlbumEntity, AssetEntity])], - controllers: [AlbumController], - providers: [AlbumService, { provide: IAlbumRepository, useClass: AlbumRepository }], -}) -export class AlbumModule {} diff --git a/server/src/immich/api-v1/album/album.service.spec.ts b/server/src/immich/api-v1/album/album.service.spec.ts deleted file mode 100644 index 98edf6c8f2..0000000000 --- a/server/src/immich/api-v1/album/album.service.spec.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { AlbumResponseDto, AuthUserDto, mapUser } from '@app/domain'; -import { AlbumEntity, UserEntity } from '@app/infra/entities'; -import { ForbiddenException, NotFoundException } from '@nestjs/common'; -import { userStub } from '@test'; -import { IAlbumRepository } from './album-repository'; -import { AlbumService } from './album.service'; -import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto'; - -describe('Album service', () => { - let sut: AlbumService; - let albumRepositoryMock: jest.Mocked; - - const authUser: AuthUserDto = Object.freeze({ - id: '1111', - email: 'auth@test.com', - isAdmin: false, - }); - - const albumOwner: UserEntity = Object.freeze({ - ...authUser, - firstName: 'auth', - lastName: 'user', - createdAt: new Date('2022-06-19T23:41:36.910Z'), - deletedAt: null, - updatedAt: new Date('2022-06-19T23:41:36.910Z'), - profileImagePath: '', - shouldChangePassword: false, - oauthId: '', - tags: [], - assets: [], - storageLabel: null, - externalPath: null, - }); - const albumId = 'f19ab956-4761-41ea-a5d6-bae948308d58'; - const sharedAlbumOwnerId = '2222'; - const sharedAlbumSharedAlsoWithId = '3333'; - - const _getOwnedAlbum = () => { - const albumEntity = new AlbumEntity(); - albumEntity.ownerId = albumOwner.id; - albumEntity.owner = albumOwner; - albumEntity.id = albumId; - albumEntity.albumName = 'name'; - albumEntity.createdAt = new Date('2022-06-19T23:41:36.910Z'); - albumEntity.updatedAt = new Date('2022-06-19T23:41:36.910Z'); - albumEntity.sharedUsers = []; - albumEntity.assets = []; - albumEntity.albumThumbnailAssetId = null; - albumEntity.sharedLinks = []; - return albumEntity; - }; - - const _getSharedWithAuthUserAlbum = () => { - const albumEntity = new AlbumEntity(); - albumEntity.ownerId = sharedAlbumOwnerId; - albumEntity.owner = albumOwner; - albumEntity.id = albumId; - albumEntity.albumName = 'name'; - albumEntity.createdAt = new Date('2022-06-19T23:41:36.910Z'); - albumEntity.assets = []; - albumEntity.albumThumbnailAssetId = null; - albumEntity.sharedUsers = [ - { - ...userStub.user1, - id: authUser.id, - }, - { - ...userStub.user1, - id: sharedAlbumSharedAlsoWithId, - }, - ]; - albumEntity.sharedLinks = []; - - return albumEntity; - }; - - const _getNotOwnedNotSharedAlbum = () => { - const albumEntity = new AlbumEntity(); - albumEntity.ownerId = '5555'; - albumEntity.id = albumId; - albumEntity.albumName = 'name'; - albumEntity.createdAt = new Date('2022-06-19T23:41:36.910Z'); - albumEntity.sharedUsers = []; - albumEntity.assets = []; - albumEntity.albumThumbnailAssetId = null; - - return albumEntity; - }; - - beforeAll(() => { - albumRepositoryMock = { - addAssets: jest.fn(), - get: jest.fn(), - removeAssets: jest.fn(), - updateThumbnails: jest.fn(), - }; - - sut = new AlbumService(albumRepositoryMock); - }); - - it('gets an owned album', async () => { - const albumId = 'f19ab956-4761-41ea-a5d6-bae948308d58'; - - const albumEntity = _getOwnedAlbum(); - albumRepositoryMock.get.mockImplementation(() => Promise.resolve(albumEntity)); - - const expectedResult: AlbumResponseDto = { - ownerId: albumOwner.id, - owner: mapUser(albumOwner), - id: albumId, - albumName: 'name', - createdAt: new Date('2022-06-19T23:41:36.910Z'), - updatedAt: new Date('2022-06-19T23:41:36.910Z'), - sharedUsers: [], - assets: [], - albumThumbnailAssetId: null, - shared: false, - assetCount: 0, - }; - await expect(sut.get(authUser, albumId)).resolves.toEqual(expectedResult); - }); - - it('gets a shared album', async () => { - const albumEntity = _getSharedWithAuthUserAlbum(); - albumRepositoryMock.get.mockImplementation(() => Promise.resolve(albumEntity)); - - const result = await sut.get(authUser, albumId); - expect(result.id).toEqual(albumId); - expect(result.ownerId).toEqual(sharedAlbumOwnerId); - expect(result.shared).toEqual(true); - expect(result.sharedUsers).toHaveLength(2); - expect(result.sharedUsers[0].id).toEqual(authUser.id); - expect(result.sharedUsers[1].id).toEqual(sharedAlbumSharedAlsoWithId); - }); - - it('prevents retrieving an album that is not owned or shared', async () => { - const albumEntity = _getNotOwnedNotSharedAlbum(); - const albumId = albumEntity.id; - - albumRepositoryMock.get.mockImplementation(() => Promise.resolve(albumEntity)); - await expect(sut.get(authUser, albumId)).rejects.toBeInstanceOf(ForbiddenException); - }); - - it('throws a not found exception if the album is not found', async () => { - albumRepositoryMock.get.mockImplementation(() => Promise.resolve(null)); - await expect(sut.get(authUser, '0002')).rejects.toBeInstanceOf(NotFoundException); - }); - - it('adds assets to owned album', async () => { - const albumEntity = _getOwnedAlbum(); - - const albumResponse: AddAssetsResponseDto = { - alreadyInAlbum: [], - successfullyAdded: 1, - }; - - const albumId = albumEntity.id; - - albumRepositoryMock.get.mockImplementation(() => Promise.resolve(albumEntity)); - albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve(albumResponse)); - - const result = (await sut.addAssets(authUser, albumId, { assetIds: ['1'] })) as AddAssetsResponseDto; - - // TODO: stub and expect album rendered - expect(result.album?.id).toEqual(albumId); - }); - - it('adds assets to shared album (shared with auth user)', async () => { - const albumEntity = _getSharedWithAuthUserAlbum(); - - const albumResponse: AddAssetsResponseDto = { - alreadyInAlbum: [], - successfullyAdded: 1, - }; - - const albumId = albumEntity.id; - - albumRepositoryMock.get.mockImplementation(() => Promise.resolve(albumEntity)); - albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve(albumResponse)); - - const result = (await sut.addAssets(authUser, albumId, { assetIds: ['1'] })) as AddAssetsResponseDto; - - // TODO: stub and expect album rendered - expect(result.album?.id).toEqual(albumId); - }); - - it('prevents adding assets to a not owned / shared album', async () => { - const albumEntity = _getNotOwnedNotSharedAlbum(); - - const albumResponse: AddAssetsResponseDto = { - alreadyInAlbum: [], - successfullyAdded: 1, - }; - - const albumId = albumEntity.id; - - albumRepositoryMock.get.mockImplementation(() => Promise.resolve(albumEntity)); - albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve(albumResponse)); - - await expect(sut.addAssets(authUser, albumId, { assetIds: ['1'] })).rejects.toBeInstanceOf(ForbiddenException); - }); - - // it('removes assets from owned album', async () => { - // const albumEntity = _getOwnedAlbum(); - // albumRepositoryMock.get.mockImplementation(() => Promise.resolve(albumEntity)); - // albumRepositoryMock.removeAssets.mockImplementation(() => Promise.resolve(albumEntity)); - - // await expect( - // sut.removeAssetsFromAlbum( - // authUser, - // { - // assetIds: ['f19ab956-4761-41ea-a5d6-bae948308d60'], - // }, - // albumEntity.id, - // ), - // ).resolves.toBeUndefined(); - // expect(albumRepositoryMock.removeAssets).toHaveBeenCalledTimes(1); - // expect(albumRepositoryMock.removeAssets).toHaveBeenCalledWith(albumEntity, { - // assetIds: ['f19ab956-4761-41ea-a5d6-bae948308d60'], - // }); - // }); - - // it('removes assets from shared album (shared with auth user)', async () => { - // const albumEntity = _getOwnedSharedAlbum(); - // albumRepositoryMock.get.mockImplementation(() => Promise.resolve(albumEntity)); - // albumRepositoryMock.removeAssets.mockImplementation(() => Promise.resolve(albumEntity)); - - // await expect( - // sut.removeAssetsFromAlbum( - // authUser, - // { - // assetIds: ['1'], - // }, - // albumEntity.id, - // ), - // ).resolves.toBeUndefined(); - // expect(albumRepositoryMock.removeAssets).toHaveBeenCalledTimes(1); - // expect(albumRepositoryMock.removeAssets).toHaveBeenCalledWith(albumEntity, { - // assetIds: ['1'], - // }); - // }); - - it('prevents removing assets from a not owned / shared album', async () => { - const albumEntity = _getNotOwnedNotSharedAlbum(); - - const albumResponse: AddAssetsResponseDto = { - alreadyInAlbum: [], - successfullyAdded: 1, - }; - - const albumId = albumEntity.id; - - albumRepositoryMock.get.mockImplementation(() => Promise.resolve(albumEntity)); - albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve(albumResponse)); - - await expect(sut.removeAssets(authUser, albumId, { assetIds: ['1'] })).rejects.toBeInstanceOf(ForbiddenException); - }); -}); diff --git a/server/src/immich/api-v1/album/album.service.ts b/server/src/immich/api-v1/album/album.service.ts deleted file mode 100644 index cb433bcbd6..0000000000 --- a/server/src/immich/api-v1/album/album.service.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { AlbumResponseDto, AuthUserDto, mapAlbum } from '@app/domain'; -import { AlbumEntity } from '@app/infra/entities'; -import { BadRequestException, ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { IAlbumRepository } from './album-repository'; -import { AddAssetsDto } from './dto/add-assets.dto'; -import { RemoveAssetsDto } from './dto/remove-assets.dto'; -import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto'; - -@Injectable() -export class AlbumService { - private logger = new Logger(AlbumService.name); - - constructor(@Inject(IAlbumRepository) private repository: IAlbumRepository) {} - - private async _getAlbum({ - authUser, - albumId, - validateIsOwner = true, - }: { - authUser: AuthUserDto; - albumId: string; - validateIsOwner?: boolean; - }): Promise { - await this.repository.updateThumbnails(); - - const album = await this.repository.get(albumId); - if (!album) { - throw new NotFoundException('Album Not Found'); - } - const isOwner = album.ownerId == authUser.id; - - if (validateIsOwner && !isOwner) { - throw new ForbiddenException('Unauthorized Album Access'); - } else if (!isOwner && !album.sharedUsers?.some((user) => user.id == authUser.id)) { - throw new ForbiddenException('Unauthorized Album Access'); - } - return album; - } - - async get(authUser: AuthUserDto, albumId: string): Promise { - const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false }); - return mapAlbum(album); - } - - async removeAssets(authUser: AuthUserDto, albumId: string, dto: RemoveAssetsDto): Promise { - const album = await this._getAlbum({ authUser, albumId }); - const deletedCount = await this.repository.removeAssets(album, dto); - const newAlbum = await this._getAlbum({ authUser, albumId }); - - if (deletedCount !== dto.assetIds.length) { - throw new BadRequestException('Some assets were not found in the album'); - } - - return mapAlbum(newAlbum); - } - - async addAssets(authUser: AuthUserDto, albumId: string, dto: AddAssetsDto): Promise { - if (authUser.isPublicUser && !authUser.isAllowUpload) { - this.logger.warn('Deny public user attempt to add asset to album'); - throw new ForbiddenException('Public user is not allowed to upload'); - } - - const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false }); - const result = await this.repository.addAssets(album, dto); - const newAlbum = await this._getAlbum({ authUser, albumId, validateIsOwner: false }); - - return { - ...result, - album: mapAlbum(newAlbum), - }; - } -} diff --git a/server/src/immich/api-v1/album/dto/add-assets.dto.ts b/server/src/immich/api-v1/album/dto/add-assets.dto.ts deleted file mode 100644 index 9f66fab3d8..0000000000 --- a/server/src/immich/api-v1/album/dto/add-assets.dto.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { ValidateUUID } from '@app/domain'; - -export class AddAssetsDto { - @ValidateUUID({ each: true }) - assetIds!: string[]; -} diff --git a/server/src/immich/api-v1/album/dto/add-users.dto.ts b/server/src/immich/api-v1/album/dto/add-users.dto.ts deleted file mode 100644 index 3ff76a8227..0000000000 --- a/server/src/immich/api-v1/album/dto/add-users.dto.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { ValidateUUID } from '@app/domain'; - -export class AddUsersDto { - @ValidateUUID({ each: true }) - sharedUserIds!: string[]; -} diff --git a/server/src/immich/api-v1/album/dto/remove-assets.dto.ts b/server/src/immich/api-v1/album/dto/remove-assets.dto.ts deleted file mode 100644 index a663b0254d..0000000000 --- a/server/src/immich/api-v1/album/dto/remove-assets.dto.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { ValidateUUID } from '@app/domain'; - -export class RemoveAssetsDto { - @ValidateUUID({ each: true }) - assetIds!: string[]; -} diff --git a/server/src/immich/api-v1/album/response-dto/add-assets-response.dto.ts b/server/src/immich/api-v1/album/response-dto/add-assets-response.dto.ts deleted file mode 100644 index 2580d5478e..0000000000 --- a/server/src/immich/api-v1/album/response-dto/add-assets-response.dto.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { AlbumResponseDto } from '@app/domain'; -import { ApiProperty } from '@nestjs/swagger'; - -export class AddAssetsResponseDto { - @ApiProperty({ type: 'integer' }) - successfullyAdded!: number; - - @ApiProperty() - alreadyInAlbum!: string[]; - - @ApiProperty() - album?: AlbumResponseDto; -} diff --git a/server/src/immich/app.module.ts b/server/src/immich/app.module.ts index bb32db5024..1067485ac0 100644 --- a/server/src/immich/app.module.ts +++ b/server/src/immich/app.module.ts @@ -5,7 +5,6 @@ import { Module } from '@nestjs/common'; import { APP_GUARD } from '@nestjs/core'; import { ScheduleModule } from '@nestjs/schedule'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { AlbumModule } from './api-v1/album/album.module'; import { AssetRepository, IAssetRepository } from './api-v1/asset/asset-repository'; import { AssetController as AssetControllerV1 } from './api-v1/asset/asset.controller'; import { AssetService } from './api-v1/asset/asset.service'; @@ -34,7 +33,6 @@ import { imports: [ // DomainModule.register({ imports: [InfraModule] }), - AlbumModule, ScheduleModule.forRoot(), TypeOrmModule.forFeature([AssetEntity, ExifEntity]), ], diff --git a/server/src/immich/controllers/album.controller.ts b/server/src/immich/controllers/album.controller.ts index 6da64dd1ee..889a025a4c 100644 --- a/server/src/immich/controllers/album.controller.ts +++ b/server/src/immich/controllers/album.controller.ts @@ -3,6 +3,8 @@ import { AlbumCountResponseDto, AlbumService, AuthUserDto, + BulkIdResponseDto, + BulkIdsDto, CreateAlbumDto, UpdateAlbumDto, } from '@app/domain'; @@ -10,7 +12,7 @@ import { GetAlbumsDto } from '@app/domain/album/dto/get-albums.dto'; import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { ParseMeUUIDPipe } from '../api-v1/validation/parse-me-uuid-pipe'; -import { Authenticated, AuthUser } from '../app.guard'; +import { Authenticated, AuthUser, SharedLinkRoute } from '../app.guard'; import { UseValidation } from '../app.utils'; import { UUIDParamDto } from './dto/uuid-param.dto'; @@ -36,6 +38,12 @@ export class AlbumController { return this.service.create(authUser, dto); } + @SharedLinkRoute() + @Get(':id') + getAlbumInfo(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto) { + return this.service.get(authUser, id); + } + @Patch(':id') updateAlbumInfo(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto, @Body() dto: UpdateAlbumDto) { return this.service.update(authUser, id, dto); @@ -46,6 +54,25 @@ export class AlbumController { return this.service.delete(authUser, id); } + @SharedLinkRoute() + @Put(':id/assets') + addAssetsToAlbum( + @AuthUser() authUser: AuthUserDto, + @Param() { id }: UUIDParamDto, + @Body() dto: BulkIdsDto, + ): Promise { + return this.service.addAssets(authUser, id, dto); + } + + @Delete(':id/assets') + removeAssetFromAlbum( + @AuthUser() authUser: AuthUserDto, + @Body() dto: BulkIdsDto, + @Param() { id }: UUIDParamDto, + ): Promise { + return this.service.removeAssets(authUser, id, dto); + } + @Put(':id/users') addUsersToAlbum(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto, @Body() dto: AddUsersDto) { return this.service.addUsers(authUser, id, dto); diff --git a/server/src/infra/repositories/album.repository.ts b/server/src/infra/repositories/album.repository.ts index bc3cc8adc2..850b11f267 100644 --- a/server/src/infra/repositories/album.repository.ts +++ b/server/src/infra/repositories/album.repository.ts @@ -3,11 +3,35 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { In, IsNull, Not, Repository } from 'typeorm'; import { dataSource } from '../database.config'; -import { AlbumEntity } from '../entities'; +import { AlbumEntity, AssetEntity } from '../entities'; @Injectable() export class AlbumRepository implements IAlbumRepository { - constructor(@InjectRepository(AlbumEntity) private repository: Repository) {} + constructor( + @InjectRepository(AssetEntity) private assetRepository: Repository, + @InjectRepository(AlbumEntity) private repository: Repository, + ) {} + + getById(id: string): Promise { + return this.repository.findOne({ + where: { + id, + }, + relations: { + owner: true, + sharedUsers: true, + assets: { + exifInfo: true, + }, + sharedLinks: true, + }, + order: { + assets: { + fileCreatedAt: 'DESC', + }, + }, + }); + } getByIds(ids: string[]): Promise { return this.repository.find({ @@ -161,4 +185,46 @@ export class AlbumRepository implements IAlbumRepository { }, }); } + + /** + * Makes sure all thumbnails for albums are updated by: + * - Removing thumbnails from albums without assets + * - Removing references of thumbnails to assets outside the album + * - Setting a thumbnail when none is set and the album contains assets + * + * @returns Amount of updated album thumbnails or undefined when unknown + */ + async updateThumbnails(): Promise { + // Subquery for getting a new thumbnail. + const newThumbnail = this.assetRepository + .createQueryBuilder('assets') + .select('albums_assets2.assetsId') + .addFrom('albums_assets_assets', 'albums_assets2') + .where('albums_assets2.assetsId = assets.id') + .andWhere('albums_assets2.albumsId = "albums"."id"') // Reference to albums.id outside this query + .orderBy('assets.fileCreatedAt', 'DESC') + .limit(1); + + // Using dataSource, because there is no direct access to albums_assets_assets. + const albumHasAssets = dataSource + .createQueryBuilder() + .select('1') + .from('albums_assets_assets', 'albums_assets') + .where('"albums"."id" = "albums_assets"."albumsId"'); + + const albumContainsThumbnail = albumHasAssets + .clone() + .andWhere('"albums"."albumThumbnailAssetId" = "albums_assets"."assetsId"'); + + const updateAlbums = this.repository + .createQueryBuilder('albums') + .update(AlbumEntity) + .set({ albumThumbnailAssetId: () => `(${newThumbnail.getQuery()})` }) + .where(`"albums"."albumThumbnailAssetId" IS NULL AND EXISTS (${albumHasAssets.getQuery()})`) + .orWhere(`"albums"."albumThumbnailAssetId" IS NOT NULL AND NOT EXISTS (${albumContainsThumbnail.getQuery()})`); + + const result = await updateAlbums.execute(); + + return result.affected; + } } diff --git a/server/test/fixtures/album.stub.ts b/server/test/fixtures/album.stub.ts index 6762950d31..e86d949768 100644 --- a/server/test/fixtures/album.stub.ts +++ b/server/test/fixtures/album.stub.ts @@ -69,6 +69,19 @@ export const albumStub = { sharedLinks: [], sharedUsers: [], }), + twoAssets: Object.freeze({ + id: 'album-4a', + albumName: 'Album with two assets', + ownerId: authStub.admin.id, + owner: userStub.admin, + assets: [assetStub.image, assetStub.withLocation], + albumThumbnailAsset: assetStub.image, + albumThumbnailAssetId: assetStub.image.id, + createdAt: new Date(), + updatedAt: new Date(), + sharedLinks: [], + sharedUsers: [], + }), emptyWithInvalidThumbnail: Object.freeze({ id: 'album-5', albumName: 'Empty album with invalid thumbnail', diff --git a/server/test/repositories/album.repository.mock.ts b/server/test/repositories/album.repository.mock.ts index 662b183d31..8656fa64a1 100644 --- a/server/test/repositories/album.repository.mock.ts +++ b/server/test/repositories/album.repository.mock.ts @@ -2,6 +2,7 @@ import { IAlbumRepository } from '@app/domain'; export const newAlbumRepositoryMock = (): jest.Mocked => { return { + getById: jest.fn(), getByIds: jest.fn(), getByAssetId: jest.fn(), getAssetCountForIds: jest.fn(), @@ -15,5 +16,6 @@ export const newAlbumRepositoryMock = (): jest.Mocked => { create: jest.fn(), update: jest.fn(), delete: jest.fn(), + updateThumbnails: jest.fn(), }; }; diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 453e9ae84e..d94ecd5cb2 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -99,44 +99,6 @@ export interface APIKeyUpdateDto { */ 'name': string; } -/** - * - * @export - * @interface AddAssetsDto - */ -export interface AddAssetsDto { - /** - * - * @type {Array} - * @memberof AddAssetsDto - */ - 'assetIds': Array; -} -/** - * - * @export - * @interface AddAssetsResponseDto - */ -export interface AddAssetsResponseDto { - /** - * - * @type {AlbumResponseDto} - * @memberof AddAssetsResponseDto - */ - 'album'?: AlbumResponseDto; - /** - * - * @type {Array} - * @memberof AddAssetsResponseDto - */ - 'alreadyInAlbum': Array; - /** - * - * @type {number} - * @memberof AddAssetsResponseDto - */ - 'successfullyAdded': number; -} /** * * @export @@ -821,6 +783,19 @@ export const BulkIdResponseDtoErrorEnum = { export type BulkIdResponseDtoErrorEnum = typeof BulkIdResponseDtoErrorEnum[keyof typeof BulkIdResponseDtoErrorEnum]; +/** + * + * @export + * @interface BulkIdsDto + */ +export interface BulkIdsDto { + /** + * + * @type {Array} + * @memberof BulkIdsDto + */ + 'ids': Array; +} /** * * @export @@ -1927,19 +1902,6 @@ export interface QueueStatusDto { */ 'isPaused': boolean; } -/** - * - * @export - * @interface RemoveAssetsDto - */ -export interface RemoveAssetsDto { - /** - * - * @type {Array} - * @memberof RemoveAssetsDto - */ - 'assetIds': Array; -} /** * * @export @@ -3679,16 +3641,16 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration /** * * @param {string} id - * @param {AddAssetsDto} addAssetsDto + * @param {BulkIdsDto} bulkIdsDto * @param {string} [key] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - addAssetsToAlbum: async (id: string, addAssetsDto: AddAssetsDto, key?: string, options: AxiosRequestConfig = {}): Promise => { + addAssetsToAlbum: async (id: string, bulkIdsDto: BulkIdsDto, key?: string, options: AxiosRequestConfig = {}): Promise => { // verify required parameter 'id' is not null or undefined assertParamExists('addAssetsToAlbum', 'id', id) - // verify required parameter 'addAssetsDto' is not null or undefined - assertParamExists('addAssetsToAlbum', 'addAssetsDto', addAssetsDto) + // verify required parameter 'bulkIdsDto' is not null or undefined + assertParamExists('addAssetsToAlbum', 'bulkIdsDto', bulkIdsDto) const localVarPath = `/album/{id}/assets` .replace(`{${"id"}}`, encodeURIComponent(String(id))); // use dummy base URL string because the URL constructor only accepts absolute URLs. @@ -3722,7 +3684,7 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - localVarRequestOptions.data = serializeDataIfNeeded(addAssetsDto, localVarRequestOptions, configuration) + localVarRequestOptions.data = serializeDataIfNeeded(bulkIdsDto, localVarRequestOptions, configuration) return { url: toPathString(localVarUrlObj), @@ -3999,15 +3961,15 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration /** * * @param {string} id - * @param {RemoveAssetsDto} removeAssetsDto + * @param {BulkIdsDto} bulkIdsDto * @param {*} [options] Override http request option. * @throws {RequiredError} */ - removeAssetFromAlbum: async (id: string, removeAssetsDto: RemoveAssetsDto, options: AxiosRequestConfig = {}): Promise => { + removeAssetFromAlbum: async (id: string, bulkIdsDto: BulkIdsDto, options: AxiosRequestConfig = {}): Promise => { // verify required parameter 'id' is not null or undefined assertParamExists('removeAssetFromAlbum', 'id', id) - // verify required parameter 'removeAssetsDto' is not null or undefined - assertParamExists('removeAssetFromAlbum', 'removeAssetsDto', removeAssetsDto) + // verify required parameter 'bulkIdsDto' is not null or undefined + assertParamExists('removeAssetFromAlbum', 'bulkIdsDto', bulkIdsDto) const localVarPath = `/album/{id}/assets` .replace(`{${"id"}}`, encodeURIComponent(String(id))); // use dummy base URL string because the URL constructor only accepts absolute URLs. @@ -4037,7 +3999,7 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - localVarRequestOptions.data = serializeDataIfNeeded(removeAssetsDto, localVarRequestOptions, configuration) + localVarRequestOptions.data = serializeDataIfNeeded(bulkIdsDto, localVarRequestOptions, configuration) return { url: toPathString(localVarUrlObj), @@ -4151,13 +4113,13 @@ export const AlbumApiFp = function(configuration?: Configuration) { /** * * @param {string} id - * @param {AddAssetsDto} addAssetsDto + * @param {BulkIdsDto} bulkIdsDto * @param {string} [key] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async addAssetsToAlbum(id: string, addAssetsDto: AddAssetsDto, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.addAssetsToAlbum(id, addAssetsDto, key, options); + async addAssetsToAlbum(id: string, bulkIdsDto: BulkIdsDto, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.addAssetsToAlbum(id, bulkIdsDto, key, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** @@ -4225,12 +4187,12 @@ export const AlbumApiFp = function(configuration?: Configuration) { /** * * @param {string} id - * @param {RemoveAssetsDto} removeAssetsDto + * @param {BulkIdsDto} bulkIdsDto * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async removeAssetFromAlbum(id: string, removeAssetsDto: RemoveAssetsDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.removeAssetFromAlbum(id, removeAssetsDto, options); + async removeAssetFromAlbum(id: string, bulkIdsDto: BulkIdsDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.removeAssetFromAlbum(id, bulkIdsDto, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** @@ -4268,13 +4230,13 @@ export const AlbumApiFactory = function (configuration?: Configuration, basePath /** * * @param {string} id - * @param {AddAssetsDto} addAssetsDto + * @param {BulkIdsDto} bulkIdsDto * @param {string} [key] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - addAssetsToAlbum(id: string, addAssetsDto: AddAssetsDto, key?: string, options?: any): AxiosPromise { - return localVarFp.addAssetsToAlbum(id, addAssetsDto, key, options).then((request) => request(axios, basePath)); + addAssetsToAlbum(id: string, bulkIdsDto: BulkIdsDto, key?: string, options?: any): AxiosPromise> { + return localVarFp.addAssetsToAlbum(id, bulkIdsDto, key, options).then((request) => request(axios, basePath)); }, /** * @@ -4335,12 +4297,12 @@ export const AlbumApiFactory = function (configuration?: Configuration, basePath /** * * @param {string} id - * @param {RemoveAssetsDto} removeAssetsDto + * @param {BulkIdsDto} bulkIdsDto * @param {*} [options] Override http request option. * @throws {RequiredError} */ - removeAssetFromAlbum(id: string, removeAssetsDto: RemoveAssetsDto, options?: any): AxiosPromise { - return localVarFp.removeAssetFromAlbum(id, removeAssetsDto, options).then((request) => request(axios, basePath)); + removeAssetFromAlbum(id: string, bulkIdsDto: BulkIdsDto, options?: any): AxiosPromise> { + return localVarFp.removeAssetFromAlbum(id, bulkIdsDto, options).then((request) => request(axios, basePath)); }, /** * @@ -4380,10 +4342,10 @@ export interface AlbumApiAddAssetsToAlbumRequest { /** * - * @type {AddAssetsDto} + * @type {BulkIdsDto} * @memberof AlbumApiAddAssetsToAlbum */ - readonly addAssetsDto: AddAssetsDto + readonly bulkIdsDto: BulkIdsDto /** * @@ -4499,10 +4461,10 @@ export interface AlbumApiRemoveAssetFromAlbumRequest { /** * - * @type {RemoveAssetsDto} + * @type {BulkIdsDto} * @memberof AlbumApiRemoveAssetFromAlbum */ - readonly removeAssetsDto: RemoveAssetsDto + readonly bulkIdsDto: BulkIdsDto } /** @@ -4562,7 +4524,7 @@ export class AlbumApi extends BaseAPI { * @memberof AlbumApi */ public addAssetsToAlbum(requestParameters: AlbumApiAddAssetsToAlbumRequest, options?: AxiosRequestConfig) { - return AlbumApiFp(this.configuration).addAssetsToAlbum(requestParameters.id, requestParameters.addAssetsDto, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); + return AlbumApiFp(this.configuration).addAssetsToAlbum(requestParameters.id, requestParameters.bulkIdsDto, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); } /** @@ -4638,7 +4600,7 @@ export class AlbumApi extends BaseAPI { * @memberof AlbumApi */ public removeAssetFromAlbum(requestParameters: AlbumApiRemoveAssetFromAlbumRequest, options?: AxiosRequestConfig) { - return AlbumApiFp(this.configuration).removeAssetFromAlbum(requestParameters.id, requestParameters.removeAssetsDto, options).then((request) => request(this.axios, this.basePath)); + return AlbumApiFp(this.configuration).removeAssetFromAlbum(requestParameters.id, requestParameters.bulkIdsDto, options).then((request) => request(this.axios, this.basePath)); } /** diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte index 2c6fa6232c..41cdb7fb07 100644 --- a/web/src/lib/components/album-page/album-viewer.svelte +++ b/web/src/lib/components/album-page/album-viewer.svelte @@ -92,6 +92,7 @@ let multiSelectAsset: Set = new Set(); $: isMultiSelectionMode = multiSelectAsset.size > 0; + $: isMultiSelectionUserOwned = Array.from(multiSelectAsset).every((asset) => asset.ownerId === currentUser?.id); afterNavigate(({ from }) => { backUrl = from?.url.pathname ?? '/albums'; @@ -182,24 +183,24 @@ const createAlbumHandler = async (event: CustomEvent) => { const { assets }: { assets: AssetResponseDto[] } = event.detail; try { - const { data } = await api.albumApi.addAssetsToAlbum({ + const { data: results } = await api.albumApi.addAssetsToAlbum({ id: album.id, - addAssetsDto: { - assetIds: assets.map((a) => a.id), - }, + bulkIdsDto: { ids: assets.map((a) => a.id) }, key: sharedLink?.key, }); - if (data.album) { - album = data.album; - } + const count = results.filter(({ success }) => success).length; + notificationController.show({ + type: NotificationType.Info, + message: `Added ${count} asset${count === 1 ? '' : 's'}`, + }); + + const { data } = await api.albumApi.getAlbumInfo({ id: album.id }); + album = data; + isShowAssetSelection = false; } catch (e) { - console.error('Error [createAlbumHandler] ', e); - notificationController.show({ - type: NotificationType.Error, - message: 'Error creating album, check console for more details', - }); + handleError(e, 'Error creating album'); } }; @@ -307,7 +308,7 @@ {#if sharedLink?.allowDownload || !isPublicShared} {/if} - {#if isOwned} + {#if isOwned || isMultiSelectionUserOwned} {/if} diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 094ed09269..d41419ffc7 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -189,11 +189,8 @@ isShowAlbumPicker = false; const album = event.detail.album; - addAssetsToAlbum(album.id, [asset.id]).then((dto) => { - if (dto.successfullyAdded === 1 && dto.album) { - appearsInAlbums = [...appearsInAlbums, dto.album]; - } - }); + await addAssetsToAlbum(album.id, [asset.id]); + await getAllAlbums(); }; const disableKeyDownEvent = () => { diff --git a/web/src/lib/components/photos-page/actions/add-to-album.svelte b/web/src/lib/components/photos-page/actions/add-to-album.svelte index 90e2ceff45..a205d83927 100644 --- a/web/src/lib/components/photos-page/actions/add-to-album.svelte +++ b/web/src/lib/components/photos-page/actions/add-to-album.svelte @@ -44,10 +44,9 @@ const handleAddToAlbum = async (event: CustomEvent<{ album: AlbumResponseDto }>) => { showAlbumPicker = false; const album = event.detail.album; - const assetIds = Array.from(getAssets()).map((asset) => asset.id); - - addAssetsToAlbum(album.id, assetIds).then(clearSelect); + await addAssetsToAlbum(album.id, assetIds); + clearSelect(); }; diff --git a/web/src/lib/components/photos-page/actions/remove-from-album.svelte b/web/src/lib/components/photos-page/actions/remove-from-album.svelte index 1a553e031f..9067cc6e3f 100644 --- a/web/src/lib/components/photos-page/actions/remove-from-album.svelte +++ b/web/src/lib/components/photos-page/actions/remove-from-album.svelte @@ -17,14 +17,20 @@ const removeFromAlbum = async () => { try { - const { data } = await api.albumApi.removeAssetFromAlbum({ + const { data: results } = await api.albumApi.removeAssetFromAlbum({ id: album.id, - removeAssetsDto: { - assetIds: Array.from(getAssets()).map((a) => a.id), - }, + bulkIdsDto: { ids: Array.from(getAssets()).map((a) => a.id) }, }); + const { data } = await api.albumApi.getAlbumInfo({ id: album.id }); album = data; + + const count = results.filter(({ success }) => success).length; + notificationController.show({ + type: NotificationType.Info, + message: `Removed ${count} asset${count === 1 ? '' : 's'}`, + }); + clearSelect(); } catch (e) { console.error('Error [album-viewer] [removeAssetFromAlbum]', e); diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index 0be85fa89b..cd8c87d312 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -1,23 +1,24 @@ import { notificationController, NotificationType } from '$lib/components/shared-components/notification/notification'; import { downloadManager } from '$lib/stores/download'; -import { AddAssetsResponseDto, api, AssetApiGetDownloadInfoRequest, AssetResponseDto, DownloadResponseDto } from '@api'; +import { api, AssetApiGetDownloadInfoRequest, BulkIdResponseDto, AssetResponseDto, DownloadResponseDto } from '@api'; import { handleError } from './handle-error'; export const addAssetsToAlbum = async ( albumId: string, assetIds: Array, key: string | undefined = undefined, -): Promise => - api.albumApi.addAssetsToAlbum({ id: albumId, addAssetsDto: { assetIds }, key }).then(({ data: dto }) => { - if (dto.successfullyAdded > 0) { +): Promise => + api.albumApi.addAssetsToAlbum({ id: albumId, bulkIdsDto: { ids: assetIds }, key }).then(({ data: results }) => { + const count = results.filter(({ success }) => success).length; + if (count > 0) { // This might be 0 if the user tries to add an asset that is already in the album notificationController.show({ - message: `Added ${dto.successfullyAdded} to ${dto.album?.albumName}`, type: NotificationType.Info, + message: `Added ${count} asset${count === 1 ? '' : 's'}`, }); } - return dto; + return results; }); const downloadBlob = (data: Blob, filename: string) => {