diff --git a/docker/.env.test b/docker/.env.test index 23f58fe805757..82311b7d581e4 100644 --- a/docker/.env.test +++ b/docker/.env.test @@ -17,3 +17,5 @@ ENABLE_MAPBOX=false # WEB MAPBOX_KEY= VITE_SERVER_ENDPOINT=http://localhost:2283/api + +TYPESENSE_ENABLED=false diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index a0e3c078cde9f..eebde63e7435b 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -23,6 +23,7 @@ services: depends_on: - redis - database + - typesense immich-machine-learning: container_name: immich_machine_learning @@ -64,6 +65,7 @@ services: depends_on: - database - immich-server + - typesense immich-web: container_name: immich_web @@ -89,6 +91,15 @@ services: depends_on: - immich-server + typesense: + container_name: immich_typesense + image: typesense/typesense:0.24.0 + environment: + - TYPESENSE_API_KEY=${TYPESENSE_API_KEY} + - TYPESENSE_DATA_DIR=/data + volumes: + - tsdata:/data + redis: container_name: immich_redis image: redis:6.2 @@ -129,3 +140,4 @@ services: volumes: pgdata: model-cache: + tsdata: diff --git a/docker/docker-compose.test.yml b/docker/docker-compose.test.yml index 343374e5c4faa..51d397b022c82 100644 --- a/docker/docker-compose.test.yml +++ b/docker/docker-compose.test.yml @@ -1,4 +1,4 @@ -version: '3.8' +version: "3.8" services: immich-server-test: @@ -9,7 +9,7 @@ services: target: builder command: npm run test:e2e expose: - - '3000' + - "3000" volumes: - ../server:/usr/src/app - /usr/src/app/node_modules @@ -17,6 +17,7 @@ services: - .env.test environment: - NODE_ENV=development + - TYPESENSE_ENABLED=false depends_on: - immich-redis-test - immich-database-test diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 5076aebc6f18b..279a3a9edf00c 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -4,7 +4,7 @@ services: immich-server: container_name: immich_server image: altran1502/immich-server:release - entrypoint: [ "/bin/sh", "./start-server.sh" ] + entrypoint: ["/bin/sh", "./start-server.sh"] volumes: - ${UPLOAD_LOCATION}:/usr/src/app/upload env_file: @@ -14,12 +14,13 @@ services: depends_on: - redis - database + - typesense restart: always immich-microservices: container_name: immich_microservices image: altran1502/immich-server:release - entrypoint: [ "/bin/sh", "./start-microservices.sh" ] + entrypoint: ["/bin/sh", "./start-microservices.sh"] volumes: - ${UPLOAD_LOCATION}:/usr/src/app/upload env_file: @@ -29,6 +30,7 @@ services: depends_on: - redis - database + - typesense restart: always immich-machine-learning: @@ -46,11 +48,20 @@ services: immich-web: container_name: immich_web image: altran1502/immich-web:release - entrypoint: [ "/bin/sh", "./entrypoint.sh" ] + entrypoint: ["/bin/sh", "./entrypoint.sh"] env_file: - .env restart: always + typesense: + container_name: immich_typesense + image: typesense/typesense:0.24.0 + environment: + - TYPESENSE_API_KEY=${TYPESENSE_API_KEY} + - TYPESENSE_DATA_DIR=/data + volumes: + - tsdata:/data + redis: container_name: immich_redis image: redis:6.2 @@ -88,3 +99,4 @@ services: volumes: pgdata: model-cache: + tsdata: diff --git a/docker/example.env b/docker/example.env index 2cfb1e7351ba7..2f77e80723824 100644 --- a/docker/example.env +++ b/docker/example.env @@ -30,6 +30,13 @@ REDIS_HOSTNAME=immich_redis UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup + +################################################################################### +# Typesense +################################################################################### +TYPESENSE_API_KEY=some-random-text +# TYPESENSE_ENABLED=false + ################################################################################### # Reverse Geocoding # @@ -76,4 +83,4 @@ IMMICH_MACHINE_LEARNING_URL=http://immich-machine-learning:3003 # Examples: http://localhost:3001, http://immich-api.example.com, etc #################################################################################### -#IMMICH_API_URL_EXTERNAL=http://localhost:3001 \ No newline at end of file +#IMMICH_API_URL_EXTERNAL=http://localhost:3001 diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index dd3c2b3a66d31..7a37ef8886f25 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -61,7 +61,14 @@ doc/OAuthCallbackDto.md doc/OAuthConfigDto.md doc/OAuthConfigResponseDto.md doc/RemoveAssetsDto.md +doc/SearchAlbumResponseDto.md +doc/SearchApi.md doc/SearchAssetDto.md +doc/SearchAssetResponseDto.md +doc/SearchConfigResponseDto.md +doc/SearchFacetCountResponseDto.md +doc/SearchFacetResponseDto.md +doc/SearchResponseDto.md doc/ServerInfoApi.md doc/ServerInfoResponseDto.md doc/ServerPingResponse.md @@ -103,6 +110,7 @@ lib/api/authentication_api.dart lib/api/device_info_api.dart lib/api/job_api.dart lib/api/o_auth_api.dart +lib/api/search_api.dart lib/api/server_info_api.dart lib/api/share_api.dart lib/api/system_config_api.dart @@ -167,7 +175,13 @@ lib/model/o_auth_callback_dto.dart lib/model/o_auth_config_dto.dart lib/model/o_auth_config_response_dto.dart lib/model/remove_assets_dto.dart +lib/model/search_album_response_dto.dart lib/model/search_asset_dto.dart +lib/model/search_asset_response_dto.dart +lib/model/search_config_response_dto.dart +lib/model/search_facet_count_response_dto.dart +lib/model/search_facet_response_dto.dart +lib/model/search_response_dto.dart lib/model/server_info_response_dto.dart lib/model/server_ping_response.dart lib/model/server_stats_response_dto.dart @@ -254,7 +268,14 @@ test/o_auth_callback_dto_test.dart test/o_auth_config_dto_test.dart test/o_auth_config_response_dto_test.dart test/remove_assets_dto_test.dart +test/search_album_response_dto_test.dart +test/search_api_test.dart test/search_asset_dto_test.dart +test/search_asset_response_dto_test.dart +test/search_config_response_dto_test.dart +test/search_facet_count_response_dto_test.dart +test/search_facet_response_dto_test.dart +test/search_response_dto_test.dart test/server_info_api_test.dart test/server_info_response_dto_test.dart test/server_ping_response_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 987f0d36e09e7..4717cf9704c9f 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -121,6 +121,8 @@ Class | Method | HTTP request | Description *OAuthApi* | [**link**](doc//OAuthApi.md#link) | **POST** /oauth/link | *OAuthApi* | [**mobileRedirect**](doc//OAuthApi.md#mobileredirect) | **GET** /oauth/mobile-redirect | *OAuthApi* | [**unlink**](doc//OAuthApi.md#unlink) | **POST** /oauth/unlink | +*SearchApi* | [**getSearchConfig**](doc//SearchApi.md#getsearchconfig) | **GET** /search/config | +*SearchApi* | [**search**](doc//SearchApi.md#search) | **GET** /search | *ServerInfoApi* | [**getServerInfo**](doc//ServerInfoApi.md#getserverinfo) | **GET** /server-info | *ServerInfoApi* | [**getServerVersion**](doc//ServerInfoApi.md#getserverversion) | **GET** /server-info/version | *ServerInfoApi* | [**getStats**](doc//ServerInfoApi.md#getstats) | **GET** /server-info/stats | @@ -204,7 +206,13 @@ Class | Method | HTTP request | Description - [OAuthConfigDto](doc//OAuthConfigDto.md) - [OAuthConfigResponseDto](doc//OAuthConfigResponseDto.md) - [RemoveAssetsDto](doc//RemoveAssetsDto.md) + - [SearchAlbumResponseDto](doc//SearchAlbumResponseDto.md) - [SearchAssetDto](doc//SearchAssetDto.md) + - [SearchAssetResponseDto](doc//SearchAssetResponseDto.md) + - [SearchConfigResponseDto](doc//SearchConfigResponseDto.md) + - [SearchFacetCountResponseDto](doc//SearchFacetCountResponseDto.md) + - [SearchFacetResponseDto](doc//SearchFacetResponseDto.md) + - [SearchResponseDto](doc//SearchResponseDto.md) - [ServerInfoResponseDto](doc//ServerInfoResponseDto.md) - [ServerPingResponse](doc//ServerPingResponse.md) - [ServerStatsResponseDto](doc//ServerStatsResponseDto.md) diff --git a/mobile/openapi/doc/SearchAlbumResponseDto.md b/mobile/openapi/doc/SearchAlbumResponseDto.md new file mode 100644 index 0000000000000..f78a51a0f9d1a --- /dev/null +++ b/mobile/openapi/doc/SearchAlbumResponseDto.md @@ -0,0 +1,18 @@ +# openapi.model.SearchAlbumResponseDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**total** | **int** | | +**count** | **int** | | +**items** | [**List**](AlbumResponseDto.md) | | [default to const []] +**facets** | [**List**](SearchFacetResponseDto.md) | | [default to const []] + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/doc/SearchApi.md b/mobile/openapi/doc/SearchApi.md new file mode 100644 index 0000000000000..ebf8c884e6a39 --- /dev/null +++ b/mobile/openapi/doc/SearchApi.md @@ -0,0 +1,135 @@ +# openapi.api.SearchApi + +## Load the API package +```dart +import 'package:openapi/api.dart'; +``` + +All URIs are relative to */api* + +Method | HTTP request | Description +------------- | ------------- | ------------- +[**getSearchConfig**](SearchApi.md#getsearchconfig) | **GET** /search/config | +[**search**](SearchApi.md#search) | **GET** /search | + + +# **getSearchConfig** +> SearchConfigResponseDto getSearchConfig() + + + + + +### Example +```dart +import 'package:openapi/api.dart'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; + +final api_instance = SearchApi(); + +try { + final result = api_instance.getSearchConfig(); + print(result); +} catch (e) { + print('Exception when calling SearchApi->getSearchConfig: $e\n'); +} +``` + +### Parameters +This endpoint does not need any parameter. + +### Return type + +[**SearchConfigResponseDto**](SearchConfigResponseDto.md) + +### Authorization + +[bearer](../README.md#bearer), [cookie](../README.md#cookie) + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + +# **search** +> SearchResponseDto search(query, type, isFavorite, exifInfoPeriodCity, exifInfoPeriodState, exifInfoPeriodCountry, exifInfoPeriodMake, exifInfoPeriodModel, smartInfoPeriodObjects, smartInfoPeriodTags) + + + + + +### Example +```dart +import 'package:openapi/api.dart'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; + +final api_instance = SearchApi(); +final query = query_example; // String | +final type = type_example; // String | +final isFavorite = 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 smartInfoPeriodObjects = []; // List | +final smartInfoPeriodTags = []; // List | + +try { + final result = api_instance.search(query, type, isFavorite, exifInfoPeriodCity, exifInfoPeriodState, exifInfoPeriodCountry, exifInfoPeriodMake, exifInfoPeriodModel, smartInfoPeriodObjects, smartInfoPeriodTags); + print(result); +} catch (e) { + print('Exception when calling SearchApi->search: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **query** | **String**| | [optional] + **type** | **String**| | [optional] + **isFavorite** | **bool**| | [optional] + **exifInfoPeriodCity** | **String**| | [optional] + **exifInfoPeriodState** | **String**| | [optional] + **exifInfoPeriodCountry** | **String**| | [optional] + **exifInfoPeriodMake** | **String**| | [optional] + **exifInfoPeriodModel** | **String**| | [optional] + **smartInfoPeriodObjects** | [**List**](String.md)| | [optional] [default to const []] + **smartInfoPeriodTags** | [**List**](String.md)| | [optional] [default to const []] + +### Return type + +[**SearchResponseDto**](SearchResponseDto.md) + +### Authorization + +[bearer](../README.md#bearer), [cookie](../README.md#cookie) + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + diff --git a/mobile/openapi/doc/SearchAssetResponseDto.md b/mobile/openapi/doc/SearchAssetResponseDto.md new file mode 100644 index 0000000000000..871a70693a440 --- /dev/null +++ b/mobile/openapi/doc/SearchAssetResponseDto.md @@ -0,0 +1,18 @@ +# openapi.model.SearchAssetResponseDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**total** | **int** | | +**count** | **int** | | +**items** | [**List**](AssetResponseDto.md) | | [default to const []] +**facets** | [**List**](SearchFacetResponseDto.md) | | [default to const []] + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/doc/SearchConfigResponseDto.md b/mobile/openapi/doc/SearchConfigResponseDto.md new file mode 100644 index 0000000000000..25020ea756675 --- /dev/null +++ b/mobile/openapi/doc/SearchConfigResponseDto.md @@ -0,0 +1,15 @@ +# openapi.model.SearchConfigResponseDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**enabled** | **bool** | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/doc/SearchFacetCountResponseDto.md b/mobile/openapi/doc/SearchFacetCountResponseDto.md new file mode 100644 index 0000000000000..b9188b003c73b --- /dev/null +++ b/mobile/openapi/doc/SearchFacetCountResponseDto.md @@ -0,0 +1,16 @@ +# openapi.model.SearchFacetCountResponseDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**count** | **int** | | +**value** | **String** | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/doc/SearchFacetResponseDto.md b/mobile/openapi/doc/SearchFacetResponseDto.md new file mode 100644 index 0000000000000..c78d5ae8e5e9d --- /dev/null +++ b/mobile/openapi/doc/SearchFacetResponseDto.md @@ -0,0 +1,16 @@ +# openapi.model.SearchFacetResponseDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**fieldName** | **String** | | +**counts** | [**List**](SearchFacetCountResponseDto.md) | | [default to const []] + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/doc/SearchResponseDto.md b/mobile/openapi/doc/SearchResponseDto.md new file mode 100644 index 0000000000000..3b8ce07fdd4bd --- /dev/null +++ b/mobile/openapi/doc/SearchResponseDto.md @@ -0,0 +1,16 @@ +# openapi.model.SearchResponseDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**albums** | [**SearchAlbumResponseDto**](SearchAlbumResponseDto.md) | | +**assets** | [**SearchAssetResponseDto**](SearchAssetResponseDto.md) | | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 2f426ca69922c..3f0c9efe45d56 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -35,6 +35,7 @@ part 'api/authentication_api.dart'; part 'api/device_info_api.dart'; part 'api/job_api.dart'; part 'api/o_auth_api.dart'; +part 'api/search_api.dart'; part 'api/server_info_api.dart'; part 'api/share_api.dart'; part 'api/system_config_api.dart'; @@ -92,7 +93,13 @@ part 'model/o_auth_callback_dto.dart'; part 'model/o_auth_config_dto.dart'; part 'model/o_auth_config_response_dto.dart'; part 'model/remove_assets_dto.dart'; +part 'model/search_album_response_dto.dart'; part 'model/search_asset_dto.dart'; +part 'model/search_asset_response_dto.dart'; +part 'model/search_config_response_dto.dart'; +part 'model/search_facet_count_response_dto.dart'; +part 'model/search_facet_response_dto.dart'; +part 'model/search_response_dto.dart'; part 'model/server_info_response_dto.dart'; part 'model/server_ping_response.dart'; part 'model/server_stats_response_dto.dart'; diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart new file mode 100644 index 0000000000000..652270ed9b40f --- /dev/null +++ b/mobile/openapi/lib/api/search_api.dart @@ -0,0 +1,181 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class SearchApi { + SearchApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; + + final ApiClient apiClient; + + /// + /// + /// Note: This method returns the HTTP [Response]. + Future getSearchConfigWithHttpInfo() async { + // ignore: prefer_const_declarations + final path = r'/search/config'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// + Future getSearchConfig() async { + final response = await getSearchConfigWithHttpInfo(); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'SearchConfigResponseDto',) as SearchConfigResponseDto; + + } + return null; + } + + /// + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] query: + /// + /// * [String] type: + /// + /// * [bool] isFavorite: + /// + /// * [String] exifInfoPeriodCity: + /// + /// * [String] exifInfoPeriodState: + /// + /// * [String] exifInfoPeriodCountry: + /// + /// * [String] exifInfoPeriodMake: + /// + /// * [String] exifInfoPeriodModel: + /// + /// * [List] smartInfoPeriodObjects: + /// + /// * [List] smartInfoPeriodTags: + Future searchWithHttpInfo({ String? query, String? type, bool? isFavorite, String? exifInfoPeriodCity, String? exifInfoPeriodState, String? exifInfoPeriodCountry, String? exifInfoPeriodMake, String? exifInfoPeriodModel, List? smartInfoPeriodObjects, List? smartInfoPeriodTags, }) async { + // ignore: prefer_const_declarations + final path = r'/search'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + if (query != null) { + queryParams.addAll(_queryParams('', 'query', query)); + } + if (type != null) { + queryParams.addAll(_queryParams('', 'type', type)); + } + if (isFavorite != null) { + queryParams.addAll(_queryParams('', 'isFavorite', isFavorite)); + } + 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 (smartInfoPeriodObjects != null) { + queryParams.addAll(_queryParams('multi', 'smartInfo.objects', smartInfoPeriodObjects)); + } + if (smartInfoPeriodTags != null) { + queryParams.addAll(_queryParams('multi', 'smartInfo.tags', smartInfoPeriodTags)); + } + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// + /// + /// Parameters: + /// + /// * [String] query: + /// + /// * [String] type: + /// + /// * [bool] isFavorite: + /// + /// * [String] exifInfoPeriodCity: + /// + /// * [String] exifInfoPeriodState: + /// + /// * [String] exifInfoPeriodCountry: + /// + /// * [String] exifInfoPeriodMake: + /// + /// * [String] exifInfoPeriodModel: + /// + /// * [List] smartInfoPeriodObjects: + /// + /// * [List] smartInfoPeriodTags: + Future search({ String? query, String? type, bool? isFavorite, String? exifInfoPeriodCity, String? exifInfoPeriodState, String? exifInfoPeriodCountry, String? exifInfoPeriodMake, String? exifInfoPeriodModel, List? smartInfoPeriodObjects, List? smartInfoPeriodTags, }) async { + final response = await searchWithHttpInfo( query: query, type: type, isFavorite: isFavorite, exifInfoPeriodCity: exifInfoPeriodCity, exifInfoPeriodState: exifInfoPeriodState, exifInfoPeriodCountry: exifInfoPeriodCountry, exifInfoPeriodMake: exifInfoPeriodMake, exifInfoPeriodModel: exifInfoPeriodModel, smartInfoPeriodObjects: smartInfoPeriodObjects, smartInfoPeriodTags: smartInfoPeriodTags, ); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'SearchResponseDto',) as SearchResponseDto; + + } + return null; + } +} diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 8fe18d2f7964f..676e09dd6f644 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -294,8 +294,20 @@ class ApiClient { return OAuthConfigResponseDto.fromJson(value); case 'RemoveAssetsDto': return RemoveAssetsDto.fromJson(value); + case 'SearchAlbumResponseDto': + return SearchAlbumResponseDto.fromJson(value); case 'SearchAssetDto': return SearchAssetDto.fromJson(value); + case 'SearchAssetResponseDto': + return SearchAssetResponseDto.fromJson(value); + case 'SearchConfigResponseDto': + return SearchConfigResponseDto.fromJson(value); + case 'SearchFacetCountResponseDto': + return SearchFacetCountResponseDto.fromJson(value); + case 'SearchFacetResponseDto': + return SearchFacetResponseDto.fromJson(value); + case 'SearchResponseDto': + return SearchResponseDto.fromJson(value); case 'ServerInfoResponseDto': return ServerInfoResponseDto.fromJson(value); case 'ServerPingResponse': diff --git a/mobile/openapi/lib/model/search_album_response_dto.dart b/mobile/openapi/lib/model/search_album_response_dto.dart new file mode 100644 index 0000000000000..8a68fbe49f20e --- /dev/null +++ b/mobile/openapi/lib/model/search_album_response_dto.dart @@ -0,0 +1,135 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SearchAlbumResponseDto { + /// Returns a new [SearchAlbumResponseDto] instance. + SearchAlbumResponseDto({ + required this.total, + required this.count, + this.items = const [], + this.facets = const [], + }); + + int total; + + int count; + + List items; + + List facets; + + @override + bool operator ==(Object other) => identical(this, other) || other is SearchAlbumResponseDto && + other.total == total && + other.count == count && + other.items == items && + other.facets == facets; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (total.hashCode) + + (count.hashCode) + + (items.hashCode) + + (facets.hashCode); + + @override + String toString() => 'SearchAlbumResponseDto[total=$total, count=$count, items=$items, facets=$facets]'; + + Map toJson() { + final json = {}; + json[r'total'] = this.total; + json[r'count'] = this.count; + json[r'items'] = this.items; + json[r'facets'] = this.facets; + return json; + } + + /// Returns a new [SearchAlbumResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SearchAlbumResponseDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + // Ensure that the map contains the required keys. + // Note 1: the values aren't checked for validity beyond being non-null. + // Note 2: this code is stripped in release mode! + assert(() { + requiredKeys.forEach((key) { + assert(json.containsKey(key), 'Required key "SearchAlbumResponseDto[$key]" is missing from JSON.'); + assert(json[key] != null, 'Required key "SearchAlbumResponseDto[$key]" has a null value in JSON.'); + }); + return true; + }()); + + return SearchAlbumResponseDto( + total: mapValueOfType(json, r'total')!, + count: mapValueOfType(json, r'count')!, + items: AlbumResponseDto.listFromJson(json[r'items'])!, + facets: SearchFacetResponseDto.listFromJson(json[r'facets'])!, + ); + } + return null; + } + + static List? listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SearchAlbumResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SearchAlbumResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SearchAlbumResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SearchAlbumResponseDto.listFromJson(entry.value, growable: growable,); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'total', + 'count', + 'items', + 'facets', + }; +} + diff --git a/mobile/openapi/lib/model/search_asset_response_dto.dart b/mobile/openapi/lib/model/search_asset_response_dto.dart new file mode 100644 index 0000000000000..fd1b8b545f1c0 --- /dev/null +++ b/mobile/openapi/lib/model/search_asset_response_dto.dart @@ -0,0 +1,135 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SearchAssetResponseDto { + /// Returns a new [SearchAssetResponseDto] instance. + SearchAssetResponseDto({ + required this.total, + required this.count, + this.items = const [], + this.facets = const [], + }); + + int total; + + int count; + + List items; + + List facets; + + @override + bool operator ==(Object other) => identical(this, other) || other is SearchAssetResponseDto && + other.total == total && + other.count == count && + other.items == items && + other.facets == facets; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (total.hashCode) + + (count.hashCode) + + (items.hashCode) + + (facets.hashCode); + + @override + String toString() => 'SearchAssetResponseDto[total=$total, count=$count, items=$items, facets=$facets]'; + + Map toJson() { + final json = {}; + json[r'total'] = this.total; + json[r'count'] = this.count; + json[r'items'] = this.items; + json[r'facets'] = this.facets; + return json; + } + + /// Returns a new [SearchAssetResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SearchAssetResponseDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + // Ensure that the map contains the required keys. + // Note 1: the values aren't checked for validity beyond being non-null. + // Note 2: this code is stripped in release mode! + assert(() { + requiredKeys.forEach((key) { + assert(json.containsKey(key), 'Required key "SearchAssetResponseDto[$key]" is missing from JSON.'); + assert(json[key] != null, 'Required key "SearchAssetResponseDto[$key]" has a null value in JSON.'); + }); + return true; + }()); + + return SearchAssetResponseDto( + total: mapValueOfType(json, r'total')!, + count: mapValueOfType(json, r'count')!, + items: AssetResponseDto.listFromJson(json[r'items'])!, + facets: SearchFacetResponseDto.listFromJson(json[r'facets'])!, + ); + } + return null; + } + + static List? listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SearchAssetResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SearchAssetResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SearchAssetResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SearchAssetResponseDto.listFromJson(entry.value, growable: growable,); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'total', + 'count', + 'items', + 'facets', + }; +} + diff --git a/mobile/openapi/lib/model/search_config_response_dto.dart b/mobile/openapi/lib/model/search_config_response_dto.dart new file mode 100644 index 0000000000000..72faed25dcdb9 --- /dev/null +++ b/mobile/openapi/lib/model/search_config_response_dto.dart @@ -0,0 +1,111 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SearchConfigResponseDto { + /// Returns a new [SearchConfigResponseDto] instance. + SearchConfigResponseDto({ + required this.enabled, + }); + + bool enabled; + + @override + bool operator ==(Object other) => identical(this, other) || other is SearchConfigResponseDto && + other.enabled == enabled; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (enabled.hashCode); + + @override + String toString() => 'SearchConfigResponseDto[enabled=$enabled]'; + + Map toJson() { + final json = {}; + json[r'enabled'] = this.enabled; + return json; + } + + /// Returns a new [SearchConfigResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SearchConfigResponseDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + // Ensure that the map contains the required keys. + // Note 1: the values aren't checked for validity beyond being non-null. + // Note 2: this code is stripped in release mode! + assert(() { + requiredKeys.forEach((key) { + assert(json.containsKey(key), 'Required key "SearchConfigResponseDto[$key]" is missing from JSON.'); + assert(json[key] != null, 'Required key "SearchConfigResponseDto[$key]" has a null value in JSON.'); + }); + return true; + }()); + + return SearchConfigResponseDto( + enabled: mapValueOfType(json, r'enabled')!, + ); + } + return null; + } + + static List? listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SearchConfigResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SearchConfigResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SearchConfigResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SearchConfigResponseDto.listFromJson(entry.value, growable: growable,); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'enabled', + }; +} + diff --git a/mobile/openapi/lib/model/search_facet_count_response_dto.dart b/mobile/openapi/lib/model/search_facet_count_response_dto.dart new file mode 100644 index 0000000000000..8b9d30f81e087 --- /dev/null +++ b/mobile/openapi/lib/model/search_facet_count_response_dto.dart @@ -0,0 +1,119 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SearchFacetCountResponseDto { + /// Returns a new [SearchFacetCountResponseDto] instance. + SearchFacetCountResponseDto({ + required this.count, + required this.value, + }); + + int count; + + String value; + + @override + bool operator ==(Object other) => identical(this, other) || other is SearchFacetCountResponseDto && + other.count == count && + other.value == value; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (count.hashCode) + + (value.hashCode); + + @override + String toString() => 'SearchFacetCountResponseDto[count=$count, value=$value]'; + + Map toJson() { + final json = {}; + json[r'count'] = this.count; + json[r'value'] = this.value; + return json; + } + + /// Returns a new [SearchFacetCountResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SearchFacetCountResponseDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + // Ensure that the map contains the required keys. + // Note 1: the values aren't checked for validity beyond being non-null. + // Note 2: this code is stripped in release mode! + assert(() { + requiredKeys.forEach((key) { + assert(json.containsKey(key), 'Required key "SearchFacetCountResponseDto[$key]" is missing from JSON.'); + assert(json[key] != null, 'Required key "SearchFacetCountResponseDto[$key]" has a null value in JSON.'); + }); + return true; + }()); + + return SearchFacetCountResponseDto( + count: mapValueOfType(json, r'count')!, + value: mapValueOfType(json, r'value')!, + ); + } + return null; + } + + static List? listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SearchFacetCountResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SearchFacetCountResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SearchFacetCountResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SearchFacetCountResponseDto.listFromJson(entry.value, growable: growable,); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'count', + 'value', + }; +} + diff --git a/mobile/openapi/lib/model/search_facet_response_dto.dart b/mobile/openapi/lib/model/search_facet_response_dto.dart new file mode 100644 index 0000000000000..78a4756af2f41 --- /dev/null +++ b/mobile/openapi/lib/model/search_facet_response_dto.dart @@ -0,0 +1,119 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SearchFacetResponseDto { + /// Returns a new [SearchFacetResponseDto] instance. + SearchFacetResponseDto({ + required this.fieldName, + this.counts = const [], + }); + + String fieldName; + + List counts; + + @override + bool operator ==(Object other) => identical(this, other) || other is SearchFacetResponseDto && + other.fieldName == fieldName && + other.counts == counts; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (fieldName.hashCode) + + (counts.hashCode); + + @override + String toString() => 'SearchFacetResponseDto[fieldName=$fieldName, counts=$counts]'; + + Map toJson() { + final json = {}; + json[r'fieldName'] = this.fieldName; + json[r'counts'] = this.counts; + return json; + } + + /// Returns a new [SearchFacetResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SearchFacetResponseDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + // Ensure that the map contains the required keys. + // Note 1: the values aren't checked for validity beyond being non-null. + // Note 2: this code is stripped in release mode! + assert(() { + requiredKeys.forEach((key) { + assert(json.containsKey(key), 'Required key "SearchFacetResponseDto[$key]" is missing from JSON.'); + assert(json[key] != null, 'Required key "SearchFacetResponseDto[$key]" has a null value in JSON.'); + }); + return true; + }()); + + return SearchFacetResponseDto( + fieldName: mapValueOfType(json, r'fieldName')!, + counts: SearchFacetCountResponseDto.listFromJson(json[r'counts'])!, + ); + } + return null; + } + + static List? listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SearchFacetResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SearchFacetResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SearchFacetResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SearchFacetResponseDto.listFromJson(entry.value, growable: growable,); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'fieldName', + 'counts', + }; +} + diff --git a/mobile/openapi/lib/model/search_response_dto.dart b/mobile/openapi/lib/model/search_response_dto.dart new file mode 100644 index 0000000000000..c0e5c1a99e7aa --- /dev/null +++ b/mobile/openapi/lib/model/search_response_dto.dart @@ -0,0 +1,119 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SearchResponseDto { + /// Returns a new [SearchResponseDto] instance. + SearchResponseDto({ + required this.albums, + required this.assets, + }); + + SearchAlbumResponseDto albums; + + SearchAssetResponseDto assets; + + @override + bool operator ==(Object other) => identical(this, other) || other is SearchResponseDto && + other.albums == albums && + other.assets == assets; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (albums.hashCode) + + (assets.hashCode); + + @override + String toString() => 'SearchResponseDto[albums=$albums, assets=$assets]'; + + Map toJson() { + final json = {}; + json[r'albums'] = this.albums; + json[r'assets'] = this.assets; + return json; + } + + /// Returns a new [SearchResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SearchResponseDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + // Ensure that the map contains the required keys. + // Note 1: the values aren't checked for validity beyond being non-null. + // Note 2: this code is stripped in release mode! + assert(() { + requiredKeys.forEach((key) { + assert(json.containsKey(key), 'Required key "SearchResponseDto[$key]" is missing from JSON.'); + assert(json[key] != null, 'Required key "SearchResponseDto[$key]" has a null value in JSON.'); + }); + return true; + }()); + + return SearchResponseDto( + albums: SearchAlbumResponseDto.fromJson(json[r'albums'])!, + assets: SearchAssetResponseDto.fromJson(json[r'assets'])!, + ); + } + return null; + } + + static List? listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SearchResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SearchResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SearchResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SearchResponseDto.listFromJson(entry.value, growable: growable,); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'albums', + 'assets', + }; +} + diff --git a/mobile/openapi/test/search_album_response_dto_test.dart b/mobile/openapi/test/search_album_response_dto_test.dart new file mode 100644 index 0000000000000..7de4a859bf6fd --- /dev/null +++ b/mobile/openapi/test/search_album_response_dto_test.dart @@ -0,0 +1,42 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for SearchAlbumResponseDto +void main() { + // final instance = SearchAlbumResponseDto(); + + group('test SearchAlbumResponseDto', () { + // int total + test('to test the property `total`', () async { + // TODO + }); + + // int count + test('to test the property `count`', () async { + // TODO + }); + + // List items (default value: const []) + test('to test the property `items`', () async { + // TODO + }); + + // List facets (default value: const []) + test('to test the property `facets`', () async { + // TODO + }); + + + }); + +} diff --git a/mobile/openapi/test/search_api_test.dart b/mobile/openapi/test/search_api_test.dart new file mode 100644 index 0000000000000..6286e048a2b19 --- /dev/null +++ b/mobile/openapi/test/search_api_test.dart @@ -0,0 +1,35 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + + +/// tests for SearchApi +void main() { + // final instance = SearchApi(); + + group('tests for SearchApi', () { + // + // + //Future getSearchConfig() async + test('test getSearchConfig', () async { + // TODO + }); + + // + // + //Future search({ String query, String type, bool isFavorite, String exifInfoPeriodCity, String exifInfoPeriodState, String exifInfoPeriodCountry, String exifInfoPeriodMake, String exifInfoPeriodModel, List smartInfoPeriodObjects, List smartInfoPeriodTags }) async + test('test search', () async { + // TODO + }); + + }); +} diff --git a/mobile/openapi/test/search_asset_response_dto_test.dart b/mobile/openapi/test/search_asset_response_dto_test.dart new file mode 100644 index 0000000000000..b1452681ca8b6 --- /dev/null +++ b/mobile/openapi/test/search_asset_response_dto_test.dart @@ -0,0 +1,42 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for SearchAssetResponseDto +void main() { + // final instance = SearchAssetResponseDto(); + + group('test SearchAssetResponseDto', () { + // int total + test('to test the property `total`', () async { + // TODO + }); + + // int count + test('to test the property `count`', () async { + // TODO + }); + + // List items (default value: const []) + test('to test the property `items`', () async { + // TODO + }); + + // List facets (default value: const []) + test('to test the property `facets`', () async { + // TODO + }); + + + }); + +} diff --git a/mobile/openapi/test/search_config_response_dto_test.dart b/mobile/openapi/test/search_config_response_dto_test.dart new file mode 100644 index 0000000000000..2a49752011abe --- /dev/null +++ b/mobile/openapi/test/search_config_response_dto_test.dart @@ -0,0 +1,27 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for SearchConfigResponseDto +void main() { + // final instance = SearchConfigResponseDto(); + + group('test SearchConfigResponseDto', () { + // bool enabled + test('to test the property `enabled`', () async { + // TODO + }); + + + }); + +} diff --git a/mobile/openapi/test/search_facet_count_response_dto_test.dart b/mobile/openapi/test/search_facet_count_response_dto_test.dart new file mode 100644 index 0000000000000..fb3caee62769d --- /dev/null +++ b/mobile/openapi/test/search_facet_count_response_dto_test.dart @@ -0,0 +1,32 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for SearchFacetCountResponseDto +void main() { + // final instance = SearchFacetCountResponseDto(); + + group('test SearchFacetCountResponseDto', () { + // int count + test('to test the property `count`', () async { + // TODO + }); + + // String value + test('to test the property `value`', () async { + // TODO + }); + + + }); + +} diff --git a/mobile/openapi/test/search_facet_response_dto_test.dart b/mobile/openapi/test/search_facet_response_dto_test.dart new file mode 100644 index 0000000000000..06c374497a881 --- /dev/null +++ b/mobile/openapi/test/search_facet_response_dto_test.dart @@ -0,0 +1,32 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for SearchFacetResponseDto +void main() { + // final instance = SearchFacetResponseDto(); + + group('test SearchFacetResponseDto', () { + // String fieldName + test('to test the property `fieldName`', () async { + // TODO + }); + + // List counts (default value: const []) + test('to test the property `counts`', () async { + // TODO + }); + + + }); + +} diff --git a/mobile/openapi/test/search_response_dto_test.dart b/mobile/openapi/test/search_response_dto_test.dart new file mode 100644 index 0000000000000..06f8fa7a3a324 --- /dev/null +++ b/mobile/openapi/test/search_response_dto_test.dart @@ -0,0 +1,32 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for SearchResponseDto +void main() { + // final instance = SearchResponseDto(); + + group('test SearchResponseDto', () { + // SearchAlbumResponseDto albums + test('to test the property `albums`', () async { + // TODO + }); + + // SearchAssetResponseDto assets + test('to test the property `assets`', () async { + // TODO + }); + + + }); + +} diff --git a/server/apps/immich/src/api-v1/album/album.service.spec.ts b/server/apps/immich/src/api-v1/album/album.service.spec.ts index 66c7d51108998..a469940bf9dd8 100644 --- a/server/apps/immich/src/api-v1/album/album.service.spec.ts +++ b/server/apps/immich/src/api-v1/album/album.service.spec.ts @@ -2,7 +2,7 @@ import { AlbumService } from './album.service'; import { AuthUserDto } from '../../decorators/auth-user.decorator'; import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common'; import { AlbumEntity, AssetEntity, UserEntity } from '@app/infra'; -import { AlbumResponseDto, ICryptoRepository, mapUser } from '@app/domain'; +import { AlbumResponseDto, ICryptoRepository, IJobRepository, JobName, mapUser } from '@app/domain'; import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto'; import { IAlbumRepository } from './album-repository'; import { DownloadService } from '../../modules/download/download.service'; @@ -10,6 +10,7 @@ import { ISharedLinkRepository } from '@app/domain'; import { assetEntityStub, newCryptoRepositoryMock, + newJobRepositoryMock, newSharedLinkRepositoryMock, userEntityStub, } from '@app/domain/../test'; @@ -20,6 +21,7 @@ describe('Album service', () => { let sharedLinkRepositoryMock: jest.Mocked; let downloadServiceMock: jest.Mocked>; let cryptoMock: jest.Mocked; + let jobMock: jest.Mocked; const authUser: AuthUserDto = Object.freeze({ id: '1111', @@ -139,12 +141,14 @@ describe('Album service', () => { }; cryptoMock = newCryptoRepositoryMock(); + jobMock = newJobRepositoryMock(); sut = new AlbumService( albumRepositoryMock, sharedLinkRepositoryMock, downloadServiceMock as DownloadService, cryptoMock, + jobMock, ); }); @@ -158,6 +162,7 @@ describe('Album service', () => { expect(result.id).toEqual(albumEntity.id); expect(result.albumName).toEqual(albumEntity.albumName); + expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.SEARCH_INDEX_ALBUM, data: { album: albumEntity } }); }); it('gets list of albums for auth user', async () => { @@ -291,9 +296,8 @@ describe('Album service', () => { const updatedAlbumName = 'new album name'; const updatedAlbumThumbnailAssetId = '69d2f917-0b31-48d8-9d7d-673b523f1aac'; albumRepositoryMock.get.mockImplementation(() => Promise.resolve(albumEntity)); - albumRepositoryMock.updateAlbum.mockImplementation(() => - Promise.resolve({ ...albumEntity, albumName: updatedAlbumName }), - ); + const updatedAlbum = { ...albumEntity, albumName: updatedAlbumName }; + albumRepositoryMock.updateAlbum.mockResolvedValue(updatedAlbum); const result = await sut.updateAlbumInfo( authUser, @@ -311,6 +315,7 @@ describe('Album service', () => { albumName: updatedAlbumName, albumThumbnailAssetId: updatedAlbumThumbnailAssetId, }); + expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.SEARCH_INDEX_ALBUM, data: { album: updatedAlbum } }); }); it('prevents updating a not owned album (shared with auth user)', async () => { diff --git a/server/apps/immich/src/api-v1/album/album.service.ts b/server/apps/immich/src/api-v1/album/album.service.ts index 525cf50658dfb..3fa51b9a4038f 100644 --- a/server/apps/immich/src/api-v1/album/album.service.ts +++ b/server/apps/immich/src/api-v1/album/album.service.ts @@ -6,7 +6,7 @@ import { AddUsersDto } from './dto/add-users.dto'; import { RemoveAssetsDto } from './dto/remove-assets.dto'; import { UpdateAlbumDto } from './dto/update-album.dto'; import { GetAlbumsDto } from './dto/get-albums.dto'; -import { AlbumResponseDto, mapAlbum, mapAlbumExcludeAssetInfo } from '@app/domain'; +import { AlbumResponseDto, IJobRepository, JobName, mapAlbum, mapAlbumExcludeAssetInfo } from '@app/domain'; import { IAlbumRepository } from './album-repository'; import { AlbumCountResponseDto } from './response-dto/album-count-response.dto'; import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto'; @@ -27,6 +27,7 @@ export class AlbumService { @Inject(ISharedLinkRepository) sharedLinkRepository: ISharedLinkRepository, private downloadService: DownloadService, @Inject(ICryptoRepository) cryptoRepository: ICryptoRepository, + @Inject(IJobRepository) private jobRepository: IJobRepository, ) { this.shareCore = new ShareCore(sharedLinkRepository, cryptoRepository); } @@ -56,6 +57,7 @@ export class AlbumService { async create(authUser: AuthUserDto, createAlbumDto: CreateAlbumDto): Promise { const albumEntity = await this.albumRepository.create(authUser.id, createAlbumDto); + await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ALBUM, data: { album: albumEntity } }); return mapAlbum(albumEntity); } @@ -105,6 +107,7 @@ export class AlbumService { } await this.albumRepository.delete(album); + await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ALBUM, data: { id: albumId } }); } async removeUserFromAlbum(authUser: AuthUserDto, albumId: string, userId: string | 'me'): Promise { @@ -171,6 +174,9 @@ export class AlbumService { } const updatedAlbum = await this.albumRepository.updateAlbum(album, updateAlbumDto); + + await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ALBUM, data: { album: updatedAlbum } }); + return mapAlbum(updatedAlbum); } diff --git a/server/apps/immich/src/api-v1/asset/asset-repository.ts b/server/apps/immich/src/api-v1/asset/asset-repository.ts index 85599761e35ad..9c36da9ae0bfb 100644 --- a/server/apps/immich/src/api-v1/asset/asset-repository.ts +++ b/server/apps/immich/src/api-v1/asset/asset-repository.ts @@ -252,7 +252,7 @@ export class AssetRepository implements IAssetRepository { where: { id: assetId, }, - relations: ['exifInfo', 'tags', 'sharedLinks'], + relations: ['exifInfo', 'tags', 'sharedLinks', 'smartInfo'], }); } diff --git a/server/apps/immich/src/api-v1/asset/asset.service.spec.ts b/server/apps/immich/src/api-v1/asset/asset.service.spec.ts index 741ab1ac9a5c2..e7d876059457c 100644 --- a/server/apps/immich/src/api-v1/asset/asset.service.spec.ts +++ b/server/apps/immich/src/api-v1/asset/asset.service.spec.ts @@ -445,6 +445,8 @@ describe('AssetService', () => { ]); expect(jobMock.queue.mock.calls).toEqual([ + [{ name: JobName.SEARCH_REMOVE_ASSET, data: { id: 'asset1' } }], + [{ name: JobName.SEARCH_REMOVE_ASSET, data: { id: 'asset2' } }], [ { name: JobName.DELETE_FILES, diff --git a/server/apps/immich/src/api-v1/asset/asset.service.ts b/server/apps/immich/src/api-v1/asset/asset.service.ts index a7dc920e0c588..a04ec8a646560 100644 --- a/server/apps/immich/src/api-v1/asset/asset.service.ts +++ b/server/apps/immich/src/api-v1/asset/asset.service.ts @@ -170,6 +170,8 @@ export class AssetService { const updatedAsset = await this._assetRepository.update(authUser.id, asset, dto); + await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { asset: updatedAsset } }); + return mapAsset(updatedAsset); } @@ -425,6 +427,7 @@ export class AssetService { try { await this._assetRepository.remove(asset); + await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { id } }); result.push({ id, status: DeleteAssetStatusEnum.SUCCESS }); deleteQueue.push(asset.originalPath, asset.webpPath, asset.resizePath); diff --git a/server/apps/immich/src/app.module.ts b/server/apps/immich/src/app.module.ts index 5d07765043b76..ec8bc4aaed3fc 100644 --- a/server/apps/immich/src/app.module.ts +++ b/server/apps/immich/src/app.module.ts @@ -1,5 +1,5 @@ import { immichAppConfig } from '@app/common/config'; -import { Module } from '@nestjs/common'; +import { Module, OnModuleInit } from '@nestjs/common'; import { AssetModule } from './api-v1/asset/asset.module'; import { ConfigModule } from '@nestjs/config'; import { ServerInfoModule } from './api-v1/server-info/server-info.module'; @@ -9,13 +9,14 @@ import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.module'; import { JobModule } from './api-v1/job/job.module'; import { TagModule } from './api-v1/tag/tag.module'; -import { DomainModule } from '@app/domain'; +import { DomainModule, SearchService } from '@app/domain'; import { InfraModule } from '@app/infra'; import { APIKeyController, AuthController, DeviceInfoController, OAuthController, + SearchController, ShareController, SystemConfigController, UserController, @@ -46,16 +47,21 @@ import { AuthGuard } from './middlewares/auth.guard'; TagModule, ], controllers: [ - // AppController, APIKeyController, AuthController, DeviceInfoController, OAuthController, + SearchController, ShareController, SystemConfigController, UserController, ], providers: [{ provide: APP_GUARD, useExisting: AuthGuard }, AuthGuard], }) -export class AppModule {} +export class AppModule implements OnModuleInit { + constructor(private searchService: SearchService) {} + async onModuleInit() { + await this.searchService.bootstrap(); + } +} diff --git a/server/apps/immich/src/controllers/index.ts b/server/apps/immich/src/controllers/index.ts index d09e5687d64be..171a0debb3ccb 100644 --- a/server/apps/immich/src/controllers/index.ts +++ b/server/apps/immich/src/controllers/index.ts @@ -2,6 +2,7 @@ export * from './api-key.controller'; export * from './auth.controller'; export * from './device-info.controller'; export * from './oauth.controller'; +export * from './search.controller'; export * from './share.controller'; export * from './system-config.controller'; export * from './user.controller'; diff --git a/server/apps/immich/src/controllers/search.controller.ts b/server/apps/immich/src/controllers/search.controller.ts new file mode 100644 index 0000000000000..7f67927cf4dec --- /dev/null +++ b/server/apps/immich/src/controllers/search.controller.ts @@ -0,0 +1,27 @@ +import { AuthUserDto, SearchConfigResponseDto, SearchDto, SearchResponseDto, SearchService } from '@app/domain'; +import { Controller, Get, Query, ValidationPipe } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { GetAuthUser } from '../decorators/auth-user.decorator'; +import { Authenticated } from '../decorators/authenticated.decorator'; + +@ApiTags('Search') +@Authenticated() +@Controller('search') +export class SearchController { + constructor(private readonly searchService: SearchService) {} + + @Authenticated() + @Get() + async search( + @GetAuthUser() authUser: AuthUserDto, + @Query(new ValidationPipe({ transform: true })) dto: SearchDto, + ): Promise { + return this.searchService.search(authUser, dto); + } + + @Authenticated() + @Get('config') + getSearchConfig(): SearchConfigResponseDto { + return this.searchService.getConfig(); + } +} diff --git a/server/apps/immich/src/main.ts b/server/apps/immich/src/main.ts index 185ae2b184154..b90df5e238569 100644 --- a/server/apps/immich/src/main.ts +++ b/server/apps/immich/src/main.ts @@ -11,7 +11,7 @@ import { RedisIoAdapter } from './middlewares/redis-io.adapter.middleware'; import { json } from 'body-parser'; import { patchOpenAPI } from './utils/patch-open-api.util'; import { getLogLevels, MACHINE_LEARNING_ENABLED } from '@app/common'; -import { IMMICH_ACCESS_COOKIE } from '@app/domain'; +import { IMMICH_ACCESS_COOKIE, SearchService } from '@app/domain'; const logger = new Logger('ImmichServer'); @@ -73,6 +73,9 @@ async function bootstrap() { ); }); + const searchService = app.get(SearchService); + logger.warn(`Machine learning is ${MACHINE_LEARNING_ENABLED ? 'enabled' : 'disabled'}`); + logger.warn(`Search is ${searchService.isEnabled() ? 'enabled' : 'disabled'}`); } bootstrap(); diff --git a/server/apps/microservices/src/microservices.module.ts b/server/apps/microservices/src/microservices.module.ts index 845f6c158e961..68b755af651a9 100644 --- a/server/apps/microservices/src/microservices.module.ts +++ b/server/apps/microservices/src/microservices.module.ts @@ -7,6 +7,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { BackgroundTaskProcessor, MachineLearningProcessor, + SearchIndexProcessor, StorageTemplateMigrationProcessor, ThumbnailGeneratorProcessor, } from './processors'; @@ -26,6 +27,7 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor' MachineLearningProcessor, StorageTemplateMigrationProcessor, BackgroundTaskProcessor, + SearchIndexProcessor, ], }) export class MicroservicesModule {} diff --git a/server/apps/microservices/src/processors.ts b/server/apps/microservices/src/processors.ts index 00e88b90d8939..63f05aed1224c 100644 --- a/server/apps/microservices/src/processors.ts +++ b/server/apps/microservices/src/processors.ts @@ -1,12 +1,15 @@ import { AssetService, + IAlbumJob, IAssetJob, IAssetUploadedJob, IDeleteFilesJob, + IDeleteJob, IUserDeletionJob, JobName, MediaService, QueueName, + SearchService, SmartInfoService, StorageService, StorageTemplateService, @@ -61,6 +64,41 @@ export class MachineLearningProcessor { } } +@Processor(QueueName.SEARCH) +export class SearchIndexProcessor { + constructor(private searchService: SearchService) {} + + @Process(JobName.SEARCH_INDEX_ALBUMS) + async onIndexAlbums() { + await this.searchService.handleIndexAlbums(); + } + + @Process(JobName.SEARCH_INDEX_ASSETS) + async onIndexAssets() { + await this.searchService.handleIndexAssets(); + } + + @Process(JobName.SEARCH_INDEX_ALBUM) + async onIndexAlbum(job: Job) { + await this.searchService.handleIndexAlbum(job.data); + } + + @Process(JobName.SEARCH_INDEX_ASSET) + async onIndexAsset(job: Job) { + await this.searchService.handleIndexAsset(job.data); + } + + @Process(JobName.SEARCH_REMOVE_ALBUM) + async onRemoveAlbum(job: Job) { + await this.searchService.handleRemoveAlbum(job.data); + } + + @Process(JobName.SEARCH_REMOVE_ASSET) + async onRemoveAsset(job: Job) { + await this.searchService.handleRemoveAsset(job.data); + } +} + @Processor(QueueName.STORAGE_TEMPLATE_MIGRATION) export class StorageTemplateMigrationProcessor { constructor(private storageTemplateService: StorageTemplateService) {} diff --git a/server/apps/microservices/src/processors/metadata-extraction.processor.ts b/server/apps/microservices/src/processors/metadata-extraction.processor.ts index fb907538b8a9c..d6ac7a36de863 100644 --- a/server/apps/microservices/src/processors/metadata-extraction.processor.ts +++ b/server/apps/microservices/src/processors/metadata-extraction.processor.ts @@ -1,18 +1,26 @@ +import { + AssetCore, + IAssetRepository, + IAssetUploadedJob, + IReverseGeocodingJob, + ISearchRepository, + JobName, + QueueName, +} from '@app/domain'; import { AssetEntity, AssetType, ExifEntity } from '@app/infra'; -import { IReverseGeocodingJob, IAssetUploadedJob, QueueName, JobName, IAssetRepository } from '@app/domain'; import { Process, Processor } from '@nestjs/bull'; import { Inject, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; import { Job } from 'bull'; +import { ExifDateTime, exiftool, Tags } from 'exiftool-vendored'; import ffmpeg from 'fluent-ffmpeg'; +import { getName } from 'i18n-iso-countries'; +import geocoder, { InitOptions } from 'local-reverse-geocoder'; +import fs from 'node:fs'; import path from 'path'; import sharp from 'sharp'; import { Repository } from 'typeorm/repository/Repository'; -import geocoder, { InitOptions } from 'local-reverse-geocoder'; -import { getName } from 'i18n-iso-countries'; -import fs from 'node:fs'; -import { ExifDateTime, exiftool, Tags } from 'exiftool-vendored'; interface ImmichTags extends Tags { ContentIdentifier?: string; @@ -71,13 +79,19 @@ export type GeoData = { export class MetadataExtractionProcessor { private logger = new Logger(MetadataExtractionProcessor.name); private isGeocodeInitialized = false; + private assetCore: AssetCore; + constructor( - @Inject(IAssetRepository) private assetRepository: IAssetRepository, + @Inject(IAssetRepository) assetRepository: IAssetRepository, + @Inject(ISearchRepository) searchRepository: ISearchRepository, + @InjectRepository(ExifEntity) private exifRepository: Repository, configService: ConfigService, ) { + this.assetCore = new AssetCore(assetRepository, searchRepository); + if (!configService.get('DISABLE_REVERSE_GEOCODING')) { this.logger.log('Initializing Reverse Geocoding'); geocoderInit({ @@ -175,20 +189,11 @@ export class MetadataExtractionProcessor { newExif.longitude = exifData?.GPSLongitude || null; newExif.livePhotoCID = exifData?.MediaGroupUUID || null; - await this.assetRepository.save({ - id: asset.id, - fileCreatedAt: fileCreatedAt?.toISOString(), - }); - if (newExif.livePhotoCID && !asset.livePhotoVideoId) { - const motionAsset = await this.assetRepository.findLivePhotoMatch( - newExif.livePhotoCID, - asset.id, - AssetType.VIDEO, - ); + const motionAsset = await this.assetCore.findLivePhotoMatch(newExif.livePhotoCID, asset.id, AssetType.VIDEO); if (motionAsset) { - await this.assetRepository.save({ id: asset.id, livePhotoVideoId: motionAsset.id }); - await this.assetRepository.save({ id: motionAsset.id, isVisible: false }); + await this.assetCore.save({ id: asset.id, livePhotoVideoId: motionAsset.id }); + await this.assetCore.save({ id: motionAsset.id, isVisible: false }); } } @@ -226,6 +231,7 @@ export class MetadataExtractionProcessor { } await this.exifRepository.upsert(newExif, { conflictPaths: ['assetId'] }); + await this.assetCore.save({ id: asset.id, fileCreatedAt: fileCreatedAt?.toISOString() }); } catch (error: any) { this.logger.error(`Error extracting EXIF ${error}`, error?.stack); } @@ -292,14 +298,10 @@ export class MetadataExtractionProcessor { newExif.livePhotoCID = exifData?.ContentIdentifier || null; if (newExif.livePhotoCID) { - const photoAsset = await this.assetRepository.findLivePhotoMatch( - newExif.livePhotoCID, - asset.id, - AssetType.IMAGE, - ); + const photoAsset = await this.assetCore.findLivePhotoMatch(newExif.livePhotoCID, asset.id, AssetType.IMAGE); if (photoAsset) { - await this.assetRepository.save({ id: photoAsset.id, livePhotoVideoId: asset.id }); - await this.assetRepository.save({ id: asset.id, isVisible: false }); + await this.assetCore.save({ id: photoAsset.id, livePhotoVideoId: asset.id }); + await this.assetCore.save({ id: asset.id, isVisible: false }); } } @@ -355,7 +357,7 @@ export class MetadataExtractionProcessor { } await this.exifRepository.upsert(newExif, { conflictPaths: ['assetId'] }); - await this.assetRepository.save({ id: asset.id, duration: durationString, fileCreatedAt }); + await this.assetCore.save({ id: asset.id, duration: durationString, fileCreatedAt }); } catch (err) { ``; // do nothing diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 4cd07f69f9d72..ee8bc8d1d603d 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -544,6 +544,171 @@ ] } }, + "/search": { + "get": { + "operationId": "search", + "description": "", + "parameters": [ + { + "name": "query", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "type", + "required": false, + "in": "query", + "schema": { + "enum": [ + "IMAGE", + "VIDEO", + "AUDIO", + "OTHER" + ], + "type": "string" + } + }, + { + "name": "isFavorite", + "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": "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" + } + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchResponseDto" + } + } + } + } + }, + "tags": [ + "Search" + ], + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "bearer": [] + }, + { + "cookie": [] + } + ] + } + }, + "/search/config": { + "get": { + "operationId": "getSearchConfig", + "description": "", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SearchConfigResponseDto" + } + } + } + } + }, + "tags": [ + "Search" + ], + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "bearer": [] + }, + { + "cookie": [] + } + ] + } + }, "/share": { "get": { "operationId": "getAllSharedLinks", @@ -3554,13 +3719,6 @@ "url" ] }, - "SharedLinkType": { - "type": "string", - "enum": [ - "ALBUM", - "INDIVIDUAL" - ] - }, "AssetTypeEnum": { "type": "string", "enum": [ @@ -3871,6 +4029,130 @@ "owner" ] }, + "SearchFacetCountResponseDto": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "value": { + "type": "string" + } + }, + "required": [ + "count", + "value" + ] + }, + "SearchFacetResponseDto": { + "type": "object", + "properties": { + "fieldName": { + "type": "string" + }, + "counts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SearchFacetCountResponseDto" + } + } + }, + "required": [ + "fieldName", + "counts" + ] + }, + "SearchAlbumResponseDto": { + "type": "object", + "properties": { + "total": { + "type": "integer" + }, + "count": { + "type": "integer" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AlbumResponseDto" + } + }, + "facets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SearchFacetResponseDto" + } + } + }, + "required": [ + "total", + "count", + "items", + "facets" + ] + }, + "SearchAssetResponseDto": { + "type": "object", + "properties": { + "total": { + "type": "integer" + }, + "count": { + "type": "integer" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AssetResponseDto" + } + }, + "facets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SearchFacetResponseDto" + } + } + }, + "required": [ + "total", + "count", + "items", + "facets" + ] + }, + "SearchResponseDto": { + "type": "object", + "properties": { + "albums": { + "$ref": "#/components/schemas/SearchAlbumResponseDto" + }, + "assets": { + "$ref": "#/components/schemas/SearchAssetResponseDto" + } + }, + "required": [ + "albums", + "assets" + ] + }, + "SearchConfigResponseDto": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + }, + "required": [ + "enabled" + ] + }, + "SharedLinkType": { + "type": "string", + "enum": [ + "ALBUM", + "INDIVIDUAL" + ] + }, "SharedLinkResponseDto": { "type": "object", "properties": { diff --git a/server/libs/common/src/config/app.config.ts b/server/libs/common/src/config/app.config.ts index db619b32da141..a32d90f9f2bf9 100644 --- a/server/libs/common/src/config/app.config.ts +++ b/server/libs/common/src/config/app.config.ts @@ -16,6 +16,11 @@ export const immichAppConfig: ConfigModuleOptions = { 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/libs/domain/src/album/album.repository.ts b/server/libs/domain/src/album/album.repository.ts index 92137b13d8186..adc62ea971287 100644 --- a/server/libs/domain/src/album/album.repository.ts +++ b/server/libs/domain/src/album/album.repository.ts @@ -1,5 +1,9 @@ +import { AlbumEntity } from '@app/infra/db/entities'; + export const IAlbumRepository = 'IAlbumRepository'; export interface IAlbumRepository { deleteAll(userId: string): Promise; + getAll(): Promise; + save(album: Partial): Promise; } diff --git a/server/libs/domain/src/asset/asset.core.ts b/server/libs/domain/src/asset/asset.core.ts new file mode 100644 index 0000000000000..e923f29d957b3 --- /dev/null +++ b/server/libs/domain/src/asset/asset.core.ts @@ -0,0 +1,21 @@ +import { AssetEntity, AssetType } from '@app/infra/db/entities'; +import { ISearchRepository, SearchCollection } from '../search/search.repository'; +import { AssetSearchOptions, IAssetRepository } from './asset.repository'; + +export class AssetCore { + constructor(private repository: IAssetRepository, private searchRepository: ISearchRepository) {} + + getAll(options: AssetSearchOptions) { + return this.repository.getAll(options); + } + + async save(asset: Partial) { + const _asset = await this.repository.save(asset); + await this.searchRepository.index(SearchCollection.ASSETS, _asset); + return _asset; + } + + findLivePhotoMatch(livePhotoCID: string, otherAssetId: string, type: AssetType): Promise { + return this.repository.findLivePhotoMatch(livePhotoCID, otherAssetId, type); + } +} diff --git a/server/libs/domain/src/asset/asset.repository.ts b/server/libs/domain/src/asset/asset.repository.ts index 4e5600b8d8562..0173cc4ee11e6 100644 --- a/server/libs/domain/src/asset/asset.repository.ts +++ b/server/libs/domain/src/asset/asset.repository.ts @@ -1,10 +1,14 @@ import { AssetEntity, AssetType } from '@app/infra/db/entities'; +export interface AssetSearchOptions { + isVisible?: boolean; +} + export const IAssetRepository = 'IAssetRepository'; export interface IAssetRepository { deleteAll(ownerId: string): Promise; - getAll(): Promise; + getAll(options?: AssetSearchOptions): Promise; save(asset: Partial): Promise; findLivePhotoMatch(livePhotoCID: string, otherAssetId: string, type: AssetType): Promise; } diff --git a/server/libs/domain/src/asset/asset.service.spec.ts b/server/libs/domain/src/asset/asset.service.spec.ts index 75f26eae2750f..bff4efa20cc14 100644 --- a/server/libs/domain/src/asset/asset.service.spec.ts +++ b/server/libs/domain/src/asset/asset.service.spec.ts @@ -1,19 +1,25 @@ import { AssetEntity, AssetType } from '@app/infra/db/entities'; -import { newJobRepositoryMock } from '../../test'; -import { AssetService } from '../asset'; +import { assetEntityStub, newAssetRepositoryMock, newJobRepositoryMock } from '../../test'; +import { newSearchRepositoryMock } from '../../test/search.repository.mock'; +import { AssetService, IAssetRepository } from '../asset'; import { IJobRepository, JobName } from '../job'; +import { ISearchRepository } from '../search'; describe(AssetService.name, () => { let sut: AssetService; + let assetMock: jest.Mocked; let jobMock: jest.Mocked; + let searchMock: jest.Mocked; it('should work', () => { expect(sut).toBeDefined(); }); beforeEach(async () => { + assetMock = newAssetRepositoryMock(); jobMock = newJobRepositoryMock(); - sut = new AssetService(jobMock); + searchMock = newSearchRepositoryMock(); + sut = new AssetService(assetMock, jobMock, searchMock); }); describe(`handle asset upload`, () => { @@ -42,4 +48,15 @@ describe(AssetService.name, () => { ]); }); }); + + describe('save', () => { + it('should save an asset', async () => { + assetMock.save.mockResolvedValue(assetEntityStub.image); + + await sut.save(assetEntityStub.image); + + expect(assetMock.save).toHaveBeenCalledWith(assetEntityStub.image); + expect(searchMock.index).toHaveBeenCalledWith('assets', assetEntityStub.image); + }); + }); }); diff --git a/server/libs/domain/src/asset/asset.service.ts b/server/libs/domain/src/asset/asset.service.ts index 023fb960d9f40..06e8c7aa96520 100644 --- a/server/libs/domain/src/asset/asset.service.ts +++ b/server/libs/domain/src/asset/asset.service.ts @@ -1,9 +1,20 @@ -import { AssetType } from '@app/infra/db/entities'; +import { AssetEntity, AssetType } from '@app/infra/db/entities'; import { Inject } from '@nestjs/common'; import { IAssetUploadedJob, IJobRepository, JobName } from '../job'; +import { ISearchRepository } from '../search'; +import { AssetCore } from './asset.core'; +import { IAssetRepository } from './asset.repository'; export class AssetService { - constructor(@Inject(IJobRepository) private jobRepository: IJobRepository) {} + private assetCore: AssetCore; + + constructor( + @Inject(IAssetRepository) assetRepository: IAssetRepository, + @Inject(IJobRepository) private jobRepository: IJobRepository, + @Inject(ISearchRepository) searchRepository: ISearchRepository, + ) { + this.assetCore = new AssetCore(assetRepository, searchRepository); + } async handleAssetUpload(data: IAssetUploadedJob) { await this.jobRepository.queue({ name: JobName.GENERATE_JPEG_THUMBNAIL, data }); @@ -15,4 +26,8 @@ export class AssetService { await this.jobRepository.queue({ name: JobName.EXIF_EXTRACTION, data }); } } + + save(asset: Partial) { + return this.assetCore.save(asset); + } } diff --git a/server/libs/domain/src/asset/index.ts b/server/libs/domain/src/asset/index.ts index aa429787d70e4..3aff64c2cec81 100644 --- a/server/libs/domain/src/asset/index.ts +++ b/server/libs/domain/src/asset/index.ts @@ -1,3 +1,4 @@ +export * from './asset.core'; export * from './asset.repository'; export * from './asset.service'; export * from './response-dto'; diff --git a/server/libs/domain/src/domain.module.ts b/server/libs/domain/src/domain.module.ts index d3faad973d7cf..c469a2bc52d92 100644 --- a/server/libs/domain/src/domain.module.ts +++ b/server/libs/domain/src/domain.module.ts @@ -5,6 +5,7 @@ import { AuthService } from './auth'; import { DeviceInfoService } from './device-info'; import { MediaService } from './media'; import { OAuthService } from './oauth'; +import { SearchService } from './search'; import { ShareService } from './share'; import { SmartInfoService } from './smart-info'; import { StorageService } from './storage'; @@ -25,6 +26,7 @@ const providers: Provider[] = [ SystemConfigService, UserService, ShareService, + SearchService, { provide: INITIAL_SYSTEM_CONFIG, inject: [SystemConfigService], diff --git a/server/libs/domain/src/index.ts b/server/libs/domain/src/index.ts index 93768f6825e33..cf4403aed5f83 100644 --- a/server/libs/domain/src/index.ts +++ b/server/libs/domain/src/index.ts @@ -9,6 +9,7 @@ export * from './domain.module'; export * from './job'; export * from './media'; export * from './oauth'; +export * from './search'; export * from './share'; export * from './smart-info'; export * from './storage'; diff --git a/server/libs/domain/src/job/job.constants.ts b/server/libs/domain/src/job/job.constants.ts index 13939e17f237a..52ee425720b5f 100644 --- a/server/libs/domain/src/job/job.constants.ts +++ b/server/libs/domain/src/job/job.constants.ts @@ -5,6 +5,7 @@ export enum QueueName { MACHINE_LEARNING = 'machine-learning-queue', BACKGROUND_TASK = 'background-task', STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration-queue', + SEARCH = 'search-queue', } export enum JobName { @@ -22,4 +23,10 @@ export enum JobName { OBJECT_DETECTION = 'detect-object', IMAGE_TAGGING = 'tag-image', DELETE_FILES = 'delete-files', + SEARCH_INDEX_ASSETS = 'search-index-assets', + SEARCH_INDEX_ASSET = 'search-index-asset', + SEARCH_INDEX_ALBUMS = 'search-index-albums', + SEARCH_INDEX_ALBUM = 'search-index-album', + SEARCH_REMOVE_ALBUM = 'search-remove-album', + SEARCH_REMOVE_ASSET = 'search-remove-asset', } diff --git a/server/libs/domain/src/job/job.interface.ts b/server/libs/domain/src/job/job.interface.ts index e52b1d879e4d9..0810bdad07d53 100644 --- a/server/libs/domain/src/job/job.interface.ts +++ b/server/libs/domain/src/job/job.interface.ts @@ -1,4 +1,8 @@ -import { AssetEntity, UserEntity } from '@app/infra/db/entities'; +import { AlbumEntity, AssetEntity, UserEntity } from '@app/infra/db/entities'; + +export interface IAlbumJob { + album: AlbumEntity; +} export interface IAssetJob { asset: AssetEntity; @@ -9,6 +13,10 @@ export interface IAssetUploadedJob { fileName: string; } +export interface IDeleteJob { + id: string; +} + export interface IDeleteFilesJob { files: Array; } diff --git a/server/libs/domain/src/job/job.repository.ts b/server/libs/domain/src/job/job.repository.ts index f06c791a3a3c1..0867f5391c44c 100644 --- a/server/libs/domain/src/job/job.repository.ts +++ b/server/libs/domain/src/job/job.repository.ts @@ -1,5 +1,13 @@ import { JobName, QueueName } from './job.constants'; -import { IAssetJob, IAssetUploadedJob, IDeleteFilesJob, IReverseGeocodingJob, IUserDeletionJob } from './job.interface'; +import { + IAlbumJob, + IAssetJob, + IAssetUploadedJob, + IDeleteFilesJob, + IDeleteJob, + IReverseGeocodingJob, + IUserDeletionJob, +} from './job.interface'; export interface JobCounts { active: number; @@ -23,7 +31,13 @@ export type JobItem = | { name: JobName.EXTRACT_VIDEO_METADATA; data: IAssetUploadedJob } | { name: JobName.OBJECT_DETECTION; data: IAssetJob } | { name: JobName.IMAGE_TAGGING; data: IAssetJob } - | { name: JobName.DELETE_FILES; data: IDeleteFilesJob }; + | { name: JobName.DELETE_FILES; data: IDeleteFilesJob } + | { name: JobName.SEARCH_INDEX_ASSETS } + | { name: JobName.SEARCH_INDEX_ASSET; data: IAssetJob } + | { name: JobName.SEARCH_INDEX_ALBUMS } + | { name: JobName.SEARCH_INDEX_ALBUM; data: IAlbumJob } + | { name: JobName.SEARCH_REMOVE_ASSET; data: IDeleteJob } + | { name: JobName.SEARCH_REMOVE_ALBUM; data: IDeleteJob }; export const IJobRepository = 'IJobRepository'; diff --git a/server/libs/domain/src/search/dto/index.ts b/server/libs/domain/src/search/dto/index.ts new file mode 100644 index 0000000000000..cd914d0ea7f4f --- /dev/null +++ b/server/libs/domain/src/search/dto/index.ts @@ -0,0 +1 @@ +export * from './search.dto'; diff --git a/server/libs/domain/src/search/dto/search.dto.ts b/server/libs/domain/src/search/dto/search.dto.ts new file mode 100644 index 0000000000000..c080ff5eac667 --- /dev/null +++ b/server/libs/domain/src/search/dto/search.dto.ts @@ -0,0 +1,57 @@ +import { AssetType } from '@app/infra/db/entities'; +import { Transform } from 'class-transformer'; +import { IsArray, IsBoolean, IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { toBoolean } from '../../../../../apps/immich/src/utils/transform.util'; + +export class SearchDto { + @IsString() + @IsNotEmpty() + @IsOptional() + query?: string; + + @IsEnum(AssetType) + @IsOptional() + type?: AssetType; + + @IsBoolean() + @IsOptional() + @Transform(toBoolean) + isFavorite?: boolean; + + @IsString() + @IsNotEmpty() + @IsOptional() + 'exifInfo.city'?: string; + + @IsString() + @IsNotEmpty() + @IsOptional() + 'exifInfo.state'?: string; + + @IsString() + @IsNotEmpty() + @IsOptional() + 'exifInfo.country'?: string; + + @IsString() + @IsNotEmpty() + @IsOptional() + 'exifInfo.make'?: string; + + @IsString() + @IsNotEmpty() + @IsOptional() + 'exifInfo.model'?: string; + + @IsString({ each: true }) + @IsArray() + @IsOptional() + @Transform(({ value }) => value.split(',')) + 'smartInfo.objects'?: string[]; + + @IsString({ each: true }) + @IsArray() + @IsOptional() + @Transform(({ value }) => value.split(',')) + 'smartInfo.tags'?: string[]; +} diff --git a/server/libs/domain/src/search/index.ts b/server/libs/domain/src/search/index.ts new file mode 100644 index 0000000000000..173a67d76fae6 --- /dev/null +++ b/server/libs/domain/src/search/index.ts @@ -0,0 +1,4 @@ +export * from './dto'; +export * from './response-dto'; +export * from './search.repository'; +export * from './search.service'; diff --git a/server/libs/domain/src/search/response-dto/index.ts b/server/libs/domain/src/search/response-dto/index.ts new file mode 100644 index 0000000000000..e55378686d3d4 --- /dev/null +++ b/server/libs/domain/src/search/response-dto/index.ts @@ -0,0 +1,2 @@ +export * from './search-config-response.dto'; +export * from './search-response.dto'; diff --git a/server/libs/domain/src/search/response-dto/search-config-response.dto.ts b/server/libs/domain/src/search/response-dto/search-config-response.dto.ts new file mode 100644 index 0000000000000..9f2f3795877a5 --- /dev/null +++ b/server/libs/domain/src/search/response-dto/search-config-response.dto.ts @@ -0,0 +1,3 @@ +export class SearchConfigResponseDto { + enabled!: boolean; +} diff --git a/server/libs/domain/src/search/response-dto/search-response.dto.ts b/server/libs/domain/src/search/response-dto/search-response.dto.ts new file mode 100644 index 0000000000000..724cd5854b9b5 --- /dev/null +++ b/server/libs/domain/src/search/response-dto/search-response.dto.ts @@ -0,0 +1,37 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { AlbumResponseDto } from '../../album'; +import { AssetResponseDto } from '../../asset'; + +class SearchFacetCountResponseDto { + @ApiProperty({ type: 'integer' }) + count!: number; + value!: string; +} + +class SearchFacetResponseDto { + fieldName!: string; + counts!: SearchFacetCountResponseDto[]; +} + +class SearchAlbumResponseDto { + @ApiProperty({ type: 'integer' }) + total!: number; + @ApiProperty({ type: 'integer' }) + count!: number; + items!: AlbumResponseDto[]; + facets!: SearchFacetResponseDto[]; +} + +class SearchAssetResponseDto { + @ApiProperty({ type: 'integer' }) + total!: number; + @ApiProperty({ type: 'integer' }) + count!: number; + items!: AssetResponseDto[]; + facets!: SearchFacetResponseDto[]; +} + +export class SearchResponseDto { + albums!: SearchAlbumResponseDto; + assets!: SearchAssetResponseDto; +} diff --git a/server/libs/domain/src/search/search.repository.ts b/server/libs/domain/src/search/search.repository.ts new file mode 100644 index 0000000000000..f2885785026ed --- /dev/null +++ b/server/libs/domain/src/search/search.repository.ts @@ -0,0 +1,60 @@ +import { AlbumEntity, AssetEntity, AssetType } from '@app/infra/db/entities'; + +export enum SearchCollection { + ASSETS = 'assets', + ALBUMS = 'albums', +} + +export interface SearchFilter { + id?: string; + userId: string; + type?: AssetType; + isFavorite?: boolean; + city?: string; + state?: string; + country?: string; + make?: string; + model?: string; + objects?: string[]; + tags?: string[]; +} + +export interface SearchResult { + /** total matches */ + total: number; + /** collection size */ + count: number; + /** current page */ + page: number; + /** items for page */ + items: T[]; + facets: SearchFacet[]; +} + +export interface SearchFacet { + fieldName: string; + counts: Array<{ + count: number; + value: string; + }>; +} + +export type SearchCollectionIndexStatus = Record; + +export const ISearchRepository = 'ISearchRepository'; + +export interface ISearchRepository { + setup(): Promise; + checkMigrationStatus(): Promise; + + index(collection: SearchCollection.ASSETS, item: AssetEntity): Promise; + index(collection: SearchCollection.ALBUMS, item: AlbumEntity): Promise; + + delete(collection: SearchCollection, id: string): Promise; + + import(collection: SearchCollection.ASSETS, items: AssetEntity[], done: boolean): Promise; + import(collection: SearchCollection.ALBUMS, items: AlbumEntity[], done: boolean): Promise; + + search(collection: SearchCollection.ASSETS, query: string, filters: SearchFilter): Promise>; + search(collection: SearchCollection.ALBUMS, query: string, filters: SearchFilter): Promise>; +} diff --git a/server/libs/domain/src/search/search.service.spec.ts b/server/libs/domain/src/search/search.service.spec.ts new file mode 100644 index 0000000000000..813091f8d8d61 --- /dev/null +++ b/server/libs/domain/src/search/search.service.spec.ts @@ -0,0 +1,317 @@ +import { BadRequestException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { plainToInstance } from 'class-transformer'; +import { + albumStub, + assetEntityStub, + authStub, + newAlbumRepositoryMock, + newAssetRepositoryMock, + newJobRepositoryMock, + newSearchRepositoryMock, +} from '../../test'; +import { IAlbumRepository } from '../album/album.repository'; +import { IAssetRepository } from '../asset/asset.repository'; +import { JobName } from '../job'; +import { IJobRepository } from '../job/job.repository'; +import { SearchDto } from './dto'; +import { ISearchRepository } from './search.repository'; +import { SearchService } from './search.service'; + +describe(SearchService.name, () => { + let sut: SearchService; + let albumMock: jest.Mocked; + let assetMock: jest.Mocked; + let jobMock: jest.Mocked; + let searchMock: jest.Mocked; + let configMock: jest.Mocked; + + beforeEach(() => { + albumMock = newAlbumRepositoryMock(); + assetMock = newAssetRepositoryMock(); + jobMock = newJobRepositoryMock(); + searchMock = newSearchRepositoryMock(); + configMock = { get: jest.fn() } as unknown as jest.Mocked; + + sut = new SearchService(albumMock, assetMock, jobMock, searchMock, configMock); + }); + + 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']); + }); + + it('should handle empty smartInfo.tags', () => { + const instance = plainToInstance(SearchDto, {}); + expect(instance['smartInfo.tags']).toBeUndefined(); + }); + + 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']); + }); + + it('should handle empty smartInfo.objects', () => { + const instance = plainToInstance(SearchDto, {}); + expect(instance['smartInfo.objects']).toBeUndefined(); + }); + }); + + describe('isEnabled', () => { + it('should be enabled by default', () => { + expect(sut.isEnabled()).toBe(true); + }); + + it('should be disabled via an env variable', () => { + configMock.get.mockReturnValue('false'); + sut = new SearchService(albumMock, assetMock, jobMock, searchMock, configMock); + + expect(sut.isEnabled()).toBe(false); + }); + }); + + describe('getConfig', () => { + it('should return the config', () => { + expect(sut.getConfig()).toEqual({ enabled: true }); + }); + + it('should return the config when search is disabled', () => { + configMock.get.mockReturnValue('false'); + sut = new SearchService(albumMock, assetMock, jobMock, searchMock, configMock); + + expect(sut.getConfig()).toEqual({ enabled: false }); + }); + }); + + describe(`bootstrap`, () => { + it('should skip when search is disabled', async () => { + configMock.get.mockReturnValue('false'); + sut = new SearchService(albumMock, assetMock, jobMock, searchMock, configMock); + + await sut.bootstrap(); + + expect(searchMock.setup).not.toHaveBeenCalled(); + expect(searchMock.checkMigrationStatus).not.toHaveBeenCalled(); + expect(jobMock.queue).not.toHaveBeenCalled(); + }); + + it('should skip schema migration if not needed', async () => { + searchMock.checkMigrationStatus.mockResolvedValue({ assets: false, albums: false }); + await sut.bootstrap(); + + expect(searchMock.setup).toHaveBeenCalled(); + expect(jobMock.queue).not.toHaveBeenCalled(); + }); + + it('should do schema migration if needed', async () => { + searchMock.checkMigrationStatus.mockResolvedValue({ assets: true, albums: true }); + await sut.bootstrap(); + + expect(searchMock.setup).toHaveBeenCalled(); + expect(jobMock.queue.mock.calls).toEqual([ + [{ name: JobName.SEARCH_INDEX_ASSETS }], + [{ name: JobName.SEARCH_INDEX_ALBUMS }], + ]); + }); + }); + + describe('search', () => { + it('should throw an error is search is disabled', async () => { + configMock.get.mockReturnValue('false'); + sut = new SearchService(albumMock, assetMock, jobMock, searchMock, configMock); + + await expect(sut.search(authStub.admin, {})).rejects.toBeInstanceOf(BadRequestException); + + expect(searchMock.search).not.toHaveBeenCalled(); + }); + + it('should search assets and albums', async () => { + searchMock.search.mockResolvedValue({ + total: 0, + count: 0, + page: 1, + items: [], + facets: [], + }); + + await expect(sut.search(authStub.admin, {})).resolves.toEqual({ + albums: { + total: 0, + count: 0, + page: 1, + items: [], + facets: [], + }, + assets: { + total: 0, + count: 0, + page: 1, + items: [], + facets: [], + }, + }); + + expect(searchMock.search.mock.calls).toEqual([ + ['assets', '*', { userId: authStub.admin.id }], + ['albums', '*', { userId: authStub.admin.id }], + ]); + }); + }); + + describe('handleIndexAssets', () => { + it('should skip if search is disabled', async () => { + configMock.get.mockReturnValue('false'); + sut = new SearchService(albumMock, assetMock, jobMock, searchMock, configMock); + + await sut.handleIndexAssets(); + + expect(searchMock.import).not.toHaveBeenCalled(); + }); + + it('should index all the assets', async () => { + assetMock.getAll.mockResolvedValue([]); + + await sut.handleIndexAssets(); + + expect(searchMock.import).toHaveBeenCalledWith('assets', [], true); + }); + + it('should log an error', async () => { + assetMock.getAll.mockResolvedValue([]); + searchMock.import.mockRejectedValue(new Error('import failed')); + + await sut.handleIndexAssets(); + }); + }); + + describe('handleIndexAsset', () => { + it('should skip if search is disabled', async () => { + configMock.get.mockReturnValue('false'); + sut = new SearchService(albumMock, assetMock, jobMock, searchMock, configMock); + + await sut.handleIndexAsset({ asset: assetEntityStub.image }); + + expect(searchMock.index).not.toHaveBeenCalled(); + }); + + it('should index the asset', async () => { + await sut.handleIndexAsset({ asset: assetEntityStub.image }); + + expect(searchMock.index).toHaveBeenCalledWith('assets', assetEntityStub.image); + }); + + it('should log an error', async () => { + searchMock.index.mockRejectedValue(new Error('index failed')); + + await sut.handleIndexAsset({ asset: assetEntityStub.image }); + + expect(searchMock.index).toHaveBeenCalled(); + }); + }); + + describe('handleIndexAlbums', () => { + it('should skip if search is disabled', async () => { + configMock.get.mockReturnValue('false'); + sut = new SearchService(albumMock, assetMock, jobMock, searchMock, configMock); + + await sut.handleIndexAlbums(); + + expect(searchMock.import).not.toHaveBeenCalled(); + }); + + it('should index all the albums', async () => { + albumMock.getAll.mockResolvedValue([]); + + await sut.handleIndexAlbums(); + + expect(searchMock.import).toHaveBeenCalledWith('albums', [], true); + }); + + it('should log an error', async () => { + albumMock.getAll.mockResolvedValue([]); + searchMock.import.mockRejectedValue(new Error('import failed')); + + await sut.handleIndexAlbums(); + }); + }); + + describe('handleIndexAlbum', () => { + it('should skip if search is disabled', async () => { + configMock.get.mockReturnValue('false'); + sut = new SearchService(albumMock, assetMock, jobMock, searchMock, configMock); + + await sut.handleIndexAlbum({ album: albumStub.empty }); + + expect(searchMock.index).not.toHaveBeenCalled(); + }); + + it('should index the album', async () => { + await sut.handleIndexAlbum({ album: albumStub.empty }); + + expect(searchMock.index).toHaveBeenCalledWith('albums', albumStub.empty); + }); + + it('should log an error', async () => { + searchMock.index.mockRejectedValue(new Error('index failed')); + + await sut.handleIndexAlbum({ album: albumStub.empty }); + + expect(searchMock.index).toHaveBeenCalled(); + }); + }); + + describe('handleRemoveAlbum', () => { + it('should skip if search is disabled', async () => { + configMock.get.mockReturnValue('false'); + sut = new SearchService(albumMock, assetMock, jobMock, searchMock, configMock); + + await sut.handleRemoveAlbum({ id: 'album1' }); + + expect(searchMock.delete).not.toHaveBeenCalled(); + }); + + it('should remove the album', async () => { + await sut.handleRemoveAlbum({ id: 'album1' }); + + expect(searchMock.delete).toHaveBeenCalledWith('albums', 'album1'); + }); + + it('should log an error', async () => { + searchMock.delete.mockRejectedValue(new Error('remove failed')); + + await sut.handleRemoveAlbum({ id: 'album1' }); + + expect(searchMock.delete).toHaveBeenCalled(); + }); + }); + + describe('handleRemoveAsset', () => { + it('should skip if search is disabled', async () => { + configMock.get.mockReturnValue('false'); + sut = new SearchService(albumMock, assetMock, jobMock, searchMock, configMock); + + await sut.handleRemoveAsset({ id: 'asset1`' }); + + expect(searchMock.delete).not.toHaveBeenCalled(); + }); + + it('should remove the asset', async () => { + await sut.handleRemoveAsset({ id: 'asset1' }); + + expect(searchMock.delete).toHaveBeenCalledWith('assets', 'asset1'); + }); + + it('should log an error', async () => { + searchMock.delete.mockRejectedValue(new Error('remove failed')); + + await sut.handleRemoveAsset({ id: 'asset1' }); + + expect(searchMock.delete).toHaveBeenCalled(); + }); + }); +}); diff --git a/server/libs/domain/src/search/search.service.ts b/server/libs/domain/src/search/search.service.ts new file mode 100644 index 0000000000000..322644167bda4 --- /dev/null +++ b/server/libs/domain/src/search/search.service.ts @@ -0,0 +1,154 @@ +import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { IAlbumRepository } from '../album/album.repository'; +import { IAssetRepository } from '../asset/asset.repository'; +import { AuthUserDto } from '../auth'; +import { IAlbumJob, IAssetJob, IDeleteJob, IJobRepository, JobName } from '../job'; +import { SearchDto } from './dto'; +import { SearchConfigResponseDto, SearchResponseDto } from './response-dto'; +import { ISearchRepository, SearchCollection } from './search.repository'; + +@Injectable() +export class SearchService { + private logger = new Logger(SearchService.name); + private enabled: boolean; + + constructor( + @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, + @Inject(IAssetRepository) private assetRepository: IAssetRepository, + @Inject(IJobRepository) private jobRepository: IJobRepository, + @Inject(ISearchRepository) private searchRepository: ISearchRepository, + configService: ConfigService, + ) { + this.enabled = configService.get('TYPESENSE_ENABLED') !== 'false'; + } + + isEnabled() { + return this.enabled; + } + + getConfig(): SearchConfigResponseDto { + return { + enabled: this.enabled, + }; + } + + async bootstrap() { + 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 }); + } + } + + async search(authUser: AuthUserDto, dto: SearchDto): Promise { + if (!this.enabled) { + throw new BadRequestException('Search is disabled'); + } + + const query = dto.query || '*'; + + return { + assets: (await this.searchRepository.search(SearchCollection.ASSETS, query, { + userId: authUser.id, + ...dto, + })) as any, + albums: (await this.searchRepository.search(SearchCollection.ALBUMS, query, { + userId: authUser.id, + ...dto, + })) as any, + }; + } + + async handleIndexAssets() { + if (!this.enabled) { + return; + } + + try { + this.logger.debug(`Running indexAssets`); + // TODO: do this in batches based on searchIndexVersion + const assets = await this.assetRepository.getAll({ isVisible: true }); + + this.logger.log(`Indexing ${assets.length} assets`); + await this.searchRepository.import(SearchCollection.ASSETS, assets, true); + } catch (error: any) { + this.logger.error(`Unable to index all assets`, error?.stack); + } + } + + async handleIndexAsset(data: IAssetJob) { + if (!this.enabled) { + return; + } + + const { asset } = data; + + try { + await this.searchRepository.index(SearchCollection.ASSETS, asset); + } catch (error: any) { + this.logger.error(`Unable to index asset: ${asset.id}`, error?.stack); + } + } + + async handleIndexAlbums() { + if (!this.enabled) { + return; + } + + try { + const albums = await this.albumRepository.getAll(); + this.logger.log(`Indexing ${albums.length} albums`); + await this.searchRepository.import(SearchCollection.ALBUMS, albums, true); + } catch (error: any) { + this.logger.error(`Unable to index all albums`, error?.stack); + } + } + + async handleIndexAlbum(data: IAlbumJob) { + if (!this.enabled) { + return; + } + + const { album } = data; + + try { + await this.searchRepository.index(SearchCollection.ALBUMS, album); + } catch (error: any) { + this.logger.error(`Unable to index album: ${album.id}`, error?.stack); + } + } + + async handleRemoveAlbum(data: IDeleteJob) { + await this.handleRemove(SearchCollection.ALBUMS, data); + } + + async handleRemoveAsset(data: IDeleteJob) { + await this.handleRemove(SearchCollection.ASSETS, data); + } + + private async handleRemove(collection: SearchCollection, data: IDeleteJob) { + if (!this.enabled) { + return; + } + + const { id } = data; + + try { + await this.searchRepository.delete(collection, id); + } catch (error: any) { + this.logger.error(`Unable to remove ${collection}: ${id}`, error?.stack); + } + } +} diff --git a/server/libs/domain/test/album.repository.mock.ts b/server/libs/domain/test/album.repository.mock.ts index a240524aed3e3..dc21e5ecbbb09 100644 --- a/server/libs/domain/test/album.repository.mock.ts +++ b/server/libs/domain/test/album.repository.mock.ts @@ -3,5 +3,7 @@ import { IAlbumRepository } from '../src'; export const newAlbumRepositoryMock = (): jest.Mocked => { return { deleteAll: jest.fn(), + getAll: jest.fn(), + save: jest.fn(), }; }; diff --git a/server/libs/domain/test/fixtures.ts b/server/libs/domain/test/fixtures.ts index 73f822a7ff04c..da2aa42a5178b 100644 --- a/server/libs/domain/test/fixtures.ts +++ b/server/libs/domain/test/fixtures.ts @@ -1,4 +1,5 @@ import { + AlbumEntity, APIKeyEntity, AssetEntity, AssetType, @@ -155,6 +156,21 @@ export const assetEntityStub = { } as AssetEntity), }; +export const albumStub = { + empty: Object.freeze({ + id: 'album-1', + albumName: 'Empty album', + ownerId: authStub.admin.id, + owner: userEntityStub.admin, + assets: [], + albumThumbnailAssetId: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + sharedLinks: [], + sharedUsers: [], + }), +}; + const assetInfo: ExifResponseDto = { make: 'camera-make', model: 'camera-model', diff --git a/server/libs/domain/test/index.ts b/server/libs/domain/test/index.ts index d2ba46e725165..aec1d0c15aa38 100644 --- a/server/libs/domain/test/index.ts +++ b/server/libs/domain/test/index.ts @@ -6,6 +6,7 @@ export * from './device-info.repository.mock'; export * from './fixtures'; export * from './job.repository.mock'; export * from './machine-learning.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/libs/domain/test/search.repository.mock.ts b/server/libs/domain/test/search.repository.mock.ts new file mode 100644 index 0000000000000..b1918f3933e78 --- /dev/null +++ b/server/libs/domain/test/search.repository.mock.ts @@ -0,0 +1,12 @@ +import { ISearchRepository } from '../src'; + +export const newSearchRepositoryMock = (): jest.Mocked => { + return { + setup: jest.fn(), + checkMigrationStatus: jest.fn(), + index: jest.fn(), + import: jest.fn(), + search: jest.fn(), + delete: jest.fn(), + }; +}; diff --git a/server/libs/infra/src/db/repository/album.repository.ts b/server/libs/infra/src/db/repository/album.repository.ts index 1615d99577ff0..d4eca4e5008e2 100644 --- a/server/libs/infra/src/db/repository/album.repository.ts +++ b/server/libs/infra/src/db/repository/album.repository.ts @@ -11,4 +11,13 @@ export class AlbumRepository implements IAlbumRepository { async deleteAll(userId: string): Promise { await this.repository.delete({ ownerId: userId }); } + + getAll(): Promise { + return this.repository.find(); + } + + async save(album: Partial) { + const { id } = await this.repository.save(album); + return this.repository.findOneOrFail({ where: { id } }); + } } diff --git a/server/libs/infra/src/db/repository/asset.repository.ts b/server/libs/infra/src/db/repository/asset.repository.ts index 7be96048af721..6f0e65684e459 100644 --- a/server/libs/infra/src/db/repository/asset.repository.ts +++ b/server/libs/infra/src/db/repository/asset.repository.ts @@ -1,4 +1,4 @@ -import { IAssetRepository } from '@app/domain'; +import { AssetSearchOptions, IAssetRepository } from '@app/domain'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Not, Repository } from 'typeorm'; @@ -12,13 +12,32 @@ export class AssetRepository implements IAssetRepository { await this.repository.delete({ ownerId }); } - async getAll(): Promise { - return this.repository.find({ relations: { exifInfo: true } }); + getAll(options?: AssetSearchOptions | undefined): Promise { + options = options || {}; + + return this.repository.find({ + where: { + isVisible: options.isVisible, + }, + relations: { + exifInfo: true, + smartInfo: true, + tags: true, + }, + }); } async save(asset: Partial): Promise { const { id } = await this.repository.save(asset); - return this.repository.findOneOrFail({ where: { id } }); + return this.repository.findOneOrFail({ + where: { id }, + relations: { + exifInfo: true, + owner: true, + smartInfo: true, + tags: true, + }, + }); } findLivePhotoMatch(livePhotoCID: string, otherAssetId: string, type: AssetType): Promise { diff --git a/server/libs/infra/src/infra.module.ts b/server/libs/infra/src/infra.module.ts index a783fe45b4695..6633a165e624c 100644 --- a/server/libs/infra/src/infra.module.ts +++ b/server/libs/infra/src/infra.module.ts @@ -8,6 +8,7 @@ import { IKeyRepository, IMachineLearningRepository, IMediaRepository, + ISearchRepository, ISharedLinkRepository, ISmartInfoRepository, IStorageRepository, @@ -45,6 +46,7 @@ import { import { JobRepository } from './job'; import { MachineLearningRepository } from './machine-learning'; import { MediaRepository } from './media'; +import { TypesenseRepository } from './search'; import { FilesystemProvider } from './storage'; const providers: Provider[] = [ @@ -52,12 +54,12 @@ const providers: Provider[] = [ { provide: IAssetRepository, useClass: AssetRepository }, { provide: ICommunicationRepository, useClass: CommunicationRepository }, { provide: ICryptoRepository, useClass: CryptoRepository }, - { provide: ICryptoRepository, useClass: CryptoRepository }, { provide: IDeviceInfoRepository, useClass: DeviceInfoRepository }, { provide: IKeyRepository, useClass: APIKeyRepository }, { provide: IJobRepository, useClass: JobRepository }, { provide: IMachineLearningRepository, useClass: MachineLearningRepository }, { provide: IMediaRepository, useClass: MediaRepository }, + { provide: ISearchRepository, useClass: TypesenseRepository }, { provide: ISharedLinkRepository, useClass: SharedLinkRepository }, { provide: ISmartInfoRepository, useClass: SmartInfoRepository }, { provide: IStorageRepository, useClass: FilesystemProvider }, diff --git a/server/libs/infra/src/job/job.repository.ts b/server/libs/infra/src/job/job.repository.ts index 4c791a397243f..e83ce06034513 100644 --- a/server/libs/infra/src/job/job.repository.ts +++ b/server/libs/infra/src/job/job.repository.ts @@ -13,6 +13,7 @@ export class JobRepository implements IJobRepository { @InjectQueue(QueueName.STORAGE_TEMPLATE_MIGRATION) private storageTemplateMigration: Queue, @InjectQueue(QueueName.THUMBNAIL_GENERATION) private thumbnail: Queue, @InjectQueue(QueueName.VIDEO_CONVERSION) private videoTranscode: Queue, + @InjectQueue(QueueName.SEARCH) private searchIndex: Queue, ) {} async isActive(name: QueueName): Promise { @@ -70,6 +71,18 @@ export class JobRepository implements IJobRepository { await this.videoTranscode.add(item.name, item.data); break; + case JobName.SEARCH_INDEX_ASSETS: + case JobName.SEARCH_INDEX_ALBUMS: + await this.searchIndex.add(item.name); + break; + + case JobName.SEARCH_INDEX_ASSET: + case JobName.SEARCH_INDEX_ALBUM: + case JobName.SEARCH_REMOVE_ALBUM: + case JobName.SEARCH_REMOVE_ASSET: + await this.searchIndex.add(item.name, item.data); + break; + default: // TODO inject remaining queues and map job to queue this.logger.error('Invalid job', item); diff --git a/server/libs/infra/src/search/index.ts b/server/libs/infra/src/search/index.ts new file mode 100644 index 0000000000000..c7673993aa4ce --- /dev/null +++ b/server/libs/infra/src/search/index.ts @@ -0,0 +1 @@ +export * from './typesense.repository'; diff --git a/server/libs/infra/src/search/schemas/album.schema.ts b/server/libs/infra/src/search/schemas/album.schema.ts new file mode 100644 index 0000000000000..bc01aca0c75d5 --- /dev/null +++ b/server/libs/infra/src/search/schemas/album.schema.ts @@ -0,0 +1,13 @@ +import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections'; + +export const albumSchemaVersion = 1; +export const albumSchema: CollectionCreateSchema = { + name: `albums-v${albumSchemaVersion}`, + fields: [ + { name: 'ownerId', type: 'string', facet: false }, + { name: 'albumName', type: 'string', facet: false, sort: true }, + { name: 'createdAt', type: 'string', facet: false, sort: true }, + { name: 'updatedAt', type: 'string', facet: false, sort: true }, + ], + default_sorting_field: 'createdAt', +}; diff --git a/server/libs/infra/src/search/schemas/asset.schema.ts b/server/libs/infra/src/search/schemas/asset.schema.ts new file mode 100644 index 0000000000000..962f4e9b2a656 --- /dev/null +++ b/server/libs/infra/src/search/schemas/asset.schema.ts @@ -0,0 +1,37 @@ +import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections'; + +export const assetSchemaVersion = 1; +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: 'checksum', type: 'string', facet: true }, + // { name: 'tags', type: 'string[]', facet: true, 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.imageName', type: 'string', facet: false, optional: true }, + { name: 'geo', type: 'geopoint', 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 }, + + // smart info + { name: 'smartInfo.objects', type: 'string[]', facet: true, optional: true }, + { name: 'smartInfo.tags', type: 'string[]', facet: true, optional: true }, + ], + token_separators: ['.'], + enable_nested_fields: true, + default_sorting_field: 'fileCreatedAt', +}; diff --git a/server/libs/infra/src/search/typesense.repository.ts b/server/libs/infra/src/search/typesense.repository.ts new file mode 100644 index 0000000000000..b24da06546861 --- /dev/null +++ b/server/libs/infra/src/search/typesense.repository.ts @@ -0,0 +1,325 @@ +import { + ISearchRepository, + SearchCollection, + SearchCollectionIndexStatus, + SearchFilter, + SearchResult, +} from '@app/domain'; +import { Injectable, Logger } from '@nestjs/common'; +import _, { Dictionary } from 'lodash'; +import { Client } from 'typesense'; +import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections'; +import { DocumentSchema, SearchResponse } from 'typesense/lib/Typesense/Documents'; +import { AlbumEntity, AssetEntity } from '../db'; +import { albumSchema } from './schemas/album.schema'; +import { assetSchema } from './schemas/asset.schema'; + +interface GeoAssetEntity extends AssetEntity { + geo?: [number, number]; +} + +function removeNil>(item: T): Partial { + _.forOwn(item, (value, key) => { + if (_.isNil(value) || (_.isObject(value) && !_.isDate(value) && _.isEmpty(removeNil(value)))) { + delete item[key]; + } + }); + + return item; +} + +const schemaMap: Record = { + [SearchCollection.ASSETS]: assetSchema, + [SearchCollection.ALBUMS]: albumSchema, +}; + +const schemas = Object.entries(schemaMap) as [SearchCollection, CollectionCreateSchema][]; + +interface SearchUpdateQueue { + upsert: T[]; + delete: string[]; +} + +@Injectable() +export class TypesenseRepository implements ISearchRepository { + private logger = new Logger(TypesenseRepository.name); + private queue: Record = { + [SearchCollection.ASSETS]: { + upsert: [], + delete: [], + }, + [SearchCollection.ALBUMS]: { + upsert: [], + delete: [], + }, + }; + + private _client: Client | null = null; + private get client(): Client { + if (!this._client) { + throw new Error('Typesense client not available (no apiKey was provided)'); + } + return this._client; + } + + constructor() { + const apiKey = process.env.TYPESENSE_API_KEY; + if (!apiKey) { + return; + } + + this._client = new Client({ + nodes: [ + { + host: process.env.TYPESENSE_HOST || 'typesense', + port: Number(process.env.TYPESENSE_PORT) || 8108, + protocol: process.env.TYPESENSE_PROTOCOL || 'http', + }, + ], + apiKey, + numRetries: 3, + connectionTimeoutSeconds: 10, + }); + + setInterval(() => this.flush(), 5_000); + } + + async setup(): Promise { + // 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, + }; + + // 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 index(collection: SearchCollection, item: AssetEntity | AlbumEntity, immediate?: boolean): Promise { + const schema = schemaMap[collection]; + + if (collection === SearchCollection.ASSETS) { + item = this.patchAsset(item as AssetEntity); + } + + if (immediate) { + await this.client.collections(schema.name).documents().upsert(item); + return; + } + + this.queue[collection].upsert.push(item); + } + + async delete(collection: SearchCollection, id: string, immediate?: boolean): Promise { + const schema = schemaMap[collection]; + + if (immediate) { + await this.client.collections(schema.name).documents().delete(id); + return; + } + + this.queue[collection].delete.push(id); + } + + async import(collection: SearchCollection, items: AssetEntity[] | AlbumEntity[], done: boolean): Promise { + try { + const schema = schemaMap[collection]; + const _items = items.map((item) => { + if (collection === SearchCollection.ASSETS) { + item = this.patchAsset(item as AssetEntity); + } + // null values are invalid for typesense documents + return removeNil(item); + }); + if (_items.length > 0) { + await this.client + .collections(schema.name) + .documents() + .import(_items, { action: 'upsert', dirty_values: 'coerce_or_drop' }); + } + if (done) { + await this.updateAlias(collection); + } + } catch (error: any) { + this.handleError(error); + } + } + + search(collection: SearchCollection.ASSETS, query: string, filter: SearchFilter): Promise>; + search(collection: SearchCollection.ALBUMS, query: string, filter: SearchFilter): Promise>; + async search(collection: SearchCollection, query: string, filters: SearchFilter) { + const alias = await this.client.aliases(collection).retrieve(); + + const { userId } = filters; + + const _filters = [`ownerId:${userId}`]; + + if (filters.id) { + _filters.push(`id:=${filters.id}`); + } + if (collection === SearchCollection.ASSETS) { + for (const item of schemaMap[collection].fields || []) { + let value = filters[item.name as keyof SearchFilter]; + if (Array.isArray(value)) { + value = `[${value.join(',')}]`; + } + if (item.facet && value !== undefined) { + _filters.push(`${item.name}:${value}`); + } + } + + this.logger.debug(`Searching query='${query}', filters='${JSON.stringify(_filters)}'`); + + const results = await this.client + .collections(alias.collection_name) + .documents() + .search({ + q: query, + query_by: [ + 'exifInfo.imageName', + 'exifInfo.country', + 'exifInfo.state', + 'exifInfo.city', + 'exifInfo.description', + 'smartInfo.tags', + 'smartInfo.objects', + ].join(','), + filter_by: _filters.join(' && '), + per_page: 250, + facet_by: (assetSchema.fields || []) + .filter((field) => field.facet) + .map((field) => field.name) + .join(','), + }); + + return this.asResponse(results); + } + + if (collection === SearchCollection.ALBUMS) { + const results = await this.client + .collections(alias.collection_name) + .documents() + .search({ + q: query, + query_by: 'albumName', + filter_by: _filters.join(','), + }); + + return this.asResponse(results); + } + + throw new Error(`Invalid collection: ${collection}`); + } + + private asResponse(results: SearchResponse): SearchResult { + return { + page: results.page, + total: results.found, + count: results.out_of, + items: (results.hits || []).map((hit) => hit.document), + facets: (results.facet_counts || []).map((facet) => ({ + counts: facet.counts.map((item) => ({ count: item.count, value: item.value })), + fieldName: facet.field_name as string, + })), + }; + } + + private async flush() { + for (const [collection, schema] of schemas) { + if (this.queue[collection].upsert.length > 0) { + try { + const items = this.queue[collection].upsert.map((item) => removeNil(item)); + this.logger.debug(`Flushing ${items.length} ${collection} upserts to typesense`); + await this.client + .collections(schema.name) + .documents() + .import(items, { action: 'upsert', dirty_values: 'coerce_or_drop' }); + this.queue[collection].upsert = []; + } catch (error) { + this.handleError(error); + } + } + + if (this.queue[collection].delete.length > 0) { + try { + const items = this.queue[collection].delete; + this.logger.debug(`Flushing ${items.length} ${collection} deletes to typesense`); + await this.client + .collections(schema.name) + .documents() + .delete({ filter_by: `id: [${items.join(',')}]` }); + this.queue[collection].delete = []; + } catch (error) { + this.handleError(error); + } + } + } + } + + private handleError(error: any): never { + this.logger.error('Unable to index documents'); + const results = error.importResults || []; + for (const result of results) { + try { + result.document = JSON.parse(result.document); + } catch {} + } + this.logger.verbose(JSON.stringify(results, null, 2)); + throw error; + } + + 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 patchAsset(asset: AssetEntity): GeoAssetEntity { + const lat = asset.exifInfo?.latitude; + const lng = asset.exifInfo?.longitude; + if (lat && lng && lat !== 0 && lng !== 0) { + return { ...asset, geo: [lat, lng] }; + } + + return asset; + } +} diff --git a/server/package-lock.json b/server/package-lock.json index 880af80bf13fb..5e26c539cf968 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -6,9 +6,10 @@ "packages": { "": { "name": "immich", - "version": "1.49.0", + "version": "1.50.1", "license": "UNLICENSED", "dependencies": { + "@babel/runtime": "^7.20.13", "@nestjs/bull": "^0.6.2", "@nestjs/common": "^9.2.1", "@nestjs/config": "^2.2.0", @@ -46,7 +47,8 @@ "rxjs": "^7.2.0", "sanitize-filename": "^1.6.3", "sharp": "^0.28.0", - "typeorm": "^0.3.11" + "typeorm": "^0.3.11", + "typesense": "^1.5.2" }, "bin": { "immich": "bin/cli.sh" @@ -765,6 +767,17 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.20.13", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.13.tgz", + "integrity": "sha512-gt3PKXs0DBoL9xCvOIIZ2NEqAGZqHjAnmVbfQtB620V0uReIQutpel14KcneZuer7UioY8ALKZ7iocavvzTNFA==", + "dependencies": { + "regenerator-runtime": "^0.13.11" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.16.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.16.7.tgz", @@ -8104,6 +8117,18 @@ "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": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -9498,6 +9523,11 @@ "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz", "integrity": "sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==" }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" + }, "node_modules/regexpp": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", @@ -11106,6 +11136,18 @@ "node": ">=4.2.0" } }, + "node_modules/typesense": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/typesense/-/typesense-1.5.2.tgz", + "integrity": "sha512-ysARFw+4z3AdSViOACqf7K9TXoP2wAXd5p5uSGTdXW14UYjcEzpV/S/EhMoiC6YdZyrnbDdNsxgWbf+AWJ9Udw==", + "dependencies": { + "axios": "^0.26.0", + "loglevel": "^1.8.0" + }, + "peerDependencies": { + "@babel/runtime": "^7.17.2" + } + }, "node_modules/uglify-js": { "version": "3.17.4", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz", @@ -12115,6 +12157,14 @@ "@babel/helper-plugin-utils": "^7.16.7" } }, + "@babel/runtime": { + "version": "7.20.13", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.13.tgz", + "integrity": "sha512-gt3PKXs0DBoL9xCvOIIZ2NEqAGZqHjAnmVbfQtB620V0uReIQutpel14KcneZuer7UioY8ALKZ7iocavvzTNFA==", + "requires": { + "regenerator-runtime": "^0.13.11" + } + }, "@babel/template": { "version": "7.16.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.16.7.tgz", @@ -17808,6 +17858,11 @@ "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": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -18862,6 +18917,11 @@ "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz", "integrity": "sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==" }, + "regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" + }, "regexpp": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", @@ -19962,6 +20022,15 @@ "integrity": "sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==", "devOptional": true }, + "typesense": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/typesense/-/typesense-1.5.2.tgz", + "integrity": "sha512-ysARFw+4z3AdSViOACqf7K9TXoP2wAXd5p5uSGTdXW14UYjcEzpV/S/EhMoiC6YdZyrnbDdNsxgWbf+AWJ9Udw==", + "requires": { + "axios": "^0.26.0", + "loglevel": "^1.8.0" + } + }, "uglify-js": { "version": "3.17.4", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz", diff --git a/server/package.json b/server/package.json index 2c476e24bdac0..12fa9757e2db8 100644 --- a/server/package.json +++ b/server/package.json @@ -39,6 +39,7 @@ "api:generate": "bash ./bin/generate-open-api.sh" }, "dependencies": { + "@babel/runtime": "^7.20.13", "@nestjs/bull": "^0.6.2", "@nestjs/common": "^9.2.1", "@nestjs/config": "^2.2.0", @@ -76,7 +77,8 @@ "rxjs": "^7.2.0", "sanitize-filename": "^1.6.3", "sharp": "^0.28.0", - "typeorm": "^0.3.11" + "typeorm": "^0.3.11", + "typesense": "^1.5.2" }, "devDependencies": { "@nestjs/cli": "^9.1.8", diff --git a/web/src/api/api.ts b/web/src/api/api.ts index 0bd8b76fccc5a..329e146283d0e 100644 --- a/web/src/api/api.ts +++ b/web/src/api/api.ts @@ -8,6 +8,7 @@ import { DeviceInfoApi, JobApi, OAuthApi, + SearchApi, ServerInfoApi, ShareApi, SystemConfigApi, @@ -21,6 +22,7 @@ export class ImmichApi { public authenticationApi: AuthenticationApi; public oauthApi: OAuthApi; public deviceInfoApi: DeviceInfoApi; + public searchApi: SearchApi; public serverInfoApi: ServerInfoApi; public jobApi: JobApi; public keyApi: APIKeyApi; @@ -41,6 +43,7 @@ export class ImmichApi { this.serverInfoApi = new ServerInfoApi(this.config); this.jobApi = new JobApi(this.config); this.keyApi = new APIKeyApi(this.config); + this.searchApi = new SearchApi(this.config); this.systemConfigApi = new SystemConfigApi(this.config); this.shareApi = new ShareApi(this.config); } diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 921bb0ccacbf3..a04d5cb817218 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -1451,6 +1451,37 @@ export interface RemoveAssetsDto { */ 'assetIds': Array; } +/** + * + * @export + * @interface SearchAlbumResponseDto + */ +export interface SearchAlbumResponseDto { + /** + * + * @type {number} + * @memberof SearchAlbumResponseDto + */ + 'total': number; + /** + * + * @type {number} + * @memberof SearchAlbumResponseDto + */ + 'count': number; + /** + * + * @type {Array} + * @memberof SearchAlbumResponseDto + */ + 'items': Array; + /** + * + * @type {Array} + * @memberof SearchAlbumResponseDto + */ + 'facets': Array; +} /** * * @export @@ -1464,6 +1495,107 @@ export interface SearchAssetDto { */ 'searchTerm': string; } +/** + * + * @export + * @interface SearchAssetResponseDto + */ +export interface SearchAssetResponseDto { + /** + * + * @type {number} + * @memberof SearchAssetResponseDto + */ + 'total': number; + /** + * + * @type {number} + * @memberof SearchAssetResponseDto + */ + 'count': number; + /** + * + * @type {Array} + * @memberof SearchAssetResponseDto + */ + 'items': Array; + /** + * + * @type {Array} + * @memberof SearchAssetResponseDto + */ + 'facets': Array; +} +/** + * + * @export + * @interface SearchConfigResponseDto + */ +export interface SearchConfigResponseDto { + /** + * + * @type {boolean} + * @memberof SearchConfigResponseDto + */ + 'enabled': boolean; +} +/** + * + * @export + * @interface SearchFacetCountResponseDto + */ +export interface SearchFacetCountResponseDto { + /** + * + * @type {number} + * @memberof SearchFacetCountResponseDto + */ + 'count': number; + /** + * + * @type {string} + * @memberof SearchFacetCountResponseDto + */ + 'value': string; +} +/** + * + * @export + * @interface SearchFacetResponseDto + */ +export interface SearchFacetResponseDto { + /** + * + * @type {string} + * @memberof SearchFacetResponseDto + */ + 'fieldName': string; + /** + * + * @type {Array} + * @memberof SearchFacetResponseDto + */ + 'counts': Array; +} +/** + * + * @export + * @interface SearchResponseDto + */ +export interface SearchResponseDto { + /** + * + * @type {SearchAlbumResponseDto} + * @memberof SearchResponseDto + */ + 'albums': SearchAlbumResponseDto; + /** + * + * @type {SearchAssetResponseDto} + * @memberof SearchResponseDto + */ + 'assets': SearchAssetResponseDto; +} /** * * @export @@ -6485,6 +6617,248 @@ export class OAuthApi extends BaseAPI { } +/** + * SearchApi - axios parameter creator + * @export + */ +export const SearchApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getSearchConfig: async (options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/search/config`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + // authentication cookie required + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} [query] + * @param {'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER'} [type] + * @param {boolean} [isFavorite] + * @param {string} [exifInfoCity] + * @param {string} [exifInfoState] + * @param {string} [exifInfoCountry] + * @param {string} [exifInfoMake] + * @param {string} [exifInfoModel] + * @param {Array} [smartInfoObjects] + * @param {Array} [smartInfoTags] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + search: async (query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array, smartInfoTags?: Array, 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); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + // authentication cookie required + + if (query !== undefined) { + localVarQueryParameter['query'] = query; + } + + if (type !== undefined) { + localVarQueryParameter['type'] = type; + } + + if (isFavorite !== undefined) { + localVarQueryParameter['isFavorite'] = isFavorite; + } + + 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 (smartInfoObjects) { + localVarQueryParameter['smartInfo.objects'] = smartInfoObjects; + } + + if (smartInfoTags) { + localVarQueryParameter['smartInfo.tags'] = smartInfoTags; + } + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * SearchApi - functional programming interface + * @export + */ +export const SearchApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = SearchApiAxiosParamCreator(configuration) + return { + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getSearchConfig(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getSearchConfig(options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {string} [query] + * @param {'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER'} [type] + * @param {boolean} [isFavorite] + * @param {string} [exifInfoCity] + * @param {string} [exifInfoState] + * @param {string} [exifInfoCountry] + * @param {string} [exifInfoMake] + * @param {string} [exifInfoModel] + * @param {Array} [smartInfoObjects] + * @param {Array} [smartInfoTags] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async search(query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array, smartInfoTags?: Array, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.search(query, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + } +}; + +/** + * SearchApi - factory interface + * @export + */ +export const SearchApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = SearchApiFp(configuration) + return { + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getSearchConfig(options?: any): AxiosPromise { + return localVarFp.getSearchConfig(options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {string} [query] + * @param {'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER'} [type] + * @param {boolean} [isFavorite] + * @param {string} [exifInfoCity] + * @param {string} [exifInfoState] + * @param {string} [exifInfoCountry] + * @param {string} [exifInfoMake] + * @param {string} [exifInfoModel] + * @param {Array} [smartInfoObjects] + * @param {Array} [smartInfoTags] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + search(query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array, smartInfoTags?: Array, options?: any): AxiosPromise { + return localVarFp.search(query, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * SearchApi - object-oriented interface + * @export + * @class SearchApi + * @extends {BaseAPI} + */ +export class SearchApi extends BaseAPI { + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof SearchApi + */ + public getSearchConfig(options?: AxiosRequestConfig) { + return SearchApiFp(this.configuration).getSearchConfig(options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {string} [query] + * @param {'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER'} [type] + * @param {boolean} [isFavorite] + * @param {string} [exifInfoCity] + * @param {string} [exifInfoState] + * @param {string} [exifInfoCountry] + * @param {string} [exifInfoMake] + * @param {string} [exifInfoModel] + * @param {Array} [smartInfoObjects] + * @param {Array} [smartInfoTags] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof SearchApi + */ + public search(query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array, smartInfoTags?: Array, options?: AxiosRequestConfig) { + return SearchApiFp(this.configuration).search(query, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, options).then((request) => request(this.axios, this.basePath)); + } +} + + /** * ServerInfoApi - axios parameter creator * @export diff --git a/web/src/app.d.ts b/web/src/app.d.ts index 07388589937d2..c91d504d30e3e 100644 --- a/web/src/app.d.ts +++ b/web/src/app.d.ts @@ -13,7 +13,7 @@ declare namespace App { interface Error { message: string; stack?: string; - code?: string; + code?: string | number; } } diff --git a/web/src/hooks.server.ts b/web/src/hooks.server.ts index 65a4912e753d2..959ed5e3eb741 100644 --- a/web/src/hooks.server.ts +++ b/web/src/hooks.server.ts @@ -1,5 +1,5 @@ import type { Handle, HandleServerError } from '@sveltejs/kit'; -import { AxiosError } from 'axios'; +import { AxiosError, AxiosResponse } from 'axios'; import { env } from '$env/dynamic/public'; import { ImmichApi } from './api/api'; @@ -34,11 +34,24 @@ export const handle = (async ({ event, resolve }) => { return res; }) satisfies Handle; +const DEFAULT_MESSAGE = 'Hmm, not sure about that. Check the logs or open a ticket?'; + export const handleError: HandleServerError = async ({ error }) => { const httpError = error as AxiosError; + const response = httpError?.response as AxiosResponse<{ + message: string; + statusCode: number; + error: string; + }>; + + let code = response?.data?.statusCode || response?.status || httpError.code || '500'; + if (response) { + code += ` - ${response.data?.error || response.statusText}`; + } + return { - message: httpError?.message || 'Hmm, not sure about that. Check the logs or open a ticket?', - stack: httpError?.stack, - code: httpError.code || '500' + message: response?.data?.message || httpError?.message || DEFAULT_MESSAGE, + code, + stack: httpError?.stack }; }; diff --git a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte index 67ed46919625a..2cc88c42fe07c 100644 --- a/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte +++ b/web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte @@ -22,7 +22,7 @@ $: { if (assets.length < 6) { - thumbnailSize = Math.floor(viewWidth / assets.length - assets.length); + thumbnailSize = Math.min(320, Math.floor(viewWidth / assets.length - assets.length)); } else { if (viewWidth > 600) thumbnailSize = Math.floor(viewWidth / 6 - 6); else if (viewWidth > 400) thumbnailSize = Math.floor(viewWidth / 4 - 6); diff --git a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte index 348f7e1a3cea4..091ba535da11e 100644 --- a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte +++ b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte @@ -11,6 +11,7 @@ import ImmichLogo from '../immich-logo.svelte'; export let user: UserResponseDto; export let shouldShowUploadButton = true; + export let term = ''; let shouldShowAccountInfo = false; @@ -35,6 +36,10 @@ goto(data.redirectUri || '/auth/login?autoLaunch=0'); }; + + const onSearch = () => { + goto(`/search?q=${term}`); + };
-
+
-
+
diff --git a/web/src/routes/(user)/search/+page.server.ts b/web/src/routes/(user)/search/+page.server.ts new file mode 100644 index 0000000000000..26eefac32987d --- /dev/null +++ b/web/src/routes/(user)/search/+page.server.ts @@ -0,0 +1,26 @@ +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; + +export const load = (async ({ locals, parent, url }) => { + const { user } = await parent(); + if (!user) { + throw redirect(302, '/auth/login'); + } + + const term = url.searchParams.get('q') || undefined; + + const { data: results } = await locals.api.searchApi.search( + term, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + { params: url.searchParams } + ); + return { user, term, results }; +}) satisfies PageServerLoad; diff --git a/web/src/routes/(user)/search/+page.svelte b/web/src/routes/(user)/search/+page.svelte new file mode 100644 index 0000000000000..6bcf8f9568053 --- /dev/null +++ b/web/src/routes/(user)/search/+page.svelte @@ -0,0 +1,27 @@ + + +
+ +
+ +
+
+
+ {#if data.results?.assets?.items} + + {/if} +
+
+
diff --git a/web/src/routes/+error.svelte b/web/src/routes/+error.svelte index 3f0329a146dfd..a6a7edf93c488 100644 --- a/web/src/routes/+error.svelte +++ b/web/src/routes/+error.svelte @@ -68,7 +68,7 @@
-

{$page.error?.message} - {$page.error?.code}

+

{$page.error?.message} ({$page.error?.code})

{#if $page.error?.stack}
{$page.error?.stack || 'No stack'}