Jelajahi Sumber

feat(web/server): merge faces (#3121)

* feat(server/web): Merge faces

* get parent id

* update

* query to get identical asset and change controller

* change delete asset signature

* delete identical assets

* gaming time

* delete merge person

* query

* query

* generate api

* pr feedback

* generate api

* naming

* remove unused method

* Update server/src/domain/person/person.service.ts

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>

* Update server/src/domain/person/person.service.ts

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>

* better method signature

* cleaning up

* fix bug

* added interfaces

* added tests

* merge main

* api

* build merge face interface

* api

* selector interface

* style

* more style

* clean up import

* styling

* styling

* better

* styling

* styling

* add merge face diablog

* finished

* refactor: merge person endpoint

* refactor: merge person component

* chore: open api

* fix: tests

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
Alex 2 tahun lalu
induk
melakukan
c86b2ae500
30 mengubah file dengan 1478 tambahan dan 71 penghapusan
  1. 148 0
      cli/src/api/open-api/api.ts
  2. 6 0
      mobile/openapi/.openapi-generator/FILES
  3. 3 0
      mobile/openapi/README.md
  4. 17 0
      mobile/openapi/doc/BulkIdResponseDto.md
  5. 15 0
      mobile/openapi/doc/MergePersonDto.md
  6. 58 0
      mobile/openapi/doc/PersonApi.md
  7. 2 0
      mobile/openapi/lib/api.dart
  8. 55 0
      mobile/openapi/lib/api/person_api.dart
  9. 4 0
      mobile/openapi/lib/api_client.dart
  10. 197 0
      mobile/openapi/lib/model/bulk_id_response_dto.dart
  11. 100 0
      mobile/openapi/lib/model/merge_person_dto.dart
  12. 37 0
      mobile/openapi/test/bulk_id_response_dto_test.dart
  13. 27 0
      mobile/openapi/test/merge_person_dto_test.dart
  14. 5 0
      mobile/openapi/test/person_api_test.dart
  15. 94 0
      server/immich-openapi-specs.json
  16. 15 0
      server/src/domain/asset/response-dto/asset-ids-response.dto.ts
  17. 6 0
      server/src/domain/person/person.dto.ts
  18. 9 1
      server/src/domain/person/person.repository.ts
  19. 83 1
      server/src/domain/person/person.service.spec.ts
  20. 81 56
      server/src/domain/person/person.service.ts
  21. 12 1
      server/src/immich/controllers/person.controller.ts
  22. 32 2
      server/src/infra/repositories/person.repository.ts
  23. 92 0
      server/test/fixtures.ts
  24. 2 0
      server/test/repositories/person.repository.mock.ts
  25. 149 0
      web/src/api/open-api/api.ts
  26. 66 0
      web/src/lib/components/faces-page/face-thumbnail.svelte
  27. 145 0
      web/src/lib/components/faces-page/merge-face-selector.svelte
  28. 1 1
      web/src/routes/(user)/explore/+page.svelte
  29. 1 1
      web/src/routes/(user)/people/+page.svelte
  30. 16 8
      web/src/routes/(user)/people/[personId]/+page.svelte

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

@@ -798,6 +798,41 @@ export interface AuthDeviceResponseDto {
      */
     'deviceOS': string;
 }
+/**
+ * 
+ * @export
+ * @interface BulkIdResponseDto
+ */
+export interface BulkIdResponseDto {
+    /**
+     * 
+     * @type {string}
+     * @memberof BulkIdResponseDto
+     */
+    'id': string;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof BulkIdResponseDto
+     */
+    'success': boolean;
+    /**
+     * 
+     * @type {string}
+     * @memberof BulkIdResponseDto
+     */
+    'error'?: BulkIdResponseDtoErrorEnum;
+}
+
+export const BulkIdResponseDtoErrorEnum = {
+    Duplicate: 'duplicate',
+    NoPermission: 'no_permission',
+    NotFound: 'not_found',
+    Unknown: 'unknown'
+} as const;
+
+export type BulkIdResponseDtoErrorEnum = typeof BulkIdResponseDtoErrorEnum[keyof typeof BulkIdResponseDtoErrorEnum];
+
 /**
  * 
  * @export
@@ -1686,6 +1721,19 @@ export interface MemoryLaneResponseDto {
      */
     'assets': Array<AssetResponseDto>;
 }
