From 7e9dcaacff62cabc7c4c49640d0c19adac2ade53 Mon Sep 17 00:00:00 2001 From: martabal <74269598+martabal@users.noreply.github.com> Date: Sat, 11 May 2024 19:52:23 +0200 Subject: [PATCH] feat: unassign faces --- e2e/src/api/specs/asset.e2e-spec.ts | 44 +- mobile/lib/services/asset.service.dart | 2 +- mobile/openapi/.openapi-generator/FILES | 3 + mobile/openapi/README.md | 3 + mobile/openapi/doc/AssetResponseDto.md | 2 +- mobile/openapi/doc/FaceApi.md | 56 +++ .../openapi/doc/PeopleWithFacesResponseDto.md | 16 + mobile/openapi/doc/PersonApi.md | 42 ++ mobile/openapi/lib/api.dart | 1 + mobile/openapi/lib/api/face_api.dart | 48 ++ mobile/openapi/lib/api/person_api.dart | 50 ++ mobile/openapi/lib/api_client.dart | 2 + .../openapi/lib/model/asset_response_dto.dart | 20 +- .../model/people_with_faces_response_dto.dart | 106 +++++ .../openapi/test/asset_response_dto_test.dart | 2 +- mobile/openapi/test/face_api_test.dart | 5 + .../people_with_faces_response_dto_test.dart | 32 ++ mobile/openapi/test/person_api_test.dart | 5 + open-api/immich-openapi-specs.json | 95 +++- open-api/typescript-sdk/src/fetch-client.ts | 43 +- server/src/controllers/face.controller.ts | 8 +- server/src/controllers/person.controller.ts | 7 +- server/src/dtos/asset-response.dto.ts | 15 +- server/src/dtos/person.dto.ts | 6 + server/src/entities/asset-face.entity.ts | 3 + server/src/interfaces/person.interface.ts | 4 +- .../1715357609038-AddEditedAssetFace.ts | 13 + server/src/queries/asset.repository.sql | 1 + server/src/queries/person.repository.sql | 13 +- server/src/queries/search.repository.sql | 1 + server/src/repositories/person.repository.ts | 2 +- server/src/services/asset.service.ts | 2 +- server/src/services/person.service.spec.ts | 35 +- server/src/services/person.service.ts | 46 +- server/test/fixtures/face.stub.ts | 24 + server/test/fixtures/shared-link.stub.ts | 2 +- .../asset-viewer/detail-panel.svelte | 20 +- .../buttons/circle-icon-button.svelte | 10 +- .../faces-page/assign-face-side-panel.svelte | 158 +++---- .../faces-page/person-side-panel.svelte | 442 +++++++++++++----- .../unassigned-faces-side-panel.svelte | 204 ++++++++ .../faces-page/unmerge-face-selector.svelte | 35 +- web/src/lib/utils/people-utils.ts | 5 + web/src/lib/utils/person.ts | 62 ++- web/src/lib/utils/thumbnail-util.ts | 2 +- 45 files changed, 1394 insertions(+), 303 deletions(-) create mode 100644 mobile/openapi/doc/PeopleWithFacesResponseDto.md create mode 100644 mobile/openapi/lib/model/people_with_faces_response_dto.dart create mode 100644 mobile/openapi/test/people_with_faces_response_dto_test.dart create mode 100644 server/src/migrations/1715357609038-AddEditedAssetFace.ts create mode 100644 web/src/lib/components/faces-page/unassigned-faces-side-panel.svelte diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index 050fa9b1e2..54a4c0160c 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -213,15 +213,18 @@ describe('/asset', () => { expect(body).toMatchObject({ id: user1Assets[0].id, isFavorite: false, - people: [ - { - birthDate: null, - id: expect.any(String), - isHidden: false, - name: 'Test Person', - thumbnailPath: '/my/awesome/thumbnail.jpg', - }, - ], + people: { + faces: [ + { + birthDate: null, + id: expect.any(String), + isHidden: false, + name: 'Test Person', + thumbnailPath: '/my/awesome/thumbnail.jpg', + }, + ], + numberOfFaces: 1, + }, }); const sharedLink = await utils.createSharedLink(user1.accessToken, { @@ -231,7 +234,7 @@ describe('/asset', () => { const data = await request(app).get(`/asset/${user1Assets[0].id}?key=${sharedLink.key}`); expect(data.status).toBe(200); - expect(data.body).toMatchObject({ people: [] }); + expect(data.body).not.toHaveProperty('people'); }); }); @@ -480,15 +483,18 @@ describe('/asset', () => { expect(body).toMatchObject({ id: user1Assets[0].id, isFavorite: true, - people: [ - { - birthDate: null, - id: expect.any(String), - isHidden: false, - name: 'Test Person', - thumbnailPath: '/my/awesome/thumbnail.jpg', - }, - ], + people: { + faces: [ + { + birthDate: null, + id: expect.any(String), + isHidden: false, + name: 'Test Person', + thumbnailPath: '/my/awesome/thumbnail.jpg', + }, + ], + numberOfFaces: 1, + }, }); }); }); diff --git a/mobile/lib/services/asset.service.dart b/mobile/lib/services/asset.service.dart index 5610dc435d..a9fd6678f8 100644 --- a/mobile/lib/services/asset.service.dart +++ b/mobile/lib/services/asset.service.dart @@ -72,7 +72,7 @@ class AssetService { final AssetResponseDto? dto = await _apiService.assetApi.getAssetInfo(remoteId); - return dto?.people; + return dto?.people?.faces; } catch (error, stack) { log.severe( 'Error while getting remote asset info: ${error.toString()}', diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 570132ada5..b2323c3158 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -117,6 +117,7 @@ doc/PathType.md doc/PeopleResponseDto.md doc/PeopleUpdateDto.md doc/PeopleUpdateItem.md +doc/PeopleWithFacesResponseDto.md doc/PersonApi.md doc/PersonCreateDto.md doc/PersonResponseDto.md @@ -350,6 +351,7 @@ lib/model/path_type.dart lib/model/people_response_dto.dart lib/model/people_update_dto.dart lib/model/people_update_item.dart +lib/model/people_with_faces_response_dto.dart lib/model/person_create_dto.dart lib/model/person_response_dto.dart lib/model/person_statistics_response_dto.dart @@ -550,6 +552,7 @@ test/path_type_test.dart test/people_response_dto_test.dart test/people_update_dto_test.dart test/people_update_item_test.dart +test/people_with_faces_response_dto_test.dart test/person_api_test.dart test/person_create_dto_test.dart test/person_response_dto_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index c98745430c..601763537a 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -120,6 +120,7 @@ Class | Method | HTTP request | Description *DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info | *FaceApi* | [**getFaces**](doc//FaceApi.md#getfaces) | **GET** /face | *FaceApi* | [**reassignFacesById**](doc//FaceApi.md#reassignfacesbyid) | **PUT** /face/{id} | +*FaceApi* | [**unassignFace**](doc//FaceApi.md#unassignface) | **DELETE** /face/{id} | *FileReportApi* | [**fixAuditFiles**](doc//FileReportApi.md#fixauditfiles) | **POST** /report/fix | *FileReportApi* | [**getAuditFiles**](doc//FileReportApi.md#getauditfiles) | **GET** /report | *FileReportApi* | [**getFileChecksums**](doc//FileReportApi.md#getfilechecksums) | **POST** /report/checksum | @@ -158,6 +159,7 @@ Class | Method | HTTP request | Description *PersonApi* | [**getPersonThumbnail**](doc//PersonApi.md#getpersonthumbnail) | **GET** /person/{id}/thumbnail | *PersonApi* | [**mergePerson**](doc//PersonApi.md#mergeperson) | **POST** /person/{id}/merge | *PersonApi* | [**reassignFaces**](doc//PersonApi.md#reassignfaces) | **PUT** /person/{id}/reassign | +*PersonApi* | [**unassignFaces**](doc//PersonApi.md#unassignfaces) | **DELETE** /person | *PersonApi* | [**updatePeople**](doc//PersonApi.md#updatepeople) | **PUT** /person | *PersonApi* | [**updatePerson**](doc//PersonApi.md#updateperson) | **PUT** /person/{id} | *SearchApi* | [**getAssetsByCity**](doc//SearchApi.md#getassetsbycity) | **GET** /search/cities | @@ -323,6 +325,7 @@ Class | Method | HTTP request | Description - [PeopleResponseDto](doc//PeopleResponseDto.md) - [PeopleUpdateDto](doc//PeopleUpdateDto.md) - [PeopleUpdateItem](doc//PeopleUpdateItem.md) + - [PeopleWithFacesResponseDto](doc//PeopleWithFacesResponseDto.md) - [PersonCreateDto](doc//PersonCreateDto.md) - [PersonResponseDto](doc//PersonResponseDto.md) - [PersonStatisticsResponseDto](doc//PersonStatisticsResponseDto.md) diff --git a/mobile/openapi/doc/AssetResponseDto.md b/mobile/openapi/doc/AssetResponseDto.md index 98290b3745..f5d1fa3037 100644 --- a/mobile/openapi/doc/AssetResponseDto.md +++ b/mobile/openapi/doc/AssetResponseDto.md @@ -30,7 +30,7 @@ Name | Type | Description | Notes **originalPath** | **String** | | **owner** | [**UserResponseDto**](UserResponseDto.md) | | [optional] **ownerId** | **String** | | -**people** | [**List**](PersonWithFacesResponseDto.md) | | [optional] [default to const []] +**people** | [**PeopleWithFacesResponseDto**](PeopleWithFacesResponseDto.md) | | [optional] **resized** | **bool** | | **smartInfo** | [**SmartInfoResponseDto**](SmartInfoResponseDto.md) | | [optional] **stack** | [**List**](AssetResponseDto.md) | | [optional] [default to const []] diff --git a/mobile/openapi/doc/FaceApi.md b/mobile/openapi/doc/FaceApi.md index 84793a5c90..a58e2a029b 100644 --- a/mobile/openapi/doc/FaceApi.md +++ b/mobile/openapi/doc/FaceApi.md @@ -11,6 +11,7 @@ Method | HTTP request | Description ------------- | ------------- | ------------- [**getFaces**](FaceApi.md#getfaces) | **GET** /face | [**reassignFacesById**](FaceApi.md#reassignfacesbyid) | **PUT** /face/{id} | +[**unassignFace**](FaceApi.md#unassignface) | **DELETE** /face/{id} | # **getFaces** @@ -125,3 +126,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) +# **unassignFace** +> AssetFaceResponseDto unassignFace(id) + + + +### Example +```dart +import 'package:openapi/api.dart'; +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure API key authorization: api_key +//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); + +final api_instance = FaceApi(); +final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | + +try { + final result = api_instance.unassignFace(id); + print(result); +} catch (e) { + print('Exception when calling FaceApi->unassignFace: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **id** | **String**| | + +### Return type + +[**AssetFaceResponseDto**](AssetFaceResponseDto.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) + diff --git a/mobile/openapi/doc/PeopleWithFacesResponseDto.md b/mobile/openapi/doc/PeopleWithFacesResponseDto.md new file mode 100644 index 0000000000..acaa413389 --- /dev/null +++ b/mobile/openapi/doc/PeopleWithFacesResponseDto.md @@ -0,0 +1,16 @@ +# openapi.model.PeopleWithFacesResponseDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**faces** | [**List**](PersonWithFacesResponseDto.md) | | [default to const []] +**numberOfFaces** | **int** | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/doc/PersonApi.md b/mobile/openapi/doc/PersonApi.md index 48c1c3cc4a..3ceaf812b4 100644 --- a/mobile/openapi/doc/PersonApi.md +++ b/mobile/openapi/doc/PersonApi.md @@ -17,6 +17,7 @@ Method | HTTP request | Description [**getPersonThumbnail**](PersonApi.md#getpersonthumbnail) | **GET** /person/{id}/thumbnail | [**mergePerson**](PersonApi.md#mergeperson) | **POST** /person/{id}/merge | [**reassignFaces**](PersonApi.md#reassignfaces) | **PUT** /person/{id}/reassign | +[**unassignFaces**](PersonApi.md#unassignfaces) | **DELETE** /person | [**updatePeople**](PersonApi.md#updatepeople) | **PUT** /person | [**updatePerson**](PersonApi.md#updateperson) | **PUT** /person/{id} | @@ -465,6 +466,47 @@ 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) +# **unassignFaces** +> List unassignFaces(assetFaceUpdateDto) + + + +### Example +```dart +import 'package:openapi/api.dart'; + +final api_instance = PersonApi(); +final assetFaceUpdateDto = AssetFaceUpdateDto(); // AssetFaceUpdateDto | + +try { + final result = api_instance.unassignFaces(assetFaceUpdateDto); + print(result); +} catch (e) { + print('Exception when calling PersonApi->unassignFaces: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **assetFaceUpdateDto** | [**AssetFaceUpdateDto**](AssetFaceUpdateDto.md)| | + +### Return type + +[**List**](BulkIdResponseDto.md) + +### Authorization + +No authorization required + +### HTTP request headers + + - **Content-Type**: application/json + - **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + # **updatePeople** > List updatePeople(peopleUpdateDto) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 02da5876dc..a76f3ef364 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -156,6 +156,7 @@ part 'model/path_type.dart'; part 'model/people_response_dto.dart'; part 'model/people_update_dto.dart'; part 'model/people_update_item.dart'; +part 'model/people_with_faces_response_dto.dart'; part 'model/person_create_dto.dart'; part 'model/person_response_dto.dart'; part 'model/person_statistics_response_dto.dart'; diff --git a/mobile/openapi/lib/api/face_api.dart b/mobile/openapi/lib/api/face_api.dart index 6ea96760ab..fb0c426555 100644 --- a/mobile/openapi/lib/api/face_api.dart +++ b/mobile/openapi/lib/api/face_api.dart @@ -119,4 +119,52 @@ class FaceApi { } return null; } + + /// Performs an HTTP 'DELETE /face/{id}' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + Future unassignFaceWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final path = r'/face/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + Future unassignFace(String id,) async { + final response = await unassignFaceWithHttpInfo(id,); + 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) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetFaceResponseDto',) as AssetFaceResponseDto; + + } + return null; + } } diff --git a/mobile/openapi/lib/api/person_api.dart b/mobile/openapi/lib/api/person_api.dart index 411c75d715..ad3ffe8e5e 100644 --- a/mobile/openapi/lib/api/person_api.dart +++ b/mobile/openapi/lib/api/person_api.dart @@ -419,6 +419,56 @@ class PersonApi { return null; } + /// Performs an HTTP 'DELETE /person' operation and returns the [Response]. + /// Parameters: + /// + /// * [AssetFaceUpdateDto] assetFaceUpdateDto (required): + Future unassignFacesWithHttpInfo(AssetFaceUpdateDto assetFaceUpdateDto,) async { + // ignore: prefer_const_declarations + final path = r'/person'; + + // ignore: prefer_final_locals + Object? postBody = assetFaceUpdateDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [AssetFaceUpdateDto] assetFaceUpdateDto (required): + Future?> unassignFaces(AssetFaceUpdateDto assetFaceUpdateDto,) async { + final response = await unassignFacesWithHttpInfo(assetFaceUpdateDto,); + 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') as List) + .cast() + .toList(growable: false); + + } + return null; + } + /// Performs an HTTP 'PUT /person' operation and returns the [Response]. /// Parameters: /// diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 3b21ff6e0f..0a57fc67f2 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -382,6 +382,8 @@ class ApiClient { return PeopleUpdateDto.fromJson(value); case 'PeopleUpdateItem': return PeopleUpdateItem.fromJson(value); + case 'PeopleWithFacesResponseDto': + return PeopleWithFacesResponseDto.fromJson(value); case 'PersonCreateDto': return PersonCreateDto.fromJson(value); case 'PersonResponseDto': diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index 6ba7f27d60..8f5ae04942 100644 --- a/mobile/openapi/lib/model/asset_response_dto.dart +++ b/mobile/openapi/lib/model/asset_response_dto.dart @@ -35,7 +35,7 @@ class AssetResponseDto { required this.originalPath, this.owner, required this.ownerId, - this.people = const [], + this.people, required this.resized, this.smartInfo, this.stack = const [], @@ -118,7 +118,13 @@ class AssetResponseDto { String ownerId; - List people; + /// + /// 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. + /// + PeopleWithFacesResponseDto? people; bool resized; @@ -168,7 +174,7 @@ class AssetResponseDto { other.originalPath == originalPath && other.owner == owner && other.ownerId == ownerId && - _deepEquality.equals(other.people, people) && + other.people == people && other.resized == resized && other.smartInfo == smartInfo && _deepEquality.equals(other.stack, stack) && @@ -204,7 +210,7 @@ class AssetResponseDto { (originalPath.hashCode) + (owner == null ? 0 : owner!.hashCode) + (ownerId.hashCode) + - (people.hashCode) + + (people == null ? 0 : people!.hashCode) + (resized.hashCode) + (smartInfo == null ? 0 : smartInfo!.hashCode) + (stack.hashCode) + @@ -262,7 +268,11 @@ class AssetResponseDto { // json[r'owner'] = null; } json[r'ownerId'] = this.ownerId; + if (this.people != null) { json[r'people'] = this.people; + } else { + // json[r'people'] = null; + } json[r'resized'] = this.resized; if (this.smartInfo != null) { json[r'smartInfo'] = this.smartInfo; @@ -321,7 +331,7 @@ class AssetResponseDto { originalPath: mapValueOfType(json, r'originalPath')!, owner: UserResponseDto.fromJson(json[r'owner']), ownerId: mapValueOfType(json, r'ownerId')!, - people: PersonWithFacesResponseDto.listFromJson(json[r'people']), + people: PeopleWithFacesResponseDto.fromJson(json[r'people']), resized: mapValueOfType(json, r'resized')!, smartInfo: SmartInfoResponseDto.fromJson(json[r'smartInfo']), stack: AssetResponseDto.listFromJson(json[r'stack']), diff --git a/mobile/openapi/lib/model/people_with_faces_response_dto.dart b/mobile/openapi/lib/model/people_with_faces_response_dto.dart new file mode 100644 index 0000000000..4e0d23d204 --- /dev/null +++ b/mobile/openapi/lib/model/people_with_faces_response_dto.dart @@ -0,0 +1,106 @@ +// +// 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 PeopleWithFacesResponseDto { + /// Returns a new [PeopleWithFacesResponseDto] instance. + PeopleWithFacesResponseDto({ + this.faces = const [], + required this.numberOfFaces, + }); + + List faces; + + int numberOfFaces; + + @override + bool operator ==(Object other) => identical(this, other) || other is PeopleWithFacesResponseDto && + _deepEquality.equals(other.faces, faces) && + other.numberOfFaces == numberOfFaces; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (faces.hashCode) + + (numberOfFaces.hashCode); + + @override + String toString() => 'PeopleWithFacesResponseDto[faces=$faces, numberOfFaces=$numberOfFaces]'; + + Map toJson() { + final json = {}; + json[r'faces'] = this.faces; + json[r'numberOfFaces'] = this.numberOfFaces; + return json; + } + + /// Returns a new [PeopleWithFacesResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static PeopleWithFacesResponseDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return PeopleWithFacesResponseDto( + faces: PersonWithFacesResponseDto.listFromJson(json[r'faces']), + numberOfFaces: mapValueOfType(json, r'numberOfFaces')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = PeopleWithFacesResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = PeopleWithFacesResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of PeopleWithFacesResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = PeopleWithFacesResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'faces', + 'numberOfFaces', + }; +} + diff --git a/mobile/openapi/test/asset_response_dto_test.dart b/mobile/openapi/test/asset_response_dto_test.dart index fa12bc9f15..355f07a047 100644 --- a/mobile/openapi/test/asset_response_dto_test.dart +++ b/mobile/openapi/test/asset_response_dto_test.dart @@ -129,7 +129,7 @@ void main() { // TODO }); - // List people (default value: const []) + // PeopleWithFacesResponseDto people test('to test the property `people`', () async { // TODO }); diff --git a/mobile/openapi/test/face_api_test.dart b/mobile/openapi/test/face_api_test.dart index 3bd4d982f4..55c289e041 100644 --- a/mobile/openapi/test/face_api_test.dart +++ b/mobile/openapi/test/face_api_test.dart @@ -27,5 +27,10 @@ void main() { // TODO }); + //Future unassignFace(String id) async + test('test unassignFace', () async { + // TODO + }); + }); } diff --git a/mobile/openapi/test/people_with_faces_response_dto_test.dart b/mobile/openapi/test/people_with_faces_response_dto_test.dart new file mode 100644 index 0000000000..1a260f6456 --- /dev/null +++ b/mobile/openapi/test/people_with_faces_response_dto_test.dart @@ -0,0 +1,32 @@ +// +// 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 PeopleWithFacesResponseDto +void main() { + // final instance = PeopleWithFacesResponseDto(); + + group('test PeopleWithFacesResponseDto', () { + // List faces (default value: const []) + test('to test the property `faces`', () async { + // TODO + }); + + // int numberOfFaces + test('to test the property `numberOfFaces`', () async { + // TODO + }); + + + }); + +} diff --git a/mobile/openapi/test/person_api_test.dart b/mobile/openapi/test/person_api_test.dart index 959230cc59..de36966e5c 100644 --- a/mobile/openapi/test/person_api_test.dart +++ b/mobile/openapi/test/person_api_test.dart @@ -57,6 +57,11 @@ void main() { // TODO }); + //Future> unassignFaces(AssetFaceUpdateDto assetFaceUpdateDto) async + test('test unassignFaces', () async { + // TODO + }); + //Future> updatePeople(PeopleUpdateDto peopleUpdateDto) async test('test updatePeople', () async { // TODO diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index eea90fb1c9..cc4888b82f 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -2259,6 +2259,46 @@ } }, "/face/{id}": { + "delete": { + "operationId": "unassignFace", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetFaceResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Face" + ] + }, "put": { "operationId": "reassignFacesById", "parameters": [ @@ -3408,6 +3448,38 @@ } }, "/person": { + "delete": { + "operationId": "unassignFaces", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetFaceUpdateDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/BulkIdResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "tags": [ + "Person" + ] + }, "get": { "operationId": "getAllPeople", "parameters": [ @@ -7337,10 +7409,7 @@ "type": "string" }, "people": { - "items": { - "$ref": "#/components/schemas/PersonWithFacesResponseDto" - }, - "type": "array" + "$ref": "#/components/schemas/PeopleWithFacesResponseDto" }, "resized": { "type": "boolean" @@ -8943,6 +9012,24 @@ ], "type": "object" }, + "PeopleWithFacesResponseDto": { + "properties": { + "faces": { + "items": { + "$ref": "#/components/schemas/PersonWithFacesResponseDto" + }, + "type": "array" + }, + "numberOfFaces": { + "type": "integer" + } + }, + "required": [ + "faces", + "numberOfFaces" + ], + "type": "object" + }, "PersonCreateDto": { "properties": { "birthDate": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 23b3b00bed..9f23d7631e 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -100,6 +100,10 @@ export type PersonWithFacesResponseDto = { name: string; thumbnailPath: string; }; +export type PeopleWithFacesResponseDto = { + faces: PersonWithFacesResponseDto[]; + numberOfFaces: number; +}; export type SmartInfoResponseDto = { objects?: string[] | null; tags?: string[] | null; @@ -136,7 +140,7 @@ export type AssetResponseDto = { originalPath: string; owner?: UserResponseDto; ownerId: string; - people?: PersonWithFacesResponseDto[]; + people?: PeopleWithFacesResponseDto; resized: boolean; smartInfo?: SmartInfoResponseDto; stack?: AssetResponseDto[]; @@ -535,6 +539,13 @@ export type PartnerResponseDto = { export type UpdatePartnerDto = { inTimeline: boolean; }; +export type AssetFaceUpdateItem = { + assetId: string; + personId: string; +}; +export type AssetFaceUpdateDto = { + data: AssetFaceUpdateItem[]; +}; export type PeopleResponseDto = { hidden: number; people: PersonResponseDto[]; @@ -579,13 +590,6 @@ export type PersonUpdateDto = { export type MergePersonDto = { ids: string[]; }; -export type AssetFaceUpdateItem = { - assetId: string; - personId: string; -}; -export type AssetFaceUpdateDto = { - data: AssetFaceUpdateItem[]; -}; export type PersonStatisticsResponseDto = { assets: number; }; @@ -1699,6 +1703,17 @@ export function getFaces({ id }: { ...opts })); } +export function unassignFace({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AssetFaceResponseDto; + }>(`/face/${encodeURIComponent(id)}`, { + ...opts, + method: "DELETE" + })); +} export function reassignFacesById({ id, faceDto }: { id: string; faceDto: FaceDto; @@ -2000,6 +2015,18 @@ export function updatePartner({ id, updatePartnerDto }: { body: updatePartnerDto }))); } +export function unassignFaces({ assetFaceUpdateDto }: { + assetFaceUpdateDto: AssetFaceUpdateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: BulkIdResponseDto[]; + }>("/person", oazapfts.json({ + ...opts, + method: "DELETE", + body: assetFaceUpdateDto + }))); +} export function getAllPeople({ withHidden }: { withHidden?: boolean; }, opts?: Oazapfts.RequestOpts) { diff --git a/server/src/controllers/face.controller.ts b/server/src/controllers/face.controller.ts index 5b45432944..ec15bf3c59 100644 --- a/server/src/controllers/face.controller.ts +++ b/server/src/controllers/face.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Param, Put, Query } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetFaceResponseDto, FaceDto, PersonResponseDto } from 'src/dtos/person.dto'; @@ -26,4 +26,10 @@ export class FaceController { ): Promise { return this.service.reassignFacesById(auth, id, dto); } + + @Delete(':id') + @Authenticated() + unassignFace(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.unassignFace(auth, id); + } } diff --git a/server/src/controllers/person.controller.ts b/server/src/controllers/person.controller.ts index dc87825927..6b9b658aeb 100644 --- a/server/src/controllers/person.controller.ts +++ b/server/src/controllers/person.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Next, Param, Post, Put, Query, Res } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Next, Param, Post, Put, Query, Res } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { NextFunction, Response } from 'express'; import { BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; @@ -83,6 +83,11 @@ export class PersonController { return this.service.getAssets(auth, id); } + @Delete() + unassignFaces(@Auth() auth: AuthDto, @Body() dto: AssetFaceUpdateDto): Promise { + return this.service.unassignFaces(auth, dto); + } + @Put(':id/reassign') @Authenticated() reassignFaces( diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index 0afc906c95..fed3465406 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -2,7 +2,12 @@ import { ApiProperty } from '@nestjs/swagger'; import { PropertyLifecycle } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; import { ExifResponseDto, mapExif } from 'src/dtos/exif.dto'; -import { PersonWithFacesResponseDto, mapFacesWithoutPerson, mapPerson } from 'src/dtos/person.dto'; +import { + PeopleWithFacesResponseDto, + PersonWithFacesResponseDto, + mapFacesWithoutPerson, + mapPerson, +} from 'src/dtos/person.dto'; import { TagResponseDto, mapTag } from 'src/dtos/tag.dto'; import { UserResponseDto, mapUser } from 'src/dtos/user.dto'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; @@ -43,7 +48,7 @@ export class AssetResponseDto extends SanitizedAssetResponseDto { exifInfo?: ExifResponseDto; smartInfo?: SmartInfoResponseDto; tags?: TagResponseDto[]; - people?: PersonWithFacesResponseDto[]; + people?: PeopleWithFacesResponseDto; /**base64 encoded sha1 hash */ checksum!: string; stackParentId?: string | null; @@ -58,7 +63,7 @@ export type AssetMapOptions = { auth?: AuthDto; }; -const peopleWithFaces = (faces: AssetFaceEntity[]): PersonWithFacesResponseDto[] => { +const peopleWithFaces = (faces: AssetFaceEntity[]): PeopleWithFacesResponseDto => { const result: PersonWithFacesResponseDto[] = []; if (faces) { for (const face of faces) { @@ -73,7 +78,7 @@ const peopleWithFaces = (faces: AssetFaceEntity[]): PersonWithFacesResponseDto[] } } - return result; + return { faces: result, numberOfFaces: faces.length }; }; export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): AssetResponseDto { @@ -117,7 +122,7 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined, livePhotoVideoId: entity.livePhotoVideoId, tags: entity.tags?.map(mapTag), - people: peopleWithFaces(entity.faces), + people: entity.faces ? peopleWithFaces(entity.faces) : undefined, checksum: entity.checksum.toString('base64'), stackParentId: withStack ? entity.stack?.primaryAssetId : undefined, stack: withStack diff --git a/server/src/dtos/person.dto.ts b/server/src/dtos/person.dto.ts index b28f18603a..a81eb2b6ef 100644 --- a/server/src/dtos/person.dto.ts +++ b/server/src/dtos/person.dto.ts @@ -77,6 +77,12 @@ export class PersonWithFacesResponseDto extends PersonResponseDto { faces!: AssetFaceWithoutPersonResponseDto[]; } +export class PeopleWithFacesResponseDto { + faces!: PersonWithFacesResponseDto[]; + @ApiProperty({ type: 'integer' }) + numberOfFaces!: number; +} + export class AssetFaceWithoutPersonResponseDto { @ValidateUUID() id!: string; diff --git a/server/src/entities/asset-face.entity.ts b/server/src/entities/asset-face.entity.ts index 38fcd46063..0e0d9d4c3c 100644 --- a/server/src/entities/asset-face.entity.ts +++ b/server/src/entities/asset-face.entity.ts @@ -37,6 +37,9 @@ export class AssetFaceEntity { @Column({ default: 0, type: 'int' }) boundingBoxY2!: number; + @Column({ default: false }) + isEdited!: boolean; + @ManyToOne(() => AssetEntity, (asset) => asset.faces, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) asset!: AssetEntity; diff --git a/server/src/interfaces/person.interface.ts b/server/src/interfaces/person.interface.ts index 382bbda22f..41f973c3d2 100644 --- a/server/src/interfaces/person.interface.ts +++ b/server/src/interfaces/person.interface.ts @@ -23,7 +23,7 @@ export interface AssetFaceId { export interface UpdateFacesData { oldPersonId?: string; faceIds?: string[]; - newPersonId: string; + newPersonId: string | null; } export interface PersonStatistics { @@ -60,7 +60,7 @@ export interface IPersonRepository { getFacesByIds(ids: AssetFaceId[]): Promise; getRandomFace(personId: string): Promise; getStatistics(personId: string): Promise; - reassignFace(assetFaceId: string, newPersonId: string): Promise; + reassignFace(assetFaceId: string, newPersonId: string | null): Promise; getNumberOfPeople(userId: string): Promise; reassignFaces(data: UpdateFacesData): Promise; update(entity: Partial): Promise; diff --git a/server/src/migrations/1715357609038-AddEditedAssetFace.ts b/server/src/migrations/1715357609038-AddEditedAssetFace.ts new file mode 100644 index 0000000000..6e7673957c --- /dev/null +++ b/server/src/migrations/1715357609038-AddEditedAssetFace.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddEditedAssetFace1715357609038 implements MigrationInterface { + name = 'AddEditedAssetFace1715357609038'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "asset_faces" ADD "isEdited" boolean NOT NULL DEFAULT false`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "asset_faces" DROP COLUMN "isEdited"`); + } +} diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 27a74807f8..5626f96a45 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -192,6 +192,7 @@ SELECT "AssetEntity__AssetEntity_faces"."boundingBoxY1" AS "AssetEntity__AssetEntity_faces_boundingBoxY1", "AssetEntity__AssetEntity_faces"."boundingBoxX2" AS "AssetEntity__AssetEntity_faces_boundingBoxX2", "AssetEntity__AssetEntity_faces"."boundingBoxY2" AS "AssetEntity__AssetEntity_faces_boundingBoxY2", + "AssetEntity__AssetEntity_faces"."isEdited" AS "AssetEntity__AssetEntity_faces_isEdited", "8258e303a73a72cf6abb13d73fb592dde0d68280"."id" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_id", "8258e303a73a72cf6abb13d73fb592dde0d68280"."createdAt" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_createdAt", "8258e303a73a72cf6abb13d73fb592dde0d68280"."updatedAt" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_updatedAt", diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql index 68c9d520cb..894ff2e63f 100644 --- a/server/src/queries/person.repository.sql +++ b/server/src/queries/person.repository.sql @@ -71,6 +71,7 @@ SELECT "AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1", "AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2", "AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2", + "AssetFaceEntity"."isEdited" AS "AssetFaceEntity_isEdited", "AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id", "AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt", "AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt", @@ -103,6 +104,7 @@ FROM "AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1", "AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2", "AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2", + "AssetFaceEntity"."isEdited" AS "AssetFaceEntity_isEdited", "AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id", "AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt", "AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt", @@ -138,6 +140,7 @@ FROM "AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1", "AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2", "AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2", + "AssetFaceEntity"."isEdited" AS "AssetFaceEntity_isEdited", "AssetFaceEntity__AssetFaceEntity_person"."id" AS "AssetFaceEntity__AssetFaceEntity_person_id", "AssetFaceEntity__AssetFaceEntity_person"."createdAt" AS "AssetFaceEntity__AssetFaceEntity_person_createdAt", "AssetFaceEntity__AssetFaceEntity_person"."updatedAt" AS "AssetFaceEntity__AssetFaceEntity_person_updatedAt", @@ -193,9 +196,10 @@ LIMIT -- PersonRepository.reassignFace UPDATE "asset_faces" SET - "personId" = $1 + "personId" = $1, + "isEdited" = $2 WHERE - "id" = $2 + "id" = $3 -- PersonRepository.getByName SELECT @@ -281,6 +285,7 @@ FROM "AssetEntity__AssetEntity_faces"."boundingBoxY1" AS "AssetEntity__AssetEntity_faces_boundingBoxY1", "AssetEntity__AssetEntity_faces"."boundingBoxX2" AS "AssetEntity__AssetEntity_faces_boundingBoxX2", "AssetEntity__AssetEntity_faces"."boundingBoxY2" AS "AssetEntity__AssetEntity_faces_boundingBoxY2", + "AssetEntity__AssetEntity_faces"."isEdited" AS "AssetEntity__AssetEntity_faces_isEdited", "8258e303a73a72cf6abb13d73fb592dde0d68280"."id" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_id", "8258e303a73a72cf6abb13d73fb592dde0d68280"."createdAt" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_createdAt", "8258e303a73a72cf6abb13d73fb592dde0d68280"."updatedAt" AS "8258e303a73a72cf6abb13d73fb592dde0d68280_updatedAt", @@ -373,6 +378,7 @@ SELECT "AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1", "AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2", "AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2", + "AssetFaceEntity"."isEdited" AS "AssetFaceEntity_isEdited", "AssetFaceEntity__AssetFaceEntity_asset"."id" AS "AssetFaceEntity__AssetFaceEntity_asset_id", "AssetFaceEntity__AssetFaceEntity_asset"."deviceAssetId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceAssetId", "AssetFaceEntity__AssetFaceEntity_asset"."ownerId" AS "AssetFaceEntity__AssetFaceEntity_asset_ownerId", @@ -424,7 +430,8 @@ SELECT "AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1", "AssetFaceEntity"."boundingBoxY1" AS "AssetFaceEntity_boundingBoxY1", "AssetFaceEntity"."boundingBoxX2" AS "AssetFaceEntity_boundingBoxX2", - "AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2" + "AssetFaceEntity"."boundingBoxY2" AS "AssetFaceEntity_boundingBoxY2", + "AssetFaceEntity"."isEdited" AS "AssetFaceEntity_isEdited" FROM "asset_faces" "AssetFaceEntity" WHERE diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index e75cd3322a..bfc52f488f 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -207,6 +207,7 @@ WITH "faces"."boundingBoxY1" AS "boundingBoxY1", "faces"."boundingBoxX2" AS "boundingBoxX2", "faces"."boundingBoxY2" AS "boundingBoxY2", + "faces"."isEdited" AS "isEdited", "faces"."embedding" <= > $1 AS "distance" FROM "asset_faces" "faces" diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 225a2edeca..6919d22b80 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -148,7 +148,7 @@ export class PersonRepository implements IPersonRepository { const result = await this.assetFaceRepository .createQueryBuilder() .update() - .set({ personId: newPersonId }) + .set({ personId: newPersonId, isEdited: true }) .where({ id: assetFaceId }) .execute(); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 5ffa940e7b..5e3cec27a7 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -266,7 +266,7 @@ export class AssetService { } if (data.ownerId !== auth.user.id || auth.sharedLink) { - data.people = []; + delete data.people; } return data; diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index 3a1d76388e..924f99196e 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -437,6 +437,36 @@ describe(PersonService.name, () => { }); }); + describe('unassignFace', () => { + it('should unassign a face', async () => { + personMock.getFaceById.mockResolvedValueOnce(faceStub.face1); + accessMock.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id])); + accessMock.person.checkFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id])); + personMock.reassignFace.mockResolvedValue(1); + personMock.getRandomFace.mockResolvedValue(null); + personMock.getFaceById.mockResolvedValueOnce(faceStub.unassignedFace); + + await expect(sut.unassignFace(authStub.admin, faceStub.face1.id)).resolves.toStrictEqual( + mapFaces(faceStub.unassignedFace, authStub.admin), + ); + }); + }); + + describe('unassignFaces', () => { + it('should unassign a face', async () => { + personMock.getFacesByIds.mockResolvedValueOnce([faceStub.face1]); + accessMock.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id])); + accessMock.person.checkFaceOwnerAccess.mockResolvedValue(new Set([faceStub.face1.id])); + personMock.reassignFace.mockResolvedValue(1); + personMock.getRandomFace.mockResolvedValue(null); + personMock.getFaceById.mockResolvedValueOnce(faceStub.unassignedFace); + + await expect( + sut.unassignFaces(authStub.admin, { data: [{ assetId: faceStub.face1.id, personId: 'person-1' }] }), + ).resolves.toStrictEqual([{ id: 'assetFaceId1', success: true }]); + }); + }); + describe('handlePersonCleanup', () => { it('should delete people without faces', async () => { personMock.getAllWithoutFaces.mockResolvedValue([personStub.noName]); @@ -550,7 +580,10 @@ describe(PersonService.name, () => { await sut.handleQueueRecognizeFaces({}); - expect(personMock.getAllFaces).toHaveBeenCalledWith({ skip: 0, take: 1000 }, { where: { personId: IsNull() } }); + expect(personMock.getAllFaces).toHaveBeenCalledWith( + { skip: 0, take: 1000 }, + { where: { personId: IsNull(), isEdited: false } }, + ); expect(jobMock.queueAll).toHaveBeenCalledWith([ { name: JobName.FACIAL_RECOGNITION, diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index c8a16f71c7..eed488e407 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -101,6 +101,22 @@ export class PersonService { }; } + async unassignFace(auth: AuthDto, id: string): Promise { + let face = await this.repository.getFaceById(id); + await this.access.requirePermission(auth, Permission.PERSON_CREATE, face.id); + + if (face.personId) { + await this.access.requirePermission(auth, Permission.PERSON_WRITE, face.personId); + } + + await this.repository.reassignFace(face.id, null); + if (face.person && face.person.faceAssetId === face.id) { + await this.createNewFeaturePhoto([face.person.id]); + } + face = await this.repository.getFaceById(id); + return mapFaces(face, auth); + } + async reassignFaces(auth: AuthDto, personId: string, dto: AssetFaceUpdateDto): Promise { await this.access.requirePermission(auth, Permission.PERSON_WRITE, personId); const person = await this.findOrFail(personId); @@ -130,6 +146,34 @@ export class PersonService { return result; } + async unassignFaces(auth: AuthDto, dto: AssetFaceUpdateDto): Promise { + const changeFeaturePhoto: string[] = []; + const results: BulkIdResponseDto[] = []; + + for (const data of dto.data) { + const faces = await this.repository.getFacesByIds([{ personId: data.personId, assetId: data.assetId }]); + + for (const face of faces) { + await this.access.requirePermission(auth, Permission.PERSON_CREATE, face.id); + if (face.personId) { + await this.access.requirePermission(auth, Permission.PERSON_WRITE, face.personId); + } + + await this.repository.reassignFace(face.id, null); + if (face.person && face.person.faceAssetId === face.id) { + changeFeaturePhoto.push(face.person.id); + } + results.push({ id: face.id, success: true }); + } + } + if (changeFeaturePhoto.length > 0) { + // Remove duplicates + await this.createNewFeaturePhoto([...changeFeaturePhoto]); + } + + return results; + } + async reassignFacesById(auth: AuthDto, personId: string, dto: FaceDto): Promise { await this.access.requirePermission(auth, Permission.PERSON_WRITE, personId); @@ -386,7 +430,7 @@ export class PersonService { } const facePagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => - this.repository.getAllFaces(pagination, { where: force ? undefined : { personId: IsNull() } }), + this.repository.getAllFaces(pagination, { where: force ? undefined : { personId: IsNull(), isEdited: false } }), ); for await (const page of facePagination) { diff --git a/server/test/fixtures/face.stub.ts b/server/test/fixtures/face.stub.ts index 2d2acec40d..70f5576c9a 100644 --- a/server/test/fixtures/face.stub.ts +++ b/server/test/fixtures/face.stub.ts @@ -18,6 +18,7 @@ export const faceStub = { boundingBoxY2: 1, imageHeight: 1024, imageWidth: 1024, + isEdited: false, }), primaryFace1: Object.freeze>({ id: 'assetFaceId2', @@ -32,6 +33,7 @@ export const faceStub = { boundingBoxY2: 1, imageHeight: 1024, imageWidth: 1024, + isEdited: false, }), mergeFace1: Object.freeze>({ id: 'assetFaceId3', @@ -46,6 +48,7 @@ export const faceStub = { boundingBoxY2: 1, imageHeight: 1024, imageWidth: 1024, + isEdited: false, }), mergeFace2: Object.freeze>({ id: 'assetFaceId4', @@ -60,6 +63,7 @@ export const faceStub = { boundingBoxY2: 1, imageHeight: 1024, imageWidth: 1024, + isEdited: false, }), start: Object.freeze>({ id: 'assetFaceId5', @@ -74,6 +78,7 @@ export const faceStub = { boundingBoxY2: 505, imageHeight: 1000, imageWidth: 1000, + isEdited: false, }), middle: Object.freeze>({ id: 'assetFaceId6', @@ -88,6 +93,7 @@ export const faceStub = { boundingBoxY2: 200, imageHeight: 500, imageWidth: 400, + isEdited: false, }), end: Object.freeze>({ id: 'assetFaceId7', @@ -102,6 +108,7 @@ export const faceStub = { boundingBoxY2: 495, imageHeight: 500, imageWidth: 500, + isEdited: false, }), noPerson1: Object.freeze({ id: 'assetFaceId8', @@ -116,6 +123,7 @@ export const faceStub = { boundingBoxY2: 1, imageHeight: 1024, imageWidth: 1024, + isEdited: false, }), noPerson2: Object.freeze({ id: 'assetFaceId9', @@ -130,5 +138,21 @@ export const faceStub = { boundingBoxY2: 1, imageHeight: 1024, imageWidth: 1024, + isEdited: false, + }), + unassignedFace: Object.freeze({ + id: 'assetFaceId', + assetId: assetStub.image.id, + asset: assetStub.image, + personId: null, + person: null, + embedding: [1, 2, 3, 4], + boundingBoxX1: 0, + boundingBoxY1: 0, + boundingBoxX2: 1, + boundingBoxY2: 1, + imageHeight: 1024, + imageWidth: 1024, + isEdited: false, }), }; diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index 94f39a6978..cb16f07e11 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -73,7 +73,7 @@ const assetResponse: AssetResponseDto = { exifInfo: assetInfo, livePhotoVideoId: null, tags: [], - people: [], + people: undefined, checksum: 'ZmlsZSBoYXNo', isTrashed: false, libraryId: 'library-id', diff --git a/web/src/lib/components/asset-viewer/detail-panel.svelte b/web/src/lib/components/asset-viewer/detail-panel.svelte index 5c466208dd..f850c70c55 100644 --- a/web/src/lib/components/asset-viewer/detail-panel.svelte +++ b/web/src/lib/components/asset-viewer/detail-panel.svelte @@ -72,7 +72,7 @@ // Get latest description from server if (newAsset.id && !isSharedLink()) { const data = await getAssetInfo({ id: asset.id }); - people = data?.people || []; + people = data?.people || undefined; description = data.exifInfo?.description || ''; } @@ -90,7 +90,7 @@ } })(); - $: people = asset.people || []; + $: people = asset?.people || undefined; $: showingHiddenPeople = false; onMount(() => { @@ -118,7 +118,7 @@ const handleRefreshPeople = async () => { await getAssetInfo({ id: asset.id }).then((data) => { - people = data?.people || []; + people = data?.people || undefined; textArea.value = data?.exifInfo?.description || ''; }); showEditFaces = false; @@ -213,12 +213,12 @@

{description}

{/if} - {#if !isSharedLink() && people.length > 0} + {#if !isSharedLink() && people?.numberOfFaces && people?.numberOfFaces > 0}

PEOPLE

- {#if people.some((person) => person.isHidden)} + {#if people.faces.some((person) => person.isHidden)}
- {#each people as person, index (person.id)} + {#each people.faces as person (person.id)} {#if showingHiddenPeople || !person.isHidden} ($boundingBoxesArray = people[index].faces)} + on:focus={() => ($boundingBoxesArray = person.faces)} on:blur={() => ($boundingBoxesArray = [])} - on:mouseover={() => ($boundingBoxesArray = people[index].faces)} + on:mouseover={() => ($boundingBoxesArray = person.faces)} on:mouseleave={() => ($boundingBoxesArray = [])} >
@@ -614,9 +614,9 @@ { + onClose={() => { showEditFaces = false; }} - on:refresh={handleRefreshPeople} + onRefresh={handleRefreshPeople} /> {/if} diff --git a/web/src/lib/components/elements/buttons/circle-icon-button.svelte b/web/src/lib/components/elements/buttons/circle-icon-button.svelte index 765b765ab9..5ff1abe1dc 100644 --- a/web/src/lib/components/elements/buttons/circle-icon-button.svelte +++ b/web/src/lib/components/elements/buttons/circle-icon-button.svelte @@ -1,6 +1,6 @@ @@ -157,33 +94,42 @@ {/if}
-

All people

-
- {#each showPeople as person (person.id)} - {#if person.id !== editedPerson.id} -
- -
- {/if} - {/each} -
+

+ {person.name} +

+ +
+ {/if} + {/each} +
+ {:else} +
+
+ +

No faces found

+
+
+ {/if}
diff --git a/web/src/lib/components/faces-page/person-side-panel.svelte b/web/src/lib/components/faces-page/person-side-panel.svelte index 9e2148d93c..ac6dbf5cc3 100644 --- a/web/src/lib/components/faces-page/person-side-panel.svelte +++ b/web/src/lib/components/faces-page/person-side-panel.svelte @@ -5,38 +5,51 @@ import { websocketEvents } from '$lib/stores/websocket'; import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils'; import { handleError } from '$lib/utils/handle-error'; - import { getPersonNameWithHiddenValue } from '$lib/utils/person'; + import { getPersonNameWithHiddenValue, zoomImageToBase64 } from '$lib/utils/person'; import { AssetTypeEnum, createPerson, getAllPeople, getFaces, reassignFacesById, + unassignFace, type AssetFaceResponseDto, type PersonResponseDto, } from '@immich/sdk'; - import { mdiArrowLeftThin, mdiMinus, mdiRestart } from '@mdi/js'; - import { createEventDispatcher, onMount } from 'svelte'; + import { mdiAccountOff, mdiArrowLeftThin, mdiClose, mdiFaceMan, mdiMinus, mdiRestart } from '@mdi/js'; + import { onMount } from 'svelte'; import { linear } from 'svelte/easing'; import { fly } from 'svelte/transition'; - import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte'; - import { NotificationType, notificationController } from '../shared-components/notification/notification'; - import AssignFaceSidePanel from './assign-face-side-panel.svelte'; import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; + import type { FaceWithGeneretedThumbnail } from '$lib/utils/people-utils'; + import { + NotificationType, + notificationController, + } from '$lib/components/shared-components/notification/notification'; + import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte'; + import AssignFaceSidePanel from '$lib/components/faces-page/assign-face-side-panel.svelte'; + import UnassignedFacesSidePanel from '$lib/components/faces-page/unassigned-faces-side-panel.svelte'; + import Icon from '$lib/components/elements/icon.svelte'; export let assetId: string; export let assetType: AssetTypeEnum; + export let onClose = () => {}; + export let onRefresh = () => {}; // keep track of the changes let peopleToCreate: string[] = []; let assetFaceGenerated: string[] = []; // faces + let allFaces: AssetFaceResponseDto[] = []; let peopleWithFaces: AssetFaceResponseDto[] = []; let selectedPersonToReassign: Record = {}; let selectedPersonToCreate: Record = {}; + let selectedPersonToAdd: Record = {}; + let selectedFaceToRemove: Record = {}; let editedPerson: PersonResponseDto; let editedFace: AssetFaceResponseDto; + let unassignedFaces: FaceWithGeneretedThumbnail[] = []; // loading spinners let isShowLoadingDone = false; @@ -44,6 +57,7 @@ // search people let showSelectedFaces = false; + let showUnassignedFaces = false; let allPeople: PersonResponseDto[] = []; // timers @@ -52,17 +66,26 @@ const thumbnailWidth = '90px'; - const dispatch = createEventDispatcher<{ - close: void; - refresh: void; - }>(); + const generatePeopleWithoutFaces = async () => { + const peopleWithGeneratedImage = await Promise.all( + allFaces.map(async (personWithFace) => { + if (personWithFace.person === null) { + const image = await zoomImageToBase64(personWithFace, assetType, assetId); + return { ...personWithFace, customThumbnail: image }; + } + }), + ); + unassignedFaces = peopleWithGeneratedImage.filter((item): item is FaceWithGeneretedThumbnail => item !== undefined); + }; async function loadPeople() { const timeout = setTimeout(() => (isShowLoadingPeople = true), timeBeforeShowLoadingSpinner); try { const { people } = await getAllPeople({ withHidden: true }); allPeople = people; - peopleWithFaces = await getFaces({ id: assetId }); + allFaces = await getFaces({ id: assetId }); + peopleWithFaces = allFaces.filter((face) => face.person); + await generatePeopleWithoutFaces(); } catch (error) { handleError(error, "Can't get faces"); } finally { @@ -73,15 +96,10 @@ const onPersonThumbnail = (personId: string) => { assetFaceGenerated.push(personId); - if ( - isEqual(assetFaceGenerated, peopleToCreate) && - loaderLoadingDoneTimeout && - automaticRefreshTimeout && - Object.keys(selectedPersonToCreate).length === peopleToCreate.length - ) { + if (isEqual(assetFaceGenerated, peopleToCreate) && loaderLoadingDoneTimeout && automaticRefreshTimeout) { clearTimeout(loaderLoadingDoneTimeout); clearTimeout(automaticRefreshTimeout); - dispatch('refresh'); + onRefresh(); } }; @@ -94,10 +112,6 @@ return b.every((valueB) => a.includes(valueB)); }; - const handleBackButton = () => { - dispatch('close'); - }; - const handleReset = (id: string) => { if (selectedPersonToReassign[id]) { delete selectedPersonToReassign[id]; @@ -113,9 +127,22 @@ } }; + const handleRemoveFace = (face: AssetFaceResponseDto) => { + selectedFaceToRemove[face.id] = face; + }; + + const handleAbortRemove = (id: string) => { + delete selectedFaceToRemove[id]; + selectedFaceToRemove = selectedFaceToRemove; + }; + const handleEditFaces = async () => { loaderLoadingDoneTimeout = setTimeout(() => (isShowLoadingDone = true), timeBeforeShowLoadingSpinner); - const numberOfChanges = Object.keys(selectedPersonToCreate).length + Object.keys(selectedPersonToReassign).length; + const numberOfChanges = + Object.keys(selectedPersonToCreate).length + + Object.keys(selectedPersonToReassign).length + + Object.keys(selectedFaceToRemove).length + + Object.keys(selectedPersonToAdd).length; if (numberOfChanges > 0) { try { @@ -137,6 +164,28 @@ } } + for (const [id, face] of Object.entries(selectedPersonToAdd)) { + if (face.person) { + await reassignFacesById({ + id: face.person.id, + faceDto: { id }, + }); + } else { + const data = await createPerson({ personCreateDto: {} }); + peopleToCreate.push(data.id); + await reassignFacesById({ + id: data.id, + faceDto: { id }, + }); + } + } + + for (const [id, _] of Object.entries(selectedFaceToRemove)) { + await unassignFace({ + id, + }); + } + notificationController.show({ message: `Edited ${numberOfChanges} ${numberOfChanges > 1 ? 'people' : 'person'}`, type: NotificationType.Info, @@ -149,9 +198,9 @@ isShowLoadingDone = false; if (peopleToCreate.length === 0) { clearTimeout(loaderLoadingDoneTimeout); - dispatch('refresh'); + onRefresh(); } else { - automaticRefreshTimeout = setTimeout(() => dispatch('refresh'), 15_000); + automaticRefreshTimeout = setTimeout(() => onRefresh(), 15_000); } }; @@ -176,6 +225,27 @@ showSelectedFaces = true; } }; + const handleCreateOrReassignFaceFromUnassignedFace = (face: FaceWithGeneretedThumbnail) => { + selectedPersonToAdd[face.id] = face; + selectedPersonToAdd = selectedPersonToAdd; + showUnassignedFaces = false; + }; + + const handleOpenAvailableFaces = () => { + showUnassignedFaces = !showUnassignedFaces; + }; + const handleRemoveAddedFace = (face: FaceWithGeneretedThumbnail) => { + $boundingBoxesArray = []; + delete selectedPersonToAdd[face.id]; + + // trigger reactivity + selectedPersonToAdd = selectedPersonToAdd; + }; + const handleRemoveAllFaces = () => { + for (const face of peopleWithFaces) { + selectedFaceToRemove[face.id] = face; + } + };
- +

Edit faces

{#if !isShowLoadingDone} - +
+ {#if peopleWithFaces.length > Object.keys(selectedFaceToRemove).length} + + {/if} + {#if (unassignedFaces.length > 0 && unassignedFaces.length > Object.keys(selectedPersonToAdd).length) || Object.keys(selectedFaceToRemove).length > 0} + + {/if} + +
{:else} {/if}
- -
-
- {#if isShowLoadingPeople} -
- -
- {:else} - {#each peopleWithFaces as face, index} - {#if face.person} -
-
($boundingBoxesArray = [peopleWithFaces[index]])} - on:mouseover={() => ($boundingBoxesArray = [peopleWithFaces[index]])} - on:mouseleave={() => ($boundingBoxesArray = [])} - > -
- {#if selectedPersonToCreate[face.id]} - - {:else if selectedPersonToReassign[face.id]} -
- - {#if !selectedPersonToCreate[face.id]} -

- {#if selectedPersonToReassign[face.id]?.id} - {selectedPersonToReassign[face.id]?.name} + {#if peopleWithFaces.length > 0} +

+
+ {#if isShowLoadingPeople} +
+ +
+ {:else} + {#each peopleWithFaces as face, index} + {#if face.person && !selectedFaceToRemove[face.id]} +
+
($boundingBoxesArray = [peopleWithFaces[index]])} + on:mouseover={() => ($boundingBoxesArray = [peopleWithFaces[index]])} + on:mouseleave={() => ($boundingBoxesArray = [])} + > +
+ {#if selectedPersonToCreate[face.id]} + + {:else if selectedPersonToReassign[face.id]} +
-
- {#if selectedPersonToCreate[face.id] || selectedPersonToReassign[face.id]} - handleReset(face.id)} - /> - {:else} - handleFacePicker(face)} - /> + {#if !selectedPersonToCreate[face.id]} +

+ {#if selectedPersonToReassign[face.id]?.id} + {selectedPersonToReassign[face.id]?.name} + {:else} + {face.person?.name} + {/if} +

{/if} + {#if !selectedPersonToCreate[face.id] && !selectedPersonToReassign[face.id]} +
+ handleRemoveFace(face)} + /> +
+ {/if} +
+ {#if selectedPersonToCreate[face.id] || selectedPersonToReassign[face.id]} + handleReset(face.id)} + /> + {:else} + handleFacePicker(face)} + /> + {/if} +
-
- {/if} - {/each} - {/if} + {/if} + {/each} + {/if} +
+ {:else} +
+
+ +

No visible faces

+
+
+ {/if} +
+ {#if Object.keys(selectedPersonToAdd).length > 0} +
+

Faces to add

+
+ {#each Object.entries(selectedPersonToAdd) as [_, face], index} + {#if face} +
+
($boundingBoxesArray = [face])} + on:mouseover={() => ($boundingBoxesArray = [face])} + on:mouseleave={() => ($boundingBoxesArray = [])} + > +
+ +
+ {#if face.person?.name} +

+ {face.person?.name} +

{/if} + +
+ handleRemoveAddedFace(face)} + /> +
+
+
+ {/if} + {/each} +
+
+ {/if}
{#if showSelectedFaces} (showSelectedFaces = false)} - on:createPerson={(event) => handleCreatePerson(event.detail)} - on:reassign={(event) => handleReassignFace(event.detail)} + onClose={() => (showSelectedFaces = false)} + onCreatePerson={handleCreatePerson} + onReassign={handleReassignFace} + /> +{/if} + +{#if showUnassignedFaces} + (selectedFaceToRemove = selectedFaceToRemove)} + onClose={() => (showUnassignedFaces = false)} + onCreatePerson={handleCreateOrReassignFaceFromUnassignedFace} + onReassign={handleCreateOrReassignFaceFromUnassignedFace} + onAbortRemove={handleAbortRemove} /> {/if} diff --git a/web/src/lib/components/faces-page/unassigned-faces-side-panel.svelte b/web/src/lib/components/faces-page/unassigned-faces-side-panel.svelte new file mode 100644 index 0000000000..8613be61fe --- /dev/null +++ b/web/src/lib/components/faces-page/unassigned-faces-side-panel.svelte @@ -0,0 +1,204 @@ + + +
+
+
+ +

Faces available

+
+
+ {#if unassignedFaces.length > 0 && unassignedFaces.length > Object.keys(selectedPersonToAdd).length} +
+

Faces removed

+
+ {#each unassignedFaces as face, index (face.id)} + {#if !selectedPersonToAdd[face.id]} +
+ +
+ {/if} + {/each} +
+
+ {:else} +
+
+ +

No faces removed

+
+
+ {/if} + {#if Object.keys(selectedFaceToRemove).length > 0} +
+
+

Faces to be removed

+ +
+
+ {#each Object.entries(selectedFaceToRemove) as [id, face], index} +
+ +
+ {/each} +
+
+ {/if} +
+ +{#if showSeletecFaces} + (showSeletecFaces = false)} + onCreatePerson={handleCreatePerson} + onReassign={handleReassignFace} + /> +{/if} diff --git a/web/src/lib/components/faces-page/unmerge-face-selector.svelte b/web/src/lib/components/faces-page/unmerge-face-selector.svelte index 2343df68c9..dbb8cf5db3 100644 --- a/web/src/lib/components/faces-page/unmerge-face-selector.svelte +++ b/web/src/lib/components/faces-page/unmerge-face-selector.svelte @@ -6,10 +6,11 @@ createPerson, getAllPeople, reassignFaces, + unassignFaces, type AssetFaceUpdateItem, type PersonResponseDto, } from '@immich/sdk'; - import { mdiMerge, mdiPlus } from '@mdi/js'; + import { mdiMerge, mdiPlus, mdiTagRemove } from '@mdi/js'; import { createEventDispatcher, onMount } from 'svelte'; import { quintOut } from 'svelte/easing'; import { fly } from 'svelte/transition'; @@ -28,6 +29,7 @@ let disableButtons = false; let showLoadingSpinnerCreate = false; let showLoadingSpinnerReassign = false; + let showLoadingSpinnerUnassign = false; let hasSelection = false; let screenHeight: number; @@ -111,6 +113,24 @@ showLoadingSpinnerReassign = false; dispatch('confirm'); }; + + const handleUnassign = async () => { + const timeout = setTimeout(() => (showLoadingSpinnerUnassign = true), 100); + try { + disableButtons = true; + await unassignFaces({ assetFaceUpdateDto: { data: selectedPeople } }); + notificationController.show({ + message: `Un-assigned ${assetIds.length} asset${assetIds.length > 1 ? 's' : ''}`, + type: NotificationType.Info, + }); + } catch (error) { + handleError(error, 'Unable to unassign assets'); + } finally { + clearTimeout(timeout); + } + showLoadingSpinnerCreate = false; + dispatch('confirm'); + }; @@ -126,6 +146,19 @@
+