فهرست منبع

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 <Alex.Tran@conductix.com>
Jason Rasmussen 1 سال پیش
والد
کامیت
b9cda59172
51فایلهای تغییر یافته به همراه890 افزوده شده و 1282 حذف شده
  1. 39 77
      cli/src/api/open-api/api.ts
  2. 49 0
      mobile/lib/modules/album/models/add_asset_response.model.dart
  3. 21 0
      mobile/lib/modules/album/providers/album_detail.provider.dart
  4. 0 18
      mobile/lib/modules/album/providers/shared_album.provider.dart
  5. 28 8
      mobile/lib/modules/album/services/album.service.dart
  6. 2 3
      mobile/lib/modules/album/ui/add_to_album_bottom_sheet.dart
  7. 2 1
      mobile/lib/modules/album/ui/album_viewer_appbar.dart
  8. 13 6
      mobile/lib/modules/album/views/album_viewer_page.dart
  9. 4 0
      mobile/lib/modules/home/views/home_page.dart
  10. 3 9
      mobile/openapi/.openapi-generator/FILES
  11. 1 3
      mobile/openapi/README.md
  12. 0 17
      mobile/openapi/doc/AddAssetsResponseDto.md
  13. 10 10
      mobile/openapi/doc/AlbumApi.md
  14. 2 2
      mobile/openapi/doc/BulkIdsDto.md
  15. 0 15
      mobile/openapi/doc/RemoveAssetsDto.md
  16. 1 3
      mobile/openapi/lib/api.dart
  17. 22 16
      mobile/openapi/lib/api/album_api.dart
  18. 2 6
      mobile/openapi/lib/api_client.dart
  19. 0 125
      mobile/openapi/lib/model/add_assets_response_dto.dart
  20. 26 26
      mobile/openapi/lib/model/bulk_ids_dto.dart
  21. 0 100
      mobile/openapi/lib/model/remove_assets_dto.dart
  22. 0 37
      mobile/openapi/test/add_assets_response_dto_test.dart
  23. 2 2
      mobile/openapi/test/album_api_test.dart
  24. 5 5
      mobile/openapi/test/bulk_ids_dto_test.dart
  25. 0 27
      mobile/openapi/test/remove_assets_dto_test.dart
  26. 25 55
      server/immich-openapi-specs.json
  27. 27 10
      server/src/domain/access/access.core.ts
  28. 2 0
      server/src/domain/album/album.repository.ts
  29. 315 17
      server/src/domain/album/album.service.spec.ts
  30. 95 18
      server/src/domain/album/album.service.ts
  31. 7 0
      server/src/domain/asset/response-dto/asset-ids-response.dto.ts
  32. 0 132
      server/src/immich/api-v1/album/album-repository.ts
  33. 0 45
      server/src/immich/api-v1/album/album.controller.ts
  34. 0 13
      server/src/immich/api-v1/album/album.module.ts
  35. 0 258
      server/src/immich/api-v1/album/album.service.spec.ts
  36. 0 72
      server/src/immich/api-v1/album/album.service.ts
  37. 0 6
      server/src/immich/api-v1/album/dto/add-assets.dto.ts
  38. 0 6
      server/src/immich/api-v1/album/dto/add-users.dto.ts
  39. 0 6
      server/src/immich/api-v1/album/dto/remove-assets.dto.ts
  40. 0 13
      server/src/immich/api-v1/album/response-dto/add-assets-response.dto.ts
  41. 0 2
      server/src/immich/app.module.ts
  42. 28 1
      server/src/immich/controllers/album.controller.ts
  43. 68 2
      server/src/infra/repositories/album.repository.ts
  44. 13 0
      server/test/fixtures/album.stub.ts
  45. 2 0
      server/test/repositories/album.repository.mock.ts
  46. 41 79
      web/src/api/open-api/api.ts
  47. 14 13
      web/src/lib/components/album-page/album-viewer.svelte
  48. 2 5
      web/src/lib/components/asset-viewer/asset-viewer.svelte
  49. 2 3
      web/src/lib/components/photos-page/actions/add-to-album.svelte
  50. 10 4
      web/src/lib/components/photos-page/actions/remove-from-album.svelte
  51. 7 6
      web/src/lib/utils/asset-utils.ts

+ 39 - 77
cli/src/api/open-api/api.ts

@@ -99,44 +99,6 @@ export interface APIKeyUpdateDto {
      */
     'name': string;
 }
-/**
- * 
- * @export
- * @interface AddAssetsDto
- */
-export interface AddAssetsDto {
-    /**
-     * 
-     * @type {Array<string>}
-     * @memberof AddAssetsDto
-     */
-    'assetIds': Array<string>;
-}
-/**
- * 
- * @export
- * @interface AddAssetsResponseDto
- */
-export interface AddAssetsResponseDto {
-    /**
-     * 
-     * @type {AlbumResponseDto}
-     * @memberof AddAssetsResponseDto
-     */
-    'album'?: AlbumResponseDto;
-    /**
-     * 
-     * @type {Array<string>}
-     * @memberof AddAssetsResponseDto
-     */
-    'alreadyInAlbum': Array<string>;
-    /**
-     * 
-     * @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<string>}
+     * @memberof BulkIdsDto
+     */
+    'ids': Array<string>;
+}
 /**
  * 
  * @export
@@ -1927,19 +1902,6 @@ export interface QueueStatusDto {
      */
     'isPaused': boolean;
 }
