Forráskód Böngészése

restore: bulk actions (#3730)

* feat: improve bulk isArchive and isFavorite updates

* chore: open api
Jason Rasmussen 1 éve
szülő
commit
bab739efbd
30 módosított fájl, 732 hozzáadás és 55 törlés
  1. 113 0
      cli/src/api/open-api/api.ts
  2. 3 0
      mobile/openapi/.openapi-generator/FILES
  3. 2 0
      mobile/openapi/README.md
  4. 55 0
      mobile/openapi/doc/AssetApi.md
  5. 17 0
      mobile/openapi/doc/AssetBulkUpdateDto.md
  6. 1 0
      mobile/openapi/lib/api.dart
  7. 39 0
      mobile/openapi/lib/api/asset_api.dart
  8. 2 0
      mobile/openapi/lib/api_client.dart
  9. 134 0
      mobile/openapi/lib/model/asset_bulk_update_dto.dart
  10. 5 0
      mobile/openapi/test/asset_api_test.dart
  11. 37 0
      mobile/openapi/test/asset_bulk_update_dto_test.dart
  12. 54 0
      server/immich-openapi-specs.json
  13. 1 0
      server/src/domain/asset/asset.repository.ts
  14. 18 0
      server/src/domain/asset/asset.service.spec.ts
  15. 7 0
      server/src/domain/asset/asset.service.ts
  16. 12 0
      server/src/domain/asset/dto/asset.dto.ts
  17. 1 0
      server/src/domain/asset/dto/index.ts
  18. 8 1
      server/src/immich/controllers/asset.controller.ts
  19. 4 0
      server/src/infra/repositories/asset.repository.ts
  20. 1 0
      server/test/repositories/asset.repository.mock.ts
  21. 113 0
      web/src/api/open-api/api.ts
  22. 36 18
      web/src/lib/components/photos-page/actions/archive-action.svelte
  23. 16 5
      web/src/lib/components/photos-page/actions/delete-assets.svelte
  24. 38 19
      web/src/lib/components/photos-page/actions/favorite-action.svelte
  25. 2 2
      web/src/lib/components/photos-page/asset-select-control-bar.svelte
  26. 9 2
      web/src/lib/stores/assets.store.ts
  27. 1 1
      web/src/routes/(user)/archive/+page.svelte
  28. 1 1
      web/src/routes/(user)/favorites/+page.svelte
  29. 1 5
      web/src/routes/(user)/people/[personId]/+page.svelte
  30. 1 1
      web/src/routes/(user)/photos/+page.svelte

+ 113 - 0
cli/src/api/open-api/api.ts

@@ -344,6 +344,31 @@ export interface AllJobStatusResponseDto {
      */
     'videoConversion': JobStatusDto;
 }
+/**
+ * 
+ * @export
+ * @interface AssetBulkUpdateDto
+ */
+export interface AssetBulkUpdateDto {
+    /**
+     * 
+     * @type {Array<string>}
+     * @memberof AssetBulkUpdateDto
+     */
+    'ids': Array<string>;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof AssetBulkUpdateDto
+     */
+    'isArchived'?: boolean;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof AssetBulkUpdateDto
+     */
+    'isFavorite'?: boolean;
+}
 /**
  * 
  * @export
@@ -5871,6 +5896,50 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
                 options: localVarRequestOptions,
             };
         },
+        /**
+         * 
+         * @param {AssetBulkUpdateDto} assetBulkUpdateDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        updateAssets: async (assetBulkUpdateDto: AssetBulkUpdateDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            // verify required parameter 'assetBulkUpdateDto' is not null or undefined
+            assertParamExists('updateAssets', 'assetBulkUpdateDto', assetBulkUpdateDto)
+            const localVarPath = `/asset`;
+            // use dummy base URL string because the URL constructor only accepts absolute URLs.
+            const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
+            let baseOptions;
+            if (configuration) {
+                baseOptions = configuration.baseOptions;
+            }
+
+            const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options};
+            const localVarHeaderParameter = {} as any;
+            const localVarQueryParameter = {} as any;
+
+            // authentication cookie required
+
+            // authentication api_key required
+            await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
+
+            // authentication bearer required
+            // http bearer authentication required
+            await setBearerAuthToObject(localVarHeaderParameter, configuration)
+
+
+    
+            localVarHeaderParameter['Content-Type'] = 'application/json';
+
+            setSearchParams(localVarUrlObj, localVarQueryParameter);
+            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+            localVarRequestOptions.data = serializeDataIfNeeded(assetBulkUpdateDto, localVarRequestOptions, configuration)
+
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
         /**
          * 
          * @param {File} assetData 
@@ -6259,6 +6328,16 @@ export const AssetApiFp = function(configuration?: Configuration) {
             const localVarAxiosArgs = await localVarAxiosParamCreator.updateAsset(id, updateAssetDto, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
+        /**
+         * 
+         * @param {AssetBulkUpdateDto} assetBulkUpdateDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async updateAssets(assetBulkUpdateDto: AssetBulkUpdateDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.updateAssets(assetBulkUpdateDto, options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
         /**
          * 
          * @param {File} assetData 
@@ -6495,6 +6574,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
         updateAsset(requestParameters: AssetApiUpdateAssetRequest, options?: AxiosRequestConfig): AxiosPromise<AssetResponseDto> {
             return localVarFp.updateAsset(requestParameters.id, requestParameters.updateAssetDto, options).then((request) => request(axios, basePath));
         },
+        /**
+         * 
+         * @param {AssetApiUpdateAssetsRequest} requestParameters Request parameters.
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        updateAssets(requestParameters: AssetApiUpdateAssetsRequest, options?: AxiosRequestConfig): AxiosPromise<void> {
+            return localVarFp.updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(axios, basePath));
+        },
         /**
          * 
          * @param {AssetApiUploadFileRequest} requestParameters Request parameters.
@@ -7011,6 +7099,20 @@ export interface AssetApiUpdateAssetRequest {
     readonly updateAssetDto: UpdateAssetDto
 }
 
+/**
+ * Request parameters for updateAssets operation in AssetApi.
+ * @export
+ * @interface AssetApiUpdateAssetsRequest
+ */
+export interface AssetApiUpdateAssetsRequest {
+    /**
+     * 
+     * @type {AssetBulkUpdateDto}
+     * @memberof AssetApiUpdateAssets
+     */
+    readonly assetBulkUpdateDto: AssetBulkUpdateDto
+}
+
 /**
  * Request parameters for uploadFile operation in AssetApi.
  * @export
@@ -7366,6 +7468,17 @@ export class AssetApi extends BaseAPI {
         return AssetApiFp(this.configuration).updateAsset(requestParameters.id, requestParameters.updateAssetDto, options).then((request) => request(this.axios, this.basePath));
     }
 
+    /**
+     * 
+     * @param {AssetApiUpdateAssetsRequest} requestParameters Request parameters.
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof AssetApi
+     */
+    public updateAssets(requestParameters: AssetApiUpdateAssetsRequest, options?: AxiosRequestConfig) {
+        return AssetApiFp(this.configuration).updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(this.axios, this.basePath));
+    }
+
     /**
      * 
      * @param {AssetApiUploadFileRequest} requestParameters Request parameters.

+ 3 - 0
mobile/openapi/.openapi-generator/FILES

@@ -15,6 +15,7 @@ doc/AlbumCountResponseDto.md
 doc/AlbumResponseDto.md
 doc/AllJobStatusResponseDto.md
 doc/AssetApi.md
+doc/AssetBulkUpdateDto.md
 doc/AssetBulkUploadCheckDto.md
 doc/AssetBulkUploadCheckItem.md
 doc/AssetBulkUploadCheckResponseDto.md
@@ -158,6 +159,7 @@ lib/model/api_key_create_dto.dart
 lib/model/api_key_create_response_dto.dart
 lib/model/api_key_response_dto.dart
 lib/model/api_key_update_dto.dart
+lib/model/asset_bulk_update_dto.dart
 lib/model/asset_bulk_upload_check_dto.dart
 lib/model/asset_bulk_upload_check_item.dart
 lib/model/asset_bulk_upload_check_response_dto.dart
@@ -270,6 +272,7 @@ test/api_key_create_response_dto_test.dart
 test/api_key_response_dto_test.dart
 test/api_key_update_dto_test.dart
 test/asset_api_test.dart
+test/asset_bulk_update_dto_test.dart
 test/asset_bulk_upload_check_dto_test.dart
 test/asset_bulk_upload_check_item_test.dart
 test/asset_bulk_upload_check_response_dto_test.dart

+ 2 - 0
mobile/openapi/README.md

@@ -110,6 +110,7 @@ Class | Method | HTTP request | Description
 *AssetApi* | [**searchAsset**](doc//AssetApi.md#searchasset) | **POST** /asset/search | 
 *AssetApi* | [**serveFile**](doc//AssetApi.md#servefile) | **GET** /asset/file/{id} | 
 *AssetApi* | [**updateAsset**](doc//AssetApi.md#updateasset) | **PUT** /asset/{id} | 
+*AssetApi* | [**updateAssets**](doc//AssetApi.md#updateassets) | **PUT** /asset | 
 *AssetApi* | [**uploadFile**](doc//AssetApi.md#uploadfile) | **POST** /asset/upload | 
 *AuthenticationApi* | [**adminSignUp**](doc//AuthenticationApi.md#adminsignup) | **POST** /auth/admin-sign-up | 
 *AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password | 
@@ -187,6 +188,7 @@ Class | Method | HTTP request | Description
  - [AlbumCountResponseDto](doc//AlbumCountResponseDto.md)
  - [AlbumResponseDto](doc//AlbumResponseDto.md)
  - [AllJobStatusResponseDto](doc//AllJobStatusResponseDto.md)
+ - [AssetBulkUpdateDto](doc//AssetBulkUpdateDto.md)
  - [AssetBulkUploadCheckDto](doc//AssetBulkUploadCheckDto.md)
  - [AssetBulkUploadCheckItem](doc//AssetBulkUploadCheckItem.md)
  - [AssetBulkUploadCheckResponseDto](doc//AssetBulkUploadCheckResponseDto.md)

+ 55 - 0
mobile/openapi/doc/AssetApi.md

@@ -32,6 +32,7 @@ Method | HTTP request | Description
 [**searchAsset**](AssetApi.md#searchasset) | **POST** /asset/search | 
 [**serveFile**](AssetApi.md#servefile) | **GET** /asset/file/{id} | 
 [**updateAsset**](AssetApi.md#updateasset) | **PUT** /asset/{id} | 
+[**updateAssets**](AssetApi.md#updateassets) | **PUT** /asset | 
 [**uploadFile**](AssetApi.md#uploadfile) | **POST** /asset/upload | 
 
 
@@ -1366,6 +1367,60 @@ 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)
 
+# **updateAssets**
+> updateAssets(assetBulkUpdateDto)
+
+
+
+### Example
+```dart
+import 'package:openapi/api.dart';
+// TODO Configure API key authorization: cookie
+//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
+// TODO Configure API key authorization: api_key
+//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
+// TODO Configure HTTP Bearer authorization: bearer
+// Case 1. Use String Token
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
+// Case 2. Use Function which generate token.
+// String yourTokenGeneratorFunction() { ... }
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
+
+final api_instance = AssetApi();
+final assetBulkUpdateDto = AssetBulkUpdateDto(); // AssetBulkUpdateDto | 
+
+try {
+    api_instance.updateAssets(assetBulkUpdateDto);
+} catch (e) {
+    print('Exception when calling AssetApi->updateAssets: $e\n');
+}
+```
+
+### Parameters
+
+Name | Type | Description  | Notes
+------------- | ------------- | ------------- | -------------
+ **assetBulkUpdateDto** | [**AssetBulkUpdateDto**](AssetBulkUpdateDto.md)|  | 
+
+### Return type
+
+void (empty response body)
+
+### Authorization
+
+[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
+
+### HTTP request headers
+
+ - **Content-Type**: application/json
+ - **Accept**: Not defined
+
+[[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)
+
 # **uploadFile**
 > AssetFileUploadResponseDto uploadFile(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, key, duration, isArchived, isReadOnly, isVisible, livePhotoData, sidecarData)
 

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

@@ -0,0 +1,17 @@
+# openapi.model.AssetBulkUpdateDto
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**ids** | **List<String>** |  | [default to const []]
+**isArchived** | **bool** |  | [optional] 
+**isFavorite** | **bool** |  | [optional] 
+
+[[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 - 0
mobile/openapi/lib/api.dart

@@ -52,6 +52,7 @@ part 'model/admin_signup_response_dto.dart';
 part 'model/album_count_response_dto.dart';
 part 'model/album_response_dto.dart';
 part 'model/all_job_status_response_dto.dart';
+part 'model/asset_bulk_update_dto.dart';
 part 'model/asset_bulk_upload_check_dto.dart';
 part 'model/asset_bulk_upload_check_item.dart';
 part 'model/asset_bulk_upload_check_response_dto.dart';

+ 39 - 0
mobile/openapi/lib/api/asset_api.dart

@@ -1404,6 +1404,45 @@ class AssetApi {
     return null;
   }
 
+  /// Performs an HTTP 'PUT /asset' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [AssetBulkUpdateDto] assetBulkUpdateDto (required):
+  Future<Response> updateAssetsWithHttpInfo(AssetBulkUpdateDto assetBulkUpdateDto,) async {
+    // ignore: prefer_const_declarations
+    final path = r'/asset';
+
+    // ignore: prefer_final_locals
+    Object? postBody = assetBulkUpdateDto;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>['application/json'];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'PUT',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [AssetBulkUpdateDto] assetBulkUpdateDto (required):
+  Future<void> updateAssets(AssetBulkUpdateDto assetBulkUpdateDto,) async {
+    final response = await updateAssetsWithHttpInfo(assetBulkUpdateDto,);
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+  }
+
   /// Performs an HTTP 'POST /asset/upload' operation and returns the [Response].
   /// Parameters:
   ///

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

@@ -199,6 +199,8 @@ class ApiClient {
           return AlbumResponseDto.fromJson(value);
         case 'AllJobStatusResponseDto':
           return AllJobStatusResponseDto.fromJson(value);
+        case 'AssetBulkUpdateDto':
+          return AssetBulkUpdateDto.fromJson(value);
         case 'AssetBulkUploadCheckDto':
           return AssetBulkUploadCheckDto.fromJson(value);
         case 'AssetBulkUploadCheckItem':

+ 134 - 0
mobile/openapi/lib/model/asset_bulk_update_dto.dart

@@ -0,0 +1,134 @@
+//
+// 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 AssetBulkUpdateDto {
+  /// Returns a new [AssetBulkUpdateDto] instance.
+  AssetBulkUpdateDto({
+    this.ids = const [],
+    this.isArchived,
+    this.isFavorite,
+  });
+
+  List<String> ids;
+
+  ///
+  /// 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.
+  ///
+  bool? isArchived;
+
+  ///
+  /// 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.
+  ///
+  bool? isFavorite;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is AssetBulkUpdateDto &&
+     other.ids == ids &&
+     other.isArchived == isArchived &&
+     other.isFavorite == isFavorite;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (ids.hashCode) +
+    (isArchived == null ? 0 : isArchived!.hashCode) +
+    (isFavorite == null ? 0 : isFavorite!.hashCode);
+
+  @override
+  String toString() => 'AssetBulkUpdateDto[ids=$ids, isArchived=$isArchived, isFavorite=$isFavorite]';
+
+  Map<String, dynamic> toJson() {
+    final json = <String, dynamic>{};
+      json[r'ids'] = this.ids;
+    if (this.isArchived != null) {
+      json[r'isArchived'] = this.isArchived;
+    } else {
+    //  json[r'isArchived'] = null;
+    }
+    if (this.isFavorite != null) {
+      json[r'isFavorite'] = this.isFavorite;
+    } else {
+    //  json[r'isFavorite'] = null;
+    }
+    return json;
+  }
+
+  /// Returns a new [AssetBulkUpdateDto] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static AssetBulkUpdateDto? fromJson(dynamic value) {
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      return AssetBulkUpdateDto(
+        ids: json[r'ids'] is List
+            ? (json[r'ids'] as List).cast<String>()
+            : const [],
+        isArchived: mapValueOfType<bool>(json, r'isArchived'),
+        isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
+      );
+    }
+    return null;
+  }
+
+  static List<AssetBulkUpdateDto> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <AssetBulkUpdateDto>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = AssetBulkUpdateDto.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, AssetBulkUpdateDto> mapFromJson(dynamic json) {
+    final map = <String, AssetBulkUpdateDto>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = AssetBulkUpdateDto.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of AssetBulkUpdateDto-objects as value to a dart map
+  static Map<String, List<AssetBulkUpdateDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<AssetBulkUpdateDto>>{};
+    if (json is Map && json.isNotEmpty) {
+      // ignore: parameter_assignments
+      json = json.cast<String, dynamic>();
+      for (final entry in json.entries) {
+        map[entry.key] = AssetBulkUpdateDto.listFromJson(entry.value, growable: growable,);
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'ids',
+  };
+}
+

+ 5 - 0
mobile/openapi/test/asset_api_test.dart

@@ -146,6 +146,11 @@ void main() {
       // TODO
     });
 
+    //Future updateAssets(AssetBulkUpdateDto assetBulkUpdateDto) async
+    test('test updateAssets', () async {
+      // TODO
+    });
+
     //Future<AssetFileUploadResponseDto> uploadFile(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, bool isFavorite, { String key, String duration, bool isArchived, bool isReadOnly, bool isVisible, MultipartFile livePhotoData, MultipartFile sidecarData }) async
     test('test uploadFile', () async {
       // TODO

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

@@ -0,0 +1,37 @@
+//
+// 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 AssetBulkUpdateDto
+void main() {
+  // final instance = AssetBulkUpdateDto();
+
+  group('test AssetBulkUpdateDto', () {
+    // List<String> ids (default value: const [])
+    test('to test the property `ids`', () async {
+      // TODO
+    });
+
+    // bool isArchived
+    test('to test the property `isArchived`', () async {
+      // TODO
+    });
+
+    // bool isFavorite
+    test('to test the property `isFavorite`', () async {
+      // TODO
+    });
+
+
+  });
+
+}

+ 54 - 0
server/immich-openapi-specs.json

@@ -808,6 +808,39 @@
         "tags": [
           "Asset"
         ]
+      },
+      "put": {
+        "operationId": "updateAssets",
+        "parameters": [],
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/AssetBulkUpdateDto"
+              }
+            }
+          },
+          "required": true
+        },
+        "responses": {
+          "204": {
+            "description": ""
+          }
+        },
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          },
+          {
+            "api_key": []
+          }
+        ],
+        "tags": [
+          "Asset"
+        ]
       }
     },
     "/asset/assetById/{id}": {
@@ -4841,6 +4874,27 @@
         ],
         "type": "object"
       },
+      "AssetBulkUpdateDto": {
+        "properties": {
+          "ids": {
+            "items": {
+              "format": "uuid",
+              "type": "string"
+            },
+            "type": "array"
+          },
+          "isArchived": {
+            "type": "boolean"
+          },
+          "isFavorite": {
+            "type": "boolean"
+          }
+        },
+        "required": [
+          "ids"
+        ],
+        "type": "object"
+      },
       "AssetBulkUploadCheckDto": {
         "properties": {
           "assets": {

+ 1 - 0
server/src/domain/asset/asset.repository.ts

@@ -79,6 +79,7 @@ export interface IAssetRepository {
   getLastUpdatedAssetForAlbumId(albumId: string): Promise<AssetEntity | null>;
   deleteAll(ownerId: string): Promise<void>;
   getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated<AssetEntity>;
+  updateAll(ids: string[], options: Partial<AssetEntity>): Promise<void>;
   save(asset: Partial<AssetEntity>): Promise<AssetEntity>;
   findLivePhotoMatch(options: LivePhotoSearchOptions): Promise<AssetEntity | null>;
   getMapMarkers(ownerId: string, options?: MapMarkerSearchOptions): Promise<MapMarker[]>;

+ 18 - 0
server/src/domain/asset/asset.service.spec.ts

@@ -514,4 +514,22 @@ describe(AssetService.name, () => {
       expect(assetMock.getStatistics).toHaveBeenCalledWith(authStub.admin.id, {});
     });
   });
+
+  describe('updateAll', () => {
+    it('should require asset write access for all ids', async () => {
+      accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
+      await expect(
+        sut.updateAll(authStub.admin, {
+          ids: ['asset-1'],
+          isArchived: false,
+        }),
+      ).rejects.toBeInstanceOf(BadRequestException);
+    });
+
+    it('should update all assets', async () => {
+      accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
+      await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], isArchived: true });
+      expect(assetMock.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { isArchived: true });
+    });
+  });
 });

+ 7 - 0
server/src/domain/asset/asset.service.ts

@@ -11,6 +11,7 @@ import { HumanReadableSize, usePagination } from '../domain.util';
 import { ImmichReadStream, IStorageRepository, StorageCore, StorageFolder } from '../storage';
 import { IAssetRepository } from './asset.repository';
 import {
+  AssetBulkUpdateDto,
   AssetIdsDto,
   DownloadArchiveInfo,
   DownloadInfoDto,
@@ -268,4 +269,10 @@ export class AssetService {
     const stats = await this.assetRepository.getStatistics(authUser.id, dto);
     return mapStats(stats);
   }
+
+  async updateAll(authUser: AuthUserDto, dto: AssetBulkUpdateDto) {
+    const { ids, ...options } = dto;
+    await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, ids);
+    await this.assetRepository.updateAll(ids, options);
+  }
 }

+ 12 - 0
server/src/domain/asset/dto/asset.dto.ts

@@ -0,0 +1,12 @@
+import { IsBoolean, IsOptional } from 'class-validator';
+import { BulkIdsDto } from '../response-dto';
+
+export class AssetBulkUpdateDto extends BulkIdsDto {
+  @IsOptional()
+  @IsBoolean()
+  isFavorite?: boolean;
+
+  @IsOptional()
+  @IsBoolean()
+  isArchived?: boolean;
+}

+ 1 - 0
server/src/domain/asset/dto/index.ts

@@ -1,5 +1,6 @@
 export * from './asset-ids.dto';
 export * from './asset-statistics.dto';
+export * from './asset.dto';
 export * from './download.dto';
 export * from './map-marker.dto';
 export * from './memory-lane.dto';

+ 8 - 1
server/src/immich/controllers/asset.controller.ts

@@ -1,4 +1,5 @@
 import {
+  AssetBulkUpdateDto,
   AssetIdsDto,
   AssetResponseDto,
   AssetService,
@@ -15,7 +16,7 @@ import {
 } from '@app/domain';
 import { MapMarkerDto } from '@app/domain/asset/dto/map-marker.dto';
 import { MemoryLaneResponseDto } from '@app/domain/asset/response-dto/memory-lane-response.dto';
-import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Query, StreamableFile } from '@nestjs/common';
+import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Put, Query, StreamableFile } from '@nestjs/common';
 import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
 import { Authenticated, AuthUser, SharedLinkRoute } from '../app.guard';
 import { asStreamableFile, UseValidation } from '../app.utils';
@@ -76,4 +77,10 @@ export class AssetController {
   getByTimeBucket(@AuthUser() authUser: AuthUserDto, @Query() dto: TimeBucketAssetDto): Promise<AssetResponseDto[]> {
     return this.service.getByTimeBucket(authUser, dto);
   }
+
+  @Put()
+  @HttpCode(HttpStatus.NO_CONTENT)
+  updateAssets(@AuthUser() authUser: AuthUserDto, @Body() dto: AssetBulkUpdateDto): Promise<void> {
+    return this.service.updateAll(authUser, dto);
+  }
 }

+ 4 - 0
server/src/infra/repositories/asset.repository.ts

@@ -129,6 +129,10 @@ export class AssetRepository implements IAssetRepository {
     });
   }
 
+  async updateAll(ids: string[], options: Partial<AssetEntity>): Promise<void> {
+    await this.repository.update({ id: In(ids) }, options);
+  }
+
   async save(asset: Partial<AssetEntity>): Promise<AssetEntity> {
     const { id } = await this.repository.save(asset);
     return this.repository.findOneOrFail({

+ 1 - 0
server/test/repositories/asset.repository.mock.ts

@@ -11,6 +11,7 @@ export const newAssetRepositoryMock = (): jest.Mocked<IAssetRepository> => {
     getFirstAssetForAlbumId: jest.fn(),
     getLastUpdatedAssetForAlbumId: jest.fn(),
     getAll: jest.fn().mockResolvedValue({ items: [], hasNextPage: false }),
+    updateAll: jest.fn(),
     deleteAll: jest.fn(),
     save: jest.fn(),
     findLivePhotoMatch: jest.fn(),

+ 113 - 0
web/src/api/open-api/api.ts

@@ -344,6 +344,31 @@ export interface AllJobStatusResponseDto {
      */
     'videoConversion': JobStatusDto;
 }
