Ver Fonte

feat(server,web): hide faces (#3262)

* feat: hide faces

* fix: types

* pr feedback

* fix: svelte checks

* feat: new server endpoint

* refactor: rename person count dto

* fix(server): linter

* fix: remove duplicate button

* docs: add comments

* pr feedback

* fix: get unhidden faces

* fix: do not use PersonCountResponseDto

* fix: transition

* pr feedback

* pr feedback

* fix: remove unused check

* add server tests

* rename persons to people

* feat: add exit button

* pr feedback

* add server tests

* pr feedback

* pr feedback

* fix: show & hide faces

* simplify

* fix: close button

* pr feeback

* pr feeback

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
martin há 1 ano atrás
pai
commit
f28fc8fa5c
38 ficheiros alterados com 739 adições e 105 exclusões
  1. 66 7
      cli/src/api/open-api/api.ts
  2. 2 1
      mobile/lib/modules/search/services/person.service.dart
  3. 3 0
      mobile/openapi/.openapi-generator/FILES
  4. 1 0
      mobile/openapi/README.md
  5. 17 0
      mobile/openapi/doc/PeopleResponseDto.md
  6. 8 4
      mobile/openapi/doc/PersonApi.md
  7. 1 0
      mobile/openapi/doc/PersonResponseDto.md
  8. 1 0
      mobile/openapi/doc/PersonUpdateDto.md
  9. 1 0
      mobile/openapi/lib/api.dart
  10. 15 8
      mobile/openapi/lib/api/person_api.dart
  11. 2 0
      mobile/openapi/lib/api_client.dart
  12. 114 0
      mobile/openapi/lib/model/people_response_dto.dart
  13. 11 3
      mobile/openapi/lib/model/person_response_dto.dart
  14. 21 3
      mobile/openapi/lib/model/person_update_dto.dart
  15. 37 0
      mobile/openapi/test/people_response_dto_test.dart
  16. 1 1
      mobile/openapi/test/person_api_test.dart
  17. 5 0
      mobile/openapi/test/person_response_dto_test.dart
  18. 6 0
      mobile/openapi/test/person_update_dto_test.dart
  19. 43 6
      server/immich-openapi-specs.json
  20. 1 1
      server/src/domain/asset/response-dto/asset-response.dto.ts
  21. 24 2
      server/src/domain/person/person.dto.ts
  22. 47 1
      server/src/domain/person/person.service.spec.ts
  23. 22 10
      server/src/domain/person/person.service.ts
  24. 5 3
      server/src/immich/controllers/person.controller.ts
  25. 3 0
      server/src/infra/entities/person.entity.ts
  26. 14 0
      server/src/infra/migrations/1689281196844-AddHiddenFaces.ts
  27. 2 1
      server/src/infra/repositories/typesense.repository.ts
  28. 17 0
      server/test/fixtures.ts
  29. 66 7
      web/src/api/open-api/api.ts
  30. 9 2
      web/src/lib/components/assets/thumbnail/image-thumbnail.svelte
  31. 2 2
      web/src/lib/components/faces-page/merge-face-selector.svelte
  32. 4 4
      web/src/lib/components/faces-page/people-card.svelte
  33. 30 0
      web/src/lib/components/faces-page/show-hide.svelte
  34. 0 1
      web/src/lib/components/layouts/user-page-layout.svelte
  35. 2 2
      web/src/routes/(user)/explore/+page.server.ts
  36. 8 10
      web/src/routes/(user)/explore/+page.svelte
  37. 1 2
      web/src/routes/(user)/people/+page.server.ts
  38. 127 24
      web/src/routes/(user)/people/+page.svelte

+ 66 - 7
cli/src/api/open-api/api.ts

@@ -1777,6 +1777,31 @@ export interface OAuthConfigResponseDto {
      */
     'autoLaunch'?: boolean;
 }
+/**
+ * 
+ * @export
+ * @interface PeopleResponseDto
+ */
+export interface PeopleResponseDto {
+    /**
+     * 
+     * @type {number}
+     * @memberof PeopleResponseDto
+     */
+    'total': number;
+    /**
+     * 
+     * @type {number}
+     * @memberof PeopleResponseDto
+     */
+    'visible': number;
+    /**
+     * 
+     * @type {Array<PersonResponseDto>}
+     * @memberof PeopleResponseDto
+     */
+    'people': Array<PersonResponseDto>;
+}
 /**
  * 
  * @export
@@ -1801,6 +1826,12 @@ export interface PersonResponseDto {
      * @memberof PersonResponseDto
      */
     'thumbnailPath': string;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof PersonResponseDto
+     */
+    'isHidden': boolean;
 }
 /**
  * 
@@ -1820,6 +1851,12 @@ export interface PersonUpdateDto {
      * @memberof PersonUpdateDto
      */
     'featureFaceAssetId'?: string;