-/**
- * 
- * @export
- * @interface RemoveAssetsDto
- */
-export interface RemoveAssetsDto {
-    /**
-     * 
-     * @type {Array<string>}
-     * @memberof RemoveAssetsDto
-     */
-    'assetIds': Array<string>;
-}
 /**
  * 
  * @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<RequestArgs> => {
+        addAssetsToAlbum: async (id: string, bulkIdsDto: BulkIdsDto, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
             // 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<RequestArgs> => {
+        removeAssetFromAlbum: async (id: string, bulkIdsDto: BulkIdsDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
             // 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<AddAssetsResponseDto>> {
-            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<Array<BulkIdResponseDto>>> {
+            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<AlbumResponseDto>> {
-            const localVarAxiosArgs = await localVarAxiosParamCreator.removeAssetFromAlbum(id, removeAssetsDto, options);
+        async removeAssetFromAlbum(id: string, bulkIdsDto: BulkIdsDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<BulkIdResponseDto>>> {
+            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<AddAssetsResponseDto> {
-            return localVarFp.addAssetsToAlbum(requestParameters.id, requestParameters.addAssetsDto, requestParameters.key, options).then((request) => request(axios, basePath));
+        addAssetsToAlbum(requestParameters: AlbumApiAddAssetsToAlbumRequest, options?: AxiosRequestConfig): AxiosPromise<Array<BulkIdResponseDto>> {
+            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<AlbumResponseDto> {
-            return localVarFp.removeAssetFromAlbum(requestParameters.id, requestParameters.removeAssetsDto, options).then((request) => request(axios, basePath));
+        removeAssetFromAlbum(requestParameters: AlbumApiRemoveAssetFromAlbumRequest, options?: AxiosRequestConfig): AxiosPromise<Array<BulkIdResponseDto>> {
+            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));
     }
 
     /**

+ 49 - 0
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<String> alreadyInAlbum;
+  int successfullyAdded;
+
+  AddAssetsResponse({
+    required this.alreadyInAlbum,
+    required this.successfullyAdded,
+  });
+
+  AddAssetsResponse copyWith({
+    List<String>? alreadyInAlbum,
+    int? successfullyAdded,
+  }) {
+    return AddAssetsResponse(
+      alreadyInAlbum: alreadyInAlbum ?? this.alreadyInAlbum,
+      successfullyAdded: successfullyAdded ?? this.successfullyAdded,
+    );
+  }
+
+  Map<String, dynamic> toMap() {
+    return <String, dynamic>{
+      '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;
+}

+ 21 - 0
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<Album, int>((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;
+    }
+  }
+});

+ 0 - 18
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<List<Album>> {
@@ -72,19 +70,3 @@ final sharedAlbumProvider =
     ref.watch(dbProvider),
   );
 });
-
-final sharedAlbumDetailProvider =
-    StreamProvider.family<Album, int>((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;
-    }
-  }
-});

+ 28 - 8
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<AddAssetsResponseDto?> addAdditionalAssetToAlbum(
+  Future<AddAssetsResponse?> addAdditionalAssetToAlbum(
     Iterable<Asset> 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<Asset> successAssets = [];
+        List<String> 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<bool> 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);

+ 2 - 3
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);
     }
 

+ 2 - 1
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(

+ 13 - 6
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<Set<Asset>>({});
     final multiSelectEnabled = useState(false);
 
+    useEffect(
+      () {
+        // Fetch album updates, e.g., cover image
+        ref.invalidate(albumDetailProvider(albumId));
+        return null;
+      },
+      [],
+    );
+
     Future<bool> 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();

+ 4 - 0
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 {

+ 3 - 9
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

+ 1 - 3
mobile/openapi/README.md

@@ -182,8 +182,6 @@ Class | Method | HTTP request | Description
  - [APIKeyCreateResponseDto](doc//APIKeyCreateResponseDto.md)
  - [APIKeyResponseDto](doc//APIKeyResponseDto.md)
  - [APIKeyUpdateDto](doc//APIKeyUpdateDto.md)
- - [AddAssetsDto](doc//AddAssetsDto.md)
- - [AddAssetsResponseDto](doc//AddAssetsResponseDto.md)
  - [AddUsersDto](doc//AddUsersDto.md)
  - [AdminSignupResponseDto](doc//AdminSignupResponseDto.md)
  - [AlbumCountResponseDto](doc//AlbumCountResponseDto.md)
@@ -204,6 +202,7 @@ Class | Method | HTTP request | Description
  - [AudioCodec](doc//AudioCodec.md)
  - [AuthDeviceResponseDto](doc//AuthDeviceResponseDto.md)
  - [BulkIdResponseDto](doc//BulkIdResponseDto.md)
+ - [BulkIdsDto](doc//BulkIdsDto.md)
  - [ChangePasswordDto](doc//ChangePasswordDto.md)
  - [CheckDuplicateAssetDto](doc//CheckDuplicateAssetDto.md)
  - [CheckDuplicateAssetResponseDto](doc//CheckDuplicateAssetResponseDto.md)
@@ -245,7 +244,6 @@ Class | Method | HTTP request | Description
  - [PersonResponseDto](doc//PersonResponseDto.md)
  - [PersonUpdateDto](doc//PersonUpdateDto.md)
  - [QueueStatusDto](doc//QueueStatusDto.md)
- - [RemoveAssetsDto](doc//RemoveAssetsDto.md)
  - [SearchAlbumResponseDto](doc//SearchAlbumResponseDto.md)
  - [SearchAssetDto](doc//SearchAssetDto.md)
  - [SearchAssetResponseDto](doc//SearchAssetResponseDto.md)

+ 0 - 17
mobile/openapi/doc/AddAssetsResponseDto.md

@@ -1,17 +0,0 @@
-# openapi.model.AddAssetsResponseDto
-
-## Load the model package
-```dart
-import 'package:openapi/api.dart';
-```
-
-## Properties
-Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
-**album** | [**AlbumResponseDto**](AlbumResponseDto.md) |  | [optional] 
-**alreadyInAlbum** | **List<String>** |  | [default to const []]
-**successfullyAdded** | **int** |  | 
-
-[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
-
-

+ 10 - 10
mobile/openapi/doc/AlbumApi.md

@@ -22,7 +22,7 @@ Method | HTTP request | Description
 
 
 # **addAssetsToAlbum**
-> AddAssetsResponseDto addAssetsToAlbum(id, addAssetsDto, key)
+> List<BulkIdResponseDto> addAssetsToAlbum(id, bulkIdsDto, key)
 
 
 
@@ -46,11 +46,11 @@ import 'package:openapi/api.dart';
 
 final api_instance = AlbumApi();
 final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | 
-final addAssetsDto = AddAssetsDto(); // AddAssetsDto | 
+final bulkIdsDto = BulkIdsDto(); // BulkIdsDto | 
 final key = key_example; // String | 
 
 try {
-    final result = api_instance.addAssetsToAlbum(id, addAssetsDto, key);
+    final result = api_instance.addAssetsToAlbum(id, bulkIdsDto, key);
     print(result);
 } catch (e) {
     print('Exception when calling AlbumApi->addAssetsToAlbum: $e\n');
@@ -62,12 +62,12 @@ try {
 Name | Type | Description  | Notes
 ------------- | ------------- | ------------- | -------------
  **id** | **String**|  | 
- **addAssetsDto** | [**AddAssetsDto**](AddAssetsDto.md)|  | 
+ **bulkIdsDto** | [**BulkIdsDto**](BulkIdsDto.md)|  | 
  **key** | **String**|  | [optional] 
 
 ### Return type
 
-[**AddAssetsResponseDto**](AddAssetsResponseDto.md)
+[**List<BulkIdResponseDto>**](BulkIdResponseDto.md)
 
 ### Authorization
 
@@ -412,7 +412,7 @@ Name | Type | Description  | Notes
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 
 # **removeAssetFromAlbum**
-> AlbumResponseDto removeAssetFromAlbum(id, removeAssetsDto)
+> List<BulkIdResponseDto> removeAssetFromAlbum(id, bulkIdsDto)
 
 
 
@@ -436,10 +436,10 @@ import 'package:openapi/api.dart';
 
 final api_instance = AlbumApi();
 final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | 
-final removeAssetsDto = RemoveAssetsDto(); // RemoveAssetsDto | 
+final bulkIdsDto = BulkIdsDto(); // BulkIdsDto | 
 
 try {
-    final result = api_instance.removeAssetFromAlbum(id, removeAssetsDto);
+    final result = api_instance.removeAssetFromAlbum(id, bulkIdsDto);
     print(result);
 } catch (e) {
     print('Exception when calling AlbumApi->removeAssetFromAlbum: $e\n');
@@ -451,11 +451,11 @@ try {
 Name | Type | Description  | Notes
 ------------- | ------------- | ------------- | -------------
  **id** | **String**|  | 
- **removeAssetsDto** | [**RemoveAssetsDto**](RemoveAssetsDto.md)|  | 
+ **bulkIdsDto** | [**BulkIdsDto**](BulkIdsDto.md)|  | 
 
 ### Return type
 
-[**AlbumResponseDto**](AlbumResponseDto.md)
+[**List<BulkIdResponseDto>**](BulkIdResponseDto.md)
 
 ### Authorization
 

+ 2 - 2
mobile/openapi/doc/AddAssetsDto.md → mobile/openapi/doc/BulkIdsDto.md

@@ -1,4 +1,4 @@
-# openapi.model.AddAssetsDto
+# openapi.model.BulkIdsDto
 
 ## Load the model package
 ```dart
@@ -8,7 +8,7 @@ import 'package:openapi/api.dart';
 ## Properties
 Name | Type | Description | Notes
 ------------ | ------------- | ------------- | -------------
-**assetIds** | **List<String>** |  | [default to const []]
+**ids** | **List<String>** |  | [default to const []]
 
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 

+ 0 - 15
mobile/openapi/doc/RemoveAssetsDto.md

@@ -1,15 +0,0 @@
-# openapi.model.RemoveAssetsDto
-
-## Load the model package
-```dart
-import 'package:openapi/api.dart';
-```
-
-## Properties
-Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
-**assetIds** | **List<String>** |  | [default to const []]
-
-[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
-
-

+ 1 - 3
mobile/openapi/lib/api.dart

@@ -47,8 +47,6 @@ part 'model/api_key_create_dto.dart';
 part 'model/api_key_create_response_dto.dart';
 part 'model/api_key_response_dto.dart';
 part 'model/api_key_update_dto.dart';
-part 'model/add_assets_dto.dart';
-part 'model/add_assets_response_dto.dart';
 part 'model/add_users_dto.dart';
 part 'model/admin_signup_response_dto.dart';
 part 'model/album_count_response_dto.dart';
@@ -69,6 +67,7 @@ part 'model/asset_type_enum.dart';
 part 'model/audio_codec.dart';
 part 'model/auth_device_response_dto.dart';
 part 'model/bulk_id_response_dto.dart';
+part 'model/bulk_ids_dto.dart';
 part 'model/change_password_dto.dart';
 part 'model/check_duplicate_asset_dto.dart';
 part 'model/check_duplicate_asset_response_dto.dart';
@@ -110,7 +109,6 @@ part 'model/people_update_item.dart';
 part 'model/person_response_dto.dart';
 part 'model/person_update_dto.dart';
 part 'model/queue_status_dto.dart';
-part 'model/remove_assets_dto.dart';
 part 'model/search_album_response_dto.dart';
 part 'model/search_asset_dto.dart';
 part 'model/search_asset_response_dto.dart';

+ 22 - 16
mobile/openapi/lib/api/album_api.dart

@@ -21,16 +21,16 @@ class AlbumApi {
   ///
   /// * [String] id (required):
   ///
-  /// * [AddAssetsDto] addAssetsDto (required):
+  /// * [BulkIdsDto] bulkIdsDto (required):
   ///
   /// * [String] key:
-  Future<Response> addAssetsToAlbumWithHttpInfo(String id, AddAssetsDto addAssetsDto, { String? key, }) async {
+  Future<Response> addAssetsToAlbumWithHttpInfo(String id, BulkIdsDto bulkIdsDto, { String? key, }) async {
     // ignore: prefer_const_declarations
     final path = r'/album/{id}/assets'
       .replaceAll('{id}', id);
 
     // ignore: prefer_final_locals
-    Object? postBody = addAssetsDto;
+    Object? postBody = bulkIdsDto;
 
     final queryParams = <QueryParam>[];
     final headerParams = <String, String>{};
@@ -58,11 +58,11 @@ class AlbumApi {
   ///
   /// * [String] id (required):
   ///
-  /// * [AddAssetsDto] addAssetsDto (required):
+  /// * [BulkIdsDto] bulkIdsDto (required):
   ///
   /// * [String] key:
-  Future<AddAssetsResponseDto?> addAssetsToAlbum(String id, AddAssetsDto addAssetsDto, { String? key, }) async {
-    final response = await addAssetsToAlbumWithHttpInfo(id, addAssetsDto,  key: key, );
+  Future<List<BulkIdResponseDto>?> addAssetsToAlbum(String id, BulkIdsDto bulkIdsDto, { String? key, }) async {
+    final response = await addAssetsToAlbumWithHttpInfo(id, bulkIdsDto,  key: key, );
     if (response.statusCode >= HttpStatus.badRequest) {
       throw ApiException(response.statusCode, await _decodeBodyBytes(response));
     }
@@ -70,8 +70,11 @@ class AlbumApi {
     // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
     // FormatException when trying to decode an empty string.
     if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
-      return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AddAssetsResponseDto',) as AddAssetsResponseDto;
-    
+      final responseBody = await _decodeBodyBytes(response);
+      return (await apiClient.deserializeAsync(responseBody, 'List<BulkIdResponseDto>') as List)
+        .cast<BulkIdResponseDto>()
+        .toList();
+
     }
     return null;
   }
@@ -380,14 +383,14 @@ class AlbumApi {
   ///
   /// * [String] id (required):
   ///
-  /// * [RemoveAssetsDto] removeAssetsDto (required):
-  Future<Response> removeAssetFromAlbumWithHttpInfo(String id, RemoveAssetsDto removeAssetsDto,) async {
+  /// * [BulkIdsDto] bulkIdsDto (required):
+  Future<Response> removeAssetFromAlbumWithHttpInfo(String id, BulkIdsDto bulkIdsDto,) async {
     // ignore: prefer_const_declarations
     final path = r'/album/{id}/assets'
       .replaceAll('{id}', id);
 
     // ignore: prefer_final_locals
-    Object? postBody = removeAssetsDto;
+    Object? postBody = bulkIdsDto;
 
     final queryParams = <QueryParam>[];
     final headerParams = <String, String>{};
@@ -411,9 +414,9 @@ class AlbumApi {
   ///
   /// * [String] id (required):
   ///
-  /// * [RemoveAssetsDto] removeAssetsDto (required):
-  Future<AlbumResponseDto?> removeAssetFromAlbum(String id, RemoveAssetsDto removeAssetsDto,) async {
-    final response = await removeAssetFromAlbumWithHttpInfo(id, removeAssetsDto,);
+  /// * [BulkIdsDto] bulkIdsDto (required):
+  Future<List<BulkIdResponseDto>?> removeAssetFromAlbum(String id, BulkIdsDto bulkIdsDto,) async {
+    final response = await removeAssetFromAlbumWithHttpInfo(id, bulkIdsDto,);
     if (response.statusCode >= HttpStatus.badRequest) {
       throw ApiException(response.statusCode, await _decodeBodyBytes(response));
     }
@@ -421,8 +424,11 @@ class AlbumApi {
     // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
     // FormatException when trying to decode an empty string.
     if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
-      return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AlbumResponseDto',) as AlbumResponseDto;
-    
+      final responseBody = await _decodeBodyBytes(response);
+      return (await apiClient.deserializeAsync(responseBody, 'List<BulkIdResponseDto>') as List)
+        .cast<BulkIdResponseDto>()
+        .toList();
+
     }
     return null;
   }

+ 2 - 6
mobile/openapi/lib/api_client.dart

@@ -189,10 +189,6 @@ class ApiClient {
           return APIKeyResponseDto.fromJson(value);
         case 'APIKeyUpdateDto':
           return APIKeyUpdateDto.fromJson(value);
-        case 'AddAssetsDto':
-          return AddAssetsDto.fromJson(value);
-        case 'AddAssetsResponseDto':
-          return AddAssetsResponseDto.fromJson(value);
         case 'AddUsersDto':
           return AddUsersDto.fromJson(value);
         case 'AdminSignupResponseDto':
@@ -233,6 +229,8 @@ class ApiClient {
           return AuthDeviceResponseDto.fromJson(value);
         case 'BulkIdResponseDto':
           return BulkIdResponseDto.fromJson(value);
+        case 'BulkIdsDto':
+          return BulkIdsDto.fromJson(value);
         case 'ChangePasswordDto':
           return ChangePasswordDto.fromJson(value);
         case 'CheckDuplicateAssetDto':
@@ -315,8 +313,6 @@ class ApiClient {
           return PersonUpdateDto.fromJson(value);
         case 'QueueStatusDto':
           return QueueStatusDto.fromJson(value);
-        case 'RemoveAssetsDto':
-          return RemoveAssetsDto.fromJson(value);
         case 'SearchAlbumResponseDto':
           return SearchAlbumResponseDto.fromJson(value);
         case 'SearchAssetDto':

+ 0 - 125
mobile/openapi/lib/model/add_assets_response_dto.dart

@@ -1,125 +0,0 @@
-//
-// AUTO-GENERATED FILE, DO NOT MODIFY!
-//
-// @dart=2.12
-
-// ignore_for_file: unused_element, unused_import
-// ignore_for_file: always_put_required_named_parameters_first
-// ignore_for_file: constant_identifier_names
-// ignore_for_file: lines_longer_than_80_chars
-
-part of openapi.api;
-
-class AddAssetsResponseDto {
-  /// Returns a new [AddAssetsResponseDto] instance.
-  AddAssetsResponseDto({
-    this.album,
-    this.alreadyInAlbum = const [],
-    required this.successfullyAdded,
-  });
-
-  ///
-  /// Please note: This property should have been non-nullable! Since the specification file
-  /// does not include a default value (using the "default:" property), however, the generated
-  /// source code must fall back to having a nullable type.
-  /// Consider adding a "default:" property in the specification file to hide this note.
-  ///
-  AlbumResponseDto? album;
-
-  List<String> alreadyInAlbum;
-
-  int successfullyAdded;
-
-  @override
-  bool operator ==(Object other) => identical(this, other) || other is AddAssetsResponseDto &&
-     other.album == album &&
-     other.alreadyInAlbum == alreadyInAlbum &&
-     other.successfullyAdded == successfullyAdded;
-
-  @override
-  int get hashCode =>
-    // ignore: unnecessary_parenthesis
-    (album == null ? 0 : album!.hashCode) +
-    (alreadyInAlbum.hashCode) +
-    (successfullyAdded.hashCode);
-
-  @override
-  String toString() => 'AddAssetsResponseDto[album=$album, alreadyInAlbum=$alreadyInAlbum, successfullyAdded=$successfullyAdded]';
-
-  Map<String, dynamic> toJson() {
-    final json = <String, dynamic>{};
-    if (this.album != null) {
-      json[r'album'] = this.album;
-    } else {
-    //  json[r'album'] = null;
-    }
-      json[r'alreadyInAlbum'] = this.alreadyInAlbum;
-      json[r'successfullyAdded'] = this.successfullyAdded;
-    return json;
-  }
-
-  /// Returns a new [AddAssetsResponseDto] instance and imports its values from
-  /// [value] if it's a [Map], null otherwise.
-  // ignore: prefer_constructors_over_static_methods
-  static AddAssetsResponseDto? fromJson(dynamic value) {
-    if (value is Map) {
-      final json = value.cast<String, dynamic>();
-
-      return AddAssetsResponseDto(
-        album: AlbumResponseDto.fromJson(json[r'album']),
-        alreadyInAlbum: json[r'alreadyInAlbum'] is Iterable
-            ? (json[r'alreadyInAlbum'] as Iterable).cast<String>().toList(growable: false)
-            : const [],
-        successfullyAdded: mapValueOfType<int>(json, r'successfullyAdded')!,
-      );
-    }
-    return null;
-  }
-
-  static List<AddAssetsResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
-    final result = <AddAssetsResponseDto>[];
-    if (json is List && json.isNotEmpty) {
-      for (final row in json) {
-        final value = AddAssetsResponseDto.fromJson(row);
-        if (value != null) {
-          result.add(value);
-        }
-      }
-    }
-    return result.toList(growable: growable);
-  }
-
-  static Map<String, AddAssetsResponseDto> mapFromJson(dynamic json) {
-    final map = <String, AddAssetsResponseDto>{};
-    if (json is Map && json.isNotEmpty) {
-      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
-      for (final entry in json.entries) {
-        final value = AddAssetsResponseDto.fromJson(entry.value);
-        if (value != null) {
-          map[entry.key] = value;
-        }
-      }
-    }
-    return map;
-  }
-
-  // maps a json object with a list of AddAssetsResponseDto-objects as value to a dart map
-  static Map<String, List<AddAssetsResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
-    final map = <String, List<AddAssetsResponseDto>>{};
-    if (json is Map && json.isNotEmpty) {
-      // ignore: parameter_assignments
-      json = json.cast<String, dynamic>();
-      for (final entry in json.entries) {
-        map[entry.key] = AddAssetsResponseDto.listFromJson(entry.value, growable: growable,);
-      }
-    }
-    return map;
-  }
-
-  /// The list of required keys that must be present in a JSON.
-  static const requiredKeys = <String>{
-    'alreadyInAlbum',
-    'successfullyAdded',
-  };
-}
-

+ 26 - 26
mobile/openapi/lib/model/add_assets_dto.dart → mobile/openapi/lib/model/bulk_ids_dto.dart

@@ -10,53 +10,53 @@
 
 part of openapi.api;
 
-class AddAssetsDto {
-  /// Returns a new [AddAssetsDto] instance.
-  AddAssetsDto({
-    this.assetIds = const [],
+class BulkIdsDto {
+  /// Returns a new [BulkIdsDto] instance.
+  BulkIdsDto({
+    this.ids = const [],
   });
 
-  List<String> assetIds;
+  List<String> ids;
 
   @override
-  bool operator ==(Object other) => identical(this, other) || other is AddAssetsDto &&
-     other.assetIds == assetIds;
+  bool operator ==(Object other) => identical(this, other) || other is BulkIdsDto &&
+     other.ids == ids;
 
   @override
   int get hashCode =>
     // ignore: unnecessary_parenthesis
-    (assetIds.hashCode);
+    (ids.hashCode);
 
   @override
-  String toString() => 'AddAssetsDto[assetIds=$assetIds]';
+  String toString() => 'BulkIdsDto[ids=$ids]';
 
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
-      json[r'assetIds'] = this.assetIds;
+      json[r'ids'] = this.ids;
     return json;
   }
 
-  /// Returns a new [AddAssetsDto] instance and imports its values from
+  /// Returns a new [BulkIdsDto] instance and imports its values from
   /// [value] if it's a [Map], null otherwise.
   // ignore: prefer_constructors_over_static_methods
-  static AddAssetsDto? fromJson(dynamic value) {
+  static BulkIdsDto? fromJson(dynamic value) {
     if (value is Map) {
       final json = value.cast<String, dynamic>();
 
-      return AddAssetsDto(
-        assetIds: json[r'assetIds'] is Iterable
-            ? (json[r'assetIds'] as Iterable).cast<String>().toList(growable: false)
+      return BulkIdsDto(
+        ids: json[r'ids'] is Iterable
+            ? (json[r'ids'] as Iterable).cast<String>().toList(growable: false)
             : const [],
       );
     }
     return null;
   }
 
-  static List<AddAssetsDto> listFromJson(dynamic json, {bool growable = false,}) {
-    final result = <AddAssetsDto>[];
+  static List<BulkIdsDto> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <BulkIdsDto>[];
     if (json is List && json.isNotEmpty) {
       for (final row in json) {
-        final value = AddAssetsDto.fromJson(row);
+        final value = BulkIdsDto.fromJson(row);
         if (value != null) {
           result.add(value);
         }
@@ -65,12 +65,12 @@ class AddAssetsDto {
     return result.toList(growable: growable);
   }
 
-  static Map<String, AddAssetsDto> mapFromJson(dynamic json) {
-    final map = <String, AddAssetsDto>{};
+  static Map<String, BulkIdsDto> mapFromJson(dynamic json) {
+    final map = <String, BulkIdsDto>{};
     if (json is Map && json.isNotEmpty) {
       json = json.cast<String, dynamic>(); // ignore: parameter_assignments
       for (final entry in json.entries) {
-        final value = AddAssetsDto.fromJson(entry.value);
+        final value = BulkIdsDto.fromJson(entry.value);
         if (value != null) {
           map[entry.key] = value;
         }
@@ -79,14 +79,14 @@ class AddAssetsDto {
     return map;
   }
 
-  // maps a json object with a list of AddAssetsDto-objects as value to a dart map
-  static Map<String, List<AddAssetsDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
-    final map = <String, List<AddAssetsDto>>{};
+  // maps a json object with a list of BulkIdsDto-objects as value to a dart map
+  static Map<String, List<BulkIdsDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<BulkIdsDto>>{};
     if (json is Map && json.isNotEmpty) {
       // ignore: parameter_assignments
       json = json.cast<String, dynamic>();
       for (final entry in json.entries) {
-        map[entry.key] = AddAssetsDto.listFromJson(entry.value, growable: growable,);
+        map[entry.key] = BulkIdsDto.listFromJson(entry.value, growable: growable,);
       }
     }
     return map;
@@ -94,7 +94,7 @@ class AddAssetsDto {
 
   /// The list of required keys that must be present in a JSON.
   static const requiredKeys = <String>{
-    'assetIds',
+    'ids',
   };
 }
 

+ 0 - 100
mobile/openapi/lib/model/remove_assets_dto.dart

@@ -1,100 +0,0 @@
-//
-// AUTO-GENERATED FILE, DO NOT MODIFY!
-//
-// @dart=2.12
-
-// ignore_for_file: unused_element, unused_import
-// ignore_for_file: always_put_required_named_parameters_first
-// ignore_for_file: constant_identifier_names
-// ignore_for_file: lines_longer_than_80_chars
-
-part of openapi.api;
-
-class RemoveAssetsDto {
-  /// Returns a new [RemoveAssetsDto] instance.
-  RemoveAssetsDto({
-    this.assetIds = const [],
-  });
-
-  List<String> assetIds;
-
-  @override
-  bool operator ==(Object other) => identical(this, other) || other is RemoveAssetsDto &&
-     other.assetIds == assetIds;
-
-  @override
-  int get hashCode =>
-    // ignore: unnecessary_parenthesis
-    (assetIds.hashCode);
-
-  @override
-  String toString() => 'RemoveAssetsDto[assetIds=$assetIds]';
-
-  Map<String, dynamic> toJson() {
-    final json = <String, dynamic>{};
-      json[r'assetIds'] = this.assetIds;
-    return json;
-  }
-
-  /// Returns a new [RemoveAssetsDto] instance and imports its values from
-  /// [value] if it's a [Map], null otherwise.
-  // ignore: prefer_constructors_over_static_methods
-  static RemoveAssetsDto? fromJson(dynamic value) {
-    if (value is Map) {
-      final json = value.cast<String, dynamic>();
-
-      return RemoveAssetsDto(
-        assetIds: json[r'assetIds'] is Iterable
-            ? (json[r'assetIds'] as Iterable).cast<String>().toList(growable: false)
-            : const [],
-      );
-    }
-    return null;
-  }
-
-  static List<RemoveAssetsDto> listFromJson(dynamic json, {bool growable = false,}) {
-    final result = <RemoveAssetsDto>[];
-    if (json is List && json.isNotEmpty) {
-      for (final row in json) {
-        final value = RemoveAssetsDto.fromJson(row);
-        if (value != null) {
-          result.add(value);
-        }
-      }
-    }
-    return result.toList(growable: growable);
-  }
-
-  static Map<String, RemoveAssetsDto> mapFromJson(dynamic json) {
-    final map = <String, RemoveAssetsDto>{};
-    if (json is Map && json.isNotEmpty) {
-      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
-      for (final entry in json.entries) {
-        final value = RemoveAssetsDto.fromJson(entry.value);
-        if (value != null) {
-          map[entry.key] = value;
-        }
-      }
-    }
-    return map;
-  }
-
-  // maps a json object with a list of RemoveAssetsDto-objects as value to a dart map
-  static Map<String, List<RemoveAssetsDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
-    final map = <String, List<RemoveAssetsDto>>{};
-    if (json is Map && json.isNotEmpty) {
-      // ignore: parameter_assignments
-      json = json.cast<String, dynamic>();
-      for (final entry in json.entries) {
-        map[entry.key] = RemoveAssetsDto.listFromJson(entry.value, growable: growable,);
-      }
-    }
-    return map;
-  }
-
-  /// The list of required keys that must be present in a JSON.
-  static const requiredKeys = <String>{
-    'assetIds',
-  };
-}
-

+ 0 - 37
mobile/openapi/test/add_assets_response_dto_test.dart

@@ -1,37 +0,0 @@
-//
-// AUTO-GENERATED FILE, DO NOT MODIFY!
-//
-// @dart=2.12
-
-// ignore_for_file: unused_element, unused_import
-// ignore_for_file: always_put_required_named_parameters_first
-// ignore_for_file: constant_identifier_names
-// ignore_for_file: lines_longer_than_80_chars
-
-import 'package:openapi/api.dart';
-import 'package:test/test.dart';
-
-// tests for AddAssetsResponseDto
-void main() {
-  // final instance = AddAssetsResponseDto();
-
-  group('test AddAssetsResponseDto', () {
-    // AlbumResponseDto album
-    test('to test the property `album`', () async {
-      // TODO
-    });
-
-    // List<String> alreadyInAlbum (default value: const [])
-    test('to test the property `alreadyInAlbum`', () async {
-      // TODO
-    });
-
-    // int successfullyAdded
-    test('to test the property `successfullyAdded`', () async {
-      // TODO
-    });
-
-
-  });
-
-}

+ 2 - 2
mobile/openapi/test/album_api_test.dart

@@ -17,7 +17,7 @@ void main() {
   // final instance = AlbumApi();
 
   group('tests for AlbumApi', () {
-    //Future<AddAssetsResponseDto> addAssetsToAlbum(String id, AddAssetsDto addAssetsDto, { String key }) async
+    //Future<List<BulkIdResponseDto>> addAssetsToAlbum(String id, BulkIdsDto bulkIdsDto, { String key }) async
     test('test addAssetsToAlbum', () async {
       // TODO
     });
@@ -52,7 +52,7 @@ void main() {
       // TODO
     });
 
-    //Future<AlbumResponseDto> removeAssetFromAlbum(String id, RemoveAssetsDto removeAssetsDto) async
+    //Future<List<BulkIdResponseDto>> removeAssetFromAlbum(String id, BulkIdsDto bulkIdsDto) async
     test('test removeAssetFromAlbum', () async {
       // TODO
     });

+ 5 - 5
mobile/openapi/test/add_assets_dto_test.dart → mobile/openapi/test/bulk_ids_dto_test.dart

@@ -11,13 +11,13 @@
 import 'package:openapi/api.dart';
 import 'package:test/test.dart';
 
-// tests for AddAssetsDto
+// tests for BulkIdsDto
 void main() {
-  // final instance = AddAssetsDto();
+  // final instance = BulkIdsDto();
 
-  group('test AddAssetsDto', () {
-    // List<String> assetIds (default value: const [])
-    test('to test the property `assetIds`', () async {
+  group('test BulkIdsDto', () {
+    // List<String> ids (default value: const [])
+    test('to test the property `ids`', () async {
       // TODO
     });
 

+ 0 - 27
mobile/openapi/test/remove_assets_dto_test.dart

@@ -1,27 +0,0 @@
-//
-// AUTO-GENERATED FILE, DO NOT MODIFY!
-//
-// @dart=2.12
-
-// ignore_for_file: unused_element, unused_import
-// ignore_for_file: always_put_required_named_parameters_first
-// ignore_for_file: constant_identifier_names
-// ignore_for_file: lines_longer_than_80_chars
-
-import 'package:openapi/api.dart';
-import 'package:test/test.dart';
-
-// tests for RemoveAssetsDto
-void main() {
-  // final instance = RemoveAssetsDto();
-
-  group('test RemoveAssetsDto', () {
-    // List<String> assetIds (default value: const [])
-    test('to test the property `assetIds`', () async {
-      // TODO
-    });
-
-
-  });
-
-}

+ 25 - 55
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": {

+ 27 - 10
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;
+    }
   }
 }

+ 2 - 0
server/src/domain/album/album.repository.ts

@@ -8,6 +8,7 @@ export interface AlbumAssetCount {
 }
 
 export interface IAlbumRepository {
+  getById(id: string): Promise<AlbumEntity | null>;
   getByIds(ids: string[]): Promise<AlbumEntity[]>;
   getByAssetId(ownerId: string, assetId: string): Promise<AlbumEntity[]>;
   hasAsset(id: string, assetId: string): Promise<boolean>;
@@ -21,4 +22,5 @@ export interface IAlbumRepository {
   create(album: Partial<AlbumEntity>): Promise<AlbumEntity>;
   update(album: Partial<AlbumEntity>): Promise<AlbumEntity>;
   delete(album: AlbumEntity): Promise<void>;
+  updateThumbnails(): Promise<number | undefined>;
 }

+ 315 - 17
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>(albumEntity));
+  // //   albumRepositoryMock.removeAssets.mockImplementation(() => Promise.resolve<AlbumEntity>(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>(albumEntity));
+  //   albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve<AddAssetsResponseDto>(albumResponse));
+
+  //   await expect(sut.removeAssets(authUser, albumId, { ids: ['1'] })).rejects.toBeInstanceOf(ForbiddenException);
+  // });
 });

+ 95 - 18
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<AlbumResponseDto[]> {
-    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<number> {
-    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<AlbumResponseDto> {
@@ -107,7 +106,7 @@ export class AlbumService {
   async update(authUser: AuthUserDto, id: string, dto: UpdateAlbumDto): Promise<AlbumResponseDto> {
     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<void> {
     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<BulkIdResponseDto[]> {
+    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<BulkIdResponseDto[]> {
+    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');
     }

+ 7 - 0
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;

+ 0 - 132
server/src/immich/api-v1/album/album-repository.ts

@@ -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<AlbumEntity | null>;
-  removeAssets(album: AlbumEntity, removeAssets: RemoveAssetsDto): Promise<number>;
-  addAssets(album: AlbumEntity, addAssetsDto: AddAssetsDto): Promise<AddAssetsResponseDto>;
-  updateThumbnails(): Promise<number | undefined>;
-}
-
-export const IAlbumRepository = 'IAlbumRepository';
-
-@Injectable()
-export class AlbumRepository implements IAlbumRepository {
-  constructor(
-    @InjectRepository(AlbumEntity) private albumRepository: Repository<AlbumEntity>,
-    @InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
-  ) {}
-
-  async get(albumId: string): Promise<AlbumEntity | null> {
-    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<number> {
-    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<AddAssetsResponseDto> {
-    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<number | undefined> {
-    // 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;
-  }
-}

+ 0 - 45
server/src/immich/api-v1/album/album.controller.ts

@@ -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<AddAssetsResponseDto> {
-    // 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<AlbumResponseDto> {
-    return this.service.removeAssets(authUser, id, dto);
-  }
-}

+ 0 - 13
server/src/immich/api-v1/album/album.module.ts

@@ -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 {}

+ 0 - 258
server/src/immich/api-v1/album/album.service.spec.ts

@@ -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<IAlbumRepository>;
-
-  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>(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>(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>(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>(albumEntity));
-    albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve<AddAssetsResponseDto>(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>(albumEntity));
-    albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve<AddAssetsResponseDto>(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>(albumEntity));
-    albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve<AddAssetsResponseDto>(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>(albumEntity));
-  //   albumRepositoryMock.removeAssets.mockImplementation(() => Promise.resolve<AlbumEntity>(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>(albumEntity));
-  //   albumRepositoryMock.removeAssets.mockImplementation(() => Promise.resolve<AlbumEntity>(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>(albumEntity));
-    albumRepositoryMock.addAssets.mockImplementation(() => Promise.resolve<AddAssetsResponseDto>(albumResponse));
-
-    await expect(sut.removeAssets(authUser, albumId, { assetIds: ['1'] })).rejects.toBeInstanceOf(ForbiddenException);
-  });
-});

+ 0 - 72
server/src/immich/api-v1/album/album.service.ts

@@ -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<AlbumEntity> {
-    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<AlbumResponseDto> {
-    const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false });
-    return mapAlbum(album);
-  }
-
-  async removeAssets(authUser: AuthUserDto, albumId: string, dto: RemoveAssetsDto): Promise<AlbumResponseDto> {
-    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<AddAssetsResponseDto> {
-    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),
-    };
-  }
-}

+ 0 - 6
server/src/immich/api-v1/album/dto/add-assets.dto.ts

@@ -1,6 +0,0 @@
-import { ValidateUUID } from '@app/domain';
-
-export class AddAssetsDto {
-  @ValidateUUID({ each: true })
-  assetIds!: string[];
-}

+ 0 - 6
server/src/immich/api-v1/album/dto/add-users.dto.ts

@@ -1,6 +0,0 @@
-import { ValidateUUID } from '@app/domain';
-
-export class AddUsersDto {
-  @ValidateUUID({ each: true })
-  sharedUserIds!: string[];
-}

+ 0 - 6
server/src/immich/api-v1/album/dto/remove-assets.dto.ts

@@ -1,6 +0,0 @@
-import { ValidateUUID } from '@app/domain';
-
-export class RemoveAssetsDto {
-  @ValidateUUID({ each: true })
-  assetIds!: string[];
-}

+ 0 - 13
server/src/immich/api-v1/album/response-dto/add-assets-response.dto.ts

@@ -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;
-}

+ 0 - 2
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]),
   ],

+ 28 - 1
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<BulkIdResponseDto[]> {
+    return this.service.addAssets(authUser, id, dto);
+  }
+
+  @Delete(':id/assets')
+  removeAssetFromAlbum(
+    @AuthUser() authUser: AuthUserDto,
+    @Body() dto: BulkIdsDto,
+    @Param() { id }: UUIDParamDto,
+  ): Promise<BulkIdResponseDto[]> {
+    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);

+ 68 - 2
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<AlbumEntity>) {}
+  constructor(
+    @InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
+    @InjectRepository(AlbumEntity) private repository: Repository<AlbumEntity>,
+  ) {}
+
+  getById(id: string): Promise<AlbumEntity | null> {
+    return this.repository.findOne({
+      where: {
+        id,
+      },
+      relations: {
+        owner: true,
+        sharedUsers: true,
+        assets: {
+          exifInfo: true,
+        },
+        sharedLinks: true,
+      },
+      order: {
+        assets: {
+          fileCreatedAt: 'DESC',
+        },
+      },
+    });
+  }
 
   getByIds(ids: string[]): Promise<AlbumEntity[]> {
     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<number | undefined> {
+    // 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;
+  }
 }

+ 13 - 0
server/test/fixtures/album.stub.ts

@@ -69,6 +69,19 @@ export const albumStub = {
     sharedLinks: [],
     sharedUsers: [],
   }),
+  twoAssets: Object.freeze<AlbumEntity>({
+    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<AlbumEntity>({
     id: 'album-5',
     albumName: 'Empty album with invalid thumbnail',

+ 2 - 0
server/test/repositories/album.repository.mock.ts

@@ -2,6 +2,7 @@ import { IAlbumRepository } from '@app/domain';
 
 export const newAlbumRepositoryMock = (): jest.Mocked<IAlbumRepository> => {
   return {
+    getById: jest.fn(),
     getByIds: jest.fn(),
     getByAssetId: jest.fn(),
     getAssetCountForIds: jest.fn(),
@@ -15,5 +16,6 @@ export const newAlbumRepositoryMock = (): jest.Mocked<IAlbumRepository> => {
     create: jest.fn(),
     update: jest.fn(),
     delete: jest.fn(),
+    updateThumbnails: jest.fn(),
   };
 };

+ 41 - 79
web/src/api/open-api/api.ts

@@ -99,44 +99,6 @@ export interface APIKeyUpdateDto {
      */
     'name': string;
 }
