mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	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>
This commit is contained in:
		| @@ -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 } }), | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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; | ||||
| } | ||||
|   | ||||
| @@ -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; | ||||
|   } | ||||
|   | ||||
| @@ -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 { | ||||
|   | ||||
| @@ -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
									
								
								server/src/infra/migrations/1697272818851-UnassignFace.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								server/src/infra/migrations/1697272818851-UnassignFace.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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")`); | ||||
|   } | ||||
| } | ||||
| @@ -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
									
								
								server/test/fixtures/face.stub.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										7
									
								
								server/test/fixtures/face.stub.ts
									
									
									
									
										vendored
									
									
								
							| @@ -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, | ||||
|   | ||||
		Reference in New Issue
	
	Block a user