diff --git a/docs/docs/features/search.md b/docs/docs/features/search.md index 2c5fa03a5..0805007e5 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 d70cfd75e..524ce03f5 100644 --- a/mobile/openapi/doc/SearchApi.md +++ b/mobile/openapi/doc/SearchApi.md @@ -66,7 +66,7 @@ This endpoint does not need any parameter. [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) # **search** -> SearchResponseDto search(q, query, clip, type, recent, motion) +> SearchResponseDto search(q, query, clip, type, recent, motion, withArchived) @@ -95,9 +95,10 @@ final clip = true; // bool | final type = type_example; // String | final recent = true; // bool | final motion = true; // bool | +final withArchived = true; // bool | try { - final result = api_instance.search(q, query, clip, type, recent, motion); + final result = api_instance.search(q, query, clip, type, recent, motion, withArchived); print(result); } catch (e) { print('Exception when calling SearchApi->search: $e\n'); @@ -114,6 +115,7 @@ Name | Type | Description | Notes **type** | **String**| | [optional] **recent** | **bool**| | [optional] **motion** | **bool**| | [optional] + **withArchived** | **bool**| | [optional] ### Return type diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart index c1b6a51f8..5dd8dc0c7 100644 --- a/mobile/openapi/lib/api/search_api.dart +++ b/mobile/openapi/lib/api/search_api.dart @@ -74,7 +74,9 @@ class SearchApi { /// * [bool] recent: /// /// * [bool] motion: - Future searchWithHttpInfo({ String? q, String? query, bool? clip, String? type, bool? recent, bool? motion, }) async { + /// + /// * [bool] withArchived: + Future searchWithHttpInfo({ String? q, String? query, bool? clip, String? type, bool? recent, bool? motion, bool? withArchived, }) async { // ignore: prefer_const_declarations final path = r'/search'; @@ -103,6 +105,9 @@ class SearchApi { if (motion != null) { queryParams.addAll(_queryParams('', 'motion', motion)); } + if (withArchived != null) { + queryParams.addAll(_queryParams('', 'withArchived', withArchived)); + } const contentTypes = []; @@ -131,8 +136,10 @@ class SearchApi { /// * [bool] recent: /// /// * [bool] motion: - Future search({ String? q, String? query, bool? clip, String? type, bool? recent, bool? motion, }) async { - final response = await searchWithHttpInfo( q: q, query: query, clip: clip, type: type, recent: recent, motion: motion, ); + /// + /// * [bool] withArchived: + Future search({ String? q, String? query, bool? clip, String? type, bool? recent, bool? motion, bool? withArchived, }) async { + final response = await searchWithHttpInfo( q: q, query: query, clip: clip, type: type, recent: recent, motion: motion, withArchived: withArchived, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/test/search_api_test.dart b/mobile/openapi/test/search_api_test.dart index 09788ee44..edbbfa45b 100644 --- a/mobile/openapi/test/search_api_test.dart +++ b/mobile/openapi/test/search_api_test.dart @@ -22,7 +22,7 @@ void main() { // TODO }); - //Future search({ String q, String query, bool clip, String type, bool recent, bool motion }) async + //Future search({ String q, String query, bool clip, String type, bool recent, bool motion, bool withArchived }) async test('test search', () async { // TODO }); diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 61f2d26c9..9ed206cfd 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -4576,6 +4576,14 @@ "schema": { "type": "boolean" } + }, + { + "name": "withArchived", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } } ], "responses": { diff --git a/open-api/typescript-sdk/client/api.ts b/open-api/typescript-sdk/client/api.ts index f6e1e9532..9cb8d4ffb 100644 --- a/open-api/typescript-sdk/client/api.ts +++ b/open-api/typescript-sdk/client/api.ts @@ -14638,10 +14638,11 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio * @param {'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER'} [type] * @param {boolean} [recent] * @param {boolean} [motion] + * @param {boolean} [withArchived] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - search: async (q?: string, query?: string, clip?: boolean, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', recent?: boolean, motion?: boolean, options: AxiosRequestConfig = {}): Promise => { + 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 c35ec1d84..81c68e049 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 3a77bd4b7..acfb91f0c 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 9541d8f1d..7b182b9d3 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 ef5a42fe4..2bd65daab 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 ae8bea2d0..37d1766ea 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')