1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-26 10:50:29 +02:00

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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 467 additions and 74 deletions

View File

@ -2465,6 +2465,19 @@ export interface PersonResponseDto {
*/ */
'thumbnailPath': string; 'thumbnailPath': string;
} }
/**
*
* @export
* @interface PersonStatisticsResponseDto
*/
export interface PersonStatisticsResponseDto {
/**
*
* @type {number}
* @memberof PersonStatisticsResponseDto
*/
'assets': number;
}
/** /**
* *
* @export * @export
@ -12010,6 +12023,48 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getPersonStatistics: async (id: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('getPersonStatistics', 'id', id)
const localVarPath = `/person/{id}/statistics`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
setSearchParams(localVarUrlObj, localVarQueryParameter); setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@ -12241,6 +12296,16 @@ export const PersonApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.getPersonAssets(id, options); const localVarAxiosArgs = await localVarAxiosParamCreator.getPersonAssets(id, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
}, },
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getPersonStatistics(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PersonStatisticsResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getPersonStatistics(id, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/** /**
* *
* @param {string} id * @param {string} id
@ -12320,6 +12385,15 @@ export const PersonApiFactory = function (configuration?: Configuration, basePat
getPersonAssets(requestParameters: PersonApiGetPersonAssetsRequest, options?: AxiosRequestConfig): AxiosPromise<Array<AssetResponseDto>> { getPersonAssets(requestParameters: PersonApiGetPersonAssetsRequest, options?: AxiosRequestConfig): AxiosPromise<Array<AssetResponseDto>> {
return localVarFp.getPersonAssets(requestParameters.id, options).then((request) => request(axios, basePath)); return localVarFp.getPersonAssets(requestParameters.id, options).then((request) => request(axios, basePath));
}, },
/**
*
* @param {PersonApiGetPersonStatisticsRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getPersonStatistics(requestParameters: PersonApiGetPersonStatisticsRequest, options?: AxiosRequestConfig): AxiosPromise<PersonStatisticsResponseDto> {
return localVarFp.getPersonStatistics(requestParameters.id, options).then((request) => request(axios, basePath));
},
/** /**
* *
* @param {PersonApiGetPersonThumbnailRequest} requestParameters Request parameters. * @param {PersonApiGetPersonThumbnailRequest} requestParameters Request parameters.
@ -12401,6 +12475,20 @@ export interface PersonApiGetPersonAssetsRequest {
readonly id: string readonly id: string
} }
/**
* Request parameters for getPersonStatistics operation in PersonApi.
* @export
* @interface PersonApiGetPersonStatisticsRequest
*/
export interface PersonApiGetPersonStatisticsRequest {
/**
*
* @type {string}
* @memberof PersonApiGetPersonStatistics
*/
readonly id: string
}
/** /**
* Request parameters for getPersonThumbnail operation in PersonApi. * Request parameters for getPersonThumbnail operation in PersonApi.
* @export * @export
@ -12511,6 +12599,17 @@ export class PersonApi extends BaseAPI {
return PersonApiFp(this.configuration).getPersonAssets(requestParameters.id, options).then((request) => request(this.axios, this.basePath)); return PersonApiFp(this.configuration).getPersonAssets(requestParameters.id, options).then((request) => request(this.axios, this.basePath));
} }
/**
*
* @param {PersonApiGetPersonStatisticsRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof PersonApi
*/
public getPersonStatistics(requestParameters: PersonApiGetPersonStatisticsRequest, options?: AxiosRequestConfig) {
return PersonApiFp(this.configuration).getPersonStatistics(requestParameters.id, options).then((request) => request(this.axios, this.basePath));
}
/** /**
* *
* @param {PersonApiGetPersonThumbnailRequest} requestParameters Request parameters. * @param {PersonApiGetPersonThumbnailRequest} requestParameters Request parameters.
@ -12722,10 +12821,11 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio
/** /**
* *
* @param {string} name * @param {string} name
* @param {boolean} [withHidden]
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
*/ */
searchPerson: async (name: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => { searchPerson: async (name: string, withHidden?: boolean, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'name' is not null or undefined // verify required parameter 'name' is not null or undefined
assertParamExists('searchPerson', 'name', name) assertParamExists('searchPerson', 'name', name)
const localVarPath = `/search/person`; const localVarPath = `/search/person`;
@ -12753,6 +12853,10 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio
localVarQueryParameter['name'] = name; localVarQueryParameter['name'] = name;
} }
if (withHidden !== undefined) {
localVarQueryParameter['withHidden'] = withHidden;
}
setSearchParams(localVarUrlObj, localVarQueryParameter); setSearchParams(localVarUrlObj, localVarQueryParameter);
@ -12811,11 +12915,12 @@ export const SearchApiFp = function(configuration?: Configuration) {
/** /**
* *
* @param {string} name * @param {string} name
* @param {boolean} [withHidden]
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
*/ */
async searchPerson(name: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<PersonResponseDto>>> { async searchPerson(name: string, withHidden?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<PersonResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.searchPerson(name, options); const localVarAxiosArgs = await localVarAxiosParamCreator.searchPerson(name, withHidden, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
}, },
} }
@ -12852,7 +12957,7 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat
* @throws {RequiredError} * @throws {RequiredError}
*/ */
searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: AxiosRequestConfig): AxiosPromise<Array<PersonResponseDto>> { searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: AxiosRequestConfig): AxiosPromise<Array<PersonResponseDto>> {
return localVarFp.searchPerson(requestParameters.name, options).then((request) => request(axios, basePath)); return localVarFp.searchPerson(requestParameters.name, requestParameters.withHidden, options).then((request) => request(axios, basePath));
}, },
}; };
}; };
@ -12988,6 +13093,13 @@ export interface SearchApiSearchPersonRequest {
* @memberof SearchApiSearchPerson * @memberof SearchApiSearchPerson
*/ */
readonly name: string readonly name: string
/**
*
* @type {boolean}
* @memberof SearchApiSearchPerson
*/
readonly withHidden?: boolean
} }
/** /**
@ -13026,7 +13138,7 @@ export class SearchApi extends BaseAPI {
* @memberof SearchApi * @memberof SearchApi
*/ */
public searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: AxiosRequestConfig) { public searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: AxiosRequestConfig) {
return SearchApiFp(this.configuration).searchPerson(requestParameters.name, options).then((request) => request(this.axios, this.basePath)); return SearchApiFp(this.configuration).searchPerson(requestParameters.name, requestParameters.withHidden, options).then((request) => request(this.axios, this.basePath));
} }
} }

