mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	feat(server)!: search via typesense (#1778)
* build: add typesense to docker * feat(server): typesense search * feat(web): search * fix(web): show api error response message * chore: search tests * chore: regenerate open api * fix: disable typesense on e2e * fix: number properties for open api (dart) * fix: e2e test * fix: change lat/lng from floats to typesense geopoint * dev: Add smartInfo relation to findAssetById to be able to query against it --------- Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
		| @@ -17,3 +17,5 @@ ENABLE_MAPBOX=false | |||||||
| # WEB | # WEB | ||||||
| MAPBOX_KEY= | MAPBOX_KEY= | ||||||
| VITE_SERVER_ENDPOINT=http://localhost:2283/api | VITE_SERVER_ENDPOINT=http://localhost:2283/api | ||||||
|  |  | ||||||
|  | TYPESENSE_ENABLED=false | ||||||
|   | |||||||
| @@ -23,6 +23,7 @@ services: | |||||||
|     depends_on: |     depends_on: | ||||||
|       - redis |       - redis | ||||||
|       - database |       - database | ||||||
|  |       - typesense | ||||||
|  |  | ||||||
|   immich-machine-learning: |   immich-machine-learning: | ||||||
|     container_name: immich_machine_learning |     container_name: immich_machine_learning | ||||||
| @@ -64,6 +65,7 @@ services: | |||||||
|     depends_on: |     depends_on: | ||||||
|       - database |       - database | ||||||
|       - immich-server |       - immich-server | ||||||
|  |       - typesense | ||||||
|  |  | ||||||
|   immich-web: |   immich-web: | ||||||
|     container_name: immich_web |     container_name: immich_web | ||||||
| @@ -89,6 +91,15 @@ services: | |||||||
|     depends_on: |     depends_on: | ||||||
|       - immich-server |       - 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: |   redis: | ||||||
|     container_name: immich_redis |     container_name: immich_redis | ||||||
|     image: redis:6.2 |     image: redis:6.2 | ||||||
| @@ -129,3 +140,4 @@ services: | |||||||
| volumes: | volumes: | ||||||
|   pgdata: |   pgdata: | ||||||
|   model-cache: |   model-cache: | ||||||
|  |   tsdata: | ||||||
|   | |||||||
| @@ -1,4 +1,4 @@ | |||||||
| version: '3.8' | version: "3.8" | ||||||
|  |  | ||||||
| services: | services: | ||||||
|   immich-server-test: |   immich-server-test: | ||||||
| @@ -9,7 +9,7 @@ services: | |||||||
|       target: builder |       target: builder | ||||||
|     command: npm run test:e2e |     command: npm run test:e2e | ||||||
|     expose: |     expose: | ||||||
|       - '3000' |       - "3000" | ||||||
|     volumes: |     volumes: | ||||||
|       - ../server:/usr/src/app |       - ../server:/usr/src/app | ||||||
|       - /usr/src/app/node_modules |       - /usr/src/app/node_modules | ||||||
| @@ -17,6 +17,7 @@ services: | |||||||
|       - .env.test |       - .env.test | ||||||
|     environment: |     environment: | ||||||
|       - NODE_ENV=development |       - NODE_ENV=development | ||||||
|  |       - TYPESENSE_ENABLED=false | ||||||
|     depends_on: |     depends_on: | ||||||
|       - immich-redis-test |       - immich-redis-test | ||||||
|       - immich-database-test |       - immich-database-test | ||||||
|   | |||||||
| @@ -4,7 +4,7 @@ services: | |||||||
|   immich-server: |   immich-server: | ||||||
|     container_name: immich_server |     container_name: immich_server | ||||||
|     image: altran1502/immich-server:release |     image: altran1502/immich-server:release | ||||||
|     entrypoint: [ "/bin/sh", "./start-server.sh" ] |     entrypoint: ["/bin/sh", "./start-server.sh"] | ||||||
|     volumes: |     volumes: | ||||||
|       - ${UPLOAD_LOCATION}:/usr/src/app/upload |       - ${UPLOAD_LOCATION}:/usr/src/app/upload | ||||||
|     env_file: |     env_file: | ||||||
| @@ -14,12 +14,13 @@ services: | |||||||
|     depends_on: |     depends_on: | ||||||
|       - redis |       - redis | ||||||
|       - database |       - database | ||||||
|  |       - typesense | ||||||
|     restart: always |     restart: always | ||||||
|  |  | ||||||
|   immich-microservices: |   immich-microservices: | ||||||
|     container_name: immich_microservices |     container_name: immich_microservices | ||||||
|     image: altran1502/immich-server:release |     image: altran1502/immich-server:release | ||||||
|     entrypoint: [ "/bin/sh", "./start-microservices.sh" ] |     entrypoint: ["/bin/sh", "./start-microservices.sh"] | ||||||
|     volumes: |     volumes: | ||||||
|       - ${UPLOAD_LOCATION}:/usr/src/app/upload |       - ${UPLOAD_LOCATION}:/usr/src/app/upload | ||||||
|     env_file: |     env_file: | ||||||
| @@ -29,6 +30,7 @@ services: | |||||||
|     depends_on: |     depends_on: | ||||||
|       - redis |       - redis | ||||||
|       - database |       - database | ||||||
|  |       - typesense | ||||||
|     restart: always |     restart: always | ||||||
|  |  | ||||||
|   immich-machine-learning: |   immich-machine-learning: | ||||||
| @@ -46,11 +48,20 @@ services: | |||||||
|   immich-web: |   immich-web: | ||||||
|     container_name: immich_web |     container_name: immich_web | ||||||
|     image: altran1502/immich-web:release |     image: altran1502/immich-web:release | ||||||
|     entrypoint: [ "/bin/sh", "./entrypoint.sh" ] |     entrypoint: ["/bin/sh", "./entrypoint.sh"] | ||||||
|     env_file: |     env_file: | ||||||
|       - .env |       - .env | ||||||
|     restart: always |     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: |   redis: | ||||||
|     container_name: immich_redis |     container_name: immich_redis | ||||||
|     image: redis:6.2 |     image: redis:6.2 | ||||||
| @@ -88,3 +99,4 @@ services: | |||||||
| volumes: | volumes: | ||||||
|   pgdata: |   pgdata: | ||||||
|   model-cache: |   model-cache: | ||||||
|  |   tsdata: | ||||||
|   | |||||||
| @@ -30,6 +30,13 @@ REDIS_HOSTNAME=immich_redis | |||||||
|  |  | ||||||
| UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup | 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 | # 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 | # Examples: http://localhost:3001, http://immich-api.example.com, etc | ||||||
| #################################################################################### | #################################################################################### | ||||||
|  |  | ||||||
| #IMMICH_API_URL_EXTERNAL=http://localhost:3001 | #IMMICH_API_URL_EXTERNAL=http://localhost:3001 | ||||||
|   | |||||||
							
								
								
									
										21
									
								
								mobile/openapi/.openapi-generator/FILES
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										21
									
								
								mobile/openapi/.openapi-generator/FILES
									
									
									
										generated
									
									
									
								
							| @@ -61,7 +61,14 @@ doc/OAuthCallbackDto.md | |||||||
| doc/OAuthConfigDto.md | doc/OAuthConfigDto.md | ||||||
| doc/OAuthConfigResponseDto.md | doc/OAuthConfigResponseDto.md | ||||||
| doc/RemoveAssetsDto.md | doc/RemoveAssetsDto.md | ||||||
|  | doc/SearchAlbumResponseDto.md | ||||||
|  | doc/SearchApi.md | ||||||
| doc/SearchAssetDto.md | doc/SearchAssetDto.md | ||||||
|  | doc/SearchAssetResponseDto.md | ||||||
|  | doc/SearchConfigResponseDto.md | ||||||
|  | doc/SearchFacetCountResponseDto.md | ||||||
|  | doc/SearchFacetResponseDto.md | ||||||
|  | doc/SearchResponseDto.md | ||||||
| doc/ServerInfoApi.md | doc/ServerInfoApi.md | ||||||
| doc/ServerInfoResponseDto.md | doc/ServerInfoResponseDto.md | ||||||
| doc/ServerPingResponse.md | doc/ServerPingResponse.md | ||||||
| @@ -103,6 +110,7 @@ lib/api/authentication_api.dart | |||||||
| lib/api/device_info_api.dart | lib/api/device_info_api.dart | ||||||
| lib/api/job_api.dart | lib/api/job_api.dart | ||||||
| lib/api/o_auth_api.dart | lib/api/o_auth_api.dart | ||||||
|  | lib/api/search_api.dart | ||||||
| lib/api/server_info_api.dart | lib/api/server_info_api.dart | ||||||
| lib/api/share_api.dart | lib/api/share_api.dart | ||||||
| lib/api/system_config_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_dto.dart | ||||||
| lib/model/o_auth_config_response_dto.dart | lib/model/o_auth_config_response_dto.dart | ||||||
| lib/model/remove_assets_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_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_info_response_dto.dart | ||||||
| lib/model/server_ping_response.dart | lib/model/server_ping_response.dart | ||||||
| lib/model/server_stats_response_dto.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_dto_test.dart | ||||||
| test/o_auth_config_response_dto_test.dart | test/o_auth_config_response_dto_test.dart | ||||||
| test/remove_assets_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_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_api_test.dart | ||||||
| test/server_info_response_dto_test.dart | test/server_info_response_dto_test.dart | ||||||
| test/server_ping_response_test.dart | test/server_ping_response_test.dart | ||||||
|   | |||||||
							
								
								
									
										8
									
								
								mobile/openapi/README.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										8
									
								
								mobile/openapi/README.md
									
									
									
										generated
									
									
									
								
							| @@ -121,6 +121,8 @@ Class | Method | HTTP request | Description | |||||||
| *OAuthApi* | [**link**](doc//OAuthApi.md#link) | **POST** /oauth/link |  | *OAuthApi* | [**link**](doc//OAuthApi.md#link) | **POST** /oauth/link |  | ||||||
| *OAuthApi* | [**mobileRedirect**](doc//OAuthApi.md#mobileredirect) | **GET** /oauth/mobile-redirect |  | *OAuthApi* | [**mobileRedirect**](doc//OAuthApi.md#mobileredirect) | **GET** /oauth/mobile-redirect |  | ||||||
| *OAuthApi* | [**unlink**](doc//OAuthApi.md#unlink) | **POST** /oauth/unlink |  | *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* | [**getServerInfo**](doc//ServerInfoApi.md#getserverinfo) | **GET** /server-info |  | ||||||
| *ServerInfoApi* | [**getServerVersion**](doc//ServerInfoApi.md#getserverversion) | **GET** /server-info/version |  | *ServerInfoApi* | [**getServerVersion**](doc//ServerInfoApi.md#getserverversion) | **GET** /server-info/version |  | ||||||
| *ServerInfoApi* | [**getStats**](doc//ServerInfoApi.md#getstats) | **GET** /server-info/stats |  | *ServerInfoApi* | [**getStats**](doc//ServerInfoApi.md#getstats) | **GET** /server-info/stats |  | ||||||
| @@ -204,7 +206,13 @@ Class | Method | HTTP request | Description | |||||||
|  - [OAuthConfigDto](doc//OAuthConfigDto.md) |  - [OAuthConfigDto](doc//OAuthConfigDto.md) | ||||||
|  - [OAuthConfigResponseDto](doc//OAuthConfigResponseDto.md) |  - [OAuthConfigResponseDto](doc//OAuthConfigResponseDto.md) | ||||||
|  - [RemoveAssetsDto](doc//RemoveAssetsDto.md) |  - [RemoveAssetsDto](doc//RemoveAssetsDto.md) | ||||||
|  |  - [SearchAlbumResponseDto](doc//SearchAlbumResponseDto.md) | ||||||
|  - [SearchAssetDto](doc//SearchAssetDto.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) |  - [ServerInfoResponseDto](doc//ServerInfoResponseDto.md) | ||||||
|  - [ServerPingResponse](doc//ServerPingResponse.md) |  - [ServerPingResponse](doc//ServerPingResponse.md) | ||||||
|  - [ServerStatsResponseDto](doc//ServerStatsResponseDto.md) |  - [ServerStatsResponseDto](doc//ServerStatsResponseDto.md) | ||||||
|   | |||||||
							
								
								
									
										18
									
								
								mobile/openapi/doc/SearchAlbumResponseDto.md
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								mobile/openapi/doc/SearchAlbumResponseDto.md
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -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>**](AlbumResponseDto.md) |  | [default to const []] | ||||||
|  | **facets** | [**List<SearchFacetResponseDto>**](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) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
							
								
								
									
										135
									
								
								mobile/openapi/doc/SearchApi.md
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										135
									
								
								mobile/openapi/doc/SearchApi.md
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -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<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); | ||||||
|  | // Case 2. Use Function which generate token. | ||||||
|  | // String yourTokenGeneratorFunction() { ... } | ||||||
|  | //defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction); | ||||||
|  | // TODO Configure API key authorization: cookie | ||||||
|  | //defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY'; | ||||||
|  | // uncomment below to setup prefix (e.g. Bearer) for API key, if needed | ||||||
|  | //defaultApiClient.getAuthentication<ApiKeyAuth>('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<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); | ||||||
|  | // Case 2. Use Function which generate token. | ||||||
|  | // String yourTokenGeneratorFunction() { ... } | ||||||
|  | //defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction); | ||||||
|  | // TODO Configure API key authorization: cookie | ||||||
|  | //defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY'; | ||||||
|  | // uncomment below to setup prefix (e.g. Bearer) for API key, if needed | ||||||
|  | //defaultApiClient.getAuthentication<ApiKeyAuth>('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<String> |  | ||||||
|  | final smartInfoPeriodTags = []; // List<String> |  | ||||||
|  | 
 | ||||||
|  | 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>**](String.md)|  | [optional] [default to const []] | ||||||
|  |  **smartInfoPeriodTags** | [**List<String>**](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) | ||||||
|  | 
 | ||||||
							
								
								
									
										18
									
								
								mobile/openapi/doc/SearchAssetResponseDto.md
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								mobile/openapi/doc/SearchAssetResponseDto.md
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -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>**](AssetResponseDto.md) |  | [default to const []] | ||||||
|  | **facets** | [**List<SearchFacetResponseDto>**](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) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
							
								
								
									
										15
									
								
								mobile/openapi/doc/SearchConfigResponseDto.md
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								mobile/openapi/doc/SearchConfigResponseDto.md
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -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) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
							
								
								
									
										16
									
								
								mobile/openapi/doc/SearchFacetCountResponseDto.md
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								mobile/openapi/doc/SearchFacetCountResponseDto.md
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -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) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
							
								
								
									
										16
									
								
								mobile/openapi/doc/SearchFacetResponseDto.md
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								mobile/openapi/doc/SearchFacetResponseDto.md
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -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>**](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) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
							
								
								
									
										16
									
								
								mobile/openapi/doc/SearchResponseDto.md
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								mobile/openapi/doc/SearchResponseDto.md
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -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) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
							
								
								
									
										7
									
								
								mobile/openapi/lib/api.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										7
									
								
								mobile/openapi/lib/api.dart
									
									
									
										generated
									
									
									
								
							| @@ -35,6 +35,7 @@ part 'api/authentication_api.dart'; | |||||||
| part 'api/device_info_api.dart'; | part 'api/device_info_api.dart'; | ||||||
| part 'api/job_api.dart'; | part 'api/job_api.dart'; | ||||||
| part 'api/o_auth_api.dart'; | part 'api/o_auth_api.dart'; | ||||||
|  | part 'api/search_api.dart'; | ||||||
| part 'api/server_info_api.dart'; | part 'api/server_info_api.dart'; | ||||||
| part 'api/share_api.dart'; | part 'api/share_api.dart'; | ||||||
| part 'api/system_config_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_dto.dart'; | ||||||
| part 'model/o_auth_config_response_dto.dart'; | part 'model/o_auth_config_response_dto.dart'; | ||||||
| part 'model/remove_assets_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_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_info_response_dto.dart'; | ||||||
| part 'model/server_ping_response.dart'; | part 'model/server_ping_response.dart'; | ||||||
| part 'model/server_stats_response_dto.dart'; | part 'model/server_stats_response_dto.dart'; | ||||||
|   | |||||||
							
								
								
									
										181
									
								
								mobile/openapi/lib/api/search_api.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										181
									
								
								mobile/openapi/lib/api/search_api.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -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<Response> getSearchConfigWithHttpInfo() async { | ||||||
|  |     // ignore: prefer_const_declarations | ||||||
|  |     final path = r'/search/config'; | ||||||
|  | 
 | ||||||
|  |     // ignore: prefer_final_locals | ||||||
|  |     Object? postBody; | ||||||
|  | 
 | ||||||
|  |     final queryParams = <QueryParam>[]; | ||||||
|  |     final headerParams = <String, String>{}; | ||||||
|  |     final formParams = <String, String>{}; | ||||||
|  | 
 | ||||||
|  |     const contentTypes = <String>[]; | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     return apiClient.invokeAPI( | ||||||
|  |       path, | ||||||
|  |       'GET', | ||||||
|  |       queryParams, | ||||||
|  |       postBody, | ||||||
|  |       headerParams, | ||||||
|  |       formParams, | ||||||
|  |       contentTypes.isEmpty ? null : contentTypes.first, | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   ///  | ||||||
|  |   Future<SearchConfigResponseDto?> 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<String>] smartInfoPeriodObjects: | ||||||
|  |   /// | ||||||
|  |   /// * [List<String>] smartInfoPeriodTags: | ||||||
|  |   Future<Response> searchWithHttpInfo({ String? query, String? type, bool? isFavorite, String? exifInfoPeriodCity, String? exifInfoPeriodState, String? exifInfoPeriodCountry, String? exifInfoPeriodMake, String? exifInfoPeriodModel, List<String>? smartInfoPeriodObjects, List<String>? smartInfoPeriodTags, }) async { | ||||||
|  |     // ignore: prefer_const_declarations | ||||||
|  |     final path = r'/search'; | ||||||
|  | 
 | ||||||
|  |     // ignore: prefer_final_locals | ||||||
|  |     Object? postBody; | ||||||
|  | 
 | ||||||
|  |     final queryParams = <QueryParam>[]; | ||||||
|  |     final headerParams = <String, String>{}; | ||||||
|  |     final formParams = <String, String>{}; | ||||||
|  | 
 | ||||||
|  |     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 = <String>[]; | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     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<String>] smartInfoPeriodObjects: | ||||||
|  |   /// | ||||||
|  |   /// * [List<String>] smartInfoPeriodTags: | ||||||
|  |   Future<SearchResponseDto?> search({ String? query, String? type, bool? isFavorite, String? exifInfoPeriodCity, String? exifInfoPeriodState, String? exifInfoPeriodCountry, String? exifInfoPeriodMake, String? exifInfoPeriodModel, List<String>? smartInfoPeriodObjects, List<String>? 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; | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										12
									
								
								mobile/openapi/lib/api_client.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										12
									
								
								mobile/openapi/lib/api_client.dart
									
									
									
										generated
									
									
									
								
							| @@ -294,8 +294,20 @@ class ApiClient { | |||||||
|           return OAuthConfigResponseDto.fromJson(value); |           return OAuthConfigResponseDto.fromJson(value); | ||||||
|         case 'RemoveAssetsDto': |         case 'RemoveAssetsDto': | ||||||
|           return RemoveAssetsDto.fromJson(value); |           return RemoveAssetsDto.fromJson(value); | ||||||
|  |         case 'SearchAlbumResponseDto': | ||||||
|  |           return SearchAlbumResponseDto.fromJson(value); | ||||||
|         case 'SearchAssetDto': |         case 'SearchAssetDto': | ||||||
|           return SearchAssetDto.fromJson(value); |           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': |         case 'ServerInfoResponseDto': | ||||||
|           return ServerInfoResponseDto.fromJson(value); |           return ServerInfoResponseDto.fromJson(value); | ||||||
|         case 'ServerPingResponse': |         case 'ServerPingResponse': | ||||||
|   | |||||||
							
								
								
									
										135
									
								
								mobile/openapi/lib/model/search_album_response_dto.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										135
									
								
								mobile/openapi/lib/model/search_album_response_dto.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -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<AlbumResponseDto> items; | ||||||
|  | 
 | ||||||
|  |   List<SearchFacetResponseDto> 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<String, dynamic> toJson() { | ||||||
|  |     final json = <String, dynamic>{}; | ||||||
|  |       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<String, dynamic>(); | ||||||
|  | 
 | ||||||
|  |       // 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<int>(json, r'total')!, | ||||||
|  |         count: mapValueOfType<int>(json, r'count')!, | ||||||
|  |         items: AlbumResponseDto.listFromJson(json[r'items'])!, | ||||||
|  |         facets: SearchFacetResponseDto.listFromJson(json[r'facets'])!, | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |     return null; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   static List<SearchAlbumResponseDto>? listFromJson(dynamic json, {bool growable = false,}) { | ||||||
|  |     final result = <SearchAlbumResponseDto>[]; | ||||||
|  |     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<String, SearchAlbumResponseDto> mapFromJson(dynamic json) { | ||||||
|  |     final map = <String, SearchAlbumResponseDto>{}; | ||||||
|  |     if (json is Map && json.isNotEmpty) { | ||||||
|  |       json = json.cast<String, dynamic>(); // 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<String, List<SearchAlbumResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) { | ||||||
|  |     final map = <String, List<SearchAlbumResponseDto>>{}; | ||||||
|  |     if (json is Map && json.isNotEmpty) { | ||||||
|  |       json = json.cast<String, dynamic>(); // 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 = <String>{ | ||||||
|  |     'total', | ||||||
|  |     'count', | ||||||
|  |     'items', | ||||||
|  |     'facets', | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
							
								
								
									
										135
									
								
								mobile/openapi/lib/model/search_asset_response_dto.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										135
									
								
								mobile/openapi/lib/model/search_asset_response_dto.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -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<AssetResponseDto> items; | ||||||
|  | 
 | ||||||
|  |   List<SearchFacetResponseDto> 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<String, dynamic> toJson() { | ||||||
|  |     final json = <String, dynamic>{}; | ||||||
|  |       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<String, dynamic>(); | ||||||
|  | 
 | ||||||
|  |       // 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<int>(json, r'total')!, | ||||||
|  |         count: mapValueOfType<int>(json, r'count')!, | ||||||
|  |         items: AssetResponseDto.listFromJson(json[r'items'])!, | ||||||
|  |         facets: SearchFacetResponseDto.listFromJson(json[r'facets'])!, | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |     return null; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   static List<SearchAssetResponseDto>? listFromJson(dynamic json, {bool growable = false,}) { | ||||||
|  |     final result = <SearchAssetResponseDto>[]; | ||||||
|  |     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<String, SearchAssetResponseDto> mapFromJson(dynamic json) { | ||||||
|  |     final map = <String, SearchAssetResponseDto>{}; | ||||||
|  |     if (json is Map && json.isNotEmpty) { | ||||||
|  |       json = json.cast<String, dynamic>(); // 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<String, List<SearchAssetResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) { | ||||||
|  |     final map = <String, List<SearchAssetResponseDto>>{}; | ||||||
|  |     if (json is Map && json.isNotEmpty) { | ||||||
|  |       json = json.cast<String, dynamic>(); // 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 = <String>{ | ||||||
|  |     'total', | ||||||
|  |     'count', | ||||||
|  |     'items', | ||||||
|  |     'facets', | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
							
								
								
									
										111
									
								
								mobile/openapi/lib/model/search_config_response_dto.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										111
									
								
								mobile/openapi/lib/model/search_config_response_dto.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -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<String, dynamic> toJson() { | ||||||
|  |     final json = <String, dynamic>{}; | ||||||
|  |       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<String, dynamic>(); | ||||||
|  | 
 | ||||||
|  |       // 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<bool>(json, r'enabled')!, | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |     return null; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   static List<SearchConfigResponseDto>? listFromJson(dynamic json, {bool growable = false,}) { | ||||||
|  |     final result = <SearchConfigResponseDto>[]; | ||||||
|  |     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<String, SearchConfigResponseDto> mapFromJson(dynamic json) { | ||||||
|  |     final map = <String, SearchConfigResponseDto>{}; | ||||||
|  |     if (json is Map && json.isNotEmpty) { | ||||||
|  |       json = json.cast<String, dynamic>(); // 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<String, List<SearchConfigResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) { | ||||||
|  |     final map = <String, List<SearchConfigResponseDto>>{}; | ||||||
|  |     if (json is Map && json.isNotEmpty) { | ||||||
|  |       json = json.cast<String, dynamic>(); // 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 = <String>{ | ||||||
|  |     'enabled', | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
							
								
								
									
										119
									
								
								mobile/openapi/lib/model/search_facet_count_response_dto.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										119
									
								
								mobile/openapi/lib/model/search_facet_count_response_dto.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -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<String, dynamic> toJson() { | ||||||
|  |     final json = <String, dynamic>{}; | ||||||
|  |       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<String, dynamic>(); | ||||||
|  | 
 | ||||||
|  |       // 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<int>(json, r'count')!, | ||||||
|  |         value: mapValueOfType<String>(json, r'value')!, | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |     return null; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   static List<SearchFacetCountResponseDto>? listFromJson(dynamic json, {bool growable = false,}) { | ||||||
|  |     final result = <SearchFacetCountResponseDto>[]; | ||||||
|  |     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<String, SearchFacetCountResponseDto> mapFromJson(dynamic json) { | ||||||
|  |     final map = <String, SearchFacetCountResponseDto>{}; | ||||||
|  |     if (json is Map && json.isNotEmpty) { | ||||||
|  |       json = json.cast<String, dynamic>(); // 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<String, List<SearchFacetCountResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) { | ||||||
|  |     final map = <String, List<SearchFacetCountResponseDto>>{}; | ||||||
|  |     if (json is Map && json.isNotEmpty) { | ||||||
|  |       json = json.cast<String, dynamic>(); // 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 = <String>{ | ||||||
|  |     'count', | ||||||
|  |     'value', | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
							
								
								
									
										119
									
								
								mobile/openapi/lib/model/search_facet_response_dto.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										119
									
								
								mobile/openapi/lib/model/search_facet_response_dto.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -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<SearchFacetCountResponseDto> 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<String, dynamic> toJson() { | ||||||
|  |     final json = <String, dynamic>{}; | ||||||
|  |       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<String, dynamic>(); | ||||||
|  | 
 | ||||||
|  |       // 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<String>(json, r'fieldName')!, | ||||||
|  |         counts: SearchFacetCountResponseDto.listFromJson(json[r'counts'])!, | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  |     return null; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   static List<SearchFacetResponseDto>? listFromJson(dynamic json, {bool growable = false,}) { | ||||||
|  |     final result = <SearchFacetResponseDto>[]; | ||||||
|  |     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<String, SearchFacetResponseDto> mapFromJson(dynamic json) { | ||||||
|  |     final map = <String, SearchFacetResponseDto>{}; | ||||||
|  |     if (json is Map && json.isNotEmpty) { | ||||||
|  |       json = json.cast<String, dynamic>(); // 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<String, List<SearchFacetResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) { | ||||||
|  |     final map = <String, List<SearchFacetResponseDto>>{}; | ||||||
|  |     if (json is Map && json.isNotEmpty) { | ||||||
|  |       json = json.cast<String, dynamic>(); // 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 = <String>{ | ||||||
|  |     'fieldName', | ||||||
|  |     'counts', | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
							
								
								
									
										119
									
								
								mobile/openapi/lib/model/search_response_dto.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										119
									
								
								mobile/openapi/lib/model/search_response_dto.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -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<String, dynamic> toJson() { | ||||||
|  |     final json = <String, dynamic>{}; | ||||||
|  |       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<String, dynamic>(); | ||||||
|  | 
 | ||||||
|  |       // 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<SearchResponseDto>? listFromJson(dynamic json, {bool growable = false,}) { | ||||||
|  |     final result = <SearchResponseDto>[]; | ||||||
|  |     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<String, SearchResponseDto> mapFromJson(dynamic json) { | ||||||
|  |     final map = <String, SearchResponseDto>{}; | ||||||
|  |     if (json is Map && json.isNotEmpty) { | ||||||
|  |       json = json.cast<String, dynamic>(); // 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<String, List<SearchResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) { | ||||||
|  |     final map = <String, List<SearchResponseDto>>{}; | ||||||
|  |     if (json is Map && json.isNotEmpty) { | ||||||
|  |       json = json.cast<String, dynamic>(); // 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 = <String>{ | ||||||
|  |     'albums', | ||||||
|  |     'assets', | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
							
								
								
									
										42
									
								
								mobile/openapi/test/search_album_response_dto_test.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								mobile/openapi/test/search_album_response_dto_test.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -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<AlbumResponseDto> items (default value: const []) | ||||||
|  |     test('to test the property `items`', () async { | ||||||
|  |       // TODO | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     // List<SearchFacetResponseDto> facets (default value: const []) | ||||||
|  |     test('to test the property `facets`', () async { | ||||||
|  |       // TODO | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  | } | ||||||
							
								
								
									
										35
									
								
								mobile/openapi/test/search_api_test.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								mobile/openapi/test/search_api_test.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -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<SearchConfigResponseDto> getSearchConfig() async | ||||||
|  |     test('test getSearchConfig', () async { | ||||||
|  |       // TODO | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     //  | ||||||
|  |     // | ||||||
|  |     //Future<SearchResponseDto> search({ String query, String type, bool isFavorite, String exifInfoPeriodCity, String exifInfoPeriodState, String exifInfoPeriodCountry, String exifInfoPeriodMake, String exifInfoPeriodModel, List<String> smartInfoPeriodObjects, List<String> smartInfoPeriodTags }) async | ||||||
|  |     test('test search', () async { | ||||||
|  |       // TODO | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |   }); | ||||||
|  | } | ||||||
							
								
								
									
										42
									
								
								mobile/openapi/test/search_asset_response_dto_test.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										42
									
								
								mobile/openapi/test/search_asset_response_dto_test.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -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<AssetResponseDto> items (default value: const []) | ||||||
|  |     test('to test the property `items`', () async { | ||||||
|  |       // TODO | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  |     // List<SearchFacetResponseDto> facets (default value: const []) | ||||||
|  |     test('to test the property `facets`', () async { | ||||||
|  |       // TODO | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  | } | ||||||
							
								
								
									
										27
									
								
								mobile/openapi/test/search_config_response_dto_test.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								mobile/openapi/test/search_config_response_dto_test.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -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 | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  | } | ||||||
							
								
								
									
										32
									
								
								mobile/openapi/test/search_facet_count_response_dto_test.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								mobile/openapi/test/search_facet_count_response_dto_test.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -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 | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  | } | ||||||
							
								
								
									
										32
									
								
								mobile/openapi/test/search_facet_response_dto_test.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								mobile/openapi/test/search_facet_response_dto_test.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -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<SearchFacetCountResponseDto> counts (default value: const []) | ||||||
|  |     test('to test the property `counts`', () async { | ||||||
|  |       // TODO | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  | } | ||||||
							
								
								
									
										32
									
								
								mobile/openapi/test/search_response_dto_test.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								mobile/openapi/test/search_response_dto_test.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -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 | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  | } | ||||||
| @@ -2,7 +2,7 @@ import { AlbumService } from './album.service'; | |||||||
| import { AuthUserDto } from '../../decorators/auth-user.decorator'; | import { AuthUserDto } from '../../decorators/auth-user.decorator'; | ||||||
| import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common'; | import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common'; | ||||||
| import { AlbumEntity, AssetEntity, UserEntity } from '@app/infra'; | 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 { AddAssetsResponseDto } from './response-dto/add-assets-response.dto'; | ||||||
| import { IAlbumRepository } from './album-repository'; | import { IAlbumRepository } from './album-repository'; | ||||||
| import { DownloadService } from '../../modules/download/download.service'; | import { DownloadService } from '../../modules/download/download.service'; | ||||||
| @@ -10,6 +10,7 @@ import { ISharedLinkRepository } from '@app/domain'; | |||||||
| import { | import { | ||||||
|   assetEntityStub, |   assetEntityStub, | ||||||
|   newCryptoRepositoryMock, |   newCryptoRepositoryMock, | ||||||
|  |   newJobRepositoryMock, | ||||||
|   newSharedLinkRepositoryMock, |   newSharedLinkRepositoryMock, | ||||||
|   userEntityStub, |   userEntityStub, | ||||||
| } from '@app/domain/../test'; | } from '@app/domain/../test'; | ||||||
| @@ -20,6 +21,7 @@ describe('Album service', () => { | |||||||
|   let sharedLinkRepositoryMock: jest.Mocked<ISharedLinkRepository>; |   let sharedLinkRepositoryMock: jest.Mocked<ISharedLinkRepository>; | ||||||
|   let downloadServiceMock: jest.Mocked<Partial<DownloadService>>; |   let downloadServiceMock: jest.Mocked<Partial<DownloadService>>; | ||||||
|   let cryptoMock: jest.Mocked<ICryptoRepository>; |   let cryptoMock: jest.Mocked<ICryptoRepository>; | ||||||
|  |   let jobMock: jest.Mocked<IJobRepository>; | ||||||
|  |  | ||||||
|   const authUser: AuthUserDto = Object.freeze({ |   const authUser: AuthUserDto = Object.freeze({ | ||||||
|     id: '1111', |     id: '1111', | ||||||
| @@ -139,12 +141,14 @@ describe('Album service', () => { | |||||||
|     }; |     }; | ||||||
|  |  | ||||||
|     cryptoMock = newCryptoRepositoryMock(); |     cryptoMock = newCryptoRepositoryMock(); | ||||||
|  |     jobMock = newJobRepositoryMock(); | ||||||
|  |  | ||||||
|     sut = new AlbumService( |     sut = new AlbumService( | ||||||
|       albumRepositoryMock, |       albumRepositoryMock, | ||||||
|       sharedLinkRepositoryMock, |       sharedLinkRepositoryMock, | ||||||
|       downloadServiceMock as DownloadService, |       downloadServiceMock as DownloadService, | ||||||
|       cryptoMock, |       cryptoMock, | ||||||
|  |       jobMock, | ||||||
|     ); |     ); | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
| @@ -158,6 +162,7 @@ describe('Album service', () => { | |||||||
|  |  | ||||||
|     expect(result.id).toEqual(albumEntity.id); |     expect(result.id).toEqual(albumEntity.id); | ||||||
|     expect(result.albumName).toEqual(albumEntity.albumName); |     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 () => { |   it('gets list of albums for auth user', async () => { | ||||||
| @@ -291,9 +296,8 @@ describe('Album service', () => { | |||||||
|     const updatedAlbumName = 'new album name'; |     const updatedAlbumName = 'new album name'; | ||||||
|     const updatedAlbumThumbnailAssetId = '69d2f917-0b31-48d8-9d7d-673b523f1aac'; |     const updatedAlbumThumbnailAssetId = '69d2f917-0b31-48d8-9d7d-673b523f1aac'; | ||||||
|     albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity)); |     albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity)); | ||||||
|     albumRepositoryMock.updateAlbum.mockImplementation(() => |     const updatedAlbum = { ...albumEntity, albumName: updatedAlbumName }; | ||||||
|       Promise.resolve<AlbumEntity>({ ...albumEntity, albumName: updatedAlbumName }), |     albumRepositoryMock.updateAlbum.mockResolvedValue(updatedAlbum); | ||||||
|     ); |  | ||||||
|  |  | ||||||
|     const result = await sut.updateAlbumInfo( |     const result = await sut.updateAlbumInfo( | ||||||
|       authUser, |       authUser, | ||||||
| @@ -311,6 +315,7 @@ describe('Album service', () => { | |||||||
|       albumName: updatedAlbumName, |       albumName: updatedAlbumName, | ||||||
|       albumThumbnailAssetId: updatedAlbumThumbnailAssetId, |       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 () => { |   it('prevents updating a not owned album (shared with auth user)', async () => { | ||||||
|   | |||||||
| @@ -6,7 +6,7 @@ import { AddUsersDto } from './dto/add-users.dto'; | |||||||
| import { RemoveAssetsDto } from './dto/remove-assets.dto'; | import { RemoveAssetsDto } from './dto/remove-assets.dto'; | ||||||
| import { UpdateAlbumDto } from './dto/update-album.dto'; | import { UpdateAlbumDto } from './dto/update-album.dto'; | ||||||
| import { GetAlbumsDto } from './dto/get-albums.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 { IAlbumRepository } from './album-repository'; | ||||||
| import { AlbumCountResponseDto } from './response-dto/album-count-response.dto'; | import { AlbumCountResponseDto } from './response-dto/album-count-response.dto'; | ||||||
| import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto'; | import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto'; | ||||||
| @@ -27,6 +27,7 @@ export class AlbumService { | |||||||
|     @Inject(ISharedLinkRepository) sharedLinkRepository: ISharedLinkRepository, |     @Inject(ISharedLinkRepository) sharedLinkRepository: ISharedLinkRepository, | ||||||
|     private downloadService: DownloadService, |     private downloadService: DownloadService, | ||||||
|     @Inject(ICryptoRepository) cryptoRepository: ICryptoRepository, |     @Inject(ICryptoRepository) cryptoRepository: ICryptoRepository, | ||||||
|  |     @Inject(IJobRepository) private jobRepository: IJobRepository, | ||||||
|   ) { |   ) { | ||||||
|     this.shareCore = new ShareCore(sharedLinkRepository, cryptoRepository); |     this.shareCore = new ShareCore(sharedLinkRepository, cryptoRepository); | ||||||
|   } |   } | ||||||
| @@ -56,6 +57,7 @@ export class AlbumService { | |||||||
|  |  | ||||||
|   async create(authUser: AuthUserDto, createAlbumDto: CreateAlbumDto): Promise<AlbumResponseDto> { |   async create(authUser: AuthUserDto, createAlbumDto: CreateAlbumDto): Promise<AlbumResponseDto> { | ||||||
|     const albumEntity = await this.albumRepository.create(authUser.id, createAlbumDto); |     const albumEntity = await this.albumRepository.create(authUser.id, createAlbumDto); | ||||||
|  |     await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ALBUM, data: { album: albumEntity } }); | ||||||
|     return mapAlbum(albumEntity); |     return mapAlbum(albumEntity); | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -105,6 +107,7 @@ export class AlbumService { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     await this.albumRepository.delete(album); |     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<void> { |   async removeUserFromAlbum(authUser: AuthUserDto, albumId: string, userId: string | 'me'): Promise<void> { | ||||||
| @@ -171,6 +174,9 @@ export class AlbumService { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     const updatedAlbum = await this.albumRepository.updateAlbum(album, updateAlbumDto); |     const updatedAlbum = await this.albumRepository.updateAlbum(album, updateAlbumDto); | ||||||
|  |  | ||||||
|  |     await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ALBUM, data: { album: updatedAlbum } }); | ||||||
|  |  | ||||||
|     return mapAlbum(updatedAlbum); |     return mapAlbum(updatedAlbum); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -252,7 +252,7 @@ export class AssetRepository implements IAssetRepository { | |||||||
|       where: { |       where: { | ||||||
|         id: assetId, |         id: assetId, | ||||||
|       }, |       }, | ||||||
|       relations: ['exifInfo', 'tags', 'sharedLinks'], |       relations: ['exifInfo', 'tags', 'sharedLinks', 'smartInfo'], | ||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -445,6 +445,8 @@ describe('AssetService', () => { | |||||||
|       ]); |       ]); | ||||||
|  |  | ||||||
|       expect(jobMock.queue.mock.calls).toEqual([ |       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, |             name: JobName.DELETE_FILES, | ||||||
|   | |||||||
| @@ -170,6 +170,8 @@ export class AssetService { | |||||||
|  |  | ||||||
|     const updatedAsset = await this._assetRepository.update(authUser.id, asset, dto); |     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); |     return mapAsset(updatedAsset); | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -425,6 +427,7 @@ export class AssetService { | |||||||
|  |  | ||||||
|       try { |       try { | ||||||
|         await this._assetRepository.remove(asset); |         await this._assetRepository.remove(asset); | ||||||
|  |         await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { id } }); | ||||||
|  |  | ||||||
|         result.push({ id, status: DeleteAssetStatusEnum.SUCCESS }); |         result.push({ id, status: DeleteAssetStatusEnum.SUCCESS }); | ||||||
|         deleteQueue.push(asset.originalPath, asset.webpPath, asset.resizePath); |         deleteQueue.push(asset.originalPath, asset.webpPath, asset.resizePath); | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| import { immichAppConfig } from '@app/common/config'; | 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 { AssetModule } from './api-v1/asset/asset.module'; | ||||||
| import { ConfigModule } from '@nestjs/config'; | import { ConfigModule } from '@nestjs/config'; | ||||||
| import { ServerInfoModule } from './api-v1/server-info/server-info.module'; | 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 { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.module'; | ||||||
| import { JobModule } from './api-v1/job/job.module'; | import { JobModule } from './api-v1/job/job.module'; | ||||||
| import { TagModule } from './api-v1/tag/tag.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 { InfraModule } from '@app/infra'; | ||||||
| import { | import { | ||||||
|   APIKeyController, |   APIKeyController, | ||||||
|   AuthController, |   AuthController, | ||||||
|   DeviceInfoController, |   DeviceInfoController, | ||||||
|   OAuthController, |   OAuthController, | ||||||
|  |   SearchController, | ||||||
|   ShareController, |   ShareController, | ||||||
|   SystemConfigController, |   SystemConfigController, | ||||||
|   UserController, |   UserController, | ||||||
| @@ -46,16 +47,21 @@ import { AuthGuard } from './middlewares/auth.guard'; | |||||||
|     TagModule, |     TagModule, | ||||||
|   ], |   ], | ||||||
|   controllers: [ |   controllers: [ | ||||||
|     // |  | ||||||
|     AppController, |     AppController, | ||||||
|     APIKeyController, |     APIKeyController, | ||||||
|     AuthController, |     AuthController, | ||||||
|     DeviceInfoController, |     DeviceInfoController, | ||||||
|     OAuthController, |     OAuthController, | ||||||
|  |     SearchController, | ||||||
|     ShareController, |     ShareController, | ||||||
|     SystemConfigController, |     SystemConfigController, | ||||||
|     UserController, |     UserController, | ||||||
|   ], |   ], | ||||||
|   providers: [{ provide: APP_GUARD, useExisting: AuthGuard }, AuthGuard], |   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(); | ||||||
|  |   } | ||||||
|  | } | ||||||
|   | |||||||
| @@ -2,6 +2,7 @@ export * from './api-key.controller'; | |||||||
| export * from './auth.controller'; | export * from './auth.controller'; | ||||||
| export * from './device-info.controller'; | export * from './device-info.controller'; | ||||||
| export * from './oauth.controller'; | export * from './oauth.controller'; | ||||||
|  | export * from './search.controller'; | ||||||
| export * from './share.controller'; | export * from './share.controller'; | ||||||
| export * from './system-config.controller'; | export * from './system-config.controller'; | ||||||
| export * from './user.controller'; | export * from './user.controller'; | ||||||
|   | |||||||
							
								
								
									
										27
									
								
								server/apps/immich/src/controllers/search.controller.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								server/apps/immich/src/controllers/search.controller.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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<SearchResponseDto> { | ||||||
|  |     return this.searchService.search(authUser, dto); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @Authenticated() | ||||||
|  |   @Get('config') | ||||||
|  |   getSearchConfig(): SearchConfigResponseDto { | ||||||
|  |     return this.searchService.getConfig(); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -11,7 +11,7 @@ import { RedisIoAdapter } from './middlewares/redis-io.adapter.middleware'; | |||||||
| import { json } from 'body-parser'; | import { json } from 'body-parser'; | ||||||
| import { patchOpenAPI } from './utils/patch-open-api.util'; | import { patchOpenAPI } from './utils/patch-open-api.util'; | ||||||
| import { getLogLevels, MACHINE_LEARNING_ENABLED } from '@app/common'; | 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'); | 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(`Machine learning is ${MACHINE_LEARNING_ENABLED ? 'enabled' : 'disabled'}`); | ||||||
|  |   logger.warn(`Search is ${searchService.isEnabled() ? 'enabled' : 'disabled'}`); | ||||||
| } | } | ||||||
| bootstrap(); | bootstrap(); | ||||||
|   | |||||||
| @@ -7,6 +7,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; | |||||||
| import { | import { | ||||||
|   BackgroundTaskProcessor, |   BackgroundTaskProcessor, | ||||||
|   MachineLearningProcessor, |   MachineLearningProcessor, | ||||||
|  |   SearchIndexProcessor, | ||||||
|   StorageTemplateMigrationProcessor, |   StorageTemplateMigrationProcessor, | ||||||
|   ThumbnailGeneratorProcessor, |   ThumbnailGeneratorProcessor, | ||||||
| } from './processors'; | } from './processors'; | ||||||
| @@ -26,6 +27,7 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor' | |||||||
|     MachineLearningProcessor, |     MachineLearningProcessor, | ||||||
|     StorageTemplateMigrationProcessor, |     StorageTemplateMigrationProcessor, | ||||||
|     BackgroundTaskProcessor, |     BackgroundTaskProcessor, | ||||||
|  |     SearchIndexProcessor, | ||||||
|   ], |   ], | ||||||
| }) | }) | ||||||
| export class MicroservicesModule {} | export class MicroservicesModule {} | ||||||
|   | |||||||
| @@ -1,12 +1,15 @@ | |||||||
| import { | import { | ||||||
|   AssetService, |   AssetService, | ||||||
|  |   IAlbumJob, | ||||||
|   IAssetJob, |   IAssetJob, | ||||||
|   IAssetUploadedJob, |   IAssetUploadedJob, | ||||||
|   IDeleteFilesJob, |   IDeleteFilesJob, | ||||||
|  |   IDeleteJob, | ||||||
|   IUserDeletionJob, |   IUserDeletionJob, | ||||||
|   JobName, |   JobName, | ||||||
|   MediaService, |   MediaService, | ||||||
|   QueueName, |   QueueName, | ||||||
|  |   SearchService, | ||||||
|   SmartInfoService, |   SmartInfoService, | ||||||
|   StorageService, |   StorageService, | ||||||
|   StorageTemplateService, |   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<IAlbumJob>) { | ||||||
|  |     await this.searchService.handleIndexAlbum(job.data); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @Process(JobName.SEARCH_INDEX_ASSET) | ||||||
|  |   async onIndexAsset(job: Job<IAssetJob>) { | ||||||
|  |     await this.searchService.handleIndexAsset(job.data); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @Process(JobName.SEARCH_REMOVE_ALBUM) | ||||||
|  |   async onRemoveAlbum(job: Job<IDeleteJob>) { | ||||||
|  |     await this.searchService.handleRemoveAlbum(job.data); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   @Process(JobName.SEARCH_REMOVE_ASSET) | ||||||
|  |   async onRemoveAsset(job: Job<IDeleteJob>) { | ||||||
|  |     await this.searchService.handleRemoveAsset(job.data); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
| @Processor(QueueName.STORAGE_TEMPLATE_MIGRATION) | @Processor(QueueName.STORAGE_TEMPLATE_MIGRATION) | ||||||
| export class StorageTemplateMigrationProcessor { | export class StorageTemplateMigrationProcessor { | ||||||
|   constructor(private storageTemplateService: StorageTemplateService) {} |   constructor(private storageTemplateService: StorageTemplateService) {} | ||||||
|   | |||||||
| @@ -1,18 +1,26 @@ | |||||||
|  | import { | ||||||
|  |   AssetCore, | ||||||
|  |   IAssetRepository, | ||||||
|  |   IAssetUploadedJob, | ||||||
|  |   IReverseGeocodingJob, | ||||||
|  |   ISearchRepository, | ||||||
|  |   JobName, | ||||||
|  |   QueueName, | ||||||
|  | } from '@app/domain'; | ||||||
| import { AssetEntity, AssetType, ExifEntity } from '@app/infra'; | import { AssetEntity, AssetType, ExifEntity } from '@app/infra'; | ||||||
| import { IReverseGeocodingJob, IAssetUploadedJob, QueueName, JobName, IAssetRepository } from '@app/domain'; |  | ||||||
| import { Process, Processor } from '@nestjs/bull'; | import { Process, Processor } from '@nestjs/bull'; | ||||||
| import { Inject, Logger } from '@nestjs/common'; | import { Inject, Logger } from '@nestjs/common'; | ||||||
| import { ConfigService } from '@nestjs/config'; | import { ConfigService } from '@nestjs/config'; | ||||||
| import { InjectRepository } from '@nestjs/typeorm'; | import { InjectRepository } from '@nestjs/typeorm'; | ||||||
| import { Job } from 'bull'; | import { Job } from 'bull'; | ||||||
|  | import { ExifDateTime, exiftool, Tags } from 'exiftool-vendored'; | ||||||
| import ffmpeg from 'fluent-ffmpeg'; | 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 path from 'path'; | ||||||
| import sharp from 'sharp'; | import sharp from 'sharp'; | ||||||
| import { Repository } from 'typeorm/repository/Repository'; | 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 { | interface ImmichTags extends Tags { | ||||||
|   ContentIdentifier?: string; |   ContentIdentifier?: string; | ||||||
| @@ -71,13 +79,19 @@ export type GeoData = { | |||||||
| export class MetadataExtractionProcessor { | export class MetadataExtractionProcessor { | ||||||
|   private logger = new Logger(MetadataExtractionProcessor.name); |   private logger = new Logger(MetadataExtractionProcessor.name); | ||||||
|   private isGeocodeInitialized = false; |   private isGeocodeInitialized = false; | ||||||
|  |   private assetCore: AssetCore; | ||||||
|  |  | ||||||
|   constructor( |   constructor( | ||||||
|     @Inject(IAssetRepository) private assetRepository: IAssetRepository, |     @Inject(IAssetRepository) assetRepository: IAssetRepository, | ||||||
|  |     @Inject(ISearchRepository) searchRepository: ISearchRepository, | ||||||
|  |  | ||||||
|     @InjectRepository(ExifEntity) |     @InjectRepository(ExifEntity) | ||||||
|     private exifRepository: Repository<ExifEntity>, |     private exifRepository: Repository<ExifEntity>, | ||||||
|  |  | ||||||
|     configService: ConfigService, |     configService: ConfigService, | ||||||
|   ) { |   ) { | ||||||
|  |     this.assetCore = new AssetCore(assetRepository, searchRepository); | ||||||
|  |  | ||||||
|     if (!configService.get('DISABLE_REVERSE_GEOCODING')) { |     if (!configService.get('DISABLE_REVERSE_GEOCODING')) { | ||||||
|       this.logger.log('Initializing Reverse Geocoding'); |       this.logger.log('Initializing Reverse Geocoding'); | ||||||
|       geocoderInit({ |       geocoderInit({ | ||||||
| @@ -175,20 +189,11 @@ export class MetadataExtractionProcessor { | |||||||
|       newExif.longitude = exifData?.GPSLongitude || null; |       newExif.longitude = exifData?.GPSLongitude || null; | ||||||
|       newExif.livePhotoCID = exifData?.MediaGroupUUID || null; |       newExif.livePhotoCID = exifData?.MediaGroupUUID || null; | ||||||
|  |  | ||||||
|       await this.assetRepository.save({ |  | ||||||
|         id: asset.id, |  | ||||||
|         fileCreatedAt: fileCreatedAt?.toISOString(), |  | ||||||
|       }); |  | ||||||
|  |  | ||||||
|       if (newExif.livePhotoCID && !asset.livePhotoVideoId) { |       if (newExif.livePhotoCID && !asset.livePhotoVideoId) { | ||||||
|         const motionAsset = await this.assetRepository.findLivePhotoMatch( |         const motionAsset = await this.assetCore.findLivePhotoMatch(newExif.livePhotoCID, asset.id, AssetType.VIDEO); | ||||||
|           newExif.livePhotoCID, |  | ||||||
|           asset.id, |  | ||||||
|           AssetType.VIDEO, |  | ||||||
|         ); |  | ||||||
|         if (motionAsset) { |         if (motionAsset) { | ||||||
|           await this.assetRepository.save({ id: asset.id, livePhotoVideoId: motionAsset.id }); |           await this.assetCore.save({ id: asset.id, livePhotoVideoId: motionAsset.id }); | ||||||
|           await this.assetRepository.save({ id: motionAsset.id, isVisible: false }); |           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.exifRepository.upsert(newExif, { conflictPaths: ['assetId'] }); | ||||||
|  |       await this.assetCore.save({ id: asset.id, fileCreatedAt: fileCreatedAt?.toISOString() }); | ||||||
|     } catch (error: any) { |     } catch (error: any) { | ||||||
|       this.logger.error(`Error extracting EXIF ${error}`, error?.stack); |       this.logger.error(`Error extracting EXIF ${error}`, error?.stack); | ||||||
|     } |     } | ||||||
| @@ -292,14 +298,10 @@ export class MetadataExtractionProcessor { | |||||||
|       newExif.livePhotoCID = exifData?.ContentIdentifier || null; |       newExif.livePhotoCID = exifData?.ContentIdentifier || null; | ||||||
|  |  | ||||||
|       if (newExif.livePhotoCID) { |       if (newExif.livePhotoCID) { | ||||||
|         const photoAsset = await this.assetRepository.findLivePhotoMatch( |         const photoAsset = await this.assetCore.findLivePhotoMatch(newExif.livePhotoCID, asset.id, AssetType.IMAGE); | ||||||
|           newExif.livePhotoCID, |  | ||||||
|           asset.id, |  | ||||||
|           AssetType.IMAGE, |  | ||||||
|         ); |  | ||||||
|         if (photoAsset) { |         if (photoAsset) { | ||||||
|           await this.assetRepository.save({ id: photoAsset.id, livePhotoVideoId: asset.id }); |           await this.assetCore.save({ id: photoAsset.id, livePhotoVideoId: asset.id }); | ||||||
|           await this.assetRepository.save({ id: asset.id, isVisible: false }); |           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.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) { |     } catch (err) { | ||||||
|       ``; |       ``; | ||||||
|       // do nothing |       // do nothing | ||||||
|   | |||||||
| @@ -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": { |     "/share": { | ||||||
|       "get": { |       "get": { | ||||||
|         "operationId": "getAllSharedLinks", |         "operationId": "getAllSharedLinks", | ||||||
| @@ -3554,13 +3719,6 @@ | |||||||
|           "url" |           "url" | ||||||
|         ] |         ] | ||||||
|       }, |       }, | ||||||
|       "SharedLinkType": { |  | ||||||
|         "type": "string", |  | ||||||
|         "enum": [ |  | ||||||
|           "ALBUM", |  | ||||||
|           "INDIVIDUAL" |  | ||||||
|         ] |  | ||||||
|       }, |  | ||||||
|       "AssetTypeEnum": { |       "AssetTypeEnum": { | ||||||
|         "type": "string", |         "type": "string", | ||||||
|         "enum": [ |         "enum": [ | ||||||
| @@ -3871,6 +4029,130 @@ | |||||||
|           "owner" |           "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": { |       "SharedLinkResponseDto": { | ||||||
|         "type": "object", |         "type": "object", | ||||||
|         "properties": { |         "properties": { | ||||||
|   | |||||||
| @@ -16,6 +16,11 @@ export const immichAppConfig: ConfigModuleOptions = { | |||||||
|     DB_PASSWORD: WHEN_DB_URL_SET, |     DB_PASSWORD: WHEN_DB_URL_SET, | ||||||
|     DB_DATABASE_NAME: WHEN_DB_URL_SET, |     DB_DATABASE_NAME: WHEN_DB_URL_SET, | ||||||
|     DB_URL: Joi.string().optional(), |     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), |     DISABLE_REVERSE_GEOCODING: Joi.boolean().optional().valid(true, false).default(false), | ||||||
|     REVERSE_GEOCODING_PRECISION: Joi.number().optional().valid(0, 1, 2, 3).default(3), |     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'), |     LOG_LEVEL: Joi.string().optional().valid('simple', 'verbose', 'debug', 'log', 'warn', 'error').default('log'), | ||||||
|   | |||||||
| @@ -1,5 +1,9 @@ | |||||||
|  | import { AlbumEntity } from '@app/infra/db/entities'; | ||||||
|  |  | ||||||
| export const IAlbumRepository = 'IAlbumRepository'; | export const IAlbumRepository = 'IAlbumRepository'; | ||||||
|  |  | ||||||
| export interface IAlbumRepository { | export interface IAlbumRepository { | ||||||
|   deleteAll(userId: string): Promise<void>; |   deleteAll(userId: string): Promise<void>; | ||||||
|  |   getAll(): Promise<AlbumEntity[]>; | ||||||
|  |   save(album: Partial<AlbumEntity>): Promise<AlbumEntity>; | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										21
									
								
								server/libs/domain/src/asset/asset.core.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								server/libs/domain/src/asset/asset.core.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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<AssetEntity>) { | ||||||
|  |     const _asset = await this.repository.save(asset); | ||||||
|  |     await this.searchRepository.index(SearchCollection.ASSETS, _asset); | ||||||
|  |     return _asset; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   findLivePhotoMatch(livePhotoCID: string, otherAssetId: string, type: AssetType): Promise<AssetEntity | null> { | ||||||
|  |     return this.repository.findLivePhotoMatch(livePhotoCID, otherAssetId, type); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -1,10 +1,14 @@ | |||||||
| import { AssetEntity, AssetType } from '@app/infra/db/entities'; | import { AssetEntity, AssetType } from '@app/infra/db/entities'; | ||||||
|  |  | ||||||
|  | export interface AssetSearchOptions { | ||||||
|  |   isVisible?: boolean; | ||||||
|  | } | ||||||
|  |  | ||||||
| export const IAssetRepository = 'IAssetRepository'; | export const IAssetRepository = 'IAssetRepository'; | ||||||
|  |  | ||||||
| export interface IAssetRepository { | export interface IAssetRepository { | ||||||
|   deleteAll(ownerId: string): Promise<void>; |   deleteAll(ownerId: string): Promise<void>; | ||||||
|   getAll(): Promise<AssetEntity[]>; |   getAll(options?: AssetSearchOptions): Promise<AssetEntity[]>; | ||||||
|   save(asset: Partial<AssetEntity>): Promise<AssetEntity>; |   save(asset: Partial<AssetEntity>): Promise<AssetEntity>; | ||||||
|   findLivePhotoMatch(livePhotoCID: string, otherAssetId: string, type: AssetType): Promise<AssetEntity | null>; |   findLivePhotoMatch(livePhotoCID: string, otherAssetId: string, type: AssetType): Promise<AssetEntity | null>; | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,19 +1,25 @@ | |||||||
| import { AssetEntity, AssetType } from '@app/infra/db/entities'; | import { AssetEntity, AssetType } from '@app/infra/db/entities'; | ||||||
| import { newJobRepositoryMock } from '../../test'; | import { assetEntityStub, newAssetRepositoryMock, newJobRepositoryMock } from '../../test'; | ||||||
| import { AssetService } from '../asset'; | import { newSearchRepositoryMock } from '../../test/search.repository.mock'; | ||||||
|  | import { AssetService, IAssetRepository } from '../asset'; | ||||||
| import { IJobRepository, JobName } from '../job'; | import { IJobRepository, JobName } from '../job'; | ||||||
|  | import { ISearchRepository } from '../search'; | ||||||
|  |  | ||||||
| describe(AssetService.name, () => { | describe(AssetService.name, () => { | ||||||
|   let sut: AssetService; |   let sut: AssetService; | ||||||
|  |   let assetMock: jest.Mocked<IAssetRepository>; | ||||||
|   let jobMock: jest.Mocked<IJobRepository>; |   let jobMock: jest.Mocked<IJobRepository>; | ||||||
|  |   let searchMock: jest.Mocked<ISearchRepository>; | ||||||
|  |  | ||||||
|   it('should work', () => { |   it('should work', () => { | ||||||
|     expect(sut).toBeDefined(); |     expect(sut).toBeDefined(); | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   beforeEach(async () => { |   beforeEach(async () => { | ||||||
|  |     assetMock = newAssetRepositoryMock(); | ||||||
|     jobMock = newJobRepositoryMock(); |     jobMock = newJobRepositoryMock(); | ||||||
|     sut = new AssetService(jobMock); |     searchMock = newSearchRepositoryMock(); | ||||||
|  |     sut = new AssetService(assetMock, jobMock, searchMock); | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   describe(`handle asset upload`, () => { |   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); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
| }); | }); | ||||||
|   | |||||||
| @@ -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 { Inject } from '@nestjs/common'; | ||||||
| import { IAssetUploadedJob, IJobRepository, JobName } from '../job'; | import { IAssetUploadedJob, IJobRepository, JobName } from '../job'; | ||||||
|  | import { ISearchRepository } from '../search'; | ||||||
|  | import { AssetCore } from './asset.core'; | ||||||
|  | import { IAssetRepository } from './asset.repository'; | ||||||
|  |  | ||||||
| export class AssetService { | 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) { |   async handleAssetUpload(data: IAssetUploadedJob) { | ||||||
|     await this.jobRepository.queue({ name: JobName.GENERATE_JPEG_THUMBNAIL, data }); |     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 }); |       await this.jobRepository.queue({ name: JobName.EXIF_EXTRACTION, data }); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   save(asset: Partial<AssetEntity>) { | ||||||
|  |     return this.assetCore.save(asset); | ||||||
|  |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,3 +1,4 @@ | |||||||
|  | export * from './asset.core'; | ||||||
| export * from './asset.repository'; | export * from './asset.repository'; | ||||||
| export * from './asset.service'; | export * from './asset.service'; | ||||||
| export * from './response-dto'; | export * from './response-dto'; | ||||||
|   | |||||||
| @@ -5,6 +5,7 @@ import { AuthService } from './auth'; | |||||||
| import { DeviceInfoService } from './device-info'; | import { DeviceInfoService } from './device-info'; | ||||||
| import { MediaService } from './media'; | import { MediaService } from './media'; | ||||||
| import { OAuthService } from './oauth'; | import { OAuthService } from './oauth'; | ||||||
|  | import { SearchService } from './search'; | ||||||
| import { ShareService } from './share'; | import { ShareService } from './share'; | ||||||
| import { SmartInfoService } from './smart-info'; | import { SmartInfoService } from './smart-info'; | ||||||
| import { StorageService } from './storage'; | import { StorageService } from './storage'; | ||||||
| @@ -25,6 +26,7 @@ const providers: Provider[] = [ | |||||||
|   SystemConfigService, |   SystemConfigService, | ||||||
|   UserService, |   UserService, | ||||||
|   ShareService, |   ShareService, | ||||||
|  |   SearchService, | ||||||
|   { |   { | ||||||
|     provide: INITIAL_SYSTEM_CONFIG, |     provide: INITIAL_SYSTEM_CONFIG, | ||||||
|     inject: [SystemConfigService], |     inject: [SystemConfigService], | ||||||
|   | |||||||
| @@ -9,6 +9,7 @@ export * from './domain.module'; | |||||||
| export * from './job'; | export * from './job'; | ||||||
| export * from './media'; | export * from './media'; | ||||||
| export * from './oauth'; | export * from './oauth'; | ||||||
|  | export * from './search'; | ||||||
| export * from './share'; | export * from './share'; | ||||||
| export * from './smart-info'; | export * from './smart-info'; | ||||||
| export * from './storage'; | export * from './storage'; | ||||||
|   | |||||||
| @@ -5,6 +5,7 @@ export enum QueueName { | |||||||
|   MACHINE_LEARNING = 'machine-learning-queue', |   MACHINE_LEARNING = 'machine-learning-queue', | ||||||
|   BACKGROUND_TASK = 'background-task', |   BACKGROUND_TASK = 'background-task', | ||||||
|   STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration-queue', |   STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration-queue', | ||||||
|  |   SEARCH = 'search-queue', | ||||||
| } | } | ||||||
|  |  | ||||||
| export enum JobName { | export enum JobName { | ||||||
| @@ -22,4 +23,10 @@ export enum JobName { | |||||||
|   OBJECT_DETECTION = 'detect-object', |   OBJECT_DETECTION = 'detect-object', | ||||||
|   IMAGE_TAGGING = 'tag-image', |   IMAGE_TAGGING = 'tag-image', | ||||||
|   DELETE_FILES = 'delete-files', |   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', | ||||||
| } | } | ||||||
|   | |||||||
| @@ -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 { | export interface IAssetJob { | ||||||
|   asset: AssetEntity; |   asset: AssetEntity; | ||||||
| @@ -9,6 +13,10 @@ export interface IAssetUploadedJob { | |||||||
|   fileName: string; |   fileName: string; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | export interface IDeleteJob { | ||||||
|  |   id: string; | ||||||
|  | } | ||||||
|  |  | ||||||
| export interface IDeleteFilesJob { | export interface IDeleteFilesJob { | ||||||
|   files: Array<string | null | undefined>; |   files: Array<string | null | undefined>; | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,5 +1,13 @@ | |||||||
| import { JobName, QueueName } from './job.constants'; | 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 { | export interface JobCounts { | ||||||
|   active: number; |   active: number; | ||||||
| @@ -23,7 +31,13 @@ export type JobItem = | |||||||
|   | { name: JobName.EXTRACT_VIDEO_METADATA; data: IAssetUploadedJob } |   | { name: JobName.EXTRACT_VIDEO_METADATA; data: IAssetUploadedJob } | ||||||
|   | { name: JobName.OBJECT_DETECTION; data: IAssetJob } |   | { name: JobName.OBJECT_DETECTION; data: IAssetJob } | ||||||
|   | { name: JobName.IMAGE_TAGGING; 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'; | export const IJobRepository = 'IJobRepository'; | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										1
									
								
								server/libs/domain/src/search/dto/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								server/libs/domain/src/search/dto/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | |||||||
|  | export * from './search.dto'; | ||||||
							
								
								
									
										57
									
								
								server/libs/domain/src/search/dto/search.dto.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								server/libs/domain/src/search/dto/search.dto.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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[]; | ||||||
|  | } | ||||||
							
								
								
									
										4
									
								
								server/libs/domain/src/search/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								server/libs/domain/src/search/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | |||||||
|  | export * from './dto'; | ||||||
|  | export * from './response-dto'; | ||||||
|  | export * from './search.repository'; | ||||||
|  | export * from './search.service'; | ||||||
							
								
								
									
										2
									
								
								server/libs/domain/src/search/response-dto/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								server/libs/domain/src/search/response-dto/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | |||||||
|  | export * from './search-config-response.dto'; | ||||||
|  | export * from './search-response.dto'; | ||||||
| @@ -0,0 +1,3 @@ | |||||||
|  | export class SearchConfigResponseDto { | ||||||
|  |   enabled!: boolean; | ||||||
|  | } | ||||||
| @@ -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; | ||||||
|  | } | ||||||
							
								
								
									
										60
									
								
								server/libs/domain/src/search/search.repository.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								server/libs/domain/src/search/search.repository.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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<T> { | ||||||
|  |   /** 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<SearchCollection, boolean>; | ||||||
|  |  | ||||||
|  | export const ISearchRepository = 'ISearchRepository'; | ||||||
|  |  | ||||||
|  | export interface ISearchRepository { | ||||||
|  |   setup(): Promise<void>; | ||||||
|  |   checkMigrationStatus(): Promise<SearchCollectionIndexStatus>; | ||||||
|  |  | ||||||
|  |   index(collection: SearchCollection.ASSETS, item: AssetEntity): Promise<void>; | ||||||
|  |   index(collection: SearchCollection.ALBUMS, item: AlbumEntity): Promise<void>; | ||||||
|  |  | ||||||
|  |   delete(collection: SearchCollection, id: string): Promise<void>; | ||||||
|  |  | ||||||
|  |   import(collection: SearchCollection.ASSETS, items: AssetEntity[], done: boolean): Promise<void>; | ||||||
|  |   import(collection: SearchCollection.ALBUMS, items: AlbumEntity[], done: boolean): Promise<void>; | ||||||
|  |  | ||||||
|  |   search(collection: SearchCollection.ASSETS, query: string, filters: SearchFilter): Promise<SearchResult<AssetEntity>>; | ||||||
|  |   search(collection: SearchCollection.ALBUMS, query: string, filters: SearchFilter): Promise<SearchResult<AlbumEntity>>; | ||||||
|  | } | ||||||
							
								
								
									
										317
									
								
								server/libs/domain/src/search/search.service.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										317
									
								
								server/libs/domain/src/search/search.service.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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<IAlbumRepository>; | ||||||
|  |   let assetMock: jest.Mocked<IAssetRepository>; | ||||||
|  |   let jobMock: jest.Mocked<IJobRepository>; | ||||||
|  |   let searchMock: jest.Mocked<ISearchRepository>; | ||||||
|  |   let configMock: jest.Mocked<ConfigService>; | ||||||
|  |  | ||||||
|  |   beforeEach(() => { | ||||||
|  |     albumMock = newAlbumRepositoryMock(); | ||||||
|  |     assetMock = newAssetRepositoryMock(); | ||||||
|  |     jobMock = newJobRepositoryMock(); | ||||||
|  |     searchMock = newSearchRepositoryMock(); | ||||||
|  |     configMock = { get: jest.fn() } as unknown as jest.Mocked<ConfigService>; | ||||||
|  |  | ||||||
|  |     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(); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | }); | ||||||
							
								
								
									
										154
									
								
								server/libs/domain/src/search/search.service.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										154
									
								
								server/libs/domain/src/search/search.service.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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<SearchResponseDto> { | ||||||
|  |     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); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -3,5 +3,7 @@ import { IAlbumRepository } from '../src'; | |||||||
| export const newAlbumRepositoryMock = (): jest.Mocked<IAlbumRepository> => { | export const newAlbumRepositoryMock = (): jest.Mocked<IAlbumRepository> => { | ||||||
|   return { |   return { | ||||||
|     deleteAll: jest.fn(), |     deleteAll: jest.fn(), | ||||||
|  |     getAll: jest.fn(), | ||||||
|  |     save: jest.fn(), | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -1,4 +1,5 @@ | |||||||
| import { | import { | ||||||
|  |   AlbumEntity, | ||||||
|   APIKeyEntity, |   APIKeyEntity, | ||||||
|   AssetEntity, |   AssetEntity, | ||||||
|   AssetType, |   AssetType, | ||||||
| @@ -155,6 +156,21 @@ export const assetEntityStub = { | |||||||
|   } as AssetEntity), |   } as AssetEntity), | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | export const albumStub = { | ||||||
|  |   empty: Object.freeze<AlbumEntity>({ | ||||||
|  |     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 = { | const assetInfo: ExifResponseDto = { | ||||||
|   make: 'camera-make', |   make: 'camera-make', | ||||||
|   model: 'camera-model', |   model: 'camera-model', | ||||||
|   | |||||||
| @@ -6,6 +6,7 @@ export * from './device-info.repository.mock'; | |||||||
| export * from './fixtures'; | export * from './fixtures'; | ||||||
| export * from './job.repository.mock'; | export * from './job.repository.mock'; | ||||||
| export * from './machine-learning.repository.mock'; | export * from './machine-learning.repository.mock'; | ||||||
|  | export * from './search.repository.mock'; | ||||||
| export * from './shared-link.repository.mock'; | export * from './shared-link.repository.mock'; | ||||||
| export * from './smart-info.repository.mock'; | export * from './smart-info.repository.mock'; | ||||||
| export * from './storage.repository.mock'; | export * from './storage.repository.mock'; | ||||||
|   | |||||||
							
								
								
									
										12
									
								
								server/libs/domain/test/search.repository.mock.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								server/libs/domain/test/search.repository.mock.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | |||||||
|  | import { ISearchRepository } from '../src'; | ||||||
|  |  | ||||||
|  | export const newSearchRepositoryMock = (): jest.Mocked<ISearchRepository> => { | ||||||
|  |   return { | ||||||
|  |     setup: jest.fn(), | ||||||
|  |     checkMigrationStatus: jest.fn(), | ||||||
|  |     index: jest.fn(), | ||||||
|  |     import: jest.fn(), | ||||||
|  |     search: jest.fn(), | ||||||
|  |     delete: jest.fn(), | ||||||
|  |   }; | ||||||
|  | }; | ||||||
| @@ -11,4 +11,13 @@ export class AlbumRepository implements IAlbumRepository { | |||||||
|   async deleteAll(userId: string): Promise<void> { |   async deleteAll(userId: string): Promise<void> { | ||||||
|     await this.repository.delete({ ownerId: userId }); |     await this.repository.delete({ ownerId: userId }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   getAll(): Promise<AlbumEntity[]> { | ||||||
|  |     return this.repository.find(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   async save(album: Partial<AlbumEntity>) { | ||||||
|  |     const { id } = await this.repository.save(album); | ||||||
|  |     return this.repository.findOneOrFail({ where: { id } }); | ||||||
|  |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,4 +1,4 @@ | |||||||
| import { IAssetRepository } from '@app/domain'; | import { AssetSearchOptions, IAssetRepository } from '@app/domain'; | ||||||
| import { Injectable } from '@nestjs/common'; | import { Injectable } from '@nestjs/common'; | ||||||
| import { InjectRepository } from '@nestjs/typeorm'; | import { InjectRepository } from '@nestjs/typeorm'; | ||||||
| import { Not, Repository } from 'typeorm'; | import { Not, Repository } from 'typeorm'; | ||||||
| @@ -12,13 +12,32 @@ export class AssetRepository implements IAssetRepository { | |||||||
|     await this.repository.delete({ ownerId }); |     await this.repository.delete({ ownerId }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   async getAll(): Promise<AssetEntity[]> { |   getAll(options?: AssetSearchOptions | undefined): Promise<AssetEntity[]> { | ||||||
|     return this.repository.find({ relations: { exifInfo: true } }); |     options = options || {}; | ||||||
|  |  | ||||||
|  |     return this.repository.find({ | ||||||
|  |       where: { | ||||||
|  |         isVisible: options.isVisible, | ||||||
|  |       }, | ||||||
|  |       relations: { | ||||||
|  |         exifInfo: true, | ||||||
|  |         smartInfo: true, | ||||||
|  |         tags: true, | ||||||
|  |       }, | ||||||
|  |     }); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   async save(asset: Partial<AssetEntity>): Promise<AssetEntity> { |   async save(asset: Partial<AssetEntity>): Promise<AssetEntity> { | ||||||
|     const { id } = await this.repository.save(asset); |     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<AssetEntity | null> { |   findLivePhotoMatch(livePhotoCID: string, otherAssetId: string, type: AssetType): Promise<AssetEntity | null> { | ||||||
|   | |||||||
| @@ -8,6 +8,7 @@ import { | |||||||
|   IKeyRepository, |   IKeyRepository, | ||||||
|   IMachineLearningRepository, |   IMachineLearningRepository, | ||||||
|   IMediaRepository, |   IMediaRepository, | ||||||
|  |   ISearchRepository, | ||||||
|   ISharedLinkRepository, |   ISharedLinkRepository, | ||||||
|   ISmartInfoRepository, |   ISmartInfoRepository, | ||||||
|   IStorageRepository, |   IStorageRepository, | ||||||
| @@ -45,6 +46,7 @@ import { | |||||||
| import { JobRepository } from './job'; | import { JobRepository } from './job'; | ||||||
| import { MachineLearningRepository } from './machine-learning'; | import { MachineLearningRepository } from './machine-learning'; | ||||||
| import { MediaRepository } from './media'; | import { MediaRepository } from './media'; | ||||||
|  | import { TypesenseRepository } from './search'; | ||||||
| import { FilesystemProvider } from './storage'; | import { FilesystemProvider } from './storage'; | ||||||
|  |  | ||||||
| const providers: Provider[] = [ | const providers: Provider[] = [ | ||||||
| @@ -52,12 +54,12 @@ const providers: Provider[] = [ | |||||||
|   { provide: IAssetRepository, useClass: AssetRepository }, |   { provide: IAssetRepository, useClass: AssetRepository }, | ||||||
|   { provide: ICommunicationRepository, useClass: CommunicationRepository }, |   { provide: ICommunicationRepository, useClass: CommunicationRepository }, | ||||||
|   { provide: ICryptoRepository, useClass: CryptoRepository }, |   { provide: ICryptoRepository, useClass: CryptoRepository }, | ||||||
|   { provide: ICryptoRepository, useClass: CryptoRepository }, |  | ||||||
|   { provide: IDeviceInfoRepository, useClass: DeviceInfoRepository }, |   { provide: IDeviceInfoRepository, useClass: DeviceInfoRepository }, | ||||||
|   { provide: IKeyRepository, useClass: APIKeyRepository }, |   { provide: IKeyRepository, useClass: APIKeyRepository }, | ||||||
|   { provide: IJobRepository, useClass: JobRepository }, |   { provide: IJobRepository, useClass: JobRepository }, | ||||||
|   { provide: IMachineLearningRepository, useClass: MachineLearningRepository }, |   { provide: IMachineLearningRepository, useClass: MachineLearningRepository }, | ||||||
|   { provide: IMediaRepository, useClass: MediaRepository }, |   { provide: IMediaRepository, useClass: MediaRepository }, | ||||||
|  |   { provide: ISearchRepository, useClass: TypesenseRepository }, | ||||||
|   { provide: ISharedLinkRepository, useClass: SharedLinkRepository }, |   { provide: ISharedLinkRepository, useClass: SharedLinkRepository }, | ||||||
|   { provide: ISmartInfoRepository, useClass: SmartInfoRepository }, |   { provide: ISmartInfoRepository, useClass: SmartInfoRepository }, | ||||||
|   { provide: IStorageRepository, useClass: FilesystemProvider }, |   { provide: IStorageRepository, useClass: FilesystemProvider }, | ||||||
|   | |||||||
| @@ -13,6 +13,7 @@ export class JobRepository implements IJobRepository { | |||||||
|     @InjectQueue(QueueName.STORAGE_TEMPLATE_MIGRATION) private storageTemplateMigration: Queue, |     @InjectQueue(QueueName.STORAGE_TEMPLATE_MIGRATION) private storageTemplateMigration: Queue, | ||||||
|     @InjectQueue(QueueName.THUMBNAIL_GENERATION) private thumbnail: Queue, |     @InjectQueue(QueueName.THUMBNAIL_GENERATION) private thumbnail: Queue, | ||||||
|     @InjectQueue(QueueName.VIDEO_CONVERSION) private videoTranscode: Queue<IAssetJob>, |     @InjectQueue(QueueName.VIDEO_CONVERSION) private videoTranscode: Queue<IAssetJob>, | ||||||
|  |     @InjectQueue(QueueName.SEARCH) private searchIndex: Queue, | ||||||
|   ) {} |   ) {} | ||||||
|  |  | ||||||
|   async isActive(name: QueueName): Promise<boolean> { |   async isActive(name: QueueName): Promise<boolean> { | ||||||
| @@ -70,6 +71,18 @@ export class JobRepository implements IJobRepository { | |||||||
|         await this.videoTranscode.add(item.name, item.data); |         await this.videoTranscode.add(item.name, item.data); | ||||||
|         break; |         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: |       default: | ||||||
|         // TODO inject remaining queues and map job to queue |         // TODO inject remaining queues and map job to queue | ||||||
|         this.logger.error('Invalid job', item); |         this.logger.error('Invalid job', item); | ||||||
|   | |||||||
							
								
								
									
										1
									
								
								server/libs/infra/src/search/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								server/libs/infra/src/search/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | |||||||
|  | export * from './typesense.repository'; | ||||||
							
								
								
									
										13
									
								
								server/libs/infra/src/search/schemas/album.schema.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								server/libs/infra/src/search/schemas/album.schema.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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', | ||||||
|  | }; | ||||||
							
								
								
									
										37
									
								
								server/libs/infra/src/search/schemas/asset.schema.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								server/libs/infra/src/search/schemas/asset.schema.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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', | ||||||
|  | }; | ||||||
							
								
								
									
										325
									
								
								server/libs/infra/src/search/typesense.repository.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										325
									
								
								server/libs/infra/src/search/typesense.repository.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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<T extends Dictionary<any>>(item: T): Partial<T> { | ||||||
|  |   _.forOwn(item, (value, key) => { | ||||||
|  |     if (_.isNil(value) || (_.isObject(value) && !_.isDate(value) && _.isEmpty(removeNil(value)))) { | ||||||
|  |       delete item[key]; | ||||||
|  |     } | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   return item; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const schemaMap: Record<SearchCollection, CollectionCreateSchema> = { | ||||||
|  |   [SearchCollection.ASSETS]: assetSchema, | ||||||
|  |   [SearchCollection.ALBUMS]: albumSchema, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const schemas = Object.entries(schemaMap) as [SearchCollection, CollectionCreateSchema][]; | ||||||
|  |  | ||||||
|  | interface SearchUpdateQueue<T = any> { | ||||||
|  |   upsert: T[]; | ||||||
|  |   delete: string[]; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @Injectable() | ||||||
|  | export class TypesenseRepository implements ISearchRepository { | ||||||
|  |   private logger = new Logger(TypesenseRepository.name); | ||||||
|  |   private queue: Record<SearchCollection, SearchUpdateQueue> = { | ||||||
|  |     [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<void> { | ||||||
|  |     // 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<SearchCollectionIndexStatus> { | ||||||
|  |     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<void> { | ||||||
|  |     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<void> { | ||||||
|  |     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<void> { | ||||||
|  |     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<SearchResult<AssetEntity>>; | ||||||
|  |   search(collection: SearchCollection.ALBUMS, query: string, filter: SearchFilter): Promise<SearchResult<AlbumEntity>>; | ||||||
|  |   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<AssetEntity>(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<AlbumEntity>(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<T extends DocumentSchema>(results: SearchResponse<T>): SearchResult<T> { | ||||||
|  |     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; | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										73
									
								
								server/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										73
									
								
								server/package-lock.json
									
									
									
										generated
									
									
									
								
							| @@ -6,9 +6,10 @@ | |||||||
|   "packages": { |   "packages": { | ||||||
|     "": { |     "": { | ||||||
|       "name": "immich", |       "name": "immich", | ||||||
|       "version": "1.49.0", |       "version": "1.50.1", | ||||||
|       "license": "UNLICENSED", |       "license": "UNLICENSED", | ||||||
|       "dependencies": { |       "dependencies": { | ||||||
|  |         "@babel/runtime": "^7.20.13", | ||||||
|         "@nestjs/bull": "^0.6.2", |         "@nestjs/bull": "^0.6.2", | ||||||
|         "@nestjs/common": "^9.2.1", |         "@nestjs/common": "^9.2.1", | ||||||
|         "@nestjs/config": "^2.2.0", |         "@nestjs/config": "^2.2.0", | ||||||
| @@ -46,7 +47,8 @@ | |||||||
|         "rxjs": "^7.2.0", |         "rxjs": "^7.2.0", | ||||||
|         "sanitize-filename": "^1.6.3", |         "sanitize-filename": "^1.6.3", | ||||||
|         "sharp": "^0.28.0", |         "sharp": "^0.28.0", | ||||||
|         "typeorm": "^0.3.11" |         "typeorm": "^0.3.11", | ||||||
|  |         "typesense": "^1.5.2" | ||||||
|       }, |       }, | ||||||
|       "bin": { |       "bin": { | ||||||
|         "immich": "bin/cli.sh" |         "immich": "bin/cli.sh" | ||||||
| @@ -765,6 +767,17 @@ | |||||||
|         "@babel/core": "^7.0.0-0" |         "@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": { |     "node_modules/@babel/template": { | ||||||
|       "version": "7.16.7", |       "version": "7.16.7", | ||||||
|       "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.16.7.tgz", |       "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.16.7.tgz", | ||||||
| @@ -8104,6 +8117,18 @@ | |||||||
|         "url": "https://github.com/sponsors/sindresorhus" |         "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": { |     "node_modules/lru-cache": { | ||||||
|       "version": "6.0.0", |       "version": "6.0.0", | ||||||
|       "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", |       "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", |       "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz", | ||||||
|       "integrity": "sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==" |       "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": { |     "node_modules/regexpp": { | ||||||
|       "version": "3.2.0", |       "version": "3.2.0", | ||||||
|       "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", |       "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", | ||||||
| @@ -11106,6 +11136,18 @@ | |||||||
|         "node": ">=4.2.0" |         "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": { |     "node_modules/uglify-js": { | ||||||
|       "version": "3.17.4", |       "version": "3.17.4", | ||||||
|       "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz", |       "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/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": { |     "@babel/template": { | ||||||
|       "version": "7.16.7", |       "version": "7.16.7", | ||||||
|       "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.16.7.tgz", |       "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.16.7.tgz", | ||||||
| @@ -17808,6 +17858,11 @@ | |||||||
|         "is-unicode-supported": "^0.1.0" |         "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": { |     "lru-cache": { | ||||||
|       "version": "6.0.0", |       "version": "6.0.0", | ||||||
|       "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", |       "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", |       "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz", | ||||||
|       "integrity": "sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==" |       "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": { |     "regexpp": { | ||||||
|       "version": "3.2.0", |       "version": "3.2.0", | ||||||
|       "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", |       "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", | ||||||
| @@ -19962,6 +20022,15 @@ | |||||||
|       "integrity": "sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==", |       "integrity": "sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==", | ||||||
|       "devOptional": true |       "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": { |     "uglify-js": { | ||||||
|       "version": "3.17.4", |       "version": "3.17.4", | ||||||
|       "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz", |       "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz", | ||||||
|   | |||||||
| @@ -39,6 +39,7 @@ | |||||||
|     "api:generate": "bash ./bin/generate-open-api.sh" |     "api:generate": "bash ./bin/generate-open-api.sh" | ||||||
|   }, |   }, | ||||||
|   "dependencies": { |   "dependencies": { | ||||||
|  |     "@babel/runtime": "^7.20.13", | ||||||
|     "@nestjs/bull": "^0.6.2", |     "@nestjs/bull": "^0.6.2", | ||||||
|     "@nestjs/common": "^9.2.1", |     "@nestjs/common": "^9.2.1", | ||||||
|     "@nestjs/config": "^2.2.0", |     "@nestjs/config": "^2.2.0", | ||||||
| @@ -76,7 +77,8 @@ | |||||||
|     "rxjs": "^7.2.0", |     "rxjs": "^7.2.0", | ||||||
|     "sanitize-filename": "^1.6.3", |     "sanitize-filename": "^1.6.3", | ||||||
|     "sharp": "^0.28.0", |     "sharp": "^0.28.0", | ||||||
|     "typeorm": "^0.3.11" |     "typeorm": "^0.3.11", | ||||||
|  |     "typesense": "^1.5.2" | ||||||
|   }, |   }, | ||||||
|   "devDependencies": { |   "devDependencies": { | ||||||
|     "@nestjs/cli": "^9.1.8", |     "@nestjs/cli": "^9.1.8", | ||||||
|   | |||||||
| @@ -8,6 +8,7 @@ import { | |||||||
| 	DeviceInfoApi, | 	DeviceInfoApi, | ||||||
| 	JobApi, | 	JobApi, | ||||||
| 	OAuthApi, | 	OAuthApi, | ||||||
|  | 	SearchApi, | ||||||
| 	ServerInfoApi, | 	ServerInfoApi, | ||||||
| 	ShareApi, | 	ShareApi, | ||||||
| 	SystemConfigApi, | 	SystemConfigApi, | ||||||
| @@ -21,6 +22,7 @@ export class ImmichApi { | |||||||
| 	public authenticationApi: AuthenticationApi; | 	public authenticationApi: AuthenticationApi; | ||||||
| 	public oauthApi: OAuthApi; | 	public oauthApi: OAuthApi; | ||||||
| 	public deviceInfoApi: DeviceInfoApi; | 	public deviceInfoApi: DeviceInfoApi; | ||||||
|  | 	public searchApi: SearchApi; | ||||||
| 	public serverInfoApi: ServerInfoApi; | 	public serverInfoApi: ServerInfoApi; | ||||||
| 	public jobApi: JobApi; | 	public jobApi: JobApi; | ||||||
| 	public keyApi: APIKeyApi; | 	public keyApi: APIKeyApi; | ||||||
| @@ -41,6 +43,7 @@ export class ImmichApi { | |||||||
| 		this.serverInfoApi = new ServerInfoApi(this.config); | 		this.serverInfoApi = new ServerInfoApi(this.config); | ||||||
| 		this.jobApi = new JobApi(this.config); | 		this.jobApi = new JobApi(this.config); | ||||||
| 		this.keyApi = new APIKeyApi(this.config); | 		this.keyApi = new APIKeyApi(this.config); | ||||||
|  | 		this.searchApi = new SearchApi(this.config); | ||||||
| 		this.systemConfigApi = new SystemConfigApi(this.config); | 		this.systemConfigApi = new SystemConfigApi(this.config); | ||||||
| 		this.shareApi = new ShareApi(this.config); | 		this.shareApi = new ShareApi(this.config); | ||||||
| 	} | 	} | ||||||
|   | |||||||
							
								
								
									
										374
									
								
								web/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										374
									
								
								web/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							| @@ -1451,6 +1451,37 @@ export interface RemoveAssetsDto { | |||||||
|      */ |      */ | ||||||
|     'assetIds': Array<string>; |     'assetIds': Array<string>; | ||||||
| } | } | ||||||
|  | /** | ||||||
|  |  *  | ||||||
|  |  * @export | ||||||
|  |  * @interface SearchAlbumResponseDto | ||||||
|  |  */ | ||||||
|  | export interface SearchAlbumResponseDto { | ||||||
|  |     /** | ||||||
|  |      *  | ||||||
|  |      * @type {number} | ||||||
|  |      * @memberof SearchAlbumResponseDto | ||||||
|  |      */ | ||||||
|  |     'total': number; | ||||||
|  |     /** | ||||||
|  |      *  | ||||||
|  |      * @type {number} | ||||||
|  |      * @memberof SearchAlbumResponseDto | ||||||
|  |      */ | ||||||
|  |     'count': number; | ||||||
|  |     /** | ||||||
|  |      *  | ||||||
|  |      * @type {Array<AlbumResponseDto>} | ||||||
|  |      * @memberof SearchAlbumResponseDto | ||||||
|  |      */ | ||||||
|  |     'items': Array<AlbumResponseDto>; | ||||||
|  |     /** | ||||||
|  |      *  | ||||||
|  |      * @type {Array<SearchFacetResponseDto>} | ||||||
|  |      * @memberof SearchAlbumResponseDto | ||||||
|  |      */ | ||||||
|  |     'facets': Array<SearchFacetResponseDto>; | ||||||
|  | } | ||||||
| /** | /** | ||||||
|  *  |  *  | ||||||
|  * @export |  * @export | ||||||
| @@ -1464,6 +1495,107 @@ export interface SearchAssetDto { | |||||||
|      */ |      */ | ||||||
|     'searchTerm': string; |     'searchTerm': string; | ||||||
| } | } | ||||||
|  | /** | ||||||
|  |  *  | ||||||
|  |  * @export | ||||||
|  |  * @interface SearchAssetResponseDto | ||||||
|  |  */ | ||||||
|  | export interface SearchAssetResponseDto { | ||||||
|  |     /** | ||||||
|  |      *  | ||||||
|  |      * @type {number} | ||||||
|  |      * @memberof SearchAssetResponseDto | ||||||
|  |      */ | ||||||
|  |     'total': number; | ||||||
|  |     /** | ||||||
|  |      *  | ||||||
|  |      * @type {number} | ||||||
|  |      * @memberof SearchAssetResponseDto | ||||||
|  |      */ | ||||||
|  |     'count': number; | ||||||
|  |     /** | ||||||
|  |      *  | ||||||
|  |      * @type {Array<AssetResponseDto>} | ||||||
|  |      * @memberof SearchAssetResponseDto | ||||||
|  |      */ | ||||||
|  |     'items': Array<AssetResponseDto>; | ||||||
|  |     /** | ||||||
|  |      *  | ||||||
|  |      * @type {Array<SearchFacetResponseDto>} | ||||||
|  |      * @memberof SearchAssetResponseDto | ||||||
|  |      */ | ||||||
|  |     'facets': Array<SearchFacetResponseDto>; | ||||||
|  | } | ||||||
|  | /** | ||||||
|  |  *  | ||||||
|  |  * @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<SearchFacetCountResponseDto>} | ||||||
|  |      * @memberof SearchFacetResponseDto | ||||||
|  |      */ | ||||||
|  |     'counts': Array<SearchFacetCountResponseDto>; | ||||||
|  | } | ||||||
|  | /** | ||||||
|  |  *  | ||||||
|  |  * @export | ||||||
|  |  * @interface SearchResponseDto | ||||||
|  |  */ | ||||||
|  | export interface SearchResponseDto { | ||||||
|  |     /** | ||||||
|  |      *  | ||||||
|  |      * @type {SearchAlbumResponseDto} | ||||||
|  |      * @memberof SearchResponseDto | ||||||
|  |      */ | ||||||
|  |     'albums': SearchAlbumResponseDto; | ||||||
|  |     /** | ||||||
|  |      *  | ||||||
|  |      * @type {SearchAssetResponseDto} | ||||||
|  |      * @memberof SearchResponseDto | ||||||
|  |      */ | ||||||
|  |     'assets': SearchAssetResponseDto; | ||||||
|  | } | ||||||
| /** | /** | ||||||
|  *  |  *  | ||||||
|  * @export |  * @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<RequestArgs> => { | ||||||
|  |             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<string>} [smartInfoObjects]  | ||||||
|  |          * @param {Array<string>} [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<string>, smartInfoTags?: Array<string>, options: AxiosRequestConfig = {}): Promise<RequestArgs> => { | ||||||
|  |             const localVarPath = `/search`; | ||||||
|  |             // use dummy base URL string because the URL constructor only accepts absolute URLs.
 | ||||||
|  |             const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); | ||||||
|  |             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<SearchConfigResponseDto>> { | ||||||
|  |             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<string>} [smartInfoObjects]  | ||||||
|  |          * @param {Array<string>} [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<string>, smartInfoTags?: Array<string>, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SearchResponseDto>> { | ||||||
|  |             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<SearchConfigResponseDto> { | ||||||
|  |             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<string>} [smartInfoObjects]  | ||||||
|  |          * @param {Array<string>} [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<string>, smartInfoTags?: Array<string>, options?: any): AxiosPromise<SearchResponseDto> { | ||||||
|  |             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<string>} [smartInfoObjects]  | ||||||
|  |      * @param {Array<string>} [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<string>, smartInfoTags?: Array<string>, 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 |  * ServerInfoApi - axios parameter creator | ||||||
|  * @export |  * @export | ||||||
|   | |||||||
							
								
								
									
										2
									
								
								web/src/app.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								web/src/app.d.ts
									
									
									
									
										vendored
									
									
								
							| @@ -13,7 +13,7 @@ declare namespace App { | |||||||
| 	interface Error { | 	interface Error { | ||||||
| 		message: string; | 		message: string; | ||||||
| 		stack?: string; | 		stack?: string; | ||||||
| 		code?: string; | 		code?: string | number; | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| import type { Handle, HandleServerError } from '@sveltejs/kit'; | import type { Handle, HandleServerError } from '@sveltejs/kit'; | ||||||
| import { AxiosError } from 'axios'; | import { AxiosError, AxiosResponse } from 'axios'; | ||||||
| import { env } from '$env/dynamic/public'; | import { env } from '$env/dynamic/public'; | ||||||
| import { ImmichApi } from './api/api'; | import { ImmichApi } from './api/api'; | ||||||
|  |  | ||||||
| @@ -34,11 +34,24 @@ export const handle = (async ({ event, resolve }) => { | |||||||
| 	return res; | 	return res; | ||||||
| }) satisfies Handle; | }) satisfies Handle; | ||||||
|  |  | ||||||
|  | const DEFAULT_MESSAGE = 'Hmm, not sure about that. Check the logs or open a ticket?'; | ||||||
|  |  | ||||||
| export const handleError: HandleServerError = async ({ error }) => { | export const handleError: HandleServerError = async ({ error }) => { | ||||||
| 	const httpError = error as AxiosError; | 	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 { | 	return { | ||||||
| 		message: httpError?.message || 'Hmm, not sure about that. Check the logs or open a ticket?', | 		message: response?.data?.message || httpError?.message || DEFAULT_MESSAGE, | ||||||
| 		stack: httpError?.stack, | 		code, | ||||||
| 		code: httpError.code || '500' | 		stack: httpError?.stack | ||||||
| 	}; | 	}; | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -22,7 +22,7 @@ | |||||||
|  |  | ||||||
| 	$: { | 	$: { | ||||||
| 		if (assets.length < 6) { | 		if (assets.length < 6) { | ||||||
| 			thumbnailSize = Math.floor(viewWidth / assets.length - assets.length); | 			thumbnailSize = Math.min(320, Math.floor(viewWidth / assets.length - assets.length)); | ||||||
| 		} else { | 		} else { | ||||||
| 			if (viewWidth > 600) thumbnailSize = Math.floor(viewWidth / 6 - 6); | 			if (viewWidth > 600) thumbnailSize = Math.floor(viewWidth / 6 - 6); | ||||||
| 			else if (viewWidth > 400) thumbnailSize = Math.floor(viewWidth / 4 - 6); | 			else if (viewWidth > 400) thumbnailSize = Math.floor(viewWidth / 4 - 6); | ||||||
|   | |||||||
| @@ -11,6 +11,7 @@ | |||||||
| 	import ImmichLogo from '../immich-logo.svelte'; | 	import ImmichLogo from '../immich-logo.svelte'; | ||||||
| 	export let user: UserResponseDto; | 	export let user: UserResponseDto; | ||||||
| 	export let shouldShowUploadButton = true; | 	export let shouldShowUploadButton = true; | ||||||
|  | 	export let term = ''; | ||||||
|  |  | ||||||
| 	let shouldShowAccountInfo = false; | 	let shouldShowAccountInfo = false; | ||||||
|  |  | ||||||
| @@ -35,6 +36,10 @@ | |||||||
|  |  | ||||||
| 		goto(data.redirectUri || '/auth/login?autoLaunch=0'); | 		goto(data.redirectUri || '/auth/login?autoLaunch=0'); | ||||||
| 	}; | 	}; | ||||||
|  |  | ||||||
|  | 	const onSearch = () => { | ||||||
|  | 		goto(`/search?q=${term}`); | ||||||
|  | 	}; | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <section | <section | ||||||
| @@ -52,12 +57,16 @@ | |||||||
| 				IMMICH | 				IMMICH | ||||||
| 			</h1> | 			</h1> | ||||||
| 		</a> | 		</a> | ||||||
| 		<div class="flex-1 ml-24"> | 		<form class="flex-1 ml-24" autocomplete="off" on:submit|preventDefault={onSearch}> | ||||||
| 			<input | 			<input | ||||||
|  | 				type="text" | ||||||
|  | 				name="search" | ||||||
| 				class="w-[50%] rounded-3xl bg-gray-200 dark:bg-immich-dark-gray  px-8 py-4" | 				class="w-[50%] rounded-3xl bg-gray-200 dark:bg-immich-dark-gray  px-8 py-4" | ||||||
| 				placeholder="Search - Coming soon" | 				placeholder="Search" | ||||||
|  | 				required | ||||||
|  | 				bind:value={term} | ||||||
| 			/> | 			/> | ||||||
| 		</div> | 		</form> | ||||||
| 		<section class="flex gap-4 place-items-center"> | 		<section class="flex gap-4 place-items-center"> | ||||||
| 			<ThemeButton /> | 			<ThemeButton /> | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										26
									
								
								web/src/routes/(user)/search/+page.server.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								web/src/routes/(user)/search/+page.server.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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; | ||||||
							
								
								
									
										27
									
								
								web/src/routes/(user)/search/+page.svelte
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								web/src/routes/(user)/search/+page.svelte
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | |||||||
|  | <script lang="ts"> | ||||||
|  | 	import { page } from '$app/stores'; | ||||||
|  | 	import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte'; | ||||||
|  | 	import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte'; | ||||||
|  | 	import type { PageData } from './$types'; | ||||||
|  |  | ||||||
|  | 	export let data: PageData; | ||||||
|  |  | ||||||
|  | 	const term = $page.url.searchParams.get('q') || ''; | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <section> | ||||||
|  | 	<NavigationBar {term} user={data.user} shouldShowUploadButton={false} /> | ||||||
|  | </section> | ||||||
|  |  | ||||||
|  | <section class="relative pt-[72px] h-screen bg-immich-bg  dark:bg-immich-dark-bg"> | ||||||
|  | 	<section class="overflow-y-auto relative immich-scrollbar"> | ||||||
|  | 		<section | ||||||
|  | 			id="search-content" | ||||||
|  | 			class="relative pt-8 pl-4 mb-12 bg-immich-bg dark:bg-immich-dark-bg" | ||||||
|  | 		> | ||||||
|  | 			{#if data.results?.assets?.items} | ||||||
|  | 				<GalleryViewer assets={data.results.assets.items} /> | ||||||
|  | 			{/if} | ||||||
|  | 		</section> | ||||||
|  | 	</section> | ||||||
|  | </section> | ||||||
| @@ -68,7 +68,7 @@ | |||||||
|  |  | ||||||
| 					<div class="p-4 max-h-[75vh] min-h-[300px] overflow-y-auto immich-scrollbar pb-4 gap-4"> | 					<div class="p-4 max-h-[75vh] min-h-[300px] overflow-y-auto immich-scrollbar pb-4 gap-4"> | ||||||
| 						<div class="flex flex-col w-full gap-2"> | 						<div class="flex flex-col w-full gap-2"> | ||||||
| 							<p class="text-red-500">{$page.error?.message} - {$page.error?.code}</p> | 							<p class="text-red-500">{$page.error?.message} ({$page.error?.code})</p> | ||||||
| 							{#if $page.error?.stack} | 							{#if $page.error?.stack} | ||||||
| 								<label for="stacktrace">Stacktrace</label> | 								<label for="stacktrace">Stacktrace</label> | ||||||
| 								<pre id="stacktrace" class="text-xs">{$page.error?.stack || 'No stack'}</pre> | 								<pre id="stacktrace" class="text-xs">{$page.error?.stack || 'No stack'}</pre> | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user