From d4146e3e6d0d463200a223ec3983e6545cbb85d7 Mon Sep 17 00:00:00 2001 From: Steven Carter Date: Wed, 17 Jan 2024 21:08:00 -0500 Subject: [PATCH] feat(server): provide the ability to search archived photos (#6332) * Feat: provide the ability to search archived photos Adds a query parameter (`searchArchived`) to the search URL parameters to allow the results to contain archived photos. * chore: rename includeArchived => withArchived * chore: open api --------- Co-authored-by: Jason Rasmussen --- docs/docs/features/search.md | 2 ++ mobile/openapi/doc/SearchApi.md | Bin 6589 -> 6700 bytes mobile/openapi/lib/api/search_api.dart | Bin 6311 -> 6561 bytes mobile/openapi/test/search_api_test.dart | Bin 925 -> 944 bytes open-api/immich-openapi-specs.json | 8 +++++ open-api/typescript-sdk/client/api.ts | 23 +++++++++--- .../repositories/smart-info.repository.ts | 1 + server/src/domain/search/dto/search.dto.ts | 5 +++ .../src/domain/search/search.service.spec.ts | 34 ++++++++++++++++++ server/src/domain/search/search.service.ts | 8 ++++- .../repositories/smart-info.repository.ts | 14 +++++--- 11 files changed, 85 insertions(+), 10 deletions(-) diff --git a/docs/docs/features/search.md b/docs/docs/features/search.md index 2c5fa03a5c..0805007e54 100644 --- a/docs/docs/features/search.md +++ b/docs/docs/features/search.md @@ -6,6 +6,8 @@ Smart search is powered by the [pgvecto.rs](https://github.com/tensorchord/pgvec Metadata search (prefixed with `m:`) can search specifically by text without the use of a model. +Archived photos are not included in search results by default. To include them, add the query parameter `withArchived=true` to the url. + Some search examples: diff --git a/mobile/openapi/doc/SearchApi.md b/mobile/openapi/doc/SearchApi.md index d70cfd75ea06c71640312d35b2687c1732c605ce..524ce03f5b42a01f1487b2d1a7c82ff61f2074b4 100644 GIT binary patch delta 89 zcmdmMyvAe$A19xVLV0FMhGS83MrK)R%4R;!4=j`K@v`wCi%x#Un})71fNwt|zk-$) MvbxP5`Ok0y0IU@t?f?J) delta 38 ucmZ2uve$S6ALr&?Ar+R%4!mb3e;3o89M7jZ*`6(M^JBg|#?5sC>o@@vkPYMj diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart index c1b6a51f831a767bf90684c8ecb34ce4cbcf4725..5dd8dc0c7044577e168191fdaf88e21af8c665bf 100644 GIT binary patch delta 205 zcmZ2(xX^e*J;!8M4q=}1%#sYpqU4OsvecBx_XMRUPveLeO3KgAu~$Hm(iRHX?96$U zNkjn%GSd_^P-F$T&83kA)OApFZ|39K#)xU%0p1F1R@I8?O-|v9l0i1m3dKAfh0XI> M?lEqjBf!l803r-X#sB~S delta 49 zcmV-10M7rRGp8}Ie+ZMJ2zIk92_pluzYMGala3B_0ezES1SON776y|-4_dRI1l|F& HmJtgG#j+7P diff --git a/mobile/openapi/test/search_api_test.dart b/mobile/openapi/test/search_api_test.dart index 09788ee449ad875a132dcb10d39d56272dff9491..edbbfa45b7e640cc74a89e3a4dc9f562cfe1a196 100644 GIT binary patch delta 35 rcmbQszJYziHYQ;mg{1ua9EI}Ck_^Y9 => { + search: async (q?: string, query?: string, clip?: boolean, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', recent?: boolean, motion?: boolean, withArchived?: 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); @@ -14687,6 +14688,10 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio localVarQueryParameter['motion'] = motion; } + if (withArchived !== undefined) { + localVarQueryParameter['withArchived'] = withArchived; + } + setSearchParams(localVarUrlObj, localVarQueryParameter); @@ -14775,11 +14780,12 @@ export const SearchApiFp = function(configuration?: Configuration) { * @param {'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER'} [type] * @param {boolean} [recent] * @param {boolean} [motion] + * @param {boolean} [withArchived] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async search(q?: string, query?: string, clip?: boolean, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', recent?: boolean, motion?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.search(q, query, clip, type, recent, motion, options); + async search(q?: string, query?: string, clip?: boolean, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', recent?: boolean, motion?: boolean, withArchived?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.search(q, query, clip, type, recent, motion, withArchived, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** @@ -14818,7 +14824,7 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat * @throws {RequiredError} */ search(requestParameters: SearchApiSearchRequest = {}, options?: AxiosRequestConfig): AxiosPromise { - return localVarFp.search(requestParameters.q, requestParameters.query, requestParameters.clip, requestParameters.type, requestParameters.recent, requestParameters.motion, options).then((request) => request(axios, basePath)); + return localVarFp.search(requestParameters.q, requestParameters.query, requestParameters.clip, requestParameters.type, requestParameters.recent, requestParameters.motion, requestParameters.withArchived, options).then((request) => request(axios, basePath)); }, /** * @@ -14879,6 +14885,13 @@ export interface SearchApiSearchRequest { * @memberof SearchApiSearch */ readonly motion?: boolean + + /** + * + * @type {boolean} + * @memberof SearchApiSearch + */ + readonly withArchived?: boolean } /** @@ -14927,7 +14940,7 @@ export class SearchApi extends BaseAPI { * @memberof SearchApi */ public search(requestParameters: SearchApiSearchRequest = {}, options?: AxiosRequestConfig) { - return SearchApiFp(this.configuration).search(requestParameters.q, requestParameters.query, requestParameters.clip, requestParameters.type, requestParameters.recent, requestParameters.motion, options).then((request) => request(this.axios, this.basePath)); + return SearchApiFp(this.configuration).search(requestParameters.q, requestParameters.query, requestParameters.clip, requestParameters.type, requestParameters.recent, requestParameters.motion, requestParameters.withArchived, options).then((request) => request(this.axios, this.basePath)); } /** diff --git a/server/src/domain/repositories/smart-info.repository.ts b/server/src/domain/repositories/smart-info.repository.ts index c35ec1d84c..81c68e0494 100644 --- a/server/src/domain/repositories/smart-info.repository.ts +++ b/server/src/domain/repositories/smart-info.repository.ts @@ -9,6 +9,7 @@ export interface EmbeddingSearch { embedding: Embedding; numResults: number; maxDistance?: number; + withArchived?: boolean; } export interface ISmartInfoRepository { diff --git a/server/src/domain/search/dto/search.dto.ts b/server/src/domain/search/dto/search.dto.ts index 3a77bd4b7c..acfb91f0cb 100644 --- a/server/src/domain/search/dto/search.dto.ts +++ b/server/src/domain/search/dto/search.dto.ts @@ -32,6 +32,11 @@ export class SearchDto { @Optional() @Transform(toBoolean) motion?: boolean; + + @IsBoolean() + @Optional() + @Transform(toBoolean) + withArchived?: boolean; } export class SearchPeopleDto { diff --git a/server/src/domain/search/search.service.spec.ts b/server/src/domain/search/search.service.spec.ts index 9541d8f1d4..7b182b9d30 100644 --- a/server/src/domain/search/search.service.spec.ts +++ b/server/src/domain/search/search.service.spec.ts @@ -114,6 +114,39 @@ describe(SearchService.name, () => { expect(smartInfoMock.searchCLIP).not.toHaveBeenCalled(); }); + it('should search archived photos if `withArchived` option is true', async () => { + const dto: SearchDto = { q: 'test query', clip: true, withArchived: true }; + const embedding = [1, 2, 3]; + smartInfoMock.searchCLIP.mockResolvedValueOnce([assetStub.image]); + machineMock.encodeText.mockResolvedValueOnce(embedding); + partnerMock.getAll.mockResolvedValueOnce([]); + const expectedResponse = { + albums: { + total: 0, + count: 0, + items: [], + facets: [], + }, + assets: { + total: 1, + count: 1, + items: [mapAsset(assetStub.image)], + facets: [], + }, + }; + + const result = await sut.search(authStub.user1, dto); + + expect(result).toEqual(expectedResponse); + expect(smartInfoMock.searchCLIP).toHaveBeenCalledWith({ + userIds: [authStub.user1.user.id], + embedding, + numResults: 100, + withArchived: true, + }); + expect(assetMock.searchMetadata).not.toHaveBeenCalled(); + }); + it('should search by CLIP if `clip` option is true', async () => { const dto: SearchDto = { q: 'test query', clip: true }; const embedding = [1, 2, 3]; @@ -142,6 +175,7 @@ describe(SearchService.name, () => { userIds: [authStub.user1.user.id], embedding, numResults: 100, + withArchived: false, }); expect(assetMock.searchMetadata).not.toHaveBeenCalled(); }); diff --git a/server/src/domain/search/search.service.ts b/server/src/domain/search/search.service.ts index ef5a42fe46..2bd65daab9 100644 --- a/server/src/domain/search/search.service.ts +++ b/server/src/domain/search/search.service.ts @@ -67,6 +67,7 @@ export class SearchService { } const strategy = dto.clip ? SearchStrategy.CLIP : SearchStrategy.TEXT; const userIds = await this.getUserIdsToSearch(auth); + const withArchived = dto.withArchived || false; let assets: AssetEntity[] = []; @@ -77,7 +78,12 @@ export class SearchService { { text: query }, machineLearning.clip, ); - assets = await this.smartInfoRepository.searchCLIP({ userIds: userIds, embedding, numResults: 100 }); + assets = await this.smartInfoRepository.searchCLIP({ + userIds: userIds, + embedding, + numResults: 100, + withArchived, + }); break; case SearchStrategy.TEXT: assets = await this.assetRepository.searchMetadata(query, userIds, { numResults: 250 }); diff --git a/server/src/infra/repositories/smart-info.repository.ts b/server/src/infra/repositories/smart-info.repository.ts index ae8bea2d09..37d1766ea4 100644 --- a/server/src/infra/repositories/smart-info.repository.ts +++ b/server/src/infra/repositories/smart-info.repository.ts @@ -43,7 +43,7 @@ export class SmartInfoRepository implements ISmartInfoRepository { @GenerateSql({ params: [{ userIds: [DummyValue.UUID], embedding: Array.from({ length: 512 }, Math.random), numResults: 100 }], }) - async searchCLIP({ userIds, embedding, numResults }: EmbeddingSearch): Promise { + async searchCLIP({ userIds, embedding, numResults, withArchived }: EmbeddingSearch): Promise { if (!isValidInteger(numResults, { min: 1 })) { throw new Error(`Invalid value for 'numResults': ${numResults}`); } @@ -52,12 +52,18 @@ export class SmartInfoRepository implements ISmartInfoRepository { await this.assetRepository.manager.transaction(async (manager) => { await manager.query(`SET LOCAL vectors.k = '${numResults}'`); await manager.query(`SET LOCAL vectors.enable_prefilter = on`); - results = await manager + + const query = manager .createQueryBuilder(AssetEntity, 'a') .innerJoin('a.smartSearch', 's') .where('a.ownerId IN (:...userIds )') - .andWhere('a.isVisible = true') - .andWhere('a.isArchived = false') + .andWhere('a.isVisible = true'); + + if (!withArchived) { + query.andWhere('a.isArchived = false'); + } + + results = await query .andWhere('a.fileCreatedAt < NOW()') .leftJoinAndSelect('a.exifInfo', 'e') .orderBy('s.embedding <=> :embedding')