View File

@ -96,6 +96,7 @@ doc/PeopleUpdateDto.md
doc/PeopleUpdateItem.md doc/PeopleUpdateItem.md
doc/PersonApi.md doc/PersonApi.md
doc/PersonResponseDto.md doc/PersonResponseDto.md
doc/PersonStatisticsResponseDto.md
doc/PersonUpdateDto.md doc/PersonUpdateDto.md
doc/QueueStatusDto.md doc/QueueStatusDto.md
doc/RecognitionConfig.md doc/RecognitionConfig.md
@ -269,6 +270,7 @@ lib/model/people_response_dto.dart
lib/model/people_update_dto.dart lib/model/people_update_dto.dart
lib/model/people_update_item.dart lib/model/people_update_item.dart
lib/model/person_response_dto.dart lib/model/person_response_dto.dart
lib/model/person_statistics_response_dto.dart
lib/model/person_update_dto.dart lib/model/person_update_dto.dart
lib/model/queue_status_dto.dart lib/model/queue_status_dto.dart
lib/model/recognition_config.dart lib/model/recognition_config.dart
@ -421,6 +423,7 @@ test/people_update_dto_test.dart
test/people_update_item_test.dart test/people_update_item_test.dart
test/person_api_test.dart test/person_api_test.dart
test/person_response_dto_test.dart test/person_response_dto_test.dart
test/person_statistics_response_dto_test.dart
test/person_update_dto_test.dart test/person_update_dto_test.dart
test/queue_status_dto_test.dart test/queue_status_dto_test.dart
test/recognition_config_test.dart test/recognition_config_test.dart

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -3685,6 +3685,48 @@
] ]
} }
}, },
"/person/{id}/statistics": {
"get": {
"operationId": "getPersonStatistics",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PersonStatisticsResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Person"
]
}
},
"/person/{id}/thumbnail": { "/person/{id}/thumbnail": {
"get": { "get": {
"operationId": "getPersonThumbnail", "operationId": "getPersonThumbnail",
@ -3947,6 +3989,14 @@
"schema": { "schema": {
"type": "string" "type": "string"
} }
},
{
"name": "withHidden",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
} }
], ],
"responses": { "responses": {
@ -7401,6 +7451,17 @@
], ],
"type": "object" "type": "object"
}, },
"PersonStatisticsResponseDto": {
"properties": {
"assets": {
"type": "integer"
}
},
"required": [
"assets"
],
"type": "object"
},
"PersonUpdateDto": { "PersonUpdateDto": {
"properties": { "properties": {
"birthDate": { "birthDate": {

View File

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

View File

@ -42,6 +42,8 @@ const responseDto: PersonResponseDto = {
isHidden: false, isHidden: false,
}; };
const statistics = { assets: 3 };
const croppedFace = Buffer.from('Cropped Face'); const croppedFace = Buffer.from('Cropped Face');
const detectFaceMock = { const detectFaceMock = {
@ -731,4 +733,21 @@ describe(PersonService.name, () => {
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1'); 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, PeopleUpdateDto,
PersonResponseDto, PersonResponseDto,
PersonSearchDto, PersonSearchDto,
PersonStatisticsResponseDto,
PersonUpdateDto, PersonUpdateDto,
mapPerson, mapPerson,
} from './person.dto'; } from './person.dto';
@ -84,6 +85,11 @@ export class PersonService {
return this.findOrFail(id).then(mapPerson); 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> { async getThumbnail(authUser: AuthUserDto, id: string): Promise<ImmichReadStream> {
await this.access.requirePermission(authUser, Permission.PERSON_READ, id); await this.access.requirePermission(authUser, Permission.PERSON_READ, id);
const person = await this.repository.getById(id); const person = await this.repository.getById(id);

View File

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

View File

@ -90,4 +90,9 @@ export class SearchPeopleDto {
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
name!: string; 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[]> { searchPerson(authUser: AuthUserDto, dto: SearchPeopleDto): Promise<PersonResponseDto[]> {
return await this.personRepository.getByName(authUser.id, dto.name); return this.personRepository.getByName(authUser.id, dto.name, { withHidden: dto.withHidden });
} }
async handleIndexAlbums() { async handleIndexAlbums() {

View File

@ -9,6 +9,7 @@ import {
PersonResponseDto, PersonResponseDto,
PersonSearchDto, PersonSearchDto,
PersonService, PersonService,
PersonStatisticsResponseDto,
PersonUpdateDto, PersonUpdateDto,
} from '@app/domain'; } from '@app/domain';
import { Body, Controller, Get, Param, Post, Put, Query, StreamableFile } from '@nestjs/common'; 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); 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') @Get(':id/thumbnail')
@ApiOkResponse({ @ApiOkResponse({
content: { 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 { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm'; import { In, Repository } from 'typeorm';
import { AssetEntity, AssetFaceEntity, PersonEntity } from '../entities'; import { AssetEntity, AssetFaceEntity, PersonEntity } from '../entities';
@ -96,14 +103,37 @@ export class PersonRepository implements IPersonRepository {
return this.personRepository.findOne({ where: { id: personId } }); return this.personRepository.findOne({ where: { id: personId } });
} }
getByName(userId: string, personName: string): Promise<PersonEntity[]> { getByName(userId: string, personName: string, { withHidden }: PersonNameSearchOptions): Promise<PersonEntity[]> {
return this.personRepository const queryBuilder = this.personRepository
.createQueryBuilder('person') .createQueryBuilder('person')
.leftJoin('person.faces', 'face') .leftJoin('person.faces', 'face')
.where('person.ownerId = :userId', { userId }) .where('person.ownerId = :userId', { userId })
.andWhere('LOWER(person.name) LIKE :name', { name: `${personName.toLowerCase()}%` }) .andWhere('LOWER(person.name) LIKE :nameStart OR LOWER(person.name) LIKE :nameAnywhere', {
.limit(20) nameStart: `${personName.toLowerCase()}%`,
.getMany(); 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[]> { getAssets(personId: string): Promise<AssetEntity[]> {

View File

@ -16,6 +16,7 @@ export const newPersonRepositoryMock = (): jest.Mocked<IPersonRepository> => {
deleteAll: jest.fn(), deleteAll: jest.fn(),
delete: jest.fn(), delete: jest.fn(),
getStatistics: jest.fn(),
getAllFaces: jest.fn(), getAllFaces: jest.fn(),
getFacesByIds: jest.fn(), getFacesByIds: jest.fn(),
getRandomFace: jest.fn(), getRandomFace: jest.fn(),

View File

@ -2465,6 +2465,19 @@ export interface PersonResponseDto {
*/ */
'thumbnailPath': string; 'thumbnailPath': string;
} }
/**
*
* @export
* @interface PersonStatisticsResponseDto
*/
export interface PersonStatisticsResponseDto {
/**
*
* @type {number}
* @memberof PersonStatisticsResponseDto
*/
'assets': number;
}
/** /**
* *
* @export * @export
@ -12010,6 +12023,48 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getPersonStatistics: async (id: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('getPersonStatistics', 'id', id)
const localVarPath = `/person/{id}/statistics`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
setSearchParams(localVarUrlObj, localVarQueryParameter); setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@ -12241,6 +12296,16 @@ export const PersonApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.getPersonAssets(id, options); const localVarAxiosArgs = await localVarAxiosParamCreator.getPersonAssets(id, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
}, },
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getPersonStatistics(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PersonStatisticsResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getPersonStatistics(id, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/** /**
* *
* @param {string} id * @param {string} id
@ -12320,6 +12385,15 @@ export const PersonApiFactory = function (configuration?: Configuration, basePat
getPersonAssets(requestParameters: PersonApiGetPersonAssetsRequest, options?: AxiosRequestConfig): AxiosPromise<Array<AssetResponseDto>> { getPersonAssets(requestParameters: PersonApiGetPersonAssetsRequest, options?: AxiosRequestConfig): AxiosPromise<Array<AssetResponseDto>> {
return localVarFp.getPersonAssets(requestParameters.id, options).then((request) => request(axios, basePath)); return localVarFp.getPersonAssets(requestParameters.id, options).then((request) => request(axios, basePath));
}, },
/**
*
* @param {PersonApiGetPersonStatisticsRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getPersonStatistics(requestParameters: PersonApiGetPersonStatisticsRequest, options?: AxiosRequestConfig): AxiosPromise<PersonStatisticsResponseDto> {
return localVarFp.getPersonStatistics(requestParameters.id, options).then((request) => request(axios, basePath));
},
/** /**
* *
* @param {PersonApiGetPersonThumbnailRequest} requestParameters Request parameters. * @param {PersonApiGetPersonThumbnailRequest} requestParameters Request parameters.
@ -12401,6 +12475,20 @@ export interface PersonApiGetPersonAssetsRequest {
readonly id: string readonly id: string
} }
/**
* Request parameters for getPersonStatistics operation in PersonApi.
* @export
* @interface PersonApiGetPersonStatisticsRequest
*/
export interface PersonApiGetPersonStatisticsRequest {
/**
*
* @type {string}
* @memberof PersonApiGetPersonStatistics
*/
readonly id: string
}
/** /**
* Request parameters for getPersonThumbnail operation in PersonApi. * Request parameters for getPersonThumbnail operation in PersonApi.
* @export * @export
@ -12511,6 +12599,17 @@ export class PersonApi extends BaseAPI {
return PersonApiFp(this.configuration).getPersonAssets(requestParameters.id, options).then((request) => request(this.axios, this.basePath)); return PersonApiFp(this.configuration).getPersonAssets(requestParameters.id, options).then((request) => request(this.axios, this.basePath));
} }
/**
*
* @param {PersonApiGetPersonStatisticsRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof PersonApi
*/
public getPersonStatistics(requestParameters: PersonApiGetPersonStatisticsRequest, options?: AxiosRequestConfig) {
return PersonApiFp(this.configuration).getPersonStatistics(requestParameters.id, options).then((request) => request(this.axios, this.basePath));
}
/** /**
* *
* @param {PersonApiGetPersonThumbnailRequest} requestParameters Request parameters. * @param {PersonApiGetPersonThumbnailRequest} requestParameters Request parameters.
@ -12722,10 +12821,11 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio
/** /**
* *
* @param {string} name * @param {string} name
* @param {boolean} [withHidden]
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
*/ */
searchPerson: async (name: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => { searchPerson: async (name: string, withHidden?: boolean, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'name' is not null or undefined // verify required parameter 'name' is not null or undefined
assertParamExists('searchPerson', 'name', name) assertParamExists('searchPerson', 'name', name)
const localVarPath = `/search/person`; const localVarPath = `/search/person`;
@ -12753,6 +12853,10 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio
localVarQueryParameter['name'] = name; localVarQueryParameter['name'] = name;
} }
if (withHidden !== undefined) {
localVarQueryParameter['withHidden'] = withHidden;
}
setSearchParams(localVarUrlObj, localVarQueryParameter); setSearchParams(localVarUrlObj, localVarQueryParameter);
@ -12811,11 +12915,12 @@ export const SearchApiFp = function(configuration?: Configuration) {
/** /**
* *
* @param {string} name * @param {string} name
* @param {boolean} [withHidden]
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
*/ */
async searchPerson(name: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<PersonResponseDto>>> { async searchPerson(name: string, withHidden?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<PersonResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.searchPerson(name, options); const localVarAxiosArgs = await localVarAxiosParamCreator.searchPerson(name, withHidden, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
}, },
} }
@ -12852,7 +12957,7 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat
* @throws {RequiredError} * @throws {RequiredError}
*/ */
searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: AxiosRequestConfig): AxiosPromise<Array<PersonResponseDto>> { searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: AxiosRequestConfig): AxiosPromise<Array<PersonResponseDto>> {
return localVarFp.searchPerson(requestParameters.name, options).then((request) => request(axios, basePath)); return localVarFp.searchPerson(requestParameters.name, requestParameters.withHidden, options).then((request) => request(axios, basePath));
}, },
}; };
}; };
@ -12988,6 +13093,13 @@ export interface SearchApiSearchPersonRequest {
* @memberof SearchApiSearchPerson * @memberof SearchApiSearchPerson
*/ */
readonly name: string readonly name: string
/**
*
* @type {boolean}
* @memberof SearchApiSearchPerson
*/
readonly withHidden?: boolean
} }
/** /**
@ -13026,7 +13138,7 @@ export class SearchApi extends BaseAPI {
* @memberof SearchApi * @memberof SearchApi
*/ */
public searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: AxiosRequestConfig) { public searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: AxiosRequestConfig) {
return SearchApiFp(this.configuration).searchPerson(requestParameters.name, options).then((request) => request(this.axios, this.basePath)); return SearchApiFp(this.configuration).searchPerson(requestParameters.name, requestParameters.withHidden, options).then((request) => request(this.axios, this.basePath));
} }
} }

View File

@ -11,12 +11,13 @@
const dispatch = createEventDispatcher<{ const dispatch = createEventDispatcher<{
change: string; change: string;
cancel: void; cancel: void;
input: void;
}>(); }>();
</script> </script>
<div <div
class="flex w-full place-items-center {suggestedPeople class="flex w-full h-14 place-items-center {suggestedPeople
? 'rounded-t-lg border-b dark:border-immich-dark-gray' ? 'rounded-t-lg dark:border-immich-dark-gray'
: 'rounded-lg'} bg-gray-100 p-2 dark:bg-gray-700" : 'rounded-lg'} bg-gray-100 p-2 dark:bg-gray-700"
> >
<ImageThumbnail <ImageThumbnail
@ -39,6 +40,7 @@
type="text" type="text"
placeholder="New name or nickname" placeholder="New name or nickname"
bind:value={name} bind:value={name}
on:input={() => dispatch('input')}
/> />
<Button size="sm" type="submit">Done</Button> <Button size="sm" type="submit">Done</Button>
</form> </form>

View File

@ -258,7 +258,7 @@
changeName(); changeName();
return; return;
} }
const { data } = await api.searchApi.searchPerson({ name: personName }); const { data } = await api.searchApi.searchPerson({ name: personName, withHidden: true });
// We check if another person has the same name as the name entered by the user // We check if another person has the same name as the name entered by the user

View File

@ -9,10 +9,12 @@ export const load = (async ({ locals, parent, params }) => {
} }
const { data: person } = await locals.api.personApi.getPerson({ id: params.personId }); const { data: person } = await locals.api.personApi.getPerson({ id: params.personId });
const { data: statistics } = await locals.api.personApi.getPersonStatistics({ id: params.personId });
return { return {
user, user,
person, person,
statistics,
meta: { meta: {
title: person.name || 'Person', title: person.name || 'Person',
}, },

View File

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { afterNavigate, goto, invalidateAll } from '$app/navigation'; import { afterNavigate, goto } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte'; import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
import EditNameInput from '$lib/components/faces-page/edit-name-input.svelte'; import EditNameInput from '$lib/components/faces-page/edit-name-input.svelte';
@ -35,11 +35,11 @@
import type { PageData } from './$types'; import type { PageData } from './$types';
import { clickOutside } from '$lib/utils/click-outside'; import { clickOutside } from '$lib/utils/click-outside';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { browser } from '$app/environment';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
export let data: PageData; export let data: PageData;
let numberOfAssets = data.statistics.assets;
let { isViewing: showAssetViewer } = assetViewingStore; let { isViewing: showAssetViewer } = assetViewingStore;
enum ViewMode { enum ViewMode {
@ -63,7 +63,7 @@
let isEditingName = false; let isEditingName = false;
let previousRoute: string = AppRoute.EXPLORE; let previousRoute: string = AppRoute.EXPLORE;
let previousPersonId: string = data.person.id; let previousPersonId: string = data.person.id;
let people: PersonResponseDto[]; let people: PersonResponseDto[] = [];
let personMerge1: PersonResponseDto; let personMerge1: PersonResponseDto;
let personMerge2: PersonResponseDto; let personMerge2: PersonResponseDto;
let potentialMergePeople: PersonResponseDto[] = []; let potentialMergePeople: PersonResponseDto[] = [];
@ -84,34 +84,27 @@
* or if the new search word starts with another word / letter * or if the new search word starts with another word / letter
**/ **/
let searchWord: string; let searchWord: string;
let maxPeople = false;
let isSearchingPeople = false; let isSearchingPeople = false;
const searchPeople = async () => { const searchPeople = async () => {
isSearchingPeople = true; if ((people.length < 20 && name.startsWith(searchWord)) || name === '') {
people = []; return;
}
const timeout = setTimeout(() => (isSearchingPeople = true), 300);
try { try {
const { data } = await api.searchApi.searchPerson({ name }); const { data } = await api.searchApi.searchPerson({ name });
people = data; people = data;
searchWord = name; searchWord = name;
if (data.length < 20) {
maxPeople = false;
} else {
maxPeople = true;
}
} catch (error) { } catch (error) {
people = [];
handleError(error, "Can't search people"); handleError(error, "Can't search people");
} finally {
clearTimeout(timeout);
} }
isSearchingPeople = false; isSearchingPeople = false;
}; };
$: {
if (name !== '' && browser) {
if (maxPeople === true || (!name.startsWith(searchWord) && maxPeople === false)) searchPeople();
}
}
$: isAllArchive = Array.from($selectedAssets).every((asset) => asset.isArchived); $: isAllArchive = Array.from($selectedAssets).every((asset) => asset.isArchived);
$: isAllFavorite = Array.from($selectedAssets).every((asset) => asset.isFavorite); $: isAllFavorite = Array.from($selectedAssets).every((asset) => asset.isFavorite);
$: $onPersonThumbnail === data.person.id && $: $onPersonThumbnail === data.person.id &&
@ -122,10 +115,13 @@
suggestedPeople = !name suggestedPeople = !name
? [] ? []
: people : people
.filter( .filter((person: PersonResponseDto) => {
(person: PersonResponseDto) => const nameParts = person.name.split(' ');
person.name.toLowerCase().startsWith(name.toLowerCase()) && person.id !== data.person.id, return (
) nameParts.some((splitName) => splitName.toLowerCase().startsWith(name.toLowerCase())) &&
person.id !== data.person.id
);
})
.slice(0, 5); .slice(0, 5);
} }
} }
@ -204,6 +200,17 @@
viewMode = ViewMode.VIEW_ASSETS; viewMode = ViewMode.VIEW_ASSETS;
}; };
const updateAssetCount = async () => {
try {
const { data: statistics } = await api.personApi.getPersonStatistics({
id: data.person.id,
});
numberOfAssets = statistics.assets;
} catch (error) {
handleError(error, "Can't update the asset count");
}
};
const handleMergeSameFace = async (response: [PersonResponseDto, PersonResponseDto]) => { const handleMergeSameFace = async (response: [PersonResponseDto, PersonResponseDto]) => {
const [personToMerge, personToBeMergedIn] = response; const [personToMerge, personToBeMergedIn] = response;
viewMode = ViewMode.VIEW_ASSETS; viewMode = ViewMode.VIEW_ASSETS;
@ -219,8 +226,8 @@
}); });
people = people.filter((person: PersonResponseDto) => person.id !== personToMerge.id); people = people.filter((person: PersonResponseDto) => person.id !== personToMerge.id);
if (personToBeMergedIn.name != personName && data.person.id === personToBeMergedIn.id) { if (personToBeMergedIn.name != personName && data.person.id === personToBeMergedIn.id) {
changeName(); await updateAssetCount();
invalidateAll(); refreshAssetGrid = !refreshAssetGrid;
return; return;
} }
goto(`${AppRoute.PEOPLE}/${personToBeMergedIn.id}`, { replaceState: true }); goto(`${AppRoute.PEOPLE}/${personToBeMergedIn.id}`, { replaceState: true });
@ -232,6 +239,7 @@
const handleSuggestPeople = (person: PersonResponseDto) => { const handleSuggestPeople = (person: PersonResponseDto) => {
isEditingName = false; isEditingName = false;
potentialMergePeople = []; potentialMergePeople = [];
personName = person.name;
personMerge1 = data.person; personMerge1 = data.person;
personMerge2 = person; personMerge2 = person;
viewMode = ViewMode.SUGGEST_MERGE; viewMode = ViewMode.SUGGEST_MERGE;
@ -266,6 +274,7 @@
}; };
const handleNameChange = async (name: string) => { const handleNameChange = async (name: string) => {
isEditingName = false;
potentialMergePeople = []; potentialMergePeople = [];
personName = name; personName = name;
@ -277,7 +286,7 @@
return; return;
} }
const result = await api.searchApi.searchPerson({ name: personName }); const result = await api.searchApi.searchPerson({ name: personName, withHidden: true });
const existingPerson = result.data.find( const existingPerson = result.data.find(
(person: PersonResponseDto) => (person: PersonResponseDto) =>
@ -413,42 +422,49 @@
on:outclick={handleCancelEditName} on:outclick={handleCancelEditName}
on:escape={handleCancelEditName} on:escape={handleCancelEditName}
> >
<section class="flex w-96 place-items-center border-black"> <section class="flex w-64 sm:w-96 place-items-center border-black">
{#if isEditingName} {#if isEditingName}
<EditNameInput <EditNameInput
person={data.person} person={data.person}
suggestedPeople={suggestedPeople.length > 0 || isSearchingPeople} suggestedPeople={suggestedPeople.length > 0 || isSearchingPeople}
bind:name bind:name
on:change={(event) => handleNameChange(event.detail)} on:change={(event) => handleNameChange(event.detail)}
on:input={searchPeople}
/> />
{:else} {:else}
<button on:click={() => (viewMode = ViewMode.VIEW_ASSETS)}> <div class="relative">
<ImageThumbnail <button
circle class="flex items-center justify-center"
shadow title="Edit name"
url={thumbnailData} on:click={() => (isEditingName = true)}
altText={data.person.name} >
widthStyle="3.375rem" <ImageThumbnail
heightStyle="3.375rem" circle
/> shadow
</button> url={thumbnailData}
altText={data.person.name}
<button widthStyle="3.375rem"
title="Edit name" heightStyle="3.375rem"
class="px-4 text-immich-primary dark:text-immich-dark-primary" />
on:click={() => (isEditingName = true)} <div
> class="flex flex-col justify-center text-left px-4 h-14 text-immich-primary dark:text-immich-dark-primary"
{#if data.person.name} >
<p class="py-2 font-medium">{data.person.name}</p> {#if data.person.name}
{:else} <p class="w-40 sm:w-72 font-medium truncate">{data.person.name}</p>
<p class="w-fit font-medium">Add a name</p> <p class="absolute w-fit text-sm text-gray-500 dark:text-immich-gray bottom-0">
<p class="text-sm text-gray-500 dark:text-immich-gray">Find them fast by name with search</p> {`${numberOfAssets} asset${numberOfAssets > 1 ? 's' : ''}`}
{/if} </p>
</button> {:else}
<p class="font-medium">Add a name</p>
<p class="text-sm text-gray-500 dark:text-immich-gray">Find them fast by name with search</p>
{/if}
</div>
</button>
</div>
{/if} {/if}
</section> </section>
{#if isEditingName} {#if isEditingName}
<div class="absolute z-[999] w-96"> <div class="absolute z-[999] w-64 sm:w-96">
{#if isSearchingPeople} {#if isSearchingPeople}
<div <div
class="flex rounded-b-lg dark:border-immich-dark-gray place-items-center bg-gray-100 p-2 dark:bg-gray-700" class="flex rounded-b-lg dark:border-immich-dark-gray place-items-center bg-gray-100 p-2 dark:bg-gray-700"
@ -460,9 +476,8 @@
{:else} {:else}
{#each suggestedPeople as person, index (person.id)} {#each suggestedPeople as person, index (person.id)}
<div <div
class="flex {index === suggestedPeople.length - 1 class="flex border-t dark:border-immich-dark-gray place-items-center bg-gray-100 p-2 dark:bg-gray-700 {index ===
? 'rounded-b-lg' suggestedPeople.length - 1 && 'rounded-b-lg'}"
: 'border-b dark:border-immich-dark-gray'} place-items-center bg-gray-100 p-2 dark:bg-gray-700"
> >
<button class="flex w-full place-items-center" on:click={() => handleSuggestPeople(person)}> <button class="flex w-full place-items-center" on:click={() => handleSuggestPeople(person)}>
<ImageThumbnail <ImageThumbnail