+/**
+ * 
+ * @export
+ * @interface MergePersonDto
+ */
+export interface MergePersonDto {
+    /**
+     * 
+     * @type {Array<string>}
+     * @memberof MergePersonDto
+     */
+    'ids': Array<string>;
+}
 /**
  * 
  * @export
@@ -8807,6 +8855,54 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio
                 options: localVarRequestOptions,
             };
         },
+        /**
+         * 
+         * @param {string} id 
+         * @param {MergePersonDto} mergePersonDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        mergePerson: async (id: string, mergePersonDto: MergePersonDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            // verify required parameter 'id' is not null or undefined
+            assertParamExists('mergePerson', 'id', id)
+            // verify required parameter 'mergePersonDto' is not null or undefined
+            assertParamExists('mergePerson', 'mergePersonDto', mergePersonDto)
+            const localVarPath = `/person/{id}/merge`
+                .replace(`{${"id"}}`, encodeURIComponent(String(id)));
+            // 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: 'POST', ...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(mergePersonDto, localVarRequestOptions, configuration)
+
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
         /**
          * 
          * @param {string} id 
@@ -8904,6 +9000,17 @@ export const PersonApiFp = function(configuration?: Configuration) {
             const localVarAxiosArgs = await localVarAxiosParamCreator.getPersonThumbnail(id, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
+        /**
+         * 
+         * @param {string} id 
+         * @param {MergePersonDto} mergePersonDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async mergePerson(id: string, mergePersonDto: MergePersonDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<BulkIdResponseDto>>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.mergePerson(id, mergePersonDto, options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
         /**
          * 
          * @param {string} id 
@@ -8960,6 +9067,15 @@ export const PersonApiFactory = function (configuration?: Configuration, basePat
         getPersonThumbnail(requestParameters: PersonApiGetPersonThumbnailRequest, options?: AxiosRequestConfig): AxiosPromise<File> {
             return localVarFp.getPersonThumbnail(requestParameters.id, options).then((request) => request(axios, basePath));
         },
+        /**
+         * 
+         * @param {PersonApiMergePersonRequest} requestParameters Request parameters.
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        mergePerson(requestParameters: PersonApiMergePersonRequest, options?: AxiosRequestConfig): AxiosPromise<Array<BulkIdResponseDto>> {
+            return localVarFp.mergePerson(requestParameters.id, requestParameters.mergePersonDto, options).then((request) => request(axios, basePath));
+        },
         /**
          * 
          * @param {PersonApiUpdatePersonRequest} requestParameters Request parameters.
@@ -9014,6 +9130,27 @@ export interface PersonApiGetPersonThumbnailRequest {
     readonly id: string
 }
 
+/**
+ * Request parameters for mergePerson operation in PersonApi.
+ * @export
+ * @interface PersonApiMergePersonRequest
+ */
+export interface PersonApiMergePersonRequest {
+    /**
+     * 
+     * @type {string}
+     * @memberof PersonApiMergePerson
+     */
+    readonly id: string
+
+    /**
+     * 
+     * @type {MergePersonDto}
+     * @memberof PersonApiMergePerson
+     */
+    readonly mergePersonDto: MergePersonDto
+}
+
 /**
  * Request parameters for updatePerson operation in PersonApi.
  * @export
@@ -9085,6 +9222,17 @@ export class PersonApi extends BaseAPI {
         return PersonApiFp(this.configuration).getPersonThumbnail(requestParameters.id, options).then((request) => request(this.axios, this.basePath));
     }
 
+    /**
+     * 
+     * @param {PersonApiMergePersonRequest} requestParameters Request parameters.
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof PersonApi
+     */
+    public mergePerson(requestParameters: PersonApiMergePersonRequest, options?: AxiosRequestConfig) {
+        return PersonApiFp(this.configuration).mergePerson(requestParameters.id, requestParameters.mergePersonDto, options).then((request) => request(this.axios, this.basePath));
+    }
+
     /**
      * 
      * @param {PersonApiUpdatePersonRequest} requestParameters Request parameters.

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

@@ -32,6 +32,7 @@ doc/AssetTypeEnum.md
 doc/AudioCodec.md
 doc/AuthDeviceResponseDto.md
 doc/AuthenticationApi.md
+doc/BulkIdResponseDto.md
 doc/ChangePasswordDto.md
 doc/CheckDuplicateAssetDto.md
 doc/CheckDuplicateAssetResponseDto.md
@@ -64,6 +65,7 @@ doc/LoginResponseDto.md
 doc/LogoutResponseDto.md
 doc/MapMarkerResponseDto.md
 doc/MemoryLaneResponseDto.md
+doc/MergePersonDto.md
 doc/OAuthApi.md
 doc/OAuthCallbackDto.md
 doc/OAuthConfigDto.md
@@ -169,6 +171,7 @@ lib/model/asset_response_dto.dart
 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/change_password_dto.dart
 lib/model/check_duplicate_asset_dto.dart
 lib/model/check_duplicate_asset_response_dto.dart
@@ -200,6 +203,7 @@ lib/model/login_response_dto.dart
 lib/model/logout_response_dto.dart
 lib/model/map_marker_response_dto.dart
 lib/model/memory_lane_response_dto.dart
+lib/model/merge_person_dto.dart
 lib/model/o_auth_callback_dto.dart
 lib/model/o_auth_config_dto.dart
 lib/model/o_auth_config_response_dto.dart
@@ -277,6 +281,7 @@ test/asset_type_enum_test.dart
 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/change_password_dto_test.dart
 test/check_duplicate_asset_dto_test.dart
 test/check_duplicate_asset_response_dto_test.dart
@@ -309,6 +314,7 @@ test/login_response_dto_test.dart
 test/logout_response_dto_test.dart
 test/map_marker_response_dto_test.dart
 test/memory_lane_response_dto_test.dart
+test/merge_person_dto_test.dart
 test/o_auth_api_test.dart
 test/o_auth_callback_dto_test.dart
 test/o_auth_config_dto_test.dart

+ 3 - 0
mobile/openapi/README.md

@@ -134,6 +134,7 @@ Class | Method | HTTP request | Description
 *PersonApi* | [**getPerson**](doc//PersonApi.md#getperson) | **GET** /person/{id} | 
 *PersonApi* | [**getPersonAssets**](doc//PersonApi.md#getpersonassets) | **GET** /person/{id}/assets | 
 *PersonApi* | [**getPersonThumbnail**](doc//PersonApi.md#getpersonthumbnail) | **GET** /person/{id}/thumbnail | 
+*PersonApi* | [**mergePerson**](doc//PersonApi.md#mergeperson) | **POST** /person/{id}/merge | 
 *PersonApi* | [**updatePerson**](doc//PersonApi.md#updateperson) | **PUT** /person/{id} | 
 *SearchApi* | [**getExploreData**](doc//SearchApi.md#getexploredata) | **GET** /search/explore | 
 *SearchApi* | [**getSearchConfig**](doc//SearchApi.md#getsearchconfig) | **GET** /search/config | 
@@ -201,6 +202,7 @@ Class | Method | HTTP request | Description
  - [AssetTypeEnum](doc//AssetTypeEnum.md)
  - [AudioCodec](doc//AudioCodec.md)
  - [AuthDeviceResponseDto](doc//AuthDeviceResponseDto.md)
+ - [BulkIdResponseDto](doc//BulkIdResponseDto.md)
  - [ChangePasswordDto](doc//ChangePasswordDto.md)
  - [CheckDuplicateAssetDto](doc//CheckDuplicateAssetDto.md)
  - [CheckDuplicateAssetResponseDto](doc//CheckDuplicateAssetResponseDto.md)
@@ -232,6 +234,7 @@ Class | Method | HTTP request | Description
  - [LogoutResponseDto](doc//LogoutResponseDto.md)
  - [MapMarkerResponseDto](doc//MapMarkerResponseDto.md)
  - [MemoryLaneResponseDto](doc//MemoryLaneResponseDto.md)
+ - [MergePersonDto](doc//MergePersonDto.md)
  - [OAuthCallbackDto](doc//OAuthCallbackDto.md)
  - [OAuthConfigDto](doc//OAuthConfigDto.md)
  - [OAuthConfigResponseDto](doc//OAuthConfigResponseDto.md)

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

@@ -0,0 +1,17 @@
+# openapi.model.BulkIdResponseDto
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**id** | **String** |  | 
+**success** | **bool** |  | 
+**error** | **String** |  | [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)
+
+

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

@@ -0,0 +1,15 @@
+# openapi.model.MergePersonDto
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**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)
+
+

+ 58 - 0
mobile/openapi/doc/PersonApi.md

@@ -13,6 +13,7 @@ Method | HTTP request | Description
 [**getPerson**](PersonApi.md#getperson) | **GET** /person/{id} | 
 [**getPersonAssets**](PersonApi.md#getpersonassets) | **GET** /person/{id}/assets | 
 [**getPersonThumbnail**](PersonApi.md#getpersonthumbnail) | **GET** /person/{id}/thumbnail | 
+[**mergePerson**](PersonApi.md#mergeperson) | **POST** /person/{id}/merge | 
 [**updatePerson**](PersonApi.md#updateperson) | **PUT** /person/{id} | 
 
 
@@ -232,6 +233,63 @@ 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)
 
+# **mergePerson**
+> List<BulkIdResponseDto> mergePerson(id, mergePersonDto)
+
+
+
+### 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 = PersonApi();
+final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | 
+final mergePersonDto = MergePersonDto(); // MergePersonDto | 
+
+try {
+    final result = api_instance.mergePerson(id, mergePersonDto);
+    print(result);
+} catch (e) {
+    print('Exception when calling PersonApi->mergePerson: $e\n');
+}
+```
+
+### Parameters
+
+Name | Type | Description  | Notes
+------------- | ------------- | ------------- | -------------
+ **id** | **String**|  | 
+ **mergePersonDto** | [**MergePersonDto**](MergePersonDto.md)|  | 
+
+### Return type
+
+[**List<BulkIdResponseDto>**](BulkIdResponseDto.md)
+
+### Authorization
+
+[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
+
+### HTTP request headers
+
+ - **Content-Type**: application/json
+ - **Accept**: application/json
+
+[[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)
+
 # **updatePerson**
 > PersonResponseDto updatePerson(id, personUpdateDto)
 

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

@@ -68,6 +68,7 @@ part 'model/asset_response_dto.dart';
 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/change_password_dto.dart';
 part 'model/check_duplicate_asset_dto.dart';
 part 'model/check_duplicate_asset_response_dto.dart';
@@ -99,6 +100,7 @@ part 'model/login_response_dto.dart';
 part 'model/logout_response_dto.dart';
 part 'model/map_marker_response_dto.dart';
 part 'model/memory_lane_response_dto.dart';
+part 'model/merge_person_dto.dart';
 part 'model/o_auth_callback_dto.dart';
 part 'model/o_auth_config_dto.dart';
 part 'model/o_auth_config_response_dto.dart';

+ 55 - 0
mobile/openapi/lib/api/person_api.dart

@@ -207,6 +207,61 @@ class PersonApi {
     return null;
   }
 
+  /// Performs an HTTP 'POST /person/{id}/merge' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [String] id (required):
+  ///
+  /// * [MergePersonDto] mergePersonDto (required):
+  Future<Response> mergePersonWithHttpInfo(String id, MergePersonDto mergePersonDto,) async {
+    // ignore: prefer_const_declarations
+    final path = r'/person/{id}/merge'
+      .replaceAll('{id}', id);
+
+    // ignore: prefer_final_locals
+    Object? postBody = mergePersonDto;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>['application/json'];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'POST',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [String] id (required):
+  ///
+  /// * [MergePersonDto] mergePersonDto (required):
+  Future<List<BulkIdResponseDto>?> mergePerson(String id, MergePersonDto mergePersonDto,) async {
+    final response = await mergePersonWithHttpInfo(id, mergePersonDto,);
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+    // When a remote server returns no body with a status of 204, we shall not decode it.
+    // 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) {
+      final responseBody = await _decodeBodyBytes(response);
+      return (await apiClient.deserializeAsync(responseBody, 'List<BulkIdResponseDto>') as List)
+        .cast<BulkIdResponseDto>()
+        .toList();
+
+    }
+    return null;
+  }
+
   /// Performs an HTTP 'PUT /person/{id}' operation and returns the [Response].
   /// Parameters:
   ///

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

@@ -231,6 +231,8 @@ class ApiClient {
           return AudioCodecTypeTransformer().decode(value);
         case 'AuthDeviceResponseDto':
           return AuthDeviceResponseDto.fromJson(value);
+        case 'BulkIdResponseDto':
+          return BulkIdResponseDto.fromJson(value);
         case 'ChangePasswordDto':
           return ChangePasswordDto.fromJson(value);
         case 'CheckDuplicateAssetDto':
@@ -293,6 +295,8 @@ class ApiClient {
           return MapMarkerResponseDto.fromJson(value);
         case 'MemoryLaneResponseDto':
           return MemoryLaneResponseDto.fromJson(value);
+        case 'MergePersonDto':
+          return MergePersonDto.fromJson(value);
         case 'OAuthCallbackDto':
           return OAuthCallbackDto.fromJson(value);
         case 'OAuthConfigDto':

+ 197 - 0
mobile/openapi/lib/model/bulk_id_response_dto.dart

@@ -0,0 +1,197 @@
+//
+// 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 BulkIdResponseDto {
+  /// Returns a new [BulkIdResponseDto] instance.
+  BulkIdResponseDto({
+    required this.id,
+    required this.success,
+    this.error,
+  });
+
+  String id;
+
+  bool success;
+
+  BulkIdResponseDtoErrorEnum? error;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is BulkIdResponseDto &&
+     other.id == id &&
+     other.success == success &&
+     other.error == error;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (id.hashCode) +
+    (success.hashCode) +
+    (error == null ? 0 : error!.hashCode);
+
+  @override
+  String toString() => 'BulkIdResponseDto[id=$id, success=$success, error=$error]';
+
+  Map<String, dynamic> toJson() {
+    final json = <String, dynamic>{};
+      json[r'id'] = this.id;
+      json[r'success'] = this.success;
+    if (this.error != null) {
+      json[r'error'] = this.error;
+    } else {
+    //  json[r'error'] = null;
+    }
+    return json;
+  }
+
+  /// Returns a new [BulkIdResponseDto] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static BulkIdResponseDto? fromJson(dynamic value) {
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      return BulkIdResponseDto(
+        id: mapValueOfType<String>(json, r'id')!,
+        success: mapValueOfType<bool>(json, r'success')!,
+        error: BulkIdResponseDtoErrorEnum.fromJson(json[r'error']),
+      );
+    }
+    return null;
+  }
+
+  static List<BulkIdResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <BulkIdResponseDto>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = BulkIdResponseDto.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, BulkIdResponseDto> mapFromJson(dynamic json) {
+    final map = <String, BulkIdResponseDto>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = BulkIdResponseDto.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of BulkIdResponseDto-objects as value to a dart map
+  static Map<String, List<BulkIdResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<BulkIdResponseDto>>{};
+    if (json is Map && json.isNotEmpty) {
+      // ignore: parameter_assignments
+      json = json.cast<String, dynamic>();
+      for (final entry in json.entries) {
+        map[entry.key] = BulkIdResponseDto.listFromJson(entry.value, growable: growable,);
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'id',
+    'success',
+  };
+}
+
+
+class BulkIdResponseDtoErrorEnum {
+  /// Instantiate a new enum with the provided [value].
+  const BulkIdResponseDtoErrorEnum._(this.value);
+
+  /// The underlying value of this enum member.
+  final String value;
+
+  @override
+  String toString() => value;
+
+  String toJson() => value;
+
+  static const duplicate = BulkIdResponseDtoErrorEnum._(r'duplicate');
+  static const noPermission = BulkIdResponseDtoErrorEnum._(r'no_permission');
+  static const notFound = BulkIdResponseDtoErrorEnum._(r'not_found');
+  static const unknown = BulkIdResponseDtoErrorEnum._(r'unknown');
+
+  /// List of all possible values in this [enum][BulkIdResponseDtoErrorEnum].
+  static const values = <BulkIdResponseDtoErrorEnum>[
+    duplicate,
+    noPermission,
+    notFound,
+    unknown,
+  ];
+
+  static BulkIdResponseDtoErrorEnum? fromJson(dynamic value) => BulkIdResponseDtoErrorEnumTypeTransformer().decode(value);
+
+  static List<BulkIdResponseDtoErrorEnum>? listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <BulkIdResponseDtoErrorEnum>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = BulkIdResponseDtoErrorEnum.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+}
+
+/// Transformation class that can [encode] an instance of [BulkIdResponseDtoErrorEnum] to String,
+/// and [decode] dynamic data back to [BulkIdResponseDtoErrorEnum].
+class BulkIdResponseDtoErrorEnumTypeTransformer {
+  factory BulkIdResponseDtoErrorEnumTypeTransformer() => _instance ??= const BulkIdResponseDtoErrorEnumTypeTransformer._();
+
+  const BulkIdResponseDtoErrorEnumTypeTransformer._();
+
+  String encode(BulkIdResponseDtoErrorEnum data) => data.value;
+
+  /// Decodes a [dynamic value][data] to a BulkIdResponseDtoErrorEnum.
+  ///
+  /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
+  /// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
+  /// cannot be decoded successfully, then an [UnimplementedError] is thrown.
+  ///
+  /// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
+  /// and users are still using an old app with the old code.
+  BulkIdResponseDtoErrorEnum? decode(dynamic data, {bool allowNull = true}) {
+    if (data != null) {
+      switch (data) {
+        case r'duplicate': return BulkIdResponseDtoErrorEnum.duplicate;
+        case r'no_permission': return BulkIdResponseDtoErrorEnum.noPermission;
+        case r'not_found': return BulkIdResponseDtoErrorEnum.notFound;
+        case r'unknown': return BulkIdResponseDtoErrorEnum.unknown;
+        default:
+          if (!allowNull) {
+            throw ArgumentError('Unknown enum value to decode: $data');
+          }
+      }
+    }
+    return null;
+  }
+
+  /// Singleton [BulkIdResponseDtoErrorEnumTypeTransformer] instance.
+  static BulkIdResponseDtoErrorEnumTypeTransformer? _instance;
+}
+
+

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

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

+ 37 - 0
mobile/openapi/test/bulk_id_response_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 BulkIdResponseDto
+void main() {
+  // final instance = BulkIdResponseDto();
+
+  group('test BulkIdResponseDto', () {
+    // String id
+    test('to test the property `id`', () async {
+      // TODO
+    });
+
+    // bool success
+    test('to test the property `success`', () async {
+      // TODO
+    });
+
+    // String error
+    test('to test the property `error`', () async {
+      // TODO
+    });
+
+
+  });
+
+}

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

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

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

@@ -37,6 +37,11 @@ void main() {
       // TODO
     });
 
+    //Future<List<BulkIdResponseDto>> mergePerson(String id, MergePersonDto mergePersonDto) async
+    test('test mergePerson', () async {
+      // TODO
+    });
+
     //Future<PersonResponseDto> updatePerson(String id, PersonUpdateDto personUpdateDto) async
     test('test updatePerson', () async {
       // TODO

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

@@ -2693,6 +2693,61 @@
         ]
       }
     },
+    "/person/{id}/merge": {
+      "post": {
+        "operationId": "mergePerson",
+        "parameters": [
+          {
+            "name": "id",
+            "required": true,
+            "in": "path",
+            "schema": {
+              "format": "uuid",
+              "type": "string"
+            }
+          }
+        ],
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/MergePersonDto"
+              }
+            }
+          }
+        },
+        "responses": {
+          "201": {
+            "description": "",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "array",
+                  "items": {
+                    "$ref": "#/components/schemas/BulkIdResponseDto"
+                  }
+                }
+              }
+            }
+          }
+        },
+        "tags": [
+          "Person"
+        ],
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          },
+          {
+            "api_key": []
+          }
+        ]
+      }
+    },
     "/person/{id}/thumbnail": {
       "get": {
         "operationId": "getPersonThumbnail",
@@ -4963,6 +5018,30 @@
           "deviceOS"
         ]
       },
+      "BulkIdResponseDto": {
+        "type": "object",
+        "properties": {
+          "id": {
+            "type": "string"
+          },
+          "success": {
+            "type": "boolean"
+          },
+          "error": {
+            "type": "string",
+            "enum": [
+              "duplicate",
+              "no_permission",
+              "not_found",
+              "unknown"
+            ]
+          }
+        },
+        "required": [
+          "id",
+          "success"
+        ]
+      },
       "ChangePasswordDto": {
         "type": "object",
         "properties": {
@@ -5756,6 +5835,21 @@
           "assets"
         ]
       },
+      "MergePersonDto": {
+        "type": "object",
+        "properties": {
+          "ids": {
+            "type": "array",
+            "items": {
+              "type": "string",
+              "format": "uuid"
+            }
+          }
+        },
+        "required": [
+          "ids"
+        ]
+      },
       "OAuthCallbackDto": {
         "type": "object",
         "properties": {

+ 15 - 0
server/src/domain/asset/response-dto/asset-ids-response.dto.ts

@@ -1,11 +1,26 @@
+/** @deprecated Use `BulkIdResponseDto` instead */
 export enum AssetIdErrorReason {
   DUPLICATE = 'duplicate',
   NO_PERMISSION = 'no_permission',
   NOT_FOUND = 'not_found',
 }
 