+/**
+ * 
+ * @export
+ * @interface AssetBulkUpdateDto
+ */
+export interface AssetBulkUpdateDto {
+    /**
+     * 
+     * @type {Array<string>}
+     * @memberof AssetBulkUpdateDto
+     */
+    'ids': Array<string>;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof AssetBulkUpdateDto
+     */
+    'isArchived'?: boolean;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof AssetBulkUpdateDto
+     */
+    'isFavorite'?: boolean;
+}
 /**
  * 
  * @export
@@ -5871,6 +5896,50 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
                 options: localVarRequestOptions,
             };
         },
+        /**
+         * 
+         * @param {AssetBulkUpdateDto} assetBulkUpdateDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        updateAssets: async (assetBulkUpdateDto: AssetBulkUpdateDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            // verify required parameter 'assetBulkUpdateDto' is not null or undefined
+            assertParamExists('updateAssets', 'assetBulkUpdateDto', assetBulkUpdateDto)
+            const localVarPath = `/asset`;
+            // use dummy base URL string because the URL constructor only accepts absolute URLs.
+            const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
+            let baseOptions;
+            if (configuration) {
+                baseOptions = configuration.baseOptions;
+            }
+
+            const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options};
+            const localVarHeaderParameter = {} as any;
+            const localVarQueryParameter = {} as any;
+
+            // authentication cookie required
+
+            // authentication api_key required
+            await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
+
+            // authentication bearer required
+            // http bearer authentication required
+            await setBearerAuthToObject(localVarHeaderParameter, configuration)
+
+
+    
+            localVarHeaderParameter['Content-Type'] = 'application/json';
+
+            setSearchParams(localVarUrlObj, localVarQueryParameter);
+            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+            localVarRequestOptions.data = serializeDataIfNeeded(assetBulkUpdateDto, localVarRequestOptions, configuration)
+
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
         /**
          * 
          * @param {File} assetData 
@@ -6259,6 +6328,16 @@ export const AssetApiFp = function(configuration?: Configuration) {
             const localVarAxiosArgs = await localVarAxiosParamCreator.updateAsset(id, updateAssetDto, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
+        /**
+         * 
+         * @param {AssetBulkUpdateDto} assetBulkUpdateDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async updateAssets(assetBulkUpdateDto: AssetBulkUpdateDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.updateAssets(assetBulkUpdateDto, options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
         /**
          * 
          * @param {File} assetData 
@@ -6495,6 +6574,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
         updateAsset(requestParameters: AssetApiUpdateAssetRequest, options?: AxiosRequestConfig): AxiosPromise<AssetResponseDto> {
             return localVarFp.updateAsset(requestParameters.id, requestParameters.updateAssetDto, options).then((request) => request(axios, basePath));
         },
+        /**
+         * 
+         * @param {AssetApiUpdateAssetsRequest} requestParameters Request parameters.
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        updateAssets(requestParameters: AssetApiUpdateAssetsRequest, options?: AxiosRequestConfig): AxiosPromise<void> {
+            return localVarFp.updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(axios, basePath));
+        },
         /**
          * 
          * @param {AssetApiUploadFileRequest} requestParameters Request parameters.
@@ -7011,6 +7099,20 @@ export interface AssetApiUpdateAssetRequest {
     readonly updateAssetDto: UpdateAssetDto
 }
 
+/**
+ * Request parameters for updateAssets operation in AssetApi.
+ * @export
+ * @interface AssetApiUpdateAssetsRequest
+ */
+export interface AssetApiUpdateAssetsRequest {
+    /**
+     * 
+     * @type {AssetBulkUpdateDto}
+     * @memberof AssetApiUpdateAssets
+     */
+    readonly assetBulkUpdateDto: AssetBulkUpdateDto
+}
+
 /**
  * Request parameters for uploadFile operation in AssetApi.
  * @export
@@ -7366,6 +7468,17 @@ export class AssetApi extends BaseAPI {
         return AssetApiFp(this.configuration).updateAsset(requestParameters.id, requestParameters.updateAssetDto, options).then((request) => request(this.axios, this.basePath));
     }
 
+    /**
+     * 
+     * @param {AssetApiUpdateAssetsRequest} requestParameters Request parameters.
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof AssetApi
+     */
+    public updateAssets(requestParameters: AssetApiUpdateAssetsRequest, options?: AxiosRequestConfig) {
+        return AssetApiFp(this.configuration).updateAssets(requestParameters.assetBulkUpdateDto, options).then((request) => request(this.axios, this.basePath));
+    }
+
     /**
      * 
      * @param {AssetApiUploadFileRequest} requestParameters Request parameters.

+ 36 - 18
web/src/lib/components/photos-page/actions/archive-action.svelte

@@ -4,15 +4,15 @@
     NotificationType,
     notificationController,
   } from '$lib/components/shared-components/notification/notification';
+  import { handleError } from '$lib/utils/handle-error';
   import { api } from '@api';
   import ArchiveArrowDownOutline from 'svelte-material-icons/ArchiveArrowDownOutline.svelte';
   import ArchiveArrowUpOutline from 'svelte-material-icons/ArchiveArrowUpOutline.svelte';
+  import TimerSand from 'svelte-material-icons/TimerSand.svelte';
   import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
-  import { OnAssetArchive, getAssetControlContext } from '../asset-select-control-bar.svelte';
+  import { OnArchive, getAssetControlContext } from '../asset-select-control-bar.svelte';
 
-  export let onAssetArchive: OnAssetArchive = (asset, isArchived) => {
-    asset.isArchived = isArchived;
-  };
+  export let onArchive: OnArchive | undefined = undefined;
 
   export let menuItem = false;
   export let unarchive = false;
@@ -20,32 +20,50 @@
   $: text = unarchive ? 'Unarchive' : 'Archive';
   $: logo = unarchive ? ArchiveArrowUpOutline : ArchiveArrowDownOutline;
 
+  let loading = false;
+
   const { getAssets, clearSelect } = getAssetControlContext();
 
   const handleArchive = async () => {
     const isArchived = !unarchive;
-    let cnt = 0;
+    loading = true;
 
-    for (const asset of getAssets()) {
-      if (asset.isArchived !== isArchived) {
-        api.assetApi.updateAsset({ id: asset.id, updateAssetDto: { isArchived } });
+    try {
+      const assets = Array.from(getAssets()).filter((asset) => asset.isArchived !== isArchived);
+      const ids = assets.map(({ id }) => id);
 
-        onAssetArchive(asset, isArchived);
-        cnt = cnt + 1;
+      if (ids.length > 0) {
+        await api.assetApi.updateAssets({ assetBulkUpdateDto: { ids, isArchived } });
+      }
+
+      for (const asset of assets) {
+        asset.isArchived = isArchived;
       }
-    }
 
-    notificationController.show({
-      message: `${isArchived ? 'Archived' : 'Unarchived'} ${cnt}`,
-      type: NotificationType.Info,
-    });
+      onArchive?.(ids, isArchived);
 
-    clearSelect();
+      notificationController.show({
+        message: `${isArchived ? 'Archived' : 'Unarchived'} ${ids.length}`,
+        type: NotificationType.Info,
+      });
+
+      clearSelect();
+    } catch (error) {
+      handleError(error, `Unable to ${isArchived ? 'archive' : 'unarchive'}`);
+    } finally {
+      loading = false;
+    }
   };
 </script>
 
 {#if menuItem}
   <MenuOption {text} on:click={handleArchive} />
-{:else}
-  <CircleIconButton title={text} {logo} on:click={handleArchive} />
+{/if}
+
+{#if !menuItem}
+  {#if loading}
+    <CircleIconButton title="Loading" logo={TimerSand} />
+  {:else}
+    <CircleIconButton title={text} {logo} on:click={handleArchive} />
+  {/if}
 {/if}

+ 16 - 5
web/src/lib/components/photos-page/actions/delete-assets.svelte

@@ -1,23 +1,27 @@
 <script lang="ts">
   import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
+  import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
   import {
     NotificationType,
     notificationController,
   } from '$lib/components/shared-components/notification/notification';
+  import { handleError } from '$lib/utils/handle-error';
   import { api } from '@api';
   import DeleteOutline from 'svelte-material-icons/DeleteOutline.svelte';
-  import { OnAssetDelete, getAssetControlContext } from '../asset-select-control-bar.svelte';
-  import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
-  import { handleError } from '../../../utils/handle-error';
+  import TimerSand from 'svelte-material-icons/TimerSand.svelte';
   import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
+  import { OnAssetDelete, getAssetControlContext } from '../asset-select-control-bar.svelte';
 
   export let onAssetDelete: OnAssetDelete;
   export let menuItem = false;
   const { getAssets, clearSelect } = getAssetControlContext();
 
   let isShowConfirmation = false;
+  let loading = false;
 
   const handleDelete = async () => {
+    loading = true;
+
     try {
       let count = 0;
 
@@ -44,14 +48,21 @@
       handleError(e, 'Error deleting assets');
     } finally {
       isShowConfirmation = false;
+      loading = false;
     }
   };
 </script>
 
 {#if menuItem}
   <MenuOption text="Delete" on:click={() => (isShowConfirmation = true)} />
-{:else}
-  <CircleIconButton title="Delete" logo={DeleteOutline} on:click={() => (isShowConfirmation = true)} />
+{/if}
+
+{#if !menuItem}
+  {#if loading}
+    <CircleIconButton title="Loading" logo={TimerSand} />
+  {:else}
+    <CircleIconButton title="Delete" logo={DeleteOutline} on:click={() => (isShowConfirmation = true)} />
+  {/if}
 {/if}
 
 {#if isShowConfirmation}

+ 38 - 19
web/src/lib/components/photos-page/actions/favorite-action.svelte

@@ -5,14 +5,14 @@
     NotificationType,
     notificationController,
   } from '$lib/components/shared-components/notification/notification';
+  import { handleError } from '$lib/utils/handle-error';
   import { api } from '@api';
   import HeartMinusOutline from 'svelte-material-icons/HeartMinusOutline.svelte';
   import HeartOutline from 'svelte-material-icons/HeartOutline.svelte';
-  import { OnAssetFavorite, getAssetControlContext } from '../asset-select-control-bar.svelte';
+  import TimerSand from 'svelte-material-icons/TimerSand.svelte';
+  import { OnFavorite, getAssetControlContext } from '../asset-select-control-bar.svelte';
 
-  export let onAssetFavorite: OnAssetFavorite = (asset, isFavorite) => {
-    asset.isFavorite = isFavorite;
-  };
+  export let onFavorite: OnFavorite | undefined = undefined;
 
   export let menuItem = false;
   export let removeFavorite: boolean;
@@ -20,31 +20,50 @@
   $: text = removeFavorite ? 'Remove from Favorites' : 'Favorite';
   $: logo = removeFavorite ? HeartMinusOutline : HeartOutline;
 
+  let loading = false;
+
   const { getAssets, clearSelect } = getAssetControlContext();
 
-  const handleFavorite = () => {
+  const handleFavorite = async () => {
     const isFavorite = !removeFavorite;
+    loading = true;
+
+    try {
+      const assets = Array.from(getAssets()).filter((asset) => asset.isFavorite !== isFavorite);
+      const ids = assets.map(({ id }) => id);
 
-    let cnt = 0;
-    for (const asset of getAssets()) {
-      if (asset.isFavorite !== isFavorite) {
-        api.assetApi.updateAsset({ id: asset.id, updateAssetDto: { isFavorite } });
-        onAssetFavorite(asset, isFavorite);
-        cnt = cnt + 1;
+      if (ids.length > 0) {
+        await api.assetApi.updateAssets({ assetBulkUpdateDto: { ids, isFavorite } });
       }
-    }
 
-    notificationController.show({
-      message: isFavorite ? `Added ${cnt} to favorites` : `Removed ${cnt} from favorites`,
-      type: NotificationType.Info,
-    });
+      for (const asset of assets) {
+        asset.isFavorite = isFavorite;
+      }
+
+      onFavorite?.(ids, isFavorite);
+
+      notificationController.show({
+        message: isFavorite ? `Added ${ids.length} to favorites` : `Removed ${ids.length} from favorites`,
+        type: NotificationType.Info,
+      });
 
-    clearSelect();
+      clearSelect();
+    } catch (error) {
+      handleError(error, `Unable to ${isFavorite ? 'add to' : 'remove from'} favorites`);
+    } finally {
+      loading = false;
+    }
   };
 </script>
 
 {#if menuItem}
   <MenuOption {text} on:click={handleFavorite} />
-{:else}
-  <CircleIconButton title={text} {logo} on:click={handleFavorite} />
+{/if}
+
+{#if !menuItem}
+  {#if loading}
+    <CircleIconButton title="Loading" logo={TimerSand} />
+  {:else}
+    <CircleIconButton title={text} {logo} on:click={handleFavorite} />
+  {/if}
 {/if}

+ 2 - 2
web/src/lib/components/photos-page/asset-select-control-bar.svelte

@@ -2,8 +2,8 @@
   import { createContext } from '$lib/utils/context';
 
   export type OnAssetDelete = (assetId: string) => void;
-  export type OnAssetArchive = (asset: AssetResponseDto, archived: boolean) => void;
-  export type OnAssetFavorite = (asset: AssetResponseDto, favorite: boolean) => void;
+  export type OnArchive = (ids: string[], isArchived: boolean) => void;
+  export type OnFavorite = (ids: string[], favorite: boolean) => void;
 
   export interface AssetControlContext {
     // Wrap assets in a function, because context isn't reactive.

+ 9 - 2
web/src/lib/stores/assets.store.ts

@@ -180,12 +180,19 @@ export class AssetStore {
     this.emit(false);
   }
 
-  removeAsset(assetId: string) {
+  removeAssets(ids: string[]) {
+    // TODO: this could probably be more efficient
+    for (const id of ids) {
+      this.removeAsset(id);
+    }
+  }
+
+  removeAsset(id: string) {
     for (let i = 0; i < this.buckets.length; i++) {
       const bucket = this.buckets[i];
       for (let j = 0; j < bucket.assets.length; j++) {
         const asset = bucket.assets[j];
-        if (asset.id !== assetId) {
+        if (asset.id !== id) {
           continue;
         }
 

+ 1 - 1
web/src/routes/(user)/archive/+page.svelte

@@ -37,7 +37,7 @@
 
 {#if $isMultiSelectState}
   <AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}>
-    <ArchiveAction unarchive onAssetArchive={(asset) => assetStore.removeAsset(asset.id)} />
+    <ArchiveAction unarchive onArchive={(ids) => assetStore.removeAssets(ids)} />
     <CreateSharedLink />
     <SelectAllAssets {assetStore} {assetInteractionStore} />
     <AssetSelectContextMenu icon={Plus} title="Add">

+ 1 - 1
web/src/routes/(user)/favorites/+page.svelte

@@ -38,7 +38,7 @@
 <!-- Multiselection mode app bar -->
 {#if $isMultiSelectState}
   <AssetSelectControlBar assets={$selectedAssets} clearSelect={() => assetInteractionStore.clearMultiselect()}>
-    <FavoriteAction removeFavorite onAssetFavorite={(asset) => assetStore.removeAsset(asset.id)} />
+    <FavoriteAction removeFavorite onFavorite={(ids) => assetStore.removeAssets(ids)} />
     <CreateSharedLink />
     <SelectAllAssets {assetStore} {assetInteractionStore} />
     <AssetSelectContextMenu icon={Plus} title="Add">

+ 1 - 5
web/src/routes/(user)/people/[personId]/+page.svelte

@@ -202,11 +202,7 @@
       <AssetSelectContextMenu icon={DotsVertical} title="Add">
         <DownloadAction menuItem filename="{data.person.name || 'immich'}.zip" />
         <FavoriteAction menuItem removeFavorite={isAllFavorite} />
-        <ArchiveAction
-          menuItem
-          unarchive={isAllArchive}
-          onAssetArchive={(asset) => $assetStore.removeAsset(asset.id)}
-        />
+        <ArchiveAction menuItem unarchive={isAllArchive} onArchive={(ids) => $assetStore.removeAssets(ids)} />
       </AssetSelectContextMenu>
     </AssetSelectControlBar>
   {:else}

+ 1 - 1
web/src/routes/(user)/photos/+page.svelte

@@ -51,7 +51,7 @@
         <AssetSelectContextMenu icon={DotsVertical} title="Menu">
           <FavoriteAction menuItem removeFavorite={isAllFavorite} />
           <DownloadAction menuItem />
-          <ArchiveAction menuItem onAssetArchive={(asset) => assetStore.removeAsset(asset.id)} />
+          <ArchiveAction menuItem onArchive={(ids) => assetStore.removeAssets(ids)} />
         </AssetSelectContextMenu>
       </AssetSelectControlBar>
     {/if}