From 73a2063d96e629754e575c87914d4f11b1fc5fc2 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 20 Mar 2023 16:16:32 -0400 Subject: [PATCH] fix(server): search and explore issues (#2029) * fix: send assets to typesense in batches * fix: run classs transformer on search endpoint * chore: log typesense filters --- mobile/openapi/doc/SearchApi.md | Bin 4846 -> 6732 bytes mobile/openapi/lib/api/search_api.dart | Bin 4627 -> 8322 bytes mobile/openapi/test/search_api_test.dart | Bin 868 -> 1172 bytes .../src/controllers/search.controller.ts | 2 +- server/immich-openapi-specs.json | 127 +++++++++++++++++- .../libs/domain/src/search/search.service.ts | 10 +- .../infra/src/search/typesense.repository.ts | 13 +- web/src/api/open-api/api.ts | 126 ++++++++++++++++- web/src/routes/(user)/search/+page.server.ts | 18 ++- 9 files changed, 283 insertions(+), 13 deletions(-) diff --git a/mobile/openapi/doc/SearchApi.md b/mobile/openapi/doc/SearchApi.md index 2a8a5733b64a1c079327eaca59cba1c66e74e2d6..8ba8a88088d8a8b27dd939d44e2351cf7b3bfe42 100644 GIT binary patch literal 6732 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 F{{p;HiP8W7 delta 48 zcmV-00MGw|L6an~9SxJP2N9E04-=E!2Than5W|x!1NoEb8XJ=r3MG?h922ux3NHea GJ0M52N)W&R diff --git a/mobile/openapi/test/search_api_test.dart b/mobile/openapi/test/search_api_test.dart index ba9adaec59bdacf564833657f61f06d1a5d1802c..89037f3c4977141a32a4e1e7ebb8bfdb15815f97 100644 GIT binary patch delta 318 zcmaFDHidJ86mxyGLU2h@W?s5Np$?o?np#w;qmY!JpQDhRlUV?lD5)$+g^FhuyCs(8 z7iE?J1)v6`R%E7m=B4Eaq!wl7r#NSp0L{QE6;r%R diff --git a/server/apps/immich/src/controllers/search.controller.ts b/server/apps/immich/src/controllers/search.controller.ts index d172c7592b..2c2248c3fc 100644 --- a/server/apps/immich/src/controllers/search.controller.ts +++ b/server/apps/immich/src/controllers/search.controller.ts @@ -20,7 +20,7 @@ export class SearchController { @Get() async search( @GetAuthUser() authUser: AuthUserDto, - @Query(new ValidationPipe({ transform: true })) dto: SearchDto | any, + @Query(new ValidationPipe({ transform: true })) dto: SearchDto, ): Promise { return this.searchService.search(authUser, dto); } diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 2991174870..2d61b30803 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -620,7 +620,132 @@ "get": { "operationId": "search", "description": "", - "parameters": [], + "parameters": [ + { + "name": "q", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "query", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "clip", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "type", + "required": false, + "in": "query", + "schema": { + "enum": [ + "IMAGE", + "VIDEO", + "AUDIO", + "OTHER" + ], + "type": "string" + } + }, + { + "name": "isFavorite", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "exifInfo.city", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "exifInfo.state", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "exifInfo.country", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "exifInfo.make", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "exifInfo.model", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "smartInfo.objects", + "required": false, + "in": "query", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "smartInfo.tags", + "required": false, + "in": "query", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "recent", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "motion", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + } + ], "responses": { "200": { "description": "", diff --git a/server/libs/domain/src/search/search.service.ts b/server/libs/domain/src/search/search.service.ts index dab2f5ad3c..a9a18a835f 100644 --- a/server/libs/domain/src/search/search.service.ts +++ b/server/libs/domain/src/search/search.service.ts @@ -145,7 +145,15 @@ export class SearchService { // TODO: do this in batches based on searchIndexVersion const assets = this.patchAssets(await this.assetRepository.getAll({ isVisible: true })); this.logger.log(`Indexing ${assets.length} assets`); - await this.searchRepository.importAssets(assets, true); + + const chunkSize = 1000; + for (let i = 0; i < assets.length; i += chunkSize) { + const end = i + chunkSize; + const chunk = assets.slice(i, end); + const done = end >= assets.length - 1; + await this.searchRepository.importAssets(chunk, done); + } + this.logger.debug('Finished re-indexing all assets'); } catch (error: any) { this.logger.error(`Unable to index all assets`, error?.stack); diff --git a/server/libs/infra/src/search/typesense.repository.ts b/server/libs/infra/src/search/typesense.repository.ts index f5bb23d8eb..91c801c54f 100644 --- a/server/libs/infra/src/search/typesense.repository.ts +++ b/server/libs/infra/src/search/typesense.repository.ts @@ -365,7 +365,11 @@ export class TypesenseRepository implements ISearchRepository { } } - return _filters.join(' && '); + const result = _filters.join(' && '); + + this.logger.debug(`Album filters are: ${result}`); + + return result; } private getAssetFilters(filters: SearchFilter) { @@ -382,6 +386,11 @@ export class TypesenseRepository implements ISearchRepository { _filters.push(`${item.name}:${value}`); } } - return _filters.join(' && '); + + const result = _filters.join(' && '); + + this.logger.debug(`Asset filters are: ${result}`); + + return result; } } diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 038ff5e55a..f66c191d9a 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -6761,10 +6761,24 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio }, /** * + * @param {string} [q] + * @param {string} [query] + * @param {boolean} [clip] + * @param {'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER'} [type] + * @param {boolean} [isFavorite] + * @param {string} [exifInfoCity] + * @param {string} [exifInfoState] + * @param {string} [exifInfoCountry] + * @param {string} [exifInfoMake] + * @param {string} [exifInfoModel] + * @param {Array} [smartInfoObjects] + * @param {Array} [smartInfoTags] + * @param {boolean} [recent] + * @param {boolean} [motion] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - search: async (options: AxiosRequestConfig = {}): Promise => { + search: async (q?: string, query?: string, clip?: boolean, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array, smartInfoTags?: Array, recent?: boolean, motion?: boolean, options: AxiosRequestConfig = {}): Promise => { const localVarPath = `/search`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); @@ -6783,6 +6797,62 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio // authentication cookie required + if (q !== undefined) { + localVarQueryParameter['q'] = q; + } + + if (query !== undefined) { + localVarQueryParameter['query'] = query; + } + + if (clip !== undefined) { + localVarQueryParameter['clip'] = clip; + } + + if (type !== undefined) { + localVarQueryParameter['type'] = type; + } + + if (isFavorite !== undefined) { + localVarQueryParameter['isFavorite'] = isFavorite; + } + + if (exifInfoCity !== undefined) { + localVarQueryParameter['exifInfo.city'] = exifInfoCity; + } + + if (exifInfoState !== undefined) { + localVarQueryParameter['exifInfo.state'] = exifInfoState; + } + + if (exifInfoCountry !== undefined) { + localVarQueryParameter['exifInfo.country'] = exifInfoCountry; + } + + if (exifInfoMake !== undefined) { + localVarQueryParameter['exifInfo.make'] = exifInfoMake; + } + + if (exifInfoModel !== undefined) { + localVarQueryParameter['exifInfo.model'] = exifInfoModel; + } + + if (smartInfoObjects) { + localVarQueryParameter['smartInfo.objects'] = smartInfoObjects; + } + + if (smartInfoTags) { + localVarQueryParameter['smartInfo.tags'] = smartInfoTags; + } + + if (recent !== undefined) { + localVarQueryParameter['recent'] = recent; + } + + if (motion !== undefined) { + localVarQueryParameter['motion'] = motion; + } + setSearchParams(localVarUrlObj, localVarQueryParameter); @@ -6824,11 +6894,25 @@ export const SearchApiFp = function(configuration?: Configuration) { }, /** * + * @param {string} [q] + * @param {string} [query] + * @param {boolean} [clip] + * @param {'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER'} [type] + * @param {boolean} [isFavorite] + * @param {string} [exifInfoCity] + * @param {string} [exifInfoState] + * @param {string} [exifInfoCountry] + * @param {string} [exifInfoMake] + * @param {string} [exifInfoModel] + * @param {Array} [smartInfoObjects] + * @param {Array} [smartInfoTags] + * @param {boolean} [recent] + * @param {boolean} [motion] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async search(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.search(options); + async search(q?: string, query?: string, clip?: boolean, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array, smartInfoTags?: Array, recent?: boolean, motion?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.search(q, query, clip, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, recent, motion, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, } @@ -6859,11 +6943,25 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat }, /** * + * @param {string} [q] + * @param {string} [query] + * @param {boolean} [clip] + * @param {'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER'} [type] + * @param {boolean} [isFavorite] + * @param {string} [exifInfoCity] + * @param {string} [exifInfoState] + * @param {string} [exifInfoCountry] + * @param {string} [exifInfoMake] + * @param {string} [exifInfoModel] + * @param {Array} [smartInfoObjects] + * @param {Array} [smartInfoTags] + * @param {boolean} [recent] + * @param {boolean} [motion] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - search(options?: any): AxiosPromise { - return localVarFp.search(options).then((request) => request(axios, basePath)); + search(q?: string, query?: string, clip?: boolean, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array, smartInfoTags?: Array, recent?: boolean, motion?: boolean, options?: any): AxiosPromise { + return localVarFp.search(q, query, clip, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, recent, motion, options).then((request) => request(axios, basePath)); }, }; }; @@ -6897,12 +6995,26 @@ export class SearchApi extends BaseAPI { /** * + * @param {string} [q] + * @param {string} [query] + * @param {boolean} [clip] + * @param {'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER'} [type] + * @param {boolean} [isFavorite] + * @param {string} [exifInfoCity] + * @param {string} [exifInfoState] + * @param {string} [exifInfoCountry] + * @param {string} [exifInfoMake] + * @param {string} [exifInfoModel] + * @param {Array} [smartInfoObjects] + * @param {Array} [smartInfoTags] + * @param {boolean} [recent] + * @param {boolean} [motion] * @param {*} [options] Override http request option. * @throws {RequiredError} * @memberof SearchApi */ - public search(options?: AxiosRequestConfig) { - return SearchApiFp(this.configuration).search(options).then((request) => request(this.axios, this.basePath)); + public search(q?: string, query?: string, clip?: boolean, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array, smartInfoTags?: Array, recent?: boolean, motion?: boolean, options?: AxiosRequestConfig) { + return SearchApiFp(this.configuration).search(q, query, clip, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, recent, motion, options).then((request) => request(this.axios, this.basePath)); } } diff --git a/web/src/routes/(user)/search/+page.server.ts b/web/src/routes/(user)/search/+page.server.ts index 71122f4582..790cceae21 100644 --- a/web/src/routes/(user)/search/+page.server.ts +++ b/web/src/routes/(user)/search/+page.server.ts @@ -9,7 +9,23 @@ export const load = (async ({ locals, parent, url }) => { const term = url.searchParams.get('q') || url.searchParams.get('query') || undefined; - const { data: results } = await locals.api.searchApi.search({ params: url.searchParams }); + const { data: results } = await locals.api.searchApi.search( + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + { params: url.searchParams } + ); return { user,