浏览代码

feat(server,web): improve performances in person page (1) (#4387)

* feat: improve performances in people page

* feat: add loadingspinner when searching

* fix: reset people on error

* fix: case insensitive

* feat: better sql query

* fix: reset people list before api request

* fix: format
martin 1 年之前
父节点
当前提交
b8d6cc1e09

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

@@ -12139,6 +12139,51 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio
 
 
 
 
     
     
+            setSearchParams(localVarUrlObj, localVarQueryParameter);
+            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
+        /**
+         * 
+         * @param {string} name 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        searchPerson: async (name: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            // verify required parameter 'name' is not null or undefined
+            assertParamExists('searchPerson', 'name', name)
+            const localVarPath = `/search/person`;
+            // 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: 'GET', ...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)
+
+            if (name !== undefined) {
+                localVarQueryParameter['name'] = name;
+            }
+
+
+    
             setSearchParams(localVarUrlObj, localVarQueryParameter);
             setSearchParams(localVarUrlObj, localVarQueryParameter);
             let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
             let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
             localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
             localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@@ -12192,6 +12237,16 @@ export const SearchApiFp = function(configuration?: Configuration) {
             const localVarAxiosArgs = await localVarAxiosParamCreator.search(q, query, clip, type, isFavorite, isArchived, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, exifInfoProjectionType, smartInfoObjects, smartInfoTags, recent, motion, options);
             const localVarAxiosArgs = await localVarAxiosParamCreator.search(q, query, clip, type, isFavorite, isArchived, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, exifInfoProjectionType, smartInfoObjects, smartInfoTags, recent, motion, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
         },
+        /**
+         * 
+         * @param {string} name 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async searchPerson(name: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<PersonResponseDto>>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.searchPerson(name, options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
     }
     }
 };
 };
 
 
@@ -12219,6 +12274,15 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat
         search(requestParameters: SearchApiSearchRequest = {}, options?: AxiosRequestConfig): AxiosPromise<SearchResponseDto> {
         search(requestParameters: SearchApiSearchRequest = {}, options?: AxiosRequestConfig): AxiosPromise<SearchResponseDto> {
             return localVarFp.search(requestParameters.q, requestParameters.query, requestParameters.clip, requestParameters.type, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.exifInfoCity, requestParameters.exifInfoState, requestParameters.exifInfoCountry, requestParameters.exifInfoMake, requestParameters.exifInfoModel, requestParameters.exifInfoProjectionType, requestParameters.smartInfoObjects, requestParameters.smartInfoTags, requestParameters.recent, requestParameters.motion, options).then((request) => request(axios, basePath));
             return localVarFp.search(requestParameters.q, requestParameters.query, requestParameters.clip, requestParameters.type, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.exifInfoCity, requestParameters.exifInfoState, requestParameters.exifInfoCountry, requestParameters.exifInfoMake, requestParameters.exifInfoModel, requestParameters.exifInfoProjectionType, requestParameters.smartInfoObjects, requestParameters.smartInfoTags, requestParameters.recent, requestParameters.motion, options).then((request) => request(axios, basePath));
         },
         },
+        /**
+         * 
+         * @param {SearchApiSearchPersonRequest} requestParameters Request parameters.
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: AxiosRequestConfig): AxiosPromise<Array<PersonResponseDto>> {
+            return localVarFp.searchPerson(requestParameters.name, options).then((request) => request(axios, basePath));
+        },
     };
     };
 };
 };
 
 
@@ -12341,6 +12405,20 @@ export interface SearchApiSearchRequest {
     readonly motion?: boolean
     readonly motion?: boolean
 }
 }
 
 
+/**
+ * Request parameters for searchPerson operation in SearchApi.
+ * @export
+ * @interface SearchApiSearchPersonRequest
+ */
+export interface SearchApiSearchPersonRequest {
+    /**
+     * 
+     * @type {string}
+     * @memberof SearchApiSearchPerson
+     */
+    readonly name: string
+}
+
 /**
 /**
  * SearchApi - object-oriented interface
  * SearchApi - object-oriented interface
  * @export
  * @export
@@ -12368,6 +12446,17 @@ export class SearchApi extends BaseAPI {
     public search(requestParameters: SearchApiSearchRequest = {}, options?: AxiosRequestConfig) {
     public search(requestParameters: SearchApiSearchRequest = {}, options?: AxiosRequestConfig) {
         return SearchApiFp(this.configuration).search(requestParameters.q, requestParameters.query, requestParameters.clip, requestParameters.type, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.exifInfoCity, requestParameters.exifInfoState, requestParameters.exifInfoCountry, requestParameters.exifInfoMake, requestParameters.exifInfoModel, requestParameters.exifInfoProjectionType, requestParameters.smartInfoObjects, requestParameters.smartInfoTags, requestParameters.recent, requestParameters.motion, options).then((request) => request(this.axios, this.basePath));
         return SearchApiFp(this.configuration).search(requestParameters.q, requestParameters.query, requestParameters.clip, requestParameters.type, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.exifInfoCity, requestParameters.exifInfoState, requestParameters.exifInfoCountry, requestParameters.exifInfoMake, requestParameters.exifInfoModel, requestParameters.exifInfoProjectionType, requestParameters.smartInfoObjects, requestParameters.smartInfoTags, requestParameters.recent, requestParameters.motion, options).then((request) => request(this.axios, this.basePath));
     }
     }
+
+    /**
+     * 
+     * @param {SearchApiSearchPersonRequest} requestParameters Request parameters.
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof SearchApi
+     */
+    public searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: AxiosRequestConfig) {
+        return SearchApiFp(this.configuration).searchPerson(requestParameters.name, options).then((request) => request(this.axios, this.basePath));
+    }
 }
 }
 
 
 
 