+/** @deprecated Use `BulkIdResponseDto` instead */
 export class AssetIdsResponseDto {
   assetId!: string;
   success!: boolean;
   error?: AssetIdErrorReason;
 }
+
+export enum BulkIdErrorReason {
+  DUPLICATE = 'duplicate',
+  NO_PERMISSION = 'no_permission',
+  NOT_FOUND = 'not_found',
+  UNKNOWN = 'unknown',
+}
+
+export class BulkIdResponseDto {
+  id!: string;
+  success!: boolean;
+  error?: BulkIdErrorReason;
+}

+ 6 - 0
server/src/domain/person/person.dto.ts

@@ -1,5 +1,6 @@
 import { AssetFaceEntity, PersonEntity } from '@app/infra/entities';
 import { IsOptional, IsString } from 'class-validator';
+import { ValidateUUID } from '../domain.util';
 
 export class PersonUpdateDto {
   /**
@@ -17,6 +18,11 @@ export class PersonUpdateDto {
   featureFaceAssetId?: string;
 }
 
+export class MergePersonDto {
+  @ValidateUUID({ each: true })
+  ids!: string[];
+}
+
 export class PersonResponseDto {
   id!: string;
   name!: string;

+ 9 - 1
server/src/domain/person/person.repository.ts

@@ -6,11 +6,19 @@ export interface PersonSearchOptions {
   minimumFaceCount: number;
 }
 
+export interface UpdateFacesData {
+  oldPersonId: string;
+  newPersonId: string;
+}
+
 export interface IPersonRepository {
   getAll(userId: string, options: PersonSearchOptions): Promise<PersonEntity[]>;
   getAllWithoutFaces(): Promise<PersonEntity[]>;
   getById(userId: string, personId: string): Promise<PersonEntity | null>;
-  getAssets(userId: string, id: string): Promise<AssetEntity[]>;
+
+  getAssets(userId: string, personId: string): Promise<AssetEntity[]>;
+  prepareReassignFaces(data: UpdateFacesData): Promise<string[]>;
+  reassignFaces(data: UpdateFacesData): Promise<number>;
 
   create(entity: Partial<PersonEntity>): Promise<PersonEntity>;
   update(entity: Partial<PersonEntity>): Promise<PersonEntity>;

+ 83 - 1
server/src/domain/person/person.service.spec.ts

@@ -8,7 +8,8 @@ import {
   newStorageRepositoryMock,
   personStub,
 } from '@test';
-import { IJobRepository, JobName } from '..';
+import { BulkIdErrorReason } from '../asset';
+import { IJobRepository, JobName } from '../job';
 import { IStorageRepository } from '../storage';
 import { PersonResponseDto } from './person.dto';
 import { IPersonRepository } from './person.repository';
@@ -154,4 +155,85 @@ describe(PersonService.name, () => {
       });
     });
   });
+
+  describe('mergePerson', () => {
+    it('should merge two people', async () => {
+      personMock.getById.mockResolvedValueOnce(personStub.primaryPerson);
+      personMock.getById.mockResolvedValueOnce(personStub.mergePerson);
+      personMock.prepareReassignFaces.mockResolvedValue([]);
+      personMock.delete.mockResolvedValue(personStub.mergePerson);
+
+      await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([
+        { id: 'person-2', success: true },
+      ]);
+
+      expect(personMock.prepareReassignFaces).toHaveBeenCalledWith({
+        newPersonId: personStub.primaryPerson.id,
+        oldPersonId: personStub.mergePerson.id,
+      });
+
+      expect(personMock.reassignFaces).toHaveBeenCalledWith({
+        newPersonId: personStub.primaryPerson.id,
+        oldPersonId: personStub.mergePerson.id,
+      });
+
+      expect(personMock.delete).toHaveBeenCalledWith(personStub.mergePerson);
+    });
+
+    it('should delete conflicting faces before merging', async () => {
+      personMock.getById.mockResolvedValue(personStub.primaryPerson);
+      personMock.getById.mockResolvedValue(personStub.mergePerson);
+      personMock.prepareReassignFaces.mockResolvedValue([assetEntityStub.image.id]);
+
+      await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([
+        { id: 'person-2', success: true },
+      ]);
+
+      expect(personMock.prepareReassignFaces).toHaveBeenCalledWith({
+        newPersonId: personStub.primaryPerson.id,
+        oldPersonId: personStub.mergePerson.id,
+      });
+
+      expect(jobMock.queue).toHaveBeenCalledWith({
+        name: JobName.SEARCH_REMOVE_FACE,
+        data: { assetId: assetEntityStub.image.id, personId: personStub.mergePerson.id },
+      });
+    });
+
+    it('should throw an error when the primary person is not found', async () => {
+      personMock.getById.mockResolvedValue(null);
+
+      await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).rejects.toBeInstanceOf(
+        BadRequestException,
+      );
+
+      expect(personMock.delete).not.toHaveBeenCalled();
+    });
+
+    it('should handle invalid merge ids', async () => {
+      personMock.getById.mockResolvedValueOnce(personStub.primaryPerson);
+      personMock.getById.mockResolvedValueOnce(null);
+
+      await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([
+        { id: 'person-2', success: false, error: BulkIdErrorReason.NOT_FOUND },
+      ]);
+
+      expect(personMock.prepareReassignFaces).not.toHaveBeenCalled();
+      expect(personMock.reassignFaces).not.toHaveBeenCalled();
+      expect(personMock.delete).not.toHaveBeenCalled();
+    });
+
+    it('should handle an error reassigning faces', async () => {
+      personMock.getById.mockResolvedValue(personStub.primaryPerson);
+      personMock.getById.mockResolvedValue(personStub.mergePerson);
+      personMock.prepareReassignFaces.mockResolvedValue([assetEntityStub.image.id]);
+      personMock.reassignFaces.mockRejectedValue(new Error('update failed'));
+
+      await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([
+        { id: 'person-2', success: false, error: BulkIdErrorReason.UNKNOWN },
+      ]);
+
+      expect(personMock.delete).not.toHaveBeenCalled();
+    });
+  });
 });

+ 81 - 56
server/src/domain/person/person.service.ts

@@ -1,12 +1,11 @@
-import { PersonEntity } from '@app/infra/entities';
 import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
-import { AssetResponseDto, mapAsset } from '../asset';
+import { AssetResponseDto, BulkIdErrorReason, BulkIdResponseDto, mapAsset } from '../asset';
 import { AuthUserDto } from '../auth';
 import { mimeTypes } from '../domain.constant';
 import { IJobRepository, JobName } from '../job';
 import { ImmichReadStream, IStorageRepository } from '../storage';
-import { mapPerson, PersonResponseDto, PersonUpdateDto } from './person.dto';
-import { IPersonRepository } from './person.repository';
+import { mapPerson, MergePersonDto, PersonResponseDto, PersonUpdateDto } from './person.dto';
+import { IPersonRepository, UpdateFacesData } from './person.repository';
 
 @Injectable()
 export class PersonService {
@@ -30,17 +29,12 @@ export class PersonService {
     );
   }
 
-  async getById(authUser: AuthUserDto, personId: string): Promise<PersonResponseDto> {
-    const person = await this.repository.getById(authUser.id, personId);
-    if (!person) {
-      throw new BadRequestException();
-    }
-
-    return mapPerson(person);
+  getById(authUser: AuthUserDto, id: string): Promise<PersonResponseDto> {
+    return this.findOrFail(authUser, id).then(mapPerson);
   }
 
-  async getThumbnail(authUser: AuthUserDto, personId: string): Promise<ImmichReadStream> {
-    const person = await this.repository.getById(authUser.id, personId);
+  async getThumbnail(authUser: AuthUserDto, id: string): Promise<ImmichReadStream> {
+    const person = await this.repository.getById(authUser.id, id);
     if (!person || !person.thumbnailPath) {
       throw new NotFoundException();
     }
@@ -48,60 +42,46 @@ export class PersonService {
     return this.storageRepository.createReadStream(person.thumbnailPath, mimeTypes.lookup(person.thumbnailPath));
   }
 
-  async getAssets(authUser: AuthUserDto, personId: string): Promise<AssetResponseDto[]> {
-    const assets = await this.repository.getAssets(authUser.id, personId);
+  async getAssets(authUser: AuthUserDto, id: string): Promise<AssetResponseDto[]> {
+    const assets = await this.repository.getAssets(authUser.id, id);
     return assets.map(mapAsset);
   }
 
-  async update(authUser: AuthUserDto, personId: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
-    let person = await this.repository.getById(authUser.id, personId);
-    if (!person) {
-      throw new BadRequestException();
-    }
+  async update(authUser: AuthUserDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
+    let person = await this.findOrFail(authUser, id);
 
     if (dto.name) {
-      person = await this.updateName(authUser, personId, dto.name);
+      person = await this.repository.update({ id, name: dto.name });
+      const assets = await this.repository.getAssets(authUser.id, id);
+      const ids = assets.map((asset) => asset.id);
+      await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } });
     }
 
     if (dto.featureFaceAssetId) {
-      await this.updateFaceThumbnail(personId, dto.featureFaceAssetId);
-    }
-
-    return mapPerson(person);
-  }
-
-  private async updateName(authUser: AuthUserDto, personId: string, name: string): Promise<PersonEntity> {
-    const person = await this.repository.update({ id: personId, name });
-
-    const relatedAsset = await this.getAssets(authUser, personId);
-    const assetIds = relatedAsset.map((asset) => asset.id);
-    await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids: assetIds } });
-
-    return person;
-  }
-
-  private async updateFaceThumbnail(personId: string, assetId: string): Promise<void> {
-    const face = await this.repository.getFaceById({ assetId, personId });
+      const assetId = dto.featureFaceAssetId;
+      const face = await this.repository.getFaceById({ personId: id, assetId });
+      if (!face) {
+        throw new BadRequestException('Invalid assetId for feature face');
+      }
 
-    if (!face) {
-      throw new BadRequestException();
+      await this.jobRepository.queue({
+        name: JobName.GENERATE_FACE_THUMBNAIL,
+        data: {
+          personId: id,
+          assetId,
+          boundingBox: {
+            x1: face.boundingBoxX1,
+            x2: face.boundingBoxX2,
+            y1: face.boundingBoxY1,
+            y2: face.boundingBoxY2,
+          },
+          imageHeight: face.imageHeight,
+          imageWidth: face.imageWidth,
+        },
+      });
     }
 
-    return await this.jobRepository.queue({
-      name: JobName.GENERATE_FACE_THUMBNAIL,
-      data: {
-        assetId: assetId,
-        personId,
-        boundingBox: {
-          x1: face.boundingBoxX1,
-          x2: face.boundingBoxX2,
-          y1: face.boundingBoxY1,
-          y2: face.boundingBoxY2,
-        },
-        imageHeight: face.imageHeight,
-        imageWidth: face.imageWidth,
-      },
-    });
+    return mapPerson(person);
   }
 
   async handlePersonCleanup() {
@@ -118,4 +98,49 @@ export class PersonService {
 
     return true;
   }
+
+  async mergePerson(authUser: AuthUserDto, id: string, dto: MergePersonDto): Promise<BulkIdResponseDto[]> {
+    const mergeIds = dto.ids;
+    const primaryPerson = await this.findOrFail(authUser, id);
+    const primaryName = primaryPerson.name || primaryPerson.id;
+
+    const results: BulkIdResponseDto[] = [];
+
+    for (const mergeId of mergeIds) {
+      try {
+        const mergePerson = await this.repository.getById(authUser.id, mergeId);
+        if (!mergePerson) {
+          results.push({ id: mergeId, success: false, error: BulkIdErrorReason.NOT_FOUND });
+          continue;
+        }
+
+        const mergeName = mergePerson.name || mergePerson.id;
+        const mergeData: UpdateFacesData = { oldPersonId: mergeId, newPersonId: id };
+        this.logger.log(`Merging ${mergeName} into ${primaryName}`);
+
+        const assetIds = await this.repository.prepareReassignFaces(mergeData);
+        for (const assetId of assetIds) {
+          await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_FACE, data: { assetId, personId: mergeId } });
+        }
+        await this.repository.reassignFaces(mergeData);
+        await this.repository.delete(mergePerson);
+
+        this.logger.log(`Merged ${mergeName} into ${primaryName}`);
+        results.push({ id: mergeId, success: true });
+      } catch (error: Error | any) {
+        this.logger.error(`Unable to merge ${mergeId} into ${id}: ${error}`, error?.stack);
+        results.push({ id: mergeId, success: false, error: BulkIdErrorReason.UNKNOWN });
+      }
+    }
+
+    return results;
+  }
+
+  private async findOrFail(authUser: AuthUserDto, id: string) {
+    const person = await this.repository.getById(authUser.id, id);
+    if (!person) {
+      throw new BadRequestException('Person not found');
+    }
+    return person;
+  }
 }

+ 12 - 1
server/src/immich/controllers/person.controller.ts

@@ -1,12 +1,14 @@
 import {
   AssetResponseDto,
   AuthUserDto,
+  BulkIdResponseDto,
   ImmichReadStream,
+  MergePersonDto,
   PersonResponseDto,
   PersonService,
   PersonUpdateDto,
 } from '@app/domain';
-import { Body, Controller, Get, Param, Put, StreamableFile } from '@nestjs/common';
+import { Body, Controller, Get, Param, Post, Put, StreamableFile } from '@nestjs/common';
 import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
 import { Authenticated, AuthUser } from '../app.guard';
 import { UseValidation } from '../app.utils';
@@ -56,4 +58,13 @@ export class PersonController {
   getPersonAssets(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<AssetResponseDto[]> {
     return this.service.getAssets(authUser, id);
   }
+
+  @Post(':id/merge')
+  mergePerson(
+    @AuthUser() authUser: AuthUserDto,
+    @Param() { id }: UUIDParamDto,
+    @Body() dto: MergePersonDto,
+  ): Promise<BulkIdResponseDto[]> {
+    return this.service.mergePerson(authUser, id, dto);
+  }
 }

+ 32 - 2
server/src/infra/repositories/person.repository.ts

@@ -1,6 +1,6 @@
-import { AssetFaceId, IPersonRepository, PersonSearchOptions } from '@app/domain';
+import { AssetFaceId, IPersonRepository, PersonSearchOptions, UpdateFacesData } from '@app/domain';
 import { InjectRepository } from '@nestjs/typeorm';
-import { Repository } from 'typeorm';
+import { In, Repository } from 'typeorm';
 import { AssetEntity, AssetFaceEntity, PersonEntity } from '../entities';
 
 export class PersonRepository implements IPersonRepository {
@@ -10,6 +10,36 @@ export class PersonRepository implements IPersonRepository {
     @InjectRepository(AssetFaceEntity) private assetFaceRepository: Repository<AssetFaceEntity>,
   ) {}
 
+  /**
+   * Before reassigning faces, delete potential key violations
+   */
+  async prepareReassignFaces({ oldPersonId, newPersonId }: UpdateFacesData): Promise<string[]> {
+    const results = await this.assetFaceRepository
+      .createQueryBuilder('face')
+      .select('face."assetId"')
+      .where(`face."personId" IN (:...ids)`, { ids: [oldPersonId, newPersonId] })
+      .groupBy('face."assetId"')
+      .having('COUNT(face."personId") > 1')
+      .getRawMany();
+
+    const assetIds = results.map(({ assetId }) => assetId);
+
+    await this.assetFaceRepository.delete({ personId: oldPersonId, assetId: In(assetIds) });
+
+    return assetIds;
+  }
+
+  async reassignFaces({ oldPersonId, newPersonId }: UpdateFacesData): Promise<number> {
+    const result = await this.assetFaceRepository
+      .createQueryBuilder()
+      .update()
+      .set({ personId: newPersonId })
+      .where({ personId: oldPersonId })
+      .execute();
+
+    return result.affected ?? 0;
+  }
+
   delete(entity: PersonEntity): Promise<PersonEntity | null> {
     return this.personRepository.remove(entity);
   }

+ 92 - 0
server/test/fixtures.ts

@@ -327,6 +327,39 @@ export const assetEntityStub = {
       fileSizeInByte: 5_000,
     } as ExifEntity,
   }),
