mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	feat(server,web): hide faces (#3262)
* feat: hide faces * fix: types * pr feedback * fix: svelte checks * feat: new server endpoint * refactor: rename person count dto * fix(server): linter * fix: remove duplicate button * docs: add comments * pr feedback * fix: get unhidden faces * fix: do not use PersonCountResponseDto * fix: transition * pr feedback * pr feedback * fix: remove unused check * add server tests * rename persons to people * feat: add exit button * pr feedback * add server tests * pr feedback * pr feedback * fix: show & hide faces * simplify * fix: close button * pr feeback * pr feeback --------- Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
		| @@ -2509,17 +2509,24 @@ | ||||
|     "/person": { | ||||
|       "get": { | ||||
|         "operationId": "getAllPeople", | ||||
|         "parameters": [], | ||||
|         "parameters": [ | ||||
|           { | ||||
|             "name": "withHidden", | ||||
|             "required": false, | ||||
|             "in": "query", | ||||
|             "schema": { | ||||
|               "default": false, | ||||
|               "type": "boolean" | ||||
|             } | ||||
|           } | ||||
|         ], | ||||
|         "responses": { | ||||
|           "200": { | ||||
|             "description": "", | ||||
|             "content": { | ||||
|               "application/json": { | ||||
|                 "schema": { | ||||
|                   "type": "array", | ||||
|                   "items": { | ||||
|                     "$ref": "#/components/schemas/PersonResponseDto" | ||||
|                   } | ||||
|                   "$ref": "#/components/schemas/PeopleResponseDto" | ||||
|                 } | ||||
|               } | ||||
|             } | ||||
| @@ -5877,6 +5884,28 @@ | ||||
|           "passwordLoginEnabled" | ||||
|         ] | ||||
|       }, | ||||
|       "PeopleResponseDto": { | ||||
|         "type": "object", | ||||
|         "properties": { | ||||
|           "total": { | ||||
|             "type": "number" | ||||
|           }, | ||||
|           "visible": { | ||||
|             "type": "number" | ||||
|           }, | ||||
|           "people": { | ||||
|             "type": "array", | ||||
|             "items": { | ||||
|               "$ref": "#/components/schemas/PersonResponseDto" | ||||
|             } | ||||
|           } | ||||
|         }, | ||||
|         "required": [ | ||||
|           "total", | ||||
|           "visible", | ||||
|           "people" | ||||
|         ] | ||||
|       }, | ||||
|       "PersonResponseDto": { | ||||
|         "type": "object", | ||||
|         "properties": { | ||||
| @@ -5888,12 +5917,16 @@ | ||||
|           }, | ||||
|           "thumbnailPath": { | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "isHidden": { | ||||
|             "type": "boolean" | ||||
|           } | ||||
|         }, | ||||
|         "required": [ | ||||
|           "id", | ||||
|           "name", | ||||
|           "thumbnailPath" | ||||
|           "thumbnailPath", | ||||
|           "isHidden" | ||||
|         ] | ||||
|       }, | ||||
|       "PersonUpdateDto": { | ||||
| @@ -5906,6 +5939,10 @@ | ||||
|           "featureFaceAssetId": { | ||||
|             "type": "string", | ||||
|             "description": "Asset is used to get the feature face thumbnail." | ||||
|           }, | ||||
|           "isHidden": { | ||||
|             "type": "boolean", | ||||
|             "description": "Person visibility" | ||||
|           } | ||||
|         } | ||||
|       }, | ||||
|   | ||||
| @@ -54,7 +54,7 @@ export function mapAsset(entity: AssetEntity): AssetResponseDto { | ||||
|     smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined, | ||||
|     livePhotoVideoId: entity.livePhotoVideoId, | ||||
|     tags: entity.tags?.map(mapTag), | ||||
|     people: entity.faces?.map(mapFace), | ||||
|     people: entity.faces?.map(mapFace).filter((person) => !person.isHidden), | ||||
|     checksum: entity.checksum.toString('base64'), | ||||
|   }; | ||||
| } | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| import { AssetFaceEntity, PersonEntity } from '@app/infra/entities'; | ||||
| import { IsOptional, IsString } from 'class-validator'; | ||||
| import { ValidateUUID } from '../domain.util'; | ||||
| import { Transform } from 'class-transformer'; | ||||
| import { IsBoolean, IsOptional, IsString } from 'class-validator'; | ||||
| import { toBoolean, ValidateUUID } from '../domain.util'; | ||||
|  | ||||
| export class PersonUpdateDto { | ||||
|   /** | ||||
| @@ -16,6 +17,13 @@ export class PersonUpdateDto { | ||||
|   @IsOptional() | ||||
|   @IsString() | ||||
|   featureFaceAssetId?: string; | ||||
|  | ||||
|   /** | ||||
|    * Person visibility | ||||
|    */ | ||||
|   @IsOptional() | ||||
|   @IsBoolean() | ||||
|   isHidden?: boolean; | ||||
| } | ||||
|  | ||||
| export class MergePersonDto { | ||||
| @@ -23,10 +31,23 @@ export class MergePersonDto { | ||||
|   ids!: string[]; | ||||
| } | ||||
|  | ||||
| export class PersonSearchDto { | ||||
|   @IsBoolean() | ||||
|   @Transform(toBoolean) | ||||
|   withHidden?: boolean = false; | ||||
| } | ||||
|  | ||||
| export class PersonResponseDto { | ||||
|   id!: string; | ||||
|   name!: string; | ||||
|   thumbnailPath!: string; | ||||
|   isHidden!: boolean; | ||||
| } | ||||
|  | ||||
| export class PeopleResponseDto { | ||||
|   total!: number; | ||||
|   visible!: number; | ||||
|   people!: PersonResponseDto[]; | ||||
| } | ||||
|  | ||||
| export function mapPerson(person: PersonEntity): PersonResponseDto { | ||||
| @@ -34,6 +55,7 @@ export function mapPerson(person: PersonEntity): PersonResponseDto { | ||||
|     id: person.id, | ||||
|     name: person.name, | ||||
|     thumbnailPath: person.thumbnailPath, | ||||
|     isHidden: person.isHidden, | ||||
|   }; | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -19,6 +19,7 @@ const responseDto: PersonResponseDto = { | ||||
|   id: 'person-1', | ||||
|   name: 'Person 1', | ||||
|   thumbnailPath: '/path/to/thumbnail.jpg', | ||||
|   isHidden: false, | ||||
| }; | ||||
|  | ||||
| describe(PersonService.name, () => { | ||||
| @@ -41,7 +42,37 @@ describe(PersonService.name, () => { | ||||
|   describe('getAll', () => { | ||||
|     it('should get all people with thumbnails', async () => { | ||||
|       personMock.getAll.mockResolvedValue([personStub.withName, personStub.noThumbnail]); | ||||
|       await expect(sut.getAll(authStub.admin)).resolves.toEqual([responseDto]); | ||||
|       await expect(sut.getAll(authStub.admin, { withHidden: undefined })).resolves.toEqual({ | ||||
|         total: 1, | ||||
|         visible: 1, | ||||
|         people: [responseDto], | ||||
|       }); | ||||
|       expect(personMock.getAll).toHaveBeenCalledWith(authStub.admin.id, { minimumFaceCount: 1 }); | ||||
|     }); | ||||
|     it('should get all visible people with thumbnails', async () => { | ||||
|       personMock.getAll.mockResolvedValue([personStub.withName, personStub.hidden]); | ||||
|       await expect(sut.getAll(authStub.admin, { withHidden: false })).resolves.toEqual({ | ||||
|         total: 2, | ||||
|         visible: 1, | ||||
|         people: [responseDto], | ||||
|       }); | ||||
|       expect(personMock.getAll).toHaveBeenCalledWith(authStub.admin.id, { minimumFaceCount: 1 }); | ||||
|     }); | ||||
|     it('should get all hidden and visible people with thumbnails', async () => { | ||||
|       personMock.getAll.mockResolvedValue([personStub.withName, personStub.hidden]); | ||||
|       await expect(sut.getAll(authStub.admin, { withHidden: true })).resolves.toEqual({ | ||||
|         total: 2, | ||||
|         visible: 1, | ||||
|         people: [ | ||||
|           responseDto, | ||||
|           { | ||||
|             id: 'person-1', | ||||
|             name: '', | ||||
|             thumbnailPath: '/path/to/thumbnail.jpg', | ||||
|             isHidden: true, | ||||
|           }, | ||||
|         ], | ||||
|       }); | ||||
|       expect(personMock.getAll).toHaveBeenCalledWith(authStub.admin.id, { minimumFaceCount: 1 }); | ||||
|     }); | ||||
|   }); | ||||
| @@ -111,6 +142,21 @@ describe(PersonService.name, () => { | ||||
|       }); | ||||
|     }); | ||||
|  | ||||
|     it('should update a person visibility', async () => { | ||||
|       personMock.getById.mockResolvedValue(personStub.hidden); | ||||
|       personMock.update.mockResolvedValue(personStub.withName); | ||||
|       personMock.getAssets.mockResolvedValue([assetEntityStub.image]); | ||||
|  | ||||
|       await expect(sut.update(authStub.admin, 'person-1', { isHidden: false })).resolves.toEqual(responseDto); | ||||
|  | ||||
|       expect(personMock.getById).toHaveBeenCalledWith('admin_id', 'person-1'); | ||||
|       expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', isHidden: false }); | ||||
|       expect(jobMock.queue).toHaveBeenCalledWith({ | ||||
|         name: JobName.SEARCH_INDEX_ASSET, | ||||
|         data: { ids: [assetEntityStub.image.id] }, | ||||
|       }); | ||||
|     }); | ||||
|  | ||||
|     it("should update a person's thumbnailPath", async () => { | ||||
|       personMock.getById.mockResolvedValue(personStub.withName); | ||||
|       personMock.getFaceById.mockResolvedValue(faceStub.face1); | ||||
|   | ||||
| @@ -4,7 +4,14 @@ import { AuthUserDto } from '../auth'; | ||||
| import { mimeTypes } from '../domain.constant'; | ||||
| import { IJobRepository, JobName } from '../job'; | ||||
| import { ImmichReadStream, IStorageRepository } from '../storage'; | ||||
| import { mapPerson, MergePersonDto, PersonResponseDto, PersonUpdateDto } from './person.dto'; | ||||
| import { | ||||
|   mapPerson, | ||||
|   MergePersonDto, | ||||
|   PeopleResponseDto, | ||||
|   PersonResponseDto, | ||||
|   PersonSearchDto, | ||||
|   PersonUpdateDto, | ||||
| } from './person.dto'; | ||||
| import { IPersonRepository, UpdateFacesData } from './person.repository'; | ||||
|  | ||||
| @Injectable() | ||||
| @@ -17,16 +24,21 @@ export class PersonService { | ||||
|     @Inject(IJobRepository) private jobRepository: IJobRepository, | ||||
|   ) {} | ||||
|  | ||||
|   async getAll(authUser: AuthUserDto): Promise<PersonResponseDto[]> { | ||||
|   async getAll(authUser: AuthUserDto, dto: PersonSearchDto): Promise<PeopleResponseDto> { | ||||
|     const people = await this.repository.getAll(authUser.id, { minimumFaceCount: 1 }); | ||||
|     const named = people.filter((person) => !!person.name); | ||||
|     const unnamed = people.filter((person) => !person.name); | ||||
|     return ( | ||||
|       [...named, ...unnamed] | ||||
|         // with thumbnails | ||||
|         .filter((person) => !!person.thumbnailPath) | ||||
|         .map((person) => mapPerson(person)) | ||||
|     ); | ||||
|  | ||||
|     const persons: PersonResponseDto[] = [...named, ...unnamed] | ||||
|       // with thumbnails | ||||
|       .filter((person) => !!person.thumbnailPath) | ||||
|       .map((person) => mapPerson(person)); | ||||
|  | ||||
|     return { | ||||
|       people: persons.filter((person) => dto.withHidden || !person.isHidden), | ||||
|       total: persons.length, | ||||
|       visible: persons.filter((person: PersonResponseDto) => !person.isHidden).length, | ||||
|     }; | ||||
|   } | ||||
|  | ||||
|   getById(authUser: AuthUserDto, id: string): Promise<PersonResponseDto> { | ||||
| @@ -50,8 +62,8 @@ export class PersonService { | ||||
|   async update(authUser: AuthUserDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> { | ||||
|     let person = await this.findOrFail(authUser, id); | ||||
|  | ||||
|     if (dto.name !== undefined) { | ||||
|       person = await this.repository.update({ id, name: dto.name }); | ||||
|     if (dto.name != undefined || dto.isHidden !== undefined) { | ||||
|       person = await this.repository.update({ id, name: dto.name, isHidden: dto.isHidden }); | ||||
|       const assets = await this.repository.getAssets(authUser.id, id); | ||||
|       const ids = assets.map((asset) => asset.id); | ||||
|       await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } }); | ||||
|   | ||||
| @@ -4,11 +4,13 @@ import { | ||||
|   BulkIdResponseDto, | ||||
|   ImmichReadStream, | ||||
|   MergePersonDto, | ||||
|   PeopleResponseDto, | ||||
|   PersonResponseDto, | ||||
|   PersonSearchDto, | ||||
|   PersonService, | ||||
|   PersonUpdateDto, | ||||
| } from '@app/domain'; | ||||
| import { Body, Controller, Get, Param, Post, Put, StreamableFile } from '@nestjs/common'; | ||||
| import { Body, Controller, Get, Param, Post, Put, Query, StreamableFile } from '@nestjs/common'; | ||||
| import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; | ||||
| import { Authenticated, AuthUser } from '../app.guard'; | ||||
| import { UseValidation } from '../app.utils'; | ||||
| @@ -26,8 +28,8 @@ export class PersonController { | ||||
|   constructor(private service: PersonService) {} | ||||
|  | ||||
|   @Get() | ||||
|   getAllPeople(@AuthUser() authUser: AuthUserDto): Promise<PersonResponseDto[]> { | ||||
|     return this.service.getAll(authUser); | ||||
|   getAllPeople(@AuthUser() authUser: AuthUserDto, @Query() withHidden: PersonSearchDto): Promise<PeopleResponseDto> { | ||||
|     return this.service.getAll(authUser, withHidden); | ||||
|   } | ||||
|  | ||||
|   @Get(':id') | ||||
|   | ||||
| @@ -35,4 +35,7 @@ export class PersonEntity { | ||||
|  | ||||
|   @OneToMany(() => AssetFaceEntity, (assetFace) => assetFace.person) | ||||
|   faces!: AssetFaceEntity[]; | ||||
|  | ||||
|   @Column({ default: false }) | ||||
|   isHidden!: boolean; | ||||
| } | ||||
|   | ||||
							
								
								
									
										14
									
								
								server/src/infra/migrations/1689281196844-AddHiddenFaces.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								server/src/infra/migrations/1689281196844-AddHiddenFaces.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | ||||
| import { MigrationInterface, QueryRunner } from "typeorm"; | ||||
|  | ||||
| export class Infra1689281196844 implements MigrationInterface { | ||||
|     name = 'Infra1689281196844' | ||||
|  | ||||
|     public async up(queryRunner: QueryRunner): Promise<void> { | ||||
|         await queryRunner.query(`ALTER TABLE "person" ADD "isHidden" boolean NOT NULL DEFAULT false`); | ||||
|     } | ||||
|  | ||||
|     public async down(queryRunner: QueryRunner): Promise<void> { | ||||
|         await queryRunner.query(`ALTER TABLE "person" DROP COLUMN "isHidden"`); | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -385,7 +385,8 @@ export class TypesenseRepository implements ISearchRepository { | ||||
|       custom = { ...custom, geo: [lat, lng] }; | ||||
|     } | ||||
|  | ||||
|     const people = asset.faces?.map((face) => face.person.name).filter((name) => name) || []; | ||||
|     const people = | ||||
|       asset.faces?.filter((face) => !face.person.isHidden && face.person.name).map((face) => face.person.name) || []; | ||||
|     if (people.length) { | ||||
|       custom = { ...custom, people }; | ||||
|     } | ||||
|   | ||||
| @@ -1094,6 +1094,18 @@ export const personStub = { | ||||
|     name: '', | ||||
|     thumbnailPath: '/path/to/thumbnail.jpg', | ||||
|     faces: [], | ||||
|     isHidden: false, | ||||
|   }), | ||||
|   hidden: Object.freeze<PersonEntity>({ | ||||
|     id: 'person-1', | ||||
|     createdAt: new Date('2021-01-01'), | ||||
|     updatedAt: new Date('2021-01-01'), | ||||
|     ownerId: userEntityStub.admin.id, | ||||
|     owner: userEntityStub.admin, | ||||
|     name: '', | ||||
|     thumbnailPath: '/path/to/thumbnail.jpg', | ||||
|     faces: [], | ||||
|     isHidden: true, | ||||
|   }), | ||||
|   withName: Object.freeze<PersonEntity>({ | ||||
|     id: 'person-1', | ||||
| @@ -1104,6 +1116,7 @@ export const personStub = { | ||||
|     name: 'Person 1', | ||||
|     thumbnailPath: '/path/to/thumbnail.jpg', | ||||
|     faces: [], | ||||
|     isHidden: false, | ||||
|   }), | ||||
|   noThumbnail: Object.freeze<PersonEntity>({ | ||||
|     id: 'person-1', | ||||
| @@ -1114,6 +1127,7 @@ export const personStub = { | ||||
|     name: '', | ||||
|     thumbnailPath: '', | ||||
|     faces: [], | ||||
|     isHidden: false, | ||||
|   }), | ||||
|   newThumbnail: Object.freeze<PersonEntity>({ | ||||
|     id: 'person-1', | ||||
| @@ -1124,6 +1138,7 @@ export const personStub = { | ||||
|     name: '', | ||||
|     thumbnailPath: '/new/path/to/thumbnail.jpg', | ||||
|     faces: [], | ||||
|     isHidden: false, | ||||
|   }), | ||||
|   primaryPerson: Object.freeze<PersonEntity>({ | ||||
|     id: 'person-1', | ||||
| @@ -1134,6 +1149,7 @@ export const personStub = { | ||||
|     name: 'Person 1', | ||||
|     thumbnailPath: '/path/to/thumbnail', | ||||
|     faces: [], | ||||
|     isHidden: false, | ||||
|   }), | ||||
|   mergePerson: Object.freeze<PersonEntity>({ | ||||
|     id: 'person-2', | ||||
| @@ -1144,6 +1160,7 @@ export const personStub = { | ||||
|     name: 'Person 2', | ||||
|     thumbnailPath: '/path/to/thumbnail', | ||||
|     faces: [], | ||||
|     isHidden: false, | ||||
|   }), | ||||
| }; | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user