瀏覽代碼

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>
Jason Rasmussen 2 年之前
父節點
當前提交
0aaeab124d
共有 87 個文件被更改,包括 3638 次插入77 次删除
  1. 2 0
      docker/.env.test
  2. 12 0
      docker/docker-compose.dev.yml
  3. 3 2
      docker/docker-compose.test.yml
  4. 15 3
      docker/docker-compose.yml
  5. 8 1
      docker/example.env
  6. 21 0
      mobile/openapi/.openapi-generator/FILES
  7. 8 0
      mobile/openapi/README.md
  8. 18 0
      mobile/openapi/doc/SearchAlbumResponseDto.md
  9. 135 0
      mobile/openapi/doc/SearchApi.md
  10. 18 0
      mobile/openapi/doc/SearchAssetResponseDto.md
  11. 15 0
      mobile/openapi/doc/SearchConfigResponseDto.md
  12. 16 0
      mobile/openapi/doc/SearchFacetCountResponseDto.md
  13. 16 0
      mobile/openapi/doc/SearchFacetResponseDto.md
  14. 16 0
      mobile/openapi/doc/SearchResponseDto.md
  15. 7 0
      mobile/openapi/lib/api.dart
  16. 181 0
      mobile/openapi/lib/api/search_api.dart
  17. 12 0
      mobile/openapi/lib/api_client.dart
  18. 135 0
      mobile/openapi/lib/model/search_album_response_dto.dart
  19. 135 0
      mobile/openapi/lib/model/search_asset_response_dto.dart
  20. 111 0
      mobile/openapi/lib/model/search_config_response_dto.dart
  21. 119 0
      mobile/openapi/lib/model/search_facet_count_response_dto.dart
  22. 119 0
      mobile/openapi/lib/model/search_facet_response_dto.dart
  23. 119 0
      mobile/openapi/lib/model/search_response_dto.dart
  24. 42 0
      mobile/openapi/test/search_album_response_dto_test.dart
  25. 35 0
      mobile/openapi/test/search_api_test.dart
  26. 42 0
      mobile/openapi/test/search_asset_response_dto_test.dart
  27. 27 0
      mobile/openapi/test/search_config_response_dto_test.dart
  28. 32 0
      mobile/openapi/test/search_facet_count_response_dto_test.dart
  29. 32 0
      mobile/openapi/test/search_facet_response_dto_test.dart
  30. 32 0
      mobile/openapi/test/search_response_dto_test.dart
  31. 9 4
      server/apps/immich/src/api-v1/album/album.service.spec.ts
  32. 7 1
      server/apps/immich/src/api-v1/album/album.service.ts
  33. 1 1
      server/apps/immich/src/api-v1/asset/asset-repository.ts
  34. 2 0
      server/apps/immich/src/api-v1/asset/asset.service.spec.ts
  35. 3 0
      server/apps/immich/src/api-v1/asset/asset.service.ts
  36. 10 4
      server/apps/immich/src/app.module.ts
  37. 1 0
      server/apps/immich/src/controllers/index.ts
  38. 27 0
      server/apps/immich/src/controllers/search.controller.ts
  39. 4 1
      server/apps/immich/src/main.ts
  40. 2 0
      server/apps/microservices/src/microservices.module.ts
  41. 38 0
      server/apps/microservices/src/processors.ts
  42. 28 26
      server/apps/microservices/src/processors/metadata-extraction.processor.ts
  43. 289 7
      server/immich-openapi-specs.json
  44. 5 0
      server/libs/common/src/config/app.config.ts
  45. 4 0
      server/libs/domain/src/album/album.repository.ts
  46. 21 0
      server/libs/domain/src/asset/asset.core.ts
  47. 5 1
      server/libs/domain/src/asset/asset.repository.ts
  48. 20 3
      server/libs/domain/src/asset/asset.service.spec.ts
  49. 17 2
      server/libs/domain/src/asset/asset.service.ts
  50. 1 0
      server/libs/domain/src/asset/index.ts
  51. 2 0
      server/libs/domain/src/domain.module.ts
  52. 1 0
      server/libs/domain/src/index.ts
  53. 7 0
      server/libs/domain/src/job/job.constants.ts
  54. 9 1
      server/libs/domain/src/job/job.interface.ts
  55. 16 2
      server/libs/domain/src/job/job.repository.ts
  56. 1 0
      server/libs/domain/src/search/dto/index.ts
  57. 57 0
      server/libs/domain/src/search/dto/search.dto.ts
  58. 4 0
      server/libs/domain/src/search/index.ts
  59. 2 0
      server/libs/domain/src/search/response-dto/index.ts
  60. 3 0
      server/libs/domain/src/search/response-dto/search-config-response.dto.ts
  61. 37 0
      server/libs/domain/src/search/response-dto/search-response.dto.ts
  62. 60 0
      server/libs/domain/src/search/search.repository.ts
  63. 317 0
      server/libs/domain/src/search/search.service.spec.ts
  64. 154 0
      server/libs/domain/src/search/search.service.ts
  65. 2 0
      server/libs/domain/test/album.repository.mock.ts
  66. 16 0
      server/libs/domain/test/fixtures.ts
  67. 1 0
      server/libs/domain/test/index.ts
  68. 12 0
      server/libs/domain/test/search.repository.mock.ts
  69. 9 0
      server/libs/infra/src/db/repository/album.repository.ts
  70. 23 4
      server/libs/infra/src/db/repository/asset.repository.ts
  71. 3 1
      server/libs/infra/src/infra.module.ts
  72. 13 0
      server/libs/infra/src/job/job.repository.ts
  73. 1 0
      server/libs/infra/src/search/index.ts
  74. 13 0
      server/libs/infra/src/search/schemas/album.schema.ts
  75. 37 0
      server/libs/infra/src/search/schemas/asset.schema.ts
  76. 325 0
      server/libs/infra/src/search/typesense.repository.ts
  77. 71 2
      server/package-lock.json
  78. 3 1
      server/package.json
  79. 3 0
      web/src/api/api.ts
  80. 374 0
      web/src/api/open-api/api.ts
  81. 1 1
      web/src/app.d.ts
  82. 17 4
      web/src/hooks.server.ts
  83. 1 1
      web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte
  84. 12 3
      web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte
  85. 26 0
      web/src/routes/(user)/search/+page.server.ts
  86. 27 0
      web/src/routes/(user)/search/+page.svelte
  87. 1 1
      web/src/routes/+error.svelte

+ 2 - 0
docker/.env.test

@@ -17,3 +17,5 @@ ENABLE_MAPBOX=false
 # WEB
 MAPBOX_KEY=
 VITE_SERVER_ENDPOINT=http://localhost:2283/api
+
+TYPESENSE_ENABLED=false

+ 12 - 0
docker/docker-compose.dev.yml

@@ -23,6 +23,7 @@ services:
     depends_on:
       - redis
       - database
+      - typesense
 
   immich-machine-learning:
     container_name: immich_machine_learning
@@ -64,6 +65,7 @@ services:
     depends_on:
       - database
       - immich-server
+      - typesense
 
   immich-web:
     container_name: immich_web
@@ -89,6 +91,15 @@ services:
     depends_on:
       - immich-server
 
+  typesense:
+    container_name: immich_typesense
+    image: typesense/typesense:0.24.0
+    environment:
+      - TYPESENSE_API_KEY=${TYPESENSE_API_KEY}
+      - TYPESENSE_DATA_DIR=/data
+    volumes:
+      - tsdata:/data
+
   redis:
     container_name: immich_redis
     image: redis:6.2
@@ -129,3 +140,4 @@ services:
 volumes:
   pgdata:
   model-cache:
+  tsdata:

+ 3 - 2
docker/docker-compose.test.yml

@@ -1,4 +1,4 @@
-version: '3.8'
+version: "3.8"
 
 services:
   immich-server-test:
@@ -9,7 +9,7 @@ services:
       target: builder
     command: npm run test:e2e
     expose:
-      - '3000'
+      - "3000"
     volumes:
       - ../server:/usr/src/app
       - /usr/src/app/node_modules
@@ -17,6 +17,7 @@ services:
       - .env.test
     environment:
       - NODE_ENV=development
+      - TYPESENSE_ENABLED=false
     depends_on:
       - immich-redis-test
       - immich-database-test

+ 15 - 3
docker/docker-compose.yml

@@ -4,7 +4,7 @@ services:
   immich-server:
     container_name: immich_server
     image: altran1502/immich-server:release
-    entrypoint: [ "/bin/sh", "./start-server.sh" ]
+    entrypoint: ["/bin/sh", "./start-server.sh"]
     volumes:
       - ${UPLOAD_LOCATION}:/usr/src/app/upload
     env_file:
@@ -14,12 +14,13 @@ services:
     depends_on:
       - redis
       - database
+      - typesense
     restart: always
 
   immich-microservices:
     container_name: immich_microservices
     image: altran1502/immich-server:release
-    entrypoint: [ "/bin/sh", "./start-microservices.sh" ]
+    entrypoint: ["/bin/sh", "./start-microservices.sh"]
     volumes:
       - ${UPLOAD_LOCATION}:/usr/src/app/upload
     env_file:
@@ -29,6 +30,7 @@ services:
     depends_on:
       - redis
       - database
+      - typesense
     restart: always
 
   immich-machine-learning:
@@ -46,11 +48,20 @@ services:
   immich-web:
     container_name: immich_web
     image: altran1502/immich-web:release
-    entrypoint: [ "/bin/sh", "./entrypoint.sh" ]
+    entrypoint: ["/bin/sh", "./entrypoint.sh"]
     env_file:
       - .env
     restart: always
 
+  typesense:
+    container_name: immich_typesense
+    image: typesense/typesense:0.24.0
+    environment:
+      - TYPESENSE_API_KEY=${TYPESENSE_API_KEY}
+      - TYPESENSE_DATA_DIR=/data
+    volumes:
+      - tsdata:/data
+
   redis:
     container_name: immich_redis
     image: redis:6.2
@@ -88,3 +99,4 @@ services:
 volumes:
   pgdata:
   model-cache:
+  tsdata:

+ 8 - 1
docker/example.env

@@ -30,6 +30,13 @@ REDIS_HOSTNAME=immich_redis
 
 UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup
 
+
+###################################################################################
+# Typesense
+###################################################################################
+TYPESENSE_API_KEY=some-random-text
+# TYPESENSE_ENABLED=false
+
 ###################################################################################
 # Reverse Geocoding
 #
@@ -76,4 +83,4 @@ IMMICH_MACHINE_LEARNING_URL=http://immich-machine-learning:3003
 # Examples: http://localhost:3001, http://immich-api.example.com, etc
 ####################################################################################
 
-#IMMICH_API_URL_EXTERNAL=http://localhost:3001
+#IMMICH_API_URL_EXTERNAL=http://localhost:3001

+ 21 - 0
mobile/openapi/.openapi-generator/FILES

@@ -61,7 +61,14 @@ doc/OAuthCallbackDto.md
 doc/OAuthConfigDto.md
 doc/OAuthConfigResponseDto.md
 doc/RemoveAssetsDto.md
