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:
@@ -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 } });
|
||||
|
||||
Reference in New Issue
Block a user