fix: suggest people (#4566)

* fix: suggest people

* feat: remove hidden people

* add hidden people when merging faces

* pr feedback

* fix: don't use reactive statement

* fixed section height

* improve merging

* fix: migration

* fix migration

* feat: add asset count

* fix: test

* rename endpoint

* add server test

* improve responsive design

* fix: remove videos from live photos in the asset count

* pr feedback

* fix: rename asset count endpoint

* fix: return firstname and lastname

* fix: reset people only on error

* fix: search

* fix: responsive design & div flickering

* fix: cleanup

* chore: open api

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
martin
2023-10-24 17:53:49 +02:00
committed by GitHub
parent 1aae29a0b8
commit 3e3598fd92
29 changed files with 736 additions and 80 deletions

View File

@@ -73,6 +73,11 @@ export class PersonResponseDto {
isHidden!: boolean;
}
export class PersonStatisticsResponseDto {
@ApiProperty({ type: 'integer' })
assets!: number;
}
export class PeopleResponseDto {
@ApiProperty({ type: 'integer' })
total!: number;

View File

@@ -42,6 +42,8 @@ const responseDto: PersonResponseDto = {
isHidden: false,
};
const statistics = { assets: 3 };
const croppedFace = Buffer.from('Cropped Face');
const detectFaceMock = {
@@ -731,4 +733,21 @@ describe(PersonService.name, () => {
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
});
});
describe('getStatistics', () => {
it('should get correct number of person', async () => {
personMock.getById.mockResolvedValue(personStub.primaryPerson);
personMock.getStatistics.mockResolvedValue(statistics);
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
await expect(sut.getStatistics(authStub.admin, 'person-1')).resolves.toEqual({ assets: 3 });
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
});
it('should require person.read permission', async () => {
personMock.getById.mockResolvedValue(personStub.primaryPerson);
accessMock.person.hasOwnerAccess.mockResolvedValue(false);
await expect(sut.getStatistics(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException);
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
});
});
});

View File

