diff --git a/e2e/src/specs/server/api/asset.e2e-spec.ts b/e2e/src/specs/server/api/asset.e2e-spec.ts index ccebd1ca77..0a71cc1ba2 100644 --- a/e2e/src/specs/server/api/asset.e2e-spec.ts +++ b/e2e/src/specs/server/api/asset.e2e-spec.ts @@ -130,7 +130,7 @@ describe('/asset', () => { }); await utils.createFace({ assetId: user1Assets[0].id, - personId: person1.id, + faceClusterId: person1.faceClusterId, }); }; beforeAll(setupTests, 30_000); diff --git a/e2e/src/specs/server/api/person.e2e-spec.ts b/e2e/src/specs/server/api/person.e2e-spec.ts index 20290cd941..e2526ba051 100644 --- a/e2e/src/specs/server/api/person.e2e-spec.ts +++ b/e2e/src/specs/server/api/person.e2e-spec.ts @@ -82,32 +82,32 @@ describe('/people', () => { const asset4 = await utils.createAsset(admin.accessToken); await Promise.all([ - utils.createFace({ assetId: asset1.id, personId: visiblePerson.id }), - utils.createFace({ assetId: asset1.id, personId: hiddenPerson.id }), - utils.createFace({ assetId: asset1.id, personId: multipleAssetsPerson.id }), - utils.createFace({ assetId: asset1.id, personId: multipleAssetsPerson.id }), - utils.createFace({ assetId: asset2.id, personId: multipleAssetsPerson.id }), - utils.createFace({ assetId: asset3.id, personId: multipleAssetsPerson.id }), // 4 assets + utils.createFace({ assetId: asset1.id, faceClusterId: visiblePerson.faceClusterId }), + utils.createFace({ assetId: asset1.id, faceClusterId: hiddenPerson.faceClusterId }), + utils.createFace({ assetId: asset1.id, faceClusterId: multipleAssetsPerson.faceClusterId }), + utils.createFace({ assetId: asset1.id, faceClusterId: multipleAssetsPerson.faceClusterId }), + utils.createFace({ assetId: asset2.id, faceClusterId: multipleAssetsPerson.faceClusterId }), + utils.createFace({ assetId: asset3.id, faceClusterId: multipleAssetsPerson.faceClusterId }), // 4 assets // Named persons - utils.createFace({ assetId: asset1.id, personId: nameCharliePerson.id }), // 1 asset - utils.createFace({ assetId: asset1.id, personId: nameBobPerson.id }), - utils.createFace({ assetId: asset2.id, personId: nameBobPerson.id }), // 2 assets - utils.createFace({ assetId: asset1.id, personId: nameAlicePerson.id }), // 1 asset + utils.createFace({ assetId: asset1.id, faceClusterId: nameCharliePerson.faceClusterId }), // 1 asset + utils.createFace({ assetId: asset1.id, faceClusterId: nameBobPerson.faceClusterId }), + utils.createFace({ assetId: asset2.id, faceClusterId: nameBobPerson.faceClusterId }), // 2 assets + utils.createFace({ assetId: asset1.id, faceClusterId: nameAlicePerson.faceClusterId }), // 1 asset // Null-named person 4 assets - utils.createFace({ assetId: asset1.id, personId: nameNullPerson4Assets.id }), - utils.createFace({ assetId: asset2.id, personId: nameNullPerson4Assets.id }), - utils.createFace({ assetId: asset3.id, personId: nameNullPerson4Assets.id }), - utils.createFace({ assetId: asset4.id, personId: nameNullPerson4Assets.id }), // 4 assets + utils.createFace({ assetId: asset1.id, faceClusterId: nameNullPerson4Assets.faceClusterId }), + utils.createFace({ assetId: asset2.id, faceClusterId: nameNullPerson4Assets.faceClusterId }), + utils.createFace({ assetId: asset3.id, faceClusterId: nameNullPerson4Assets.faceClusterId }), + utils.createFace({ assetId: asset4.id, faceClusterId: nameNullPerson4Assets.faceClusterId }), // 4 assets // Null-named person 3 assets - utils.createFace({ assetId: asset1.id, personId: nameNullPerson3Assets.id }), - utils.createFace({ assetId: asset2.id, personId: nameNullPerson3Assets.id }), - utils.createFace({ assetId: asset3.id, personId: nameNullPerson3Assets.id }), // 3 assets + utils.createFace({ assetId: asset1.id, faceClusterId: nameNullPerson3Assets.faceClusterId }), + utils.createFace({ assetId: asset2.id, faceClusterId: nameNullPerson3Assets.faceClusterId }), + utils.createFace({ assetId: asset3.id, faceClusterId: nameNullPerson3Assets.faceClusterId }), // 3 assets // Null-named person 1 asset - utils.createFace({ assetId: asset3.id, personId: nameNullPerson1Asset.id }), + utils.createFace({ assetId: asset3.id, faceClusterId: nameNullPerson1Asset.faceClusterId }), // Favourite People - utils.createFace({ assetId: asset1.id, personId: nameFreddyPersonFavourite.id }), - utils.createFace({ assetId: asset2.id, personId: nameFreddyPersonFavourite.id }), - utils.createFace({ assetId: asset1.id, personId: nameBillPersonFavourite.id }), + utils.createFace({ assetId: asset1.id, faceClusterId: nameFreddyPersonFavourite.faceClusterId }), + utils.createFace({ assetId: asset2.id, faceClusterId: nameFreddyPersonFavourite.faceClusterId }), + utils.createFace({ assetId: asset1.id, faceClusterId: nameBillPersonFavourite.faceClusterId }), ]); }); @@ -296,24 +296,6 @@ describe('/people', () => { expect(body).toMatchObject({ birthDate: null }); }); - it('should set a color', async () => { - const { status, body } = await request(app) - .put(`/people/${visiblePerson.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ color: '#555' }); - expect(status).toBe(200); - expect(body).toMatchObject({ color: '#555' }); - }); - - it('should clear a color', async () => { - const { status, body } = await request(app) - .put(`/people/${visiblePerson.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ color: null }); - expect(status).toBe(200); - expect(body.color).toBeUndefined(); - }); - it('should mark a person as favorite', async () => { const person = await utils.createPerson(admin.accessToken, { name: 'visible_person', diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index 3124dd0609..0b8e0e0aa4 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -434,12 +434,12 @@ export const utils = { return person; }, - createFace: async ({ assetId, personId }: { assetId: string; personId: string }) => { + createFace: async ({ assetId, faceClusterId }: { assetId: string; faceClusterId: string }) => { if (!client) { return; } - await client.query('INSERT INTO asset_face ("assetId", "personId") VALUES ($1, $2)', [assetId, personId]); + await client.query('INSERT INTO asset_face ("assetId", "faceClusterId") VALUES ($1, $2)', [assetId, faceClusterId]); }, setPersonThumbnail: async (personId: string) => { diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 2f5b51e071..3f8f914d76 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -618,6 +618,8 @@ Class | Method | HTTP request | Description - [SyncAssetV2](doc//SyncAssetV2.md) - [SyncAuthUserV1](doc//SyncAuthUserV1.md) - [SyncEntityType](doc//SyncEntityType.md) + - [SyncFaceClusterDeleteV1Schema](doc//SyncFaceClusterDeleteV1Schema.md) + - [SyncFaceClusterV1](doc//SyncFaceClusterV1.md) - [SyncMemoryAssetDeleteV1](doc//SyncMemoryAssetDeleteV1.md) - [SyncMemoryAssetV1](doc//SyncMemoryAssetV1.md) - [SyncMemoryDeleteV1](doc//SyncMemoryDeleteV1.md) @@ -626,6 +628,7 @@ Class | Method | HTTP request | Description - [SyncPartnerV1](doc//SyncPartnerV1.md) - [SyncPersonDeleteV1](doc//SyncPersonDeleteV1.md) - [SyncPersonV1](doc//SyncPersonV1.md) + - [SyncPersonV2](doc//SyncPersonV2.md) - [SyncRequestType](doc//SyncRequestType.md) - [SyncStackDeleteV1](doc//SyncStackDeleteV1.md) - [SyncStackV1](doc//SyncStackV1.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 47425b56fe..10bab8452f 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -339,6 +339,8 @@ part 'model/sync_asset_v1.dart'; part 'model/sync_asset_v2.dart'; part 'model/sync_auth_user_v1.dart'; part 'model/sync_entity_type.dart'; +part 'model/sync_face_cluster_delete_v1_schema.dart'; +part 'model/sync_face_cluster_v1.dart'; part 'model/sync_memory_asset_delete_v1.dart'; part 'model/sync_memory_asset_v1.dart'; part 'model/sync_memory_delete_v1.dart'; @@ -347,6 +349,7 @@ part 'model/sync_partner_delete_v1.dart'; part 'model/sync_partner_v1.dart'; part 'model/sync_person_delete_v1.dart'; part 'model/sync_person_v1.dart'; +part 'model/sync_person_v2.dart'; part 'model/sync_request_type.dart'; part 'model/sync_stack_delete_v1.dart'; part 'model/sync_stack_v1.dart'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 4c6c1b5c72..a3a01f71c7 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -723,6 +723,10 @@ class ApiClient { return SyncAuthUserV1.fromJson(value); case 'SyncEntityType': return SyncEntityTypeTypeTransformer().decode(value); + case 'SyncFaceClusterDeleteV1Schema': + return SyncFaceClusterDeleteV1Schema.fromJson(value); + case 'SyncFaceClusterV1': + return SyncFaceClusterV1.fromJson(value); case 'SyncMemoryAssetDeleteV1': return SyncMemoryAssetDeleteV1.fromJson(value); case 'SyncMemoryAssetV1': @@ -739,6 +743,8 @@ class ApiClient { return SyncPersonDeleteV1.fromJson(value); case 'SyncPersonV1': return SyncPersonV1.fromJson(value); + case 'SyncPersonV2': + return SyncPersonV2.fromJson(value); case 'SyncRequestType': return SyncRequestTypeTypeTransformer().decode(value); case 'SyncStackDeleteV1': diff --git a/mobile/openapi/lib/model/person_response_dto.dart b/mobile/openapi/lib/model/person_response_dto.dart index a99f465236..e143898101 100644 --- a/mobile/openapi/lib/model/person_response_dto.dart +++ b/mobile/openapi/lib/model/person_response_dto.dart @@ -15,6 +15,7 @@ class PersonResponseDto { PersonResponseDto({ required this.birthDate, this.color = const Optional.absent(), + required this.faceClusterId, required this.id, this.isFavorite = const Optional.absent(), required this.isHidden, @@ -35,6 +36,9 @@ class PersonResponseDto { /// Optional color; + /// Face cluster ID + String faceClusterId; + /// Person ID String id; @@ -69,6 +73,7 @@ class PersonResponseDto { bool operator ==(Object other) => identical(this, other) || other is PersonResponseDto && other.birthDate == birthDate && other.color == color && + other.faceClusterId == faceClusterId && other.id == id && other.isFavorite == isFavorite && other.isHidden == isHidden && @@ -81,6 +86,7 @@ class PersonResponseDto { // ignore: unnecessary_parenthesis (birthDate == null ? 0 : birthDate!.hashCode) + (color == null ? 0 : color!.hashCode) + + (faceClusterId.hashCode) + (id.hashCode) + (isFavorite == null ? 0 : isFavorite!.hashCode) + (isHidden.hashCode) + @@ -89,7 +95,7 @@ class PersonResponseDto { (updatedAt == null ? 0 : updatedAt!.hashCode); @override - String toString() => 'PersonResponseDto[birthDate=$birthDate, color=$color, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]'; + String toString() => 'PersonResponseDto[birthDate=$birthDate, color=$color, faceClusterId=$faceClusterId, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]'; Map toJson() { final json = {}; @@ -102,6 +108,7 @@ class PersonResponseDto { final value = this.color.value; json[r'color'] = value; } + json[r'faceClusterId'] = this.faceClusterId; json[r'id'] = this.id; if (this.isFavorite.isPresent) { final value = this.isFavorite.value; @@ -128,6 +135,7 @@ class PersonResponseDto { return PersonResponseDto( birthDate: mapDateTime(json, r'birthDate', r''), color: json.containsKey(r'color') ? Optional.present(mapValueOfType(json, r'color')) : const Optional.absent(), + faceClusterId: mapValueOfType(json, r'faceClusterId')!, id: mapValueOfType(json, r'id')!, isFavorite: json.containsKey(r'isFavorite') ? Optional.present(mapValueOfType(json, r'isFavorite')) : const Optional.absent(), isHidden: mapValueOfType(json, r'isHidden')!, @@ -182,6 +190,7 @@ class PersonResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { 'birthDate', + 'faceClusterId', 'id', 'isHidden', 'name', diff --git a/mobile/openapi/lib/model/sync_entity_type.dart b/mobile/openapi/lib/model/sync_entity_type.dart index 7a6a518a9f..56b34b896a 100644 --- a/mobile/openapi/lib/model/sync_entity_type.dart +++ b/mobile/openapi/lib/model/sync_entity_type.dart @@ -62,7 +62,10 @@ enum SyncEntityType { stackV1._(r'StackV1'), stackDeleteV1._(r'StackDeleteV1'), personV1._(r'PersonV1'), + personV2._(r'PersonV2'), personDeleteV1._(r'PersonDeleteV1'), + faceClusterV1._(r'FaceClusterV1'), + faceClusterDeleteV1._(r'FaceClusterDeleteV1'), assetFaceV1._(r'AssetFaceV1'), assetFaceV2._(r'AssetFaceV2'), assetFaceDeleteV1._(r'AssetFaceDeleteV1'), @@ -180,7 +183,10 @@ class SyncEntityTypeTypeTransformer { case r'StackV1': return SyncEntityType.stackV1; case r'StackDeleteV1': return SyncEntityType.stackDeleteV1; case r'PersonV1': return SyncEntityType.personV1; + case r'PersonV2': return SyncEntityType.personV2; case r'PersonDeleteV1': return SyncEntityType.personDeleteV1; + case r'FaceClusterV1': return SyncEntityType.faceClusterV1; + case r'FaceClusterDeleteV1': return SyncEntityType.faceClusterDeleteV1; case r'AssetFaceV1': return SyncEntityType.assetFaceV1; case r'AssetFaceV2': return SyncEntityType.assetFaceV2; case r'AssetFaceDeleteV1': return SyncEntityType.assetFaceDeleteV1; diff --git a/mobile/openapi/lib/model/sync_face_cluster_delete_v1_schema.dart b/mobile/openapi/lib/model/sync_face_cluster_delete_v1_schema.dart new file mode 100644 index 0000000000..14154e2c08 --- /dev/null +++ b/mobile/openapi/lib/model/sync_face_cluster_delete_v1_schema.dart @@ -0,0 +1,100 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// 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 SyncFaceClusterDeleteV1Schema { + /// Returns a new [SyncFaceClusterDeleteV1Schema] instance. + SyncFaceClusterDeleteV1Schema({ + required this.faceClusterId, + }); + + /// Face cluster ID + String faceClusterId; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncFaceClusterDeleteV1Schema && + other.faceClusterId == faceClusterId; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (faceClusterId.hashCode); + + @override + String toString() => 'SyncFaceClusterDeleteV1Schema[faceClusterId=$faceClusterId]'; + + Map toJson() { + final json = {}; + json[r'faceClusterId'] = this.faceClusterId; + return json; + } + + /// Returns a new [SyncFaceClusterDeleteV1Schema] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncFaceClusterDeleteV1Schema? fromJson(dynamic value) { + upgradeDto(value, "SyncFaceClusterDeleteV1Schema"); + if (value is Map) { + final json = value.cast(); + + return SyncFaceClusterDeleteV1Schema( + faceClusterId: mapValueOfType(json, r'faceClusterId')!, + ); + } + 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 = SyncFaceClusterDeleteV1Schema.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 = SyncFaceClusterDeleteV1Schema.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncFaceClusterDeleteV1Schema-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] = SyncFaceClusterDeleteV1Schema.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'faceClusterId', + }; +} + diff --git a/mobile/openapi/lib/model/sync_face_cluster_v1.dart b/mobile/openapi/lib/model/sync_face_cluster_v1.dart new file mode 100644 index 0000000000..3f099d7a13 --- /dev/null +++ b/mobile/openapi/lib/model/sync_face_cluster_v1.dart @@ -0,0 +1,159 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// 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 SyncFaceClusterV1 { + /// Returns a new [SyncFaceClusterV1] instance. + SyncFaceClusterV1({ + required this.birthDate, + required this.createdAt, + required this.featureFaceAssetId, + required this.id, + required this.name, + required this.updatedAt, + }); + + /// Birth date + DateTime? birthDate; + + /// Created at + DateTime createdAt; + + /// Feature face asset ID + String? featureFaceAssetId; + + /// Face cluster ID + String id; + + /// Name + String name; + + /// Updated at + DateTime updatedAt; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncFaceClusterV1 && + other.birthDate == birthDate && + other.createdAt == createdAt && + other.featureFaceAssetId == featureFaceAssetId && + other.id == id && + other.name == name && + other.updatedAt == updatedAt; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (birthDate == null ? 0 : birthDate!.hashCode) + + (createdAt.hashCode) + + (featureFaceAssetId == null ? 0 : featureFaceAssetId!.hashCode) + + (id.hashCode) + + (name.hashCode) + + (updatedAt.hashCode); + + @override + String toString() => 'SyncFaceClusterV1[birthDate=$birthDate, createdAt=$createdAt, featureFaceAssetId=$featureFaceAssetId, id=$id, name=$name, updatedAt=$updatedAt]'; + + Map toJson() { + final json = {}; + if (this.birthDate != null) { + json[r'birthDate'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$/') + ? this.birthDate!.millisecondsSinceEpoch + : this.birthDate!.toUtc().toIso8601String(); + } else { + json[r'birthDate'] = null; + } + json[r'createdAt'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$/') + ? this.createdAt.millisecondsSinceEpoch + : this.createdAt.toUtc().toIso8601String(); + if (this.featureFaceAssetId != null) { + json[r'featureFaceAssetId'] = this.featureFaceAssetId; + } else { + json[r'featureFaceAssetId'] = null; + } + json[r'id'] = this.id; + json[r'name'] = this.name; + json[r'updatedAt'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$/') + ? this.updatedAt.millisecondsSinceEpoch + : this.updatedAt.toUtc().toIso8601String(); + return json; + } + + /// Returns a new [SyncFaceClusterV1] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncFaceClusterV1? fromJson(dynamic value) { + upgradeDto(value, "SyncFaceClusterV1"); + if (value is Map) { + final json = value.cast(); + + return SyncFaceClusterV1( + birthDate: mapDateTime(json, r'birthDate', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$/'), + createdAt: mapDateTime(json, r'createdAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$/')!, + featureFaceAssetId: mapValueOfType(json, r'featureFaceAssetId'), + id: mapValueOfType(json, r'id')!, + name: mapValueOfType(json, r'name')!, + updatedAt: mapDateTime(json, r'updatedAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$/')!, + ); + } + 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 = SyncFaceClusterV1.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 = SyncFaceClusterV1.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncFaceClusterV1-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] = SyncFaceClusterV1.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'birthDate', + 'createdAt', + 'featureFaceAssetId', + 'id', + 'name', + 'updatedAt', + }; +} + diff --git a/mobile/openapi/lib/model/sync_person_v2.dart b/mobile/openapi/lib/model/sync_person_v2.dart new file mode 100644 index 0000000000..d5125e9e9c --- /dev/null +++ b/mobile/openapi/lib/model/sync_person_v2.dart @@ -0,0 +1,158 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// 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 SyncPersonV2 { + /// Returns a new [SyncPersonV2] instance. + SyncPersonV2({ + required this.createdAt, + required this.faceClusterId, + required this.id, + required this.isFavorite, + required this.isHidden, + required this.ownerId, + required this.updatedAt, + }); + + /// Created at + DateTime createdAt; + + /// Face cluster ID + String faceClusterId; + + /// Person ID + String id; + + /// Is favorite + bool isFavorite; + + /// Is hidden + bool isHidden; + + /// Owner ID + String ownerId; + + /// Updated at + DateTime updatedAt; + + @override + bool operator ==(Object other) => identical(this, other) || other is SyncPersonV2 && + other.createdAt == createdAt && + other.faceClusterId == faceClusterId && + other.id == id && + other.isFavorite == isFavorite && + other.isHidden == isHidden && + other.ownerId == ownerId && + other.updatedAt == updatedAt; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (createdAt.hashCode) + + (faceClusterId.hashCode) + + (id.hashCode) + + (isFavorite.hashCode) + + (isHidden.hashCode) + + (ownerId.hashCode) + + (updatedAt.hashCode); + + @override + String toString() => 'SyncPersonV2[createdAt=$createdAt, faceClusterId=$faceClusterId, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, ownerId=$ownerId, updatedAt=$updatedAt]'; + + Map toJson() { + final json = {}; + json[r'createdAt'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$/') + ? this.createdAt.millisecondsSinceEpoch + : this.createdAt.toUtc().toIso8601String(); + json[r'faceClusterId'] = this.faceClusterId; + json[r'id'] = this.id; + json[r'isFavorite'] = this.isFavorite; + json[r'isHidden'] = this.isHidden; + json[r'ownerId'] = this.ownerId; + json[r'updatedAt'] = _isEpochMarker(r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$/') + ? this.updatedAt.millisecondsSinceEpoch + : this.updatedAt.toUtc().toIso8601String(); + return json; + } + + /// Returns a new [SyncPersonV2] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SyncPersonV2? fromJson(dynamic value) { + upgradeDto(value, "SyncPersonV2"); + if (value is Map) { + final json = value.cast(); + + return SyncPersonV2( + createdAt: mapDateTime(json, r'createdAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$/')!, + faceClusterId: mapValueOfType(json, r'faceClusterId')!, + id: mapValueOfType(json, r'id')!, + isFavorite: mapValueOfType(json, r'isFavorite')!, + isHidden: mapValueOfType(json, r'isHidden')!, + ownerId: mapValueOfType(json, r'ownerId')!, + updatedAt: mapDateTime(json, r'updatedAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$/')!, + ); + } + 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 = SyncPersonV2.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 = SyncPersonV2.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SyncPersonV2-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] = SyncPersonV2.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'createdAt', + 'faceClusterId', + 'id', + 'isFavorite', + 'isHidden', + 'ownerId', + 'updatedAt', + }; +} + diff --git a/mobile/openapi/lib/model/sync_request_type.dart b/mobile/openapi/lib/model/sync_request_type.dart index f7c964d0e5..8c6ff1ebee 100644 --- a/mobile/openapi/lib/model/sync_request_type.dart +++ b/mobile/openapi/lib/model/sync_request_type.dart @@ -36,6 +36,8 @@ enum SyncRequestType { stacksV1._(r'StacksV1'), usersV1._(r'UsersV1'), peopleV1._(r'PeopleV1'), + peopleV2._(r'PeopleV2'), + faceClusterV1._(r'FaceClusterV1'), assetFacesV1._(r'AssetFacesV1'), assetFacesV2._(r'AssetFacesV2'), userMetadataV1._(r'UserMetadataV1'), @@ -122,6 +124,8 @@ class SyncRequestTypeTypeTransformer { case r'StacksV1': return SyncRequestType.stacksV1; case r'UsersV1': return SyncRequestType.usersV1; case r'PeopleV1': return SyncRequestType.peopleV1; + case r'PeopleV2': return SyncRequestType.peopleV2; + case r'FaceClusterV1': return SyncRequestType.faceClusterV1; case r'AssetFacesV1': return SyncRequestType.assetFacesV1; case r'AssetFacesV2': return SyncRequestType.assetFacesV2; case r'UserMetadataV1': return SyncRequestType.userMetadataV1; diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index e73760a3de..1a3028e207 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -20849,7 +20849,14 @@ "description": "Person color (hex)", "nullable": true, "pattern": "^#?([0-9A-Fa-f]{3}|[0-9A-Fa-f]{4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v3.1.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "featureFaceAssetId": { "description": "Asset ID used for feature face thumbnail", @@ -21054,7 +21061,14 @@ "description": "Person color (hex)", "nullable": true, "pattern": "^#?([0-9A-Fa-f]{3}|[0-9A-Fa-f]{4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v3.1.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "isFavorite": { "description": "Mark as favorite", @@ -21090,6 +21104,27 @@ { "version": "v2", "state": "Stable" + }, + { + "version": "v3.2.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" + }, + "faceClusterId": { + "description": "Face cluster ID", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string", + "x-immich-history": [ + { + "version": "v3.2.0", + "state": "Added" + }, + { + "version": "v3.2.0", + "state": "Stable" } ], "x-immich-state": "Stable" @@ -21146,6 +21181,7 @@ }, "required": [ "birthDate", + "faceClusterId", "id", "isHidden", "name", @@ -21179,7 +21215,14 @@ "description": "Person color (hex)", "nullable": true, "pattern": "^#?([0-9A-Fa-f]{3}|[0-9A-Fa-f]{4}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$", - "type": "string" + "type": "string", + "x-immich-history": [ + { + "version": "v3.1.0", + "state": "Deprecated" + } + ], + "x-immich-state": "Deprecated" }, "featureFaceAssetId": { "description": "Asset ID used for feature face thumbnail", @@ -25037,7 +25080,10 @@ "StackV1", "StackDeleteV1", "PersonV1", + "PersonV2", "PersonDeleteV1", + "FaceClusterV1", + "FaceClusterDeleteV1", "AssetFaceV1", "AssetFaceV2", "AssetFaceDeleteV1", @@ -25049,6 +25095,72 @@ ], "type": "string" }, + "SyncFaceClusterDeleteV1Schema": { + "properties": { + "faceClusterId": { + "description": "Face cluster ID", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + }, + "required": [ + "faceClusterId" + ], + "type": "object" + }, + "SyncFaceClusterV1": { + "properties": { + "birthDate": { + "description": "Birth date", + "example": "2024-01-01T00:00:00.000Z", + "format": "date-time", + "nullable": true, + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "type": "string" + }, + "createdAt": { + "description": "Created at", + "example": "2024-01-01T00:00:00.000Z", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "type": "string" + }, + "featureFaceAssetId": { + "description": "Feature face asset ID", + "format": "uuid", + "nullable": true, + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + }, + "id": { + "description": "Face cluster ID", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + }, + "name": { + "description": "Name", + "type": "string" + }, + "updatedAt": { + "description": "Updated at", + "example": "2024-01-01T00:00:00.000Z", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "type": "string" + } + }, + "required": [ + "birthDate", + "createdAt", + "featureFaceAssetId", + "id", + "name", + "updatedAt" + ], + "type": "object" + }, "SyncMemoryAssetDeleteV1": { "properties": { "assetId": { @@ -25335,6 +25447,60 @@ ], "type": "object" }, + "SyncPersonV2": { + "properties": { + "createdAt": { + "description": "Created at", + "example": "2024-01-01T00:00:00.000Z", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "type": "string" + }, + "faceClusterId": { + "description": "Face cluster ID", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + }, + "id": { + "description": "Person ID", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + }, + "isFavorite": { + "description": "Is favorite", + "type": "boolean" + }, + "isHidden": { + "description": "Is hidden", + "type": "boolean" + }, + "ownerId": { + "description": "Owner ID", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + }, + "updatedAt": { + "description": "Updated at", + "example": "2024-01-01T00:00:00.000Z", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$", + "type": "string" + } + }, + "required": [ + "createdAt", + "faceClusterId", + "id", + "isFavorite", + "isHidden", + "ownerId", + "updatedAt" + ], + "type": "object" + }, "SyncRequestType": { "description": "Sync request type", "enum": [ @@ -25362,6 +25528,8 @@ "StacksV1", "UsersV1", "PeopleV1", + "PeopleV2", + "FaceClusterV1", "AssetFacesV1", "AssetFacesV2", "UserMetadataV1" diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index 8983de8ef7..5597a52638 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -835,6 +835,8 @@ export type PersonResponseDto = { birthDate: string | null; /** Person color (hex) */ color?: string; + /** Face cluster ID */ + faceClusterId: string; /** Person ID */ id: string; /** Is favorite */ @@ -3212,6 +3214,24 @@ export type SyncAuthUserV1 = { storageLabel: string | null; }; export type SyncCompleteV1 = {}; +export type SyncFaceClusterDeleteV1Schema = { + /** Face cluster ID */ + faceClusterId: string; +}; +export type SyncFaceClusterV1 = { + /** Birth date */ + birthDate: string | null; + /** Created at */ + createdAt: string; + /** Feature face asset ID */ + featureFaceAssetId: string | null; + /** Face cluster ID */ + id: string; + /** Name */ + name: string; + /** Updated at */ + updatedAt: string; +}; export type SyncMemoryAssetDeleteV1 = { /** Asset ID */ assetId: string; @@ -3295,6 +3315,22 @@ export type SyncPersonV1 = { /** Updated at */ updatedAt: string; }; +export type SyncPersonV2 = { + /** Created at */ + createdAt: string; + /** Face cluster ID */ + faceClusterId: string; + /** Person ID */ + id: string; + /** Is favorite */ + isFavorite: boolean; + /** Is hidden */ + isHidden: boolean; + /** Owner ID */ + ownerId: string; + /** Updated at */ + updatedAt: string; +}; export type SyncResetV1 = {}; export type SyncStackDeleteV1 = { /** Stack ID */ @@ -7569,7 +7605,10 @@ export enum SyncEntityType { StackV1 = "StackV1", StackDeleteV1 = "StackDeleteV1", PersonV1 = "PersonV1", + PersonV2 = "PersonV2", PersonDeleteV1 = "PersonDeleteV1", + FaceClusterV1 = "FaceClusterV1", + FaceClusterDeleteV1 = "FaceClusterDeleteV1", AssetFaceV1 = "AssetFaceV1", AssetFaceV2 = "AssetFaceV2", AssetFaceDeleteV1 = "AssetFaceDeleteV1", @@ -7604,6 +7643,8 @@ export enum SyncRequestType { StacksV1 = "StacksV1", UsersV1 = "UsersV1", PeopleV1 = "PeopleV1", + PeopleV2 = "PeopleV2", + FaceClusterV1 = "FaceClusterV1", AssetFacesV1 = "AssetFacesV1", AssetFacesV2 = "AssetFacesV2", UserMetadataV1 = "UserMetadataV1" diff --git a/server/src/database.ts b/server/src/database.ts index 100ba451e7..d3df66558e 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -246,12 +246,11 @@ export type Person = { updatedAt: Date; updateId: string; isFavorite: boolean; - name: string; - birthDate: Date | null; - color: string | null; - faceAssetId: string | null; isHidden: boolean; + birthDate: Date | null; thumbnailPath: string; + name: string; + faceClusterId: string; }; export type AssetFace = { @@ -264,7 +263,7 @@ export type AssetFace = { boundingBoxY2: number; imageHeight: number; imageWidth: number; - personId: string | null; + faceClusterId: string | null; sourceType: SourceType; person?: ShallowDehydrateObject | null; updatedAt: Date; diff --git a/server/src/dtos/person.dto.ts b/server/src/dtos/person.dto.ts index fa56ded176..da73528ba9 100644 --- a/server/src/dtos/person.dto.ts +++ b/server/src/dtos/person.dto.ts @@ -24,7 +24,11 @@ const PersonCreateSchema = z .describe('Person date of birth'), isHidden: z.boolean().optional().describe('Person visibility (hidden)'), isFavorite: z.boolean().optional().describe('Mark as favorite'), - color: hexColor.nullable().optional().describe('Person color (hex)'), + color: hexColor + .nullable() + .optional() + .describe('Person color (hex)') + .meta(new HistoryBuilder().deprecated('v3.1.0').getExtensions()), }) .meta({ id: 'PersonCreateDto' }); @@ -82,7 +86,11 @@ export const PersonResponseSchema = z .string() .optional() .describe('Person color (hex)') - .meta(new HistoryBuilder().added('v1.126.0').stable('v2').getExtensions()), + .meta(new HistoryBuilder().added('v1.126.0').stable('v2').deprecated('v3.2.0').getExtensions()), + faceClusterId: z + .uuidv4() + .describe('Face cluster ID') + .meta(new HistoryBuilder().added('v3.2.0').stable('v3.2.0').getExtensions()), }) .meta({ id: 'PersonResponseDto' }); @@ -179,8 +187,8 @@ export function mapPerson(person: MaybeDehydrated): PersonResponseDto { thumbnailPath: person.thumbnailPath, isHidden: person.isHidden, isFavorite: person.isFavorite, - color: person.color ?? undefined, updatedAt: asDateTimeString(person.updatedAt), + faceClusterId: person.faceClusterId, }; } diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index 1d5fe64aa1..6771826515 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -346,10 +346,31 @@ const SyncPersonV1Schema = z }) .meta({ id: 'SyncPersonV1' }); +const SyncPersonV2Schema = SyncPersonV1Schema.extend({ + faceClusterId: z.uuidv4().describe('Face cluster ID'), +}) + .omit({ name: true, birthDate: true, color: true, faceAssetId: true }) + .meta({ id: 'SyncPersonV2' }); + const SyncPersonDeleteV1Schema = z .object({ personId: z.uuidv4().describe('Person ID') }) .meta({ id: 'SyncPersonDeleteV1' }); +const SyncFaceClusterV1Schema = z + .object({ + id: z.uuidv4().describe('Face cluster ID'), + name: z.string().describe('Name'), + createdAt: isoDatetimeToDate.describe('Created at'), + updatedAt: isoDatetimeToDate.describe('Updated at'), + birthDate: isoDatetimeToDate.nullable().describe('Birth date'), + featureFaceAssetId: z.uuidv4().nullable().describe('Feature face asset ID'), + }) + .meta({ id: 'SyncFaceClusterV1' }); + +const SyncFaceClusterDeleteV1Schema = z + .object({ faceClusterId: z.uuidv4().describe('Face cluster ID') }) + .meta({ id: 'SyncFaceClusterDeleteV1Schema' }); + const SyncAssetFaceV1Schema = z .object({ id: z.uuidv4().describe('Asset face ID'), @@ -443,8 +464,14 @@ class SyncStackDeleteV1 extends createZodDto(SyncStackDeleteV1Schema) {} @ExtraModel() class SyncPersonV1 extends createZodDto(SyncPersonV1Schema) {} @ExtraModel() +class SyncPersonV2 extends createZodDto(SyncPersonV2Schema) {} +@ExtraModel() class SyncPersonDeleteV1 extends createZodDto(SyncPersonDeleteV1Schema) {} @ExtraModel() +class SyncFaceClusterV1 extends createZodDto(SyncFaceClusterV1Schema) {} +@ExtraModel() +class SyncFaceClusterDeleteV1 extends createZodDto(SyncFaceClusterDeleteV1Schema) {} +@ExtraModel() class SyncAssetFaceV1 extends createZodDto(SyncAssetFaceV1Schema) {} @ExtraModel() class SyncAssetFaceV2 extends createZodDto(SyncAssetFaceV2Schema) {} @@ -506,7 +533,10 @@ export type SyncItem = { [SyncEntityType.PartnerStackDeleteV1]: SyncStackDeleteV1; [SyncEntityType.PartnerStackV1]: SyncStackV1; [SyncEntityType.PersonV1]: SyncPersonV1; + [SyncEntityType.PersonV2]: SyncPersonV2; [SyncEntityType.PersonDeleteV1]: SyncPersonDeleteV1; + [SyncEntityType.FaceClusterV1]: SyncFaceClusterV1; + [SyncEntityType.FaceClusterDeleteV1]: SyncFaceClusterDeleteV1; [SyncEntityType.AssetFaceV1]: SyncAssetFaceV1; [SyncEntityType.AssetFaceV2]: SyncAssetFaceV2; [SyncEntityType.AssetFaceDeleteV1]: SyncAssetFaceDeleteV1; diff --git a/server/src/enum.ts b/server/src/enum.ts index 0d29244e09..a22eb67119 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -1014,7 +1014,10 @@ export enum SyncRequestType { PartnerStacksV1 = 'PartnerStacksV1', StacksV1 = 'StacksV1', UsersV1 = 'UsersV1', + /** @deprecated */ PeopleV1 = 'PeopleV1', + PeopleV2 = 'PeopleV2', + FaceClusterV1 = 'FaceClusterV1', /** @deprecated */ AssetFacesV1 = 'AssetFacesV1', AssetFacesV2 = 'AssetFacesV2', @@ -1095,8 +1098,12 @@ export enum SyncEntityType { StackDeleteV1 = 'StackDeleteV1', PersonV1 = 'PersonV1', + PersonV2 = 'PersonV2', PersonDeleteV1 = 'PersonDeleteV1', + FaceClusterV1 = 'FaceClusterV1', + FaceClusterDeleteV1 = 'FaceClusterDeleteV1', + AssetFaceV1 = 'AssetFaceV1', AssetFaceV2 = 'AssetFaceV2', AssetFaceDeleteV1 = 'AssetFaceDeleteV1', diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index cc694dd63a..5fe2b40125 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -188,11 +188,14 @@ select "asset_face" left join lateral ( select - "person".* + "person".*, + "face_cluster"."name", + "face_cluster"."birthDate" from "person" + inner join "face_cluster" on "face_cluster"."id" = "person"."faceClusterId" where - "asset_face"."personId" = "person"."id" + "asset_face"."faceClusterId" = "person"."faceClusterId" ) as "person" on true where "asset_face"."assetId" = "asset"."id" diff --git a/server/src/queries/memory.repository.sql b/server/src/queries/memory.repository.sql index 44339cbcd9..8315788df2 100644 --- a/server/src/queries/memory.repository.sql +++ b/server/src/queries/memory.repository.sql @@ -47,7 +47,7 @@ select $1 as "one" from "asset_face" - inner join "person" on "person"."id" = "asset_face"."personId" + inner join "person" on "person"."faceClusterId" = "asset_face"."faceClusterId" where "asset_face"."assetId" = "asset"."id" and "person"."isHidden" = $2 @@ -86,7 +86,7 @@ select $1 as "one" from "asset_face" - inner join "person" on "person"."id" = "asset_face"."personId" + inner join "person" on "person"."faceClusterId" = "asset_face"."faceClusterId" where "asset_face"."assetId" = "asset"."id" and "person"."isHidden" = $2 diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql index a2f3f64442..0bbf8122f5 100644 --- a/server/src/queries/person.repository.sql +++ b/server/src/queries/person.repository.sql @@ -3,9 +3,6 @@ -- PersonRepository.reassignFaces update "asset_face" set - "personId" = $1 -where - "asset_face"."personId" = $2 -- PersonRepository.delete delete from "person" @@ -25,10 +22,14 @@ limit -- PersonRepository.getAllForUser select - "person".* + "person".*, + "face_cluster"."birthDate", + "face_cluster"."name", + "face_cluster"."featureFaceAssetId" from "person" - inner join "asset_face" on "asset_face"."personId" = "person"."id" + inner join "asset_face" on "asset_face"."faceClusterId" = "person"."faceClusterId" + inner join "face_cluster" on "face_cluster"."id" = "person"."faceClusterId" inner join "asset" on "asset_face"."assetId" = "asset"."id" and "asset"."visibility" = 'timeline' and "asset"."deletedAt" is null @@ -38,10 +39,11 @@ where and "asset_face"."isVisible" is true and "person"."isHidden" = $2 group by - "person"."id" + "person"."id", + "face_cluster"."id" having ( - "person"."name" != $3 + "face_cluster"."name" != $3 or count("asset_face"."assetId") >= COALESCE( ( SELECT @@ -58,9 +60,9 @@ having order by "person"."isHidden" asc, "person"."isFavorite" desc, - NULLIF(person.name, '') is null asc, + NULLIF(face_cluster.name, '') is null asc, count("asset_face"."assetId") desc, - NULLIF(person.name, '') asc nulls last, + NULLIF(face_cluster.name, '') asc nulls last, "person"."createdAt" limit $5 @@ -72,7 +74,8 @@ select "person".* from "person" - left join "asset_face" on "asset_face"."personId" = "person"."id" + left join "asset_face" on "asset_face"."faceClusterId" = "person"."faceClusterId" + inner join "face_cluster" on "face_cluster"."id" = "person"."faceClusterId" where "asset_face"."deletedAt" is null and "asset_face"."isVisible" is true @@ -90,19 +93,35 @@ select from ( select - "person".* + "person".*, + "face_cluster"."featureFaceAssetId", + "face_cluster"."birthDate", + "face_cluster"."name", + "face_cluster"."featureFaceAssetId" from "person" + inner join "face_cluster" on "face_cluster"."id" = "asset_face"."faceClusterId" where - "person"."id" = "asset_face"."personId" + "person"."faceClusterId" = "asset_face"."faceClusterId" + order by + "person"."ownerId" = ( + select + "asset"."ownerId" + from + "asset" + where + "asset"."id" = "asset_face"."assetId" + ) desc + limit + $1 ) as obj ) as "person" from "asset_face" where - "asset_face"."assetId" = $1 + "asset_face"."assetId" = $2 and "asset_face"."deletedAt" is null - and "asset_face"."isVisible" = $2 + and "asset_face"."isVisible" = $3 order by "asset_face"."boundingBoxX1" asc @@ -115,23 +134,39 @@ select from ( select - "person".* + "person".*, + "face_cluster"."featureFaceAssetId", + "face_cluster"."birthDate", + "face_cluster"."name", + "face_cluster"."featureFaceAssetId" from "person" + inner join "face_cluster" on "face_cluster"."id" = "asset_face"."faceClusterId" where - "person"."id" = "asset_face"."personId" + "person"."faceClusterId" = "asset_face"."faceClusterId" + order by + "person"."ownerId" = ( + select + "asset"."ownerId" + from + "asset" + where + "asset"."id" = "asset_face"."assetId" + ) desc + limit + $1 ) as obj ) as "person" from "asset_face" where - "asset_face"."id" = $1 + "asset_face"."id" = $2 and "asset_face"."deletedAt" is null -- PersonRepository.getFaceForFacialRecognitionJob select "asset_face"."id", - "asset_face"."personId", + "asset_face"."faceClusterId", "asset_face"."sourceType", ( select @@ -191,7 +226,8 @@ select ) as "previewPath" from "person" - inner join "asset_face" on "asset_face"."id" = "person"."faceAssetId" + inner join "face_cluster" on "face_cluster"."id" = "person"."faceClusterId" + inner join "asset_face" on "asset_face"."id" = "face_cluster"."featureFaceAssetId" inner join "asset" on "asset_face"."assetId" = "asset"."id" left join "asset_exif" on "asset_exif"."assetId" = "asset"."id" where @@ -201,7 +237,7 @@ where -- PersonRepository.reassignFace update "asset_face" set - "personId" = $1 + "faceClusterId" = $1 where "asset_face"."id" = $2 @@ -212,10 +248,14 @@ with set_config('pg_trgm.word_similarity_threshold', '0.5', true) as "thresh" ) select + "face_cluster"."birthDate", + "face_cluster"."featureFaceAssetId", + "face_cluster"."name", "person".* from "similarity_threshold", "person" + inner join "face_cluster" on "face_cluster"."id" = "person"."faceClusterId" where "person"."ownerId" = $1 and f_unaccent ("person"."name") %> f_unaccent ($2) @@ -226,14 +266,15 @@ limit -- PersonRepository.getDistinctNames select distinct - on (lower("person"."name")) "person"."id", - "person"."name" + on (lower("face_cluster"."name")) "face_cluster"."id", + "face_cluster"."name" from "person" + inner join "face_cluster" on "face_cluster"."id" = "person"."faceClusterId" where ( "person"."ownerId" = $1 - and "person"."name" != $2 + and "face_cluster"."name" != $2 ) -- PersonRepository.getStatistics @@ -247,7 +288,14 @@ from where "asset_face"."deletedAt" is null and "asset_face"."isVisible" is true - and "asset_face"."personId" = $1 + and "asset_face"."faceClusterId" = ( + select + "person"."faceClusterId" + from + "person" + where + "person"."id" = $1 + ) -- PersonRepository.getNumberOfPeople select @@ -267,7 +315,7 @@ where from "asset_face" where - "asset_face"."personId" = "person"."id" + "asset_face"."faceClusterId" = "person"."faceClusterId" and "asset_face"."deletedAt" is null and "asset_face"."isVisible" = $2 and exists ( @@ -297,6 +345,15 @@ from 1 ) as "dummy" +-- PersonRepository.updateAllFaceClusters +insert into + "face_cluster" ("id", "name") +values + ($1, $2) +on conflict ("id") do update +set + "name" = "excluded"."name" + -- PersonRepository.getFacesByIds select "asset_face".*, @@ -306,18 +363,35 @@ select from ( select - "person".* + "person".*, + "face_cluster"."featureFaceAssetId", + "face_cluster"."birthDate", + "face_cluster"."name", + "face_cluster"."featureFaceAssetId" from "person" + inner join "face_cluster" on "face_cluster"."id" = "asset_face"."faceClusterId" where - "person"."id" = "asset_face"."personId" + "person"."faceClusterId" = "asset_face"."faceClusterId" + order by + "person"."ownerId" = ( + select + "asset"."ownerId" + from + "asset" + where + "asset"."id" = "asset_face"."assetId" + ) desc + limit + $1 ) as obj ) as "person" from "asset_face" + inner join "person" on "person"."faceClusterId" = "asset_face"."faceClusterId" where - "asset_face"."assetId" in ($1) - and "asset_face"."personId" in ($2) + "asset_face"."assetId" in ($2) + and "person"."id" in ($3) and "asset_face"."deletedAt" is null -- PersonRepository.getRandomFace @@ -325,9 +399,10 @@ select "asset_face".* from "asset_face" + inner join "person" on "asset_face"."faceClusterId" = "person"."faceClusterId" + and "person"."id" = $1 where - "asset_face"."personId" = $1 - and "asset_face"."deletedAt" is null + "asset_face"."deletedAt" is null and "asset_face"."isVisible" is true -- PersonRepository.getLatestFaceDate @@ -350,8 +425,8 @@ where -- PersonRepository.getForPeopleDelete select - "id", - "thumbnailPath" + "person"."id", + "person"."thumbnailPath" from "person" where @@ -362,8 +437,9 @@ select "asset_face"."id" from "asset_face" + inner join "person" on "person"."faceClusterId" = "asset_face"."faceClusterId" + and "person"."id" = $1 inner join "asset" on "asset"."id" = "asset_face"."assetId" - and "asset"."isOffline" = $1 + and "asset"."isOffline" = $2 where - "asset_face"."assetId" = $2 - and "asset_face"."personId" = $3 + "asset_face"."assetId" = $3 diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index efd7236bb5..20c4753d41 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -218,13 +218,13 @@ with "cte" as ( select "asset_face"."id", - "asset_face"."personId", + "asset_face"."faceClusterId", face_search.embedding <=> $1 as "distance" from "asset_face" inner join "asset" on "asset"."id" = "asset_face"."assetId" inner join "face_search" on "face_search"."faceId" = "asset_face"."id" - left join "person" on "person"."id" = "asset_face"."personId" + left join "face_cluster" on "face_cluster"."id" = "asset_face"."faceClusterId" where "asset"."ownerId" = any ($2::uuid[]) and "asset"."deletedAt" is null @@ -845,15 +845,16 @@ where "asset_face"."assetId" from "asset_face" + inner join "person" on "person"."faceClusterId" = "asset_face"."faceClusterId" where "asset_face"."assetId" = "asset"."id" and "asset_face"."deletedAt" is null and "asset_face"."isVisible" = $2 - and "asset_face"."personId" = any ($3::uuid[]) + and "person"."id" = any ($3::uuid[]) group by "asset_face"."assetId" having - count(distinct "asset_face"."personId") = $4 + count(distinct "person"."id") = $4 ) order by "asset"."fileCreatedAt" desc, @@ -1370,11 +1371,12 @@ where select from "asset_face" + inner join "person" on "person"."faceClusterId" = "asset_face"."faceClusterId" where "asset_face"."assetId" = "asset"."id" and "asset_face"."deletedAt" is null and "asset_face"."isVisible" = $3 - and "asset_face"."personId" = any ($4::uuid[]) + and "person"."id" = any ($4::uuid[]) ) ) order by diff --git a/server/src/queries/sync.repository.sql b/server/src/queries/sync.repository.sql index 69f7981bf3..6029a0a252 100644 --- a/server/src/queries/sync.repository.sql +++ b/server/src/queries/sync.repository.sql @@ -536,7 +536,7 @@ order by select "asset_face"."id", "assetId", - "personId", + "faceClusterId", "imageWidth", "imageHeight", "boundingBoxX1", @@ -655,6 +655,43 @@ where order by "user"."updateId" asc +-- SyncRepository.faceCluster.getDeletes +select + "id", + "faceClusterId" +from + "face_cluster_audit" as "face_cluster_audit" +where + "face_cluster_audit"."id" < $1 + and "face_cluster_audit"."id" > $2 +order by + "face_cluster_audit"."id" asc + +-- SyncRepository.faceCluster.getUpserts +select + "id", + "birthDate", + "createdAt", + "featureFaceAssetId", + "name", + "updateId", + "updatedAt" +from + "face_cluster" as "face_cluster" +where + "face_cluster"."updateId" < $1 + and "face_cluster"."updateId" > $2 +order by + "face_cluster"."updateId" asc + +-- SyncRepository.faceCluster.getById +select + * +from + "face_cluster" +where + "face_cluster"."id" = $1 + -- SyncRepository.memory.getDeletes select "id", @@ -1045,13 +1082,10 @@ select "createdAt", "updatedAt", "ownerId", - "name", - "birthDate", "isHidden", "isFavorite", - "color", "updateId", - "faceAssetId" + "faceClusterId" from "person" as "person" where @@ -1061,6 +1095,15 @@ where order by "person"."updateId" asc +-- SyncRepository.person.getByFaceClusterId +select + * +from + "person" +where + "person"."faceClusterId" = $1 + and "person"."ownerId" = $2 + -- SyncRepository.stack.getDeletes select "id", diff --git a/server/src/repositories/media.repository.spec.ts b/server/src/repositories/media.repository.spec.ts index e8106c0ff9..fc86b588c2 100644 --- a/server/src/repositories/media.repository.spec.ts +++ b/server/src/repositories/media.repository.spec.ts @@ -329,7 +329,7 @@ describe(MediaRepository.name, () => { const baseFace: AssetFace = { id: 'face-1', assetId: 'asset-1', - personId: 'person-1', + faceClusterId: 'face-cluster-1', boundingBoxX1: 100, boundingBoxY1: 100, boundingBoxX2: 200, diff --git a/server/src/repositories/memory.repository.ts b/server/src/repositories/memory.repository.ts index 09aa5ad880..6c68049cd1 100644 --- a/server/src/repositories/memory.repository.ts +++ b/server/src/repositories/memory.repository.ts @@ -73,7 +73,7 @@ export class MemoryRepository implements IBulkAsset { eb.exists( eb .selectFrom('asset_face') - .innerJoin('person', 'person.id', 'asset_face.personId') + .innerJoin('person', 'person.faceClusterId', 'asset_face.faceClusterId') .select((eb) => eb.val(1).as('one')) .whereRef('asset_face.assetId', '=', 'asset.id') .where('person.isHidden', '=', true), diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 0db03a18c7..f9ac3760bb 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -7,6 +7,7 @@ import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { AssetFileType, AssetVisibility, SourceType, UserMetadataKey } from 'src/enum'; import { DB } from 'src/schema'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; +import { FaceClusterTable } from 'src/schema/tables/face-cluster.table'; import { FaceSearchTable } from 'src/schema/tables/face-search.table'; import { PersonTable } from 'src/schema/tables/person.table'; import { dummy, removeUndefinedKeys, withFilePath } from 'src/utils/database'; @@ -32,9 +33,9 @@ export interface AssetFaceId { } export interface UpdateFacesData { - oldPersonId?: string; + oldFaceClusterId?: string; faceIds?: string[]; - newPersonId: string; + newFaceClusterId: string; } export interface PersonStatistics { @@ -53,7 +54,7 @@ export interface GetAllPeopleOptions { } export interface GetAllFacesOptions { - personId?: string | null; + faceClusterId?: string | null; assetId?: string; sourceType?: SourceType; } @@ -62,9 +63,30 @@ export type UnassignFacesOptions = DeleteFacesOptions; export type SelectFaceOptions = (keyof Selectable)[]; -const withPerson = (eb: ExpressionBuilder) => { +const withPerson = (eb: ExpressionBuilder, userId?: string) => { return jsonObjectFrom( - eb.selectFrom('person').selectAll('person').whereRef('person.id', '=', 'asset_face.personId'), + eb + .selectFrom('person') + .selectAll('person') + .whereRef('person.faceClusterId', '=', 'asset_face.faceClusterId') + .innerJoin('face_cluster', 'face_cluster.id', 'asset_face.faceClusterId') + .select([ + 'face_cluster.featureFaceAssetId', + 'face_cluster.birthDate', + 'face_cluster.name', + 'face_cluster.featureFaceAssetId', + ]) + .$if(!!userId, (qb) => qb.where((eb) => eb('person.ownerId', '=', userId!))) + .orderBy( + (eb) => + eb( + 'person.ownerId', + '=', + eb.selectFrom('asset').select('asset.ownerId').whereRef('asset.id', '=', 'asset_face.assetId'), + ), + 'desc', + ) + .limit(1), ).as('person'); }; @@ -79,11 +101,11 @@ export class PersonRepository { constructor(@InjectKysely() private db: Kysely) {} @GenerateSql({ params: [{ oldPersonId: DummyValue.UUID, newPersonId: DummyValue.UUID }] }) - async reassignFaces({ oldPersonId, faceIds, newPersonId }: UpdateFacesData): Promise { + async reassignFaces({ oldFaceClusterId, faceIds, newFaceClusterId }: UpdateFacesData): Promise { const result = await this.db .updateTable('asset_face') - .set({ personId: newPersonId }) - .$if(!!oldPersonId, (qb) => qb.where('asset_face.personId', '=', oldPersonId!)) + .set({ faceClusterId: newFaceClusterId }) + .$if(!!oldFaceClusterId, (qb) => qb.where('asset_face.faceClusterId', '=', oldFaceClusterId!)) .$if(!!faceIds, (qb) => qb.where('asset_face.id', 'in', faceIds!)) .executeTakeFirst(); @@ -93,7 +115,7 @@ export class PersonRepository { async unassignFaces({ sourceType }: UnassignFacesOptions): Promise { await this.db .updateTable('asset_face') - .set({ personId: null }) + .set({ faceClusterId: null }) .where('asset_face.sourceType', '=', sourceType) .execute(); } @@ -108,6 +130,16 @@ export class PersonRepository { await this.db.deleteFrom('person').where('person.id', 'in', ids).execute(); } + @GenerateSql({ params: [[DummyValue.UUID]] }) + @Chunked() + async deleteFaceClusters(ids: string[]) { + if (ids.length === 0) { + return; + } + + await this.db.deleteFrom('face_cluster').where('face_cluster.id', 'in', ids).execute(); + } + async deleteFaces({ sourceType }: DeleteFacesOptions): Promise { await this.db.deleteFrom('asset_face').where('asset_face.sourceType', '=', sourceType).execute(); } @@ -116,8 +148,8 @@ export class PersonRepository { return this.db .selectFrom('asset_face') .selectAll('asset_face') - .$if(options.personId === null, (qb) => qb.where('asset_face.personId', 'is', null)) - .$if(!!options.personId, (qb) => qb.where('asset_face.personId', '=', options.personId!)) + .$if(options.faceClusterId === null, (qb) => qb.where('asset_face.faceClusterId', 'is', null)) + .$if(!!options.faceClusterId, (qb) => qb.where('asset_face.faceClusterId', '=', options.faceClusterId!)) .$if(!!options.sourceType, (qb) => qb.where('asset_face.sourceType', '=', options.sourceType!)) .$if(!!options.assetId, (qb) => qb.where('asset_face.assetId', '=', options.assetId!)) .where('asset_face.deletedAt', 'is', null) @@ -129,10 +161,11 @@ export class PersonRepository { return this.db .selectFrom('person') .selectAll('person') + .innerJoin('face_cluster', 'face_cluster.id', 'person.faceClusterId') .$if(!!options.ownerId, (qb) => qb.where('person.ownerId', '=', options.ownerId!)) .$if(options.thumbnailPath !== undefined, (qb) => qb.where('person.thumbnailPath', '=', options.thumbnailPath!)) - .$if(options.faceAssetId === null, (qb) => qb.where('person.faceAssetId', 'is', null)) - .$if(!!options.faceAssetId, (qb) => qb.where('person.faceAssetId', '=', options.faceAssetId!)) + .$if(options.faceAssetId === null, (qb) => qb.where('face_cluster.featureFaceAssetId', 'is', null)) + .$if(!!options.faceAssetId, (qb) => qb.where('face_cluster.featureFaceAssetId', '=', options.faceAssetId!)) .$if(options.isHidden !== undefined, (qb) => qb.where('person.isHidden', '=', options.isHidden!)) .stream(); } @@ -152,7 +185,9 @@ export class PersonRepository { const items = await this.db .selectFrom('person') .selectAll('person') - .innerJoin('asset_face', 'asset_face.personId', 'person.id') + .innerJoin('asset_face', 'asset_face.faceClusterId', 'person.faceClusterId') + .innerJoin('face_cluster', 'face_cluster.id', 'person.faceClusterId') + .select(['face_cluster.birthDate', 'face_cluster.name', 'face_cluster.featureFaceAssetId']) .innerJoin('asset', (join) => join .onRef('asset_face.assetId', '=', 'asset.id') @@ -166,7 +201,7 @@ export class PersonRepository { .orderBy('person.isFavorite', 'desc') .having((eb) => eb.or([ - eb('person.name', '!=', ''), + eb('face_cluster.name', '!=', ''), eb( (innerEb) => innerEb.fn.count('asset_face.assetId'), '>=', @@ -181,6 +216,7 @@ export class PersonRepository { ]), ) .groupBy('person.id') + .groupBy('face_cluster.id') .$if(!!options?.closestFaceAssetId, (qb) => qb.orderBy((eb) => eb( @@ -188,7 +224,7 @@ export class PersonRepository { eb .selectFrom('face_search') .select('face_search.embedding') - .whereRef('face_search.faceId', '=', 'person.faceAssetId'), + .whereRef('face_search.faceId', '=', 'face_cluster.featureFaceAssetId'), '<=>', (eb) => eb @@ -200,9 +236,9 @@ export class PersonRepository { ) .$if(!options?.closestFaceAssetId, (qb) => qb - .orderBy(sql`NULLIF(person.name, '') is null`, 'asc') + .orderBy(sql`NULLIF(face_cluster.name, '') is null`, 'asc') .orderBy((eb) => eb.fn.count('asset_face.assetId'), 'desc') - .orderBy(sql`NULLIF(person.name, '')`, (om) => om.asc().nullsLast()) + .orderBy(sql`NULLIF(face_cluster.name, '')`, (om) => om.asc().nullsLast()) .orderBy('person.createdAt'), ) .$if(!options?.withHidden, (qb) => qb.where('person.isHidden', '=', false)) @@ -214,26 +250,26 @@ export class PersonRepository { } @GenerateSql() - getAllWithoutFaces() { + getForPeopleCleanup() { return this.db - .selectFrom('person') - .selectAll('person') - .leftJoin('asset_face', 'asset_face.personId', 'person.id') + .selectFrom('face_cluster') + .leftJoin('asset_face', 'asset_face.faceClusterId', 'face_cluster.id') + .leftJoin('person', 'person.faceClusterId', 'face_cluster.id') + .select(['face_cluster.id', 'person.thumbnailPath']) .where('asset_face.deletedAt', 'is', null) .where('asset_face.isVisible', 'is', true) .having((eb) => eb.fn.count('asset_face.assetId'), '=', 0) - .groupBy('person.id') .execute(); } @GenerateSql({ params: [DummyValue.UUID] }) - getFaces(assetId: string, options?: { isVisible?: boolean }) { - const isVisible = options === undefined ? true : options.isVisible; + getFaces(assetId: string, options: { isVisible?: boolean; userId?: string } = {}) { + const { isVisible = true, userId } = options; return this.db .selectFrom('asset_face') .selectAll('asset_face') - .select(withPerson) + .select((eb) => withPerson(eb, userId)) .where('asset_face.assetId', '=', assetId) .where('asset_face.deletedAt', 'is', null) .$if(isVisible !== undefined, (qb) => qb.where('asset_face.isVisible', '=', isVisible!)) @@ -257,7 +293,7 @@ export class PersonRepository { getFaceForFacialRecognitionJob(id: string) { return this.db .selectFrom('asset_face') - .select(['asset_face.id', 'asset_face.personId', 'asset_face.sourceType']) + .select(['asset_face.id', 'asset_face.faceClusterId', 'asset_face.sourceType']) .select((eb) => jsonObjectFrom( eb @@ -276,7 +312,8 @@ export class PersonRepository { getDataForThumbnailGenerationJob(id: string) { return this.db .selectFrom('person') - .innerJoin('asset_face', 'asset_face.id', 'person.faceAssetId') + .innerJoin('face_cluster', 'face_cluster.id', 'person.faceClusterId') + .innerJoin('asset_face', 'asset_face.id', 'face_cluster.featureFaceAssetId') .innerJoin('asset', 'asset_face.assetId', 'asset.id') .leftJoin('asset_exif', 'asset_exif.assetId', 'asset.id') .select([ @@ -298,10 +335,10 @@ export class PersonRepository { } @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] }) - async reassignFace(assetFaceId: string, newPersonId: string): Promise { + async reassignFace(assetFaceId: string, newFaceClusterId: string): Promise { const result = await this.db .updateTable('asset_face') - .set({ personId: newPersonId }) + .set({ faceClusterId: newFaceClusterId }) .where('asset_face.id', '=', assetFaceId) .executeTakeFirst(); @@ -309,8 +346,10 @@ export class PersonRepository { } getById(personId: string) { - return this.db // + return this.db .selectFrom('person') + .innerJoin('face_cluster', 'face_cluster.id', 'person.faceClusterId') + .select(['face_cluster.birthDate', 'face_cluster.featureFaceAssetId', 'face_cluster.name']) .selectAll('person') .where('person.id', '=', personId) .executeTakeFirst(); @@ -323,6 +362,8 @@ export class PersonRepository { db.selectNoFrom(sql`set_config('pg_trgm.word_similarity_threshold', '0.5', true)`.as('thresh')), ) .selectFrom(['similarity_threshold', 'person']) + .innerJoin('face_cluster', 'face_cluster.id', 'person.faceClusterId') + .select(['face_cluster.birthDate', 'face_cluster.featureFaceAssetId', 'face_cluster.name']) .selectAll('person') .where('person.ownerId', '=', userId) .where(() => sql`f_unaccent("person"."name") %> f_unaccent(${personName})`) @@ -336,9 +377,10 @@ export class PersonRepository { getDistinctNames(userId: string, { withHidden }: PersonNameSearchOptions): Promise { return this.db .selectFrom('person') - .select(['person.id', 'person.name']) - .distinctOn((eb) => eb.fn('lower', ['person.name'])) - .where((eb) => eb.and([eb('person.ownerId', '=', userId), eb('person.name', '!=', '')])) + .innerJoin('face_cluster', 'face_cluster.id', 'person.faceClusterId') + .select(['face_cluster.id', 'face_cluster.name']) + .distinctOn((eb) => eb.fn('lower', ['face_cluster.name'])) + .where((eb) => eb.and([eb('person.ownerId', '=', userId), eb('face_cluster.name', '!=', '')])) .$if(!withHidden, (qb) => qb.where('person.isHidden', '=', false)) .execute(); } @@ -356,7 +398,9 @@ export class PersonRepository { .select((eb) => eb.fn.count(eb.fn('distinct', ['asset.id'])).as('count')) .where('asset_face.deletedAt', 'is', null) .where('asset_face.isVisible', 'is', true) - .where('asset_face.personId', '=', personId) + .where('asset_face.faceClusterId', '=', (eb) => + eb.selectFrom('person').select('person.faceClusterId').where('person.id', '=', personId), + ) .executeTakeFirst(); return { @@ -373,7 +417,7 @@ export class PersonRepository { eb.exists((eb) => eb .selectFrom('asset_face') - .whereRef('asset_face.personId', '=', 'person.id') + .whereRef('asset_face.faceClusterId', '=', 'person.faceClusterId') .where('asset_face.deletedAt', 'is', null) .where('asset_face.isVisible', '=', true) .where((eb) => @@ -393,10 +437,16 @@ export class PersonRepository { .executeTakeFirstOrThrow(); } - create(person: Insertable) { + async create(person: Insertable) { return this.db.insertInto('person').values(person).returningAll().executeTakeFirstOrThrow(); } + async createFaceCluster(faceCluster: Insertable) { + return Object.values(faceCluster).some((value) => value !== undefined) + ? this.db.insertInto('face_cluster').values(faceCluster).returningAll().executeTakeFirstOrThrow() + : this.db.insertInto('face_cluster').defaultValues().returningAll().executeTakeFirstOrThrow(); + } + async createAll(people: Insertable[]): Promise { if (people.length === 0) { return []; @@ -406,6 +456,15 @@ export class PersonRepository { return results.map(({ id }) => id); } + async createAllFaceClusters(faceClusters: Insertable[]): Promise { + if (faceClusters.length === 0) { + return []; + } + + const results = await this.db.insertInto('face_cluster').values(faceClusters).returning('id').execute(); + return results.map(({ id }) => id); + } + @GenerateSql({ params: [[], [], [{ faceId: DummyValue.UUID, embedding: DummyValue.VECTOR }]] }) async refreshFaces( facesToAdd: (Insertable & { assetId: string })[], @@ -439,6 +498,15 @@ export class PersonRepository { .executeTakeFirstOrThrow(); } + async updateFaceCluster(faceCluster: Updateable & { id: string }) { + return this.db + .updateTable('face_cluster') + .set(faceCluster) + .where('face_cluster.id', '=', faceCluster.id) + .returningAll() + .executeTakeFirstOrThrow(); + } + async updateAll(people: Insertable[]): Promise { if (people.length === 0) { return; @@ -451,13 +519,9 @@ export class PersonRepository { oc.column('id').doUpdateSet((eb) => removeUndefinedKeys( { - name: eb.ref('excluded.name'), - birthDate: eb.ref('excluded.birthDate'), - thumbnailPath: eb.ref('excluded.thumbnailPath'), - faceAssetId: eb.ref('excluded.faceAssetId'), + faceClusterId: eb.ref('excluded.faceClusterId'), isHidden: eb.ref('excluded.isHidden'), isFavorite: eb.ref('excluded.isFavorite'), - color: eb.ref('excluded.color'), }, people[0], ), @@ -466,6 +530,30 @@ export class PersonRepository { .execute(); } + @GenerateSql({ params: [[{ id: DummyValue.UUID, name: 'foo' }]] }) + async updateAllFaceClusters(faceClusters: Insertable[]) { + if (faceClusters.length === 0) { + return; + } + + await this.db + .insertInto('face_cluster') + .values(faceClusters) + .onConflict((oc) => + oc.column('id').doUpdateSet((eb) => + removeUndefinedKeys( + { + birthDate: eb.ref('excluded.birthDate'), + featureFaceAssetId: eb.ref('excluded.featureFaceAssetId'), + name: eb.ref('excluded.name'), + }, + faceClusters[0], + ), + ), + ) + .execute(); + } + @GenerateSql({ params: [[{ assetId: DummyValue.UUID, personId: DummyValue.UUID }]] }) @ChunkedArray() getFacesByIds(ids: AssetFaceId[]) { @@ -484,8 +572,9 @@ export class PersonRepository { .selectFrom('asset_face') .selectAll('asset_face') .select(withPerson) + .innerJoin('person', (join) => join.onRef('person.faceClusterId', '=', 'asset_face.faceClusterId')) .where('asset_face.assetId', 'in', assetIds) - .where('asset_face.personId', 'in', personIds) + .where('person.id', 'in', personIds) .where('asset_face.deletedAt', 'is', null) .execute(); } @@ -495,7 +584,9 @@ export class PersonRepository { return this.db .selectFrom('asset_face') .selectAll('asset_face') - .where('asset_face.personId', '=', personId) + .innerJoin('person', (join) => + join.onRef('asset_face.faceClusterId', '=', 'person.faceClusterId').on('person.id', '=', personId), + ) .where('asset_face.deletedAt', 'is', null) .where('asset_face.isVisible', 'is', true) .executeTakeFirst(); @@ -540,7 +631,7 @@ export class PersonRepository { if (ids.length === 0) { return Promise.resolve([]); } - return this.db.selectFrom('person').select(['id', 'thumbnailPath']).where('id', 'in', ids).execute(); + return this.db.selectFrom('person').select(['person.id', 'person.thumbnailPath']).where('id', 'in', ids).execute(); } @GenerateSql({ params: [[], []] }) @@ -582,8 +673,19 @@ export class PersonRepository { .selectFrom('asset_face') .select('asset_face.id') .where('asset_face.assetId', '=', assetId) - .where('asset_face.personId', '=', personId) + .innerJoin('person', (join) => + join.onRef('person.faceClusterId', '=', 'asset_face.faceClusterId').on('person.id', '=', personId), + ) .innerJoin('asset', (join) => join.onRef('asset.id', '=', 'asset_face.assetId').on('asset.isOffline', '=', false)) .executeTakeFirst(); } + + streamForQueueThumbnailGeneration(thumbnailPath?: string) { + return this.db + .selectFrom('face_cluster') + .innerJoin('person', 'person.faceClusterId', 'face_cluster.id') + .select(['person.id as personId', 'face_cluster.featureFaceAssetId', 'face_cluster.id as faceClusterId']) + .$if(thumbnailPath !== undefined, (qb) => qb.where('person.thumbnailPath', '=', thumbnailPath!)) + .stream(); + } } diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 70ba09ef2c..22a9492698 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -163,7 +163,7 @@ export type OcrSearchOptions = SearchDateOptions & SearchOcrOptions; export type LargeAssetSearchOptions = AssetSearchOptions & { minFileSize?: number }; export interface FaceEmbeddingSearch extends SearchEmbeddingOptions { - hasPerson?: boolean; + hasFaceCluster?: boolean; numResults: number; maxDistance: number; minBirthDate?: Date | null; @@ -172,7 +172,7 @@ export interface FaceEmbeddingSearch extends SearchEmbeddingOptions { export interface FaceSearchResult { distance: number; id: string; - personId: string | null; + faceClusterId: string | null; } export interface AssetDuplicateResult { @@ -341,7 +341,7 @@ export class SearchRepository { }, ], }) - searchFaces({ userIds, embedding, numResults, maxDistance, hasPerson, minBirthDate }: FaceEmbeddingSearch) { + searchFaces({ userIds, embedding, numResults, maxDistance, hasFaceCluster, minBirthDate }: FaceEmbeddingSearch) { if (!z.int().min(1).max(1000).safeParse(numResults).success) { throw new Error(`Invalid value for 'numResults': ${numResults}`); } @@ -354,18 +354,18 @@ export class SearchRepository { .selectFrom('asset_face') .select([ 'asset_face.id', - 'asset_face.personId', + 'asset_face.faceClusterId', sql`face_search.embedding <=> ${embedding}`.as('distance'), ]) .innerJoin('asset', 'asset.id', 'asset_face.assetId') .innerJoin('face_search', 'face_search.faceId', 'asset_face.id') - .leftJoin('person', 'person.id', 'asset_face.personId') + .leftJoin('face_cluster', 'face_cluster.id', 'asset_face.faceClusterId') .where('asset.ownerId', '=', anyUuid(userIds)) .where('asset.deletedAt', 'is', null) - .$if(!!hasPerson, (qb) => qb.where('asset_face.personId', 'is not', null)) + .$if(!!hasFaceCluster, (qb) => qb.where('asset_face.faceClusterId', 'is not', null)) .$if(!!minBirthDate, (qb) => qb.where((eb) => - eb.or([eb('person.birthDate', 'is', null), eb('person.birthDate', '<=', minBirthDate!)]), + eb.or([eb('face_cluster.birthDate', 'is', null), eb('face_cluster.birthDate', '<=', minBirthDate!)]), ), ) .orderBy('distance') diff --git a/server/src/repositories/sync.repository.ts b/server/src/repositories/sync.repository.ts index eca8d18e66..f53bd79fbf 100644 --- a/server/src/repositories/sync.repository.ts +++ b/server/src/repositories/sync.repository.ts @@ -58,6 +58,7 @@ export class SyncRepository { assetMetadata: AssetMetadataSync; assetOcr: AssetOcrSync; authUser: AuthUserSync; + faceCluster: FaceClusterSync; memory: MemorySync; memoryToAsset: MemoryToAssetSync; partner: PartnerSync; @@ -82,6 +83,7 @@ export class SyncRepository { this.assetMetadata = new AssetMetadataSync(this.db); this.assetOcr = new AssetOcrSync(this.db); this.authUser = new AuthUserSync(this.db); + this.faceCluster = new FaceClusterSync(this.db); this.memory = new MemorySync(this.db); this.memoryToAsset = new MemoryToAssetSync(this.db); this.partner = new PartnerSync(this.db); @@ -434,22 +436,56 @@ class PersonSync extends BaseSync { @GenerateSql({ params: [dummyQueryOptions], stream: true }) getUpserts(options: SyncQueryOptions) { return this.upsertQuery('person', options) - .select([ - 'id', - 'createdAt', - 'updatedAt', - 'ownerId', - 'name', - 'birthDate', - 'isHidden', - 'isFavorite', - 'color', - 'updateId', - 'faceAssetId', - ]) + .select(['id', 'createdAt', 'updatedAt', 'ownerId', 'isHidden', 'isFavorite', 'updateId', 'faceClusterId']) .where('ownerId', '=', options.userId) .stream(); } + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] }) + getByFaceClusterId(faceClusterId: string, ownerId: string) { + return this.db + .selectFrom('person') + .selectAll() + .where('person.faceClusterId', '=', faceClusterId) + .where('person.ownerId', '=', ownerId) + .executeTakeFirst(); + } +} + +class FaceClusterSync extends BaseSync { + @GenerateSql({ params: [dummyQueryOptions], stream: true }) + getDeletes(options: SyncQueryOptions) { + return this.auditQuery('face_cluster_audit', options) + .select(['id', 'faceClusterId']) + .where('face_cluster_audit.userId', '=', options.userId) + .stream(); + } + + cleanupAuditTable(daysAgo: number) { + return this.auditCleanup('face_cluster_audit', daysAgo); + } + + @GenerateSql({ params: [dummyQueryOptions], stream: true }) + getUpserts(options: SyncQueryOptions) { + return this.upsertQuery('face_cluster', options) + .select([ + 'face_cluster.id', + 'face_cluster.birthDate', + 'face_cluster.createdAt', + 'face_cluster.featureFaceAssetId', + 'face_cluster.name', + 'face_cluster.updateId', + 'face_cluster.updatedAt', + ]) + .innerJoin('person', 'person.faceClusterId', 'face_cluster.id') + .where('person.ownerId', '=', options.userId) + .stream(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + getById(id: string) { + return this.db.selectFrom('face_cluster').selectAll().where('face_cluster.id', '=', id).executeTakeFirstOrThrow(); + } } class AssetFaceSync extends BaseSync { @@ -472,7 +508,7 @@ class AssetFaceSync extends BaseSync { .select([ 'asset_face.id', 'assetId', - 'personId', + 'faceClusterId', 'imageWidth', 'imageHeight', 'boundingBoxX1', diff --git a/server/src/schema/functions.ts b/server/src/schema/functions.ts index 230f22478d..2fafe065c3 100644 --- a/server/src/schema/functions.ts +++ b/server/src/schema/functions.ts @@ -205,6 +205,21 @@ export const person_delete_audit = registerFunction({ END`, }); +export const face_cluster_delete_audit = registerFunction({ + name: 'face_cluster_delete_audit', + returnType: 'TRIGGER', + language: 'PLPGSQL', + body: ` + #variable_conflict use_column + BEGIN + INSERT INTO face_cluster_audit ("faceClusterId", "userId") + SELECT "old"."id", "person"."ownerId" + FROM OLD + INNER JOIN person ON "person"."faceClusterId" = "old"."id"; + RETURN NULL; + END`, +}); + export const user_metadata_audit = registerFunction({ name: 'user_metadata_audit', returnType: 'TRIGGER', diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index 4e55ff4cf6..c25095eb4e 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -14,6 +14,7 @@ import { asset_ocr_delete_audit, f_concat_ws, f_unaccent, + face_cluster_delete_audit, immich_uuid_v7, ll_to_earth_public, memory_asset_delete_audit, @@ -47,6 +48,8 @@ import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table'; import { AssetOcrAuditTable } from 'src/schema/tables/asset-ocr-audit.table'; import { AssetOcrTable } from 'src/schema/tables/asset-ocr.table'; import { AssetTable } from 'src/schema/tables/asset.table'; +import { FaceClusterAuditTable } from 'src/schema/tables/face-cluster-audit.table'; +import { FaceClusterTable } from 'src/schema/tables/face-cluster.table'; import { FaceSearchTable } from 'src/schema/tables/face-search.table'; import { GeodataPlacesTable } from 'src/schema/tables/geodata-places.table'; import { IntegrityReportTable } from 'src/schema/tables/integrity-report.table'; @@ -114,6 +117,8 @@ export class ImmichDatabase { AssetTable, AssetFileTable, AssetExifTable, + FaceClusterAuditTable, + FaceClusterTable, FaceSearchTable, GeodataPlacesTable, IntegrityReportTable, @@ -170,6 +175,7 @@ export class ImmichDatabase { memory_asset_delete_audit, stack_delete_audit, person_delete_audit, + face_cluster_delete_audit, user_metadata_audit, asset_metadata_audit, asset_face_audit, @@ -217,6 +223,9 @@ export interface DB { asset_keyframe: AssetKeyframeTable; ocr_search: OcrSearchTable; + face_cluster: FaceClusterTable; + face_cluster_audit: FaceClusterAuditTable; + face_search: FaceSearchTable; geodata_places: GeodataPlacesTable; diff --git a/server/src/schema/migrations/1784729921063-FaceClusterTable.ts b/server/src/schema/migrations/1784729921063-FaceClusterTable.ts new file mode 100644 index 0000000000..eb2b21a474 --- /dev/null +++ b/server/src/schema/migrations/1784729921063-FaceClusterTable.ts @@ -0,0 +1,148 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "asset_face" RENAME COLUMN "personId" TO "faceClusterId";`.execute(db); + await sql`CREATE INDEX "asset_face_faceClusterId_assetId_notDeleted_isVisible_idx" ON "asset_face" ("faceClusterId", "assetId") WHERE ("deletedAt" IS NULL AND "isVisible" IS TRUE);`.execute( + db, + ); + await sql`CREATE INDEX "asset_face_assetId_faceClusterId_idx" ON "asset_face" ("assetId", "faceClusterId");`.execute( + db, + ); + await sql`DROP INDEX "asset_face_personId_assetId_notDeleted_isVisible_idx";`.execute(db); + await sql`DROP INDEX "asset_face_assetId_personId_idx";`.execute(db); + await sql`ALTER TABLE "person" RENAME TO "face_cluster"`.execute(db); + + await sql`CREATE TABLE "person" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), + "ownerId" uuid NOT NULL, + "faceClusterId" uuid NOT NULL, + "isHidden" boolean DEFAULT false, + "isFavorite" boolean DEFAULT false, + "thumbnailPath" character varying DEFAULT '', + "updateId" uuid NOT NULL DEFAULT immich_uuid_v7() + );`.execute(db); + await db + .insertInto('person') + .columns(['faceClusterId', 'createdAt', 'ownerId', 'isHidden', 'isFavorite', 'thumbnailPath']) + .expression((eb) => eb.selectFrom('face_cluster').select(['id', 'createdAt', 'ownerId', 'isHidden', 'isFavorite', 'thumbnailPath'])) + .execute(); + + await sql`ALTER TABLE "face_cluster" DROP COLUMN "ownerId";`.execute(db); + await sql`ALTER TABLE "face_cluster" DROP COLUMN "thumbnailPath";`.execute(db); + await sql`ALTER TABLE "face_cluster" DROP COLUMN "isHidden";`.execute(db); + await sql`ALTER TABLE "face_cluster" RENAME COLUMN "faceAssetId" TO "featureFaceAssetId";`.execute(db); + await sql`ALTER TABLE "face_cluster" DROP COLUMN "isFavorite";`.execute(db); + await sql`ALTER TABLE "face_cluster" DROP COLUMN "color";`.execute(db); + await sql`ALTER TABLE "asset_face" ADD CONSTRAINT "asset_face_faceClusterId_fkey" FOREIGN KEY ("faceClusterId") REFERENCES "face_cluster" ("id") ON UPDATE CASCADE ON DELETE SET NULL;`.execute( + db, + ); + await sql`ALTER TABLE "asset_face" DROP CONSTRAINT "asset_face_personId_fkey";`.execute(db); + await sql`CREATE INDEX "person_faceClusterId_idx" ON "person" ("faceClusterId");`.execute(db); + await sql`DROP INDEX "person_faceAssetId_idx";`.execute(db); + await sql`DROP INDEX "idx_person_name_trigram";`.execute(db); + await sql`ALTER TABLE "person" ADD CONSTRAINT "person_faceClusterId_fkey" FOREIGN KEY ("faceClusterId") REFERENCES "face_cluster" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute( + db, + ); + await sql`ALTER TABLE "face_cluster" DROP CONSTRAINT "person_faceAssetId_fkey";`.execute(db); + await sql`ALTER TABLE "face_cluster" DROP CONSTRAINT "person_birthDate_chk";`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "face_cluster_updatedAt" + BEFORE UPDATE ON "person" + FOR EACH ROW + EXECUTE FUNCTION updated_at();`.execute(db); + await sql`DROP TRIGGER "person_updatedAt" ON "face_cluster";`.execute(db); + await sql`CREATE INDEX "idx_person_name_trigram" ON "face_cluster" USING gin (f_unaccent("name") gin_trgm_ops);`.execute( + db, + ); + await sql`CREATE INDEX "face_cluster_featureFaceAssetId_idx" ON "face_cluster" ("featureFaceAssetId");`.execute(db); + await sql`CREATE INDEX "face_cluster_updateId_idx" ON "face_cluster" ("updateId");`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "person_delete_audit" + AFTER DELETE ON "face_cluster" + REFERENCING OLD TABLE AS "old" + FOR EACH STATEMENT + WHEN (pg_trigger_depth() = 0) + EXECUTE FUNCTION person_delete_audit();`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "face_cluster_updatedAt" + BEFORE UPDATE ON "face_cluster" + FOR EACH ROW + EXECUTE FUNCTION updated_at();`.execute(db); + await sql`ALTER TABLE "face_cluster" ADD CONSTRAINT "face_cluster_featureFaceAssetId_fkey" FOREIGN KEY ("featureFaceAssetId") REFERENCES "asset_face" ("id") ON UPDATE NO ACTION ON DELETE SET NULL;`.execute( + db, + ); + await sql`CREATE TABLE "face_cluster_audit" ( + "id" uuid NOT NULL DEFAULT immich_uuid_v7(), + "faceClusterId" uuid NOT NULL, + "deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp(), + CONSTRAINT "face_cluster_audit_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "face_cluster_audit_faceClusterId_idx" ON "face_cluster_audit" ("faceClusterId");`.execute(db); + await sql`CREATE INDEX "face_cluster_audit_deletedAt_idx" ON "face_cluster_audit" ("deletedAt");`.execute(db); + await sql`UPDATE "migration_overrides" SET "value" = '{"type":"index","name":"idx_person_name_trigram","sql":"CREATE INDEX \\"idx_person_name_trigram\\" ON \\"face_cluster\\" USING gin (f_unaccent(\\"name\\") gin_trgm_ops);"}'::jsonb WHERE "name" = 'index_idx_person_name_trigram';`.execute( + db, + ); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_face_cluster_updatedAt', '{"type":"trigger","name":"face_cluster_updatedAt","sql":"CREATE OR REPLACE TRIGGER \\"face_cluster_updatedAt\\"\\n BEFORE UPDATE ON \\"person\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();"}'::jsonb);`.execute( + db, + ); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_asset_face_faceClusterId_assetId_notDeleted_isVisible_idx', '{"type":"index","name":"asset_face_faceClusterId_assetId_notDeleted_isVisible_idx","sql":"CREATE INDEX \\"asset_face_faceClusterId_assetId_notDeleted_isVisible_idx\\" ON \\"asset_face\\" (\\"faceClusterId\\", \\"assetId\\") WHERE (\\"deletedAt\\" IS NULL AND \\"isVisible\\" IS TRUE);"}'::jsonb);`.execute( + db, + ); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_person_updatedAt';`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_asset_face_personId_assetId_notDeleted_isVisible_idx';`.execute( + db, + ); +} + +export async function down(_db: Kysely): Promise { + // probably not supported? + // + // await sql`ALTER TABLE "person" ADD "birthDate" date;`.execute(db); + // await sql`ALTER TABLE "person" ADD "faceAssetId" uuid;`.execute(db); + // await sql`ALTER TABLE "person" ADD "name" character varying NOT NULL DEFAULT ''::character varying;`.execute(db); + // await sql`ALTER TABLE "person" ADD "color" character varying;`.execute(db); + // await sql`ALTER TABLE "person" DROP COLUMN "faceClusterId";`.execute(db); + // await sql`CREATE INDEX "person_faceAssetId_idx" ON "person" ("faceAssetId");`.execute(db); + // await sql`CREATE INDEX "idx_person_name_trigram" ON "person" USING gin (f_unaccent((name)::text));`.execute(db); + // await sql`DROP INDEX "person_faceClusterId_idx";`.execute(db); + // await sql`ALTER TABLE "person" ADD CONSTRAINT "person_faceAssetId_fkey" FOREIGN KEY ("faceAssetId") REFERENCES "asset_face" ("id") ON UPDATE NO ACTION ON DELETE SET NULL;`.execute( + // db, + // ); + // await sql`ALTER TABLE "person" ADD CONSTRAINT "person_birthDate_chk" CHECK ((("birthDate" <= CURRENT_DATE)));`.execute( + // db, + // ); + // await sql`ALTER TABLE "person" DROP CONSTRAINT "person_faceClusterId_fkey";`.execute(db); + // await sql`CREATE OR REPLACE TRIGGER "person_updatedAt" + // BEFORE UPDATE ON "person" + // FOR EACH ROW + // EXECUTE FUNCTION updated_at();`.execute(db); + // await sql`DROP TRIGGER "face_cluster_updatedAt" ON "person";`.execute(db); + // await sql`ALTER TABLE "asset_face" RENAME COLUMN "faceClusterId" TO "personId";`.execute(db); + // await sql`CREATE INDEX "asset_face_personId_assetId_notDeleted_isVisible_idx" ON "asset_face" ("personId", "assetId") WHERE ((("deletedAt" IS NULL) AND ("isVisible" IS TRUE)));`.execute( + // db, + // ); + // await sql`CREATE INDEX "asset_face_assetId_personId_idx" ON "asset_face" ("assetId", "personId");`.execute(db); + // await sql`DROP INDEX "asset_face_faceClusterId_assetId_notDeleted_isVisible_idx";`.execute(db); + // await sql`DROP INDEX "asset_face_assetId_faceClusterId_idx";`.execute(db); + // await sql`ALTER TABLE "asset_face" ADD CONSTRAINT "asset_face_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person" ("id") ON UPDATE CASCADE ON DELETE SET NULL;`.execute( + // db, + // ); + // await sql`ALTER TABLE "asset_face" DROP CONSTRAINT "asset_face_faceClusterId_fkey";`.execute(db); + // await sql`ALTER TABLE "face_cluster" DROP CONSTRAINT "face_cluster_featureFaceAssetId_fkey";`.execute(db); + // await sql`DROP TABLE "face_cluster";`.execute(db); + // await sql`DROP TRIGGER "person_delete_audit" ON "face_cluster";`.execute(db); + // await sql`DROP TRIGGER "face_cluster_updatedAt" ON "face_cluster";`.execute(db); + // await sql`DROP TABLE "face_cluster_audit";`.execute(db); + // await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE INDEX \\"idx_person_name_trigram\\" ON \\"person\\" USING gin (f_unaccent(\\"name\\") gin_trgm_ops);","name":"idx_person_name_trigram","type":"index"}'::jsonb WHERE "name" = 'index_idx_person_name_trigram';`.execute( + // db, + // ); + // await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_person_updatedAt', '{"sql":"CREATE OR REPLACE TRIGGER \\"person_updatedAt\\"\\n BEFORE UPDATE ON \\"person\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();","name":"person_updatedAt","type":"trigger"}'::jsonb);`.execute( + // db, + // ); + // await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_asset_face_personId_assetId_notDeleted_isVisible_idx', '{"sql":"CREATE INDEX \\"asset_face_personId_assetId_notDeleted_isVisible_idx\\" ON \\"asset_face\\" (\\"personId\\", \\"assetId\\") WHERE (\\"deletedAt\\" IS NULL AND \\"isVisible\\" IS TRUE);","name":"asset_face_personId_assetId_notDeleted_isVisible_idx","type":"index"}'::jsonb);`.execute( + // db, + // ); + // await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_face_cluster_updatedAt';`.execute(db); + // await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_asset_face_faceClusterId_assetId_notDeleted_isVisible_idx';`.execute( + // db, + // ); +} diff --git a/server/src/schema/migrations/1784729968082-FaceClusterMigrationCleanup.ts b/server/src/schema/migrations/1784729968082-FaceClusterMigrationCleanup.ts new file mode 100644 index 0000000000..9ccb6fe7f3 --- /dev/null +++ b/server/src/schema/migrations/1784729968082-FaceClusterMigrationCleanup.ts @@ -0,0 +1,38 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`DROP INDEX "person_updateId_idx";`.execute(db); + await sql`ALTER TABLE "face_cluster" RENAME CONSTRAINT "person_pkey" TO "face_cluster_pkey";`.execute(db); + await sql`ALTER TABLE "face_cluster" ADD CONSTRAINT "face_cluster_birthDate_chk" CHECK ("birthDate" <= CURRENT_DATE);`.execute(db); + await sql`ALTER INDEX "asset_face_personId_assetId_idx" RENAME TO "asset_face_faceClusterId_assetId_idx";`.execute(db); + await sql`ALTER TABLE "person" ALTER COLUMN "isHidden" SET NOT NULL;`.execute(db); + await sql`ALTER TABLE "person" ALTER COLUMN "isFavorite" SET NOT NULL;`.execute(db); + await sql`ALTER TABLE "person" ALTER COLUMN "thumbnailPath" SET NOT NULL;`.execute(db); + await sql`CREATE INDEX "person_ownerId_idx" ON "person" ("ownerId");`.execute(db); + await sql`CREATE INDEX "person_updateId_idx" ON "person" ("updateId");`.execute(db); + await sql`ALTER TABLE "person" ADD CONSTRAINT "person_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "user" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db); + await sql`ALTER TABLE "person" ADD CONSTRAINT "UQ_ownerId_faceClusterId" UNIQUE ("ownerId", "faceClusterId");`.execute(db); + await sql`ALTER TABLE "person" ADD CONSTRAINT "person_pkey" PRIMARY KEY ("id");`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "person_delete_audit" + AFTER DELETE ON "person" + REFERENCING OLD TABLE AS "old" + FOR EACH STATEMENT + WHEN (pg_trigger_depth() = 0) + EXECUTE FUNCTION person_delete_audit();`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "person" ALTER COLUMN "isHidden" DROP NOT NULL;`.execute(db); + await sql`ALTER TABLE "person" ALTER COLUMN "isFavorite" DROP NOT NULL;`.execute(db); + await sql`ALTER TABLE "person" ALTER COLUMN "thumbnailPath" DROP NOT NULL;`.execute(db); + await sql`DROP INDEX "person_ownerId_idx";`.execute(db); + await sql`DROP INDEX "person_updateId_idx";`.execute(db); + await sql`ALTER TABLE "person" DROP CONSTRAINT "person_ownerId_fkey";`.execute(db); + await sql`ALTER TABLE "person" DROP CONSTRAINT "UQ_ownerId_faceClusterId";`.execute(db); + await sql`ALTER TABLE "person" DROP CONSTRAINT "person_pkey";`.execute(db); + await sql`DROP TRIGGER "person_delete_audit" ON "person";`.execute(db); + await sql`CREATE INDEX "person_updateId_idx" ON "face_cluster" ("updateId");`.execute(db); + await sql`ALTER TABLE "face_cluster" RENAME CONSTRAINT "face_cluster_pkey" TO "person_pkey";`.execute(db); + await sql`ALTER TABLE "face_cluster" DROP CONSTRAINT "face_cluster_birthDate_chk";`.execute(db); + await sql`ALTER INDEX "asset_face_faceClusterId_assetId_idx" RENAME TO "asset_face_personId_assetId_idx";`.execute(db); +} diff --git a/server/src/schema/migrations/1784890527928-FaceClusterAfterDeleteTrigger.ts b/server/src/schema/migrations/1784890527928-FaceClusterAfterDeleteTrigger.ts new file mode 100644 index 0000000000..c9e0686be7 --- /dev/null +++ b/server/src/schema/migrations/1784890527928-FaceClusterAfterDeleteTrigger.ts @@ -0,0 +1,37 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE OR REPLACE FUNCTION face_cluster_delete_audit() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS $$ + BEGIN + INSERT INTO face_cluster_audit ("faceClusterId") + SELECT "id" + FROM OLD; + RETURN NULL; + END + $$;`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "face_cluster_delete_audit" + AFTER DELETE ON "face_cluster" + REFERENCING OLD TABLE AS "old" + FOR EACH STATEMENT + WHEN (pg_trigger_depth() = 0) + EXECUTE FUNCTION face_cluster_delete_audit();`.execute(db); + await sql`DROP TRIGGER "person_delete_audit" ON "face_cluster";`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_face_cluster_delete_audit', '{"type":"function","name":"face_cluster_delete_audit","sql":"CREATE OR REPLACE FUNCTION face_cluster_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO face_cluster_audit (\\"faceClusterId\\")\\n SELECT \\"id\\"\\n FROM OLD;\\n RETURN NULL;\\n END\\n $$;"}'::jsonb);`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_face_cluster_delete_audit', '{"type":"trigger","name":"face_cluster_delete_audit","sql":"CREATE OR REPLACE TRIGGER \\"face_cluster_delete_audit\\"\\n AFTER DELETE ON \\"face_cluster\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() = 0)\\n EXECUTE FUNCTION face_cluster_delete_audit();"}'::jsonb);`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DROP TRIGGER "face_cluster_delete_audit" ON "face_cluster";`.execute(db); + await sql`DROP FUNCTION face_cluster_delete_audit;`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "person_delete_audit" + AFTER DELETE ON "face_cluster" + REFERENCING OLD TABLE AS "old" + FOR EACH STATEMENT + WHEN ((pg_trigger_depth() = 0)) + EXECUTE FUNCTION person_delete_audit();`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_face_cluster_delete_audit';`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_face_cluster_delete_audit';`.execute(db); +} diff --git a/server/src/schema/migrations/1784894647978-FaceClusterIndexNameFixes.ts b/server/src/schema/migrations/1784894647978-FaceClusterIndexNameFixes.ts new file mode 100644 index 0000000000..c9ef51fe27 --- /dev/null +++ b/server/src/schema/migrations/1784894647978-FaceClusterIndexNameFixes.ts @@ -0,0 +1,27 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER INDEX "idx_person_name_trigram" RENAME TO "idx_face_cluster_name_trigram";`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "person_cluster_updatedAt" + BEFORE UPDATE ON "person" + FOR EACH ROW + EXECUTE FUNCTION updated_at();`.execute(db); + await sql`DROP TRIGGER "face_cluster_updatedAt" ON "person";`.execute(db); + await sql`UPDATE "migration_overrides" SET "value" = '{"type":"trigger","name":"face_cluster_updatedAt","sql":"CREATE OR REPLACE TRIGGER \\"face_cluster_updatedAt\\"\\n BEFORE UPDATE ON \\"face_cluster\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();"}'::jsonb WHERE "name" = 'trigger_face_cluster_updatedAt';`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_idx_face_cluster_name_trigram', '{"type":"index","name":"idx_face_cluster_name_trigram","sql":"CREATE INDEX \\"idx_face_cluster_name_trigram\\" ON \\"face_cluster\\" USING gin (f_unaccent(\\"name\\") gin_trgm_ops);"}'::jsonb);`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_person_cluster_updatedAt', '{"type":"trigger","name":"person_cluster_updatedAt","sql":"CREATE OR REPLACE TRIGGER \\"person_cluster_updatedAt\\"\\n BEFORE UPDATE ON \\"person\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();"}'::jsonb);`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_idx_person_name_trigram';`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`CREATE OR REPLACE TRIGGER "face_cluster_updatedAt" + BEFORE UPDATE ON "person" + FOR EACH ROW + EXECUTE FUNCTION updated_at();`.execute(db); + await sql`DROP TRIGGER "person_cluster_updatedAt" ON "person";`.execute(db); + await sql`ALTER INDEX "idx_face_cluster_name_trigram" RENAME TO "idx_person_name_trigram";`.execute(db); + await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE OR REPLACE TRIGGER \\"face_cluster_updatedAt\\"\\n BEFORE UPDATE ON \\"person\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();","name":"face_cluster_updatedAt","type":"trigger"}'::jsonb WHERE "name" = 'trigger_face_cluster_updatedAt';`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_idx_person_name_trigram', '{"sql":"CREATE INDEX \\"idx_person_name_trigram\\" ON \\"face_cluster\\" USING gin (f_unaccent(\\"name\\") gin_trgm_ops);","name":"idx_person_name_trigram","type":"index"}'::jsonb);`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_idx_face_cluster_name_trigram';`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_person_cluster_updatedAt';`.execute(db); +} diff --git a/server/src/schema/migrations/1784901570124-FaceClusterAuditTableUserId.ts b/server/src/schema/migrations/1784901570124-FaceClusterAuditTableUserId.ts new file mode 100644 index 0000000000..06af6ceb8f --- /dev/null +++ b/server/src/schema/migrations/1784901570124-FaceClusterAuditTableUserId.ts @@ -0,0 +1,37 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE OR REPLACE FUNCTION face_cluster_delete_audit() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS $$ + BEGIN + INSERT INTO face_cluster_audit ("faceClusterId", "userId") + SELECT "OLD"."id", "person"."ownerId" + FROM OLD + INNER JOIN person ON "person"."faceClusterId" = "OLD"."id"; + RETURN NULL; + END + $$;`.execute(db); + await sql`ALTER TABLE "face_cluster_audit" ADD "userId" uuid NOT NULL;`.execute(db); + await sql`CREATE INDEX "face_cluster_audit_userId_idx" ON "face_cluster_audit" ("userId");`.execute(db); + await sql`UPDATE "migration_overrides" SET "value" = '{"type":"function","name":"face_cluster_delete_audit","sql":"CREATE OR REPLACE FUNCTION face_cluster_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO face_cluster_audit (\\"faceClusterId\\", \\"userId\\")\\n SELECT \\"OLD\\".\\"id\\", \\"person\\".\\"ownerId\\"\\n FROM OLD\\n INNER JOIN person ON \\"person\\".\\"faceClusterId\\" = \\"OLD\\".\\"id\\";\\n RETURN NULL;\\n END\\n $$;"}'::jsonb WHERE "name" = 'function_face_cluster_delete_audit';`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`CREATE OR REPLACE FUNCTION public.face_cluster_delete_audit() + RETURNS trigger + LANGUAGE plpgsql +AS $function$ + BEGIN + INSERT INTO face_cluster_audit ("faceClusterId") + SELECT "id" + FROM OLD; + RETURN NULL; + END + $function$ +`.execute(db); + await sql`ALTER TABLE "face_cluster_audit" DROP COLUMN "userId";`.execute(db); + await sql`DROP INDEX "face_cluster_audit_userId_idx";`.execute(db); + await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE OR REPLACE FUNCTION face_cluster_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO face_cluster_audit (\\"faceClusterId\\")\\n SELECT \\"id\\"\\n FROM OLD;\\n RETURN NULL;\\n END\\n $$;","name":"face_cluster_delete_audit","type":"function"}'::jsonb WHERE "name" = 'function_face_cluster_delete_audit';`.execute(db); +} diff --git a/server/src/schema/migrations/1784901789626-FaceClusterAuditTriggerOldCasing.ts b/server/src/schema/migrations/1784901789626-FaceClusterAuditTriggerOldCasing.ts new file mode 100644 index 0000000000..c2fd75cafb --- /dev/null +++ b/server/src/schema/migrations/1784901789626-FaceClusterAuditTriggerOldCasing.ts @@ -0,0 +1,34 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE OR REPLACE FUNCTION face_cluster_delete_audit() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS $$ + BEGIN + INSERT INTO face_cluster_audit ("faceClusterId", "userId") + SELECT OLD."id", "person"."ownerId" + FROM OLD + INNER JOIN person ON "person"."faceClusterId" = OLD."id"; + RETURN NULL; + END + $$;`.execute(db); + await sql`UPDATE "migration_overrides" SET "value" = '{"type":"function","name":"face_cluster_delete_audit","sql":"CREATE OR REPLACE FUNCTION face_cluster_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO face_cluster_audit (\\"faceClusterId\\", \\"userId\\")\\n SELECT OLD.\\"id\\", \\"person\\".\\"ownerId\\"\\n FROM OLD\\n INNER JOIN person ON \\"person\\".\\"faceClusterId\\" = OLD.\\"id\\";\\n RETURN NULL;\\n END\\n $$;"}'::jsonb WHERE "name" = 'function_face_cluster_delete_audit';`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`CREATE OR REPLACE FUNCTION public.face_cluster_delete_audit() + RETURNS trigger + LANGUAGE plpgsql +AS $function$ + BEGIN + INSERT INTO face_cluster_audit ("faceClusterId", "userId") + SELECT "OLD"."id", "person"."ownerId" + FROM OLD + INNER JOIN person ON "person"."faceClusterId" = "OLD"."id"; + RETURN NULL; + END + $function$ +`.execute(db); + await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE OR REPLACE FUNCTION face_cluster_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO face_cluster_audit (\\"faceClusterId\\", \\"userId\\")\\n SELECT \\"OLD\\".\\"id\\", \\"person\\".\\"ownerId\\"\\n FROM OLD\\n INNER JOIN person ON \\"person\\".\\"faceClusterId\\" = \\"OLD\\".\\"id\\";\\n RETURN NULL;\\n END\\n $$;","name":"face_cluster_delete_audit","type":"function"}'::jsonb WHERE "name" = 'function_face_cluster_delete_audit';`.execute(db); +} diff --git a/server/src/schema/migrations/1784901995401-FaceClusterFixIdek.ts b/server/src/schema/migrations/1784901995401-FaceClusterFixIdek.ts new file mode 100644 index 0000000000..74f33b30ab --- /dev/null +++ b/server/src/schema/migrations/1784901995401-FaceClusterFixIdek.ts @@ -0,0 +1,34 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE OR REPLACE FUNCTION face_cluster_delete_audit() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS $$ + BEGIN + INSERT INTO face_cluster_audit ("faceClusterId", "userId") + SELECT OLD.id, "person"."ownerId" + FROM OLD + INNER JOIN person ON "person"."faceClusterId" = OLD.id; + RETURN NULL; + END + $$;`.execute(db); + await sql`UPDATE "migration_overrides" SET "value" = '{"type":"function","name":"face_cluster_delete_audit","sql":"CREATE OR REPLACE FUNCTION face_cluster_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO face_cluster_audit (\\"faceClusterId\\", \\"userId\\")\\n SELECT OLD.id, \\"person\\".\\"ownerId\\"\\n FROM OLD\\n INNER JOIN person ON \\"person\\".\\"faceClusterId\\" = OLD.id;\\n RETURN NULL;\\n END\\n $$;"}'::jsonb WHERE "name" = 'function_face_cluster_delete_audit';`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`CREATE OR REPLACE FUNCTION public.face_cluster_delete_audit() + RETURNS trigger + LANGUAGE plpgsql +AS $function$ + BEGIN + INSERT INTO face_cluster_audit ("faceClusterId", "userId") + SELECT OLD."id", "person"."ownerId" + FROM OLD + INNER JOIN person ON "person"."faceClusterId" = OLD."id"; + RETURN NULL; + END + $function$ +`.execute(db); + await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE OR REPLACE FUNCTION face_cluster_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO face_cluster_audit (\\"faceClusterId\\", \\"userId\\")\\n SELECT OLD.\\"id\\", \\"person\\".\\"ownerId\\"\\n FROM OLD\\n INNER JOIN person ON \\"person\\".\\"faceClusterId\\" = OLD.\\"id\\";\\n RETURN NULL;\\n END\\n $$;","name":"face_cluster_delete_audit","type":"function"}'::jsonb WHERE "name" = 'function_face_cluster_delete_audit';`.execute(db); +} diff --git a/server/src/schema/migrations/1784902324220-FaceClusterFixIdek2.ts b/server/src/schema/migrations/1784902324220-FaceClusterFixIdek2.ts new file mode 100644 index 0000000000..963ea3e24e --- /dev/null +++ b/server/src/schema/migrations/1784902324220-FaceClusterFixIdek2.ts @@ -0,0 +1,34 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE OR REPLACE FUNCTION face_cluster_delete_audit() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS $$ + BEGIN + INSERT INTO face_cluster_audit ("faceClusterId", "userId") + SELECT "old"."id", "person"."ownerId" + FROM OLD + INNER JOIN person ON "person"."faceClusterId" = "old"."id"; + RETURN NULL; + END + $$;`.execute(db); + await sql`UPDATE "migration_overrides" SET "value" = '{"type":"function","name":"face_cluster_delete_audit","sql":"CREATE OR REPLACE FUNCTION face_cluster_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO face_cluster_audit (\\"faceClusterId\\", \\"userId\\")\\n SELECT \\"old\\".\\"id\\", \\"person\\".\\"ownerId\\"\\n FROM OLD\\n INNER JOIN person ON \\"person\\".\\"faceClusterId\\" = \\"old\\".\\"id\\";\\n RETURN NULL;\\n END\\n $$;"}'::jsonb WHERE "name" = 'function_face_cluster_delete_audit';`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`CREATE OR REPLACE FUNCTION public.face_cluster_delete_audit() + RETURNS trigger + LANGUAGE plpgsql +AS $function$ + BEGIN + INSERT INTO face_cluster_audit ("faceClusterId", "userId") + SELECT OLD.id, "person"."ownerId" + FROM OLD + INNER JOIN person ON "person"."faceClusterId" = OLD.id; + RETURN NULL; + END + $function$ +`.execute(db); + await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE OR REPLACE FUNCTION face_cluster_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO face_cluster_audit (\\"faceClusterId\\", \\"userId\\")\\n SELECT OLD.id, \\"person\\".\\"ownerId\\"\\n FROM OLD\\n INNER JOIN person ON \\"person\\".\\"faceClusterId\\" = OLD.id;\\n RETURN NULL;\\n END\\n $$;","name":"face_cluster_delete_audit","type":"function"}'::jsonb WHERE "name" = 'function_face_cluster_delete_audit';`.execute(db); +} diff --git a/server/src/schema/migrations/1784902476471-FaceClusterFixIdek2.ts b/server/src/schema/migrations/1784902476471-FaceClusterFixIdek2.ts new file mode 100644 index 0000000000..c721a40e6f --- /dev/null +++ b/server/src/schema/migrations/1784902476471-FaceClusterFixIdek2.ts @@ -0,0 +1,35 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE OR REPLACE FUNCTION face_cluster_delete_audit() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS $$ + #variable_conflict use_column + BEGIN + INSERT INTO face_cluster_audit ("faceClusterId", "userId") + SELECT "old"."id", "person"."ownerId" + FROM OLD + INNER JOIN person ON "person"."faceClusterId" = "old"."id"; + RETURN NULL; + END + $$;`.execute(db); + await sql`UPDATE "migration_overrides" SET "value" = '{"type":"function","name":"face_cluster_delete_audit","sql":"CREATE OR REPLACE FUNCTION face_cluster_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n #variable_conflict use_column\\n BEGIN\\n INSERT INTO face_cluster_audit (\\"faceClusterId\\", \\"userId\\")\\n SELECT \\"old\\".\\"id\\", \\"person\\".\\"ownerId\\"\\n FROM OLD\\n INNER JOIN person ON \\"person\\".\\"faceClusterId\\" = \\"old\\".\\"id\\";\\n RETURN NULL;\\n END\\n $$;"}'::jsonb WHERE "name" = 'function_face_cluster_delete_audit';`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`CREATE OR REPLACE FUNCTION public.face_cluster_delete_audit() + RETURNS trigger + LANGUAGE plpgsql +AS $function$ + BEGIN + INSERT INTO face_cluster_audit ("faceClusterId", "userId") + SELECT "old"."id", "person"."ownerId" + FROM OLD + INNER JOIN person ON "person"."faceClusterId" = "old"."id"; + RETURN NULL; + END + $function$ +`.execute(db); + await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE OR REPLACE FUNCTION face_cluster_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO face_cluster_audit (\\"faceClusterId\\", \\"userId\\")\\n SELECT \\"old\\".\\"id\\", \\"person\\".\\"ownerId\\"\\n FROM OLD\\n INNER JOIN person ON \\"person\\".\\"faceClusterId\\" = \\"old\\".\\"id\\";\\n RETURN NULL;\\n END\\n $$;","name":"face_cluster_delete_audit","type":"function"}'::jsonb WHERE "name" = 'function_face_cluster_delete_audit';`.execute(db); +} diff --git a/server/src/schema/tables/asset-face.table.ts b/server/src/schema/tables/asset-face.table.ts index b67e5e5dac..8c0277b3cb 100644 --- a/server/src/schema/tables/asset-face.table.ts +++ b/server/src/schema/tables/asset-face.table.ts @@ -15,7 +15,7 @@ import { SourceType } from 'src/enum'; import { asset_face_source_type } from 'src/schema/enums'; import { asset_face_audit } from 'src/schema/functions'; import { AssetTable } from 'src/schema/tables/asset.table'; -import { PersonTable } from 'src/schema/tables/person.table'; +import { FaceClusterTable } from 'src/schema/tables/face-cluster.table'; @Table({ name: 'asset_face' }) @UpdatedAtTrigger('asset_face_updatedAt') @@ -26,13 +26,13 @@ import { PersonTable } from 'src/schema/tables/person.table'; when: 'pg_trigger_depth() = 0', }) // schemaFromDatabase does not preserve column order -@Index({ name: 'asset_face_assetId_personId_idx', columns: ['assetId', 'personId'] }) +@Index({ name: 'asset_face_assetId_faceClusterId_idx', columns: ['assetId', 'faceClusterId'] }) @Index({ - name: 'asset_face_personId_assetId_notDeleted_isVisible_idx', - columns: ['personId', 'assetId'], + name: 'asset_face_faceClusterId_assetId_notDeleted_isVisible_idx', + columns: ['faceClusterId', 'assetId'], where: '"deletedAt" IS NULL AND "isVisible" IS TRUE', }) -@Index({ columns: ['personId', 'assetId'] }) +@Index({ columns: ['faceClusterId', 'assetId'] }) export class AssetFaceTable { @PrimaryGeneratedColumn() id!: Generated; @@ -45,14 +45,14 @@ export class AssetFaceTable { }) assetId!: string; - @ForeignKeyColumn(() => PersonTable, { + @ForeignKeyColumn(() => FaceClusterTable, { onDelete: 'SET NULL', onUpdate: 'CASCADE', nullable: true, - // [personId, assetId] makes this redundant + // [faceClusterId, assetId] makes this redundant index: false, }) - personId!: string | null; + faceClusterId!: string | null; @Column({ default: 0, type: 'integer' }) imageWidth!: Generated; diff --git a/server/src/schema/tables/face-cluster-audit.table.ts b/server/src/schema/tables/face-cluster-audit.table.ts new file mode 100644 index 0000000000..dea05e30c2 --- /dev/null +++ b/server/src/schema/tables/face-cluster-audit.table.ts @@ -0,0 +1,17 @@ +import { Column, CreateDateColumn, Generated, Table, Timestamp } from '@immich/sql-tools'; +import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; + +@Table('face_cluster_audit') +export class FaceClusterAuditTable { + @PrimaryGeneratedUuidV7Column() + id!: Generated; + + @Column({ type: 'uuid', index: true }) + faceClusterId!: string; + + @Column({ type: 'uuid', index: true }) + userId!: string; + + @CreateDateColumn({ default: () => 'clock_timestamp()', index: true }) + deletedAt!: Generated; +} diff --git a/server/src/schema/tables/face-cluster.table.ts b/server/src/schema/tables/face-cluster.table.ts new file mode 100644 index 0000000000..af1ebbf42d --- /dev/null +++ b/server/src/schema/tables/face-cluster.table.ts @@ -0,0 +1,53 @@ +import { + AfterDeleteTrigger, + Check, + Column, + CreateDateColumn, + ForeignKeyColumn, + Generated, + Index, + PrimaryGeneratedColumn, + Table, + Timestamp, + UpdateDateColumn, +} from '@immich/sql-tools'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { face_cluster_delete_audit } from 'src/schema/functions'; +import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; + +@Table('face_cluster') +@Index({ + name: 'idx_face_cluster_name_trigram', + using: 'gin', + expression: 'f_unaccent("name") gin_trgm_ops', +}) +@UpdatedAtTrigger('face_cluster_updatedAt') +@AfterDeleteTrigger({ + scope: 'statement', + function: face_cluster_delete_audit, + referencingOldTableAs: 'old', + when: 'pg_trigger_depth() = 0', +}) +@Check({ name: 'face_cluster_birthDate_chk', expression: `"birthDate" <= CURRENT_DATE` }) +export class FaceClusterTable { + @PrimaryGeneratedColumn('uuid') + id!: Generated; + + @CreateDateColumn() + createdAt!: Generated; + + @UpdateDateColumn() + updatedAt!: Generated; + + @Column({ default: '' }) + name!: Generated; + + @Column({ type: 'date', nullable: true }) + birthDate!: Timestamp | null; + + @ForeignKeyColumn(() => AssetFaceTable, { onDelete: 'SET NULL', nullable: true }) + featureFaceAssetId!: string | null; + + @UpdateIdColumn({ index: true }) + updateId!: Generated; +} diff --git a/server/src/schema/tables/person.table.ts b/server/src/schema/tables/person.table.ts index 35447acfd0..61c68830dc 100644 --- a/server/src/schema/tables/person.table.ts +++ b/server/src/schema/tables/person.table.ts @@ -1,35 +1,29 @@ import { AfterDeleteTrigger, - Check, Column, CreateDateColumn, ForeignKeyColumn, Generated, - Index, PrimaryGeneratedColumn, Table, Timestamp, + Unique, UpdateDateColumn, } from '@immich/sql-tools'; import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { person_delete_audit } from 'src/schema/functions'; -import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; +import { FaceClusterTable } from 'src/schema/tables/face-cluster.table'; import { UserTable } from 'src/schema/tables/user.table'; @Table('person') -@Index({ - name: 'idx_person_name_trigram', - using: 'gin', - expression: 'f_unaccent("name") gin_trgm_ops', -}) -@UpdatedAtTrigger('person_updatedAt') +@UpdatedAtTrigger('person_cluster_updatedAt') @AfterDeleteTrigger({ scope: 'statement', function: person_delete_audit, referencingOldTableAs: 'old', when: 'pg_trigger_depth() = 0', }) -@Check({ name: 'person_birthDate_chk', expression: `"birthDate" <= CURRENT_DATE` }) +@Unique({ name: 'UQ_ownerId_faceClusterId', columns: ['ownerId', 'faceClusterId'] }) export class PersonTable { @PrimaryGeneratedColumn('uuid') id!: Generated; @@ -43,26 +37,17 @@ export class PersonTable { @ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false }) ownerId!: string; - @Column({ default: '' }) - name!: Generated; - - @Column({ default: '' }) - thumbnailPath!: Generated; + @ForeignKeyColumn(() => FaceClusterTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', index: true }) + faceClusterId!: string; @Column({ type: 'boolean', default: false }) isHidden!: Generated; - @Column({ type: 'date', nullable: true }) - birthDate!: Timestamp | null; - - @ForeignKeyColumn(() => AssetFaceTable, { onDelete: 'SET NULL', nullable: true }) - faceAssetId!: string | null; - @Column({ type: 'boolean', default: false }) isFavorite!: Generated; - @Column({ type: 'character varying', nullable: true, default: null }) - color!: string | null; + @Column({ default: '' }) + thumbnailPath!: Generated; @UpdateIdColumn({ index: true }) updateId!: Generated; diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 58b797a18b..28bd5ac9bd 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -28,7 +28,7 @@ import { PersonFactory } from 'test/factories/person.factory'; import { probeStub } from 'test/fixtures/media.stub'; import { personThumbnailStub } from 'test/fixtures/person.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; -import { getForGenerateThumbnail } from 'test/mappers'; +import { getForGenerateThumbnail, getForPerson, getForQueueFaceThumbnailGeneration } from 'test/mappers'; import { factory, newUuid } from 'test/small.factory'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; @@ -53,10 +53,12 @@ describe(MediaService.name, () => { describe('handleQueueGenerateThumbnails', () => { it('should queue all assets', async () => { const asset = AssetFactory.create(); - const person = PersonFactory.create({ faceAssetId: newUuid() }); + const person = PersonFactory.from().faceCluster({ featureFaceAssetId: newUuid() }).build(); mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset])); - mocks.person.getAll.mockReturnValue(makeStream([person])); + mocks.person.streamForQueueThumbnailGeneration.mockReturnValue( + makeStream([getForQueueFaceThumbnailGeneration(person)]), + ); await sut.handleQueueGenerateThumbnails({ force: true }); @@ -68,7 +70,7 @@ describe(MediaService.name, () => { }, ]); - expect(mocks.person.getAll).toHaveBeenCalledWith(undefined); + expect(mocks.person.streamForQueueThumbnailGeneration).toHaveBeenCalledWith(undefined); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.PersonGenerateThumbnail, @@ -80,7 +82,7 @@ describe(MediaService.name, () => { it('should queue trashed assets when force is true', async () => { const asset = AssetFactory.create({ status: AssetStatus.Trashed, deletedAt: new Date() }); mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset])); - mocks.person.getAll.mockReturnValue(makeStream()); + mocks.person.streamForQueueThumbnailGeneration.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: true }); @@ -96,7 +98,7 @@ describe(MediaService.name, () => { it('should queue archived assets when force is true', async () => { const asset = AssetFactory.create({ visibility: AssetVisibility.Archive }); mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset])); - mocks.person.getAll.mockReturnValue(makeStream()); + mocks.person.streamForQueueThumbnailGeneration.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: true }); @@ -116,15 +118,17 @@ describe(MediaService.name, () => { ]; mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([AssetFactory.create()])); - mocks.person.getAll.mockReturnValue(makeStream([person1, person2])); + mocks.person.streamForQueueThumbnailGeneration.mockReturnValue( + makeStream([getForQueueFaceThumbnailGeneration(person1), getForQueueFaceThumbnailGeneration(person2)]), + ); mocks.person.getRandomFace.mockResolvedValueOnce(AssetFaceFactory.create()); await sut.handleQueueGenerateThumbnails({ force: false }); expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false }); - expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); + expect(mocks.person.streamForQueueThumbnailGeneration).toHaveBeenCalledWith(''); expect(mocks.person.getRandomFace).toHaveBeenCalled(); - expect(mocks.person.update).toHaveBeenCalledTimes(1); + expect(mocks.person.updateFaceCluster).toHaveBeenCalledTimes(1); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.PersonGenerateThumbnail, @@ -138,7 +142,7 @@ describe(MediaService.name, () => { it('should queue all assets with missing resize path', async () => { const asset = AssetFactory.create(); mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset])); - mocks.person.getAll.mockReturnValue(makeStream()); + mocks.person.streamForQueueThumbnailGeneration.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: false }); expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false }); @@ -149,20 +153,20 @@ describe(MediaService.name, () => { }, ]); - expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); + expect(mocks.person.streamForQueueThumbnailGeneration).toHaveBeenCalledWith(''); }); it('should queue all assets with missing preview', async () => { const asset = AssetFactory.create(); mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset])); - mocks.person.getAll.mockReturnValue(makeStream()); + mocks.person.streamForQueueThumbnailGeneration.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: false }); expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false }); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.AssetGenerateThumbnails, data: { id: asset.id } }, ]); - expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); + expect(mocks.person.streamForQueueThumbnailGeneration).toHaveBeenCalledWith(''); }); it('should queue all assets with missing thumbhash', async () => { @@ -170,7 +174,7 @@ describe(MediaService.name, () => { .files([AssetFileType.Thumbnail, AssetFileType.Preview]) .build(); mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset])); - mocks.person.getAll.mockReturnValue(makeStream()); + mocks.person.streamForQueueThumbnailGeneration.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: false }); expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false }); @@ -178,14 +182,14 @@ describe(MediaService.name, () => { { name: JobName.AssetGenerateThumbnails, data: { id: asset.id } }, ]); - expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); + expect(mocks.person.streamForQueueThumbnailGeneration).toHaveBeenCalledWith(''); }); it('should queue all assets with missing fullsize when feature is enabled', async () => { mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true } } }); const asset = { id: factory.uuid(), isEdited: false }; mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset])); - mocks.person.getAll.mockReturnValue(makeStream()); + mocks.person.streamForQueueThumbnailGeneration.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: false }); expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: true }); @@ -196,26 +200,26 @@ describe(MediaService.name, () => { }, ]); - expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); + expect(mocks.person.streamForQueueThumbnailGeneration).toHaveBeenCalledWith(''); }); it('should not queue assets with missing fullsize when feature is disabled', async () => { mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: false } } }); const asset = { id: factory.uuid(), isEdited: false }; mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset])); - mocks.person.getAll.mockReturnValue(makeStream()); + mocks.person.streamForQueueThumbnailGeneration.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: false }); expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false }); expect(mocks.job.queueAll).toHaveBeenCalledWith([]); - expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); + expect(mocks.person.streamForQueueThumbnailGeneration).toHaveBeenCalledWith(''); }); it('should queue assets with edits but missing edited thumbnails', async () => { const asset = AssetFactory.from().edit().build(); mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset])); - mocks.person.getAll.mockReturnValue(makeStream()); + mocks.person.streamForQueueThumbnailGeneration.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: false }); expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false }); @@ -226,27 +230,27 @@ describe(MediaService.name, () => { }, ]); - expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); + expect(mocks.person.streamForQueueThumbnailGeneration).toHaveBeenCalledWith(''); }); it('should not queue assets with missing edited fullsize when feature is disabled', async () => { const asset = AssetFactory.from().edit().build(); mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: false } } }); mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset])); - mocks.person.getAll.mockReturnValue(makeStream()); + mocks.person.streamForQueueThumbnailGeneration.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: false }); expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false }); expect(mocks.job.queueAll).toHaveBeenCalledWith([]); - expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); + expect(mocks.person.streamForQueueThumbnailGeneration).toHaveBeenCalledWith(''); }); it('should queue assets with missing fullsize when force is true, regardless of setting', async () => { mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: false } } }); const asset = { id: factory.uuid(), isEdited: false }; mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset])); - mocks.person.getAll.mockReturnValue(makeStream()); + mocks.person.streamForQueueThumbnailGeneration.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: true }); expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: true, fullsizeEnabled: false }); @@ -257,13 +261,13 @@ describe(MediaService.name, () => { }, ]); - expect(mocks.person.getAll).toHaveBeenCalled(); + expect(mocks.person.streamForQueueThumbnailGeneration).toHaveBeenCalled(); }); it('should queue both regular and edited thumbnails for assets with edits when force is true', async () => { const asset = AssetFactory.from().edit().build(); mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset])); - mocks.person.getAll.mockReturnValue(makeStream()); + mocks.person.streamForQueueThumbnailGeneration.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: true }); expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: true, fullsizeEnabled: false }); @@ -278,7 +282,7 @@ describe(MediaService.name, () => { }, ]); - expect(mocks.person.getAll).toHaveBeenCalledWith(undefined); + expect(mocks.person.streamForQueueThumbnailGeneration).toHaveBeenCalledWith(undefined); }); }); @@ -1521,8 +1525,8 @@ describe(MediaService.name, () => { }); it('should skip a person without a face asset id', async () => { - const person = PersonFactory.create({ faceAssetId: null }); - mocks.person.getById.mockResolvedValue(person); + const person = PersonFactory.from().faceCluster({ featureFaceAssetId: null }).build(); + mocks.person.getById.mockResolvedValue(getForPerson(person)); await sut.handleGeneratePersonThumbnail({ id: person.id }); expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); }); diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 37817e0508..32f8604659 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -96,19 +96,19 @@ export class MediaService extends BaseService { await queueAll(); - const people = this.personRepository.getAll(force ? undefined : { thumbnailPath: '' }); + const people = this.personRepository.streamForQueueThumbnailGeneration(force ? undefined : ''); - for await (const person of people) { - if (!person.faceAssetId) { - const face = await this.personRepository.getRandomFace(person.id); + for await (const { faceClusterId, personId, featureFaceAssetId } of people) { + if (!featureFaceAssetId) { + const face = await this.personRepository.getRandomFace(personId); if (!face) { continue; } - await this.personRepository.update({ id: person.id, faceAssetId: face.id }); + await this.personRepository.updateFaceCluster({ id: faceClusterId, featureFaceAssetId: face.id }); } - jobs.push({ name: JobName.PersonGenerateThumbnail, data: { id: person.id } }); + jobs.push({ name: JobName.PersonGenerateThumbnail, data: { id: personId } }); if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) { await queueAll(); } diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 57c029961e..6a17a6912c 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -17,6 +17,7 @@ import { import { ImmichTags } from 'src/repositories/metadata.repository'; import { firstDateTime, MetadataService } from 'src/services/metadata.service'; import { AssetFactory } from 'test/factories/asset.factory'; +import { FaceClusterFactory } from 'test/factories/face-cluster.factory'; import { PersonFactory } from 'test/factories/person.factory'; import { videoInfoStub } from 'test/fixtures/media.stub'; import { tagStub } from 'test/fixtures/tag.stub'; @@ -1368,6 +1369,7 @@ describe(MetadataService.name, () => { mockReadTags(makeFaceTags({ Name: '' })); mocks.person.getDistinctNames.mockResolvedValue([]); mocks.person.createAll.mockResolvedValue([]); + mocks.person.createAllFaceClusters.mockResolvedValue([]); await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.person.createAll).not.toHaveBeenCalled(); expect(mocks.person.refreshFaces).not.toHaveBeenCalled(); @@ -1376,11 +1378,11 @@ describe(MetadataService.name, () => { it('should handle string coordinates in face region bounding box calculation by limiting to 16 decimal places', async () => { const asset = AssetFactory.create(); - const person = PersonFactory.create(); + const faceCluster = FaceClusterFactory.create(); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); - const faceTags = makeFaceTags({ Name: person.name }); + const faceTags = makeFaceTags({ Name: faceCluster.name }); // Simulating EXIF returning a string with >16 decimal places faceTags.RegionInfo!.RegionList[0].Area.X = '0.48564814814814824'; @@ -1388,8 +1390,9 @@ describe(MetadataService.name, () => { mockReadTags(faceTags); mocks.person.getDistinctNames.mockResolvedValue([]); - mocks.person.createAll.mockResolvedValue([person.id]); - mocks.person.update.mockResolvedValue(person); + mocks.person.createAll.mockResolvedValue([faceCluster.id]); + mocks.person.createAllFaceClusters.mockResolvedValue([]); + mocks.person.updateFaceCluster.mockResolvedValue(faceCluster); await sut.handleMetadataExtraction({ id: asset.id }); @@ -1409,20 +1412,25 @@ describe(MetadataService.name, () => { mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); - mockReadTags(makeFaceTags({ Name: person.name })); + mockReadTags(makeFaceTags({ Name: person.faceCluster.name })); mocks.person.getDistinctNames.mockResolvedValue([]); mocks.person.createAll.mockResolvedValue([person.id]); - mocks.person.update.mockResolvedValue(person); + mocks.person.createAllFaceClusters.mockResolvedValue([person.faceClusterId]); await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(asset.ownerId, { withHidden: true }); - expect(mocks.person.createAll).toHaveBeenCalledWith([expect.objectContaining({ name: person.name })]); + expect(mocks.person.createAll).toHaveBeenCalledWith([ + expect.objectContaining({ faceClusterId: person.faceClusterId }), + ]); + expect(mocks.person.createAllFaceClusters).toHaveBeenCalledWith([ + expect.objectContaining({ name: person.faceCluster.name }), + ]); expect(mocks.person.refreshFaces).toHaveBeenCalledWith( [ { id: 'random-uuid', assetId: asset.id, - personId: 'random-uuid', + faceClusterId: 'random-uuid', imageHeight: 100, imageWidth: 1000, boundingBoxX1: 0, @@ -1434,8 +1442,8 @@ describe(MetadataService.name, () => { ], [], ); - expect(mocks.person.updateAll).toHaveBeenCalledWith([ - { id: 'random-uuid', ownerId: asset.ownerId, faceAssetId: 'random-uuid' }, + expect(mocks.person.updateAllFaceClusters).toHaveBeenCalledWith([ + { id: 'random-uuid', featureFaceAssetId: 'random-uuid' }, ]); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { @@ -1451,10 +1459,9 @@ describe(MetadataService.name, () => { mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); - mockReadTags(makeFaceTags({ Name: person.name })); - mocks.person.getDistinctNames.mockResolvedValue([{ id: person.id, name: person.name }]); + mockReadTags(makeFaceTags({ Name: person.faceCluster.name })); + mocks.person.getDistinctNames.mockResolvedValue([{ id: person.faceCluster.id, name: person.faceCluster.name }]); mocks.person.createAll.mockResolvedValue([]); - mocks.person.update.mockResolvedValue(person); await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(asset.ownerId, { withHidden: true }); @@ -1464,7 +1471,7 @@ describe(MetadataService.name, () => { { id: 'random-uuid', assetId: asset.id, - personId: person.id, + faceClusterId: person.faceClusterId, imageHeight: 100, imageWidth: 1000, boundingBoxX1: 0, @@ -1476,7 +1483,7 @@ describe(MetadataService.name, () => { ], [], ); - expect(mocks.person.updateAll).not.toHaveBeenCalled(); + expect(mocks.person.updateAllFaceClusters).not.toHaveBeenCalled(); expect(mocks.job.queueAll).not.toHaveBeenCalledWith(); }); @@ -1538,22 +1545,27 @@ describe(MetadataService.name, () => { mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset)); mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } }); - mockReadTags(makeFaceTags({ Name: person.name }, orientation)); + mockReadTags(makeFaceTags({ Name: person.faceCluster.name }, orientation)); mocks.person.getDistinctNames.mockResolvedValue([]); mocks.person.createAll.mockResolvedValue([person.id]); - mocks.person.update.mockResolvedValue(person); + mocks.person.createAllFaceClusters.mockResolvedValue([person.faceClusterId]); await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(asset.ownerId, { withHidden: true, }); - expect(mocks.person.createAll).toHaveBeenCalledWith([expect.objectContaining({ name: person.name })]); + expect(mocks.person.createAll).toHaveBeenCalledWith([ + expect.objectContaining({ faceClusterId: person.faceClusterId }), + ]); + expect(mocks.person.createAllFaceClusters).toHaveBeenCalledWith([ + expect.objectContaining({ name: person.faceCluster.name }), + ]); expect(mocks.person.refreshFaces).toHaveBeenCalledWith( [ { id: 'random-uuid', assetId: asset.id, - personId: 'random-uuid', + faceClusterId: 'random-uuid', imageWidth: imgW, imageHeight: imgH, boundingBoxX1: x1, @@ -1565,8 +1577,8 @@ describe(MetadataService.name, () => { ], [], ); - expect(mocks.person.updateAll).toHaveBeenCalledWith([ - { id: 'random-uuid', ownerId: asset.ownerId, faceAssetId: 'random-uuid' }, + expect(mocks.person.updateAllFaceClusters).toHaveBeenCalledWith([ + { id: 'random-uuid', featureFaceAssetId: 'random-uuid' }, ]); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 171dcfe514..e8501c5952 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -28,7 +28,7 @@ import { ReverseGeocodeResult } from 'src/repositories/map.repository'; import { ImmichTags } from 'src/repositories/metadata.repository'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; -import { PersonTable } from 'src/schema/tables/person.table'; +import { FaceClusterTable } from 'src/schema/tables/face-cluster.table'; import { BaseService } from 'src/services/base.service'; import { JobItem, JobOf } from 'src/types'; import { getAssetFiles } from 'src/utils/asset.util'; @@ -916,8 +916,8 @@ export class MetadataService extends BaseService { const facesToAdd: (Insertable & { assetId: string })[] = []; const existingNames = await this.personRepository.getDistinctNames(asset.ownerId, { withHidden: true }); const existingNameMap = new Map(existingNames.map(({ id, name }) => [name.toLowerCase(), id])); - const missing: (Insertable & { ownerId: string })[] = []; - const missingWithFaceAsset: { id: string; ownerId: string; faceAssetId: string }[] = []; + const missing: Insertable[] = []; + const missingWithFaceAsset: { id: string; featureFaceAssetId: string }[] = []; const adjustedRegionInfo = this.orientRegionInfo(tags.RegionInfo, tags.Orientation); const imageWidth = adjustedRegionInfo.AppliedToDimensions.W; @@ -929,7 +929,7 @@ export class MetadataService extends BaseService { } const loweredName = region.Name.toLowerCase(); - const personId = existingNameMap.get(loweredName) || this.cryptoRepository.randomUUID(); + const faceClusterId = existingNameMap.get(loweredName) || this.cryptoRepository.randomUUID(); const X = Number(region.Area.X); const Y = Number(region.Area.Y); @@ -938,7 +938,7 @@ export class MetadataService extends BaseService { const face = { id: this.cryptoRepository.randomUUID(), - personId, + faceClusterId, assetId: asset.id, imageWidth, imageHeight, @@ -951,14 +951,17 @@ export class MetadataService extends BaseService { facesToAdd.push(face); if (!existingNameMap.has(loweredName)) { - missing.push({ id: personId, ownerId: asset.ownerId, name: region.Name }); - missingWithFaceAsset.push({ id: personId, ownerId: asset.ownerId, faceAssetId: face.id }); + missing.push({ id: faceClusterId, name: region.Name }); + missingWithFaceAsset.push({ id: faceClusterId, featureFaceAssetId: face.id }); } } if (missing.length > 0) { this.logger.debugFn(() => `Creating missing persons: ${missing.map((p) => `${p.name}/${p.id}`)}`); - const newPersonIds = await this.personRepository.createAll(missing); + const newFaceClusterIds = await this.personRepository.createAllFaceClusters(missing); + const newPersonIds = await this.personRepository.createAll( + newFaceClusterIds.map((faceClusterId) => ({ faceClusterId, ownerId: asset.ownerId })), + ); const jobs = newPersonIds.map((id) => ({ name: JobName.PersonGenerateThumbnail, data: { id } }) as const); await this.jobRepository.queueAll(jobs); } @@ -979,7 +982,7 @@ export class MetadataService extends BaseService { } if (missingWithFaceAsset.length > 0) { - await this.personRepository.updateAll(missingWithFaceAsset); + await this.personRepository.updateAllFaceClusters(missingWithFaceAsset); } } diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index e6a11786af..077c9f4a6f 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -18,6 +18,7 @@ import { getForAssetFace, getForDetectedFaces, getForFacialRecognitionJob, + getForPerson, } from 'test/mappers'; import { newDate, newUuid } from 'test/small.factory'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; @@ -40,7 +41,7 @@ describe(PersonService.name, () => { const [person, hiddenPerson] = [PersonFactory.create(), PersonFactory.create({ isHidden: true })]; mocks.person.getAllForUser.mockResolvedValue({ - items: [person, hiddenPerson], + items: [getForPerson(person), getForPerson(hiddenPerson)], hasNextPage: false, }); mocks.person.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 }); @@ -66,7 +67,7 @@ describe(PersonService.name, () => { const [isFavorite, person] = [PersonFactory.create({ isFavorite: true }), PersonFactory.create()]; mocks.person.getAllForUser.mockResolvedValue({ - items: [isFavorite, person], + items: [getForPerson(isFavorite), getForPerson(person)], hasNextPage: false, }); mocks.person.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 }); @@ -92,7 +93,7 @@ describe(PersonService.name, () => { it('should require person.read permission', async () => { const auth = AuthFactory.create(); const person = PersonFactory.create(); - mocks.person.getById.mockResolvedValue(person); + mocks.person.getById.mockResolvedValue(getForPerson(person)); await expect(sut.getById(auth, person.id)).rejects.toBeInstanceOf(BadRequestException); expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); }); @@ -108,7 +109,7 @@ describe(PersonService.name, () => { const auth = AuthFactory.create(); const person = PersonFactory.create(); - mocks.person.getById.mockResolvedValue(person); + mocks.person.getById.mockResolvedValue(getForPerson(person)); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); await expect(sut.getById(auth, person.id)).resolves.toEqual(expect.objectContaining({ id: person.id })); expect(mocks.person.getById).toHaveBeenCalledWith(person.id); @@ -121,7 +122,7 @@ describe(PersonService.name, () => { const auth = AuthFactory.create(); const person = PersonFactory.create(); - mocks.person.getById.mockResolvedValue(person); + mocks.person.getById.mockResolvedValue(getForPerson(person)); await expect(sut.getThumbnail(auth, person.id)).rejects.toBeInstanceOf(BadRequestException); expect(mocks.storage.createReadStream).not.toHaveBeenCalled(); expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); @@ -140,7 +141,7 @@ describe(PersonService.name, () => { const auth = AuthFactory.create(); const person = PersonFactory.create({ thumbnailPath: '' }); - mocks.person.getById.mockResolvedValue(person); + mocks.person.getById.mockResolvedValue(getForPerson(person)); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); await expect(sut.getThumbnail(auth, person.id)).rejects.toBeInstanceOf(NotFoundException); expect(mocks.storage.createReadStream).not.toHaveBeenCalled(); @@ -151,7 +152,7 @@ describe(PersonService.name, () => { const auth = AuthFactory.create(); const person = PersonFactory.create(); - mocks.person.getById.mockResolvedValue(person); + mocks.person.getById.mockResolvedValue(getForPerson(person)); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); await expect(sut.getThumbnail(auth, person.id)).resolves.toEqual( new ImmichFileResponse({ @@ -169,7 +170,7 @@ describe(PersonService.name, () => { const auth = AuthFactory.create(); const person = PersonFactory.create(); - mocks.person.getById.mockResolvedValue(person); + mocks.person.getById.mockResolvedValue(getForPerson(person)); await expect(sut.update(auth, person.id, { name: 'Person 1' })).rejects.toBeInstanceOf(BadRequestException); expect(mocks.person.update).not.toHaveBeenCalled(); expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); @@ -186,36 +187,46 @@ describe(PersonService.name, () => { it("should update a person's name", async () => { const auth = AuthFactory.create(); - const person = PersonFactory.create({ name: 'Person 1' }); + const person = PersonFactory.from().faceCluster({ name: 'Person 1' }).build(); mocks.person.update.mockResolvedValue(person); + mocks.person.updateFaceCluster.mockResolvedValue(person.faceCluster); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); + mocks.person.getById.mockResolvedValue(getForPerson(person)); await expect(sut.update(auth, person.id, { name: 'Person 1' })).resolves.toEqual( expect.objectContaining({ id: person.id, name: 'Person 1' }), ); - expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, name: 'Person 1' }); + expect(mocks.person.updateFaceCluster).toHaveBeenCalledWith({ id: person.faceClusterId, name: 'Person 1' }); expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); }); it("should update a person's date of birth", async () => { const auth = AuthFactory.create(); - const person = PersonFactory.create({ birthDate: new Date('1976-06-30') }); + const person = PersonFactory.from() + .faceCluster({ birthDate: new Date('1976-06-30') }) + .build(); mocks.person.update.mockResolvedValue(person); + mocks.person.updateFaceCluster.mockResolvedValue(person.faceCluster); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); + mocks.person.getById.mockResolvedValue(getForPerson(person)); await expect(sut.update(auth, person.id, { birthDate: '1976-06-30' })).resolves.toEqual({ id: person.id, - name: person.name, + name: person.faceCluster.name, + faceClusterId: person.faceClusterId, birthDate: '1976-06-30', thumbnailPath: person.thumbnailPath, isHidden: false, isFavorite: false, updatedAt: expect.any(String), }); - expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, birthDate: '1976-06-30' }); + expect(mocks.person.updateFaceCluster).toHaveBeenCalledWith({ + id: person.faceClusterId, + birthDate: '1976-06-30', + }); expect(mocks.job.queue).not.toHaveBeenCalled(); expect(mocks.job.queueAll).not.toHaveBeenCalled(); expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); @@ -227,6 +238,7 @@ describe(PersonService.name, () => { mocks.person.update.mockResolvedValue(person); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); + mocks.person.getById.mockResolvedValue(getForPerson(person)); await expect(sut.update(auth, person.id, { isHidden: true })).resolves.toEqual( expect.objectContaining({ isHidden: true }), @@ -242,6 +254,7 @@ describe(PersonService.name, () => { mocks.person.update.mockResolvedValue(person); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); + mocks.person.getById.mockResolvedValue(getForPerson(person)); await expect(sut.update(auth, person.id, { isFavorite: true })).resolves.toEqual( expect.objectContaining({ isFavorite: true }), @@ -257,15 +270,20 @@ describe(PersonService.name, () => { const person = PersonFactory.create(); mocks.person.update.mockResolvedValue(person); + mocks.person.updateFaceCluster.mockResolvedValue(person.faceCluster); mocks.person.getForFeatureFaceUpdate.mockResolvedValue(face); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([face.assetId])); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); + mocks.person.getById.mockResolvedValue(getForPerson(person)); await expect(sut.update(auth, person.id, { featureFaceAssetId: face.assetId })).resolves.toEqual( expect.objectContaining({ id: person.id }), ); - expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, faceAssetId: face.id }); + expect(mocks.person.updateFaceCluster).toHaveBeenCalledWith({ + id: person.faceClusterId, + featureFaceAssetId: face.id, + }); expect(mocks.person.getForFeatureFaceUpdate).toHaveBeenCalledWith({ assetId: face.assetId, personId: person.id, @@ -281,11 +299,11 @@ describe(PersonService.name, () => { const auth = AuthFactory.create(); const person = PersonFactory.create(); - mocks.person.getById.mockResolvedValue(person); + mocks.person.getById.mockResolvedValue(getForPerson(person)); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); await expect(sut.update(auth, person.id, { featureFaceAssetId: '-1' })).rejects.toThrow(BadRequestException); - expect(mocks.person.update).not.toHaveBeenCalled(); + expect(mocks.person.updateFaceCluster).not.toHaveBeenCalled(); expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); }); }); @@ -321,7 +339,7 @@ describe(PersonService.name, () => { const person = PersonFactory.create(); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); - mocks.person.getById.mockResolvedValue(person); + mocks.person.getById.mockResolvedValue(getForPerson(person)); mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([face.id])); mocks.person.getFacesByIds.mockResolvedValue([getForAssetFace(face)]); mocks.person.reassignFace.mockResolvedValue(1); @@ -378,19 +396,19 @@ describe(PersonService.name, () => { it('should create a manual face and initialize the person feature photo creation', async () => { const auth = AuthFactory.create(); const asset = AssetFactory.create(); - const person = PersonFactory.create({ faceAssetId: null }); + const person = PersonFactory.from().faceCluster({ featureFaceAssetId: null }).build(); const featureFace = AssetFaceFactory.create({ assetId: asset.id, - personId: person.id, + faceClusterId: person.faceClusterId, sourceType: SourceType.Manual, }); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); mocks.asset.getById.mockResolvedValue(getForAsset(asset)); - mocks.person.getById.mockResolvedValue(person); + mocks.person.getById.mockResolvedValue(getForPerson(person)); mocks.person.getRandomFace.mockResolvedValue(featureFace); - mocks.person.update.mockResolvedValue({ ...person, faceAssetId: featureFace.id }); + mocks.person.updateFaceCluster.mockResolvedValue({ ...person.faceCluster, featureFaceAssetId: featureFace.id }); await expect( sut.createFace(auth, { @@ -408,7 +426,7 @@ describe(PersonService.name, () => { expect(mocks.asset.getById).toHaveBeenCalledWith(asset.id, { edits: true, exifInfo: true }); expect(mocks.person.createAssetFace).toHaveBeenCalledWith({ assetId: asset.id, - personId: person.id, + faceClusterId: person.faceClusterId, imageHeight: 500, imageWidth: 400, boundingBoxX1: 10, @@ -418,7 +436,10 @@ describe(PersonService.name, () => { sourceType: SourceType.Manual, }); expect(mocks.person.getRandomFace).toHaveBeenCalledWith(person.id); - expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, faceAssetId: featureFace.id }); + expect(mocks.person.updateFaceCluster).toHaveBeenCalledWith({ + id: person.faceClusterId, + featureFaceAssetId: featureFace.id, + }); expect(mocks.job.queueAll).toHaveBeenCalledWith([ { name: JobName.PersonGenerateThumbnail, data: { id: person.id } }, ]); @@ -427,12 +448,12 @@ describe(PersonService.name, () => { it('should not update the person feature photo if one already exists', async () => { const auth = AuthFactory.create(); const asset = AssetFactory.create(); - const person = PersonFactory.create({ faceAssetId: newUuid() }); + const person = PersonFactory.from().faceCluster({ featureFaceAssetId: newUuid() }).build(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); mocks.asset.getById.mockResolvedValue(getForAsset(asset)); - mocks.person.getById.mockResolvedValue(person); + mocks.person.getById.mockResolvedValue(getForPerson(person)); await expect( sut.createFace(auth, { @@ -456,7 +477,7 @@ describe(PersonService.name, () => { it('should reject creating a face on an asset the user does not own', async () => { const auth = AuthFactory.create(); const asset = AssetFactory.create(); - const person = PersonFactory.create({ faceAssetId: null }); + const person = PersonFactory.from().faceCluster({ featureFaceAssetId: null }).build(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set()); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); @@ -502,13 +523,14 @@ describe(PersonService.name, () => { mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([face.id])); mocks.person.getFaceById.mockResolvedValue(getForAssetFace(face)); mocks.person.reassignFace.mockResolvedValue(1); - mocks.person.getById.mockResolvedValue(person); + mocks.person.getById.mockResolvedValue(getForPerson(person)); await expect(sut.reassignFacesById(AuthFactory.create(), person.id, { id: face.id })).resolves.toEqual({ - birthDate: person.birthDate, + birthDate: person.faceCluster.birthDate, isHidden: person.isHidden, isFavorite: person.isFavorite, id: person.id, - name: person.name, + faceClusterId: person.faceClusterId, + name: person.faceCluster.name, thumbnailPath: person.thumbnailPath, updatedAt: expect.any(String), }); @@ -524,7 +546,7 @@ describe(PersonService.name, () => { mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); mocks.person.getFaceById.mockResolvedValue(getForAssetFace(face)); mocks.person.reassignFace.mockResolvedValue(1); - mocks.person.getById.mockResolvedValue(person); + mocks.person.getById.mockResolvedValue(getForPerson(person)); await expect( sut.reassignFacesById(AuthFactory.create(), person.id, { id: face.id, @@ -539,11 +561,14 @@ describe(PersonService.name, () => { describe('createPerson', () => { it('should create a new person', async () => { const auth = AuthFactory.create(); + const person = PersonFactory.create(); - mocks.person.create.mockResolvedValue(PersonFactory.create()); + mocks.person.createFaceCluster.mockResolvedValue(person.faceCluster); + mocks.person.create.mockResolvedValue(person); + mocks.person.getById.mockResolvedValue(getForPerson(person)); await expect(sut.create(auth, {})).resolves.toBeDefined(); - expect(mocks.person.create).toHaveBeenCalledWith({ ownerId: auth.user.id }); + expect(mocks.person.create).toHaveBeenCalledWith({ ownerId: auth.user.id, faceClusterId: person.faceClusterId }); }); }); @@ -703,7 +728,7 @@ describe(PersonService.name, () => { await sut.handleQueueRecognizeFaces({}); expect(mocks.person.getAllFaces).toHaveBeenCalledWith({ - personId: null, + faceClusterId: null, sourceType: SourceType.MachineLearning, }); expect(mocks.job.queueAll).toHaveBeenCalledWith([ @@ -769,7 +794,7 @@ describe(PersonService.name, () => { expect(mocks.systemMetadata.get).toHaveBeenCalledWith(SystemMetadataKey.FacialRecognitionState); expect(mocks.person.getLatestFaceDate).toHaveBeenCalledOnce(); expect(mocks.person.getAllFaces).toHaveBeenCalledWith({ - personId: null, + faceClusterId: null, sourceType: SourceType.MachineLearning, }); expect(mocks.job.queueAll).toHaveBeenCalledWith([ @@ -1036,11 +1061,11 @@ describe(PersonService.name, () => { expect(mocks.person.reassignFaces).toHaveBeenCalledTimes(1); expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ faceIds: expect.arrayContaining([noPerson1.id]), - newPersonId: primaryFace.person!.id, + newFaceClusterId: primaryFace.person!.faceClusterId, }); expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ faceIds: expect.not.arrayContaining([face.id]), - newPersonId: primaryFace.person!.id, + newFaceClusterId: primaryFace.person!.faceClusterId, }); }); @@ -1049,7 +1074,9 @@ describe(PersonService.name, () => { const [noPerson, face, faceWithBirthDate] = [ AssetFaceFactory.create({ assetId: asset.id }), AssetFaceFactory.from().person().build(), - AssetFaceFactory.from().person({ birthDate: newDate() }).build(), + AssetFaceFactory.from() + .person({}, (builder) => builder.faceCluster({ birthDate: newDate() })) + .build(), ]; const faces = [ @@ -1069,11 +1096,11 @@ describe(PersonService.name, () => { expect(mocks.person.reassignFaces).toHaveBeenCalledTimes(1); expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ faceIds: expect.arrayContaining([noPerson.id]), - newPersonId: face.person!.id, + newFaceClusterId: face.person!.faceClusterId, }); expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ faceIds: expect.not.arrayContaining([face.id]), - newPersonId: face.person!.id, + newFaceClusterId: face.person!.faceClusterId, }); }); @@ -1082,7 +1109,9 @@ describe(PersonService.name, () => { const [noPerson, face, faceWithBirthDate] = [ AssetFaceFactory.create({ assetId: asset.id }), AssetFaceFactory.from().person().build(), - AssetFaceFactory.from().person({ birthDate: newDate() }).build(), + AssetFaceFactory.from() + .person({}, (builder) => builder.faceCluster({ birthDate: newDate() })) + .build(), ]; const faces = [ @@ -1102,11 +1131,11 @@ describe(PersonService.name, () => { expect(mocks.person.reassignFaces).toHaveBeenCalledTimes(1); expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ faceIds: expect.arrayContaining([noPerson.id]), - newPersonId: faceWithBirthDate.person!.id, + newFaceClusterId: faceWithBirthDate.person!.faceClusterId, }); expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ faceIds: expect.not.arrayContaining([face.id]), - newPersonId: faceWithBirthDate.person!.id, + newFaceClusterId: faceWithBirthDate.person!.faceClusterId, }); }); @@ -1123,17 +1152,21 @@ describe(PersonService.name, () => { mocks.systemMetadata.get.mockResolvedValue({ machineLearning: { facialRecognition: { minFaces: 1 } } }); mocks.search.searchFaces.mockResolvedValue(faces); mocks.person.getFaceForFacialRecognitionJob.mockResolvedValue(getForFacialRecognitionJob(noPerson1, asset)); + mocks.person.createFaceCluster.mockResolvedValue(person.faceCluster); mocks.person.create.mockResolvedValue(person); await sut.handleRecognizeFaces({ id: noPerson1.id }); expect(mocks.person.create).toHaveBeenCalledWith({ ownerId: asset.ownerId, - faceAssetId: noPerson1.id, + faceClusterId: person.faceClusterId, + }); + expect(mocks.person.createFaceCluster).toHaveBeenCalledWith({ + featureFaceAssetId: noPerson1.id, }); expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ faceIds: [noPerson1.id], - newPersonId: person.id, + newFaceClusterId: person.faceClusterId, }); }); @@ -1207,8 +1240,8 @@ describe(PersonService.name, () => { const auth = AuthFactory.create(); const [person, mergePerson] = [PersonFactory.create(), PersonFactory.create()]; - mocks.person.getById.mockResolvedValueOnce(person); - mocks.person.getById.mockResolvedValueOnce(mergePerson); + mocks.person.getById.mockResolvedValueOnce(getForPerson(person)); + mocks.person.getById.mockResolvedValueOnce(getForPerson(mergePerson)); await expect(sut.mergePerson(auth, person.id, { ids: [mergePerson.id] })).rejects.toBeInstanceOf( BadRequestException, @@ -1224,8 +1257,8 @@ describe(PersonService.name, () => { const auth = AuthFactory.create(); const [person, mergePerson] = [PersonFactory.create(), PersonFactory.create()]; - mocks.person.getById.mockResolvedValueOnce(person); - mocks.person.getById.mockResolvedValueOnce(mergePerson); + mocks.person.getById.mockResolvedValueOnce(getForPerson(person)); + mocks.person.getById.mockResolvedValueOnce(getForPerson(mergePerson)); mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([person.id])); mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([mergePerson.id])); @@ -1234,8 +1267,8 @@ describe(PersonService.name, () => { ]); expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ - newPersonId: person.id, - oldPersonId: mergePerson.id, + newFaceClusterId: person.faceClusterId, + oldFaceClusterId: mergePerson.faceClusterId, }); expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); @@ -1244,13 +1277,13 @@ describe(PersonService.name, () => { it('should merge two people with smart merge', async () => { const auth = AuthFactory.create(); const [person, mergePerson] = [ - PersonFactory.create({ name: undefined }), - PersonFactory.create({ name: 'Merge person' }), + PersonFactory.from().faceCluster({ name: undefined }).build(), + PersonFactory.from().faceCluster({ name: 'Merge person' }).build(), ]; - mocks.person.getById.mockResolvedValueOnce(person); - mocks.person.getById.mockResolvedValueOnce(mergePerson); - mocks.person.update.mockResolvedValue({ ...person, name: mergePerson.name }); + mocks.person.getById.mockResolvedValueOnce(getForPerson(person)); + mocks.person.getById.mockResolvedValueOnce(getForPerson(mergePerson)); + mocks.person.updateFaceCluster.mockResolvedValue({ ...person.faceCluster, name: mergePerson.faceCluster.name }); mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([person.id])); mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([mergePerson.id])); @@ -1259,13 +1292,13 @@ describe(PersonService.name, () => { ]); expect(mocks.person.reassignFaces).toHaveBeenCalledWith({ - newPersonId: person.id, - oldPersonId: mergePerson.id, + newFaceClusterId: person.faceClusterId, + oldFaceClusterId: mergePerson.faceClusterId, }); - expect(mocks.person.update).toHaveBeenCalledWith({ - id: person.id, - name: mergePerson.name, + expect(mocks.person.updateFaceCluster).toHaveBeenCalledWith({ + id: person.faceCluster.id, + name: mergePerson.faceCluster.name, }); expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); @@ -1286,7 +1319,7 @@ describe(PersonService.name, () => { const auth = AuthFactory.create(); const person = PersonFactory.create(); - mocks.person.getById.mockResolvedValueOnce(person); + mocks.person.getById.mockResolvedValueOnce(getForPerson(person)); mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([person.id])); mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['unknown'])); @@ -1303,8 +1336,8 @@ describe(PersonService.name, () => { const auth = AuthFactory.create(); const [person, mergePerson] = [PersonFactory.create(), PersonFactory.create()]; - mocks.person.getById.mockResolvedValueOnce(person); - mocks.person.getById.mockResolvedValueOnce(mergePerson); + mocks.person.getById.mockResolvedValueOnce(getForPerson(person)); + mocks.person.getById.mockResolvedValueOnce(getForPerson(mergePerson)); mocks.person.reassignFaces.mockRejectedValue(new Error('update failed')); mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([person.id])); mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([mergePerson.id])); @@ -1323,7 +1356,7 @@ describe(PersonService.name, () => { const auth = AuthFactory.create(); const person = PersonFactory.create(); - mocks.person.getById.mockResolvedValue(person); + mocks.person.getById.mockResolvedValue(getForPerson(person)); mocks.person.getStatistics.mockResolvedValue({ assets: 3 }); mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id])); await expect(sut.getStatistics(auth, person.id)).resolves.toEqual({ assets: 3 }); @@ -1334,7 +1367,7 @@ describe(PersonService.name, () => { const auth = AuthFactory.create(); const person = PersonFactory.create(); - mocks.person.getById.mockResolvedValue(person); + mocks.person.getById.mockResolvedValue(getForPerson(person)); await expect(sut.getStatistics(auth, person.id)).rejects.toBeInstanceOf(BadRequestException); expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id])); }); @@ -1356,7 +1389,7 @@ describe(PersonService.name, () => { imageHeight: 500, imageWidth: 400, sourceType: SourceType.MachineLearning, - person: mapPerson(person), + person: mapPerson(getForPerson(person)), }); }); diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 8e0cd2ff01..54f6a36a50 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -1,7 +1,6 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { Insertable, Updateable } from 'kysely'; import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; -import { Person } from 'src/database'; import { Chunked, OnJob } from 'src/decorators'; import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; @@ -37,6 +36,7 @@ import { import { BoundingBox } from 'src/repositories/machine-learning.repository'; import { UpdateFacesData } from 'src/repositories/person.repository'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; +import { FaceClusterTable } from 'src/schema/tables/face-cluster.table'; import { FaceSearchTable } from 'src/schema/tables/face-search.table'; import { BaseService } from 'src/services/base.service'; import { JobItem, JobOf } from 'src/types'; @@ -58,10 +58,10 @@ export class PersonService extends BaseService { if (closestPersonId) { const person = await this.personRepository.getById(closestPersonId); - if (!person?.faceAssetId) { + if (!person?.featureFaceAssetId) { throw new NotFoundException('Person not found'); } - closestFaceAssetId = person.faceAssetId; + closestFaceAssetId = person.featureFaceAssetId; } const { items, hasNextPage } = await this.personRepository.getAllForUser(pagination, auth.user.id, { withHidden, @@ -87,10 +87,10 @@ export class PersonService extends BaseService { for (const face of faces) { await this.requireAccess({ auth, permission: Permission.PersonCreate, ids: [face.id] }); - if (person.faceAssetId === null) { + if (person.featureFaceAssetId === null) { changeFeaturePhoto.push(person.id); } - if (face.person && face.person.faceAssetId === face.id) { + if (face.person && face.person.featureFaceAssetId === face.id) { changeFeaturePhoto.push(face.person.id); } @@ -113,10 +113,10 @@ export class PersonService extends BaseService { const person = await this.findOrFail(personId); await this.personRepository.reassignFace(face.id, personId); - if (person.faceAssetId === null) { + if (person.featureFaceAssetId === null) { await this.createNewFeaturePhoto([person.id]); } - if (face.person && face.person.faceAssetId === face.id) { + if (face.person && face.person.featureFaceAssetId === face.id) { await this.createNewFeaturePhoto([face.person.id]); } @@ -142,7 +142,10 @@ export class PersonService extends BaseService { const assetFace = await this.personRepository.getRandomFace(personId); if (assetFace) { - await this.personRepository.update({ id: personId, faceAssetId: assetFace.id }); + await this.personRepository.updateFaceCluster({ + id: assetFace.faceClusterId!, + featureFaceAssetId: assetFace.id, + }); jobs.push({ name: JobName.PersonGenerateThumbnail, data: { id: personId } }); } } @@ -175,22 +178,24 @@ export class PersonService extends BaseService { } async create(auth: AuthDto, dto: PersonCreateDto): Promise { - const person = await this.personRepository.create({ - ownerId: auth.user.id, + const { id: faceClusterId } = await this.personRepository.createFaceCluster({ name: dto.name, birthDate: dto.birthDate, + }); + const { id } = await this.personRepository.create({ + ownerId: auth.user.id, isHidden: dto.isHidden, isFavorite: dto.isFavorite, - color: dto.color, + faceClusterId, }); - return mapPerson(person); + return mapPerson(await this.findOrFail(id)); } async update(auth: AuthDto, id: string, dto: PersonUpdateDto): Promise { await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [id] }); - const { name, birthDate, isHidden, featureFaceAssetId: assetId, isFavorite, color } = dto; + const { name, birthDate, isHidden, featureFaceAssetId: assetId, isFavorite } = dto; // TODO: set by faceId directly let faceId: string | undefined; if (assetId) { @@ -205,19 +210,24 @@ export class PersonService extends BaseService { const person = await this.personRepository.update({ id, - faceAssetId: faceId, - name, - birthDate, isHidden, isFavorite, - color, }); + if (name !== undefined || birthDate !== undefined || faceId !== undefined) { + await this.personRepository.updateFaceCluster({ + id: person.faceClusterId, + name, + birthDate, + featureFaceAssetId: faceId, + }); + } + if (assetId) { await this.jobRepository.queue({ name: JobName.PersonGenerateThumbnail, data: { id } }); } - return mapPerson(person); + return mapPerson(await this.findOrFail(id)); } delete(auth: AuthDto, id: string): Promise { @@ -247,20 +257,25 @@ export class PersonService extends BaseService { async deleteAll(auth: AuthDto, { ids }: BulkIdsDto): Promise { await this.requireAccess({ auth, permission: Permission.PersonDelete, ids }); const people = await this.personRepository.getForPeopleDelete(ids); - await this.removeAllPeople(people); + await Promise.all(people.map(({ thumbnailPath }) => this.storageRepository.unlink(thumbnailPath))); + await this.personRepository.delete(people.map(({ id }) => id)); } @Chunked() - private async removeAllPeople(people: { id: string; thumbnailPath: string }[]) { - await Promise.all(people.map((person) => this.storageRepository.unlink(person.thumbnailPath))); - await this.personRepository.delete(people.map((person) => person.id)); - this.logger.debug(`Deleted ${people.length} people`); + private async removeAllFaceClusters(faceClusters: { id: string; thumbnailPath: string | null }[]) { + await Promise.all( + faceClusters + .filter((faceCluster) => faceCluster.thumbnailPath !== null) + .map(({ thumbnailPath }) => this.storageRepository.unlink(thumbnailPath!)), + ); + await this.personRepository.deleteFaceClusters(faceClusters.map(({ id }) => id)); + this.logger.debug(`Deleted ${faceClusters.length} face clusters`); } @OnJob({ name: JobName.PersonCleanup, queue: QueueName.BackgroundTask }) async handlePersonCleanup(): Promise { - const people = await this.personRepository.getAllWithoutFaces(); - await this.removeAllPeople(people); + const faceClusters = await this.personRepository.getForPeopleCleanup(); + await this.removeAllFaceClusters(faceClusters); return JobStatus.Success; } @@ -436,7 +451,7 @@ export class PersonService extends BaseService { const lastRun = new Date().toISOString(); const facePagination = this.personRepository.getAllFaces( - force ? undefined : { personId: null, sourceType: SourceType.MachineLearning }, + force ? undefined : { faceClusterId: null, sourceType: SourceType.MachineLearning }, ); let jobs: { name: JobName.FacialRecognition; data: { id: string; deferred: false } }[] = []; @@ -479,8 +494,8 @@ export class PersonService extends BaseService { return JobStatus.Failed; } - if (face.personId) { - this.logger.debug(`Face ${id} already has a person assigned`); + if (face.faceClusterId) { + this.logger.debug(`Face ${id} already has a face cluster assigned`); return JobStatus.Skipped; } @@ -509,32 +524,36 @@ export class PersonService extends BaseService { return JobStatus.Skipped; } - let personId = matches.find((match) => match.personId)?.personId; - if (!personId) { - const matchWithPerson = await this.searchRepository.searchFaces({ + let faceClusterId = matches.find((match) => match.faceClusterId)?.faceClusterId; + if (!faceClusterId) { + const matchWithFaceCluster = await this.searchRepository.searchFaces({ userIds: [face.asset.ownerId], embedding: face.faceSearch.embedding, maxDistance: machineLearning.facialRecognition.maxDistance, numResults: 1, - hasPerson: true, + hasFaceCluster: true, minBirthDate: new Date(face.asset.fileCreatedAt), }); - if (matchWithPerson.length > 0) { - personId = matchWithPerson[0].personId; + if (matchWithFaceCluster.length > 0) { + faceClusterId = matchWithFaceCluster[0].faceClusterId; } } - if (isCore && !personId) { + if (isCore && !faceClusterId) { this.logger.log(`Creating new person for face ${id}`); - const newPerson = await this.personRepository.create({ ownerId: face.asset.ownerId, faceAssetId: face.id }); + const faceCluster = await this.personRepository.createFaceCluster({ featureFaceAssetId: face.id }); + const newPerson = await this.personRepository.create({ + ownerId: face.asset.ownerId, + faceClusterId: faceCluster.id, + }); await this.jobRepository.queue({ name: JobName.PersonGenerateThumbnail, data: { id: newPerson.id } }); - personId = newPerson.id; + faceClusterId = newPerson.faceClusterId; } - if (personId) { - this.logger.debug(`Assigning face ${id} to person ${personId}`); - await this.personRepository.reassignFaces({ faceIds: [id], newPersonId: personId }); + if (faceClusterId) { + this.logger.debug(`Assigning face ${id} to face cluster ${faceClusterId}`); + await this.personRepository.reassignFaces({ faceIds: [id], newFaceClusterId: faceClusterId }); } return JobStatus.Success; @@ -552,6 +571,7 @@ export class PersonService extends BaseService { return JobStatus.Success; } + // TODO BREAKING change this and the endpoint to `mergeFaceCluster` async mergePerson(auth: AuthDto, id: string, dto: MergePersonDto): Promise { const mergeIds = dto.ids; if (mergeIds.includes(id)) { @@ -559,7 +579,7 @@ export class PersonService extends BaseService { } await this.requireAccess({ auth, permission: Permission.PersonUpdate, ids: [id] }); - let primaryPerson = await this.findOrFail(id); + const primaryPerson = await this.findOrFail(id); const primaryName = primaryPerson.name || primaryPerson.id; const results: BulkIdResponseDto[] = []; @@ -584,7 +604,7 @@ export class PersonService extends BaseService { continue; } - const update: Updateable & { id: string } = { id: primaryPerson.id }; + const update: Updateable & { id: string } = { id: primaryPerson.faceClusterId }; if (!primaryPerson.name && mergePerson.name) { update.name = mergePerson.name; } @@ -594,15 +614,18 @@ export class PersonService extends BaseService { } if (Object.keys(update).length > 1) { - primaryPerson = await this.personRepository.update(update); + await this.personRepository.updateFaceCluster(update); } const mergeName = mergePerson.name || mergePerson.id; - const mergeData: UpdateFacesData = { oldPersonId: mergeId, newPersonId: id }; + const mergeData: UpdateFacesData = { + oldFaceClusterId: mergePerson.faceClusterId, + newFaceClusterId: primaryPerson.faceClusterId, + }; this.logger.log(`Merging ${mergeName} into ${primaryName}`); await this.personRepository.reassignFaces(mergeData); - await this.removeAllPeople([mergePerson]); + await this.removeAllFaceClusters([{ id: mergePerson.faceClusterId, thumbnailPath: mergePerson.thumbnailPath }]); this.logger.log(`Merged ${mergeName} into ${primaryName}`); results.push({ id: mergeId, success: true }); @@ -679,7 +702,7 @@ export class PersonService extends BaseService { } await this.personRepository.createAssetFace({ - personId: dto.personId, + faceClusterId: person.faceClusterId, assetId: dto.assetId, imageHeight: dto.imageHeight, imageWidth: dto.imageWidth, @@ -690,7 +713,7 @@ export class PersonService extends BaseService { sourceType: SourceType.Manual, }); - if (!person.faceAssetId) { + if (!person.featureFaceAssetId) { await this.createNewFeaturePhoto([person.id]); } } diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index 87fc40306f..db857f7dfc 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -72,7 +72,9 @@ export const SYNC_TYPES_ORDER = [ SyncRequestType.PartnerAssetExifsV1, SyncRequestType.MemoriesV1, SyncRequestType.MemoryToAssetsV1, + SyncRequestType.FaceClusterV1, SyncRequestType.PeopleV1, + SyncRequestType.PeopleV2, SyncRequestType.AssetFacesV1, SyncRequestType.AssetFacesV2, SyncRequestType.UserMetadataV1, @@ -187,6 +189,8 @@ export class SyncService extends BaseService { [SyncRequestType.StacksV1]: () => this.syncStackV1(options, response, checkpointMap), [SyncRequestType.PartnerStacksV1]: () => this.syncPartnerStackV1(options, response, checkpointMap, session.id), [SyncRequestType.PeopleV1]: () => this.syncPeopleV1(options, response, checkpointMap), + [SyncRequestType.PeopleV2]: () => this.syncPeopleV2(options, response, checkpointMap), + [SyncRequestType.FaceClusterV1]: () => this.syncFaceClusterV1(options, response, checkpointMap), [SyncRequestType.AssetFacesV2]: () => this.syncAssetFacesV2(options, response, checkpointMap), [SyncRequestType.UserMetadataV1]: () => this.syncUserMetadataV1(options, response, checkpointMap), [SyncRequestType.AssetOcrV1]: () => this.syncAssetOcrV1(options, response, checkpointMap, auth), @@ -221,6 +225,7 @@ export class SyncService extends BaseService { await this.syncRepository.memoryToAsset.cleanupAuditTable(pruneThreshold); await this.syncRepository.partner.cleanupAuditTable(pruneThreshold); await this.syncRepository.person.cleanupAuditTable(pruneThreshold); + await this.syncRepository.faceCluster.cleanupAuditTable(pruneThreshold); await this.syncRepository.stack.cleanupAuditTable(pruneThreshold); await this.syncRepository.user.cleanupAuditTable(pruneThreshold); await this.syncRepository.userMetadata.cleanupAuditTable(pruneThreshold); @@ -828,6 +833,50 @@ export class SyncService extends BaseService { const upsertType = SyncEntityType.PersonV1; const upserts = this.syncRepository.person.getUpserts({ ...options, ack: checkpointMap[upsertType] }); + for await (const { updateId, ...data } of upserts) { + const faceCluster = await this.syncRepository.faceCluster.getById(data.faceClusterId); + send(response, { + type: upsertType, + ids: [updateId], + data: { + birthDate: faceCluster.birthDate, + color: null, + createdAt: data.createdAt, + faceAssetId: faceCluster.featureFaceAssetId, + id: data.id, + isFavorite: data.isFavorite, + isHidden: data.isHidden, + name: faceCluster.name, + ownerId: data.ownerId, + updatedAt: data.updatedAt, + }, + }); + } + } + + private async syncPeopleV2(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap) { + const deleteType = SyncEntityType.PersonDeleteV1; + const deletes = this.syncRepository.person.getDeletes({ ...options, ack: checkpointMap[deleteType] }); + for await (const { id, ...data } of deletes) { + send(response, { type: deleteType, ids: [id], data }); + } + + const upsertType = SyncEntityType.PersonV2; + const upserts = this.syncRepository.person.getUpserts({ ...options, ack: checkpointMap[upsertType] }); + for await (const { updateId, ...data } of upserts) { + send(response, { type: upsertType, ids: [updateId], data }); + } + } + + private async syncFaceClusterV1(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap) { + const deleteType = SyncEntityType.FaceClusterDeleteV1; + const deletes = this.syncRepository.faceCluster.getDeletes({ ...options, ack: checkpointMap[deleteType] }); + for await (const { id, ...data } of deletes) { + send(response, { type: deleteType, ids: [id], data }); + } + + const upsertType = SyncEntityType.FaceClusterV1; + const upserts = this.syncRepository.faceCluster.getUpserts({ ...options, ack: checkpointMap[upsertType] }); for await (const { updateId, ...data } of upserts) { send(response, { type: upsertType, ids: [updateId], data }); } @@ -848,8 +897,11 @@ export class SyncService extends BaseService { const upsertType = SyncEntityType.AssetFaceV2; const upserts = this.syncRepository.assetFace.getUpserts({ ...options, ack: checkpointMap[upsertType] }); - for await (const { updateId, ...data } of upserts) { - send(response, { type: upsertType, ids: [updateId], data }); + for await (const { updateId, faceClusterId, ...data } of upserts) { + const person = faceClusterId + ? await this.syncRepository.person.getByFaceClusterId(faceClusterId, options.userId) + : undefined; + send(response, { type: upsertType, ids: [updateId], data: { ...data, personId: person?.id ?? null } }); } } diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 0f4d8775b6..16ae36cfab 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -247,7 +247,13 @@ export function withFacesAndPeople( .selectFrom('asset_face') .leftJoinLateral( (eb) => - eb.selectFrom('person').selectAll('person').whereRef('asset_face.personId', '=', 'person.id').as('person'), + eb + .selectFrom('person') + .innerJoin('face_cluster', 'face_cluster.id', 'person.faceClusterId') + .selectAll('person') + .select(['face_cluster.name', 'face_cluster.birthDate']) + .whereRef('asset_face.faceClusterId', '=', 'person.faceClusterId') + .as('person'), (join) => join.onTrue(), ) .selectAll('asset_face') @@ -264,11 +270,12 @@ export function hasPeople(qb: SelectQueryBuilder, personIds: eb .selectFrom('asset_face') .select('assetId') - .where('personId', '=', anyUuid(personIds!)) + .innerJoin('person', 'person.faceClusterId', 'asset_face.faceClusterId') + .where('person.id', '=', anyUuid(personIds!)) .where('deletedAt', 'is', null) .where('isVisible', 'is', true) .groupBy('assetId') - .having((eb) => eb.fn.count('personId').distinct(), '=', personIds.length) + .having((eb) => eb.fn.count('person.id').distinct(), '=', personIds.length) .as('has_people'), (join) => join.onRef('has_people.assetId', '=', 'asset.id'), ); @@ -569,7 +576,10 @@ function albumIdsPredicates(eb: AssetExpressionBuilder, filter?: IdsFilter) { } function personIdsPredicates(eb: AssetExpressionBuilder, filter?: IdsFilter) { - const matching = (ids: string[]) => visibleFaces(eb).where('asset_face.personId', '=', anyUuid(ids)); + const matching = (ids: string[]) => + visibleFaces(eb) + .innerJoin('person', 'person.faceClusterId', 'asset_face.faceClusterId') + .where('person.id', '=', anyUuid(ids)); return idsPredicates(eb, filter, { matchesAny: (ids) => eb.exists(matching(ids)), matchesAll: (ids) => @@ -577,7 +587,7 @@ function personIdsPredicates(eb: AssetExpressionBuilder, filter?: IdsFilter) { matching(ids) .select('asset_face.assetId') .groupBy('asset_face.assetId') - .having((eb) => eb.fn.count('asset_face.personId').distinct(), '=', ids.length), + .having((eb) => eb.fn.count('person.id').distinct(), '=', ids.length), ), }); } diff --git a/server/src/utils/editor.spec.ts b/server/src/utils/editor.spec.ts index 17db0d9da3..df4a492cd2 100644 --- a/server/src/utils/editor.spec.ts +++ b/server/src/utils/editor.spec.ts @@ -64,7 +64,7 @@ const createFace = (params: Partial = {}): AssetFace => ({ boundingBoxY2: 200, imageWidth: 1000, imageHeight: 1000, - personId: null, + faceClusterId: null, sourceType: SourceType.MachineLearning, person: null, updatedAt: new Date(), diff --git a/server/test/factories/asset-face.factory.ts b/server/test/factories/asset-face.factory.ts index b2286cad54..1951ac2de0 100644 --- a/server/test/factories/asset-face.factory.ts +++ b/server/test/factories/asset-face.factory.ts @@ -27,7 +27,7 @@ export class AssetFaceFactory { imageHeight: 500, imageWidth: 400, isVisible: true, - personId: null, + faceClusterId: null, sourceType: SourceType.MachineLearning, updatedAt: newDate(), updateId: newUuidV7(), @@ -37,7 +37,7 @@ export class AssetFaceFactory { person(dto: PersonLike = {}, builder?: FactoryBuilder) { this.#person = build(PersonFactory.from(dto), builder); - this.value.personId = this.#person.build().id; + this.value.faceClusterId = this.#person.build().faceClusterId; return this; } diff --git a/server/test/factories/face-cluster.factory.ts b/server/test/factories/face-cluster.factory.ts new file mode 100644 index 0000000000..85b451545c --- /dev/null +++ b/server/test/factories/face-cluster.factory.ts @@ -0,0 +1,29 @@ +import { Selectable } from 'kysely'; +import { FaceClusterTable } from 'src/schema/tables/face-cluster.table'; +import { FaceClusterLike } from 'test/factories/types'; +import { newDate, newUuid, newUuidV7 } from 'test/small.factory'; + +export class FaceClusterFactory { + private constructor(private readonly value: Selectable) {} + + static create(dto: FaceClusterLike = {}) { + return FaceClusterFactory.from(dto).build(); + } + + static from(dto: FaceClusterLike = {}) { + return new FaceClusterFactory({ + birthDate: null, + createdAt: newDate(), + featureFaceAssetId: null, + id: newUuid(), + name: 'person', + updatedAt: newDate(), + updateId: newUuidV7(), + ...dto, + }); + } + + build() { + return { ...this.value }; + } +} diff --git a/server/test/factories/person.factory.ts b/server/test/factories/person.factory.ts index 8e016e5398..f7356c0e52 100644 --- a/server/test/factories/person.factory.ts +++ b/server/test/factories/person.factory.ts @@ -1,9 +1,13 @@ import { Selectable } from 'kysely'; import { PersonTable } from 'src/schema/tables/person.table'; -import { PersonLike } from 'test/factories/types'; +import { build } from 'test/factories/builder.factory'; +import { FaceClusterFactory } from 'test/factories/face-cluster.factory'; +import { FaceClusterLike, FactoryBuilder, PersonLike } from 'test/factories/types'; import { newDate, newUuid, newUuidV7 } from 'test/small.factory'; export class PersonFactory { + #faceCluster!: FaceClusterFactory; + private constructor(private readonly value: Selectable) {} static create(dto: PersonLike = {}) { @@ -11,24 +15,28 @@ export class PersonFactory { } static from(dto: PersonLike = {}) { + const faceClusterId = dto.faceClusterId ?? newUuid(); return new PersonFactory({ - birthDate: null, - color: null, createdAt: newDate(), - faceAssetId: null, id: newUuid(), isFavorite: false, isHidden: false, - name: 'person', ownerId: newUuid(), thumbnailPath: '/data/thumbs/person-thumbnail.jpg', updatedAt: newDate(), updateId: newUuidV7(), + faceClusterId, ...dto, - }); + }).faceCluster({ id: faceClusterId }); + } + + faceCluster(dto: FaceClusterLike = {}, builder?: FactoryBuilder) { + this.#faceCluster = build(FaceClusterFactory.from(dto), builder); + this.value.faceClusterId = this.#faceCluster.build().id; + return this; } build() { - return { ...this.value }; + return { ...this.value, faceCluster: this.#faceCluster.build() }; } } diff --git a/server/test/factories/types.ts b/server/test/factories/types.ts index 5c8c8ee2c2..6294364014 100644 --- a/server/test/factories/types.ts +++ b/server/test/factories/types.ts @@ -9,6 +9,7 @@ import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; import { AssetFileTable } from 'src/schema/tables/asset-file.table'; import { AssetTable } from 'src/schema/tables/asset.table'; +import { FaceClusterTable } from 'src/schema/tables/face-cluster.table'; import { MemoryTable } from 'src/schema/tables/memory.table'; import { PartnerTable } from 'src/schema/tables/partner.table'; import { PersonTable } from 'src/schema/tables/person.table'; @@ -36,3 +37,4 @@ export type ActivityLike = Partial>; export type ApiKeyLike = Partial>; export type SessionLike = Partial>; export type OAuthProfileLike = Partial; +export type FaceClusterLike = Partial>; diff --git a/server/test/mappers.ts b/server/test/mappers.ts index 40ae78fe26..014c9228b1 100644 --- a/server/test/mappers.ts +++ b/server/test/mappers.ts @@ -9,6 +9,7 @@ import { AlbumFactory } from 'test/factories/album.factory'; import { AssetFaceFactory } from 'test/factories/asset-face.factory'; import { AssetFactory } from 'test/factories/asset.factory'; import { MemoryFactory } from 'test/factories/memory.factory'; +import { PersonFactory } from 'test/factories/person.factory'; import { SharedLinkFactory } from 'test/factories/shared-link.factory'; import { StackFactory } from 'test/factories/stack.factory'; import { UserFactory } from 'test/factories/user.factory'; @@ -97,7 +98,7 @@ export const getForAsset = (asset: ReturnType) => { ...asset, faces: asset.faces.map((face) => ({ ...getDehydrated(face), - person: face.person ? getDehydrated(face.person) : null, + person: face.person ? getDehydrated(getForPerson(face.person)) : null, })), owner: getDehydrated(asset.owner), stack: asset.stack @@ -109,6 +110,15 @@ export const getForAsset = (asset: ReturnType) => { }; }; +export const getForPerson = (person: ReturnType) => { + return { + ...person, + birthDate: person.faceCluster.birthDate, + name: person.faceCluster.name, + featureFaceAssetId: person.faceCluster.featureFaceAssetId, + }; +}; + export const getForPartner = ( partner: Selectable & Record<'sharedWith' | 'sharedBy', ReturnType>, ) => ({ @@ -162,7 +172,7 @@ export const getForGenerateThumbnail = (asset: ReturnType export const getForAssetFace = (face: ReturnType) => ({ ...face, - person: face.person ? getDehydrated(face.person) : null, + person: face.person ? getDehydrated(getForPerson(face.person)) : null, }); export const getForDetectedFaces = (asset: ReturnType) => ({ @@ -225,3 +235,9 @@ export const getForSharedLink = (sharedLink: ReturnType) => ({ + personId: person.id, + featureFaceAssetId: person.faceCluster.featureFaceAssetId, + faceClusterId: person.faceClusterId, +}); diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index 4bbe8573fe..e67f35d87b 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -64,6 +64,7 @@ import { AssetFileTable } from 'src/schema/tables/asset-file.table'; import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table'; import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table'; import { AssetTable } from 'src/schema/tables/asset.table'; +import { FaceClusterTable } from 'src/schema/tables/face-cluster.table'; import { FaceSearchTable } from 'src/schema/tables/face-search.table'; import { MemoryTable } from 'src/schema/tables/memory.table'; import { PersonTable } from 'src/schema/tables/person.table'; @@ -256,11 +257,21 @@ export class MediumTestContext { } async newPerson(dto: Partial> & { ownerId: string }) { + if (!dto.faceClusterId) { + const { faceCluster } = await this.newFaceCluster(); + dto.faceClusterId = faceCluster.id; + } const person = mediumFactory.personInsert(dto); const result = await this.get(PersonRepository).create(person); return { person, result }; } + async newFaceCluster(dto: Partial> = {}) { + const faceCluster = mediumFactory.faceClusterInsert(dto); + const result = await this.get(PersonRepository).createFaceCluster(faceCluster); + return { faceCluster, result }; + } + async newSession(dto: Partial> & { userId: string }) { const session = mediumFactory.sessionInsert(dto); const result = await this.get(SessionRepository).create(session); @@ -619,7 +630,7 @@ const assetFaceInsert = (assetFace: Partial & { assetId: string }) => id: assetFace.id ?? newUuid(), imageHeight: assetFace.imageHeight ?? 10, imageWidth: assetFace.imageWidth ?? 10, - personId: assetFace.personId ?? null, + faceClusterId: assetFace.faceClusterId ?? null, sourceType: assetFace.sourceType ?? SourceType.MachineLearning, isVisible: assetFace.isVisible ?? true, }; @@ -648,23 +659,31 @@ const assetJobStatusInsert = ( const personInsert = (person: Partial> & { ownerId: string }) => { const defaults = { - birthDate: person.birthDate || null, - color: person.color || null, - createdAt: person.createdAt || newDate(), - faceAssetId: person.faceAssetId || null, - id: person.id || newUuid(), - isFavorite: person.isFavorite || false, - isHidden: person.isHidden || false, - name: person.name || 'Test Name', - ownerId: person.ownerId || newUuid(), - thumbnailPath: person.thumbnailPath || '/path/to/thumbnail.jpg', - }; + createdAt: newDate(), + id: newUuid(), + isFavorite: false, + isHidden: false, + ownerId: newUuid(), + thumbnailPath: '/path/to/thumbnail.jpg', + faceClusterId: newUuid(), + } satisfies Insertable; return { ...defaults, ...person, }; }; +const faceClusterInsert = (faceCluster: Partial>) => { + const defaults = { + birthDate: null, + createdAt: newDate(), + featureFaceAssetId: null, + id: newUuid(), + name: 'Test Name', + } satisfies Insertable; + return { ...defaults, ...faceCluster }; +}; + const sha256 = (value: string) => createHash('sha256').update(value).digest(); const sessionInsert = ({ @@ -806,6 +825,7 @@ export const mediumFactory = { albumInsert, faceInsert, personInsert, + faceClusterInsert, sessionInsert, syncStream, userInsert, diff --git a/server/test/medium/specs/repositories/person.repository.spec.ts b/server/test/medium/specs/repositories/person.repository.spec.ts index 30f0fd33c0..7ad1524689 100644 --- a/server/test/medium/specs/repositories/person.repository.spec.ts +++ b/server/test/medium/specs/repositories/person.repository.spec.ts @@ -33,7 +33,7 @@ describe(PersonRepository.name, () => { const { assetFace } = await ctx.newAssetFace({ assetId: asset.id, - personId: person.id, + faceClusterId: person.faceClusterId, boundingBoxX1: 10, boundingBoxY1: 10, boundingBoxX2: 90, @@ -41,7 +41,11 @@ describe(PersonRepository.name, () => { }); // theres a circular dependency between assetFace and person, so we need to update the person after creating the assetFace - await ctx.database.updateTable('person').set({ faceAssetId: assetFace.id }).where('id', '=', person.id).execute(); + await ctx.database + .updateTable('face_cluster') + .set({ featureFaceAssetId: assetFace.id }) + .where('id', '=', person.faceClusterId) + .execute(); await ctx.newAssetFile({ assetId: asset.id, diff --git a/server/test/medium/specs/services/search.service.spec.ts b/server/test/medium/specs/services/search.service.spec.ts index 044cd8d4f5..db2ece76ec 100644 --- a/server/test/medium/specs/services/search.service.spec.ts +++ b/server/test/medium/specs/services/search.service.spec.ts @@ -69,7 +69,7 @@ describe(SearchService.name, () => { const { user } = await ctx.newUser(); const { asset } = await ctx.newAsset({ ownerId: user.id }); const { person } = await ctx.newPerson({ ownerId: user.id }); - await ctx.newAssetFace({ assetId: asset.id, personId: person.id }); + await ctx.newAssetFace({ assetId: asset.id, faceClusterId: person.faceClusterId }); const auth = factory.auth({ user: { id: user.id } }); diff --git a/server/test/medium/specs/sync/sync-asset-face.spec.ts b/server/test/medium/specs/sync/sync-asset-face.spec.ts index 74d4c536f1..3a2846b759 100644 --- a/server/test/medium/specs/sync/sync-asset-face.spec.ts +++ b/server/test/medium/specs/sync/sync-asset-face.spec.ts @@ -23,7 +23,7 @@ describe(SyncEntityType.AssetFaceV2, () => { const { auth, ctx } = await setup(); const { asset } = await ctx.newAsset({ ownerId: auth.user.id }); const { person } = await ctx.newPerson({ ownerId: auth.user.id }); - const { assetFace } = await ctx.newAssetFace({ assetId: asset.id, personId: person.id }); + const { assetFace } = await ctx.newAssetFace({ assetId: asset.id, faceClusterId: person.faceClusterId }); const response = await ctx.syncStream(auth, [SyncRequestType.AssetFacesV2]); expect(response).toEqual([ @@ -103,7 +103,7 @@ describe(SyncEntityType.AssetFaceV2, () => { const { auth, ctx } = await setup(); const { asset } = await ctx.newAsset({ ownerId: auth.user.id }); const { person } = await ctx.newPerson({ ownerId: auth.user.id }); - const { assetFace } = await ctx.newAssetFace({ assetId: asset.id, personId: person.id }); + const { assetFace } = await ctx.newAssetFace({ assetId: asset.id, faceClusterId: person.faceClusterId }); const response = await ctx.syncStream(auth, [SyncRequestType.AssetFacesV2]); expect(response).toEqual([ @@ -182,7 +182,7 @@ describe(SyncEntityType.AssetFaceV2, () => { const personRepo = ctx.get(PersonRepository); const { asset } = await ctx.newAsset({ ownerId: auth.user.id }); const { person } = await ctx.newPerson({ ownerId: auth.user.id }); - const { assetFace } = await ctx.newAssetFace({ assetId: asset.id, personId: person.id }); + const { assetFace } = await ctx.newAssetFace({ assetId: asset.id, faceClusterId: person.faceClusterId }); let response = await ctx.syncStream(auth, [SyncRequestType.AssetFacesV2]); expect(response).toEqual([ diff --git a/server/test/medium/specs/sync/sync-face-cluster.spec.ts b/server/test/medium/specs/sync/sync-face-cluster.spec.ts new file mode 100644 index 0000000000..f03120c9a0 --- /dev/null +++ b/server/test/medium/specs/sync/sync-face-cluster.spec.ts @@ -0,0 +1,66 @@ +import { Kysely } from 'kysely'; +import { SyncEntityType, SyncRequestType } from 'src/enum'; +import { PersonRepository } from 'src/repositories/person.repository'; +import { DB } from 'src/schema'; +import { SyncTestContext } from 'test/medium.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = async (db?: Kysely) => { + const ctx = new SyncTestContext(db || defaultDatabase); + const { auth, user, session } = await ctx.newSyncAuthUser(); + return { auth, user, session, ctx }; +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(SyncEntityType.FaceClusterV1, () => { + it('should detect and sync the first face cluster', async () => { + const { auth, ctx } = await setup(); + const { faceCluster } = await ctx.newFaceCluster(); + await ctx.newPerson({ ownerId: auth.user.id, faceClusterId: faceCluster.id }); + + const response = await ctx.syncStream(auth, [SyncRequestType.FaceClusterV1]); + expect(response).toEqual([ + { + ack: expect.any(String), + data: expect.objectContaining({ + id: faceCluster.id, + name: faceCluster.name, + featureFaceAssetId: faceCluster.featureFaceAssetId, + birthDate: faceCluster.birthDate, + }), + type: 'FaceClusterV1', + }, + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + + await ctx.syncAckAll(auth, response); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.FaceClusterV1]); + }); + + it('should detect and sync a deleted face cluster', async () => { + const { auth, ctx } = await setup(await getKyselyDB()); + const personRepo = ctx.get(PersonRepository); + const { person } = await ctx.newPerson({ ownerId: auth.user.id }); + await personRepo.deleteFaceClusters([person.faceClusterId]); + + const response = await ctx.syncStream(auth, [SyncRequestType.FaceClusterV1]); + expect(response).toEqual([ + { + ack: expect.any(String), + data: { + faceClusterId: person.faceClusterId, + }, + type: 'FaceClusterDeleteV1', + }, + expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }), + ]); + + await ctx.syncAckAll(auth, response); + await ctx.assertSyncIsComplete(auth, [SyncRequestType.FaceClusterV1]); + }); +}); diff --git a/server/test/medium/specs/sync/sync-person.spec.ts b/server/test/medium/specs/sync/sync-person.spec.ts index 6fdb5a58f2..5e43fdb0f6 100644 --- a/server/test/medium/specs/sync/sync-person.spec.ts +++ b/server/test/medium/specs/sync/sync-person.spec.ts @@ -21,7 +21,8 @@ beforeAll(async () => { describe(SyncEntityType.PersonV1, () => { it('should detect and sync the first person', async () => { const { auth, ctx } = await setup(); - const { person } = await ctx.newPerson({ ownerId: auth.user.id }); + const { faceCluster } = await ctx.newFaceCluster(); + const { person } = await ctx.newPerson({ ownerId: auth.user.id, faceClusterId: faceCluster.id }); const response = await ctx.syncStream(auth, [SyncRequestType.PeopleV1]); expect(response).toEqual([ @@ -29,13 +30,13 @@ describe(SyncEntityType.PersonV1, () => { ack: expect.any(String), data: expect.objectContaining({ id: person.id, - name: person.name, + name: faceCluster.name, isHidden: person.isHidden, - birthDate: person.birthDate, - faceAssetId: person.faceAssetId, + birthDate: faceCluster.birthDate, + faceAssetId: faceCluster.featureFaceAssetId, isFavorite: person.isFavorite, ownerId: auth.user.id, - color: person.color, + color: null, }), type: 'PersonV1', }, diff --git a/web/src/lib/utils/person.spec.ts b/web/src/lib/utils/person.spec.ts index 3e4d4f36b7..5f2519ba47 100644 --- a/web/src/lib/utils/person.spec.ts +++ b/web/src/lib/utils/person.spec.ts @@ -7,6 +7,7 @@ const makePerson = (overrides: Partial = {}): PersonResponseD thumbnailPath: '', isHidden: false, birthDate: null, + faceClusterId: 'cluster-id', ...overrides, }); diff --git a/web/src/test-data/factories/person-factory.ts b/web/src/test-data/factories/person-factory.ts index 3a12b431ba..459415ea57 100644 --- a/web/src/test-data/factories/person-factory.ts +++ b/web/src/test-data/factories/person-factory.ts @@ -9,4 +9,5 @@ export const personFactory = Sync.makeFactory({ name: Sync.each(() => faker.person.fullName()), thumbnailPath: Sync.each(() => faker.system.filePath()), updatedAt: Sync.each(() => faker.date.recent().toISOString()), + faceClusterId: Sync.each(() => faker.string.uuid()), });