diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts index 8979df1299..e3736984cc 100644 --- a/server/src/domain/asset/asset.service.ts +++ b/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 } }), ), ); } diff --git a/server/src/domain/asset/response-dto/asset-response.dto.ts b/server/src/domain/asset/response-dto/asset-response.dto.ts index 0e57840553..1c8d5ba34d 100644 --- a/server/src/domain/asset/response-dto/asset-response.dto.ts +++ b/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, diff --git a/server/src/domain/person/person.dto.ts b/server/src/domain/person/person.dto.ts index ac62d13128..cae05fa408 100644 --- a/server/src/domain/person/person.dto.ts +++ b/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; } diff --git a/server/src/domain/person/person.service.ts b/server/src/domain/person/person.service.ts index 89f8515105..26e80229d5 100644 --- a/server/src/domain/person/person.service.ts +++ b/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; } diff --git a/server/src/domain/search/search.service.ts b/server/src/domain/search/search.service.ts index ba637bb3bb..445d6b89d0 100644 --- a/server/src/domain/search/search.service.ts +++ b/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 { diff --git a/server/src/infra/entities/asset-face.entity.ts b/server/src/infra/entities/asset-face.entity.ts index cf59bc0c62..66f5c2fd14 100644 --- a/server/src/infra/entities/asset-face.entity.ts +++ b/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; } diff --git a/server/src/infra/migrations/1697272818851-UnassignFace.ts b/server/src/infra/migrations/1697272818851-UnassignFace.ts new file mode 100644 index 0000000000..49eebf4cc1 --- /dev/null +++ b/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 { + 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 { + 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")`); + } +} diff --git a/server/src/infra/repositories/typesense.repository.ts b/server/src/infra/repositories/typesense.repository.ts index 5ab9b2a8eb..403e2bfc72 100644 --- a/server/src/infra/repositories/typesense.repository.ts +++ b/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 }; } diff --git a/server/test/fixtures/face.stub.ts b/server/test/fixtures/face.stub.ts index 4396b7340b..d009ddadce 100644 --- a/server/test/fixtures/face.stub.ts +++ b/server/test/fixtures/face.stub.ts @@ -4,6 +4,7 @@ import { personStub } from './person.stub'; export const faceStub = { face1: Object.freeze({ + 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({ + 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({ + 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({ + 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({ + 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({ + 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({ + id: 'assetFaceId', assetId: assetStub.image.id, asset: assetStub.image, personId: personStub.newThumbnail.id,