chore: face cluster abstraction

This commit is contained in:
Daniel Dietzler
2026-07-27 15:46:17 +02:00
parent 792d88961a
commit 56c154ae59
65 changed files with 2157 additions and 424 deletions
+1 -1
View File
@@ -130,7 +130,7 @@ describe('/asset', () => {
});
await utils.createFace({
assetId: user1Assets[0].id,
personId: person1.id,
faceClusterId: person1.faceClusterId,
});
};
beforeAll(setupTests, 30_000);
+21 -39
View File
@@ -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',
+2 -2
View File
@@ -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) => {
+3
View File
@@ -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)
+3
View File
@@ -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';
+6
View File
@@ -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':
+10 -1
View File
@@ -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<String?> 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<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -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<String>(json, r'color')) : const Optional.absent(),
faceClusterId: mapValueOfType<String>(json, r'faceClusterId')!,
id: mapValueOfType<String>(json, r'id')!,
isFavorite: json.containsKey(r'isFavorite') ? Optional.present(mapValueOfType<bool>(json, r'isFavorite')) : const Optional.absent(),
isHidden: mapValueOfType<bool>(json, r'isHidden')!,
@@ -182,6 +190,7 @@ class PersonResponseDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'birthDate',
'faceClusterId',
'id',
'isHidden',
'name',
+6
View File
@@ -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;
@@ -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<String, dynamic> toJson() {
final json = <String, dynamic>{};
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<String, dynamic>();
return SyncFaceClusterDeleteV1Schema(
faceClusterId: mapValueOfType<String>(json, r'faceClusterId')!,
);
}
return null;
}
static List<SyncFaceClusterDeleteV1Schema> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SyncFaceClusterDeleteV1Schema>[];
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<String, SyncFaceClusterDeleteV1Schema> mapFromJson(dynamic json) {
final map = <String, SyncFaceClusterDeleteV1Schema>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // 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<String, List<SyncFaceClusterDeleteV1Schema>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SyncFaceClusterDeleteV1Schema>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
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 = <String>{
'faceClusterId',
};
}
+159
View File
@@ -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<String, dynamic> toJson() {
final json = <String, dynamic>{};
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<String, dynamic>();
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<String>(json, r'featureFaceAssetId'),
id: mapValueOfType<String>(json, r'id')!,
name: mapValueOfType<String>(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<SyncFaceClusterV1> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SyncFaceClusterV1>[];
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<String, SyncFaceClusterV1> mapFromJson(dynamic json) {
final map = <String, SyncFaceClusterV1>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // 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<String, List<SyncFaceClusterV1>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SyncFaceClusterV1>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
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 = <String>{
'birthDate',
'createdAt',
'featureFaceAssetId',
'id',
'name',
'updatedAt',
};
}
+158
View File
@@ -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<String, dynamic> toJson() {
final json = <String, dynamic>{};
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<String, dynamic>();
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<String>(json, r'faceClusterId')!,
id: mapValueOfType<String>(json, r'id')!,
isFavorite: mapValueOfType<bool>(json, r'isFavorite')!,
isHidden: mapValueOfType<bool>(json, r'isHidden')!,
ownerId: mapValueOfType<String>(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<SyncPersonV2> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SyncPersonV2>[];
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<String, SyncPersonV2> mapFromJson(dynamic json) {
final map = <String, SyncPersonV2>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // 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<String, List<SyncPersonV2>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SyncPersonV2>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
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 = <String>{
'createdAt',
'faceClusterId',
'id',
'isFavorite',
'isHidden',
'ownerId',
'updatedAt',
};
}
+4
View File
@@ -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;
+171 -3
View File
@@ -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"
+41
View File
@@ -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"
+4 -5
View File
@@ -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<Person> | null;
updatedAt: Date;
+11 -3
View File
@@ -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<Person>): PersonResponseDto {
thumbnailPath: person.thumbnailPath,
isHidden: person.isHidden,
isFavorite: person.isFavorite,
color: person.color ?? undefined,
updatedAt: asDateTimeString(person.updatedAt),
faceClusterId: person.faceClusterId,
};
}
+30
View File
@@ -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;
+7
View File
@@ -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',
+5 -2
View File
@@ -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"
+2 -2
View File
@@ -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
+112 -36
View File
@@ -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
+7 -5
View File
@@ -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
+48 -5
View File
@@ -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",
@@ -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,
+1 -1
View File
@@ -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),
+148 -46
View File
@@ -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<AssetFaceTable>)[];
const withPerson = (eb: ExpressionBuilder<DB, 'asset_face'>) => {
const withPerson = (eb: ExpressionBuilder<DB, 'asset_face'>, 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<DB>) {}
@GenerateSql({ params: [{ oldPersonId: DummyValue.UUID, newPersonId: DummyValue.UUID }] })
async reassignFaces({ oldPersonId, faceIds, newPersonId }: UpdateFacesData): Promise<number> {
async reassignFaces({ oldFaceClusterId, faceIds, newFaceClusterId }: UpdateFacesData): Promise<number> {
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<void> {
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<void> {
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<number> {
async reassignFace(assetFaceId: string, newFaceClusterId: string): Promise<number> {
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<PersonNameResponse[]> {
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<PersonTable>) {
async create(person: Insertable<PersonTable>) {
return this.db.insertInto('person').values(person).returningAll().executeTakeFirstOrThrow();
}
async createFaceCluster(faceCluster: Insertable<FaceClusterTable>) {
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<PersonTable>[]): Promise<string[]> {
if (people.length === 0) {
return [];
@@ -406,6 +456,15 @@ export class PersonRepository {
return results.map(({ id }) => id);
}
async createAllFaceClusters(faceClusters: Insertable<FaceClusterTable>[]): Promise<string[]> {
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<AssetFaceTable> & { assetId: string })[],
@@ -439,6 +498,15 @@ export class PersonRepository {
.executeTakeFirstOrThrow();
}
async updateFaceCluster(faceCluster: Updateable<FaceClusterTable> & { id: string }) {
return this.db
.updateTable('face_cluster')
.set(faceCluster)
.where('face_cluster.id', '=', faceCluster.id)
.returningAll()
.executeTakeFirstOrThrow();
}
async updateAll(people: Insertable<PersonTable>[]): Promise<void> {
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<FaceClusterTable>[]) {
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();
}
}
+7 -7
View File
@@ -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<number>`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')
+50 -14
View File
@@ -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',
+15
View File
@@ -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',
+9
View File
@@ -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;
@@ -0,0 +1,148 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
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<any>): Promise<void> {
// 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,
// );
}
@@ -0,0 +1,38 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
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<any>): Promise<void> {
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);
}
@@ -0,0 +1,37 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
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<any>): Promise<void> {
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);
}
@@ -0,0 +1,27 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
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<any>): Promise<void> {
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);
}
@@ -0,0 +1,37 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
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<any>): Promise<void> {
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);
}
@@ -0,0 +1,34 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
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<any>): Promise<void> {
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);
}
@@ -0,0 +1,34 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
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<any>): Promise<void> {
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);
}
@@ -0,0 +1,34 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
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<any>): Promise<void> {
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);
}
@@ -0,0 +1,35 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
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<any>): Promise<void> {
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);
}
+8 -8
View File
@@ -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<string>;
@@ -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<number>;
@@ -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<string>;
@Column({ type: 'uuid', index: true })
faceClusterId!: string;
@Column({ type: 'uuid', index: true })
userId!: string;
@CreateDateColumn({ default: () => 'clock_timestamp()', index: true })
deletedAt!: Generated<Timestamp>;
}
@@ -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<string>;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
@Column({ default: '' })
name!: Generated<string>;
@Column({ type: 'date', nullable: true })
birthDate!: Timestamp | null;
@ForeignKeyColumn(() => AssetFaceTable, { onDelete: 'SET NULL', nullable: true })
featureFaceAssetId!: string | null;
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
}
+8 -23
View File
@@ -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<string>;
@@ -43,26 +37,17 @@ export class PersonTable {
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
ownerId!: string;
@Column({ default: '' })
name!: Generated<string>;
@Column({ default: '' })
thumbnailPath!: Generated<string>;
@ForeignKeyColumn(() => FaceClusterTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', index: true })
faceClusterId!: string;
@Column({ type: 'boolean', default: false })
isHidden!: Generated<boolean>;
@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<boolean>;
@Column({ type: 'character varying', nullable: true, default: null })
color!: string | null;
@Column({ default: '' })
thumbnailPath!: Generated<string>;
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
+33 -29
View File
@@ -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();
});
+6 -6
View File
@@ -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();
}
+33 -21
View File
@@ -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([
{
+12 -9
View File
@@ -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<AssetFaceTable> & { 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<PersonTable> & { ownerId: string })[] = [];
const missingWithFaceAsset: { id: string; ownerId: string; faceAssetId: string }[] = [];
const missing: Insertable<FaceClusterTable>[] = [];
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);
}
}
+99 -66
View File
@@ -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)),
});
});
+70 -47
View File
@@ -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<PersonResponseDto> {
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<PersonResponseDto> {
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<void> {
@@ -247,20 +257,25 @@ export class PersonService extends BaseService {
async deleteAll(auth: AuthDto, { ids }: BulkIdsDto): Promise<void> {
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<JobStatus> {
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<BulkIdResponseDto[]> {
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<Person> & { id: string } = { id: primaryPerson.id };
const update: Updateable<FaceClusterTable> & { 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]);
}
}
+54 -2
View File
@@ -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 } });
}
}
+15 -5
View File
@@ -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<O>(qb: SelectQueryBuilder<DB, 'asset', O>, 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),
),
});
}
+1 -1
View File
@@ -64,7 +64,7 @@ const createFace = (params: Partial<AssetFace> = {}): AssetFace => ({
boundingBoxY2: 200,
imageWidth: 1000,
imageHeight: 1000,
personId: null,
faceClusterId: null,
sourceType: SourceType.MachineLearning,
person: null,
updatedAt: new Date(),
+2 -2
View File
@@ -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<PersonFactory>) {
this.#person = build(PersonFactory.from(dto), builder);
this.value.personId = this.#person.build().id;
this.value.faceClusterId = this.#person.build().faceClusterId;
return this;
}
@@ -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<FaceClusterTable>) {}
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 };
}
}
+15 -7
View File
@@ -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<PersonTable>) {}
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<FaceClusterFactory>) {
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() };
}
}
+2
View File
@@ -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<Selectable<ActivityTable>>;
export type ApiKeyLike = Partial<Selectable<ApiKeyTable>>;
export type SessionLike = Partial<Selectable<SessionTable>>;
export type OAuthProfileLike = Partial<OAuthProfile>;
export type FaceClusterLike = Partial<Selectable<FaceClusterTable>>;
+18 -2
View File
@@ -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<AssetFactory['build']>) => {
...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<AssetFactory['build']>) => {
};
};
export const getForPerson = (person: ReturnType<PersonFactory['build']>) => {
return {
...person,
birthDate: person.faceCluster.birthDate,
name: person.faceCluster.name,
featureFaceAssetId: person.faceCluster.featureFaceAssetId,
};
};
export const getForPartner = (
partner: Selectable<PartnerTable> & Record<'sharedWith' | 'sharedBy', ReturnType<UserFactory['build']>>,
) => ({
@@ -162,7 +172,7 @@ export const getForGenerateThumbnail = (asset: ReturnType<AssetFactory['build']>
export const getForAssetFace = (face: ReturnType<AssetFaceFactory['build']>) => ({
...face,
person: face.person ? getDehydrated(face.person) : null,
person: face.person ? getDehydrated(getForPerson(face.person)) : null,
});
export const getForDetectedFaces = (asset: ReturnType<AssetFactory['build']>) => ({
@@ -225,3 +235,9 @@ export const getForSharedLink = (sharedLink: ReturnType<SharedLinkFactory['build
}
: null,
});
export const getForQueueFaceThumbnailGeneration = (person: ReturnType<PersonFactory['build']>) => ({
personId: person.id,
featureFaceAssetId: person.faceCluster.featureFaceAssetId,
faceClusterId: person.faceClusterId,
});
+32 -12
View File
@@ -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<S extends BaseService = BaseService> {
}
async newPerson(dto: Partial<Insertable<PersonTable>> & { 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<Insertable<FaceClusterTable>> = {}) {
const faceCluster = mediumFactory.faceClusterInsert(dto);
const result = await this.get(PersonRepository).createFaceCluster(faceCluster);
return { faceCluster, result };
}
async newSession(dto: Partial<Insertable<SessionTable>> & { userId: string }) {
const session = mediumFactory.sessionInsert(dto);
const result = await this.get(SessionRepository).create(session);
@@ -619,7 +630,7 @@ const assetFaceInsert = (assetFace: Partial<AssetFace> & { 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<Insertable<PersonTable>> & { 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<PersonTable>;
return {
...defaults,
...person,
};
};
const faceClusterInsert = (faceCluster: Partial<Insertable<FaceClusterTable>>) => {
const defaults = {
birthDate: null,
createdAt: newDate(),
featureFaceAssetId: null,
id: newUuid(),
name: 'Test Name',
} satisfies Insertable<FaceClusterTable>;
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,
@@ -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,
@@ -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 } });
@@ -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([
@@ -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<DB>;
const setup = async (db?: Kysely<DB>) => {
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]);
});
});
@@ -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',
},
+1
View File
@@ -7,6 +7,7 @@ const makePerson = (overrides: Partial<PersonResponseDto> = {}): PersonResponseD
thumbnailPath: '',
isHidden: false,
birthDate: null,
faceClusterId: 'cluster-id',
...overrides,
});
@@ -9,4 +9,5 @@ export const personFactory = Sync.makeFactory<PersonResponseDto>({
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()),
});