Compare commits

...

1 Commits

Author SHA1 Message Date
Daniel Dietzler c66d7e97cb chore: face cluster abstraction 2026-03-25 12:23:04 +01:00
19 changed files with 502 additions and 64 deletions
+184 -3
View File
@@ -8409,7 +8409,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/MergePersonDto" "$ref": "#/components/schemas/MergeFaceClusterDto"
} }
} }
}, },
@@ -18878,10 +18878,10 @@
}, },
"type": "object" "type": "object"
}, },
"MergePersonDto": { "MergeFaceClusterDto": {
"properties": { "properties": {
"ids": { "ids": {
"description": "Person IDs to merge", "description": "Face cluster IDs to merge",
"items": { "items": {
"format": "uuid", "format": "uuid",
"type": "string" "type": "string"
@@ -23055,6 +23055,70 @@
], ],
"type": "object" "type": "object"
}, },
"SyncAssetFaceV3": {
"properties": {
"assetId": {
"description": "Asset ID",
"type": "string"
},
"boundingBoxX1": {
"type": "integer"
},
"boundingBoxX2": {
"type": "integer"
},
"boundingBoxY1": {
"type": "integer"
},
"boundingBoxY2": {
"type": "integer"
},
"deletedAt": {
"description": "Face deleted at",
"format": "date-time",
"nullable": true,
"type": "string"
},
"faceClusterId": {
"description": "Face cluster ID",
"nullable": true,
"type": "string"
},
"id": {
"description": "Asset face ID",
"type": "string"
},
"imageHeight": {
"type": "integer"
},
"imageWidth": {
"type": "integer"
},
"isVisible": {
"description": "Is the face visible in the asset",
"type": "boolean"
},
"sourceType": {
"description": "Source type",
"type": "string"
}
},
"required": [
"assetId",
"boundingBoxX1",
"boundingBoxX2",
"boundingBoxY1",
"boundingBoxY2",
"deletedAt",
"faceClusterId",
"id",
"imageHeight",
"imageWidth",
"isVisible",
"sourceType"
],
"type": "object"
},
"SyncAssetMetadataDeleteV1": { "SyncAssetMetadataDeleteV1": {
"properties": { "properties": {
"assetId": { "assetId": {
@@ -23348,10 +23412,14 @@
"StackV1", "StackV1",
"StackDeleteV1", "StackDeleteV1",
"PersonV1", "PersonV1",
"PersonV2",
"PersonDeleteV1", "PersonDeleteV1",
"AssetFaceV1", "AssetFaceV1",
"AssetFaceV2", "AssetFaceV2",
"AssetFaceV3",
"AssetFaceDeleteV1", "AssetFaceDeleteV1",
"FaceClusterV1",
"FaceClusterDeleteV1",
"UserMetadataV1", "UserMetadataV1",
"UserMetadataDeleteV1", "UserMetadataDeleteV1",
"SyncAckV1", "SyncAckV1",
@@ -23360,6 +23428,47 @@
], ],
"type": "string" "type": "string"
}, },
"SyncFaceClusterDeleteV1": {
"properties": {
"faceClusterId": {
"description": "Face cluster ID",
"type": "string"
}
},
"required": [
"faceClusterId"
],
"type": "object"
},
"SyncFaceClusterV1": {
"properties": {
"createdAt": {
"description": "Created at",
"format": "date-time",
"type": "string"
},
"id": {
"description": "Face cluster ID",
"type": "string"
},
"ownerId": {
"description": "Owner ID",
"type": "string"
},
"updatedAt": {
"description": "Updated at",
"format": "date-time",
"type": "string"
}
},
"required": [
"createdAt",
"id",
"ownerId",
"updatedAt"
],
"type": "object"
},
"SyncMemoryAssetDeleteV1": { "SyncMemoryAssetDeleteV1": {
"properties": { "properties": {
"assetId": { "assetId": {
@@ -23602,6 +23711,75 @@
], ],
"type": "object" "type": "object"
}, },
"SyncPersonV2": {
"properties": {
"birthDate": {
"description": "Birth date",
"format": "date-time",
"nullable": true,
"type": "string"
},
"color": {
"description": "Color",
"nullable": true,
"type": "string"
},
"createdAt": {
"description": "Created at",
"format": "date-time",
"type": "string"
},
"faceAssetId": {
"description": "Face asset ID",
"nullable": true,
"type": "string"
},
"faceClusterId": {
"description": "Face cluster ID",
"nullable": true,
"type": "string"
},
"id": {
"description": "Person ID",
"type": "string"
},
"isFavorite": {
"description": "Is favorite",
"type": "boolean"
},
"isHidden": {
"description": "Is hidden",
"type": "boolean"
},
"name": {
"description": "Person name",
"type": "string"
},
"ownerId": {
"description": "Owner ID",
"type": "string"
},
"updatedAt": {
"description": "Updated at",
"format": "date-time",
"type": "string"
}
},
"required": [
"birthDate",
"color",
"createdAt",
"faceAssetId",
"faceClusterId",
"id",
"isFavorite",
"isHidden",
"name",
"ownerId",
"updatedAt"
],
"type": "object"
},
"SyncRequestType": { "SyncRequestType": {
"description": "Sync request types", "description": "Sync request types",
"enum": [ "enum": [
@@ -23624,8 +23802,11 @@
"StacksV1", "StacksV1",
"UsersV1", "UsersV1",
"PeopleV1", "PeopleV1",
"PeopleV2",
"AssetFacesV1", "AssetFacesV1",
"AssetFacesV2", "AssetFacesV2",
"AssetFacesV3",
"FaceClusterV1",
"UserMetadataV1" "UserMetadataV1"
], ],
"type": "string" "type": "string"
+2 -2
View File
@@ -19,7 +19,7 @@ import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { import {
AssetFaceUpdateDto, AssetFaceUpdateDto,
MergePersonDto, MergeFaceClusterDto,
PeopleResponseDto, PeopleResponseDto,
PeopleUpdateDto, PeopleUpdateDto,
PersonCreateDto, PersonCreateDto,
@@ -182,7 +182,7 @@ export class PersonController {
mergePerson( mergePerson(
@Auth() auth: AuthDto, @Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto, @Param() { id }: UUIDParamDto,
@Body() dto: MergePersonDto, @Body() dto: MergeFaceClusterDto,
): Promise<BulkIdResponseDto[]> { ): Promise<BulkIdResponseDto[]> {
return this.service.mergePerson(auth, id, dto); return this.service.mergePerson(auth, id, dto);
} }
+1 -1
View File
@@ -269,7 +269,7 @@ export type AssetFace = {
boundingBoxY2: number; boundingBoxY2: number;
imageHeight: number; imageHeight: number;
imageWidth: number; imageWidth: number;
personId: string | null; faceClusterId: string | null;
sourceType: SourceType; sourceType: SourceType;
person?: ShallowDehydrateObject<Person> | null; person?: ShallowDehydrateObject<Person> | null;
updatedAt: Date; updatedAt: Date;
+2 -2
View File
@@ -67,8 +67,8 @@ export class PeopleUpdateItem extends PersonUpdateDto {
id!: string; id!: string;
} }
export class MergePersonDto { export class MergeFaceClusterDto {
@ValidateUUID({ each: true, description: 'Person IDs to merge' }) @ValidateUUID({ each: true, description: 'Face cluster IDs to merge' })
ids!: string[]; ids!: string[];
} }
+68
View File
@@ -411,6 +411,18 @@ export class SyncPersonV1 {
faceAssetId!: string | null; faceAssetId!: string | null;
} }
@ExtraModel()
export class SyncPersonV2 extends SyncPersonV1 {
@ApiProperty({ description: 'Face cluster ID' })
faceClusterId!: string | null;
}
export function syncPersonV2ToV1(personV2: SyncPersonV2): SyncPersonV1 {
const { faceClusterId: _, ...personV1 } = personV2;
return personV1;
}
@ExtraModel() @ExtraModel()
export class SyncPersonDeleteV1 { export class SyncPersonDeleteV1 {
@ApiProperty({ description: 'Person ID' }) @ApiProperty({ description: 'Person ID' })
@@ -449,6 +461,40 @@ export class SyncAssetFaceV2 extends SyncAssetFaceV1 {
isVisible!: boolean; isVisible!: boolean;
} }
@ExtraModel()
export class SyncAssetFaceV3 {
@ApiProperty({ description: 'Asset face ID' })
id!: string;
@ApiProperty({ description: 'Asset ID' })
assetId!: string;
@ApiProperty({ description: 'Face cluster ID' })
faceClusterId!: string | null;
@ApiProperty({ type: 'integer' })
imageWidth!: number;
@ApiProperty({ type: 'integer' })
imageHeight!: number;
@ApiProperty({ type: 'integer' })
boundingBoxX1!: number;
@ApiProperty({ type: 'integer' })
boundingBoxY1!: number;
@ApiProperty({ type: 'integer' })
boundingBoxX2!: number;
@ApiProperty({ type: 'integer' })
boundingBoxY2!: number;
@ApiProperty({ description: 'Source type' })
sourceType!: string;
@ApiProperty({ description: 'Face deleted at' })
deletedAt!: Date | null;
@ApiProperty({ description: 'Is the face visible in the asset' })
isVisible!: boolean;
}
export function syncAssetFaceV3ToV2(faceV3: SyncAssetFaceV3, personId: string | null): SyncAssetFaceV2 {
const { faceClusterId: _, ...face } = faceV3;
return { ...face, personId };
}
export function syncAssetFaceV2ToV1(faceV2: SyncAssetFaceV2): SyncAssetFaceV1 { export function syncAssetFaceV2ToV1(faceV2: SyncAssetFaceV2): SyncAssetFaceV1 {
const { deletedAt: _, isVisible: __, ...faceV1 } = faceV2; const { deletedAt: _, isVisible: __, ...faceV1 } = faceV2;
@@ -461,6 +507,24 @@ export class SyncAssetFaceDeleteV1 {
assetFaceId!: string; assetFaceId!: string;
} }
@ExtraModel()
export class SyncFaceClusterV1 {
@ApiProperty({ description: 'Face cluster ID' })
id!: string;
@ApiProperty({ description: 'Created at' })
createdAt!: Date;
@ApiProperty({ description: 'Updated at' })
updatedAt!: Date;
@ApiProperty({ description: 'Owner ID' })
ownerId!: string;
}
@ExtraModel()
export class SyncFaceClusterDeleteV1 {
@ApiProperty({ description: 'Face cluster ID' })
faceClusterId!: string;
}
@ExtraModel() @ExtraModel()
export class SyncUserMetadataV1 { export class SyncUserMetadataV1 {
@ApiProperty({ description: 'User ID' }) @ApiProperty({ description: 'User ID' })
@@ -530,10 +594,14 @@ export type SyncItem = {
[SyncEntityType.PartnerStackDeleteV1]: SyncStackDeleteV1; [SyncEntityType.PartnerStackDeleteV1]: SyncStackDeleteV1;
[SyncEntityType.PartnerStackV1]: SyncStackV1; [SyncEntityType.PartnerStackV1]: SyncStackV1;
[SyncEntityType.PersonV1]: SyncPersonV1; [SyncEntityType.PersonV1]: SyncPersonV1;
[SyncEntityType.PersonV2]: SyncPersonV2;
[SyncEntityType.PersonDeleteV1]: SyncPersonDeleteV1; [SyncEntityType.PersonDeleteV1]: SyncPersonDeleteV1;
[SyncEntityType.AssetFaceV1]: SyncAssetFaceV1; [SyncEntityType.AssetFaceV1]: SyncAssetFaceV1;
[SyncEntityType.AssetFaceV2]: SyncAssetFaceV2; [SyncEntityType.AssetFaceV2]: SyncAssetFaceV2;
[SyncEntityType.AssetFaceV3]: SyncAssetFaceV3;
[SyncEntityType.AssetFaceDeleteV1]: SyncAssetFaceDeleteV1; [SyncEntityType.AssetFaceDeleteV1]: SyncAssetFaceDeleteV1;
[SyncEntityType.FaceClusterV1]: SyncFaceClusterV1;
[SyncEntityType.FaceClusterDeleteV1]: SyncFaceClusterDeleteV1;
[SyncEntityType.UserMetadataV1]: SyncUserMetadataV1; [SyncEntityType.UserMetadataV1]: SyncUserMetadataV1;
[SyncEntityType.UserMetadataDeleteV1]: SyncUserMetadataDeleteV1; [SyncEntityType.UserMetadataDeleteV1]: SyncUserMetadataDeleteV1;
[SyncEntityType.SyncAckV1]: SyncAckV1; [SyncEntityType.SyncAckV1]: SyncAckV1;
+8
View File
@@ -735,8 +735,11 @@ export enum SyncRequestType {
StacksV1 = 'StacksV1', StacksV1 = 'StacksV1',
UsersV1 = 'UsersV1', UsersV1 = 'UsersV1',
PeopleV1 = 'PeopleV1', PeopleV1 = 'PeopleV1',
PeopleV2 = 'PeopleV2',
AssetFacesV1 = 'AssetFacesV1', AssetFacesV1 = 'AssetFacesV1',
AssetFacesV2 = 'AssetFacesV2', AssetFacesV2 = 'AssetFacesV2',
AssetFacesV3 = 'AssetFacesV3',
FaceClusterV1 = 'FaceClusterV1',
UserMetadataV1 = 'UserMetadataV1', UserMetadataV1 = 'UserMetadataV1',
} }
@@ -794,12 +797,17 @@ export enum SyncEntityType {
StackDeleteV1 = 'StackDeleteV1', StackDeleteV1 = 'StackDeleteV1',
PersonV1 = 'PersonV1', PersonV1 = 'PersonV1',
PersonV2 = 'PersonV2',
PersonDeleteV1 = 'PersonDeleteV1', PersonDeleteV1 = 'PersonDeleteV1',
AssetFaceV1 = 'AssetFaceV1', AssetFaceV1 = 'AssetFaceV1',
AssetFaceV2 = 'AssetFaceV2', AssetFaceV2 = 'AssetFaceV2',
AssetFaceV3 = 'AssetFaceV3',
AssetFaceDeleteV1 = 'AssetFaceDeleteV1', AssetFaceDeleteV1 = 'AssetFaceDeleteV1',
FaceClusterV1 = 'FaceClusterV1',
FaceClusterDeleteV1 = 'FaceClusterDeleteV1',
UserMetadataV1 = 'UserMetadataV1', UserMetadataV1 = 'UserMetadataV1',
UserMetadataDeleteV1 = 'UserMetadataDeleteV1', UserMetadataDeleteV1 = 'UserMetadataDeleteV1',
+35 -23
View File
@@ -33,9 +33,9 @@ export interface AssetFaceId {
} }
export interface UpdateFacesData { export interface UpdateFacesData {
oldPersonId?: string; oldFaceClusterId?: string;
faceIds?: string[]; faceIds?: string[];
newPersonId: string; newFaceClusterId: string;
} }
export interface PersonStatistics { export interface PersonStatistics {
@@ -54,7 +54,7 @@ export interface GetAllPeopleOptions {
} }
export interface GetAllFacesOptions { export interface GetAllFacesOptions {
personId?: string | null; faceClusterId?: string | null;
assetId?: string; assetId?: string;
sourceType?: SourceType; sourceType?: SourceType;
} }
@@ -65,7 +65,7 @@ export type SelectFaceOptions = (keyof Selectable<AssetFaceTable>)[];
const withPerson = (eb: ExpressionBuilder<DB, 'asset_face'>) => { const withPerson = (eb: ExpressionBuilder<DB, 'asset_face'>) => {
return jsonObjectFrom( return jsonObjectFrom(
eb.selectFrom('person').selectAll('person').whereRef('person.id', '=', 'asset_face.personId'), eb.selectFrom('person').selectAll('person').whereRef('person.faceClusterId', '=', 'asset_face.faceClusterId'),
).as('person'); ).as('person');
}; };
@@ -80,11 +80,11 @@ export class PersonRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {} constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ params: [{ oldPersonId: DummyValue.UUID, newPersonId: DummyValue.UUID }] }) @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 const result = await this.db
.updateTable('asset_face') .updateTable('asset_face')
.set({ personId: newPersonId }) .set({ faceClusterId: newFaceClusterId })
.$if(!!oldPersonId, (qb) => qb.where('asset_face.personId', '=', oldPersonId!)) .$if(!!oldFaceClusterId, (qb) => qb.where('asset_face.faceClusterId', '=', oldFaceClusterId!))
.$if(!!faceIds, (qb) => qb.where('asset_face.id', 'in', faceIds!)) .$if(!!faceIds, (qb) => qb.where('asset_face.id', 'in', faceIds!))
.executeTakeFirst(); .executeTakeFirst();
@@ -94,7 +94,7 @@ export class PersonRepository {
async unassignFaces({ sourceType }: UnassignFacesOptions): Promise<void> { async unassignFaces({ sourceType }: UnassignFacesOptions): Promise<void> {
await this.db await this.db
.updateTable('asset_face') .updateTable('asset_face')
.set({ personId: null }) .set({ faceClusterId: null })
.where('asset_face.sourceType', '=', sourceType) .where('asset_face.sourceType', '=', sourceType)
.execute(); .execute();
} }
@@ -117,8 +117,8 @@ export class PersonRepository {
return this.db return this.db
.selectFrom('asset_face') .selectFrom('asset_face')
.selectAll('asset_face') .selectAll('asset_face')
.$if(options.personId === null, (qb) => qb.where('asset_face.personId', 'is', null)) .$if(options.faceClusterId === null, (qb) => qb.where('asset_face.faceClusterId', 'is', null))
.$if(!!options.personId, (qb) => qb.where('asset_face.personId', '=', options.personId!)) .$if(!!options.faceClusterId, (qb) => qb.where('asset_face.faceClusterId', '=', options.faceClusterId!))
.$if(!!options.sourceType, (qb) => qb.where('asset_face.sourceType', '=', options.sourceType!)) .$if(!!options.sourceType, (qb) => qb.where('asset_face.sourceType', '=', options.sourceType!))
.$if(!!options.assetId, (qb) => qb.where('asset_face.assetId', '=', options.assetId!)) .$if(!!options.assetId, (qb) => qb.where('asset_face.assetId', '=', options.assetId!))
.where('asset_face.deletedAt', 'is', null) .where('asset_face.deletedAt', 'is', null)
@@ -153,7 +153,7 @@ export class PersonRepository {
const items = await this.db const items = await this.db
.selectFrom('person') .selectFrom('person')
.selectAll('person') .selectAll('person')
.innerJoin('asset_face', 'asset_face.personId', 'person.id') .innerJoin('asset_face', 'asset_face.faceClusterId', 'person.faceClusterId')
.innerJoin('asset', (join) => .innerJoin('asset', (join) =>
join join
.onRef('asset_face.assetId', '=', 'asset.id') .onRef('asset_face.assetId', '=', 'asset.id')
@@ -209,7 +209,7 @@ export class PersonRepository {
return this.db return this.db
.selectFrom('person') .selectFrom('person')
.selectAll('person') .selectAll('person')
.leftJoin('asset_face', 'asset_face.personId', 'person.id') .leftJoin('asset_face', 'asset_face.faceClusterId', 'person.faceClusterId')
.where('asset_face.deletedAt', 'is', null) .where('asset_face.deletedAt', 'is', null)
.where('asset_face.isVisible', 'is', true) .where('asset_face.isVisible', 'is', true)
.having((eb) => eb.fn.count('asset_face.assetId'), '=', 0) .having((eb) => eb.fn.count('asset_face.assetId'), '=', 0)
@@ -248,7 +248,7 @@ export class PersonRepository {
getFaceForFacialRecognitionJob(id: string) { getFaceForFacialRecognitionJob(id: string) {
return this.db return this.db
.selectFrom('asset_face') .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) => .select((eb) =>
jsonObjectFrom( jsonObjectFrom(
eb eb
@@ -297,10 +297,10 @@ export class PersonRepository {
} }
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] }) @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 const result = await this.db
.updateTable('asset_face') .updateTable('asset_face')
.set({ personId: newPersonId }) .set({ faceClusterId: newFaceClusterId })
.where('asset_face.id', '=', assetFaceId) .where('asset_face.id', '=', assetFaceId)
.executeTakeFirst(); .executeTakeFirst();
@@ -346,13 +346,13 @@ export class PersonRepository {
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
async getStatistics(personId: string): Promise<PersonStatistics> { async getStatistics(faceClusterId: string): Promise<PersonStatistics> {
const result = await this.db const result = await this.db
.selectFrom('asset_face') .selectFrom('asset_face')
.leftJoin('asset', (join) => .leftJoin('asset', (join) =>
join join
.onRef('asset.id', '=', 'asset_face.assetId') .onRef('asset.id', '=', 'asset_face.assetId')
.on('asset_face.personId', '=', personId) .on('asset_face.faceClusterId', '=', faceClusterId)
.on('asset.visibility', '=', sql.lit(AssetVisibility.Timeline)) .on('asset.visibility', '=', sql.lit(AssetVisibility.Timeline))
.on('asset.deletedAt', 'is', null), .on('asset.deletedAt', 'is', null),
) )
@@ -375,7 +375,7 @@ export class PersonRepository {
eb.exists((eb) => eb.exists((eb) =>
eb eb
.selectFrom('asset_face') .selectFrom('asset_face')
.whereRef('asset_face.personId', '=', 'person.id') .whereRef('asset_face.faceClusterId', '=', 'person.faceClusterId')
.where('asset_face.deletedAt', 'is', null) .where('asset_face.deletedAt', 'is', null)
.where('asset_face.isVisible', '=', true) .where('asset_face.isVisible', '=', true)
.where((eb) => .where((eb) =>
@@ -395,7 +395,16 @@ export class PersonRepository {
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
} }
create(person: Insertable<PersonTable>) { async create(person: Insertable<PersonTable>) {
if (!person.faceClusterId) {
const { id } = await this.db
.insertInto('face_cluster')
.values({ ownerId: person.ownerId })
.returning('id')
.executeTakeFirstOrThrow();
person.faceClusterId = id;
}
return this.db.insertInto('person').values(person).returningAll().executeTakeFirstOrThrow(); return this.db.insertInto('person').values(person).returningAll().executeTakeFirstOrThrow();
} }
@@ -486,18 +495,19 @@ export class PersonRepository {
.selectFrom('asset_face') .selectFrom('asset_face')
.selectAll('asset_face') .selectAll('asset_face')
.select(withPerson) .select(withPerson)
.innerJoin('person', (join) => join.onRef('person.faceClusterId', '=', 'asset_face.faceClusterId'))
.where('person.id', 'in', personIds)
.where('asset_face.assetId', 'in', assetIds) .where('asset_face.assetId', 'in', assetIds)
.where('asset_face.personId', 'in', personIds)
.where('asset_face.deletedAt', 'is', null) .where('asset_face.deletedAt', 'is', null)
.execute(); .execute();
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
getRandomFace(personId: string) { getRandomFace(faceClusterId: string) {
return this.db return this.db
.selectFrom('asset_face') .selectFrom('asset_face')
.selectAll('asset_face') .selectAll('asset_face')
.where('asset_face.personId', '=', personId) .where('asset_face.faceClusterId', '=', faceClusterId)
.where('asset_face.deletedAt', 'is', null) .where('asset_face.deletedAt', 'is', null)
.where('asset_face.isVisible', 'is', true) .where('asset_face.isVisible', 'is', true)
.executeTakeFirst(); .executeTakeFirst();
@@ -584,7 +594,9 @@ export class PersonRepository {
.selectFrom('asset_face') .selectFrom('asset_face')
.select('asset_face.id') .select('asset_face.id')
.where('asset_face.assetId', '=', assetId) .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)) .innerJoin('asset', (join) => join.onRef('asset.id', '=', 'asset_face.assetId').on('asset.isOffline', '=', false))
.executeTakeFirst(); .executeTakeFirst();
} }
+3 -3
View File
@@ -338,15 +338,15 @@ export class SearchRepository {
.selectFrom('asset_face') .selectFrom('asset_face')
.select([ .select([
'asset_face.id', 'asset_face.id',
'asset_face.personId', 'asset_face.faceClusterId',
sql<number>`face_search.embedding <=> ${embedding}`.as('distance'), sql<number>`face_search.embedding <=> ${embedding}`.as('distance'),
]) ])
.innerJoin('asset', 'asset.id', 'asset_face.assetId') .innerJoin('asset', 'asset.id', 'asset_face.assetId')
.innerJoin('face_search', 'face_search.faceId', 'asset_face.id') .innerJoin('face_search', 'face_search.faceId', 'asset_face.id')
.leftJoin('person', 'person.id', 'asset_face.personId') .leftJoin('person', 'person.faceClusterId', 'asset_face.faceClusterId')
.where('asset.ownerId', '=', anyUuid(userIds)) .where('asset.ownerId', '=', anyUuid(userIds))
.where('asset.deletedAt', 'is', null) .where('asset.deletedAt', 'is', null)
.$if(!!hasPerson, (qb) => qb.where('asset_face.personId', 'is not', null)) .$if(!!hasPerson, (qb) => qb.where('asset_face.faceClusterId', 'is not', null))
.$if(!!minBirthDate, (qb) => .$if(!!minBirthDate, (qb) =>
qb.where((eb) => qb.where((eb) =>
eb.or([eb('person.birthDate', 'is', null), eb('person.birthDate', '<=', minBirthDate!)]), eb.or([eb('person.birthDate', 'is', null), eb('person.birthDate', '<=', minBirthDate!)]),
+35 -1
View File
@@ -57,6 +57,7 @@ export class SyncRepository {
assetFace: AssetFaceSync; assetFace: AssetFaceSync;
assetMetadata: AssetMetadataSync; assetMetadata: AssetMetadataSync;
authUser: AuthUserSync; authUser: AuthUserSync;
faceCluster: FaceClusterSync;
memory: MemorySync; memory: MemorySync;
memoryToAsset: MemoryToAssetSync; memoryToAsset: MemoryToAssetSync;
partner: PartnerSync; partner: PartnerSync;
@@ -80,6 +81,7 @@ export class SyncRepository {
this.assetFace = new AssetFaceSync(this.db); this.assetFace = new AssetFaceSync(this.db);
this.assetMetadata = new AssetMetadataSync(this.db); this.assetMetadata = new AssetMetadataSync(this.db);
this.authUser = new AuthUserSync(this.db); this.authUser = new AuthUserSync(this.db);
this.faceCluster = new FaceClusterSync(this.db);
this.memory = new MemorySync(this.db); this.memory = new MemorySync(this.db);
this.memoryToAsset = new MemoryToAssetSync(this.db); this.memoryToAsset = new MemoryToAssetSync(this.db);
this.partner = new PartnerSync(this.db); this.partner = new PartnerSync(this.db);
@@ -447,6 +449,7 @@ class PersonSync extends BaseSync {
'color', 'color',
'updateId', 'updateId',
'faceAssetId', 'faceAssetId',
'faceClusterId',
]) ])
.where('ownerId', '=', options.userId) .where('ownerId', '=', options.userId)
.stream(); .stream();
@@ -473,7 +476,7 @@ class AssetFaceSync extends BaseSync {
.select([ .select([
'asset_face.id', 'asset_face.id',
'assetId', 'assetId',
'personId', 'faceClusterId',
'imageWidth', 'imageWidth',
'imageHeight', 'imageHeight',
'boundingBoxX1', 'boundingBoxX1',
@@ -485,6 +488,8 @@ class AssetFaceSync extends BaseSync {
'asset_face.deletedAt', 'asset_face.deletedAt',
'asset_face.updateId', 'asset_face.updateId',
]) ])
.leftJoin('person', 'person.faceClusterId', 'asset_face.faceClusterId')
.select('person.id as personId')
.leftJoin('asset', 'asset.id', 'asset_face.assetId') .leftJoin('asset', 'asset.id', 'asset_face.assetId')
.where('asset.ownerId', '=', options.userId) .where('asset.ownerId', '=', options.userId)
.where('asset_face.isVisible', '=', true) .where('asset_face.isVisible', '=', true)
@@ -492,6 +497,35 @@ class AssetFaceSync extends BaseSync {
} }
} }
class FaceClusterSync extends BaseSync {
@GenerateSql({ params: [dummyQueryOptions], stream: true })
getDeletes(options: SyncQueryOptions) {
return this.auditQuery('face_cluster_audit', options)
.select(['face_cluster_audit.id', 'face_cluster_audit.faceClusterId'])
.leftJoin('face_cluster', 'face_cluster.id', 'face_cluster_audit.id')
.where('face_cluster.ownerId', '=', 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.createdAt',
'face_cluster.updatedAt',
'face_cluster.ownerId',
'face_cluster.updateId',
])
.where('face_cluster.ownerId', '=', options.userId)
.stream();
}
}
class AssetExifSync extends BaseSync { class AssetExifSync extends BaseSync {
@GenerateSql({ params: [dummyQueryOptions], stream: true }) @GenerateSql({ params: [dummyQueryOptions], stream: true })
getUpserts(options: SyncQueryOptions) { getUpserts(options: SyncQueryOptions) {
+13
View File
@@ -299,3 +299,16 @@ export const asset_edit_audit = registerFunction({
RETURN NULL; RETURN NULL;
END`, END`,
}); });
export const face_cluster_delete_audit = registerFunction({
name: 'face_cluster_delete_audit',
returnType: 'TRIGGER',
language: 'PLPGSQL',
body: `
BEGIN
INSERT INTO face_cluster_audit ("faceClusterId", "ownerId")
SELECT "id", "ownerId"
FROM OLD;
RETURN NULL;
END`,
});
+4
View File
@@ -41,6 +41,8 @@ import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table';
import { AssetOcrTable } from 'src/schema/tables/asset-ocr.table'; import { AssetOcrTable } from 'src/schema/tables/asset-ocr.table';
import { AssetTable } from 'src/schema/tables/asset.table'; import { AssetTable } from 'src/schema/tables/asset.table';
import { AuditTable } from 'src/schema/tables/audit.table'; import { AuditTable } from 'src/schema/tables/audit.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 { FaceSearchTable } from 'src/schema/tables/face-search.table';
import { GeodataPlacesTable } from 'src/schema/tables/geodata-places.table'; import { GeodataPlacesTable } from 'src/schema/tables/geodata-places.table';
import { LibraryTable } from 'src/schema/tables/library.table'; import { LibraryTable } from 'src/schema/tables/library.table';
@@ -199,6 +201,8 @@ export interface DB {
audit: AuditTable; audit: AuditTable;
face_cluster: FaceClusterTable;
face_cluster_audit: FaceClusterAuditTable;
face_search: FaceSearchTable; face_search: FaceSearchTable;
geodata_places: GeodataPlacesTable; geodata_places: GeodataPlacesTable;
+8 -8
View File
@@ -15,7 +15,7 @@ import { SourceType } from 'src/enum';
import { asset_face_source_type } from 'src/schema/enums'; import { asset_face_source_type } from 'src/schema/enums';
import { asset_face_audit } from 'src/schema/functions'; import { asset_face_audit } from 'src/schema/functions';
import { AssetTable } from 'src/schema/tables/asset.table'; 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' }) @Table({ name: 'asset_face' })
@UpdatedAtTrigger('asset_face_updatedAt') @UpdatedAtTrigger('asset_face_updatedAt')
@@ -26,13 +26,13 @@ import { PersonTable } from 'src/schema/tables/person.table';
when: 'pg_trigger_depth() = 0', when: 'pg_trigger_depth() = 0',
}) })
// schemaFromDatabase does not preserve column order // 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({ @Index({
name: 'asset_face_personId_assetId_notDeleted_isVisible_idx', name: 'asset_face_faceClusterId_assetId_notDeleted_isVisible_idx',
columns: ['personId', 'assetId'], columns: ['faceClusterId', 'assetId'],
where: '"deletedAt" IS NULL AND "isVisible" IS TRUE', where: '"deletedAt" IS NULL AND "isVisible" IS TRUE',
}) })
@Index({ columns: ['personId', 'assetId'] }) @Index({ columns: ['faceClusterId', 'assetId'] })
export class AssetFaceTable { export class AssetFaceTable {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id!: Generated<string>; id!: Generated<string>;
@@ -45,14 +45,14 @@ export class AssetFaceTable {
}) })
assetId!: string; assetId!: string;
@ForeignKeyColumn(() => PersonTable, { @ForeignKeyColumn(() => FaceClusterTable, {
onDelete: 'SET NULL', onDelete: 'SET NULL',
onUpdate: 'CASCADE', onUpdate: 'CASCADE',
nullable: true, nullable: true,
// [personId, assetId] makes this redundant // [faceClusterId, assetId] makes this redundant
index: false, index: false,
}) })
personId!: string | null; faceClusterId!: string | null;
@Column({ default: 0, type: 'integer' }) @Column({ default: 0, type: 'integer' })
imageWidth!: Generated<number>; 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 })
ownerId!: string;
@CreateDateColumn({ default: () => 'clock_timestamp()', index: true })
deletedAt!: Generated<Timestamp>;
}
@@ -0,0 +1,38 @@
import {
AfterDeleteTrigger,
CreateDateColumn,
ForeignKeyColumn,
Generated,
PrimaryGeneratedColumn,
Table,
Timestamp,
UpdateDateColumn,
} from '@immich/sql-tools';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { face_cluster_delete_audit } from 'src/schema/functions';
import { UserTable } from 'src/schema/tables/user.table';
@Table('face_cluster')
@UpdatedAtTrigger('face_cluster_updatedAt')
@AfterDeleteTrigger({
scope: 'statement',
function: face_cluster_delete_audit,
referencingOldTableAs: 'old',
when: 'pg_trigger_depth() = 0',
})
export class FaceClusterTable {
@PrimaryGeneratedColumn('uuid')
id!: Generated<string>;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
ownerId!: string;
}
+4
View File
@@ -13,6 +13,7 @@ import {
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { person_delete_audit } from 'src/schema/functions'; import { person_delete_audit } from 'src/schema/functions';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; 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'; import { UserTable } from 'src/schema/tables/user.table';
@Table('person') @Table('person')
@@ -60,4 +61,7 @@ export class PersonTable {
@UpdateIdColumn({ index: true }) @UpdateIdColumn({ index: true })
updateId!: Generated<string>; updateId!: Generated<string>;
@ForeignKeyColumn(() => FaceClusterTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: true, index: true })
faceClusterId!: string | null;
} }
+1 -1
View File
@@ -876,7 +876,7 @@ export class MetadataService extends BaseService {
} }
if (missing.length > 0) { if (missing.length > 0) {
this.logger.debugFn(() => `Creating missing persons: ${missing.map((p) => `${p.name}/${p.id}`)}`); this.logger.debug(`Creating missing people: ${missing.map((p) => `${p.name}/${p.id}`)}`);
const newPersonIds = await this.personRepository.createAll(missing); const newPersonIds = await this.personRepository.createAll(missing);
const jobs = newPersonIds.map((id) => ({ name: JobName.PersonGenerateThumbnail, data: { id } }) as const); const jobs = newPersonIds.map((id) => ({ name: JobName.PersonGenerateThumbnail, data: { id } }) as const);
await this.jobRepository.queueAll(jobs); await this.jobRepository.queueAll(jobs);
+21 -15
View File
@@ -13,7 +13,7 @@ import {
FaceDto, FaceDto,
mapFaces, mapFaces,
mapPerson, mapPerson,
MergePersonDto, MergeFaceClusterDto,
PeopleResponseDto, PeopleResponseDto,
PeopleUpdateDto, PeopleUpdateDto,
PersonCreateDto, PersonCreateDto,
@@ -438,7 +438,7 @@ export class PersonService extends BaseService {
const lastRun = new Date().toISOString(); const lastRun = new Date().toISOString();
const facePagination = this.personRepository.getAllFaces( 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 } }[] = []; let jobs: { name: JobName.FacialRecognition; data: { id: string; deferred: false } }[] = [];
@@ -481,8 +481,8 @@ export class PersonService extends BaseService {
return JobStatus.Failed; return JobStatus.Failed;
} }
if (face.personId) { if (face.faceClusterId) {
this.logger.debug(`Face ${id} already has a person assigned`); this.logger.debug(`Face ${id} already belongs to a face cluster`);
return JobStatus.Skipped; return JobStatus.Skipped;
} }
@@ -511,8 +511,8 @@ export class PersonService extends BaseService {
return JobStatus.Skipped; return JobStatus.Skipped;
} }
let personId = matches.find((match) => match.personId)?.personId; let faceClusterId = matches.find((match) => match.faceClusterId)?.faceClusterId;
if (!personId) { if (!faceClusterId) {
const matchWithPerson = await this.searchRepository.searchFaces({ const matchWithPerson = await this.searchRepository.searchFaces({
userIds: [face.asset.ownerId], userIds: [face.asset.ownerId],
embedding: face.faceSearch.embedding, embedding: face.faceSearch.embedding,
@@ -523,20 +523,20 @@ export class PersonService extends BaseService {
}); });
if (matchWithPerson.length > 0) { if (matchWithPerson.length > 0) {
personId = matchWithPerson[0].personId; faceClusterId = matchWithPerson[0].faceClusterId;
} }
} }
if (isCore && !personId) { if (isCore && !faceClusterId) {
this.logger.log(`Creating new person for face ${id}`); this.logger.log(`Creating new person for face ${id}`);
const newPerson = await this.personRepository.create({ ownerId: face.asset.ownerId, faceAssetId: face.id }); const newPerson = await this.personRepository.create({ ownerId: face.asset.ownerId, faceAssetId: face.id });
await this.jobRepository.queue({ name: JobName.PersonGenerateThumbnail, data: { id: newPerson.id } }); await this.jobRepository.queue({ name: JobName.PersonGenerateThumbnail, data: { id: newPerson.id } });
personId = newPerson.id; faceClusterId = newPerson.faceClusterId;
} }
if (personId) { if (faceClusterId) {
this.logger.debug(`Assigning face ${id} to person ${personId}`); this.logger.debug(`Assigning face ${id} to face cluster ${faceClusterId}`);
await this.personRepository.reassignFaces({ faceIds: [id], newPersonId: personId }); await this.personRepository.reassignFaces({ faceIds: [id], newFaceClusterId: faceClusterId });
} }
return JobStatus.Success; return JobStatus.Success;
@@ -554,7 +554,7 @@ export class PersonService extends BaseService {
return JobStatus.Success; return JobStatus.Success;
} }
async mergePerson(auth: AuthDto, id: string, dto: MergePersonDto): Promise<BulkIdResponseDto[]> { async mergePerson(auth: AuthDto, id: string, dto: MergeFaceClusterDto): Promise<BulkIdResponseDto[]> {
const mergeIds = dto.ids; const mergeIds = dto.ids;
if (mergeIds.includes(id)) { if (mergeIds.includes(id)) {
throw new BadRequestException('Cannot merge a person into themselves'); throw new BadRequestException('Cannot merge a person into themselves');
@@ -600,7 +600,7 @@ export class PersonService extends BaseService {
} }
const mergeName = mergePerson.name || mergePerson.id; const mergeName = mergePerson.name || mergePerson.id;
const mergeData: UpdateFacesData = { oldPersonId: mergeId, newPersonId: id }; const mergeData: UpdateFacesData = { oldFaceClusterId: mergeId, newFaceClusterId: id };
this.logger.log(`Merging ${mergeName} into ${primaryName}`); this.logger.log(`Merging ${mergeName} into ${primaryName}`);
await this.personRepository.reassignFaces(mergeData); await this.personRepository.reassignFaces(mergeData);
@@ -678,8 +678,14 @@ export class PersonService extends BaseService {
dto.imageHeight = originalDimensions.height; dto.imageHeight = originalDimensions.height;
} }
const person = await this.personRepository.getById(dto.personId);
if (!person?.faceClusterId) {
throw new Error('Person must already have some recognized faces and belong to a face cluster');
}
await this.personRepository.createAssetFace({ await this.personRepository.createAssetFace({
personId: dto.personId, faceClusterId: person.faceClusterId,
assetId: dto.assetId, assetId: dto.assetId,
imageHeight: dto.imageHeight, imageHeight: dto.imageHeight,
imageWidth: dto.imageWidth, imageWidth: dto.imageWidth,
+49 -2
View File
@@ -13,8 +13,10 @@ import {
SyncAckDeleteDto, SyncAckDeleteDto,
SyncAckSetDto, SyncAckSetDto,
syncAssetFaceV2ToV1, syncAssetFaceV2ToV1,
syncAssetFaceV3ToV2,
SyncAssetV1, SyncAssetV1,
SyncItem, SyncItem,
syncPersonV2ToV1,
SyncStreamDto, SyncStreamDto,
} from 'src/dtos/sync.dto'; } from 'src/dtos/sync.dto';
import { import {
@@ -192,8 +194,11 @@ export class SyncService extends BaseService {
[SyncRequestType.StacksV1]: () => this.syncStackV1(options, response, checkpointMap), [SyncRequestType.StacksV1]: () => this.syncStackV1(options, response, checkpointMap),
[SyncRequestType.PartnerStacksV1]: () => this.syncPartnerStackV1(options, response, checkpointMap, session.id), [SyncRequestType.PartnerStacksV1]: () => this.syncPartnerStackV1(options, response, checkpointMap, session.id),
[SyncRequestType.PeopleV1]: () => this.syncPeopleV1(options, response, checkpointMap), [SyncRequestType.PeopleV1]: () => this.syncPeopleV1(options, response, checkpointMap),
[SyncRequestType.PeopleV2]: () => this.syncPeopleV2(options, response, checkpointMap),
[SyncRequestType.AssetFacesV1]: async () => this.syncAssetFacesV1(options, response, checkpointMap), [SyncRequestType.AssetFacesV1]: async () => this.syncAssetFacesV1(options, response, checkpointMap),
[SyncRequestType.AssetFacesV2]: async () => this.syncAssetFacesV2(options, response, checkpointMap), [SyncRequestType.AssetFacesV2]: async () => this.syncAssetFacesV2(options, response, checkpointMap),
[SyncRequestType.AssetFacesV3]: async () => this.syncAssetFacesV3(options, response, checkpointMap),
[SyncRequestType.FaceClusterV1]: async () => this.syncFaceClusterV1(options, response, checkpointMap),
[SyncRequestType.UserMetadataV1]: () => this.syncUserMetadataV1(options, response, checkpointMap), [SyncRequestType.UserMetadataV1]: () => this.syncUserMetadataV1(options, response, checkpointMap),
}; };
@@ -796,6 +801,20 @@ export class SyncService extends BaseService {
const upsertType = SyncEntityType.PersonV1; const upsertType = SyncEntityType.PersonV1;
const upserts = this.syncRepository.person.getUpserts({ ...options, ack: checkpointMap[upsertType] }); const upserts = this.syncRepository.person.getUpserts({ ...options, ack: checkpointMap[upsertType] });
for await (const { updateId, ...data } of upserts) {
send(response, { type: upsertType, ids: [updateId], data: syncPersonV2ToV1(data) });
}
}
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) { for await (const { updateId, ...data } of upserts) {
send(response, { type: upsertType, ids: [updateId], data }); send(response, { type: upsertType, ids: [updateId], data });
} }
@@ -810,8 +829,8 @@ export class SyncService extends BaseService {
const upsertType = SyncEntityType.AssetFaceV1; const upsertType = SyncEntityType.AssetFaceV1;
const upserts = this.syncRepository.assetFace.getUpserts({ ...options, ack: checkpointMap[upsertType] }); const upserts = this.syncRepository.assetFace.getUpserts({ ...options, ack: checkpointMap[upsertType] });
for await (const { updateId, ...data } of upserts) { for await (const { updateId, personId, ...data } of upserts) {
const v1 = syncAssetFaceV2ToV1(data); const v1 = syncAssetFaceV2ToV1(syncAssetFaceV3ToV2(data, personId));
send(response, { type: upsertType, ids: [updateId], data: v1 }); send(response, { type: upsertType, ids: [updateId], data: v1 });
} }
} }
@@ -825,6 +844,34 @@ export class SyncService extends BaseService {
const upsertType = SyncEntityType.AssetFaceV2; const upsertType = SyncEntityType.AssetFaceV2;
const upserts = this.syncRepository.assetFace.getUpserts({ ...options, ack: checkpointMap[upsertType] }); const upserts = this.syncRepository.assetFace.getUpserts({ ...options, ack: checkpointMap[upsertType] });
for await (const { updateId, personId, ...data } of upserts) {
send(response, { type: upsertType, ids: [updateId], data: syncAssetFaceV3ToV2(data, personId) });
}
}
private async syncAssetFacesV3(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap) {
const deleteType = SyncEntityType.AssetFaceDeleteV1;
const deletes = this.syncRepository.assetFace.getDeletes({ ...options, ack: checkpointMap[deleteType] });
for await (const { id, ...data } of deletes) {
send(response, { type: deleteType, ids: [id], data });
}
const upsertType = SyncEntityType.AssetFaceV3;
const upserts = this.syncRepository.assetFace.getUpserts({ ...options, ack: checkpointMap[upsertType] });
for await (const { updateId, personId: _, ...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) { for await (const { updateId, ...data } of upserts) {
send(response, { type: upsertType, ids: [updateId], data }); send(response, { type: upsertType, ids: [updateId], data });
} }
+9 -3
View File
@@ -144,7 +144,12 @@ export function withFacesAndPeople(
.selectFrom('asset_face') .selectFrom('asset_face')
.leftJoinLateral( .leftJoinLateral(
(eb) => (eb) =>
eb.selectFrom('person').selectAll('person').whereRef('asset_face.personId', '=', 'person.id').as('person'), eb
.selectFrom('face_cluster')
.where('face_cluster.id', '=', 'asset_face.faceClusterId')
.innerJoin('person', 'person.faceClusterId', 'face_cluster.id')
.selectAll('person')
.as('person'),
(join) => join.onTrue(), (join) => join.onTrue(),
) )
.selectAll('asset_face') .selectAll('asset_face')
@@ -161,11 +166,12 @@ export function hasPeople<O>(qb: SelectQueryBuilder<DB, 'asset', O>, personIds:
eb eb
.selectFrom('asset_face') .selectFrom('asset_face')
.select('assetId') .select('assetId')
.where('personId', '=', anyUuid(personIds!)) .innerJoin('person', 'person.faceClusterId', 'asset_face.faceClusterId')
.where('person.id', '=', anyUuid(personIds!))
.where('deletedAt', 'is', null) .where('deletedAt', 'is', null)
.where('isVisible', 'is', true) .where('isVisible', 'is', true)
.groupBy('assetId') .groupBy('assetId')
.having((eb) => eb.fn.count('personId').distinct(), '=', personIds.length) .having((eb) => eb.fn.count('person.id').distinct(), '=', personIds.length)
.as('has_people'), .as('has_people'),
(join) => join.onRef('has_people.assetId', '=', 'asset.id'), (join) => join.onRef('has_people.assetId', '=', 'asset.id'),
); );