-/**
- * 
- * @export
- * @interface AddAssetsDto
- */
-export interface AddAssetsDto {
-    /**
-     * 
-     * @type {Array<string>}
-     * @memberof AddAssetsDto
-     */
-    'assetIds': Array<string>;
-}
-/**
- * 
- * @export
- * @interface AddAssetsResponseDto
- */
-export interface AddAssetsResponseDto {
-    /**
-     * 
-     * @type {AlbumResponseDto}
-     * @memberof AddAssetsResponseDto
-     */
-    'album'?: AlbumResponseDto;
-    /**
-     * 
-     * @type {Array<string>}
-     * @memberof AddAssetsResponseDto
-     */
-    'alreadyInAlbum': Array<string>;
-    /**
-     * 
-     * @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<string>}
+     * @memberof BulkIdsDto
+     */
+    'ids': Array<string>;
+}
 /**
  * 
  * @export
@@ -1927,19 +1902,6 @@ export interface QueueStatusDto {
      */
     'isPaused': boolean;
 }
-/**
- * 
- * @export
- * @interface RemoveAssetsDto
- */
-export interface RemoveAssetsDto {
-    /**
-     * 
-     * @type {Array<string>}
-     * @memberof RemoveAssetsDto
-     */
-    'assetIds': Array<string>;
-}
 /**
  * 
  * @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<RequestArgs> => {
+        addAssetsToAlbum: async (id: string, bulkIdsDto: BulkIdsDto, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
             // 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<RequestArgs> => {
+        removeAssetFromAlbum: async (id: string, bulkIdsDto: BulkIdsDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
             // 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<AddAssetsResponseDto>> {
-            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<Array<BulkIdResponseDto>>> {
+            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<AlbumResponseDto>> {
-            const localVarAxiosArgs = await localVarAxiosParamCreator.removeAssetFromAlbum(id, removeAssetsDto, options);
+        async removeAssetFromAlbum(id: string, bulkIdsDto: BulkIdsDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<BulkIdResponseDto>>> {
+            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<AddAssetsResponseDto> {
-            return localVarFp.addAssetsToAlbum(id, addAssetsDto, key, options).then((request) => request(axios, basePath));
+        addAssetsToAlbum(id: string, bulkIdsDto: BulkIdsDto, key?: string, options?: any): AxiosPromise<Array<BulkIdResponseDto>> {
+            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<AlbumResponseDto> {
-            return localVarFp.removeAssetFromAlbum(id, removeAssetsDto, options).then((request) => request(axios, basePath));
+        removeAssetFromAlbum(id: string, bulkIdsDto: BulkIdsDto, options?: any): AxiosPromise<Array<BulkIdResponseDto>> {
+            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));
     }
 
     /**

+ 14 - 13
web/src/lib/components/album-page/album-viewer.svelte

@@ -92,6 +92,7 @@
 
   let multiSelectAsset: Set<AssetResponseDto> = 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;
-      }
-      isShowAssetSelection = false;
-    } catch (e) {
-      console.error('Error [createAlbumHandler] ', e);
+      const count = results.filter(({ success }) => success).length;
       notificationController.show({
-        type: NotificationType.Error,
-        message: 'Error creating album, check console for more details',
+        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) {
+      handleError(e, 'Error creating album');
     }
   };
 
@@ -307,7 +308,7 @@
       {#if sharedLink?.allowDownload || !isPublicShared}
         <DownloadAction filename="{album.albumName}.zip" sharedLinkKey={sharedLink?.key} />
       {/if}
-      {#if isOwned}
+      {#if isOwned || isMultiSelectionUserOwned}
         <RemoveFromAlbum bind:album />
       {/if}
     </AssetSelectControlBar>

+ 2 - 5
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 = () => {

+ 2 - 3
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();
   };
 </script>
 

+ 10 - 4
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);

+ 7 - 6
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<string>,
   key: string | undefined = undefined,
-): Promise<AddAssetsResponseDto> =>
-  api.albumApi.addAssetsToAlbum({ id: albumId, addAssetsDto: { assetIds }, key }).then(({ data: dto }) => {
-    if (dto.successfullyAdded > 0) {
+): Promise<BulkIdResponseDto[]> =>
+  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) => {