mirror of
https://github.com/immich-app/immich.git
synced 2025-05-30 19:54:52 -04: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:
parent
1cc184ed10
commit
0aaeab124d
@ -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>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user