+doc/SearchAlbumResponseDto.md
+doc/SearchApi.md
 doc/SearchAssetDto.md
+doc/SearchAssetResponseDto.md
+doc/SearchConfigResponseDto.md
+doc/SearchFacetCountResponseDto.md
+doc/SearchFacetResponseDto.md
+doc/SearchResponseDto.md
 doc/ServerInfoApi.md
 doc/ServerInfoResponseDto.md
 doc/ServerPingResponse.md
@@ -103,6 +110,7 @@ lib/api/authentication_api.dart
 lib/api/device_info_api.dart
 lib/api/job_api.dart
 lib/api/o_auth_api.dart
+lib/api/search_api.dart
 lib/api/server_info_api.dart
 lib/api/share_api.dart
 lib/api/system_config_api.dart
@@ -167,7 +175,13 @@ lib/model/o_auth_callback_dto.dart
 lib/model/o_auth_config_dto.dart
 lib/model/o_auth_config_response_dto.dart
 lib/model/remove_assets_dto.dart
+lib/model/search_album_response_dto.dart
 lib/model/search_asset_dto.dart
+lib/model/search_asset_response_dto.dart
+lib/model/search_config_response_dto.dart
+lib/model/search_facet_count_response_dto.dart
+lib/model/search_facet_response_dto.dart
+lib/model/search_response_dto.dart
 lib/model/server_info_response_dto.dart
 lib/model/server_ping_response.dart
 lib/model/server_stats_response_dto.dart
@@ -254,7 +268,14 @@ test/o_auth_callback_dto_test.dart
 test/o_auth_config_dto_test.dart
 test/o_auth_config_response_dto_test.dart
 test/remove_assets_dto_test.dart
+test/search_album_response_dto_test.dart
+test/search_api_test.dart
 test/search_asset_dto_test.dart
+test/search_asset_response_dto_test.dart
+test/search_config_response_dto_test.dart
+test/search_facet_count_response_dto_test.dart
+test/search_facet_response_dto_test.dart
+test/search_response_dto_test.dart
 test/server_info_api_test.dart
 test/server_info_response_dto_test.dart
 test/server_ping_response_test.dart

+ 8 - 0
mobile/openapi/README.md