+  image1: Object.freeze<AssetEntity>({
+    id: 'asset-id-1',
+    deviceAssetId: 'device-asset-id',
+    fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
+    fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
+    owner: userEntityStub.user1,
+    ownerId: 'user-id',
+    deviceId: 'device-id',
+    originalPath: '/original/path.ext',
+    resizePath: '/uploads/user-id/thumbs/path.ext',
+    checksum: Buffer.from('file hash', 'utf8'),
+    type: AssetType.IMAGE,
+    webpPath: '/uploads/user-id/webp/path.ext',
+    thumbhash: Buffer.from('blablabla', 'base64'),
+    encodedVideoPath: null,
+    createdAt: new Date('2023-02-23T05:06:29.716Z'),
+    updatedAt: new Date('2023-02-23T05:06:29.716Z'),
+    isFavorite: true,
+    isArchived: false,
+    isReadOnly: false,
+    duration: null,
+    isVisible: true,
+    livePhotoVideo: null,
+    livePhotoVideoId: null,
+    tags: [],
+    sharedLinks: [],
+    originalFileName: 'asset-id.ext',
+    faces: [],
+    sidecarPath: null,
+    exifInfo: {
+      fileSizeInByte: 5_000,
+    } as ExifEntity,
+  }),
   video: Object.freeze<AssetEntity>({
     id: 'asset-id',
     originalFileName: 'asset-id.ext',
@@ -1158,6 +1191,26 @@ export const personStub = {
     thumbnailPath: '/new/path/to/thumbnail.jpg',
     faces: [],
   }),
+  primaryPerson: Object.freeze<PersonEntity>({
+    id: 'person-1',
+    createdAt: new Date('2021-01-01'),
+    updatedAt: new Date('2021-01-01'),
+    ownerId: userEntityStub.admin.id,
+    owner: userEntityStub.admin,
+    name: 'Person 1',
+    thumbnailPath: '/path/to/thumbnail',
+    faces: [],
+  }),
+  mergePerson: Object.freeze<PersonEntity>({
+    id: 'person-2',
+    createdAt: new Date('2021-01-01'),
+    updatedAt: new Date('2021-01-01'),
+    ownerId: userEntityStub.admin.id,
+    owner: userEntityStub.admin,
+    name: 'Person 2',
+    thumbnailPath: '/path/to/thumbnail',
+    faces: [],
+  }),
 };
 
 export const partnerStub = {
@@ -1193,6 +1246,45 @@ export const faceStub = {
     imageHeight: 1024,
     imageWidth: 1024,
   }),
