From 92a91211d5f3cc8b248332d13b9230e3d26ce826 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Mon, 15 Jan 2024 01:39:49 -0500 Subject: [PATCH 01/38] initial pagination impl --- open-api/immich-openapi-specs.json | 16 +++++++ .../repositories/smart-info.repository.ts | 3 +- server/src/domain/search/dto/search.dto.ts | 10 +++- .../src/domain/search/search.service.spec.ts | 2 +- server/src/domain/search/search.service.ts | 8 +--- server/src/infra/infra.utils.ts | 45 +++++++++++++----- .../repositories/smart-info.repository.ts | 47 ++++++++----------- 7 files changed, 83 insertions(+), 48 deletions(-) diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 4c6317c2c2dcb..99dd3fe674696 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -4427,6 +4427,22 @@ "schema": { "type": "boolean" } + }, + { + "name": "take", + "required": false, + "in": "query", + "schema": { + "type": "number" + } + }, + { + "name": "skip", + "required": false, + "in": "query", + "schema": { + "type": "number" + } } ], "responses": { diff --git a/server/src/domain/repositories/smart-info.repository.ts b/server/src/domain/repositories/smart-info.repository.ts index acb907bc8f20b..7ce1574e9c994 100644 --- a/server/src/domain/repositories/smart-info.repository.ts +++ b/server/src/domain/repositories/smart-info.repository.ts @@ -1,4 +1,5 @@ import { AssetEntity, AssetFaceEntity, SmartInfoEntity } from '@app/infra/entities'; +import { Paginated, PaginationOptions } from '../domain.util'; export const ISmartInfoRepository = 'ISmartInfoRepository'; @@ -23,7 +24,7 @@ export interface FaceSearchResult { export interface ISmartInfoRepository { init(modelName: string): Promise; - searchCLIP(search: EmbeddingSearch): Promise; + searchCLIP(search: EmbeddingSearch, pagination: PaginationOptions): Paginated; searchFaces(search: FaceEmbeddingSearch): Promise; upsert(smartInfo: Partial, embedding?: Embedding): Promise; } diff --git a/server/src/domain/search/dto/search.dto.ts b/server/src/domain/search/dto/search.dto.ts index 3ddcb3a32c683..b564e375b6abf 100644 --- a/server/src/domain/search/dto/search.dto.ts +++ b/server/src/domain/search/dto/search.dto.ts @@ -1,6 +1,6 @@ import { AssetType } from '@app/infra/entities'; import { Transform } from 'class-transformer'; -import { IsBoolean, IsEnum, IsNotEmpty, IsString } from 'class-validator'; +import { IsBoolean, IsEnum, IsInt, IsNotEmpty, IsPositive, IsString } from 'class-validator'; import { Optional, toBoolean } from '../../domain.util'; export class SearchDto { @@ -43,6 +43,14 @@ export class SearchDto { @Optional() @Transform(toBoolean) withArchived?: boolean; + + @IsPositive() + @Optional() + take?: number; + + @IsInt() + @Optional() + skip?: number; } export class SearchPeopleDto { diff --git a/server/src/domain/search/search.service.spec.ts b/server/src/domain/search/search.service.spec.ts index 86373ce2d2f76..462863c6b3cbf 100644 --- a/server/src/domain/search/search.service.spec.ts +++ b/server/src/domain/search/search.service.spec.ts @@ -150,7 +150,7 @@ describe(SearchService.name, () => { it('should search by CLIP if `clip` option is true', async () => { const dto: SearchDto = { q: 'test query', clip: true }; const embedding = [1, 2, 3]; - smartInfoMock.searchCLIP.mockResolvedValueOnce([assetStub.image]); + smartInfoMock.searchCLIP.mockResolvedValueOnce({ items: [assetStub.image], hasNextPage: false }); machineMock.encodeText.mockResolvedValueOnce(embedding); partnerMock.getAll.mockResolvedValueOnce([]); const expectedResponse = { diff --git a/server/src/domain/search/search.service.ts b/server/src/domain/search/search.service.ts index 932a865d04f22..5b4889b9011a7 100644 --- a/server/src/domain/search/search.service.ts +++ b/server/src/domain/search/search.service.ts @@ -81,12 +81,8 @@ export class SearchService { { text: query }, machineLearning.clip, ); - assets = await this.smartInfoRepository.searchCLIP({ - userIds: userIds, - embedding, - numResults: 100, - withArchived, - }); + const results = await this.smartInfoRepository.searchCLIP({ userIds, embedding, withArchived }, { take: dto.take || 100 }); + assets = results.items; break; } case SearchStrategy.TEXT: { diff --git a/server/src/infra/infra.utils.ts b/server/src/infra/infra.utils.ts index 1036df2afaccb..656a0ab7569e5 100644 --- a/server/src/infra/infra.utils.ts +++ b/server/src/infra/infra.utils.ts @@ -1,6 +1,14 @@ import { Paginated, PaginationOptions } from '@app/domain'; import _ from 'lodash'; -import { Between, FindManyOptions, LessThanOrEqual, MoreThanOrEqual, ObjectLiteral, Repository } from 'typeorm'; +import { + Between, + FindManyOptions, + LessThanOrEqual, + MoreThanOrEqual, + ObjectLiteral, + Repository, + SelectQueryBuilder, +} from 'typeorm'; import { chunks, setUnion } from '../domain/domain.util'; import { DATABASE_PARAMETER_CHUNK_SIZE } from './infra.util'; @@ -18,9 +26,14 @@ export function OptionalBetween(from?: T, to?: T) { } } +export const isValidInteger = (value: number, options: { min?: number; max?: number }): value is number => { + const { min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER } = options; + return Number.isInteger(value) && value >= min && value <= max; +}; + export async function paginate( repository: Repository, - paginationOptions: PaginationOptions, + { take, skip }: PaginationOptions, searchOptions?: FindManyOptions, ): Paginated { const items = await repository.find( @@ -28,15 +41,30 @@ export async function paginate( { ...searchOptions, // Take one more item to check if there's a next page - take: paginationOptions.take + 1, - skip: paginationOptions.skip, + take: take + 1, + skip, }, _.isUndefined, ), ); - const hasNextPage = items.length > paginationOptions.take; - items.splice(paginationOptions.take); + const hasNextPage = items.length > take; + items.splice(take); + + return { items, hasNextPage }; +} + +export async function paginatedBuilder( + qb: SelectQueryBuilder, + { take, skip }: PaginationOptions, +): Paginated { + const items = await qb + .take(take + 1) + .skip(skip) + .getMany(); + + const hasNextPage = items.length > take; + items.splice(take); return { items, hasNextPage }; } @@ -44,11 +72,6 @@ export async function paginate( export const asVector = (embedding: number[], quote = false) => quote ? `'[${embedding.join(',')}]'` : `[${embedding.join(',')}]`; -export const isValidInteger = (value: number, options: { min?: number; max?: number }): value is number => { - const { min = Number.MIN_SAFE_INTEGER, max = Number.MAX_SAFE_INTEGER } = options; - return Number.isInteger(value) && value >= min && value <= max; -}; - /** * Wraps a method that takes a collection of parameters and sequentially calls it with chunks of the collection, * to overcome the maximum number of parameters allowed by the database driver. diff --git a/server/src/infra/repositories/smart-info.repository.ts b/server/src/infra/repositories/smart-info.repository.ts index f74fd4232d9e5..d9c97fd5135ce 100644 --- a/server/src/infra/repositories/smart-info.repository.ts +++ b/server/src/infra/repositories/smart-info.repository.ts @@ -5,6 +5,9 @@ import { FaceEmbeddingSearch, FaceSearchResult, ISmartInfoRepository, + Paginated, + PaginationOptions, + PaginationResult, } from '@app/domain'; import { getCLIPModelInfo } from '@app/domain/smart-info/smart-info.constant'; import { AssetEntity, AssetFaceEntity, SmartInfoEntity, SmartSearchEntity } from '@app/infra/entities'; @@ -14,7 +17,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { vectorExt } from '../database.config'; import { DummyValue, GenerateSql } from '../infra.util'; -import { asVector, isValidInteger } from '../infra.utils'; +import { asVector, isValidInteger, paginatedBuilder } from '../infra.utils'; @Injectable() export class SmartInfoRepository implements ISmartInfoRepository { @@ -51,35 +54,23 @@ export class SmartInfoRepository implements ISmartInfoRepository { @GenerateSql({ params: [{ userIds: [DummyValue.UUID], embedding: Array.from({ length: 512 }, Math.random), numResults: 100 }], }) - async searchCLIP({ userIds, embedding, numResults, withArchived }: EmbeddingSearch): Promise { - if (!isValidInteger(numResults, { min: 1 })) { - throw new Error(`Invalid value for 'numResults': ${numResults}`); + async searchCLIP({ userIds, embedding }: EmbeddingSearch, pagination: PaginationOptions): Paginated { + const query = this.assetRepository + .createQueryBuilder('a') + .innerJoin('a.smartSearch', 's') + .where('a.ownerId IN (:...userIds )') + .andWhere('a.isVisible = true') + .andWhere('a.fileCreatedAt < NOW()') + .leftJoinAndSelect('a.exifInfo', 'e') + // .orderBy('s.embedding <=> :embedding') + .setParameters({ userIds, embedding: asVector(embedding) }); + + if (!withArchived) { + query.andWhere('a.isArchived = false'); } - // setting this too low messes with prefilter recall - numResults = Math.max(numResults, 64); - - let results: AssetEntity[] = []; - await this.assetRepository.manager.transaction(async (manager) => { - const query = manager - .createQueryBuilder(AssetEntity, 'a') - .innerJoin('a.smartSearch', 's') - .leftJoinAndSelect('a.exifInfo', 'e') - .where('a.ownerId IN (:...userIds )') - .orderBy('s.embedding <=> :embedding') - .setParameters({ userIds, embedding: asVector(embedding) }); - - if (!withArchived) { - query.andWhere('a.isArchived = false'); - } - query.andWhere('a.isVisible = true').andWhere('a.fileCreatedAt < NOW()'); - query.limit(numResults); - - await manager.query(this.getRuntimeConfig(numResults)); - results = await query.getMany(); - }); - - return results; + await manager.query(this.getRuntimeConfig(numResults)); + return paginatedBuilder(query, pagination);; } @GenerateSql({ From f60ad738545400db2612e1690d4f0fc9c0de6fa1 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Tue, 16 Jan 2024 01:05:56 -0500 Subject: [PATCH 02/38] use limit + offset instead of take + skip --- server/src/infra/infra.utils.ts | 4 ++-- server/src/infra/repositories/smart-info.repository.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/server/src/infra/infra.utils.ts b/server/src/infra/infra.utils.ts index 656a0ab7569e5..355d6d4097916 100644 --- a/server/src/infra/infra.utils.ts +++ b/server/src/infra/infra.utils.ts @@ -59,8 +59,8 @@ export async function paginatedBuilder( { take, skip }: PaginationOptions, ): Paginated { const items = await qb - .take(take + 1) - .skip(skip) + .limit(take + 1) + .offset(skip) .getMany(); const hasNextPage = items.length > take; diff --git a/server/src/infra/repositories/smart-info.repository.ts b/server/src/infra/repositories/smart-info.repository.ts index d9c97fd5135ce..8f05bdf5f9136 100644 --- a/server/src/infra/repositories/smart-info.repository.ts +++ b/server/src/infra/repositories/smart-info.repository.ts @@ -62,7 +62,7 @@ export class SmartInfoRepository implements ISmartInfoRepository { .andWhere('a.isVisible = true') .andWhere('a.fileCreatedAt < NOW()') .leftJoinAndSelect('a.exifInfo', 'e') - // .orderBy('s.embedding <=> :embedding') + .orderBy('s.embedding <=> :embedding') .setParameters({ userIds, embedding: asVector(embedding) }); if (!withArchived) { From 61b418535dc04bdbcc7af21d3671516f99bdfe5d Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Tue, 16 Jan 2024 22:57:47 -0500 Subject: [PATCH 03/38] wip web pagination --- mobile/openapi/doc/SearchApi.md | 8 +++-- mobile/openapi/lib/api/search_api.dart | 20 +++++++++-- mobile/openapi/test/search_api_test.dart | 2 +- open-api/immich-openapi-specs.json | 2 +- open-api/typescript-sdk/axios-client/api.ts | 36 ++++++++++++++++--- open-api/typescript-sdk/fetch-client.ts | 8 +++-- server/src/domain/search/dto/search.dto.ts | 9 +++-- server/src/domain/search/search.service.ts | 4 ++- .../asset-viewer/intersection-observer.svelte | 2 +- .../assets/thumbnail/thumbnail.svelte | 7 ++-- .../gallery-viewer/gallery-viewer.svelte | 26 ++++++++++++-- .../search-bar/search-bar.svelte | 2 ++ web/src/routes/(user)/search/+page.svelte | 5 +-- web/src/routes/(user)/search/+page.ts | 16 +++++++-- 14 files changed, 117 insertions(+), 30 deletions(-) diff --git a/mobile/openapi/doc/SearchApi.md b/mobile/openapi/doc/SearchApi.md index dcf453b55d2ec..950f3abe934a8 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(clip, motion, q, query, recent, smart, type, withArchived) +> SearchResponseDto search(clip, motion, q, query, recent, smart, type, withArchived, take, page) @@ -97,9 +97,11 @@ final recent = true; // bool | final smart = true; // bool | final type = type_example; // String | final withArchived = true; // bool | +final take = 8.14; // num | +final page = 8.14; // num | try { - final result = api_instance.search(clip, motion, q, query, recent, smart, type, withArchived); + final result = api_instance.search(clip, motion, q, query, recent, smart, type, withArchived, take, page); print(result); } catch (e) { print('Exception when calling SearchApi->search: $e\n'); @@ -118,6 +120,8 @@ Name | Type | Description | Notes **smart** | **bool**| | [optional] **type** | **String**| | [optional] **withArchived** | **bool**| | [optional] + **take** | **num**| | [optional] + **page** | **num**| | [optional] ### Return type diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart index e2bde2a17f02a..309a1364dd3f5 100644 --- a/mobile/openapi/lib/api/search_api.dart +++ b/mobile/openapi/lib/api/search_api.dart @@ -79,7 +79,11 @@ class SearchApi { /// * [String] type: /// /// * [bool] withArchived: - Future searchWithHttpInfo({ bool? clip, bool? motion, String? q, String? query, bool? recent, bool? smart, String? type, bool? withArchived, }) async { + /// + /// * [num] take: + /// + /// * [num] page: + Future searchWithHttpInfo({ bool? clip, bool? motion, String? q, String? query, bool? recent, bool? smart, String? type, bool? withArchived, num? take, num? page, }) async { // ignore: prefer_const_declarations final path = r'/search'; @@ -114,6 +118,12 @@ class SearchApi { if (withArchived != null) { queryParams.addAll(_queryParams('', 'withArchived', withArchived)); } + if (take != null) { + queryParams.addAll(_queryParams('', 'take', take)); + } + if (page != null) { + queryParams.addAll(_queryParams('', 'page', page)); + } const contentTypes = []; @@ -147,8 +157,12 @@ class SearchApi { /// * [String] type: /// /// * [bool] withArchived: - Future search({ bool? clip, bool? motion, String? q, String? query, bool? recent, bool? smart, String? type, bool? withArchived, }) async { - final response = await searchWithHttpInfo( clip: clip, motion: motion, q: q, query: query, recent: recent, smart: smart, type: type, withArchived: withArchived, ); + /// + /// * [num] take: + /// + /// * [num] page: + Future search({ bool? clip, bool? motion, String? q, String? query, bool? recent, bool? smart, String? type, bool? withArchived, num? take, num? page, }) async { + final response = await searchWithHttpInfo( clip: clip, motion: motion, q: q, query: query, recent: recent, smart: smart, type: type, withArchived: withArchived, take: take, page: page, ); 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 769ad3194367b..0226bb73ef38d 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({ bool clip, bool motion, String q, String query, bool recent, bool smart, String type, bool withArchived }) async + //Future search({ bool clip, bool motion, String q, String query, bool recent, bool smart, String type, bool withArchived, num take, num page }) async test('test search', () async { // TODO }); diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 99dd3fe674696..1f87850ca123d 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -4437,7 +4437,7 @@ } }, { - "name": "skip", + "name": "page", "required": false, "in": "query", "schema": { diff --git a/open-api/typescript-sdk/axios-client/api.ts b/open-api/typescript-sdk/axios-client/api.ts index 542fa0580ea98..7b39da761b62c 100644 --- a/open-api/typescript-sdk/axios-client/api.ts +++ b/open-api/typescript-sdk/axios-client/api.ts @@ -14506,10 +14506,12 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio * @param {boolean} [smart] * @param {SearchTypeEnum} [type] * @param {boolean} [withArchived] + * @param {number} [take] + * @param {number} [page] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - search: async (clip?: boolean, motion?: boolean, q?: string, query?: string, recent?: boolean, smart?: boolean, type?: SearchTypeEnum, withArchived?: boolean, options: RawAxiosRequestConfig = {}): Promise => { + search: async (clip?: boolean, motion?: boolean, q?: string, query?: string, recent?: boolean, smart?: boolean, type?: SearchTypeEnum, withArchived?: boolean, take?: number, page?: number, options: RawAxiosRequestConfig = {}): 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); @@ -14563,6 +14565,14 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio localVarQueryParameter['withArchived'] = withArchived; } + if (take !== undefined) { + localVarQueryParameter['take'] = take; + } + + if (page !== undefined) { + localVarQueryParameter['page'] = page; + } + setSearchParams(localVarUrlObj, localVarQueryParameter); @@ -14655,11 +14665,13 @@ export const SearchApiFp = function(configuration?: Configuration) { * @param {boolean} [smart] * @param {SearchTypeEnum} [type] * @param {boolean} [withArchived] + * @param {number} [take] + * @param {number} [page] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async search(clip?: boolean, motion?: boolean, q?: string, query?: string, recent?: boolean, smart?: boolean, type?: SearchTypeEnum, withArchived?: boolean, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.search(clip, motion, q, query, recent, smart, type, withArchived, options); + async search(clip?: boolean, motion?: boolean, q?: string, query?: string, recent?: boolean, smart?: boolean, type?: SearchTypeEnum, withArchived?: boolean, take?: number, page?: number, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.search(clip, motion, q, query, recent, smart, type, withArchived, take, page, options); const index = configuration?.serverIndex ?? 0; const operationBasePath = operationServerMap['SearchApi.search']?.[index]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, operationBasePath || basePath); @@ -14702,7 +14714,7 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat * @throws {RequiredError} */ search(requestParameters: SearchApiSearchRequest = {}, options?: RawAxiosRequestConfig): AxiosPromise { - return localVarFp.search(requestParameters.clip, requestParameters.motion, requestParameters.q, requestParameters.query, requestParameters.recent, requestParameters.smart, requestParameters.type, requestParameters.withArchived, options).then((request) => request(axios, basePath)); + return localVarFp.search(requestParameters.clip, requestParameters.motion, requestParameters.q, requestParameters.query, requestParameters.recent, requestParameters.smart, requestParameters.type, requestParameters.withArchived, requestParameters.take, requestParameters.page, options).then((request) => request(axios, basePath)); }, /** * @@ -14777,6 +14789,20 @@ export interface SearchApiSearchRequest { * @memberof SearchApiSearch */ readonly withArchived?: boolean + + /** + * + * @type {number} + * @memberof SearchApiSearch + */ + readonly take?: number + + /** + * + * @type {number} + * @memberof SearchApiSearch + */ + readonly page?: number } /** @@ -14825,7 +14851,7 @@ export class SearchApi extends BaseAPI { * @memberof SearchApi */ public search(requestParameters: SearchApiSearchRequest = {}, options?: RawAxiosRequestConfig) { - return SearchApiFp(this.configuration).search(requestParameters.clip, requestParameters.motion, requestParameters.q, requestParameters.query, requestParameters.recent, requestParameters.smart, requestParameters.type, requestParameters.withArchived, options).then((request) => request(this.axios, this.basePath)); + return SearchApiFp(this.configuration).search(requestParameters.clip, requestParameters.motion, requestParameters.q, requestParameters.query, requestParameters.recent, requestParameters.smart, requestParameters.type, requestParameters.withArchived, requestParameters.take, requestParameters.page, options).then((request) => request(this.axios, this.basePath)); } /** diff --git a/open-api/typescript-sdk/fetch-client.ts b/open-api/typescript-sdk/fetch-client.ts index cb2c42f5dd871..b0609418e7893 100644 --- a/open-api/typescript-sdk/fetch-client.ts +++ b/open-api/typescript-sdk/fetch-client.ts @@ -2047,7 +2047,7 @@ export function getPersonThumbnail({ id }: { ...opts })); } -export function search({ clip, motion, q, query, recent, smart, $type, withArchived }: { +export function search({ clip, motion, q, query, recent, smart, $type, withArchived, take, page }: { clip?: boolean; motion?: boolean; q?: string; @@ -2056,6 +2056,8 @@ export function search({ clip, motion, q, query, recent, smart, $type, withArchi smart?: boolean; $type?: "IMAGE" | "VIDEO" | "AUDIO" | "OTHER"; withArchived?: boolean; + take?: number; + page?: number; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -2068,7 +2070,9 @@ export function search({ clip, motion, q, query, recent, smart, $type, withArchi recent, smart, "type": $type, - withArchived + withArchived, + take, + page }))}`, { ...opts })); diff --git a/server/src/domain/search/dto/search.dto.ts b/server/src/domain/search/dto/search.dto.ts index b564e375b6abf..6914d67dfa9fe 100644 --- a/server/src/domain/search/dto/search.dto.ts +++ b/server/src/domain/search/dto/search.dto.ts @@ -1,6 +1,6 @@ import { AssetType } from '@app/infra/entities'; -import { Transform } from 'class-transformer'; -import { IsBoolean, IsEnum, IsInt, IsNotEmpty, IsPositive, IsString } from 'class-validator'; +import { Transform, Type } from 'class-transformer'; +import { IsBoolean, IsEnum, IsInt, IsNotEmpty, IsPositive, IsString, Min } from 'class-validator'; import { Optional, toBoolean } from '../../domain.util'; export class SearchDto { @@ -45,12 +45,15 @@ export class SearchDto { withArchived?: boolean; @IsPositive() + @Type(() => Number) @Optional() take?: number; @IsInt() + @Min(0) + @Type(() => Number) @Optional() - skip?: number; + page?: number; } export class SearchPeopleDto { diff --git a/server/src/domain/search/search.service.ts b/server/src/domain/search/search.service.ts index 5b4889b9011a7..c20eaa8bb96ec 100644 --- a/server/src/domain/search/search.service.ts +++ b/server/src/domain/search/search.service.ts @@ -81,7 +81,9 @@ export class SearchService { { text: query }, machineLearning.clip, ); - const results = await this.smartInfoRepository.searchCLIP({ userIds, embedding, withArchived }, { take: dto.take || 100 }); + const take = dto.take || 100; + const skip = dto.page ? (dto.page - 1) * take : 0; + const results = await this.smartInfoRepository.searchCLIP({ userIds, embedding }, { take, skip }); assets = results.items; break; } diff --git a/web/src/lib/components/asset-viewer/intersection-observer.svelte b/web/src/lib/components/asset-viewer/intersection-observer.svelte index f1fbc4aa206e0..df89a2ed7d6c3 100644 --- a/web/src/lib/components/asset-viewer/intersection-observer.svelte +++ b/web/src/lib/components/asset-viewer/intersection-observer.svelte @@ -9,7 +9,7 @@ export let right = 0; export let root: HTMLElement | null = null; - let intersecting = false; + export let intersecting = false; let container: HTMLDivElement; const dispatch = createEventDispatcher<{ hidden: HTMLDivElement; diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index de540b3208a97..1df6a0d9d5765 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -37,6 +37,7 @@ export let readonly = false; export let showArchiveIcon = false; export let showStackedIcon = true; + export let intersecting = false; let className = ''; export { className as class }; @@ -85,7 +86,7 @@ }; - +
onMouseEnter()} - on:mouseleave={() => onMouseLeave()} + on:mouseenter={onMouseEnter} + on:mouseleave={onMouseLeave} on:click={thumbnailClickedHandler} on:keydown={thumbnailKeyDownHandler} > diff --git a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte index 669d3ec81f04b..d88fe8c92d2ad 100644 --- a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte +++ b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte @@ -7,7 +7,7 @@ import { flip } from 'svelte/animate'; import { getThumbnailSize } from '$lib/utils/thumbnail-util'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; - import { onDestroy } from 'svelte'; + import { createEventDispatcher, onDestroy } from 'svelte'; export let assets: AssetResponseDto[]; export let selectedAssets: Set = new Set(); @@ -18,12 +18,16 @@ let selectedAsset: AssetResponseDto; let currentViewAssetIndex = 0; - + let scrolledToBottomCount = 0; let viewWidth: number; $: thumbnailSize = getThumbnailSize(assets.length, viewWidth); $: isMultiSelectionMode = selectedAssets.size > 0; + const dispatch = createEventDispatcher<{ + 'onScrollBottom': { count: number }; + }>(); + const viewAssetHandler = (event: CustomEvent) => { const { asset }: { asset: AssetResponseDto } = event.detail; @@ -88,7 +92,22 @@ {#if assets.length > 0}
- {#each assets as asset (asset.id)} + {#each assets.slice(0, -1) as asset (asset.id)} +
+ (isMultiSelectionMode ? selectAssetHandler(e) : viewAssetHandler(e))} + on:select={selectAssetHandler} + selected={selectedAssets.has(asset)} + {showArchiveIcon} + /> +
+ {/each} + + {#each assets.slice(-1) as asset (asset.id)}
(isMultiSelectionMode ? selectAssetHandler(e) : viewAssetHandler(e))} on:select={selectAssetHandler} + on:intersected selected={selectedAssets.has(asset)} {showArchiveIcon} /> diff --git a/web/src/lib/components/shared-components/search-bar/search-bar.svelte b/web/src/lib/components/shared-components/search-bar/search-bar.svelte index 8ed9d55fa97a1..4e82588f4a306 100644 --- a/web/src/lib/components/shared-components/search-bar/search-bar.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-bar.svelte @@ -32,6 +32,8 @@ const parameters = new URLSearchParams({ q: searchValue, smart: smartSearch, + take: '100', + page: '0' }); showHistory = false; diff --git a/web/src/routes/(user)/search/+page.svelte b/web/src/routes/(user)/search/+page.svelte index 42f9af0f0ff39..526ba44aac4b6 100644 --- a/web/src/routes/(user)/search/+page.svelte +++ b/web/src/routes/(user)/search/+page.svelte @@ -14,7 +14,6 @@ import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte'; import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte'; import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte'; - import type { AssetResponseDto } from '@api'; import type { PageData } from './$types'; import Icon from '$lib/components/elements/icon.svelte'; import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte'; @@ -27,6 +26,7 @@ import { preventRaceConditionSearchBar } from '$lib/stores/search.store'; import { shouldIgnoreShortcut } from '$lib/utils/shortcut'; import { mdiArrowLeft, mdiDotsVertical, mdiImageOffOutline, mdiPlus, mdiSelectAll } from '@mdi/js'; + import type { AssetResponseDto } from '@immich/sdk'; export let data: PageData; @@ -36,6 +36,7 @@ // behavior for history.back(). To prevent that we store the previous page // manually and navigate back to that. let previousRoute = AppRoute.EXPLORE as string; + let curPage = 0; $: albums = data.results?.albums.items; const onKeyboardPress = (event: KeyboardEvent) => handleKeyboardPress(event); @@ -164,7 +165,7 @@
{#if searchResultAssets && searchResultAssets.length > 0}
- + {console.log(curPage); $page.url.searchParams.set('page', String(++curPage))}} showArchiveIcon={true} />
{:else}
diff --git a/web/src/routes/(user)/search/+page.ts b/web/src/routes/(user)/search/+page.ts index b6cbac101af62..84bc9a7e612c9 100644 --- a/web/src/routes/(user)/search/+page.ts +++ b/web/src/routes/(user)/search/+page.ts @@ -1,5 +1,5 @@ import { authenticate } from '$lib/utils/auth'; -import { type SearchResponseDto, api } from '@api'; +import { type AssetResponseDto, type SearchResponseDto, api } from '@api'; import type { PageLoad } from './$types'; import { QueryParameter } from '$lib/constants'; @@ -10,8 +10,18 @@ export const load = (async (data) => { url.searchParams.get(QueryParameter.SEARCH_TERM) || url.searchParams.get(QueryParameter.QUERY) || undefined; let results: SearchResponseDto | null = null; if (term) { - const { data } = await api.searchApi.search({}, { params: url.searchParams }); - results = data; + const res = await api.searchApi.search({}, { params: data.url.searchParams }); + const assetItems: Array = (data as any).results?.assets.items; + console.log('assetItems', assetItems); + const assets = { + ...res.data.assets, + items: assetItems ? assetItems.concat(res.data.assets.items) : res.data.assets.items + }; + results = { + assets, + albums: res.data.albums + } + // results = res.data; } return { From 181c375d364ca549cf1b9e89b5a2a738f7db6cc8 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Wed, 17 Jan 2024 00:55:36 -0500 Subject: [PATCH 04/38] working infinite scroll --- mobile/openapi/doc/SearchAssetResponseDto.md | 1 + .../test/search_asset_response_dto_test.dart | 5 +++ .../response-dto/search-response.dto.ts | 1 + server/src/domain/search/search.service.ts | 14 +++++-- .../repositories/smart-info.repository.ts | 34 ++++++++++------- .../gallery-viewer/gallery-viewer.svelte | 7 +--- web/src/routes/(user)/search/+page.svelte | 38 +++++++++++++++++-- web/src/routes/(user)/search/+page.ts | 1 - 8 files changed, 74 insertions(+), 27 deletions(-) diff --git a/mobile/openapi/doc/SearchAssetResponseDto.md b/mobile/openapi/doc/SearchAssetResponseDto.md index 2fc33feb418c8..6b20ba7266c96 100644 --- a/mobile/openapi/doc/SearchAssetResponseDto.md +++ b/mobile/openapi/doc/SearchAssetResponseDto.md @@ -11,6 +11,7 @@ Name | Type | Description | Notes **count** | **int** | | **facets** | [**List**](SearchFacetResponseDto.md) | | [default to const []] **items** | [**List**](AssetResponseDto.md) | | [default to const []] +**nextPage** | **String** | | [optional] **total** | **int** | | [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/mobile/openapi/test/search_asset_response_dto_test.dart b/mobile/openapi/test/search_asset_response_dto_test.dart index 87a7a61e9ddf7..56e8276171759 100644 --- a/mobile/openapi/test/search_asset_response_dto_test.dart +++ b/mobile/openapi/test/search_asset_response_dto_test.dart @@ -31,6 +31,11 @@ void main() { // TODO }); + // String nextPage + test('to test the property `nextPage`', () async { + // TODO + }); + // int total test('to test the property `total`', () async { // TODO diff --git a/server/src/domain/search/response-dto/search-response.dto.ts b/server/src/domain/search/response-dto/search-response.dto.ts index 724cd5854b9b5..9dd65e7cc3b4d 100644 --- a/server/src/domain/search/response-dto/search-response.dto.ts +++ b/server/src/domain/search/response-dto/search-response.dto.ts @@ -29,6 +29,7 @@ class SearchAssetResponseDto { count!: number; items!: AssetResponseDto[]; facets!: SearchFacetResponseDto[]; + nextPage!: string | null; } export class SearchResponseDto { diff --git a/server/src/domain/search/search.service.ts b/server/src/domain/search/search.service.ts index c20eaa8bb96ec..e26be3344ace0 100644 --- a/server/src/domain/search/search.service.ts +++ b/server/src/domain/search/search.service.ts @@ -73,7 +73,10 @@ export class SearchService { const withArchived = dto.withArchived || false; let assets: AssetEntity[] = []; - + const page = dto.page ?? 0; + const take = dto.take || 100; + const skip = page * take; + let nextPage: string | null = null; switch (strategy) { case SearchStrategy.SMART: { const embedding = await this.machineLearning.encodeText( @@ -81,9 +84,13 @@ export class SearchService { { text: query }, machineLearning.clip, ); - const take = dto.take || 100; - const skip = dto.page ? (dto.page - 1) * take : 0; + + this.logger.log(`Take: ${take}, skip: ${skip}`); const results = await this.smartInfoRepository.searchCLIP({ userIds, embedding }, { take, skip }); + // this.logger.log(JSON.stringify(results, null, 2)); + if (results.hasNextPage) { + nextPage = (page + 1).toString(); + } assets = results.items; break; } @@ -107,6 +114,7 @@ export class SearchService { count: assets.length, items: assets.map((asset) => mapAsset(asset)), facets: [], + nextPage, }, }; } diff --git a/server/src/infra/repositories/smart-info.repository.ts b/server/src/infra/repositories/smart-info.repository.ts index 8f05bdf5f9136..53b2b70a255f0 100644 --- a/server/src/infra/repositories/smart-info.repository.ts +++ b/server/src/infra/repositories/smart-info.repository.ts @@ -54,23 +54,29 @@ export class SmartInfoRepository implements ISmartInfoRepository { @GenerateSql({ params: [{ userIds: [DummyValue.UUID], embedding: Array.from({ length: 512 }, Math.random), numResults: 100 }], }) - async searchCLIP({ userIds, embedding }: EmbeddingSearch, pagination: PaginationOptions): Paginated { - const query = this.assetRepository - .createQueryBuilder('a') - .innerJoin('a.smartSearch', 's') - .where('a.ownerId IN (:...userIds )') - .andWhere('a.isVisible = true') - .andWhere('a.fileCreatedAt < NOW()') - .leftJoinAndSelect('a.exifInfo', 'e') - .orderBy('s.embedding <=> :embedding') - .setParameters({ userIds, embedding: asVector(embedding) }); - - if (!withArchived) { + async searchCLIP({ userIds, embedding, withArchived }: EmbeddingSearch, pagination: PaginationOptions): Paginated { + let results: PaginationResult = { items: [], hasNextPage: false }; + + await this.assetRepository.manager.transaction(async (manager) => { + const query = manager + .createQueryBuilder(AssetEntity, 'a') + .innerJoin('a.smartSearch', 's') + .where('a.ownerId IN (:...userIds )') + .andWhere('a.isVisible = true') + .andWhere('a.fileCreatedAt < NOW()') + .leftJoinAndSelect('a.exifInfo', 'e') + .orderBy('s.embedding <=> :embedding') + .setParameters({ userIds, embedding: asVector(embedding) }); + + if (!withArchived) { query.andWhere('a.isArchived = false'); } - await manager.query(this.getRuntimeConfig(numResults)); - return paginatedBuilder(query, pagination);; + await manager.query(this.getRuntimeConfig(pagination.take)); + results = await paginatedBuilder(query, pagination); + }); + + return results; } @GenerateSql({ diff --git a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte index d88fe8c92d2ad..647a39908f1f8 100644 --- a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte +++ b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte @@ -7,7 +7,7 @@ import { flip } from 'svelte/animate'; import { getThumbnailSize } from '$lib/utils/thumbnail-util'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; - import { createEventDispatcher, onDestroy } from 'svelte'; + import { onDestroy } from 'svelte'; export let assets: AssetResponseDto[]; export let selectedAssets: Set = new Set(); @@ -18,16 +18,11 @@ let selectedAsset: AssetResponseDto; let currentViewAssetIndex = 0; - let scrolledToBottomCount = 0; let viewWidth: number; $: thumbnailSize = getThumbnailSize(assets.length, viewWidth); $: isMultiSelectionMode = selectedAssets.size > 0; - const dispatch = createEventDispatcher<{ - 'onScrollBottom': { count: number }; - }>(); - const viewAssetHandler = (event: CustomEvent) => { const { asset }: { asset: AssetResponseDto } = event.detail; diff --git a/web/src/routes/(user)/search/+page.svelte b/web/src/routes/(user)/search/+page.svelte index 526ba44aac4b6..e60853c49b16c 100644 --- a/web/src/routes/(user)/search/+page.svelte +++ b/web/src/routes/(user)/search/+page.svelte @@ -26,7 +26,9 @@ import { preventRaceConditionSearchBar } from '$lib/stores/search.store'; import { shouldIgnoreShortcut } from '$lib/utils/shortcut'; import { mdiArrowLeft, mdiDotsVertical, mdiImageOffOutline, mdiPlus, mdiSelectAll } from '@mdi/js'; - import type { AssetResponseDto } from '@immich/sdk'; + import type { AssetResponseDto, SearchResponseDto } from '@immich/sdk'; + import { authenticate } from '$lib/utils/auth'; + import { api } from '../../../api/api'; export let data: PageData; @@ -36,7 +38,7 @@ // behavior for history.back(). To prevent that we store the previous page // manually and navigate back to that. let previousRoute = AppRoute.EXPLORE as string; - let curPage = 0; + $: curPage = data.results?.assets.nextPage; $: albums = data.results?.albums.items; const onKeyboardPress = (event: KeyboardEvent) => handleKeyboardPress(event); @@ -108,6 +110,36 @@ const handleSelectAll = () => { selectedAssets = new Set(searchResultAssets); }; + + export const loadNextPage = (async () => { + console.log('loadPage', curPage, term) + if (curPage == null || !term) { + return; + } + + await authenticate(); + let results: SearchResponseDto | null = null; + $page.url.searchParams.set('page', curPage.toString()); + console.log('searchParams', $page.url.searchParams.toString()); + const res = await api.searchApi.search({}, { params: $page.url.searchParams }); + console.log('searchResultAssets', searchResultAssets); + if (searchResultAssets) { + searchResultAssets.push(...res.data.assets.items) + } else { + searchResultAssets = res.data.assets.items; + } + + const assets = { + ...res.data.assets, + items: searchResultAssets + }; + results = { + assets, + albums: res.data.albums + } + + data.results = results; + });
@@ -165,7 +197,7 @@
{#if searchResultAssets && searchResultAssets.length > 0}
- {console.log(curPage); $page.url.searchParams.set('page', String(++curPage))}} showArchiveIcon={true} /> +
{:else}
diff --git a/web/src/routes/(user)/search/+page.ts b/web/src/routes/(user)/search/+page.ts index 84bc9a7e612c9..0b7ffc5bb1a1b 100644 --- a/web/src/routes/(user)/search/+page.ts +++ b/web/src/routes/(user)/search/+page.ts @@ -21,7 +21,6 @@ export const load = (async (data) => { assets, albums: res.data.albums } - // results = res.data; } return { From 5b9c235089d9902728b8beeba1aae81b79d96e02 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Wed, 17 Jan 2024 00:55:50 -0500 Subject: [PATCH 05/38] update api --- mobile/openapi/doc/SearchAssetResponseDto.md | 2 +- .../lib/model/search_asset_response_dto.dart | 14 +++++++++++++- open-api/immich-openapi-specs.json | 5 +++++ open-api/typescript-sdk/axios-client/api.ts | 6 ++++++ open-api/typescript-sdk/fetch-client.ts | 1 + 5 files changed, 26 insertions(+), 2 deletions(-) diff --git a/mobile/openapi/doc/SearchAssetResponseDto.md b/mobile/openapi/doc/SearchAssetResponseDto.md index 6b20ba7266c96..5b55a559dc0ae 100644 --- a/mobile/openapi/doc/SearchAssetResponseDto.md +++ b/mobile/openapi/doc/SearchAssetResponseDto.md @@ -11,7 +11,7 @@ Name | Type | Description | Notes **count** | **int** | | **facets** | [**List**](SearchFacetResponseDto.md) | | [default to const []] **items** | [**List**](AssetResponseDto.md) | | [default to const []] -**nextPage** | **String** | | [optional] +**nextPage** | **String** | | **total** | **int** | | [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/mobile/openapi/lib/model/search_asset_response_dto.dart b/mobile/openapi/lib/model/search_asset_response_dto.dart index 98291307d421b..abdbc5d4e35d6 100644 --- a/mobile/openapi/lib/model/search_asset_response_dto.dart +++ b/mobile/openapi/lib/model/search_asset_response_dto.dart @@ -16,6 +16,7 @@ class SearchAssetResponseDto { required this.count, this.facets = const [], this.items = const [], + required this.nextPage, required this.total, }); @@ -25,6 +26,8 @@ class SearchAssetResponseDto { List items; + String? nextPage; + int total; @override @@ -32,6 +35,7 @@ class SearchAssetResponseDto { other.count == count && _deepEquality.equals(other.facets, facets) && _deepEquality.equals(other.items, items) && + other.nextPage == nextPage && other.total == total; @override @@ -40,16 +44,22 @@ class SearchAssetResponseDto { (count.hashCode) + (facets.hashCode) + (items.hashCode) + + (nextPage == null ? 0 : nextPage!.hashCode) + (total.hashCode); @override - String toString() => 'SearchAssetResponseDto[count=$count, facets=$facets, items=$items, total=$total]'; + String toString() => 'SearchAssetResponseDto[count=$count, facets=$facets, items=$items, nextPage=$nextPage, total=$total]'; Map toJson() { final json = {}; json[r'count'] = this.count; json[r'facets'] = this.facets; json[r'items'] = this.items; + if (this.nextPage != null) { + json[r'nextPage'] = this.nextPage; + } else { + // json[r'nextPage'] = null; + } json[r'total'] = this.total; return json; } @@ -65,6 +75,7 @@ class SearchAssetResponseDto { count: mapValueOfType(json, r'count')!, facets: SearchFacetResponseDto.listFromJson(json[r'facets']), items: AssetResponseDto.listFromJson(json[r'items']), + nextPage: mapValueOfType(json, r'nextPage'), total: mapValueOfType(json, r'total')!, ); } @@ -116,6 +127,7 @@ class SearchAssetResponseDto { 'count', 'facets', 'items', + 'nextPage', 'total', }; } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 1f87850ca123d..3113c599b4a08 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -8474,6 +8474,10 @@ }, "type": "array" }, + "nextPage": { + "nullable": true, + "type": "string" + }, "total": { "type": "integer" } @@ -8482,6 +8486,7 @@ "count", "facets", "items", + "nextPage", "total" ], "type": "object" diff --git a/open-api/typescript-sdk/axios-client/api.ts b/open-api/typescript-sdk/axios-client/api.ts index 7b39da761b62c..0823d30be96cb 100644 --- a/open-api/typescript-sdk/axios-client/api.ts +++ b/open-api/typescript-sdk/axios-client/api.ts @@ -2887,6 +2887,12 @@ export interface SearchAssetResponseDto { * @memberof SearchAssetResponseDto */ 'items': Array; + /** + * + * @type {string} + * @memberof SearchAssetResponseDto + */ + 'nextPage': string | null; /** * * @type {number} diff --git a/open-api/typescript-sdk/fetch-client.ts b/open-api/typescript-sdk/fetch-client.ts index b0609418e7893..75099310a4139 100644 --- a/open-api/typescript-sdk/fetch-client.ts +++ b/open-api/typescript-sdk/fetch-client.ts @@ -588,6 +588,7 @@ export type SearchAssetResponseDto = { count: number; facets: SearchFacetResponseDto[]; items: AssetResponseDto[]; + nextPage: string | null; total: number; }; export type SearchResponseDto = { From f32e66286b36cbc8619fd93a083e4f8e68f4b63e Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Wed, 17 Jan 2024 01:05:07 -0500 Subject: [PATCH 06/38] formatting --- server/src/domain/search/search.service.ts | 2 +- .../search-bar/search-bar.svelte | 1 - web/src/routes/(user)/search/+page.svelte | 49 ++++++++++--------- web/src/routes/(user)/search/+page.ts | 6 +-- 4 files changed, 31 insertions(+), 27 deletions(-) diff --git a/server/src/domain/search/search.service.ts b/server/src/domain/search/search.service.ts index e26be3344ace0..6ad660651eacb 100644 --- a/server/src/domain/search/search.service.ts +++ b/server/src/domain/search/search.service.ts @@ -84,7 +84,7 @@ export class SearchService { { text: query }, machineLearning.clip, ); - + this.logger.log(`Take: ${take}, skip: ${skip}`); const results = await this.smartInfoRepository.searchCLIP({ userIds, embedding }, { take, skip }); // this.logger.log(JSON.stringify(results, null, 2)); diff --git a/web/src/lib/components/shared-components/search-bar/search-bar.svelte b/web/src/lib/components/shared-components/search-bar/search-bar.svelte index 4e82588f4a306..8dfba184ad205 100644 --- a/web/src/lib/components/shared-components/search-bar/search-bar.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-bar.svelte @@ -33,7 +33,6 @@ q: searchValue, smart: smartSearch, take: '100', - page: '0' }); showHistory = false; diff --git a/web/src/routes/(user)/search/+page.svelte b/web/src/routes/(user)/search/+page.svelte index e60853c49b16c..fb7c3f094c190 100644 --- a/web/src/routes/(user)/search/+page.svelte +++ b/web/src/routes/(user)/search/+page.svelte @@ -111,35 +111,35 @@ selectedAssets = new Set(searchResultAssets); }; - export const loadNextPage = (async () => { - console.log('loadPage', curPage, term) + export const loadNextPage = async () => { + console.log('loadPage', curPage, term); if (curPage == null || !term) { - return; + return; } await authenticate(); let results: SearchResponseDto | null = null; - $page.url.searchParams.set('page', curPage.toString()); - console.log('searchParams', $page.url.searchParams.toString()); - const res = await api.searchApi.search({}, { params: $page.url.searchParams }); - console.log('searchResultAssets', searchResultAssets); - if (searchResultAssets) { - searchResultAssets.push(...res.data.assets.items) - } else { - searchResultAssets = res.data.assets.items; - } + $page.url.searchParams.set('page', curPage.toString()); + console.log('searchParams', $page.url.searchParams.toString()); + const res = await api.searchApi.search({}, { params: $page.url.searchParams }); + console.log('searchResultAssets', searchResultAssets); + if (searchResultAssets) { + searchResultAssets.push(...res.data.assets.items); + } else { + searchResultAssets = res.data.assets.items; + } - const assets = { - ...res.data.assets, - items: searchResultAssets - }; - results = { - assets, - albums: res.data.albums - } + const assets = { + ...res.data.assets, + items: searchResultAssets, + }; + results = { + assets, + albums: res.data.albums, + }; data.results = results; - }); + };
@@ -197,7 +197,12 @@
{#if searchResultAssets && searchResultAssets.length > 0}
- +
{:else}
diff --git a/web/src/routes/(user)/search/+page.ts b/web/src/routes/(user)/search/+page.ts index 0b7ffc5bb1a1b..271f8103693a5 100644 --- a/web/src/routes/(user)/search/+page.ts +++ b/web/src/routes/(user)/search/+page.ts @@ -15,12 +15,12 @@ export const load = (async (data) => { console.log('assetItems', assetItems); const assets = { ...res.data.assets, - items: assetItems ? assetItems.concat(res.data.assets.items) : res.data.assets.items + items: assetItems ? assetItems.concat(res.data.assets.items) : res.data.assets.items, }; results = { assets, - albums: res.data.albums - } + albums: res.data.albums, + }; } return { From 29f21668531892848b2b71d0fb12168409810ff1 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Fri, 19 Jan 2024 17:25:45 -0500 Subject: [PATCH 07/38] fix rebase --- server/src/domain/search/search.service.ts | 16 +++++++++------- .../infra/repositories/smart-info.repository.ts | 15 +++++++++------ 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/server/src/domain/search/search.service.ts b/server/src/domain/search/search.service.ts index 6ad660651eacb..00a61b9f149aa 100644 --- a/server/src/domain/search/search.service.ts +++ b/server/src/domain/search/search.service.ts @@ -71,12 +71,12 @@ export class SearchService { const userIds = await this.getUserIdsToSearch(auth); const withArchived = dto.withArchived || false; - - let assets: AssetEntity[] = []; const page = dto.page ?? 0; const take = dto.take || 100; const skip = page * take; + let nextPage: string | null = null; + let assets: AssetEntity[] = []; switch (strategy) { case SearchStrategy.SMART: { const embedding = await this.machineLearning.encodeText( @@ -85,13 +85,15 @@ export class SearchService { machineLearning.clip, ); - this.logger.log(`Take: ${take}, skip: ${skip}`); - const results = await this.smartInfoRepository.searchCLIP({ userIds, embedding }, { take, skip }); - // this.logger.log(JSON.stringify(results, null, 2)); - if (results.hasNextPage) { + this.logger.debug(`Take: ${take}, skip: ${skip}`); + const { hasNextPage, items } = await this.smartInfoRepository.searchCLIP( + { userIds, embedding, withArchived }, + { take, skip }, + ); + if (hasNextPage) { nextPage = (page + 1).toString(); } - assets = results.items; + assets = items; break; } case SearchStrategy.TEXT: { diff --git a/server/src/infra/repositories/smart-info.repository.ts b/server/src/infra/repositories/smart-info.repository.ts index 53b2b70a255f0..b9afe710bffa2 100644 --- a/server/src/infra/repositories/smart-info.repository.ts +++ b/server/src/infra/repositories/smart-info.repository.ts @@ -54,7 +54,10 @@ export class SmartInfoRepository implements ISmartInfoRepository { @GenerateSql({ params: [{ userIds: [DummyValue.UUID], embedding: Array.from({ length: 512 }, Math.random), numResults: 100 }], }) - async searchCLIP({ userIds, embedding, withArchived }: EmbeddingSearch, pagination: PaginationOptions): Paginated { + async searchCLIP( + { userIds, embedding, withArchived }: EmbeddingSearch, + pagination: PaginationOptions, + ): Paginated { let results: PaginationResult = { items: [], hasNextPage: false }; await this.assetRepository.manager.transaction(async (manager) => { @@ -63,17 +66,17 @@ export class SmartInfoRepository implements ISmartInfoRepository { .innerJoin('a.smartSearch', 's') .where('a.ownerId IN (:...userIds )') .andWhere('a.isVisible = true') - .andWhere('a.fileCreatedAt < NOW()') + .andWhere('a.fileCreatedAt < NOW()') .leftJoinAndSelect('a.exifInfo', 'e') .orderBy('s.embedding <=> :embedding') .setParameters({ userIds, embedding: asVector(embedding) }); if (!withArchived) { - query.andWhere('a.isArchived = false'); - } + query.andWhere('a.isArchived = false'); + } - await manager.query(this.getRuntimeConfig(pagination.take)); - results = await paginatedBuilder(query, pagination); + await manager.query(this.getRuntimeConfig(pagination.take)); + results = await paginatedBuilder(query, pagination); }); return results; From 1a8c0509a965d9cc2d3da11ab5e454b9d8af9d38 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Sat, 20 Jan 2024 00:43:35 -0500 Subject: [PATCH 08/38] search refactor --- server/e2e/api/specs/search.e2e-spec.ts | 6 +- server/src/domain/asset/asset.service.ts | 165 ++++++++++++++-- server/src/domain/domain.util.ts | 11 ++ .../src/domain/person/person.service.spec.ts | 4 +- server/src/domain/person/person.service.ts | 13 +- .../domain/repositories/asset.repository.ts | 61 +----- server/src/domain/repositories/index.ts | 1 - .../domain/repositories/search.repository.ts | 121 +++++++++++- .../repositories/smart-info.repository.ts | 30 --- .../src/domain/search/search.service.spec.ts | 4 +- server/src/domain/search/search.service.ts | 17 +- .../smart-info/smart-info.service.spec.ts | 4 +- .../domain/smart-info/smart-info.service.ts | 4 +- .../system-config.service.spec.ts | 4 +- .../system-config/system-config.service.ts | 4 +- server/src/infra/infra.module.ts | 6 +- server/src/infra/infra.utils.ts | 121 ++++++++++-- .../infra/repositories/asset.repository.ts | 178 +----------------- server/src/infra/repositories/index.ts | 2 +- ...nfo.repository.ts => search.repository.ts} | 129 +++++++------ server/src/infra/sql-generator/index.ts | 4 +- server/test/repositories/index.ts | 2 +- .../repositories/search.repository.mock.ts | 11 ++ .../smart-info.repository.mock.ts | 10 - 24 files changed, 511 insertions(+), 401 deletions(-) delete mode 100644 server/src/domain/repositories/smart-info.repository.ts rename server/src/infra/repositories/{smart-info.repository.ts => search.repository.ts} (65%) create mode 100644 server/test/repositories/search.repository.mock.ts delete mode 100644 server/test/repositories/smart-info.repository.mock.ts diff --git a/server/e2e/api/specs/search.e2e-spec.ts b/server/e2e/api/specs/search.e2e-spec.ts index 8d17ee9304d29..74988396d73fd 100644 --- a/server/e2e/api/specs/search.e2e-spec.ts +++ b/server/e2e/api/specs/search.e2e-spec.ts @@ -1,7 +1,7 @@ import { AssetResponseDto, IAssetRepository, - ISmartInfoRepository, + ISearchRepository, LibraryResponseDto, LoginResponseDto, mapAsset, @@ -20,14 +20,14 @@ describe(`${SearchController.name}`, () => { let accessToken: string; let libraries: LibraryResponseDto[]; let assetRepository: IAssetRepository; - let smartInfoRepository: ISmartInfoRepository; + let smartInfoRepository: ISearchRepository; let asset1: AssetResponseDto; beforeAll(async () => { app = await testApp.create(); server = app.getHttpServer(); assetRepository = app.get(IAssetRepository); - smartInfoRepository = app.get(ISmartInfoRepository); + smartInfoRepository = app.get(ISearchRepository); }); afterAll(async () => { diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts index e73858c31155b..0406d351e35cb 100644 --- a/server/src/domain/asset/asset.service.ts +++ b/server/src/domain/asset/asset.service.ts @@ -18,6 +18,7 @@ import { ICommunicationRepository, IJobRepository, IPartnerRepository, + ISearchRepository, IStorageRepository, ISystemConfigRepository, IUserRepository, @@ -87,6 +88,7 @@ export class AssetService { @Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository, @Inject(IPartnerRepository) private partnerRepository: IPartnerRepository, @Inject(IAssetStackRepository) private assetStackRepository: IAssetStackRepository, + @Inject(ISearchRepository) private searchRepository: ISearchRepository, ) { this.access = AccessCore.create(accessRepository); this.configCore = SystemConfigCore.create(configRepository); @@ -101,17 +103,18 @@ export class AssetService { } const enumToOrder = { [AssetOrder.ASC]: 'ASC', [AssetOrder.DESC]: 'DESC' } as const; - const order = dto.order ? enumToOrder[dto.order] : undefined; - - return this.assetRepository - .search({ - ...dto, - order, - checksum, - ownerId: auth.user.id, - }) - .then((assets) => - assets.map((asset) => + const order = dto.order ? { direction: enumToOrder[dto.order] } : undefined; + + return this.searchRepository + .searchAssets( + { page: 0, size: 250 }, + { + id: { checksum, ownerId: auth.user.id }, + order, + }, + ) + .then(({ items }) => + items.map((asset) => mapAsset(asset, { stripMetadata: false, withStack: true, @@ -275,6 +278,111 @@ export class AssetService { return { ...options, userIds }; } + async downloadFile(auth: AuthDto, id: string): Promise { + await this.access.requirePermission(auth, Permission.ASSET_DOWNLOAD, id); + + const [asset] = await this.assetRepository.getByIds([id]); + if (!asset) { + throw new BadRequestException('Asset not found'); + } + + if (asset.isOffline) { + throw new BadRequestException('Asset is offline'); + } + + return new ImmichFileResponse({ + path: asset.originalPath, + contentType: mimeTypes.lookup(asset.originalPath), + cacheControl: CacheControl.NONE, + }); + } + + async getDownloadInfo(auth: AuthDto, dto: DownloadInfoDto): Promise { + const targetSize = dto.archiveSize || HumanReadableSize.GiB * 4; + const archives: DownloadArchiveInfo[] = []; + let archive: DownloadArchiveInfo = { size: 0, assetIds: [] }; + + const assetPagination = await this.getDownloadAssets(auth, dto); + for await (const assets of assetPagination) { + // motion part of live photos + const motionIds = assets.map((asset) => asset.livePhotoVideoId).filter((id): id is string => !!id); + if (motionIds.length > 0) { + assets.push(...(await this.assetRepository.getByIds(motionIds))); + } + + for (const asset of assets) { + archive.size += Number(asset.exifInfo?.fileSizeInByte || 0); + archive.assetIds.push(asset.id); + + if (archive.size > targetSize) { + archives.push(archive); + archive = { size: 0, assetIds: [] }; + } + } + + if (archive.assetIds.length > 0) { + archives.push(archive); + } + } + + return { + totalSize: archives.reduce((total, item) => (total += item.size), 0), + archives, + }; + } + + async downloadArchive(auth: AuthDto, dto: AssetIdsDto): Promise { + await this.access.requirePermission(auth, Permission.ASSET_DOWNLOAD, dto.assetIds); + + const zip = this.storageRepository.createZipStream(); + const assets = await this.assetRepository.getByIds(dto.assetIds); + const paths: Record = {}; + + for (const { originalPath, originalFileName } of assets) { + const ext = extname(originalPath); + let filename = `${originalFileName}${ext}`; + const count = paths[filename] || 0; + paths[filename] = count + 1; + if (count !== 0) { + filename = `${originalFileName}+${count}${ext}`; + } + + zip.addFile(originalPath, filename); + } + + void zip.finalize(); + + return { stream: zip.stream }; + } + + private async getDownloadAssets(auth: AuthDto, dto: DownloadInfoDto): Promise> { + const PAGINATION_SIZE = 2500; + + if (dto.assetIds) { + const assetIds = dto.assetIds; + await this.access.requirePermission(auth, Permission.ASSET_DOWNLOAD, assetIds); + const assets = await this.assetRepository.getByIds(assetIds); + return (async function* () { + yield assets; + })(); + } + + if (dto.albumId) { + const albumId = dto.albumId; + await this.access.requirePermission(auth, Permission.ALBUM_DOWNLOAD, albumId); + return usePagination(PAGINATION_SIZE, (pagination) => this.assetRepository.getByAlbumId(pagination, albumId)); + } + + if (dto.userId) { + const userId = dto.userId; + await this.access.requirePermission(auth, Permission.TIMELINE_DOWNLOAD, userId); + return usePagination(PAGINATION_SIZE, (pagination) => + this.assetRepository.getByUserId(pagination, userId, { status: { isVisible: true } }), + ); + } + + throw new BadRequestException('assetIds, albumId, or userId is required'); + } async getStatistics(auth: AuthDto, dto: AssetStatsDto) { const stats = await this.assetRepository.getStatistics(auth.user.id, dto); @@ -413,7 +521,7 @@ export class AssetService { .minus(Duration.fromObject({ days: trashedDays })) .toJSDate(); const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => - this.assetRepository.getAll(pagination, { trashedBefore }), + this.assetRepository.getAll(pagination, { date: { trashedBefore } }), ); for await (const assets of assetPagination) { @@ -494,6 +602,39 @@ export class AssetService { } } + async handleTrashAction(auth: AuthDto, action: TrashAction): Promise { + const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => + this.assetRepository.getByUserId(pagination, auth.user.id, { + date: { trashedBefore: DateTime.now().toJSDate() }, + }), + ); + + if (action == TrashAction.RESTORE_ALL) { + for await (const assets of assetPagination) { + const ids = assets.map((a) => a.id); + await this.assetRepository.restoreAll(ids); + this.communicationRepository.send(ClientEvent.ASSET_RESTORE, auth.user.id, ids); + } + return; + } + + if (action == TrashAction.EMPTY_ALL) { + for await (const assets of assetPagination) { + await this.jobRepository.queueAll( + assets.map((asset) => ({ name: JobName.ASSET_DELETION, data: { id: asset.id } })), + ); + } + return; + } + } + + async restoreAll(auth: AuthDto, dto: BulkIdsDto): Promise { + const { ids } = dto; + await this.access.requirePermission(auth, Permission.ASSET_RESTORE, ids); + await this.assetRepository.restoreAll(ids); + this.communicationRepository.send(ClientEvent.ASSET_RESTORE, auth.user.id, ids); + } + async updateStackParent(auth: AuthDto, dto: UpdateStackParentDto): Promise { const { oldParentId, newParentId } = dto; await this.access.requirePermission(auth, Permission.ASSET_READ, oldParentId); diff --git a/server/src/domain/domain.util.ts b/server/src/domain/domain.util.ts index 43efbce255453..5fdfd7ec5ba9e 100644 --- a/server/src/domain/domain.util.ts +++ b/server/src/domain/domain.util.ts @@ -137,6 +137,17 @@ export interface PaginationOptions { skip?: number; } +export enum PaginationMode { + LIMIT_OFFSET = 'limit-offset', + SKIP_TAKE = 'skip-take', +} + +export interface PaginatedBuilderOptions { + take: number; + skip?: number; + mode?: PaginationMode; +} + export interface PaginationResult { items: T[]; hasNextPage: boolean; diff --git a/server/src/domain/person/person.service.spec.ts b/server/src/domain/person/person.service.spec.ts index 9d55abc8e655b..63066797e5368 100644 --- a/server/src/domain/person/person.service.spec.ts +++ b/server/src/domain/person/person.service.spec.ts @@ -31,7 +31,7 @@ import { IMediaRepository, IMoveRepository, IPersonRepository, - ISmartInfoRepository, + ISearchRepository, IStorageRepository, ISystemConfigRepository, WithoutProperty, @@ -76,7 +76,7 @@ describe(PersonService.name, () => { let moveMock: jest.Mocked; let personMock: jest.Mocked; let storageMock: jest.Mocked; - let smartInfoMock: jest.Mocked; + let smartInfoMock: jest.Mocked; let cryptoMock: jest.Mocked; let sut: PersonService; diff --git a/server/src/domain/person/person.service.ts b/server/src/domain/person/person.service.ts index 63fc350002103..ca80065bb310e 100644 --- a/server/src/domain/person/person.service.ts +++ b/server/src/domain/person/person.service.ts @@ -20,7 +20,7 @@ import { IMediaRepository, IMoveRepository, IPersonRepository, - ISmartInfoRepository, + ISearchRepository, IStorageRepository, ISystemConfigRepository, JobItem, @@ -61,7 +61,7 @@ export class PersonService { @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, - @Inject(ISmartInfoRepository) private smartInfoRepository: ISmartInfoRepository, + @Inject(ISearchRepository) private smartInfoRepository: ISearchRepository, @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, ) { this.access = AccessCore.create(accessRepository); @@ -286,13 +286,8 @@ export class PersonService { const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { return force ? this.assetRepository.getAll(pagination, { - order: 'DESC', - withFaces: true, - withPeople: false, - withSmartInfo: false, - withSmartSearch: false, - withExif: false, - withStacked: false, + order: { direction: 'DESC' }, + relation: { withFaces: true }, }) : this.assetRepository.getWithout(pagination, WithoutProperty.FACES); }); diff --git a/server/src/domain/repositories/asset.repository.ts b/server/src/domain/repositories/asset.repository.ts index 00a74f8293ad6..4ff58cdb1be04 100644 --- a/server/src/domain/repositories/asset.repository.ts +++ b/server/src/domain/repositories/asset.repository.ts @@ -1,4 +1,4 @@ -import { SearchExploreItem } from '@app/domain'; +import { AssetSearchOptions, SearchExploreItem } from '@app/domain'; import { AssetEntity, AssetJobStatusEntity, AssetType, ExifEntity } from '@app/infra/entities'; import { FindOptionsRelations, FindOptionsSelect } from 'typeorm'; import { Paginated, PaginationOptions } from '../domain.util'; @@ -11,64 +11,6 @@ export interface AssetStatsOptions { isTrashed?: boolean; } -export interface AssetSearchOptions { - id?: string; - libraryId?: string; - deviceAssetId?: string; - deviceId?: string; - ownerId?: string; - type?: AssetType; - checksum?: Buffer; - - isArchived?: boolean; - isEncoded?: boolean; - isExternal?: boolean; - isFavorite?: boolean; - isMotion?: boolean; - isOffline?: boolean; - isReadOnly?: boolean; - isVisible?: boolean; - - withDeleted?: boolean; - withStacked?: boolean; - withExif?: boolean; - withPeople?: boolean; - withSmartInfo?: boolean; - withSmartSearch?: boolean; - withFaces?: boolean; - - createdBefore?: Date; - createdAfter?: Date; - updatedBefore?: Date; - updatedAfter?: Date; - trashedBefore?: Date; - trashedAfter?: Date; - takenBefore?: Date; - takenAfter?: Date; - - originalFileName?: string; - originalPath?: string; - resizePath?: string; - webpPath?: string; - encodedVideoPath?: string; - - city?: string; - state?: string; - country?: string; - make?: string; - model?: string; - lensModel?: string; - - /** defaults to 'DESC' */ - order?: 'ASC' | 'DESC'; - - /** defaults to 1 */ - page?: number; - - /** defaults to 250 */ - size?: number; -} - export interface LivePhotoSearchOptions { ownerId: string; livePhotoCID: string; @@ -204,7 +146,6 @@ export interface IAssetRepository { getTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise; upsertExif(exif: Partial): Promise; upsertJobStatus(jobStatus: Partial): Promise; - search(options: AssetSearchOptions): Promise; getAssetIdByCity(userId: string, options: AssetExploreFieldOptions): Promise>; getAssetIdByTag(userId: string, options: AssetExploreFieldOptions): Promise>; searchMetadata(query: string, userIds: string[], options: MetadataSearchOptions): Promise; diff --git a/server/src/domain/repositories/index.ts b/server/src/domain/repositories/index.ts index 48b1d7e8e289c..636abd2bea8b5 100644 --- a/server/src/domain/repositories/index.ts +++ b/server/src/domain/repositories/index.ts @@ -19,7 +19,6 @@ export * from './person.repository'; export * from './search.repository'; export * from './server-info.repository'; export * from './shared-link.repository'; -export * from './smart-info.repository'; export * from './storage.repository'; export * from './system-config.repository'; export * from './system-metadata.repository'; diff --git a/server/src/domain/repositories/search.repository.ts b/server/src/domain/repositories/search.repository.ts index 5c3497c8ef61d..2de961a33e388 100644 --- a/server/src/domain/repositories/search.repository.ts +++ b/server/src/domain/repositories/search.repository.ts @@ -1,4 +1,7 @@ -import { AssetType } from '@app/infra/entities'; +import { AssetEntity, AssetFaceEntity, AssetType, SmartInfoEntity } from '@app/infra/entities'; +import { Paginated } from '../domain.util'; + +export const ISearchRepository = 'ISearchRepository'; export enum SearchStrategy { SMART = 'SMART', @@ -54,3 +57,119 @@ export interface SearchExploreItem { fieldName: string; items: SearchExploreItemSet; } + +export type Embedding = number[]; + +export interface SearchIDOptions { + checksum?: Buffer; + deviceAssetId?: string; + deviceId?: string; + id?: string; + libraryId?: string; + ownerId?: string; +} + +export interface SearchStatusOptions { + isArchived?: boolean; + isEncoded?: boolean; + isExternal?: boolean; + isFavorite?: boolean; + isMotion?: boolean; + isOffline?: boolean; + isReadOnly?: boolean; + isVisible?: boolean; + type?: AssetType; + withArchived?: boolean; + withDeleted?: boolean; +} + +export interface SearchOneToOneRelationOptions { + withExif?: boolean; + withSmartInfo?: boolean; +} + +export interface SearchRelationOptions extends SearchOneToOneRelationOptions { + withFaces?: boolean; + withPeople?: boolean; + withStacked?: boolean; +} + +export interface SearchDateOptions { + createdBefore?: Date; + createdAfter?: Date; + takenBefore?: Date; + takenAfter?: Date; + trashedBefore?: Date; + trashedAfter?: Date; + updatedBefore?: Date; + updatedAfter?: Date; +} + +export interface SearchPathOptions { + encodedVideoPath?: string; + originalFileName?: string; + originalPath?: string; + resizePath?: string; + webpPath?: string; +} + +export interface SearchExifOptions { + city?: string; + country?: string; + lensModel?: string; + make?: string; + model?: string; + state?: string; +} + +export interface SearchEmbeddingOptions { + embedding: Embedding; + userIds: string[]; +} + +export interface SearchOrderOptions { + direction: 'ASC' | 'DESC'; +} + +export interface SearchPaginationOptions { + page: number; + size: number; +} + +export interface AssetSearchOptions { + date?: SearchDateOptions; + id?: SearchIDOptions; + exif?: SearchExifOptions; + order?: SearchOrderOptions; + path?: SearchPathOptions; + relation?: SearchRelationOptions; + status?: SearchStatusOptions; +} + +export type AssetSearchBuilderOptions = Omit; + +export interface SmartSearchOptions extends SearchEmbeddingOptions { + date?: SearchDateOptions; + exif?: SearchExifOptions; + relation?: SearchRelationOptions; + status?: SearchStatusOptions; +} + +export interface FaceEmbeddingSearch extends SearchEmbeddingOptions { + hasPerson?: boolean; + numResults?: number; + maxDistance?: number; +} + +export interface FaceSearchResult { + distance: number; + face: AssetFaceEntity; +} + +export interface ISearchRepository { + init(modelName: string): Promise; + searchAssets(pagination: SearchPaginationOptions, options: AssetSearchOptions): Paginated; + searchCLIP(pagination: SearchPaginationOptions, options: SmartSearchOptions): Paginated; + searchFaces(search: FaceEmbeddingSearch): Promise; + upsert(smartInfo: Partial, embedding?: Embedding): Promise; +} diff --git a/server/src/domain/repositories/smart-info.repository.ts b/server/src/domain/repositories/smart-info.repository.ts deleted file mode 100644 index 7ce1574e9c994..0000000000000 --- a/server/src/domain/repositories/smart-info.repository.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { AssetEntity, AssetFaceEntity, SmartInfoEntity } from '@app/infra/entities'; -import { Paginated, PaginationOptions } from '../domain.util'; - -export const ISmartInfoRepository = 'ISmartInfoRepository'; - -export type Embedding = number[]; - -export interface EmbeddingSearch { - userIds: string[]; - embedding: Embedding; - numResults: number; - withArchived?: boolean; -} - -export interface FaceEmbeddingSearch extends EmbeddingSearch { - maxDistance?: number; - hasPerson?: boolean; -} - -export interface FaceSearchResult { - face: AssetFaceEntity; - distance: number; -} - -export interface ISmartInfoRepository { - init(modelName: string): Promise; - searchCLIP(search: EmbeddingSearch, pagination: PaginationOptions): Paginated; - searchFaces(search: FaceEmbeddingSearch): Promise; - upsert(smartInfo: Partial, embedding?: Embedding): Promise; -} diff --git a/server/src/domain/search/search.service.spec.ts b/server/src/domain/search/search.service.spec.ts index 462863c6b3cbf..807b5c4722b42 100644 --- a/server/src/domain/search/search.service.spec.ts +++ b/server/src/domain/search/search.service.spec.ts @@ -16,7 +16,7 @@ import { IMachineLearningRepository, IPartnerRepository, IPersonRepository, - ISmartInfoRepository, + ISearchRepository, ISystemConfigRepository, } from '../repositories'; import { SearchDto } from './dto'; @@ -30,7 +30,7 @@ describe(SearchService.name, () => { let configMock: jest.Mocked; let machineMock: jest.Mocked; let personMock: jest.Mocked; - let smartInfoMock: jest.Mocked; + let smartInfoMock: jest.Mocked; let partnerMock: jest.Mocked; beforeEach(() => { diff --git a/server/src/domain/search/search.service.ts b/server/src/domain/search/search.service.ts index 00a61b9f149aa..5e83a9a53752d 100644 --- a/server/src/domain/search/search.service.ts +++ b/server/src/domain/search/search.service.ts @@ -9,7 +9,7 @@ import { IMachineLearningRepository, IPartnerRepository, IPersonRepository, - ISmartInfoRepository, + ISearchRepository, ISystemConfigRepository, SearchExploreItem, SearchStrategy, @@ -27,7 +27,7 @@ export class SearchService { @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, @Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository, @Inject(IPersonRepository) private personRepository: IPersonRepository, - @Inject(ISmartInfoRepository) private smartInfoRepository: ISmartInfoRepository, + @Inject(ISearchRepository) private smartInfoRepository: ISearchRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IPartnerRepository) private partnerRepository: IPartnerRepository, ) { @@ -70,10 +70,8 @@ export class SearchService { } const userIds = await this.getUserIdsToSearch(auth); - const withArchived = dto.withArchived || false; const page = dto.page ?? 0; - const take = dto.take || 100; - const skip = page * take; + const size = dto.take || 100; let nextPage: string | null = null; let assets: AssetEntity[] = []; @@ -85,10 +83,13 @@ export class SearchService { machineLearning.clip, ); - this.logger.debug(`Take: ${take}, skip: ${skip}`); const { hasNextPage, items } = await this.smartInfoRepository.searchCLIP( - { userIds, embedding, withArchived }, - { take, skip }, + { page, size }, + { + userIds, + embedding, + status: { withArchived: !!dto.withArchived }, + }, ); if (hasNextPage) { nextPage = (page + 1).toString(); diff --git a/server/src/domain/smart-info/smart-info.service.spec.ts b/server/src/domain/smart-info/smart-info.service.spec.ts index 373f8da91c0ef..23b2b0faba99c 100644 --- a/server/src/domain/smart-info/smart-info.service.spec.ts +++ b/server/src/domain/smart-info/smart-info.service.spec.ts @@ -14,7 +14,7 @@ import { IDatabaseRepository, IJobRepository, IMachineLearningRepository, - ISmartInfoRepository, + ISearchRepository, ISystemConfigRepository, WithoutProperty, } from '../repositories'; @@ -31,7 +31,7 @@ describe(SmartInfoService.name, () => { let assetMock: jest.Mocked; let configMock: jest.Mocked; let jobMock: jest.Mocked; - let smartMock: jest.Mocked; + let smartMock: jest.Mocked; let machineMock: jest.Mocked; let databaseMock: jest.Mocked; diff --git a/server/src/domain/smart-info/smart-info.service.ts b/server/src/domain/smart-info/smart-info.service.ts index 55e4b7080fba1..d193b29b510c3 100644 --- a/server/src/domain/smart-info/smart-info.service.ts +++ b/server/src/domain/smart-info/smart-info.service.ts @@ -8,7 +8,7 @@ import { IDatabaseRepository, IJobRepository, IMachineLearningRepository, - ISmartInfoRepository, + ISearchRepository, ISystemConfigRepository, WithoutProperty, } from '../repositories'; @@ -24,7 +24,7 @@ export class SmartInfoService { @Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository, - @Inject(ISmartInfoRepository) private repository: ISmartInfoRepository, + @Inject(ISearchRepository) private repository: ISearchRepository, @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, ) { this.configCore = SystemConfigCore.create(configRepository); diff --git a/server/src/domain/system-config/system-config.service.spec.ts b/server/src/domain/system-config/system-config.service.spec.ts index e8fa3f62e85ed..8addc63a0f61f 100644 --- a/server/src/domain/system-config/system-config.service.spec.ts +++ b/server/src/domain/system-config/system-config.service.spec.ts @@ -15,7 +15,7 @@ import { ImmichLogger } from '@app/infra/logger'; import { BadRequestException } from '@nestjs/common'; import { newCommunicationRepositoryMock, newSystemConfigRepositoryMock } from '@test'; import { QueueName } from '../job'; -import { ICommunicationRepository, ISmartInfoRepository, ISystemConfigRepository, ServerEvent } from '../repositories'; +import { ICommunicationRepository, ISearchRepository, ISystemConfigRepository, ServerEvent } from '../repositories'; import { defaults, SystemConfigValidator } from './system-config.core'; import { SystemConfigService } from './system-config.service'; @@ -146,7 +146,7 @@ describe(SystemConfigService.name, () => { let sut: SystemConfigService; let configMock: jest.Mocked; let communicationMock: jest.Mocked; - let smartInfoMock: jest.Mocked; + let smartInfoMock: jest.Mocked; beforeEach(async () => { delete process.env.IMMICH_CONFIG_FILE; diff --git a/server/src/domain/system-config/system-config.service.ts b/server/src/domain/system-config/system-config.service.ts index 5bf597e35dac6..39a3ea1dfb1f0 100644 --- a/server/src/domain/system-config/system-config.service.ts +++ b/server/src/domain/system-config/system-config.service.ts @@ -6,7 +6,7 @@ import _ from 'lodash'; import { ClientEvent, ICommunicationRepository, - ISmartInfoRepository, + ISearchRepository, ISystemConfigRepository, ServerEvent, } from '../repositories'; @@ -32,7 +32,7 @@ export class SystemConfigService { constructor( @Inject(ISystemConfigRepository) private repository: ISystemConfigRepository, @Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository, - @Inject(ISmartInfoRepository) private smartInfoRepository: ISmartInfoRepository, + @Inject(ISearchRepository) private smartInfoRepository: ISearchRepository, ) { this.core = SystemConfigCore.create(repository); this.communicationRepository.on(ServerEvent.CONFIG_UPDATE, () => this.handleConfigUpdate()); diff --git a/server/src/infra/infra.module.ts b/server/src/infra/infra.module.ts index 93cb8fb6812b0..b36fdf6f0a57c 100644 --- a/server/src/infra/infra.module.ts +++ b/server/src/infra/infra.module.ts @@ -17,9 +17,9 @@ import { IMoveRepository, IPartnerRepository, IPersonRepository, + ISearchRepository, IServerInfoRepository, ISharedLinkRepository, - ISmartInfoRepository, IStorageRepository, ISystemConfigRepository, ISystemMetadataRepository, @@ -56,9 +56,9 @@ import { MoveRepository, PartnerRepository, PersonRepository, + SearchRepository, ServerInfoRepository, SharedLinkRepository, - SmartInfoRepository, SystemConfigRepository, SystemMetadataRepository, TagRepository, @@ -86,7 +86,7 @@ const providers: Provider[] = [ { provide: IPersonRepository, useClass: PersonRepository }, { provide: IServerInfoRepository, useClass: ServerInfoRepository }, { provide: ISharedLinkRepository, useClass: SharedLinkRepository }, - { provide: ISmartInfoRepository, useClass: SmartInfoRepository }, + { provide: ISearchRepository, useClass: SearchRepository }, { provide: IStorageRepository, useClass: FilesystemProvider }, { provide: ISystemConfigRepository, useClass: SystemConfigRepository }, { provide: ISystemMetadataRepository, useClass: SystemMetadataRepository }, diff --git a/server/src/infra/infra.utils.ts b/server/src/infra/infra.utils.ts index 355d6d4097916..86f33b89c5ea7 100644 --- a/server/src/infra/infra.utils.ts +++ b/server/src/infra/infra.utils.ts @@ -1,16 +1,20 @@ -import { Paginated, PaginationOptions } from '@app/domain'; +import { AssetSearchBuilderOptions, Paginated, PaginationOptions } from '@app/domain'; import _ from 'lodash'; import { Between, + Brackets, FindManyOptions, + IsNull, LessThanOrEqual, MoreThanOrEqual, + Not, ObjectLiteral, Repository, SelectQueryBuilder, } from 'typeorm'; -import { chunks, setUnion } from '../domain/domain.util'; +import { PaginatedBuilderOptions, PaginationMode, PaginationResult, chunks, setUnion } from '../domain/domain.util'; import { DATABASE_PARAMETER_CHUNK_SIZE } from './infra.util'; +import { AssetEntity } from './entities'; /** * Allows optional values unlike the regular Between and uses MoreThanOrEqual @@ -31,6 +35,13 @@ export const isValidInteger = (value: number, options: { min?: number; max?: num return Number.isInteger(value) && value >= min && value <= max; }; +function paginationHelper(items: Entity[], take: number): PaginationResult { + const hasNextPage = items.length > take; + items.splice(take); + + return { items, hasNextPage }; +} + export async function paginate( repository: Repository, { take, skip }: PaginationOptions, @@ -48,25 +59,21 @@ export async function paginate( ), ); - const hasNextPage = items.length > take; - items.splice(take); - - return { items, hasNextPage }; + return paginationHelper(items, take); } export async function paginatedBuilder( qb: SelectQueryBuilder, - { take, skip }: PaginationOptions, + { take, skip, mode }: PaginatedBuilderOptions, ): Paginated { - const items = await qb - .limit(take + 1) - .offset(skip) - .getMany(); - - const hasNextPage = items.length > take; - items.splice(take); + if (mode === PaginationMode.LIMIT_OFFSET) { + qb.limit(take + 1).offset(skip); + } else { + qb.skip(take + 1).take(skip); + } - return { items, hasNextPage }; + const items = await qb.getMany(); + return paginationHelper(items, take); } export const asVector = (embedding: number[], quote = false) => @@ -114,3 +121,87 @@ export function ChunkedArray(options?: { paramIndex?: number }): MethodDecorator export function ChunkedSet(options?: { paramIndex?: number }): MethodDecorator { return Chunked({ ...options, mergeFn: setUnion }); } + +export function searchAssetBuilder( + builder: SelectQueryBuilder, + options: AssetSearchBuilderOptions, +): SelectQueryBuilder { + const { date, id, exif, path, relation, status } = options; + + if (date) { + builder.andWhere( + _.omitBy( + { + createdAt: OptionalBetween(date.createdAfter, date.createdBefore), + updatedAt: OptionalBetween(date.updatedAfter, date.updatedBefore), + deletedAt: OptionalBetween(date.trashedAfter, date.trashedBefore), + fileCreatedAt: OptionalBetween(date.takenAfter, date.takenBefore), + }, + _.isUndefined, + ), + ); + } + + if (exif) { + const exifWhere = _.omitBy(exif, _.isUndefined); + builder.andWhere(exifWhere); + if (Object.keys(exifWhere).length > 0) { + builder.leftJoin(`${builder.alias}.exifInfo`, 'exifInfo'); + } + } + + if (id) { + builder.andWhere(_.omitBy(id, _.isUndefined)); + } + + if (path) { + builder.andWhere(_.omitBy(path, _.isUndefined)); + } + + if (status) { + const { isEncoded, isMotion, ...otherStatuses } = status; + builder.andWhere(_.omitBy(otherStatuses, _.isUndefined)); + + if (isEncoded && !path?.encodedVideoPath) { + builder.andWhere({ encodedVideoPath: Not(IsNull()) }); + } + + if (isMotion) { + builder.andWhere({ livePhotoVideoId: Not(IsNull()) }); + } + } + + if (relation) { + const { withExif, withFaces, withPeople, withSmartInfo, withStacked } = relation; + + if (withExif) { + builder.leftJoinAndSelect(`${builder.alias}.exifInfo`, 'exifInfo'); + } + + if (withFaces || withPeople) { + builder.leftJoinAndSelect(`${builder.alias}.faces`, 'faces'); + } + + if (withPeople) { + builder.leftJoinAndSelect(`${builder.alias}.person`, 'person'); + } + + if (withSmartInfo) { + builder.leftJoinAndSelect(`${builder.alias}.smartInfo`, 'smartInfo'); + } + + if (withStacked) { + builder + .leftJoinAndSelect(`${builder.alias}.stack`, 'stack') + .leftJoinAndSelect('stack.assets', 'stackedAssets') + .andWhere(new Brackets((qb) => qb.where(`stack.primaryAssetId = ${builder.alias}.id`).orWhere('asset.stackId IS NULL'))); + } + } + + const withDeleted = status?.withDeleted ?? (date?.trashedAfter !== undefined || date?.trashedBefore !== undefined); + if (withDeleted) { + builder.withDeleted(); + } + + return builder; +} diff --git a/server/src/infra/repositories/asset.repository.ts b/server/src/infra/repositories/asset.repository.ts index 95a227b693010..f38d293e1a331 100644 --- a/server/src/infra/repositories/asset.repository.ts +++ b/server/src/infra/repositories/asset.repository.ts @@ -12,6 +12,7 @@ import { MetadataSearchOptions, MonthDay, Paginated, + PaginationMode, PaginationOptions, SearchExploreItem, TimeBucketItem, @@ -39,9 +40,7 @@ import { } from 'typeorm'; import { AssetEntity, AssetJobStatusEntity, AssetType, ExifEntity, SmartInfoEntity } from '../entities'; import { DummyValue, GenerateSql } from '../infra.util'; -import { Chunked, ChunkedArray, OptionalBetween, paginate } from '../infra.utils'; - -const DEFAULT_SEARCH_SIZE = 250; +import { Chunked, ChunkedArray, OptionalBetween, paginate, paginatedBuilder, searchAssetBuilder } from '../infra.utils'; const truncateMap: Record = { [TimeBucketSize.DAY]: 'day', @@ -70,142 +69,6 @@ export class AssetRepository implements IAssetRepository { await this.jobStatusRepository.upsert(jobStatus, { conflictPaths: ['assetId'] }); } - search(options: AssetSearchOptions): Promise { - const { - id, - libraryId, - deviceAssetId, - type, - checksum, - ownerId, - - isVisible, - isFavorite, - isExternal, - isReadOnly, - isOffline, - isArchived, - isMotion, - isEncoded, - - createdBefore, - createdAfter, - updatedBefore, - updatedAfter, - trashedBefore, - trashedAfter, - takenBefore, - takenAfter, - - originalFileName, - originalPath, - resizePath, - webpPath, - encodedVideoPath, - - city, - state, - country, - make, - model, - lensModel, - - withDeleted: _withDeleted, - withExif: _withExif, - withStacked, - withPeople, - withSmartInfo, - - order, - } = options; - - const withDeleted = _withDeleted ?? (trashedAfter !== undefined || trashedBefore !== undefined); - - const page = Math.max(options.page || 1, 1); - const size = Math.min(options.size || DEFAULT_SEARCH_SIZE, DEFAULT_SEARCH_SIZE); - - const exifWhere = _.omitBy( - { - city, - state, - country, - make, - model, - lensModel, - }, - _.isUndefined, - ); - - const withExif = Object.keys(exifWhere).length > 0 || _withExif; - - const where: FindOptionsWhere = _.omitBy( - { - ownerId, - id, - libraryId, - deviceAssetId, - type, - checksum, - isVisible, - isFavorite, - isExternal, - isReadOnly, - isOffline, - isArchived, - livePhotoVideoId: isMotion && Not(IsNull()), - originalFileName, - originalPath, - resizePath, - webpPath, - encodedVideoPath: encodedVideoPath ?? (isEncoded && Not(IsNull())), - createdAt: OptionalBetween(createdAfter, createdBefore), - updatedAt: OptionalBetween(updatedAfter, updatedBefore), - deletedAt: OptionalBetween(trashedAfter, trashedBefore), - fileCreatedAt: OptionalBetween(takenAfter, takenBefore), - exifInfo: Object.keys(exifWhere).length > 0 ? exifWhere : undefined, - }, - _.isUndefined, - ); - - const builder = this.repository.createQueryBuilder('asset'); - - if (withExif) { - if (_withExif) { - builder.leftJoinAndSelect('asset.exifInfo', 'exifInfo'); - } else { - builder.leftJoin('asset.exifInfo', 'exifInfo'); - } - } - - if (withPeople) { - builder.leftJoinAndSelect('asset.faces', 'faces'); - builder.leftJoinAndSelect('faces.person', 'person'); - } - - if (withSmartInfo) { - builder.leftJoinAndSelect('asset.smartInfo', 'smartInfo'); - } - - if (withDeleted) { - builder.withDeleted(); - } - - builder.where(where); - - if (withStacked) { - builder - .leftJoinAndSelect('asset.stack', 'stack') - .leftJoinAndSelect('stack.assets', 'stackedAssets') - .andWhere(new Brackets((qb) => qb.where('stack.primaryAssetId = asset.id').orWhere('asset.stackId IS NULL'))); - } - - return builder - .skip(size * (page - 1)) - .take(size) - .orderBy('asset.fileCreatedAt', order ?? 'DESC') - .getMany(); - } - create(asset: AssetCreate): Promise { return this.repository.save(asset); } @@ -316,17 +179,7 @@ export class AssetRepository implements IAssetRepository { } getByUserId(pagination: PaginationOptions, userId: string, options: AssetSearchOptions = {}): Paginated { - return paginate(this.repository, pagination, { - where: { - ownerId: userId, - isVisible: options.isVisible, - deletedAt: options.trashedBefore ? And(Not(IsNull()), LessThan(options.trashedBefore)) : undefined, - }, - relations: { - exifInfo: true, - }, - withDeleted: !!options.trashedBefore, - }); + return this.getAll(pagination, { ...options, id: { ...options.id, id: userId } }); } @GenerateSql({ params: [[DummyValue.UUID]] }) @@ -345,24 +198,13 @@ export class AssetRepository implements IAssetRepository { } getAll(pagination: PaginationOptions, options: AssetSearchOptions = {}): Paginated { - return paginate(this.repository, pagination, { - where: { - isVisible: options.isVisible, - type: options.type, - deletedAt: options.trashedBefore ? And(Not(IsNull()), LessThan(options.trashedBefore)) : undefined, - }, - relations: { - exifInfo: options.withExif !== false, - smartInfo: options.withSmartInfo !== false, - tags: options.withSmartInfo !== false, - faces: options.withFaces !== false, - smartSearch: options.withSmartInfo === true, - }, - withDeleted: options.withDeleted ?? !!options.trashedBefore, - order: { - // Ensures correct order when paginating - createdAt: options.order ?? 'ASC', - }, + let builder = this.repository.createQueryBuilder('asset'); + builder = searchAssetBuilder(builder, options); + builder.orderBy('asset.createdAt', options.order?.direction ?? 'ASC'); + return paginatedBuilder(builder, { + mode: PaginationMode.SKIP_TAKE, + skip: pagination.skip, + take: pagination.take, }); } diff --git a/server/src/infra/repositories/index.ts b/server/src/infra/repositories/index.ts index 21703ec8c8ce8..d684f6b00406d 100644 --- a/server/src/infra/repositories/index.ts +++ b/server/src/infra/repositories/index.ts @@ -17,9 +17,9 @@ export * from './metadata.repository'; export * from './move.repository'; export * from './partner.repository'; export * from './person.repository'; +export * from './search.repository'; export * from './server-info.repository'; export * from './shared-link.repository'; -export * from './smart-info.repository'; export * from './system-config.repository'; export * from './system-metadata.repository'; export * from './tag.repository'; diff --git a/server/src/infra/repositories/smart-info.repository.ts b/server/src/infra/repositories/search.repository.ts similarity index 65% rename from server/src/infra/repositories/smart-info.repository.ts rename to server/src/infra/repositories/search.repository.ts index b9afe710bffa2..a8ad484348192 100644 --- a/server/src/infra/repositories/smart-info.repository.ts +++ b/server/src/infra/repositories/search.repository.ts @@ -1,27 +1,29 @@ import { - DatabaseExtension, + AssetSearchBuilderOptions, + AssetSearchOptions, Embedding, - EmbeddingSearch, FaceEmbeddingSearch, FaceSearchResult, - ISmartInfoRepository, + ISearchRepository, Paginated, - PaginationOptions, + PaginationMode, PaginationResult, + SearchPaginationOptions, + SmartSearchOptions, } from '@app/domain'; import { getCLIPModelInfo } from '@app/domain/smart-info/smart-info.constant'; import { AssetEntity, AssetFaceEntity, SmartInfoEntity, SmartSearchEntity } from '@app/infra/entities'; import { ImmichLogger } from '@app/infra/logger'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import _ from 'lodash'; import { Repository } from 'typeorm'; -import { vectorExt } from '../database.config'; import { DummyValue, GenerateSql } from '../infra.util'; -import { asVector, isValidInteger, paginatedBuilder } from '../infra.utils'; +import { asVector, isValidInteger, paginatedBuilder, searchAssetBuilder } from '../infra.utils'; @Injectable() -export class SmartInfoRepository implements ISmartInfoRepository { - private logger = new ImmichLogger(SmartInfoRepository.name); +export class SearchRepository implements ISearchRepository { + private logger = new ImmichLogger(SearchRepository.name); private faceColumns: string[]; constructor( @@ -42,41 +44,51 @@ export class SmartInfoRepository implements ISmartInfoRepository { throw new Error(`Invalid CLIP model name: ${modelName}`); } - const currentDimSize = await this.getDimSize(); - this.logger.verbose(`Current database CLIP dimension size is ${currentDimSize}`); + const curDimSize = await this.getDimSize(); + this.logger.verbose(`Current database CLIP dimension size is ${curDimSize}`); - if (dimSize != currentDimSize) { - this.logger.log(`Dimension size of model ${modelName} is ${dimSize}, but database expects ${currentDimSize}.`); + if (dimSize != curDimSize) { + this.logger.log(`Dimension size of model ${modelName} is ${dimSize}, but database expects ${curDimSize}.`); await this.updateDimSize(dimSize); } } + async searchAssets(pagination: SearchPaginationOptions, options: AssetSearchOptions): Paginated { + let builder = this.assetRepository.createQueryBuilder('asset'); + builder = searchAssetBuilder(builder, options); + + if (options.order) { + builder.orderBy('asset.fileCreatedAt', options.order.direction); + } + + return paginatedBuilder(builder, { + mode: PaginationMode.SKIP_TAKE, + skip: pagination.page * pagination.size, + take: pagination.size, + }); + } + @GenerateSql({ params: [{ userIds: [DummyValue.UUID], embedding: Array.from({ length: 512 }, Math.random), numResults: 100 }], }) - async searchCLIP( - { userIds, embedding, withArchived }: EmbeddingSearch, - pagination: PaginationOptions, - ): Paginated { + async searchCLIP(pagination: SearchPaginationOptions, options: SmartSearchOptions): Paginated { let results: PaginationResult = { items: [], hasNextPage: false }; await this.assetRepository.manager.transaction(async (manager) => { - const query = manager - .createQueryBuilder(AssetEntity, 'a') + await manager.query(`SET LOCAL vectors.search_mode=vbase`); + let builder = manager.createQueryBuilder(AssetEntity, 'asset'); + builder = searchAssetBuilder(builder, options); + builder .innerJoin('a.smartSearch', 's') - .where('a.ownerId IN (:...userIds )') - .andWhere('a.isVisible = true') - .andWhere('a.fileCreatedAt < NOW()') - .leftJoinAndSelect('a.exifInfo', 'e') + .andWhere('a.ownerId IN (:...userIds )') .orderBy('s.embedding <=> :embedding') - .setParameters({ userIds, embedding: asVector(embedding) }); - - if (!withArchived) { - query.andWhere('a.isArchived = false'); - } + .setParameters({ userIds: options.userIds, embedding: asVector(options.embedding) }); - await manager.query(this.getRuntimeConfig(pagination.take)); - results = await paginatedBuilder(query, pagination); + return paginatedBuilder(builder, { + mode: PaginationMode.SKIP_TAKE, + skip: pagination.page * pagination.size, + take: pagination.size, + }); }); return results; @@ -99,16 +111,9 @@ export class SmartInfoRepository implements ISmartInfoRepository { maxDistance, hasPerson, }: FaceEmbeddingSearch): Promise { - if (!isValidInteger(numResults, { min: 1 })) { - throw new Error(`Invalid value for 'numResults': ${numResults}`); - } - - // setting this too low messes with prefilter recall - numResults = Math.max(numResults, 64); - let results: Array = []; await this.assetRepository.manager.transaction(async (manager) => { - const cte = manager + let cte = manager .createQueryBuilder(AssetFaceEntity, 'faces') .select('faces.embedding <=> :embedding', 'distance') .innerJoin('faces.asset', 'asset') @@ -116,17 +121,24 @@ export class SmartInfoRepository implements ISmartInfoRepository { .orderBy('faces.embedding <=> :embedding') .setParameters({ userIds, embedding: asVector(embedding) }); - cte.limit(numResults); + let runtimeConfig = 'SET LOCAL vectors.enable_prefilter=on; SET LOCAL vectors.search_mode=basic;'; + if (numResults) { + if (!isValidInteger(numResults, { min: 1 })) { + throw new Error(`Invalid value for 'numResults': ${numResults}`); + } + const limit = Math.max(numResults, 64); + cte = cte.limit(limit); + // setting this too low messes with prefilter recall + runtimeConfig += ` SET LOCAL vectors.hnsw_ef_search = ${limit}`; + } if (hasPerson) { - cte.andWhere('faces."personId" IS NOT NULL'); + cte = cte.andWhere('faces."personId" IS NOT NULL'); } - for (const col of this.faceColumns) { - cte.addSelect(`faces.${col}`, col); - } + this.faceColumns.forEach((col) => cte.addSelect(`faces.${col}`, col)); - await manager.query(this.getRuntimeConfig(numResults)); + await manager.query(runtimeConfig); results = await manager .createQueryBuilder() .select('res.*') @@ -163,17 +175,14 @@ export class SmartInfoRepository implements ISmartInfoRepository { throw new Error(`Invalid CLIP dimension size: ${dimSize}`); } - const currentDimSize = await this.getDimSize(); - if (currentDimSize === dimSize) { + const curDimSize = await this.getDimSize(); + if (curDimSize === dimSize) { return; } this.logger.log(`Updating database CLIP dimension size to ${dimSize}.`); await this.smartSearchRepository.manager.transaction(async (manager) => { - if (vectorExt === DatabaseExtension.VECTORS) { - await manager.query(`SET vectors.pgvector_compatibility=on`); - } await manager.query(`DROP TABLE smart_search`); await manager.query(` @@ -182,12 +191,15 @@ export class SmartInfoRepository implements ISmartInfoRepository { embedding vector(${dimSize}) NOT NULL )`); await manager.query(` - CREATE INDEX IF NOT EXISTS clip_index ON smart_search - USING hnsw (embedding vector_cosine_ops) - WITH (ef_construction = 300, m = 16)`); + CREATE INDEX clip_index ON smart_search + USING vectors (embedding vector_cos_ops) WITH (options = $$ + [indexing.hnsw] + m = 16 + ef_construction = 300 + $$)`); }); - this.logger.log(`Successfully updated database CLIP dimension size from ${currentDimSize} to ${dimSize}.`); + this.logger.log(`Successfully updated database CLIP dimension size from ${curDimSize} to ${dimSize}.`); } private async getDimSize(): Promise { @@ -206,17 +218,4 @@ export class SmartInfoRepository implements ISmartInfoRepository { } return dimSize; } - - private getRuntimeConfig(numResults?: number): string { - if (vectorExt === DatabaseExtension.VECTOR) { - return 'SET LOCAL hnsw.ef_search = 1000;'; // mitigate post-filter recall - } - - let runtimeConfig = 'SET LOCAL vectors.enable_prefilter=on; SET LOCAL vectors.search_mode=vbase;'; - if (numResults) { - runtimeConfig += ` SET LOCAL vectors.hnsw_ef_search = ${numResults};`; - } - - return runtimeConfig; - } } diff --git a/server/src/infra/sql-generator/index.ts b/server/src/infra/sql-generator/index.ts index 348762d9571e8..38fb283b44000 100644 --- a/server/src/infra/sql-generator/index.ts +++ b/server/src/infra/sql-generator/index.ts @@ -19,8 +19,8 @@ import { MoveRepository, PartnerRepository, PersonRepository, + SearchRepository, SharedLinkRepository, - SmartInfoRepository, SystemConfigRepository, SystemMetadataRepository, TagRepository, @@ -41,7 +41,7 @@ const repositories = [ PartnerRepository, PersonRepository, SharedLinkRepository, - SmartInfoRepository, + SearchRepository, SystemConfigRepository, SystemMetadataRepository, TagRepository, diff --git a/server/test/repositories/index.ts b/server/test/repositories/index.ts index e31a3a1c452b1..90fd1326b4adc 100644 --- a/server/test/repositories/index.ts +++ b/server/test/repositories/index.ts @@ -15,8 +15,8 @@ export * from './metadata.repository.mock'; export * from './move.repository.mock'; export * from './partner.repository.mock'; export * from './person.repository.mock'; +export * from './search.repository.mock'; export * from './shared-link.repository.mock'; -export * from './smart-info.repository.mock'; export * from './storage.repository.mock'; export * from './system-config.repository.mock'; export * from './system-info.repository.mock'; diff --git a/server/test/repositories/search.repository.mock.ts b/server/test/repositories/search.repository.mock.ts new file mode 100644 index 0000000000000..55bcc9db5be91 --- /dev/null +++ b/server/test/repositories/search.repository.mock.ts @@ -0,0 +1,11 @@ +import { ISearchRepository } from '@app/domain'; + +export const newSearchRepositoryMock = (): jest.Mocked => { + return { + init: jest.fn(), + searchAssets: jest.fn(), + searchCLIP: jest.fn(), + searchFaces: jest.fn(), + upsert: jest.fn(), + }; +}; diff --git a/server/test/repositories/smart-info.repository.mock.ts b/server/test/repositories/smart-info.repository.mock.ts deleted file mode 100644 index c7bc4f5c56b3b..0000000000000 --- a/server/test/repositories/smart-info.repository.mock.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { ISmartInfoRepository } from '@app/domain'; - -export const newSmartInfoRepositoryMock = (): jest.Mocked => { - return { - init: jest.fn(), - searchCLIP: jest.fn(), - searchFaces: jest.fn(), - upsert: jest.fn(), - }; -}; From 8824cb044cc038f85f073d0a73fee042a78f3841 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Fri, 9 Feb 2024 19:56:41 -0500 Subject: [PATCH 09/38] re-add runtime config for vector search --- .../infra/repositories/search.repository.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/server/src/infra/repositories/search.repository.ts b/server/src/infra/repositories/search.repository.ts index a8ad484348192..7e89fa457323e 100644 --- a/server/src/infra/repositories/search.repository.ts +++ b/server/src/infra/repositories/search.repository.ts @@ -1,6 +1,6 @@ import { - AssetSearchBuilderOptions, AssetSearchOptions, + DatabaseExtension, Embedding, FaceEmbeddingSearch, FaceSearchResult, @@ -20,6 +20,7 @@ import _ from 'lodash'; import { Repository } from 'typeorm'; import { DummyValue, GenerateSql } from '../infra.util'; import { asVector, isValidInteger, paginatedBuilder, searchAssetBuilder } from '../infra.utils'; +import { vectorExt } from '../database.config'; @Injectable() export class SearchRepository implements ISearchRepository { @@ -84,8 +85,9 @@ export class SearchRepository implements ISearchRepository { .orderBy('s.embedding <=> :embedding') .setParameters({ userIds: options.userIds, embedding: asVector(options.embedding) }); + await manager.query(this.getRuntimeConfig(pagination.size)); return paginatedBuilder(builder, { - mode: PaginationMode.SKIP_TAKE, + mode: PaginationMode.LIMIT_OFFSET, skip: pagination.page * pagination.size, take: pagination.size, }); @@ -218,4 +220,17 @@ export class SearchRepository implements ISearchRepository { } return dimSize; } + + private getRuntimeConfig(numResults?: number): string { + if (vectorExt === DatabaseExtension.VECTOR) { + return 'SET LOCAL hnsw.ef_search = 1000;'; // mitigate post-filter recall + } + + let runtimeConfig = 'SET LOCAL vectors.enable_prefilter=on; SET LOCAL vectors.search_mode=vbase;'; + if (numResults) { + runtimeConfig += ` SET LOCAL vectors.hnsw_ef_search = ${numResults};`; + } + + return runtimeConfig; + } } From 4ff4b386636de21e8a0c2726ca5e1f5df40190ce Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Fri, 9 Feb 2024 20:01:40 -0500 Subject: [PATCH 10/38] fix rebase --- server/src/domain/asset/asset.service.ts | 160 ++--------------------- 1 file changed, 8 insertions(+), 152 deletions(-) diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts index 0406d351e35cb..9c2d6499fd54c 100644 --- a/server/src/domain/asset/asset.service.ts +++ b/server/src/domain/asset/asset.service.ts @@ -89,12 +89,12 @@ export class AssetService { @Inject(IPartnerRepository) private partnerRepository: IPartnerRepository, @Inject(IAssetStackRepository) private assetStackRepository: IAssetStackRepository, @Inject(ISearchRepository) private searchRepository: ISearchRepository, - ) { + ) { this.access = AccessCore.create(accessRepository); this.configCore = SystemConfigCore.create(configRepository); } - search(auth: AuthDto, dto: AssetSearchDto) { + async search(auth: AuthDto, dto: AssetSearchDto) { let checksum: Buffer | undefined; if (dto.checksum) { @@ -104,23 +104,17 @@ export class AssetService { const enumToOrder = { [AssetOrder.ASC]: 'ASC', [AssetOrder.DESC]: 'DESC' } as const; const order = dto.order ? { direction: enumToOrder[dto.order] } : undefined; - - return this.searchRepository + const { items } = await this.searchRepository .searchAssets( { page: 0, size: 250 }, { id: { checksum, ownerId: auth.user.id }, order, - }, - ) - .then(({ items }) => - items.map((asset) => - mapAsset(asset, { - stripMetadata: false, - withStack: true, - }), - ), - ); + }); + return items.map((asset) => mapAsset(asset, { + stripMetadata: false, + withStack: true, + })); } canUploadFile({ auth, fieldName, file }: UploadRequest): true { @@ -278,111 +272,6 @@ export class AssetService { return { ...options, userIds }; } - async downloadFile(auth: AuthDto, id: string): Promise { - await this.access.requirePermission(auth, Permission.ASSET_DOWNLOAD, id); - - const [asset] = await this.assetRepository.getByIds([id]); - if (!asset) { - throw new BadRequestException('Asset not found'); - } - - if (asset.isOffline) { - throw new BadRequestException('Asset is offline'); - } - - return new ImmichFileResponse({ - path: asset.originalPath, - contentType: mimeTypes.lookup(asset.originalPath), - cacheControl: CacheControl.NONE, - }); - } - - async getDownloadInfo(auth: AuthDto, dto: DownloadInfoDto): Promise { - const targetSize = dto.archiveSize || HumanReadableSize.GiB * 4; - const archives: DownloadArchiveInfo[] = []; - let archive: DownloadArchiveInfo = { size: 0, assetIds: [] }; - - const assetPagination = await this.getDownloadAssets(auth, dto); - for await (const assets of assetPagination) { - // motion part of live photos - const motionIds = assets.map((asset) => asset.livePhotoVideoId).filter((id): id is string => !!id); - if (motionIds.length > 0) { - assets.push(...(await this.assetRepository.getByIds(motionIds))); - } - - for (const asset of assets) { - archive.size += Number(asset.exifInfo?.fileSizeInByte || 0); - archive.assetIds.push(asset.id); - - if (archive.size > targetSize) { - archives.push(archive); - archive = { size: 0, assetIds: [] }; - } - } - - if (archive.assetIds.length > 0) { - archives.push(archive); - } - } - - return { - totalSize: archives.reduce((total, item) => (total += item.size), 0), - archives, - }; - } - - async downloadArchive(auth: AuthDto, dto: AssetIdsDto): Promise { - await this.access.requirePermission(auth, Permission.ASSET_DOWNLOAD, dto.assetIds); - - const zip = this.storageRepository.createZipStream(); - const assets = await this.assetRepository.getByIds(dto.assetIds); - const paths: Record = {}; - - for (const { originalPath, originalFileName } of assets) { - const ext = extname(originalPath); - let filename = `${originalFileName}${ext}`; - const count = paths[filename] || 0; - paths[filename] = count + 1; - if (count !== 0) { - filename = `${originalFileName}+${count}${ext}`; - } - - zip.addFile(originalPath, filename); - } - - void zip.finalize(); - - return { stream: zip.stream }; - } - - private async getDownloadAssets(auth: AuthDto, dto: DownloadInfoDto): Promise> { - const PAGINATION_SIZE = 2500; - - if (dto.assetIds) { - const assetIds = dto.assetIds; - await this.access.requirePermission(auth, Permission.ASSET_DOWNLOAD, assetIds); - const assets = await this.assetRepository.getByIds(assetIds); - return (async function* () { - yield assets; - })(); - } - - if (dto.albumId) { - const albumId = dto.albumId; - await this.access.requirePermission(auth, Permission.ALBUM_DOWNLOAD, albumId); - return usePagination(PAGINATION_SIZE, (pagination) => this.assetRepository.getByAlbumId(pagination, albumId)); - } - - if (dto.userId) { - const userId = dto.userId; - await this.access.requirePermission(auth, Permission.TIMELINE_DOWNLOAD, userId); - return usePagination(PAGINATION_SIZE, (pagination) => - this.assetRepository.getByUserId(pagination, userId, { status: { isVisible: true } }), - ); - } - - throw new BadRequestException('assetIds, albumId, or userId is required'); - } async getStatistics(auth: AuthDto, dto: AssetStatsDto) { const stats = await this.assetRepository.getStatistics(auth.user.id, dto); @@ -602,39 +491,6 @@ export class AssetService { } } - async handleTrashAction(auth: AuthDto, action: TrashAction): Promise { - const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => - this.assetRepository.getByUserId(pagination, auth.user.id, { - date: { trashedBefore: DateTime.now().toJSDate() }, - }), - ); - - if (action == TrashAction.RESTORE_ALL) { - for await (const assets of assetPagination) { - const ids = assets.map((a) => a.id); - await this.assetRepository.restoreAll(ids); - this.communicationRepository.send(ClientEvent.ASSET_RESTORE, auth.user.id, ids); - } - return; - } - - if (action == TrashAction.EMPTY_ALL) { - for await (const assets of assetPagination) { - await this.jobRepository.queueAll( - assets.map((asset) => ({ name: JobName.ASSET_DELETION, data: { id: asset.id } })), - ); - } - return; - } - } - - async restoreAll(auth: AuthDto, dto: BulkIdsDto): Promise { - const { ids } = dto; - await this.access.requirePermission(auth, Permission.ASSET_RESTORE, ids); - await this.assetRepository.restoreAll(ids); - this.communicationRepository.send(ClientEvent.ASSET_RESTORE, auth.user.id, ids); - } - async updateStackParent(auth: AuthDto, dto: UpdateStackParentDto): Promise { const { oldParentId, newParentId } = dto; await this.access.requirePermission(auth, Permission.ASSET_READ, oldParentId); From b2828a97ce73b6819d02778ab99010a4602f7b3b Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Fri, 9 Feb 2024 21:32:20 -0500 Subject: [PATCH 11/38] fixes --- server/src/domain/audit/audit.service.ts | 2 +- .../src/domain/download/download.service.ts | 2 +- server/src/domain/media/media.service.ts | 2 +- server/src/domain/trash/trash.service.ts | 4 +- server/src/infra/infra.utils.ts | 4 +- .../infra/repositories/search.repository.ts | 32 +++-- server/src/infra/sql/search.repository.sql | 43 ++++++ .../src/infra/sql/smart.info.repository.sql | 129 ------------------ 8 files changed, 72 insertions(+), 146 deletions(-) create mode 100644 server/src/infra/sql/search.repository.sql delete mode 100644 server/src/infra/sql/smart.info.repository.sql diff --git a/server/src/domain/audit/audit.service.ts b/server/src/domain/audit/audit.service.ts index 887b72e2cda5c..5e4529fbdcbc7 100644 --- a/server/src/domain/audit/audit.service.ts +++ b/server/src/domain/audit/audit.service.ts @@ -167,7 +167,7 @@ export class AuditService { `Found ${libraryFiles.size} original files, ${thumbFiles.size} thumbnails, ${videoFiles.size} encoded videos, ${profileFiles.size} profile files`, ); const pagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (options) => - this.assetRepository.getAll(options, { withDeleted: true }), + this.assetRepository.getAll(options, { status: { withDeleted: true } }), ); let assetCount = 0; diff --git a/server/src/domain/download/download.service.ts b/server/src/domain/download/download.service.ts index 03bd6fee60f21..4dcb67e687c12 100644 --- a/server/src/domain/download/download.service.ts +++ b/server/src/domain/download/download.service.ts @@ -122,7 +122,7 @@ export class DownloadService { const userId = dto.userId; await this.access.requirePermission(auth, Permission.TIMELINE_DOWNLOAD, userId); return usePagination(PAGINATION_SIZE, (pagination) => - this.assetRepository.getByUserId(pagination, userId, { isVisible: true }), + this.assetRepository.getByUserId(pagination, userId, { status: { isVisible: true } }), ); } diff --git a/server/src/domain/media/media.service.ts b/server/src/domain/media/media.service.ts index 68f861d7e2d55..0461738701ab5 100644 --- a/server/src/domain/media/media.service.ts +++ b/server/src/domain/media/media.service.ts @@ -240,7 +240,7 @@ export class MediaService { const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { return force - ? this.assetRepository.getAll(pagination, { type: AssetType.VIDEO }) + ? this.assetRepository.getAll(pagination, { status: { type: AssetType.VIDEO } }) : this.assetRepository.getWithout(pagination, WithoutProperty.ENCODED_VIDEO); }); diff --git a/server/src/domain/trash/trash.service.ts b/server/src/domain/trash/trash.service.ts index b1a38f72c9983..3c421162da55e 100644 --- a/server/src/domain/trash/trash.service.ts +++ b/server/src/domain/trash/trash.service.ts @@ -33,7 +33,7 @@ export class TrashService { async restore(auth: AuthDto): Promise { const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => - this.assetRepository.getByUserId(pagination, auth.user.id, { trashedBefore: DateTime.now().toJSDate() }), + this.assetRepository.getByUserId(pagination, auth.user.id, { date: { trashedBefore: DateTime.now().toJSDate() } }), ); for await (const assets of assetPagination) { @@ -44,7 +44,7 @@ export class TrashService { async empty(auth: AuthDto): Promise { const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => - this.assetRepository.getByUserId(pagination, auth.user.id, { trashedBefore: DateTime.now().toJSDate() }), + this.assetRepository.getByUserId(pagination, auth.user.id, { date: { trashedBefore: DateTime.now().toJSDate() } }), ); for await (const assets of assetPagination) { diff --git a/server/src/infra/infra.utils.ts b/server/src/infra/infra.utils.ts index 86f33b89c5ea7..fee1a5f5e6330 100644 --- a/server/src/infra/infra.utils.ts +++ b/server/src/infra/infra.utils.ts @@ -124,10 +124,8 @@ export function ChunkedSet(options?: { paramIndex?: number }): MethodDecorator { export function searchAssetBuilder( builder: SelectQueryBuilder, - options: AssetSearchBuilderOptions, + { date, id, exif, path, relation, status }: AssetSearchBuilderOptions, ): SelectQueryBuilder { - const { date, id, exif, path, relation, status } = options; - if (date) { builder.andWhere( _.omitBy( diff --git a/server/src/infra/repositories/search.repository.ts b/server/src/infra/repositories/search.repository.ts index 7e89fa457323e..e0ec24c83710a 100644 --- a/server/src/infra/repositories/search.repository.ts +++ b/server/src/infra/repositories/search.repository.ts @@ -18,9 +18,9 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import _ from 'lodash'; import { Repository } from 'typeorm'; +import { vectorExt } from '../database.config'; import { DummyValue, GenerateSql } from '../infra.util'; import { asVector, isValidInteger, paginatedBuilder, searchAssetBuilder } from '../infra.utils'; -import { vectorExt } from '../database.config'; @Injectable() export class SearchRepository implements ISearchRepository { @@ -70,23 +70,37 @@ export class SearchRepository implements ISearchRepository { } @GenerateSql({ - params: [{ userIds: [DummyValue.UUID], embedding: Array.from({ length: 512 }, Math.random), numResults: 100 }], + params: [ + { + pagination: { page: 0, size: 100 }, + options: { + date: { takenAfter: DummyValue.DATE }, + embedding: Array.from({ length: 512 }, Math.random), + exif: { cameraModel: DummyValue.STRING }, + relation: { withStacked: true }, + status: { isFavorite: true }, + userIds: [DummyValue.UUID], + }, + }, + ], }) - async searchCLIP(pagination: SearchPaginationOptions, options: SmartSearchOptions): Paginated { + async searchCLIP( + pagination: SearchPaginationOptions, + { embedding, userIds, ...options }: SmartSearchOptions, + ): Paginated { let results: PaginationResult = { items: [], hasNextPage: false }; await this.assetRepository.manager.transaction(async (manager) => { - await manager.query(`SET LOCAL vectors.search_mode=vbase`); let builder = manager.createQueryBuilder(AssetEntity, 'asset'); builder = searchAssetBuilder(builder, options); builder - .innerJoin('a.smartSearch', 's') - .andWhere('a.ownerId IN (:...userIds )') - .orderBy('s.embedding <=> :embedding') - .setParameters({ userIds: options.userIds, embedding: asVector(options.embedding) }); + .innerJoin('asset.smartSearch', 'search') + .andWhere('asset.ownerId IN (:...userIds )') + .orderBy('search.embedding <=> :embedding') + .setParameters({ userIds, embedding: asVector(embedding) }); await manager.query(this.getRuntimeConfig(pagination.size)); - return paginatedBuilder(builder, { + results = await paginatedBuilder(builder, { mode: PaginationMode.LIMIT_OFFSET, skip: pagination.page * pagination.size, take: pagination.size, diff --git a/server/src/infra/sql/search.repository.sql b/server/src/infra/sql/search.repository.sql new file mode 100644 index 0000000000000..73995316c26f3 --- /dev/null +++ b/server/src/infra/sql/search.repository.sql @@ -0,0 +1,43 @@ +-- NOTE: This file is auto generated by ./sql-generator + +-- SearchRepository.searchFaces +START TRANSACTION +SET + LOCAL vectors.enable_prefilter = on; + +SET + LOCAL vectors.search_mode = basic; + +SET + LOCAL vectors.hnsw_ef_search = 100 +WITH + "cte" AS ( + SELECT + "faces"."id" AS "id", + "faces"."assetId" AS "assetId", + "faces"."personId" AS "personId", + "faces"."imageWidth" AS "imageWidth", + "faces"."imageHeight" AS "imageHeight", + "faces"."boundingBoxX1" AS "boundingBoxX1", + "faces"."boundingBoxY1" AS "boundingBoxY1", + "faces"."boundingBoxX2" AS "boundingBoxX2", + "faces"."boundingBoxY2" AS "boundingBoxY2", + "faces"."embedding" <= > $1 AS "distance" + FROM + "asset_faces" "faces" + INNER JOIN "assets" "asset" ON "asset"."id" = "faces"."assetId" + AND ("asset"."deletedAt" IS NULL) + WHERE + "asset"."ownerId" IN ($2) + ORDER BY + "faces"."embedding" <= > $1 ASC + LIMIT + 100 + ) +SELECT + res.* +FROM + "cte" "res" +WHERE + res.distance <= $3 +COMMIT diff --git a/server/src/infra/sql/smart.info.repository.sql b/server/src/infra/sql/smart.info.repository.sql deleted file mode 100644 index 3151aede73a59..0000000000000 --- a/server/src/infra/sql/smart.info.repository.sql +++ /dev/null @@ -1,129 +0,0 @@ --- NOTE: This file is auto generated by ./sql-generator - --- SmartInfoRepository.searchCLIP -START TRANSACTION -SET - LOCAL vectors.enable_prefilter = on; - -SET - LOCAL vectors.search_mode = vbase; - -SET - LOCAL vectors.hnsw_ef_search = 100; -SELECT - "a"."id" AS "a_id", - "a"."deviceAssetId" AS "a_deviceAssetId", - "a"."ownerId" AS "a_ownerId", - "a"."libraryId" AS "a_libraryId", - "a"."deviceId" AS "a_deviceId", - "a"."type" AS "a_type", - "a"."originalPath" AS "a_originalPath", - "a"."resizePath" AS "a_resizePath", - "a"."webpPath" AS "a_webpPath", - "a"."thumbhash" AS "a_thumbhash", - "a"."encodedVideoPath" AS "a_encodedVideoPath", - "a"."createdAt" AS "a_createdAt", - "a"."updatedAt" AS "a_updatedAt", - "a"."deletedAt" AS "a_deletedAt", - "a"."fileCreatedAt" AS "a_fileCreatedAt", - "a"."localDateTime" AS "a_localDateTime", - "a"."fileModifiedAt" AS "a_fileModifiedAt", - "a"."isFavorite" AS "a_isFavorite", - "a"."isArchived" AS "a_isArchived", - "a"."isExternal" AS "a_isExternal", - "a"."isReadOnly" AS "a_isReadOnly", - "a"."isOffline" AS "a_isOffline", - "a"."checksum" AS "a_checksum", - "a"."duration" AS "a_duration", - "a"."isVisible" AS "a_isVisible", - "a"."livePhotoVideoId" AS "a_livePhotoVideoId", - "a"."originalFileName" AS "a_originalFileName", - "a"."sidecarPath" AS "a_sidecarPath", - "a"."stackId" AS "a_stackId", - "e"."assetId" AS "e_assetId", - "e"."description" AS "e_description", - "e"."exifImageWidth" AS "e_exifImageWidth", - "e"."exifImageHeight" AS "e_exifImageHeight", - "e"."fileSizeInByte" AS "e_fileSizeInByte", - "e"."orientation" AS "e_orientation", - "e"."dateTimeOriginal" AS "e_dateTimeOriginal", - "e"."modifyDate" AS "e_modifyDate", - "e"."timeZone" AS "e_timeZone", - "e"."latitude" AS "e_latitude", - "e"."longitude" AS "e_longitude", - "e"."projectionType" AS "e_projectionType", - "e"."city" AS "e_city", - "e"."livePhotoCID" AS "e_livePhotoCID", - "e"."autoStackId" AS "e_autoStackId", - "e"."state" AS "e_state", - "e"."country" AS "e_country", - "e"."make" AS "e_make", - "e"."model" AS "e_model", - "e"."lensModel" AS "e_lensModel", - "e"."fNumber" AS "e_fNumber", - "e"."focalLength" AS "e_focalLength", - "e"."iso" AS "e_iso", - "e"."exposureTime" AS "e_exposureTime", - "e"."profileDescription" AS "e_profileDescription", - "e"."colorspace" AS "e_colorspace", - "e"."bitsPerSample" AS "e_bitsPerSample", - "e"."fps" AS "e_fps" -FROM - "assets" "a" - INNER JOIN "smart_search" "s" ON "s"."assetId" = "a"."id" - LEFT JOIN "exif" "e" ON "e"."assetId" = "a"."id" -WHERE - ( - "a"."ownerId" IN ($1) - AND "a"."isArchived" = false - AND "a"."isVisible" = true - AND "a"."fileCreatedAt" < NOW() - ) - AND ("a"."deletedAt" IS NULL) -ORDER BY - "s"."embedding" <= > $2 ASC -LIMIT - 100 -COMMIT - --- SmartInfoRepository.searchFaces -START TRANSACTION -SET - LOCAL vectors.enable_prefilter = on; - -SET - LOCAL vectors.search_mode = vbase; - -SET - LOCAL vectors.hnsw_ef_search = 100; -WITH - "cte" AS ( - SELECT - "faces"."id" AS "id", - "faces"."assetId" AS "assetId", - "faces"."personId" AS "personId", - "faces"."imageWidth" AS "imageWidth", - "faces"."imageHeight" AS "imageHeight", - "faces"."boundingBoxX1" AS "boundingBoxX1", - "faces"."boundingBoxY1" AS "boundingBoxY1", - "faces"."boundingBoxX2" AS "boundingBoxX2", - "faces"."boundingBoxY2" AS "boundingBoxY2", - "faces"."embedding" <= > $1 AS "distance" - FROM - "asset_faces" "faces" - INNER JOIN "assets" "asset" ON "asset"."id" = "faces"."assetId" - AND ("asset"."deletedAt" IS NULL) - WHERE - "asset"."ownerId" IN ($2) - ORDER BY - "faces"."embedding" <= > $1 ASC - LIMIT - 100 - ) -SELECT - res.* -FROM - "cte" "res" -WHERE - res.distance <= $3 -COMMIT From b7f86015c87e1fbf3bf09fc83e7bfc7428e40cab Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Fri, 9 Feb 2024 21:39:24 -0500 Subject: [PATCH 12/38] useless omitBy --- server/src/infra/infra.utils.ts | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/server/src/infra/infra.utils.ts b/server/src/infra/infra.utils.ts index fee1a5f5e6330..635ab4c67d5e7 100644 --- a/server/src/infra/infra.utils.ts +++ b/server/src/infra/infra.utils.ts @@ -13,8 +13,8 @@ import { SelectQueryBuilder, } from 'typeorm'; import { PaginatedBuilderOptions, PaginationMode, PaginationResult, chunks, setUnion } from '../domain/domain.util'; -import { DATABASE_PARAMETER_CHUNK_SIZE } from './infra.util'; import { AssetEntity } from './entities'; +import { DATABASE_PARAMETER_CHUNK_SIZE } from './infra.util'; /** * Allows optional values unlike the regular Between and uses MoreThanOrEqual @@ -127,17 +127,12 @@ export function searchAssetBuilder( { date, id, exif, path, relation, status }: AssetSearchBuilderOptions, ): SelectQueryBuilder { if (date) { - builder.andWhere( - _.omitBy( - { - createdAt: OptionalBetween(date.createdAfter, date.createdBefore), - updatedAt: OptionalBetween(date.updatedAfter, date.updatedBefore), - deletedAt: OptionalBetween(date.trashedAfter, date.trashedBefore), - fileCreatedAt: OptionalBetween(date.takenAfter, date.takenBefore), - }, - _.isUndefined, - ), - ); + builder.andWhere({ + createdAt: OptionalBetween(date.createdAfter, date.createdBefore), + updatedAt: OptionalBetween(date.updatedAfter, date.updatedBefore), + deletedAt: OptionalBetween(date.trashedAfter, date.trashedBefore), + fileCreatedAt: OptionalBetween(date.takenAfter, date.takenBefore), + }); } if (exif) { @@ -192,7 +187,9 @@ export function searchAssetBuilder( builder .leftJoinAndSelect(`${builder.alias}.stack`, 'stack') .leftJoinAndSelect('stack.assets', 'stackedAssets') - .andWhere(new Brackets((qb) => qb.where(`stack.primaryAssetId = ${builder.alias}.id`).orWhere('asset.stackId IS NULL'))); + .andWhere( + new Brackets((qb) => qb.where(`stack.primaryAssetId = ${builder.alias}.id`).orWhere('asset.stackId IS NULL')), + ); } } From 9b63f69508af5f8f09ded1fa1f73747de3865a38 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Fri, 9 Feb 2024 21:51:45 -0500 Subject: [PATCH 13/38] unnecessary handling --- server/src/infra/repositories/search.repository.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/server/src/infra/repositories/search.repository.ts b/server/src/infra/repositories/search.repository.ts index e0ec24c83710a..019c18e61fb1f 100644 --- a/server/src/infra/repositories/search.repository.ts +++ b/server/src/infra/repositories/search.repository.ts @@ -41,10 +41,6 @@ export class SearchRepository implements ISearchRepository { async init(modelName: string): Promise { const { dimSize } = getCLIPModelInfo(modelName); - if (dimSize == null) { - throw new Error(`Invalid CLIP model name: ${modelName}`); - } - const curDimSize = await this.getDimSize(); this.logger.verbose(`Current database CLIP dimension size is ${curDimSize}`); From b46a4d5ac4f71bdf3da1ea46f6a71ef16943f8d4 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Fri, 9 Feb 2024 21:53:48 -0500 Subject: [PATCH 14/38] add sql decorator for `searchAssets` --- server/src/infra/repositories/search.repository.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/server/src/infra/repositories/search.repository.ts b/server/src/infra/repositories/search.repository.ts index 019c18e61fb1f..0281ce1555ddc 100644 --- a/server/src/infra/repositories/search.repository.ts +++ b/server/src/infra/repositories/search.repository.ts @@ -50,6 +50,20 @@ export class SearchRepository implements ISearchRepository { } } + @GenerateSql({ + params: [ + { + pagination: { page: 0, size: 100 }, + options: { + date: { takenAfter: DummyValue.DATE }, + exif: { cameraModel: DummyValue.STRING }, + id: { ownerId: DummyValue.UUID }, + relation: { withStacked: true }, + status: { isFavorite: true }, + }, + }, + ], + }) async searchAssets(pagination: SearchPaginationOptions, options: AssetSearchOptions): Paginated { let builder = this.assetRepository.createQueryBuilder('asset'); builder = searchAssetBuilder(builder, options); From f10e2976c4a36880a31f366fab3c7e633b4e0971 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Fri, 9 Feb 2024 22:37:36 -0500 Subject: [PATCH 15/38] fixed search builder --- server/src/infra/infra.utils.ts | 6 +- .../infra/repositories/search.repository.ts | 32 ++- server/src/infra/sql-generator/index.ts | 2 +- server/src/infra/sql/search.repository.sql | 196 ++++++++++++++++++ 4 files changed, 215 insertions(+), 21 deletions(-) diff --git a/server/src/infra/infra.utils.ts b/server/src/infra/infra.utils.ts index 635ab4c67d5e7..a40f7485d57f7 100644 --- a/server/src/infra/infra.utils.ts +++ b/server/src/infra/infra.utils.ts @@ -137,9 +137,9 @@ export function searchAssetBuilder( if (exif) { const exifWhere = _.omitBy(exif, _.isUndefined); - builder.andWhere(exifWhere); if (Object.keys(exifWhere).length > 0) { builder.leftJoin(`${builder.alias}.exifInfo`, 'exifInfo'); + builder.andWhere(Object.entries(exifWhere).map(([key, value]) => ({ [`exifInfo.${key}`]: value }))); } } @@ -152,7 +152,9 @@ export function searchAssetBuilder( } if (status) { - const { isEncoded, isMotion, ...otherStatuses } = status; + const { isEncoded, isMotion, withArchived, withDeleted, ...otherStatuses } = status; + otherStatuses.isArchived ??= !!withArchived; + builder.andWhere(_.omitBy(otherStatuses, _.isUndefined)); if (isEncoded && !path?.encodedVideoPath) { diff --git a/server/src/infra/repositories/search.repository.ts b/server/src/infra/repositories/search.repository.ts index 0281ce1555ddc..e5cfbc9ef89e9 100644 --- a/server/src/infra/repositories/search.repository.ts +++ b/server/src/infra/repositories/search.repository.ts @@ -52,16 +52,14 @@ export class SearchRepository implements ISearchRepository { @GenerateSql({ params: [ + { page: 0, size: 100 }, { - pagination: { page: 0, size: 100 }, - options: { - date: { takenAfter: DummyValue.DATE }, - exif: { cameraModel: DummyValue.STRING }, - id: { ownerId: DummyValue.UUID }, - relation: { withStacked: true }, - status: { isFavorite: true }, - }, - }, + date: { takenAfter: DummyValue.DATE }, + exif: { lensModel: DummyValue.STRING }, + id: { ownerId: DummyValue.UUID }, + relation: { withStacked: true }, + status: { isFavorite: true }, + } ], }) async searchAssets(pagination: SearchPaginationOptions, options: AssetSearchOptions): Paginated { @@ -81,16 +79,14 @@ export class SearchRepository implements ISearchRepository { @GenerateSql({ params: [ + { page: 0, size: 100 }, { - pagination: { page: 0, size: 100 }, - options: { - date: { takenAfter: DummyValue.DATE }, - embedding: Array.from({ length: 512 }, Math.random), - exif: { cameraModel: DummyValue.STRING }, - relation: { withStacked: true }, - status: { isFavorite: true }, - userIds: [DummyValue.UUID], - }, + date: { takenAfter: DummyValue.DATE }, + embedding: Array.from({ length: 512 }, Math.random), + exif: { lensModel: DummyValue.STRING }, + relation: { withStacked: true }, + status: { isFavorite: true }, + userIds: [DummyValue.UUID], }, ], }) diff --git a/server/src/infra/sql-generator/index.ts b/server/src/infra/sql-generator/index.ts index 38fb283b44000..d95d07d9abd2e 100644 --- a/server/src/infra/sql-generator/index.ts +++ b/server/src/infra/sql-generator/index.ts @@ -142,7 +142,7 @@ class SqlGenerator { this.sqlLogger.clear(); // errors still generate sql, which is all we care about - await target.apply(instance, params).catch(() => null); + await target.apply(instance, params).catch((err: Error) => console.error(`${queryLabel} error: ${err}`)); if (this.sqlLogger.queries.length === 0) { console.warn(`No queries recorded for ${queryLabel}`); diff --git a/server/src/infra/sql/search.repository.sql b/server/src/infra/sql/search.repository.sql index 73995316c26f3..801bb18189df2 100644 --- a/server/src/infra/sql/search.repository.sql +++ b/server/src/infra/sql/search.repository.sql @@ -1,5 +1,201 @@ -- NOTE: This file is auto generated by ./sql-generator +-- SearchRepository.searchAssets +SELECT DISTINCT + "distinctAlias"."asset_id" AS "ids_asset_id" +FROM + ( + SELECT + "asset"."id" AS "asset_id", + "asset"."deviceAssetId" AS "asset_deviceAssetId", + "asset"."ownerId" AS "asset_ownerId", + "asset"."libraryId" AS "asset_libraryId", + "asset"."deviceId" AS "asset_deviceId", + "asset"."type" AS "asset_type", + "asset"."originalPath" AS "asset_originalPath", + "asset"."resizePath" AS "asset_resizePath", + "asset"."webpPath" AS "asset_webpPath", + "asset"."thumbhash" AS "asset_thumbhash", + "asset"."encodedVideoPath" AS "asset_encodedVideoPath", + "asset"."createdAt" AS "asset_createdAt", + "asset"."updatedAt" AS "asset_updatedAt", + "asset"."deletedAt" AS "asset_deletedAt", + "asset"."fileCreatedAt" AS "asset_fileCreatedAt", + "asset"."localDateTime" AS "asset_localDateTime", + "asset"."fileModifiedAt" AS "asset_fileModifiedAt", + "asset"."isFavorite" AS "asset_isFavorite", + "asset"."isArchived" AS "asset_isArchived", + "asset"."isExternal" AS "asset_isExternal", + "asset"."isReadOnly" AS "asset_isReadOnly", + "asset"."isOffline" AS "asset_isOffline", + "asset"."checksum" AS "asset_checksum", + "asset"."duration" AS "asset_duration", + "asset"."isVisible" AS "asset_isVisible", + "asset"."livePhotoVideoId" AS "asset_livePhotoVideoId", + "asset"."originalFileName" AS "asset_originalFileName", + "asset"."sidecarPath" AS "asset_sidecarPath", + "asset"."stackId" AS "asset_stackId", + "stack"."id" AS "stack_id", + "stack"."primaryAssetId" AS "stack_primaryAssetId", + "stackedAssets"."id" AS "stackedAssets_id", + "stackedAssets"."deviceAssetId" AS "stackedAssets_deviceAssetId", + "stackedAssets"."ownerId" AS "stackedAssets_ownerId", + "stackedAssets"."libraryId" AS "stackedAssets_libraryId", + "stackedAssets"."deviceId" AS "stackedAssets_deviceId", + "stackedAssets"."type" AS "stackedAssets_type", + "stackedAssets"."originalPath" AS "stackedAssets_originalPath", + "stackedAssets"."resizePath" AS "stackedAssets_resizePath", + "stackedAssets"."webpPath" AS "stackedAssets_webpPath", + "stackedAssets"."thumbhash" AS "stackedAssets_thumbhash", + "stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath", + "stackedAssets"."createdAt" AS "stackedAssets_createdAt", + "stackedAssets"."updatedAt" AS "stackedAssets_updatedAt", + "stackedAssets"."deletedAt" AS "stackedAssets_deletedAt", + "stackedAssets"."fileCreatedAt" AS "stackedAssets_fileCreatedAt", + "stackedAssets"."localDateTime" AS "stackedAssets_localDateTime", + "stackedAssets"."fileModifiedAt" AS "stackedAssets_fileModifiedAt", + "stackedAssets"."isFavorite" AS "stackedAssets_isFavorite", + "stackedAssets"."isArchived" AS "stackedAssets_isArchived", + "stackedAssets"."isExternal" AS "stackedAssets_isExternal", + "stackedAssets"."isReadOnly" AS "stackedAssets_isReadOnly", + "stackedAssets"."isOffline" AS "stackedAssets_isOffline", + "stackedAssets"."checksum" AS "stackedAssets_checksum", + "stackedAssets"."duration" AS "stackedAssets_duration", + "stackedAssets"."isVisible" AS "stackedAssets_isVisible", + "stackedAssets"."livePhotoVideoId" AS "stackedAssets_livePhotoVideoId", + "stackedAssets"."originalFileName" AS "stackedAssets_originalFileName", + "stackedAssets"."sidecarPath" AS "stackedAssets_sidecarPath", + "stackedAssets"."stackId" AS "stackedAssets_stackId" + FROM + "assets" "asset" + LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id" + LEFT JOIN "asset_stack" "stack" ON "stack"."id" = "asset"."stackId" + LEFT JOIN "assets" "stackedAssets" ON "stackedAssets"."stackId" = "stack"."id" + AND ("stackedAssets"."deletedAt" IS NULL) + WHERE + ( + ( + "asset"."createdAt" = $1 + AND "asset"."updatedAt" = $2 + AND "asset"."deletedAt" = $3 + AND "asset"."fileCreatedAt" >= $4 + ) + AND "exifInfo"."lensModel" = $5 + AND "asset"."ownerId" = $6 + AND ( + "asset"."isFavorite" = $7 + AND "asset"."isArchived" = $8 + ) + AND ( + "stack"."primaryAssetId" = "asset"."id" + OR "asset"."stackId" IS NULL + ) + ) + AND ("asset"."deletedAt" IS NULL) + ) "distinctAlias" +ORDER BY + "asset_id" ASC +OFFSET + 101 + +-- SearchRepository.searchCLIP +START TRANSACTION +SET + LOCAL hnsw.ef_search = 1000; +SELECT + "asset"."id" AS "asset_id", + "asset"."deviceAssetId" AS "asset_deviceAssetId", + "asset"."ownerId" AS "asset_ownerId", + "asset"."libraryId" AS "asset_libraryId", + "asset"."deviceId" AS "asset_deviceId", + "asset"."type" AS "asset_type", + "asset"."originalPath" AS "asset_originalPath", + "asset"."resizePath" AS "asset_resizePath", + "asset"."webpPath" AS "asset_webpPath", + "asset"."thumbhash" AS "asset_thumbhash", + "asset"."encodedVideoPath" AS "asset_encodedVideoPath", + "asset"."createdAt" AS "asset_createdAt", + "asset"."updatedAt" AS "asset_updatedAt", + "asset"."deletedAt" AS "asset_deletedAt", + "asset"."fileCreatedAt" AS "asset_fileCreatedAt", + "asset"."localDateTime" AS "asset_localDateTime", + "asset"."fileModifiedAt" AS "asset_fileModifiedAt", + "asset"."isFavorite" AS "asset_isFavorite", + "asset"."isArchived" AS "asset_isArchived", + "asset"."isExternal" AS "asset_isExternal", + "asset"."isReadOnly" AS "asset_isReadOnly", + "asset"."isOffline" AS "asset_isOffline", + "asset"."checksum" AS "asset_checksum", + "asset"."duration" AS "asset_duration", + "asset"."isVisible" AS "asset_isVisible", + "asset"."livePhotoVideoId" AS "asset_livePhotoVideoId", + "asset"."originalFileName" AS "asset_originalFileName", + "asset"."sidecarPath" AS "asset_sidecarPath", + "asset"."stackId" AS "asset_stackId", + "stack"."id" AS "stack_id", + "stack"."primaryAssetId" AS "stack_primaryAssetId", + "stackedAssets"."id" AS "stackedAssets_id", + "stackedAssets"."deviceAssetId" AS "stackedAssets_deviceAssetId", + "stackedAssets"."ownerId" AS "stackedAssets_ownerId", + "stackedAssets"."libraryId" AS "stackedAssets_libraryId", + "stackedAssets"."deviceId" AS "stackedAssets_deviceId", + "stackedAssets"."type" AS "stackedAssets_type", + "stackedAssets"."originalPath" AS "stackedAssets_originalPath", + "stackedAssets"."resizePath" AS "stackedAssets_resizePath", + "stackedAssets"."webpPath" AS "stackedAssets_webpPath", + "stackedAssets"."thumbhash" AS "stackedAssets_thumbhash", + "stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath", + "stackedAssets"."createdAt" AS "stackedAssets_createdAt", + "stackedAssets"."updatedAt" AS "stackedAssets_updatedAt", + "stackedAssets"."deletedAt" AS "stackedAssets_deletedAt", + "stackedAssets"."fileCreatedAt" AS "stackedAssets_fileCreatedAt", + "stackedAssets"."localDateTime" AS "stackedAssets_localDateTime", + "stackedAssets"."fileModifiedAt" AS "stackedAssets_fileModifiedAt", + "stackedAssets"."isFavorite" AS "stackedAssets_isFavorite", + "stackedAssets"."isArchived" AS "stackedAssets_isArchived", + "stackedAssets"."isExternal" AS "stackedAssets_isExternal", + "stackedAssets"."isReadOnly" AS "stackedAssets_isReadOnly", + "stackedAssets"."isOffline" AS "stackedAssets_isOffline", + "stackedAssets"."checksum" AS "stackedAssets_checksum", + "stackedAssets"."duration" AS "stackedAssets_duration", + "stackedAssets"."isVisible" AS "stackedAssets_isVisible", + "stackedAssets"."livePhotoVideoId" AS "stackedAssets_livePhotoVideoId", + "stackedAssets"."originalFileName" AS "stackedAssets_originalFileName", + "stackedAssets"."sidecarPath" AS "stackedAssets_sidecarPath", + "stackedAssets"."stackId" AS "stackedAssets_stackId" +FROM + "assets" "asset" + LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id" + LEFT JOIN "asset_stack" "stack" ON "stack"."id" = "asset"."stackId" + LEFT JOIN "assets" "stackedAssets" ON "stackedAssets"."stackId" = "stack"."id" + AND ("stackedAssets"."deletedAt" IS NULL) + INNER JOIN "smart_search" "search" ON "search"."assetId" = "asset"."id" +WHERE + ( + ( + "asset"."createdAt" = $1 + AND "asset"."updatedAt" = $2 + AND "asset"."deletedAt" = $3 + AND "asset"."fileCreatedAt" >= $4 + ) + AND "exifInfo"."lensModel" = $5 + AND ( + "asset"."isFavorite" = $6 + AND "asset"."isArchived" = $7 + ) + AND ( + "stack"."primaryAssetId" = "asset"."id" + OR "asset"."stackId" IS NULL + ) + AND "asset"."ownerId" IN ($8) + ) + AND ("asset"."deletedAt" IS NULL) +ORDER BY + "search"."embedding" <= > $9 ASC +LIMIT + 101 +COMMIT + -- SearchRepository.searchFaces START TRANSACTION SET From 32974064bd8c25be4a9f31412aef1fd305c04da7 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Fri, 9 Feb 2024 22:41:43 -0500 Subject: [PATCH 16/38] fixed sql --- server/src/infra/repositories/api-key.repository.ts | 2 +- server/src/infra/repositories/asset.repository.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/infra/repositories/api-key.repository.ts b/server/src/infra/repositories/api-key.repository.ts index 71226a5376ac5..b7ebc303dc03e 100644 --- a/server/src/infra/repositories/api-key.repository.ts +++ b/server/src/infra/repositories/api-key.repository.ts @@ -42,7 +42,7 @@ export class ApiKeyRepository implements IKeyRepository { return this.repository.findOne({ where: { userId, id } }); } - @GenerateSql({ params: [DummyValue.STRING] }) + @GenerateSql({ params: [DummyValue.UUID] }) getByUserId(userId: string): Promise { return this.repository.find({ where: { userId }, order: { createdAt: 'DESC' } }); } diff --git a/server/src/infra/repositories/asset.repository.ts b/server/src/infra/repositories/asset.repository.ts index f38d293e1a331..9f6db1071eacd 100644 --- a/server/src/infra/repositories/asset.repository.ts +++ b/server/src/infra/repositories/asset.repository.ts @@ -277,7 +277,7 @@ export class AssetRepository implements IAssetRepository { await this.repository.remove(asset); } - @GenerateSql({ params: [[DummyValue.UUID], DummyValue.BUFFER] }) + @GenerateSql({ params: [DummyValue.UUID, DummyValue.BUFFER] }) getByChecksum(userId: string, checksum: Buffer): Promise { return this.repository.findOne({ where: { ownerId: userId, checksum } }); } From 8a6296c5531dac1e5af0d74715a817ec117cd8cf Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Fri, 9 Feb 2024 22:49:31 -0500 Subject: [PATCH 17/38] remove mock method --- server/test/repositories/asset.repository.mock.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index 9d778883de980..1c98b78c9edd7 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -32,7 +32,6 @@ export const newAssetRepositoryMock = (): jest.Mocked => { getTimeBuckets: jest.fn(), restoreAll: jest.fn(), softDeleteAll: jest.fn(), - search: jest.fn(), getAssetIdByCity: jest.fn(), getAssetIdByTag: jest.fn(), searchMetadata: jest.fn(), From 4129a7711e74baa73c0c433f81b55203be1de505 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Fri, 9 Feb 2024 22:59:11 -0500 Subject: [PATCH 18/38] linting --- web/src/routes/(user)/search/+page.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/routes/(user)/search/+page.ts b/web/src/routes/(user)/search/+page.ts index 271f8103693a5..37a502a2b6cb9 100644 --- a/web/src/routes/(user)/search/+page.ts +++ b/web/src/routes/(user)/search/+page.ts @@ -11,7 +11,7 @@ export const load = (async (data) => { let results: SearchResponseDto | null = null; if (term) { const res = await api.searchApi.search({}, { params: data.url.searchParams }); - const assetItems: Array = (data as any).results?.assets.items; + const assetItems: AssetResponseDto[] = (data as unknown as { results: SearchResponseDto }).results?.assets.items; console.log('assetItems', assetItems); const assets = { ...res.data.assets, From 6b7a32ff8a7abe1feea04dd309042cf15a3e9b8b Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Sat, 10 Feb 2024 14:12:05 -0500 Subject: [PATCH 19/38] fixed pagination --- server/src/infra/infra.utils.ts | 2 +- server/src/infra/sql/search.repository.sql | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/server/src/infra/infra.utils.ts b/server/src/infra/infra.utils.ts index a40f7485d57f7..7ac56478f873c 100644 --- a/server/src/infra/infra.utils.ts +++ b/server/src/infra/infra.utils.ts @@ -69,7 +69,7 @@ export async function paginatedBuilder( if (mode === PaginationMode.LIMIT_OFFSET) { qb.limit(take + 1).offset(skip); } else { - qb.skip(take + 1).take(skip); + qb.take(take + 1).skip(skip); } const items = await qb.getMany(); diff --git a/server/src/infra/sql/search.repository.sql b/server/src/infra/sql/search.repository.sql index 801bb18189df2..3849bfad3d77f 100644 --- a/server/src/infra/sql/search.repository.sql +++ b/server/src/infra/sql/search.repository.sql @@ -95,13 +95,19 @@ FROM ) "distinctAlias" ORDER BY "asset_id" ASC -OFFSET +LIMIT 101 -- SearchRepository.searchCLIP START TRANSACTION SET - LOCAL hnsw.ef_search = 1000; + LOCAL vectors.enable_prefilter = on; + +SET + LOCAL vectors.search_mode = vbase; + +SET + LOCAL vectors.hnsw_ef_search = 100; SELECT "asset"."id" AS "asset_id", "asset"."deviceAssetId" AS "asset_deviceAssetId", From 15ea21c655f43c03fa2f01f315cdbb83c9d7de64 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Sat, 10 Feb 2024 14:56:55 -0500 Subject: [PATCH 20/38] fixed unit tests --- server/src/domain/asset/asset.service.spec.ts | 5 +++ .../domain/download/download.service.spec.ts | 4 +- server/src/domain/media/media.service.spec.ts | 2 +- .../src/domain/person/person.service.spec.ts | 26 +++++------ .../src/domain/search/search.service.spec.ts | 45 +++++++++++-------- .../smart-info/smart-info.service.spec.ts | 14 +++--- 6 files changed, 55 insertions(+), 41 deletions(-) diff --git a/server/src/domain/asset/asset.service.spec.ts b/server/src/domain/asset/asset.service.spec.ts index a6b2cde3e88eb..9672330c796d8 100644 --- a/server/src/domain/asset/asset.service.spec.ts +++ b/server/src/domain/asset/asset.service.spec.ts @@ -12,6 +12,7 @@ import { newCommunicationRepositoryMock, newJobRepositoryMock, newPartnerRepositoryMock, + newSearchRepositoryMock, newStorageRepositoryMock, newSystemConfigRepositoryMock, newUserRepositoryMock, @@ -26,6 +27,7 @@ import { ICommunicationRepository, IJobRepository, IPartnerRepository, + ISearchRepository, IStorageRepository, ISystemConfigRepository, IUserRepository, @@ -164,6 +166,7 @@ describe(AssetService.name, () => { let configMock: jest.Mocked; let partnerMock: jest.Mocked; let assetStackMock: jest.Mocked; + let searchMock: jest.Mocked; it('should work', () => { expect(sut).toBeDefined(); @@ -179,6 +182,7 @@ describe(AssetService.name, () => { configMock = newSystemConfigRepositoryMock(); partnerMock = newPartnerRepositoryMock(); assetStackMock = newAssetStackRepositoryMock(); + searchMock = newSearchRepositoryMock(); sut = new AssetService( accessMock, @@ -190,6 +194,7 @@ describe(AssetService.name, () => { communicationMock, partnerMock, assetStackMock, + searchMock, ); when(assetMock.getById) diff --git a/server/src/domain/download/download.service.spec.ts b/server/src/domain/download/download.service.spec.ts index f59374d706f72..2d74faba3e622 100644 --- a/server/src/domain/download/download.service.spec.ts +++ b/server/src/domain/download/download.service.spec.ts @@ -163,7 +163,9 @@ describe(DownloadService.name, () => { ); expect(assetMock.getByUserId).toHaveBeenCalledWith({ take: 2500, skip: 0 }, authStub.admin.user.id, { - isVisible: true, + status: { + isVisible: true, + }, }); }); diff --git a/server/src/domain/media/media.service.spec.ts b/server/src/domain/media/media.service.spec.ts index 6406b2887a67a..74e06fbed2a90 100644 --- a/server/src/domain/media/media.service.spec.ts +++ b/server/src/domain/media/media.service.spec.ts @@ -411,7 +411,7 @@ describe(MediaService.name, () => { await sut.handleQueueVideoConversion({ force: true }); - expect(assetMock.getAll).toHaveBeenCalledWith({ skip: 0, take: 1000 }, { type: AssetType.VIDEO }); + expect(assetMock.getAll).toHaveBeenCalledWith({ skip: 0, take: 1000 }, { status: { type: AssetType.VIDEO } }); expect(assetMock.getWithout).not.toHaveBeenCalled(); expect(jobMock.queueAll).toHaveBeenCalledWith([ { diff --git a/server/src/domain/person/person.service.spec.ts b/server/src/domain/person/person.service.spec.ts index 63066797e5368..5da8666016080 100644 --- a/server/src/domain/person/person.service.spec.ts +++ b/server/src/domain/person/person.service.spec.ts @@ -13,7 +13,7 @@ import { newMediaRepositoryMock, newMoveRepositoryMock, newPersonRepositoryMock, - newSmartInfoRepositoryMock, + newSearchRepositoryMock, newStorageRepositoryMock, newSystemConfigRepositoryMock, personStub, @@ -76,7 +76,7 @@ describe(PersonService.name, () => { let moveMock: jest.Mocked; let personMock: jest.Mocked; let storageMock: jest.Mocked; - let smartInfoMock: jest.Mocked; + let searchMock: jest.Mocked; let cryptoMock: jest.Mocked; let sut: PersonService; @@ -90,7 +90,7 @@ describe(PersonService.name, () => { mediaMock = newMediaRepositoryMock(); personMock = newPersonRepositoryMock(); storageMock = newStorageRepositoryMock(); - smartInfoMock = newSmartInfoRepositoryMock(); + searchMock = newSearchRepositoryMock(); cryptoMock = newCryptoRepositoryMock(); sut = new PersonService( accessMock, @@ -102,7 +102,7 @@ describe(PersonService.name, () => { configMock, storageMock, jobMock, - smartInfoMock, + searchMock, cryptoMock, ); @@ -752,7 +752,7 @@ describe(PersonService.name, () => { it('should create a face with no person and queue recognition job', async () => { personMock.createFaces.mockResolvedValue([faceStub.face1.id]); machineLearningMock.detectFaces.mockResolvedValue([detectFaceMock]); - smartInfoMock.searchFaces.mockResolvedValue([{ face: faceStub.face1, distance: 0.7 }]); + searchMock.searchFaces.mockResolvedValue([{ face: faceStub.face1, distance: 0.7 }]); assetMock.getByIds.mockResolvedValue([assetStub.image]); const face = { assetId: 'asset-id', @@ -823,7 +823,7 @@ describe(PersonService.name, () => { configMock.load.mockResolvedValue([ { key: SystemConfigKey.MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_FACES, value: 1 }, ]); - smartInfoMock.searchFaces.mockResolvedValue(faces); + searchMock.searchFaces.mockResolvedValue(faces); personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); personMock.create.mockResolvedValue(faceStub.primaryFace1.person); @@ -850,7 +850,7 @@ describe(PersonService.name, () => { configMock.load.mockResolvedValue([ { key: SystemConfigKey.MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_FACES, value: 1 }, ]); - smartInfoMock.searchFaces.mockResolvedValue(faces); + searchMock.searchFaces.mockResolvedValue(faces); personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); personMock.create.mockResolvedValue(personStub.withName); @@ -869,14 +869,14 @@ describe(PersonService.name, () => { it('should not queue face with no matches', async () => { const faces = [{ face: faceStub.noPerson1, distance: 0 }] as FaceSearchResult[]; - smartInfoMock.searchFaces.mockResolvedValue(faces); + searchMock.searchFaces.mockResolvedValue(faces); personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); personMock.create.mockResolvedValue(personStub.withName); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id }); expect(jobMock.queue).not.toHaveBeenCalled(); - expect(smartInfoMock.searchFaces).toHaveBeenCalledTimes(1); + expect(searchMock.searchFaces).toHaveBeenCalledTimes(1); expect(personMock.create).not.toHaveBeenCalled(); expect(personMock.reassignFaces).not.toHaveBeenCalled(); }); @@ -890,7 +890,7 @@ describe(PersonService.name, () => { configMock.load.mockResolvedValue([ { key: SystemConfigKey.MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_FACES, value: 3 }, ]); - smartInfoMock.searchFaces.mockResolvedValue(faces); + searchMock.searchFaces.mockResolvedValue(faces); personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); personMock.create.mockResolvedValue(personStub.withName); @@ -900,7 +900,7 @@ describe(PersonService.name, () => { name: JobName.FACIAL_RECOGNITION, data: { id: faceStub.noPerson1.id, deferred: true }, }); - expect(smartInfoMock.searchFaces).toHaveBeenCalledTimes(1); + expect(searchMock.searchFaces).toHaveBeenCalledTimes(1); expect(personMock.create).not.toHaveBeenCalled(); expect(personMock.reassignFaces).not.toHaveBeenCalled(); }); @@ -914,14 +914,14 @@ describe(PersonService.name, () => { configMock.load.mockResolvedValue([ { key: SystemConfigKey.MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_FACES, value: 3 }, ]); - smartInfoMock.searchFaces.mockResolvedValueOnce(faces).mockResolvedValueOnce([]); + searchMock.searchFaces.mockResolvedValueOnce(faces).mockResolvedValueOnce([]); personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.noPerson1); personMock.create.mockResolvedValue(personStub.withName); await sut.handleRecognizeFaces({ id: faceStub.noPerson1.id, deferred: true }); expect(jobMock.queue).not.toHaveBeenCalled(); - expect(smartInfoMock.searchFaces).toHaveBeenCalledTimes(2); + expect(searchMock.searchFaces).toHaveBeenCalledTimes(2); expect(personMock.create).not.toHaveBeenCalled(); expect(personMock.reassignFaces).not.toHaveBeenCalled(); }); diff --git a/server/src/domain/search/search.service.spec.ts b/server/src/domain/search/search.service.spec.ts index 807b5c4722b42..5b52cd0c811fc 100644 --- a/server/src/domain/search/search.service.spec.ts +++ b/server/src/domain/search/search.service.spec.ts @@ -6,7 +6,7 @@ import { newMachineLearningRepositoryMock, newPartnerRepositoryMock, newPersonRepositoryMock, - newSmartInfoRepositoryMock, + newSearchRepositoryMock, newSystemConfigRepositoryMock, personStub, } from '@test'; @@ -30,7 +30,7 @@ describe(SearchService.name, () => { let configMock: jest.Mocked; let machineMock: jest.Mocked; let personMock: jest.Mocked; - let smartInfoMock: jest.Mocked; + let searchMock: jest.Mocked; let partnerMock: jest.Mocked; beforeEach(() => { @@ -38,9 +38,9 @@ describe(SearchService.name, () => { configMock = newSystemConfigRepositoryMock(); machineMock = newMachineLearningRepositoryMock(); personMock = newPersonRepositoryMock(); - smartInfoMock = newSmartInfoRepositoryMock(); + searchMock = newSearchRepositoryMock(); partnerMock = newPartnerRepositoryMock(); - sut = new SearchService(configMock, machineMock, personMock, smartInfoMock, assetMock, partnerMock); + sut = new SearchService(configMock, machineMock, personMock, searchMock, assetMock, partnerMock); }); it('should work', () => { @@ -104,6 +104,7 @@ describe(SearchService.name, () => { count: 1, items: [mapAsset(assetStub.image)], facets: [], + nextPage: null, }, }; @@ -111,13 +112,13 @@ describe(SearchService.name, () => { expect(result).toEqual(expectedResponse); expect(assetMock.searchMetadata).toHaveBeenCalledWith(dto.q, [authStub.user1.user.id], { numResults: 250 }); - expect(smartInfoMock.searchCLIP).not.toHaveBeenCalled(); + expect(searchMock.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]); + searchMock.searchCLIP.mockResolvedValueOnce({ items: [assetStub.image], hasNextPage: false }); machineMock.encodeText.mockResolvedValueOnce(embedding); partnerMock.getAll.mockResolvedValueOnce([]); const expectedResponse = { @@ -132,25 +133,28 @@ describe(SearchService.name, () => { count: 1, items: [mapAsset(assetStub.image)], facets: [], + nextPage: null, }, }; 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(searchMock.searchCLIP).toHaveBeenCalledWith( + { page: 0, size: 100 }, + { + userIds: [authStub.user1.user.id], + embedding, + status: { 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]; - smartInfoMock.searchCLIP.mockResolvedValueOnce({ items: [assetStub.image], hasNextPage: false }); + searchMock.searchCLIP.mockResolvedValueOnce({ items: [assetStub.image], hasNextPage: false }); machineMock.encodeText.mockResolvedValueOnce(embedding); partnerMock.getAll.mockResolvedValueOnce([]); const expectedResponse = { @@ -165,18 +169,21 @@ describe(SearchService.name, () => { count: 1, items: [mapAsset(assetStub.image)], facets: [], + nextPage: null, }, }; 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: false, - }); + expect(searchMock.searchCLIP).toHaveBeenCalledWith( + { page: 0, size: 100 }, + { + userIds: [authStub.user1.user.id], + embedding, + status: { withArchived: false }, + }, + ); expect(assetMock.searchMetadata).not.toHaveBeenCalled(); }); diff --git a/server/src/domain/smart-info/smart-info.service.spec.ts b/server/src/domain/smart-info/smart-info.service.spec.ts index 23b2b0faba99c..5da7b7824bb98 100644 --- a/server/src/domain/smart-info/smart-info.service.spec.ts +++ b/server/src/domain/smart-info/smart-info.service.spec.ts @@ -5,7 +5,7 @@ import { newDatabaseRepositoryMock, newJobRepositoryMock, newMachineLearningRepositoryMock, - newSmartInfoRepositoryMock, + newSearchRepositoryMock, newSystemConfigRepositoryMock, } from '@test'; import { JobName } from '../job'; @@ -31,18 +31,18 @@ describe(SmartInfoService.name, () => { let assetMock: jest.Mocked; let configMock: jest.Mocked; let jobMock: jest.Mocked; - let smartMock: jest.Mocked; + let searchMock: jest.Mocked; let machineMock: jest.Mocked; let databaseMock: jest.Mocked; beforeEach(async () => { assetMock = newAssetRepositoryMock(); configMock = newSystemConfigRepositoryMock(); - smartMock = newSmartInfoRepositoryMock(); + searchMock = newSearchRepositoryMock(); jobMock = newJobRepositoryMock(); machineMock = newMachineLearningRepositoryMock(); databaseMock = newDatabaseRepositoryMock(); - sut = new SmartInfoService(assetMock, databaseMock, jobMock, machineMock, smartMock, configMock); + sut = new SmartInfoService(assetMock, databaseMock, jobMock, machineMock, searchMock, configMock); assetMock.getByIds.mockResolvedValue([asset]); }); @@ -102,12 +102,12 @@ describe(SmartInfoService.name, () => { await sut.handleEncodeClip({ id: asset.id }); - expect(smartMock.upsert).not.toHaveBeenCalled(); + expect(searchMock.upsert).not.toHaveBeenCalled(); expect(machineMock.encodeImage).not.toHaveBeenCalled(); }); it('should save the returned objects', async () => { - smartMock.upsert.mockResolvedValue(); + searchMock.upsert.mockResolvedValue(); machineMock.encodeImage.mockResolvedValue([0.01, 0.02, 0.03]); await sut.handleEncodeClip({ id: asset.id }); @@ -117,7 +117,7 @@ describe(SmartInfoService.name, () => { { imagePath: 'path/to/resize.ext' }, { enabled: true, modelName: 'ViT-B-32__openai' }, ); - expect(smartMock.upsert).toHaveBeenCalledWith( + expect(searchMock.upsert).toHaveBeenCalledWith( { assetId: 'asset-1', }, From 993e0456c70eb81937ff7be0e5f451bd204f9f29 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Sat, 10 Feb 2024 15:07:51 -0500 Subject: [PATCH 21/38] formatting --- server/src/domain/asset/asset.service.ts | 26 ++++++++++--------- server/src/domain/trash/trash.service.ts | 8 ++++-- .../infra/repositories/search.repository.ts | 2 +- 3 files changed, 21 insertions(+), 15 deletions(-) diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts index 9c2d6499fd54c..424c0723f7e65 100644 --- a/server/src/domain/asset/asset.service.ts +++ b/server/src/domain/asset/asset.service.ts @@ -89,7 +89,7 @@ export class AssetService { @Inject(IPartnerRepository) private partnerRepository: IPartnerRepository, @Inject(IAssetStackRepository) private assetStackRepository: IAssetStackRepository, @Inject(ISearchRepository) private searchRepository: ISearchRepository, - ) { + ) { this.access = AccessCore.create(accessRepository); this.configCore = SystemConfigCore.create(configRepository); } @@ -104,17 +104,19 @@ export class AssetService { const enumToOrder = { [AssetOrder.ASC]: 'ASC', [AssetOrder.DESC]: 'DESC' } as const; const order = dto.order ? { direction: enumToOrder[dto.order] } : undefined; - const { items } = await this.searchRepository - .searchAssets( - { page: 0, size: 250 }, - { - id: { checksum, ownerId: auth.user.id }, - order, - }); - return items.map((asset) => mapAsset(asset, { - stripMetadata: false, - withStack: true, - })); + const { items } = await this.searchRepository.searchAssets( + { page: 0, size: 250 }, + { + id: { checksum, ownerId: auth.user.id }, + order, + }, + ); + return items.map((asset) => + mapAsset(asset, { + stripMetadata: false, + withStack: true, + }), + ); } canUploadFile({ auth, fieldName, file }: UploadRequest): true { diff --git a/server/src/domain/trash/trash.service.ts b/server/src/domain/trash/trash.service.ts index 3c421162da55e..179bf598030ee 100644 --- a/server/src/domain/trash/trash.service.ts +++ b/server/src/domain/trash/trash.service.ts @@ -33,7 +33,9 @@ export class TrashService { async restore(auth: AuthDto): Promise { const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => - this.assetRepository.getByUserId(pagination, auth.user.id, { date: { trashedBefore: DateTime.now().toJSDate() } }), + this.assetRepository.getByUserId(pagination, auth.user.id, { + date: { trashedBefore: DateTime.now().toJSDate() }, + }), ); for await (const assets of assetPagination) { @@ -44,7 +46,9 @@ export class TrashService { async empty(auth: AuthDto): Promise { const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => - this.assetRepository.getByUserId(pagination, auth.user.id, { date: { trashedBefore: DateTime.now().toJSDate() } }), + this.assetRepository.getByUserId(pagination, auth.user.id, { + date: { trashedBefore: DateTime.now().toJSDate() }, + }), ); for await (const assets of assetPagination) { diff --git a/server/src/infra/repositories/search.repository.ts b/server/src/infra/repositories/search.repository.ts index e5cfbc9ef89e9..01565f43fc176 100644 --- a/server/src/infra/repositories/search.repository.ts +++ b/server/src/infra/repositories/search.repository.ts @@ -59,7 +59,7 @@ export class SearchRepository implements ISearchRepository { id: { ownerId: DummyValue.UUID }, relation: { withStacked: true }, status: { isFavorite: true }, - } + }, ], }) async searchAssets(pagination: SearchPaginationOptions, options: AssetSearchOptions): Paginated { From e325875b858648057c394882e600667f113f1967 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Sat, 10 Feb 2024 17:31:16 -0500 Subject: [PATCH 22/38] fix e2e tests --- server/e2e/api/specs/asset.e2e-spec.ts | 2 +- server/src/domain/asset/asset.service.ts | 53 ++++++++++++++++++- .../domain/repositories/search.repository.ts | 2 +- server/src/infra/infra.utils.ts | 23 +++++--- .../infra/repositories/search.repository.ts | 4 +- 5 files changed, 69 insertions(+), 15 deletions(-) diff --git a/server/e2e/api/specs/asset.e2e-spec.ts b/server/e2e/api/specs/asset.e2e-spec.ts index 6f6bc889037e8..533faf20b23a5 100644 --- a/server/e2e/api/specs/asset.e2e-spec.ts +++ b/server/e2e/api/specs/asset.e2e-spec.ts @@ -478,7 +478,7 @@ describe(`${AssetController.name} (e2e)`, () => { }), }, { - should: 'sohuld search by make', + should: 'should search by make', deferred: () => ({ query: { make: 'Cannon' }, assets: [asset3], diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts index 424c0723f7e65..43e287a76bd71 100644 --- a/server/src/domain/asset/asset.service.ts +++ b/server/src/domain/asset/asset.service.ts @@ -105,10 +105,59 @@ export class AssetService { const enumToOrder = { [AssetOrder.ASC]: 'ASC', [AssetOrder.DESC]: 'DESC' } as const; const order = dto.order ? { direction: enumToOrder[dto.order] } : undefined; const { items } = await this.searchRepository.searchAssets( - { page: 0, size: 250 }, + { page: dto.page ? dto.page - 1 : 0, size: dto.size ?? 250 }, { - id: { checksum, ownerId: auth.user.id }, + date: { + createdAfter: dto.createdAfter, + createdBefore: dto.createdBefore, + takenAfter: dto.takenAfter, + takenBefore: dto.takenBefore, + trashedAfter: dto.trashedAfter, + trashedBefore: dto.trashedBefore, + updatedAfter: dto.updatedAfter, + updatedBefore: dto.updatedBefore, + }, + exif: { + city: dto.city, + country: dto.country, + lensModel: dto.lensModel, + make: dto.make, + model: dto.model, + state: dto.state, + }, + id: { + checksum, + deviceAssetId: dto.deviceAssetId, + deviceId: dto.deviceId, + id: dto.id, + libraryId: dto.libraryId, + ownerId: auth.user.id, + }, order, + path: { + encodedVideoPath: dto.encodedVideoPath, + originalFileName: dto.originalFileName, + originalPath: dto.originalPath, + resizePath: dto.resizePath, + webpPath: dto.webpPath, + }, + relation: { + withExif: dto.withExif, + withPeople: dto.withPeople, + withStacked: dto.withStacked, + }, + status: { + isArchived: dto.isArchived, + isEncoded: dto.isArchived, + isExternal: dto.isExternal, + isFavorite: dto.isFavorite, + isMotion: dto.isMotion, + isOffline: dto.isOffline, + isReadOnly: dto.isReadOnly, + isVisible: dto.isVisible, + type: dto.type, + withDeleted: dto.withDeleted, + }, }, ); return items.map((asset) => diff --git a/server/src/domain/repositories/search.repository.ts b/server/src/domain/repositories/search.repository.ts index 2de961a33e388..8f157585b6a70 100644 --- a/server/src/domain/repositories/search.repository.ts +++ b/server/src/domain/repositories/search.repository.ts @@ -138,8 +138,8 @@ export interface SearchPaginationOptions { export interface AssetSearchOptions { date?: SearchDateOptions; - id?: SearchIDOptions; exif?: SearchExifOptions; + id?: SearchIDOptions; order?: SearchOrderOptions; path?: SearchPathOptions; relation?: SearchRelationOptions; diff --git a/server/src/infra/infra.utils.ts b/server/src/infra/infra.utils.ts index 7ac56478f873c..47de290fe4cb8 100644 --- a/server/src/infra/infra.utils.ts +++ b/server/src/infra/infra.utils.ts @@ -127,19 +127,24 @@ export function searchAssetBuilder( { date, id, exif, path, relation, status }: AssetSearchBuilderOptions, ): SelectQueryBuilder { if (date) { - builder.andWhere({ - createdAt: OptionalBetween(date.createdAfter, date.createdBefore), - updatedAt: OptionalBetween(date.updatedAfter, date.updatedBefore), - deletedAt: OptionalBetween(date.trashedAfter, date.trashedBefore), - fileCreatedAt: OptionalBetween(date.takenAfter, date.takenBefore), - }); + builder.andWhere( + _.omitBy( + { + createdAt: OptionalBetween(date.createdAfter, date.createdBefore), + updatedAt: OptionalBetween(date.updatedAfter, date.updatedBefore), + deletedAt: OptionalBetween(date.trashedAfter, date.trashedBefore), + fileCreatedAt: OptionalBetween(date.takenAfter, date.takenBefore), + }, + _.isUndefined, + ), + ); } if (exif) { const exifWhere = _.omitBy(exif, _.isUndefined); if (Object.keys(exifWhere).length > 0) { builder.leftJoin(`${builder.alias}.exifInfo`, 'exifInfo'); - builder.andWhere(Object.entries(exifWhere).map(([key, value]) => ({ [`exifInfo.${key}`]: value }))); + builder.andWhere({ exifInfo: exifWhere }); } } @@ -153,7 +158,9 @@ export function searchAssetBuilder( if (status) { const { isEncoded, isMotion, withArchived, withDeleted, ...otherStatuses } = status; - otherStatuses.isArchived ??= !!withArchived; + if (withArchived != null) { + otherStatuses.isArchived ??= withArchived; + } builder.andWhere(_.omitBy(otherStatuses, _.isUndefined)); diff --git a/server/src/infra/repositories/search.repository.ts b/server/src/infra/repositories/search.repository.ts index 01565f43fc176..e8a9fa06b0491 100644 --- a/server/src/infra/repositories/search.repository.ts +++ b/server/src/infra/repositories/search.repository.ts @@ -66,9 +66,7 @@ export class SearchRepository implements ISearchRepository { let builder = this.assetRepository.createQueryBuilder('asset'); builder = searchAssetBuilder(builder, options); - if (options.order) { - builder.orderBy('asset.fileCreatedAt', options.order.direction); - } + builder.orderBy('asset.fileCreatedAt', options.order?.direction ? options.order.direction : 'DESC'); return paginatedBuilder(builder, { mode: PaginationMode.SKIP_TAKE, From 0cc54b1f09b4edb4268266fe8b2bef632e6236d2 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Sat, 10 Feb 2024 18:16:25 -0500 Subject: [PATCH 23/38] re-flatten search builder --- server/src/domain/asset/asset.service.ts | 58 +------- server/src/domain/audit/audit.service.ts | 2 +- .../domain/download/download.service.spec.ts | 4 +- .../src/domain/download/download.service.ts | 2 +- server/src/domain/media/media.service.spec.ts | 2 +- server/src/domain/media/media.service.ts | 2 +- server/src/domain/person/person.service.ts | 5 +- .../domain/repositories/search.repository.ts | 31 ++-- .../src/domain/search/search.service.spec.ts | 4 +- server/src/domain/search/search.service.ts | 2 +- server/src/domain/trash/trash.service.ts | 4 +- server/src/infra/infra.utils.ts | 134 +++++++++--------- .../infra/repositories/asset.repository.ts | 4 +- .../infra/repositories/search.repository.ts | 20 +-- 14 files changed, 109 insertions(+), 165 deletions(-) diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts index 43e287a76bd71..67e97a8161eed 100644 --- a/server/src/domain/asset/asset.service.ts +++ b/server/src/domain/asset/asset.service.ts @@ -103,61 +103,13 @@ export class AssetService { } const enumToOrder = { [AssetOrder.ASC]: 'ASC', [AssetOrder.DESC]: 'DESC' } as const; - const order = dto.order ? { direction: enumToOrder[dto.order] } : undefined; const { items } = await this.searchRepository.searchAssets( { page: dto.page ? dto.page - 1 : 0, size: dto.size ?? 250 }, { - date: { - createdAfter: dto.createdAfter, - createdBefore: dto.createdBefore, - takenAfter: dto.takenAfter, - takenBefore: dto.takenBefore, - trashedAfter: dto.trashedAfter, - trashedBefore: dto.trashedBefore, - updatedAfter: dto.updatedAfter, - updatedBefore: dto.updatedBefore, - }, - exif: { - city: dto.city, - country: dto.country, - lensModel: dto.lensModel, - make: dto.make, - model: dto.model, - state: dto.state, - }, - id: { - checksum, - deviceAssetId: dto.deviceAssetId, - deviceId: dto.deviceId, - id: dto.id, - libraryId: dto.libraryId, - ownerId: auth.user.id, - }, - order, - path: { - encodedVideoPath: dto.encodedVideoPath, - originalFileName: dto.originalFileName, - originalPath: dto.originalPath, - resizePath: dto.resizePath, - webpPath: dto.webpPath, - }, - relation: { - withExif: dto.withExif, - withPeople: dto.withPeople, - withStacked: dto.withStacked, - }, - status: { - isArchived: dto.isArchived, - isEncoded: dto.isArchived, - isExternal: dto.isExternal, - isFavorite: dto.isFavorite, - isMotion: dto.isMotion, - isOffline: dto.isOffline, - isReadOnly: dto.isReadOnly, - isVisible: dto.isVisible, - type: dto.type, - withDeleted: dto.withDeleted, - }, + ...dto, + checksum, + ownerId: auth.user.id, + orderDirection: dto.order ? enumToOrder[dto.order] : 'DESC', }, ); return items.map((asset) => @@ -461,7 +413,7 @@ export class AssetService { .minus(Duration.fromObject({ days: trashedDays })) .toJSDate(); const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => - this.assetRepository.getAll(pagination, { date: { trashedBefore } }), + this.assetRepository.getAll(pagination, { trashedBefore }), ); for await (const assets of assetPagination) { diff --git a/server/src/domain/audit/audit.service.ts b/server/src/domain/audit/audit.service.ts index 5e4529fbdcbc7..887b72e2cda5c 100644 --- a/server/src/domain/audit/audit.service.ts +++ b/server/src/domain/audit/audit.service.ts @@ -167,7 +167,7 @@ export class AuditService { `Found ${libraryFiles.size} original files, ${thumbFiles.size} thumbnails, ${videoFiles.size} encoded videos, ${profileFiles.size} profile files`, ); const pagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (options) => - this.assetRepository.getAll(options, { status: { withDeleted: true } }), + this.assetRepository.getAll(options, { withDeleted: true }), ); let assetCount = 0; diff --git a/server/src/domain/download/download.service.spec.ts b/server/src/domain/download/download.service.spec.ts index 2d74faba3e622..f59374d706f72 100644 --- a/server/src/domain/download/download.service.spec.ts +++ b/server/src/domain/download/download.service.spec.ts @@ -163,9 +163,7 @@ describe(DownloadService.name, () => { ); expect(assetMock.getByUserId).toHaveBeenCalledWith({ take: 2500, skip: 0 }, authStub.admin.user.id, { - status: { - isVisible: true, - }, + isVisible: true, }); }); diff --git a/server/src/domain/download/download.service.ts b/server/src/domain/download/download.service.ts index 4dcb67e687c12..03bd6fee60f21 100644 --- a/server/src/domain/download/download.service.ts +++ b/server/src/domain/download/download.service.ts @@ -122,7 +122,7 @@ export class DownloadService { const userId = dto.userId; await this.access.requirePermission(auth, Permission.TIMELINE_DOWNLOAD, userId); return usePagination(PAGINATION_SIZE, (pagination) => - this.assetRepository.getByUserId(pagination, userId, { status: { isVisible: true } }), + this.assetRepository.getByUserId(pagination, userId, { isVisible: true }), ); } diff --git a/server/src/domain/media/media.service.spec.ts b/server/src/domain/media/media.service.spec.ts index 74e06fbed2a90..6406b2887a67a 100644 --- a/server/src/domain/media/media.service.spec.ts +++ b/server/src/domain/media/media.service.spec.ts @@ -411,7 +411,7 @@ describe(MediaService.name, () => { await sut.handleQueueVideoConversion({ force: true }); - expect(assetMock.getAll).toHaveBeenCalledWith({ skip: 0, take: 1000 }, { status: { type: AssetType.VIDEO } }); + expect(assetMock.getAll).toHaveBeenCalledWith({ skip: 0, take: 1000 }, { type: AssetType.VIDEO }); expect(assetMock.getWithout).not.toHaveBeenCalled(); expect(jobMock.queueAll).toHaveBeenCalledWith([ { diff --git a/server/src/domain/media/media.service.ts b/server/src/domain/media/media.service.ts index 0461738701ab5..68f861d7e2d55 100644 --- a/server/src/domain/media/media.service.ts +++ b/server/src/domain/media/media.service.ts @@ -240,7 +240,7 @@ export class MediaService { const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { return force - ? this.assetRepository.getAll(pagination, { status: { type: AssetType.VIDEO } }) + ? this.assetRepository.getAll(pagination, { type: AssetType.VIDEO }) : this.assetRepository.getWithout(pagination, WithoutProperty.ENCODED_VIDEO); }); diff --git a/server/src/domain/person/person.service.ts b/server/src/domain/person/person.service.ts index ca80065bb310e..359084bf21c9a 100644 --- a/server/src/domain/person/person.service.ts +++ b/server/src/domain/person/person.service.ts @@ -285,10 +285,7 @@ export class PersonService { const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { return force - ? this.assetRepository.getAll(pagination, { - order: { direction: 'DESC' }, - relation: { withFaces: true }, - }) + ? this.assetRepository.getAll(pagination, { orderDirection: 'DESC', withFaces: true }) : this.assetRepository.getWithout(pagination, WithoutProperty.FACES); }); diff --git a/server/src/domain/repositories/search.repository.ts b/server/src/domain/repositories/search.repository.ts index 8f157585b6a70..3226c7280976d 100644 --- a/server/src/domain/repositories/search.repository.ts +++ b/server/src/domain/repositories/search.repository.ts @@ -128,7 +128,7 @@ export interface SearchEmbeddingOptions { } export interface SearchOrderOptions { - direction: 'ASC' | 'DESC'; + orderDirection?: 'ASC' | 'DESC'; } export interface SearchPaginationOptions { @@ -136,24 +136,21 @@ export interface SearchPaginationOptions { size: number; } -export interface AssetSearchOptions { - date?: SearchDateOptions; - exif?: SearchExifOptions; - id?: SearchIDOptions; - order?: SearchOrderOptions; - path?: SearchPathOptions; - relation?: SearchRelationOptions; - status?: SearchStatusOptions; -} +export type AssetSearchOptions = SearchDateOptions & + SearchIDOptions & + SearchExifOptions & + SearchOrderOptions & + SearchPathOptions & + SearchRelationOptions & + SearchStatusOptions; -export type AssetSearchBuilderOptions = Omit; +export type AssetSearchBuilderOptions = Omit; -export interface SmartSearchOptions extends SearchEmbeddingOptions { - date?: SearchDateOptions; - exif?: SearchExifOptions; - relation?: SearchRelationOptions; - status?: SearchStatusOptions; -} +export type SmartSearchOptions = SearchEmbeddingOptions & + SearchDateOptions & + SearchExifOptions & + SearchRelationOptions & + SearchStatusOptions; export interface FaceEmbeddingSearch extends SearchEmbeddingOptions { hasPerson?: boolean; diff --git a/server/src/domain/search/search.service.spec.ts b/server/src/domain/search/search.service.spec.ts index 5b52cd0c811fc..9df58f771d6c2 100644 --- a/server/src/domain/search/search.service.spec.ts +++ b/server/src/domain/search/search.service.spec.ts @@ -145,7 +145,7 @@ describe(SearchService.name, () => { { userIds: [authStub.user1.user.id], embedding, - status: { withArchived: true }, + withArchived: true, }, ); expect(assetMock.searchMetadata).not.toHaveBeenCalled(); @@ -181,7 +181,7 @@ describe(SearchService.name, () => { { userIds: [authStub.user1.user.id], embedding, - status: { withArchived: false }, + 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 5e83a9a53752d..dfcb0e68a8fc4 100644 --- a/server/src/domain/search/search.service.ts +++ b/server/src/domain/search/search.service.ts @@ -88,7 +88,7 @@ export class SearchService { { userIds, embedding, - status: { withArchived: !!dto.withArchived }, + withArchived: !!dto.withArchived, }, ); if (hasNextPage) { diff --git a/server/src/domain/trash/trash.service.ts b/server/src/domain/trash/trash.service.ts index 179bf598030ee..30fd6843e2e9a 100644 --- a/server/src/domain/trash/trash.service.ts +++ b/server/src/domain/trash/trash.service.ts @@ -34,7 +34,7 @@ export class TrashService { async restore(auth: AuthDto): Promise { const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => this.assetRepository.getByUserId(pagination, auth.user.id, { - date: { trashedBefore: DateTime.now().toJSDate() }, + trashedBefore: DateTime.now().toJSDate(), }), ); @@ -47,7 +47,7 @@ export class TrashService { async empty(auth: AuthDto): Promise { const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => this.assetRepository.getByUserId(pagination, auth.user.id, { - date: { trashedBefore: DateTime.now().toJSDate() }, + trashedBefore: DateTime.now().toJSDate(), }), ); diff --git a/server/src/infra/infra.utils.ts b/server/src/infra/infra.utils.ts index 47de290fe4cb8..bc8a219c53ab2 100644 --- a/server/src/infra/infra.utils.ts +++ b/server/src/infra/infra.utils.ts @@ -1,5 +1,17 @@ -import { AssetSearchBuilderOptions, Paginated, PaginationOptions } from '@app/domain'; +import { + AssetSearchBuilderOptions, + Paginated, + PaginationOptions, + SearchDateOptions, + SearchExifOptions, + SearchIDOptions, + SearchPathOptions, + SearchRelationOptions, + SearchStatusOptions, +} from '@app/domain'; +import { date } from 'joi'; import _ from 'lodash'; +import path from 'node:path'; import { Between, Brackets, @@ -124,85 +136,73 @@ export function ChunkedSet(options?: { paramIndex?: number }): MethodDecorator { export function searchAssetBuilder( builder: SelectQueryBuilder, - { date, id, exif, path, relation, status }: AssetSearchBuilderOptions, + options: AssetSearchBuilderOptions, ): SelectQueryBuilder { - if (date) { - builder.andWhere( - _.omitBy( - { - createdAt: OptionalBetween(date.createdAfter, date.createdBefore), - updatedAt: OptionalBetween(date.updatedAfter, date.updatedBefore), - deletedAt: OptionalBetween(date.trashedAfter, date.trashedBefore), - fileCreatedAt: OptionalBetween(date.takenAfter, date.takenBefore), - }, - _.isUndefined, - ), - ); - } + builder.andWhere( + _.omitBy( + { + createdAt: OptionalBetween(options.createdAfter, options.createdBefore), + updatedAt: OptionalBetween(options.updatedAfter, options.updatedBefore), + deletedAt: OptionalBetween(options.trashedAfter, options.trashedBefore), + fileCreatedAt: OptionalBetween(options.takenAfter, options.takenBefore), + }, + _.isUndefined, + ), + ); - if (exif) { - const exifWhere = _.omitBy(exif, _.isUndefined); - if (Object.keys(exifWhere).length > 0) { - builder.leftJoin(`${builder.alias}.exifInfo`, 'exifInfo'); - builder.andWhere({ exifInfo: exifWhere }); - } + const exifInfo = _.omitBy(_.pick(options, ['city', 'country', 'lensModel', 'make', 'model', 'state']), _.isUndefined); + if (Object.keys(exifInfo).length > 0) { + builder.leftJoin(`${builder.alias}.exifInfo`, 'exifInfo'); + builder.andWhere({ exifInfo }); } - if (id) { - builder.andWhere(_.omitBy(id, _.isUndefined)); - } + const id = _.pick(options, ['checksum', 'deviceAssetId', 'deviceId', 'id', 'libraryId', 'ownerId']); + builder.andWhere(_.omitBy(id, _.isUndefined)); - if (path) { - builder.andWhere(_.omitBy(path, _.isUndefined)); - } + const path = _.pick(options, ['encodedVideoPath', 'originalFileName', 'originalPath', 'resizePath', 'webpPath']); + builder.andWhere(_.omitBy(path, _.isUndefined)); - if (status) { - const { isEncoded, isMotion, withArchived, withDeleted, ...otherStatuses } = status; - if (withArchived != null) { - otherStatuses.isArchived ??= withArchived; - } + const status = _.pick(options, ['isExternal', 'isFavorite', 'isOffline', 'isReadOnly', 'isVisible', 'type']); + const { isArchived, isEncoded, isMotion, withArchived } = options; + builder.andWhere( + _.omitBy( + { + ...status, + isArchived: isArchived ?? withArchived, + encodedVideoPath: isEncoded ? Not(IsNull()) : undefined, + livePhotoVideoId: isMotion ? Not(IsNull()) : undefined, + }, + _.isUndefined, + ), + ); - builder.andWhere(_.omitBy(otherStatuses, _.isUndefined)); + if (options.withExif) { + builder.leftJoinAndSelect(`${builder.alias}.exifInfo`, 'exifInfo'); + } - if (isEncoded && !path?.encodedVideoPath) { - builder.andWhere({ encodedVideoPath: Not(IsNull()) }); - } + if (options.withFaces || options.withPeople) { + builder.leftJoinAndSelect(`${builder.alias}.faces`, 'faces'); + } + + if (options.withPeople) { + builder.leftJoinAndSelect(`${builder.alias}.person`, 'person'); + } - if (isMotion) { - builder.andWhere({ livePhotoVideoId: Not(IsNull()) }); - } + if (options.withSmartInfo) { + builder.leftJoinAndSelect(`${builder.alias}.smartInfo`, 'smartInfo'); } - if (relation) { - const { withExif, withFaces, withPeople, withSmartInfo, withStacked } = relation; - - if (withExif) { - builder.leftJoinAndSelect(`${builder.alias}.exifInfo`, 'exifInfo'); - } - - if (withFaces || withPeople) { - builder.leftJoinAndSelect(`${builder.alias}.faces`, 'faces'); - } - - if (withPeople) { - builder.leftJoinAndSelect(`${builder.alias}.person`, 'person'); - } - - if (withSmartInfo) { - builder.leftJoinAndSelect(`${builder.alias}.smartInfo`, 'smartInfo'); - } - - if (withStacked) { - builder - .leftJoinAndSelect(`${builder.alias}.stack`, 'stack') - .leftJoinAndSelect('stack.assets', 'stackedAssets') - .andWhere( - new Brackets((qb) => qb.where(`stack.primaryAssetId = ${builder.alias}.id`).orWhere('asset.stackId IS NULL')), - ); - } + if (options.withStacked) { + builder + .leftJoinAndSelect(`${builder.alias}.stack`, 'stack') + .leftJoinAndSelect('stack.assets', 'stackedAssets') + .andWhere( + new Brackets((qb) => qb.where(`stack.primaryAssetId = ${builder.alias}.id`).orWhere('asset.stackId IS NULL')), + ); } - const withDeleted = status?.withDeleted ?? (date?.trashedAfter !== undefined || date?.trashedBefore !== undefined); + const withDeleted = + options.withDeleted ?? (options.trashedAfter !== undefined || options.trashedBefore !== undefined); if (withDeleted) { builder.withDeleted(); } diff --git a/server/src/infra/repositories/asset.repository.ts b/server/src/infra/repositories/asset.repository.ts index 9f6db1071eacd..22f890cf8f52f 100644 --- a/server/src/infra/repositories/asset.repository.ts +++ b/server/src/infra/repositories/asset.repository.ts @@ -179,7 +179,7 @@ export class AssetRepository implements IAssetRepository { } getByUserId(pagination: PaginationOptions, userId: string, options: AssetSearchOptions = {}): Paginated { - return this.getAll(pagination, { ...options, id: { ...options.id, id: userId } }); + return this.getAll(pagination, { ...options, id: userId }); } @GenerateSql({ params: [[DummyValue.UUID]] }) @@ -200,7 +200,7 @@ export class AssetRepository implements IAssetRepository { getAll(pagination: PaginationOptions, options: AssetSearchOptions = {}): Paginated { let builder = this.repository.createQueryBuilder('asset'); builder = searchAssetBuilder(builder, options); - builder.orderBy('asset.createdAt', options.order?.direction ?? 'ASC'); + builder.orderBy('asset.createdAt', options.orderDirection ?? 'ASC'); return paginatedBuilder(builder, { mode: PaginationMode.SKIP_TAKE, skip: pagination.skip, diff --git a/server/src/infra/repositories/search.repository.ts b/server/src/infra/repositories/search.repository.ts index e8a9fa06b0491..555499443f533 100644 --- a/server/src/infra/repositories/search.repository.ts +++ b/server/src/infra/repositories/search.repository.ts @@ -54,11 +54,11 @@ export class SearchRepository implements ISearchRepository { params: [ { page: 0, size: 100 }, { - date: { takenAfter: DummyValue.DATE }, - exif: { lensModel: DummyValue.STRING }, - id: { ownerId: DummyValue.UUID }, - relation: { withStacked: true }, - status: { isFavorite: true }, + takenAfter: DummyValue.DATE, + lensModel: DummyValue.STRING, + ownerId: DummyValue.UUID, + withStacked: true, + isFavorite: true, }, ], }) @@ -66,7 +66,7 @@ export class SearchRepository implements ISearchRepository { let builder = this.assetRepository.createQueryBuilder('asset'); builder = searchAssetBuilder(builder, options); - builder.orderBy('asset.fileCreatedAt', options.order?.direction ? options.order.direction : 'DESC'); + builder.orderBy('asset.fileCreatedAt', options.orderDirection ?? 'DESC'); return paginatedBuilder(builder, { mode: PaginationMode.SKIP_TAKE, @@ -79,11 +79,11 @@ export class SearchRepository implements ISearchRepository { params: [ { page: 0, size: 100 }, { - date: { takenAfter: DummyValue.DATE }, + takenAfter: DummyValue.DATE, embedding: Array.from({ length: 512 }, Math.random), - exif: { lensModel: DummyValue.STRING }, - relation: { withStacked: true }, - status: { isFavorite: true }, + lensModel: DummyValue.STRING, + withStacked: true, + isFavorite: true, userIds: [DummyValue.UUID], }, ], From 5efc1cae29da40ff1c9f57e09c06a049cc9614ab Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Sat, 10 Feb 2024 18:52:54 -0500 Subject: [PATCH 24/38] refactor endpoints --- server/src/domain/asset/asset.service.ts | 27 -- server/src/domain/asset/dto/asset.dto.ts | 152 +--------- .../domain/repositories/search.repository.ts | 22 +- server/src/domain/search/dto/search.dto.ts | 276 ++++++++++++++++-- .../src/domain/search/search.service.spec.ts | 20 +- server/src/domain/search/search.service.ts | 95 +++--- .../immich/controllers/asset.controller.ts | 8 +- .../immich/controllers/search.controller.ts | 2 +- .../infra/repositories/search.repository.ts | 4 +- .../repositories/search.repository.mock.ts | 4 +- 10 files changed, 331 insertions(+), 279 deletions(-) diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts index 67e97a8161eed..999966812d51d 100644 --- a/server/src/domain/asset/asset.service.ts +++ b/server/src/domain/asset/asset.service.ts @@ -88,38 +88,11 @@ export class AssetService { @Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository, @Inject(IPartnerRepository) private partnerRepository: IPartnerRepository, @Inject(IAssetStackRepository) private assetStackRepository: IAssetStackRepository, - @Inject(ISearchRepository) private searchRepository: ISearchRepository, ) { this.access = AccessCore.create(accessRepository); this.configCore = SystemConfigCore.create(configRepository); } - async search(auth: AuthDto, dto: AssetSearchDto) { - let checksum: Buffer | undefined; - - if (dto.checksum) { - const encoding = dto.checksum.length === 28 ? 'base64' : 'hex'; - checksum = Buffer.from(dto.checksum, encoding); - } - - const enumToOrder = { [AssetOrder.ASC]: 'ASC', [AssetOrder.DESC]: 'DESC' } as const; - const { items } = await this.searchRepository.searchAssets( - { page: dto.page ? dto.page - 1 : 0, size: dto.size ?? 250 }, - { - ...dto, - checksum, - ownerId: auth.user.id, - orderDirection: dto.order ? enumToOrder[dto.order] : 'DESC', - }, - ); - return items.map((asset) => - mapAsset(asset, { - stripMetadata: false, - withStack: true, - }), - ); - } - canUploadFile({ auth, fieldName, file }: UploadRequest): true { this.access.requireUploadAccess(auth); diff --git a/server/src/domain/asset/dto/asset.dto.ts b/server/src/domain/asset/dto/asset.dto.ts index bd4cf93ba6849..0244ecd90eb25 100644 --- a/server/src/domain/asset/dto/asset.dto.ts +++ b/server/src/domain/asset/dto/asset.dto.ts @@ -1,20 +1,16 @@ -import { AssetType } from '@app/infra/entities'; -import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsBoolean, IsDateString, - IsEnum, IsInt, IsLatitude, IsLongitude, IsNotEmpty, IsPositive, IsString, - Min, ValidateIf, } from 'class-validator'; -import { Optional, QueryBoolean, QueryDate, ValidateUUID } from '../../domain.util'; +import { Optional, ValidateUUID } from '../../domain.util'; import { BulkIdsDto } from '../response-dto'; export class DeviceIdDto { @@ -32,152 +28,6 @@ const hasGPS = (o: { latitude: undefined; longitude: undefined }) => o.latitude !== undefined || o.longitude !== undefined; const ValidateGPS = () => ValidateIf(hasGPS); -export class AssetSearchDto { - @ValidateUUID({ optional: true }) - id?: string; - - @ValidateUUID({ optional: true }) - libraryId?: string; - - @IsString() - @Optional() - deviceAssetId?: string; - - @IsString() - @Optional() - deviceId?: string; - - @IsEnum(AssetType) - @Optional() - @ApiProperty({ enumName: 'AssetTypeEnum', enum: AssetType }) - type?: AssetType; - - @IsString() - @Optional() - checksum?: string; - - @QueryBoolean({ optional: true }) - isArchived?: boolean; - - @QueryBoolean({ optional: true }) - isEncoded?: boolean; - - @QueryBoolean({ optional: true }) - isExternal?: boolean; - - @QueryBoolean({ optional: true }) - isFavorite?: boolean; - - @QueryBoolean({ optional: true }) - isMotion?: boolean; - - @QueryBoolean({ optional: true }) - isOffline?: boolean; - - @QueryBoolean({ optional: true }) - isReadOnly?: boolean; - - @QueryBoolean({ optional: true }) - isVisible?: boolean; - - @QueryBoolean({ optional: true }) - withDeleted?: boolean; - - @QueryBoolean({ optional: true }) - withStacked?: boolean; - - @QueryBoolean({ optional: true }) - withExif?: boolean; - - @QueryBoolean({ optional: true }) - withPeople?: boolean; - - @QueryDate({ optional: true }) - createdBefore?: Date; - - @QueryDate({ optional: true }) - createdAfter?: Date; - - @QueryDate({ optional: true }) - updatedBefore?: Date; - - @QueryDate({ optional: true }) - updatedAfter?: Date; - - @QueryDate({ optional: true }) - trashedBefore?: Date; - - @QueryDate({ optional: true }) - trashedAfter?: Date; - - @QueryDate({ optional: true }) - takenBefore?: Date; - - @QueryDate({ optional: true }) - takenAfter?: Date; - - @IsString() - @Optional() - originalFileName?: string; - - @IsString() - @Optional() - originalPath?: string; - - @IsString() - @Optional() - resizePath?: string; - - @IsString() - @Optional() - webpPath?: string; - - @IsString() - @Optional() - encodedVideoPath?: string; - - @IsString() - @Optional() - city?: string; - - @IsString() - @Optional() - state?: string; - - @IsString() - @Optional() - country?: string; - - @IsString() - @Optional() - make?: string; - - @IsString() - @Optional() - model?: string; - - @IsString() - @Optional() - lensModel?: string; - - @IsEnum(AssetOrder) - @Optional() - @ApiProperty({ enumName: 'AssetOrder', enum: AssetOrder }) - order?: AssetOrder; - - @IsInt() - @Min(1) - @Type(() => Number) - @Optional() - page?: number; - - @IsInt() - @Min(1) - @Type(() => Number) - @Optional() - size?: number; -} - export class AssetBulkUpdateDto extends BulkIdsDto { @Optional() @IsBoolean() diff --git a/server/src/domain/repositories/search.repository.ts b/server/src/domain/repositories/search.repository.ts index 3226c7280976d..93c65d9cb87d5 100644 --- a/server/src/domain/repositories/search.repository.ts +++ b/server/src/domain/repositories/search.repository.ts @@ -60,15 +60,20 @@ export interface SearchExploreItem { export type Embedding = number[]; -export interface SearchIDOptions { +export interface SearchAssetIDOptions { checksum?: Buffer; deviceAssetId?: string; - deviceId?: string; id?: string; +} + +export interface SearchUserIDOptions { + deviceId?: string; libraryId?: string; ownerId?: string; } +export type SearchIDOptions = SearchAssetIDOptions & SearchUserIDOptions; + export interface SearchStatusOptions { isArchived?: boolean; isEncoded?: boolean; @@ -146,11 +151,12 @@ export type AssetSearchOptions = SearchDateOptions & export type AssetSearchBuilderOptions = Omit; -export type SmartSearchOptions = SearchEmbeddingOptions & - SearchDateOptions & +export type SmartSearchOptions = SearchDateOptions & + SearchEmbeddingOptions & SearchExifOptions & - SearchRelationOptions & - SearchStatusOptions; + SearchOneToOneRelationOptions & + SearchStatusOptions & + SearchUserIDOptions; export interface FaceEmbeddingSearch extends SearchEmbeddingOptions { hasPerson?: boolean; @@ -165,8 +171,8 @@ export interface FaceSearchResult { export interface ISearchRepository { init(modelName: string): Promise; - searchAssets(pagination: SearchPaginationOptions, options: AssetSearchOptions): Paginated; - searchCLIP(pagination: SearchPaginationOptions, options: SmartSearchOptions): Paginated; + searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions): Paginated; + searchSmart(pagination: SearchPaginationOptions, options: SmartSearchOptions): Paginated; searchFaces(search: FaceEmbeddingSearch): Promise; upsert(smartInfo: Partial, embedding?: Embedding): Promise; } diff --git a/server/src/domain/search/dto/search.dto.ts b/server/src/domain/search/dto/search.dto.ts index 6914d67dfa9fe..426af8150a626 100644 --- a/server/src/domain/search/dto/search.dto.ts +++ b/server/src/domain/search/dto/search.dto.ts @@ -1,59 +1,285 @@ +import { AssetOrder } from '@app/domain/asset/dto/asset.dto'; import { AssetType } from '@app/infra/entities'; +import { ApiProperty } from '@nestjs/swagger'; import { Transform, Type } from 'class-transformer'; -import { IsBoolean, IsEnum, IsInt, IsNotEmpty, IsPositive, IsString, Min } from 'class-validator'; -import { Optional, toBoolean } from '../../domain.util'; +import { IsBoolean, IsEnum, IsInt, IsNotEmpty, IsString, Min } from 'class-validator'; +import { Optional, QueryBoolean, QueryDate, ValidateUUID, toBoolean } from '../../domain.util'; + +export class AssetSearchDto { + @ValidateUUID({ optional: true }) + id?: string; + + @ValidateUUID({ optional: true }) + libraryId?: string; -export class SearchDto { @IsString() @IsNotEmpty() @Optional() - q?: string; + deviceAssetId?: string; @IsString() @IsNotEmpty() @Optional() - query?: string; + deviceId?: string; - @IsBoolean() + @IsEnum(AssetType) @Optional() - @Transform(toBoolean) - smart?: boolean; + @ApiProperty({ enumName: 'AssetTypeEnum', enum: AssetType }) + type?: AssetType; - /** @deprecated */ - @IsBoolean() + @IsString() + @IsNotEmpty() @Optional() - @Transform(toBoolean) - clip?: boolean; + checksum?: string; + + @QueryBoolean({ optional: true }) + isArchived?: boolean; + + @QueryBoolean({ optional: true }) + isEncoded?: boolean; + + @QueryBoolean({ optional: true }) + isExternal?: boolean; + + @QueryBoolean({ optional: true }) + isFavorite?: boolean; + + @QueryBoolean({ optional: true }) + isMotion?: boolean; + + @QueryBoolean({ optional: true }) + isOffline?: boolean; + + @QueryBoolean({ optional: true }) + isReadOnly?: boolean; + + @QueryBoolean({ optional: true }) + isVisible?: boolean; + + @QueryBoolean({ optional: true }) + withDeleted?: boolean; + + @QueryBoolean({ optional: true }) + withStacked?: boolean; + + @QueryBoolean({ optional: true }) + withExif?: boolean; + + @QueryBoolean({ optional: true }) + withPeople?: boolean; + + @QueryDate({ optional: true }) + createdBefore?: Date; + + @QueryDate({ optional: true }) + createdAfter?: Date; + + @QueryDate({ optional: true }) + updatedBefore?: Date; + + @QueryDate({ optional: true }) + updatedAfter?: Date; + + @QueryDate({ optional: true }) + trashedBefore?: Date; + + @QueryDate({ optional: true }) + trashedAfter?: Date; + + @QueryDate({ optional: true }) + takenBefore?: Date; + + @QueryDate({ optional: true }) + takenAfter?: Date; + + @IsString() + @IsNotEmpty() + @Optional() + originalFileName?: string; + + @IsString() + @IsNotEmpty() + @Optional() + originalPath?: string; + + @IsString() + @IsNotEmpty() + @Optional() + resizePath?: string; + + @IsString() + @IsNotEmpty() + @Optional() + webpPath?: string; + + @IsString() + @IsNotEmpty() + @Optional() + encodedVideoPath?: string; + + @IsString() + @IsNotEmpty() + @Optional() + city?: string; + + @IsString() + @IsNotEmpty() + @Optional() + state?: string; + + @IsString() + @IsNotEmpty() + @Optional() + country?: string; + + @IsString() + @IsNotEmpty() + @Optional() + make?: string; + + @IsString() + @IsNotEmpty() + @Optional() + model?: string; + + @IsString() + @IsNotEmpty() + @Optional() + lensModel?: string; + + @IsEnum(AssetOrder) + @Optional() + @ApiProperty({ enumName: 'AssetOrder', enum: AssetOrder }) + order?: AssetOrder; + + @IsInt() + @Min(1) + @Type(() => Number) + @Optional() + page?: number; + + @IsInt() + @Min(1) + @Type(() => Number) + @Optional() + size?: number; +} + +export class SmartSearchDto { + @IsString() + @IsNotEmpty() + query!: string; + + @ValidateUUID({ optional: true }) + libraryId?: string; + + @IsString() + @Optional() + deviceId?: string; @IsEnum(AssetType) @Optional() + @ApiProperty({ enumName: 'AssetTypeEnum', enum: AssetType }) type?: AssetType; - @IsBoolean() + @QueryBoolean({ optional: true }) + isArchived?: boolean; + + @QueryBoolean({ optional: true }) + withArchived?: boolean; + + @QueryBoolean({ optional: true }) + isEncoded?: boolean; + + @QueryBoolean({ optional: true }) + isExternal?: boolean; + + @QueryBoolean({ optional: true }) + isFavorite?: boolean; + + @QueryBoolean({ optional: true }) + isMotion?: boolean; + + @QueryBoolean({ optional: true }) + isOffline?: boolean; + + @QueryBoolean({ optional: true }) + isReadOnly?: boolean; + + @QueryBoolean({ optional: true }) + isVisible?: boolean; + + @QueryBoolean({ optional: true }) + withDeleted?: boolean; + + @QueryBoolean({ optional: true }) + withExif?: boolean; + + @QueryDate({ optional: true }) + createdBefore?: Date; + + @QueryDate({ optional: true }) + createdAfter?: Date; + + @QueryDate({ optional: true }) + updatedBefore?: Date; + + @QueryDate({ optional: true }) + updatedAfter?: Date; + + @QueryDate({ optional: true }) + trashedBefore?: Date; + + @QueryDate({ optional: true }) + trashedAfter?: Date; + + @QueryDate({ optional: true }) + takenBefore?: Date; + + @QueryDate({ optional: true }) + takenAfter?: Date; + + @IsString() + @IsNotEmpty() @Optional() - @Transform(toBoolean) - recent?: boolean; + city?: string; - @IsBoolean() + @IsString() + @IsNotEmpty() @Optional() - @Transform(toBoolean) - motion?: boolean; + state?: string; - @IsBoolean() + @IsString() + @IsNotEmpty() @Optional() - @Transform(toBoolean) - withArchived?: boolean; + country?: string; - @IsPositive() - @Type(() => Number) + @IsString() + @IsNotEmpty() + @Optional() + make?: string; + + @IsString() + @IsNotEmpty() @Optional() - take?: number; + model?: string; + + @IsString() + @IsNotEmpty() + @Optional() + lensModel?: string; @IsInt() - @Min(0) + @Min(1) @Type(() => Number) @Optional() page?: number; + + @IsInt() + @Min(1) + @Type(() => Number) + @Optional() + size?: number; } export class SearchPeopleDto { diff --git a/server/src/domain/search/search.service.spec.ts b/server/src/domain/search/search.service.spec.ts index 9df58f771d6c2..51a4c165b114a 100644 --- a/server/src/domain/search/search.service.spec.ts +++ b/server/src/domain/search/search.service.spec.ts @@ -85,7 +85,7 @@ describe(SearchService.name, () => { describe('search', () => { it('should throw an error if query is missing', async () => { - await expect(sut.search(authStub.user1, { q: '' })).rejects.toThrow('Missing query'); + await expect(sut.searchSmart(authStub.user1, { q: '' })).rejects.toThrow('Missing query'); }); it('should search by metadata if `clip` option is false', async () => { @@ -108,17 +108,17 @@ describe(SearchService.name, () => { }, }; - const result = await sut.search(authStub.user1, dto); + const result = await sut.searchSmart(authStub.user1, dto); expect(result).toEqual(expectedResponse); expect(assetMock.searchMetadata).toHaveBeenCalledWith(dto.q, [authStub.user1.user.id], { numResults: 250 }); - expect(searchMock.searchCLIP).not.toHaveBeenCalled(); + expect(searchMock.searchSmart).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]; - searchMock.searchCLIP.mockResolvedValueOnce({ items: [assetStub.image], hasNextPage: false }); + searchMock.searchSmart.mockResolvedValueOnce({ items: [assetStub.image], hasNextPage: false }); machineMock.encodeText.mockResolvedValueOnce(embedding); partnerMock.getAll.mockResolvedValueOnce([]); const expectedResponse = { @@ -137,10 +137,10 @@ describe(SearchService.name, () => { }, }; - const result = await sut.search(authStub.user1, dto); + const result = await sut.searchSmart(authStub.user1, dto); expect(result).toEqual(expectedResponse); - expect(searchMock.searchCLIP).toHaveBeenCalledWith( + expect(searchMock.searchSmart).toHaveBeenCalledWith( { page: 0, size: 100 }, { userIds: [authStub.user1.user.id], @@ -154,7 +154,7 @@ describe(SearchService.name, () => { it('should search by CLIP if `clip` option is true', async () => { const dto: SearchDto = { q: 'test query', clip: true }; const embedding = [1, 2, 3]; - searchMock.searchCLIP.mockResolvedValueOnce({ items: [assetStub.image], hasNextPage: false }); + searchMock.searchSmart.mockResolvedValueOnce({ items: [assetStub.image], hasNextPage: false }); machineMock.encodeText.mockResolvedValueOnce(embedding); partnerMock.getAll.mockResolvedValueOnce([]); const expectedResponse = { @@ -173,10 +173,10 @@ describe(SearchService.name, () => { }, }; - const result = await sut.search(authStub.user1, dto); + const result = await sut.searchSmart(authStub.user1, dto); expect(result).toEqual(expectedResponse); - expect(searchMock.searchCLIP).toHaveBeenCalledWith( + expect(searchMock.searchSmart).toHaveBeenCalledWith( { page: 0, size: 100 }, { userIds: [authStub.user1.user.id], @@ -194,7 +194,7 @@ describe(SearchService.name, () => { const dto: SearchDto = { q: 'test query', clip: true }; configMock.load.mockResolvedValue([{ key, value: false }]); - await expect(sut.search(authStub.user1, dto)).rejects.toThrow('Smart search is not enabled'); + await expect(sut.searchSmart(authStub.user1, dto)).rejects.toThrow('Smart search is not enabled'); }); }); }); diff --git a/server/src/domain/search/search.service.ts b/server/src/domain/search/search.service.ts index dfcb0e68a8fc4..e6d01163cdc34 100644 --- a/server/src/domain/search/search.service.ts +++ b/server/src/domain/search/search.service.ts @@ -1,7 +1,7 @@ import { AssetEntity } from '@app/infra/entities'; import { ImmichLogger } from '@app/infra/logger'; import { Inject, Injectable } from '@nestjs/common'; -import { AssetResponseDto, mapAsset } from '../asset'; +import { AssetOrder, AssetResponseDto, mapAsset } from '../asset'; import { AuthDto } from '../auth'; import { PersonResponseDto } from '../person'; import { @@ -15,7 +15,7 @@ import { SearchStrategy, } from '../repositories'; import { FeatureFlag, SystemConfigCore } from '../system-config'; -import { SearchDto, SearchPeopleDto } from './dto'; +import { AssetSearchDto, SearchPeopleDto, SmartSearchDto } from './dto'; import { SearchResponseDto } from './response-dto'; @Injectable() @@ -27,7 +27,7 @@ export class SearchService { @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, @Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository, @Inject(IPersonRepository) private personRepository: IPersonRepository, - @Inject(ISearchRepository) private smartInfoRepository: ISearchRepository, + @Inject(ISearchRepository) private searchRepository: ISearchRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IPartnerRepository) private partnerRepository: IPartnerRepository, ) { @@ -55,55 +55,50 @@ export class SearchService { })); } - async search(auth: AuthDto, dto: SearchDto): Promise { - await this.configCore.requireFeature(FeatureFlag.SEARCH); - const { machineLearning } = await this.configCore.getConfig(); - const query = dto.q || dto.query; - if (!query) { - throw new Error('Missing query'); - } + async searchMetadata(auth: AuthDto, dto: AssetSearchDto) { + let checksum: Buffer | undefined; - let strategy = SearchStrategy.TEXT; - if (dto.smart || dto.clip) { - await this.configCore.requireFeature(FeatureFlag.SMART_SEARCH); - strategy = SearchStrategy.SMART; + if (dto.checksum) { + const encoding = dto.checksum.length === 28 ? 'base64' : 'hex'; + checksum = Buffer.from(dto.checksum, encoding); } + const enumToOrder = { [AssetOrder.ASC]: 'ASC', [AssetOrder.DESC]: 'DESC' } as const; + const { items } = await this.searchRepository.searchMetadata( + { page: dto.page ? dto.page - 1 : 0, size: dto.size ?? 250 }, + { + ...dto, + checksum, + ownerId: auth.user.id, + orderDirection: dto.order ? enumToOrder[dto.order] : 'DESC', + }, + ); + return items.map((asset) => + mapAsset(asset, { + stripMetadata: false, + withStack: true, + }), + ); + } + + async searchSmart(auth: AuthDto, dto: SmartSearchDto): Promise { + await this.configCore.requireFeature(FeatureFlag.SEARCH); + await this.configCore.requireFeature(FeatureFlag.SMART_SEARCH); + const { machineLearning } = await this.configCore.getConfig(); const userIds = await this.getUserIdsToSearch(auth); - const page = dto.page ?? 0; - const size = dto.take || 100; - let nextPage: string | null = null; - let assets: AssetEntity[] = []; - switch (strategy) { - case SearchStrategy.SMART: { - const embedding = await this.machineLearning.encodeText( - machineLearning.url, - { text: query }, - machineLearning.clip, - ); + const embedding = await this.machineLearning.encodeText( + machineLearning.url, + { text: dto.query }, + machineLearning.clip, + ); - const { hasNextPage, items } = await this.smartInfoRepository.searchCLIP( - { page, size }, - { - userIds, - embedding, - withArchived: !!dto.withArchived, - }, - ); - if (hasNextPage) { - nextPage = (page + 1).toString(); - } - assets = items; - break; - } - case SearchStrategy.TEXT: { - assets = await this.assetRepository.searchMetadata(query, userIds, { numResults: 250 }); - } - default: { - break; - } - } + const page = dto.page ?? 0; + const size = dto.size || 100; + const { hasNextPage, items } = await this.searchRepository.searchSmart( + { page, size }, + { ...dto, userIds, embedding }, + ); return { albums: { @@ -113,11 +108,11 @@ export class SearchService { facets: [], }, assets: { - total: assets.length, - count: assets.length, - items: assets.map((asset) => mapAsset(asset)), + total: items.length, + count: items.length, + items: items.map((asset) => mapAsset(asset)), facets: [], - nextPage, + nextPage: hasNextPage ? (page + 1).toString() : null, }, }; } diff --git a/server/src/immich/controllers/asset.controller.ts b/server/src/immich/controllers/asset.controller.ts index ad29ff080d99a..eb22ac7a8a96c 100644 --- a/server/src/immich/controllers/asset.controller.ts +++ b/server/src/immich/controllers/asset.controller.ts @@ -15,6 +15,7 @@ import { MemoryLaneDto, MemoryLaneResponseDto, RandomAssetsDto, + SearchService, TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto, @@ -23,7 +24,7 @@ import { UpdateStackParentDto, } from '@app/domain'; import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { Auth, Authenticated, SharedLinkRoute } from '../app.guard'; import { UseValidation } from '../app.utils'; import { Route } from '../interceptors'; @@ -34,11 +35,12 @@ import { UUIDParamDto } from './dto/uuid-param.dto'; @Authenticated() @UseValidation() export class AssetsController { - constructor(private service: AssetService) {} + constructor(private searchService: SearchService) {} @Get() + @ApiOperation({deprecated: true}) searchAssets(@Auth() auth: AuthDto, @Query() dto: AssetSearchDto): Promise { - return this.service.search(auth, dto); + return this.searchService.searchMetadata(auth, dto); } } diff --git a/server/src/immich/controllers/search.controller.ts b/server/src/immich/controllers/search.controller.ts index 51ee900eec0b4..03f229b740204 100644 --- a/server/src/immich/controllers/search.controller.ts +++ b/server/src/immich/controllers/search.controller.ts @@ -21,7 +21,7 @@ export class SearchController { @Get() search(@Auth() auth: AuthDto, @Query() dto: SearchDto): Promise { - return this.service.search(auth, dto); + return this.service.searchSmart(auth, dto); } @Get('explore') diff --git a/server/src/infra/repositories/search.repository.ts b/server/src/infra/repositories/search.repository.ts index 555499443f533..eb2a981949bf0 100644 --- a/server/src/infra/repositories/search.repository.ts +++ b/server/src/infra/repositories/search.repository.ts @@ -62,7 +62,7 @@ export class SearchRepository implements ISearchRepository { }, ], }) - async searchAssets(pagination: SearchPaginationOptions, options: AssetSearchOptions): Paginated { + async searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions): Paginated { let builder = this.assetRepository.createQueryBuilder('asset'); builder = searchAssetBuilder(builder, options); @@ -88,7 +88,7 @@ export class SearchRepository implements ISearchRepository { }, ], }) - async searchCLIP( + async searchSmart( pagination: SearchPaginationOptions, { embedding, userIds, ...options }: SmartSearchOptions, ): Paginated { diff --git a/server/test/repositories/search.repository.mock.ts b/server/test/repositories/search.repository.mock.ts index 55bcc9db5be91..e0bdab269a02c 100644 --- a/server/test/repositories/search.repository.mock.ts +++ b/server/test/repositories/search.repository.mock.ts @@ -3,8 +3,8 @@ import { ISearchRepository } from '@app/domain'; export const newSearchRepositoryMock = (): jest.Mocked => { return { init: jest.fn(), - searchAssets: jest.fn(), - searchCLIP: jest.fn(), + searchMetadata: jest.fn(), + searchSmart: jest.fn(), searchFaces: jest.fn(), upsert: jest.fn(), }; From bfc154a53c10316ebef2c5c6f0a9d0a578da70d0 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Sat, 10 Feb 2024 19:00:27 -0500 Subject: [PATCH 25/38] clean up dto --- server/src/domain/search/dto/search.dto.ts | 159 ++++----------------- 1 file changed, 26 insertions(+), 133 deletions(-) diff --git a/server/src/domain/search/dto/search.dto.ts b/server/src/domain/search/dto/search.dto.ts index 426af8150a626..fe38185cc8cd2 100644 --- a/server/src/domain/search/dto/search.dto.ts +++ b/server/src/domain/search/dto/search.dto.ts @@ -5,20 +5,11 @@ import { Transform, Type } from 'class-transformer'; import { IsBoolean, IsEnum, IsInt, IsNotEmpty, IsString, Min } from 'class-validator'; import { Optional, QueryBoolean, QueryDate, ValidateUUID, toBoolean } from '../../domain.util'; -export class AssetSearchDto { - @ValidateUUID({ optional: true }) - id?: string; - +class BaseSearchDto { @ValidateUUID({ optional: true }) libraryId?: string; @IsString() - @IsNotEmpty() - @Optional() - deviceAssetId?: string; - - @IsString() - @IsNotEmpty() @Optional() deviceId?: string; @@ -27,14 +18,12 @@ export class AssetSearchDto { @ApiProperty({ enumName: 'AssetTypeEnum', enum: AssetType }) type?: AssetType; - @IsString() - @IsNotEmpty() - @Optional() - checksum?: string; - @QueryBoolean({ optional: true }) isArchived?: boolean; + @QueryBoolean({ optional: true }) + withArchived?: boolean; + @QueryBoolean({ optional: true }) isEncoded?: boolean; @@ -59,15 +48,9 @@ export class AssetSearchDto { @QueryBoolean({ optional: true }) withDeleted?: boolean; - @QueryBoolean({ optional: true }) - withStacked?: boolean; - @QueryBoolean({ optional: true }) withExif?: boolean; - @QueryBoolean({ optional: true }) - withPeople?: boolean; - @QueryDate({ optional: true }) createdBefore?: Date; @@ -92,31 +75,6 @@ export class AssetSearchDto { @QueryDate({ optional: true }) takenAfter?: Date; - @IsString() - @IsNotEmpty() - @Optional() - originalFileName?: string; - - @IsString() - @IsNotEmpty() - @Optional() - originalPath?: string; - - @IsString() - @IsNotEmpty() - @Optional() - resizePath?: string; - - @IsString() - @IsNotEmpty() - @Optional() - webpPath?: string; - - @IsString() - @IsNotEmpty() - @Optional() - encodedVideoPath?: string; - @IsString() @IsNotEmpty() @Optional() @@ -147,11 +105,6 @@ export class AssetSearchDto { @Optional() lensModel?: string; - @IsEnum(AssetOrder) - @Optional() - @ApiProperty({ enumName: 'AssetOrder', enum: AssetOrder }) - order?: AssetOrder; - @IsInt() @Min(1) @Type(() => Number) @@ -165,121 +118,61 @@ export class AssetSearchDto { size?: number; } -export class SmartSearchDto { - @IsString() - @IsNotEmpty() - query!: string; - +export class AssetSearchDto extends BaseSearchDto { @ValidateUUID({ optional: true }) - libraryId?: string; + id?: string; @IsString() + @IsNotEmpty() @Optional() - deviceId?: string; + deviceAssetId?: string; - @IsEnum(AssetType) + @IsString() + @IsNotEmpty() @Optional() - @ApiProperty({ enumName: 'AssetTypeEnum', enum: AssetType }) - type?: AssetType; - - @QueryBoolean({ optional: true }) - isArchived?: boolean; - - @QueryBoolean({ optional: true }) - withArchived?: boolean; - - @QueryBoolean({ optional: true }) - isEncoded?: boolean; - - @QueryBoolean({ optional: true }) - isExternal?: boolean; - - @QueryBoolean({ optional: true }) - isFavorite?: boolean; - - @QueryBoolean({ optional: true }) - isMotion?: boolean; - - @QueryBoolean({ optional: true }) - isOffline?: boolean; - - @QueryBoolean({ optional: true }) - isReadOnly?: boolean; - - @QueryBoolean({ optional: true }) - isVisible?: boolean; + checksum?: string; @QueryBoolean({ optional: true }) - withDeleted?: boolean; + withStacked?: boolean; @QueryBoolean({ optional: true }) - withExif?: boolean; - - @QueryDate({ optional: true }) - createdBefore?: Date; - - @QueryDate({ optional: true }) - createdAfter?: Date; - - @QueryDate({ optional: true }) - updatedBefore?: Date; - - @QueryDate({ optional: true }) - updatedAfter?: Date; - - @QueryDate({ optional: true }) - trashedBefore?: Date; - - @QueryDate({ optional: true }) - trashedAfter?: Date; - - @QueryDate({ optional: true }) - takenBefore?: Date; - - @QueryDate({ optional: true }) - takenAfter?: Date; - - @IsString() - @IsNotEmpty() - @Optional() - city?: string; + withPeople?: boolean; @IsString() @IsNotEmpty() @Optional() - state?: string; + originalFileName?: string; @IsString() @IsNotEmpty() @Optional() - country?: string; + originalPath?: string; @IsString() @IsNotEmpty() @Optional() - make?: string; + resizePath?: string; @IsString() @IsNotEmpty() @Optional() - model?: string; + webpPath?: string; @IsString() @IsNotEmpty() @Optional() - lensModel?: string; + encodedVideoPath?: string; - @IsInt() - @Min(1) - @Type(() => Number) + @IsEnum(AssetOrder) @Optional() - page?: number; + @ApiProperty({ enumName: 'AssetOrder', enum: AssetOrder }) + order?: AssetOrder; +} - @IsInt() - @Min(1) - @Type(() => Number) - @Optional() - size?: number; +export class SmartSearchDto extends BaseSearchDto { + @IsString() + @IsNotEmpty() + query!: string; } export class SearchPeopleDto { From 8cbe5687fe3b176d938fbcc2b50b4624d6e96bb4 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Sat, 10 Feb 2024 19:12:21 -0500 Subject: [PATCH 26/38] refinements --- server/src/domain/asset/asset.service.ts | 3 -- server/src/domain/search/dto/search.dto.ts | 5 ++- server/src/domain/search/search.service.ts | 38 +++++++++---------- .../immich/controllers/asset.controller.ts | 7 ++-- .../immich/controllers/search.controller.ts | 10 ++++- 5 files changed, 34 insertions(+), 29 deletions(-) diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts index 999966812d51d..d46678b8b4390 100644 --- a/server/src/domain/asset/asset.service.ts +++ b/server/src/domain/asset/asset.service.ts @@ -18,7 +18,6 @@ import { ICommunicationRepository, IJobRepository, IPartnerRepository, - ISearchRepository, IStorageRepository, ISystemConfigRepository, IUserRepository, @@ -32,8 +31,6 @@ import { AssetBulkUpdateDto, AssetJobName, AssetJobsDto, - AssetOrder, - AssetSearchDto, AssetStatsDto, MapMarkerDto, MemoryLaneDto, diff --git a/server/src/domain/search/dto/search.dto.ts b/server/src/domain/search/dto/search.dto.ts index fe38185cc8cd2..de103729d04aa 100644 --- a/server/src/domain/search/dto/search.dto.ts +++ b/server/src/domain/search/dto/search.dto.ts @@ -2,7 +2,7 @@ import { AssetOrder } from '@app/domain/asset/dto/asset.dto'; import { AssetType } from '@app/infra/entities'; import { ApiProperty } from '@nestjs/swagger'; import { Transform, Type } from 'class-transformer'; -import { IsBoolean, IsEnum, IsInt, IsNotEmpty, IsString, Min } from 'class-validator'; +import { IsBoolean, IsEnum, IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator'; import { Optional, QueryBoolean, QueryDate, ValidateUUID, toBoolean } from '../../domain.util'; class BaseSearchDto { @@ -113,12 +113,13 @@ class BaseSearchDto { @IsInt() @Min(1) + @Max(1000) @Type(() => Number) @Optional() size?: number; } -export class AssetSearchDto extends BaseSearchDto { +export class MetadataSearchDto extends BaseSearchDto { @ValidateUUID({ optional: true }) id?: string; diff --git a/server/src/domain/search/search.service.ts b/server/src/domain/search/search.service.ts index e6d01163cdc34..f2e7b84a043e6 100644 --- a/server/src/domain/search/search.service.ts +++ b/server/src/domain/search/search.service.ts @@ -1,4 +1,3 @@ -import { AssetEntity } from '@app/infra/entities'; import { ImmichLogger } from '@app/infra/logger'; import { Inject, Injectable } from '@nestjs/common'; import { AssetOrder, AssetResponseDto, mapAsset } from '../asset'; @@ -12,10 +11,9 @@ import { ISearchRepository, ISystemConfigRepository, SearchExploreItem, - SearchStrategy, } from '../repositories'; import { FeatureFlag, SystemConfigCore } from '../system-config'; -import { AssetSearchDto, SearchPeopleDto, SmartSearchDto } from './dto'; +import { MetadataSearchDto, SearchPeopleDto, SmartSearchDto } from './dto'; import { SearchResponseDto } from './response-dto'; @Injectable() @@ -55,7 +53,7 @@ export class SearchService { })); } - async searchMetadata(auth: AuthDto, dto: AssetSearchDto) { + async searchMetadata(auth: AuthDto, dto: MetadataSearchDto): Promise { let checksum: Buffer | undefined; if (dto.checksum) { @@ -63,9 +61,11 @@ export class SearchService { checksum = Buffer.from(dto.checksum, encoding); } + const page = dto.page ?? 1; + const size = dto.size || 250; const enumToOrder = { [AssetOrder.ASC]: 'ASC', [AssetOrder.DESC]: 'DESC' } as const; - const { items } = await this.searchRepository.searchMetadata( - { page: dto.page ? dto.page - 1 : 0, size: dto.size ?? 250 }, + const { hasNextPage, items } = await this.searchRepository.searchMetadata( + { page, size }, { ...dto, checksum, @@ -73,12 +73,17 @@ export class SearchService { orderDirection: dto.order ? enumToOrder[dto.order] : 'DESC', }, ); - return items.map((asset) => - mapAsset(asset, { - stripMetadata: false, - withStack: true, - }), - ); + + return { + albums: { total: 0, count: 0, items: [], facets: [] }, + assets: { + total: items.length, + count: items.length, + items: items.map((asset) => mapAsset(asset)), + facets: [], + nextPage: hasNextPage ? (page + 1).toString() : null, + }, + }; } async searchSmart(auth: AuthDto, dto: SmartSearchDto): Promise { @@ -93,7 +98,7 @@ export class SearchService { machineLearning.clip, ); - const page = dto.page ?? 0; + const page = dto.page ?? 1; const size = dto.size || 100; const { hasNextPage, items } = await this.searchRepository.searchSmart( { page, size }, @@ -101,12 +106,7 @@ export class SearchService { ); return { - albums: { - total: 0, - count: 0, - items: [], - facets: [], - }, + albums: { total: 0, count: 0, items: [], facets: [] }, assets: { total: items.length, count: items.length, diff --git a/server/src/immich/controllers/asset.controller.ts b/server/src/immich/controllers/asset.controller.ts index eb22ac7a8a96c..a780299769f05 100644 --- a/server/src/immich/controllers/asset.controller.ts +++ b/server/src/immich/controllers/asset.controller.ts @@ -3,7 +3,7 @@ import { AssetBulkUpdateDto, AssetJobsDto, AssetResponseDto, - AssetSearchDto, + MetadataSearchDto, AssetService, AssetStatsDto, AssetStatsResponseDto, @@ -39,8 +39,9 @@ export class AssetsController { @Get() @ApiOperation({deprecated: true}) - searchAssets(@Auth() auth: AuthDto, @Query() dto: AssetSearchDto): Promise { - return this.searchService.searchMetadata(auth, dto); + async searchAssets(@Auth() auth: AuthDto, @Query() dto: MetadataSearchDto): Promise { + const { assets } = await this.searchService.searchMetadata(auth, dto); + return assets.items; } } diff --git a/server/src/immich/controllers/search.controller.ts b/server/src/immich/controllers/search.controller.ts index 03f229b740204..2bb252da9055d 100644 --- a/server/src/immich/controllers/search.controller.ts +++ b/server/src/immich/controllers/search.controller.ts @@ -1,11 +1,12 @@ import { + MetadataSearchDto, AuthDto, PersonResponseDto, - SearchDto, SearchExploreResponseDto, SearchPeopleDto, SearchResponseDto, SearchService, + SmartSearchDto, } from '@app/domain'; import { Controller, Get, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; @@ -20,7 +21,12 @@ export class SearchController { constructor(private service: SearchService) {} @Get() - search(@Auth() auth: AuthDto, @Query() dto: SearchDto): Promise { + searchMetadata(@Auth() auth: AuthDto, @Query() dto: MetadataSearchDto): Promise { + return this.service.searchMetadata(auth, dto); + } + + @Get() + searchSmart(@Auth() auth: AuthDto, @Query() dto: SmartSearchDto): Promise { return this.service.searchSmart(auth, dto); } From 4fe20824a5555a7f7d924d40c08498a03cbcb0a8 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Sat, 10 Feb 2024 20:05:37 -0500 Subject: [PATCH 27/38] don't break everything just yet --- server/e2e/api/specs/asset.e2e-spec.ts | 6 +- server/src/domain/asset/asset.service.spec.ts | 1 - .../domain/repositories/search.repository.ts | 3 +- server/src/domain/search/dto/search.dto.ts | 57 ++++++++++++ .../src/domain/search/search.service.spec.ts | 14 +-- server/src/domain/search/search.service.ts | 93 ++++++++++++++----- .../immich/controllers/asset.controller.ts | 10 +- .../immich/controllers/search.controller.ts | 15 ++- .../infra/repositories/search.repository.ts | 8 +- 9 files changed, 164 insertions(+), 43 deletions(-) diff --git a/server/e2e/api/specs/asset.e2e-spec.ts b/server/e2e/api/specs/asset.e2e-spec.ts index 533faf20b23a5..5993a7040033d 100644 --- a/server/e2e/api/specs/asset.e2e-spec.ts +++ b/server/e2e/api/specs/asset.e2e-spec.ts @@ -169,7 +169,11 @@ describe(`${AssetController.name} (e2e)`, () => { { should: 'should reject size as a string', query: { size: 'abc' }, - expected: ['size must not be less than 1', 'size must be an integer number'], + expected: [ + 'size must not be greater than 1000', + 'size must not be less than 1', + 'size must be an integer number', + ], }, { should: 'should reject an invalid size', diff --git a/server/src/domain/asset/asset.service.spec.ts b/server/src/domain/asset/asset.service.spec.ts index 9672330c796d8..547fce2405919 100644 --- a/server/src/domain/asset/asset.service.spec.ts +++ b/server/src/domain/asset/asset.service.spec.ts @@ -194,7 +194,6 @@ describe(AssetService.name, () => { communicationMock, partnerMock, assetStackMock, - searchMock, ); when(assetMock.getById) diff --git a/server/src/domain/repositories/search.repository.ts b/server/src/domain/repositories/search.repository.ts index 93c65d9cb87d5..dd12ab1a66aca 100644 --- a/server/src/domain/repositories/search.repository.ts +++ b/server/src/domain/repositories/search.repository.ts @@ -1,4 +1,5 @@ import { AssetEntity, AssetFaceEntity, AssetType, SmartInfoEntity } from '@app/infra/entities'; +import { SearchDto } from '..'; import { Paginated } from '../domain.util'; export const ISearchRepository = 'ISearchRepository'; @@ -155,7 +156,7 @@ export type SmartSearchOptions = SearchDateOptions & SearchEmbeddingOptions & SearchExifOptions & SearchOneToOneRelationOptions & - SearchStatusOptions & + SearchStatusOptions & SearchUserIDOptions; export interface FaceEmbeddingSearch extends SearchEmbeddingOptions { diff --git a/server/src/domain/search/dto/search.dto.ts b/server/src/domain/search/dto/search.dto.ts index de103729d04aa..246a066811eed 100644 --- a/server/src/domain/search/dto/search.dto.ts +++ b/server/src/domain/search/dto/search.dto.ts @@ -176,6 +176,63 @@ export class SmartSearchDto extends BaseSearchDto { query!: string; } +// TODO: remove after implementing new search filters +/** @deprecated */ +export class SearchDto { + @IsString() + @IsNotEmpty() + @Optional() + q?: string; + + @IsString() + @IsNotEmpty() + @Optional() + query?: string; + + @IsBoolean() + @Optional() + @Transform(toBoolean) + smart?: boolean; + + /** @deprecated */ + @IsBoolean() + @Optional() + @Transform(toBoolean) + clip?: boolean; + + @IsEnum(AssetType) + @Optional() + type?: AssetType; + + @IsBoolean() + @Optional() + @Transform(toBoolean) + recent?: boolean; + + @IsBoolean() + @Optional() + @Transform(toBoolean) + motion?: boolean; + + @IsBoolean() + @Optional() + @Transform(toBoolean) + withArchived?: boolean; + + @IsInt() + @Min(1) + @Type(() => Number) + @Optional() + page?: number; + + @IsInt() + @Min(1) + @Max(1000) + @Type(() => Number) + @Optional() + size?: number; +} + export class SearchPeopleDto { @IsString() @IsNotEmpty() diff --git a/server/src/domain/search/search.service.spec.ts b/server/src/domain/search/search.service.spec.ts index 51a4c165b114a..4f8d8c8fe0ddf 100644 --- a/server/src/domain/search/search.service.spec.ts +++ b/server/src/domain/search/search.service.spec.ts @@ -85,7 +85,7 @@ describe(SearchService.name, () => { describe('search', () => { it('should throw an error if query is missing', async () => { - await expect(sut.searchSmart(authStub.user1, { q: '' })).rejects.toThrow('Missing query'); + await expect(sut.search(authStub.user1, { q: '' })).rejects.toThrow('Missing query'); }); it('should search by metadata if `clip` option is false', async () => { @@ -108,7 +108,7 @@ describe(SearchService.name, () => { }, }; - const result = await sut.searchSmart(authStub.user1, dto); + const result = await sut.search(authStub.user1, dto); expect(result).toEqual(expectedResponse); expect(assetMock.searchMetadata).toHaveBeenCalledWith(dto.q, [authStub.user1.user.id], { numResults: 250 }); @@ -137,11 +137,11 @@ describe(SearchService.name, () => { }, }; - const result = await sut.searchSmart(authStub.user1, dto); + const result = await sut.search(authStub.user1, dto); expect(result).toEqual(expectedResponse); expect(searchMock.searchSmart).toHaveBeenCalledWith( - { page: 0, size: 100 }, + { page: 1, size: 100 }, { userIds: [authStub.user1.user.id], embedding, @@ -173,11 +173,11 @@ describe(SearchService.name, () => { }, }; - const result = await sut.searchSmart(authStub.user1, dto); + const result = await sut.search(authStub.user1, dto); expect(result).toEqual(expectedResponse); expect(searchMock.searchSmart).toHaveBeenCalledWith( - { page: 0, size: 100 }, + { page: 1, size: 100 }, { userIds: [authStub.user1.user.id], embedding, @@ -194,7 +194,7 @@ describe(SearchService.name, () => { const dto: SearchDto = { q: 'test query', clip: true }; configMock.load.mockResolvedValue([{ key, value: false }]); - await expect(sut.searchSmart(authStub.user1, dto)).rejects.toThrow('Smart search is not enabled'); + await expect(sut.search(authStub.user1, dto)).rejects.toThrow('Smart search is not enabled'); }); }); }); diff --git a/server/src/domain/search/search.service.ts b/server/src/domain/search/search.service.ts index f2e7b84a043e6..6ceb9c5d5f8c4 100644 --- a/server/src/domain/search/search.service.ts +++ b/server/src/domain/search/search.service.ts @@ -1,3 +1,4 @@ +import { AssetEntity } from '@app/infra/entities'; import { ImmichLogger } from '@app/infra/logger'; import { Inject, Injectable } from '@nestjs/common'; import { AssetOrder, AssetResponseDto, mapAsset } from '../asset'; @@ -11,9 +12,10 @@ import { ISearchRepository, ISystemConfigRepository, SearchExploreItem, + SearchStrategy, } from '../repositories'; import { FeatureFlag, SystemConfigCore } from '../system-config'; -import { MetadataSearchDto, SearchPeopleDto, SmartSearchDto } from './dto'; +import { MetadataSearchDto, SearchDto, SearchPeopleDto, SmartSearchDto } from './dto'; import { SearchResponseDto } from './response-dto'; @Injectable() @@ -74,16 +76,7 @@ export class SearchService { }, ); - return { - albums: { total: 0, count: 0, items: [], facets: [] }, - assets: { - total: items.length, - count: items.length, - items: items.map((asset) => mapAsset(asset)), - facets: [], - nextPage: hasNextPage ? (page + 1).toString() : null, - }, - }; + return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null); } async searchSmart(auth: AuthDto, dto: SmartSearchDto): Promise { @@ -105,16 +98,61 @@ export class SearchService { { ...dto, userIds, embedding }, ); - return { - albums: { total: 0, count: 0, items: [], facets: [] }, - assets: { - total: items.length, - count: items.length, - items: items.map((asset) => mapAsset(asset)), - facets: [], - nextPage: hasNextPage ? (page + 1).toString() : null, - }, - }; + return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null); + } + + // TODO: remove after implementing new search filters + /** @deprecated */ + async search(auth: AuthDto, dto: SearchDto): Promise { + await this.configCore.requireFeature(FeatureFlag.SEARCH); + const { machineLearning } = await this.configCore.getConfig(); + const query = dto.q || dto.query; + if (!query) { + throw new Error('Missing query'); + } + + let strategy = SearchStrategy.TEXT; + if (dto.smart || dto.clip) { + await this.configCore.requireFeature(FeatureFlag.SMART_SEARCH); + strategy = SearchStrategy.SMART; + } + + const userIds = await this.getUserIdsToSearch(auth); + const page = dto.page ?? 1; + + let nextPage: string | null = null; + let assets: AssetEntity[] = []; + switch (strategy) { + case SearchStrategy.SMART: { + const embedding = await this.machineLearning.encodeText( + machineLearning.url, + { text: query }, + machineLearning.clip, + ); + + const { hasNextPage, items } = await this.searchRepository.searchSmart( + { page, size: dto.size || 100 }, + { + userIds, + embedding, + withArchived: !!dto.withArchived, + }, + ); + if (hasNextPage) { + nextPage = (page + 1).toString(); + } + assets = items; + break; + } + case SearchStrategy.TEXT: { + assets = await this.assetRepository.searchMetadata(query, userIds, { numResults: dto.size || 250 }); + } + default: { + break; + } + } + + return this.mapResponse(assets, nextPage); } private async getUserIdsToSearch(auth: AuthDto): Promise { @@ -126,4 +164,17 @@ export class SearchService { userIds.push(...partnersIds); return userIds; } + + private async mapResponse(assets: AssetEntity[], nextPage: string | null): Promise { + return { + albums: { total: 0, count: 0, items: [], facets: [] }, + assets: { + total: assets.length, + count: assets.length, + items: assets.map((asset) => mapAsset(asset)), + facets: [], + nextPage, + }, + }; + } } diff --git a/server/src/immich/controllers/asset.controller.ts b/server/src/immich/controllers/asset.controller.ts index a780299769f05..57a67d33fee71 100644 --- a/server/src/immich/controllers/asset.controller.ts +++ b/server/src/immich/controllers/asset.controller.ts @@ -3,7 +3,6 @@ import { AssetBulkUpdateDto, AssetJobsDto, AssetResponseDto, - MetadataSearchDto, AssetService, AssetStatsDto, AssetStatsResponseDto, @@ -14,6 +13,7 @@ import { MapMarkerResponseDto, MemoryLaneDto, MemoryLaneResponseDto, + MetadataSearchDto, RandomAssetsDto, SearchService, TimeBucketAssetDto, @@ -38,10 +38,12 @@ export class AssetsController { constructor(private searchService: SearchService) {} @Get() - @ApiOperation({deprecated: true}) + @ApiOperation({ deprecated: true }) async searchAssets(@Auth() auth: AuthDto, @Query() dto: MetadataSearchDto): Promise { - const { assets } = await this.searchService.searchMetadata(auth, dto); - return assets.items; + const { + assets: { items }, + } = await this.searchService.searchMetadata(auth, dto); + return items; } } diff --git a/server/src/immich/controllers/search.controller.ts b/server/src/immich/controllers/search.controller.ts index 2bb252da9055d..1cc204e8405dd 100644 --- a/server/src/immich/controllers/search.controller.ts +++ b/server/src/immich/controllers/search.controller.ts @@ -1,7 +1,8 @@ import { - MetadataSearchDto, AuthDto, + MetadataSearchDto, PersonResponseDto, + SearchDto, SearchExploreResponseDto, SearchPeopleDto, SearchResponseDto, @@ -9,7 +10,7 @@ import { SmartSearchDto, } from '@app/domain'; import { Controller, Get, Query } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { Auth, Authenticated } from '../app.guard'; import { UseValidation } from '../app.utils'; @@ -20,16 +21,22 @@ import { UseValidation } from '../app.utils'; export class SearchController { constructor(private service: SearchService) {} - @Get() + @Get('metadata') searchMetadata(@Auth() auth: AuthDto, @Query() dto: MetadataSearchDto): Promise { return this.service.searchMetadata(auth, dto); } - @Get() + @Get('smart') searchSmart(@Auth() auth: AuthDto, @Query() dto: SmartSearchDto): Promise { return this.service.searchSmart(auth, dto); } + @Get() + @ApiOperation({ deprecated: true }) + search(@Auth() auth: AuthDto, @Query() dto: SearchDto): Promise { + return this.service.search(auth, dto); + } + @Get('explore') getExploreData(@Auth() auth: AuthDto): Promise { return this.service.getExploreData(auth) as Promise; diff --git a/server/src/infra/repositories/search.repository.ts b/server/src/infra/repositories/search.repository.ts index eb2a981949bf0..2902d442aac3a 100644 --- a/server/src/infra/repositories/search.repository.ts +++ b/server/src/infra/repositories/search.repository.ts @@ -52,7 +52,7 @@ export class SearchRepository implements ISearchRepository { @GenerateSql({ params: [ - { page: 0, size: 100 }, + { page: 1, size: 100 }, { takenAfter: DummyValue.DATE, lensModel: DummyValue.STRING, @@ -70,14 +70,14 @@ export class SearchRepository implements ISearchRepository { return paginatedBuilder(builder, { mode: PaginationMode.SKIP_TAKE, - skip: pagination.page * pagination.size, + skip: (pagination.page - 1) * pagination.size, take: pagination.size, }); } @GenerateSql({ params: [ - { page: 0, size: 100 }, + { page: 1, size: 100 }, { takenAfter: DummyValue.DATE, embedding: Array.from({ length: 512 }, Math.random), @@ -106,7 +106,7 @@ export class SearchRepository implements ISearchRepository { await manager.query(this.getRuntimeConfig(pagination.size)); results = await paginatedBuilder(builder, { mode: PaginationMode.LIMIT_OFFSET, - skip: pagination.page * pagination.size, + skip: (pagination.page - 1) * pagination.size, take: pagination.size, }); }); From 6a259d8903b9337ea857b6697e7030b81cffc6b2 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Sat, 10 Feb 2024 20:11:37 -0500 Subject: [PATCH 28/38] update openapi spec & sql --- open-api/immich-openapi-specs.json | 735 ++++++++++++++++++++- server/src/infra/sql/search.repository.sql | 45 +- 2 files changed, 720 insertions(+), 60 deletions(-) diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 3113c599b4a08..501218fc8c3d4 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -2130,6 +2130,7 @@ }, "/assets": { "get": { + "deprecated": true, "operationId": "searchAssets", "parameters": [ { @@ -2430,6 +2431,14 @@ "type": "string" } }, + { + "name": "withArchived", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, { "name": "withDeleted", "required": false, @@ -4354,6 +4363,7 @@ }, "/search": { "get": { + "deprecated": true, "operationId": "search", "parameters": [ { @@ -4374,6 +4384,14 @@ "type": "boolean" } }, + { + "name": "page", + "required": false, + "in": "query", + "schema": { + "type": "number" + } + }, { "name": "q", "required": false, @@ -4398,6 +4416,14 @@ "type": "boolean" } }, + { + "name": "size", + "required": false, + "in": "query", + "schema": { + "type": "number" + } + }, { "name": "smart", "required": false, @@ -4427,22 +4453,6 @@ "schema": { "type": "boolean" } - }, - { - "name": "take", - "required": false, - "in": "query", - "schema": { - "type": "number" - } - }, - { - "name": "page", - "required": false, - "in": "query", - "schema": { - "type": "number" - } } ], "responses": { @@ -4508,36 +4518,697 @@ ] } }, - "/search/person": { + "/search/metadata": { "get": { - "operationId": "searchPerson", + "operationId": "searchMetadata", "parameters": [ { - "name": "name", - "required": true, + "name": "checksum", + "required": false, "in": "query", "schema": { "type": "string" } }, { - "name": "withHidden", + "name": "city", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "country", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "createdAfter", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "name": "createdBefore", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "name": "deviceAssetId", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "deviceId", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "encodedVideoPath", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "id", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "isArchived", "required": false, "in": "query", "schema": { "type": "boolean" } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/PersonResponseDto" - }, - "type": "array" + }, + { + "name": "isEncoded", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isExternal", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isFavorite", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isMotion", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isOffline", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isReadOnly", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isVisible", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "lensModel", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "libraryId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "make", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "model", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "order", + "required": false, + "in": "query", + "schema": { + "$ref": "#/components/schemas/AssetOrder" + } + }, + { + "name": "originalFileName", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "originalPath", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "page", + "required": false, + "in": "query", + "schema": { + "type": "number" + } + }, + { + "name": "resizePath", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "size", + "required": false, + "in": "query", + "schema": { + "type": "number" + } + }, + { + "name": "state", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "takenAfter", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "name": "takenBefore", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "name": "trashedAfter", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "name": "trashedBefore", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "name": "type", + "required": false, + "in": "query", + "schema": { + "$ref": "#/components/schemas/AssetTypeEnum" + } + }, + { + "name": "updatedAfter", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "name": "updatedBefore", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "name": "webpPath", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "withArchived", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "withDeleted", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "withExif", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "withPeople", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "withStacked", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Search" + ] + } + }, + "/search/person": { + "get": { + "operationId": "searchPerson", + "parameters": [ + { + "name": "name", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "withHidden", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/PersonResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Search" + ] + } + }, + "/search/smart": { + "get": { + "operationId": "searchSmart", + "parameters": [ + { + "name": "city", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "country", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "createdAfter", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "name": "createdBefore", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "name": "deviceId", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "isArchived", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isEncoded", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isExternal", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isFavorite", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isMotion", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isOffline", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isReadOnly", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isVisible", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "lensModel", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "libraryId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "make", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "model", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "page", + "required": false, + "in": "query", + "schema": { + "type": "number" + } + }, + { + "name": "query", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "size", + "required": false, + "in": "query", + "schema": { + "type": "number" + } + }, + { + "name": "state", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "takenAfter", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "name": "takenBefore", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "name": "trashedAfter", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "name": "trashedBefore", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "name": "type", + "required": false, + "in": "query", + "schema": { + "$ref": "#/components/schemas/AssetTypeEnum" + } + }, + { + "name": "updatedAfter", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "name": "updatedBefore", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "name": "withArchived", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "withDeleted", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "withExif", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchResponseDto" } } }, diff --git a/server/src/infra/sql/search.repository.sql b/server/src/infra/sql/search.repository.sql index 3849bfad3d77f..974e130e92863 100644 --- a/server/src/infra/sql/search.repository.sql +++ b/server/src/infra/sql/search.repository.sql @@ -1,8 +1,9 @@ -- NOTE: This file is auto generated by ./sql-generator --- SearchRepository.searchAssets +-- SearchRepository.searchMetadata SELECT DISTINCT - "distinctAlias"."asset_id" AS "ids_asset_id" + "distinctAlias"."asset_id" AS "ids_asset_id", + "distinctAlias"."asset_fileCreatedAt" FROM ( SELECT @@ -74,18 +75,11 @@ FROM AND ("stackedAssets"."deletedAt" IS NULL) WHERE ( - ( - "asset"."createdAt" = $1 - AND "asset"."updatedAt" = $2 - AND "asset"."deletedAt" = $3 - AND "asset"."fileCreatedAt" >= $4 - ) - AND "exifInfo"."lensModel" = $5 - AND "asset"."ownerId" = $6 - AND ( - "asset"."isFavorite" = $7 - AND "asset"."isArchived" = $8 - ) + "asset"."fileCreatedAt" >= $1 + AND "exifInfo"."lensModel" = $2 + AND "asset"."ownerId" = $3 + AND 1 = 1 + AND "asset"."isFavorite" = $4 AND ( "stack"."primaryAssetId" = "asset"."id" OR "asset"."stackId" IS NULL @@ -94,11 +88,12 @@ FROM AND ("asset"."deletedAt" IS NULL) ) "distinctAlias" ORDER BY + "distinctAlias"."asset_fileCreatedAt" DESC, "asset_id" ASC LIMIT 101 --- SearchRepository.searchCLIP +-- SearchRepository.searchSmart START TRANSACTION SET LOCAL vectors.enable_prefilter = on; @@ -178,26 +173,20 @@ FROM INNER JOIN "smart_search" "search" ON "search"."assetId" = "asset"."id" WHERE ( - ( - "asset"."createdAt" = $1 - AND "asset"."updatedAt" = $2 - AND "asset"."deletedAt" = $3 - AND "asset"."fileCreatedAt" >= $4 - ) - AND "exifInfo"."lensModel" = $5 - AND ( - "asset"."isFavorite" = $6 - AND "asset"."isArchived" = $7 - ) + "asset"."fileCreatedAt" >= $1 + AND "exifInfo"."lensModel" = $2 + AND 1 = 1 + AND 1 = 1 + AND "asset"."isFavorite" = $3 AND ( "stack"."primaryAssetId" = "asset"."id" OR "asset"."stackId" IS NULL ) - AND "asset"."ownerId" IN ($8) + AND "asset"."ownerId" IN ($4) ) AND ("asset"."deletedAt" IS NULL) ORDER BY - "search"."embedding" <= > $9 ASC + "search"."embedding" <= > $5 ASC LIMIT 101 COMMIT From f99deda000af31e3cb24cb1f96d311891fc15a44 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Sat, 10 Feb 2024 20:12:02 -0500 Subject: [PATCH 29/38] update api --- mobile/openapi/README.md | 2 + mobile/openapi/doc/AssetApi.md | 6 +- mobile/openapi/doc/SearchApi.md | 262 +++- mobile/openapi/lib/api/asset_api.dart | 13 +- mobile/openapi/lib/api/search_api.dart | 618 +++++++- mobile/openapi/test/asset_api_test.dart | 2 +- mobile/openapi/test/search_api_test.dart | 12 +- open-api/typescript-sdk/axios-client/api.ts | 1495 ++++++++++++++++--- open-api/typescript-sdk/fetch-client.ts | 180 ++- 9 files changed, 2381 insertions(+), 209 deletions(-) diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 3c662af41f09e..1657aa69cb1ee 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -162,7 +162,9 @@ Class | Method | HTTP request | Description *PersonApi* | [**updatePerson**](doc//PersonApi.md#updateperson) | **PUT** /person/{id} | *SearchApi* | [**getExploreData**](doc//SearchApi.md#getexploredata) | **GET** /search/explore | *SearchApi* | [**search**](doc//SearchApi.md#search) | **GET** /search | +*SearchApi* | [**searchMetadata**](doc//SearchApi.md#searchmetadata) | **GET** /search/metadata | *SearchApi* | [**searchPerson**](doc//SearchApi.md#searchperson) | **GET** /search/person | +*SearchApi* | [**searchSmart**](doc//SearchApi.md#searchsmart) | **GET** /search/smart | *ServerInfoApi* | [**getServerConfig**](doc//ServerInfoApi.md#getserverconfig) | **GET** /server-info/config | *ServerInfoApi* | [**getServerFeatures**](doc//ServerInfoApi.md#getserverfeatures) | **GET** /server-info/features | *ServerInfoApi* | [**getServerInfo**](doc//ServerInfoApi.md#getserverinfo) | **GET** /server-info | diff --git a/mobile/openapi/doc/AssetApi.md b/mobile/openapi/doc/AssetApi.md index a7ea1c07c79a6..623d88b388d50 100644 --- a/mobile/openapi/doc/AssetApi.md +++ b/mobile/openapi/doc/AssetApi.md @@ -1034,7 +1034,7 @@ void (empty response body) [[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) # **searchAssets** -> List searchAssets(checksum, city, country, createdAfter, createdBefore, deviceAssetId, deviceId, encodedVideoPath, id, isArchived, isEncoded, isExternal, isFavorite, isMotion, isOffline, isReadOnly, isVisible, lensModel, libraryId, make, model, order, originalFileName, originalPath, page, resizePath, size, state, takenAfter, takenBefore, trashedAfter, trashedBefore, type, updatedAfter, updatedBefore, webpPath, withDeleted, withExif, withPeople, withStacked) +> List searchAssets(checksum, city, country, createdAfter, createdBefore, deviceAssetId, deviceId, encodedVideoPath, id, isArchived, isEncoded, isExternal, isFavorite, isMotion, isOffline, isReadOnly, isVisible, lensModel, libraryId, make, model, order, originalFileName, originalPath, page, resizePath, size, state, takenAfter, takenBefore, trashedAfter, trashedBefore, type, updatedAfter, updatedBefore, webpPath, withArchived, withDeleted, withExif, withPeople, withStacked) @@ -1093,13 +1093,14 @@ final type = ; // AssetTypeEnum | final updatedAfter = 2013-10-20T19:20:30+01:00; // DateTime | final updatedBefore = 2013-10-20T19:20:30+01:00; // DateTime | final webpPath = webpPath_example; // String | +final withArchived = true; // bool | final withDeleted = true; // bool | final withExif = true; // bool | final withPeople = true; // bool | final withStacked = true; // bool | try { - final result = api_instance.searchAssets(checksum, city, country, createdAfter, createdBefore, deviceAssetId, deviceId, encodedVideoPath, id, isArchived, isEncoded, isExternal, isFavorite, isMotion, isOffline, isReadOnly, isVisible, lensModel, libraryId, make, model, order, originalFileName, originalPath, page, resizePath, size, state, takenAfter, takenBefore, trashedAfter, trashedBefore, type, updatedAfter, updatedBefore, webpPath, withDeleted, withExif, withPeople, withStacked); + final result = api_instance.searchAssets(checksum, city, country, createdAfter, createdBefore, deviceAssetId, deviceId, encodedVideoPath, id, isArchived, isEncoded, isExternal, isFavorite, isMotion, isOffline, isReadOnly, isVisible, lensModel, libraryId, make, model, order, originalFileName, originalPath, page, resizePath, size, state, takenAfter, takenBefore, trashedAfter, trashedBefore, type, updatedAfter, updatedBefore, webpPath, withArchived, withDeleted, withExif, withPeople, withStacked); print(result); } catch (e) { print('Exception when calling AssetApi->searchAssets: $e\n'); @@ -1146,6 +1147,7 @@ Name | Type | Description | Notes **updatedAfter** | **DateTime**| | [optional] **updatedBefore** | **DateTime**| | [optional] **webpPath** | **String**| | [optional] + **withArchived** | **bool**| | [optional] **withDeleted** | **bool**| | [optional] **withExif** | **bool**| | [optional] **withPeople** | **bool**| | [optional] diff --git a/mobile/openapi/doc/SearchApi.md b/mobile/openapi/doc/SearchApi.md index 950f3abe934a8..40b44fb011ba8 100644 --- a/mobile/openapi/doc/SearchApi.md +++ b/mobile/openapi/doc/SearchApi.md @@ -11,7 +11,9 @@ Method | HTTP request | Description ------------- | ------------- | ------------- [**getExploreData**](SearchApi.md#getexploredata) | **GET** /search/explore | [**search**](SearchApi.md#search) | **GET** /search | +[**searchMetadata**](SearchApi.md#searchmetadata) | **GET** /search/metadata | [**searchPerson**](SearchApi.md#searchperson) | **GET** /search/person | +[**searchSmart**](SearchApi.md#searchsmart) | **GET** /search/smart | # **getExploreData** @@ -66,7 +68,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(clip, motion, q, query, recent, smart, type, withArchived, take, page) +> SearchResponseDto search(clip, motion, page, q, query, recent, size, smart, type, withArchived) @@ -91,17 +93,17 @@ import 'package:openapi/api.dart'; final api_instance = SearchApi(); final clip = true; // bool | @deprecated final motion = true; // bool | +final page = 8.14; // num | final q = q_example; // String | final query = query_example; // String | final recent = true; // bool | +final size = 8.14; // num | final smart = true; // bool | final type = type_example; // String | final withArchived = true; // bool | -final take = 8.14; // num | -final page = 8.14; // num | try { - final result = api_instance.search(clip, motion, q, query, recent, smart, type, withArchived, take, page); + final result = api_instance.search(clip, motion, page, q, query, recent, size, smart, type, withArchived); print(result); } catch (e) { print('Exception when calling SearchApi->search: $e\n'); @@ -114,14 +116,149 @@ Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- **clip** | **bool**| @deprecated | [optional] **motion** | **bool**| | [optional] + **page** | **num**| | [optional] **q** | **String**| | [optional] **query** | **String**| | [optional] **recent** | **bool**| | [optional] + **size** | **num**| | [optional] **smart** | **bool**| | [optional] **type** | **String**| | [optional] **withArchived** | **bool**| | [optional] - **take** | **num**| | [optional] + +### Return type + +[**SearchResponseDto**](SearchResponseDto.md) + +### Authorization + +[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: application/json + +[[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) + +# **searchMetadata** +> SearchResponseDto searchMetadata(checksum, city, country, createdAfter, createdBefore, deviceAssetId, deviceId, encodedVideoPath, id, isArchived, isEncoded, isExternal, isFavorite, isMotion, isOffline, isReadOnly, isVisible, lensModel, libraryId, make, model, order, originalFileName, originalPath, page, resizePath, size, state, takenAfter, takenBefore, trashedAfter, trashedBefore, type, updatedAfter, updatedBefore, webpPath, withArchived, withDeleted, withExif, withPeople, withStacked) + + + +### Example +```dart +import 'package:openapi/api.dart'; +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure API key authorization: api_key +//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); + +final api_instance = SearchApi(); +final checksum = checksum_example; // String | +final city = city_example; // String | +final country = country_example; // String | +final createdAfter = 2013-10-20T19:20:30+01:00; // DateTime | +final createdBefore = 2013-10-20T19:20:30+01:00; // DateTime | +final deviceAssetId = deviceAssetId_example; // String | +final deviceId = deviceId_example; // String | +final encodedVideoPath = encodedVideoPath_example; // String | +final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | +final isArchived = true; // bool | +final isEncoded = true; // bool | +final isExternal = true; // bool | +final isFavorite = true; // bool | +final isMotion = true; // bool | +final isOffline = true; // bool | +final isReadOnly = true; // bool | +final isVisible = true; // bool | +final lensModel = lensModel_example; // String | +final libraryId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | +final make = make_example; // String | +final model = model_example; // String | +final order = ; // AssetOrder | +final originalFileName = originalFileName_example; // String | +final originalPath = originalPath_example; // String | +final page = 8.14; // num | +final resizePath = resizePath_example; // String | +final size = 8.14; // num | +final state = state_example; // String | +final takenAfter = 2013-10-20T19:20:30+01:00; // DateTime | +final takenBefore = 2013-10-20T19:20:30+01:00; // DateTime | +final trashedAfter = 2013-10-20T19:20:30+01:00; // DateTime | +final trashedBefore = 2013-10-20T19:20:30+01:00; // DateTime | +final type = ; // AssetTypeEnum | +final updatedAfter = 2013-10-20T19:20:30+01:00; // DateTime | +final updatedBefore = 2013-10-20T19:20:30+01:00; // DateTime | +final webpPath = webpPath_example; // String | +final withArchived = true; // bool | +final withDeleted = true; // bool | +final withExif = true; // bool | +final withPeople = true; // bool | +final withStacked = true; // bool | + +try { + final result = api_instance.searchMetadata(checksum, city, country, createdAfter, createdBefore, deviceAssetId, deviceId, encodedVideoPath, id, isArchived, isEncoded, isExternal, isFavorite, isMotion, isOffline, isReadOnly, isVisible, lensModel, libraryId, make, model, order, originalFileName, originalPath, page, resizePath, size, state, takenAfter, takenBefore, trashedAfter, trashedBefore, type, updatedAfter, updatedBefore, webpPath, withArchived, withDeleted, withExif, withPeople, withStacked); + print(result); +} catch (e) { + print('Exception when calling SearchApi->searchMetadata: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **checksum** | **String**| | [optional] + **city** | **String**| | [optional] + **country** | **String**| | [optional] + **createdAfter** | **DateTime**| | [optional] + **createdBefore** | **DateTime**| | [optional] + **deviceAssetId** | **String**| | [optional] + **deviceId** | **String**| | [optional] + **encodedVideoPath** | **String**| | [optional] + **id** | **String**| | [optional] + **isArchived** | **bool**| | [optional] + **isEncoded** | **bool**| | [optional] + **isExternal** | **bool**| | [optional] + **isFavorite** | **bool**| | [optional] + **isMotion** | **bool**| | [optional] + **isOffline** | **bool**| | [optional] + **isReadOnly** | **bool**| | [optional] + **isVisible** | **bool**| | [optional] + **lensModel** | **String**| | [optional] + **libraryId** | **String**| | [optional] + **make** | **String**| | [optional] + **model** | **String**| | [optional] + **order** | [**AssetOrder**](.md)| | [optional] + **originalFileName** | **String**| | [optional] + **originalPath** | **String**| | [optional] **page** | **num**| | [optional] + **resizePath** | **String**| | [optional] + **size** | **num**| | [optional] + **state** | **String**| | [optional] + **takenAfter** | **DateTime**| | [optional] + **takenBefore** | **DateTime**| | [optional] + **trashedAfter** | **DateTime**| | [optional] + **trashedBefore** | **DateTime**| | [optional] + **type** | [**AssetTypeEnum**](.md)| | [optional] + **updatedAfter** | **DateTime**| | [optional] + **updatedBefore** | **DateTime**| | [optional] + **webpPath** | **String**| | [optional] + **withArchived** | **bool**| | [optional] + **withDeleted** | **bool**| | [optional] + **withExif** | **bool**| | [optional] + **withPeople** | **bool**| | [optional] + **withStacked** | **bool**| | [optional] ### Return type @@ -195,3 +332,118 @@ Name | Type | Description | Notes [[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) +# **searchSmart** +> SearchResponseDto searchSmart(query, city, country, createdAfter, createdBefore, deviceId, isArchived, isEncoded, isExternal, isFavorite, isMotion, isOffline, isReadOnly, isVisible, lensModel, libraryId, make, model, page, size, state, takenAfter, takenBefore, trashedAfter, trashedBefore, type, updatedAfter, updatedBefore, withArchived, withDeleted, withExif) + + + +### Example +```dart +import 'package:openapi/api.dart'; +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure API key authorization: api_key +//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); + +final api_instance = SearchApi(); +final query = query_example; // String | +final city = city_example; // String | +final country = country_example; // String | +final createdAfter = 2013-10-20T19:20:30+01:00; // DateTime | +final createdBefore = 2013-10-20T19:20:30+01:00; // DateTime | +final deviceId = deviceId_example; // String | +final isArchived = true; // bool | +final isEncoded = true; // bool | +final isExternal = true; // bool | +final isFavorite = true; // bool | +final isMotion = true; // bool | +final isOffline = true; // bool | +final isReadOnly = true; // bool | +final isVisible = true; // bool | +final lensModel = lensModel_example; // String | +final libraryId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | +final make = make_example; // String | +final model = model_example; // String | +final page = 8.14; // num | +final size = 8.14; // num | +final state = state_example; // String | +final takenAfter = 2013-10-20T19:20:30+01:00; // DateTime | +final takenBefore = 2013-10-20T19:20:30+01:00; // DateTime | +final trashedAfter = 2013-10-20T19:20:30+01:00; // DateTime | +final trashedBefore = 2013-10-20T19:20:30+01:00; // DateTime | +final type = ; // AssetTypeEnum | +final updatedAfter = 2013-10-20T19:20:30+01:00; // DateTime | +final updatedBefore = 2013-10-20T19:20:30+01:00; // DateTime | +final withArchived = true; // bool | +final withDeleted = true; // bool | +final withExif = true; // bool | + +try { + final result = api_instance.searchSmart(query, city, country, createdAfter, createdBefore, deviceId, isArchived, isEncoded, isExternal, isFavorite, isMotion, isOffline, isReadOnly, isVisible, lensModel, libraryId, make, model, page, size, state, takenAfter, takenBefore, trashedAfter, trashedBefore, type, updatedAfter, updatedBefore, withArchived, withDeleted, withExif); + print(result); +} catch (e) { + print('Exception when calling SearchApi->searchSmart: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **query** | **String**| | + **city** | **String**| | [optional] + **country** | **String**| | [optional] + **createdAfter** | **DateTime**| | [optional] + **createdBefore** | **DateTime**| | [optional] + **deviceId** | **String**| | [optional] + **isArchived** | **bool**| | [optional] + **isEncoded** | **bool**| | [optional] + **isExternal** | **bool**| | [optional] + **isFavorite** | **bool**| | [optional] + **isMotion** | **bool**| | [optional] + **isOffline** | **bool**| | [optional] + **isReadOnly** | **bool**| | [optional] + **isVisible** | **bool**| | [optional] + **lensModel** | **String**| | [optional] + **libraryId** | **String**| | [optional] + **make** | **String**| | [optional] + **model** | **String**| | [optional] + **page** | **num**| | [optional] + **size** | **num**| | [optional] + **state** | **String**| | [optional] + **takenAfter** | **DateTime**| | [optional] + **takenBefore** | **DateTime**| | [optional] + **trashedAfter** | **DateTime**| | [optional] + **trashedBefore** | **DateTime**| | [optional] + **type** | [**AssetTypeEnum**](.md)| | [optional] + **updatedAfter** | **DateTime**| | [optional] + **updatedBefore** | **DateTime**| | [optional] + **withArchived** | **bool**| | [optional] + **withDeleted** | **bool**| | [optional] + **withExif** | **bool**| | [optional] + +### Return type + +[**SearchResponseDto**](SearchResponseDto.md) + +### Authorization + +[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: application/json + +[[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) + diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index 7f4528c12f048..4dec34d407400 100644 --- a/mobile/openapi/lib/api/asset_api.dart +++ b/mobile/openapi/lib/api/asset_api.dart @@ -1177,6 +1177,8 @@ class AssetApi { /// /// * [String] webpPath: /// + /// * [bool] withArchived: + /// /// * [bool] withDeleted: /// /// * [bool] withExif: @@ -1184,7 +1186,7 @@ class AssetApi { /// * [bool] withPeople: /// /// * [bool] withStacked: - Future searchAssetsWithHttpInfo({ String? checksum, String? city, String? country, DateTime? createdAfter, DateTime? createdBefore, String? deviceAssetId, String? deviceId, String? encodedVideoPath, String? id, bool? isArchived, bool? isEncoded, bool? isExternal, bool? isFavorite, bool? isMotion, bool? isOffline, bool? isReadOnly, bool? isVisible, String? lensModel, String? libraryId, String? make, String? model, AssetOrder? order, String? originalFileName, String? originalPath, num? page, String? resizePath, num? size, String? state, DateTime? takenAfter, DateTime? takenBefore, DateTime? trashedAfter, DateTime? trashedBefore, AssetTypeEnum? type, DateTime? updatedAfter, DateTime? updatedBefore, String? webpPath, bool? withDeleted, bool? withExif, bool? withPeople, bool? withStacked, }) async { + Future searchAssetsWithHttpInfo({ String? checksum, String? city, String? country, DateTime? createdAfter, DateTime? createdBefore, String? deviceAssetId, String? deviceId, String? encodedVideoPath, String? id, bool? isArchived, bool? isEncoded, bool? isExternal, bool? isFavorite, bool? isMotion, bool? isOffline, bool? isReadOnly, bool? isVisible, String? lensModel, String? libraryId, String? make, String? model, AssetOrder? order, String? originalFileName, String? originalPath, num? page, String? resizePath, num? size, String? state, DateTime? takenAfter, DateTime? takenBefore, DateTime? trashedAfter, DateTime? trashedBefore, AssetTypeEnum? type, DateTime? updatedAfter, DateTime? updatedBefore, String? webpPath, bool? withArchived, bool? withDeleted, bool? withExif, bool? withPeople, bool? withStacked, }) async { // ignore: prefer_const_declarations final path = r'/assets'; @@ -1303,6 +1305,9 @@ class AssetApi { if (webpPath != null) { queryParams.addAll(_queryParams('', 'webpPath', webpPath)); } + if (withArchived != null) { + queryParams.addAll(_queryParams('', 'withArchived', withArchived)); + } if (withDeleted != null) { queryParams.addAll(_queryParams('', 'withDeleted', withDeleted)); } @@ -1404,6 +1409,8 @@ class AssetApi { /// /// * [String] webpPath: /// + /// * [bool] withArchived: + /// /// * [bool] withDeleted: /// /// * [bool] withExif: @@ -1411,8 +1418,8 @@ class AssetApi { /// * [bool] withPeople: /// /// * [bool] withStacked: - Future?> searchAssets({ String? checksum, String? city, String? country, DateTime? createdAfter, DateTime? createdBefore, String? deviceAssetId, String? deviceId, String? encodedVideoPath, String? id, bool? isArchived, bool? isEncoded, bool? isExternal, bool? isFavorite, bool? isMotion, bool? isOffline, bool? isReadOnly, bool? isVisible, String? lensModel, String? libraryId, String? make, String? model, AssetOrder? order, String? originalFileName, String? originalPath, num? page, String? resizePath, num? size, String? state, DateTime? takenAfter, DateTime? takenBefore, DateTime? trashedAfter, DateTime? trashedBefore, AssetTypeEnum? type, DateTime? updatedAfter, DateTime? updatedBefore, String? webpPath, bool? withDeleted, bool? withExif, bool? withPeople, bool? withStacked, }) async { - final response = await searchAssetsWithHttpInfo( checksum: checksum, city: city, country: country, createdAfter: createdAfter, createdBefore: createdBefore, deviceAssetId: deviceAssetId, deviceId: deviceId, encodedVideoPath: encodedVideoPath, id: id, isArchived: isArchived, isEncoded: isEncoded, isExternal: isExternal, isFavorite: isFavorite, isMotion: isMotion, isOffline: isOffline, isReadOnly: isReadOnly, isVisible: isVisible, lensModel: lensModel, libraryId: libraryId, make: make, model: model, order: order, originalFileName: originalFileName, originalPath: originalPath, page: page, resizePath: resizePath, size: size, state: state, takenAfter: takenAfter, takenBefore: takenBefore, trashedAfter: trashedAfter, trashedBefore: trashedBefore, type: type, updatedAfter: updatedAfter, updatedBefore: updatedBefore, webpPath: webpPath, withDeleted: withDeleted, withExif: withExif, withPeople: withPeople, withStacked: withStacked, ); + Future?> searchAssets({ String? checksum, String? city, String? country, DateTime? createdAfter, DateTime? createdBefore, String? deviceAssetId, String? deviceId, String? encodedVideoPath, String? id, bool? isArchived, bool? isEncoded, bool? isExternal, bool? isFavorite, bool? isMotion, bool? isOffline, bool? isReadOnly, bool? isVisible, String? lensModel, String? libraryId, String? make, String? model, AssetOrder? order, String? originalFileName, String? originalPath, num? page, String? resizePath, num? size, String? state, DateTime? takenAfter, DateTime? takenBefore, DateTime? trashedAfter, DateTime? trashedBefore, AssetTypeEnum? type, DateTime? updatedAfter, DateTime? updatedBefore, String? webpPath, bool? withArchived, bool? withDeleted, bool? withExif, bool? withPeople, bool? withStacked, }) async { + final response = await searchAssetsWithHttpInfo( checksum: checksum, city: city, country: country, createdAfter: createdAfter, createdBefore: createdBefore, deviceAssetId: deviceAssetId, deviceId: deviceId, encodedVideoPath: encodedVideoPath, id: id, isArchived: isArchived, isEncoded: isEncoded, isExternal: isExternal, isFavorite: isFavorite, isMotion: isMotion, isOffline: isOffline, isReadOnly: isReadOnly, isVisible: isVisible, lensModel: lensModel, libraryId: libraryId, make: make, model: model, order: order, originalFileName: originalFileName, originalPath: originalPath, page: page, resizePath: resizePath, size: size, state: state, takenAfter: takenAfter, takenBefore: takenBefore, trashedAfter: trashedAfter, trashedBefore: trashedBefore, type: type, updatedAfter: updatedAfter, updatedBefore: updatedBefore, webpPath: webpPath, withArchived: withArchived, withDeleted: withDeleted, withExif: withExif, withPeople: withPeople, withStacked: withStacked, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart index 309a1364dd3f5..55d737a8dbb0f 100644 --- a/mobile/openapi/lib/api/search_api.dart +++ b/mobile/openapi/lib/api/search_api.dart @@ -68,22 +68,22 @@ class SearchApi { /// /// * [bool] motion: /// + /// * [num] page: + /// /// * [String] q: /// /// * [String] query: /// /// * [bool] recent: /// + /// * [num] size: + /// /// * [bool] smart: /// /// * [String] type: /// /// * [bool] withArchived: - /// - /// * [num] take: - /// - /// * [num] page: - Future searchWithHttpInfo({ bool? clip, bool? motion, String? q, String? query, bool? recent, bool? smart, String? type, bool? withArchived, num? take, num? page, }) async { + Future searchWithHttpInfo({ bool? clip, bool? motion, num? page, String? q, String? query, bool? recent, num? size, bool? smart, String? type, bool? withArchived, }) async { // ignore: prefer_const_declarations final path = r'/search'; @@ -100,6 +100,9 @@ class SearchApi { if (motion != null) { queryParams.addAll(_queryParams('', 'motion', motion)); } + if (page != null) { + queryParams.addAll(_queryParams('', 'page', page)); + } if (q != null) { queryParams.addAll(_queryParams('', 'q', q)); } @@ -109,6 +112,9 @@ class SearchApi { if (recent != null) { queryParams.addAll(_queryParams('', 'recent', recent)); } + if (size != null) { + queryParams.addAll(_queryParams('', 'size', size)); + } if (smart != null) { queryParams.addAll(_queryParams('', 'smart', smart)); } @@ -118,12 +124,6 @@ class SearchApi { if (withArchived != null) { queryParams.addAll(_queryParams('', 'withArchived', withArchived)); } - if (take != null) { - queryParams.addAll(_queryParams('', 'take', take)); - } - if (page != null) { - queryParams.addAll(_queryParams('', 'page', page)); - } const contentTypes = []; @@ -146,23 +146,354 @@ class SearchApi { /// /// * [bool] motion: /// + /// * [num] page: + /// /// * [String] q: /// /// * [String] query: /// /// * [bool] recent: /// + /// * [num] size: + /// /// * [bool] smart: /// /// * [String] type: /// /// * [bool] withArchived: + Future search({ bool? clip, bool? motion, num? page, String? q, String? query, bool? recent, num? size, bool? smart, String? type, bool? withArchived, }) async { + final response = await searchWithHttpInfo( clip: clip, motion: motion, page: page, q: q, query: query, recent: recent, size: size, smart: smart, type: type, withArchived: withArchived, ); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'SearchResponseDto',) as SearchResponseDto; + + } + return null; + } + + /// Performs an HTTP 'GET /search/metadata' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] checksum: + /// + /// * [String] city: + /// + /// * [String] country: + /// + /// * [DateTime] createdAfter: + /// + /// * [DateTime] createdBefore: + /// + /// * [String] deviceAssetId: + /// + /// * [String] deviceId: + /// + /// * [String] encodedVideoPath: + /// + /// * [String] id: + /// + /// * [bool] isArchived: + /// + /// * [bool] isEncoded: + /// + /// * [bool] isExternal: + /// + /// * [bool] isFavorite: + /// + /// * [bool] isMotion: + /// + /// * [bool] isOffline: + /// + /// * [bool] isReadOnly: + /// + /// * [bool] isVisible: + /// + /// * [String] lensModel: + /// + /// * [String] libraryId: + /// + /// * [String] make: + /// + /// * [String] model: + /// + /// * [AssetOrder] order: + /// + /// * [String] originalFileName: + /// + /// * [String] originalPath: + /// + /// * [num] page: + /// + /// * [String] resizePath: + /// + /// * [num] size: + /// + /// * [String] state: + /// + /// * [DateTime] takenAfter: + /// + /// * [DateTime] takenBefore: + /// + /// * [DateTime] trashedAfter: + /// + /// * [DateTime] trashedBefore: + /// + /// * [AssetTypeEnum] type: + /// + /// * [DateTime] updatedAfter: + /// + /// * [DateTime] updatedBefore: + /// + /// * [String] webpPath: + /// + /// * [bool] withArchived: + /// + /// * [bool] withDeleted: + /// + /// * [bool] withExif: + /// + /// * [bool] withPeople: + /// + /// * [bool] withStacked: + Future searchMetadataWithHttpInfo({ String? checksum, String? city, String? country, DateTime? createdAfter, DateTime? createdBefore, String? deviceAssetId, String? deviceId, String? encodedVideoPath, String? id, bool? isArchived, bool? isEncoded, bool? isExternal, bool? isFavorite, bool? isMotion, bool? isOffline, bool? isReadOnly, bool? isVisible, String? lensModel, String? libraryId, String? make, String? model, AssetOrder? order, String? originalFileName, String? originalPath, num? page, String? resizePath, num? size, String? state, DateTime? takenAfter, DateTime? takenBefore, DateTime? trashedAfter, DateTime? trashedBefore, AssetTypeEnum? type, DateTime? updatedAfter, DateTime? updatedBefore, String? webpPath, bool? withArchived, bool? withDeleted, bool? withExif, bool? withPeople, bool? withStacked, }) async { + // ignore: prefer_const_declarations + final path = r'/search/metadata'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + if (checksum != null) { + queryParams.addAll(_queryParams('', 'checksum', checksum)); + } + if (city != null) { + queryParams.addAll(_queryParams('', 'city', city)); + } + if (country != null) { + queryParams.addAll(_queryParams('', 'country', country)); + } + if (createdAfter != null) { + queryParams.addAll(_queryParams('', 'createdAfter', createdAfter)); + } + if (createdBefore != null) { + queryParams.addAll(_queryParams('', 'createdBefore', createdBefore)); + } + if (deviceAssetId != null) { + queryParams.addAll(_queryParams('', 'deviceAssetId', deviceAssetId)); + } + if (deviceId != null) { + queryParams.addAll(_queryParams('', 'deviceId', deviceId)); + } + if (encodedVideoPath != null) { + queryParams.addAll(_queryParams('', 'encodedVideoPath', encodedVideoPath)); + } + if (id != null) { + queryParams.addAll(_queryParams('', 'id', id)); + } + if (isArchived != null) { + queryParams.addAll(_queryParams('', 'isArchived', isArchived)); + } + if (isEncoded != null) { + queryParams.addAll(_queryParams('', 'isEncoded', isEncoded)); + } + if (isExternal != null) { + queryParams.addAll(_queryParams('', 'isExternal', isExternal)); + } + if (isFavorite != null) { + queryParams.addAll(_queryParams('', 'isFavorite', isFavorite)); + } + if (isMotion != null) { + queryParams.addAll(_queryParams('', 'isMotion', isMotion)); + } + if (isOffline != null) { + queryParams.addAll(_queryParams('', 'isOffline', isOffline)); + } + if (isReadOnly != null) { + queryParams.addAll(_queryParams('', 'isReadOnly', isReadOnly)); + } + if (isVisible != null) { + queryParams.addAll(_queryParams('', 'isVisible', isVisible)); + } + if (lensModel != null) { + queryParams.addAll(_queryParams('', 'lensModel', lensModel)); + } + if (libraryId != null) { + queryParams.addAll(_queryParams('', 'libraryId', libraryId)); + } + if (make != null) { + queryParams.addAll(_queryParams('', 'make', make)); + } + if (model != null) { + queryParams.addAll(_queryParams('', 'model', model)); + } + if (order != null) { + queryParams.addAll(_queryParams('', 'order', order)); + } + if (originalFileName != null) { + queryParams.addAll(_queryParams('', 'originalFileName', originalFileName)); + } + if (originalPath != null) { + queryParams.addAll(_queryParams('', 'originalPath', originalPath)); + } + if (page != null) { + queryParams.addAll(_queryParams('', 'page', page)); + } + if (resizePath != null) { + queryParams.addAll(_queryParams('', 'resizePath', resizePath)); + } + if (size != null) { + queryParams.addAll(_queryParams('', 'size', size)); + } + if (state != null) { + queryParams.addAll(_queryParams('', 'state', state)); + } + if (takenAfter != null) { + queryParams.addAll(_queryParams('', 'takenAfter', takenAfter)); + } + if (takenBefore != null) { + queryParams.addAll(_queryParams('', 'takenBefore', takenBefore)); + } + if (trashedAfter != null) { + queryParams.addAll(_queryParams('', 'trashedAfter', trashedAfter)); + } + if (trashedBefore != null) { + queryParams.addAll(_queryParams('', 'trashedBefore', trashedBefore)); + } + if (type != null) { + queryParams.addAll(_queryParams('', 'type', type)); + } + if (updatedAfter != null) { + queryParams.addAll(_queryParams('', 'updatedAfter', updatedAfter)); + } + if (updatedBefore != null) { + queryParams.addAll(_queryParams('', 'updatedBefore', updatedBefore)); + } + if (webpPath != null) { + queryParams.addAll(_queryParams('', 'webpPath', webpPath)); + } + if (withArchived != null) { + queryParams.addAll(_queryParams('', 'withArchived', withArchived)); + } + if (withDeleted != null) { + queryParams.addAll(_queryParams('', 'withDeleted', withDeleted)); + } + if (withExif != null) { + queryParams.addAll(_queryParams('', 'withExif', withExif)); + } + if (withPeople != null) { + queryParams.addAll(_queryParams('', 'withPeople', withPeople)); + } + if (withStacked != null) { + queryParams.addAll(_queryParams('', 'withStacked', withStacked)); + } + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] checksum: + /// + /// * [String] city: + /// + /// * [String] country: + /// + /// * [DateTime] createdAfter: + /// + /// * [DateTime] createdBefore: + /// + /// * [String] deviceAssetId: + /// + /// * [String] deviceId: + /// + /// * [String] encodedVideoPath: + /// + /// * [String] id: + /// + /// * [bool] isArchived: + /// + /// * [bool] isEncoded: + /// + /// * [bool] isExternal: + /// + /// * [bool] isFavorite: + /// + /// * [bool] isMotion: + /// + /// * [bool] isOffline: + /// + /// * [bool] isReadOnly: + /// + /// * [bool] isVisible: + /// + /// * [String] lensModel: /// - /// * [num] take: + /// * [String] libraryId: + /// + /// * [String] make: + /// + /// * [String] model: + /// + /// * [AssetOrder] order: + /// + /// * [String] originalFileName: + /// + /// * [String] originalPath: /// /// * [num] page: - Future search({ bool? clip, bool? motion, String? q, String? query, bool? recent, bool? smart, String? type, bool? withArchived, num? take, num? page, }) async { - final response = await searchWithHttpInfo( clip: clip, motion: motion, q: q, query: query, recent: recent, smart: smart, type: type, withArchived: withArchived, take: take, page: page, ); + /// + /// * [String] resizePath: + /// + /// * [num] size: + /// + /// * [String] state: + /// + /// * [DateTime] takenAfter: + /// + /// * [DateTime] takenBefore: + /// + /// * [DateTime] trashedAfter: + /// + /// * [DateTime] trashedBefore: + /// + /// * [AssetTypeEnum] type: + /// + /// * [DateTime] updatedAfter: + /// + /// * [DateTime] updatedBefore: + /// + /// * [String] webpPath: + /// + /// * [bool] withArchived: + /// + /// * [bool] withDeleted: + /// + /// * [bool] withExif: + /// + /// * [bool] withPeople: + /// + /// * [bool] withStacked: + Future searchMetadata({ String? checksum, String? city, String? country, DateTime? createdAfter, DateTime? createdBefore, String? deviceAssetId, String? deviceId, String? encodedVideoPath, String? id, bool? isArchived, bool? isEncoded, bool? isExternal, bool? isFavorite, bool? isMotion, bool? isOffline, bool? isReadOnly, bool? isVisible, String? lensModel, String? libraryId, String? make, String? model, AssetOrder? order, String? originalFileName, String? originalPath, num? page, String? resizePath, num? size, String? state, DateTime? takenAfter, DateTime? takenBefore, DateTime? trashedAfter, DateTime? trashedBefore, AssetTypeEnum? type, DateTime? updatedAfter, DateTime? updatedBefore, String? webpPath, bool? withArchived, bool? withDeleted, bool? withExif, bool? withPeople, bool? withStacked, }) async { + final response = await searchMetadataWithHttpInfo( checksum: checksum, city: city, country: country, createdAfter: createdAfter, createdBefore: createdBefore, deviceAssetId: deviceAssetId, deviceId: deviceId, encodedVideoPath: encodedVideoPath, id: id, isArchived: isArchived, isEncoded: isEncoded, isExternal: isExternal, isFavorite: isFavorite, isMotion: isMotion, isOffline: isOffline, isReadOnly: isReadOnly, isVisible: isVisible, lensModel: lensModel, libraryId: libraryId, make: make, model: model, order: order, originalFileName: originalFileName, originalPath: originalPath, page: page, resizePath: resizePath, size: size, state: state, takenAfter: takenAfter, takenBefore: takenBefore, trashedAfter: trashedAfter, trashedBefore: trashedBefore, type: type, updatedAfter: updatedAfter, updatedBefore: updatedBefore, webpPath: webpPath, withArchived: withArchived, withDeleted: withDeleted, withExif: withExif, withPeople: withPeople, withStacked: withStacked, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -234,4 +565,263 @@ class SearchApi { } return null; } + + /// Performs an HTTP 'GET /search/smart' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] query (required): + /// + /// * [String] city: + /// + /// * [String] country: + /// + /// * [DateTime] createdAfter: + /// + /// * [DateTime] createdBefore: + /// + /// * [String] deviceId: + /// + /// * [bool] isArchived: + /// + /// * [bool] isEncoded: + /// + /// * [bool] isExternal: + /// + /// * [bool] isFavorite: + /// + /// * [bool] isMotion: + /// + /// * [bool] isOffline: + /// + /// * [bool] isReadOnly: + /// + /// * [bool] isVisible: + /// + /// * [String] lensModel: + /// + /// * [String] libraryId: + /// + /// * [String] make: + /// + /// * [String] model: + /// + /// * [num] page: + /// + /// * [num] size: + /// + /// * [String] state: + /// + /// * [DateTime] takenAfter: + /// + /// * [DateTime] takenBefore: + /// + /// * [DateTime] trashedAfter: + /// + /// * [DateTime] trashedBefore: + /// + /// * [AssetTypeEnum] type: + /// + /// * [DateTime] updatedAfter: + /// + /// * [DateTime] updatedBefore: + /// + /// * [bool] withArchived: + /// + /// * [bool] withDeleted: + /// + /// * [bool] withExif: + Future searchSmartWithHttpInfo(String query, { String? city, String? country, DateTime? createdAfter, DateTime? createdBefore, String? deviceId, bool? isArchived, bool? isEncoded, bool? isExternal, bool? isFavorite, bool? isMotion, bool? isOffline, bool? isReadOnly, bool? isVisible, String? lensModel, String? libraryId, String? make, String? model, num? page, num? size, String? state, DateTime? takenAfter, DateTime? takenBefore, DateTime? trashedAfter, DateTime? trashedBefore, AssetTypeEnum? type, DateTime? updatedAfter, DateTime? updatedBefore, bool? withArchived, bool? withDeleted, bool? withExif, }) async { + // ignore: prefer_const_declarations + final path = r'/search/smart'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + if (city != null) { + queryParams.addAll(_queryParams('', 'city', city)); + } + if (country != null) { + queryParams.addAll(_queryParams('', 'country', country)); + } + if (createdAfter != null) { + queryParams.addAll(_queryParams('', 'createdAfter', createdAfter)); + } + if (createdBefore != null) { + queryParams.addAll(_queryParams('', 'createdBefore', createdBefore)); + } + if (deviceId != null) { + queryParams.addAll(_queryParams('', 'deviceId', deviceId)); + } + if (isArchived != null) { + queryParams.addAll(_queryParams('', 'isArchived', isArchived)); + } + if (isEncoded != null) { + queryParams.addAll(_queryParams('', 'isEncoded', isEncoded)); + } + if (isExternal != null) { + queryParams.addAll(_queryParams('', 'isExternal', isExternal)); + } + if (isFavorite != null) { + queryParams.addAll(_queryParams('', 'isFavorite', isFavorite)); + } + if (isMotion != null) { + queryParams.addAll(_queryParams('', 'isMotion', isMotion)); + } + if (isOffline != null) { + queryParams.addAll(_queryParams('', 'isOffline', isOffline)); + } + if (isReadOnly != null) { + queryParams.addAll(_queryParams('', 'isReadOnly', isReadOnly)); + } + if (isVisible != null) { + queryParams.addAll(_queryParams('', 'isVisible', isVisible)); + } + if (lensModel != null) { + queryParams.addAll(_queryParams('', 'lensModel', lensModel)); + } + if (libraryId != null) { + queryParams.addAll(_queryParams('', 'libraryId', libraryId)); + } + if (make != null) { + queryParams.addAll(_queryParams('', 'make', make)); + } + if (model != null) { + queryParams.addAll(_queryParams('', 'model', model)); + } + if (page != null) { + queryParams.addAll(_queryParams('', 'page', page)); + } + queryParams.addAll(_queryParams('', 'query', query)); + if (size != null) { + queryParams.addAll(_queryParams('', 'size', size)); + } + if (state != null) { + queryParams.addAll(_queryParams('', 'state', state)); + } + if (takenAfter != null) { + queryParams.addAll(_queryParams('', 'takenAfter', takenAfter)); + } + if (takenBefore != null) { + queryParams.addAll(_queryParams('', 'takenBefore', takenBefore)); + } + if (trashedAfter != null) { + queryParams.addAll(_queryParams('', 'trashedAfter', trashedAfter)); + } + if (trashedBefore != null) { + queryParams.addAll(_queryParams('', 'trashedBefore', trashedBefore)); + } + if (type != null) { + queryParams.addAll(_queryParams('', 'type', type)); + } + if (updatedAfter != null) { + queryParams.addAll(_queryParams('', 'updatedAfter', updatedAfter)); + } + if (updatedBefore != null) { + queryParams.addAll(_queryParams('', 'updatedBefore', updatedBefore)); + } + if (withArchived != null) { + queryParams.addAll(_queryParams('', 'withArchived', withArchived)); + } + if (withDeleted != null) { + queryParams.addAll(_queryParams('', 'withDeleted', withDeleted)); + } + if (withExif != null) { + queryParams.addAll(_queryParams('', 'withExif', withExif)); + } + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] query (required): + /// + /// * [String] city: + /// + /// * [String] country: + /// + /// * [DateTime] createdAfter: + /// + /// * [DateTime] createdBefore: + /// + /// * [String] deviceId: + /// + /// * [bool] isArchived: + /// + /// * [bool] isEncoded: + /// + /// * [bool] isExternal: + /// + /// * [bool] isFavorite: + /// + /// * [bool] isMotion: + /// + /// * [bool] isOffline: + /// + /// * [bool] isReadOnly: + /// + /// * [bool] isVisible: + /// + /// * [String] lensModel: + /// + /// * [String] libraryId: + /// + /// * [String] make: + /// + /// * [String] model: + /// + /// * [num] page: + /// + /// * [num] size: + /// + /// * [String] state: + /// + /// * [DateTime] takenAfter: + /// + /// * [DateTime] takenBefore: + /// + /// * [DateTime] trashedAfter: + /// + /// * [DateTime] trashedBefore: + /// + /// * [AssetTypeEnum] type: + /// + /// * [DateTime] updatedAfter: + /// + /// * [DateTime] updatedBefore: + /// + /// * [bool] withArchived: + /// + /// * [bool] withDeleted: + /// + /// * [bool] withExif: + Future searchSmart(String query, { String? city, String? country, DateTime? createdAfter, DateTime? createdBefore, String? deviceId, bool? isArchived, bool? isEncoded, bool? isExternal, bool? isFavorite, bool? isMotion, bool? isOffline, bool? isReadOnly, bool? isVisible, String? lensModel, String? libraryId, String? make, String? model, num? page, num? size, String? state, DateTime? takenAfter, DateTime? takenBefore, DateTime? trashedAfter, DateTime? trashedBefore, AssetTypeEnum? type, DateTime? updatedAfter, DateTime? updatedBefore, bool? withArchived, bool? withDeleted, bool? withExif, }) async { + final response = await searchSmartWithHttpInfo(query, city: city, country: country, createdAfter: createdAfter, createdBefore: createdBefore, deviceId: deviceId, isArchived: isArchived, isEncoded: isEncoded, isExternal: isExternal, isFavorite: isFavorite, isMotion: isMotion, isOffline: isOffline, isReadOnly: isReadOnly, isVisible: isVisible, lensModel: lensModel, libraryId: libraryId, make: make, model: model, page: page, size: size, state: state, takenAfter: takenAfter, takenBefore: takenBefore, trashedAfter: trashedAfter, trashedBefore: trashedBefore, type: type, updatedAfter: updatedAfter, updatedBefore: updatedBefore, withArchived: withArchived, withDeleted: withDeleted, withExif: withExif, ); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'SearchResponseDto',) as SearchResponseDto; + + } + return null; + } } diff --git a/mobile/openapi/test/asset_api_test.dart b/mobile/openapi/test/asset_api_test.dart index c34c85466c999..a7a63f38e633f 100644 --- a/mobile/openapi/test/asset_api_test.dart +++ b/mobile/openapi/test/asset_api_test.dart @@ -110,7 +110,7 @@ void main() { // TODO }); - //Future> searchAssets({ String checksum, String city, String country, DateTime createdAfter, DateTime createdBefore, String deviceAssetId, String deviceId, String encodedVideoPath, String id, bool isArchived, bool isEncoded, bool isExternal, bool isFavorite, bool isMotion, bool isOffline, bool isReadOnly, bool isVisible, String lensModel, String libraryId, String make, String model, AssetOrder order, String originalFileName, String originalPath, num page, String resizePath, num size, String state, DateTime takenAfter, DateTime takenBefore, DateTime trashedAfter, DateTime trashedBefore, AssetTypeEnum type, DateTime updatedAfter, DateTime updatedBefore, String webpPath, bool withDeleted, bool withExif, bool withPeople, bool withStacked }) async + //Future> searchAssets({ String checksum, String city, String country, DateTime createdAfter, DateTime createdBefore, String deviceAssetId, String deviceId, String encodedVideoPath, String id, bool isArchived, bool isEncoded, bool isExternal, bool isFavorite, bool isMotion, bool isOffline, bool isReadOnly, bool isVisible, String lensModel, String libraryId, String make, String model, AssetOrder order, String originalFileName, String originalPath, num page, String resizePath, num size, String state, DateTime takenAfter, DateTime takenBefore, DateTime trashedAfter, DateTime trashedBefore, AssetTypeEnum type, DateTime updatedAfter, DateTime updatedBefore, String webpPath, bool withArchived, bool withDeleted, bool withExif, bool withPeople, bool withStacked }) async test('test searchAssets', () async { // TODO }); diff --git a/mobile/openapi/test/search_api_test.dart b/mobile/openapi/test/search_api_test.dart index 0226bb73ef38d..be12c7e1f821e 100644 --- a/mobile/openapi/test/search_api_test.dart +++ b/mobile/openapi/test/search_api_test.dart @@ -22,15 +22,25 @@ void main() { // TODO }); - //Future search({ bool clip, bool motion, String q, String query, bool recent, bool smart, String type, bool withArchived, num take, num page }) async + //Future search({ bool clip, bool motion, num page, String q, String query, bool recent, num size, bool smart, String type, bool withArchived }) async test('test search', () async { // TODO }); + //Future searchMetadata({ String checksum, String city, String country, DateTime createdAfter, DateTime createdBefore, String deviceAssetId, String deviceId, String encodedVideoPath, String id, bool isArchived, bool isEncoded, bool isExternal, bool isFavorite, bool isMotion, bool isOffline, bool isReadOnly, bool isVisible, String lensModel, String libraryId, String make, String model, AssetOrder order, String originalFileName, String originalPath, num page, String resizePath, num size, String state, DateTime takenAfter, DateTime takenBefore, DateTime trashedAfter, DateTime trashedBefore, AssetTypeEnum type, DateTime updatedAfter, DateTime updatedBefore, String webpPath, bool withArchived, bool withDeleted, bool withExif, bool withPeople, bool withStacked }) async + test('test searchMetadata', () async { + // TODO + }); + //Future> searchPerson(String name, { bool withHidden }) async test('test searchPerson', () async { // TODO }); + //Future searchSmart(String query, { String city, String country, DateTime createdAfter, DateTime createdBefore, String deviceId, bool isArchived, bool isEncoded, bool isExternal, bool isFavorite, bool isMotion, bool isOffline, bool isReadOnly, bool isVisible, String lensModel, String libraryId, String make, String model, num page, num size, String state, DateTime takenAfter, DateTime takenBefore, DateTime trashedAfter, DateTime trashedBefore, AssetTypeEnum type, DateTime updatedAfter, DateTime updatedBefore, bool withArchived, bool withDeleted, bool withExif }) async + test('test searchSmart', () async { + // TODO + }); + }); } diff --git a/open-api/typescript-sdk/axios-client/api.ts b/open-api/typescript-sdk/axios-client/api.ts index 0823d30be96cb..c231aa61713d0 100644 --- a/open-api/typescript-sdk/axios-client/api.ts +++ b/open-api/typescript-sdk/axios-client/api.ts @@ -7766,14 +7766,16 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration * @param {string} [updatedAfter] * @param {string} [updatedBefore] * @param {string} [webpPath] + * @param {boolean} [withArchived] * @param {boolean} [withDeleted] * @param {boolean} [withExif] * @param {boolean} [withPeople] * @param {boolean} [withStacked] * @param {*} [options] Override http request option. + * @deprecated * @throws {RequiredError} */ - searchAssets: async (checksum?: string, city?: string, country?: string, createdAfter?: string, createdBefore?: string, deviceAssetId?: string, deviceId?: string, encodedVideoPath?: string, id?: string, isArchived?: boolean, isEncoded?: boolean, isExternal?: boolean, isFavorite?: boolean, isMotion?: boolean, isOffline?: boolean, isReadOnly?: boolean, isVisible?: boolean, lensModel?: string, libraryId?: string, make?: string, model?: string, order?: AssetOrder, originalFileName?: string, originalPath?: string, page?: number, resizePath?: string, size?: number, state?: string, takenAfter?: string, takenBefore?: string, trashedAfter?: string, trashedBefore?: string, type?: AssetTypeEnum, updatedAfter?: string, updatedBefore?: string, webpPath?: string, withDeleted?: boolean, withExif?: boolean, withPeople?: boolean, withStacked?: boolean, options: RawAxiosRequestConfig = {}): Promise => { + searchAssets: async (checksum?: string, city?: string, country?: string, createdAfter?: string, createdBefore?: string, deviceAssetId?: string, deviceId?: string, encodedVideoPath?: string, id?: string, isArchived?: boolean, isEncoded?: boolean, isExternal?: boolean, isFavorite?: boolean, isMotion?: boolean, isOffline?: boolean, isReadOnly?: boolean, isVisible?: boolean, lensModel?: string, libraryId?: string, make?: string, model?: string, order?: AssetOrder, originalFileName?: string, originalPath?: string, page?: number, resizePath?: string, size?: number, state?: string, takenAfter?: string, takenBefore?: string, trashedAfter?: string, trashedBefore?: string, type?: AssetTypeEnum, updatedAfter?: string, updatedBefore?: string, webpPath?: string, withArchived?: boolean, withDeleted?: boolean, withExif?: boolean, withPeople?: boolean, withStacked?: boolean, options: RawAxiosRequestConfig = {}): Promise => { const localVarPath = `/assets`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); @@ -7955,6 +7957,10 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration localVarQueryParameter['webpPath'] = webpPath; } + if (withArchived !== undefined) { + localVarQueryParameter['withArchived'] = withArchived; + } + if (withDeleted !== undefined) { localVarQueryParameter['withDeleted'] = withDeleted; } @@ -8591,15 +8597,17 @@ export const AssetApiFp = function(configuration?: Configuration) { * @param {string} [updatedAfter] * @param {string} [updatedBefore] * @param {string} [webpPath] + * @param {boolean} [withArchived] * @param {boolean} [withDeleted] * @param {boolean} [withExif] * @param {boolean} [withPeople] * @param {boolean} [withStacked] * @param {*} [options] Override http request option. + * @deprecated * @throws {RequiredError} */ - async searchAssets(checksum?: string, city?: string, country?: string, createdAfter?: string, createdBefore?: string, deviceAssetId?: string, deviceId?: string, encodedVideoPath?: string, id?: string, isArchived?: boolean, isEncoded?: boolean, isExternal?: boolean, isFavorite?: boolean, isMotion?: boolean, isOffline?: boolean, isReadOnly?: boolean, isVisible?: boolean, lensModel?: string, libraryId?: string, make?: string, model?: string, order?: AssetOrder, originalFileName?: string, originalPath?: string, page?: number, resizePath?: string, size?: number, state?: string, takenAfter?: string, takenBefore?: string, trashedAfter?: string, trashedBefore?: string, type?: AssetTypeEnum, updatedAfter?: string, updatedBefore?: string, webpPath?: string, withDeleted?: boolean, withExif?: boolean, withPeople?: boolean, withStacked?: boolean, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { - const localVarAxiosArgs = await localVarAxiosParamCreator.searchAssets(checksum, city, country, createdAfter, createdBefore, deviceAssetId, deviceId, encodedVideoPath, id, isArchived, isEncoded, isExternal, isFavorite, isMotion, isOffline, isReadOnly, isVisible, lensModel, libraryId, make, model, order, originalFileName, originalPath, page, resizePath, size, state, takenAfter, takenBefore, trashedAfter, trashedBefore, type, updatedAfter, updatedBefore, webpPath, withDeleted, withExif, withPeople, withStacked, options); + async searchAssets(checksum?: string, city?: string, country?: string, createdAfter?: string, createdBefore?: string, deviceAssetId?: string, deviceId?: string, encodedVideoPath?: string, id?: string, isArchived?: boolean, isEncoded?: boolean, isExternal?: boolean, isFavorite?: boolean, isMotion?: boolean, isOffline?: boolean, isReadOnly?: boolean, isVisible?: boolean, lensModel?: string, libraryId?: string, make?: string, model?: string, order?: AssetOrder, originalFileName?: string, originalPath?: string, page?: number, resizePath?: string, size?: number, state?: string, takenAfter?: string, takenBefore?: string, trashedAfter?: string, trashedBefore?: string, type?: AssetTypeEnum, updatedAfter?: string, updatedBefore?: string, webpPath?: string, withArchived?: boolean, withDeleted?: boolean, withExif?: boolean, withPeople?: boolean, withStacked?: boolean, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.searchAssets(checksum, city, country, createdAfter, createdBefore, deviceAssetId, deviceId, encodedVideoPath, id, isArchived, isEncoded, isExternal, isFavorite, isMotion, isOffline, isReadOnly, isVisible, lensModel, libraryId, make, model, order, originalFileName, originalPath, page, resizePath, size, state, takenAfter, takenBefore, trashedAfter, trashedBefore, type, updatedAfter, updatedBefore, webpPath, withArchived, withDeleted, withExif, withPeople, withStacked, options); const index = configuration?.serverIndex ?? 0; const operationBasePath = operationServerMap['AssetApi.searchAssets']?.[index]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, operationBasePath || basePath); @@ -8847,10 +8855,11 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath * * @param {AssetApiSearchAssetsRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. + * @deprecated * @throws {RequiredError} */ searchAssets(requestParameters: AssetApiSearchAssetsRequest = {}, options?: RawAxiosRequestConfig): AxiosPromise> { - return localVarFp.searchAssets(requestParameters.checksum, requestParameters.city, requestParameters.country, requestParameters.createdAfter, requestParameters.createdBefore, requestParameters.deviceAssetId, requestParameters.deviceId, requestParameters.encodedVideoPath, requestParameters.id, requestParameters.isArchived, requestParameters.isEncoded, requestParameters.isExternal, requestParameters.isFavorite, requestParameters.isMotion, requestParameters.isOffline, requestParameters.isReadOnly, requestParameters.isVisible, requestParameters.lensModel, requestParameters.libraryId, requestParameters.make, requestParameters.model, requestParameters.order, requestParameters.originalFileName, requestParameters.originalPath, requestParameters.page, requestParameters.resizePath, requestParameters.size, requestParameters.state, requestParameters.takenAfter, requestParameters.takenBefore, requestParameters.trashedAfter, requestParameters.trashedBefore, requestParameters.type, requestParameters.updatedAfter, requestParameters.updatedBefore, requestParameters.webpPath, requestParameters.withDeleted, requestParameters.withExif, requestParameters.withPeople, requestParameters.withStacked, options).then((request) => request(axios, basePath)); + return localVarFp.searchAssets(requestParameters.checksum, requestParameters.city, requestParameters.country, requestParameters.createdAfter, requestParameters.createdBefore, requestParameters.deviceAssetId, requestParameters.deviceId, requestParameters.encodedVideoPath, requestParameters.id, requestParameters.isArchived, requestParameters.isEncoded, requestParameters.isExternal, requestParameters.isFavorite, requestParameters.isMotion, requestParameters.isOffline, requestParameters.isReadOnly, requestParameters.isVisible, requestParameters.lensModel, requestParameters.libraryId, requestParameters.make, requestParameters.model, requestParameters.order, requestParameters.originalFileName, requestParameters.originalPath, requestParameters.page, requestParameters.resizePath, requestParameters.size, requestParameters.state, requestParameters.takenAfter, requestParameters.takenBefore, requestParameters.trashedAfter, requestParameters.trashedBefore, requestParameters.type, requestParameters.updatedAfter, requestParameters.updatedBefore, requestParameters.webpPath, requestParameters.withArchived, requestParameters.withDeleted, requestParameters.withExif, requestParameters.withPeople, requestParameters.withStacked, options).then((request) => request(axios, basePath)); }, /** * @@ -9599,6 +9608,13 @@ export interface AssetApiSearchAssetsRequest { */ readonly webpPath?: string + /** + * + * @type {boolean} + * @memberof AssetApiSearchAssets + */ + readonly withArchived?: boolean + /** * * @type {boolean} @@ -10026,11 +10042,12 @@ export class AssetApi extends BaseAPI { * * @param {AssetApiSearchAssetsRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. + * @deprecated * @throws {RequiredError} * @memberof AssetApi */ public searchAssets(requestParameters: AssetApiSearchAssetsRequest = {}, options?: RawAxiosRequestConfig) { - return AssetApiFp(this.configuration).searchAssets(requestParameters.checksum, requestParameters.city, requestParameters.country, requestParameters.createdAfter, requestParameters.createdBefore, requestParameters.deviceAssetId, requestParameters.deviceId, requestParameters.encodedVideoPath, requestParameters.id, requestParameters.isArchived, requestParameters.isEncoded, requestParameters.isExternal, requestParameters.isFavorite, requestParameters.isMotion, requestParameters.isOffline, requestParameters.isReadOnly, requestParameters.isVisible, requestParameters.lensModel, requestParameters.libraryId, requestParameters.make, requestParameters.model, requestParameters.order, requestParameters.originalFileName, requestParameters.originalPath, requestParameters.page, requestParameters.resizePath, requestParameters.size, requestParameters.state, requestParameters.takenAfter, requestParameters.takenBefore, requestParameters.trashedAfter, requestParameters.trashedBefore, requestParameters.type, requestParameters.updatedAfter, requestParameters.updatedBefore, requestParameters.webpPath, requestParameters.withDeleted, requestParameters.withExif, requestParameters.withPeople, requestParameters.withStacked, options).then((request) => request(this.axios, this.basePath)); + return AssetApiFp(this.configuration).searchAssets(requestParameters.checksum, requestParameters.city, requestParameters.country, requestParameters.createdAfter, requestParameters.createdBefore, requestParameters.deviceAssetId, requestParameters.deviceId, requestParameters.encodedVideoPath, requestParameters.id, requestParameters.isArchived, requestParameters.isEncoded, requestParameters.isExternal, requestParameters.isFavorite, requestParameters.isMotion, requestParameters.isOffline, requestParameters.isReadOnly, requestParameters.isVisible, requestParameters.lensModel, requestParameters.libraryId, requestParameters.make, requestParameters.model, requestParameters.order, requestParameters.originalFileName, requestParameters.originalPath, requestParameters.page, requestParameters.resizePath, requestParameters.size, requestParameters.state, requestParameters.takenAfter, requestParameters.takenBefore, requestParameters.trashedAfter, requestParameters.trashedBefore, requestParameters.type, requestParameters.updatedAfter, requestParameters.updatedBefore, requestParameters.webpPath, requestParameters.withArchived, requestParameters.withDeleted, requestParameters.withExif, requestParameters.withPeople, requestParameters.withStacked, options).then((request) => request(this.axios, this.basePath)); } /** @@ -14506,18 +14523,19 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio * * @param {boolean} [clip] @deprecated * @param {boolean} [motion] + * @param {number} [page] * @param {string} [q] * @param {string} [query] * @param {boolean} [recent] + * @param {number} [size] * @param {boolean} [smart] * @param {SearchTypeEnum} [type] * @param {boolean} [withArchived] - * @param {number} [take] - * @param {number} [page] * @param {*} [options] Override http request option. + * @deprecated * @throws {RequiredError} */ - search: async (clip?: boolean, motion?: boolean, q?: string, query?: string, recent?: boolean, smart?: boolean, type?: SearchTypeEnum, withArchived?: boolean, take?: number, page?: number, options: RawAxiosRequestConfig = {}): Promise => { + search: async (clip?: boolean, motion?: boolean, page?: number, q?: string, query?: string, recent?: boolean, size?: number, smart?: boolean, type?: SearchTypeEnum, withArchived?: boolean, options: RawAxiosRequestConfig = {}): 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); @@ -14547,6 +14565,10 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio localVarQueryParameter['motion'] = motion; } + if (page !== undefined) { + localVarQueryParameter['page'] = page; + } + if (q !== undefined) { localVarQueryParameter['q'] = q; } @@ -14559,6 +14581,10 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio localVarQueryParameter['recent'] = recent; } + if (size !== undefined) { + localVarQueryParameter['size'] = size; + } + if (smart !== undefined) { localVarQueryParameter['smart'] = smart; } @@ -14571,14 +14597,6 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio localVarQueryParameter['withArchived'] = withArchived; } - if (take !== undefined) { - localVarQueryParameter['take'] = take; - } - - if (page !== undefined) { - localVarQueryParameter['page'] = page; - } - setSearchParams(localVarUrlObj, localVarQueryParameter); @@ -14592,15 +14610,52 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio }, /** * - * @param {string} name - * @param {boolean} [withHidden] + * @param {string} [checksum] + * @param {string} [city] + * @param {string} [country] + * @param {string} [createdAfter] + * @param {string} [createdBefore] + * @param {string} [deviceAssetId] + * @param {string} [deviceId] + * @param {string} [encodedVideoPath] + * @param {string} [id] + * @param {boolean} [isArchived] + * @param {boolean} [isEncoded] + * @param {boolean} [isExternal] + * @param {boolean} [isFavorite] + * @param {boolean} [isMotion] + * @param {boolean} [isOffline] + * @param {boolean} [isReadOnly] + * @param {boolean} [isVisible] + * @param {string} [lensModel] + * @param {string} [libraryId] + * @param {string} [make] + * @param {string} [model] + * @param {AssetOrder} [order] + * @param {string} [originalFileName] + * @param {string} [originalPath] + * @param {number} [page] + * @param {string} [resizePath] + * @param {number} [size] + * @param {string} [state] + * @param {string} [takenAfter] + * @param {string} [takenBefore] + * @param {string} [trashedAfter] + * @param {string} [trashedBefore] + * @param {AssetTypeEnum} [type] + * @param {string} [updatedAfter] + * @param {string} [updatedBefore] + * @param {string} [webpPath] + * @param {boolean} [withArchived] + * @param {boolean} [withDeleted] + * @param {boolean} [withExif] + * @param {boolean} [withPeople] + * @param {boolean} [withStacked] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - searchPerson: async (name: string, withHidden?: boolean, options: RawAxiosRequestConfig = {}): Promise => { - // verify required parameter 'name' is not null or undefined - assertParamExists('searchPerson', 'name', name) - const localVarPath = `/search/person`; + searchMetadata: async (checksum?: string, city?: string, country?: string, createdAfter?: string, createdBefore?: string, deviceAssetId?: string, deviceId?: string, encodedVideoPath?: string, id?: string, isArchived?: boolean, isEncoded?: boolean, isExternal?: boolean, isFavorite?: boolean, isMotion?: boolean, isOffline?: boolean, isReadOnly?: boolean, isVisible?: boolean, lensModel?: string, libraryId?: string, make?: string, model?: string, order?: AssetOrder, originalFileName?: string, originalPath?: string, page?: number, resizePath?: string, size?: number, state?: string, takenAfter?: string, takenBefore?: string, trashedAfter?: string, trashedBefore?: string, type?: AssetTypeEnum, updatedAfter?: string, updatedBefore?: string, webpPath?: string, withArchived?: boolean, withDeleted?: boolean, withExif?: boolean, withPeople?: boolean, withStacked?: boolean, options: RawAxiosRequestConfig = {}): Promise => { + const localVarPath = `/search/metadata`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); let baseOptions; @@ -14621,194 +14676,1035 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio // http bearer authentication required await setBearerAuthToObject(localVarHeaderParameter, configuration) - if (name !== undefined) { - localVarQueryParameter['name'] = name; + if (checksum !== undefined) { + localVarQueryParameter['checksum'] = checksum; } - if (withHidden !== undefined) { - localVarQueryParameter['withHidden'] = withHidden; + if (city !== undefined) { + localVarQueryParameter['city'] = city; } + if (country !== undefined) { + localVarQueryParameter['country'] = country; + } - - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + if (createdAfter !== undefined) { + localVarQueryParameter['createdAfter'] = (createdAfter as any instanceof Date) ? + (createdAfter as any).toISOString() : + createdAfter; + } - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, - } -}; + if (createdBefore !== undefined) { + localVarQueryParameter['createdBefore'] = (createdBefore as any instanceof Date) ? + (createdBefore as any).toISOString() : + createdBefore; + } -/** - * SearchApi - functional programming interface - * @export - */ -export const SearchApiFp = function(configuration?: Configuration) { - const localVarAxiosParamCreator = SearchApiAxiosParamCreator(configuration) - return { - /** - * - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async getExploreData(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { - const localVarAxiosArgs = await localVarAxiosParamCreator.getExploreData(options); - const index = configuration?.serverIndex ?? 0; - const operationBasePath = operationServerMap['SearchApi.getExploreData']?.[index]?.url; - return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, operationBasePath || basePath); - }, - /** - * - * @param {boolean} [clip] @deprecated - * @param {boolean} [motion] - * @param {string} [q] - * @param {string} [query] - * @param {boolean} [recent] - * @param {boolean} [smart] - * @param {SearchTypeEnum} [type] - * @param {boolean} [withArchived] - * @param {number} [take] - * @param {number} [page] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async search(clip?: boolean, motion?: boolean, q?: string, query?: string, recent?: boolean, smart?: boolean, type?: SearchTypeEnum, withArchived?: boolean, take?: number, page?: number, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.search(clip, motion, q, query, recent, smart, type, withArchived, take, page, options); - const index = configuration?.serverIndex ?? 0; - const operationBasePath = operationServerMap['SearchApi.search']?.[index]?.url; - return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, operationBasePath || basePath); - }, - /** - * - * @param {string} name - * @param {boolean} [withHidden] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async searchPerson(name: string, withHidden?: boolean, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { - const localVarAxiosArgs = await localVarAxiosParamCreator.searchPerson(name, withHidden, options); - const index = configuration?.serverIndex ?? 0; - const operationBasePath = operationServerMap['SearchApi.searchPerson']?.[index]?.url; - return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, operationBasePath || basePath); - }, - } -}; + if (deviceAssetId !== undefined) { + localVarQueryParameter['deviceAssetId'] = deviceAssetId; + } -/** - * SearchApi - factory interface - * @export - */ -export const SearchApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { - const localVarFp = SearchApiFp(configuration) - return { - /** - * - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - getExploreData(options?: RawAxiosRequestConfig): AxiosPromise> { - return localVarFp.getExploreData(options).then((request) => request(axios, basePath)); - }, - /** - * - * @param {SearchApiSearchRequest} requestParameters Request parameters. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - search(requestParameters: SearchApiSearchRequest = {}, options?: RawAxiosRequestConfig): AxiosPromise { - return localVarFp.search(requestParameters.clip, requestParameters.motion, requestParameters.q, requestParameters.query, requestParameters.recent, requestParameters.smart, requestParameters.type, requestParameters.withArchived, requestParameters.take, requestParameters.page, options).then((request) => request(axios, basePath)); - }, - /** - * - * @param {SearchApiSearchPersonRequest} requestParameters Request parameters. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: RawAxiosRequestConfig): AxiosPromise> { - return localVarFp.searchPerson(requestParameters.name, requestParameters.withHidden, options).then((request) => request(axios, basePath)); - }, - }; -}; + if (deviceId !== undefined) { + localVarQueryParameter['deviceId'] = deviceId; + } -/** - * Request parameters for search operation in SearchApi. - * @export - * @interface SearchApiSearchRequest - */ -export interface SearchApiSearchRequest { - /** - * @deprecated - * @type {boolean} - * @memberof SearchApiSearch - */ - readonly clip?: boolean + if (encodedVideoPath !== undefined) { + localVarQueryParameter['encodedVideoPath'] = encodedVideoPath; + } - /** - * - * @type {boolean} - * @memberof SearchApiSearch - */ - readonly motion?: boolean + if (id !== undefined) { + localVarQueryParameter['id'] = id; + } - /** - * - * @type {string} - * @memberof SearchApiSearch - */ - readonly q?: string + if (isArchived !== undefined) { + localVarQueryParameter['isArchived'] = isArchived; + } - /** - * - * @type {string} - * @memberof SearchApiSearch - */ - readonly query?: string + if (isEncoded !== undefined) { + localVarQueryParameter['isEncoded'] = isEncoded; + } - /** - * - * @type {boolean} - * @memberof SearchApiSearch - */ - readonly recent?: boolean + if (isExternal !== undefined) { + localVarQueryParameter['isExternal'] = isExternal; + } + + if (isFavorite !== undefined) { + localVarQueryParameter['isFavorite'] = isFavorite; + } + + if (isMotion !== undefined) { + localVarQueryParameter['isMotion'] = isMotion; + } + + if (isOffline !== undefined) { + localVarQueryParameter['isOffline'] = isOffline; + } + + if (isReadOnly !== undefined) { + localVarQueryParameter['isReadOnly'] = isReadOnly; + } + + if (isVisible !== undefined) { + localVarQueryParameter['isVisible'] = isVisible; + } + + if (lensModel !== undefined) { + localVarQueryParameter['lensModel'] = lensModel; + } + + if (libraryId !== undefined) { + localVarQueryParameter['libraryId'] = libraryId; + } + + if (make !== undefined) { + localVarQueryParameter['make'] = make; + } + + if (model !== undefined) { + localVarQueryParameter['model'] = model; + } + + if (order !== undefined) { + localVarQueryParameter['order'] = order; + } + + if (originalFileName !== undefined) { + localVarQueryParameter['originalFileName'] = originalFileName; + } + + if (originalPath !== undefined) { + localVarQueryParameter['originalPath'] = originalPath; + } + + if (page !== undefined) { + localVarQueryParameter['page'] = page; + } + + if (resizePath !== undefined) { + localVarQueryParameter['resizePath'] = resizePath; + } + + if (size !== undefined) { + localVarQueryParameter['size'] = size; + } + + if (state !== undefined) { + localVarQueryParameter['state'] = state; + } + + if (takenAfter !== undefined) { + localVarQueryParameter['takenAfter'] = (takenAfter as any instanceof Date) ? + (takenAfter as any).toISOString() : + takenAfter; + } + + if (takenBefore !== undefined) { + localVarQueryParameter['takenBefore'] = (takenBefore as any instanceof Date) ? + (takenBefore as any).toISOString() : + takenBefore; + } + + if (trashedAfter !== undefined) { + localVarQueryParameter['trashedAfter'] = (trashedAfter as any instanceof Date) ? + (trashedAfter as any).toISOString() : + trashedAfter; + } + + if (trashedBefore !== undefined) { + localVarQueryParameter['trashedBefore'] = (trashedBefore as any instanceof Date) ? + (trashedBefore as any).toISOString() : + trashedBefore; + } + + if (type !== undefined) { + localVarQueryParameter['type'] = type; + } + + if (updatedAfter !== undefined) { + localVarQueryParameter['updatedAfter'] = (updatedAfter as any instanceof Date) ? + (updatedAfter as any).toISOString() : + updatedAfter; + } + + if (updatedBefore !== undefined) { + localVarQueryParameter['updatedBefore'] = (updatedBefore as any instanceof Date) ? + (updatedBefore as any).toISOString() : + updatedBefore; + } + + if (webpPath !== undefined) { + localVarQueryParameter['webpPath'] = webpPath; + } + + if (withArchived !== undefined) { + localVarQueryParameter['withArchived'] = withArchived; + } + + if (withDeleted !== undefined) { + localVarQueryParameter['withDeleted'] = withDeleted; + } + + if (withExif !== undefined) { + localVarQueryParameter['withExif'] = withExif; + } + + if (withPeople !== undefined) { + localVarQueryParameter['withPeople'] = withPeople; + } + + if (withStacked !== undefined) { + localVarQueryParameter['withStacked'] = withStacked; + } + + + + 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 {boolean} [withHidden] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + searchPerson: async (name: string, withHidden?: boolean, options: RawAxiosRequestConfig = {}): Promise => { + // 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; + } + + if (withHidden !== undefined) { + localVarQueryParameter['withHidden'] = withHidden; + } + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} query + * @param {string} [city] + * @param {string} [country] + * @param {string} [createdAfter] + * @param {string} [createdBefore] + * @param {string} [deviceId] + * @param {boolean} [isArchived] + * @param {boolean} [isEncoded] + * @param {boolean} [isExternal] + * @param {boolean} [isFavorite] + * @param {boolean} [isMotion] + * @param {boolean} [isOffline] + * @param {boolean} [isReadOnly] + * @param {boolean} [isVisible] + * @param {string} [lensModel] + * @param {string} [libraryId] + * @param {string} [make] + * @param {string} [model] + * @param {number} [page] + * @param {number} [size] + * @param {string} [state] + * @param {string} [takenAfter] + * @param {string} [takenBefore] + * @param {string} [trashedAfter] + * @param {string} [trashedBefore] + * @param {AssetTypeEnum} [type] + * @param {string} [updatedAfter] + * @param {string} [updatedBefore] + * @param {boolean} [withArchived] + * @param {boolean} [withDeleted] + * @param {boolean} [withExif] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + searchSmart: async (query: string, city?: string, country?: string, createdAfter?: string, createdBefore?: string, deviceId?: string, isArchived?: boolean, isEncoded?: boolean, isExternal?: boolean, isFavorite?: boolean, isMotion?: boolean, isOffline?: boolean, isReadOnly?: boolean, isVisible?: boolean, lensModel?: string, libraryId?: string, make?: string, model?: string, page?: number, size?: number, state?: string, takenAfter?: string, takenBefore?: string, trashedAfter?: string, trashedBefore?: string, type?: AssetTypeEnum, updatedAfter?: string, updatedBefore?: string, withArchived?: boolean, withDeleted?: boolean, withExif?: boolean, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'query' is not null or undefined + assertParamExists('searchSmart', 'query', query) + const localVarPath = `/search/smart`; + // 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 (city !== undefined) { + localVarQueryParameter['city'] = city; + } + + if (country !== undefined) { + localVarQueryParameter['country'] = country; + } + + if (createdAfter !== undefined) { + localVarQueryParameter['createdAfter'] = (createdAfter as any instanceof Date) ? + (createdAfter as any).toISOString() : + createdAfter; + } + + if (createdBefore !== undefined) { + localVarQueryParameter['createdBefore'] = (createdBefore as any instanceof Date) ? + (createdBefore as any).toISOString() : + createdBefore; + } + + if (deviceId !== undefined) { + localVarQueryParameter['deviceId'] = deviceId; + } + + if (isArchived !== undefined) { + localVarQueryParameter['isArchived'] = isArchived; + } + + if (isEncoded !== undefined) { + localVarQueryParameter['isEncoded'] = isEncoded; + } + + if (isExternal !== undefined) { + localVarQueryParameter['isExternal'] = isExternal; + } + + if (isFavorite !== undefined) { + localVarQueryParameter['isFavorite'] = isFavorite; + } + + if (isMotion !== undefined) { + localVarQueryParameter['isMotion'] = isMotion; + } + + if (isOffline !== undefined) { + localVarQueryParameter['isOffline'] = isOffline; + } + + if (isReadOnly !== undefined) { + localVarQueryParameter['isReadOnly'] = isReadOnly; + } + + if (isVisible !== undefined) { + localVarQueryParameter['isVisible'] = isVisible; + } + + if (lensModel !== undefined) { + localVarQueryParameter['lensModel'] = lensModel; + } + + if (libraryId !== undefined) { + localVarQueryParameter['libraryId'] = libraryId; + } + + if (make !== undefined) { + localVarQueryParameter['make'] = make; + } + + if (model !== undefined) { + localVarQueryParameter['model'] = model; + } + + if (page !== undefined) { + localVarQueryParameter['page'] = page; + } + + if (query !== undefined) { + localVarQueryParameter['query'] = query; + } + + if (size !== undefined) { + localVarQueryParameter['size'] = size; + } + + if (state !== undefined) { + localVarQueryParameter['state'] = state; + } + + if (takenAfter !== undefined) { + localVarQueryParameter['takenAfter'] = (takenAfter as any instanceof Date) ? + (takenAfter as any).toISOString() : + takenAfter; + } + + if (takenBefore !== undefined) { + localVarQueryParameter['takenBefore'] = (takenBefore as any instanceof Date) ? + (takenBefore as any).toISOString() : + takenBefore; + } + + if (trashedAfter !== undefined) { + localVarQueryParameter['trashedAfter'] = (trashedAfter as any instanceof Date) ? + (trashedAfter as any).toISOString() : + trashedAfter; + } + + if (trashedBefore !== undefined) { + localVarQueryParameter['trashedBefore'] = (trashedBefore as any instanceof Date) ? + (trashedBefore as any).toISOString() : + trashedBefore; + } + + if (type !== undefined) { + localVarQueryParameter['type'] = type; + } + + if (updatedAfter !== undefined) { + localVarQueryParameter['updatedAfter'] = (updatedAfter as any instanceof Date) ? + (updatedAfter as any).toISOString() : + updatedAfter; + } + + if (updatedBefore !== undefined) { + localVarQueryParameter['updatedBefore'] = (updatedBefore as any instanceof Date) ? + (updatedBefore as any).toISOString() : + updatedBefore; + } + + if (withArchived !== undefined) { + localVarQueryParameter['withArchived'] = withArchived; + } + + if (withDeleted !== undefined) { + localVarQueryParameter['withDeleted'] = withDeleted; + } + + if (withExif !== undefined) { + localVarQueryParameter['withExif'] = withExif; + } + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * SearchApi - functional programming interface + * @export + */ +export const SearchApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = SearchApiAxiosParamCreator(configuration) + return { + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getExploreData(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getExploreData(options); + const index = configuration?.serverIndex ?? 0; + const operationBasePath = operationServerMap['SearchApi.getExploreData']?.[index]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, operationBasePath || basePath); + }, + /** + * + * @param {boolean} [clip] @deprecated + * @param {boolean} [motion] + * @param {number} [page] + * @param {string} [q] + * @param {string} [query] + * @param {boolean} [recent] + * @param {number} [size] + * @param {boolean} [smart] + * @param {SearchTypeEnum} [type] + * @param {boolean} [withArchived] + * @param {*} [options] Override http request option. + * @deprecated + * @throws {RequiredError} + */ + async search(clip?: boolean, motion?: boolean, page?: number, q?: string, query?: string, recent?: boolean, size?: number, smart?: boolean, type?: SearchTypeEnum, withArchived?: boolean, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.search(clip, motion, page, q, query, recent, size, smart, type, withArchived, options); + const index = configuration?.serverIndex ?? 0; + const operationBasePath = operationServerMap['SearchApi.search']?.[index]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, operationBasePath || basePath); + }, + /** + * + * @param {string} [checksum] + * @param {string} [city] + * @param {string} [country] + * @param {string} [createdAfter] + * @param {string} [createdBefore] + * @param {string} [deviceAssetId] + * @param {string} [deviceId] + * @param {string} [encodedVideoPath] + * @param {string} [id] + * @param {boolean} [isArchived] + * @param {boolean} [isEncoded] + * @param {boolean} [isExternal] + * @param {boolean} [isFavorite] + * @param {boolean} [isMotion] + * @param {boolean} [isOffline] + * @param {boolean} [isReadOnly] + * @param {boolean} [isVisible] + * @param {string} [lensModel] + * @param {string} [libraryId] + * @param {string} [make] + * @param {string} [model] + * @param {AssetOrder} [order] + * @param {string} [originalFileName] + * @param {string} [originalPath] + * @param {number} [page] + * @param {string} [resizePath] + * @param {number} [size] + * @param {string} [state] + * @param {string} [takenAfter] + * @param {string} [takenBefore] + * @param {string} [trashedAfter] + * @param {string} [trashedBefore] + * @param {AssetTypeEnum} [type] + * @param {string} [updatedAfter] + * @param {string} [updatedBefore] + * @param {string} [webpPath] + * @param {boolean} [withArchived] + * @param {boolean} [withDeleted] + * @param {boolean} [withExif] + * @param {boolean} [withPeople] + * @param {boolean} [withStacked] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async searchMetadata(checksum?: string, city?: string, country?: string, createdAfter?: string, createdBefore?: string, deviceAssetId?: string, deviceId?: string, encodedVideoPath?: string, id?: string, isArchived?: boolean, isEncoded?: boolean, isExternal?: boolean, isFavorite?: boolean, isMotion?: boolean, isOffline?: boolean, isReadOnly?: boolean, isVisible?: boolean, lensModel?: string, libraryId?: string, make?: string, model?: string, order?: AssetOrder, originalFileName?: string, originalPath?: string, page?: number, resizePath?: string, size?: number, state?: string, takenAfter?: string, takenBefore?: string, trashedAfter?: string, trashedBefore?: string, type?: AssetTypeEnum, updatedAfter?: string, updatedBefore?: string, webpPath?: string, withArchived?: boolean, withDeleted?: boolean, withExif?: boolean, withPeople?: boolean, withStacked?: boolean, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.searchMetadata(checksum, city, country, createdAfter, createdBefore, deviceAssetId, deviceId, encodedVideoPath, id, isArchived, isEncoded, isExternal, isFavorite, isMotion, isOffline, isReadOnly, isVisible, lensModel, libraryId, make, model, order, originalFileName, originalPath, page, resizePath, size, state, takenAfter, takenBefore, trashedAfter, trashedBefore, type, updatedAfter, updatedBefore, webpPath, withArchived, withDeleted, withExif, withPeople, withStacked, options); + const index = configuration?.serverIndex ?? 0; + const operationBasePath = operationServerMap['SearchApi.searchMetadata']?.[index]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, operationBasePath || basePath); + }, + /** + * + * @param {string} name + * @param {boolean} [withHidden] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async searchPerson(name: string, withHidden?: boolean, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.searchPerson(name, withHidden, options); + const index = configuration?.serverIndex ?? 0; + const operationBasePath = operationServerMap['SearchApi.searchPerson']?.[index]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, operationBasePath || basePath); + }, + /** + * + * @param {string} query + * @param {string} [city] + * @param {string} [country] + * @param {string} [createdAfter] + * @param {string} [createdBefore] + * @param {string} [deviceId] + * @param {boolean} [isArchived] + * @param {boolean} [isEncoded] + * @param {boolean} [isExternal] + * @param {boolean} [isFavorite] + * @param {boolean} [isMotion] + * @param {boolean} [isOffline] + * @param {boolean} [isReadOnly] + * @param {boolean} [isVisible] + * @param {string} [lensModel] + * @param {string} [libraryId] + * @param {string} [make] + * @param {string} [model] + * @param {number} [page] + * @param {number} [size] + * @param {string} [state] + * @param {string} [takenAfter] + * @param {string} [takenBefore] + * @param {string} [trashedAfter] + * @param {string} [trashedBefore] + * @param {AssetTypeEnum} [type] + * @param {string} [updatedAfter] + * @param {string} [updatedBefore] + * @param {boolean} [withArchived] + * @param {boolean} [withDeleted] + * @param {boolean} [withExif] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async searchSmart(query: string, city?: string, country?: string, createdAfter?: string, createdBefore?: string, deviceId?: string, isArchived?: boolean, isEncoded?: boolean, isExternal?: boolean, isFavorite?: boolean, isMotion?: boolean, isOffline?: boolean, isReadOnly?: boolean, isVisible?: boolean, lensModel?: string, libraryId?: string, make?: string, model?: string, page?: number, size?: number, state?: string, takenAfter?: string, takenBefore?: string, trashedAfter?: string, trashedBefore?: string, type?: AssetTypeEnum, updatedAfter?: string, updatedBefore?: string, withArchived?: boolean, withDeleted?: boolean, withExif?: boolean, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.searchSmart(query, city, country, createdAfter, createdBefore, deviceId, isArchived, isEncoded, isExternal, isFavorite, isMotion, isOffline, isReadOnly, isVisible, lensModel, libraryId, make, model, page, size, state, takenAfter, takenBefore, trashedAfter, trashedBefore, type, updatedAfter, updatedBefore, withArchived, withDeleted, withExif, options); + const index = configuration?.serverIndex ?? 0; + const operationBasePath = operationServerMap['SearchApi.searchSmart']?.[index]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, operationBasePath || basePath); + }, + } +}; + +/** + * SearchApi - factory interface + * @export + */ +export const SearchApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = SearchApiFp(configuration) + return { + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getExploreData(options?: RawAxiosRequestConfig): AxiosPromise> { + return localVarFp.getExploreData(options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {SearchApiSearchRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @deprecated + * @throws {RequiredError} + */ + search(requestParameters: SearchApiSearchRequest = {}, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.search(requestParameters.clip, requestParameters.motion, requestParameters.page, requestParameters.q, requestParameters.query, requestParameters.recent, requestParameters.size, requestParameters.smart, requestParameters.type, requestParameters.withArchived, options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {SearchApiSearchMetadataRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + searchMetadata(requestParameters: SearchApiSearchMetadataRequest = {}, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.searchMetadata(requestParameters.checksum, requestParameters.city, requestParameters.country, requestParameters.createdAfter, requestParameters.createdBefore, requestParameters.deviceAssetId, requestParameters.deviceId, requestParameters.encodedVideoPath, requestParameters.id, requestParameters.isArchived, requestParameters.isEncoded, requestParameters.isExternal, requestParameters.isFavorite, requestParameters.isMotion, requestParameters.isOffline, requestParameters.isReadOnly, requestParameters.isVisible, requestParameters.lensModel, requestParameters.libraryId, requestParameters.make, requestParameters.model, requestParameters.order, requestParameters.originalFileName, requestParameters.originalPath, requestParameters.page, requestParameters.resizePath, requestParameters.size, requestParameters.state, requestParameters.takenAfter, requestParameters.takenBefore, requestParameters.trashedAfter, requestParameters.trashedBefore, requestParameters.type, requestParameters.updatedAfter, requestParameters.updatedBefore, requestParameters.webpPath, requestParameters.withArchived, requestParameters.withDeleted, requestParameters.withExif, requestParameters.withPeople, requestParameters.withStacked, options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {SearchApiSearchPersonRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: RawAxiosRequestConfig): AxiosPromise> { + return localVarFp.searchPerson(requestParameters.name, requestParameters.withHidden, options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {SearchApiSearchSmartRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + searchSmart(requestParameters: SearchApiSearchSmartRequest, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.searchSmart(requestParameters.query, requestParameters.city, requestParameters.country, requestParameters.createdAfter, requestParameters.createdBefore, requestParameters.deviceId, requestParameters.isArchived, requestParameters.isEncoded, requestParameters.isExternal, requestParameters.isFavorite, requestParameters.isMotion, requestParameters.isOffline, requestParameters.isReadOnly, requestParameters.isVisible, requestParameters.lensModel, requestParameters.libraryId, requestParameters.make, requestParameters.model, requestParameters.page, requestParameters.size, requestParameters.state, requestParameters.takenAfter, requestParameters.takenBefore, requestParameters.trashedAfter, requestParameters.trashedBefore, requestParameters.type, requestParameters.updatedAfter, requestParameters.updatedBefore, requestParameters.withArchived, requestParameters.withDeleted, requestParameters.withExif, options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * Request parameters for search operation in SearchApi. + * @export + * @interface SearchApiSearchRequest + */ +export interface SearchApiSearchRequest { + /** + * @deprecated + * @type {boolean} + * @memberof SearchApiSearch + */ + readonly clip?: boolean + + /** + * + * @type {boolean} + * @memberof SearchApiSearch + */ + readonly motion?: boolean + + /** + * + * @type {number} + * @memberof SearchApiSearch + */ + readonly page?: number + + /** + * + * @type {string} + * @memberof SearchApiSearch + */ + readonly q?: string + + /** + * + * @type {string} + * @memberof SearchApiSearch + */ + readonly query?: string + + /** + * + * @type {boolean} + * @memberof SearchApiSearch + */ + readonly recent?: boolean + + /** + * + * @type {number} + * @memberof SearchApiSearch + */ + readonly size?: number + + /** + * + * @type {boolean} + * @memberof SearchApiSearch + */ + readonly smart?: boolean + + /** + * + * @type {'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER'} + * @memberof SearchApiSearch + */ + readonly type?: SearchTypeEnum + + /** + * + * @type {boolean} + * @memberof SearchApiSearch + */ + readonly withArchived?: boolean +} + +/** + * Request parameters for searchMetadata operation in SearchApi. + * @export + * @interface SearchApiSearchMetadataRequest + */ +export interface SearchApiSearchMetadataRequest { + /** + * + * @type {string} + * @memberof SearchApiSearchMetadata + */ + readonly checksum?: string + + /** + * + * @type {string} + * @memberof SearchApiSearchMetadata + */ + readonly city?: string + + /** + * + * @type {string} + * @memberof SearchApiSearchMetadata + */ + readonly country?: string + + /** + * + * @type {string} + * @memberof SearchApiSearchMetadata + */ + readonly createdAfter?: string + + /** + * + * @type {string} + * @memberof SearchApiSearchMetadata + */ + readonly createdBefore?: string + + /** + * + * @type {string} + * @memberof SearchApiSearchMetadata + */ + readonly deviceAssetId?: string + + /** + * + * @type {string} + * @memberof SearchApiSearchMetadata + */ + readonly deviceId?: string + + /** + * + * @type {string} + * @memberof SearchApiSearchMetadata + */ + readonly encodedVideoPath?: string + + /** + * + * @type {string} + * @memberof SearchApiSearchMetadata + */ + readonly id?: string + + /** + * + * @type {boolean} + * @memberof SearchApiSearchMetadata + */ + readonly isArchived?: boolean + + /** + * + * @type {boolean} + * @memberof SearchApiSearchMetadata + */ + readonly isEncoded?: boolean + + /** + * + * @type {boolean} + * @memberof SearchApiSearchMetadata + */ + readonly isExternal?: boolean + + /** + * + * @type {boolean} + * @memberof SearchApiSearchMetadata + */ + readonly isFavorite?: boolean + + /** + * + * @type {boolean} + * @memberof SearchApiSearchMetadata + */ + readonly isMotion?: boolean + + /** + * + * @type {boolean} + * @memberof SearchApiSearchMetadata + */ + readonly isOffline?: boolean + + /** + * + * @type {boolean} + * @memberof SearchApiSearchMetadata + */ + readonly isReadOnly?: boolean + + /** + * + * @type {boolean} + * @memberof SearchApiSearchMetadata + */ + readonly isVisible?: boolean + + /** + * + * @type {string} + * @memberof SearchApiSearchMetadata + */ + readonly lensModel?: string + + /** + * + * @type {string} + * @memberof SearchApiSearchMetadata + */ + readonly libraryId?: string + + /** + * + * @type {string} + * @memberof SearchApiSearchMetadata + */ + readonly make?: string + + /** + * + * @type {string} + * @memberof SearchApiSearchMetadata + */ + readonly model?: string + + /** + * + * @type {AssetOrder} + * @memberof SearchApiSearchMetadata + */ + readonly order?: AssetOrder + + /** + * + * @type {string} + * @memberof SearchApiSearchMetadata + */ + readonly originalFileName?: string + + /** + * + * @type {string} + * @memberof SearchApiSearchMetadata + */ + readonly originalPath?: string + + /** + * + * @type {number} + * @memberof SearchApiSearchMetadata + */ + readonly page?: number + + /** + * + * @type {string} + * @memberof SearchApiSearchMetadata + */ + readonly resizePath?: string + + /** + * + * @type {number} + * @memberof SearchApiSearchMetadata + */ + readonly size?: number + + /** + * + * @type {string} + * @memberof SearchApiSearchMetadata + */ + readonly state?: string + + /** + * + * @type {string} + * @memberof SearchApiSearchMetadata + */ + readonly takenAfter?: string + + /** + * + * @type {string} + * @memberof SearchApiSearchMetadata + */ + readonly takenBefore?: string + + /** + * + * @type {string} + * @memberof SearchApiSearchMetadata + */ + readonly trashedAfter?: string + + /** + * + * @type {string} + * @memberof SearchApiSearchMetadata + */ + readonly trashedBefore?: string /** * - * @type {boolean} - * @memberof SearchApiSearch + * @type {AssetTypeEnum} + * @memberof SearchApiSearchMetadata */ - readonly smart?: boolean + readonly type?: AssetTypeEnum /** * - * @type {'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER'} - * @memberof SearchApiSearch + * @type {string} + * @memberof SearchApiSearchMetadata */ - readonly type?: SearchTypeEnum + readonly updatedAfter?: string + + /** + * + * @type {string} + * @memberof SearchApiSearchMetadata + */ + readonly updatedBefore?: string + + /** + * + * @type {string} + * @memberof SearchApiSearchMetadata + */ + readonly webpPath?: string /** * * @type {boolean} - * @memberof SearchApiSearch + * @memberof SearchApiSearchMetadata */ readonly withArchived?: boolean /** * - * @type {number} - * @memberof SearchApiSearch + * @type {boolean} + * @memberof SearchApiSearchMetadata */ - readonly take?: number + readonly withDeleted?: boolean /** * - * @type {number} - * @memberof SearchApiSearch + * @type {boolean} + * @memberof SearchApiSearchMetadata */ - readonly page?: number + readonly withExif?: boolean + + /** + * + * @type {boolean} + * @memberof SearchApiSearchMetadata + */ + readonly withPeople?: boolean + + /** + * + * @type {boolean} + * @memberof SearchApiSearchMetadata + */ + readonly withStacked?: boolean } /** @@ -14832,6 +15728,230 @@ export interface SearchApiSearchPersonRequest { readonly withHidden?: boolean } +/** + * Request parameters for searchSmart operation in SearchApi. + * @export + * @interface SearchApiSearchSmartRequest + */ +export interface SearchApiSearchSmartRequest { + /** + * + * @type {string} + * @memberof SearchApiSearchSmart + */ + readonly query: string + + /** + * + * @type {string} + * @memberof SearchApiSearchSmart + */ + readonly city?: string + + /** + * + * @type {string} + * @memberof SearchApiSearchSmart + */ + readonly country?: string + + /** + * + * @type {string} + * @memberof SearchApiSearchSmart + */ + readonly createdAfter?: string + + /** + * + * @type {string} + * @memberof SearchApiSearchSmart + */ + readonly createdBefore?: string + + /** + * + * @type {string} + * @memberof SearchApiSearchSmart + */ + readonly deviceId?: string + + /** + * + * @type {boolean} + * @memberof SearchApiSearchSmart + */ + readonly isArchived?: boolean + + /** + * + * @type {boolean} + * @memberof SearchApiSearchSmart + */ + readonly isEncoded?: boolean + + /** + * + * @type {boolean} + * @memberof SearchApiSearchSmart + */ + readonly isExternal?: boolean + + /** + * + * @type {boolean} + * @memberof SearchApiSearchSmart + */ + readonly isFavorite?: boolean + + /** + * + * @type {boolean} + * @memberof SearchApiSearchSmart + */ + readonly isMotion?: boolean + + /** + * + * @type {boolean} + * @memberof SearchApiSearchSmart + */ + readonly isOffline?: boolean + + /** + * + * @type {boolean} + * @memberof SearchApiSearchSmart + */ + readonly isReadOnly?: boolean + + /** + * + * @type {boolean} + * @memberof SearchApiSearchSmart + */ + readonly isVisible?: boolean + + /** + * + * @type {string} + * @memberof SearchApiSearchSmart + */ + readonly lensModel?: string + + /** + * + * @type {string} + * @memberof SearchApiSearchSmart + */ + readonly libraryId?: string + + /** + * + * @type {string} + * @memberof SearchApiSearchSmart + */ + readonly make?: string + + /** + * + * @type {string} + * @memberof SearchApiSearchSmart + */ + readonly model?: string + + /** + * + * @type {number} + * @memberof SearchApiSearchSmart + */ + readonly page?: number + + /** + * + * @type {number} + * @memberof SearchApiSearchSmart + */ + readonly size?: number + + /** + * + * @type {string} + * @memberof SearchApiSearchSmart + */ + readonly state?: string + + /** + * + * @type {string} + * @memberof SearchApiSearchSmart + */ + readonly takenAfter?: string + + /** + * + * @type {string} + * @memberof SearchApiSearchSmart + */ + readonly takenBefore?: string + + /** + * + * @type {string} + * @memberof SearchApiSearchSmart + */ + readonly trashedAfter?: string + + /** + * + * @type {string} + * @memberof SearchApiSearchSmart + */ + readonly trashedBefore?: string + + /** + * + * @type {AssetTypeEnum} + * @memberof SearchApiSearchSmart + */ + readonly type?: AssetTypeEnum + + /** + * + * @type {string} + * @memberof SearchApiSearchSmart + */ + readonly updatedAfter?: string + + /** + * + * @type {string} + * @memberof SearchApiSearchSmart + */ + readonly updatedBefore?: string + + /** + * + * @type {boolean} + * @memberof SearchApiSearchSmart + */ + readonly withArchived?: boolean + + /** + * + * @type {boolean} + * @memberof SearchApiSearchSmart + */ + readonly withDeleted?: boolean + + /** + * + * @type {boolean} + * @memberof SearchApiSearchSmart + */ + readonly withExif?: boolean +} + /** * SearchApi - object-oriented interface * @export @@ -14853,11 +15973,23 @@ export class SearchApi extends BaseAPI { * * @param {SearchApiSearchRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. + * @deprecated * @throws {RequiredError} * @memberof SearchApi */ public search(requestParameters: SearchApiSearchRequest = {}, options?: RawAxiosRequestConfig) { - return SearchApiFp(this.configuration).search(requestParameters.clip, requestParameters.motion, requestParameters.q, requestParameters.query, requestParameters.recent, requestParameters.smart, requestParameters.type, requestParameters.withArchived, requestParameters.take, requestParameters.page, options).then((request) => request(this.axios, this.basePath)); + return SearchApiFp(this.configuration).search(requestParameters.clip, requestParameters.motion, requestParameters.page, requestParameters.q, requestParameters.query, requestParameters.recent, requestParameters.size, requestParameters.smart, requestParameters.type, requestParameters.withArchived, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {SearchApiSearchMetadataRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof SearchApi + */ + public searchMetadata(requestParameters: SearchApiSearchMetadataRequest = {}, options?: RawAxiosRequestConfig) { + return SearchApiFp(this.configuration).searchMetadata(requestParameters.checksum, requestParameters.city, requestParameters.country, requestParameters.createdAfter, requestParameters.createdBefore, requestParameters.deviceAssetId, requestParameters.deviceId, requestParameters.encodedVideoPath, requestParameters.id, requestParameters.isArchived, requestParameters.isEncoded, requestParameters.isExternal, requestParameters.isFavorite, requestParameters.isMotion, requestParameters.isOffline, requestParameters.isReadOnly, requestParameters.isVisible, requestParameters.lensModel, requestParameters.libraryId, requestParameters.make, requestParameters.model, requestParameters.order, requestParameters.originalFileName, requestParameters.originalPath, requestParameters.page, requestParameters.resizePath, requestParameters.size, requestParameters.state, requestParameters.takenAfter, requestParameters.takenBefore, requestParameters.trashedAfter, requestParameters.trashedBefore, requestParameters.type, requestParameters.updatedAfter, requestParameters.updatedBefore, requestParameters.webpPath, requestParameters.withArchived, requestParameters.withDeleted, requestParameters.withExif, requestParameters.withPeople, requestParameters.withStacked, options).then((request) => request(this.axios, this.basePath)); } /** @@ -14870,6 +16002,17 @@ export class SearchApi extends BaseAPI { public searchPerson(requestParameters: SearchApiSearchPersonRequest, options?: RawAxiosRequestConfig) { return SearchApiFp(this.configuration).searchPerson(requestParameters.name, requestParameters.withHidden, options).then((request) => request(this.axios, this.basePath)); } + + /** + * + * @param {SearchApiSearchSmartRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof SearchApi + */ + public searchSmart(requestParameters: SearchApiSearchSmartRequest, options?: RawAxiosRequestConfig) { + return SearchApiFp(this.configuration).searchSmart(requestParameters.query, requestParameters.city, requestParameters.country, requestParameters.createdAfter, requestParameters.createdBefore, requestParameters.deviceId, requestParameters.isArchived, requestParameters.isEncoded, requestParameters.isExternal, requestParameters.isFavorite, requestParameters.isMotion, requestParameters.isOffline, requestParameters.isReadOnly, requestParameters.isVisible, requestParameters.lensModel, requestParameters.libraryId, requestParameters.make, requestParameters.model, requestParameters.page, requestParameters.size, requestParameters.state, requestParameters.takenAfter, requestParameters.takenBefore, requestParameters.trashedAfter, requestParameters.trashedBefore, requestParameters.type, requestParameters.updatedAfter, requestParameters.updatedBefore, requestParameters.withArchived, requestParameters.withDeleted, requestParameters.withExif, options).then((request) => request(this.axios, this.basePath)); + } } /** diff --git a/open-api/typescript-sdk/fetch-client.ts b/open-api/typescript-sdk/fetch-client.ts index 75099310a4139..afdb705c60660 100644 --- a/open-api/typescript-sdk/fetch-client.ts +++ b/open-api/typescript-sdk/fetch-client.ts @@ -1462,7 +1462,7 @@ export function updateAsset({ id, updateAssetDto }: { body: updateAssetDto }))); } -export function searchAssets({ checksum, city, country, createdAfter, createdBefore, deviceAssetId, deviceId, encodedVideoPath, id, isArchived, isEncoded, isExternal, isFavorite, isMotion, isOffline, isReadOnly, isVisible, lensModel, libraryId, make, model, order, originalFileName, originalPath, page, resizePath, size, state, takenAfter, takenBefore, trashedAfter, trashedBefore, $type, updatedAfter, updatedBefore, webpPath, withDeleted, withExif, withPeople, withStacked }: { +export function searchAssets({ checksum, city, country, createdAfter, createdBefore, deviceAssetId, deviceId, encodedVideoPath, id, isArchived, isEncoded, isExternal, isFavorite, isMotion, isOffline, isReadOnly, isVisible, lensModel, libraryId, make, model, order, originalFileName, originalPath, page, resizePath, size, state, takenAfter, takenBefore, trashedAfter, trashedBefore, $type, updatedAfter, updatedBefore, webpPath, withArchived, withDeleted, withExif, withPeople, withStacked }: { checksum?: string; city?: string; country?: string; @@ -1499,6 +1499,7 @@ export function searchAssets({ checksum, city, country, createdAfter, createdBef updatedAfter?: string; updatedBefore?: string; webpPath?: string; + withArchived?: boolean; withDeleted?: boolean; withExif?: boolean; withPeople?: boolean; @@ -1544,6 +1545,7 @@ export function searchAssets({ checksum, city, country, createdAfter, createdBef updatedAfter, updatedBefore, webpPath, + withArchived, withDeleted, withExif, withPeople, @@ -2048,17 +2050,17 @@ export function getPersonThumbnail({ id }: { ...opts })); } -export function search({ clip, motion, q, query, recent, smart, $type, withArchived, take, page }: { +export function search({ clip, motion, page, q, query, recent, size, smart, $type, withArchived }: { clip?: boolean; motion?: boolean; + page?: number; q?: string; query?: string; recent?: boolean; + size?: number; smart?: boolean; $type?: "IMAGE" | "VIDEO" | "AUDIO" | "OTHER"; withArchived?: boolean; - take?: number; - page?: number; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -2066,14 +2068,14 @@ export function search({ clip, motion, q, query, recent, smart, $type, withArchi }>(`/search${QS.query(QS.explode({ clip, motion, + page, q, query, recent, + size, smart, "type": $type, - withArchived, - take, - page + withArchived }))}`, { ...opts })); @@ -2086,6 +2088,98 @@ export function getExploreData(opts?: Oazapfts.RequestOpts) { ...opts })); } +export function searchMetadata({ checksum, city, country, createdAfter, createdBefore, deviceAssetId, deviceId, encodedVideoPath, id, isArchived, isEncoded, isExternal, isFavorite, isMotion, isOffline, isReadOnly, isVisible, lensModel, libraryId, make, model, order, originalFileName, originalPath, page, resizePath, size, state, takenAfter, takenBefore, trashedAfter, trashedBefore, $type, updatedAfter, updatedBefore, webpPath, withArchived, withDeleted, withExif, withPeople, withStacked }: { + checksum?: string; + city?: string; + country?: string; + createdAfter?: string; + createdBefore?: string; + deviceAssetId?: string; + deviceId?: string; + encodedVideoPath?: string; + id?: string; + isArchived?: boolean; + isEncoded?: boolean; + isExternal?: boolean; + isFavorite?: boolean; + isMotion?: boolean; + isOffline?: boolean; + isReadOnly?: boolean; + isVisible?: boolean; + lensModel?: string; + libraryId?: string; + make?: string; + model?: string; + order?: AssetOrder; + originalFileName?: string; + originalPath?: string; + page?: number; + resizePath?: string; + size?: number; + state?: string; + takenAfter?: string; + takenBefore?: string; + trashedAfter?: string; + trashedBefore?: string; + $type?: AssetTypeEnum; + updatedAfter?: string; + updatedBefore?: string; + webpPath?: string; + withArchived?: boolean; + withDeleted?: boolean; + withExif?: boolean; + withPeople?: boolean; + withStacked?: boolean; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: SearchResponseDto; + }>(`/search/metadata${QS.query(QS.explode({ + checksum, + city, + country, + createdAfter, + createdBefore, + deviceAssetId, + deviceId, + encodedVideoPath, + id, + isArchived, + isEncoded, + isExternal, + isFavorite, + isMotion, + isOffline, + isReadOnly, + isVisible, + lensModel, + libraryId, + make, + model, + order, + originalFileName, + originalPath, + page, + resizePath, + size, + state, + takenAfter, + takenBefore, + trashedAfter, + trashedBefore, + "type": $type, + updatedAfter, + updatedBefore, + webpPath, + withArchived, + withDeleted, + withExif, + withPeople, + withStacked + }))}`, { + ...opts + })); +} export function searchPerson({ name, withHidden }: { name: string; withHidden?: boolean; @@ -2100,6 +2194,78 @@ export function searchPerson({ name, withHidden }: { ...opts })); } +export function searchSmart({ city, country, createdAfter, createdBefore, deviceId, isArchived, isEncoded, isExternal, isFavorite, isMotion, isOffline, isReadOnly, isVisible, lensModel, libraryId, make, model, page, query, size, state, takenAfter, takenBefore, trashedAfter, trashedBefore, $type, updatedAfter, updatedBefore, withArchived, withDeleted, withExif }: { + city?: string; + country?: string; + createdAfter?: string; + createdBefore?: string; + deviceId?: string; + isArchived?: boolean; + isEncoded?: boolean; + isExternal?: boolean; + isFavorite?: boolean; + isMotion?: boolean; + isOffline?: boolean; + isReadOnly?: boolean; + isVisible?: boolean; + lensModel?: string; + libraryId?: string; + make?: string; + model?: string; + page?: number; + query: string; + size?: number; + state?: string; + takenAfter?: string; + takenBefore?: string; + trashedAfter?: string; + trashedBefore?: string; + $type?: AssetTypeEnum; + updatedAfter?: string; + updatedBefore?: string; + withArchived?: boolean; + withDeleted?: boolean; + withExif?: boolean; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: SearchResponseDto; + }>(`/search/smart${QS.query(QS.explode({ + city, + country, + createdAfter, + createdBefore, + deviceId, + isArchived, + isEncoded, + isExternal, + isFavorite, + isMotion, + isOffline, + isReadOnly, + isVisible, + lensModel, + libraryId, + make, + model, + page, + query, + size, + state, + takenAfter, + takenBefore, + trashedAfter, + trashedBefore, + "type": $type, + updatedAfter, + updatedBefore, + withArchived, + withDeleted, + withExif + }))}`, { + ...opts + })); +} export function getServerInfo(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; From 452ba4e6c89cdc7a27e566fe574d3948e77f9560 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Sat, 10 Feb 2024 20:30:38 -0500 Subject: [PATCH 30/38] linting --- server/src/domain/asset/asset.service.spec.ts | 4 --- .../domain/repositories/search.repository.ts | 3 +- server/src/infra/infra.utils.ts | 14 +-------- .../infra/repositories/asset.repository.ts | 3 -- .../infra/repositories/search.repository.ts | 30 +++++++++---------- server/src/infra/sql-generator/index.ts | 2 +- 6 files changed, 17 insertions(+), 39 deletions(-) diff --git a/server/src/domain/asset/asset.service.spec.ts b/server/src/domain/asset/asset.service.spec.ts index 547fce2405919..a6b2cde3e88eb 100644 --- a/server/src/domain/asset/asset.service.spec.ts +++ b/server/src/domain/asset/asset.service.spec.ts @@ -12,7 +12,6 @@ import { newCommunicationRepositoryMock, newJobRepositoryMock, newPartnerRepositoryMock, - newSearchRepositoryMock, newStorageRepositoryMock, newSystemConfigRepositoryMock, newUserRepositoryMock, @@ -27,7 +26,6 @@ import { ICommunicationRepository, IJobRepository, IPartnerRepository, - ISearchRepository, IStorageRepository, ISystemConfigRepository, IUserRepository, @@ -166,7 +164,6 @@ describe(AssetService.name, () => { let configMock: jest.Mocked; let partnerMock: jest.Mocked; let assetStackMock: jest.Mocked; - let searchMock: jest.Mocked; it('should work', () => { expect(sut).toBeDefined(); @@ -182,7 +179,6 @@ describe(AssetService.name, () => { configMock = newSystemConfigRepositoryMock(); partnerMock = newPartnerRepositoryMock(); assetStackMock = newAssetStackRepositoryMock(); - searchMock = newSearchRepositoryMock(); sut = new AssetService( accessMock, diff --git a/server/src/domain/repositories/search.repository.ts b/server/src/domain/repositories/search.repository.ts index dd12ab1a66aca..4d720f98ad1a0 100644 --- a/server/src/domain/repositories/search.repository.ts +++ b/server/src/domain/repositories/search.repository.ts @@ -1,5 +1,4 @@ import { AssetEntity, AssetFaceEntity, AssetType, SmartInfoEntity } from '@app/infra/entities'; -import { SearchDto } from '..'; import { Paginated } from '../domain.util'; export const ISearchRepository = 'ISearchRepository'; @@ -161,7 +160,7 @@ export type SmartSearchOptions = SearchDateOptions & export interface FaceEmbeddingSearch extends SearchEmbeddingOptions { hasPerson?: boolean; - numResults?: number; + numResults: number; maxDistance?: number; } diff --git a/server/src/infra/infra.utils.ts b/server/src/infra/infra.utils.ts index bc8a219c53ab2..89bd319662e5a 100644 --- a/server/src/infra/infra.utils.ts +++ b/server/src/infra/infra.utils.ts @@ -1,17 +1,5 @@ -import { - AssetSearchBuilderOptions, - Paginated, - PaginationOptions, - SearchDateOptions, - SearchExifOptions, - SearchIDOptions, - SearchPathOptions, - SearchRelationOptions, - SearchStatusOptions, -} from '@app/domain'; -import { date } from 'joi'; +import { AssetSearchBuilderOptions, Paginated, PaginationOptions } from '@app/domain'; import _ from 'lodash'; -import path from 'node:path'; import { Between, Brackets, diff --git a/server/src/infra/repositories/asset.repository.ts b/server/src/infra/repositories/asset.repository.ts index 22f890cf8f52f..215d280f4c25e 100644 --- a/server/src/infra/repositories/asset.repository.ts +++ b/server/src/infra/repositories/asset.repository.ts @@ -23,18 +23,15 @@ import { } from '@app/domain'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import _ from 'lodash'; import { DateTime } from 'luxon'; import path from 'node:path'; import { - And, Brackets, FindOptionsRelations, FindOptionsSelect, FindOptionsWhere, In, IsNull, - LessThan, Not, Repository, } from 'typeorm'; diff --git a/server/src/infra/repositories/search.repository.ts b/server/src/infra/repositories/search.repository.ts index 2902d442aac3a..7d0421b05d17c 100644 --- a/server/src/infra/repositories/search.repository.ts +++ b/server/src/infra/repositories/search.repository.ts @@ -16,7 +16,6 @@ import { AssetEntity, AssetFaceEntity, SmartInfoEntity, SmartSearchEntity } from import { ImmichLogger } from '@app/infra/logger'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import _ from 'lodash'; import { Repository } from 'typeorm'; import { vectorExt } from '../database.config'; import { DummyValue, GenerateSql } from '../infra.util'; @@ -131,9 +130,16 @@ export class SearchRepository implements ISearchRepository { maxDistance, hasPerson, }: FaceEmbeddingSearch): Promise { + if (!isValidInteger(numResults, { min: 1 })) { + throw new Error(`Invalid value for 'numResults': ${numResults}`); + } + + // setting this too low messes with prefilter recall + numResults = Math.max(numResults, 64); + let results: Array = []; await this.assetRepository.manager.transaction(async (manager) => { - let cte = manager + const cte = manager .createQueryBuilder(AssetFaceEntity, 'faces') .select('faces.embedding <=> :embedding', 'distance') .innerJoin('faces.asset', 'asset') @@ -141,24 +147,17 @@ export class SearchRepository implements ISearchRepository { .orderBy('faces.embedding <=> :embedding') .setParameters({ userIds, embedding: asVector(embedding) }); - let runtimeConfig = 'SET LOCAL vectors.enable_prefilter=on; SET LOCAL vectors.search_mode=basic;'; - if (numResults) { - if (!isValidInteger(numResults, { min: 1 })) { - throw new Error(`Invalid value for 'numResults': ${numResults}`); - } - const limit = Math.max(numResults, 64); - cte = cte.limit(limit); - // setting this too low messes with prefilter recall - runtimeConfig += ` SET LOCAL vectors.hnsw_ef_search = ${limit}`; - } + cte.limit(numResults); if (hasPerson) { - cte = cte.andWhere('faces."personId" IS NOT NULL'); + cte.andWhere('faces."personId" IS NOT NULL'); } - this.faceColumns.forEach((col) => cte.addSelect(`faces.${col}`, col)); + for (const col of this.faceColumns) { + cte.addSelect(`faces.${col}`, col); + } - await manager.query(runtimeConfig); + await manager.query(this.getRuntimeConfig(numResults)); results = await manager .createQueryBuilder() .select('res.*') @@ -167,7 +166,6 @@ export class SearchRepository implements ISearchRepository { .where('res.distance <= :maxDistance', { maxDistance }) .getRawMany(); }); - return results.map((row) => ({ face: this.assetFaceRepository.create(row), distance: row.distance, diff --git a/server/src/infra/sql-generator/index.ts b/server/src/infra/sql-generator/index.ts index d95d07d9abd2e..0b10c018c12ef 100644 --- a/server/src/infra/sql-generator/index.ts +++ b/server/src/infra/sql-generator/index.ts @@ -142,7 +142,7 @@ class SqlGenerator { this.sqlLogger.clear(); // errors still generate sql, which is all we care about - await target.apply(instance, params).catch((err: Error) => console.error(`${queryLabel} error: ${err}`)); + await target.apply(instance, params).catch((error: Error) => console.error(`${queryLabel} error: ${error}`)); if (this.sqlLogger.queries.length === 0) { console.warn(`No queries recorded for ${queryLabel}`); From 86ca069b9d34e46a33fb434272a0ed5f1cc8bd5e Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Sat, 10 Feb 2024 20:33:11 -0500 Subject: [PATCH 31/38] update sql --- server/src/infra/sql/search.repository.sql | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/infra/sql/search.repository.sql b/server/src/infra/sql/search.repository.sql index 974e130e92863..538a85409441f 100644 --- a/server/src/infra/sql/search.repository.sql +++ b/server/src/infra/sql/search.repository.sql @@ -197,10 +197,10 @@ SET LOCAL vectors.enable_prefilter = on; SET - LOCAL vectors.search_mode = basic; + LOCAL vectors.search_mode = vbase; SET - LOCAL vectors.hnsw_ef_search = 100 + LOCAL vectors.hnsw_ef_search = 100; WITH "cte" AS ( SELECT From 436f5254d588fe133df2a55e3d2ab1c023d232d7 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Sun, 11 Feb 2024 17:00:03 -0500 Subject: [PATCH 32/38] fixes --- server/src/domain/search/dto/search.dto.ts | 1 + server/src/domain/search/search.service.ts | 1 - web/src/routes/(user)/search/+page.svelte | 3 --- 3 files changed, 1 insertion(+), 4 deletions(-) diff --git a/server/src/domain/search/dto/search.dto.ts b/server/src/domain/search/dto/search.dto.ts index 246a066811eed..a4e0396688c5c 100644 --- a/server/src/domain/search/dto/search.dto.ts +++ b/server/src/domain/search/dto/search.dto.ts @@ -10,6 +10,7 @@ class BaseSearchDto { libraryId?: string; @IsString() + @IsNotEmpty() @Optional() deviceId?: string; diff --git a/server/src/domain/search/search.service.ts b/server/src/domain/search/search.service.ts index 6ceb9c5d5f8c4..1438dc3be3fa2 100644 --- a/server/src/domain/search/search.service.ts +++ b/server/src/domain/search/search.service.ts @@ -80,7 +80,6 @@ export class SearchService { } async searchSmart(auth: AuthDto, dto: SmartSearchDto): Promise { - await this.configCore.requireFeature(FeatureFlag.SEARCH); await this.configCore.requireFeature(FeatureFlag.SMART_SEARCH); const { machineLearning } = await this.configCore.getConfig(); const userIds = await this.getUserIdsToSearch(auth); diff --git a/web/src/routes/(user)/search/+page.svelte b/web/src/routes/(user)/search/+page.svelte index fb7c3f094c190..18cfb899eca0a 100644 --- a/web/src/routes/(user)/search/+page.svelte +++ b/web/src/routes/(user)/search/+page.svelte @@ -112,7 +112,6 @@ }; export const loadNextPage = async () => { - console.log('loadPage', curPage, term); if (curPage == null || !term) { return; } @@ -120,9 +119,7 @@ await authenticate(); let results: SearchResponseDto | null = null; $page.url.searchParams.set('page', curPage.toString()); - console.log('searchParams', $page.url.searchParams.toString()); const res = await api.searchApi.search({}, { params: $page.url.searchParams }); - console.log('searchResultAssets', searchResultAssets); if (searchResultAssets) { searchResultAssets.push(...res.data.assets.items); } else { From 415cbc25b780027fe91c67565265f3b419f9676a Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Sun, 11 Feb 2024 17:53:43 -0500 Subject: [PATCH 33/38] optimize web code --- .../gallery-viewer/gallery-viewer.svelte | 23 +++++-------------- web/src/routes/(user)/search/+page.ts | 13 ++++++----- 2 files changed, 13 insertions(+), 23 deletions(-) diff --git a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte index 647a39908f1f8..e83e05319d824 100644 --- a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte +++ b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte @@ -8,6 +8,9 @@ import { getThumbnailSize } from '$lib/utils/thumbnail-util'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { onDestroy } from 'svelte'; + import { createEventDispatcher } from 'svelte'; + + const dispatch = createEventDispatcher<{ intersect: void }>(); export let assets: AssetResponseDto[]; export let selectedAssets: Set = new Set(); @@ -87,22 +90,7 @@ {#if assets.length > 0}
- {#each assets.slice(0, -1) as asset (asset.id)} -
- (isMultiSelectionMode ? selectAssetHandler(e) : viewAssetHandler(e))} - on:select={selectAssetHandler} - selected={selectedAssets.has(asset)} - {showArchiveIcon} - /> -
- {/each} - - {#each assets.slice(-1) as asset (asset.id)} + {#each assets as asset, i (asset.id)}
(isMultiSelectionMode ? selectAssetHandler(e) : viewAssetHandler(e))} on:select={selectAssetHandler} - on:intersected + on:intersected={(event) => + i === Math.max(1, assets.length - 7) ? dispatch('intersected', event.detail) : undefined} selected={selectedAssets.has(asset)} {showArchiveIcon} /> diff --git a/web/src/routes/(user)/search/+page.ts b/web/src/routes/(user)/search/+page.ts index 37a502a2b6cb9..75b9df487f1e7 100644 --- a/web/src/routes/(user)/search/+page.ts +++ b/web/src/routes/(user)/search/+page.ts @@ -11,12 +11,13 @@ export const load = (async (data) => { let results: SearchResponseDto | null = null; if (term) { const res = await api.searchApi.search({}, { params: data.url.searchParams }); - const assetItems: AssetResponseDto[] = (data as unknown as { results: SearchResponseDto }).results?.assets.items; - console.log('assetItems', assetItems); - const assets = { - ...res.data.assets, - items: assetItems ? assetItems.concat(res.data.assets.items) : res.data.assets.items, - }; + let items: AssetResponseDto[] = (data as unknown as { results: SearchResponseDto }).results?.assets.items; + if (items) { + items.push(...res.data.assets.items); + } else { + items = res.data.assets.items; + } + const assets = { ...res.data.assets, items }; results = { assets, albums: res.data.albums, From 9e561766342b9e246fa434f1585557092934bf81 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Sun, 11 Feb 2024 17:57:42 -0500 Subject: [PATCH 34/38] fix typing --- .../shared-components/gallery-viewer/gallery-viewer.svelte | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte index e83e05319d824..a3bc54c45430b 100644 --- a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte +++ b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte @@ -9,8 +9,9 @@ import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { onDestroy } from 'svelte'; import { createEventDispatcher } from 'svelte'; + import type { BucketPosition } from '$lib/stores/assets.store'; - const dispatch = createEventDispatcher<{ intersect: void }>(); + const dispatch = createEventDispatcher<{ intersected: { container: HTMLDivElement; position: BucketPosition } }>(); export let assets: AssetResponseDto[]; export let selectedAssets: Set = new Set(); From 3d66b0b303eaec90cfa17df6a7e1a4a1bf21b229 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Mon, 12 Feb 2024 19:22:39 -0500 Subject: [PATCH 35/38] add page limit --- web/src/routes/(user)/search/+page.svelte | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web/src/routes/(user)/search/+page.svelte b/web/src/routes/(user)/search/+page.svelte index 18cfb899eca0a..14701543d1506 100644 --- a/web/src/routes/(user)/search/+page.svelte +++ b/web/src/routes/(user)/search/+page.svelte @@ -32,7 +32,9 @@ export let data: PageData; + const MAX_PAGE_COUNT = 50; let { isViewing: showAssetViewer } = assetViewingStore; + let pageCount = 1; // The GalleryViewer pushes it's own history state, which causes weird // behavior for history.back(). To prevent that we store the previous page @@ -112,7 +114,7 @@ }; export const loadNextPage = async () => { - if (curPage == null || !term) { + if (curPage == null || !term || pageCount >= MAX_PAGE_COUNT) { return; } @@ -136,6 +138,7 @@ }; data.results = results; + pageCount++; }; From 935bc9c07bac6f6daf2c36f1a313a0c086c216e7 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Mon, 12 Feb 2024 19:27:08 -0500 Subject: [PATCH 36/38] make limit based on asset count --- web/src/routes/(user)/search/+page.svelte | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/web/src/routes/(user)/search/+page.svelte b/web/src/routes/(user)/search/+page.svelte index 14701543d1506..5bb18984d17ad 100644 --- a/web/src/routes/(user)/search/+page.svelte +++ b/web/src/routes/(user)/search/+page.svelte @@ -32,9 +32,8 @@ export let data: PageData; - const MAX_PAGE_COUNT = 50; + const MAX_ASSET_COUNT = 1000; let { isViewing: showAssetViewer } = assetViewingStore; - let pageCount = 1; // The GalleryViewer pushes it's own history state, which causes weird // behavior for history.back(). To prevent that we store the previous page @@ -114,7 +113,7 @@ }; export const loadNextPage = async () => { - if (curPage == null || !term || pageCount >= MAX_PAGE_COUNT) { + if (curPage == null || !term || (searchResultAssets && searchResultAssets.length >= MAX_ASSET_COUNT)) { return; } @@ -138,7 +137,6 @@ }; data.results = results; - pageCount++; }; From 1dbc3bc2ccab06798f287e833a9d0f0dccf613f9 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Mon, 12 Feb 2024 19:27:39 -0500 Subject: [PATCH 37/38] increase limit --- web/src/routes/(user)/search/+page.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/routes/(user)/search/+page.svelte b/web/src/routes/(user)/search/+page.svelte index 5bb18984d17ad..b9a58edc811f1 100644 --- a/web/src/routes/(user)/search/+page.svelte +++ b/web/src/routes/(user)/search/+page.svelte @@ -32,7 +32,7 @@ export let data: PageData; - const MAX_ASSET_COUNT = 1000; + const MAX_ASSET_COUNT = 5000; let { isViewing: showAssetViewer } = assetViewingStore; // The GalleryViewer pushes it's own history state, which causes weird From bf879db563acbce9ec62b94586b33e90c171a36c Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Mon, 12 Feb 2024 20:18:13 -0500 Subject: [PATCH 38/38] simpler import --- web/src/routes/(user)/search/+page.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/routes/(user)/search/+page.svelte b/web/src/routes/(user)/search/+page.svelte index b9a58edc811f1..ea068a71377aa 100644 --- a/web/src/routes/(user)/search/+page.svelte +++ b/web/src/routes/(user)/search/+page.svelte @@ -28,7 +28,7 @@ import { mdiArrowLeft, mdiDotsVertical, mdiImageOffOutline, mdiPlus, mdiSelectAll } from '@mdi/js'; import type { AssetResponseDto, SearchResponseDto } from '@immich/sdk'; import { authenticate } from '$lib/utils/auth'; - import { api } from '../../../api/api'; + import { api } from '@api'; export let data: PageData;