+    /**
+     * Person visibility
+     * @type {boolean}
+     * @memberof PersonUpdateDto
+     */
+    'isHidden'?: boolean;
 }
 /**
  * 
@@ -8644,10 +8681,11 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio
     return {
         /**
          * 
+         * @param {boolean} [withHidden] 
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          */
-        getAllPeople: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+        getAllPeople: async (withHidden?: boolean, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
             const localVarPath = `/person`;
             // use dummy base URL string because the URL constructor only accepts absolute URLs.
             const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@@ -8669,6 +8707,10 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio
             // http bearer authentication required
             await setBearerAuthToObject(localVarHeaderParameter, configuration)
 
+            if (withHidden !== undefined) {
+                localVarQueryParameter['withHidden'] = withHidden;
+            }
+
 
     
             setSearchParams(localVarUrlObj, localVarQueryParameter);
@@ -8914,11 +8956,12 @@ export const PersonApiFp = function(configuration?: Configuration) {
     return {
         /**
          * 
+         * @param {boolean} [withHidden] 
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          */
-        async getAllPeople(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<PersonResponseDto>>> {
-            const localVarAxiosArgs = await localVarAxiosParamCreator.getAllPeople(options);
+        async getAllPeople(withHidden?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PeopleResponseDto>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.getAllPeople(withHidden, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
         /**
@@ -8985,11 +9028,12 @@ export const PersonApiFactory = function (configuration?: Configuration, basePat
     return {
         /**
          * 
+         * @param {PersonApiGetAllPeopleRequest} requestParameters Request parameters.
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          */
-        getAllPeople(options?: AxiosRequestConfig): AxiosPromise<Array<PersonResponseDto>> {
-            return localVarFp.getAllPeople(options).then((request) => request(axios, basePath));
+        getAllPeople(requestParameters: PersonApiGetAllPeopleRequest = {}, options?: AxiosRequestConfig): AxiosPromise<PeopleResponseDto> {
+            return localVarFp.getAllPeople(requestParameters.withHidden, options).then((request) => request(axios, basePath));
         },
         /**
          * 
@@ -9039,6 +9083,20 @@ export const PersonApiFactory = function (configuration?: Configuration, basePat
     };
 };
 
+/**
+ * Request parameters for getAllPeople operation in PersonApi.
+ * @export
+ * @interface PersonApiGetAllPeopleRequest
+ */
+export interface PersonApiGetAllPeopleRequest {
+    /**
+     * 
+     * @type {boolean}
+     * @memberof PersonApiGetAllPeople
+     */
+    readonly withHidden?: boolean
+}
+
 /**
  * Request parameters for getPerson operation in PersonApi.
  * @export
@@ -9132,12 +9190,13 @@ export interface PersonApiUpdatePersonRequest {
 export class PersonApi extends BaseAPI {
     /**
      * 
+     * @param {PersonApiGetAllPeopleRequest} requestParameters Request parameters.
      * @param {*} [options] Override http request option.
      * @throws {RequiredError}
      * @memberof PersonApi
      */
-    public getAllPeople(options?: AxiosRequestConfig) {
-        return PersonApiFp(this.configuration).getAllPeople(options).then((request) => request(this.axios, this.basePath));
+    public getAllPeople(requestParameters: PersonApiGetAllPeopleRequest = {}, options?: AxiosRequestConfig) {
+        return PersonApiFp(this.configuration).getAllPeople(requestParameters.withHidden, options).then((request) => request(this.axios, this.basePath));
     }
 
     /**

+ 2 - 1
mobile/lib/modules/search/services/person.service.dart

@@ -18,7 +18,8 @@ class PersonService {
 
   Future<List<PersonResponseDto>?> getCuratedPeople() async {
     try {
-      return await _apiService.personApi.getAllPeople();
+      final peopleResponseDto = await _apiService.personApi.getAllPeople();
+      return peopleResponseDto?.people;
     } catch (e) {
       debugPrint("Error [getCuratedPeople] ${e.toString()}");
       return null;

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

@@ -71,6 +71,7 @@ doc/OAuthCallbackDto.md
 doc/OAuthConfigDto.md
 doc/OAuthConfigResponseDto.md
 doc/PartnerApi.md
+doc/PeopleResponseDto.md
 doc/PersonApi.md
 doc/PersonResponseDto.md
 doc/PersonUpdateDto.md
@@ -208,6 +209,7 @@ 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
+lib/model/people_response_dto.dart
 lib/model/person_response_dto.dart
 lib/model/person_update_dto.dart
 lib/model/queue_status_dto.dart
@@ -322,6 +324,7 @@ test/o_auth_callback_dto_test.dart
 test/o_auth_config_dto_test.dart
 test/o_auth_config_response_dto_test.dart
 test/partner_api_test.dart
+test/people_response_dto_test.dart
 test/person_api_test.dart
 test/person_response_dto_test.dart
 test/person_update_dto_test.dart

+ 1 - 0
mobile/openapi/README.md

@@ -238,6 +238,7 @@ Class | Method | HTTP request | Description
  - [OAuthCallbackDto](doc//OAuthCallbackDto.md)
  - [OAuthConfigDto](doc//OAuthConfigDto.md)
  - [OAuthConfigResponseDto](doc//OAuthConfigResponseDto.md)
+ - [PeopleResponseDto](doc//PeopleResponseDto.md)
  - [PersonResponseDto](doc//PersonResponseDto.md)
  - [PersonUpdateDto](doc//PersonUpdateDto.md)
  - [QueueStatusDto](doc//QueueStatusDto.md)

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

@@ -0,0 +1,17 @@
+# openapi.model.PeopleResponseDto
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**total** | **num** |  | 
+**visible** | **num** |  | 
+**people** | [**List<PersonResponseDto>**](PersonResponseDto.md) |  | [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)
+
+

+ 8 - 4
mobile/openapi/doc/PersonApi.md

@@ -18,7 +18,7 @@ Method | HTTP request | Description
 
 
 # **getAllPeople**
-> List<PersonResponseDto> getAllPeople()
+> PeopleResponseDto getAllPeople(withHidden)
 
 
 
@@ -41,9 +41,10 @@ import 'package:openapi/api.dart';
 //defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
 
 final api_instance = PersonApi();
+final withHidden = true; // bool | 
 
 try {
-    final result = api_instance.getAllPeople();
+    final result = api_instance.getAllPeople(withHidden);
     print(result);
 } catch (e) {
     print('Exception when calling PersonApi->getAllPeople: $e\n');
@@ -51,11 +52,14 @@ try {
 ```
 
 ### Parameters
-This endpoint does not need any parameter.
+
+Name | Type | Description  | Notes
+------------- | ------------- | ------------- | -------------
+ **withHidden** | **bool**|  | [optional] [default to false]
 
 ### Return type
 
-[**List<PersonResponseDto>**](PersonResponseDto.md)
+[**PeopleResponseDto**](PeopleResponseDto.md)
 
 ### Authorization
 

+ 1 - 0
mobile/openapi/doc/PersonResponseDto.md

@@ -11,6 +11,7 @@ Name | Type | Description | Notes
 **id** | **String** |  | 
 **name** | **String** |  | 
 **thumbnailPath** | **String** |  | 
+**isHidden** | **bool** |  | 
 
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 

+ 1 - 0
mobile/openapi/doc/PersonUpdateDto.md

@@ -10,6 +10,7 @@ Name | Type | Description | Notes
 ------------ | ------------- | ------------- | -------------
 **name** | **String** | Person name. | [optional] 
 **featureFaceAssetId** | **String** | Asset is used to get the feature face thumbnail. | [optional] 
+**isHidden** | **bool** | Person visibility | [optional] 
 
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 

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

@@ -104,6 +104,7 @@ 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';
+part 'model/people_response_dto.dart';
 part 'model/person_response_dto.dart';
 part 'model/person_update_dto.dart';
 part 'model/queue_status_dto.dart';

+ 15 - 8
mobile/openapi/lib/api/person_api.dart

@@ -17,7 +17,10 @@ class PersonApi {
   final ApiClient apiClient;
 
   /// Performs an HTTP 'GET /person' operation and returns the [Response].
-  Future<Response> getAllPeopleWithHttpInfo() async {
+  /// Parameters:
+  ///
+  /// * [bool] withHidden:
+  Future<Response> getAllPeopleWithHttpInfo({ bool? withHidden, }) async {
     // ignore: prefer_const_declarations
     final path = r'/person';
 
@@ -28,6 +31,10 @@ class PersonApi {
     final headerParams = <String, String>{};
     final formParams = <String, String>{};
 
+    if (withHidden != null) {
+      queryParams.addAll(_queryParams('', 'withHidden', withHidden));
+    }
+
     const contentTypes = <String>[];
 
 
@@ -42,8 +49,11 @@ class PersonApi {
     );
   }
 
-  Future<List<PersonResponseDto>?> getAllPeople() async {
-    final response = await getAllPeopleWithHttpInfo();
+  /// Parameters:
+  ///
+  /// * [bool] withHidden:
+  Future<PeopleResponseDto?> getAllPeople({ bool? withHidden, }) async {
+    final response = await getAllPeopleWithHttpInfo( withHidden: withHidden, );
     if (response.statusCode >= HttpStatus.badRequest) {
       throw ApiException(response.statusCode, await _decodeBodyBytes(response));
     }
@@ -51,11 +61,8 @@ class PersonApi {
     // 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 await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'PeopleResponseDto',) as PeopleResponseDto;
+    
     }
     return null;
   }

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

@@ -303,6 +303,8 @@ class ApiClient {
           return OAuthConfigDto.fromJson(value);
         case 'OAuthConfigResponseDto':
           return OAuthConfigResponseDto.fromJson(value);
+        case 'PeopleResponseDto':
+          return PeopleResponseDto.fromJson(value);
         case 'PersonResponseDto':
           return PersonResponseDto.fromJson(value);
         case 'PersonUpdateDto':

+ 114 - 0
mobile/openapi/lib/model/people_response_dto.dart

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

+ 11 - 3
mobile/openapi/lib/model/person_response_dto.dart

@@ -16,6 +16,7 @@ class PersonResponseDto {
     required this.id,
     required this.name,
     required this.thumbnailPath,
+    required this.isHidden,
   });
 
   String id;
@@ -24,27 +25,32 @@ class PersonResponseDto {
 
   String thumbnailPath;
 
+  bool isHidden;
+
   @override
   bool operator ==(Object other) => identical(this, other) || other is PersonResponseDto &&
      other.id == id &&
      other.name == name &&
-     other.thumbnailPath == thumbnailPath;
+     other.thumbnailPath == thumbnailPath &&
+     other.isHidden == isHidden;
 
   @override
   int get hashCode =>
     // ignore: unnecessary_parenthesis
     (id.hashCode) +
     (name.hashCode) +
-    (thumbnailPath.hashCode);
+    (thumbnailPath.hashCode) +
+    (isHidden.hashCode);
 
   @override
-  String toString() => 'PersonResponseDto[id=$id, name=$name, thumbnailPath=$thumbnailPath]';
+  String toString() => 'PersonResponseDto[id=$id, name=$name, thumbnailPath=$thumbnailPath, isHidden=$isHidden]';
 
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
       json[r'id'] = this.id;
       json[r'name'] = this.name;
       json[r'thumbnailPath'] = this.thumbnailPath;
+      json[r'isHidden'] = this.isHidden;
     return json;
   }
 
@@ -59,6 +65,7 @@ class PersonResponseDto {
         id: mapValueOfType<String>(json, r'id')!,
         name: mapValueOfType<String>(json, r'name')!,
         thumbnailPath: mapValueOfType<String>(json, r'thumbnailPath')!,
+        isHidden: mapValueOfType<bool>(json, r'isHidden')!,
       );
     }
     return null;
@@ -109,6 +116,7 @@ class PersonResponseDto {
     'id',
     'name',
     'thumbnailPath',
+    'isHidden',
   };
 }
 

+ 21 - 3
mobile/openapi/lib/model/person_update_dto.dart

@@ -15,6 +15,7 @@ class PersonUpdateDto {
   PersonUpdateDto({
     this.name,
     this.featureFaceAssetId,
+    this.isHidden,
   });
 
   /// Person name.
@@ -35,19 +36,30 @@ class PersonUpdateDto {
   ///
   String? featureFaceAssetId;
 
+  /// Person visibility
+  ///
+  /// Please note: This property should have been non-nullable! Since the specification file
+  /// does not include a default value (using the "default:" property), however, the generated
+  /// source code must fall back to having a nullable type.
+  /// Consider adding a "default:" property in the specification file to hide this note.
+  ///
+  bool? isHidden;
+
   @override
   bool operator ==(Object other) => identical(this, other) || other is PersonUpdateDto &&
      other.name == name &&
-     other.featureFaceAssetId == featureFaceAssetId;
+     other.featureFaceAssetId == featureFaceAssetId &&
+     other.isHidden == isHidden;
 
   @override
   int get hashCode =>
     // ignore: unnecessary_parenthesis
     (name == null ? 0 : name!.hashCode) +
-    (featureFaceAssetId == null ? 0 : featureFaceAssetId!.hashCode);
+    (featureFaceAssetId == null ? 0 : featureFaceAssetId!.hashCode) +
+    (isHidden == null ? 0 : isHidden!.hashCode);
 
   @override
-  String toString() => 'PersonUpdateDto[name=$name, featureFaceAssetId=$featureFaceAssetId]';
+  String toString() => 'PersonUpdateDto[name=$name, featureFaceAssetId=$featureFaceAssetId, isHidden=$isHidden]';
 
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
@@ -61,6 +73,11 @@ class PersonUpdateDto {
     } else {
     //  json[r'featureFaceAssetId'] = null;
     }
+    if (this.isHidden != null) {
+      json[r'isHidden'] = this.isHidden;
+    } else {
+    //  json[r'isHidden'] = null;
+    }
     return json;
   }
 
@@ -74,6 +91,7 @@ class PersonUpdateDto {
       return PersonUpdateDto(
         name: mapValueOfType<String>(json, r'name'),
         featureFaceAssetId: mapValueOfType<String>(json, r'featureFaceAssetId'),
+        isHidden: mapValueOfType<bool>(json, r'isHidden'),
       );
     }
     return null;

+ 37 - 0
mobile/openapi/test/people_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 PeopleResponseDto
+void main() {
+  // final instance = PeopleResponseDto();
+
+  group('test PeopleResponseDto', () {
+    // num total
+    test('to test the property `total`', () async {
+      // TODO
+    });
+
+    // num visible
+    test('to test the property `visible`', () async {
+      // TODO
+    });
+
+    // List<PersonResponseDto> people (default value: const [])
+    test('to test the property `people`', () async {
+      // TODO
+    });
+
+
+  });
+
+}

+ 1 - 1
mobile/openapi/test/person_api_test.dart

@@ -17,7 +17,7 @@ void main() {
   // final instance = PersonApi();
 
   group('tests for PersonApi', () {
-    //Future<List<PersonResponseDto>> getAllPeople() async
+    //Future<PeopleResponseDto> getAllPeople({ bool withHidden }) async
     test('test getAllPeople', () async {
       // TODO
     });

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

@@ -31,6 +31,11 @@ void main() {
       // TODO
     });
 
+    // bool isHidden
+    test('to test the property `isHidden`', () async {
+      // TODO
+    });
+
 
   });
 

+ 6 - 0
mobile/openapi/test/person_update_dto_test.dart

@@ -28,6 +28,12 @@ void main() {
       // TODO
     });
 
+    // Person visibility
+    // bool isHidden
+    test('to test the property `isHidden`', () async {
+      // TODO
+    });
+
 
   });
 

+ 43 - 6
server/immich-openapi-specs.json

@@ -2509,17 +2509,24 @@
     "/person": {
       "get": {
         "operationId": "getAllPeople",
-        "parameters": [],
+        "parameters": [
+          {
+            "name": "withHidden",
+            "required": false,
+            "in": "query",
+            "schema": {
+              "default": false,
+              "type": "boolean"
+            }
+          }
+        ],
         "responses": {
           "200": {
             "description": "",
             "content": {
               "application/json": {
                 "schema": {
-                  "type": "array",
-                  "items": {
-                    "$ref": "#/components/schemas/PersonResponseDto"
-                  }
+                  "$ref": "#/components/schemas/PeopleResponseDto"
                 }
               }
             }
@@ -5877,6 +5884,28 @@
           "passwordLoginEnabled"
         ]
       },
+      "PeopleResponseDto": {
+        "type": "object",
+        "properties": {
+          "total": {
+            "type": "number"
+          },
+          "visible": {
+            "type": "number"
+          },
+          "people": {
+            "type": "array",
+            "items": {
+              "$ref": "#/components/schemas/PersonResponseDto"
+            }
+          }
+        },
+        "required": [
+          "total",
+          "visible",
+          "people"
+        ]
+      },
       "PersonResponseDto": {
         "type": "object",
         "properties": {
@@ -5888,12 +5917,16 @@
           },
           "thumbnailPath": {
             "type": "string"
+          },
+          "isHidden": {
+            "type": "boolean"
           }
         },
         "required": [
           "id",
           "name",
-          "thumbnailPath"
+          "thumbnailPath",
+          "isHidden"
         ]
       },
       "PersonUpdateDto": {
@@ -5906,6 +5939,10 @@
           "featureFaceAssetId": {
             "type": "string",
             "description": "Asset is used to get the feature face thumbnail."
+          },
+          "isHidden": {
+            "type": "boolean",
+            "description": "Person visibility"
           }
         }
       },

+ 1 - 1
server/src/domain/asset/response-dto/asset-response.dto.ts

@@ -54,7 +54,7 @@ export function mapAsset(entity: AssetEntity): AssetResponseDto {
     smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
     livePhotoVideoId: entity.livePhotoVideoId,
     tags: entity.tags?.map(mapTag),
-    people: entity.faces?.map(mapFace),
+    people: entity.faces?.map(mapFace).filter((person) => !person.isHidden),
     checksum: entity.checksum.toString('base64'),
   };
 }

+ 24 - 2
server/src/domain/person/person.dto.ts

@@ -1,6 +1,7 @@
 import { AssetFaceEntity, PersonEntity } from '@app/infra/entities';
-import { IsOptional, IsString } from 'class-validator';
-import { ValidateUUID } from '../domain.util';
+import { Transform } from 'class-transformer';
+import { IsBoolean, IsOptional, IsString } from 'class-validator';
+import { toBoolean, ValidateUUID } from '../domain.util';
 
 export class PersonUpdateDto {
   /**
@@ -16,6 +17,13 @@ export class PersonUpdateDto {
   @IsOptional()
   @IsString()
   featureFaceAssetId?: string;
+
+  /**
+   * Person visibility
+   */
+  @IsOptional()
+  @IsBoolean()
+  isHidden?: boolean;
 }
 
 export class MergePersonDto {
@@ -23,10 +31,23 @@ export class MergePersonDto {
   ids!: string[];
 }
 
+export class PersonSearchDto {
+  @IsBoolean()
+  @Transform(toBoolean)
+  withHidden?: boolean = false;
+}
+
 export class PersonResponseDto {
   id!: string;
   name!: string;
   thumbnailPath!: string;
+  isHidden!: boolean;
+}
+
+export class PeopleResponseDto {
+  total!: number;
+  visible!: number;
+  people!: PersonResponseDto[];
 }
 
 export function mapPerson(person: PersonEntity): PersonResponseDto {
@@ -34,6 +55,7 @@ export function mapPerson(person: PersonEntity): PersonResponseDto {
     id: person.id,
     name: person.name,
     thumbnailPath: person.thumbnailPath,
+    isHidden: person.isHidden,
   };
 }
 

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

@@ -19,6 +19,7 @@ const responseDto: PersonResponseDto = {
   id: 'person-1',
   name: 'Person 1',
   thumbnailPath: '/path/to/thumbnail.jpg',
+  isHidden: false,
 };
 
 describe(PersonService.name, () => {
@@ -41,7 +42,37 @@ describe(PersonService.name, () => {
   describe('getAll', () => {
     it('should get all people with thumbnails', async () => {
       personMock.getAll.mockResolvedValue([personStub.withName, personStub.noThumbnail]);
-      await expect(sut.getAll(authStub.admin)).resolves.toEqual([responseDto]);
+      await expect(sut.getAll(authStub.admin, { withHidden: undefined })).resolves.toEqual({
+        total: 1,
+        visible: 1,
+        people: [responseDto],
+      });
+      expect(personMock.getAll).toHaveBeenCalledWith(authStub.admin.id, { minimumFaceCount: 1 });
+    });
+    it('should get all visible people with thumbnails', async () => {
+      personMock.getAll.mockResolvedValue([personStub.withName, personStub.hidden]);
+      await expect(sut.getAll(authStub.admin, { withHidden: false })).resolves.toEqual({
+        total: 2,
+        visible: 1,
+        people: [responseDto],
+      });
+      expect(personMock.getAll).toHaveBeenCalledWith(authStub.admin.id, { minimumFaceCount: 1 });
+    });
+    it('should get all hidden and visible people with thumbnails', async () => {
+      personMock.getAll.mockResolvedValue([personStub.withName, personStub.hidden]);
+      await expect(sut.getAll(authStub.admin, { withHidden: true })).resolves.toEqual({
+        total: 2,
+        visible: 1,
+        people: [
+          responseDto,
+          {
+            id: 'person-1',
+            name: '',
+            thumbnailPath: '/path/to/thumbnail.jpg',
+            isHidden: true,
+          },
+        ],
+      });
       expect(personMock.getAll).toHaveBeenCalledWith(authStub.admin.id, { minimumFaceCount: 1 });
     });
   });
@@ -111,6 +142,21 @@ describe(PersonService.name, () => {
       });
     });
 
+    it('should update a person visibility', async () => {
+      personMock.getById.mockResolvedValue(personStub.hidden);
+      personMock.update.mockResolvedValue(personStub.withName);
+      personMock.getAssets.mockResolvedValue([assetEntityStub.image]);
+
+      await expect(sut.update(authStub.admin, 'person-1', { isHidden: false })).resolves.toEqual(responseDto);
+
+      expect(personMock.getById).toHaveBeenCalledWith('admin_id', 'person-1');
+      expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', isHidden: false });
+      expect(jobMock.queue).toHaveBeenCalledWith({
+        name: JobName.SEARCH_INDEX_ASSET,
+        data: { ids: [assetEntityStub.image.id] },
+      });
+    });
+
     it("should update a person's thumbnailPath", async () => {
       personMock.getById.mockResolvedValue(personStub.withName);
       personMock.getFaceById.mockResolvedValue(faceStub.face1);

+ 22 - 10
server/src/domain/person/person.service.ts

@@ -4,7 +4,14 @@ import { AuthUserDto } from '../auth';
 import { mimeTypes } from '../domain.constant';
 import { IJobRepository, JobName } from '../job';
 import { ImmichReadStream, IStorageRepository } from '../storage';
-import { mapPerson, MergePersonDto, PersonResponseDto, PersonUpdateDto } from './person.dto';
+import {
+  mapPerson,
+  MergePersonDto,
+  PeopleResponseDto,
+  PersonResponseDto,
+  PersonSearchDto,
+  PersonUpdateDto,
+} from './person.dto';
 import { IPersonRepository, UpdateFacesData } from './person.repository';
 
 @Injectable()
@@ -17,16 +24,21 @@ export class PersonService {
     @Inject(IJobRepository) private jobRepository: IJobRepository,
   ) {}
 
-  async getAll(authUser: AuthUserDto): Promise<PersonResponseDto[]> {
+  async getAll(authUser: AuthUserDto, dto: PersonSearchDto): Promise<PeopleResponseDto> {
     const people = await this.repository.getAll(authUser.id, { minimumFaceCount: 1 });
     const named = people.filter((person) => !!person.name);
     const unnamed = people.filter((person) => !person.name);
-    return (
-      [...named, ...unnamed]
-        // with thumbnails
-        .filter((person) => !!person.thumbnailPath)
-        .map((person) => mapPerson(person))
-    );
+
+    const persons: PersonResponseDto[] = [...named, ...unnamed]
+      // with thumbnails
+      .filter((person) => !!person.thumbnailPath)
+      .map((person) => mapPerson(person));
+
+    return {
+      people: persons.filter((person) => dto.withHidden || !person.isHidden),
+      total: persons.length,
+      visible: persons.filter((person: PersonResponseDto) => !person.isHidden).length,
+    };
   }
 
   getById(authUser: AuthUserDto, id: string): Promise<PersonResponseDto> {
@@ -50,8 +62,8 @@ export class PersonService {
   async update(authUser: AuthUserDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
     let person = await this.findOrFail(authUser, id);
 
-    if (dto.name !== undefined) {
-      person = await this.repository.update({ id, name: dto.name });
+    if (dto.name != undefined || dto.isHidden !== undefined) {
+      person = await this.repository.update({ id, name: dto.name, isHidden: dto.isHidden });
       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 } });

+ 5 - 3
server/src/immich/controllers/person.controller.ts

@@ -4,11 +4,13 @@ import {
   BulkIdResponseDto,
   ImmichReadStream,
   MergePersonDto,
+  PeopleResponseDto,
   PersonResponseDto,
+  PersonSearchDto,
   PersonService,
   PersonUpdateDto,
 } from '@app/domain';
-import { Body, Controller, Get, Param, Post, Put, StreamableFile } from '@nestjs/common';
+import { Body, Controller, Get, Param, Post, Put, Query, StreamableFile } from '@nestjs/common';
 import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
 import { Authenticated, AuthUser } from '../app.guard';
 import { UseValidation } from '../app.utils';
@@ -26,8 +28,8 @@ export class PersonController {
   constructor(private service: PersonService) {}
 
   @Get()
-  getAllPeople(@AuthUser() authUser: AuthUserDto): Promise<PersonResponseDto[]> {
-    return this.service.getAll(authUser);
+  getAllPeople(@AuthUser() authUser: AuthUserDto, @Query() withHidden: PersonSearchDto): Promise<PeopleResponseDto> {
+    return this.service.getAll(authUser, withHidden);
   }
 
   @Get(':id')

+ 3 - 0
server/src/infra/entities/person.entity.ts

@@ -35,4 +35,7 @@ export class PersonEntity {
 
   @OneToMany(() => AssetFaceEntity, (assetFace) => assetFace.person)
   faces!: AssetFaceEntity[];
+
+  @Column({ default: false })
+  isHidden!: boolean;
 }

+ 14 - 0
server/src/infra/migrations/1689281196844-AddHiddenFaces.ts

@@ -0,0 +1,14 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+export class Infra1689281196844 implements MigrationInterface {
+    name = 'Infra1689281196844'
+
+    public async up(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`ALTER TABLE "person" ADD "isHidden" boolean NOT NULL DEFAULT false`);
+    }
+
+    public async down(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`ALTER TABLE "person" DROP COLUMN "isHidden"`);
+    }
+
+}

+ 2 - 1
server/src/infra/repositories/typesense.repository.ts

@@ -385,7 +385,8 @@ export class TypesenseRepository implements ISearchRepository {
       custom = { ...custom, geo: [lat, lng] };
     }
 
-    const people = asset.faces?.map((face) => face.person.name).filter((name) => name) || [];
+    const people =
+      asset.faces?.filter((face) => !face.person.isHidden && face.person.name).map((face) => face.person.name) || [];
     if (people.length) {
       custom = { ...custom, people };
     }

+ 17 - 0
server/test/fixtures.ts

@@ -1094,6 +1094,18 @@ export const personStub = {
     name: '',
     thumbnailPath: '/path/to/thumbnail.jpg',
     faces: [],
+    isHidden: false,
+  }),
+  hidden: 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: '',
+    thumbnailPath: '/path/to/thumbnail.jpg',
+    faces: [],
+    isHidden: true,
   }),
   withName: Object.freeze<PersonEntity>({
     id: 'person-1',
@@ -1104,6 +1116,7 @@ export const personStub = {
     name: 'Person 1',
     thumbnailPath: '/path/to/thumbnail.jpg',
     faces: [],
+    isHidden: false,
   }),
   noThumbnail: Object.freeze<PersonEntity>({
     id: 'person-1',
@@ -1114,6 +1127,7 @@ export const personStub = {
     name: '',
     thumbnailPath: '',
     faces: [],
+    isHidden: false,
   }),
   newThumbnail: Object.freeze<PersonEntity>({
     id: 'person-1',
@@ -1124,6 +1138,7 @@ export const personStub = {
     name: '',
     thumbnailPath: '/new/path/to/thumbnail.jpg',
     faces: [],
+    isHidden: false,
   }),
   primaryPerson: Object.freeze<PersonEntity>({
     id: 'person-1',
@@ -1134,6 +1149,7 @@ export const personStub = {
     name: 'Person 1',
     thumbnailPath: '/path/to/thumbnail',
     faces: [],
+    isHidden: false,
   }),
   mergePerson: Object.freeze<PersonEntity>({
     id: 'person-2',
@@ -1144,6 +1160,7 @@ export const personStub = {
     name: 'Person 2',
     thumbnailPath: '/path/to/thumbnail',
     faces: [],
+    isHidden: false,
   }),
 };
 

+ 66 - 7
web/src/api/open-api/api.ts

@@ -1777,6 +1777,31 @@ export interface OAuthConfigResponseDto {
      */
     'autoLaunch'?: boolean;
 }
+/**
+ * 
+ * @export
+ * @interface PeopleResponseDto
+ */
+export interface PeopleResponseDto {
+    /**
+     * 
+     * @type {number}
+     * @memberof PeopleResponseDto
+     */
+    'total': number;
+    /**
+     * 
+     * @type {number}
+     * @memberof PeopleResponseDto
+     */
+    'visible': number;
+    /**
+     * 
+     * @type {Array<PersonResponseDto>}
+     * @memberof PeopleResponseDto
+     */
+    'people': Array<PersonResponseDto>;
+}
 /**
  * 
  * @export
@@ -1801,6 +1826,12 @@ export interface PersonResponseDto {
      * @memberof PersonResponseDto
      */
     'thumbnailPath': string;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof PersonResponseDto
+     */
+    'isHidden': boolean;
 }
 /**
  * 
@@ -1820,6 +1851,12 @@ export interface PersonUpdateDto {
      * @memberof PersonUpdateDto
      */
     'featureFaceAssetId'?: string;
+    /**
+     * Person visibility
+     * @type {boolean}
+     * @memberof PersonUpdateDto
+     */
+    'isHidden'?: boolean;
 }
 /**
  * 
@@ -8688,10 +8725,11 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio
     return {
         /**
          * 
+         * @param {boolean} [withHidden] 
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          */
-        getAllPeople: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+        getAllPeople: async (withHidden?: boolean, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
             const localVarPath = `/person`;
             // use dummy base URL string because the URL constructor only accepts absolute URLs.
             const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@@ -8713,6 +8751,10 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio
             // http bearer authentication required
             await setBearerAuthToObject(localVarHeaderParameter, configuration)
 
+            if (withHidden !== undefined) {
+                localVarQueryParameter['withHidden'] = withHidden;
+            }
+
 
     
             setSearchParams(localVarUrlObj, localVarQueryParameter);
@@ -8958,11 +9000,12 @@ export const PersonApiFp = function(configuration?: Configuration) {
     return {
         /**
          * 
+         * @param {boolean} [withHidden] 
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          */
-        async getAllPeople(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<PersonResponseDto>>> {
-            const localVarAxiosArgs = await localVarAxiosParamCreator.getAllPeople(options);
+        async getAllPeople(withHidden?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PeopleResponseDto>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.getAllPeople(withHidden, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
         /**
@@ -9029,11 +9072,12 @@ export const PersonApiFactory = function (configuration?: Configuration, basePat
     return {
         /**
          * 
+         * @param {boolean} [withHidden] 
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          */
-        getAllPeople(options?: any): AxiosPromise<Array<PersonResponseDto>> {
-            return localVarFp.getAllPeople(options).then((request) => request(axios, basePath));
+        getAllPeople(withHidden?: boolean, options?: any): AxiosPromise<PeopleResponseDto> {
+            return localVarFp.getAllPeople(withHidden, options).then((request) => request(axios, basePath));
         },
         /**
          * 
@@ -9085,6 +9129,20 @@ export const PersonApiFactory = function (configuration?: Configuration, basePat
     };
 };
 
+/**
+ * Request parameters for getAllPeople operation in PersonApi.
+ * @export
+ * @interface PersonApiGetAllPeopleRequest
+ */
+export interface PersonApiGetAllPeopleRequest {
+    /**
+     * 
+     * @type {boolean}
+     * @memberof PersonApiGetAllPeople
+     */
+    readonly withHidden?: boolean
+}
+
 /**
  * Request parameters for getPerson operation in PersonApi.
  * @export
@@ -9178,12 +9236,13 @@ export interface PersonApiUpdatePersonRequest {
 export class PersonApi extends BaseAPI {
     /**
      * 
+     * @param {PersonApiGetAllPeopleRequest} requestParameters Request parameters.
      * @param {*} [options] Override http request option.
      * @throws {RequiredError}
      * @memberof PersonApi
      */
-    public getAllPeople(options?: AxiosRequestConfig) {
-        return PersonApiFp(this.configuration).getAllPeople(options).then((request) => request(this.axios, this.basePath));
+    public getAllPeople(requestParameters: PersonApiGetAllPeopleRequest = {}, options?: AxiosRequestConfig) {
+        return PersonApiFp(this.configuration).getAllPeople(requestParameters.withHidden, options).then((request) => request(this.axios, this.basePath));
     }
 
     /**

+ 9 - 2
web/src/lib/components/assets/thumbnail/image-thumbnail.svelte

@@ -3,6 +3,7 @@
   import { fade } from 'svelte/transition';
   import { thumbHashToDataURL } from 'thumbhash';
   import { Buffer } from 'buffer';
+  import EyeOffOutline from 'svelte-material-icons/EyeOffOutline.svelte';
 
   export let url: string;
   export let altText: string;
@@ -12,16 +13,17 @@
   export let curve = false;
   export let shadow = false;
   export let circle = false;
-
+  export let hidden = false;
   let complete = false;
 </script>
 
 <img
   style:width={widthStyle}
   style:height={heightStyle}
+  style:filter={hidden ? 'grayscale(75%)' : 'none'}
   src={url}
   alt={altText}
-  class="object-cover transition-opacity duration-300"
+  class="object-cover transition duration-300"
   class:rounded-lg={curve}
   class:shadow-lg={shadow}
   class:rounded-full={circle}
@@ -30,6 +32,11 @@
   use:imageLoad
   on:image-load|once={() => (complete = true)}
 />
+{#if hidden}
+  <div class="absolute top-1/2 left-1/2 transform translate-x-[-50%] translate-y-[-50%]">
+    <EyeOffOutline size="2em" />
+  </div>
+{/if}
 
 {#if thumbhash && !complete}
   <img

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

@@ -25,8 +25,8 @@
   $: unselectedPeople = people.filter((source) => !selectedPeople.includes(source) && source.id !== person.id);
 
   onMount(async () => {
-    const { data } = await api.personApi.getAllPeople();
-    people = data;
+    const { data } = await api.personApi.getAllPeople({ withHidden: true });
+    people = data.people;
   });
 
   const onClose = () => {

+ 4 - 4
web/src/lib/components/faces-page/people-card.svelte

@@ -24,12 +24,12 @@
 
 <div id="people-card" class="relative">
   <a href="/people/{person.id}" draggable="false">
-    <div class="filter brightness-95 rounded-xl w-48">
+    <div class="w-48 rounded-xl brightness-95 filter">
       <ImageThumbnail shadow url={api.getPeopleThumbnailUrl(person.id)} altText={person.name} widthStyle="100%" />
     </div>
     {#if person.name}
       <span
-        class="absolute bottom-2 w-full text-center font-medium text-white text-ellipsis w-100 px-1 hover:cursor-pointer backdrop-blur-[1px]"
+        class="w-100 absolute bottom-2 w-full text-ellipsis px-1 text-center font-medium text-white backdrop-blur-[1px] hover:cursor-pointer"
       >
         {person.name}
       </span>
@@ -37,7 +37,7 @@
   </a>
 
   <button
-    class="absolute top-2 right-2 z-20"
+    class="absolute right-2 top-2 z-20"
     on:click|stopPropagation|preventDefault={() => {
       showContextMenu = !showContextMenu;
     }}
@@ -59,6 +59,6 @@
 
 {#if showContextMenu}
   <Portal target="body">
-    <div class="absolute top-0 left-0 heyo w-screen h-screen bg-transparent z-10" />
+    <div class="heyo absolute left-0 top-0 z-10 h-screen w-screen bg-transparent" />
   </Portal>
 {/if}

+ 30 - 0
web/src/lib/components/faces-page/show-hide.svelte

@@ -0,0 +1,30 @@
+<script>
+  import { fly } from 'svelte/transition';
+  import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
+  import { quintOut } from 'svelte/easing';
+  import Close from 'svelte-material-icons/Close.svelte';
+  import IconButton from '../elements/buttons/icon-button.svelte';
+  import { createEventDispatcher } from 'svelte';
+
+  const dispatch = createEventDispatcher();
+</script>
+
+<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]"
+>
+  <div
+    class="absolute border-b dark:border-immich-dark-gray flex justify-between place-items-center dark:text-immich-dark-fg w-full h-16"
+  >
+    <div class="flex items-center justify-between p-8 w-full">
+      <div class="flex items-center">
+        <CircleIconButton logo={Close} on:click={() => dispatch('closeClick')} />
+        <p class="ml-4">Show & hide faces</p>
+      </div>
+      <IconButton on:click={() => dispatch('doneClick')}>Done</IconButton>
+    </div>
+    <div class="absolute top-16 h-[calc(100%-theme(spacing.16))] w-full immich-scrollbar p-4 pb-8">
+      <slot />
+    </div>
+  </div>
+</section>

+ 0 - 1
web/src/lib/components/layouts/user-page-layout.svelte

@@ -16,7 +16,6 @@
 
   <slot name="header" />
 </header>
-
 <main
   class="grid md:grid-cols-[theme(spacing.64)_auto] grid-cols-[theme(spacing.18)_auto] relative pt-[var(--navbar-height)] h-screen overflow-hidden bg-immich-bg dark:bg-immich-dark-bg"
 >

+ 2 - 2
web/src/routes/(user)/explore/+page.server.ts

@@ -9,11 +9,11 @@ export const load = (async ({ locals, parent }) => {
   }
 
   const { data: items } = await locals.api.searchApi.getExploreData();
-  const { data: people } = await locals.api.personApi.getAllPeople();
+  const { data: response } = await locals.api.personApi.getAllPeople({ withHidden: false });
   return {
     user,
     items,
-    people,
+    response,
     meta: {
       title: 'Explore',
     },

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

@@ -19,7 +19,6 @@
   }
 
   const MAX_ITEMS = 12;
-
   const getFieldItems = (items: SearchExploreResponseDto[], field: Field) => {
     const targetField = items.find((item) => item.fieldName === field);
     return targetField?.items || [];
@@ -27,21 +26,20 @@
 
   $: things = getFieldItems(data.items, Field.OBJECTS);
   $: places = getFieldItems(data.items, Field.CITY);
-  $: people = data.people.slice(0, MAX_ITEMS);
+  $: people = data.response.people.slice(0, MAX_ITEMS);
+  $: hasPeople = data.response.total > 0;
 </script>
 
 <UserPageLayout user={data.user} title={data.meta.title}>
-  {#if people.length > 0}
+  {#if hasPeople}
     <div class="mb-6 mt-2">
       <div class="flex justify-between">
         <p class="mb-4 dark:text-immich-dark-fg font-medium">People</p>
-        {#if data.people.length > MAX_ITEMS}
-          <a
-            href={AppRoute.PEOPLE}
-            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}
+        <a
+          href={AppRoute.PEOPLE}
+          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
+        >
       </div>
       <div class="flex flex-row flex-wrap gap-4">
         {#each people as person (person.id)}

+ 1 - 2
web/src/routes/(user)/people/+page.server.ts

@@ -8,8 +8,7 @@ export const load = (async ({ locals, parent }) => {
     throw redirect(302, AppRoute.AUTH_LOGIN);
   }
 
-  const { data: people } = await locals.api.personApi.getAllPeople();
-
+  const { data: people } = await locals.api.personApi.getAllPeople({ withHidden: true });
   return {
     user,
     people,

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

@@ -6,14 +6,74 @@
   import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
   import Button from '$lib/components/elements/buttons/button.svelte';
   import { api, type PersonResponseDto } from '@api';
+  import { goto } from '$app/navigation';
+  import { AppRoute } from '$lib/constants';
   import { handleError } from '$lib/utils/handle-error';
   import {
-    notificationController,
     NotificationType,
+    notificationController,
   } from '$lib/components/shared-components/notification/notification';
-  import { goto } from '$app/navigation';
-  import { AppRoute } from '$lib/constants';
+  import ShowHide from '$lib/components/faces-page/show-hide.svelte';
+  import IconButton from '$lib/components/elements/buttons/icon-button.svelte';
+  import EyeOutline from 'svelte-material-icons/EyeOutline.svelte';
+  import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
+
   export let data: PageData;
+  let selectHidden = false;
+  let changeCounter = 0;
+  let initialHiddenValues: Record<string, boolean> = {};
+
+  let people = data.people.people;
+  let countTotalPeople = data.people.total;
+  let countVisiblePeople = data.people.visible;
+
+  people.forEach((person: PersonResponseDto) => {
+    initialHiddenValues[person.id] = person.isHidden;
+  });
+
+  const handleCloseClick = () => {
+    selectHidden = false;
+    people.forEach((person: PersonResponseDto) => {
+      person.isHidden = initialHiddenValues[person.id];
+    });
+  };
+
+  const handleDoneClick = async () => {
+    selectHidden = false;
+    try {
+      // Reset the counter before checking changes
+      let changeCounter = 0;
+
+      // Check if the visibility for each person has been changed
+      for (const person of people) {
+        if (person.isHidden !== initialHiddenValues[person.id]) {
+          changeCounter++;
+          await api.personApi.updatePerson({
+            id: person.id,
+            personUpdateDto: { isHidden: person.isHidden },
+          });
+
+          // Update the initial hidden values
+          initialHiddenValues[person.id] = person.isHidden;
+
+          // Update the count of hidden/visible people
+          countVisiblePeople += person.isHidden ? -1 : 1;
+        }
+      }
+
+      if (changeCounter > 0) {
+        notificationController.show({
+          type: NotificationType.Info,
+          message: `Visibility changed for ${changeCounter} ${changeCounter <= 1 ? 'person' : 'people'}`,
+        });
+      }
+    } catch (error) {
+      handleError(
+        error,
+        `Unable to change the visibility for ${changeCounter} ${changeCounter <= 1 ? 'person' : 'people'}`,
+      );
+    }
+  };
 
   let showChangeNameModal = false;
   let personName = '';
@@ -37,7 +97,7 @@
           personUpdateDto: { name: personName },
         });
 
-        data.people = data.people.map((person: PersonResponseDto) => {
+        people = people.map((person: PersonResponseDto) => {
           if (person.id === updatedPerson.id) {
             return updatedPerson;
           }
@@ -57,35 +117,48 @@
   };
 </script>
 
-<UserPageLayout user={data.user} showUploadButton title="People">
-  <section>
-    {#if data.people.length > 0}
-      <div class="pl-4">
-        <div class="flex flex-row flex-wrap gap-1">
-          {#each data.people as person (person.id)}
-            <PeopleCard {person} on:change-name={handleChangeName} on:merge-faces={handleMergeFaces} />
-          {/each}
+<UserPageLayout user={data.user} title="People">
+  <svelte:fragment slot="buttons">
+    {#if countTotalPeople > 0}
+      <IconButton on:click={() => (selectHidden = !selectHidden)}>
+        <div class="flex flex-wrap place-items-center justify-center gap-x-1 text-sm">
+          <EyeOutline size="18" />
+          <p class="ml-2">Show & hide faces</p>
         </div>
+      </IconButton>
+    {/if}
+  </svelte:fragment>
+
+  {#if countVisiblePeople > 0}
+    <div class="pl-4">
+      <div class="flex flex-row flex-wrap gap-1">
+        {#key selectHidden}
+          {#each people as person (person.id)}
+            {#if !person.isHidden}
+              <PeopleCard {person} on:change-name={handleChangeName} on:merge-faces={handleMergeFaces} />
+            {/if}
+          {/each}
+        {/key}
       </div>
-    {:else}
-      <div class="flex items-center place-content-center w-full min-h-[calc(66vh_-_11rem)] dark:text-white">
-        <div class="flex flex-col content-center items-center text-center">
-          <AccountOff size="3.5em" />
-          <p class="font-medium text-3xl mt-5">No people</p>
-        </div>
+    </div>
+  {:else}
+    <div class="flex min-h-[calc(66vh_-_11rem)] w-full place-content-center items-center dark:text-white">
+      <div class="flex flex-col content-center items-center text-center">
+        <AccountOff size="3.5em" />
+        <p class="mt-5 text-3xl font-medium">No people</p>
       </div>
-    {/if}
-  </section>
+    </div>
+  {/if}
 
   {#if showChangeNameModal}
     <FullScreenModal on:clickOutside={() => (showChangeNameModal = false)}>
       <div
-        class="border bg-immich-bg dark:bg-immich-dark-gray dark:border-immich-dark-gray p-4 shadow-sm w-[500px] max-w-[95vw] rounded-3xl py-8 dark:text-immich-dark-fg"
+        class="bg-immich-bg dark:bg-immich-dark-gray dark:border-immich-dark-gray dark:text-immich-dark-fg w-[500px] max-w-[95vw] rounded-3xl border p-4 py-8 shadow-sm"
       >
         <div
-          class="flex flex-col place-items-center place-content-center gap-4 px-4 text-immich-primary dark:text-immich-dark-primary"
+          class="text-immich-primary dark:text-immich-dark-primary flex flex-col place-content-center place-items-center gap-4 px-4"
         >
-          <h1 class="text-2xl text-immich-primary dark:text-immich-dark-primary font-medium">Change name</h1>
+          <h1 class="text-immich-primary dark:text-immich-dark-primary text-2xl font-medium">Change name</h1>
         </div>
 
         <form on:submit|preventDefault={submitNameChange} autocomplete="off">
@@ -95,7 +168,7 @@
             <input class="immich-form-input" id="name" name="name" type="text" bind:value={personName} autofocus />
           </div>
 
-          <div class="flex w-full px-4 gap-4 mt-8">
+          <div class="mt-8 flex w-full gap-4 px-4">
             <Button
               color="gray"
               fullwidth
@@ -110,3 +183,33 @@
     </FullScreenModal>
   {/if}
 </UserPageLayout>
+{#if selectHidden}
+  <ShowHide on:doneClick={handleDoneClick} on:closeClick={handleCloseClick}>
+    <div class="pl-4">
+      <div class="flex flex-row flex-wrap gap-1">
+        {#each people as person (person.id)}
+          <div class="relative">
+            <div class="h-48 w-48 rounded-xl brightness-95 filter">
+              <button class="h-full w-full" on:click={() => (person.isHidden = !person.isHidden)}>
+                <ImageThumbnail
+                  bind:hidden={person.isHidden}
+                  shadow
+                  url={api.getPeopleThumbnailUrl(person.id)}
+                  altText={person.name}
+                  widthStyle="100%"
+                />
+              </button>
+            </div>
+            {#if person.name}
+              <span
+                class="w-100 absolute bottom-2 w-full text-ellipsis px-1 text-center font-medium text-white backdrop-blur-[1px] hover:cursor-pointer"
+              >
+                {person.name}
+              </span>
+            {/if}
+          </div>
+        {/each}
+      </div>
+    </div>
+  </ShowHide>
+{/if}