+  primaryFace1: Object.freeze<AssetFaceEntity>({
+    assetId: assetEntityStub.image.id,
+    asset: assetEntityStub.image,
+    personId: personStub.primaryPerson.id,
+    person: personStub.primaryPerson,
+    embedding: [1, 2, 3, 4],
+    boundingBoxX1: 0,
+    boundingBoxY1: 0,
+    boundingBoxX2: 1,
+    boundingBoxY2: 1,
+    imageHeight: 1024,
+    imageWidth: 1024,
+  }),
+  mergeFace1: Object.freeze<AssetFaceEntity>({
+    assetId: assetEntityStub.image.id,
+    asset: assetEntityStub.image,
+    personId: personStub.mergePerson.id,
+    person: personStub.mergePerson,
+    embedding: [1, 2, 3, 4],
+    boundingBoxX1: 0,
+    boundingBoxY1: 0,
+    boundingBoxX2: 1,
+    boundingBoxY2: 1,
+    imageHeight: 1024,
+    imageWidth: 1024,
+  }),
+  mergeFace2: Object.freeze<AssetFaceEntity>({
+    assetId: assetEntityStub.image1.id,
+    asset: assetEntityStub.image1,
+    personId: personStub.mergePerson.id,
+    person: personStub.mergePerson,
+    embedding: [1, 2, 3, 4],
+    boundingBoxX1: 0,
+    boundingBoxY1: 0,
+    boundingBoxX2: 1,
+    boundingBoxY2: 1,
+    imageHeight: 1024,
+    imageWidth: 1024,
+  }),
 };
 
 export const tagStub = {

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

@@ -13,5 +13,7 @@ export const newPersonRepositoryMock = (): jest.Mocked<IPersonRepository> => {
     delete: jest.fn(),
 
     getFaceById: jest.fn(),
+    prepareReassignFaces: jest.fn(),
+    reassignFaces: jest.fn(),
   };
 };

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

