forked from Cutlery/immich
		
	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 <jrasm91@gmail.com>
This commit is contained in:
		
							parent
							
								
									f0b328fb6b
								
							
						
					
					
						commit
						d4146e3e6d
					
				@ -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:
 | 
			
		||||
<img src={require('./img/search-ex-2.webp').default} title='Search Example 1' />
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										6
									
								
								mobile/openapi/doc/SearchApi.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										6
									
								
								mobile/openapi/doc/SearchApi.md
									
									
									
										generated
									
									
									
								
							@ -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
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										13
									
								
								mobile/openapi/lib/api/search_api.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										13
									
								
								mobile/openapi/lib/api/search_api.dart
									
									
									
										generated
									
									
									
								
							@ -74,7 +74,9 @@ class SearchApi {
 | 
			
		||||
  /// * [bool] recent:
 | 
			
		||||
  ///
 | 
			
		||||
  /// * [bool] motion:
 | 
			
		||||
  Future<Response> searchWithHttpInfo({ String? q, String? query, bool? clip, String? type, bool? recent, bool? motion, }) async {
 | 
			
		||||
  ///
 | 
			
		||||
  /// * [bool] withArchived:
 | 
			
		||||
  Future<Response> 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 = <String>[];
 | 
			
		||||
 | 
			
		||||
@ -131,8 +136,10 @@ class SearchApi {
 | 
			
		||||
  /// * [bool] recent:
 | 
			
		||||
  ///
 | 
			
		||||
  /// * [bool] motion:
 | 
			
		||||
  Future<SearchResponseDto?> 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<SearchResponseDto?> 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));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										2
									
								
								mobile/openapi/test/search_api_test.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2
									
								
								mobile/openapi/test/search_api_test.dart
									
									
									
										generated
									
									
									
								
							@ -22,7 +22,7 @@ void main() {
 | 
			
		||||
      // TODO
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    //Future<SearchResponseDto> search({ String q, String query, bool clip, String type, bool recent, bool motion }) async
 | 
			
		||||
    //Future<SearchResponseDto> search({ String q, String query, bool clip, String type, bool recent, bool motion, bool withArchived }) async
 | 
			
		||||
    test('test search', () async {
 | 
			
		||||
      // TODO
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
@ -4576,6 +4576,14 @@
 | 
			
		||||
            "schema": {
 | 
			
		||||
              "type": "boolean"
 | 
			
		||||
            }
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            "name": "withArchived",
 | 
			
		||||
            "required": false,
 | 
			
		||||
            "in": "query",
 | 
			
		||||
            "schema": {
 | 
			
		||||
              "type": "boolean"
 | 
			
		||||
            }
 | 
			
		||||
          }
 | 
			
		||||
        ],
 | 
			
		||||
        "responses": {
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										23
									
								
								open-api/typescript-sdk/client/api.ts
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										23
									
								
								open-api/typescript-sdk/client/api.ts
									
									
									
										generated
									
									
									
								
							@ -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<RequestArgs> => {
 | 
			
		||||
        search: async (q?: string, query?: string, clip?: boolean, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', recent?: boolean, motion?: boolean, withArchived?: boolean, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
 | 
			
		||||
            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<SearchResponseDto>> {
 | 
			
		||||
            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<SearchResponseDto>> {
 | 
			
		||||
            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<SearchResponseDto> {
 | 
			
		||||
            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));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
 | 
			
		||||
@ -9,6 +9,7 @@ export interface EmbeddingSearch {
 | 
			
		||||
  embedding: Embedding;
 | 
			
		||||
  numResults: number;
 | 
			
		||||
  maxDistance?: number;
 | 
			
		||||
  withArchived?: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface ISmartInfoRepository {
 | 
			
		||||
 | 
			
		||||
@ -32,6 +32,11 @@ export class SearchDto {
 | 
			
		||||
  @Optional()
 | 
			
		||||
  @Transform(toBoolean)
 | 
			
		||||
  motion?: boolean;
 | 
			
		||||
 | 
			
		||||
  @IsBoolean()
 | 
			
		||||
  @Optional()
 | 
			
		||||
  @Transform(toBoolean)
 | 
			
		||||
  withArchived?: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class SearchPeopleDto {
 | 
			
		||||
 | 
			
		||||
@ -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();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
@ -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 });
 | 
			
		||||
 | 
			
		||||
@ -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<AssetEntity[]> {
 | 
			
		||||
  async searchCLIP({ userIds, embedding, numResults, withArchived }: EmbeddingSearch): Promise<AssetEntity[]> {
 | 
			
		||||
    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')
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user