mirror of
https://github.com/immich-app/immich.git
synced 2026-07-28 14:47:30 -07:00
chore: face cluster abstraction
This commit is contained in:
@@ -130,7 +130,7 @@ describe('/asset', () => {
|
||||
});
|
||||
await utils.createFace({
|
||||
assetId: user1Assets[0].id,
|
||||
personId: person1.id,
|
||||
faceClusterId: person1.faceClusterId,
|
||||
});
|
||||
};
|
||||
beforeAll(setupTests, 30_000);
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
Generated
+3
@@ -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)
|
||||
|
||||
Generated
+3
@@ -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';
|
||||
|
||||
Generated
+6
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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() };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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()),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user