@@ -798,6 +798,41 @@ export interface AuthDeviceResponseDto {
      */
     'deviceOS': string;
 }
+/**
+ * 
+ * @export
+ * @interface BulkIdResponseDto
+ */
+export interface BulkIdResponseDto {
+    /**
+     * 
+     * @type {string}
+     * @memberof BulkIdResponseDto
+     */
+    'id': string;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof BulkIdResponseDto
+     */
+    'success': boolean;
+    /**
+     * 
+     * @type {string}
+     * @memberof BulkIdResponseDto
+     */
+    'error'?: BulkIdResponseDtoErrorEnum;
+}
+
+export const BulkIdResponseDtoErrorEnum = {
+    Duplicate: 'duplicate',
+    NoPermission: 'no_permission',
+    NotFound: 'not_found',
+    Unknown: 'unknown'
+} as const;
+
+export type BulkIdResponseDtoErrorEnum = typeof BulkIdResponseDtoErrorEnum[keyof typeof BulkIdResponseDtoErrorEnum];
+
 /**
  * 
  * @export
@@ -1686,6 +1721,19 @@ export interface MemoryLaneResponseDto {
      */
     'assets': Array<AssetResponseDto>;
 }
+/**
+ * 
+ * @export
+ * @interface MergePersonDto
+ */
+export interface MergePersonDto {
+    /**
+     * 
+     * @type {Array<string>}
+     * @memberof MergePersonDto
+     */
+    'ids': Array<string>;
+}
 /**
  * 
  * @export
@@ -8852,6 +8900,54 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio
                 options: localVarRequestOptions,
             };
         },
+        /**
+         * 
+         * @param {string} id 
+         * @param {MergePersonDto} mergePersonDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        mergePerson: async (id: string, mergePersonDto: MergePersonDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            // verify required parameter 'id' is not null or undefined
+            assertParamExists('mergePerson', 'id', id)
+            // verify required parameter 'mergePersonDto' is not null or undefined
+            assertParamExists('mergePerson', 'mergePersonDto', mergePersonDto)
+            const localVarPath = `/person/{id}/merge`
+                .replace(`{${"id"}}`, encodeURIComponent(String(id)));
+            // 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: 'POST', ...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(mergePersonDto, localVarRequestOptions, configuration)
+
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
         /**
          * 
          * @param {string} id 
@@ -8949,6 +9045,17 @@ export const PersonApiFp = function(configuration?: Configuration) {
             const localVarAxiosArgs = await localVarAxiosParamCreator.getPersonThumbnail(id, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
+        /**
+         * 
+         * @param {string} id 
+         * @param {MergePersonDto} mergePersonDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async mergePerson(id: string, mergePersonDto: MergePersonDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<BulkIdResponseDto>>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.mergePerson(id, mergePersonDto, options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
         /**
          * 
          * @param {string} id 
@@ -9005,6 +9112,16 @@ export const PersonApiFactory = function (configuration?: Configuration, basePat
         getPersonThumbnail(id: string, options?: any): AxiosPromise<File> {
             return localVarFp.getPersonThumbnail(id, options).then((request) => request(axios, basePath));
         },
+        /**
+         * 
+         * @param {string} id 
+         * @param {MergePersonDto} mergePersonDto 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        mergePerson(id: string, mergePersonDto: MergePersonDto, options?: any): AxiosPromise<Array<BulkIdResponseDto>> {
+            return localVarFp.mergePerson(id, mergePersonDto, options).then((request) => request(axios, basePath));
+        },
         /**
          * 
          * @param {string} id 
@@ -9060,6 +9177,27 @@ export interface PersonApiGetPersonThumbnailRequest {
     readonly id: string
 }
 
+/**
+ * Request parameters for mergePerson operation in PersonApi.
+ * @export
+ * @interface PersonApiMergePersonRequest
+ */
+export interface PersonApiMergePersonRequest {
+    /**
+     * 
+     * @type {string}
+     * @memberof PersonApiMergePerson
+     */
+    readonly id: string
+
+    /**
+     * 
+     * @type {MergePersonDto}
+     * @memberof PersonApiMergePerson
+     */
+    readonly mergePersonDto: MergePersonDto
+}
+
 /**
  * Request parameters for updatePerson operation in PersonApi.
  * @export
@@ -9131,6 +9269,17 @@ export class PersonApi extends BaseAPI {
         return PersonApiFp(this.configuration).getPersonThumbnail(requestParameters.id, options).then((request) => request(this.axios, this.basePath));
     }
 
+    /**
+     * 
+     * @param {PersonApiMergePersonRequest} requestParameters Request parameters.
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof PersonApi
+     */
+    public mergePerson(requestParameters: PersonApiMergePersonRequest, options?: AxiosRequestConfig) {
+        return PersonApiFp(this.configuration).mergePerson(requestParameters.id, requestParameters.mergePersonDto, options).then((request) => request(this.axios, this.basePath));
+    }
+
     /**
      * 
      * @param {PersonApiUpdatePersonRequest} requestParameters Request parameters.

+ 66 - 0
web/src/lib/components/faces-page/face-thumbnail.svelte

@@ -0,0 +1,66 @@
+<script lang="ts">
+  import { api, type PersonResponseDto } from '@api';
+  import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
+  import { createEventDispatcher } from 'svelte';
+
+  export let person: PersonResponseDto;
+  export let selectable = false;
+  export let selected = false;
+  export let thumbnailSize: number | null = null;
+  export let circle = false;
+  export let border = false;
+
+  let dispatch = createEventDispatcher();
+
+  const handleOnClicked = () => {
+    dispatch('click', person);
+  };
+</script>
+
+<button
+  class="relative transition-all rounded-lg"
+  on:click={handleOnClicked}
+  disabled={!selectable}
+  style:width={thumbnailSize ? thumbnailSize + 'px' : '100%'}
+  style:height={thumbnailSize ? thumbnailSize + 'px' : '100%'}
+>
+  <div
+    class="filter w-full h-full brightness-90 border-2"
+    class:rounded-full={circle}
+    class:rounded-lg={!circle}
+    class:border-transparent={!border}
+    class:dark:border-immich-dark-primary={border}
+    class:border-immich-primary={border}
+  >
+    <ImageThumbnail
+      {circle}
+      url={api.getPeopleThumbnailUrl(person.id)}
+      altText={person.name}
+      widthStyle="100%"
+      shadow
+    />
+  </div>
+
+  <div
+    class="absolute top-0 left-0 w-full h-full bg-immich-primary/30 opacity-0"
+    class:hover:opacity-100={selectable}
+    class:rounded-full={circle}
+    class:rounded-lg={!circle}
+  />
+
+  {#if selected}
+    <div
+      class="absolute top-0 left-0 w-full h-full bg-blue-500/80"
+      class:rounded-full={circle}
+      class:rounded-lg={!circle}
+    />
+  {/if}
+
+  {#if person.name}
+    <span
+      class="absolute bottom-2 left-0 w-full text-center font-medium text-white text-ellipsis w-100 px-1 hover:cursor-pointer backdrop-blur-[1px]"
+    >
+      {person.name}
+    </span>
+  {/if}
+</button>

+ 145 - 0
web/src/lib/components/faces-page/merge-face-selector.svelte

@@ -0,0 +1,145 @@
+<script lang="ts">
+  import { createEventDispatcher, onMount } from 'svelte';
+  import { api, type PersonResponseDto } from '@api';
+  import FaceThumbnail from './face-thumbnail.svelte';
+  import { quintOut } from 'svelte/easing';
+  import { fly } from 'svelte/transition';
+  import ControlAppBar from '../shared-components/control-app-bar.svelte';
+  import Button from '../elements/buttons/button.svelte';
+  import Merge from 'svelte-material-icons/Merge.svelte';
+  import CallMerge from 'svelte-material-icons/CallMerge.svelte';
+  import { flip } from 'svelte/animate';
+  import { NotificationType, notificationController } from '../shared-components/notification/notification';
+  import ConfirmDialogue from '../shared-components/confirm-dialogue.svelte';
+  import { handleError } from '$lib/utils/handle-error';
+  import { invalidateAll } from '$app/navigation';
+
+  export let person: PersonResponseDto;
+  let people: PersonResponseDto[] = [];
+  let selectedPeople: PersonResponseDto[] = [];
+  let screenHeight: number;
+  let isShowConfirmation = false;
+  let dispatch = createEventDispatcher();
+
+  $: hasSelection = selectedPeople.length > 0;
+  $: unselectedPeople = people.filter((source) => !selectedPeople.includes(source) && source.id !== person.id);
+
+  onMount(async () => {
+    const { data } = await api.personApi.getAllPeople();
+    people = data;
+  });
+
+  const onClose = () => {
+    dispatch('go-back');
+  };
+
+  const onSelect = (selected: PersonResponseDto) => {
+    if (selectedPeople.includes(selected)) {
+      selectedPeople = selectedPeople.filter((person) => person.id !== selected.id);
+      return;
+    }
+
+    if (selectedPeople.length >= 5) {
+      notificationController.show({
+        message: 'You can only merge up to 5 faces at a time',
+        type: NotificationType.Info,
+      });
+      return;
+    }
+
+    selectedPeople = [selected, ...selectedPeople];
+  };
+
+  const handleMerge = async () => {
+    try {
+      const { data: results } = await api.personApi.mergePerson({
+        id: person.id,
+        mergePersonDto: { ids: selectedPeople.map(({ id }) => id) },
+      });
+      const count = results.filter(({ success }) => success).length;
+      notificationController.show({
+        message: `Merged ${count} ${count === 1 ? 'person' : 'people'}`,
+        type: NotificationType.Info,
+      });
+      await invalidateAll();
+      onClose();
+    } catch (error) {
+      handleError(error, 'Cannot merge faces');
+    } finally {
+      isShowConfirmation = false;
+    }
+  };
+</script>
+
+<svelte:window bind:innerHeight={screenHeight} />
+
+<section
+  transition:fly={{ y: 500, duration: 100, easing: quintOut }}
+  class="absolute top-0 left-0 w-full h-full bg-immich-bg dark:bg-immich-dark-bg z-[9999]"
+>
+  <ControlAppBar on:close-button-click={onClose}>
+    <svelte:fragment slot="leading">
+      {#if hasSelection}
+        Selected {selectedPeople.length}
+      {:else}
+        Merge faces
+      {/if}
+      <div />
+    </svelte:fragment>
+    <svelte:fragment slot="trailing">
+      <Button
+        size={'sm'}
+        disabled={!hasSelection}
+        on:click={() => {
+          isShowConfirmation = true;
+        }}
+      >
+        <Merge size={18} />
+        <span class="ml-2"> Merge</span></Button
+      >
+    </svelte:fragment>
+  </ControlAppBar>
+  <section class="pt-[100px] px-[70px] bg-immich-bg dark:bg-immich-dark-bg">
+    <section id="merge-face-selector relative">
+      <div class="place-items-center place-content-center mb-10 h-[200px]">
+        <p class="uppercase mb-4 dark:text-white text-center">Choose matching faces to merge</p>
+
+        <div class="grid grid-flow-col-dense place-items-center place-content-center gap-4">
+          {#each selectedPeople as person (person.id)}
+            <div animate:flip={{ duration: 250, easing: quintOut }}>
+              <FaceThumbnail border circle {person} selectable thumbnailSize={120} on:click={() => onSelect(person)} />
+            </div>
+          {/each}
+
+          {#if hasSelection}
+            <span><CallMerge size={48} class="rotate-90 dark:text-white" /> </span>
+          {/if}
+          <FaceThumbnail {person} border circle selectable={false} thumbnailSize={180} />
+        </div>
+      </div>
+      <div
+        class="p-10 overflow-y-auto rounded-3xl bg-gray-200 dark:bg-immich-dark-gray"
+        style:max-height={screenHeight - 200 - 200 + 'px'}
+      >
+        <div class="grid grid-col-2 md:grid-cols-3 lg:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-10 gap-8">
+          {#each unselectedPeople as person (person.id)}
+            <FaceThumbnail {person} on:click={() => onSelect(person)} circle border selectable />
+          {/each}
+        </div>
+      </div>
+    </section>
+
+    {#if isShowConfirmation}
+      <ConfirmDialogue
+        title="Merge faces"
+        confirmText="Merge"
+        on:confirm={handleMerge}
+        on:cancel={() => (isShowConfirmation = false)}
+      >
+        <svelte:fragment slot="prompt">
+          <p>Are you sure you want merge these faces? <br />This action is <strong>irreversible</strong>.</p>
+        </svelte:fragment>
+      </ConfirmDialogue>
+    {/if}
+  </section>
+</section>

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

@@ -38,7 +38,7 @@
         {#if data.people.length > MAX_ITEMS}
           <a
             href={AppRoute.PEOPLE}
-            class="font-medium hover:text-immich-primary dark:hover:text-immich-dark-primary dark:text-immich-dark-fg"
+            class="font-medium text-sm pr-4 hover:text-immich-primary dark:hover:text-immich-dark-primary dark:text-immich-dark-fg"
             draggable="false">View All</a
           >
         {/if}

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

@@ -15,7 +15,7 @@
         {#each data.people as person (person.id)}
           <div class="relative">
             <a href="/people/{person.id}" draggable="false">
-              <div class="filter brightness-75 rounded-xl w-48">
+              <div class="filter brightness-95 rounded-xl w-48">
                 <ImageThumbnail
                   shadow
                   url={api.getPeopleThumbnailUrl(person.id)}

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

@@ -28,17 +28,20 @@
     NotificationType,
     notificationController,
   } from '$lib/components/shared-components/notification/notification';
+  import MergeFaceSelector from '$lib/components/faces-page/merge-face-selector.svelte';
 
   export let data: PageData;
-
   let isEditingName = false;
-  let isSelectingFace = false;
+  let showFaceThumbnailSelection = false;
+  let showMergeFacePanel = false;
   let previousRoute: string = AppRoute.EXPLORE;
   let selectedAssets: Set<AssetResponseDto> = new Set();
   $: isMultiSelectionMode = selectedAssets.size > 0;
   $: isAllArchive = Array.from(selectedAssets).every((asset) => asset.isArchived);
   $: isAllFavorite = Array.from(selectedAssets).every((asset) => asset.isFavorite);
 
+  $: showAssets = !showMergeFacePanel && !showFaceThumbnailSelection;
+
   afterNavigate(({ from }) => {
     // Prevent setting previousRoute to the current page.
     if (from && from.route.id !== $page.route.id) {
@@ -64,7 +67,7 @@
   };
 
   const handleSelectFeaturePhoto = async (event: CustomEvent) => {
-    isSelectingFace = false;
+    showFaceThumbnailSelection = false;
 
     const { selectedAsset }: { selectedAsset: AssetResponseDto | undefined } = event.detail;
 
@@ -102,7 +105,8 @@
   <ControlAppBar showBackButton backIcon={ArrowLeft} on:close-button-click={() => goto(previousRoute)}>
     <svelte:fragment slot="trailing">
       <AssetSelectContextMenu icon={DotsVertical} title="Menu">
-        <MenuOption text="Change feature photo" on:click={() => (isSelectingFace = true)} />
+        <MenuOption text="Change feature photo" on:click={() => (showFaceThumbnailSelection = true)} />
+        <MenuOption text="Merge face" on:click={() => (showMergeFacePanel = true)} />
       </AssetSelectContextMenu>
     </svelte:fragment>
   </ControlAppBar>
@@ -117,7 +121,7 @@
       on:cancel={() => (isEditingName = false)}
     />
   {:else}
-    <button on:click={() => (isSelectingFace = true)}>
+    <button on:click={() => (showFaceThumbnailSelection = true)}>
       <ImageThumbnail
         circle
         shadow
@@ -144,9 +148,9 @@
 </section>
 
 <!-- Gallery Block -->
-{#if !isSelectingFace}
+{#if showAssets}
   <section class="relative pt-8 sm:px-4 mb-12 bg-immich-bg dark:bg-immich-dark-bg">
-    <section class="overflow-y-auto relative immich-scrollbar">
+    <section class="overflow-y-scroll relative immich-scrollbar">
       <section id="search-content" class="relative bg-immich-bg dark:bg-immich-dark-bg">
         <GalleryViewer assets={data.assets} viewFrom="search-page" showArchiveIcon={true} bind:selectedAssets />
       </section>
@@ -154,6 +158,10 @@
   </section>
 {/if}
 
-{#if isSelectingFace}
+{#if showFaceThumbnailSelection}
   <FaceThumbnailSelector assets={data.assets} on:go-back={handleSelectFeaturePhoto} />
 {/if}
+
+{#if showMergeFacePanel}
+  <MergeFaceSelector person={data.person} on:go-back={() => (showMergeFacePanel = false)} />
+{/if}