diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 67fbb14ee0e9d..3927de3c7d77b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -213,7 +213,7 @@ jobs: runs-on: ubuntu-latest services: postgres: - image: postgres@sha256:6dfee32131933ab4ca25a00360c3f427fdc134de56f9a90c6c9a4956b48aea85 + image: tensorchord/pgvecto-rs:pg14-v0.1.11 env: POSTGRES_PASSWORD: postgres POSTGRES_USER: postgres @@ -261,6 +261,8 @@ jobs: - name: Run SQL generation run: npm run sql:generate + env: + DB_URL: postgres://postgres:postgres@localhost:5432/immich - name: Find file changes uses: tj-actions/verify-changed-files@v13.1 diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index aaf97672e2f76..b6fb87e1df775 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -14565,22 +14565,12 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio * @param {string} [query] * @param {boolean} [clip] * @param {'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER'} [type] - * @param {boolean} [isFavorite] - * @param {boolean} [isArchived] - * @param {string} [exifInfoCity] - * @param {string} [exifInfoState] - * @param {string} [exifInfoCountry] - * @param {string} [exifInfoMake] - * @param {string} [exifInfoModel] - * @param {string} [exifInfoProjectionType] - * @param {Array} [smartInfoObjects] - * @param {Array} [smartInfoTags] * @param {boolean} [recent] * @param {boolean} [motion] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - search: async (q?: string, query?: string, clip?: boolean, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, isArchived?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, exifInfoProjectionType?: string, smartInfoObjects?: Array, smartInfoTags?: Array, recent?: boolean, motion?: boolean, options: AxiosRequestConfig = {}): Promise => { + search: async (q?: string, query?: string, clip?: boolean, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', recent?: boolean, motion?: boolean, options: AxiosRequestConfig = {}): Promise => { const localVarPath = `/search`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); @@ -14618,46 +14608,6 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio localVarQueryParameter['type'] = type; } - if (isFavorite !== undefined) { - localVarQueryParameter['isFavorite'] = isFavorite; - } - - if (isArchived !== undefined) { - localVarQueryParameter['isArchived'] = isArchived; - } - - if (exifInfoCity !== undefined) { - localVarQueryParameter['exifInfo.city'] = exifInfoCity; - } - - if (exifInfoState !== undefined) { - localVarQueryParameter['exifInfo.state'] = exifInfoState; - } - - if (exifInfoCountry !== undefined) { - localVarQueryParameter['exifInfo.country'] = exifInfoCountry; - } - - if (exifInfoMake !== undefined) { - localVarQueryParameter['exifInfo.make'] = exifInfoMake; - } - - if (exifInfoModel !== undefined) { - localVarQueryParameter['exifInfo.model'] = exifInfoModel; - } - - if (exifInfoProjectionType !== undefined) { - localVarQueryParameter['exifInfo.projectionType'] = exifInfoProjectionType; - } - - if (smartInfoObjects) { - localVarQueryParameter['smartInfo.objects'] = smartInfoObjects; - } - - if (smartInfoTags) { - localVarQueryParameter['smartInfo.tags'] = smartInfoTags; - } - if (recent !== undefined) { localVarQueryParameter['recent'] = recent; } @@ -14752,23 +14702,13 @@ export const SearchApiFp = function(configuration?: Configuration) { * @param {string} [query] * @param {boolean} [clip] * @param {'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER'} [type] - * @param {boolean} [isFavorite] - * @param {boolean} [isArchived] - * @param {string} [exifInfoCity] - * @param {string} [exifInfoState] - * @param {string} [exifInfoCountry] - * @param {string} [exifInfoMake] - * @param {string} [exifInfoModel] - * @param {string} [exifInfoProjectionType] - * @param {Array} [smartInfoObjects] - * @param {Array} [smartInfoTags] * @param {boolean} [recent] * @param {boolean} [motion] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async search(q?: string, query?: string, clip?: boolean, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, isArchived?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, exifInfoProjectionType?: string, smartInfoObjects?: Array, smartInfoTags?: Array, recent?: boolean, motion?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.search(q, query, clip, type, isFavorite, isArchived, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, exifInfoProjectionType, smartInfoObjects, smartInfoTags, recent, motion, options); + async search(q?: string, query?: string, clip?: boolean, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', recent?: boolean, motion?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.search(q, query, clip, type, recent, motion, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** @@ -14807,7 +14747,7 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat * @throws {RequiredError} */ search(requestParameters: SearchApiSearchRequest = {}, options?: AxiosRequestConfig): AxiosPromise { - return localVarFp.search(requestParameters.q, requestParameters.query, requestParameters.clip, requestParameters.type, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.exifInfoCity, requestParameters.exifInfoState, requestParameters.exifInfoCountry, requestParameters.exifInfoMake, requestParameters.exifInfoModel, requestParameters.exifInfoProjectionType, requestParameters.smartInfoObjects, requestParameters.smartInfoTags, requestParameters.recent, requestParameters.motion, options).then((request) => request(axios, basePath)); + return localVarFp.search(requestParameters.q, requestParameters.query, requestParameters.clip, requestParameters.type, requestParameters.recent, requestParameters.motion, options).then((request) => request(axios, basePath)); }, /** * @@ -14855,76 +14795,6 @@ export interface SearchApiSearchRequest { */ readonly type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER' - /** - * - * @type {boolean} - * @memberof SearchApiSearch - */ - readonly isFavorite?: boolean - - /** - * - * @type {boolean} - * @memberof SearchApiSearch - */ - readonly isArchived?: boolean - - /** - * - * @type {string} - * @memberof SearchApiSearch - */ - readonly exifInfoCity?: string - - /** - * - * @type {string} - * @memberof SearchApiSearch - */ - readonly exifInfoState?: string - - /** - * - * @type {string} - * @memberof SearchApiSearch - */ - readonly exifInfoCountry?: string - - /** - * - * @type {string} - * @memberof SearchApiSearch - */ - readonly exifInfoMake?: string - - /** - * - * @type {string} - * @memberof SearchApiSearch - */ - readonly exifInfoModel?: string - - /** - * - * @type {string} - * @memberof SearchApiSearch - */ - readonly exifInfoProjectionType?: string - - /** - * - * @type {Array} - * @memberof SearchApiSearch - */ - readonly smartInfoObjects?: Array - - /** - * - * @type {Array} - * @memberof SearchApiSearch - */ - readonly smartInfoTags?: Array - /** * * @type {boolean} @@ -14986,7 +14856,7 @@ export class SearchApi extends BaseAPI { * @memberof SearchApi */ public search(requestParameters: SearchApiSearchRequest = {}, options?: AxiosRequestConfig) { - return SearchApiFp(this.configuration).search(requestParameters.q, requestParameters.query, requestParameters.clip, requestParameters.type, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.exifInfoCity, requestParameters.exifInfoState, requestParameters.exifInfoCountry, requestParameters.exifInfoMake, requestParameters.exifInfoModel, requestParameters.exifInfoProjectionType, requestParameters.smartInfoObjects, requestParameters.smartInfoTags, requestParameters.recent, requestParameters.motion, options).then((request) => request(this.axios, this.basePath)); + return SearchApiFp(this.configuration).search(requestParameters.q, requestParameters.query, requestParameters.clip, requestParameters.type, requestParameters.recent, requestParameters.motion, options).then((request) => request(this.axios, this.basePath)); } /** diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 247087aaffc07..8b30484ba2537 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -37,7 +37,6 @@ services: depends_on: - redis - database - - typesense immich-microservices: container_name: immich_microservices @@ -51,7 +50,6 @@ services: depends_on: - database - immich-server - - typesense immich-web: container_name: immich_web @@ -95,24 +93,13 @@ services: - database restart: unless-stopped - typesense: - container_name: immich_typesense - image: typesense/typesense:0.24.1@sha256:9bcff2b829f12074426ca044b56160ca9d777a0c488303469143dd9f8259d4dd - environment: - - TYPESENSE_API_KEY=${TYPESENSE_API_KEY} - - TYPESENSE_DATA_DIR=/data - # remove this to get debug messages - - GLOG_minloglevel=1 - volumes: - - ${UPLOAD_LOCATION}/typesense:/data - redis: container_name: immich_redis image: redis:6.2-alpine@sha256:60e49e22fa5706cd8df7d5e0bc50ee9bab7c608039fa653c4d961014237cca46 database: container_name: immich_postgres - image: postgres:14-alpine@sha256:6a0e35296341e676fe6bd8d236c72afffe2dfe3d7eb9c2405c0f3fc04500cd07 + image: tensorchord/pgvecto-rs:pg14-v0.1.11 env_file: - .env environment: diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml index 93bfc5f643a53..f9f012a4f833e 100644 --- a/docker/docker-compose.prod.yml +++ b/docker/docker-compose.prod.yml @@ -24,7 +24,6 @@ services: depends_on: - redis - database - - typesense immich-microservices: container_name: immich_microservices @@ -36,7 +35,6 @@ services: depends_on: - redis - database - - typesense - immich-server immich-machine-learning: @@ -51,18 +49,6 @@ services: - .env restart: always - typesense: - container_name: immich_typesense - image: typesense/typesense:0.24.1@sha256:9bcff2b829f12074426ca044b56160ca9d777a0c488303469143dd9f8259d4dd - environment: - - TYPESENSE_API_KEY=${TYPESENSE_API_KEY} - - TYPESENSE_DATA_DIR=/data - # remove this to get debug messages - - GLOG_minloglevel=1 - volumes: - - ${UPLOAD_LOCATION}/typesense:/data - restart: always - redis: container_name: immich_redis image: redis:6.2-alpine@sha256:60e49e22fa5706cd8df7d5e0bc50ee9bab7c608039fa653c4d961014237cca46 @@ -70,7 +56,7 @@ services: database: container_name: immich_postgres - image: postgres:14-alpine@sha256:6a0e35296341e676fe6bd8d236c72afffe2dfe3d7eb9c2405c0f3fc04500cd07 + image: tensorchord/pgvecto-rs:pg14-v0.1.11 env_file: - .env environment: diff --git a/docker/docker-compose.test.yml b/docker/docker-compose.test.yml index daa6524f80aa3..4ef2052ce249f 100644 --- a/docker/docker-compose.test.yml +++ b/docker/docker-compose.test.yml @@ -23,8 +23,7 @@ services: - database database: - image: postgres:14-alpine@sha256:6a0e35296341e676fe6bd8d236c72afffe2dfe3d7eb9c2405c0f3fc04500cd07 - command: -c fsync=off + image: tensorchord/pgvecto-rs:pg14-v0.1.11 environment: POSTGRES_PASSWORD: postgres POSTGRES_USER: postgres diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 9452975fb1610..9a9b87bf804c0 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -25,7 +25,6 @@ services: depends_on: - redis - database - - typesense restart: always immich-microservices: @@ -43,7 +42,6 @@ services: depends_on: - redis - database - - typesense restart: always immich-machine-learning: @@ -55,18 +53,6 @@ services: - .env restart: always - typesense: - container_name: immich_typesense - image: typesense/typesense:0.24.1@sha256:9bcff2b829f12074426ca044b56160ca9d777a0c488303469143dd9f8259d4dd - environment: - - TYPESENSE_API_KEY=${TYPESENSE_API_KEY} - - TYPESENSE_DATA_DIR=/data - # remove this to get debug messages - - GLOG_minloglevel=1 - volumes: - - tsdata:/data - restart: always - redis: container_name: immich_redis image: redis:6.2-alpine@sha256:60e49e22fa5706cd8df7d5e0bc50ee9bab7c608039fa653c4d961014237cca46 @@ -74,7 +60,7 @@ services: database: container_name: immich_postgres - image: postgres:14-alpine@sha256:6a0e35296341e676fe6bd8d236c72afffe2dfe3d7eb9c2405c0f3fc04500cd07 + image: tensorchord/pgvecto-rs:pg14-v0.1.11 env_file: - .env environment: @@ -88,4 +74,3 @@ services: volumes: pgdata: model-cache: - tsdata: diff --git a/docker/example.env b/docker/example.env index 319bb39c27c90..40d379f482a2c 100644 --- a/docker/example.env +++ b/docker/example.env @@ -6,8 +6,7 @@ UPLOAD_LOCATION=./library # The Immich version to use. You can pin this to a specific version like "v1.71.0" IMMICH_VERSION=release -# Connection secrets for postgres and typesense. You should change these to random passwords -TYPESENSE_API_KEY=some-random-text +# Connection secret for postgres. You should change it to a random password DB_PASSWORD=postgres # The values below this line do not need to be changed diff --git a/docs/docs/developer/architecture.md b/docs/docs/developer/architecture.md index 0ade082274f2d..c2f7d40391c43 100644 --- a/docs/docs/developer/architecture.md +++ b/docs/docs/developer/architecture.md @@ -45,7 +45,6 @@ The Immich backend is divided into several services, which are run as individual 1. `immich-machine-learning` - Execute machine learning models 1. `postgres` - Persistent data storage 1. `redis`- Queue management for `immich-microservices` -1. `typesense`- Specialized database for search, specifically with vector comparison features ### Immich Server @@ -75,7 +74,6 @@ The Immich Microservices image uses the same `Dockerfile` as the Immich Server, - Object Tagging - Facial Recognition - Storage Template Migration -- Search (Typesense synchronization) - Sidecar (see [XMP Sidecars](/docs/features/xmp-sidecars.md)) - Background jobs (file deletion, user deletion) @@ -108,9 +106,3 @@ See [Database Migrations](./database-migrations.md) for more information about h ### Redis Immich uses [Redis](https://redis.com/) via [BullMQ](https://docs.bullmq.io/) to manage job queues. Some jobs trigger subsequent jobs. For example, object detection relies on thumbnail generation and automatically run after one is generated. - -### Typesense - -Immich synchronizes some of the Postgres data into Typesense, so it can execute vector related queries in order to implement certain features including, facial recognition and CLIP search. - - diff --git a/docs/docs/features/search.md b/docs/docs/features/search.md index bbe0c3c2eb92f..2c5fa03a5c969 100644 --- a/docs/docs/features/search.md +++ b/docs/docs/features/search.md @@ -1,18 +1,10 @@ # Search -Immich uses Typesense as the primary search database to enable high performance search mechanism. +Immich uses Postgres as its search database for both metadata and smart search. -Typesense is a powerful search engine that can be integrated with popular natural language processing (NLP) models like CLIP and SBERT to provide highly accurate and relevant search results. Here are some benefits of using Typesense integrated search for CLIP and SBERT: +Smart search is powered by the [pgvecto.rs](https://github.com/tensorchord/pgvecto.rs) extension, utilizing machine learning models like CLIP to provide relevant search results. This allows for freeform searches without requiring specific keywords in the image or video metadata. -Improved Search Accuracy: Typesense uses a combination of indexing, querying, and ranking algorithms to quickly and accurately retrieve relevant search results. When integrated with CLIP and SBERT, Typesense can leverage the semantic understanding and deep learning capabilities of these models to further improve the accuracy of search results. - -Faster Search Response Times: Typesense is optimized for lightning-fast search response times, making it ideal for applications that require near-instantaneous search results. By integrating with CLIP and SBERT, Typesense can reduce the time required to process complex search queries, making it even faster and more efficient. - -Enhanced Semantic Search Capabilities: CLIP and SBERT are powerful NLP models that can extract the semantic meaning from text, enabling more nuanced search queries. By integrating with Typesense, these models can help to improve the accuracy of semantic search, enabling users to find the most relevant results based on the true meaning of their query. - -Greater Search Flexibility: Typesense provides flexible search capabilities, including fuzzy search, partial search, enabling users to find the information they need quickly and easily. When integrated with CLIP and SBERT, Typesense can offer even greater flexibility, allowing users to refine their search queries using natural language and providing more accurate and relevant results. - -(Generated by Chat-GPT4) +Metadata search (prefixed with `m:`) can search specifically by text without the use of a model. Some search examples: diff --git a/docs/docs/install/docker-compose.md b/docs/docs/install/docker-compose.md index 58e13a95adaea..de51c4eb820c9 100644 --- a/docs/docs/install/docker-compose.md +++ b/docs/docs/install/docker-compose.md @@ -88,15 +88,6 @@ UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_ba LOG_LEVEL=simple -################################################################################### -# Typesense -################################################################################### -# TYPESENSE_ENABLED=false -TYPESENSE_API_KEY=some-random-text -# TYPESENSE_HOST: typesense -# TYPESENSE_PORT: 8108 -# TYPESENSE_PROTOCOL: http - ################################################################################### # Reverse Geocoding # @@ -137,7 +128,6 @@ PUBLIC_LOGIN_PAGE_MESSAGE="My Family Photos and Videos Backup Server" - Populate custom database information if necessary. - Populate `UPLOAD_LOCATION` with your preferred location for storing backup assets. - Consider changing `DB_PASSWORD` to something randomly generated -- Consider changing `TYPESENSE_API_KEY` to something randomly generated ### Step 3 - Start the containers diff --git a/docs/docs/install/environment-variables.md b/docs/docs/install/environment-variables.md index 025568a67fb32..2972f5fb38563 100644 --- a/docs/docs/install/environment-variables.md +++ b/docs/docs/install/environment-variables.md @@ -17,10 +17,10 @@ If this should not work, try running `docker compose up -d --force-recreate`. ## Docker Compose -| Variable | Description | Default | Services | -| :---------------- | :-------------------- | :-------: | :------------------------------------------------------------- | -| `IMMICH_VERSION` | Image tags | `release` | server, microservices, machine learning, web, proxy, typesense | -| `UPLOAD_LOCATION` | Host Path for uploads | | server, microservices | +| Variable | Description | Default | Services | +| :---------------- | :-------------------- | :-------: | :-------------------------------------------------- | +| `IMMICH_VERSION` | Image tags | `release` | server, microservices, machine learning, web, proxy | +| `UPLOAD_LOCATION` | Host Path for uploads | | server, microservices | :::tip @@ -124,51 +124,6 @@ Redis (Sentinel) URL example JSON before encoding: } ``` -## Typesense - -| Variable | Description | Default | Services | -| :------------------- | :----------------------- | :---------: | :------------------------------- | -| `TYPESENSE_ENABLED` | Enable Typesense | | server, microservices | -| `TYPESENSE_URL` | Typesense URL | | server, microservices | -| `TYPESENSE_HOST` | Typesense Host | `typesense` | server, microservices | -| `TYPESENSE_PORT` | Typesense Port | `8108` | server, microservices | -| `TYPESENSE_PROTOCOL` | Typesense Protocol | `http` | server, microservices | -| `TYPESENSE_API_KEY` | Typesense API Key | | server, microservices, typesense | -| `TYPESENSE_DATA_DIR` | Typesense Data Directory | `/data` | typesense | - -:::info - -`TYPESENSE_URL` must start with `ha://` and then include a `base64` encoded JSON string for the configuration. - -`TYPESENSE_ENABLED`: Anything other than `false`, behaves as `true`. -Even undefined is treated as `true`. - -- When `TYPESENSE_URL` is defined, the other typesense (`TYPESENSE_*`) variables are ignored. - -::: - -Typesense URL example JSON before encoding: - -```json -[ - { - "host": "typesense-1.example.net", - "port": "443", - "protocol": "https" - }, - { - "host": "typesense-2.example.net", - "port": "443", - "protocol": "https" - }, - { - "host": "typesense-3.example.net", - "port": "443", - "protocol": "https" - } -] -``` - ## Machine Learning | Variable | Description | Default | Services | diff --git a/mobile/openapi/doc/SearchApi.md b/mobile/openapi/doc/SearchApi.md index b5c6c9de8d84e..d70cfd75ea06c 100644 --- a/mobile/openapi/doc/SearchApi.md +++ b/mobile/openapi/doc/SearchApi.md @@ -66,7 +66,7 @@ This endpoint does not need any parameter. [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) # **search** -> SearchResponseDto search(q, query, clip, type, isFavorite, isArchived, exifInfoPeriodCity, exifInfoPeriodState, exifInfoPeriodCountry, exifInfoPeriodMake, exifInfoPeriodModel, exifInfoPeriodProjectionType, smartInfoPeriodObjects, smartInfoPeriodTags, recent, motion) +> SearchResponseDto search(q, query, clip, type, recent, motion) @@ -93,21 +93,11 @@ final q = q_example; // String | final query = query_example; // String | final clip = true; // bool | final type = type_example; // String | -final isFavorite = true; // bool | -final isArchived = true; // bool | -final exifInfoPeriodCity = exifInfoPeriodCity_example; // String | -final exifInfoPeriodState = exifInfoPeriodState_example; // String | -final exifInfoPeriodCountry = exifInfoPeriodCountry_example; // String | -final exifInfoPeriodMake = exifInfoPeriodMake_example; // String | -final exifInfoPeriodModel = exifInfoPeriodModel_example; // String | -final exifInfoPeriodProjectionType = exifInfoPeriodProjectionType_example; // String | -final smartInfoPeriodObjects = []; // List | -final smartInfoPeriodTags = []; // List | final recent = true; // bool | final motion = true; // bool | try { - final result = api_instance.search(q, query, clip, type, isFavorite, isArchived, exifInfoPeriodCity, exifInfoPeriodState, exifInfoPeriodCountry, exifInfoPeriodMake, exifInfoPeriodModel, exifInfoPeriodProjectionType, smartInfoPeriodObjects, smartInfoPeriodTags, recent, motion); + final result = api_instance.search(q, query, clip, type, recent, motion); print(result); } catch (e) { print('Exception when calling SearchApi->search: $e\n'); @@ -122,16 +112,6 @@ Name | Type | Description | Notes **query** | **String**| | [optional] **clip** | **bool**| | [optional] **type** | **String**| | [optional] - **isFavorite** | **bool**| | [optional] - **isArchived** | **bool**| | [optional] - **exifInfoPeriodCity** | **String**| | [optional] - **exifInfoPeriodState** | **String**| | [optional] - **exifInfoPeriodCountry** | **String**| | [optional] - **exifInfoPeriodMake** | **String**| | [optional] - **exifInfoPeriodModel** | **String**| | [optional] - **exifInfoPeriodProjectionType** | **String**| | [optional] - **smartInfoPeriodObjects** | [**List**](String.md)| | [optional] [default to const []] - **smartInfoPeriodTags** | [**List**](String.md)| | [optional] [default to const []] **recent** | **bool**| | [optional] **motion** | **bool**| | [optional] diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart index 7c01d5e9b9f29..c1b6a51f831a7 100644 --- a/mobile/openapi/lib/api/search_api.dart +++ b/mobile/openapi/lib/api/search_api.dart @@ -71,30 +71,10 @@ class SearchApi { /// /// * [String] type: /// - /// * [bool] isFavorite: - /// - /// * [bool] isArchived: - /// - /// * [String] exifInfoPeriodCity: - /// - /// * [String] exifInfoPeriodState: - /// - /// * [String] exifInfoPeriodCountry: - /// - /// * [String] exifInfoPeriodMake: - /// - /// * [String] exifInfoPeriodModel: - /// - /// * [String] exifInfoPeriodProjectionType: - /// - /// * [List] smartInfoPeriodObjects: - /// - /// * [List] smartInfoPeriodTags: - /// /// * [bool] recent: /// /// * [bool] motion: - Future searchWithHttpInfo({ String? q, String? query, bool? clip, String? type, bool? isFavorite, bool? isArchived, String? exifInfoPeriodCity, String? exifInfoPeriodState, String? exifInfoPeriodCountry, String? exifInfoPeriodMake, String? exifInfoPeriodModel, String? exifInfoPeriodProjectionType, List? smartInfoPeriodObjects, List? smartInfoPeriodTags, bool? recent, bool? motion, }) async { + Future searchWithHttpInfo({ String? q, String? query, bool? clip, String? type, bool? recent, bool? motion, }) async { // ignore: prefer_const_declarations final path = r'/search'; @@ -117,36 +97,6 @@ class SearchApi { if (type != null) { queryParams.addAll(_queryParams('', 'type', type)); } - if (isFavorite != null) { - queryParams.addAll(_queryParams('', 'isFavorite', isFavorite)); - } - if (isArchived != null) { - queryParams.addAll(_queryParams('', 'isArchived', isArchived)); - } - if (exifInfoPeriodCity != null) { - queryParams.addAll(_queryParams('', 'exifInfo.city', exifInfoPeriodCity)); - } - if (exifInfoPeriodState != null) { - queryParams.addAll(_queryParams('', 'exifInfo.state', exifInfoPeriodState)); - } - if (exifInfoPeriodCountry != null) { - queryParams.addAll(_queryParams('', 'exifInfo.country', exifInfoPeriodCountry)); - } - if (exifInfoPeriodMake != null) { - queryParams.addAll(_queryParams('', 'exifInfo.make', exifInfoPeriodMake)); - } - if (exifInfoPeriodModel != null) { - queryParams.addAll(_queryParams('', 'exifInfo.model', exifInfoPeriodModel)); - } - if (exifInfoPeriodProjectionType != null) { - queryParams.addAll(_queryParams('', 'exifInfo.projectionType', exifInfoPeriodProjectionType)); - } - if (smartInfoPeriodObjects != null) { - queryParams.addAll(_queryParams('multi', 'smartInfo.objects', smartInfoPeriodObjects)); - } - if (smartInfoPeriodTags != null) { - queryParams.addAll(_queryParams('multi', 'smartInfo.tags', smartInfoPeriodTags)); - } if (recent != null) { queryParams.addAll(_queryParams('', 'recent', recent)); } @@ -178,31 +128,11 @@ class SearchApi { /// /// * [String] type: /// - /// * [bool] isFavorite: - /// - /// * [bool] isArchived: - /// - /// * [String] exifInfoPeriodCity: - /// - /// * [String] exifInfoPeriodState: - /// - /// * [String] exifInfoPeriodCountry: - /// - /// * [String] exifInfoPeriodMake: - /// - /// * [String] exifInfoPeriodModel: - /// - /// * [String] exifInfoPeriodProjectionType: - /// - /// * [List] smartInfoPeriodObjects: - /// - /// * [List] smartInfoPeriodTags: - /// /// * [bool] recent: /// /// * [bool] motion: - Future search({ String? q, String? query, bool? clip, String? type, bool? isFavorite, bool? isArchived, String? exifInfoPeriodCity, String? exifInfoPeriodState, String? exifInfoPeriodCountry, String? exifInfoPeriodMake, String? exifInfoPeriodModel, String? exifInfoPeriodProjectionType, List? smartInfoPeriodObjects, List? smartInfoPeriodTags, bool? recent, bool? motion, }) async { - final response = await searchWithHttpInfo( q: q, query: query, clip: clip, type: type, isFavorite: isFavorite, isArchived: isArchived, exifInfoPeriodCity: exifInfoPeriodCity, exifInfoPeriodState: exifInfoPeriodState, exifInfoPeriodCountry: exifInfoPeriodCountry, exifInfoPeriodMake: exifInfoPeriodMake, exifInfoPeriodModel: exifInfoPeriodModel, exifInfoPeriodProjectionType: exifInfoPeriodProjectionType, smartInfoPeriodObjects: smartInfoPeriodObjects, smartInfoPeriodTags: smartInfoPeriodTags, recent: recent, motion: motion, ); + Future search({ String? q, String? query, bool? clip, String? type, bool? recent, bool? motion, }) async { + final response = await searchWithHttpInfo( q: q, query: query, clip: clip, type: type, recent: recent, motion: motion, ); 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 a2fba73e7d96a..09788ee449ad8 100644 --- a/mobile/openapi/test/search_api_test.dart +++ b/mobile/openapi/test/search_api_test.dart @@ -22,7 +22,7 @@ void main() { // TODO }); - //Future search({ String q, String query, bool clip, String type, bool isFavorite, bool isArchived, String exifInfoPeriodCity, String exifInfoPeriodState, String exifInfoPeriodCountry, String exifInfoPeriodMake, String exifInfoPeriodModel, String exifInfoPeriodProjectionType, List smartInfoPeriodObjects, List smartInfoPeriodTags, bool recent, bool motion }) async + //Future search({ String q, String query, bool clip, String type, bool recent, bool motion }) async test('test search', () async { // TODO }); diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index c3e10a2523b99..43ac6615bf33b 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -4567,92 +4567,6 @@ "type": "string" } }, - { - "name": "isFavorite", - "required": false, - "in": "query", - "schema": { - "type": "boolean" - } - }, - { - "name": "isArchived", - "required": false, - "in": "query", - "schema": { - "type": "boolean" - } - }, - { - "name": "exifInfo.city", - "required": false, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "exifInfo.state", - "required": false, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "exifInfo.country", - "required": false, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "exifInfo.make", - "required": false, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "exifInfo.model", - "required": false, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "exifInfo.projectionType", - "required": false, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "smartInfo.objects", - "required": false, - "in": "query", - "schema": { - "type": "array", - "items": { - "type": "string" - } - } - }, - { - "name": "smartInfo.tags", - "required": false, - "in": "query", - "schema": { - "type": "array", - "items": { - "type": "string" - } - } - }, { "name": "recent", "required": false, diff --git a/server/package-lock.json b/server/package-lock.json index 236d11fdcd059..d72ae38dd8fb6 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -23,6 +23,7 @@ "@nestjs/websockets": "^10.2.2", "@socket.io/redis-adapter": "^8.2.1", "archiver": "^6.0.0", + "async-lock": "^1.4.0", "axios": "^1.5.0", "bcrypt": "^5.1.1", "bullmq": "^4.8.0", @@ -50,7 +51,6 @@ "sharp": "^0.32.6", "thumbhash": "^0.1.1", "typeorm": "^0.3.17", - "typesense": "^1.7.1", "ua-parser-js": "^1.0.35" }, "devDependencies": { @@ -60,6 +60,7 @@ "@openapitools/openapi-generator-cli": "2.7.0", "@testcontainers/postgresql": "^10.2.1", "@types/archiver": "^6.0.0", + "@types/async-lock": "^1.4.2", "@types/bcrypt": "^5.0.0", "@types/cookie-parser": "^1.4.3", "@types/cron": "^2.0.1", @@ -357,12 +358,12 @@ } }, "node_modules/@babel/generator": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.0.tgz", - "integrity": "sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.3.tgz", + "integrity": "sha512-keeZWAV4LU3tW0qRi19HRpabC/ilM0HRBBzf9/k8FFiG4KVpiv0FIy4hHfLfFQZNhziCTPTmd59zoyv6DNISzg==", "dev": true, "dependencies": { - "@babel/types": "^7.23.0", + "@babel/types": "^7.23.3", "@jridgewell/gen-mapping": "^0.3.2", "@jridgewell/trace-mapping": "^0.3.17", "jsesc": "^2.5.1" @@ -612,9 +613,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.0.tgz", - "integrity": "sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.3.tgz", + "integrity": "sha512-uVsWNvlVsIninV2prNz/3lHCb+5CJ+e+IUBfbjToAHODtfGYLfCFuY4AU7TskI+dAKk+njsPiBjq1gKTvZOBaw==", "dev": true, "bin": { "parser": "bin/babel-parser.js" @@ -826,19 +827,19 @@ } }, "node_modules/@babel/traverse": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.0.tgz", - "integrity": "sha512-t/QaEvyIoIkwzpiZ7aoSKK8kObQYeF7T2v+dazAYCb8SXtp58zEVkWW7zAnju8FNKNdr4ScAOEDmMItbyOmEYw==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.3.tgz", + "integrity": "sha512-+K0yF1/9yR0oHdE0StHuEj3uTPzwwbrLGfNOndVJVV2TqA5+j3oljJUb4nmB954FLGjNem976+B+eDuLIjesiQ==", "dev": true, "dependencies": { "@babel/code-frame": "^7.22.13", - "@babel/generator": "^7.23.0", + "@babel/generator": "^7.23.3", "@babel/helper-environment-visitor": "^7.22.20", "@babel/helper-function-name": "^7.23.0", "@babel/helper-hoist-variables": "^7.22.5", "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.23.0", - "@babel/types": "^7.23.0", + "@babel/parser": "^7.23.3", + "@babel/types": "^7.23.3", "debug": "^4.1.0", "globals": "^11.1.0" }, @@ -856,9 +857,9 @@ } }, "node_modules/@babel/types": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz", - "integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.3.tgz", + "integrity": "sha512-OZnvoH2l8PK5eUvEcUyCt/sXgr/h+UWpVuBbOljwcrAgUl6lpchoQ++PHGyQy1AtYnVA6CEq3y5xeEI10brpXw==", "dev": true, "dependencies": { "@babel/helper-string-parser": "^7.22.5", @@ -2798,6 +2799,12 @@ "@types/readdir-glob": "*" } }, + "node_modules/@types/async-lock": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@types/async-lock/-/async-lock-1.4.2.tgz", + "integrity": "sha512-HlZ6Dcr205BmNhwkdXqrg2vkFMN2PluI7Lgr8In3B3wE5PiQHhjRqtW/lGdVU9gw+sM0JcIDx2AN+cW8oSWIcw==", + "dev": true + }, "node_modules/@types/babel__core": { "version": "7.20.2", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.2.tgz", @@ -3938,8 +3945,7 @@ "node_modules/async-lock": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.0.tgz", - "integrity": "sha512-coglx5yIWuetakm3/1dsX9hxCNox22h7+V80RQOu2XUUMidtArxKoZoOtHUPuR84SycKTXzgGzAUR5hJxujyJQ==", - "dev": true + "integrity": "sha512-coglx5yIWuetakm3/1dsX9hxCNox22h7+V80RQOu2XUUMidtArxKoZoOtHUPuR84SycKTXzgGzAUR5hJxujyJQ==" }, "node_modules/asynckit": { "version": "0.4.0", @@ -8458,18 +8464,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/loglevel": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.8.1.tgz", - "integrity": "sha512-tCRIJM51SHjAayKwC+QAg8hT8vg6z7GSgLJKGvzuPb1Wc+hLzqtuVLxp6/HzSPOozuK+8ErAhy7U/sVzw8Dgfg==", - "engines": { - "node": ">= 0.6.0" - }, - "funding": { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/loglevel" - } - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -12175,29 +12169,6 @@ "node": ">=14.17" } }, - "node_modules/typesense": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/typesense/-/typesense-1.7.2.tgz", - "integrity": "sha512-hgQESOiyNJq+w2mpRJa/a1UMhWtJ/+sb0p7NoeCDSkikm9sasisJdnc7uhQchM6vTWKw2sMLWUBNbAhItR6zUQ==", - "dependencies": { - "axios": "^0.26.0", - "loglevel": "^1.8.0" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@babel/runtime": "^7.17.2" - } - }, - "node_modules/typesense/node_modules/axios": { - "version": "0.26.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.26.1.tgz", - "integrity": "sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==", - "dependencies": { - "follow-redirects": "^1.14.8" - } - }, "node_modules/ua-parser-js": { "version": "1.0.37", "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.37.tgz", @@ -12999,12 +12970,12 @@ } }, "@babel/generator": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.0.tgz", - "integrity": "sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.3.tgz", + "integrity": "sha512-keeZWAV4LU3tW0qRi19HRpabC/ilM0HRBBzf9/k8FFiG4KVpiv0FIy4hHfLfFQZNhziCTPTmd59zoyv6DNISzg==", "dev": true, "requires": { - "@babel/types": "^7.23.0", + "@babel/types": "^7.23.3", "@jridgewell/gen-mapping": "^0.3.2", "@jridgewell/trace-mapping": "^0.3.17", "jsesc": "^2.5.1" @@ -13192,9 +13163,9 @@ } }, "@babel/parser": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.0.tgz", - "integrity": "sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.3.tgz", + "integrity": "sha512-uVsWNvlVsIninV2prNz/3lHCb+5CJ+e+IUBfbjToAHODtfGYLfCFuY4AU7TskI+dAKk+njsPiBjq1gKTvZOBaw==", "dev": true }, "@babel/plugin-syntax-async-generators": { @@ -13343,19 +13314,19 @@ } }, "@babel/traverse": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.0.tgz", - "integrity": "sha512-t/QaEvyIoIkwzpiZ7aoSKK8kObQYeF7T2v+dazAYCb8SXtp58zEVkWW7zAnju8FNKNdr4ScAOEDmMItbyOmEYw==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.3.tgz", + "integrity": "sha512-+K0yF1/9yR0oHdE0StHuEj3uTPzwwbrLGfNOndVJVV2TqA5+j3oljJUb4nmB954FLGjNem976+B+eDuLIjesiQ==", "dev": true, "requires": { "@babel/code-frame": "^7.22.13", - "@babel/generator": "^7.23.0", + "@babel/generator": "^7.23.3", "@babel/helper-environment-visitor": "^7.22.20", "@babel/helper-function-name": "^7.23.0", "@babel/helper-hoist-variables": "^7.22.5", "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.23.0", - "@babel/types": "^7.23.0", + "@babel/parser": "^7.23.3", + "@babel/types": "^7.23.3", "debug": "^4.1.0", "globals": "^11.1.0" }, @@ -13369,9 +13340,9 @@ } }, "@babel/types": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.0.tgz", - "integrity": "sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==", + "version": "7.23.3", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.3.tgz", + "integrity": "sha512-OZnvoH2l8PK5eUvEcUyCt/sXgr/h+UWpVuBbOljwcrAgUl6lpchoQ++PHGyQy1AtYnVA6CEq3y5xeEI10brpXw==", "dev": true, "requires": { "@babel/helper-string-parser": "^7.22.5", @@ -14778,6 +14749,12 @@ "@types/readdir-glob": "*" } }, + "@types/async-lock": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@types/async-lock/-/async-lock-1.4.2.tgz", + "integrity": "sha512-HlZ6Dcr205BmNhwkdXqrg2vkFMN2PluI7Lgr8In3B3wE5PiQHhjRqtW/lGdVU9gw+sM0JcIDx2AN+cW8oSWIcw==", + "dev": true + }, "@types/babel__core": { "version": "7.20.2", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.2.tgz", @@ -15750,8 +15727,7 @@ "async-lock": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.0.tgz", - "integrity": "sha512-coglx5yIWuetakm3/1dsX9hxCNox22h7+V80RQOu2XUUMidtArxKoZoOtHUPuR84SycKTXzgGzAUR5hJxujyJQ==", - "dev": true + "integrity": "sha512-coglx5yIWuetakm3/1dsX9hxCNox22h7+V80RQOu2XUUMidtArxKoZoOtHUPuR84SycKTXzgGzAUR5hJxujyJQ==" }, "asynckit": { "version": "0.4.0", @@ -19092,11 +19068,6 @@ "is-unicode-supported": "^0.1.0" } }, - "loglevel": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.8.1.tgz", - "integrity": "sha512-tCRIJM51SHjAayKwC+QAg8hT8vg6z7GSgLJKGvzuPb1Wc+hLzqtuVLxp6/HzSPOozuK+8ErAhy7U/sVzw8Dgfg==" - }, "lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -21789,25 +21760,6 @@ "integrity": "sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==", "devOptional": true }, - "typesense": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/typesense/-/typesense-1.7.2.tgz", - "integrity": "sha512-hgQESOiyNJq+w2mpRJa/a1UMhWtJ/+sb0p7NoeCDSkikm9sasisJdnc7uhQchM6vTWKw2sMLWUBNbAhItR6zUQ==", - "requires": { - "axios": "^0.26.0", - "loglevel": "^1.8.0" - }, - "dependencies": { - "axios": { - "version": "0.26.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.26.1.tgz", - "integrity": "sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==", - "requires": { - "follow-redirects": "^1.14.8" - } - } - } - }, "ua-parser-js": { "version": "1.0.37", "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.37.tgz", diff --git a/server/package.json b/server/package.json index d1e87c28d810f..c801aac72ee2a 100644 --- a/server/package.json +++ b/server/package.json @@ -50,6 +50,7 @@ "@nestjs/websockets": "^10.2.2", "@socket.io/redis-adapter": "^8.2.1", "archiver": "^6.0.0", + "async-lock": "^1.4.0", "axios": "^1.5.0", "bcrypt": "^5.1.1", "bullmq": "^4.8.0", @@ -77,7 +78,6 @@ "sharp": "^0.32.6", "thumbhash": "^0.1.1", "typeorm": "^0.3.17", - "typesense": "^1.7.1", "ua-parser-js": "^1.0.35" }, "devDependencies": { @@ -87,6 +87,7 @@ "@openapitools/openapi-generator-cli": "2.7.0", "@testcontainers/postgresql": "^10.2.1", "@types/archiver": "^6.0.0", + "@types/async-lock": "^1.4.2", "@types/bcrypt": "^5.0.0", "@types/cookie-parser": "^1.4.3", "@types/cron": "^2.0.1", diff --git a/server/src/domain/album/album.service.spec.ts b/server/src/domain/album/album.service.spec.ts index 9a4614c79268d..7fc6344ea6ce6 100644 --- a/server/src/domain/album/album.service.spec.ts +++ b/server/src/domain/album/album.service.spec.ts @@ -12,7 +12,6 @@ import { } from '@test'; import _ from 'lodash'; import { BulkIdErrorReason } from '../asset'; -import { JobName } from '../job'; import { IAlbumRepository, IAssetRepository, IJobRepository, IUserRepository } from '../repositories'; import { AlbumService } from './album.service'; @@ -188,11 +187,6 @@ describe(AlbumService.name, () => { assetIds: ['123'], }); - expect(jobMock.queue).toHaveBeenCalledWith({ - name: JobName.SEARCH_INDEX_ALBUM, - data: { ids: [albumStub.empty.id] }, - }); - expect(albumMock.create).toHaveBeenCalledWith({ ownerId: authStub.admin.id, albumName: albumStub.empty.albumName, @@ -270,10 +264,6 @@ describe(AlbumService.name, () => { id: 'album-4', albumName: 'new album name', }); - expect(jobMock.queue).toHaveBeenCalledWith({ - name: JobName.SEARCH_INDEX_ALBUM, - data: { ids: [albumStub.oneAsset.id] }, - }); }); }); diff --git a/server/src/domain/album/album.service.ts b/server/src/domain/album/album.service.ts index 308735d43c945..74f5da7fc2eb3 100644 --- a/server/src/domain/album/album.service.ts +++ b/server/src/domain/album/album.service.ts @@ -4,7 +4,6 @@ import { AccessCore, Permission } from '../access'; import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from '../asset'; import { AuthUserDto } from '../auth'; import { setUnion } from '../domain.util'; -import { JobName } from '../job'; import { AlbumAssetCount, AlbumInfoOptions, @@ -131,7 +130,6 @@ export class AlbumService { albumThumbnailAssetId: dto.assetIds?.[0] || null, }); - await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ALBUM, data: { ids: [album.id] } }); return mapAlbumWithAssets(album); } @@ -154,8 +152,6 @@ export class AlbumService { isActivityEnabled: dto.isActivityEnabled, }); - await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ALBUM, data: { ids: [updatedAlbum.id] } }); - return mapAlbumWithoutAssets(updatedAlbum); } @@ -165,7 +161,6 @@ export class AlbumService { const album = await this.findOrFail(id, { withAssets: false }); await this.albumRepository.delete(album); - await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ALBUM, data: { ids: [id] } }); } async addAssets(authUser: AuthUserDto, id: string, dto: BulkIdsDto): Promise { diff --git a/server/src/domain/asset/asset.service.spec.ts b/server/src/domain/asset/asset.service.spec.ts index e4052fb34bffa..16d3877d553a1 100644 --- a/server/src/domain/asset/asset.service.spec.ts +++ b/server/src/domain/asset/asset.service.spec.ts @@ -794,14 +794,7 @@ describe(AssetService.name, () => { await sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'], force: false }); expect(assetMock.softDeleteAll).toHaveBeenCalledWith(['asset1', 'asset2']); - expect(jobMock.queue.mock.calls).toEqual([ - [ - { - name: JobName.SEARCH_REMOVE_ASSET, - data: { ids: ['asset1', 'asset2'] }, - }, - ], - ]); + expect(jobMock.queue.mock.calls).toEqual([]); }); }); @@ -820,14 +813,7 @@ describe(AssetService.name, () => { await sut.restoreAll(authStub.user1, { ids: ['asset1', 'asset2'] }); expect(assetMock.restoreAll).toHaveBeenCalledWith(['asset1', 'asset2']); - expect(jobMock.queue.mock.calls).toEqual([ - [ - { - name: JobName.SEARCH_INDEX_ASSET, - data: { ids: ['asset1', 'asset2'] }, - }, - ], - ]); + expect(jobMock.queue.mock.calls).toEqual([]); }); }); @@ -853,19 +839,6 @@ describe(AssetService.name, () => { await sut.handleAssetDeletion({ id: assetWithFace.id }); expect(jobMock.queue.mock.calls).toEqual([ - [ - { - name: JobName.SEARCH_REMOVE_FACE, - data: { assetId: faceStub.face1.assetId, personId: faceStub.face1.personId }, - }, - ], - [ - { - name: JobName.SEARCH_REMOVE_FACE, - data: { assetId: faceStub.mergeFace1.assetId, personId: faceStub.mergeFace1.personId }, - }, - ], - [{ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [assetWithFace.id] } }], [ { name: JobName.DELETE_FILES, @@ -907,9 +880,7 @@ describe(AssetService.name, () => { await sut.handleAssetDeletion({ id: assetStub.readOnly.id }); - expect(jobMock.queue.mock.calls).toEqual([ - [{ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [assetStub.readOnly.id] } }], - ]); + expect(jobMock.queue.mock.calls).toEqual([]); expect(assetMock.remove).toHaveBeenCalledWith(assetStub.readOnly); }); @@ -934,7 +905,6 @@ describe(AssetService.name, () => { expect(assetMock.remove).toHaveBeenCalledWith(assetStub.external); expect(jobMock.queue.mock.calls).toEqual([ - [{ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [assetStub.external.id] } }], [ { name: JobName.DELETE_FILES, @@ -955,9 +925,7 @@ describe(AssetService.name, () => { await sut.handleAssetDeletion({ id: assetStub.livePhotoStillAsset.id }); expect(jobMock.queue.mock.calls).toEqual([ - [{ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [assetStub.livePhotoStillAsset.id] } }], [{ name: JobName.ASSET_DELETION, data: { id: assetStub.livePhotoMotionAsset.id } }], - [{ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [assetStub.livePhotoMotionAsset.id] } }], [ { name: JobName.DELETE_FILES, diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts index c547d6a6db4b5..f4fb84d546ba0 100644 --- a/server/src/domain/asset/asset.service.ts +++ b/server/src/domain/asset/asset.service.ts @@ -397,7 +397,6 @@ export class AssetService { await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude }); const asset = await this.assetRepository.save({ id, ...rest }); - await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids: [id] } }); return mapAsset(asset); } @@ -426,7 +425,10 @@ export class AssetService { await this.updateMetadata({ id, dateTimeOriginal, latitude, longitude }); } - await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } }); + for (const id of ids) { + await this.updateMetadata({ id, dateTimeOriginal, latitude, longitude }); + } + await this.assetRepository.updateAll(ids, options); this.communicationRepository.send(CommunicationEvent.ASSET_UPDATE, authUser.id, ids); } @@ -463,16 +465,6 @@ export class AssetService { return false; } - if (asset.faces) { - await Promise.all( - asset.faces.map( - ({ assetId, personId }) => - personId != null && - this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_FACE, data: { assetId, personId } }), - ), - ); - } - // Replace the parent of the stack children with a new asset if (asset.stack && asset.stack.length != 0) { const stackIds = asset.stack.map((a) => a.id); @@ -482,7 +474,6 @@ export class AssetService { } await this.assetRepository.remove(asset); - await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [asset.id] } }); this.communicationRepository.send(CommunicationEvent.ASSET_DELETE, asset.ownerId, id); // TODO refactor this to use cascades @@ -513,7 +504,6 @@ export class AssetService { } } else { await this.assetRepository.softDeleteAll(ids); - await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { ids } }); this.communicationRepository.send(CommunicationEvent.ASSET_TRASH, authUser.id, ids); } } @@ -527,7 +517,6 @@ export class AssetService { for await (const assets of assetPagination) { const ids = assets.map((a) => a.id); await this.assetRepository.restoreAll(ids); - await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } }); this.communicationRepository.send(CommunicationEvent.ASSET_RESTORE, authUser.id, ids); } return; @@ -547,7 +536,6 @@ export class AssetService { const { ids } = dto; await this.access.requirePermission(authUser, Permission.ASSET_RESTORE, ids); await this.assetRepository.restoreAll(ids); - await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } }); this.communicationRepository.send(CommunicationEvent.ASSET_RESTORE, authUser.id, ids); } diff --git a/server/src/domain/domain.config.ts b/server/src/domain/domain.config.ts index bb26f4bcc7ac4..99ceb76099d32 100644 --- a/server/src/domain/domain.config.ts +++ b/server/src/domain/domain.config.ts @@ -13,16 +13,11 @@ export const immichAppConfig: ConfigModuleOptions = { envFilePath: '.env', isGlobal: true, validationSchema: Joi.object({ - NODE_ENV: Joi.string().required().valid('development', 'production', 'staging').default('development'), + NODE_ENV: Joi.string().optional().valid('development', 'production', 'staging').default('development'), DB_USERNAME: WHEN_DB_URL_SET, DB_PASSWORD: WHEN_DB_URL_SET, DB_DATABASE_NAME: WHEN_DB_URL_SET, DB_URL: Joi.string().optional(), - TYPESENSE_API_KEY: Joi.when('TYPESENSE_ENABLED', { - is: 'false', - then: Joi.string().optional(), - otherwise: Joi.string().required(), - }), DISABLE_REVERSE_GEOCODING: Joi.boolean().optional().valid(true, false).default(false), REVERSE_GEOCODING_PRECISION: Joi.number().optional().valid(0, 1, 2, 3).default(3), LOG_LEVEL: Joi.string().optional().valid('simple', 'verbose', 'debug', 'log', 'warn', 'error').default('log'), diff --git a/server/src/domain/domain.module.ts b/server/src/domain/domain.module.ts index d03fd27d45389..3f40b924f8d69 100644 --- a/server/src/domain/domain.module.ts +++ b/server/src/domain/domain.module.ts @@ -1,4 +1,4 @@ -import { DynamicModule, Global, Module, ModuleMetadata, OnApplicationShutdown, Provider } from '@nestjs/common'; +import { DynamicModule, Global, Module, ModuleMetadata, Provider } from '@nestjs/common'; import { ActivityService } from './activity'; import { AlbumService } from './album'; import { APIKeyService } from './api-key'; @@ -54,9 +54,7 @@ const providers: Provider[] = [ @Global() @Module({}) -export class DomainModule implements OnApplicationShutdown { - constructor(private searchService: SearchService) {} - +export class DomainModule { static register(options: Pick): DynamicModule { return { module: DomainModule, @@ -65,8 +63,4 @@ export class DomainModule implements OnApplicationShutdown { exports: [...providers], }; } - - onApplicationShutdown() { - this.searchService.teardown(); - } } diff --git a/server/src/domain/job/job.constants.ts b/server/src/domain/job/job.constants.ts index b7fe0d1dd0dd8..0dd15d260f84e 100644 --- a/server/src/domain/job/job.constants.ts +++ b/server/src/domain/job/job.constants.ts @@ -78,17 +78,6 @@ export enum JobName { DELETE_FILES = 'delete-files', CLEAN_OLD_AUDIT_LOGS = 'clean-old-audit-logs', - // search - SEARCH_INDEX_ASSETS = 'search-index-assets', - SEARCH_INDEX_ASSET = 'search-index-asset', - SEARCH_INDEX_FACE = 'search-index-face', - SEARCH_INDEX_FACES = 'search-index-faces', - SEARCH_INDEX_ALBUMS = 'search-index-albums', - SEARCH_INDEX_ALBUM = 'search-index-album', - SEARCH_REMOVE_ALBUM = 'search-remove-album', - SEARCH_REMOVE_ASSET = 'search-remove-asset', - SEARCH_REMOVE_FACE = 'search-remove-face', - // clip QUEUE_ENCODE_CLIP = 'queue-clip-encode', ENCODE_CLIP = 'clip-encode', @@ -151,21 +140,6 @@ export const JOBS_TO_QUEUE: Record = { [JobName.QUEUE_ENCODE_CLIP]: QueueName.CLIP_ENCODING, [JobName.ENCODE_CLIP]: QueueName.CLIP_ENCODING, - // search - albums - [JobName.SEARCH_INDEX_ALBUMS]: QueueName.SEARCH, - [JobName.SEARCH_INDEX_ALBUM]: QueueName.SEARCH, - [JobName.SEARCH_REMOVE_ALBUM]: QueueName.SEARCH, - - // search - assets - [JobName.SEARCH_INDEX_ASSETS]: QueueName.SEARCH, - [JobName.SEARCH_INDEX_ASSET]: QueueName.SEARCH, - [JobName.SEARCH_REMOVE_ASSET]: QueueName.SEARCH, - - // search - faces - [JobName.SEARCH_INDEX_FACES]: QueueName.SEARCH, - [JobName.SEARCH_INDEX_FACE]: QueueName.SEARCH, - [JobName.SEARCH_REMOVE_FACE]: QueueName.SEARCH, - // XMP sidecars [JobName.QUEUE_SIDECAR]: QueueName.SIDECAR, [JobName.SIDECAR_DISCOVERY]: QueueName.SIDECAR, diff --git a/server/src/domain/job/job.interface.ts b/server/src/domain/job/job.interface.ts index be76f6645ec4b..88e2a9c14d4b4 100644 --- a/server/src/domain/job/job.interface.ts +++ b/server/src/domain/job/job.interface.ts @@ -2,11 +2,6 @@ export interface IBaseJob { force?: boolean; } -export interface IAssetFaceJob extends IBaseJob { - assetId: string; - personId: string; -} - export interface IEntityJob extends IBaseJob { id: string; source?: 'upload' | 'sidecar-write'; diff --git a/server/src/domain/job/job.service.spec.ts b/server/src/domain/job/job.service.spec.ts index fa909d1ae8183..859bc39f0b327 100644 --- a/server/src/domain/job/job.service.spec.ts +++ b/server/src/domain/job/job.service.spec.ts @@ -1,4 +1,4 @@ -import { SystemConfig } from '@app/infra/entities'; +import { SystemConfig, SystemConfigKey } from '@app/infra/entities'; import { BadRequestException } from '@nestjs/common'; import { assetStub, @@ -18,7 +18,7 @@ import { JobHandler, JobItem, } from '../repositories'; -import { SystemConfigCore } from '../system-config/system-config.core'; +import { FeatureFlag, SystemConfigCore } from '../system-config/system-config.core'; import { JobCommand, JobName, QueueName } from './job.constants'; import { JobService } from './job.service'; @@ -271,7 +271,7 @@ describe(JobService.name, () => { }, { item: { name: JobName.LINK_LIVE_PHOTOS, data: { id: 'asset-1' } }, - jobs: [JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, JobName.SEARCH_INDEX_ASSET], + jobs: [JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE], }, { item: { name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data: { id: 'asset-1', source: 'upload' } }, @@ -281,6 +281,10 @@ describe(JobService.name, () => { item: { name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data: { id: 'asset-1' } }, jobs: [], }, + { + item: { name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: 'asset-1' } }, + jobs: [], + }, { item: { name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id: 'asset-1' } }, jobs: [ @@ -315,15 +319,15 @@ describe(JobService.name, () => { }, { item: { name: JobName.CLASSIFY_IMAGE, data: { id: 'asset-1' } }, - jobs: [JobName.SEARCH_INDEX_ASSET], + jobs: [], }, { item: { name: JobName.ENCODE_CLIP, data: { id: 'asset-1' } }, - jobs: [JobName.SEARCH_INDEX_ASSET], + jobs: [], }, { item: { name: JobName.RECOGNIZE_FACES, data: { id: 'asset-1' } }, - jobs: [JobName.SEARCH_INDEX_ASSET], + jobs: [], }, ]; @@ -357,5 +361,32 @@ describe(JobService.name, () => { expect(jobMock.queue).not.toHaveBeenCalled(); }); } + + const featureTests: Array<{ queue: QueueName; feature: FeatureFlag; configKey: SystemConfigKey }> = [ + { + queue: QueueName.CLIP_ENCODING, + feature: FeatureFlag.CLIP_ENCODE, + configKey: SystemConfigKey.MACHINE_LEARNING_CLIP_ENABLED, + }, + { + queue: QueueName.OBJECT_TAGGING, + feature: FeatureFlag.TAG_IMAGE, + configKey: SystemConfigKey.MACHINE_LEARNING_CLASSIFICATION_ENABLED, + }, + { + queue: QueueName.RECOGNIZE_FACES, + feature: FeatureFlag.FACIAL_RECOGNITION, + configKey: SystemConfigKey.MACHINE_LEARNING_FACIAL_RECOGNITION_ENABLED, + }, + ]; + + for (const { queue, feature, configKey } of featureTests) { + it(`should throw an error if attempting to queue ${queue} when ${feature} is disabled`, async () => { + configMock.load.mockResolvedValue([{ key: configKey, value: false }]); + jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false }); + + await expect(sut.handleCommand(queue, { command: JobCommand.START, force: false })).rejects.toThrow(); + }); + } }); }); diff --git a/server/src/domain/job/job.service.ts b/server/src/domain/job/job.service.ts index d313302eac65e..b57013ae006e0 100644 --- a/server/src/domain/job/job.service.ts +++ b/server/src/domain/job/job.service.ts @@ -236,15 +236,5 @@ export class JobService { } } } - - // In addition to the above jobs, all of these should queue `SEARCH_INDEX_ASSET` - switch (item.name) { - case JobName.CLASSIFY_IMAGE: - case JobName.ENCODE_CLIP: - case JobName.RECOGNIZE_FACES: - case JobName.LINK_LIVE_PHOTOS: - await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids: [item.data.id] } }); - break; - } } } diff --git a/server/src/domain/person/person.service.spec.ts b/server/src/domain/person/person.service.spec.ts index 0ce15f5aece65..6a636342527f3 100644 --- a/server/src/domain/person/person.service.spec.ts +++ b/server/src/domain/person/person.service.spec.ts @@ -12,7 +12,7 @@ import { newMediaRepositoryMock, newMoveRepositoryMock, newPersonRepositoryMock, - newSearchRepositoryMock, + newSmartInfoRepositoryMock, newStorageRepositoryMock, newSystemConfigRepositoryMock, personStub, @@ -26,12 +26,12 @@ import { IMediaRepository, IMoveRepository, IPersonRepository, - ISearchRepository, + ISmartInfoRepository, IStorageRepository, ISystemConfigRepository, WithoutProperty, } from '../repositories'; -import { PersonResponseDto, mapFaces } from './person.dto'; +import { PersonResponseDto, mapFaces, mapPerson } from './person.dto'; import { PersonService } from './person.service'; const responseDto: PersonResponseDto = { @@ -61,33 +61,6 @@ const detectFaceMock = { score: 0.2, }; -const faceSearch = { - noMatch: { - total: 0, - count: 0, - page: 1, - items: [], - distances: [], - facets: [], - }, - oneMatch: { - total: 1, - count: 1, - page: 1, - items: [faceStub.face1], - distances: [0.1], - facets: [], - }, - oneRemoteMatch: { - total: 1, - count: 1, - page: 1, - items: [faceStub.face1], - distances: [0.8], - facets: [], - }, -}; - describe(PersonService.name, () => { let accessMock: IAccessRepositoryMock; let assetMock: jest.Mocked; @@ -97,8 +70,8 @@ describe(PersonService.name, () => { let mediaMock: jest.Mocked; let moveMock: jest.Mocked; let personMock: jest.Mocked; - let searchMock: jest.Mocked; let storageMock: jest.Mocked; + let smartInfoMock: jest.Mocked; let sut: PersonService; beforeEach(async () => { @@ -110,8 +83,8 @@ describe(PersonService.name, () => { moveMock = newMoveRepositoryMock(); mediaMock = newMediaRepositoryMock(); personMock = newPersonRepositoryMock(); - searchMock = newSearchRepositoryMock(); storageMock = newStorageRepositoryMock(); + smartInfoMock = newSmartInfoRepositoryMock(); sut = new PersonService( accessMock, assetMock, @@ -119,10 +92,10 @@ describe(PersonService.name, () => { moveMock, mediaMock, personMock, - searchMock, configMock, storageMock, jobMock, + smartInfoMock, ); mediaMock.crop.mockResolvedValue(croppedFace); @@ -283,10 +256,6 @@ describe(PersonService.name, () => { expect(personMock.getById).toHaveBeenCalledWith('person-1'); expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', name: 'Person 1' }); - expect(jobMock.queue).toHaveBeenCalledWith({ - name: JobName.SEARCH_INDEX_ASSET, - data: { ids: [assetStub.image.id] }, - }); expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1'])); }); @@ -320,10 +289,6 @@ describe(PersonService.name, () => { expect(personMock.getById).toHaveBeenCalledWith('person-1'); expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', isHidden: false }); - expect(jobMock.queue).toHaveBeenCalledWith({ - name: JobName.SEARCH_INDEX_ASSET, - data: { ids: [assetStub.image.id] }, - }); expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1'])); }); @@ -508,6 +473,17 @@ describe(PersonService.name, () => { }); }); + describe('handlePersonDelete', () => { + it('should delete person', async () => { + personMock.getById.mockResolvedValue(personStub.withName); + + await sut.handlePersonDelete({ id: personStub.withName.id }); + + expect(personMock.delete).toHaveBeenCalledWith(personStub.withName); + expect(storageMock.unlink).toHaveBeenCalledWith(personStub.withName.thumbnailPath); + }); + }); + describe('handlePersonCleanup', () => { it('should delete people without faces', async () => { personMock.getAllWithoutFaces.mockResolvedValue([personStub.noName]); @@ -547,7 +523,7 @@ describe(PersonService.name, () => { hasNextPage: false, }); personMock.getAll.mockResolvedValue([personStub.withName]); - searchMock.deleteAllFaces.mockResolvedValue(100); + personMock.deleteAll.mockResolvedValue(5); await sut.handleQueueRecognizeFaces({ force: true }); @@ -626,7 +602,7 @@ describe(PersonService.name, () => { it('should match existing people', async () => { machineLearningMock.detectFaces.mockResolvedValue([detectFaceMock]); - searchMock.searchFaces.mockResolvedValue(faceSearch.oneMatch); + smartInfoMock.searchFaces.mockResolvedValue([faceStub.face1]); assetMock.getByIds.mockResolvedValue([assetStub.image]); await sut.handleRecognizeFaces({ id: assetStub.image.id }); @@ -645,7 +621,7 @@ describe(PersonService.name, () => { it('should create a new person', async () => { machineLearningMock.detectFaces.mockResolvedValue([detectFaceMock]); - searchMock.searchFaces.mockResolvedValue(faceSearch.oneRemoteMatch); + smartInfoMock.searchFaces.mockResolvedValue([]); personMock.create.mockResolvedValue(personStub.noName); assetMock.getByIds.mockResolvedValue([assetStub.image]); personMock.createFace.mockResolvedValue(faceStub.primaryFace1); @@ -664,10 +640,6 @@ describe(PersonService.name, () => { imageHeight: 500, imageWidth: 400, }); - expect(jobMock.queue.mock.calls).toEqual([ - [{ name: JobName.SEARCH_INDEX_FACE, data: { personId: 'person-1', assetId: 'asset-id' } }], - [{ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: 'person-1' } }], - ]); }); }); describe('handleGeneratePersonThumbnail', () => { @@ -873,4 +845,27 @@ describe(PersonService.name, () => { expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, new Set(['person-1'])); }); }); + + describe('mapFace', () => { + it('should map a face', () => { + expect(mapFaces(faceStub.face1, personStub.withName.owner)).toEqual({ + boundingBoxX1: 0, + boundingBoxX2: 1, + boundingBoxY1: 0, + boundingBoxY2: 1, + id: 'assetFaceId', + imageHeight: 1024, + imageWidth: 1024, + person: mapPerson(personStub.withName), + }); + }); + + it('should not map person if person is null', () => { + expect(mapFaces({ ...faceStub.face1, person: null }, authStub.user1).person).toBeNull(); + }); + + it('should not map person if person does not match auth user id', () => { + expect(mapFaces(faceStub.face1, authStub.user1).person).toBeNull(); + }); + }); }); diff --git a/server/src/domain/person/person.service.ts b/server/src/domain/person/person.service.ts index 79fdcbafe662c..ffd1cc149f581 100644 --- a/server/src/domain/person/person.service.ts +++ b/server/src/domain/person/person.service.ts @@ -9,7 +9,6 @@ import { usePagination } from '../domain.util'; import { IBaseJob, IEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job'; import { FACE_THUMBNAIL_SIZE } from '../media'; import { - AssetFaceId, CropOptions, IAccessRepository, IAssetRepository, @@ -18,7 +17,7 @@ import { IMediaRepository, IMoveRepository, IPersonRepository, - ISearchRepository, + ISmartInfoRepository, IStorageRepository, ISystemConfigRepository, ImmichReadStream, @@ -56,10 +55,10 @@ export class PersonService { @Inject(IMoveRepository) moveRepository: IMoveRepository, @Inject(IMediaRepository) private mediaRepository: IMediaRepository, @Inject(IPersonRepository) private repository: IPersonRepository, - @Inject(ISearchRepository) private searchRepository: ISearchRepository, @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, + @Inject(ISmartInfoRepository) private smartInfoRepository: ISmartInfoRepository, ) { this.access = AccessCore.create(accessRepository); this.configCore = SystemConfigCore.create(configRepository); @@ -198,11 +197,6 @@ export class PersonService { if (name !== undefined || birthDate !== undefined || isHidden !== undefined) { person = await this.repository.update({ id, name, birthDate, isHidden }); - if (this.needsSearchIndexUpdate(dto)) { - const assets = await this.repository.getAssets(id); - const ids = assets.map((asset) => asset.id); - await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } }); - } } if (assetId) { @@ -281,8 +275,7 @@ export class PersonService { for (const person of people) { await this.jobRepository.queue({ name: JobName.PERSON_DELETE, data: { id: person.id } }); } - const faces = await this.searchRepository.deleteAllFaces(); - this.logger.debug(`Deleted ${people} people and ${faces} faces`); + this.logger.debug(`Deleted ${people.length} people`); } for await (const assets of assetPagination) { @@ -318,20 +311,17 @@ export class PersonService { ); this.logger.debug(`${faces.length} faces detected in ${asset.resizePath}`); - this.logger.verbose(faces.map((face) => ({ ...face, embedding: `float[${face.embedding.length}]` }))); + this.logger.verbose(faces.map((face) => ({ ...face, embedding: `vector(${face.embedding.length})` }))); for (const { embedding, ...rest } of faces) { - const faceSearchResult = await this.searchRepository.searchFaces(embedding, { ownerId: asset.ownerId }); - - let personId: string | null = null; - - // try to find a matching face and link to the associated person - // The closer to 0, the better the match. Range is from 0 to 2 - if (faceSearchResult.total && faceSearchResult.distances[0] <= machineLearning.facialRecognition.maxDistance) { - this.logger.verbose(`Match face with distance ${faceSearchResult.distances[0]}`); - personId = faceSearchResult.items[0].personId; - } + const matches = await this.smartInfoRepository.searchFaces({ + ownerId: asset.ownerId, + embedding, + numResults: 1, + maxDistance: machineLearning.facialRecognition.maxDistance, + }); + let personId = matches[0]?.personId || null; let newPerson: PersonEntity | null = null; if (!personId) { this.logger.debug('No matches, creating a new person.'); @@ -350,8 +340,6 @@ export class PersonService { boundingBoxY1: rest.boundingBox.y1, boundingBoxY2: rest.boundingBox.y2, }); - const faceId: AssetFaceId = { assetId: asset.id, personId }; - await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_FACE, data: faceId }); if (newPerson) { await this.repository.update({ id: personId, faceAssetId: face.id }); @@ -489,21 +477,9 @@ export class PersonService { } } - // Re-index all faces in typesense for up-to-date search results - await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_FACES }); - return results; } - /** - * Returns true if the given person update is going to require an update of the search index. - * @param dto the Person going to be updated - * @private - */ - private needsSearchIndexUpdate(dto: PersonUpdateDto): boolean { - return dto.name !== undefined || dto.isHidden !== undefined; - } - private async findOrFail(id: string) { const person = await this.repository.getById(id); if (!person) { diff --git a/server/src/domain/repositories/asset.repository.ts b/server/src/domain/repositories/asset.repository.ts index a42952958cb7d..48f83de37bc13 100644 --- a/server/src/domain/repositories/asset.repository.ts +++ b/server/src/domain/repositories/asset.repository.ts @@ -1,3 +1,4 @@ +import { SearchExploreItem } from '@app/domain'; import { AssetEntity, AssetJobStatusEntity, AssetType, ExifEntity } from '@app/infra/entities'; import { FindOptionsRelations } from 'typeorm'; import { Paginated, PaginationOptions } from '../domain.util'; @@ -105,8 +106,7 @@ export enum TimeBucketSize { MONTH = 'MONTH', } -export interface TimeBucketOptions { - size: TimeBucketSize; +export interface AssetBuilderOptions { isArchived?: boolean; isFavorite?: boolean; isTrashed?: boolean; @@ -114,6 +114,12 @@ export interface TimeBucketOptions { personId?: string; userIds?: string[]; withStacked?: boolean; + exifInfo?: boolean; + assetType?: AssetType; +} + +export interface TimeBucketOptions extends AssetBuilderOptions { + size: TimeBucketSize; } export interface TimeBucketItem { @@ -142,6 +148,21 @@ export interface MonthDay { month: number; } +export interface AssetExploreFieldOptions { + maxFields: number; + minAssetsPerField: number; +} + +export interface AssetExploreOptions extends AssetExploreFieldOptions { + relation: keyof AssetEntity; + relatedField: string; + unnest?: boolean; +} + +export interface MetadataSearchOptions { + numResults: number; +} + export const IAssetRepository = 'IAssetRepository'; export interface IAssetRepository { @@ -152,7 +173,7 @@ export interface IAssetRepository { getByChecksum(userId: string, checksum: Buffer): Promise; getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated; getByUserId(pagination: PaginationOptions, userId: string, options?: AssetSearchOptions): Paginated; - getById(id: string): Promise; + getById(id: string, relations?: FindOptionsRelations): Promise; getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated; getWith(pagination: PaginationOptions, property: WithProperty, libraryId?: string): Paginated; getRandom(userId: string, count: number): Promise; @@ -176,4 +197,7 @@ export interface IAssetRepository { 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, userId: string, options: MetadataSearchOptions): Promise; } diff --git a/server/src/domain/repositories/job.repository.ts b/server/src/domain/repositories/job.repository.ts index f3cbedec961bc..a1998b79221b7 100644 --- a/server/src/domain/repositories/job.repository.ts +++ b/server/src/domain/repositories/job.repository.ts @@ -2,9 +2,7 @@ import { JobName, QueueName } from '../job/job.constants'; import { IAssetDeletionJob, - IAssetFaceJob, IBaseJob, - IBulkEntityJob, IDeleteFilesJob, IEntityJob, ILibraryFileJob, @@ -96,18 +94,7 @@ export type JobItem = | { name: JobName.LIBRARY_REMOVE_OFFLINE; data: IEntityJob } | { name: JobName.LIBRARY_DELETE; data: IEntityJob } | { name: JobName.LIBRARY_QUEUE_SCAN_ALL; data: IBaseJob } - | { name: JobName.LIBRARY_QUEUE_CLEANUP; data: IBaseJob } - - // Search - | { name: JobName.SEARCH_INDEX_ASSETS; data?: IBaseJob } - | { name: JobName.SEARCH_INDEX_ASSET; data: IBulkEntityJob } - | { name: JobName.SEARCH_INDEX_FACES; data?: IBaseJob } - | { name: JobName.SEARCH_INDEX_FACE; data: IAssetFaceJob } - | { name: JobName.SEARCH_INDEX_ALBUMS; data?: IBaseJob } - | { name: JobName.SEARCH_INDEX_ALBUM; data: IBulkEntityJob } - | { name: JobName.SEARCH_REMOVE_ASSET; data: IBulkEntityJob } - | { name: JobName.SEARCH_REMOVE_ALBUM; data: IBulkEntityJob } - | { name: JobName.SEARCH_REMOVE_FACE; data: IAssetFaceJob }; + | { name: JobName.LIBRARY_QUEUE_CLEANUP; data: IBaseJob }; export type JobHandler = (data: T) => boolean | Promise; export type JobItemHandler = (item: JobItem) => Promise; diff --git a/server/src/domain/repositories/person.repository.ts b/server/src/domain/repositories/person.repository.ts index 4c6dcdc9c9bba..dfa9d218bd658 100644 --- a/server/src/domain/repositories/person.repository.ts +++ b/server/src/domain/repositories/person.repository.ts @@ -41,9 +41,7 @@ export interface IPersonRepository { update(entity: Partial): Promise; delete(entity: PersonEntity): Promise; deleteAll(): Promise; - getStatistics(personId: string): Promise; - getAllFaces(): Promise; getFacesByIds(ids: AssetFaceId[]): Promise; getRandomFace(personId: string): Promise; diff --git a/server/src/domain/repositories/search.repository.ts b/server/src/domain/repositories/search.repository.ts index 40ff4e8520d2a..c2a0201efe87e 100644 --- a/server/src/domain/repositories/search.repository.ts +++ b/server/src/domain/repositories/search.repository.ts @@ -1,20 +1,10 @@ -import { AlbumEntity, AssetEntity, AssetFaceEntity, AssetType } from '@app/infra/entities'; - -export enum SearchCollection { - ASSETS = 'assets', - ALBUMS = 'albums', - FACES = 'faces', -} +import { AssetType } from '@app/infra/entities'; export enum SearchStrategy { CLIP = 'CLIP', TEXT = 'TEXT', } -export interface SearchFaceFilter { - ownerId: string; -} - export interface SearchFilter { id?: string; userId: string; @@ -55,43 +45,12 @@ export interface SearchFacet { }>; } +export type SearchExploreItemSet = Array<{ + value: string; + data: T; +}>; + export interface SearchExploreItem { fieldName: string; - items: Array<{ - value: string; - data: T; - }>; -} - -export type OwnedFaceEntity = Pick & { - /** computed as assetId|personId */ - id: string; - /** copied from asset.id */ - ownerId: string; -}; - -export type SearchCollectionIndexStatus = Record; - -export const ISearchRepository = 'ISearchRepository'; - -export interface ISearchRepository { - setup(): Promise; - checkMigrationStatus(): Promise; - - importAlbums(items: AlbumEntity[], done: boolean): Promise; - importAssets(items: AssetEntity[], done: boolean): Promise; - importFaces(items: OwnedFaceEntity[], done: boolean): Promise; - - deleteAlbums(ids: string[]): Promise; - deleteAssets(ids: string[]): Promise; - deleteFaces(ids: string[]): Promise; - deleteAllFaces(): Promise; - updateCLIPField(num_dim: number): Promise; - - searchAlbums(query: string, filters: SearchFilter): Promise>; - searchAssets(query: string, filters: SearchFilter): Promise>; - vectorSearch(query: number[], filters: SearchFilter): Promise>; - searchFaces(query: number[], filters: SearchFaceFilter): Promise>; - - explore(userId: string): Promise[]>; + items: SearchExploreItemSet; } diff --git a/server/src/domain/repositories/smart-info.repository.ts b/server/src/domain/repositories/smart-info.repository.ts index fdc61cda25e39..f26061f1b09a2 100644 --- a/server/src/domain/repositories/smart-info.repository.ts +++ b/server/src/domain/repositories/smart-info.repository.ts @@ -1,7 +1,19 @@ -import { SmartInfoEntity } from '@app/infra/entities'; +import { AssetEntity, AssetFaceEntity, SmartInfoEntity } from '@app/infra/entities'; export const ISmartInfoRepository = 'ISmartInfoRepository'; -export interface ISmartInfoRepository { - upsert(info: Partial): Promise; +export type Embedding = number[]; + +export interface EmbeddingSearch { + ownerId: string; + embedding: Embedding; + numResults: number; + maxDistance?: number; +} + +export interface ISmartInfoRepository { + init(modelName: string): Promise; + searchCLIP(search: EmbeddingSearch): Promise; + searchFaces(search: EmbeddingSearch): 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 85d2b55f9348a..3a77bd4b7c5eb 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 { IsArray, IsBoolean, IsEnum, IsNotEmpty, IsString } from 'class-validator'; +import { IsBoolean, IsEnum, IsNotEmpty, IsString } from 'class-validator'; import { Optional, toBoolean } from '../../domain.util'; export class SearchDto { @@ -23,58 +23,6 @@ export class SearchDto { @Optional() type?: AssetType; - @IsBoolean() - @Optional() - @Transform(toBoolean) - isFavorite?: boolean; - - @IsBoolean() - @Optional() - @Transform(toBoolean) - isArchived?: boolean; - - @IsString() - @IsNotEmpty() - @Optional() - 'exifInfo.city'?: string; - - @IsString() - @IsNotEmpty() - @Optional() - 'exifInfo.state'?: string; - - @IsString() - @IsNotEmpty() - @Optional() - 'exifInfo.country'?: string; - - @IsString() - @IsNotEmpty() - @Optional() - 'exifInfo.make'?: string; - - @IsString() - @IsNotEmpty() - @Optional() - 'exifInfo.model'?: string; - - @IsString() - @IsNotEmpty() - @Optional() - 'exifInfo.projectionType'?: string; - - @IsString({ each: true }) - @IsArray() - @Optional() - @Transform(({ value }) => value.split(',')) - 'smartInfo.objects'?: string[]; - - @IsString({ each: true }) - @IsArray() - @Optional() - @Transform(({ value }) => value.split(',')) - 'smartInfo.tags'?: string[]; - @IsBoolean() @Optional() @Transform(toBoolean) diff --git a/server/src/domain/search/search.service.spec.ts b/server/src/domain/search/search.service.spec.ts index 2beb4a134b236..ae0b0935da648 100644 --- a/server/src/domain/search/search.service.spec.ts +++ b/server/src/domain/search/search.service.spec.ts @@ -1,29 +1,20 @@ -import { BadRequestException } from '@nestjs/common'; +import { SystemConfigKey } from '@app/infra/entities'; import { - albumStub, assetStub, - asyncTick, authStub, - faceStub, - newAlbumRepositoryMock, newAssetRepositoryMock, - newJobRepositoryMock, newMachineLearningRepositoryMock, newPersonRepositoryMock, - newSearchRepositoryMock, + newSmartInfoRepositoryMock, newSystemConfigRepositoryMock, - searchStub, + personStub, } from '@test'; -import { plainToInstance } from 'class-transformer'; import { mapAsset } from '../asset'; -import { JobName } from '../job'; import { - IAlbumRepository, IAssetRepository, - IJobRepository, IMachineLearningRepository, IPersonRepository, - ISearchRepository, + ISmartInfoRepository, ISystemConfigRepository, } from '../repositories'; import { SearchDto } from './dto'; @@ -33,401 +24,126 @@ jest.useFakeTimers(); describe(SearchService.name, () => { let sut: SearchService; - let albumMock: jest.Mocked; let assetMock: jest.Mocked; let configMock: jest.Mocked; - let jobMock: jest.Mocked; - let personMock: jest.Mocked; let machineMock: jest.Mocked; - let searchMock: jest.Mocked; + let personMock: jest.Mocked; + let smartInfoMock: jest.Mocked; - beforeEach(async () => { - albumMock = newAlbumRepositoryMock(); + beforeEach(() => { assetMock = newAssetRepositoryMock(); configMock = newSystemConfigRepositoryMock(); - jobMock = newJobRepositoryMock(); - personMock = newPersonRepositoryMock(); machineMock = newMachineLearningRepositoryMock(); - searchMock = newSearchRepositoryMock(); - - sut = new SearchService(albumMock, assetMock, jobMock, machineMock, personMock, searchMock, configMock); - - searchMock.checkMigrationStatus.mockResolvedValue({ assets: false, albums: false, faces: false }); - - delete process.env.TYPESENSE_ENABLED; - await sut.init(); - }); - - const disableSearch = () => { - searchMock.setup.mockClear(); - searchMock.checkMigrationStatus.mockClear(); - jobMock.queue.mockClear(); - process.env.TYPESENSE_ENABLED = 'false'; - }; - - afterEach(() => { - sut.teardown(); + personMock = newPersonRepositoryMock(); + smartInfoMock = newSmartInfoRepositoryMock(); + sut = new SearchService(configMock, machineMock, personMock, smartInfoMock, assetMock); }); it('should work', () => { expect(sut).toBeDefined(); }); - describe('request dto', () => { - it('should convert smartInfo.tags to a string list', () => { - const instance = plainToInstance(SearchDto, { 'smartInfo.tags': 'a,b,c' }); - expect(instance['smartInfo.tags']).toEqual(['a', 'b', 'c']); - }); + describe('searchPerson', () => { + it('should pass options to search', async () => { + const { name } = personStub.withName; - it('should handle empty smartInfo.tags', () => { - const instance = plainToInstance(SearchDto, {}); - expect(instance['smartInfo.tags']).toBeUndefined(); - }); + await sut.searchPerson(authStub.user1, { name, withHidden: false }); - it('should convert smartInfo.objects to a string list', () => { - const instance = plainToInstance(SearchDto, { 'smartInfo.objects': 'a,b,c' }); - expect(instance['smartInfo.objects']).toEqual(['a', 'b', 'c']); - }); + expect(personMock.getByName).toHaveBeenCalledWith(authStub.user1.id, name, { withHidden: false }); - it('should handle empty smartInfo.objects', () => { - const instance = plainToInstance(SearchDto, {}); - expect(instance['smartInfo.objects']).toBeUndefined(); - }); - }); + await sut.searchPerson(authStub.user1, { name, withHidden: true }); - describe(`init`, () => { - it('should skip when search is disabled', async () => { - disableSearch(); - await sut.init(); - - expect(searchMock.setup).not.toHaveBeenCalled(); - expect(searchMock.checkMigrationStatus).not.toHaveBeenCalled(); - expect(jobMock.queue).not.toHaveBeenCalled(); - }); - - it('should skip schema migration if not needed', async () => { - await sut.init(); - - expect(searchMock.setup).toHaveBeenCalled(); - expect(jobMock.queue).not.toHaveBeenCalled(); - }); - - it('should do schema migration if needed', async () => { - searchMock.checkMigrationStatus.mockResolvedValue({ assets: true, albums: true, faces: true }); - await sut.init(); - - expect(searchMock.setup).toHaveBeenCalled(); - expect(jobMock.queue.mock.calls).toEqual([ - [{ name: JobName.SEARCH_INDEX_ASSETS }], - [{ name: JobName.SEARCH_INDEX_ALBUMS }], - [{ name: JobName.SEARCH_INDEX_FACES }], - ]); + expect(personMock.getByName).toHaveBeenCalledWith(authStub.user1.id, name, { withHidden: true }); }); }); describe('getExploreData', () => { - it('should throw bad request exception if search is disabled', async () => { - disableSearch(); - await expect(sut.getExploreData(authStub.admin)).rejects.toBeInstanceOf(BadRequestException); - expect(searchMock.explore).not.toHaveBeenCalled(); - }); + it('should get assets by city and tag', async () => { + assetMock.getAssetIdByCity.mockResolvedValueOnce({ + fieldName: 'exifInfo.city', + items: [{ value: 'Paris', data: assetStub.image.id }], + }); + assetMock.getAssetIdByTag.mockResolvedValueOnce({ + fieldName: 'smartInfo.tags', + items: [{ value: 'train', data: assetStub.imageFrom2015.id }], + }); + assetMock.getByIds.mockResolvedValueOnce([assetStub.image, assetStub.imageFrom2015]); + const expectedResponse = [ + { fieldName: 'exifInfo.city', items: [{ value: 'Paris', data: mapAsset(assetStub.image) }] }, + { fieldName: 'smartInfo.tags', items: [{ value: 'train', data: mapAsset(assetStub.imageFrom2015) }] }, + ]; - it('should return explore data if feature flag SEARCH is set', async () => { - searchMock.explore.mockResolvedValue([{ fieldName: 'name', items: [{ value: 'image', data: assetStub.image }] }]); - assetMock.getByIds.mockResolvedValue([assetStub.image]); + const result = await sut.getExploreData(authStub.user1); - await expect(sut.getExploreData(authStub.admin)).resolves.toEqual([ - { - fieldName: 'name', - items: [{ value: 'image', data: mapAsset(assetStub.image) }], - }, - ]); - - expect(searchMock.explore).toHaveBeenCalledWith(authStub.admin.id); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); + expect(result).toEqual(expectedResponse); }); }); describe('search', () => { - // it('should throw an error is search is disabled', async () => { - // sut['enabled'] = false; + it('should throw an error if query is missing', async () => { + await expect(sut.search(authStub.user1, { q: '' })).rejects.toThrow('Missing query'); + }); - // await expect(sut.search(authStub.admin, {})).rejects.toBeInstanceOf(BadRequestException); - - // expect(searchMock.searchAlbums).not.toHaveBeenCalled(); - // expect(searchMock.searchAssets).not.toHaveBeenCalled(); - // }); - - it('should search assets and albums using text search', async () => { - searchMock.searchAssets.mockResolvedValue(searchStub.withImage); - searchMock.searchAlbums.mockResolvedValue(searchStub.emptyResults); - assetMock.getByIds.mockResolvedValue([assetStub.image]); - - await expect(sut.search(authStub.admin, {})).resolves.toEqual({ + it('should search by metadata if `clip` option is false', async () => { + const dto: SearchDto = { q: 'test query', clip: false }; + assetMock.searchMetadata.mockResolvedValueOnce([assetStub.image]); + const expectedResponse = { albums: { total: 0, count: 0, - page: 1, items: [], facets: [], - distances: [], }, assets: { total: 1, count: 1, - page: 1, items: [mapAsset(assetStub.image)], facets: [], - distances: [], }, - }); + }; - // expect(searchMock.searchAssets).toHaveBeenCalledWith('*', { userId: authStub.admin.id }); - expect(searchMock.searchAlbums).toHaveBeenCalledWith('*', { userId: authStub.admin.id }); + const result = await sut.search(authStub.user1, dto); + + expect(result).toEqual(expectedResponse); + expect(assetMock.searchMetadata).toHaveBeenCalledWith(dto.q, authStub.user1.id, { numResults: 250 }); + expect(smartInfoMock.searchCLIP).not.toHaveBeenCalled(); }); - it('should search assets and albums using vector search', async () => { - searchMock.vectorSearch.mockResolvedValue(searchStub.emptyResults); - searchMock.searchAlbums.mockResolvedValue(searchStub.emptyResults); - machineMock.encodeText.mockResolvedValue([123]); - - await expect(sut.search(authStub.admin, { clip: true, query: 'foo' })).resolves.toEqual({ + 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]); + machineMock.encodeText.mockResolvedValueOnce(embedding); + const expectedResponse = { albums: { total: 0, count: 0, - page: 1, items: [], facets: [], - distances: [], }, assets: { - total: 0, - count: 0, - page: 1, - items: [], + total: 1, + count: 1, + items: [mapAsset(assetStub.image)], facets: [], - distances: [], }, - }); + }; - expect(machineMock.encodeText).toHaveBeenCalledWith(expect.any(String), { text: 'foo' }, expect.any(Object)); - expect(searchMock.vectorSearch).toHaveBeenCalledWith([123], { - userId: authStub.admin.id, - clip: true, - query: 'foo', - }); - expect(searchMock.searchAlbums).toHaveBeenCalledWith('foo', { - userId: authStub.admin.id, - clip: true, - query: 'foo', - }); - }); - }); + const result = await sut.search(authStub.user1, dto); - describe('handleIndexAssets', () => { - it('should call done, even when there are no assets', async () => { - await sut.handleIndexAssets(); - - expect(searchMock.importAssets).toHaveBeenCalledWith([], true); + expect(result).toEqual(expectedResponse); + expect(smartInfoMock.searchCLIP).toHaveBeenCalledWith({ ownerId: authStub.user1.id, embedding, numResults: 100 }); + expect(assetMock.searchMetadata).not.toHaveBeenCalled(); }); - it('should index all the assets', async () => { - assetMock.getAll.mockResolvedValue({ - items: [assetStub.image], - hasNextPage: false, - }); + it('should throw an error if clip is requested but disabled', async () => { + const dto: SearchDto = { q: 'test query', clip: true }; + configMock.load + .mockResolvedValueOnce([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]) + .mockResolvedValueOnce([{ key: SystemConfigKey.MACHINE_LEARNING_CLIP_ENABLED, value: false }]); - await sut.handleIndexAssets(); - - expect(searchMock.importAssets.mock.calls).toEqual([ - [[assetStub.image], false], - [[], true], - ]); - }); - - it('should skip if search is disabled', async () => { - sut['enabled'] = false; - - await sut.handleIndexAssets(); - - expect(searchMock.importAssets).not.toHaveBeenCalled(); - expect(searchMock.importAlbums).not.toHaveBeenCalled(); - }); - }); - - describe('handleIndexAsset', () => { - it('should skip if search is disabled', () => { - sut['enabled'] = false; - sut.handleIndexAsset({ ids: [assetStub.image.id] }); - }); - - it('should index the asset', () => { - sut.handleIndexAsset({ ids: [assetStub.image.id] }); - }); - }); - - describe('handleIndexAlbums', () => { - it('should skip if search is disabled', async () => { - sut['enabled'] = false; - await sut.handleIndexAlbums(); - }); - - it('should index all the albums', async () => { - albumMock.getAll.mockResolvedValue([albumStub.empty]); - - await sut.handleIndexAlbums(); - - expect(searchMock.importAlbums).toHaveBeenCalledWith([albumStub.empty], true); - }); - }); - - describe('handleIndexAlbum', () => { - it('should skip if search is disabled', () => { - sut['enabled'] = false; - sut.handleIndexAlbum({ ids: [albumStub.empty.id] }); - }); - - it('should index the album', () => { - sut.handleIndexAlbum({ ids: [albumStub.empty.id] }); - }); - }); - - describe('handleRemoveAlbum', () => { - it('should skip if search is disabled', () => { - sut['enabled'] = false; - sut.handleRemoveAlbum({ ids: ['album1'] }); - }); - - it('should remove the album', () => { - sut.handleRemoveAlbum({ ids: ['album1'] }); - }); - }); - - describe('handleRemoveAsset', () => { - it('should skip if search is disabled', () => { - sut['enabled'] = false; - sut.handleRemoveAsset({ ids: ['asset1'] }); - }); - - it('should remove the asset', () => { - sut.handleRemoveAsset({ ids: ['asset1'] }); - }); - }); - - describe('handleIndexFaces', () => { - it('should call done, even when there are no faces', async () => { - personMock.getAllFaces.mockResolvedValue([]); - - await sut.handleIndexFaces(); - - expect(searchMock.importFaces).toHaveBeenCalledWith([], true); - }); - - it('should index all the faces', async () => { - personMock.getAllFaces.mockResolvedValue([faceStub.face1]); - - await sut.handleIndexFaces(); - - expect(searchMock.importFaces.mock.calls).toEqual([ - [ - [ - { - id: 'asset-id|person-1', - ownerId: 'user-id', - assetId: 'asset-id', - personId: 'person-1', - embedding: [1, 2, 3, 4], - }, - ], - false, - ], - [[], true], - ]); - }); - - it('should skip if search is disabled', async () => { - sut['enabled'] = false; - - await sut.handleIndexFaces(); - - expect(searchMock.importFaces).not.toHaveBeenCalled(); - }); - }); - - describe('handleIndexAsset', () => { - it('should skip if search is disabled', async () => { - sut['enabled'] = false; - await sut.handleIndexFace({ assetId: 'asset-1', personId: 'person-1' }); - - expect(searchMock.importFaces).not.toHaveBeenCalled(); - expect(personMock.getFacesByIds).not.toHaveBeenCalled(); - }); - - it('should index the face', async () => { - personMock.getFacesByIds.mockResolvedValue([faceStub.face1]); - - await sut.handleIndexFace({ assetId: 'asset-1', personId: 'person-1' }); - - expect(personMock.getFacesByIds).toHaveBeenCalledWith([{ assetId: 'asset-1', personId: 'person-1' }]); - }); - }); - - describe('handleRemoveFace', () => { - it('should skip if search is disabled', () => { - sut['enabled'] = false; - sut.handleRemoveFace({ assetId: 'asset-1', personId: 'person-1' }); - }); - - it('should remove the face', () => { - sut.handleRemoveFace({ assetId: 'asset-1', personId: 'person-1' }); - }); - }); - - describe('flush', () => { - it('should flush queued album updates', async () => { - albumMock.getByIds.mockResolvedValue([albumStub.empty]); - - sut.handleIndexAlbum({ ids: ['album1'] }); - - jest.runOnlyPendingTimers(); - - await asyncTick(4); - - expect(albumMock.getByIds).toHaveBeenCalledWith(['album1']); - expect(searchMock.importAlbums).toHaveBeenCalledWith([albumStub.empty], false); - }); - - it('should flush queued album deletes', async () => { - sut.handleRemoveAlbum({ ids: ['album1'] }); - - jest.runOnlyPendingTimers(); - - await asyncTick(4); - - expect(searchMock.deleteAlbums).toHaveBeenCalledWith(['album1']); - }); - - it('should flush queued asset updates', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); - - sut.handleIndexAsset({ ids: ['asset1'] }); - - jest.runOnlyPendingTimers(); - - await asyncTick(4); - - expect(assetMock.getByIds).toHaveBeenCalledWith(['asset1']); - expect(searchMock.importAssets).toHaveBeenCalledWith([assetStub.image], false); - }); - - it('should flush queued asset deletes', async () => { - sut.handleRemoveAsset({ ids: ['asset1'] }); - - jest.runOnlyPendingTimers(); - - await asyncTick(4); - - expect(searchMock.deleteAssets).toHaveBeenCalledWith(['asset1']); + await expect(sut.search(authStub.user1, dto)).rejects.toThrow('CLIP is not enabled'); + await expect(sut.search(authStub.user1, dto)).rejects.toThrow('CLIP is not enabled'); }); }); }); diff --git a/server/src/domain/search/search.service.ts b/server/src/domain/search/search.service.ts index be88f29e60630..3347909fccbad 100644 --- a/server/src/domain/search/search.service.ts +++ b/server/src/domain/search/search.service.ts @@ -1,396 +1,99 @@ -import { AlbumEntity, AssetEntity, AssetFaceEntity } from '@app/infra/entities'; +import { AssetEntity } from '@app/infra/entities'; import { Inject, Injectable, Logger } from '@nestjs/common'; -import { mapAlbumWithAssets } from '../album'; import { AssetResponseDto, mapAsset } from '../asset'; import { AuthUserDto } from '../auth'; -import { usePagination } from '../domain.util'; -import { IAssetFaceJob, IBulkEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job'; -import { PersonResponseDto } from '../person/person.dto'; +import { PersonResponseDto } from '../person'; import { - AssetFaceId, - IAlbumRepository, IAssetRepository, - IJobRepository, IMachineLearningRepository, IPersonRepository, - ISearchRepository, + ISmartInfoRepository, ISystemConfigRepository, - OwnedFaceEntity, - SearchCollection, SearchExploreItem, - SearchResult, SearchStrategy, } from '../repositories'; import { FeatureFlag, SystemConfigCore } from '../system-config'; import { SearchDto, SearchPeopleDto } from './dto'; import { SearchResponseDto } from './response-dto'; -interface SyncQueue { - upsert: Set; - delete: Set; -} - @Injectable() export class SearchService { private logger = new Logger(SearchService.name); - private enabled = false; - private timer: NodeJS.Timeout | null = null; private configCore: SystemConfigCore; - private albumQueue: SyncQueue = { - upsert: new Set(), - delete: new Set(), - }; - - private assetQueue: SyncQueue = { - upsert: new Set(), - delete: new Set(), - }; - - private faceQueue: SyncQueue = { - upsert: new Set(), - delete: new Set(), - }; - constructor( - @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, - @Inject(IAssetRepository) private assetRepository: IAssetRepository, - @Inject(IJobRepository) private jobRepository: IJobRepository, + @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, @Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository, @Inject(IPersonRepository) private personRepository: IPersonRepository, - @Inject(ISearchRepository) private searchRepository: ISearchRepository, - @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, + @Inject(ISmartInfoRepository) private smartInfoRepository: ISmartInfoRepository, + @Inject(IAssetRepository) private assetRepository: IAssetRepository, ) { this.configCore = SystemConfigCore.create(configRepository); } - teardown() { - if (this.timer) { - clearInterval(this.timer); - this.timer = null; - } - } - - async init() { - this.enabled = await this.configCore.hasFeature(FeatureFlag.SEARCH); - if (!this.enabled) { - return; - } - - this.logger.log('Running bootstrap'); - await this.searchRepository.setup(); - - const migrationStatus = await this.searchRepository.checkMigrationStatus(); - if (migrationStatus[SearchCollection.ASSETS]) { - this.logger.debug('Queueing job to re-index all assets'); - await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSETS }); - } - if (migrationStatus[SearchCollection.ALBUMS]) { - this.logger.debug('Queueing job to re-index all albums'); - await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ALBUMS }); - } - if (migrationStatus[SearchCollection.FACES]) { - this.logger.debug('Queueing job to re-index all faces'); - await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_FACES }); - } - - this.timer = setInterval(() => this.flush(), 5_000); + async searchPerson(authUser: AuthUserDto, dto: SearchPeopleDto): Promise { + return this.personRepository.getByName(authUser.id, dto.name, { withHidden: dto.withHidden }); } async getExploreData(authUser: AuthUserDto): Promise[]> { await this.configCore.requireFeature(FeatureFlag.SEARCH); - - const results = await this.searchRepository.explore(authUser.id); - const lookup = await this.getLookupMap( - results.reduce( - (ids: string[], result: SearchExploreItem) => [ - ...ids, - ...result.items.map((item) => item.data.id), - ], - [], - ), - ); + const options = { maxFields: 12, minAssetsPerField: 5 }; + const results = await Promise.all([ + this.assetRepository.getAssetIdByCity(authUser.id, options), + this.assetRepository.getAssetIdByTag(authUser.id, options), + ]); + const assetIds = new Set(results.flatMap((field) => field.items.map((item) => item.data))); + const assets = await this.assetRepository.getByIds(Array.from(assetIds)); + const assetMap = new Map(assets.map((asset) => [asset.id, mapAsset(asset)])); return results.map(({ fieldName, items }) => ({ fieldName, - items: items - .map(({ value, data }) => ({ value, data: lookup[data.id] })) - .filter(({ data }) => !!data) - .map(({ value, data }) => ({ value, data: mapAsset(data) })), + items: items.map(({ value, data }) => ({ value, data: assetMap.get(data) as AssetResponseDto })), })); } async search(authUser: AuthUserDto, dto: SearchDto): Promise { const { machineLearning } = await this.configCore.getConfig(); - await this.configCore.requireFeature(FeatureFlag.SEARCH); - - const query = dto.q || dto.query || '*'; + const query = dto.q || dto.query; + if (!query) { + throw new Error('Missing query'); + } const hasClip = machineLearning.enabled && machineLearning.clip.enabled; - const strategy = dto.clip && hasClip ? SearchStrategy.CLIP : SearchStrategy.TEXT; - const filters = { userId: authUser.id, ...dto }; + if (dto.clip && !hasClip) { + throw new Error('CLIP is not enabled'); + } + const strategy = dto.clip ? SearchStrategy.CLIP : SearchStrategy.TEXT; + + let assets: AssetEntity[] = []; - let assets: SearchResult; switch (strategy) { case SearchStrategy.CLIP: - const { - machineLearning: { clip }, - } = await this.configCore.getConfig(); - const embedding = await this.machineLearning.encodeText(machineLearning.url, { text: query }, clip); - assets = await this.searchRepository.vectorSearch(embedding, filters); + const embedding = await this.machineLearning.encodeText( + machineLearning.url, + { text: query }, + machineLearning.clip, + ); + assets = await this.smartInfoRepository.searchCLIP({ ownerId: authUser.id, embedding, numResults: 100 }); break; case SearchStrategy.TEXT: + assets = await this.assetRepository.searchMetadata(query, authUser.id, { numResults: 250 }); default: - assets = await this.searchRepository.searchAssets(query, filters); break; } - const albums = await this.searchRepository.searchAlbums(query, filters); - const lookup = await this.getLookupMap(assets.items.map((asset) => asset.id)); - return { - albums: { ...albums, items: albums.items.map(mapAlbumWithAssets) }, + albums: { + total: 0, + count: 0, + items: [], + facets: [], + }, assets: { - ...assets, - items: assets.items - .map((item) => lookup[item.id]) - .filter((item) => !!item) - .map((asset) => mapAsset(asset)), + total: assets.length, + count: assets.length, + items: assets.map((asset) => mapAsset(asset)), + facets: [], }, }; } - - searchPerson(authUser: AuthUserDto, dto: SearchPeopleDto): Promise { - return this.personRepository.getByName(authUser.id, dto.name, { withHidden: dto.withHidden }); - } - - async handleIndexAlbums() { - if (!this.enabled) { - return false; - } - - const albums = this.patchAlbums(await this.albumRepository.getAll()); - this.logger.log(`Indexing ${albums.length} albums`); - await this.searchRepository.importAlbums(albums, true); - - return true; - } - - async handleIndexAssets() { - if (!this.enabled) { - return false; - } - - // TODO: do this in batches based on searchIndexVersion - const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => - this.assetRepository.getAll(pagination, { isVisible: true }), - ); - - for await (const assets of assetPagination) { - this.logger.debug(`Indexing ${assets.length} assets`); - - const patchedAssets = this.patchAssets(assets); - await this.searchRepository.importAssets(patchedAssets, false); - } - - await this.searchRepository.importAssets([], true); - - this.logger.debug('Finished re-indexing all assets'); - - return false; - } - - async handleIndexFaces() { - if (!this.enabled) { - return false; - } - await this.searchRepository.deleteAllFaces(); - - // TODO: do this in batches based on searchIndexVersion - const faces = this.patchFaces(await this.personRepository.getAllFaces()); - this.logger.log(`Indexing ${faces.length} faces`); - - const chunkSize = 1000; - for (let i = 0; i < faces.length; i += chunkSize) { - await this.searchRepository.importFaces(faces.slice(i, i + chunkSize), false); - } - - await this.searchRepository.importFaces([], true); - - this.logger.debug('Finished re-indexing all faces'); - - return true; - } - - handleIndexAlbum({ ids }: IBulkEntityJob) { - if (!this.enabled) { - return false; - } - - for (const id of ids) { - this.albumQueue.upsert.add(id); - } - - return true; - } - - handleIndexAsset({ ids }: IBulkEntityJob) { - if (!this.enabled) { - return false; - } - - for (const id of ids) { - this.assetQueue.upsert.add(id); - } - - return true; - } - - async handleIndexFace({ assetId, personId }: IAssetFaceJob) { - if (!this.enabled) { - return false; - } - - // immediately push to typesense - await this.searchRepository.importFaces(await this.idsToFaces([{ assetId, personId }]), false); - - return true; - } - - handleRemoveAlbum({ ids }: IBulkEntityJob) { - if (!this.enabled) { - return false; - } - - for (const id of ids) { - this.albumQueue.delete.add(id); - } - - return true; - } - - handleRemoveAsset({ ids }: IBulkEntityJob) { - if (!this.enabled) { - return false; - } - - for (const id of ids) { - this.assetQueue.delete.add(id); - } - - return true; - } - - handleRemoveFace({ assetId, personId }: IAssetFaceJob) { - if (!this.enabled) { - return false; - } - - this.faceQueue.delete.add(this.asKey({ assetId, personId })); - - return true; - } - - private async flush() { - if (this.albumQueue.upsert.size > 0) { - const ids = [...this.albumQueue.upsert.keys()]; - const items = await this.idsToAlbums(ids); - this.logger.debug(`Flushing ${items.length} album upserts`); - await this.searchRepository.importAlbums(items, false); - this.albumQueue.upsert.clear(); - } - - if (this.albumQueue.delete.size > 0) { - const ids = [...this.albumQueue.delete.keys()]; - this.logger.debug(`Flushing ${ids.length} album deletes`); - await this.searchRepository.deleteAlbums(ids); - this.albumQueue.delete.clear(); - } - - if (this.assetQueue.upsert.size > 0) { - const ids = [...this.assetQueue.upsert.keys()]; - const items = await this.idsToAssets(ids); - this.logger.debug(`Flushing ${items.length} asset upserts`); - await this.searchRepository.importAssets(items, false); - this.assetQueue.upsert.clear(); - } - - if (this.assetQueue.delete.size > 0) { - const ids = [...this.assetQueue.delete.keys()]; - this.logger.debug(`Flushing ${ids.length} asset deletes`); - await this.searchRepository.deleteAssets(ids); - this.assetQueue.delete.clear(); - } - - if (this.faceQueue.upsert.size > 0) { - const ids = [...this.faceQueue.upsert.keys()].map((key) => this.asParts(key)); - const items = await this.idsToFaces(ids); - this.logger.debug(`Flushing ${items.length} face upserts`); - await this.searchRepository.importFaces(items, false); - this.faceQueue.upsert.clear(); - } - - if (this.faceQueue.delete.size > 0) { - const ids = [...this.faceQueue.delete.keys()]; - this.logger.debug(`Flushing ${ids.length} face deletes`); - await this.searchRepository.deleteFaces(ids); - this.faceQueue.delete.clear(); - } - } - - private async idsToAlbums(ids: string[]): Promise { - const entities = await this.albumRepository.getByIds(ids); - return this.patchAlbums(entities); - } - - private async idsToAssets(ids: string[]): Promise { - const entities = await this.assetRepository.getByIds(ids); - return this.patchAssets(entities.filter((entity) => entity.isVisible)); - } - - private async idsToFaces(ids: AssetFaceId[]): Promise { - return this.patchFaces(await this.personRepository.getFacesByIds(ids)); - } - - private patchAssets(assets: AssetEntity[]): AssetEntity[] { - return assets; - } - - private patchAlbums(albums: AlbumEntity[]): AlbumEntity[] { - return albums.map((entity) => ({ ...entity, assets: [] })); - } - - private patchFaces(faces: AssetFaceEntity[]): OwnedFaceEntity[] { - const results: OwnedFaceEntity[] = []; - for (const face of faces) { - if (face.personId) { - results.push({ - id: this.asKey(face as AssetFaceId), - ownerId: face.asset.ownerId, - assetId: face.assetId, - personId: face.personId, - embedding: face.embedding, - }); - } - } - - return results; - } - - private asKey(face: AssetFaceId): string { - return `${face.assetId}|${face.personId}`; - } - - private asParts(key: string): AssetFaceId { - const [assetId, personId] = key.split('|'); - return { assetId, personId }; - } - - private async getLookupMap(assetIds: string[]) { - const assets = await this.assetRepository.getByIds(assetIds); - const lookup: Record = {}; - for (const asset of assets) { - lookup[asset.id] = asset; - } - return lookup; - } } diff --git a/server/src/domain/smart-info/smart-info.constant.ts b/server/src/domain/smart-info/smart-info.constant.ts new file mode 100644 index 0000000000000..6fd863495ac3d --- /dev/null +++ b/server/src/domain/smart-info/smart-info.constant.ts @@ -0,0 +1,107 @@ +export type ModelInfo = { + dimSize: number; +}; + +export const CLIP_MODEL_INFO: Record = { + RN50__openai: { + dimSize: 1024, + }, + RN50__yfcc15m: { + dimSize: 1024, + }, + RN50__cc12m: { + dimSize: 1024, + }, + RN101__openai: { + dimSize: 512, + }, + RN101__yfcc15m: { + dimSize: 512, + }, + RN50x4__openai: { + dimSize: 640, + }, + RN50x16__openai: { + dimSize: 768, + }, + RN50x64__openai: { + dimSize: 1024, + }, + 'ViT-B-32__openai': { + dimSize: 512, + }, + 'ViT-B-32__laion2b_e16': { + dimSize: 512, + }, + 'ViT-B-32__laion400m_e31': { + dimSize: 512, + }, + 'ViT-B-32__laion400m_e32': { + dimSize: 512, + }, + 'ViT-B-32__laion2b-s34b-b79k': { + dimSize: 512, + }, + 'ViT-B-16__openai': { + dimSize: 512, + }, + 'ViT-B-16__laion400m_e31': { + dimSize: 512, + }, + 'ViT-B-16__laion400m_e32': { + dimSize: 512, + }, + 'ViT-B-16-plus-240__laion400m_e31': { + dimSize: 640, + }, + 'ViT-B-16-plus-240__laion400m_e32': { + dimSize: 640, + }, + 'ViT-L-14__openai': { + dimSize: 768, + }, + 'ViT-L-14__laion400m_e31': { + dimSize: 768, + }, + 'ViT-L-14__laion400m_e32': { + dimSize: 768, + }, + 'ViT-L-14__laion2b-s32b-b82k': { + dimSize: 768, + }, + 'ViT-L-14-336__openai': { + dimSize: 768, + }, + 'ViT-H-14__laion2b-s32b-b79k': { + dimSize: 1024, + }, + 'ViT-g-14__laion2b-s12b-b42k': { + dimSize: 1024, + }, + 'LABSE-Vit-L-14': { + dimSize: 768, + }, + 'XLM-Roberta-Large-Vit-B-32': { + dimSize: 512, + }, + 'XLM-Roberta-Large-Vit-B-16Plus': { + dimSize: 640, + }, + 'XLM-Roberta-Large-Vit-L-14': { + dimSize: 768, + }, +}; + +export function cleanModelName(modelName: string): string { + const tokens = modelName.split('/'); + return tokens[tokens.length - 1].replace(/:/g, '_'); +} + +export function getCLIPModelInfo(modelName: string): ModelInfo { + const modelInfo = CLIP_MODEL_INFO[cleanModelName(modelName)]; + if (!modelInfo) { + throw new Error(`Unknown CLIP model: ${modelName}`); + } + + return modelInfo; +} 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 96e2af4b3d3e8..e3b5acce3f06f 100644 --- a/server/src/domain/smart-info/smart-info.service.spec.ts +++ b/server/src/domain/smart-info/smart-info.service.spec.ts @@ -16,6 +16,7 @@ import { ISystemConfigRepository, WithoutProperty, } from '../repositories'; +import { cleanModelName, getCLIPModelInfo } from './smart-info.constant'; import { SmartInfoService } from './smart-info.service'; const asset = { @@ -195,10 +196,29 @@ describe(SmartInfoService.name, () => { { imagePath: 'path/to/resize.ext' }, { enabled: true, modelName: 'ViT-B-32__openai' }, ); - expect(smartMock.upsert).toHaveBeenCalledWith({ - assetId: 'asset-1', - clipEmbedding: [0.01, 0.02, 0.03], - }); + expect(smartMock.upsert).toHaveBeenCalledWith( + { + assetId: 'asset-1', + }, + [0.01, 0.02, 0.03], + ); + }); + }); + + describe('cleanModelName', () => { + it('should clean name', () => { + expect(cleanModelName('ViT-B-32::openai')).toEqual('ViT-B-32__openai'); + expect(cleanModelName('M-CLIP/XLM-Roberta-Large-Vit-L-14')).toEqual('XLM-Roberta-Large-Vit-L-14'); + }); + }); + + describe('getCLIPModelInfo', () => { + it('should return the model info', () => { + expect(getCLIPModelInfo('ViT-B-32__openai')).toEqual({ dimSize: 512 }); + }); + + it('should throw an error if the model is not present', () => { + expect(() => getCLIPModelInfo('test-model')).toThrow('Unknown CLIP model: test-model'); }); }); }); diff --git a/server/src/domain/smart-info/smart-info.service.ts b/server/src/domain/smart-info/smart-info.service.ts index 743e23b9f6fe2..d9157c2be493e 100644 --- a/server/src/domain/smart-info/smart-info.service.ts +++ b/server/src/domain/smart-info/smart-info.service.ts @@ -1,6 +1,7 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { setTimeout } from 'timers/promises'; import { usePagination } from '../domain.util'; -import { IBaseJob, IEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job'; +import { IBaseJob, IEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobName, QueueName } from '../job'; import { IAssetRepository, IJobRepository, @@ -14,6 +15,7 @@ import { SystemConfigCore } from '../system-config'; @Injectable() export class SmartInfoService { private configCore: SystemConfigCore; + private logger = new Logger(SmartInfoService.name); constructor( @Inject(IAssetRepository) private assetRepository: IAssetRepository, @@ -25,6 +27,24 @@ export class SmartInfoService { this.configCore = SystemConfigCore.create(configRepository); } + async init() { + await this.jobRepository.pause(QueueName.CLIP_ENCODING); + + let { isActive } = await this.jobRepository.getQueueStatus(QueueName.CLIP_ENCODING); + while (isActive) { + this.logger.verbose('Waiting for CLIP encoding queue to stop...'); + await setTimeout(1000).then(async () => { + ({ isActive } = await this.jobRepository.getQueueStatus(QueueName.CLIP_ENCODING)); + }); + } + + const { machineLearning } = await this.configCore.getConfig(); + + await this.repository.init(machineLearning.clip.modelName); + + await this.jobRepository.resume(QueueName.CLIP_ENCODING); + } + async handleQueueObjectTagging({ force }: IBaseJob) { const { machineLearning } = await this.configCore.getConfig(); if (!machineLearning.enabled || !machineLearning.classification.enabled) { @@ -105,7 +125,7 @@ export class SmartInfoService { machineLearning.clip, ); - await this.repository.upsert({ assetId: asset.id, clipEmbedding: clipEmbedding }); + await this.repository.upsert({ assetId: asset.id }, clipEmbedding); return true; } diff --git a/server/src/domain/system-config/system-config.core.ts b/server/src/domain/system-config/system-config.core.ts index bfab4bb4fc6cc..7433bac79f615 100644 --- a/server/src/domain/system-config/system-config.core.ts +++ b/server/src/domain/system-config/system-config.core.ts @@ -210,7 +210,7 @@ export class SystemConfigCore { [FeatureFlag.MAP]: config.map.enabled, [FeatureFlag.REVERSE_GEOCODING]: config.reverseGeocoding.enabled, [FeatureFlag.SIDECAR]: true, - [FeatureFlag.SEARCH]: process.env.TYPESENSE_ENABLED !== 'false', + [FeatureFlag.SEARCH]: true, [FeatureFlag.TRASH]: config.trash.enabled, // TODO: use these instead of `POST oauth/config` 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 6ff4ac5c4564f..30e216a7d3ba5 100644 --- a/server/src/domain/system-config/system-config.service.spec.ts +++ b/server/src/domain/system-config/system-config.service.spec.ts @@ -13,7 +13,12 @@ import { import { BadRequestException } from '@nestjs/common'; import { newCommunicationRepositoryMock, newJobRepositoryMock, newSystemConfigRepositoryMock } from '@test'; import { JobName, QueueName } from '../job'; -import { ICommunicationRepository, IJobRepository, ISystemConfigRepository } from '../repositories'; +import { + ICommunicationRepository, + IJobRepository, + ISmartInfoRepository, + ISystemConfigRepository, +} from '../repositories'; import { defaults, SystemConfigValidator } from './system-config.core'; import { SystemConfigService } from './system-config.service'; @@ -133,13 +138,14 @@ describe(SystemConfigService.name, () => { let configMock: jest.Mocked; let communicationMock: jest.Mocked; let jobMock: jest.Mocked; + let smartInfoMock: jest.Mocked; beforeEach(async () => { delete process.env.IMMICH_CONFIG_FILE; configMock = newSystemConfigRepositoryMock(); communicationMock = newCommunicationRepositoryMock(); jobMock = newJobRepositoryMock(); - sut = new SystemConfigService(configMock, communicationMock, jobMock); + sut = new SystemConfigService(configMock, communicationMock, jobMock, smartInfoMock); }); it('should work', () => { diff --git a/server/src/domain/system-config/system-config.service.ts b/server/src/domain/system-config/system-config.service.ts index c81c462e8983f..6a53a3e46551b 100644 --- a/server/src/domain/system-config/system-config.service.ts +++ b/server/src/domain/system-config/system-config.service.ts @@ -1,6 +1,12 @@ import { Inject, Injectable } from '@nestjs/common'; import { JobName } from '../job'; -import { CommunicationEvent, ICommunicationRepository, IJobRepository, ISystemConfigRepository } from '../repositories'; +import { + CommunicationEvent, + ICommunicationRepository, + IJobRepository, + ISmartInfoRepository, + ISystemConfigRepository, +} from '../repositories'; import { SystemConfigDto, mapConfig } from './dto/system-config.dto'; import { SystemConfigTemplateStorageOptionDto } from './response-dto/system-config-template-storage-option.dto'; import { @@ -22,6 +28,7 @@ export class SystemConfigService { @Inject(ISystemConfigRepository) private repository: ISystemConfigRepository, @Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, + @Inject(ISmartInfoRepository) private smartInfoRepository: ISmartInfoRepository, ) { this.core = SystemConfigCore.create(repository); } @@ -41,10 +48,14 @@ export class SystemConfigService { } async updateConfig(dto: SystemConfigDto): Promise { - const config = await this.core.updateConfig(dto); + const oldConfig = await this.core.getConfig(); + const newConfig = await this.core.updateConfig(dto); await this.jobRepository.queue({ name: JobName.SYSTEM_CONFIG_CHANGE }); this.communicationRepository.broadcast(CommunicationEvent.CONFIG_UPDATE, {}); - return mapConfig(config); + if (oldConfig.machineLearning.clip.modelName !== newConfig.machineLearning.clip.modelName) { + await this.smartInfoRepository.init(newConfig.machineLearning.clip.modelName); + } + return mapConfig(newConfig); } async refreshConfig() { diff --git a/server/src/immich/api-v1/asset/asset-repository.ts b/server/src/immich/api-v1/asset/asset-repository.ts index 13cf6bf17c6dd..88e558680f6da 100644 --- a/server/src/immich/api-v1/asset/asset-repository.ts +++ b/server/src/immich/api-v1/asset/asset-repository.ts @@ -1,6 +1,6 @@ import { AssetCreate } from '@app/domain'; import { AssetEntity } from '@app/infra/entities'; -import OptionalBetween from '@app/infra/utils/optional-between.util'; +import { OptionalBetween } from '@app/infra/infra.utils'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { In } from 'typeorm/find-options/operator/In'; diff --git a/server/src/immich/app.module.ts b/server/src/immich/app.module.ts index cafaefb3f4132..3694626f26857 100644 --- a/server/src/immich/app.module.ts +++ b/server/src/immich/app.module.ts @@ -1,7 +1,7 @@ import { DomainModule } from '@app/domain'; import { InfraModule } from '@app/infra'; import { AssetEntity } from '@app/infra/entities'; -import { Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { Module, OnModuleInit } from '@nestjs/common'; import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; import { ScheduleModule } from '@nestjs/schedule'; import { TypeOrmModule } from '@nestjs/typeorm'; @@ -73,14 +73,10 @@ import { ErrorInterceptor, FileUploadInterceptor } from './interceptors'; FileUploadInterceptor, ], }) -export class AppModule implements OnModuleInit, OnModuleDestroy { +export class AppModule implements OnModuleInit { constructor(private appService: AppService) {} async onModuleInit() { await this.appService.init(); } - - async onModuleDestroy() { - await this.appService.destroy(); - } } diff --git a/server/src/immich/app.service.ts b/server/src/immich/app.service.ts index ef9975d8c428f..0683b65515221 100644 --- a/server/src/immich/app.service.ts +++ b/server/src/immich/app.service.ts @@ -1,4 +1,4 @@ -import { JobService, LibraryService, ONE_HOUR, SearchService, ServerInfoService, StorageService } from '@app/domain'; +import { JobService, LibraryService, ONE_HOUR, ServerInfoService, StorageService } from '@app/domain'; import { Injectable, Logger } from '@nestjs/common'; import { Cron, CronExpression, Interval } from '@nestjs/schedule'; @@ -9,7 +9,6 @@ export class AppService { constructor( private jobService: JobService, private libraryService: LibraryService, - private searchService: SearchService, private storageService: StorageService, private serverService: ServerInfoService, ) {} @@ -26,13 +25,7 @@ export class AppService { async init() { this.storageService.init(); - await this.searchService.init(); await this.serverService.handleVersionCheck(); this.logger.log(`Feature Flags: ${JSON.stringify(await this.serverService.getFeatures(), null, 2)}`); - await this.libraryService.init(); - } - - async destroy() { - this.searchService.teardown(); } } diff --git a/server/src/immich/main.ts b/server/src/immich/main.ts index 832d988af12c9..1ee9dedc8bfa3 100644 --- a/server/src/immich/main.ts +++ b/server/src/immich/main.ts @@ -1,5 +1,5 @@ import { envName, getLogLevels, isDev, serverVersion } from '@app/domain'; -import { RedisIoAdapter } from '@app/infra'; +import { RedisIoAdapter, enablePrefilter } from '@app/infra'; import { Logger } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import { NestExpressApplication } from '@nestjs/platform-express'; @@ -29,6 +29,8 @@ export async function bootstrap() { app.useStaticAssets('www'); app.use(indexFallback(excludePaths)); + await enablePrefilter(); + const server = await app.listen(port); server.requestTimeout = 30 * 60 * 1000; diff --git a/server/src/infra/database-locks.ts b/server/src/infra/database-locks.ts new file mode 100644 index 0000000000000..f46027316f312 --- /dev/null +++ b/server/src/infra/database-locks.ts @@ -0,0 +1,41 @@ +import { dataSource } from '@app/infra'; +import AsyncLock from 'async-lock'; +export enum DatabaseLock { + GeodataImport = 100, + CLIPDimSize = 512, +} + +export async function acquireLock(lock: DatabaseLock): Promise { + return dataSource.query('SELECT pg_advisory_lock($1)', [lock]); +} + +export async function releaseLock(lock: DatabaseLock): Promise { + return dataSource.query('SELECT pg_advisory_unlock($1)', [lock]); +} + +export const asyncLock = new AsyncLock(); + +export function RequireLock( + lock: DatabaseLock, +): (target: any, propertyKey: string, descriptor: PropertyDescriptor) => void { + return function (target: any, propertyKey: string, descriptor: PropertyDescriptor): void { + const originalMethod = descriptor.value; + descriptor.value = async function (...args: any[]): Promise { + if (!dataSource.isInitialized) { + await dataSource.initialize(); + } + + let res; + await asyncLock.acquire(DatabaseLock[lock], async () => { + try { + await acquireLock(lock); + res = await originalMethod.apply(this, args); + } finally { + await releaseLock(lock); + } + }); + + return res as any; + }; + }; +} diff --git a/server/src/infra/database.config.ts b/server/src/infra/database.config.ts index 089fd6878c76d..191c428337381 100644 --- a/server/src/infra/database.config.ts +++ b/server/src/infra/database.config.ts @@ -25,3 +25,10 @@ export const databaseConfig: PostgresConnectionOptions = { // this export is used by TypeORM commands in package.json#scripts export const dataSource = new DataSource(databaseConfig); + +export async function enablePrefilter() { + if (!dataSource.isInitialized) { + await dataSource.initialize(); + } + await dataSource.query(`SET vectors.enable_prefilter = on`); +} diff --git a/server/src/infra/entities/asset-face.entity.ts b/server/src/infra/entities/asset-face.entity.ts index c47074d2ea672..4e9f83370e5fb 100644 --- a/server/src/infra/entities/asset-face.entity.ts +++ b/server/src/infra/entities/asset-face.entity.ts @@ -2,7 +2,7 @@ import { Column, Entity, Index, ManyToOne, PrimaryGeneratedColumn } from 'typeor import { AssetEntity } from './asset.entity'; import { PersonEntity } from './person.entity'; -@Entity('asset_faces') +@Entity('asset_faces', { synchronize: false }) @Index(['personId', 'assetId']) export class AssetFaceEntity { @PrimaryGeneratedColumn('uuid') @@ -14,12 +14,9 @@ export class AssetFaceEntity { @Column({ nullable: true, type: 'uuid' }) personId!: string | null; - @Column({ - type: 'float4', - array: true, - nullable: true, - }) - embedding!: number[] | null; + @Index('face_index', { synchronize: false }) + @Column({ type: 'float4', array: true, select: false }) + embedding!: number[]; @Column({ default: 0, type: 'int' }) imageWidth!: number; diff --git a/server/src/infra/entities/asset.entity.ts b/server/src/infra/entities/asset.entity.ts index b1f254da42d9b..07de0068584af 100644 --- a/server/src/infra/entities/asset.entity.ts +++ b/server/src/infra/entities/asset.entity.ts @@ -20,6 +20,7 @@ import { ExifEntity } from './exif.entity'; import { LibraryEntity } from './library.entity'; import { SharedLinkEntity } from './shared-link.entity'; import { SmartInfoEntity } from './smart-info.entity'; +import { SmartSearchEntity } from './smart-search.entity'; import { TagEntity } from './tag.entity'; import { UserEntity } from './user.entity'; @@ -137,6 +138,9 @@ export class AssetEntity { @OneToOne(() => SmartInfoEntity, (smartInfoEntity) => smartInfoEntity.asset) smartInfo?: SmartInfoEntity; + @OneToOne(() => SmartSearchEntity, (smartSearchEntity) => smartSearchEntity.asset) + smartSearch?: SmartSearchEntity; + @ManyToMany(() => TagEntity, (tag) => tag.assets, { cascade: true }) @JoinTable({ name: 'tag_asset' }) tags!: TagEntity[]; diff --git a/server/src/infra/entities/exif.entity.ts b/server/src/infra/entities/exif.entity.ts index 2a10a61a83250..7b465c3c508c9 100644 --- a/server/src/infra/entities/exif.entity.ts +++ b/server/src/infra/entities/exif.entity.ts @@ -46,6 +46,7 @@ export class ExifEntity { @Column({ type: 'varchar', nullable: true }) projectionType!: string | null; + @Index('exif_city') @Column({ type: 'varchar', nullable: true }) city!: string | null; @@ -98,6 +99,7 @@ export class ExifEntity { @Column({ type: 'tsvector', generatedType: 'STORED', + select: false, asExpression: `TO_TSVECTOR('english', COALESCE(make, '') || ' ' || COALESCE(model, '') || ' ' || diff --git a/server/src/infra/entities/index.ts b/server/src/infra/entities/index.ts index 6c662a20adbd2..c4252b655bb9d 100644 --- a/server/src/infra/entities/index.ts +++ b/server/src/infra/entities/index.ts @@ -15,6 +15,7 @@ import { PartnerEntity } from './partner.entity'; import { PersonEntity } from './person.entity'; import { SharedLinkEntity } from './shared-link.entity'; import { SmartInfoEntity } from './smart-info.entity'; +import { SmartSearchEntity } from './smart-search.entity'; import { SystemConfigEntity } from './system-config.entity'; import { SystemMetadataEntity } from './system-metadata.entity'; import { TagEntity } from './tag.entity'; @@ -38,6 +39,7 @@ export * from './partner.entity'; export * from './person.entity'; export * from './shared-link.entity'; export * from './smart-info.entity'; +export * from './smart-search.entity'; export * from './system-config.entity'; export * from './system-metadata.entity'; export * from './tag.entity'; @@ -61,6 +63,7 @@ export const databaseEntities = [ PersonEntity, SharedLinkEntity, SmartInfoEntity, + SmartSearchEntity, SystemConfigEntity, SystemMetadataEntity, TagEntity, diff --git a/server/src/infra/entities/smart-info.entity.ts b/server/src/infra/entities/smart-info.entity.ts index cd6b9e17239dd..2606de60ebee6 100644 --- a/server/src/infra/entities/smart-info.entity.ts +++ b/server/src/infra/entities/smart-info.entity.ts @@ -1,7 +1,7 @@ import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn } from 'typeorm'; import { AssetEntity } from './asset.entity'; -@Entity('smart_info') +@Entity('smart_info', { synchronize: false }) export class SmartInfoEntity { @OneToOne(() => AssetEntity, { onDelete: 'CASCADE', nullable: true }) @JoinColumn({ name: 'assetId', referencedColumnName: 'id' }) @@ -15,11 +15,4 @@ export class SmartInfoEntity { @Column({ type: 'text', array: true, nullable: true }) objects!: string[] | null; - - @Column({ - type: 'float4', - array: true, - nullable: true, - }) - clipEmbedding!: number[] | null; } diff --git a/server/src/infra/entities/smart-search.entity.ts b/server/src/infra/entities/smart-search.entity.ts new file mode 100644 index 0000000000000..2b295ac909cfb --- /dev/null +++ b/server/src/infra/entities/smart-search.entity.ts @@ -0,0 +1,20 @@ +import { Column, Entity, Index, JoinColumn, OneToOne, PrimaryColumn } from 'typeorm'; +import { AssetEntity } from './asset.entity'; + +@Entity('smart_search', { synchronize: false }) +export class SmartSearchEntity { + @OneToOne(() => AssetEntity, { onDelete: 'CASCADE', nullable: true }) + @JoinColumn({ name: 'assetId', referencedColumnName: 'id' }) + asset?: AssetEntity; + + @PrimaryColumn() + assetId!: string; + + @Index('clip_index', { synchronize: false }) + @Column({ + type: 'float4', + array: true, + select: false, + }) + embedding!: number[]; +} diff --git a/server/src/infra/index.ts b/server/src/infra/index.ts index 158385ace54dc..c3b1a4377387a 100644 --- a/server/src/infra/index.ts +++ b/server/src/infra/index.ts @@ -1,3 +1,4 @@ +export * from './database-locks'; export * from './database.config'; export * from './infra.config'; export * from './infra.module'; diff --git a/server/src/infra/infra.config.ts b/server/src/infra/infra.config.ts index 7f242303264fb..5e001d9dc0f63 100644 --- a/server/src/infra/infra.config.ts +++ b/server/src/infra/infra.config.ts @@ -2,7 +2,6 @@ import { QueueName } from '@app/domain'; import { RegisterQueueOptions } from '@nestjs/bullmq'; import { QueueOptions } from 'bullmq'; import { RedisOptions } from 'ioredis'; -import { ConfigurationOptions } from 'typesense/lib/Typesense/Configuration'; function parseRedisConfig(): RedisOptions { if (process.env.IMMICH_TEST_ENV == 'true') { @@ -41,36 +40,3 @@ export const bullConfig: QueueOptions = { }; export const bullQueues: RegisterQueueOptions[] = Object.values(QueueName).map((name) => ({ name })); - -function parseTypeSenseConfig(): ConfigurationOptions { - const typesenseURL = process.env.TYPESENSE_URL; - const common = { - apiKey: process.env.TYPESENSE_API_KEY as string, - numRetries: 15, - retryIntervalSeconds: 4, - connectionTimeoutSeconds: 10, - }; - if (typesenseURL && typesenseURL.startsWith('ha://')) { - try { - const decodedString = Buffer.from(typesenseURL.slice(5), 'base64').toString(); - return { - nodes: JSON.parse(decodedString), - ...common, - }; - } catch (error) { - throw new Error(`Failed to decode typesense options: ${error}`); - } - } - return { - nodes: [ - { - host: process.env.TYPESENSE_HOST || 'typesense', - port: Number(process.env.TYPESENSE_PORT) || 8108, - protocol: process.env.TYPESENSE_PROTOCOL || 'http', - }, - ], - ...common, - }; -} - -export const typesenseConfig: ConfigurationOptions = parseTypeSenseConfig(); diff --git a/server/src/infra/infra.module.ts b/server/src/infra/infra.module.ts index 6cfaebefcb5c2..56ea2b4878027 100644 --- a/server/src/infra/infra.module.ts +++ b/server/src/infra/infra.module.ts @@ -15,7 +15,6 @@ import { IMoveRepository, IPartnerRepository, IPersonRepository, - ISearchRepository, IServerInfoRepository, ISharedLinkRepository, ISmartInfoRepository, @@ -59,7 +58,6 @@ import { SystemConfigRepository, SystemMetadataRepository, TagRepository, - TypesenseRepository, UserRepository, UserTokenRepository, } from './repositories'; @@ -80,7 +78,6 @@ const providers: Provider[] = [ { provide: IMoveRepository, useClass: MoveRepository }, { provide: IPartnerRepository, useClass: PartnerRepository }, { provide: IPersonRepository, useClass: PersonRepository }, - { provide: ISearchRepository, useClass: TypesenseRepository }, { provide: IServerInfoRepository, useClass: ServerInfoRepository }, { provide: ISharedLinkRepository, useClass: SharedLinkRepository }, { provide: ISmartInfoRepository, useClass: SmartInfoRepository }, diff --git a/server/src/infra/infra.utils.ts b/server/src/infra/infra.utils.ts new file mode 100644 index 0000000000000..6956b2fbd73f1 --- /dev/null +++ b/server/src/infra/infra.utils.ts @@ -0,0 +1,42 @@ +import { Paginated, PaginationOptions } from '@app/domain'; +import { Between, FindOneOptions, LessThanOrEqual, MoreThanOrEqual, ObjectLiteral, Repository } from 'typeorm'; + +/** + * Allows optional values unlike the regular Between and uses MoreThanOrEqual + * or LessThanOrEqual when only one parameter is specified. + */ +export function OptionalBetween(from?: T, to?: T) { + if (from && to) { + return Between(from, to); + } else if (from) { + return MoreThanOrEqual(from); + } else if (to) { + return LessThanOrEqual(to); + } +} + +export async function paginate( + repository: Repository, + paginationOptions: PaginationOptions, + searchOptions?: FindOneOptions, +): Paginated { + const items = await repository.find({ + ...searchOptions, + // Take one more item to check if there's a next page + take: paginationOptions.take + 1, + skip: paginationOptions.skip, + }); + + const hasNextPage = items.length > paginationOptions.take; + items.splice(paginationOptions.take); + + return { items, hasNextPage }; +} + +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; +}; diff --git a/server/src/infra/migrations/1700713871511-UsePgVectors.ts b/server/src/infra/migrations/1700713871511-UsePgVectors.ts new file mode 100644 index 0000000000000..62a5a885f792c --- /dev/null +++ b/server/src/infra/migrations/1700713871511-UsePgVectors.ts @@ -0,0 +1,51 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class UsePgVectors1700713871511 implements MigrationInterface { + name = 'UsePgVectors1700713871511'; + + public async up(queryRunner: QueryRunner): Promise { + const faceDimQuery = await queryRunner.query(` + SELECT CARDINALITY(embedding::real[]) as dimsize + FROM asset_faces + LIMIT 1`); + const clipDimQuery = await queryRunner.query(` + SELECT CARDINALITY("clipEmbedding"::real[]) as dimsize + FROM smart_info + LIMIT 1`); + + const faceDimSize = faceDimQuery?.[0]?.['dimsize'] ?? 512; + const clipDimSize = clipDimQuery?.[0]?.['dimsize'] ?? 512; + + await queryRunner.query('CREATE EXTENSION IF NOT EXISTS vectors'); + + await queryRunner.query(` + ALTER TABLE asset_faces + ALTER COLUMN embedding SET NOT NULL, + ALTER COLUMN embedding TYPE vector(${faceDimSize})`); + + await queryRunner.query(` + CREATE TABLE smart_search ( + "assetId" uuid PRIMARY KEY NOT NULL REFERENCES assets(id) ON DELETE CASCADE, + embedding vector(${clipDimSize}) NOT NULL )`); + + await queryRunner.query(` + INSERT INTO smart_search("assetId", embedding) + SELECT si."assetId", si."clipEmbedding" + FROM smart_info si + WHERE "clipEmbedding" IS NOT NULL`); + + await queryRunner.query(`ALTER TABLE smart_info DROP COLUMN IF EXISTS "clipEmbedding"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE asset_faces ALTER COLUMN embedding TYPE real array`); + await queryRunner.query(`ALTER TABLE smart_info ADD COLUMN IF NOT EXISTS "clipEmbedding" TYPE real array`); + await queryRunner.query(` + INSERT INTO smart_info + ("assetId", "clipEmbedding") + SELECT s."assetId", s.embedding + FROM smart_search s + ON CONFLICT (s."assetId") DO UPDATE SET "clipEmbedding" = s.embedding`); + await queryRunner.query(`DROP TABLE IF EXISTS smart_search`); + } +} diff --git a/server/src/infra/migrations/1700713994428-AddCLIPEmbeddingIndex.ts b/server/src/infra/migrations/1700713994428-AddCLIPEmbeddingIndex.ts new file mode 100644 index 0000000000000..7a1a1144d6fa8 --- /dev/null +++ b/server/src/infra/migrations/1700713994428-AddCLIPEmbeddingIndex.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddCLIPEmbeddingIndex1700713994428 implements MigrationInterface { + name = 'AddCLIPEmbeddingIndex1700713994428'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS clip_index ON smart_search + USING vectors (embedding cosine_ops) WITH (options = $$ + [indexing.hnsw] + m = 16 + ef_construction = 300 + $$);`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS clip_index`); + } +} diff --git a/server/src/infra/migrations/1700714033632-AddFaceEmbeddingIndex.ts b/server/src/infra/migrations/1700714033632-AddFaceEmbeddingIndex.ts new file mode 100644 index 0000000000000..0ac7b0cd4cd1f --- /dev/null +++ b/server/src/infra/migrations/1700714033632-AddFaceEmbeddingIndex.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddFaceEmbeddingIndex1700714033632 implements MigrationInterface { + name = 'AddFaceEmbeddingIndex1700714033632'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS face_index ON asset_faces + USING vectors (embedding cosine_ops) WITH (options = $$ + [indexing.hnsw] + m = 16 + ef_construction = 300 + $$);`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS face_index`); + } +} diff --git a/server/src/infra/migrations/1700714072055-AddSmartInfoTagsIndex.ts b/server/src/infra/migrations/1700714072055-AddSmartInfoTagsIndex.ts new file mode 100644 index 0000000000000..b850d3da099f3 --- /dev/null +++ b/server/src/infra/migrations/1700714072055-AddSmartInfoTagsIndex.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddSmartInfoTagsIndex1700714072055 implements MigrationInterface { + name = 'AddSmartInfoTagsIndex1700714072055'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE INDEX IF NOT EXISTS si_tags ON smart_info USING GIN (tags);`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS si_tags;`); + } +} diff --git a/server/src/infra/migrations/1700714140297-CreateSmartInfoTextSearchIndex.ts b/server/src/infra/migrations/1700714140297-CreateSmartInfoTextSearchIndex.ts new file mode 100644 index 0000000000000..b42291f6fdb2f --- /dev/null +++ b/server/src/infra/migrations/1700714140297-CreateSmartInfoTextSearchIndex.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateSmartInfoTextSearchIndex1700714140297 implements MigrationInterface { + name = 'CreateSmartInfoTextSearchIndex1700714140297'; + + public async up(queryRunner: QueryRunner): Promise { + // https://dba.stackexchange.com/a/164081 + await queryRunner.query(` + CREATE OR REPLACE FUNCTION f_concat_ws(text, text[]) + RETURNS text + LANGUAGE sql IMMUTABLE PARALLEL SAFE AS + 'SELECT array_to_string($2, $1)'`); + + await queryRunner.query(` + ALTER TABLE smart_info ADD "smartInfoTextSearchableColumn" tsvector + GENERATED ALWAYS AS ( + TO_TSVECTOR( + 'english', + f_concat_ws( + ' '::text, + COALESCE(tags, array[]::text[]) || COALESCE(objects, array[]::text[]) + ) + ) + ) + STORED NOT NULL`); + + await queryRunner.query(` + CREATE INDEX smart_info_text_searchable_idx + ON smart_info + USING GIN ("smartInfoTextSearchableColumn")`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP FUNCTION IF EXISTS immutable_concat_ws`); + await queryRunner.query(`ALTER TABLE smart_info DROP IF EXISTS "smartInfoTextSearchableColumn"`); + } +} diff --git a/server/src/infra/migrations/1701665867595-AddExifCityIndex.ts b/server/src/infra/migrations/1701665867595-AddExifCityIndex.ts new file mode 100644 index 0000000000000..9979762dc4f34 --- /dev/null +++ b/server/src/infra/migrations/1701665867595-AddExifCityIndex.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddExifCityIndex1701665867595 implements MigrationInterface { + name = 'AddExifCityIndex1701665867595' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE INDEX "exif_city" ON "exif" ("city") `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "public"."exif_city"`); + } + +} diff --git a/server/src/infra/repositories/asset.repository.ts b/server/src/infra/repositories/asset.repository.ts index 09ecf66925642..e96aa636f36e9 100644 --- a/server/src/infra/repositories/asset.repository.ts +++ b/server/src/infra/repositories/asset.repository.ts @@ -1,5 +1,7 @@ import { + AssetBuilderOptions, AssetCreate, + AssetExploreFieldOptions, AssetSearchOptions, AssetStats, AssetStatsOptions, @@ -7,24 +9,25 @@ import { LivePhotoSearchOptions, MapMarker, MapMarkerSearchOptions, + MetadataSearchOptions, MonthDay, Paginated, PaginationOptions, + SearchExploreItem, TimeBucketItem, TimeBucketOptions, TimeBucketSize, - WithoutProperty, WithProperty, + WithoutProperty, } from '@app/domain'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import _ from 'lodash'; import { DateTime } from 'luxon'; import { And, FindOptionsRelations, FindOptionsWhere, In, IsNull, LessThan, Not, Repository } from 'typeorm'; -import { AssetEntity, AssetJobStatusEntity, AssetType, ExifEntity } from '../entities'; +import { AssetEntity, AssetJobStatusEntity, AssetType, ExifEntity, SmartInfoEntity } from '../entities'; import { DummyValue, GenerateSql } from '../infra.util'; -import OptionalBetween from '../utils/optional-between.util'; -import { paginate } from '../utils/pagination.util'; +import { OptionalBetween, paginate } from '../infra.utils'; const DEFAULT_SEARCH_SIZE = 250; @@ -44,6 +47,7 @@ export class AssetRepository implements IAssetRepository { @InjectRepository(AssetEntity) private repository: Repository, @InjectRepository(ExifEntity) private exifRepository: Repository, @InjectRepository(AssetJobStatusEntity) private jobStatusRepository: Repository, + @InjectRepository(SmartInfoEntity) private smartInfoRepository: Repository, ) {} async upsertExif(exif: Partial): Promise { @@ -356,16 +360,20 @@ export class AssetRepository implements IAssetRepository { } @GenerateSql({ params: [DummyValue.UUID] }) - getById(id: string): Promise { - return this.repository.findOne({ - where: { id }, - relations: { + getById(id: string, relations: FindOptionsRelations): Promise { + if (!relations) { + relations = { faces: { person: true, }, library: true, stack: true, - }, + }; + } + + return this.repository.findOne({ + where: { id }, + relations, // We are specifically asking for this asset. Return it even if it is soft deleted withDeleted: true, }); @@ -472,13 +480,13 @@ export class AssetRepository implements IAssetRepository { case WithoutProperty.CLIP_ENCODING: relations = { - smartInfo: true, + smartSearch: true, }; where = { isVisible: true, resizePath: Not(IsNull()), - smartInfo: { - clipEmbedding: IsNull(), + smartSearch: { + embedding: IsNull(), }, }; break; @@ -689,15 +697,82 @@ export class AssetRepository implements IAssetRepository { ); } - private getBuilder(options: TimeBucketOptions) { - const { isArchived, isFavorite, isTrashed, albumId, personId, userIds, withStacked } = options; + @GenerateSql({ params: [DummyValue.UUID, { minAssetsPerField: 5, maxFields: 12 }] }) + async getAssetIdByCity( + ownerId: string, + { minAssetsPerField, maxFields }: AssetExploreFieldOptions, + ): Promise> { + const cte = this.exifRepository + .createQueryBuilder('e') + .select('city') + .groupBy('city') + .having('count(city) >= :minAssetsPerField', { minAssetsPerField }) + .orderBy('random()') + .limit(maxFields); + + const items = await this.getBuilder({ + userIds: [ownerId], + exifInfo: false, + assetType: AssetType.IMAGE, + isArchived: false, + }) + .select('c.city', 'value') + .addSelect('asset.id', 'data') + .distinctOn(['c.city']) + .innerJoin('exif', 'e', 'asset.id = e."assetId"') + .addCommonTableExpression(cte, 'cities') + .innerJoin('cities', 'c', 'c.city = e.city') + .limit(maxFields) + .getRawMany(); + + return { fieldName: 'exifInfo.city', items }; + } + + @GenerateSql({ params: [DummyValue.UUID, { minAssetsPerField: 5, maxFields: 12 }] }) + async getAssetIdByTag( + ownerId: string, + { minAssetsPerField, maxFields }: AssetExploreFieldOptions, + ): Promise> { + const cte = this.smartInfoRepository + .createQueryBuilder('si') + .select('unnest(tags)', 'tag') + .groupBy('tag') + .having('count(*) >= :minAssetsPerField', { minAssetsPerField }) + .orderBy('random()') + .limit(maxFields); + + const items = await this.getBuilder({ + userIds: [ownerId], + exifInfo: false, + assetType: AssetType.IMAGE, + isArchived: false, + }) + .select('unnest(si.tags)', 'value') + .addSelect('asset.id', 'data') + .distinctOn(['unnest(si.tags)']) + .innerJoin('smart_info', 'si', 'asset.id = si."assetId"') + .addCommonTableExpression(cte, 'random_tags') + .innerJoin('random_tags', 't', 'si.tags @> ARRAY[t.tag]') + .limit(maxFields) + .getRawMany(); + + return { fieldName: 'smartInfo.tags', items }; + } + + private getBuilder(options: AssetBuilderOptions) { + const { isArchived, isFavorite, isTrashed, albumId, personId, userIds, withStacked, exifInfo, assetType } = options; let builder = this.repository .createQueryBuilder('asset') .where('asset.isVisible = true') - .andWhere('asset.fileCreatedAt < NOW()') - .leftJoinAndSelect('asset.exifInfo', 'exifInfo') - .leftJoinAndSelect('asset.stack', 'stack'); + .andWhere('asset.fileCreatedAt < NOW()'); + if (assetType !== undefined) { + builder = builder.andWhere('asset.type = :assetType', { assetType }); + } + + if (exifInfo !== false) { + builder = builder.leftJoinAndSelect('asset.exifInfo', 'exifInfo').leftJoinAndSelect('asset.stack', 'stack'); + } if (albumId) { builder = builder.leftJoin('asset.albums', 'album').andWhere('album.id = :albumId', { albumId }); @@ -732,4 +807,46 @@ export class AssetRepository implements IAssetRepository { return builder; } + + async searchMetadata(query: string, ownerId: string, { numResults }: MetadataSearchOptions): Promise { + const rows = await this.repository + .createQueryBuilder('assets') + .select('assets.*') + .addSelect('e.country', 'country') + .addSelect('e.state', 'state') + .addSelect('e.city', 'city') + .addSelect('e.description', 'description') + .addSelect('e.model', 'model') + .addSelect('e.make', 'make') + .addSelect('COALESCE(si.tags, array[]::text[])', 'tags') + .addSelect('COALESCE(si.objects, array[]::text[])', 'objects') + .innerJoin('smart_info', 'si', 'si."assetId" = assets."id"') + .innerJoin('exif', 'e', 'assets."id" = e."assetId"') + .where('a.ownerId = :ownerId', { ownerId }) + .where( + '(e."exifTextSearchableColumn" || si."smartInfoTextSearchableColumn") @@ PLAINTO_TSQUERY(\'english\', :query)', + { query }, + ) + .limit(numResults) + .getRawMany(); + + return rows.map( + ({ tags, objects, country, state, city, description, model, make, ...assetInfo }) => + ({ + exifInfo: { + country, + state, + city, + description, + model, + make, + }, + smartInfo: { + tags, + objects, + }, + ...assetInfo, + }) as AssetEntity, + ); + } } diff --git a/server/src/infra/repositories/index.ts b/server/src/infra/repositories/index.ts index 0324fef43cada..0fd0070e23dcd 100644 --- a/server/src/infra/repositories/index.ts +++ b/server/src/infra/repositories/index.ts @@ -21,6 +21,5 @@ export * from './smart-info.repository'; export * from './system-config.repository'; export * from './system-metadata.repository'; export * from './tag.repository'; -export * from './typesense.repository'; export * from './user-token.repository'; export * from './user.repository'; diff --git a/server/src/infra/repositories/metadata.repository.ts b/server/src/infra/repositories/metadata.repository.ts index d8f91dd1a2034..15ccabd0c48d4 100644 --- a/server/src/infra/repositories/metadata.repository.ts +++ b/server/src/infra/repositories/metadata.repository.ts @@ -5,8 +5,8 @@ import { ISystemMetadataRepository, ReverseGeocodeResult, } from '@app/domain'; +import { DatabaseLock, RequireLock } from '@app/infra'; import { GeodataAdmin1Entity, GeodataAdmin2Entity, GeodataPlacesEntity, SystemMetadataKey } from '@app/infra/entities'; -import { DatabaseLock } from '@app/infra/utils/database-locks'; import { Inject, Logger } from '@nestjs/common'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { DefaultReadTaskOptions, exiftool, Tags } from 'exiftool-vendored'; @@ -33,16 +33,14 @@ export class MetadataRepository implements IMetadataRepository { private logger = new Logger(MetadataRepository.name); + @RequireLock(DatabaseLock.GeodataImport) async init(): Promise { this.logger.log('Initializing metadata repository'); const geodataDate = await readFile('/usr/src/resources/geodata-date.txt', 'utf8'); - await this.geodataPlacesRepository.query('SELECT pg_advisory_lock($1)', [DatabaseLock.GeodataImport]); - const geocodingMetadata = await this.systemMetadataRepository.get(SystemMetadataKey.REVERSE_GEOCODING_STATE); if (geocodingMetadata?.lastUpdate === geodataDate) { - await this.dataSource.query('SELECT pg_advisory_unlock($1)', [DatabaseLock.GeodataImport]); return; } @@ -72,7 +70,6 @@ export class MetadataRepository implements IMetadataRepository { lastImportFileName: CITIES_FILE, }); - await this.dataSource.query('SELECT pg_advisory_unlock($1)', [DatabaseLock.GeodataImport]); this.logger.log('Geodata import completed'); } diff --git a/server/src/infra/repositories/person.repository.ts b/server/src/infra/repositories/person.repository.ts index 038b955a1a02e..ed0fab8d045df 100644 --- a/server/src/infra/repositories/person.repository.ts +++ b/server/src/infra/repositories/person.repository.ts @@ -10,6 +10,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { In, Repository } from 'typeorm'; import { AssetEntity, AssetFaceEntity, PersonEntity } from '../entities'; import { DummyValue, GenerateSql } from '../infra.util'; +import { asVector } from '../infra.utils'; export class PersonRepository implements IPersonRepository { constructor( @@ -215,8 +216,15 @@ export class PersonRepository implements IPersonRepository { return this.personRepository.save(entity); } - createFace(entity: Partial): Promise { - return this.assetFaceRepository.save(entity); + async createFace(entity: AssetFaceEntity): Promise { + if (!entity.personId) { + throw new Error('Person ID is required to create a face'); + } + if (!entity.embedding) { + throw new Error('Embedding is required to create a face'); + } + await this.assetFaceRepository.insert({ ...entity, embedding: () => asVector(entity.embedding, true) }); + return this.assetFaceRepository.findOneByOrFail({ assetId: entity.assetId, personId: entity.personId }); } async update(entity: Partial): Promise { diff --git a/server/src/infra/repositories/smart-info.repository.ts b/server/src/infra/repositories/smart-info.repository.ts index 8932efaf050cd..fea1ef24d58c9 100644 --- a/server/src/infra/repositories/smart-info.repository.ts +++ b/server/src/infra/repositories/smart-info.repository.ts @@ -1,14 +1,171 @@ -import { ISmartInfoRepository } from '@app/domain'; -import { Injectable } from '@nestjs/common'; +import { Embedding, EmbeddingSearch, ISmartInfoRepository } from '@app/domain'; +import { getCLIPModelInfo } from '@app/domain/smart-info/smart-info.constant'; +import { DatabaseLock, RequireLock, asyncLock } from '@app/infra'; +import { AssetEntity, AssetFaceEntity, SmartInfoEntity, SmartSearchEntity } from '@app/infra/entities'; +import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { SmartInfoEntity } from '../entities'; +import { DummyValue, GenerateSql } from '../infra.util'; +import { asVector, isValidInteger } from '../infra.utils'; @Injectable() export class SmartInfoRepository implements ISmartInfoRepository { - constructor(@InjectRepository(SmartInfoEntity) private repository: Repository) {} + private logger = new Logger(SmartInfoRepository.name); - async upsert(info: Partial): Promise { - await this.repository.upsert(info, { conflictPaths: ['assetId'] }); + constructor( + @InjectRepository(SmartInfoEntity) private repository: Repository, + @InjectRepository(AssetEntity) private assetRepository: Repository, + @InjectRepository(AssetFaceEntity) private assetFaceRepository: Repository, + @InjectRepository(SmartSearchEntity) private smartSearchRepository: Repository, + ) {} + + 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}`); + + if (dimSize != curDimSize) { + this.logger.log(`Dimension size of model ${modelName} is ${dimSize}, but database expects ${curDimSize}.`); + await this.updateDimSize(dimSize); + } + } + + @GenerateSql({ + params: [{ ownerId: DummyValue.UUID, embedding: Array.from({ length: 512 }, Math.random), numResults: 100 }], + }) + async searchCLIP({ ownerId, embedding, numResults }: EmbeddingSearch): Promise { + if (!isValidInteger(numResults, { min: 1 })) { + throw new Error(`Invalid value for 'numResults': ${numResults}`); + } + + let results: AssetEntity[] = []; + await this.assetRepository.manager.transaction(async (manager) => { + await manager.query(`SET LOCAL vectors.k = '${numResults}'`); + results = await manager + .createQueryBuilder(AssetEntity, 'a') + .innerJoin('a.smartSearch', 's') + .where('a.ownerId = :ownerId') + .leftJoinAndSelect('a.exifInfo', 'e') + .orderBy('s.embedding <=> :embedding') + .setParameters({ ownerId, embedding: asVector(embedding) }) + .limit(numResults) + .getMany(); + }); + + return results; + } + + @GenerateSql({ + params: [ + { + ownerId: DummyValue.UUID, + embedding: Array.from({ length: 512 }, Math.random), + numResults: 100, + maxDistance: 0.6, + }, + ], + }) + async searchFaces({ ownerId, embedding, numResults, maxDistance }: EmbeddingSearch): Promise { + if (!isValidInteger(numResults, { min: 1 })) { + throw new Error(`Invalid value for 'numResults': ${numResults}`); + } + + let results: AssetFaceEntity[] = []; + await this.assetRepository.manager.transaction(async (manager) => { + await manager.query(`SET LOCAL vectors.k = '${numResults}'`); + const cte = manager + .createQueryBuilder(AssetFaceEntity, 'faces') + .addSelect('1 + (faces.embedding <=> :embedding)', 'distance') + .innerJoin('faces.asset', 'asset') + .where('asset.ownerId = :ownerId') + .orderBy(`faces.embedding <=> :embedding`) + .setParameters({ ownerId, embedding: asVector(embedding) }) + .limit(numResults); + + results = await manager + .createQueryBuilder() + .select('res.*') + .addCommonTableExpression(cte, 'cte') + .from('cte', 'res') + .where('res.distance <= :maxDistance', { maxDistance }) + .getRawMany(); + }); + + return this.assetFaceRepository.create(results); + } + + async upsert(smartInfo: Partial, embedding?: Embedding): Promise { + await this.repository.upsert(smartInfo, { conflictPaths: ['assetId'] }); + if (!smartInfo.assetId || !embedding) { + return; + } + + await this.upsertEmbedding(smartInfo.assetId, embedding); + } + + private async upsertEmbedding(assetId: string, embedding: number[]): Promise { + if (asyncLock.isBusy(DatabaseLock[DatabaseLock.CLIPDimSize])) { + this.logger.verbose(`Waiting for CLIP dimension size to be updated`); + await asyncLock.acquire(DatabaseLock[DatabaseLock.CLIPDimSize], () => {}); + } + + await this.smartSearchRepository.upsert( + { assetId, embedding: () => asVector(embedding, true) }, + { conflictPaths: ['assetId'] }, + ); + } + + @RequireLock(DatabaseLock.CLIPDimSize) + private async updateDimSize(dimSize: number): Promise { + if (!isValidInteger(dimSize, { min: 1, max: 2 ** 16 })) { + throw new Error(`Invalid CLIP dimension size: ${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) => { + await manager.query(`DROP TABLE smart_search`); + + await manager.query(` + CREATE TABLE smart_search ( + "assetId" uuid PRIMARY KEY REFERENCES assets(id) ON DELETE CASCADE, + embedding vector(${dimSize}) NOT NULL )`); + + await manager.query(` + CREATE INDEX clip_index ON smart_search + USING vectors (embedding cosine_ops) WITH (options = $$ + [indexing.hnsw] + m = 16 + ef_construction = 300 + $$)`); + }); + + this.logger.log(`Successfully updated database CLIP dimension size from ${curDimSize} to ${dimSize}.`); + } + + private async getDimSize(): Promise { + const res = await this.smartSearchRepository.manager.query(` + SELECT atttypmod as dimsize + FROM pg_attribute f + JOIN pg_class c ON c.oid = f.attrelid + WHERE c.relkind = 'r'::char + AND f.attnum > 0 + AND c.relname = 'smart_search' + AND f.attname = 'embedding'`); + + const dimSize = res[0]['dimsize']; + if (!isValidInteger(dimSize, { min: 1, max: 2 ** 16 })) { + throw new Error(`Could not retrieve CLIP dimension size`); + } + return dimSize; } } diff --git a/server/src/infra/repositories/typesense.repository.ts b/server/src/infra/repositories/typesense.repository.ts deleted file mode 100644 index 403e2bfc723d2..0000000000000 --- a/server/src/infra/repositories/typesense.repository.ts +++ /dev/null @@ -1,503 +0,0 @@ -import { - ISearchRepository, - OwnedFaceEntity, - SearchCollection, - SearchCollectionIndexStatus, - SearchExploreItem, - SearchFaceFilter, - SearchFilter, - SearchResult, -} from '@app/domain'; -import { Injectable, Logger } from '@nestjs/common'; -import _, { Dictionary } from 'lodash'; -import { catchError, filter, firstValueFrom, from, map, mergeMap, of, toArray } from 'rxjs'; -import { Client } from 'typesense'; -import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections'; -import { DocumentSchema, SearchResponse } from 'typesense/lib/Typesense/Documents'; -import { AlbumEntity, AssetEntity, AssetFaceEntity } from '../entities'; -import { typesenseConfig } from '../infra.config'; -import { albumSchema, assetSchema, faceSchema } from '../typesense-schemas'; - -function removeNil>(item: T): T { - _.forOwn(item, (value, key) => { - if (_.isNil(value) || (_.isObject(value) && !_.isDate(value) && _.isEmpty(removeNil(value)))) { - delete item[key]; - } - }); - - return item; -} - -interface MultiSearchError { - code: number; - error: string; -} - -interface CustomAssetEntity extends AssetEntity { - geo?: [number, number]; - motion?: boolean; - people?: string[]; -} - -const schemaMap: Record = { - [SearchCollection.ASSETS]: assetSchema, - [SearchCollection.ALBUMS]: albumSchema, - [SearchCollection.FACES]: faceSchema, -}; - -const schemas = Object.entries(schemaMap) as [SearchCollection, CollectionCreateSchema][]; - -@Injectable() -export class TypesenseRepository implements ISearchRepository { - private logger = new Logger(TypesenseRepository.name); - - private _client: Client | null = null; - private _updateCLIPLock = false; - - private get client(): Client { - if (!this._client) { - throw new Error('Typesense client not available (no apiKey was provided)'); - } - return this._client; - } - - constructor() { - if (!typesenseConfig.apiKey) { - return; - } - - this._client = new Client(typesenseConfig); - } - - async setup(): Promise { - const collections = await this.client.collections().retrieve(); - for (const collection of collections) { - this.logger.debug(`${collection.name} collection has ${collection.num_documents} documents`); - // await this.client.collections(collection.name).delete(); - } - - // upsert collections - for (const [collectionName, schema] of schemas) { - const collection = await this.client - .collections(schema.name) - .retrieve() - .catch(() => null); - if (!collection) { - this.logger.log(`Creating schema: ${collectionName}/${schema.name}`); - await this.client.collections().create(schema); - } else { - this.logger.log(`Schema up to date: ${collectionName}/${schema.name}`); - } - } - } - - async checkMigrationStatus(): Promise { - const migrationMap: SearchCollectionIndexStatus = { - [SearchCollection.ASSETS]: false, - [SearchCollection.ALBUMS]: false, - [SearchCollection.FACES]: false, - }; - - // check if alias is using the current schema - const { aliases } = await this.client.aliases().retrieve(); - this.logger.log(`Alias mapping: ${JSON.stringify(aliases)}`); - - for (const [aliasName, schema] of schemas) { - const match = aliases.find((alias) => alias.name === aliasName); - if (!match || match.collection_name !== schema.name) { - migrationMap[aliasName] = true; - } - } - - this.logger.log(`Collections needing migration: ${JSON.stringify(migrationMap)}`); - - return migrationMap; - } - - async importAlbums(items: AlbumEntity[], done: boolean): Promise { - await this.import(SearchCollection.ALBUMS, items, done); - } - - async importAssets(items: AssetEntity[], done: boolean): Promise { - await this.import(SearchCollection.ASSETS, items, done); - } - - async importFaces(items: OwnedFaceEntity[], done: boolean): Promise { - await this.import(SearchCollection.FACES, items, done); - } - - private async import( - collection: SearchCollection, - items: AlbumEntity[] | AssetEntity[] | OwnedFaceEntity[], - done: boolean, - ): Promise { - try { - if (items.length > 0) { - await this.client.collections(schemaMap[collection].name).documents().import(this.patch(collection, items), { - action: 'upsert', - dirty_values: 'coerce_or_drop', - }); - } - - if (done) { - await this.updateAlias(collection); - } - } catch (error: any) { - await this.handleError(error); - } - } - - async explore(userId: string): Promise[]> { - const common = { - q: '*', - filter_by: [this.buildFilterBy('ownerId', userId, true), this.buildFilterBy('isArchived', false)].join(' && '), - per_page: 100, - }; - - const asset$ = this.client.collections(assetSchema.name).documents(); - - const { facet_counts: facets } = await asset$.search({ - ...common, - query_by: 'originalFileName', - facet_by: 'exifInfo.city,smartInfo.objects', - max_facet_values: 12, - }); - - return firstValueFrom( - from(facets || []).pipe( - mergeMap( - (facet) => - from(facet.counts).pipe( - mergeMap((count) => { - const config = { - ...common, - query_by: 'originalFileName', - filter_by: [common.filter_by, this.buildFilterBy(facet.field_name, count.value, true)].join(' && '), - per_page: 1, - }; - - this.logger.verbose(`Explore subquery: "filter_by:${config.filter_by}" (count:${count.count})`); - - return from(asset$.search(config)).pipe( - catchError((error: any) => { - this.logger.warn(`Explore subquery error: ${error}`, error?.stack); - return of({ hits: [] }); - }), - map((result) => ({ - value: count.value, - data: result.hits?.[0]?.document as AssetEntity, - })), - filter((item) => !!item.data), - ); - }, 5), - toArray(), - map((items) => ({ - fieldName: facet.field_name as string, - items, - })), - ), - 3, - ), - toArray(), - ), - ); - } - - async deleteAlbums(ids: string[]): Promise { - await this.delete(SearchCollection.ALBUMS, ids); - } - - async deleteAssets(ids: string[]): Promise { - await this.delete(SearchCollection.ASSETS, ids); - } - - async deleteFaces(ids: string[]): Promise { - await this.delete(SearchCollection.FACES, ids); - } - - async deleteAllFaces(): Promise { - const records = await this.client.collections(faceSchema.name).documents().delete({ filter_by: 'ownerId:!=null' }); - return records.num_deleted; - } - - async deleteAllAssets(): Promise { - const records = await this.client.collections(assetSchema.name).documents().delete({ filter_by: 'ownerId:!=null' }); - return records.num_deleted; - } - - async updateCLIPField(num_dim: number): Promise { - const clipField = assetSchema.fields?.find((field) => field.name === 'smartInfo.clipEmbedding'); - if (clipField && !this._updateCLIPLock) { - try { - this._updateCLIPLock = true; - clipField.num_dim = num_dim; - await this.deleteAllAssets(); - await this.client - .collections(assetSchema.name) - .update({ fields: [{ name: 'smartInfo.clipEmbedding', drop: true } as any, clipField] }); - this.logger.log(`Successfully updated CLIP dimensions to ${num_dim}`); - } catch (err: any) { - this.logger.error(`Error while updating CLIP field: ${err.message}`); - } finally { - this._updateCLIPLock = false; - } - } - } - - async delete(collection: SearchCollection, ids: string[]): Promise { - await this.client - .collections(schemaMap[collection].name) - .documents() - .delete({ filter_by: this.buildFilterBy('id', ids, true) }); - } - - async searchAlbums(query: string, filters: SearchFilter): Promise> { - const results = await this.client - .collections(albumSchema.name) - .documents() - .search({ - q: query, - query_by: ['albumName', 'description'].join(','), - filter_by: this.getAlbumFilters(filters), - }); - - return this.asResponse(results, filters.debug); - } - - async searchAssets(query: string, filters: SearchFilter): Promise> { - const results = await this.client - .collections(assetSchema.name) - .documents() - .search({ - q: query, - query_by: [ - 'originalFileName', - 'exifInfo.country', - 'exifInfo.state', - 'exifInfo.city', - 'exifInfo.description', - 'exifInfo.model', - 'exifInfo.make', - 'smartInfo.tags', - 'smartInfo.objects', - 'people', - ].join(','), - per_page: 250, - facet_by: this.getFacetFieldNames(SearchCollection.ASSETS), - filter_by: this.getAssetFilters(filters), - sort_by: filters.recent ? 'createdAt:desc' : undefined, - }); - - return this.asResponse(results, filters.debug); - } - - async searchFaces(input: number[], filters: SearchFaceFilter): Promise> { - const { results } = await this.client.multiSearch.perform({ - searches: [ - { - collection: faceSchema.name, - q: '*', - vector_query: `embedding:([${input.join(',')}], k:5)`, - per_page: 5, - filter_by: this.buildFilterBy('ownerId', filters.ownerId, true), - } as any, - ], - }); - - return this.asResponse(results[0] as SearchResponse); - } - - async vectorSearch(input: number[], filters: SearchFilter): Promise> { - const { results } = await this.client.multiSearch.perform({ - searches: [ - { - collection: assetSchema.name, - q: '*', - vector_query: `smartInfo.clipEmbedding:([${input.join(',')}], k:100)`, - per_page: 100, - facet_by: this.getFacetFieldNames(SearchCollection.ASSETS), - filter_by: this.getAssetFilters(filters), - } as any, - ], - }); - - return this.asResponse(results[0] as SearchResponse, filters.debug); - } - - private asResponse( - resultsOrError: SearchResponse | MultiSearchError, - debug?: boolean, - ): SearchResult { - const { error, code } = resultsOrError as MultiSearchError; - if (error) { - throw new Error(`Typesense multi-search error: ${code} - ${error}`); - } - - const results = resultsOrError as SearchResponse; - - return { - page: results.page, - total: results.found, - count: results.out_of, - items: (results.hits || []).map((hit) => hit.document), - distances: (results.hits || []).map((hit: any) => hit.vector_distance), - facets: (results.facet_counts || []).map((facet) => ({ - counts: facet.counts.map((item) => ({ count: item.count, value: item.value })), - fieldName: facet.field_name as string, - })), - debug: debug ? results : undefined, - } as SearchResult; - } - - private async handleError(error: any) { - this.logger.error('Unable to index documents'); - const results = error.importResults || []; - let dimsChanged = false; - for (const result of results) { - try { - result.document = JSON.parse(result.document); - if (result.error.includes('Field `smartInfo.clipEmbedding` must have')) { - dimsChanged = true; - this.logger.warn( - `CLIP embedding dimensions have changed, now ${result.document.smartInfo.clipEmbedding.length} dims. Updating schema...`, - ); - await this.updateCLIPField(result.document.smartInfo.clipEmbedding.length); - break; - } - - if (result.document?.smartInfo?.clipEmbedding) { - result.document.smartInfo.clipEmbedding = ''; - } - } catch (err: any) { - this.logger.error(`Error while updating CLIP field: ${(err.message, err.stack)}`); - } - } - - if (!dimsChanged) { - this.logger.log(JSON.stringify(results, null, 2)); - } - } - private async updateAlias(collection: SearchCollection) { - const schema = schemaMap[collection]; - const alias = await this.client - .aliases(collection) - .retrieve() - .catch(() => null); - - // update alias to current collection - this.logger.log(`Using new schema: ${alias?.collection_name || '(unset)'} => ${schema.name}`); - await this.client.aliases().upsert(collection, { collection_name: schema.name }); - - // delete previous collection - if (alias && alias.collection_name !== schema.name) { - this.logger.log(`Deleting old schema: ${alias.collection_name}`); - await this.client.collections(alias.collection_name).delete(); - } - } - - private patch(collection: SearchCollection, items: AssetEntity[] | AlbumEntity[] | OwnedFaceEntity[]) { - return items.map((item) => { - switch (collection) { - case SearchCollection.ASSETS: - return this.patchAsset(item as AssetEntity); - case SearchCollection.ALBUMS: - return this.patchAlbum(item as AlbumEntity); - case SearchCollection.FACES: - return this.patchFace(item as OwnedFaceEntity); - } - }); - } - - private patchAlbum(album: AlbumEntity): AlbumEntity { - return removeNil(album); - } - - private patchAsset(asset: AssetEntity): CustomAssetEntity { - let custom = asset as CustomAssetEntity; - - const lat = asset.exifInfo?.latitude; - const lng = asset.exifInfo?.longitude; - if (lat && lng && lat !== 0 && lng !== 0) { - custom = { ...custom, geo: [lat, lng] }; - } - const people = asset.faces - ?.filter((face) => !face.person?.isHidden && face.person?.name) - .map((face) => face.person?.name) - .filter((name) => name !== undefined) as string[]; - if (people.length) { - custom = { ...custom, people }; - } - return removeNil({ ...custom, motion: !!asset.livePhotoVideoId }); - } - - private patchFace(face: OwnedFaceEntity): OwnedFaceEntity { - return removeNil(face); - } - - private getFacetFieldNames(collection: SearchCollection) { - return (schemaMap[collection].fields || []) - .filter((field) => field.facet) - .map((field) => field.name) - .join(','); - } - - private getAlbumFilters(filters: SearchFilter) { - const { userId } = filters; - - const _filters = [this.buildFilterBy('ownerId', userId, true)]; - - if (filters.id) { - _filters.push(this.buildFilterBy('id', filters.id, true)); - } - - for (const item of albumSchema.fields || []) { - const value = filters[item.name as keyof SearchFilter]; - if (item.facet && value !== undefined) { - _filters.push(this.buildFilterBy(item.name, value)); - } - } - - const result = _filters.join(' && '); - - this.logger.debug(`Album filters are: ${result}`); - - return result; - } - - private getAssetFilters(filters: SearchFilter) { - const { userId } = filters; - const _filters = [this.buildFilterBy('ownerId', userId, true), this.buildFilterBy('isArchived', false)]; - - if (filters.id) { - _filters.push(this.buildFilterBy('id', filters.id, true)); - } - - for (const item of assetSchema.fields || []) { - const value = filters[item.name as keyof SearchFilter]; - if (item.facet && value !== undefined) { - _filters.push(this.buildFilterBy(item.name, value)); - } - } - - const result = _filters.join(' && '); - - this.logger.debug(`Asset filters are: ${result}`); - - return result; - } - - private buildFilterBy(key: string, values: boolean | string | string[], exact?: boolean) { - const token = exact ? ':=' : ':'; - - const _values = (Array.isArray(values) ? values : [values]).map((value) => { - if (typeof value === 'boolean' || value === 'true' || value === 'false') { - return value; - } - return '`' + value + '`'; - }); - - const value = _values.length > 1 ? `[${_values.join(',')}]` : _values[0]; - - return `${key}${token}${value}`; - } -} diff --git a/server/src/infra/sql-generator/index.ts b/server/src/infra/sql-generator/index.ts index 78b412249ea3e..761deafc419ed 100644 --- a/server/src/infra/sql-generator/index.ts +++ b/server/src/infra/sql-generator/index.ts @@ -1,3 +1,4 @@ +import { ISystemConfigRepository } from '@app/domain'; import { INestApplication } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { Test } from '@nestjs/testing'; @@ -18,6 +19,7 @@ import { PartnerRepository, PersonRepository, SharedLinkRepository, + SmartInfoRepository, SystemConfigRepository, SystemMetadataRepository, TagRepository, @@ -38,6 +40,7 @@ const repositories = [ PartnerRepository, PersonRepository, SharedLinkRepository, + SmartInfoRepository, SystemConfigRepository, SystemMetadataRepository, TagRepository, @@ -82,7 +85,7 @@ class SqlGenerator { }), TypeOrmModule.forFeature(databaseEntities), ], - providers: repositories, + providers: [{ provide: ISystemConfigRepository, useClass: SystemConfigRepository }, ...repositories], }).compile(); this.app = await moduleFixture.createNestApplication().init(); diff --git a/server/src/infra/sql/asset.repository.sql b/server/src/infra/sql/asset.repository.sql index c970c997b6a54..b268d6552254d 100644 --- a/server/src/infra/sql/asset.repository.sql +++ b/server/src/infra/sql/asset.repository.sql @@ -57,8 +57,7 @@ SELECT "AssetEntity__AssetEntity_exifInfo"."profileDescription" AS "AssetEntity__AssetEntity_exifInfo_profileDescription", "AssetEntity__AssetEntity_exifInfo"."colorspace" AS "AssetEntity__AssetEntity_exifInfo_colorspace", "AssetEntity__AssetEntity_exifInfo"."bitsPerSample" AS "AssetEntity__AssetEntity_exifInfo_bitsPerSample", - "AssetEntity__AssetEntity_exifInfo"."fps" AS "AssetEntity__AssetEntity_exifInfo_fps", - "AssetEntity__AssetEntity_exifInfo"."exifTextSearchableColumn" AS "AssetEntity__AssetEntity_exifInfo_exifTextSearchableColumn" + "AssetEntity__AssetEntity_exifInfo"."fps" AS "AssetEntity__AssetEntity_exifInfo_fps" FROM "assets" "AssetEntity" LEFT JOIN "exif" "AssetEntity__AssetEntity_exifInfo" ON "AssetEntity__AssetEntity_exifInfo"."assetId" = "AssetEntity"."id" @@ -133,8 +132,7 @@ SELECT "exifInfo"."profileDescription" AS "exifInfo_profileDescription", "exifInfo"."colorspace" AS "exifInfo_colorspace", "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", - "exifInfo"."fps" AS "exifInfo_fps", - "exifInfo"."exifTextSearchableColumn" AS "exifInfo_exifTextSearchableColumn" + "exifInfo"."fps" AS "exifInfo_fps" FROM "assets" "entity" LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "entity"."id" @@ -217,11 +215,9 @@ SELECT "AssetEntity__AssetEntity_exifInfo"."colorspace" AS "AssetEntity__AssetEntity_exifInfo_colorspace", "AssetEntity__AssetEntity_exifInfo"."bitsPerSample" AS "AssetEntity__AssetEntity_exifInfo_bitsPerSample", "AssetEntity__AssetEntity_exifInfo"."fps" AS "AssetEntity__AssetEntity_exifInfo_fps", - "AssetEntity__AssetEntity_exifInfo"."exifTextSearchableColumn" AS "AssetEntity__AssetEntity_exifInfo_exifTextSearchableColumn", "AssetEntity__AssetEntity_smartInfo"."assetId" AS "AssetEntity__AssetEntity_smartInfo_assetId", "AssetEntity__AssetEntity_smartInfo"."tags" AS "AssetEntity__AssetEntity_smartInfo_tags", "AssetEntity__AssetEntity_smartInfo"."objects" AS "AssetEntity__AssetEntity_smartInfo_objects", - "AssetEntity__AssetEntity_smartInfo"."clipEmbedding" AS "AssetEntity__AssetEntity_smartInfo_clipEmbedding", "AssetEntity__AssetEntity_tags"."id" AS "AssetEntity__AssetEntity_tags_id", "AssetEntity__AssetEntity_tags"."type" AS "AssetEntity__AssetEntity_tags_type", "AssetEntity__AssetEntity_tags"."name" AS "AssetEntity__AssetEntity_tags_name", @@ -230,7 +226,6 @@ SELECT "AssetEntity__AssetEntity_faces"."id" AS "AssetEntity__AssetEntity_faces_id", "AssetEntity__AssetEntity_faces"."assetId" AS "AssetEntity__AssetEntity_faces_assetId", "AssetEntity__AssetEntity_faces"."personId" AS "AssetEntity__AssetEntity_faces_personId", - "AssetEntity__AssetEntity_faces"."embedding" AS "AssetEntity__AssetEntity_faces_embedding", "AssetEntity__AssetEntity_faces"."imageWidth" AS "AssetEntity__AssetEntity_faces_imageWidth", "AssetEntity__AssetEntity_faces"."imageHeight" AS "AssetEntity__AssetEntity_faces_imageHeight", "AssetEntity__AssetEntity_faces"."boundingBoxX1" AS "AssetEntity__AssetEntity_faces_boundingBoxX1", @@ -439,7 +434,6 @@ FROM "AssetEntity__AssetEntity_faces"."id" AS "AssetEntity__AssetEntity_faces_id", "AssetEntity__AssetEntity_faces"."assetId" AS "AssetEntity__AssetEntity_faces_assetId", "AssetEntity__AssetEntity_faces"."personId" AS "AssetEntity__AssetEntity_faces_personId", - "AssetEntity__AssetEntity_faces"."embedding" AS "AssetEntity__AssetEntity_faces_embedding", "AssetEntity__AssetEntity_faces"."imageWidth" AS "AssetEntity__AssetEntity_faces_imageWidth", "AssetEntity__AssetEntity_faces"."imageHeight" AS "AssetEntity__AssetEntity_faces_imageHeight", "AssetEntity__AssetEntity_faces"."boundingBoxX1" AS "AssetEntity__AssetEntity_faces_boundingBoxX1", @@ -612,3 +606,73 @@ ORDER BY "AssetEntity"."createdAt" ASC LIMIT 11 + +-- AssetRepository.getAssetIdByCity +WITH + "cities" AS ( + SELECT + city + FROM + "exif" "e" + GROUP BY + city + HAVING + count(city) >= $1 + ORDER BY + random() ASC + LIMIT + 12 + ) +SELECT DISTINCT + ON (c.city) "asset"."id" AS "data", + c.city AS "value" +FROM + "assets" "asset" + INNER JOIN "exif" "e" ON "asset"."id" = e."assetId" + INNER JOIN "cities" "c" ON c.city = "e"."city" +WHERE + ( + "asset"."isVisible" = true + AND "asset"."fileCreatedAt" < NOW() + AND "asset"."type" = $2 + AND "asset"."ownerId" IN ($3) + AND "asset"."isArchived" = $4 + ) + AND ("asset"."deletedAt" IS NULL) +LIMIT + 12 + +-- AssetRepository.getAssetIdByTag +WITH + "random_tags" AS ( + SELECT + unnest(tags) AS "tag" + FROM + "smart_info" "si" + GROUP BY + tag + HAVING + count(*) >= $1 + ORDER BY + random() ASC + LIMIT + 12 + ) +SELECT DISTINCT + ON (unnest("si"."tags")) "asset"."id" AS "data", + unnest("si"."tags") AS "value" +FROM + "assets" "asset" + INNER JOIN "smart_info" "si" ON "asset"."id" = si."assetId" + INNER JOIN "random_tags" "t" ON "si"."tags" @> ARRAY[t.tag] +WHERE + ( + "asset"."isVisible" = true + AND "asset"."fileCreatedAt" < NOW() + AND "asset"."type" = $2 + AND "asset"."ownerId" IN ($3) + AND "asset"."isArchived" = $4 + ) + AND ("asset"."deletedAt" IS NULL) +LIMIT + 12 diff --git a/server/src/infra/sql/person.repository.sql b/server/src/infra/sql/person.repository.sql index 68d049ae0affc..05fd4b80d0e9f 100644 --- a/server/src/infra/sql/person.repository.sql +++ b/server/src/infra/sql/person.repository.sql @@ -12,7 +12,6 @@ SELECT "AssetFaceEntity"."id" AS "AssetFaceEntity_id", "AssetFaceEntity"."assetId" AS "AssetFaceEntity_assetId", "AssetFaceEntity"."personId" AS "AssetFaceEntity_personId", - "AssetFaceEntity"."embedding" AS "AssetFaceEntity_embedding", "AssetFaceEntity"."imageWidth" AS "AssetFaceEntity_imageWidth", "AssetFaceEntity"."imageHeight" AS "AssetFaceEntity_imageHeight", "AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1", @@ -138,7 +137,6 @@ SELECT "AssetFaceEntity"."id" AS "AssetFaceEntity_id", "AssetFaceEntity"."assetId" AS "AssetFaceEntity_assetId", "AssetFaceEntity"."personId" AS "AssetFaceEntity_personId", - "AssetFaceEntity"."embedding" AS "AssetFaceEntity_embedding", "AssetFaceEntity"."imageWidth" AS "AssetFaceEntity_imageWidth", "AssetFaceEntity"."imageHeight" AS "AssetFaceEntity_imageHeight", "AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1", @@ -169,7 +167,6 @@ FROM "AssetFaceEntity"."id" AS "AssetFaceEntity_id", "AssetFaceEntity"."assetId" AS "AssetFaceEntity_assetId", "AssetFaceEntity"."personId" AS "AssetFaceEntity_personId", - "AssetFaceEntity"."embedding" AS "AssetFaceEntity_embedding", "AssetFaceEntity"."imageWidth" AS "AssetFaceEntity_imageWidth", "AssetFaceEntity"."imageHeight" AS "AssetFaceEntity_imageHeight", "AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1", @@ -205,7 +202,6 @@ FROM "AssetFaceEntity"."id" AS "AssetFaceEntity_id", "AssetFaceEntity"."assetId" AS "AssetFaceEntity_assetId", "AssetFaceEntity"."personId" AS "AssetFaceEntity_personId", - "AssetFaceEntity"."embedding" AS "AssetFaceEntity_embedding", "AssetFaceEntity"."imageWidth" AS "AssetFaceEntity_imageWidth", "AssetFaceEntity"."imageHeight" AS "AssetFaceEntity_imageHeight", "AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1", @@ -351,7 +347,6 @@ FROM "AssetEntity__AssetEntity_faces"."id" AS "AssetEntity__AssetEntity_faces_id", "AssetEntity__AssetEntity_faces"."assetId" AS "AssetEntity__AssetEntity_faces_assetId", "AssetEntity__AssetEntity_faces"."personId" AS "AssetEntity__AssetEntity_faces_personId", - "AssetEntity__AssetEntity_faces"."embedding" AS "AssetEntity__AssetEntity_faces_embedding", "AssetEntity__AssetEntity_faces"."imageWidth" AS "AssetEntity__AssetEntity_faces_imageWidth", "AssetEntity__AssetEntity_faces"."imageHeight" AS "AssetEntity__AssetEntity_faces_imageHeight", "AssetEntity__AssetEntity_faces"."boundingBoxX1" AS "AssetEntity__AssetEntity_faces_boundingBoxX1", @@ -393,8 +388,7 @@ FROM "AssetEntity__AssetEntity_exifInfo"."profileDescription" AS "AssetEntity__AssetEntity_exifInfo_profileDescription", "AssetEntity__AssetEntity_exifInfo"."colorspace" AS "AssetEntity__AssetEntity_exifInfo_colorspace", "AssetEntity__AssetEntity_exifInfo"."bitsPerSample" AS "AssetEntity__AssetEntity_exifInfo_bitsPerSample", - "AssetEntity__AssetEntity_exifInfo"."fps" AS "AssetEntity__AssetEntity_exifInfo_fps", - "AssetEntity__AssetEntity_exifInfo"."exifTextSearchableColumn" AS "AssetEntity__AssetEntity_exifInfo_exifTextSearchableColumn" + "AssetEntity__AssetEntity_exifInfo"."fps" AS "AssetEntity__AssetEntity_exifInfo_fps" FROM "assets" "AssetEntity" LEFT JOIN "asset_faces" "AssetEntity__AssetEntity_faces" ON "AssetEntity__AssetEntity_faces"."assetId" = "AssetEntity"."id" @@ -421,7 +415,6 @@ SELECT "AssetFaceEntity"."id" AS "AssetFaceEntity_id", "AssetFaceEntity"."assetId" AS "AssetFaceEntity_assetId", "AssetFaceEntity"."personId" AS "AssetFaceEntity_personId", - "AssetFaceEntity"."embedding" AS "AssetFaceEntity_embedding", "AssetFaceEntity"."imageWidth" AS "AssetFaceEntity_imageWidth", "AssetFaceEntity"."imageHeight" AS "AssetFaceEntity_imageHeight", "AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1", @@ -473,7 +466,6 @@ SELECT "AssetFaceEntity"."id" AS "AssetFaceEntity_id", "AssetFaceEntity"."assetId" AS "AssetFaceEntity_assetId", "AssetFaceEntity"."personId" AS "AssetFaceEntity_personId", - "AssetFaceEntity"."embedding" AS "AssetFaceEntity_embedding", "AssetFaceEntity"."imageWidth" AS "AssetFaceEntity_imageWidth", "AssetFaceEntity"."imageHeight" AS "AssetFaceEntity_imageHeight", "AssetFaceEntity"."boundingBoxX1" AS "AssetFaceEntity_boundingBoxX1", diff --git a/server/src/infra/sql/shared.link.repository.sql b/server/src/infra/sql/shared.link.repository.sql index ffb05cd397ed5..dc19b23cbd122 100644 --- a/server/src/infra/sql/shared.link.repository.sql +++ b/server/src/infra/sql/shared.link.repository.sql @@ -77,7 +77,6 @@ FROM "9b1d35b344d838023994a3233afd6ffe098be6d8"."colorspace" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_colorspace", "9b1d35b344d838023994a3233afd6ffe098be6d8"."bitsPerSample" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_bitsPerSample", "9b1d35b344d838023994a3233afd6ffe098be6d8"."fps" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_fps", - "9b1d35b344d838023994a3233afd6ffe098be6d8"."exifTextSearchableColumn" AS "e18de9deffa83f81ac3c43b5e8c2f08dba727bf8", "SharedLinkEntity__SharedLinkEntity_album"."id" AS "SharedLinkEntity__SharedLinkEntity_album_id", "SharedLinkEntity__SharedLinkEntity_album"."ownerId" AS "SharedLinkEntity__SharedLinkEntity_album_ownerId", "SharedLinkEntity__SharedLinkEntity_album"."albumName" AS "SharedLinkEntity__SharedLinkEntity_album_albumName", @@ -143,7 +142,6 @@ FROM "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."colorspace" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_colorspace", "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."bitsPerSample" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_bitsPerSample", "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."fps" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_fps", - "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."exifTextSearchableColumn" AS "96535c8046de591cca9b8c5825e6c5db502b0e6a", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."id" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_id", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."name" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_name", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."avatarColor" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_avatarColor", diff --git a/server/src/infra/sql/smart.info.repository.sql b/server/src/infra/sql/smart.info.repository.sql new file mode 100644 index 0000000000000..44de26ad92cf9 --- /dev/null +++ b/server/src/infra/sql/smart.info.repository.sql @@ -0,0 +1,111 @@ +-- NOTE: This file is auto generated by ./sql-generator + +-- SmartInfoRepository.searchCLIP +START TRANSACTION +SET + LOCAL vectors.k = '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"."stackParentId" AS "a_stackParentId", + "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"."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" = $1) + AND ("a"."deletedAt" IS NULL) +ORDER BY + "s"."embedding" <= > $2 ASC +LIMIT + 100 +COMMIT + +-- SmartInfoRepository.searchFaces +START TRANSACTION +SET + LOCAL vectors.k = '100' +WITH + "cte" AS ( + SELECT + "faces"."id" AS "faces_id", + "faces"."assetId" AS "faces_assetId", + "faces"."personId" AS "faces_personId", + "faces"."imageWidth" AS "faces_imageWidth", + "faces"."imageHeight" AS "faces_imageHeight", + "faces"."boundingBoxX1" AS "faces_boundingBoxX1", + "faces"."boundingBoxY1" AS "faces_boundingBoxY1", + "faces"."boundingBoxX2" AS "faces_boundingBoxX2", + "faces"."boundingBoxY2" AS "faces_boundingBoxY2", + 1 + ("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" = $2 + ORDER BY + "faces"."embedding" <= > $3 ASC + LIMIT + 100 + ) +SELECT + res.* +FROM + "cte" "res" +WHERE + res.distance <= $4 +COMMIT diff --git a/server/src/infra/typesense-schemas/album.schema.ts b/server/src/infra/typesense-schemas/album.schema.ts deleted file mode 100644 index 7a7506a863f6d..0000000000000 --- a/server/src/infra/typesense-schemas/album.schema.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections'; - -export const albumSchemaVersion = 2; -export const albumSchema: CollectionCreateSchema = { - name: `albums-v${albumSchemaVersion}`, - fields: [ - { name: 'ownerId', type: 'string', facet: false }, - { name: 'albumName', type: 'string', facet: false, sort: true }, - { name: 'description', type: 'string', facet: false }, - { name: 'createdAt', type: 'string', facet: false, sort: true }, - { name: 'updatedAt', type: 'string', facet: false, sort: true }, - ], - default_sorting_field: 'createdAt', -}; diff --git a/server/src/infra/typesense-schemas/asset.schema.ts b/server/src/infra/typesense-schemas/asset.schema.ts deleted file mode 100644 index 76a95e366cc50..0000000000000 --- a/server/src/infra/typesense-schemas/asset.schema.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections'; - -export const assetSchemaVersion = 10; -export const assetSchema: CollectionCreateSchema = { - name: `assets-v${assetSchemaVersion}`, - fields: [ - // asset - { name: 'ownerId', type: 'string', facet: false }, - { name: 'type', type: 'string', facet: true }, - { name: 'originalPath', type: 'string', facet: false }, - { name: 'createdAt', type: 'string', facet: false, sort: true }, - { name: 'updatedAt', type: 'string', facet: false, sort: true }, - { name: 'fileCreatedAt', type: 'string', facet: false, sort: true }, - { name: 'fileModifiedAt', type: 'string', facet: false, sort: true }, - { name: 'isFavorite', type: 'bool', facet: true }, - { name: 'isArchived', type: 'bool', facet: true }, - { name: 'originalFileName', type: 'string', facet: false, optional: true }, - - // exif - { name: 'exifInfo.city', type: 'string', facet: true, optional: true }, - { name: 'exifInfo.country', type: 'string', facet: true, optional: true }, - { name: 'exifInfo.state', type: 'string', facet: true, optional: true }, - { name: 'exifInfo.description', type: 'string', facet: false, optional: true }, - { name: 'exifInfo.make', type: 'string', facet: true, optional: true }, - { name: 'exifInfo.model', type: 'string', facet: true, optional: true }, - { name: 'exifInfo.orientation', type: 'string', optional: true }, - { name: 'exifInfo.projectionType', type: 'string', facet: true, optional: true }, - - // smart info - { name: 'smartInfo.objects', type: 'string[]', facet: true, optional: true }, - { name: 'smartInfo.tags', type: 'string[]', facet: true, optional: true }, - { name: 'smartInfo.clipEmbedding', type: 'float[]', facet: false, optional: true, num_dim: 512 }, - - // computed - { name: 'geo', type: 'geopoint', facet: false, optional: true }, - { name: 'motion', type: 'bool', facet: true }, - { name: 'people', type: 'string[]', facet: true, optional: true }, - ], - token_separators: ['.', '-', '_'], - enable_nested_fields: true, - default_sorting_field: 'fileCreatedAt', -}; diff --git a/server/src/infra/typesense-schemas/face.schema.ts b/server/src/infra/typesense-schemas/face.schema.ts deleted file mode 100644 index c978057ec9b1c..0000000000000 --- a/server/src/infra/typesense-schemas/face.schema.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections'; - -export const faceSchemaVersion = 1; -export const faceSchema: CollectionCreateSchema = { - name: `faces-v${faceSchemaVersion}`, - fields: [ - { name: 'ownerId', type: 'string', facet: false }, - { name: 'assetId', type: 'string', facet: false }, - { name: 'personId', type: 'string', facet: false }, - { name: 'embedding', type: 'float[]', facet: false, num_dim: 512 }, - ], -}; diff --git a/server/src/infra/typesense-schemas/index.ts b/server/src/infra/typesense-schemas/index.ts deleted file mode 100644 index a2ab8619475a5..0000000000000 --- a/server/src/infra/typesense-schemas/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './album.schema'; -export * from './asset.schema'; -export * from './face.schema'; diff --git a/server/src/infra/utils/database-locks.ts b/server/src/infra/utils/database-locks.ts deleted file mode 100644 index 756437743bfe8..0000000000000 --- a/server/src/infra/utils/database-locks.ts +++ /dev/null @@ -1,3 +0,0 @@ -export enum DatabaseLock { - GeodataImport = 100, -} diff --git a/server/src/infra/utils/optional-between.util.ts b/server/src/infra/utils/optional-between.util.ts deleted file mode 100644 index 627af28b3c0ab..0000000000000 --- a/server/src/infra/utils/optional-between.util.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; - -/** - * Allows optional values unlike the regular Between and uses MoreThanOrEqual - * or LessThanOrEqual when only one parameter is specified. - */ -export default function OptionalBetween(from?: T, to?: T) { - if (from && to) { - return Between(from, to); - } else if (from) { - return MoreThanOrEqual(from); - } else if (to) { - return LessThanOrEqual(to); - } -} diff --git a/server/src/infra/utils/pagination.util.ts b/server/src/infra/utils/pagination.util.ts deleted file mode 100644 index 2b37686f8af82..0000000000000 --- a/server/src/infra/utils/pagination.util.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Paginated, PaginationOptions } from '@app/domain'; -import { FindOneOptions, ObjectLiteral, Repository } from 'typeorm'; - -export async function paginate( - repository: Repository, - paginationOptions: PaginationOptions, - searchOptions?: FindOneOptions, -): Paginated { - const items = await repository.find({ - ...searchOptions, - // Take one more item to check if there's a next page - take: paginationOptions.take + 1, - skip: paginationOptions.skip, - }); - - const hasNextPage = items.length > paginationOptions.take; - items.splice(paginationOptions.take); - - return { items, hasNextPage }; -} diff --git a/server/src/microservices/app.service.ts b/server/src/microservices/app.service.ts index 3f89fa06fa4cd..48a167d88a463 100644 --- a/server/src/microservices/app.service.ts +++ b/server/src/microservices/app.service.ts @@ -8,7 +8,6 @@ import { MediaService, MetadataService, PersonService, - SearchService, ServerInfoService, SmartInfoService, StorageService, @@ -31,7 +30,6 @@ export class AppService { private mediaService: MediaService, private metadataService: MetadataService, private personService: PersonService, - private searchService: SearchService, private serverInfoService: ServerInfoService, private smartInfoService: SmartInfoService, private storageTemplateService: StorageTemplateService, @@ -52,15 +50,6 @@ export class AppService { [JobName.CLASSIFY_IMAGE]: (data) => this.smartInfoService.handleClassifyImage(data), [JobName.QUEUE_ENCODE_CLIP]: (data) => this.smartInfoService.handleQueueEncodeClip(data), [JobName.ENCODE_CLIP]: (data) => this.smartInfoService.handleEncodeClip(data), - [JobName.SEARCH_INDEX_ALBUMS]: () => this.searchService.handleIndexAlbums(), - [JobName.SEARCH_INDEX_ASSETS]: () => this.searchService.handleIndexAssets(), - [JobName.SEARCH_INDEX_FACES]: () => this.searchService.handleIndexFaces(), - [JobName.SEARCH_INDEX_ALBUM]: (data) => this.searchService.handleIndexAlbum(data), - [JobName.SEARCH_INDEX_ASSET]: (data) => this.searchService.handleIndexAsset(data), - [JobName.SEARCH_INDEX_FACE]: (data) => this.searchService.handleIndexFace(data), - [JobName.SEARCH_REMOVE_ALBUM]: (data) => this.searchService.handleRemoveAlbum(data), - [JobName.SEARCH_REMOVE_ASSET]: (data) => this.searchService.handleRemoveAsset(data), - [JobName.SEARCH_REMOVE_FACE]: (data) => this.searchService.handleRemoveFace(data), [JobName.STORAGE_TEMPLATE_MIGRATION]: () => this.storageTemplateService.handleMigration(), [JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE]: (data) => this.storageTemplateService.handleMigrationSingle(data), [JobName.QUEUE_MIGRATION]: () => this.mediaService.handleQueueMigration(), @@ -94,7 +83,6 @@ export class AppService { }); await this.metadataService.init(); - await this.searchService.init(); } async teardown() { diff --git a/server/src/microservices/main.ts b/server/src/microservices/main.ts index bdb289363836b..3f32bd0fce169 100644 --- a/server/src/microservices/main.ts +++ b/server/src/microservices/main.ts @@ -1,5 +1,5 @@ import { envName, getLogLevels, serverVersion } from '@app/domain'; -import { RedisIoAdapter } from '@app/infra'; +import { RedisIoAdapter, enablePrefilter } from '@app/infra'; import { Logger } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import { AppService } from './app.service'; @@ -12,6 +12,7 @@ export async function bootstrap() { const app = await NestFactory.create(MicroservicesModule, { logger: getLogLevels() }); app.useWebSocketAdapter(new RedisIoAdapter(app)); + await enablePrefilter(); await app.get(AppService).init(); await app.listen(port); diff --git a/server/test/e2e/asset.e2e-spec.ts b/server/test/e2e/asset.e2e-spec.ts index 95cc92b6accf3..ef9eff80933b3 100644 --- a/server/test/e2e/asset.e2e-spec.ts +++ b/server/test/e2e/asset.e2e-spec.ts @@ -10,19 +10,15 @@ import { usePagination, } from '@app/domain'; import { AssetController } from '@app/immich'; -import { AssetEntity, AssetType, LibraryType, SharedLinkType } from '@app/infra/entities'; +import { AssetEntity, AssetType, SharedLinkType } from '@app/infra/entities'; import { AssetRepository } from '@app/infra/repositories'; import { INestApplication } from '@nestjs/common'; import { api } from '@test/api'; import { errorStub, userDto, uuidStub } from '@test/fixtures'; -import { testApp } from '@test/test-utils'; +import { generateAsset, testApp, today, yesterday } from '@test/test-utils'; import { randomBytes } from 'crypto'; -import { DateTime } from 'luxon'; import request from 'supertest'; -const today = DateTime.fromObject({ year: 2023, month: 11, day: 3 }); -const yesterday = today.minus({ days: 1 }); - const makeUploadDto = (options?: { omit: string }): Record => { const dto: Record = { deviceAssetId: 'example-image', @@ -54,30 +50,14 @@ describe(`${AssetController.name} (e2e)`, () => { let asset4: AssetResponseDto; let asset5: AssetResponseDto; - let assetCount = 0; - const createAsset = async (loginResponse: LoginResponseDto, createdAt: Date, other: Partial = {}) => { - const id = assetCount++; - const asset = await assetRepository.create({ - createdAt: today.toJSDate(), - updatedAt: today.toJSDate(), - ownerId: loginResponse.userId, - checksum: randomBytes(20), - originalPath: `/tests/test_${id}`, - deviceAssetId: `test_${id}`, - deviceId: 'e2e-test', - libraryId: ( - libraries.find( - ({ ownerId, type }) => ownerId === loginResponse.userId && type === LibraryType.UPLOAD, - ) as LibraryResponseDto - ).id, - isVisible: true, - fileCreatedAt: createdAt, - fileModifiedAt: new Date(), - localDateTime: createdAt, - type: AssetType.IMAGE, - originalFileName: `test_${id}`, - ...other, - }); + const createAsset = async ( + loginResponse: LoginResponseDto, + fileCreatedAt: Date, + other: Partial = {}, + ) => { + const asset = await assetRepository.create( + generateAsset(loginResponse.userId, libraries, { fileCreatedAt, ...other }), + ); return mapAsset(asset); }; @@ -764,7 +744,11 @@ describe(`${AssetController.name} (e2e)`, () => { const personRepository = app.get(IPersonRepository); const person = await personRepository.create({ ownerId: asset1.ownerId, name: 'Test Person' }); - await personRepository.createFace({ assetId: asset1.id, personId: person.id }); + await personRepository.createFace({ + assetId: asset1.id, + personId: person.id, + embedding: Array.from({ length: 512 }, Math.random), + }); const { status, body } = await request(server) .put(`/asset/${asset1.id}`) @@ -1339,7 +1323,11 @@ describe(`${AssetController.name} (e2e)`, () => { beforeEach(async () => { const personRepository = app.get(IPersonRepository); const person = await personRepository.create({ ownerId: asset1.ownerId, name: 'Test Person' }); - await personRepository.createFace({ assetId: asset1.id, personId: person.id }); + await personRepository.createFace({ + assetId: asset1.id, + personId: person.id, + embedding: Array.from({ length: 512 }, Math.random), + }); }); it('should not return asset with facesRecognizedAt unset', async () => { diff --git a/server/test/e2e/person.e2e-spec.ts b/server/test/e2e/person.e2e-spec.ts index e3796619eadeb..82903ce63aa33 100644 --- a/server/test/e2e/person.e2e-spec.ts +++ b/server/test/e2e/person.e2e-spec.ts @@ -37,7 +37,11 @@ describe(`${PersonController.name}`, () => { name: 'visible_person', thumbnailPath: '/thumbnail/face_asset', }); - await personRepository.createFace({ assetId: faceAsset.id, personId: visiblePerson.id }); + await personRepository.createFace({ + assetId: faceAsset.id, + personId: visiblePerson.id, + embedding: Array.from({ length: 512 }, Math.random), + }); hiddenPerson = await personRepository.create({ ownerId: loginResponse.userId, @@ -45,7 +49,11 @@ describe(`${PersonController.name}`, () => { isHidden: true, thumbnailPath: '/thumbnail/face_asset', }); - await personRepository.createFace({ assetId: faceAsset.id, personId: hiddenPerson.id }); + await personRepository.createFace({ + assetId: faceAsset.id, + personId: hiddenPerson.id, + embedding: Array.from({ length: 512 }, Math.random), + }); }); describe('GET /person', () => { diff --git a/server/test/e2e/search.e2e-spec.ts b/server/test/e2e/search.e2e-spec.ts new file mode 100644 index 0000000000000..7ab95776cad2c --- /dev/null +++ b/server/test/e2e/search.e2e-spec.ts @@ -0,0 +1,213 @@ +import { + AssetResponseDto, + IAssetRepository, + ISmartInfoRepository, + LibraryResponseDto, + LoginResponseDto, + mapAsset, +} from '@app/domain'; +import { SearchController } from '@app/immich'; +import { INestApplication } from '@nestjs/common'; +import { api } from '@test/api'; +import { errorStub } from '@test/fixtures'; +import { generateAsset, testApp } from '@test/test-utils'; +import request from 'supertest'; + +describe(`${SearchController.name}`, () => { + let app: INestApplication; + let server: any; + let loginResponse: LoginResponseDto; + let accessToken: string; + let libraries: LibraryResponseDto[]; + let assetRepository: IAssetRepository; + let smartInfoRepository: ISmartInfoRepository; + let asset1: AssetResponseDto; + + beforeAll(async () => { + [server, app] = await testApp.create(); + assetRepository = app.get(IAssetRepository); + smartInfoRepository = app.get(ISmartInfoRepository); + }); + + afterAll(async () => { + await testApp.teardown(); + }); + + beforeEach(async () => { + await testApp.reset(); + await api.authApi.adminSignUp(server); + loginResponse = await api.authApi.adminLogin(server); + accessToken = loginResponse.accessToken; + libraries = await api.libraryApi.getAll(server, accessToken); + + const assetId = (await assetRepository.create(generateAsset(loginResponse.userId, libraries))).id; + await assetRepository.upsertExif({ + assetId, + latitude: 90, + longitude: 90, + city: 'Immich', + state: 'Nebraska', + country: 'United States', + make: 'Canon', + model: 'EOS Rebel T7', + lensModel: 'Fancy lens', + }); + await smartInfoRepository.upsert( + { assetId, objects: ['car', 'tree'], tags: ['accident'] }, + Array.from({ length: 512 }, Math.random), + ); + const assetWithMetadata = await assetRepository.getById(assetId, { exifInfo: true, smartInfo: true }); + if (!assetWithMetadata) { + throw new Error('Asset not found'); + } + asset1 = mapAsset(assetWithMetadata); + }); + + describe('GET /search', () => { + beforeEach(async () => {}); + + it('should require authentication', async () => { + const { status, body } = await request(server).get('/search'); + + expect(status).toBe(401); + expect(body).toEqual(errorStub.unauthorized); + }); + + it('should return assets when searching by exif', async () => { + if (!asset1?.exifInfo?.make) { + throw new Error('Asset 1 does not have exif info'); + } + + const { status, body } = await request(server) + .get('/search') + .set('Authorization', `Bearer ${accessToken}`) + .query({ q: asset1.exifInfo.make }); + + expect(status).toBe(200); + expect(body).toMatchObject({ + albums: { + total: 0, + count: 0, + items: [], + facets: [], + }, + assets: { + total: 1, + count: 1, + items: [ + { + id: asset1.id, + exifInfo: { + make: asset1.exifInfo.make, + }, + }, + ], + facets: [], + }, + }); + }); + + it('should be case-insensitive for metadata search', async () => { + if (!asset1?.exifInfo?.make) { + throw new Error('Asset 1 does not have exif info'); + } + + const { status, body } = await request(server) + .get('/search') + .set('Authorization', `Bearer ${accessToken}`) + .query({ q: asset1.exifInfo.make.toLowerCase() }); + + expect(status).toBe(200); + expect(body).toMatchObject({ + albums: { + total: 0, + count: 0, + items: [], + facets: [], + }, + assets: { + total: 1, + count: 1, + items: [ + { + id: asset1.id, + exifInfo: { + make: asset1.exifInfo.make, + }, + }, + ], + facets: [], + }, + }); + }); + + it('should be whitespace-insensitive for metadata search', async () => { + if (!asset1?.exifInfo?.make) { + throw new Error('Asset 1 does not have exif info'); + } + + const { status, body } = await request(server) + .get('/search') + .set('Authorization', `Bearer ${accessToken}`) + .query({ q: ` ${asset1.exifInfo.make} ` }); + + expect(status).toBe(200); + expect(body).toMatchObject({ + albums: { + total: 0, + count: 0, + items: [], + facets: [], + }, + assets: { + total: 1, + count: 1, + items: [ + { + id: asset1.id, + exifInfo: { + make: asset1.exifInfo.make, + }, + }, + ], + facets: [], + }, + }); + }); + + it('should return assets when searching by object', async () => { + if (!asset1?.smartInfo?.objects) { + throw new Error('Asset 1 does not have smart info'); + } + + const { status, body } = await request(server) + .get('/search') + .set('Authorization', `Bearer ${accessToken}`) + .query({ q: asset1.smartInfo.objects[0] }); + + expect(status).toBe(200); + expect(body).toMatchObject({ + albums: { + total: 0, + count: 0, + items: [], + facets: [], + }, + assets: { + total: 1, + count: 1, + items: [ + { + id: asset1.id, + smartInfo: { + objects: asset1.smartInfo.objects, + tags: asset1.smartInfo.tags, + }, + }, + ], + facets: [], + }, + }); + }); + }); +}); diff --git a/server/test/e2e/server-info.e2e-spec.ts b/server/test/e2e/server-info.e2e-spec.ts index cc1c8defa764d..08ec0e2cfd360 100644 --- a/server/test/e2e/server-info.e2e-spec.ts +++ b/server/test/e2e/server-info.e2e-spec.ts @@ -81,7 +81,7 @@ describe(`${ServerInfoController.name} (e2e)`, () => { oauth: false, oauthAutoLaunch: false, passwordLogin: true, - search: false, + search: true, sidecar: true, tagImage: false, trash: true, diff --git a/server/test/e2e/setup.ts b/server/test/e2e/setup.ts index 234deb7545ec2..75de2d0b0051c 100644 --- a/server/test/e2e/setup.ts +++ b/server/test/e2e/setup.ts @@ -35,7 +35,7 @@ export default async () => { if (process.env.DB_HOSTNAME === undefined) { // DB hostname not set which likely means we're not running e2e through docker compose. Start a local postgres container. - const pg = await new PostgreSqlContainer('postgres') + const pg = await new PostgreSqlContainer('tensorchord/pgvecto-rs:pg14-v0.1.11') .withExposedPorts(5432) .withDatabase('immich') .withUsername('postgres') @@ -47,7 +47,6 @@ export default async () => { } process.env.NODE_ENV = 'development'; - process.env.TYPESENSE_ENABLED = 'false'; process.env.IMMICH_MACHINE_LEARNING_ENABLED = 'false'; process.env.IMMICH_TEST_ENV = 'true'; process.env.TZ = 'Z'; diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index 56a0c104502c6..a35b3dda7e424 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -209,7 +209,6 @@ export const sharedLinkStub = { tags: [], objects: ['a', 'b', 'c'], asset: null as any, - clipEmbedding: [0.12, 0.13, 0.14], }, webpPath: '', thumbhash: null, diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index 88bbdabcfdbcf..9d778883de980 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -33,5 +33,8 @@ export const newAssetRepositoryMock = (): jest.Mocked => { restoreAll: jest.fn(), softDeleteAll: jest.fn(), search: jest.fn(), + getAssetIdByCity: jest.fn(), + getAssetIdByTag: jest.fn(), + searchMetadata: jest.fn(), }; }; diff --git a/server/test/repositories/index.ts b/server/test/repositories/index.ts index f009fa428f292..b68de4ba221cc 100644 --- a/server/test/repositories/index.ts +++ b/server/test/repositories/index.ts @@ -13,7 +13,6 @@ 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'; diff --git a/server/test/repositories/search.repository.mock.ts b/server/test/repositories/search.repository.mock.ts deleted file mode 100644 index af4dbce64dbc6..0000000000000 --- a/server/test/repositories/search.repository.mock.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ISearchRepository } from '@app/domain'; - -export const newSearchRepositoryMock = (): jest.Mocked => { - return { - setup: jest.fn(), - checkMigrationStatus: jest.fn(), - importAssets: jest.fn(), - importAlbums: jest.fn(), - importFaces: jest.fn(), - deleteAlbums: jest.fn(), - deleteAssets: jest.fn(), - deleteFaces: jest.fn(), - deleteAllFaces: jest.fn(), - updateCLIPField: jest.fn(), - searchAssets: jest.fn(), - searchAlbums: jest.fn(), - vectorSearch: jest.fn(), - explore: jest.fn(), - searchFaces: jest.fn(), - }; -}; diff --git a/server/test/repositories/smart-info.repository.mock.ts b/server/test/repositories/smart-info.repository.mock.ts index 1b89fc393bd14..c7bc4f5c56b3b 100644 --- a/server/test/repositories/smart-info.repository.mock.ts +++ b/server/test/repositories/smart-info.repository.mock.ts @@ -2,6 +2,9 @@ import { ISmartInfoRepository } from '@app/domain'; export const newSmartInfoRepositoryMock = (): jest.Mocked => { return { + init: jest.fn(), + searchCLIP: jest.fn(), + searchFaces: jest.fn(), upsert: jest.fn(), }; }; diff --git a/server/test/test-utils.ts b/server/test/test-utils.ts index 0ef80e85836d5..5e99b535ab76a 100644 --- a/server/test/test-utils.ts +++ b/server/test/test-utils.ts @@ -1,9 +1,12 @@ -import { IJobRepository, JobItem, JobItemHandler, QueueName } from '@app/domain'; +import { AssetCreate, IJobRepository, JobItem, JobItemHandler, LibraryResponseDto, QueueName } from '@app/domain'; import { AppModule } from '@app/immich'; import { dataSource } from '@app/infra'; +import { AssetEntity, AssetType, LibraryType } from '@app/infra/entities'; import { INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; +import { randomBytes } from 'crypto'; import * as fs from 'fs'; +import { DateTime } from 'luxon'; import path from 'path'; import { EntityTarget, ObjectLiteral } from 'typeorm'; import { AppService } from '../src/microservices/app.service'; @@ -11,6 +14,9 @@ import { AppService } from '../src/microservices/app.service'; export const IMMICH_TEST_ASSET_PATH = process.env.IMMICH_TEST_ASSET_PATH; export const IMMICH_TEST_ASSET_TEMP_PATH = path.normalize(`${IMMICH_TEST_ASSET_PATH}/temp/`); +export const today = DateTime.fromObject({ year: 2023, month: 11, day: 3 }); +export const yesterday = today.minus({ days: 1 }); + export interface ResetOptions { entities?: EntityTarget[]; } @@ -20,6 +26,7 @@ export const db = { await dataSource.initialize(); } + await dataSource.query(`SET vectors.enable_prefilter = on`); await dataSource.transaction(async (em) => { const entities = options?.entities || []; const tableNames = @@ -114,3 +121,37 @@ export async function restoreTempFolder(): Promise { // Create temp folder await fs.promises.mkdir(IMMICH_TEST_ASSET_TEMP_PATH); } + +function randomDate(start: Date, end: Date): Date { + return new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime())); +} + +let assetCount = 0; +export function generateAsset( + userId: string, + libraries: LibraryResponseDto[], + other: Partial = {}, +): AssetCreate { + const id = assetCount++; + const { fileCreatedAt = randomDate(new Date(1970, 1, 1), new Date(2023, 1, 1)) } = other; + + return { + createdAt: today.toJSDate(), + updatedAt: today.toJSDate(), + ownerId: userId, + checksum: randomBytes(20), + originalPath: `/tests/test_${id}`, + deviceAssetId: `test_${id}`, + deviceId: 'e2e-test', + libraryId: ( + libraries.find(({ ownerId, type }) => ownerId === userId && type === LibraryType.UPLOAD) as LibraryResponseDto + ).id, + isVisible: true, + fileCreatedAt, + fileModifiedAt: new Date(), + localDateTime: fileCreatedAt, + type: AssetType.IMAGE, + originalFileName: `test_${id}`, + ...other, + }; +} diff --git a/web/package-lock.json b/web/package-lock.json index ddd49f10c04c0..f73097f8b1750 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1882,6 +1882,54 @@ "gl-matrix": "^3.4.3" } }, + "node_modules/@esbuild/android-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, "node_modules/@esbuild/darwin-arm64": { "version": "0.18.20", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", @@ -1898,6 +1946,278 @@ "node": ">=12" } }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -5413,6 +5733,22 @@ "@esbuild/win32-x64": "0.18.20" } }, + "node_modules/esbuild/node_modules/@esbuild/linux-x64": { + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, "node_modules/escalade": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index aaf97672e2f76..b6fb87e1df775 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -14565,22 +14565,12 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio * @param {string} [query] * @param {boolean} [clip] * @param {'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER'} [type] - * @param {boolean} [isFavorite] - * @param {boolean} [isArchived] - * @param {string} [exifInfoCity] - * @param {string} [exifInfoState] - * @param {string} [exifInfoCountry] - * @param {string} [exifInfoMake] - * @param {string} [exifInfoModel] - * @param {string} [exifInfoProjectionType] - * @param {Array} [smartInfoObjects] - * @param {Array} [smartInfoTags] * @param {boolean} [recent] * @param {boolean} [motion] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - search: async (q?: string, query?: string, clip?: boolean, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, isArchived?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, exifInfoProjectionType?: string, smartInfoObjects?: Array, smartInfoTags?: Array, recent?: boolean, motion?: boolean, options: AxiosRequestConfig = {}): Promise => { + search: async (q?: string, query?: string, clip?: boolean, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', recent?: boolean, motion?: boolean, options: AxiosRequestConfig = {}): Promise => { const localVarPath = `/search`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); @@ -14618,46 +14608,6 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio localVarQueryParameter['type'] = type; } - if (isFavorite !== undefined) { - localVarQueryParameter['isFavorite'] = isFavorite; - } - - if (isArchived !== undefined) { - localVarQueryParameter['isArchived'] = isArchived; - } - - if (exifInfoCity !== undefined) { - localVarQueryParameter['exifInfo.city'] = exifInfoCity; - } - - if (exifInfoState !== undefined) { - localVarQueryParameter['exifInfo.state'] = exifInfoState; - } - - if (exifInfoCountry !== undefined) { - localVarQueryParameter['exifInfo.country'] = exifInfoCountry; - } - - if (exifInfoMake !== undefined) { - localVarQueryParameter['exifInfo.make'] = exifInfoMake; - } - - if (exifInfoModel !== undefined) { - localVarQueryParameter['exifInfo.model'] = exifInfoModel; - } - - if (exifInfoProjectionType !== undefined) { - localVarQueryParameter['exifInfo.projectionType'] = exifInfoProjectionType; - } - - if (smartInfoObjects) { - localVarQueryParameter['smartInfo.objects'] = smartInfoObjects; - } - - if (smartInfoTags) { - localVarQueryParameter['smartInfo.tags'] = smartInfoTags; - } - if (recent !== undefined) { localVarQueryParameter['recent'] = recent; } @@ -14752,23 +14702,13 @@ export const SearchApiFp = function(configuration?: Configuration) { * @param {string} [query] * @param {boolean} [clip] * @param {'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER'} [type] - * @param {boolean} [isFavorite] - * @param {boolean} [isArchived] - * @param {string} [exifInfoCity] - * @param {string} [exifInfoState] - * @param {string} [exifInfoCountry] - * @param {string} [exifInfoMake] - * @param {string} [exifInfoModel] - * @param {string} [exifInfoProjectionType] - * @param {Array} [smartInfoObjects] - * @param {Array} [smartInfoTags] * @param {boolean} [recent] * @param {boolean} [motion] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async search(q?: string, query?: string, clip?: boolean, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, isArchived?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, exifInfoProjectionType?: string, smartInfoObjects?: Array, smartInfoTags?: Array, recent?: boolean, motion?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.search(q, query, clip, type, isFavorite, isArchived, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, exifInfoProjectionType, smartInfoObjects, smartInfoTags, recent, motion, options); + async search(q?: string, query?: string, clip?: boolean, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', recent?: boolean, motion?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.search(q, query, clip, type, recent, motion, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** @@ -14807,7 +14747,7 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat * @throws {RequiredError} */ search(requestParameters: SearchApiSearchRequest = {}, options?: AxiosRequestConfig): AxiosPromise { - return localVarFp.search(requestParameters.q, requestParameters.query, requestParameters.clip, requestParameters.type, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.exifInfoCity, requestParameters.exifInfoState, requestParameters.exifInfoCountry, requestParameters.exifInfoMake, requestParameters.exifInfoModel, requestParameters.exifInfoProjectionType, requestParameters.smartInfoObjects, requestParameters.smartInfoTags, requestParameters.recent, requestParameters.motion, options).then((request) => request(axios, basePath)); + return localVarFp.search(requestParameters.q, requestParameters.query, requestParameters.clip, requestParameters.type, requestParameters.recent, requestParameters.motion, options).then((request) => request(axios, basePath)); }, /** * @@ -14855,76 +14795,6 @@ export interface SearchApiSearchRequest { */ readonly type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER' - /** - * - * @type {boolean} - * @memberof SearchApiSearch - */ - readonly isFavorite?: boolean - - /** - * - * @type {boolean} - * @memberof SearchApiSearch - */ - readonly isArchived?: boolean - - /** - * - * @type {string} - * @memberof SearchApiSearch - */ - readonly exifInfoCity?: string - - /** - * - * @type {string} - * @memberof SearchApiSearch - */ - readonly exifInfoState?: string - - /** - * - * @type {string} - * @memberof SearchApiSearch - */ - readonly exifInfoCountry?: string - - /** - * - * @type {string} - * @memberof SearchApiSearch - */ - readonly exifInfoMake?: string - - /** - * - * @type {string} - * @memberof SearchApiSearch - */ - readonly exifInfoModel?: string - - /** - * - * @type {string} - * @memberof SearchApiSearch - */ - readonly exifInfoProjectionType?: string - - /** - * - * @type {Array} - * @memberof SearchApiSearch - */ - readonly smartInfoObjects?: Array - - /** - * - * @type {Array} - * @memberof SearchApiSearch - */ - readonly smartInfoTags?: Array - /** * * @type {boolean} @@ -14986,7 +14856,7 @@ export class SearchApi extends BaseAPI { * @memberof SearchApi */ public search(requestParameters: SearchApiSearchRequest = {}, options?: AxiosRequestConfig) { - return SearchApiFp(this.configuration).search(requestParameters.q, requestParameters.query, requestParameters.clip, requestParameters.type, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.exifInfoCity, requestParameters.exifInfoState, requestParameters.exifInfoCountry, requestParameters.exifInfoMake, requestParameters.exifInfoModel, requestParameters.exifInfoProjectionType, requestParameters.smartInfoObjects, requestParameters.smartInfoTags, requestParameters.recent, requestParameters.motion, options).then((request) => request(this.axios, this.basePath)); + return SearchApiFp(this.configuration).search(requestParameters.q, requestParameters.query, requestParameters.clip, requestParameters.type, requestParameters.recent, requestParameters.motion, options).then((request) => request(this.axios, this.basePath)); } /** diff --git a/web/src/routes/(user)/explore/+page.svelte b/web/src/routes/(user)/explore/+page.svelte index 1fb2df0011ba2..2b694eea3c22d 100644 --- a/web/src/routes/(user)/explore/+page.svelte +++ b/web/src/routes/(user)/explore/+page.svelte @@ -3,16 +3,8 @@ import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte'; import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte'; import { AppRoute } from '$lib/constants'; - import { AssetTypeEnum, SearchExploreResponseDto, api } from '@api'; - import Icon from '$lib/components/elements/icon.svelte'; + import { SearchExploreResponseDto, api } from '@api'; import type { PageData } from './$types'; - import { - mdiHeartMultipleOutline, - mdiClockOutline, - mdiPlayCircleOutline, - mdiMotionPlayOutline, - mdiRotate360, - } from '@mdi/js'; export let data: PageData; @@ -123,58 +115,4 @@ {/if}
- -