Jelajahi Sumber

feat(server): allow unassigned asset-faces (#4474)

* feat: un-assign people

* regenerate api

* edit migration script

* fix: tests

* fix: typeorm

* fix: typo

* fix: type

* fix: migration

* fix: update

* fix: contraints

* fix: remove set

* feat: add assetId

* remove assetId

* remove unassignedFaces

* fix: migration

* regenerate api

* fix: tests

* remove changes to the api

* fix: migration

* fix migration

* pr feedback

* fix: revert change

* fix: tests

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
martin 1 tahun lalu
induk
melakukan
99c6f8fb13

+ 4 - 2
server/src/domain/asset/asset.service.ts

@@ -392,8 +392,10 @@ export class AssetService {
 
     if (asset.faces) {
       await Promise.all(
-        asset.faces.map(({ assetId, personId }) =>
-          this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_FACE, data: { assetId, personId } }),
+        asset.faces.map(
+          ({ assetId, personId }) =>
+            personId != null &&
+            this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_FACE, data: { assetId, personId } }),
         ),
       );
     }

+ 3 - 1
server/src/domain/asset/response-dto/asset-response.dto.ts

@@ -96,7 +96,9 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As
     smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
     livePhotoVideoId: entity.livePhotoVideoId,
     tags: entity.tags?.map(mapTag),
-    people: entity.faces?.map(mapFace).filter((person) => !person.isHidden),
+    people: entity.faces
+      ?.map(mapFace)
+      .filter((person): person is PersonResponseDto => person !== null && !person.isHidden),
     checksum: entity.checksum.toString('base64'),
     stackParentId: entity.stackParentId,
     stack: withStack ? entity.stack?.map((a) => mapAsset(a, { stripMetadata })) ?? undefined : undefined,

+ 6 - 2
server/src/domain/person/person.dto.ts

@@ -93,6 +93,10 @@ export function mapPerson(person: PersonEntity): PersonResponseDto {
   };
 }
 
-export function mapFace(face: AssetFaceEntity): PersonResponseDto {
-  return mapPerson(face.person);
+export function mapFace(face: AssetFaceEntity): PersonResponseDto | null {
+  if (face.person) {
+    return mapPerson(face.person);
+  }
+
+  return null;
 }

+ 1 - 1
server/src/domain/person/person.service.ts

@@ -345,7 +345,7 @@ export class PersonService {
     } as const;
 
     await this.mediaRepository.resize(croppedOutput, thumbnailPath, thumbnailOptions);
-    await this.repository.update({ id: personId, thumbnailPath });
+    await this.repository.update({ id: person.id, thumbnailPath });
 
     return true;
   }

+ 14 - 7
server/src/domain/search/search.service.ts

