diff --git a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart index fb5c7fdb3..e141c387b 100644 --- a/mobile/lib/infrastructure/repositories/sync_stream.repository.dart +++ b/mobile/lib/infrastructure/repositories/sync_stream.repository.dart @@ -523,7 +523,6 @@ class SyncStreamRepository extends DriftDatabaseRepository { ownerId: Value(person.ownerId), name: Value(person.name), faceAssetId: Value(person.faceAssetId), - thumbnailPath: Value(person.thumbnailPath), isFavorite: Value(person.isFavorite), isHidden: Value(person.isHidden), color: Value(person.color), diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 28fa63ba8..3e7fa4c2f 100644 Binary files a/mobile/openapi/README.md and b/mobile/openapi/README.md differ diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index becafa06b..545955a18 100644 Binary files a/mobile/openapi/lib/api.dart and b/mobile/openapi/lib/api.dart differ diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 603163f00..55d6f4108 100644 Binary files a/mobile/openapi/lib/api_client.dart and b/mobile/openapi/lib/api_client.dart differ diff --git a/mobile/openapi/lib/model/sync_asset_face_delete_v1.dart b/mobile/openapi/lib/model/sync_asset_face_delete_v1.dart new file mode 100644 index 000000000..0992bfdcb Binary files /dev/null and b/mobile/openapi/lib/model/sync_asset_face_delete_v1.dart differ diff --git a/mobile/openapi/lib/model/sync_asset_face_v1.dart b/mobile/openapi/lib/model/sync_asset_face_v1.dart new file mode 100644 index 000000000..853a8a151 Binary files /dev/null and b/mobile/openapi/lib/model/sync_asset_face_v1.dart differ diff --git a/mobile/openapi/lib/model/sync_entity_type.dart b/mobile/openapi/lib/model/sync_entity_type.dart index 61f94401c..65ed78105 100644 Binary files a/mobile/openapi/lib/model/sync_entity_type.dart and b/mobile/openapi/lib/model/sync_entity_type.dart differ diff --git a/mobile/openapi/lib/model/sync_person_v1.dart b/mobile/openapi/lib/model/sync_person_v1.dart index e86c22f64..6749beb3e 100644 Binary files a/mobile/openapi/lib/model/sync_person_v1.dart and b/mobile/openapi/lib/model/sync_person_v1.dart differ diff --git a/mobile/openapi/lib/model/sync_request_type.dart b/mobile/openapi/lib/model/sync_request_type.dart index 75ce852f9..800b3f448 100644 Binary files a/mobile/openapi/lib/model/sync_request_type.dart and b/mobile/openapi/lib/model/sync_request_type.dart differ diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 71329c3f7..2f41318d6 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -13788,6 +13788,65 @@ ], "type": "object" }, + "SyncAssetFaceDeleteV1": { + "properties": { + "assetFaceId": { + "type": "string" + } + }, + "required": [ + "assetFaceId" + ], + "type": "object" + }, + "SyncAssetFaceV1": { + "properties": { + "assetId": { + "type": "string" + }, + "boundingBoxX1": { + "type": "number" + }, + "boundingBoxX2": { + "type": "number" + }, + "boundingBoxY1": { + "type": "number" + }, + "boundingBoxY2": { + "type": "number" + }, + "id": { + "type": "string" + }, + "imageHeight": { + "type": "number" + }, + "imageWidth": { + "type": "number" + }, + "personId": { + "nullable": true, + "type": "string" + }, + "sourceType": { + "type": "string" + } + }, + "required": [ + "assetId", + "boundingBoxX1", + "boundingBoxX2", + "boundingBoxY1", + "boundingBoxY2", + "id", + "imageHeight", + "imageWidth", + "personId", + "sourceType" + ], + "type": "object" + }, "SyncAssetV1": { "properties": { "checksum": { @@ -13912,6 +13971,8 @@ "StackDeleteV1", "PersonV1", "PersonDeleteV1", + "AssetFaceV1", + "AssetFaceDeleteV1", "UserMetadataV1", "UserMetadataDeleteV1", "SyncAckV1", @@ -14109,9 +14170,6 @@ "ownerId": { "type": "string" }, - "thumbnailPath": { - "type": "string" - }, "updatedAt": { "format": "date-time", "type": "string" @@ -14127,7 +14185,6 @@ "isHidden", "name", "ownerId", - "thumbnailPath", "updatedAt" ], "type": "object" @@ -14150,6 +14207,7 @@ "StacksV1", "UsersV1", "PeopleV1", + "AssetFacesV1", "UserMetadataV1" ], "type": "string" diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index f60fa6dfe..d5f7fde52 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -4125,6 +4125,8 @@ export enum SyncEntityType { StackDeleteV1 = "StackDeleteV1", PersonV1 = "PersonV1", PersonDeleteV1 = "PersonDeleteV1", + AssetFaceV1 = "AssetFaceV1", + AssetFaceDeleteV1 = "AssetFaceDeleteV1", UserMetadataV1 = "UserMetadataV1", UserMetadataDeleteV1 = "UserMetadataDeleteV1", SyncAckV1 = "SyncAckV1", @@ -4147,6 +4149,7 @@ export enum SyncRequestType { StacksV1 = "StacksV1", UsersV1 = "UsersV1", PeopleV1 = "PeopleV1", + AssetFacesV1 = "AssetFacesV1", UserMetadataV1 = "UserMetadataV1" } export enum TranscodeHWAccel { diff --git a/server/src/database.ts b/server/src/database.ts index d42b2618a..dc99fc5b3 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -272,6 +272,8 @@ export type AssetFace = { personId: string | null; sourceType: SourceType; person?: Person | null; + updatedAt: Date; + updateId: string; }; const userColumns = ['id', 'name', 'email', 'avatarColor', 'profileImagePath', 'profileChangedAt'] as const; diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index 9725539e3..e0c9c059c 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -245,7 +245,6 @@ export class SyncPersonV1 { ownerId!: string; name!: string; birthDate!: Date | null; - thumbnailPath!: string; isHidden!: boolean; isFavorite!: boolean; color!: string | null; @@ -257,6 +256,25 @@ export class SyncPersonDeleteV1 { personId!: string; } +@ExtraModel() +export class SyncAssetFaceV1 { + id!: string; + assetId!: string; + personId!: string | null; + imageWidth!: number; + imageHeight!: number; + boundingBoxX1!: number; + boundingBoxY1!: number; + boundingBoxX2!: number; + boundingBoxY2!: number; + sourceType!: string; +} + +@ExtraModel() +export class SyncAssetFaceDeleteV1 { + assetFaceId!: string; +} + @ExtraModel() export class SyncUserMetadataV1 { userId!: string; @@ -312,6 +330,8 @@ export type SyncItem = { [SyncEntityType.PartnerStackV1]: SyncStackV1; [SyncEntityType.PersonV1]: SyncPersonV1; [SyncEntityType.PersonDeleteV1]: SyncPersonDeleteV1; + [SyncEntityType.AssetFaceV1]: SyncAssetFaceV1; + [SyncEntityType.AssetFaceDeleteV1]: SyncAssetFaceDeleteV1; [SyncEntityType.UserMetadataV1]: SyncUserMetadataV1; [SyncEntityType.UserMetadataDeleteV1]: SyncUserMetadataDeleteV1; [SyncEntityType.SyncAckV1]: SyncAckV1; diff --git a/server/src/enum.ts b/server/src/enum.ts index e41a79099..f2eae615a 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -568,6 +568,7 @@ export enum SyncRequestType { StacksV1 = 'StacksV1', UsersV1 = 'UsersV1', PeopleV1 = 'PeopleV1', + AssetFacesV1 = 'AssetFacesV1', UserMetadataV1 = 'UserMetadataV1', } @@ -619,6 +620,9 @@ export enum SyncEntityType { PersonV1 = 'PersonV1', PersonDeleteV1 = 'PersonDeleteV1', + AssetFaceV1 = 'AssetFaceV1', + AssetFaceDeleteV1 = 'AssetFaceDeleteV1', + UserMetadataV1 = 'UserMetadataV1', UserMetadataDeleteV1 = 'UserMetadataDeleteV1', diff --git a/server/src/queries/sync.repository.sql b/server/src/queries/sync.repository.sql index 4782eedf1..7502b79f5 100644 --- a/server/src/queries/sync.repository.sql +++ b/server/src/queries/sync.repository.sql @@ -409,6 +409,41 @@ where order by "updateId" asc +-- SyncRepository.assetFace.getDeletes +select + "asset_face_audit"."id", + "assetFaceId" +from + "asset_face_audit" + left join "asset" on "asset"."id" = "asset_face_audit"."assetId" +where + "asset"."ownerId" = $1 + and "asset_face_audit"."deletedAt" < now() - interval '1 millisecond' +order by + "asset_face_audit"."id" asc + +-- SyncRepository.assetFace.getUpserts +select + "asset_face"."id", + "assetId", + "personId", + "imageWidth", + "imageHeight", + "boundingBoxX1", + "boundingBoxY1", + "boundingBoxX2", + "boundingBoxY2", + "sourceType", + "asset_face"."updateId" +from + "asset_face" + left join "asset" on "asset"."id" = "asset_face"."assetId" +where + "asset_face"."updatedAt" < now() - interval '1 millisecond' + and "asset"."ownerId" = $1 +order by + "asset_face"."updateId" asc + -- SyncRepository.memory.getDeletes select "id", @@ -779,7 +814,6 @@ select "ownerId", "name", "birthDate", - "thumbnailPath", "isHidden", "isFavorite", "color", diff --git a/server/src/repositories/sync.repository.ts b/server/src/repositories/sync.repository.ts index 34c450d52..dba52d25a 100644 --- a/server/src/repositories/sync.repository.ts +++ b/server/src/repositories/sync.repository.ts @@ -17,7 +17,8 @@ type AuditTables = | 'memory_asset_audit' | 'stack_audit' | 'person_audit' - | 'user_metadata_audit'; + | 'user_metadata_audit' + | 'asset_face_audit'; type UpsertTables = | 'user' | 'partner' @@ -29,7 +30,8 @@ type UpsertTables = | 'memory_asset' | 'stack' | 'person' - | 'user_metadata'; + | 'user_metadata' + | 'asset_face'; @Injectable() export class SyncRepository { @@ -40,6 +42,7 @@ export class SyncRepository { albumUser: AlbumUserSync; asset: AssetSync; assetExif: AssetExifSync; + assetFace: AssetFaceSync; memory: MemorySync; memoryToAsset: MemoryToAssetSync; partner: PartnerSync; @@ -59,6 +62,7 @@ export class SyncRepository { this.albumUser = new AlbumUserSync(this.db); this.asset = new AssetSync(this.db); this.assetExif = new AssetExifSync(this.db); + this.assetFace = new AssetFaceSync(this.db); this.memory = new MemorySync(this.db); this.memoryToAsset = new MemoryToAssetSync(this.db); this.partner = new PartnerSync(this.db); @@ -385,7 +389,6 @@ class PersonSync extends BaseSync { 'ownerId', 'name', 'birthDate', - 'thumbnailPath', 'isHidden', 'isFavorite', 'color', @@ -398,6 +401,46 @@ class PersonSync extends BaseSync { } } +class AssetFaceSync extends BaseSync { + @GenerateSql({ params: [DummyValue.UUID], stream: true }) + getDeletes(userId: string, ack?: SyncAck) { + return this.db + .selectFrom('asset_face_audit') + .select(['asset_face_audit.id', 'assetFaceId']) + .orderBy('asset_face_audit.id', 'asc') + .leftJoin('asset', 'asset.id', 'asset_face_audit.assetId') + .where('asset.ownerId', '=', userId) + .where('asset_face_audit.deletedAt', '<', sql.raw("now() - interval '1 millisecond'")) + .$if(!!ack, (qb) => qb.where('asset_face_audit.id', '>', ack!.updateId)) + .stream(); + } + + @GenerateSql({ params: [DummyValue.UUID], stream: true }) + getUpserts(userId: string, ack?: SyncAck) { + return this.db + .selectFrom('asset_face') + .select([ + 'asset_face.id', + 'assetId', + 'personId', + 'imageWidth', + 'imageHeight', + 'boundingBoxX1', + 'boundingBoxY1', + 'boundingBoxX2', + 'boundingBoxY2', + 'sourceType', + 'asset_face.updateId', + ]) + .where('asset_face.updatedAt', '<', sql.raw("now() - interval '1 millisecond'")) + .$if(!!ack, (qb) => qb.where('asset_face.updateId', '>', ack!.updateId)) + .orderBy('asset_face.updateId', 'asc') + .leftJoin('asset', 'asset.id', 'asset_face.assetId') + .where('asset.ownerId', '=', userId) + .stream(); + } +} + class AssetExifSync extends BaseSync { @GenerateSql({ params: [DummyValue.UUID], stream: true }) getUpserts(userId: string, ack?: SyncAck) { diff --git a/server/src/schema/functions.ts b/server/src/schema/functions.ts index 557716922..786e7a1ff 100644 --- a/server/src/schema/functions.ts +++ b/server/src/schema/functions.ts @@ -229,3 +229,16 @@ export const user_metadata_audit = registerFunction({ RETURN NULL; END`, }); + +export const asset_face_audit = registerFunction({ + name: 'asset_face_audit', + returnType: 'TRIGGER', + language: 'PLPGSQL', + body: ` + BEGIN + INSERT INTO asset_face_audit ("assetFaceId", "assetId") + SELECT "id", "assetId" + FROM OLD; + RETURN NULL; + END`, +}); diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index ba25a65d4..8982437b3 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -4,6 +4,7 @@ import { album_user_after_insert, album_user_delete_audit, asset_delete_audit, + asset_face_audit, f_concat_ws, f_unaccent, immich_uuid_v7, @@ -27,6 +28,7 @@ import { AlbumTable } from 'src/schema/tables/album.table'; import { ApiKeyTable } from 'src/schema/tables/api-key.table'; import { AssetAuditTable } from 'src/schema/tables/asset-audit.table'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; +import { AssetFaceAuditTable } from 'src/schema/tables/asset-face-audit.table'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; import { AssetFileTable } from 'src/schema/tables/asset-file.table'; import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table'; @@ -78,6 +80,7 @@ export class ImmichDatabase { ApiKeyTable, AssetAuditTable, AssetFaceTable, + AssetFaceAuditTable, AssetJobStatusTable, AssetTable, AssetFileTable, @@ -132,6 +135,7 @@ export class ImmichDatabase { stack_delete_audit, person_delete_audit, user_metadata_audit, + asset_face_audit, ]; enum = [assets_status_enum, asset_face_source_type, asset_visibility_enum]; @@ -158,6 +162,7 @@ export interface DB { asset: AssetTable; asset_exif: AssetExifTable; asset_face: AssetFaceTable; + asset_face_audit: AssetFaceAuditTable; asset_file: AssetFileTable; asset_job_status: AssetJobStatusTable; asset_audit: AssetAuditTable; diff --git a/server/src/schema/migrations/1753104909784-AssetFaceUpdateIdAndAuditTable.ts b/server/src/schema/migrations/1753104909784-AssetFaceUpdateIdAndAuditTable.ts new file mode 100644 index 000000000..1f4072e34 --- /dev/null +++ b/server/src/schema/migrations/1753104909784-AssetFaceUpdateIdAndAuditTable.ts @@ -0,0 +1,52 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE OR REPLACE FUNCTION asset_face_audit() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS $$ + BEGIN + INSERT INTO asset_face_audit ("assetFaceId", "assetId") + SELECT "id", "assetId" + FROM OLD; + RETURN NULL; + END + $$;`.execute(db); + await sql`CREATE TABLE "asset_face_audit" ( + "id" uuid NOT NULL DEFAULT immich_uuid_v7(), + "assetFaceId" uuid NOT NULL, + "assetId" uuid NOT NULL, + "deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp(), + CONSTRAINT "asset_face_audit_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "asset_face_audit_assetFaceId_idx" ON "asset_face_audit" ("assetFaceId");`.execute(db); + await sql`CREATE INDEX "asset_face_audit_assetId_idx" ON "asset_face_audit" ("assetId");`.execute(db); + await sql`CREATE INDEX "asset_face_audit_deletedAt_idx" ON "asset_face_audit" ("deletedAt");`.execute(db); + await sql`ALTER TABLE "asset_face" ADD "updatedAt" timestamp with time zone NOT NULL DEFAULT now();`.execute(db); + await sql`ALTER TABLE "asset_face" ADD "updateId" uuid NOT NULL DEFAULT immich_uuid_v7();`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "asset_face_audit" + AFTER DELETE ON "asset_face" + REFERENCING OLD TABLE AS "old" + FOR EACH STATEMENT + WHEN (pg_trigger_depth() = 0) + EXECUTE FUNCTION asset_face_audit();`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "asset_face_updatedAt" + BEFORE UPDATE ON "asset_face" + FOR EACH ROW + EXECUTE FUNCTION updated_at();`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_asset_face_audit', '{"type":"function","name":"asset_face_audit","sql":"CREATE OR REPLACE FUNCTION asset_face_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO asset_face_audit (\\"assetFaceId\\", \\"assetId\\")\\n SELECT \\"id\\", \\"assetId\\"\\n FROM OLD;\\n RETURN NULL;\\n END\\n $$;"}'::jsonb);`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_asset_face_audit', '{"type":"trigger","name":"asset_face_audit","sql":"CREATE OR REPLACE TRIGGER \\"asset_face_audit\\"\\n AFTER DELETE ON \\"asset_face\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() = 0)\\n EXECUTE FUNCTION asset_face_audit();"}'::jsonb);`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_asset_face_updatedAt', '{"type":"trigger","name":"asset_face_updatedAt","sql":"CREATE OR REPLACE TRIGGER \\"asset_face_updatedAt\\"\\n BEFORE UPDATE ON \\"asset_face\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();"}'::jsonb);`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DROP TRIGGER "asset_face_audit" ON "asset_face";`.execute(db); + await sql`DROP TRIGGER "asset_face_updatedAt" ON "asset_face";`.execute(db); + await sql`ALTER TABLE "asset_face" DROP COLUMN "updatedAt";`.execute(db); + await sql`ALTER TABLE "asset_face" DROP COLUMN "updateId";`.execute(db); + await sql`DROP TABLE "asset_face_audit";`.execute(db); + await sql`DROP FUNCTION asset_face_audit;`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_asset_face_audit';`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_asset_face_audit';`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_asset_face_updatedAt';`.execute(db); +} diff --git a/server/src/schema/tables/asset-face-audit.table.ts b/server/src/schema/tables/asset-face-audit.table.ts new file mode 100644 index 000000000..4f03c22aa --- /dev/null +++ b/server/src/schema/tables/asset-face-audit.table.ts @@ -0,0 +1,17 @@ +import { PrimaryGeneratedUuidV7Column } from 'src/decorators'; +import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools'; + +@Table('asset_face_audit') +export class AssetFaceAuditTable { + @PrimaryGeneratedUuidV7Column() + id!: Generated; + + @Column({ type: 'uuid', index: true }) + assetFaceId!: string; + + @Column({ type: 'uuid', index: true }) + assetId!: string; + + @CreateDateColumn({ default: () => 'clock_timestamp()', index: true }) + deletedAt!: Generated; +} diff --git a/server/src/schema/tables/asset-face.table.ts b/server/src/schema/tables/asset-face.table.ts index 6e45a3a64..5041d945e 100644 --- a/server/src/schema/tables/asset-face.table.ts +++ b/server/src/schema/tables/asset-face.table.ts @@ -1,8 +1,11 @@ +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; 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 { + AfterDeleteTrigger, Column, DeleteDateColumn, ForeignKeyColumn, @@ -11,9 +14,17 @@ import { PrimaryGeneratedColumn, Table, Timestamp, + UpdateDateColumn, } from 'src/sql-tools'; @Table({ name: 'asset_face' }) +@UpdatedAtTrigger('asset_face_updatedAt') +@AfterDeleteTrigger({ + scope: 'statement', + function: asset_face_audit, + referencingOldTableAs: 'old', + when: 'pg_trigger_depth() = 0', +}) // schemaFromDatabase does not preserve column order @Index({ name: 'asset_face_assetId_personId_idx', columns: ['assetId', 'personId'] }) @Index({ columns: ['personId', 'assetId'] }) @@ -61,4 +72,10 @@ export class AssetFaceTable { @DeleteDateColumn() deletedAt!: Timestamp | null; + + @UpdateDateColumn() + updatedAt!: Generated; + + @UpdateIdColumn() + updateId!: Generated; } diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index 4463ab0d7..fb582ab03 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -70,6 +70,7 @@ export const SYNC_TYPES_ORDER = [ SyncRequestType.MemoriesV1, SyncRequestType.MemoryToAssetsV1, SyncRequestType.PeopleV1, + SyncRequestType.AssetFacesV1, SyncRequestType.UserMetadataV1, ]; @@ -156,6 +157,7 @@ export class SyncService extends BaseService { [SyncRequestType.StacksV1]: () => this.syncStackV1(response, checkpointMap, auth), [SyncRequestType.PartnerStacksV1]: () => this.syncPartnerStackV1(response, checkpointMap, auth, session.id), [SyncRequestType.PeopleV1]: () => this.syncPeopleV1(response, checkpointMap, auth), + [SyncRequestType.AssetFacesV1]: async () => this.syncAssetFacesV1(response, checkpointMap, auth), [SyncRequestType.UserMetadataV1]: () => this.syncUserMetadataV1(response, checkpointMap, auth), }; @@ -606,6 +608,20 @@ export class SyncService extends BaseService { } } + private async syncAssetFacesV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto) { + const deleteType = SyncEntityType.AssetFaceDeleteV1; + const deletes = this.syncRepository.assetFace.getDeletes(auth.user.id, checkpointMap[deleteType]); + for await (const { id, ...data } of deletes) { + send(response, { type: deleteType, ids: [id], data }); + } + + const upsertType = SyncEntityType.AssetFaceV1; + const upserts = this.syncRepository.assetFace.getUpserts(auth.user.id, checkpointMap[upsertType]); + for await (const { updateId, ...data } of upserts) { + send(response, { type: upsertType, ids: [updateId], data }); + } + } + private async syncUserMetadataV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto) { const deleteType = SyncEntityType.UserMetadataDeleteV1; const deletes = this.syncRepository.userMetadata.getDeletes(auth.user.id, checkpointMap[deleteType]); diff --git a/server/test/fixtures/face.stub.ts b/server/test/fixtures/face.stub.ts index beecf7c69..f655a3944 100644 --- a/server/test/fixtures/face.stub.ts +++ b/server/test/fixtures/face.stub.ts @@ -23,6 +23,8 @@ export const faceStub = { sourceType: SourceType.MachineLearning, faceSearch: { faceId: 'assetFaceId1', embedding: '[1, 2, 3, 4]' }, deletedAt: new Date(), + updatedAt: new Date('2023-01-01T00:00:00Z'), + updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', }), primaryFace1: Object.freeze({ id: 'assetFaceId2', @@ -39,6 +41,8 @@ export const faceStub = { sourceType: SourceType.MachineLearning, faceSearch: { faceId: 'assetFaceId2', embedding: '[1, 2, 3, 4]' }, deletedAt: null, + updatedAt: new Date('2023-01-01T00:00:00Z'), + updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', }), mergeFace1: Object.freeze({ id: 'assetFaceId3', @@ -55,6 +59,8 @@ export const faceStub = { sourceType: SourceType.MachineLearning, faceSearch: { faceId: 'assetFaceId3', embedding: '[1, 2, 3, 4]' }, deletedAt: null, + updatedAt: new Date('2023-01-01T00:00:00Z'), + updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', }), noPerson1: Object.freeze({ id: 'assetFaceId8', @@ -71,6 +77,8 @@ export const faceStub = { sourceType: SourceType.MachineLearning, faceSearch: { faceId: 'assetFaceId8', embedding: '[1, 2, 3, 4]' }, deletedAt: null, + updatedAt: new Date('2023-01-01T00:00:00Z'), + updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', }), noPerson2: Object.freeze({ id: 'assetFaceId9', @@ -87,6 +95,8 @@ export const faceStub = { sourceType: SourceType.MachineLearning, faceSearch: { faceId: 'assetFaceId9', embedding: '[1, 2, 3, 4]' }, deletedAt: null, + updatedAt: new Date('2023-01-01T00:00:00Z'), + updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', }), fromExif1: Object.freeze({ id: 'assetFaceId9', @@ -102,6 +112,8 @@ export const faceStub = { imageWidth: 400, sourceType: SourceType.Exif, deletedAt: null, + updatedAt: new Date('2023-01-01T00:00:00Z'), + updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', }), fromExif2: Object.freeze({ id: 'assetFaceId9', @@ -117,6 +129,8 @@ export const faceStub = { imageWidth: 1024, sourceType: SourceType.Exif, deletedAt: null, + updatedAt: new Date('2023-01-01T00:00:00Z'), + updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', }), withBirthDate: Object.freeze({ id: 'assetFaceId10', @@ -132,5 +146,7 @@ export const faceStub = { imageWidth: 1024, sourceType: SourceType.MachineLearning, deletedAt: null, + updatedAt: new Date('2023-01-01T00:00:00Z'), + updateId: '0d1173e3-4d80-4d76-b41e-57d56de21125', }), }; diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index 4d13264fa..d6038b6b8 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -154,6 +154,12 @@ export class MediumTestContext { return { asset, result }; } + async newAssetFace(dto: Partial> & { assetId: string }) { + const assetFace = mediumFactory.assetFaceInsert(dto); + const result = await this.get(PersonRepository).createAssetFace(assetFace); + return { assetFace, result }; + } + async newMemory(dto: Partial> = {}) { const memory = mediumFactory.memoryInsert(dto); const result = await this.get(MemoryRepository).create(memory, new Set()); diff --git a/server/test/medium/specs/sync/sync-asset-face.spec.ts b/server/test/medium/specs/sync/sync-asset-face.spec.ts new file mode 100644 index 000000000..68d3007c5 --- /dev/null +++ b/server/test/medium/specs/sync/sync-asset-face.spec.ts @@ -0,0 +1,92 @@ +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 { factory } from 'test/small.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = async (db?: Kysely) => { + const ctx = new SyncTestContext(db || defaultDatabase); + const { auth, user, session } = await ctx.newSyncAuthUser(); + return { auth, user, session, ctx }; +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(SyncEntityType.AssetFaceV1, () => { + it('should detect and sync the first asset face', async () => { + 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 response = await ctx.syncStream(auth, [SyncRequestType.AssetFacesV1]); + expect(response).toHaveLength(1); + expect(response).toEqual([ + { + ack: expect.any(String), + data: expect.objectContaining({ + id: assetFace.id, + assetId: asset.id, + personId: person.id, + imageWidth: assetFace.imageWidth, + imageHeight: assetFace.imageHeight, + boundingBoxX1: assetFace.boundingBoxX1, + boundingBoxY1: assetFace.boundingBoxY1, + boundingBoxX2: assetFace.boundingBoxX2, + boundingBoxY2: assetFace.boundingBoxY2, + sourceType: assetFace.sourceType, + }), + type: 'AssetFaceV1', + }, + ]); + + await ctx.syncAckAll(auth, response); + await expect(ctx.syncStream(auth, [SyncRequestType.AssetFacesV1])).resolves.toEqual([]); + }); + + it('should detect and sync a deleted asset face', async () => { + const { auth, ctx } = await setup(); + const personRepo = ctx.get(PersonRepository); + const { asset } = await ctx.newAsset({ ownerId: auth.user.id }); + const { assetFace } = await ctx.newAssetFace({ assetId: asset.id }); + await personRepo.deleteAssetFace(assetFace.id); + + const response = await ctx.syncStream(auth, [SyncRequestType.AssetFacesV1]); + expect(response).toHaveLength(1); + expect(response).toEqual([ + { + ack: expect.any(String), + data: { + assetFaceId: assetFace.id, + }, + type: 'AssetFaceDeleteV1', + }, + ]); + + await ctx.syncAckAll(auth, response); + await expect(ctx.syncStream(auth, [SyncRequestType.AssetFacesV1])).resolves.toEqual([]); + }); + + it('should not sync an asset face or asset face delete for an unrelated user', async () => { + const { auth, ctx } = await setup(); + const personRepo = ctx.get(PersonRepository); + const { user: user2 } = await ctx.newUser(); + const { session } = await ctx.newSession({ userId: user2.id }); + const { asset } = await ctx.newAsset({ ownerId: user2.id }); + const { assetFace } = await ctx.newAssetFace({ assetId: asset.id }); + const auth2 = factory.auth({ session, user: user2 }); + + expect(await ctx.syncStream(auth2, [SyncRequestType.AssetFacesV1])).toHaveLength(1); + expect(await ctx.syncStream(auth, [SyncRequestType.AssetFacesV1])).toHaveLength(0); + + await personRepo.deleteAssetFace(assetFace.id); + expect(await ctx.syncStream(auth2, [SyncRequestType.AssetFacesV1])).toHaveLength(1); + expect(await ctx.syncStream(auth, [SyncRequestType.AssetFacesV1])).toHaveLength(0); + }); +}); diff --git a/server/test/medium/specs/sync/sync-person.spec.ts b/server/test/medium/specs/sync/sync-person.spec.ts index 807e41894..fbf401e37 100644 --- a/server/test/medium/specs/sync/sync-person.spec.ts +++ b/server/test/medium/specs/sync/sync-person.spec.ts @@ -31,7 +31,6 @@ describe(SyncEntityType.PersonV1, () => { data: expect.objectContaining({ id: person.id, name: person.name, - thumbnailPath: person.thumbnailPath, isHidden: person.isHidden, birthDate: person.birthDate, faceAssetId: person.faceAssetId,