+ 1 - 0
mobile/openapi/README.md

@@ -154,6 +154,7 @@ Class | Method | HTTP request | Description
 *PersonApi* | [**updatePerson**](doc//PersonApi.md#updateperson) | **PUT** /person/{id} | 
 *PersonApi* | [**updatePerson**](doc//PersonApi.md#updateperson) | **PUT** /person/{id} | 
 *SearchApi* | [**getExploreData**](doc//SearchApi.md#getexploredata) | **GET** /search/explore | 
 *SearchApi* | [**getExploreData**](doc//SearchApi.md#getexploredata) | **GET** /search/explore | 
 *SearchApi* | [**search**](doc//SearchApi.md#search) | **GET** /search | 
 *SearchApi* | [**search**](doc//SearchApi.md#search) | **GET** /search | 
+*SearchApi* | [**searchPerson**](doc//SearchApi.md#searchperson) | **GET** /search/person | 
 *ServerInfoApi* | [**getServerConfig**](doc//ServerInfoApi.md#getserverconfig) | **GET** /server-info/config | 
 *ServerInfoApi* | [**getServerConfig**](doc//ServerInfoApi.md#getserverconfig) | **GET** /server-info/config | 
 *ServerInfoApi* | [**getServerFeatures**](doc//ServerInfoApi.md#getserverfeatures) | **GET** /server-info/features | 
 *ServerInfoApi* | [**getServerFeatures**](doc//ServerInfoApi.md#getserverfeatures) | **GET** /server-info/features | 
 *ServerInfoApi* | [**getServerInfo**](doc//ServerInfoApi.md#getserverinfo) | **GET** /server-info | 
 *ServerInfoApi* | [**getServerInfo**](doc//ServerInfoApi.md#getserverinfo) | **GET** /server-info | 

+ 56 - 0
mobile/openapi/doc/SearchApi.md

@@ -11,6 +11,7 @@ Method | HTTP request | Description
 ------------- | ------------- | -------------
 ------------- | ------------- | -------------
 [**getExploreData**](SearchApi.md#getexploredata) | **GET** /search/explore | 
 [**getExploreData**](SearchApi.md#getexploredata) | **GET** /search/explore | 
 [**search**](SearchApi.md#search) | **GET** /search | 
 [**search**](SearchApi.md#search) | **GET** /search | 
+[**searchPerson**](SearchApi.md#searchperson) | **GET** /search/person | 
 
 
 
 
 # **getExploreData**
 # **getExploreData**
@@ -149,3 +150,58 @@ 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)
 [[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)
 
 
+# **searchPerson**
+> List<PersonResponseDto> searchPerson(name)
+
+
+
+### 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 = SearchApi();
+final name = name_example; // String | 
+
+try {
+    final result = api_instance.searchPerson(name);
+    print(result);
+} catch (e) {
+    print('Exception when calling SearchApi->searchPerson: $e\n');
+}
+```
+
+### Parameters
+
+Name | Type | Description  | Notes
+------------- | ------------- | ------------- | -------------
+ **name** | **String**|  | 
+
+### Return type
+
+[**List<PersonResponseDto>**](PersonResponseDto.md)
+
+### Authorization
+
+[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
+
+### HTTP request headers
+
+ - **Content-Type**: Not defined
+ - **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)
+

+ 52 - 0
mobile/openapi/lib/api/search_api.dart

@@ -215,4 +215,56 @@ class SearchApi {
     }
     }
     return null;
     return null;
   }
   }
+
+  /// Performs an HTTP 'GET /search/person' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [String] name (required):
+  Future<Response> searchPersonWithHttpInfo(String name,) async {
+    // ignore: prefer_const_declarations
+    final path = r'/search/person';
+
+    // ignore: prefer_final_locals
+    Object? postBody;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+      queryParams.addAll(_queryParams('', 'name', name));
+
+    const contentTypes = <String>[];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'GET',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [String] name (required):
+  Future<List<PersonResponseDto>?> searchPerson(String name,) async {
+    final response = await searchPersonWithHttpInfo(name,);
+    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<PersonResponseDto>') as List)
+        .cast<PersonResponseDto>()
+        .toList();
+
+    }
+    return null;
+  }
 }
 }

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

@@ -27,5 +27,10 @@ void main() {
       // TODO
       // TODO
     });
     });
 
 
+    //Future<List<PersonResponseDto>> searchPerson(String name) async
+    test('test searchPerson', () async {
+      // TODO
+    });
+
   });
   });
 }
 }

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

@@ -3789,6 +3789,50 @@
         ]
         ]
       }
       }
     },
     },
+    "/search/person": {
+      "get": {
+        "operationId": "searchPerson",
+        "parameters": [
+          {
+            "name": "name",
+            "required": true,
+            "in": "query",
+            "schema": {
+              "type": "string"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "content": {
+              "application/json": {
+                "schema": {
+                  "items": {
+                    "$ref": "#/components/schemas/PersonResponseDto"
+                  },
+                  "type": "array"
+                }
+              }
+            },
+            "description": ""
+          }
+        },
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          },
+          {
+            "api_key": []
+          }
+        ],
+        "tags": [
+          "Search"
+        ]
+      }
+    },
     "/server-info": {
     "/server-info": {
       "get": {
       "get": {
         "operationId": "getServerInfo",
         "operationId": "getServerInfo",

+ 1 - 0
server/src/domain/repositories/person.repository.ts

@@ -22,6 +22,7 @@ export interface IPersonRepository {
   getAllForUser(userId: string, options: PersonSearchOptions): Promise<PersonEntity[]>;
   getAllForUser(userId: string, options: PersonSearchOptions): Promise<PersonEntity[]>;
   getAllWithoutFaces(): Promise<PersonEntity[]>;
   getAllWithoutFaces(): Promise<PersonEntity[]>;
   getById(personId: string): Promise<PersonEntity | null>;
   getById(personId: string): Promise<PersonEntity | null>;
+  getByName(userId: string, personName: string): Promise<PersonEntity[]>;
 
 
   getAssets(personId: string): Promise<AssetEntity[]>;
   getAssets(personId: string): Promise<AssetEntity[]>;
   prepareReassignFaces(data: UpdateFacesData): Promise<string[]>;
   prepareReassignFaces(data: UpdateFacesData): Promise<string[]>;

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

@@ -85,3 +85,9 @@ export class SearchDto {
   @Transform(toBoolean)
   @Transform(toBoolean)
   motion?: boolean;
   motion?: boolean;
 }
 }
+
+export class SearchPeopleDto {
+  @IsString()
+  @IsNotEmpty()
+  name!: string;
+}

+ 6 - 1
server/src/domain/search/search.service.ts

@@ -5,6 +5,7 @@ import { AssetResponseDto, mapAsset } from '../asset';
 import { AuthUserDto } from '../auth';
 import { AuthUserDto } from '../auth';
 import { usePagination } from '../domain.util';
 import { usePagination } from '../domain.util';
 import { IAssetFaceJob, IBulkEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job';
 import { IAssetFaceJob, IBulkEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job';
+import { PersonResponseDto } from '../person/person.dto';
 import {
 import {
   AssetFaceId,
   AssetFaceId,
   IAlbumRepository,
   IAlbumRepository,
@@ -21,7 +22,7 @@ import {
   SearchStrategy,
   SearchStrategy,
 } from '../repositories';
 } from '../repositories';
 import { FeatureFlag, SystemConfigCore } from '../system-config';
 import { FeatureFlag, SystemConfigCore } from '../system-config';
-import { SearchDto } from './dto';
+import { SearchDto, SearchPeopleDto } from './dto';
 import { SearchResponseDto } from './response-dto';
 import { SearchResponseDto } from './response-dto';
 
 
 interface SyncQueue {
 interface SyncQueue {
@@ -158,6 +159,10 @@ export class SearchService {
     };
     };
   }
   }
 
 
+  async searchPerson(authUser: AuthUserDto, dto: SearchPeopleDto): Promise<PersonResponseDto[]> {
+    return await this.personRepository.getByName(authUser.id, dto.name);
+  }
+
   async handleIndexAlbums() {
   async handleIndexAlbums() {
     if (!this.enabled) {
     if (!this.enabled) {
       return false;
       return false;

+ 14 - 1
server/src/immich/controllers/search.controller.ts

@@ -1,4 +1,12 @@
-import { AuthUserDto, SearchDto, SearchExploreResponseDto, SearchResponseDto, SearchService } from '@app/domain';
+import {
+  AuthUserDto,
+  PersonResponseDto,
+  SearchDto,
+  SearchExploreResponseDto,
+  SearchPeopleDto,
+  SearchResponseDto,
+  SearchService,
+} from '@app/domain';
 import { Controller, Get, Query } from '@nestjs/common';
 import { Controller, Get, Query } from '@nestjs/common';
 import { ApiTags } from '@nestjs/swagger';
 import { ApiTags } from '@nestjs/swagger';
 import { AuthUser, Authenticated } from '../app.guard';
 import { AuthUser, Authenticated } from '../app.guard';
@@ -11,6 +19,11 @@ import { UseValidation } from '../app.utils';
 export class SearchController {
 export class SearchController {
   constructor(private service: SearchService) {}
   constructor(private service: SearchService) {}
 
 
+  @Get('person')
+  searchPerson(@AuthUser() authUser: AuthUserDto, @Query() dto: SearchPeopleDto): Promise<PersonResponseDto[]> {
+    return this.service.searchPerson(authUser, dto);
+  }
+
   @Get()
   @Get()
   search(@AuthUser() authUser: AuthUserDto, @Query() dto: SearchDto): Promise<SearchResponseDto> {
   search(@AuthUser() authUser: AuthUserDto, @Query() dto: SearchDto): Promise<SearchResponseDto> {
     return this.service.search(authUser, dto);
     return this.service.search(authUser, dto);

+ 10 - 0
server/src/infra/repositories/person.repository.ts

@@ -95,6 +95,16 @@ export class PersonRepository implements IPersonRepository {
     return this.personRepository.findOne({ where: { id: personId } });
     return this.personRepository.findOne({ where: { id: personId } });
   }
   }
 
 
+  getByName(userId: string, personName: string): Promise<PersonEntity[]> {
+    return this.personRepository
+      .createQueryBuilder('person')
+      .leftJoin('person.faces', 'face')
+      .where('person.ownerId = :userId', { userId })
+      .andWhere('LOWER(person.name) LIKE :name', { name: `${personName.toLowerCase()}%` })
+      .limit(20)
+      .getMany();
+  }
+
   getAssets(personId: string): Promise<AssetEntity[]> {
   getAssets(personId: string): Promise<AssetEntity[]> {
     return this.assetRepository.find({
     return this.assetRepository.find({
       where: {
       where: {

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

@@ -9,6 +9,8 @@ export const newPersonRepositoryMock = (): jest.Mocked<IPersonRepository> => {
     getAssets: jest.fn(),
     getAssets: jest.fn(),
     getAllWithoutFaces: jest.fn(),
     getAllWithoutFaces: jest.fn(),
 
 
+    getByName: jest.fn(),
+
     create: jest.fn(),
     create: jest.fn(),
     update: jest.fn(),
     update: jest.fn(),
     deleteAll: jest.fn(),
     deleteAll: jest.fn(),

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

@@ -12139,6 +12139,51 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio
 
 
 
 
     
     
+            setSearchParams(localVarUrlObj, localVarQueryParameter);
+            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
+        /**
+         * 
+         * @param {string} name 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        searchPerson: async (name: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            // verify required parameter 'name' is not null or undefined
+            assertParamExists('searchPerson', 'name', name)
+            const localVarPath = `/search/person`;
+            // 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: 'GET', ...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)
+
+            if (name !== undefined) {
+                localVarQueryParameter['name'] = name;
+            }
+
+
+    
             setSearchParams(localVarUrlObj, localVarQueryParameter);
             setSearchParams(localVarUrlObj, localVarQueryParameter);
             let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
             let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
             localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
             localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@@ -12192,6 +12237,16 @@ export const SearchApiFp = function(configuration?: Configuration) {
             const localVarAxiosArgs = await localVarAxiosParamCreator.search(q, query, clip, type, isFavorite, isArchived, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, exifInfoProjectionType, smartInfoObjects, smartInfoTags, recent, motion, options);
             const localVarAxiosArgs = await localVarAxiosParamCreator.search(q, query, clip, type, isFavorite, isArchived, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, exifInfoProjectionType, smartInfoObjects, smartInfoTags, recent, motion, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
         },
+        /**
+         * 
+         * @param {string} name 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async searchPerson(name: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<PersonResponseDto>>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.searchPerson(name, options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
     }
     }
 };
 };
 
 
@@ -12219,6 +12274,15 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat
         search(requestParameters: SearchApiSearchRequest = {}, options?: AxiosRequestConfig): AxiosPromise<SearchResponseDto> {
         search(requestParameters: SearchApiSearchRequest = {}, options?: AxiosRequestConfig): AxiosPromise<SearchResponseDto> {
             return localVarFp.search(requestParameters.q, requestParameters.query, requestParameters.clip, requestParameters.type, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.exifInfoCity, requestParameters.exifInfoState, requestParameters.exifInfoCountry, requestParameters.exifInfoMake, requestParameters.exifInfoModel, requestParameters.exifInfoProjectionType, requestParameters.smartInfoObjects, requestParameters.smartInfoTags, requestParameters.recent, requestParameters.motion, options).then((request) => request(axios, basePath));
             return localVarFp.search(requestParameters.q, requestParameters.query, requestParameters.clip, requestParameters.type, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.exifInfoCity, requestParameters.exifInfoState, requestParameters.exifInfoCountry, requestParameters.exifInfoMake, requestParameters.exifInfoModel, requestParameters.exifInfoProjectionType, requestParameters.smartInfoObjects, requestParameters.smartInfoTags, requestParameters.recent, requestParameters.motion, options).then((request) => request(axios, basePath));
         },
         },
+        /**
+         * 
+         * @param {SearchApiSearchPersonRequest} requestParameters Request parameters.
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: AxiosRequestConfig): AxiosPromise<Array<PersonResponseDto>> {
+            return localVarFp.searchPerson(requestParameters.name, options).then((request) => request(axios, basePath));
+        },
     };
     };
 };
 };
 
 
@@ -12341,6 +12405,20 @@ export interface SearchApiSearchRequest {
     readonly motion?: boolean
     readonly motion?: boolean
 }
 }
 
 
+/**
+ * Request parameters for searchPerson operation in SearchApi.
+ * @export
+ * @interface SearchApiSearchPersonRequest
+ */
+export interface SearchApiSearchPersonRequest {
+    /**
+     * 
+     * @type {string}
+     * @memberof SearchApiSearchPerson
+     */
+    readonly name: string
+}
+
 /**
 /**
  * SearchApi - object-oriented interface
  * SearchApi - object-oriented interface
  * @export
  * @export
@@ -12368,6 +12446,17 @@ export class SearchApi extends BaseAPI {
     public search(requestParameters: SearchApiSearchRequest = {}, options?: AxiosRequestConfig) {
     public search(requestParameters: SearchApiSearchRequest = {}, options?: AxiosRequestConfig) {
         return SearchApiFp(this.configuration).search(requestParameters.q, requestParameters.query, requestParameters.clip, requestParameters.type, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.exifInfoCity, requestParameters.exifInfoState, requestParameters.exifInfoCountry, requestParameters.exifInfoMake, requestParameters.exifInfoModel, requestParameters.exifInfoProjectionType, requestParameters.smartInfoObjects, requestParameters.smartInfoTags, requestParameters.recent, requestParameters.motion, options).then((request) => request(this.axios, this.basePath));
         return SearchApiFp(this.configuration).search(requestParameters.q, requestParameters.query, requestParameters.clip, requestParameters.type, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.exifInfoCity, requestParameters.exifInfoState, requestParameters.exifInfoCountry, requestParameters.exifInfoMake, requestParameters.exifInfoModel, requestParameters.exifInfoProjectionType, requestParameters.smartInfoObjects, requestParameters.smartInfoTags, requestParameters.recent, requestParameters.motion, options).then((request) => request(this.axios, this.basePath));
     }
     }
+
+    /**
+     * 
+     * @param {SearchApiSearchPersonRequest} requestParameters Request parameters.
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof SearchApi
+     */
+    public searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: AxiosRequestConfig) {
+        return SearchApiFp(this.configuration).searchPerson(requestParameters.name, options).then((request) => request(this.axios, this.basePath));
+    }
 }
 }
 
 
 
 

+ 7 - 2
web/src/lib/components/faces-page/merge-face-selector.svelte

@@ -1,5 +1,5 @@
 <script lang="ts">
 <script lang="ts">
-  import { createEventDispatcher } from 'svelte';
+  import { createEventDispatcher, onMount } from 'svelte';
   import { api, type PersonResponseDto } from '@api';
   import { api, type PersonResponseDto } from '@api';
   import FaceThumbnail from './face-thumbnail.svelte';
   import FaceThumbnail from './face-thumbnail.svelte';
   import { quintOut } from 'svelte/easing';
   import { quintOut } from 'svelte/easing';
@@ -17,7 +17,7 @@
   import SwapHorizontal from 'svelte-material-icons/SwapHorizontal.svelte';
   import SwapHorizontal from 'svelte-material-icons/SwapHorizontal.svelte';
 
 
   export let person: PersonResponseDto;
   export let person: PersonResponseDto;
-  export let people: PersonResponseDto[];
+  let people: PersonResponseDto[];
   let selectedPeople: PersonResponseDto[] = [];
   let selectedPeople: PersonResponseDto[] = [];
   let screenHeight: number;
   let screenHeight: number;
   let isShowConfirmation = false;
   let isShowConfirmation = false;
@@ -28,6 +28,11 @@
     (source) => !selectedPeople.some((selected) => selected.id === source.id) && source.id !== person.id,
     (source) => !selectedPeople.some((selected) => selected.id === source.id) && source.id !== person.id,
   );
   );
 
 
+  onMount(async () => {
+    const { data } = await api.personApi.getAllPeople({ withHidden: false });
+    people = data.people;
+  });
+
   const onClose = () => {
   const onClose = () => {
     dispatch('go-back');
     dispatch('go-back');
   };
   };

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

@@ -254,9 +254,15 @@
     if (!edittingPerson || personName === edittingPerson.name) {
     if (!edittingPerson || personName === edittingPerson.name) {
       return;
       return;
     }
     }
+    if (personName === '') {
+      changeName();
+      return;
+    }
+    const { data } = await api.searchApi.searchPerson({ name: personName });
+
     // We check if another person has the same name as the name entered by the user
     // We check if another person has the same name as the name entered by the user
 
 
-    const existingPerson = people.find(
+    const existingPerson = data.find(
       (person: PersonResponseDto) =>
       (person: PersonResponseDto) =>
         person.name.toLowerCase() === personName.toLowerCase() &&
         person.name.toLowerCase() === personName.toLowerCase() &&
         edittingPerson &&
         edittingPerson &&

+ 0 - 2
web/src/routes/(user)/people/[personId]/+page.server.ts

@@ -9,12 +9,10 @@ export const load = (async ({ locals, parent, params }) => {
   }
   }
 
 
   const { data: person } = await locals.api.personApi.getPerson({ id: params.personId });
   const { data: person } = await locals.api.personApi.getPerson({ id: params.personId });
-  const { data: people } = await locals.api.personApi.getAllPeople({ withHidden: false });
 
 
   return {
   return {
     user,
     user,
     person,
     person,
-    people,
     meta: {
     meta: {
       title: person.name || 'Person',
       title: person.name || 'Person',
     },
     },

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

@@ -35,6 +35,8 @@
   import type { PageData } from './$types';
   import type { PageData } from './$types';
   import { clickOutside } from '$lib/utils/click-outside';
   import { clickOutside } from '$lib/utils/click-outside';
   import { assetViewingStore } from '$lib/stores/asset-viewing.store';
   import { assetViewingStore } from '$lib/stores/asset-viewing.store';
+  import { browser } from '$app/environment';
+  import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
 
 
   export let data: PageData;
   export let data: PageData;
 
 
@@ -61,7 +63,7 @@
   let isEditingName = false;
   let isEditingName = false;
   let previousRoute: string = AppRoute.EXPLORE;
   let previousRoute: string = AppRoute.EXPLORE;
   let previousPersonId: string = data.person.id;
   let previousPersonId: string = data.person.id;
-  let people = data.people.people;
+  let people: PersonResponseDto[];
   let personMerge1: PersonResponseDto;
   let personMerge1: PersonResponseDto;
   let personMerge2: PersonResponseDto;
   let personMerge2: PersonResponseDto;
   let potentialMergePeople: PersonResponseDto[] = [];
   let potentialMergePeople: PersonResponseDto[] = [];
@@ -74,20 +76,58 @@
   let name: string = data.person.name;
   let name: string = data.person.name;
   let suggestedPeople: PersonResponseDto[] = [];
   let suggestedPeople: PersonResponseDto[] = [];
 
 
+  /**
+   * Save the word used to search people name: for example,
+   * if searching 'r' and the server returns 15 people with names starting with 'r',
+   * there's no need to search again people with name starting with 'ri'.
+   * However, it needs to make a new api request if searching 'r' returns 20 names (arbitrary value, the limit sent back by the server).
+   * or if the new search word starts with another word / letter
+   **/
+  let searchWord: string;
+  let maxPeople = false;
+  let isSearchingPeople = false;
+
+  const searchPeople = async () => {
+    isSearchingPeople = true;
+    people = [];
+    try {
+      const { data } = await api.searchApi.searchPerson({ name });
+      people = data;
+      searchWord = name;
+      if (data.length < 20) {
+        maxPeople = false;
+      } else {
+        maxPeople = true;
+      }
+    } catch (error) {
+      handleError(error, "Can't search people");
+    }
+
+    isSearchingPeople = false;
+  };
+
+  $: {
+    if (name !== '' && browser) {
+      if (maxPeople === true || (!name.startsWith(searchWord) && maxPeople === false)) searchPeople();
+    }
+  }
+
   $: isAllArchive = Array.from($selectedAssets).every((asset) => asset.isArchived);
   $: isAllArchive = Array.from($selectedAssets).every((asset) => asset.isArchived);
   $: isAllFavorite = Array.from($selectedAssets).every((asset) => asset.isFavorite);
   $: isAllFavorite = Array.from($selectedAssets).every((asset) => asset.isFavorite);
   $: $onPersonThumbnail === data.person.id &&
   $: $onPersonThumbnail === data.person.id &&
     (thumbnailData = api.getPeopleThumbnailUrl(data.person.id) + `?now=${Date.now()}`);
     (thumbnailData = api.getPeopleThumbnailUrl(data.person.id) + `?now=${Date.now()}`);
 
 
   $: {
   $: {
-    suggestedPeople = !name
-      ? []
-      : people
-          .filter(
-            (person: PersonResponseDto) =>
-              person.name.toLowerCase().startsWith(name.toLowerCase()) && person.id !== data.person.id,
-          )
-          .slice(0, 5);
+    if (people) {
+      suggestedPeople = !name
+        ? []
+        : people
+            .filter(
+              (person: PersonResponseDto) =>
+                person.name.toLowerCase().startsWith(name.toLowerCase()) && person.id !== data.person.id,
+            )
+            .slice(0, 5);
+    }
   }
   }
 
 
   onMount(() => {
   onMount(() => {
@@ -199,18 +239,11 @@
     try {
     try {
       isEditingName = false;
       isEditingName = false;
 
 
-      const { data: updatedPerson } = await api.personApi.updatePerson({
+      await api.personApi.updatePerson({
         id: data.person.id,
         id: data.person.id,
         personUpdateDto: { name: personName },
         personUpdateDto: { name: personName },
       });
       });
 
 
-      people = people.map((person: PersonResponseDto) => {
-        if (person.id === updatedPerson.id) {
-          return updatedPerson;
-        }
-        return person;
-      });
-
       notificationController.show({
       notificationController.show({
         message: 'Change name succesfully',
         message: 'Change name succesfully',
         type: NotificationType.Info,
         type: NotificationType.Info,
@@ -235,15 +268,21 @@
     if (data.person.name === personName) {
     if (data.person.name === personName) {
       return;
       return;
     }
     }
+    if (name === '') {
+      changeName();
+      return;
+    }
+
+    const result = await api.searchApi.searchPerson({ name: personName });
 
 
-    const existingPerson = people.find(
+    const existingPerson = result.data.find(
       (person: PersonResponseDto) =>
       (person: PersonResponseDto) =>
         person.name.toLowerCase() === personName.toLowerCase() && person.id !== data.person.id && person.name,
         person.name.toLowerCase() === personName.toLowerCase() && person.id !== data.person.id && person.name,
     );
     );
     if (existingPerson) {
     if (existingPerson) {
       personMerge2 = existingPerson;
       personMerge2 = existingPerson;
       personMerge1 = data.person;
       personMerge1 = data.person;
-      potentialMergePeople = people
+      potentialMergePeople = result.data
         .filter(
         .filter(
           (person: PersonResponseDto) =>
           (person: PersonResponseDto) =>
             personMerge2.name.toLowerCase() === person.name.toLowerCase() &&
             personMerge2.name.toLowerCase() === person.name.toLowerCase() &&
@@ -310,7 +349,7 @@
 {/if}
 {/if}
 
 
 {#if viewMode === ViewMode.MERGE_FACES}
 {#if viewMode === ViewMode.MERGE_FACES}
-  <MergeFaceSelector person={data.person} bind:people on:go-back={handleGoBack} on:merge={handleMerge} />
+  <MergeFaceSelector person={data.person} on:go-back={handleGoBack} on:merge={handleMerge} />
 {/if}
 {/if}
 
 
 <header>
 <header>
@@ -374,7 +413,7 @@
             {#if isEditingName}
             {#if isEditingName}
               <EditNameInput
               <EditNameInput
                 person={data.person}
                 person={data.person}
-                suggestedPeople={suggestedPeople.length > 0}
+                suggestedPeople={suggestedPeople.length > 0 || isSearchingPeople}
                 bind:name
                 bind:name
                 on:change={(event) => handleNameChange(event.detail)}
                 on:change={(event) => handleNameChange(event.detail)}
               />
               />
@@ -406,25 +445,35 @@
           </section>
           </section>
           {#if isEditingName}
           {#if isEditingName}
             <div class="absolute z-[999] w-96">
             <div class="absolute z-[999] w-96">
-              {#each suggestedPeople as person, index (person.id)}
+              {#if isSearchingPeople}
                 <div
                 <div
-                  class="flex {index === suggestedPeople.length - 1
-                    ? 'rounded-b-lg'
-                    : 'border-b dark:border-immich-dark-gray'} place-items-center bg-gray-100 p-2 dark:bg-gray-700"
+                  class="flex rounded-b-lg dark:border-immich-dark-gray place-items-center bg-gray-100 p-2 dark:bg-gray-700"
                 >
                 >
-                  <button class="flex w-full place-items-center" on:click={() => handleSuggestPeople(person)}>
-                    <ImageThumbnail
-                      circle
-                      shadow
-                      url={api.getPeopleThumbnailUrl(person.id)}
-                      altText={person.name}
-                      widthStyle="2rem"
-                      heightStyle="2rem"
-                    />
-                    <p class="ml-4 text-gray-700 dark:text-gray-100">{person.name}</p>
-                  </button>
+                  <div class="flex w-full place-items-center">
+                    <LoadingSpinner />
+                  </div>
                 </div>
                 </div>
-              {/each}
+              {:else}
+                {#each suggestedPeople as person, index (person.id)}
+                  <div
+                    class="flex {index === suggestedPeople.length - 1
+                      ? 'rounded-b-lg'
+                      : 'border-b dark:border-immich-dark-gray'} place-items-center bg-gray-100 p-2 dark:bg-gray-700"
+                  >
+                    <button class="flex w-full place-items-center" on:click={() => handleSuggestPeople(person)}>
+                      <ImageThumbnail
+                        circle
+                        shadow
+                        url={api.getPeopleThumbnailUrl(person.id)}
+                        altText={person.name}
+                        widthStyle="2rem"
+                        heightStyle="2rem"
+                      />
+                      <p class="ml-4 text-gray-700 dark:text-gray-100">{person.name}</p>
+                    </button>
+                  </div>
+                {/each}
+              {/if}
             </div>
             </div>
           {/if}
           {/if}
         </div>
         </div>