@@ -33,6 +33,7 @@ import {
PeopleUpdateDto,
PersonResponseDto,
PersonSearchDto,
PersonStatisticsResponseDto,
PersonUpdateDto,
mapPerson,
} from './person.dto';
@@ -84,6 +85,11 @@ export class PersonService {
return this.findOrFail(id).then(mapPerson);
}
async getStatistics(authUser: AuthUserDto, id: string): Promise<PersonStatisticsResponseDto> {
await this.access.requirePermission(authUser, Permission.PERSON_READ, id);
return this.repository.getStatistics(id);
}
async getThumbnail(authUser: AuthUserDto, id: string): Promise<ImmichReadStream> {
await this.access.requirePermission(authUser, Permission.PERSON_READ, id);
const person = await this.repository.getById(id);

View File

@@ -1,4 +1,5 @@
import { AssetEntity, AssetFaceEntity, PersonEntity } from '@app/infra/entities';
export const IPersonRepository = 'IPersonRepository';
export interface PersonSearchOptions {
@@ -6,6 +7,10 @@ export interface PersonSearchOptions {
withHidden: boolean;
}
export interface PersonNameSearchOptions {
withHidden?: boolean;
}
export interface AssetFaceId {
assetId: string;
personId: string;
@@ -16,13 +21,17 @@ export interface UpdateFacesData {
newPersonId: string;
}
export interface PersonStatistics {
assets: number;
}
export interface IPersonRepository {
getAll(): Promise<PersonEntity[]>;
getAllWithoutThumbnail(): Promise<PersonEntity[]>;
getAllForUser(userId: string, options: PersonSearchOptions): Promise<PersonEntity[]>;
getAllWithoutFaces(): Promise<PersonEntity[]>;
getById(personId: string): Promise<PersonEntity | null>;
getByName(userId: string, personName: string): Promise<PersonEntity[]>;
getByName(userId: string, personName: string, options: PersonNameSearchOptions): Promise<PersonEntity[]>;
getAssets(personId: string): Promise<AssetEntity[]>;
prepareReassignFaces(data: UpdateFacesData): Promise<string[]>;
@@ -33,6 +42,8 @@ export interface IPersonRepository {
delete(entity: PersonEntity): Promise<PersonEntity | null>;
deleteAll(): Promise<number>;
getStatistics(personId: string): Promise<PersonStatistics>;
getAllFaces(): Promise<AssetFaceEntity[]>;
getFacesByIds(ids: AssetFaceId[]): Promise<AssetFaceEntity[]>;
getRandomFace(personId: string): Promise<AssetFaceEntity | null>;

View File

@@ -90,4 +90,9 @@ export class SearchPeopleDto {
@IsString()
@IsNotEmpty()
name!: string;
@IsBoolean()
@Transform(toBoolean)
@Optional()
withHidden?: boolean;
}

View File

@@ -159,8 +159,8 @@ export class SearchService {
};
}
async searchPerson(authUser: AuthUserDto, dto: SearchPeopleDto): Promise<PersonResponseDto[]> {
return await this.personRepository.getByName(authUser.id, dto.name);
searchPerson(authUser: AuthUserDto, dto: SearchPeopleDto): Promise<PersonResponseDto[]> {
return this.personRepository.getByName(authUser.id, dto.name, { withHidden: dto.withHidden });
}
async handleIndexAlbums() {

View File

@@ -9,6 +9,7 @@ import {
PersonResponseDto,
PersonSearchDto,
PersonService,
PersonStatisticsResponseDto,
PersonUpdateDto,
} from '@app/domain';
import { Body, Controller, Get, Param, Post, Put, Query, StreamableFile } from '@nestjs/common';
@@ -52,6 +53,14 @@ export class PersonController {
return this.service.update(authUser, id, dto);
}
@Get(':id/statistics')
getPersonStatistics(
@AuthUser() authUser: AuthUserDto,
@Param() { id }: UUIDParamDto,
): Promise<PersonStatisticsResponseDto> {
return this.service.getStatistics(authUser, id);
}
@Get(':id/thumbnail')
@ApiOkResponse({
content: {

View File

@@ -1,4 +1,11 @@
import { AssetFaceId, IPersonRepository, PersonSearchOptions, UpdateFacesData } from '@app/domain';
import {
AssetFaceId,
IPersonRepository,
PersonNameSearchOptions,
PersonSearchOptions,
PersonStatistics,
UpdateFacesData,
} from '@app/domain';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import { AssetEntity, AssetFaceEntity, PersonEntity } from '../entities';
@@ -96,14 +103,37 @@ export class PersonRepository implements IPersonRepository {
return this.personRepository.findOne({ where: { id: personId } });
}
getByName(userId: string, personName: string): Promise<PersonEntity[]> {
return this.personRepository
getByName(userId: string, personName: string, { withHidden }: PersonNameSearchOptions): Promise<PersonEntity[]> {
const queryBuilder = this.personRepository
.createQueryBuilder('person')
.leftJoin('person.faces', 'face')
.where('person.ownerId = :userId', { userId })
.andWhere('LOWER(person.name) LIKE :name', { name: `${personName.toLowerCase()}%` })
.limit(20)
.getMany();
.andWhere('LOWER(person.name) LIKE :nameStart OR LOWER(person.name) LIKE :nameAnywhere', {
nameStart: `${personName.toLowerCase()}%`,
nameAnywhere: `% ${personName.toLowerCase()}%`,
})
.groupBy('person.id')
.orderBy('COUNT(face.assetId)', 'DESC')
.limit(20);
if (!withHidden) {
queryBuilder.andWhere('person.isHidden = false');
}
return queryBuilder.getMany();
}
async getStatistics(personId: string): Promise<PersonStatistics> {
return {
assets: await this.assetFaceRepository
.createQueryBuilder('face')
.leftJoin('face.asset', 'asset')
.where('face.personId = :personId', { personId })
.andWhere('asset.isArchived = false')
.andWhere('asset.deletedAt IS NULL')
.andWhere('asset.livePhotoVideoId IS NULL')
.distinct(true)
.getCount(),
};
}
getAssets(personId: string): Promise<AssetEntity[]> {