@@ -360,13 +360,20 @@ export class SearchService {
   }
 
   private patchFaces(faces: AssetFaceEntity[]): OwnedFaceEntity[] {
-    return faces.map((face) => ({
-      id: this.asKey(face),
-      ownerId: face.asset.ownerId,
-      assetId: face.assetId,
-      personId: face.personId,
-      embedding: face.embedding,
-    }));
+    const results: OwnedFaceEntity[] = [];
+    for (const face of faces) {
+      if (face.personId) {
+        results.push({
+          id: this.asKey(face as AssetFaceId),
+          ownerId: face.asset.ownerId,
+          assetId: face.assetId,
+          personId: face.personId,
+          embedding: face.embedding,
+        });
+      }
+    }
+
+    return results;
   }
 
   private asKey(face: AssetFaceId): string {

+ 9 - 6
server/src/infra/entities/asset-face.entity.ts

@@ -1,14 +1,17 @@
-import { Column, Entity, ManyToOne, PrimaryColumn } from 'typeorm';
+import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
 import { AssetEntity } from './asset.entity';
 import { PersonEntity } from './person.entity';
 
 @Entity('asset_faces')
 export class AssetFaceEntity {
-  @PrimaryColumn()
+  @PrimaryGeneratedColumn('uuid')
+  id!: string;
+
+  @Column()
   assetId!: string;
 
-  @PrimaryColumn()
-  personId!: string;
+  @Column({ nullable: true, type: 'uuid' })
+  personId!: string | null;
 
   @Column({
     type: 'float4',
@@ -38,6 +41,6 @@ export class AssetFaceEntity {
   @ManyToOne(() => AssetEntity, (asset) => asset.faces, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
   asset!: AssetEntity;
 
-  @ManyToOne(() => PersonEntity, (person) => person.faces, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
-  person!: PersonEntity;
+  @ManyToOne(() => PersonEntity, (person) => person.faces, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: true })
+  person!: PersonEntity | null;
 }

+ 23 - 0
server/src/infra/migrations/1697272818851-UnassignFace.ts

@@ -0,0 +1,23 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class UnassignFace1697272818851 implements MigrationInterface {
+  name = 'UnassignFace1697272818851';
+
+  public async up(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query(`ALTER TABLE "asset_faces" DROP CONSTRAINT "PK_bf339a24070dac7e71304ec530a"`);
+    await queryRunner.query(`ALTER TABLE "asset_faces" ADD COLUMN "id" UUID DEFAULT uuid_generate_v4() NOT NULL`);
+    await queryRunner.query(`ALTER TABLE "asset_faces" ADD CONSTRAINT "PK_6df76ab2eb6f5b57b7c2f1fc684" PRIMARY KEY ("id")`);
+    await queryRunner.query(`ALTER TABLE "asset_faces" DROP CONSTRAINT "FK_95ad7106dd7b484275443f580f9"`);
+    await queryRunner.query(`ALTER TABLE "asset_faces" ALTER COLUMN "personId" DROP NOT NULL`);
+    await queryRunner.query(`ALTER TABLE "asset_faces" ADD CONSTRAINT "FK_95ad7106dd7b484275443f580f9" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE`);
+  }
+
+  public async down(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query(`ALTER TABLE "asset_faces" DROP CONSTRAINT "PK_6df76ab2eb6f5b57b7c2f1fc684"`);
+    await queryRunner.query(`ALTER TABLE "asset_faces" DROP COLUMN "id"`);
+    await queryRunner.query(`ALTER TABLE "asset_faces" DROP CONSTRAINT "FK_95ad7106dd7b484275443f580f9"`);
+    await queryRunner.query(`ALTER TABLE "asset_faces" ALTER COLUMN "personId" SET NOT NULL`);
+    await queryRunner.query(`ALTER TABLE "asset_faces" ADD CONSTRAINT "FK_95ad7106dd7b484275443f580f9" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE`);
+    await queryRunner.query(`ALTER TABLE "asset_faces" ADD  CONSTRAINT "PK_bf339a24070dac7e71304ec530a" PRIMARY KEY ("assetId", "personId")`);
+  }
+}

+ 4 - 3
server/src/infra/repositories/typesense.repository.ts

@@ -420,9 +420,10 @@ export class TypesenseRepository implements ISearchRepository {
     if (lat && lng && lat !== 0 && lng !== 0) {
       custom = { ...custom, geo: [lat, lng] };
     }
-
-    const people =
-      asset.faces?.filter((face) => !face.person.isHidden && face.person.name).map((face) => face.person.name) || [];
+    const people = asset.faces
+      ?.filter((face) => !face.person?.isHidden && face.person?.name)
+      .map((face) => face.person?.name)
+      .filter((name) => name !== undefined) as string[];
     if (people.length) {
       custom = { ...custom, people };
     }

+ 7 - 0
server/test/fixtures/face.stub.ts

@@ -4,6 +4,7 @@ import { personStub } from './person.stub';
 
 export const faceStub = {
   face1: Object.freeze<AssetFaceEntity>({
+    id: 'assetFaceId',
     assetId: assetStub.image.id,
     asset: assetStub.image,
     personId: personStub.withName.id,
@@ -17,6 +18,7 @@ export const faceStub = {
     imageWidth: 1024,
   }),
   primaryFace1: Object.freeze<AssetFaceEntity>({
+    id: 'assetFaceId',
     assetId: assetStub.image.id,
     asset: assetStub.image,
     personId: personStub.primaryPerson.id,
@@ -30,6 +32,7 @@ export const faceStub = {
     imageWidth: 1024,
   }),
   mergeFace1: Object.freeze<AssetFaceEntity>({
+    id: 'assetFaceId',
     assetId: assetStub.image.id,
     asset: assetStub.image,
     personId: personStub.mergePerson.id,
@@ -43,6 +46,7 @@ export const faceStub = {
     imageWidth: 1024,
   }),
   mergeFace2: Object.freeze<AssetFaceEntity>({
+    id: 'assetFaceId',
     assetId: assetStub.image1.id,
     asset: assetStub.image1,
     personId: personStub.mergePerson.id,
@@ -56,6 +60,7 @@ export const faceStub = {
     imageWidth: 1024,
   }),
   start: Object.freeze<AssetFaceEntity>({
+    id: 'assetFaceId',
     assetId: assetStub.image.id,
     asset: assetStub.image,
     personId: personStub.newThumbnail.id,
@@ -69,6 +74,7 @@ export const faceStub = {
     imageWidth: 1000,
   }),
   middle: Object.freeze<AssetFaceEntity>({
+    id: 'assetFaceId',
     assetId: assetStub.image.id,
     asset: assetStub.image,
     personId: personStub.newThumbnail.id,
@@ -82,6 +88,7 @@ export const faceStub = {
     imageWidth: 400,
   }),
   end: Object.freeze<AssetFaceEntity>({
+    id: 'assetFaceId',
     assetId: assetStub.image.id,
     asset: assetStub.image,
     personId: personStub.newThumbnail.id,