@@ -121,6 +121,8 @@ Class | Method | HTTP request | Description
 *OAuthApi* | [**link**](doc//OAuthApi.md#link) | **POST** /oauth/link | 
 *OAuthApi* | [**mobileRedirect**](doc//OAuthApi.md#mobileredirect) | **GET** /oauth/mobile-redirect | 
 *OAuthApi* | [**unlink**](doc//OAuthApi.md#unlink) | **POST** /oauth/unlink | 
+*SearchApi* | [**getSearchConfig**](doc//SearchApi.md#getsearchconfig) | **GET** /search/config | 
+*SearchApi* | [**search**](doc//SearchApi.md#search) | **GET** /search | 
 *ServerInfoApi* | [**getServerInfo**](doc//ServerInfoApi.md#getserverinfo) | **GET** /server-info | 
 *ServerInfoApi* | [**getServerVersion**](doc//ServerInfoApi.md#getserverversion) | **GET** /server-info/version | 
 *ServerInfoApi* | [**getStats**](doc//ServerInfoApi.md#getstats) | **GET** /server-info/stats | 
@@ -204,7 +206,13 @@ Class | Method | HTTP request | Description
  - [OAuthConfigDto](doc//OAuthConfigDto.md)
  - [OAuthConfigResponseDto](doc//OAuthConfigResponseDto.md)
  - [RemoveAssetsDto](doc//RemoveAssetsDto.md)
+ - [SearchAlbumResponseDto](doc//SearchAlbumResponseDto.md)
  - [SearchAssetDto](doc//SearchAssetDto.md)
+ - [SearchAssetResponseDto](doc//SearchAssetResponseDto.md)
+ - [SearchConfigResponseDto](doc//SearchConfigResponseDto.md)
+ - [SearchFacetCountResponseDto](doc//SearchFacetCountResponseDto.md)
+ - [SearchFacetResponseDto](doc//SearchFacetResponseDto.md)
+ - [SearchResponseDto](doc//SearchResponseDto.md)
  - [ServerInfoResponseDto](doc//ServerInfoResponseDto.md)
  - [ServerPingResponse](doc//ServerPingResponse.md)
  - [ServerStatsResponseDto](doc//ServerStatsResponseDto.md)

+ 18 - 0
mobile/openapi/doc/SearchAlbumResponseDto.md

@@ -0,0 +1,18 @@
+# openapi.model.SearchAlbumResponseDto
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**total** | **int** |  | 
+**count** | **int** |  | 
+**items** | [**List<AlbumResponseDto>**](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 - 0
mobile/openapi/doc/SearchApi.md

@@ -0,0 +1,135 @@
+# openapi.api.SearchApi
+
+## Load the API package
+```dart
+import 'package:openapi/api.dart';
+```
+
+All URIs are relative to */api*
+
+Method | HTTP request | Description
+------------- | ------------- | -------------
+[**getSearchConfig**](SearchApi.md#getsearchconfig) | **GET** /search/config | 
+[**search**](SearchApi.md#search) | **GET** /search | 
+
+
+# **getSearchConfig**
+> SearchConfigResponseDto getSearchConfig()
+
+
+
+
+
+### Example
+```dart
+import 'package:openapi/api.dart';
+// TODO Configure HTTP Bearer authorization: bearer
+// Case 1. Use String Token
+//defaultApiClient.getAuthentication<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 - 0
mobile/openapi/doc/SearchAssetResponseDto.md

@@ -0,0 +1,18 @@
+# openapi.model.SearchAssetResponseDto
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**total** | **int** |  | 
+**count** | **int** |  | 
+**items** | [**List<AssetResponseDto>**](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 - 0
mobile/openapi/doc/SearchConfigResponseDto.md

@@ -0,0 +1,15 @@
+# openapi.model.SearchConfigResponseDto
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**enabled** | **bool** |  | 
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
+

+ 16 - 0
mobile/openapi/doc/SearchFacetCountResponseDto.md

@@ -0,0 +1,16 @@
+# openapi.model.SearchFacetCountResponseDto
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**count** | **int** |  | 
+**value** | **String** |  | 
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
+

+ 16 - 0
mobile/openapi/doc/SearchFacetResponseDto.md

@@ -0,0 +1,16 @@
+# openapi.model.SearchFacetResponseDto
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**fieldName** | **String** |  | 
+**counts** | [**List<SearchFacetCountResponseDto>**](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 - 0
mobile/openapi/doc/SearchResponseDto.md

@@ -0,0 +1,16 @@
+# openapi.model.SearchResponseDto
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**albums** | [**SearchAlbumResponseDto**](SearchAlbumResponseDto.md) |  | 
+**assets** | [**SearchAssetResponseDto**](SearchAssetResponseDto.md) |  | 
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
+

+ 7 - 0
mobile/openapi/lib/api.dart

@@ -35,6 +35,7 @@ part 'api/authentication_api.dart';
 part 'api/device_info_api.dart';
 part 'api/job_api.dart';
 part 'api/o_auth_api.dart';
+part 'api/search_api.dart';
 part 'api/server_info_api.dart';
 part 'api/share_api.dart';
 part 'api/system_config_api.dart';
@@ -92,7 +93,13 @@ part 'model/o_auth_callback_dto.dart';
 part 'model/o_auth_config_dto.dart';
 part 'model/o_auth_config_response_dto.dart';
 part 'model/remove_assets_dto.dart';
+part 'model/search_album_response_dto.dart';
 part 'model/search_asset_dto.dart';
+part 'model/search_asset_response_dto.dart';
+part 'model/search_config_response_dto.dart';
+part 'model/search_facet_count_response_dto.dart';
+part 'model/search_facet_response_dto.dart';
+part 'model/search_response_dto.dart';
 part 'model/server_info_response_dto.dart';
 part 'model/server_ping_response.dart';
 part 'model/server_stats_response_dto.dart';

+ 181 - 0
mobile/openapi/lib/api/search_api.dart

@@ -0,0 +1,181 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+
+class SearchApi {
+  SearchApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
+
+  final ApiClient apiClient;
+
+  /// 
+  ///
+  /// Note: This method returns the HTTP [Response].
+  Future<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 - 0
mobile/openapi/lib/api_client.dart

@@ -294,8 +294,20 @@ class ApiClient {
           return OAuthConfigResponseDto.fromJson(value);
         case 'RemoveAssetsDto':
           return RemoveAssetsDto.fromJson(value);
+        case 'SearchAlbumResponseDto':
+          return SearchAlbumResponseDto.fromJson(value);
         case 'SearchAssetDto':
           return SearchAssetDto.fromJson(value);
+        case 'SearchAssetResponseDto':
+          return SearchAssetResponseDto.fromJson(value);
+        case 'SearchConfigResponseDto':
+          return SearchConfigResponseDto.fromJson(value);
+        case 'SearchFacetCountResponseDto':
+          return SearchFacetCountResponseDto.fromJson(value);
+        case 'SearchFacetResponseDto':
+          return SearchFacetResponseDto.fromJson(value);
+        case 'SearchResponseDto':
+          return SearchResponseDto.fromJson(value);
         case 'ServerInfoResponseDto':
           return ServerInfoResponseDto.fromJson(value);
         case 'ServerPingResponse':

+ 135 - 0
mobile/openapi/lib/model/search_album_response_dto.dart

@@ -0,0 +1,135 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class SearchAlbumResponseDto {
+  /// Returns a new [SearchAlbumResponseDto] instance.
+  SearchAlbumResponseDto({
+    required this.total,
+    required this.count,
+    this.items = const [],
+    this.facets = const [],
+  });
+
+  int total;
+
+  int count;
+
+  List<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 - 0
mobile/openapi/lib/model/search_asset_response_dto.dart

@@ -0,0 +1,135 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class SearchAssetResponseDto {
+  /// Returns a new [SearchAssetResponseDto] instance.
+  SearchAssetResponseDto({
+    required this.total,
+    required this.count,
+    this.items = const [],
+    this.facets = const [],
+  });
+
+  int total;
+
+  int count;
+
+  List<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 - 0
mobile/openapi/lib/model/search_config_response_dto.dart

@@ -0,0 +1,111 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class SearchConfigResponseDto {
+  /// Returns a new [SearchConfigResponseDto] instance.
+  SearchConfigResponseDto({
+    required this.enabled,
+  });
+
+  bool enabled;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is SearchConfigResponseDto &&
+     other.enabled == enabled;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (enabled.hashCode);
+
+  @override
+  String toString() => 'SearchConfigResponseDto[enabled=$enabled]';
+
+  Map<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 - 0
mobile/openapi/lib/model/search_facet_count_response_dto.dart

@@ -0,0 +1,119 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class SearchFacetCountResponseDto {
+  /// Returns a new [SearchFacetCountResponseDto] instance.
+  SearchFacetCountResponseDto({
+    required this.count,
+    required this.value,
+  });
+
+  int count;
+
+  String value;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is SearchFacetCountResponseDto &&
+     other.count == count &&
+     other.value == value;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (count.hashCode) +
+    (value.hashCode);
+
+  @override
+  String toString() => 'SearchFacetCountResponseDto[count=$count, value=$value]';
+
+  Map<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 - 0
mobile/openapi/lib/model/search_facet_response_dto.dart

@@ -0,0 +1,119 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class SearchFacetResponseDto {
+  /// Returns a new [SearchFacetResponseDto] instance.
+  SearchFacetResponseDto({
+    required this.fieldName,
+    this.counts = const [],
+  });
+
+  String fieldName;
+
+  List<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 - 0
mobile/openapi/lib/model/search_response_dto.dart

@@ -0,0 +1,119 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class SearchResponseDto {
+  /// Returns a new [SearchResponseDto] instance.
+  SearchResponseDto({
+    required this.albums,
+    required this.assets,
+  });
+
+  SearchAlbumResponseDto albums;
+
+  SearchAssetResponseDto assets;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is SearchResponseDto &&
+     other.albums == albums &&
+     other.assets == assets;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (albums.hashCode) +
+    (assets.hashCode);
+
+  @override
+  String toString() => 'SearchResponseDto[albums=$albums, assets=$assets]';
+
+  Map<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 - 0
mobile/openapi/test/search_album_response_dto_test.dart

@@ -0,0 +1,42 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+import 'package:openapi/api.dart';
+import 'package:test/test.dart';
+
+// tests for SearchAlbumResponseDto
+void main() {
+  // final instance = SearchAlbumResponseDto();
+
+  group('test SearchAlbumResponseDto', () {
+    // int total
+    test('to test the property `total`', () async {
+      // TODO
+    });
+
+    // int count
+    test('to test the property `count`', () async {
+      // TODO
+    });
+
+    // List<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 - 0
mobile/openapi/test/search_api_test.dart

@@ -0,0 +1,35 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+import 'package:openapi/api.dart';
+import 'package:test/test.dart';
+
+
+/// tests for SearchApi
+void main() {
+  // final instance = SearchApi();
+
+  group('tests for SearchApi', () {
+    // 
+    //
+    //Future<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 - 0
mobile/openapi/test/search_asset_response_dto_test.dart

@@ -0,0 +1,42 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+import 'package:openapi/api.dart';
+import 'package:test/test.dart';
+
+// tests for SearchAssetResponseDto
+void main() {
+  // final instance = SearchAssetResponseDto();
+
+  group('test SearchAssetResponseDto', () {
+    // int total
+    test('to test the property `total`', () async {
+      // TODO
+    });
+
+    // int count
+    test('to test the property `count`', () async {
+      // TODO
+    });
+
+    // List<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 - 0
mobile/openapi/test/search_config_response_dto_test.dart

@@ -0,0 +1,27 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+import 'package:openapi/api.dart';
+import 'package:test/test.dart';
+
+// tests for SearchConfigResponseDto
+void main() {
+  // final instance = SearchConfigResponseDto();
+
+  group('test SearchConfigResponseDto', () {
+    // bool enabled
+    test('to test the property `enabled`', () async {
+      // TODO
+    });
+
+
+  });
+
+}

+ 32 - 0
mobile/openapi/test/search_facet_count_response_dto_test.dart

@@ -0,0 +1,32 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+import 'package:openapi/api.dart';
+import 'package:test/test.dart';
+
+// tests for SearchFacetCountResponseDto
+void main() {
+  // final instance = SearchFacetCountResponseDto();
+
+  group('test SearchFacetCountResponseDto', () {
+    // int count
+    test('to test the property `count`', () async {
+      // TODO
+    });
+
+    // String value
+    test('to test the property `value`', () async {
+      // TODO
+    });
+
+
+  });
+
+}

+ 32 - 0
mobile/openapi/test/search_facet_response_dto_test.dart

@@ -0,0 +1,32 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+import 'package:openapi/api.dart';
+import 'package:test/test.dart';
+
+// tests for SearchFacetResponseDto
+void main() {
+  // final instance = SearchFacetResponseDto();
+
+  group('test SearchFacetResponseDto', () {
+    // String fieldName
+    test('to test the property `fieldName`', () async {
+      // TODO
+    });
+
+    // List<SearchFacetCountResponseDto> counts (default value: const [])
+    test('to test the property `counts`', () async {
+      // TODO
+    });
+
+
+  });
+
+}

+ 32 - 0
mobile/openapi/test/search_response_dto_test.dart

@@ -0,0 +1,32 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+import 'package:openapi/api.dart';
+import 'package:test/test.dart';
+
+// tests for SearchResponseDto
+void main() {
+  // final instance = SearchResponseDto();
+
+  group('test SearchResponseDto', () {
+    // SearchAlbumResponseDto albums
+    test('to test the property `albums`', () async {
+      // TODO
+    });
+
+    // SearchAssetResponseDto assets
+    test('to test the property `assets`', () async {
+      // TODO
+    });
+
+
+  });
+
+}

+ 9 - 4
server/apps/immich/src/api-v1/album/album.service.spec.ts

@@ -2,7 +2,7 @@ import { AlbumService } from './album.service';
 import { AuthUserDto } from '../../decorators/auth-user.decorator';
 import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common';
 import { AlbumEntity, AssetEntity, UserEntity } from '@app/infra';
-import { AlbumResponseDto, ICryptoRepository, mapUser } from '@app/domain';
+import { AlbumResponseDto, ICryptoRepository, IJobRepository, JobName, mapUser } from '@app/domain';
 import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
 import { IAlbumRepository } from './album-repository';
 import { DownloadService } from '../../modules/download/download.service';
@@ -10,6 +10,7 @@ import { ISharedLinkRepository } from '@app/domain';
 import {
   assetEntityStub,
   newCryptoRepositoryMock,
+  newJobRepositoryMock,
   newSharedLinkRepositoryMock,
   userEntityStub,
 } from '@app/domain/../test';
@@ -20,6 +21,7 @@ describe('Album service', () => {
   let sharedLinkRepositoryMock: jest.Mocked<ISharedLinkRepository>;
   let downloadServiceMock: jest.Mocked<Partial<DownloadService>>;
   let cryptoMock: jest.Mocked<ICryptoRepository>;
+  let jobMock: jest.Mocked<IJobRepository>;
 
   const authUser: AuthUserDto = Object.freeze({
     id: '1111',
@@ -139,12 +141,14 @@ describe('Album service', () => {
     };
 
     cryptoMock = newCryptoRepositoryMock();
+    jobMock = newJobRepositoryMock();
 
     sut = new AlbumService(
       albumRepositoryMock,
       sharedLinkRepositoryMock,
       downloadServiceMock as DownloadService,
       cryptoMock,
+      jobMock,
     );
   });
 
@@ -158,6 +162,7 @@ describe('Album service', () => {
 
     expect(result.id).toEqual(albumEntity.id);
     expect(result.albumName).toEqual(albumEntity.albumName);
+    expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.SEARCH_INDEX_ALBUM, data: { album: albumEntity } });
   });
 
   it('gets list of albums for auth user', async () => {
@@ -291,9 +296,8 @@ describe('Album service', () => {
     const updatedAlbumName = 'new album name';
     const updatedAlbumThumbnailAssetId = '69d2f917-0b31-48d8-9d7d-673b523f1aac';
     albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
-    albumRepositoryMock.updateAlbum.mockImplementation(() =>
-      Promise.resolve<AlbumEntity>({ ...albumEntity, albumName: updatedAlbumName }),
-    );
+    const updatedAlbum = { ...albumEntity, albumName: updatedAlbumName };
+    albumRepositoryMock.updateAlbum.mockResolvedValue(updatedAlbum);
 
     const result = await sut.updateAlbumInfo(
       authUser,
@@ -311,6 +315,7 @@ describe('Album service', () => {
       albumName: updatedAlbumName,
       albumThumbnailAssetId: updatedAlbumThumbnailAssetId,
     });
+    expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.SEARCH_INDEX_ALBUM, data: { album: updatedAlbum } });
   });
 
   it('prevents updating a not owned album (shared with auth user)', async () => {

+ 7 - 1
server/apps/immich/src/api-v1/album/album.service.ts

@@ -6,7 +6,7 @@ import { AddUsersDto } from './dto/add-users.dto';
 import { RemoveAssetsDto } from './dto/remove-assets.dto';
 import { UpdateAlbumDto } from './dto/update-album.dto';
 import { GetAlbumsDto } from './dto/get-albums.dto';
-import { AlbumResponseDto, mapAlbum, mapAlbumExcludeAssetInfo } from '@app/domain';
+import { AlbumResponseDto, IJobRepository, JobName, mapAlbum, mapAlbumExcludeAssetInfo } from '@app/domain';
 import { IAlbumRepository } from './album-repository';
 import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
 import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
@@ -27,6 +27,7 @@ export class AlbumService {
     @Inject(ISharedLinkRepository) sharedLinkRepository: ISharedLinkRepository,
     private downloadService: DownloadService,
     @Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
+    @Inject(IJobRepository) private jobRepository: IJobRepository,
   ) {
     this.shareCore = new ShareCore(sharedLinkRepository, cryptoRepository);
   }
@@ -56,6 +57,7 @@ export class AlbumService {
 
   async create(authUser: AuthUserDto, createAlbumDto: CreateAlbumDto): Promise<AlbumResponseDto> {
     const albumEntity = await this.albumRepository.create(authUser.id, createAlbumDto);
+    await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ALBUM, data: { album: albumEntity } });
     return mapAlbum(albumEntity);
   }
 
@@ -105,6 +107,7 @@ export class AlbumService {
     }
 
     await this.albumRepository.delete(album);
+    await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ALBUM, data: { id: albumId } });
   }
 
   async removeUserFromAlbum(authUser: AuthUserDto, albumId: string, userId: string | 'me'): Promise<void> {
@@ -171,6 +174,9 @@ export class AlbumService {
     }
 
     const updatedAlbum = await this.albumRepository.updateAlbum(album, updateAlbumDto);
+
+    await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ALBUM, data: { album: updatedAlbum } });
+
     return mapAlbum(updatedAlbum);
   }
 

+ 1 - 1
server/apps/immich/src/api-v1/asset/asset-repository.ts

@@ -252,7 +252,7 @@ export class AssetRepository implements IAssetRepository {
       where: {
         id: assetId,
       },
-      relations: ['exifInfo', 'tags', 'sharedLinks'],
+      relations: ['exifInfo', 'tags', 'sharedLinks', 'smartInfo'],
     });
   }
 

+ 2 - 0
server/apps/immich/src/api-v1/asset/asset.service.spec.ts

@@ -445,6 +445,8 @@ describe('AssetService', () => {
       ]);
 
       expect(jobMock.queue.mock.calls).toEqual([
+        [{ name: JobName.SEARCH_REMOVE_ASSET, data: { id: 'asset1' } }],
+        [{ name: JobName.SEARCH_REMOVE_ASSET, data: { id: 'asset2' } }],
         [
           {
             name: JobName.DELETE_FILES,

+ 3 - 0
server/apps/immich/src/api-v1/asset/asset.service.ts

@@ -170,6 +170,8 @@ export class AssetService {
 
     const updatedAsset = await this._assetRepository.update(authUser.id, asset, dto);
 
+    await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { asset: updatedAsset } });
+
     return mapAsset(updatedAsset);
   }
 
@@ -425,6 +427,7 @@ export class AssetService {
 
       try {
         await this._assetRepository.remove(asset);
+        await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { id } });
 
         result.push({ id, status: DeleteAssetStatusEnum.SUCCESS });
         deleteQueue.push(asset.originalPath, asset.webpPath, asset.resizePath);

+ 10 - 4
server/apps/immich/src/app.module.ts

@@ -1,5 +1,5 @@
 import { immichAppConfig } from '@app/common/config';
-import { Module } from '@nestjs/common';
+import { Module, OnModuleInit } from '@nestjs/common';
 import { AssetModule } from './api-v1/asset/asset.module';
 import { ConfigModule } from '@nestjs/config';
 import { ServerInfoModule } from './api-v1/server-info/server-info.module';
@@ -9,13 +9,14 @@ import { ScheduleModule } from '@nestjs/schedule';
 import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.module';
 import { JobModule } from './api-v1/job/job.module';
 import { TagModule } from './api-v1/tag/tag.module';
-import { DomainModule } from '@app/domain';
+import { DomainModule, SearchService } from '@app/domain';
 import { InfraModule } from '@app/infra';
 import {
   APIKeyController,
   AuthController,
   DeviceInfoController,
   OAuthController,
+  SearchController,
   ShareController,
   SystemConfigController,
   UserController,
@@ -46,16 +47,21 @@ import { AuthGuard } from './middlewares/auth.guard';
     TagModule,
   ],
   controllers: [
-    //
     AppController,
     APIKeyController,
     AuthController,
     DeviceInfoController,
     OAuthController,
+    SearchController,
     ShareController,
     SystemConfigController,
     UserController,
   ],
   providers: [{ provide: APP_GUARD, useExisting: AuthGuard }, AuthGuard],
 })
-export class AppModule {}
+export class AppModule implements OnModuleInit {
+  constructor(private searchService: SearchService) {}
+  async onModuleInit() {
+    await this.searchService.bootstrap();
+  }
+}

+ 1 - 0
server/apps/immich/src/controllers/index.ts

@@ -2,6 +2,7 @@ export * from './api-key.controller';
 export * from './auth.controller';
 export * from './device-info.controller';
 export * from './oauth.controller';
+export * from './search.controller';
 export * from './share.controller';
 export * from './system-config.controller';
 export * from './user.controller';

+ 27 - 0
server/apps/immich/src/controllers/search.controller.ts

@@ -0,0 +1,27 @@
+import { AuthUserDto, SearchConfigResponseDto, SearchDto, SearchResponseDto, SearchService } from '@app/domain';
+import { Controller, Get, Query, ValidationPipe } from '@nestjs/common';
+import { ApiTags } from '@nestjs/swagger';
+import { GetAuthUser } from '../decorators/auth-user.decorator';
+import { Authenticated } from '../decorators/authenticated.decorator';
+
+@ApiTags('Search')
+@Authenticated()
+@Controller('search')
+export class SearchController {
+  constructor(private readonly searchService: SearchService) {}
+
+  @Authenticated()
+  @Get()
+  async search(
+    @GetAuthUser() authUser: AuthUserDto,
+    @Query(new ValidationPipe({ transform: true })) dto: SearchDto,
+  ): Promise<SearchResponseDto> {
+    return this.searchService.search(authUser, dto);
+  }
+
+  @Authenticated()
+  @Get('config')
+  getSearchConfig(): SearchConfigResponseDto {
+    return this.searchService.getConfig();
+  }
+}

+ 4 - 1
server/apps/immich/src/main.ts

@@ -11,7 +11,7 @@ import { RedisIoAdapter } from './middlewares/redis-io.adapter.middleware';
 import { json } from 'body-parser';
 import { patchOpenAPI } from './utils/patch-open-api.util';
 import { getLogLevels, MACHINE_LEARNING_ENABLED } from '@app/common';
-import { IMMICH_ACCESS_COOKIE } from '@app/domain';
+import { IMMICH_ACCESS_COOKIE, SearchService } from '@app/domain';
 
 const logger = new Logger('ImmichServer');
 
@@ -73,6 +73,9 @@ async function bootstrap() {
     );
   });
 
+  const searchService = app.get(SearchService);
+
   logger.warn(`Machine learning is ${MACHINE_LEARNING_ENABLED ? 'enabled' : 'disabled'}`);
+  logger.warn(`Search is ${searchService.isEnabled() ? 'enabled' : 'disabled'}`);
 }
 bootstrap();

+ 2 - 0
server/apps/microservices/src/microservices.module.ts

@@ -7,6 +7,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
 import {
   BackgroundTaskProcessor,
   MachineLearningProcessor,
+  SearchIndexProcessor,
   StorageTemplateMigrationProcessor,
   ThumbnailGeneratorProcessor,
 } from './processors';
@@ -26,6 +27,7 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
     MachineLearningProcessor,
     StorageTemplateMigrationProcessor,
     BackgroundTaskProcessor,
+    SearchIndexProcessor,
   ],
 })
 export class MicroservicesModule {}

+ 38 - 0
server/apps/microservices/src/processors.ts

@@ -1,12 +1,15 @@
 import {
   AssetService,
+  IAlbumJob,
   IAssetJob,
   IAssetUploadedJob,
   IDeleteFilesJob,
+  IDeleteJob,
   IUserDeletionJob,
   JobName,
   MediaService,
   QueueName,
+  SearchService,
   SmartInfoService,
   StorageService,
   StorageTemplateService,
@@ -61,6 +64,41 @@ export class MachineLearningProcessor {
   }
 }
 
+@Processor(QueueName.SEARCH)
+export class SearchIndexProcessor {
+  constructor(private searchService: SearchService) {}
+
+  @Process(JobName.SEARCH_INDEX_ALBUMS)
+  async onIndexAlbums() {
+    await this.searchService.handleIndexAlbums();
+  }
+
+  @Process(JobName.SEARCH_INDEX_ASSETS)
+  async onIndexAssets() {
+    await this.searchService.handleIndexAssets();
+  }
+
+  @Process(JobName.SEARCH_INDEX_ALBUM)
+  async onIndexAlbum(job: Job<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)
 export class StorageTemplateMigrationProcessor {
   constructor(private storageTemplateService: StorageTemplateService) {}

+ 28 - 26
server/apps/microservices/src/processors/metadata-extraction.processor.ts

@@ -1,18 +1,26 @@
+import {
+  AssetCore,
+  IAssetRepository,
+  IAssetUploadedJob,
+  IReverseGeocodingJob,
+  ISearchRepository,
+  JobName,
+  QueueName,
+} from '@app/domain';
 import { AssetEntity, AssetType, ExifEntity } from '@app/infra';
-import { IReverseGeocodingJob, IAssetUploadedJob, QueueName, JobName, IAssetRepository } from '@app/domain';
 import { Process, Processor } from '@nestjs/bull';
 import { Inject, Logger } from '@nestjs/common';
 import { ConfigService } from '@nestjs/config';
 import { InjectRepository } from '@nestjs/typeorm';
 import { Job } from 'bull';
+import { ExifDateTime, exiftool, Tags } from 'exiftool-vendored';
 import ffmpeg from 'fluent-ffmpeg';
+import { getName } from 'i18n-iso-countries';
+import geocoder, { InitOptions } from 'local-reverse-geocoder';
+import fs from 'node:fs';
 import path from 'path';
 import sharp from 'sharp';
 import { Repository } from 'typeorm/repository/Repository';
-import geocoder, { InitOptions } from 'local-reverse-geocoder';
-import { getName } from 'i18n-iso-countries';
-import fs from 'node:fs';
-import { ExifDateTime, exiftool, Tags } from 'exiftool-vendored';
 
 interface ImmichTags extends Tags {
   ContentIdentifier?: string;
@@ -71,13 +79,19 @@ export type GeoData = {
 export class MetadataExtractionProcessor {
   private logger = new Logger(MetadataExtractionProcessor.name);
   private isGeocodeInitialized = false;
+  private assetCore: AssetCore;
+
   constructor(
-    @Inject(IAssetRepository) private assetRepository: IAssetRepository,
+    @Inject(IAssetRepository) assetRepository: IAssetRepository,
+    @Inject(ISearchRepository) searchRepository: ISearchRepository,
+
     @InjectRepository(ExifEntity)
     private exifRepository: Repository<ExifEntity>,
 
     configService: ConfigService,
   ) {
+    this.assetCore = new AssetCore(assetRepository, searchRepository);
+
     if (!configService.get('DISABLE_REVERSE_GEOCODING')) {
       this.logger.log('Initializing Reverse Geocoding');
       geocoderInit({
@@ -175,20 +189,11 @@ export class MetadataExtractionProcessor {
       newExif.longitude = exifData?.GPSLongitude || null;
       newExif.livePhotoCID = exifData?.MediaGroupUUID || null;
 
-      await this.assetRepository.save({
-        id: asset.id,
-        fileCreatedAt: fileCreatedAt?.toISOString(),
-      });
-
       if (newExif.livePhotoCID && !asset.livePhotoVideoId) {
-        const motionAsset = await this.assetRepository.findLivePhotoMatch(
-          newExif.livePhotoCID,
-          asset.id,
-          AssetType.VIDEO,
-        );
+        const motionAsset = await this.assetCore.findLivePhotoMatch(newExif.livePhotoCID, asset.id, AssetType.VIDEO);
         if (motionAsset) {
-          await this.assetRepository.save({ id: asset.id, livePhotoVideoId: motionAsset.id });
-          await this.assetRepository.save({ id: motionAsset.id, isVisible: false });
+          await this.assetCore.save({ id: asset.id, livePhotoVideoId: motionAsset.id });
+          await this.assetCore.save({ id: motionAsset.id, isVisible: false });
         }
       }
 
@@ -226,6 +231,7 @@ export class MetadataExtractionProcessor {
       }
 
       await this.exifRepository.upsert(newExif, { conflictPaths: ['assetId'] });
+      await this.assetCore.save({ id: asset.id, fileCreatedAt: fileCreatedAt?.toISOString() });
     } catch (error: any) {
       this.logger.error(`Error extracting EXIF ${error}`, error?.stack);
     }
@@ -292,14 +298,10 @@ export class MetadataExtractionProcessor {
       newExif.livePhotoCID = exifData?.ContentIdentifier || null;
 
       if (newExif.livePhotoCID) {
-        const photoAsset = await this.assetRepository.findLivePhotoMatch(
-          newExif.livePhotoCID,
-          asset.id,
-          AssetType.IMAGE,
-        );
+        const photoAsset = await this.assetCore.findLivePhotoMatch(newExif.livePhotoCID, asset.id, AssetType.IMAGE);
         if (photoAsset) {
-          await this.assetRepository.save({ id: photoAsset.id, livePhotoVideoId: asset.id });
-          await this.assetRepository.save({ id: asset.id, isVisible: false });
+          await this.assetCore.save({ id: photoAsset.id, livePhotoVideoId: asset.id });
+          await this.assetCore.save({ id: asset.id, isVisible: false });
         }
       }
 
@@ -355,7 +357,7 @@ export class MetadataExtractionProcessor {
       }
 
       await this.exifRepository.upsert(newExif, { conflictPaths: ['assetId'] });
-      await this.assetRepository.save({ id: asset.id, duration: durationString, fileCreatedAt });
+      await this.assetCore.save({ id: asset.id, duration: durationString, fileCreatedAt });
     } catch (err) {
       ``;
       // do nothing

+ 289 - 7
server/immich-openapi-specs.json

@@ -544,6 +544,171 @@
         ]
       }
     },
+    "/search": {
+      "get": {
+        "operationId": "search",
+        "description": "",
+        "parameters": [
+          {
+            "name": "query",
+            "required": false,
+            "in": "query",
+            "schema": {
+              "type": "string"
+            }
+          },
+          {
+            "name": "type",
+            "required": false,
+            "in": "query",
+            "schema": {
+              "enum": [
+                "IMAGE",
+                "VIDEO",
+                "AUDIO",
+                "OTHER"
+              ],
+              "type": "string"
+            }
+          },
+          {
+            "name": "isFavorite",
+            "required": false,
+            "in": "query",
+            "schema": {
+              "type": "boolean"
+            }
+          },
+          {
+            "name": "exifInfo.city",
+            "required": false,
+            "in": "query",
+            "schema": {
+              "type": "string"
+            }
+          },
+          {
+            "name": "exifInfo.state",
+            "required": false,
+            "in": "query",
+            "schema": {
+              "type": "string"
+            }
+          },
+          {
+            "name": "exifInfo.country",
+            "required": false,
+            "in": "query",
+            "schema": {
+              "type": "string"
+            }
+          },
+          {
+            "name": "exifInfo.make",
+            "required": false,
+            "in": "query",
+            "schema": {
+              "type": "string"
+            }
+          },
+          {
+            "name": "exifInfo.model",
+            "required": false,
+            "in": "query",
+            "schema": {
+              "type": "string"
+            }
+          },
+          {
+            "name": "smartInfo.objects",
+            "required": false,
+            "in": "query",
+            "schema": {
+              "type": "array",
+              "items": {
+                "type": "string"
+              }
+            }
+          },
+          {
+            "name": "smartInfo.tags",
+            "required": false,
+            "in": "query",
+            "schema": {
+              "type": "array",
+              "items": {
+                "type": "string"
+              }
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/SearchResponseDto"
+                }
+              }
+            }
+          }
+        },
+        "tags": [
+          "Search"
+        ],
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          },
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          }
+        ]
+      }
+    },
+    "/search/config": {
+      "get": {
+        "operationId": "getSearchConfig",
+        "description": "",
+        "parameters": [],
+        "responses": {
+          "200": {
+            "description": "",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/SearchConfigResponseDto"
+                }
+              }
+            }
+          }
+        },
+        "tags": [
+          "Search"
+        ],
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          },
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          }
+        ]
+      }
+    },
     "/share": {
       "get": {
         "operationId": "getAllSharedLinks",
@@ -3554,13 +3719,6 @@
           "url"
         ]
       },
-      "SharedLinkType": {
-        "type": "string",
-        "enum": [
-          "ALBUM",
-          "INDIVIDUAL"
-        ]
-      },
       "AssetTypeEnum": {
         "type": "string",
         "enum": [
@@ -3871,6 +4029,130 @@
           "owner"
         ]
       },
+      "SearchFacetCountResponseDto": {
+        "type": "object",
+        "properties": {
+          "count": {
+            "type": "integer"
+          },
+          "value": {
+            "type": "string"
+          }
+        },
+        "required": [
+          "count",
+          "value"
+        ]
+      },
+      "SearchFacetResponseDto": {
+        "type": "object",
+        "properties": {
+          "fieldName": {
+            "type": "string"
+          },
+          "counts": {
+            "type": "array",
+            "items": {
+              "$ref": "#/components/schemas/SearchFacetCountResponseDto"
+            }
+          }
+        },
+        "required": [
+          "fieldName",
+          "counts"
+        ]
+      },
+      "SearchAlbumResponseDto": {
+        "type": "object",
+        "properties": {
+          "total": {
+            "type": "integer"
+          },
+          "count": {
+            "type": "integer"
+          },
+          "items": {
+            "type": "array",
+            "items": {
+              "$ref": "#/components/schemas/AlbumResponseDto"
+            }
+          },
+          "facets": {
+            "type": "array",
+            "items": {
+              "$ref": "#/components/schemas/SearchFacetResponseDto"
+            }
+          }
+        },
+        "required": [
+          "total",
+          "count",
+          "items",
+          "facets"
+        ]
+      },
+      "SearchAssetResponseDto": {
+        "type": "object",
+        "properties": {
+          "total": {
+            "type": "integer"
+          },
+          "count": {
+            "type": "integer"
+          },
+          "items": {
+            "type": "array",
+            "items": {
+              "$ref": "#/components/schemas/AssetResponseDto"
+            }
+          },
+          "facets": {
+            "type": "array",
+            "items": {
+              "$ref": "#/components/schemas/SearchFacetResponseDto"
+            }
+          }
+        },
+        "required": [
+          "total",
+          "count",
+          "items",
+          "facets"
+        ]
+      },
+      "SearchResponseDto": {
+        "type": "object",
+        "properties": {
+          "albums": {
+            "$ref": "#/components/schemas/SearchAlbumResponseDto"
+          },
+          "assets": {
+            "$ref": "#/components/schemas/SearchAssetResponseDto"
+          }
+        },
+        "required": [
+          "albums",
+          "assets"
+        ]
+      },
+      "SearchConfigResponseDto": {
+        "type": "object",
+        "properties": {
+          "enabled": {
+            "type": "boolean"
+          }
+        },
+        "required": [
+          "enabled"
+        ]
+      },
+      "SharedLinkType": {
+        "type": "string",
+        "enum": [
+          "ALBUM",
+          "INDIVIDUAL"
+        ]
+      },
       "SharedLinkResponseDto": {
         "type": "object",
         "properties": {

+ 5 - 0
server/libs/common/src/config/app.config.ts

@@ -16,6 +16,11 @@ export const immichAppConfig: ConfigModuleOptions = {
     DB_PASSWORD: WHEN_DB_URL_SET,
     DB_DATABASE_NAME: WHEN_DB_URL_SET,
     DB_URL: Joi.string().optional(),
+    TYPESENSE_API_KEY: Joi.when('TYPESENSE_ENABLED', {
+      is: 'false',
+      then: Joi.string().optional(),
+      otherwise: Joi.string().required(),
+    }),
     DISABLE_REVERSE_GEOCODING: Joi.boolean().optional().valid(true, false).default(false),
     REVERSE_GEOCODING_PRECISION: Joi.number().optional().valid(0, 1, 2, 3).default(3),
     LOG_LEVEL: Joi.string().optional().valid('simple', 'verbose', 'debug', 'log', 'warn', 'error').default('log'),

+ 4 - 0
server/libs/domain/src/album/album.repository.ts

@@ -1,5 +1,9 @@
+import { AlbumEntity } from '@app/infra/db/entities';
+
 export const IAlbumRepository = 'IAlbumRepository';
 
 export interface IAlbumRepository {
   deleteAll(userId: string): Promise<void>;
+  getAll(): Promise<AlbumEntity[]>;
+  save(album: Partial<AlbumEntity>): Promise<AlbumEntity>;
 }

+ 21 - 0
server/libs/domain/src/asset/asset.core.ts

@@ -0,0 +1,21 @@
+import { AssetEntity, AssetType } from '@app/infra/db/entities';
+import { ISearchRepository, SearchCollection } from '../search/search.repository';
+import { AssetSearchOptions, IAssetRepository } from './asset.repository';
+
+export class AssetCore {
+  constructor(private repository: IAssetRepository, private searchRepository: ISearchRepository) {}
+
+  getAll(options: AssetSearchOptions) {
+    return this.repository.getAll(options);
+  }
+
+  async save(asset: Partial<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);
+  }
+}

+ 5 - 1
server/libs/domain/src/asset/asset.repository.ts

@@ -1,10 +1,14 @@
 import { AssetEntity, AssetType } from '@app/infra/db/entities';
 
+export interface AssetSearchOptions {
+  isVisible?: boolean;
+}
+
 export const IAssetRepository = 'IAssetRepository';
 
 export interface IAssetRepository {
   deleteAll(ownerId: string): Promise<void>;
-  getAll(): Promise<AssetEntity[]>;
+  getAll(options?: AssetSearchOptions): Promise<AssetEntity[]>;
   save(asset: Partial<AssetEntity>): Promise<AssetEntity>;
   findLivePhotoMatch(livePhotoCID: string, otherAssetId: string, type: AssetType): Promise<AssetEntity | null>;
 }

+ 20 - 3
server/libs/domain/src/asset/asset.service.spec.ts

@@ -1,19 +1,25 @@
 import { AssetEntity, AssetType } from '@app/infra/db/entities';
-import { newJobRepositoryMock } from '../../test';
-import { AssetService } from '../asset';
+import { assetEntityStub, newAssetRepositoryMock, newJobRepositoryMock } from '../../test';
+import { newSearchRepositoryMock } from '../../test/search.repository.mock';
+import { AssetService, IAssetRepository } from '../asset';
 import { IJobRepository, JobName } from '../job';
+import { ISearchRepository } from '../search';
 
 describe(AssetService.name, () => {
   let sut: AssetService;
+  let assetMock: jest.Mocked<IAssetRepository>;
   let jobMock: jest.Mocked<IJobRepository>;
+  let searchMock: jest.Mocked<ISearchRepository>;
 
   it('should work', () => {
     expect(sut).toBeDefined();
   });
 
   beforeEach(async () => {
+    assetMock = newAssetRepositoryMock();
     jobMock = newJobRepositoryMock();
-    sut = new AssetService(jobMock);
+    searchMock = newSearchRepositoryMock();
+    sut = new AssetService(assetMock, jobMock, searchMock);
   });
 
   describe(`handle asset upload`, () => {
@@ -42,4 +48,15 @@ describe(AssetService.name, () => {
       ]);
     });
   });
+
+  describe('save', () => {
+    it('should save an asset', async () => {
+      assetMock.save.mockResolvedValue(assetEntityStub.image);
+
+      await sut.save(assetEntityStub.image);
+
+      expect(assetMock.save).toHaveBeenCalledWith(assetEntityStub.image);
+      expect(searchMock.index).toHaveBeenCalledWith('assets', assetEntityStub.image);
+    });
+  });
 });

+ 17 - 2
server/libs/domain/src/asset/asset.service.ts

@@ -1,9 +1,20 @@
-import { AssetType } from '@app/infra/db/entities';
+import { AssetEntity, AssetType } from '@app/infra/db/entities';
 import { Inject } from '@nestjs/common';
 import { IAssetUploadedJob, IJobRepository, JobName } from '../job';
+import { ISearchRepository } from '../search';
+import { AssetCore } from './asset.core';
+import { IAssetRepository } from './asset.repository';
 
 export class AssetService {
-  constructor(@Inject(IJobRepository) private jobRepository: IJobRepository) {}
+  private assetCore: AssetCore;
+
+  constructor(
+    @Inject(IAssetRepository) assetRepository: IAssetRepository,
+    @Inject(IJobRepository) private jobRepository: IJobRepository,
+    @Inject(ISearchRepository) searchRepository: ISearchRepository,
+  ) {
+    this.assetCore = new AssetCore(assetRepository, searchRepository);
+  }
 
   async handleAssetUpload(data: IAssetUploadedJob) {
     await this.jobRepository.queue({ name: JobName.GENERATE_JPEG_THUMBNAIL, data });
@@ -15,4 +26,8 @@ export class AssetService {
       await this.jobRepository.queue({ name: JobName.EXIF_EXTRACTION, data });
     }
   }
+
+  save(asset: Partial<AssetEntity>) {
+    return this.assetCore.save(asset);
+  }
 }

+ 1 - 0
server/libs/domain/src/asset/index.ts

@@ -1,3 +1,4 @@
+export * from './asset.core';
 export * from './asset.repository';
 export * from './asset.service';
 export * from './response-dto';

+ 2 - 0
server/libs/domain/src/domain.module.ts

@@ -5,6 +5,7 @@ import { AuthService } from './auth';
 import { DeviceInfoService } from './device-info';
 import { MediaService } from './media';
 import { OAuthService } from './oauth';
+import { SearchService } from './search';
 import { ShareService } from './share';
 import { SmartInfoService } from './smart-info';
 import { StorageService } from './storage';
@@ -25,6 +26,7 @@ const providers: Provider[] = [
   SystemConfigService,
   UserService,
   ShareService,
+  SearchService,
   {
     provide: INITIAL_SYSTEM_CONFIG,
     inject: [SystemConfigService],

+ 1 - 0
server/libs/domain/src/index.ts

@@ -9,6 +9,7 @@ export * from './domain.module';
 export * from './job';
 export * from './media';
 export * from './oauth';
+export * from './search';
 export * from './share';
 export * from './smart-info';
 export * from './storage';

+ 7 - 0
server/libs/domain/src/job/job.constants.ts

@@ -5,6 +5,7 @@ export enum QueueName {
   MACHINE_LEARNING = 'machine-learning-queue',
   BACKGROUND_TASK = 'background-task',
   STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration-queue',
+  SEARCH = 'search-queue',
 }
 
 export enum JobName {
@@ -22,4 +23,10 @@ export enum JobName {
   OBJECT_DETECTION = 'detect-object',
   IMAGE_TAGGING = 'tag-image',
   DELETE_FILES = 'delete-files',
+  SEARCH_INDEX_ASSETS = 'search-index-assets',
+  SEARCH_INDEX_ASSET = 'search-index-asset',
+  SEARCH_INDEX_ALBUMS = 'search-index-albums',
+  SEARCH_INDEX_ALBUM = 'search-index-album',
+  SEARCH_REMOVE_ALBUM = 'search-remove-album',
+  SEARCH_REMOVE_ASSET = 'search-remove-asset',
 }

+ 9 - 1
server/libs/domain/src/job/job.interface.ts

@@ -1,4 +1,8 @@
-import { AssetEntity, UserEntity } from '@app/infra/db/entities';
+import { AlbumEntity, AssetEntity, UserEntity } from '@app/infra/db/entities';
+
+export interface IAlbumJob {
+  album: AlbumEntity;
+}
 
 export interface IAssetJob {
   asset: AssetEntity;
@@ -9,6 +13,10 @@ export interface IAssetUploadedJob {
   fileName: string;
 }
 
+export interface IDeleteJob {
+  id: string;
+}
+
 export interface IDeleteFilesJob {
   files: Array<string | null | undefined>;
 }

+ 16 - 2
server/libs/domain/src/job/job.repository.ts

@@ -1,5 +1,13 @@
 import { JobName, QueueName } from './job.constants';
-import { IAssetJob, IAssetUploadedJob, IDeleteFilesJob, IReverseGeocodingJob, IUserDeletionJob } from './job.interface';
+import {
+  IAlbumJob,
+  IAssetJob,
+  IAssetUploadedJob,
+  IDeleteFilesJob,
+  IDeleteJob,
+  IReverseGeocodingJob,
+  IUserDeletionJob,
+} from './job.interface';
 
 export interface JobCounts {
   active: number;
@@ -23,7 +31,13 @@ export type JobItem =
   | { name: JobName.EXTRACT_VIDEO_METADATA; data: IAssetUploadedJob }
   | { name: JobName.OBJECT_DETECTION; data: IAssetJob }
   | { name: JobName.IMAGE_TAGGING; data: IAssetJob }
-  | { name: JobName.DELETE_FILES; data: IDeleteFilesJob };
+  | { name: JobName.DELETE_FILES; data: IDeleteFilesJob }
+  | { name: JobName.SEARCH_INDEX_ASSETS }
+  | { name: JobName.SEARCH_INDEX_ASSET; data: IAssetJob }
+  | { name: JobName.SEARCH_INDEX_ALBUMS }
+  | { name: JobName.SEARCH_INDEX_ALBUM; data: IAlbumJob }
+  | { name: JobName.SEARCH_REMOVE_ASSET; data: IDeleteJob }
+  | { name: JobName.SEARCH_REMOVE_ALBUM; data: IDeleteJob };
 
 export const IJobRepository = 'IJobRepository';
 

+ 1 - 0
server/libs/domain/src/search/dto/index.ts

@@ -0,0 +1 @@
+export * from './search.dto';

+ 57 - 0
server/libs/domain/src/search/dto/search.dto.ts

@@ -0,0 +1,57 @@
+import { AssetType } from '@app/infra/db/entities';
+import { Transform } from 'class-transformer';
+import { IsArray, IsBoolean, IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator';
+import { toBoolean } from '../../../../../apps/immich/src/utils/transform.util';
+
+export class SearchDto {
+  @IsString()
+  @IsNotEmpty()
+  @IsOptional()
+  query?: string;
+
+  @IsEnum(AssetType)
+  @IsOptional()
+  type?: AssetType;
+
+  @IsBoolean()
+  @IsOptional()
+  @Transform(toBoolean)
+  isFavorite?: boolean;
+
+  @IsString()
+  @IsNotEmpty()
+  @IsOptional()
+  'exifInfo.city'?: string;
+
+  @IsString()
+  @IsNotEmpty()
+  @IsOptional()
+  'exifInfo.state'?: string;
+
+  @IsString()
+  @IsNotEmpty()
+  @IsOptional()
+  'exifInfo.country'?: string;
+
+  @IsString()
+  @IsNotEmpty()
+  @IsOptional()
+  'exifInfo.make'?: string;
+
+  @IsString()
+  @IsNotEmpty()
+  @IsOptional()
+  'exifInfo.model'?: string;
+
+  @IsString({ each: true })
+  @IsArray()
+  @IsOptional()
+  @Transform(({ value }) => value.split(','))
+  'smartInfo.objects'?: string[];
+
+  @IsString({ each: true })
+  @IsArray()
+  @IsOptional()
+  @Transform(({ value }) => value.split(','))
+  'smartInfo.tags'?: string[];
+}

+ 4 - 0
server/libs/domain/src/search/index.ts

@@ -0,0 +1,4 @@
+export * from './dto';
+export * from './response-dto';
+export * from './search.repository';
+export * from './search.service';

+ 2 - 0
server/libs/domain/src/search/response-dto/index.ts

@@ -0,0 +1,2 @@
+export * from './search-config-response.dto';
+export * from './search-response.dto';

+ 3 - 0
server/libs/domain/src/search/response-dto/search-config-response.dto.ts

@@ -0,0 +1,3 @@
+export class SearchConfigResponseDto {
+  enabled!: boolean;
+}

+ 37 - 0
server/libs/domain/src/search/response-dto/search-response.dto.ts

@@ -0,0 +1,37 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { AlbumResponseDto } from '../../album';
+import { AssetResponseDto } from '../../asset';
+
+class SearchFacetCountResponseDto {
+  @ApiProperty({ type: 'integer' })
+  count!: number;
+  value!: string;
+}
+
+class SearchFacetResponseDto {
+  fieldName!: string;
+  counts!: SearchFacetCountResponseDto[];
+}
+
+class SearchAlbumResponseDto {
+  @ApiProperty({ type: 'integer' })
+  total!: number;
+  @ApiProperty({ type: 'integer' })
+  count!: number;
+  items!: AlbumResponseDto[];
+  facets!: SearchFacetResponseDto[];
+}
+
+class SearchAssetResponseDto {
+  @ApiProperty({ type: 'integer' })
+  total!: number;
+  @ApiProperty({ type: 'integer' })
+  count!: number;
+  items!: AssetResponseDto[];
+  facets!: SearchFacetResponseDto[];
+}
+
+export class SearchResponseDto {
+  albums!: SearchAlbumResponseDto;
+  assets!: SearchAssetResponseDto;
+}

+ 60 - 0
server/libs/domain/src/search/search.repository.ts

@@ -0,0 +1,60 @@
+import { AlbumEntity, AssetEntity, AssetType } from '@app/infra/db/entities';
+
+export enum SearchCollection {
+  ASSETS = 'assets',
+  ALBUMS = 'albums',
+}
+
+export interface SearchFilter {
+  id?: string;
+  userId: string;
+  type?: AssetType;
+  isFavorite?: boolean;
+  city?: string;
+  state?: string;
+  country?: string;
+  make?: string;
+  model?: string;
+  objects?: string[];
+  tags?: string[];
+}
+
+export interface SearchResult<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 - 0
server/libs/domain/src/search/search.service.spec.ts

@@ -0,0 +1,317 @@
+import { BadRequestException } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { plainToInstance } from 'class-transformer';
+import {
+  albumStub,
+  assetEntityStub,
+  authStub,
+  newAlbumRepositoryMock,
+  newAssetRepositoryMock,
+  newJobRepositoryMock,
+  newSearchRepositoryMock,
+} from '../../test';
+import { IAlbumRepository } from '../album/album.repository';
+import { IAssetRepository } from '../asset/asset.repository';
+import { JobName } from '../job';
+import { IJobRepository } from '../job/job.repository';
+import { SearchDto } from './dto';
+import { ISearchRepository } from './search.repository';
+import { SearchService } from './search.service';
+
+describe(SearchService.name, () => {
+  let sut: SearchService;
+  let albumMock: jest.Mocked<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 - 0
server/libs/domain/src/search/search.service.ts

@@ -0,0 +1,154 @@
+import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { IAlbumRepository } from '../album/album.repository';
+import { IAssetRepository } from '../asset/asset.repository';
+import { AuthUserDto } from '../auth';
+import { IAlbumJob, IAssetJob, IDeleteJob, IJobRepository, JobName } from '../job';
+import { SearchDto } from './dto';
+import { SearchConfigResponseDto, SearchResponseDto } from './response-dto';
+import { ISearchRepository, SearchCollection } from './search.repository';
+
+@Injectable()
+export class SearchService {
+  private logger = new Logger(SearchService.name);
+  private enabled: boolean;
+
+  constructor(
+    @Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
+    @Inject(IAssetRepository) private assetRepository: IAssetRepository,
+    @Inject(IJobRepository) private jobRepository: IJobRepository,
+    @Inject(ISearchRepository) private searchRepository: ISearchRepository,
+    configService: ConfigService,
+  ) {
+    this.enabled = configService.get('TYPESENSE_ENABLED') !== 'false';
+  }
+
+  isEnabled() {
+    return this.enabled;
+  }
+
+  getConfig(): SearchConfigResponseDto {
+    return {
+      enabled: this.enabled,
+    };
+  }
+
+  async bootstrap() {
+    if (!this.enabled) {
+      return;
+    }
+
+    this.logger.log('Running bootstrap');
+    await this.searchRepository.setup();
+
+    const migrationStatus = await this.searchRepository.checkMigrationStatus();
+    if (migrationStatus[SearchCollection.ASSETS]) {
+      this.logger.debug('Queueing job to re-index all assets');
+      await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSETS });
+    }
+    if (migrationStatus[SearchCollection.ALBUMS]) {
+      this.logger.debug('Queueing job to re-index all albums');
+      await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ALBUMS });
+    }
+  }
+
+  async search(authUser: AuthUserDto, dto: SearchDto): Promise<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);
+    }
+  }
+}

+ 2 - 0
server/libs/domain/test/album.repository.mock.ts

@@ -3,5 +3,7 @@ import { IAlbumRepository } from '../src';
 export const newAlbumRepositoryMock = (): jest.Mocked<IAlbumRepository> => {
   return {
     deleteAll: jest.fn(),
+    getAll: jest.fn(),
+    save: jest.fn(),
   };
 };

+ 16 - 0
server/libs/domain/test/fixtures.ts

@@ -1,4 +1,5 @@
 import {
+  AlbumEntity,
   APIKeyEntity,
   AssetEntity,
   AssetType,
@@ -155,6 +156,21 @@ export const assetEntityStub = {
   } as AssetEntity),
 };
 
+export const albumStub = {
+  empty: Object.freeze<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 = {
   make: 'camera-make',
   model: 'camera-model',

+ 1 - 0
server/libs/domain/test/index.ts

@@ -6,6 +6,7 @@ export * from './device-info.repository.mock';
 export * from './fixtures';
 export * from './job.repository.mock';
 export * from './machine-learning.repository.mock';
+export * from './search.repository.mock';
 export * from './shared-link.repository.mock';
 export * from './smart-info.repository.mock';
 export * from './storage.repository.mock';

+ 12 - 0
server/libs/domain/test/search.repository.mock.ts

@@ -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(),
+  };
+};

+ 9 - 0
server/libs/infra/src/db/repository/album.repository.ts

@@ -11,4 +11,13 @@ export class AlbumRepository implements IAlbumRepository {
   async deleteAll(userId: string): Promise<void> {
     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 } });
+  }
 }

+ 23 - 4
server/libs/infra/src/db/repository/asset.repository.ts

@@ -1,4 +1,4 @@
-import { IAssetRepository } from '@app/domain';
+import { AssetSearchOptions, IAssetRepository } from '@app/domain';
 import { Injectable } from '@nestjs/common';
 import { InjectRepository } from '@nestjs/typeorm';
 import { Not, Repository } from 'typeorm';
@@ -12,13 +12,32 @@ export class AssetRepository implements IAssetRepository {
     await this.repository.delete({ ownerId });
   }
 
-  async getAll(): Promise<AssetEntity[]> {
-    return this.repository.find({ relations: { exifInfo: true } });
+  getAll(options?: AssetSearchOptions | undefined): Promise<AssetEntity[]> {
+    options = options || {};
+
+    return this.repository.find({
+      where: {
+        isVisible: options.isVisible,
+      },
+      relations: {
+        exifInfo: true,
+        smartInfo: true,
+        tags: true,
+      },
+    });
   }
 
   async save(asset: Partial<AssetEntity>): Promise<AssetEntity> {
     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> {

+ 3 - 1
server/libs/infra/src/infra.module.ts

@@ -8,6 +8,7 @@ import {
   IKeyRepository,
   IMachineLearningRepository,
   IMediaRepository,
+  ISearchRepository,
   ISharedLinkRepository,
   ISmartInfoRepository,
   IStorageRepository,
@@ -45,6 +46,7 @@ import {
 import { JobRepository } from './job';
 import { MachineLearningRepository } from './machine-learning';
 import { MediaRepository } from './media';
+import { TypesenseRepository } from './search';
 import { FilesystemProvider } from './storage';
 
 const providers: Provider[] = [
@@ -52,12 +54,12 @@ const providers: Provider[] = [
   { provide: IAssetRepository, useClass: AssetRepository },
   { provide: ICommunicationRepository, useClass: CommunicationRepository },
   { provide: ICryptoRepository, useClass: CryptoRepository },
-  { provide: ICryptoRepository, useClass: CryptoRepository },
   { provide: IDeviceInfoRepository, useClass: DeviceInfoRepository },
   { provide: IKeyRepository, useClass: APIKeyRepository },
   { provide: IJobRepository, useClass: JobRepository },
   { provide: IMachineLearningRepository, useClass: MachineLearningRepository },
   { provide: IMediaRepository, useClass: MediaRepository },
+  { provide: ISearchRepository, useClass: TypesenseRepository },
   { provide: ISharedLinkRepository, useClass: SharedLinkRepository },
   { provide: ISmartInfoRepository, useClass: SmartInfoRepository },
   { provide: IStorageRepository, useClass: FilesystemProvider },

+ 13 - 0
server/libs/infra/src/job/job.repository.ts

@@ -13,6 +13,7 @@ export class JobRepository implements IJobRepository {
     @InjectQueue(QueueName.STORAGE_TEMPLATE_MIGRATION) private storageTemplateMigration: Queue,
     @InjectQueue(QueueName.THUMBNAIL_GENERATION) private thumbnail: Queue,
     @InjectQueue(QueueName.VIDEO_CONVERSION) private videoTranscode: Queue<IAssetJob>,
+    @InjectQueue(QueueName.SEARCH) private searchIndex: Queue,
   ) {}
 
   async isActive(name: QueueName): Promise<boolean> {
@@ -70,6 +71,18 @@ export class JobRepository implements IJobRepository {
         await this.videoTranscode.add(item.name, item.data);
         break;
 
+      case JobName.SEARCH_INDEX_ASSETS:
+      case JobName.SEARCH_INDEX_ALBUMS:
+        await this.searchIndex.add(item.name);
+        break;
+
+      case JobName.SEARCH_INDEX_ASSET:
+      case JobName.SEARCH_INDEX_ALBUM:
+      case JobName.SEARCH_REMOVE_ALBUM:
+      case JobName.SEARCH_REMOVE_ASSET:
+        await this.searchIndex.add(item.name, item.data);
+        break;
+
       default:
         // TODO inject remaining queues and map job to queue
         this.logger.error('Invalid job', item);

+ 1 - 0
server/libs/infra/src/search/index.ts

@@ -0,0 +1 @@
+export * from './typesense.repository';

+ 13 - 0
server/libs/infra/src/search/schemas/album.schema.ts

@@ -0,0 +1,13 @@
+import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
+
+export const albumSchemaVersion = 1;
+export const albumSchema: CollectionCreateSchema = {
+  name: `albums-v${albumSchemaVersion}`,
+  fields: [
+    { name: 'ownerId', type: 'string', facet: false },
+    { name: 'albumName', type: 'string', facet: false, sort: true },
+    { name: 'createdAt', type: 'string', facet: false, sort: true },
+    { name: 'updatedAt', type: 'string', facet: false, sort: true },
+  ],
+  default_sorting_field: 'createdAt',
+};

+ 37 - 0
server/libs/infra/src/search/schemas/asset.schema.ts

@@ -0,0 +1,37 @@
+import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
+
+export const assetSchemaVersion = 1;
+export const assetSchema: CollectionCreateSchema = {
+  name: `assets-v${assetSchemaVersion}`,
+  fields: [
+    // asset
+    { name: 'ownerId', type: 'string', facet: false },
+    { name: 'type', type: 'string', facet: true },
+    { name: 'originalPath', type: 'string', facet: false },
+    { name: 'createdAt', type: 'string', facet: false, sort: true },
+    { name: 'updatedAt', type: 'string', facet: false, sort: true },
+    { name: 'fileCreatedAt', type: 'string', facet: false, sort: true },
+    { name: 'fileModifiedAt', type: 'string', facet: false, sort: true },
+    { name: 'isFavorite', type: 'bool', facet: true },
+    // { name: 'checksum', type: 'string', facet: true },
+    // { name: 'tags', type: 'string[]', facet: true, optional: true },
+
+    // exif
+    { name: 'exifInfo.city', type: 'string', facet: true, optional: true },
+    { name: 'exifInfo.country', type: 'string', facet: true, optional: true },
+    { name: 'exifInfo.state', type: 'string', facet: true, optional: true },
+    { name: 'exifInfo.description', type: 'string', facet: false, optional: true },
+    { name: 'exifInfo.imageName', type: 'string', facet: false, optional: true },
+    { name: 'geo', type: 'geopoint', facet: false, optional: true },
+    { name: 'exifInfo.make', type: 'string', facet: true, optional: true },
+    { name: 'exifInfo.model', type: 'string', facet: true, optional: true },
+    { name: 'exifInfo.orientation', type: 'string', optional: true },
+
+    // smart info
+    { name: 'smartInfo.objects', type: 'string[]', facet: true, optional: true },
+    { name: 'smartInfo.tags', type: 'string[]', facet: true, optional: true },
+  ],
+  token_separators: ['.'],
+  enable_nested_fields: true,
+  default_sorting_field: 'fileCreatedAt',
+};

+ 325 - 0
server/libs/infra/src/search/typesense.repository.ts

@@ -0,0 +1,325 @@
+import {
+  ISearchRepository,
+  SearchCollection,
+  SearchCollectionIndexStatus,
+  SearchFilter,
+  SearchResult,
+} from '@app/domain';
+import { Injectable, Logger } from '@nestjs/common';
+import _, { Dictionary } from 'lodash';
+import { Client } from 'typesense';
+import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
+import { DocumentSchema, SearchResponse } from 'typesense/lib/Typesense/Documents';
+import { AlbumEntity, AssetEntity } from '../db';
+import { albumSchema } from './schemas/album.schema';
+import { assetSchema } from './schemas/asset.schema';
+
+interface GeoAssetEntity extends AssetEntity {
+  geo?: [number, number];
+}
+
+function removeNil<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;
+  }
+}

+ 71 - 2
server/package-lock.json

@@ -6,9 +6,10 @@
   "packages": {
     "": {
       "name": "immich",
-      "version": "1.49.0",
+      "version": "1.50.1",
       "license": "UNLICENSED",
       "dependencies": {
+        "@babel/runtime": "^7.20.13",
         "@nestjs/bull": "^0.6.2",
         "@nestjs/common": "^9.2.1",
         "@nestjs/config": "^2.2.0",
@@ -46,7 +47,8 @@
         "rxjs": "^7.2.0",
         "sanitize-filename": "^1.6.3",
         "sharp": "^0.28.0",
-        "typeorm": "^0.3.11"
+        "typeorm": "^0.3.11",
+        "typesense": "^1.5.2"
       },
       "bin": {
         "immich": "bin/cli.sh"
@@ -765,6 +767,17 @@
         "@babel/core": "^7.0.0-0"
       }
     },
+    "node_modules/@babel/runtime": {
+      "version": "7.20.13",
+      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.13.tgz",
+      "integrity": "sha512-gt3PKXs0DBoL9xCvOIIZ2NEqAGZqHjAnmVbfQtB620V0uReIQutpel14KcneZuer7UioY8ALKZ7iocavvzTNFA==",
+      "dependencies": {
+        "regenerator-runtime": "^0.13.11"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
     "node_modules/@babel/template": {
       "version": "7.16.7",
       "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.16.7.tgz",
@@ -8104,6 +8117,18 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/loglevel": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.8.1.tgz",
+      "integrity": "sha512-tCRIJM51SHjAayKwC+QAg8hT8vg6z7GSgLJKGvzuPb1Wc+hLzqtuVLxp6/HzSPOozuK+8ErAhy7U/sVzw8Dgfg==",
+      "engines": {
+        "node": ">= 0.6.0"
+      },
+      "funding": {
+        "type": "tidelift",
+        "url": "https://tidelift.com/funding/github/npm/loglevel"
+      }
+    },
     "node_modules/lru-cache": {
       "version": "6.0.0",
       "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
@@ -9498,6 +9523,11 @@
       "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz",
       "integrity": "sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg=="
     },
+    "node_modules/regenerator-runtime": {
+      "version": "0.13.11",
+      "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
+      "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="
+    },
     "node_modules/regexpp": {
       "version": "3.2.0",
       "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz",
@@ -11106,6 +11136,18 @@
         "node": ">=4.2.0"
       }
     },
+    "node_modules/typesense": {
+      "version": "1.5.2",
+      "resolved": "https://registry.npmjs.org/typesense/-/typesense-1.5.2.tgz",
+      "integrity": "sha512-ysARFw+4z3AdSViOACqf7K9TXoP2wAXd5p5uSGTdXW14UYjcEzpV/S/EhMoiC6YdZyrnbDdNsxgWbf+AWJ9Udw==",
+      "dependencies": {
+        "axios": "^0.26.0",
+        "loglevel": "^1.8.0"
+      },
+      "peerDependencies": {
+        "@babel/runtime": "^7.17.2"
+      }
+    },
     "node_modules/uglify-js": {
       "version": "3.17.4",
       "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz",
@@ -12115,6 +12157,14 @@
         "@babel/helper-plugin-utils": "^7.16.7"
       }
     },
+    "@babel/runtime": {
+      "version": "7.20.13",
+      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.13.tgz",
+      "integrity": "sha512-gt3PKXs0DBoL9xCvOIIZ2NEqAGZqHjAnmVbfQtB620V0uReIQutpel14KcneZuer7UioY8ALKZ7iocavvzTNFA==",
+      "requires": {
+        "regenerator-runtime": "^0.13.11"
+      }
+    },
     "@babel/template": {
       "version": "7.16.7",
       "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.16.7.tgz",
@@ -17808,6 +17858,11 @@
         "is-unicode-supported": "^0.1.0"
       }
     },
+    "loglevel": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.8.1.tgz",
+      "integrity": "sha512-tCRIJM51SHjAayKwC+QAg8hT8vg6z7GSgLJKGvzuPb1Wc+hLzqtuVLxp6/HzSPOozuK+8ErAhy7U/sVzw8Dgfg=="
+    },
     "lru-cache": {
       "version": "6.0.0",
       "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
@@ -18862,6 +18917,11 @@
       "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz",
       "integrity": "sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg=="
     },
+    "regenerator-runtime": {
+      "version": "0.13.11",
+      "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
+      "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="
+    },
     "regexpp": {
       "version": "3.2.0",
       "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz",
@@ -19962,6 +20022,15 @@
       "integrity": "sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==",
       "devOptional": true
     },
+    "typesense": {
+      "version": "1.5.2",
+      "resolved": "https://registry.npmjs.org/typesense/-/typesense-1.5.2.tgz",
+      "integrity": "sha512-ysARFw+4z3AdSViOACqf7K9TXoP2wAXd5p5uSGTdXW14UYjcEzpV/S/EhMoiC6YdZyrnbDdNsxgWbf+AWJ9Udw==",
+      "requires": {
+        "axios": "^0.26.0",
+        "loglevel": "^1.8.0"
+      }
+    },
     "uglify-js": {
       "version": "3.17.4",
       "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz",

+ 3 - 1
server/package.json

@@ -39,6 +39,7 @@
     "api:generate": "bash ./bin/generate-open-api.sh"
   },
   "dependencies": {
+    "@babel/runtime": "^7.20.13",
     "@nestjs/bull": "^0.6.2",
     "@nestjs/common": "^9.2.1",
     "@nestjs/config": "^2.2.0",
@@ -76,7 +77,8 @@
     "rxjs": "^7.2.0",
     "sanitize-filename": "^1.6.3",
     "sharp": "^0.28.0",
-    "typeorm": "^0.3.11"
+    "typeorm": "^0.3.11",
+    "typesense": "^1.5.2"
   },
   "devDependencies": {
     "@nestjs/cli": "^9.1.8",

+ 3 - 0
web/src/api/api.ts

@@ -8,6 +8,7 @@ import {
 	DeviceInfoApi,
 	JobApi,
 	OAuthApi,
+	SearchApi,
 	ServerInfoApi,
 	ShareApi,
 	SystemConfigApi,
@@ -21,6 +22,7 @@ export class ImmichApi {
 	public authenticationApi: AuthenticationApi;
 	public oauthApi: OAuthApi;
 	public deviceInfoApi: DeviceInfoApi;
+	public searchApi: SearchApi;
 	public serverInfoApi: ServerInfoApi;
 	public jobApi: JobApi;
 	public keyApi: APIKeyApi;
@@ -41,6 +43,7 @@ export class ImmichApi {
 		this.serverInfoApi = new ServerInfoApi(this.config);
 		this.jobApi = new JobApi(this.config);
 		this.keyApi = new APIKeyApi(this.config);
+		this.searchApi = new SearchApi(this.config);
 		this.systemConfigApi = new SystemConfigApi(this.config);
 		this.shareApi = new ShareApi(this.config);
 	}

+ 374 - 0
web/src/api/open-api/api.ts

@@ -1451,6 +1451,37 @@ export interface RemoveAssetsDto {
      */
     '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
@@ -1464,6 +1495,107 @@ export interface SearchAssetDto {
      */
     'searchTerm': string;
 }
+/**
+ * 
+ * @export
+ * @interface SearchAssetResponseDto
+ */
+export interface SearchAssetResponseDto {
+    /**
+     * 
+     * @type {number}
+     * @memberof SearchAssetResponseDto
+     */
+    'total': number;
+    /**
+     * 
+     * @type {number}
+     * @memberof SearchAssetResponseDto
+     */
+    'count': number;
+    /**
+     * 
+     * @type {Array<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
@@ -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
  * @export

+ 1 - 1
web/src/app.d.ts

@@ -13,7 +13,7 @@ declare namespace App {
 	interface Error {
 		message: string;
 		stack?: string;
-		code?: string;
+		code?: string | number;
 	}
 }
 

+ 17 - 4
web/src/hooks.server.ts

@@ -1,5 +1,5 @@
 import type { Handle, HandleServerError } from '@sveltejs/kit';
-import { AxiosError } from 'axios';
+import { AxiosError, AxiosResponse } from 'axios';
 import { env } from '$env/dynamic/public';
 import { ImmichApi } from './api/api';
 
@@ -34,11 +34,24 @@ export const handle = (async ({ event, resolve }) => {
 	return res;
 }) satisfies Handle;
 
+const DEFAULT_MESSAGE = 'Hmm, not sure about that. Check the logs or open a ticket?';
+
 export const handleError: HandleServerError = async ({ error }) => {
 	const httpError = error as AxiosError;
+	const response = httpError?.response as AxiosResponse<{
+		message: string;
+		statusCode: number;
+		error: string;
+	}>;
+
+	let code = response?.data?.statusCode || response?.status || httpError.code || '500';
+	if (response) {
+		code += ` - ${response.data?.error || response.statusText}`;
+	}
+
 	return {
-		message: httpError?.message || 'Hmm, not sure about that. Check the logs or open a ticket?',
-		stack: httpError?.stack,
-		code: httpError.code || '500'
+		message: response?.data?.message || httpError?.message || DEFAULT_MESSAGE,
+		code,
+		stack: httpError?.stack
 	};
 };

+ 1 - 1
web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte

@@ -22,7 +22,7 @@
 
 	$: {
 		if (assets.length < 6) {
-			thumbnailSize = Math.floor(viewWidth / assets.length - assets.length);
+			thumbnailSize = Math.min(320, Math.floor(viewWidth / assets.length - assets.length));
 		} else {
 			if (viewWidth > 600) thumbnailSize = Math.floor(viewWidth / 6 - 6);
 			else if (viewWidth > 400) thumbnailSize = Math.floor(viewWidth / 4 - 6);

+ 12 - 3
web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte

@@ -11,6 +11,7 @@
 	import ImmichLogo from '../immich-logo.svelte';
 	export let user: UserResponseDto;
 	export let shouldShowUploadButton = true;
+	export let term = '';
 
 	let shouldShowAccountInfo = false;
 
@@ -35,6 +36,10 @@
 
 		goto(data.redirectUri || '/auth/login?autoLaunch=0');
 	};
+
+	const onSearch = () => {
+		goto(`/search?q=${term}`);
+	};
 </script>
 
 <section
@@ -52,12 +57,16 @@
 				IMMICH
 			</h1>
 		</a>
-		<div class="flex-1 ml-24">
+		<form class="flex-1 ml-24" autocomplete="off" on:submit|preventDefault={onSearch}>
 			<input
+				type="text"
+				name="search"
 				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">
 			<ThemeButton />
 

+ 26 - 0
web/src/routes/(user)/search/+page.server.ts

@@ -0,0 +1,26 @@
+import { redirect } from '@sveltejs/kit';
+import type { PageServerLoad } from './$types';
+
+export const load = (async ({ locals, parent, url }) => {
+	const { user } = await parent();
+	if (!user) {
+		throw redirect(302, '/auth/login');
+	}
+
+	const term = url.searchParams.get('q') || undefined;
+
+	const { data: results } = await locals.api.searchApi.search(
+		term,
+		undefined,
+		undefined,
+		undefined,
+		undefined,
+		undefined,
+		undefined,
+		undefined,
+		undefined,
+		undefined,
+		{ params: url.searchParams }
+	);
+	return { user, term, results };
+}) satisfies PageServerLoad;

+ 27 - 0
web/src/routes/(user)/search/+page.svelte

@@ -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>

+ 1 - 1
web/src/routes/+error.svelte

@@ -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="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}
 								<label for="stacktrace">Stacktrace</label>
 								<pre id="stacktrace" class="text-xs">{$page.error?.stack || 'No stack'}</pre>