1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2024-12-29 15:11:58 +00:00

feat(server,web): improve performances in person page (1) (#4387)

* feat: improve performances in people page

* feat: add loadingspinner when searching

* fix: reset people on error

* fix: case insensitive

* feat: better sql query

* fix: reset people list before api request

* fix: format
This commit is contained in:
martin 2023-10-10 16:34:25 +02:00 committed by GitHub
parent f36c40bc6b
commit b8d6cc1e09
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 361 additions and 44 deletions

View file

@ -12139,6 +12139,51 @@ export const SearchApiAxiosParamCreator = 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} name
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
searchPerson: async (name: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'name' is not null or undefined
assertParamExists('searchPerson', 'name', name)
const localVarPath = `/search/person`;
// 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)
if (name !== undefined) {
localVarQueryParameter['name'] = name;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@ -12192,6 +12237,16 @@ export const SearchApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.search(q, query, clip, type, isFavorite, isArchived, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, exifInfoProjectionType, smartInfoObjects, smartInfoTags, recent, motion, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} name
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async searchPerson(name: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<PersonResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.searchPerson(name, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
}
};
@ -12219,6 +12274,15 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat
search(requestParameters: SearchApiSearchRequest = {}, options?: AxiosRequestConfig): AxiosPromise<SearchResponseDto> {
return localVarFp.search(requestParameters.q, requestParameters.query, requestParameters.clip, requestParameters.type, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.exifInfoCity, requestParameters.exifInfoState, requestParameters.exifInfoCountry, requestParameters.exifInfoMake, requestParameters.exifInfoModel, requestParameters.exifInfoProjectionType, requestParameters.smartInfoObjects, requestParameters.smartInfoTags, requestParameters.recent, requestParameters.motion, options).then((request) => request(axios, basePath));
},
/**
*
* @param {SearchApiSearchPersonRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: AxiosRequestConfig): AxiosPromise<Array<PersonResponseDto>> {
return localVarFp.searchPerson(requestParameters.name, options).then((request) => request(axios, basePath));
},
};
};
@ -12341,6 +12405,20 @@ export interface SearchApiSearchRequest {
readonly motion?: boolean
}
/**
* Request parameters for searchPerson operation in SearchApi.
* @export
* @interface SearchApiSearchPersonRequest
*/
export interface SearchApiSearchPersonRequest {
/**
*
* @type {string}
* @memberof SearchApiSearchPerson
*/
readonly name: string
}
/**
* SearchApi - object-oriented interface
* @export
@ -12368,6 +12446,17 @@ export class SearchApi extends BaseAPI {
public search(requestParameters: SearchApiSearchRequest = {}, options?: AxiosRequestConfig) {
return SearchApiFp(this.configuration).search(requestParameters.q, requestParameters.query, requestParameters.clip, requestParameters.type, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.exifInfoCity, requestParameters.exifInfoState, requestParameters.exifInfoCountry, requestParameters.exifInfoMake, requestParameters.exifInfoModel, requestParameters.exifInfoProjectionType, requestParameters.smartInfoObjects, requestParameters.smartInfoTags, requestParameters.recent, requestParameters.motion, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {SearchApiSearchPersonRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof SearchApi
*/
public searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: AxiosRequestConfig) {
return SearchApiFp(this.configuration).searchPerson(requestParameters.name, options).then((request) => request(this.axios, this.basePath));
}
}

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -3789,6 +3789,50 @@
]
}
},
"/search/person": {
"get": {
"operationId": "searchPerson",
"parameters": [
{
"name": "name",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/PersonResponseDto"
},
"type": "array"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Search"
]
}
},
"/server-info": {
"get": {
"operationId": "getServerInfo",

View file

@ -22,6 +22,7 @@ export interface IPersonRepository {
getAllForUser(userId: string, options: PersonSearchOptions): Promise<PersonEntity[]>;
getAllWithoutFaces(): Promise<PersonEntity[]>;
getById(personId: string): Promise<PersonEntity | null>;
getByName(userId: string, personName: string): Promise<PersonEntity[]>;
getAssets(personId: string): Promise<AssetEntity[]>;
prepareReassignFaces(data: UpdateFacesData): Promise<string[]>;

View file

@ -85,3 +85,9 @@ export class SearchDto {
@Transform(toBoolean)
motion?: boolean;
}
export class SearchPeopleDto {
@IsString()
@IsNotEmpty()
name!: string;
}

View file

@ -5,6 +5,7 @@ import { AssetResponseDto, mapAsset } from '../asset';
import { AuthUserDto } from '../auth';
import { usePagination } from '../domain.util';
import { IAssetFaceJob, IBulkEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job';
import { PersonResponseDto } from '../person/person.dto';
import {
AssetFaceId,
IAlbumRepository,
@ -21,7 +22,7 @@ import {
SearchStrategy,
} from '../repositories';
import { FeatureFlag, SystemConfigCore } from '../system-config';
import { SearchDto } from './dto';
import { SearchDto, SearchPeopleDto } from './dto';
import { SearchResponseDto } from './response-dto';
interface SyncQueue {
@ -158,6 +159,10 @@ export class SearchService {
};
}
async searchPerson(authUser: AuthUserDto, dto: SearchPeopleDto): Promise<PersonResponseDto[]> {
return await this.personRepository.getByName(authUser.id, dto.name);
}
async handleIndexAlbums() {
if (!this.enabled) {
return false;

View file

@ -1,4 +1,12 @@
import { AuthUserDto, SearchDto, SearchExploreResponseDto, SearchResponseDto, SearchService } from '@app/domain';
import {
AuthUserDto,
PersonResponseDto,
SearchDto,
SearchExploreResponseDto,
SearchPeopleDto,
SearchResponseDto,
SearchService,
} from '@app/domain';
import { Controller, Get, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthUser, Authenticated } from '../app.guard';
@ -11,6 +19,11 @@ import { UseValidation } from '../app.utils';
export class SearchController {
constructor(private service: SearchService) {}
@Get('person')
searchPerson(@AuthUser() authUser: AuthUserDto, @Query() dto: SearchPeopleDto): Promise<PersonResponseDto[]> {
return this.service.searchPerson(authUser, dto);
}
@Get()
search(@AuthUser() authUser: AuthUserDto, @Query() dto: SearchDto): Promise<SearchResponseDto> {
return this.service.search(authUser, dto);

View file

@ -95,6 +95,16 @@ export class PersonRepository implements IPersonRepository {
return this.personRepository.findOne({ where: { id: personId } });
}
getByName(userId: string, personName: string): Promise<PersonEntity[]> {
return 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();
}
getAssets(personId: string): Promise<AssetEntity[]> {
return this.assetRepository.find({
where: {

View file

@ -9,6 +9,8 @@ export const newPersonRepositoryMock = (): jest.Mocked<IPersonRepository> => {
getAssets: jest.fn(),
getAllWithoutFaces: jest.fn(),
getByName: jest.fn(),
create: jest.fn(),
update: jest.fn(),
deleteAll: jest.fn(),

View file

@ -12139,6 +12139,51 @@ export const SearchApiAxiosParamCreator = 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} name
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
searchPerson: async (name: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'name' is not null or undefined
assertParamExists('searchPerson', 'name', name)
const localVarPath = `/search/person`;
// 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)
if (name !== undefined) {
localVarQueryParameter['name'] = name;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@ -12192,6 +12237,16 @@ export const SearchApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.search(q, query, clip, type, isFavorite, isArchived, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, exifInfoProjectionType, smartInfoObjects, smartInfoTags, recent, motion, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} name
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async searchPerson(name: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<PersonResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.searchPerson(name, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
}
};
@ -12219,6 +12274,15 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat
search(requestParameters: SearchApiSearchRequest = {}, options?: AxiosRequestConfig): AxiosPromise<SearchResponseDto> {
return localVarFp.search(requestParameters.q, requestParameters.query, requestParameters.clip, requestParameters.type, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.exifInfoCity, requestParameters.exifInfoState, requestParameters.exifInfoCountry, requestParameters.exifInfoMake, requestParameters.exifInfoModel, requestParameters.exifInfoProjectionType, requestParameters.smartInfoObjects, requestParameters.smartInfoTags, requestParameters.recent, requestParameters.motion, options).then((request) => request(axios, basePath));
},
/**
*
* @param {SearchApiSearchPersonRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: AxiosRequestConfig): AxiosPromise<Array<PersonResponseDto>> {
return localVarFp.searchPerson(requestParameters.name, options).then((request) => request(axios, basePath));
},
};
};
@ -12341,6 +12405,20 @@ export interface SearchApiSearchRequest {
readonly motion?: boolean
}
/**
* Request parameters for searchPerson operation in SearchApi.
* @export
* @interface SearchApiSearchPersonRequest
*/
export interface SearchApiSearchPersonRequest {
/**
*
* @type {string}
* @memberof SearchApiSearchPerson
*/
readonly name: string
}
/**
* SearchApi - object-oriented interface
* @export
@ -12368,6 +12446,17 @@ export class SearchApi extends BaseAPI {
public search(requestParameters: SearchApiSearchRequest = {}, options?: AxiosRequestConfig) {
return SearchApiFp(this.configuration).search(requestParameters.q, requestParameters.query, requestParameters.clip, requestParameters.type, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.exifInfoCity, requestParameters.exifInfoState, requestParameters.exifInfoCountry, requestParameters.exifInfoMake, requestParameters.exifInfoModel, requestParameters.exifInfoProjectionType, requestParameters.smartInfoObjects, requestParameters.smartInfoTags, requestParameters.recent, requestParameters.motion, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {SearchApiSearchPersonRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof SearchApi
*/
public searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: AxiosRequestConfig) {
return SearchApiFp(this.configuration).searchPerson(requestParameters.name, options).then((request) => request(this.axios, this.basePath));
}
}

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { createEventDispatcher, onMount } from 'svelte';
import { api, type PersonResponseDto } from '@api';
import FaceThumbnail from './face-thumbnail.svelte';
import { quintOut } from 'svelte/easing';
@ -17,7 +17,7 @@
import SwapHorizontal from 'svelte-material-icons/SwapHorizontal.svelte';
export let person: PersonResponseDto;
export let people: PersonResponseDto[];
let people: PersonResponseDto[];
let selectedPeople: PersonResponseDto[] = [];
let screenHeight: number;
let isShowConfirmation = false;
@ -28,6 +28,11 @@
(source) => !selectedPeople.some((selected) => selected.id === source.id) && source.id !== person.id,
);
onMount(async () => {
const { data } = await api.personApi.getAllPeople({ withHidden: false });
people = data.people;
});
const onClose = () => {
dispatch('go-back');
};

View file

@ -254,9 +254,15 @@
if (!edittingPerson || personName === edittingPerson.name) {
return;
}
if (personName === '') {
changeName();
return;
}
const { data } = await api.searchApi.searchPerson({ name: personName });
// We check if another person has the same name as the name entered by the user
const existingPerson = people.find(
const existingPerson = data.find(
(person: PersonResponseDto) =>
person.name.toLowerCase() === personName.toLowerCase() &&
edittingPerson &&

View file

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

View file

@ -35,6 +35,8 @@
import type { PageData } from './$types';
import { clickOutside } from '$lib/utils/click-outside';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { browser } from '$app/environment';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
export let data: PageData;
@ -61,7 +63,7 @@
let isEditingName = false;
let previousRoute: string = AppRoute.EXPLORE;
let previousPersonId: string = data.person.id;
let people = data.people.people;
let people: PersonResponseDto[];
let personMerge1: PersonResponseDto;
let personMerge2: PersonResponseDto;
let potentialMergePeople: PersonResponseDto[] = [];
@ -74,20 +76,58 @@
let name: string = data.person.name;
let suggestedPeople: PersonResponseDto[] = [];
/**
* Save the word used to search people name: for example,
* if searching 'r' and the server returns 15 people with names starting with 'r',
* there's no need to search again people with name starting with 'ri'.
* However, it needs to make a new api request if searching 'r' returns 20 names (arbitrary value, the limit sent back by the server).
* or if the new search word starts with another word / letter
**/
let searchWord: string;
let maxPeople = false;
let isSearchingPeople = false;
const searchPeople = async () => {
isSearchingPeople = true;
people = [];
try {
const { data } = await api.searchApi.searchPerson({ name });
people = data;
searchWord = name;
if (data.length < 20) {
maxPeople = false;
} else {
maxPeople = true;
}
} catch (error) {
handleError(error, "Can't search people");
}
isSearchingPeople = false;
};
$: {
if (name !== '' && browser) {
if (maxPeople === true || (!name.startsWith(searchWord) && maxPeople === false)) searchPeople();
}
}
$: isAllArchive = Array.from($selectedAssets).every((asset) => asset.isArchived);
$: isAllFavorite = Array.from($selectedAssets).every((asset) => asset.isFavorite);
$: $onPersonThumbnail === data.person.id &&
(thumbnailData = api.getPeopleThumbnailUrl(data.person.id) + `?now=${Date.now()}`);
$: {
suggestedPeople = !name
? []
: people
.filter(
(person: PersonResponseDto) =>
person.name.toLowerCase().startsWith(name.toLowerCase()) && person.id !== data.person.id,
)
.slice(0, 5);
if (people) {
suggestedPeople = !name
? []
: people
.filter(
(person: PersonResponseDto) =>
person.name.toLowerCase().startsWith(name.toLowerCase()) && person.id !== data.person.id,
)
.slice(0, 5);
}
}
onMount(() => {
@ -199,18 +239,11 @@
try {
isEditingName = false;
const { data: updatedPerson } = await api.personApi.updatePerson({
await api.personApi.updatePerson({
id: data.person.id,
personUpdateDto: { name: personName },
});
people = people.map((person: PersonResponseDto) => {
if (person.id === updatedPerson.id) {
return updatedPerson;
}
return person;
});
notificationController.show({
message: 'Change name succesfully',
type: NotificationType.Info,
@ -235,15 +268,21 @@
if (data.person.name === personName) {
return;
}
if (name === '') {
changeName();
return;
}
const existingPerson = people.find(
const result = await api.searchApi.searchPerson({ name: personName });
const existingPerson = result.data.find(
(person: PersonResponseDto) =>
person.name.toLowerCase() === personName.toLowerCase() && person.id !== data.person.id && person.name,
);
if (existingPerson) {
personMerge2 = existingPerson;
personMerge1 = data.person;
potentialMergePeople = people
potentialMergePeople = result.data
.filter(
(person: PersonResponseDto) =>
personMerge2.name.toLowerCase() === person.name.toLowerCase() &&
@ -310,7 +349,7 @@
{/if}
{#if viewMode === ViewMode.MERGE_FACES}
<MergeFaceSelector person={data.person} bind:people on:go-back={handleGoBack} on:merge={handleMerge} />
<MergeFaceSelector person={data.person} on:go-back={handleGoBack} on:merge={handleMerge} />
{/if}
<header>
@ -374,7 +413,7 @@
{#if isEditingName}
<EditNameInput
person={data.person}
suggestedPeople={suggestedPeople.length > 0}
suggestedPeople={suggestedPeople.length > 0 || isSearchingPeople}
bind:name
on:change={(event) => handleNameChange(event.detail)}
/>
@ -406,25 +445,35 @@
</section>
{#if isEditingName}
<div class="absolute z-[999] w-96">
{#each suggestedPeople as person, index (person.id)}
{#if isSearchingPeople}
<div
class="flex {index === 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"
class="flex rounded-b-lg 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)}>
<ImageThumbnail
circle
shadow
url={api.getPeopleThumbnailUrl(person.id)}
altText={person.name}
widthStyle="2rem"
heightStyle="2rem"
/>
<p class="ml-4 text-gray-700 dark:text-gray-100">{person.name}</p>
</button>
<div class="flex w-full place-items-center">
<LoadingSpinner />
</div>
</div>
{/each}
{:else}
{#each suggestedPeople as person, index (person.id)}
<div
class="flex {index === 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)}>
<ImageThumbnail
circle
shadow
url={api.getPeopleThumbnailUrl(person.id)}
altText={person.name}
widthStyle="2rem"
heightStyle="2rem"
/>
<p class="ml-4 text-gray-700 dark:text-gray-100">{person.name}</p>
</button>
</div>
{/each}
{/if}
</div>
{/if}
</div>