Browse Source

feat(web,server): explore (#1926)

* feat: explore

* chore: generate open api

* styling explore page

* styling no result page

* style overlay

* style: bluring text on thumbnail card for readability

* explore page tweaks

* fix(web): search urls

* feat(web): use objects for things

* feat(server): filter by motion, sort by createdAt

* More styling

* better navigation

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>
Jason Rasmussen 2 years ago
parent
commit
2ca560ebf8
35 changed files with 1079 additions and 63 deletions
  1. 6 0
      mobile/openapi/.openapi-generator/FILES
  2. 3 0
      mobile/openapi/README.md
  3. 56 2
      mobile/openapi/doc/SearchApi.md
  4. 16 0
      mobile/openapi/doc/SearchExploreItem.md
  5. 16 0
      mobile/openapi/doc/SearchExploreResponseDto.md
  6. 2 0
      mobile/openapi/lib/api.dart
  7. 64 3
      mobile/openapi/lib/api/search_api.dart
  8. 4 0
      mobile/openapi/lib/api_client.dart
  9. 119 0
      mobile/openapi/lib/model/search_explore_item.dart
  10. 119 0
      mobile/openapi/lib/model/search_explore_response_dto.dart
  11. 8 1
      mobile/openapi/test/search_api_test.dart
  12. 32 0
      mobile/openapi/test/search_explore_item_test.dart
  13. 32 0
      mobile/openapi/test/search_explore_response_dto_test.dart
  14. 13 3
      server/apps/immich/src/controllers/search.controller.ts
  15. 3 3
      server/apps/microservices/src/processors/metadata-extraction.processor.ts
  16. 77 7
      server/immich-openapi-specs.json
  17. 6 6
      server/libs/domain/src/asset/asset.core.ts
  18. 5 6
      server/libs/domain/src/asset/asset.service.spec.ts
  19. 1 3
      server/libs/domain/src/asset/asset.service.ts
  20. 10 0
      server/libs/domain/src/search/dto/search.dto.ts
  21. 1 0
      server/libs/domain/src/search/response-dto/index.ts
  22. 11 0
      server/libs/domain/src/search/response-dto/search-explore.response.dto.ts
  23. 12 0
      server/libs/domain/src/search/search.repository.ts
  24. 19 4
      server/libs/domain/src/search/search.service.ts
  25. 1 0
      server/libs/domain/test/search.repository.mock.ts
  26. 5 2
      server/libs/infra/src/search/schemas/asset.schema.ts
  27. 79 8
      server/libs/infra/src/search/typesense.repository.ts
  28. 123 7
      web/src/api/open-api/api.ts
  29. 4 2
      web/src/lib/components/shared-components/immich-thumbnail.svelte
  30. 13 0
      web/src/lib/components/shared-components/side-bar/side-bar.svelte
  31. 1 1
      web/src/lib/constants.ts
  32. 13 0
      web/src/routes/(user)/explore/+page.server.ts
  33. 173 0
      web/src/routes/(user)/explore/+page.svelte
  34. 2 1
      web/src/routes/(user)/search/+page.server.ts
  35. 30 4
      web/src/routes/(user)/search/+page.svelte

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

@@ -66,6 +66,8 @@ doc/SearchApi.md
 doc/SearchAssetDto.md
 doc/SearchAssetDto.md
 doc/SearchAssetResponseDto.md
 doc/SearchAssetResponseDto.md
 doc/SearchConfigResponseDto.md
 doc/SearchConfigResponseDto.md
+doc/SearchExploreItem.md
+doc/SearchExploreResponseDto.md
 doc/SearchFacetCountResponseDto.md
 doc/SearchFacetCountResponseDto.md
 doc/SearchFacetResponseDto.md
 doc/SearchFacetResponseDto.md
 doc/SearchResponseDto.md
 doc/SearchResponseDto.md
@@ -179,6 +181,8 @@ lib/model/search_album_response_dto.dart
 lib/model/search_asset_dto.dart
 lib/model/search_asset_dto.dart
 lib/model/search_asset_response_dto.dart
 lib/model/search_asset_response_dto.dart
 lib/model/search_config_response_dto.dart
 lib/model/search_config_response_dto.dart
+lib/model/search_explore_item.dart
+lib/model/search_explore_response_dto.dart
 lib/model/search_facet_count_response_dto.dart
 lib/model/search_facet_count_response_dto.dart
 lib/model/search_facet_response_dto.dart
 lib/model/search_facet_response_dto.dart
 lib/model/search_response_dto.dart
 lib/model/search_response_dto.dart
@@ -273,6 +277,8 @@ test/search_api_test.dart
 test/search_asset_dto_test.dart
 test/search_asset_dto_test.dart
 test/search_asset_response_dto_test.dart
 test/search_asset_response_dto_test.dart
 test/search_config_response_dto_test.dart
 test/search_config_response_dto_test.dart
+test/search_explore_item_test.dart
+test/search_explore_response_dto_test.dart
 test/search_facet_count_response_dto_test.dart
 test/search_facet_count_response_dto_test.dart
 test/search_facet_response_dto_test.dart
 test/search_facet_response_dto_test.dart
 test/search_response_dto_test.dart
 test/search_response_dto_test.dart

+ 3 - 0
mobile/openapi/README.md

@@ -121,6 +121,7 @@ Class | Method | HTTP request | Description
 *OAuthApi* | [**link**](doc//OAuthApi.md#link) | **POST** /oauth/link | 
 *OAuthApi* | [**link**](doc//OAuthApi.md#link) | **POST** /oauth/link | 
 *OAuthApi* | [**mobileRedirect**](doc//OAuthApi.md#mobileredirect) | **GET** /oauth/mobile-redirect | 
 *OAuthApi* | [**mobileRedirect**](doc//OAuthApi.md#mobileredirect) | **GET** /oauth/mobile-redirect | 
 *OAuthApi* | [**unlink**](doc//OAuthApi.md#unlink) | **POST** /oauth/unlink | 
 *OAuthApi* | [**unlink**](doc//OAuthApi.md#unlink) | **POST** /oauth/unlink | 
+*SearchApi* | [**getExploreData**](doc//SearchApi.md#getexploredata) | **GET** /search/explore | 
 *SearchApi* | [**getSearchConfig**](doc//SearchApi.md#getsearchconfig) | **GET** /search/config | 
 *SearchApi* | [**getSearchConfig**](doc//SearchApi.md#getsearchconfig) | **GET** /search/config | 
 *SearchApi* | [**search**](doc//SearchApi.md#search) | **GET** /search | 
 *SearchApi* | [**search**](doc//SearchApi.md#search) | **GET** /search | 
 *ServerInfoApi* | [**getServerInfo**](doc//ServerInfoApi.md#getserverinfo) | **GET** /server-info | 
 *ServerInfoApi* | [**getServerInfo**](doc//ServerInfoApi.md#getserverinfo) | **GET** /server-info | 
@@ -210,6 +211,8 @@ Class | Method | HTTP request | Description
  - [SearchAssetDto](doc//SearchAssetDto.md)
  - [SearchAssetDto](doc//SearchAssetDto.md)
  - [SearchAssetResponseDto](doc//SearchAssetResponseDto.md)
  - [SearchAssetResponseDto](doc//SearchAssetResponseDto.md)
  - [SearchConfigResponseDto](doc//SearchConfigResponseDto.md)
  - [SearchConfigResponseDto](doc//SearchConfigResponseDto.md)
+ - [SearchExploreItem](doc//SearchExploreItem.md)
+ - [SearchExploreResponseDto](doc//SearchExploreResponseDto.md)
  - [SearchFacetCountResponseDto](doc//SearchFacetCountResponseDto.md)
  - [SearchFacetCountResponseDto](doc//SearchFacetCountResponseDto.md)
  - [SearchFacetResponseDto](doc//SearchFacetResponseDto.md)
  - [SearchFacetResponseDto](doc//SearchFacetResponseDto.md)
  - [SearchResponseDto](doc//SearchResponseDto.md)
  - [SearchResponseDto](doc//SearchResponseDto.md)

+ 56 - 2
mobile/openapi/doc/SearchApi.md

@@ -9,10 +9,60 @@ All URIs are relative to */api*
 
 
 Method | HTTP request | Description
 Method | HTTP request | Description
 ------------- | ------------- | -------------
 ------------- | ------------- | -------------
+[**getExploreData**](SearchApi.md#getexploredata) | **GET** /search/explore | 
 [**getSearchConfig**](SearchApi.md#getsearchconfig) | **GET** /search/config | 
 [**getSearchConfig**](SearchApi.md#getsearchconfig) | **GET** /search/config | 
 [**search**](SearchApi.md#search) | **GET** /search | 
 [**search**](SearchApi.md#search) | **GET** /search | 
 
 
 
 
+# **getExploreData**
+> List<SearchExploreResponseDto> getExploreData()
+
+
+
+
+
+### 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.getExploreData();
+    print(result);
+} catch (e) {
+    print('Exception when calling SearchApi->getExploreData: $e\n');
+}
+```
+
+### Parameters
+This endpoint does not need any parameter.
+
+### Return type
+
+[**List<SearchExploreResponseDto>**](SearchExploreResponseDto.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)
+
 # **getSearchConfig**
 # **getSearchConfig**
 > SearchConfigResponseDto getSearchConfig()
 > SearchConfigResponseDto getSearchConfig()
 
 
@@ -63,7 +113,7 @@ This endpoint does not need any parameter.
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 [[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**
 # **search**
-> SearchResponseDto search(query, type, isFavorite, exifInfoPeriodCity, exifInfoPeriodState, exifInfoPeriodCountry, exifInfoPeriodMake, exifInfoPeriodModel, smartInfoPeriodObjects, smartInfoPeriodTags)
+> SearchResponseDto search(query, type, isFavorite, exifInfoPeriodCity, exifInfoPeriodState, exifInfoPeriodCountry, exifInfoPeriodMake, exifInfoPeriodModel, smartInfoPeriodObjects, smartInfoPeriodTags, recent, motion)
 
 
 
 
 
 
@@ -94,9 +144,11 @@ final exifInfoPeriodMake = exifInfoPeriodMake_example; // String |
 final exifInfoPeriodModel = exifInfoPeriodModel_example; // String | 
 final exifInfoPeriodModel = exifInfoPeriodModel_example; // String | 
 final smartInfoPeriodObjects = []; // List<String> | 
 final smartInfoPeriodObjects = []; // List<String> | 
 final smartInfoPeriodTags = []; // List<String> | 
 final smartInfoPeriodTags = []; // List<String> | 
+final recent = true; // bool | 
+final motion = true; // bool | 
 
 
 try {
 try {
-    final result = api_instance.search(query, type, isFavorite, exifInfoPeriodCity, exifInfoPeriodState, exifInfoPeriodCountry, exifInfoPeriodMake, exifInfoPeriodModel, smartInfoPeriodObjects, smartInfoPeriodTags);
+    final result = api_instance.search(query, type, isFavorite, exifInfoPeriodCity, exifInfoPeriodState, exifInfoPeriodCountry, exifInfoPeriodMake, exifInfoPeriodModel, smartInfoPeriodObjects, smartInfoPeriodTags, recent, motion);
     print(result);
     print(result);
 } catch (e) {
 } catch (e) {
     print('Exception when calling SearchApi->search: $e\n');
     print('Exception when calling SearchApi->search: $e\n');
@@ -117,6 +169,8 @@ Name | Type | Description  | Notes
  **exifInfoPeriodModel** | **String**|  | [optional] 
  **exifInfoPeriodModel** | **String**|  | [optional] 
  **smartInfoPeriodObjects** | [**List<String>**](String.md)|  | [optional] [default to const []]
  **smartInfoPeriodObjects** | [**List<String>**](String.md)|  | [optional] [default to const []]
  **smartInfoPeriodTags** | [**List<String>**](String.md)|  | [optional] [default to const []]
  **smartInfoPeriodTags** | [**List<String>**](String.md)|  | [optional] [default to const []]
+ **recent** | **bool**|  | [optional] 
+ **motion** | **bool**|  | [optional] 
 
 
 ### Return type
 ### Return type
 
 

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

@@ -0,0 +1,16 @@
+# openapi.model.SearchExploreItem
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**value** | **String** |  | 
+**data** | [**AssetResponseDto**](AssetResponseDto.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)
+
+

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

@@ -0,0 +1,16 @@
+# openapi.model.SearchExploreResponseDto
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**fieldName** | **String** |  | 
+**items** | [**List<SearchExploreItem>**](SearchExploreItem.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)
+
+

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

@@ -97,6 +97,8 @@ part 'model/search_album_response_dto.dart';
 part 'model/search_asset_dto.dart';
 part 'model/search_asset_dto.dart';
 part 'model/search_asset_response_dto.dart';
 part 'model/search_asset_response_dto.dart';
 part 'model/search_config_response_dto.dart';
 part 'model/search_config_response_dto.dart';
+part 'model/search_explore_item.dart';
+part 'model/search_explore_response_dto.dart';
 part 'model/search_facet_count_response_dto.dart';
 part 'model/search_facet_count_response_dto.dart';
 part 'model/search_facet_response_dto.dart';
 part 'model/search_facet_response_dto.dart';
 part 'model/search_response_dto.dart';
 part 'model/search_response_dto.dart';

+ 64 - 3
mobile/openapi/lib/api/search_api.dart

@@ -16,6 +16,53 @@ class SearchApi {
 
 
   final ApiClient apiClient;
   final ApiClient apiClient;
 
 
+  /// 
+  ///
+  /// Note: This method returns the HTTP [Response].
+  Future<Response> getExploreDataWithHttpInfo() async {
+    // ignore: prefer_const_declarations
+    final path = r'/search/explore';
+
+    // 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<List<SearchExploreResponseDto>?> getExploreData() async {
+    final response = await getExploreDataWithHttpInfo();
+    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) {
+      final responseBody = await _decodeBodyBytes(response);
+      return (await apiClient.deserializeAsync(responseBody, 'List<SearchExploreResponseDto>') as List)
+        .cast<SearchExploreResponseDto>()
+        .toList();
+
+    }
+    return null;
+  }
+
   /// 
   /// 
   ///
   ///
   /// Note: This method returns the HTTP [Response].
   /// Note: This method returns the HTTP [Response].
@@ -85,7 +132,11 @@ class SearchApi {
   /// * [List<String>] smartInfoPeriodObjects:
   /// * [List<String>] smartInfoPeriodObjects:
   ///
   ///
   /// * [List<String>] smartInfoPeriodTags:
   /// * [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 {
+  ///
+  /// * [bool] recent:
+  ///
+  /// * [bool] motion:
+  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, bool? recent, bool? motion, }) async {
     // ignore: prefer_const_declarations
     // ignore: prefer_const_declarations
     final path = r'/search';
     final path = r'/search';
 
 
@@ -126,6 +177,12 @@ class SearchApi {
     if (smartInfoPeriodTags != null) {
     if (smartInfoPeriodTags != null) {
       queryParams.addAll(_queryParams('multi', 'smartInfo.tags', smartInfoPeriodTags));
       queryParams.addAll(_queryParams('multi', 'smartInfo.tags', smartInfoPeriodTags));
     }
     }
+    if (recent != null) {
+      queryParams.addAll(_queryParams('', 'recent', recent));
+    }
+    if (motion != null) {
+      queryParams.addAll(_queryParams('', 'motion', motion));
+    }
 
 
     const contentTypes = <String>[];
     const contentTypes = <String>[];
 
 
@@ -164,8 +221,12 @@ class SearchApi {
   /// * [List<String>] smartInfoPeriodObjects:
   /// * [List<String>] smartInfoPeriodObjects:
   ///
   ///
   /// * [List<String>] smartInfoPeriodTags:
   /// * [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, );
+  ///
+  /// * [bool] recent:
+  ///
+  /// * [bool] motion:
+  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, bool? recent, bool? motion, }) 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, recent: recent, motion: motion, );
     if (response.statusCode >= HttpStatus.badRequest) {
     if (response.statusCode >= HttpStatus.badRequest) {
       throw ApiException(response.statusCode, await _decodeBodyBytes(response));
       throw ApiException(response.statusCode, await _decodeBodyBytes(response));
     }
     }

+ 4 - 0
mobile/openapi/lib/api_client.dart

@@ -302,6 +302,10 @@ class ApiClient {
           return SearchAssetResponseDto.fromJson(value);
           return SearchAssetResponseDto.fromJson(value);
         case 'SearchConfigResponseDto':
         case 'SearchConfigResponseDto':
           return SearchConfigResponseDto.fromJson(value);
           return SearchConfigResponseDto.fromJson(value);
+        case 'SearchExploreItem':
+          return SearchExploreItem.fromJson(value);
+        case 'SearchExploreResponseDto':
+          return SearchExploreResponseDto.fromJson(value);
         case 'SearchFacetCountResponseDto':
         case 'SearchFacetCountResponseDto':
           return SearchFacetCountResponseDto.fromJson(value);
           return SearchFacetCountResponseDto.fromJson(value);
         case 'SearchFacetResponseDto':
         case 'SearchFacetResponseDto':

+ 119 - 0
mobile/openapi/lib/model/search_explore_item.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 SearchExploreItem {
+  /// Returns a new [SearchExploreItem] instance.
+  SearchExploreItem({
+    required this.value,
+    required this.data,
+  });
+
+  String value;
+
+  AssetResponseDto data;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is SearchExploreItem &&
+     other.value == value &&
+     other.data == data;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (value.hashCode) +
+    (data.hashCode);
+
+  @override
+  String toString() => 'SearchExploreItem[value=$value, data=$data]';
+
+  Map<String, dynamic> toJson() {
+    final json = <String, dynamic>{};
+      json[r'value'] = this.value;
+      json[r'data'] = this.data;
+    return json;
+  }
+
+  /// Returns a new [SearchExploreItem] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static SearchExploreItem? 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 "SearchExploreItem[$key]" is missing from JSON.');
+          assert(json[key] != null, 'Required key "SearchExploreItem[$key]" has a null value in JSON.');
+        });
+        return true;
+      }());
+
+      return SearchExploreItem(
+        value: mapValueOfType<String>(json, r'value')!,
+        data: AssetResponseDto.fromJson(json[r'data'])!,
+      );
+    }
+    return null;
+  }
+
+  static List<SearchExploreItem>? listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <SearchExploreItem>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = SearchExploreItem.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, SearchExploreItem> mapFromJson(dynamic json) {
+    final map = <String, SearchExploreItem>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = SearchExploreItem.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of SearchExploreItem-objects as value to a dart map
+  static Map<String, List<SearchExploreItem>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<SearchExploreItem>>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = SearchExploreItem.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>{
+    'value',
+    'data',
+  };
+}
+

+ 119 - 0
mobile/openapi/lib/model/search_explore_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 SearchExploreResponseDto {
+  /// Returns a new [SearchExploreResponseDto] instance.
+  SearchExploreResponseDto({
+    required this.fieldName,
+    this.items = const [],
+  });
+
+  String fieldName;
+
+  List<SearchExploreItem> items;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is SearchExploreResponseDto &&
+     other.fieldName == fieldName &&
+     other.items == items;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (fieldName.hashCode) +
+    (items.hashCode);
+
+  @override
+  String toString() => 'SearchExploreResponseDto[fieldName=$fieldName, items=$items]';
+
+  Map<String, dynamic> toJson() {
+    final json = <String, dynamic>{};
+      json[r'fieldName'] = this.fieldName;
+      json[r'items'] = this.items;
+    return json;
+  }
+
+  /// Returns a new [SearchExploreResponseDto] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static SearchExploreResponseDto? 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 "SearchExploreResponseDto[$key]" is missing from JSON.');
+          assert(json[key] != null, 'Required key "SearchExploreResponseDto[$key]" has a null value in JSON.');
+        });
+        return true;
+      }());
+
+      return SearchExploreResponseDto(
+        fieldName: mapValueOfType<String>(json, r'fieldName')!,
+        items: SearchExploreItem.listFromJson(json[r'items'])!,
+      );
+    }
+    return null;
+  }
+
+  static List<SearchExploreResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <SearchExploreResponseDto>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = SearchExploreResponseDto.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, SearchExploreResponseDto> mapFromJson(dynamic json) {
+    final map = <String, SearchExploreResponseDto>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = SearchExploreResponseDto.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of SearchExploreResponseDto-objects as value to a dart map
+  static Map<String, List<SearchExploreResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<SearchExploreResponseDto>>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = SearchExploreResponseDto.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',
+    'items',
+  };
+}
+

+ 8 - 1
mobile/openapi/test/search_api_test.dart

@@ -17,6 +17,13 @@ void main() {
   // final instance = SearchApi();
   // final instance = SearchApi();
 
 
   group('tests for SearchApi', () {
   group('tests for SearchApi', () {
+    // 
+    //
+    //Future<List<SearchExploreResponseDto>> getExploreData() async
+    test('test getExploreData', () async {
+      // TODO
+    });
+
     // 
     // 
     //
     //
     //Future<SearchConfigResponseDto> getSearchConfig() async
     //Future<SearchConfigResponseDto> getSearchConfig() async
@@ -26,7 +33,7 @@ void main() {
 
 
     // 
     // 
     //
     //
-    //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
+    //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, bool recent, bool motion }) async
     test('test search', () async {
     test('test search', () async {
       // TODO
       // TODO
     });
     });

+ 32 - 0
mobile/openapi/test/search_explore_item_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 SearchExploreItem
+void main() {
+  // final instance = SearchExploreItem();
+
+  group('test SearchExploreItem', () {
+    // String value
+    test('to test the property `value`', () async {
+      // TODO
+    });
+
+    // AssetResponseDto data
+    test('to test the property `data`', () async {
+      // TODO
+    });
+
+
+  });
+
+}

+ 32 - 0
mobile/openapi/test/search_explore_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 SearchExploreResponseDto
+void main() {
+  // final instance = SearchExploreResponseDto();
+
+  group('test SearchExploreResponseDto', () {
+    // String fieldName
+    test('to test the property `fieldName`', () async {
+      // TODO
+    });
+
+    // List<SearchExploreItem> items (default value: const [])
+    test('to test the property `items`', () async {
+      // TODO
+    });
+
+
+  });
+
+}

+ 13 - 3
server/apps/immich/src/controllers/search.controller.ts

@@ -1,4 +1,11 @@
-import { AuthUserDto, SearchConfigResponseDto, SearchDto, SearchResponseDto, SearchService } from '@app/domain';
+import {
+  AuthUserDto,
+  SearchConfigResponseDto,
+  SearchDto,
+  SearchExploreResponseDto,
+  SearchResponseDto,
+  SearchService,
+} from '@app/domain';
 import { Controller, Get, Query, ValidationPipe } from '@nestjs/common';
 import { Controller, Get, Query, ValidationPipe } from '@nestjs/common';
 import { ApiTags } from '@nestjs/swagger';
 import { ApiTags } from '@nestjs/swagger';
 import { GetAuthUser } from '../decorators/auth-user.decorator';
 import { GetAuthUser } from '../decorators/auth-user.decorator';
@@ -10,7 +17,6 @@ import { Authenticated } from '../decorators/authenticated.decorator';
 export class SearchController {
 export class SearchController {
   constructor(private readonly searchService: SearchService) {}
   constructor(private readonly searchService: SearchService) {}
 
 
-  @Authenticated()
   @Get()
   @Get()
   async search(
   async search(
     @GetAuthUser() authUser: AuthUserDto,
     @GetAuthUser() authUser: AuthUserDto,
@@ -19,9 +25,13 @@ export class SearchController {
     return this.searchService.search(authUser, dto);
     return this.searchService.search(authUser, dto);
   }
   }
 
 
-  @Authenticated()
   @Get('config')
   @Get('config')
   getSearchConfig(): SearchConfigResponseDto {
   getSearchConfig(): SearchConfigResponseDto {
     return this.searchService.getConfig();
     return this.searchService.getConfig();
   }
   }
+
+  @Get('explore')
+  getExploreData(@GetAuthUser() authUser: AuthUserDto): Promise<SearchExploreResponseDto[]> {
+    return this.searchService.getExploreData(authUser) as Promise<SearchExploreResponseDto[]>;
+  }
 }
 }

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

@@ -2,8 +2,8 @@ import {
   AssetCore,
   AssetCore,
   IAssetRepository,
   IAssetRepository,
   IAssetUploadedJob,
   IAssetUploadedJob,
+  IJobRepository,
   IReverseGeocodingJob,
   IReverseGeocodingJob,
-  ISearchRepository,
   JobName,
   JobName,
   QueueName,
   QueueName,
 } from '@app/domain';
 } from '@app/domain';
@@ -86,14 +86,14 @@ export class MetadataExtractionProcessor {
 
 
   constructor(
   constructor(
     @Inject(IAssetRepository) assetRepository: IAssetRepository,
     @Inject(IAssetRepository) assetRepository: IAssetRepository,
-    @Inject(ISearchRepository) searchRepository: ISearchRepository,
+    @Inject(IJobRepository) jobRepository: IJobRepository,
 
 
     @InjectRepository(ExifEntity)
     @InjectRepository(ExifEntity)
     private exifRepository: Repository<ExifEntity>,
     private exifRepository: Repository<ExifEntity>,
 
 
     configService: ConfigService,
     configService: ConfigService,
   ) {
   ) {
-    this.assetCore = new AssetCore(assetRepository, searchRepository);
+    this.assetCore = new AssetCore(assetRepository, jobRepository);
 
 
     if (!configService.get('DISABLE_REVERSE_GEOCODING')) {
     if (!configService.get('DISABLE_REVERSE_GEOCODING')) {
       this.logger.log('Initializing Reverse Geocoding');
       this.logger.log('Initializing Reverse Geocoding');

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

@@ -640,6 +640,22 @@
                 "type": "string"
                 "type": "string"
               }
               }
             }
             }
+          },
+          {
+            "name": "recent",
+            "required": false,
+            "in": "query",
+            "schema": {
+              "type": "boolean"
+            }
+          },
+          {
+            "name": "motion",
+            "required": false,
+            "in": "query",
+            "schema": {
+              "type": "boolean"
+            }
           }
           }
         ],
         ],
         "responses": {
         "responses": {
@@ -658,12 +674,6 @@
           "Search"
           "Search"
         ],
         ],
         "security": [
         "security": [
-          {
-            "bearer": []
-          },
-          {
-            "cookie": []
-          },
           {
           {
             "bearer": []
             "bearer": []
           },
           },
@@ -699,7 +709,34 @@
           },
           },
           {
           {
             "cookie": []
             "cookie": []
-          },
+          }
+        ]
+      }
+    },
+    "/search/explore": {
+      "get": {
+        "operationId": "getExploreData",
+        "description": "",
+        "parameters": [],
+        "responses": {
+          "200": {
+            "description": "",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "array",
+                  "items": {
+                    "$ref": "#/components/schemas/SearchExploreResponseDto"
+                  }
+                }
+              }
+            }
+          }
+        },
+        "tags": [
+          "Search"
+        ],
+        "security": [
           {
           {
             "bearer": []
             "bearer": []
           },
           },
@@ -4149,6 +4186,39 @@
           "enabled"
           "enabled"
         ]
         ]
       },
       },
+      "SearchExploreItem": {
+        "type": "object",
+        "properties": {
+          "value": {
+            "type": "string"
+          },
+          "data": {
+            "$ref": "#/components/schemas/AssetResponseDto"
+          }
+        },
+        "required": [
+          "value",
+          "data"
+        ]
+      },
+      "SearchExploreResponseDto": {
+        "type": "object",
+        "properties": {
+          "fieldName": {
+            "type": "string"
+          },
+          "items": {
+            "type": "array",
+            "items": {
+              "$ref": "#/components/schemas/SearchExploreItem"
+            }
+          }
+        },
+        "required": [
+          "fieldName",
+          "items"
+        ]
+      },
       "SharedLinkType": {
       "SharedLinkType": {
         "type": "string",
         "type": "string",
         "enum": [
         "enum": [

+ 6 - 6
server/libs/domain/src/asset/asset.core.ts

@@ -1,21 +1,21 @@
 import { AssetEntity, AssetType } from '@app/infra/db/entities';
 import { AssetEntity, AssetType } from '@app/infra/db/entities';
-import { ISearchRepository, SearchCollection } from '../search/search.repository';
+import { IJobRepository, JobName } from '../job';
 import { AssetSearchOptions, IAssetRepository } from './asset.repository';
 import { AssetSearchOptions, IAssetRepository } from './asset.repository';
 
 
 export class AssetCore {
 export class AssetCore {
-  constructor(private repository: IAssetRepository, private searchRepository: ISearchRepository) {}
+  constructor(private assetRepository: IAssetRepository, private jobRepository: IJobRepository) {}
 
 
   getAll(options: AssetSearchOptions) {
   getAll(options: AssetSearchOptions) {
-    return this.repository.getAll(options);
+    return this.assetRepository.getAll(options);
   }
   }
 
 
   async save(asset: Partial<AssetEntity>) {
   async save(asset: Partial<AssetEntity>) {
-    const _asset = await this.repository.save(asset);
-    await this.searchRepository.index(SearchCollection.ASSETS, _asset);
+    const _asset = await this.assetRepository.save(asset);
+    await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { asset: _asset } });
     return _asset;
     return _asset;
   }
   }
 
 
   findLivePhotoMatch(livePhotoCID: string, otherAssetId: string, type: AssetType): Promise<AssetEntity | null> {
   findLivePhotoMatch(livePhotoCID: string, otherAssetId: string, type: AssetType): Promise<AssetEntity | null> {
-    return this.repository.findLivePhotoMatch(livePhotoCID, otherAssetId, type);
+    return this.assetRepository.findLivePhotoMatch(livePhotoCID, otherAssetId, type);
   }
   }
 }
 }

+ 5 - 6
server/libs/domain/src/asset/asset.service.spec.ts

@@ -1,15 +1,12 @@
 import { AssetEntity, AssetType } from '@app/infra/db/entities';
 import { AssetEntity, AssetType } from '@app/infra/db/entities';
 import { assetEntityStub, newAssetRepositoryMock, newJobRepositoryMock } from '../../test';
 import { assetEntityStub, newAssetRepositoryMock, newJobRepositoryMock } from '../../test';
-import { newSearchRepositoryMock } from '../../test/search.repository.mock';
 import { AssetService, IAssetRepository } from '../asset';
 import { AssetService, IAssetRepository } from '../asset';
 import { IJobRepository, JobName } from '../job';
 import { IJobRepository, JobName } from '../job';
-import { ISearchRepository } from '../search';
 
 
 describe(AssetService.name, () => {
 describe(AssetService.name, () => {
   let sut: AssetService;
   let sut: AssetService;
   let assetMock: jest.Mocked<IAssetRepository>;
   let assetMock: jest.Mocked<IAssetRepository>;
   let jobMock: jest.Mocked<IJobRepository>;
   let jobMock: jest.Mocked<IJobRepository>;
-  let searchMock: jest.Mocked<ISearchRepository>;
 
 
   it('should work', () => {
   it('should work', () => {
     expect(sut).toBeDefined();
     expect(sut).toBeDefined();
@@ -18,8 +15,7 @@ describe(AssetService.name, () => {
   beforeEach(async () => {
   beforeEach(async () => {
     assetMock = newAssetRepositoryMock();
     assetMock = newAssetRepositoryMock();
     jobMock = newJobRepositoryMock();
     jobMock = newJobRepositoryMock();
-    searchMock = newSearchRepositoryMock();
-    sut = new AssetService(assetMock, jobMock, searchMock);
+    sut = new AssetService(assetMock, jobMock);
   });
   });
 
 
   describe(`handle asset upload`, () => {
   describe(`handle asset upload`, () => {
@@ -56,7 +52,10 @@ describe(AssetService.name, () => {
       await sut.save(assetEntityStub.image);
       await sut.save(assetEntityStub.image);
 
 
       expect(assetMock.save).toHaveBeenCalledWith(assetEntityStub.image);
       expect(assetMock.save).toHaveBeenCalledWith(assetEntityStub.image);
-      expect(searchMock.index).toHaveBeenCalledWith('assets', assetEntityStub.image);
+      expect(jobMock.queue).toHaveBeenCalledWith({
+        name: JobName.SEARCH_INDEX_ASSET,
+        data: { asset: assetEntityStub.image },
+      });
     });
     });
   });
   });
 });
 });

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

@@ -1,7 +1,6 @@
 import { AssetEntity, AssetType } from '@app/infra/db/entities';
 import { AssetEntity, AssetType } from '@app/infra/db/entities';
 import { Inject } from '@nestjs/common';
 import { Inject } from '@nestjs/common';
 import { IAssetUploadedJob, IJobRepository, JobName } from '../job';
 import { IAssetUploadedJob, IJobRepository, JobName } from '../job';
-import { ISearchRepository } from '../search';
 import { AssetCore } from './asset.core';
 import { AssetCore } from './asset.core';
 import { IAssetRepository } from './asset.repository';
 import { IAssetRepository } from './asset.repository';
 
 
@@ -11,9 +10,8 @@ export class AssetService {
   constructor(
   constructor(
     @Inject(IAssetRepository) assetRepository: IAssetRepository,
     @Inject(IAssetRepository) assetRepository: IAssetRepository,
     @Inject(IJobRepository) private jobRepository: IJobRepository,
     @Inject(IJobRepository) private jobRepository: IJobRepository,
-    @Inject(ISearchRepository) searchRepository: ISearchRepository,
   ) {
   ) {
-    this.assetCore = new AssetCore(assetRepository, searchRepository);
+    this.assetCore = new AssetCore(assetRepository, jobRepository);
   }
   }
 
 
   async handleAssetUpload(data: IAssetUploadedJob) {
   async handleAssetUpload(data: IAssetUploadedJob) {

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

@@ -54,4 +54,14 @@ export class SearchDto {
   @IsOptional()
   @IsOptional()
   @Transform(({ value }) => value.split(','))
   @Transform(({ value }) => value.split(','))
   'smartInfo.tags'?: string[];
   'smartInfo.tags'?: string[];
+
+  @IsBoolean()
+  @IsOptional()
+  @Transform(toBoolean)
+  recent?: boolean;
+
+  @IsBoolean()
+  @IsOptional()
+  @Transform(toBoolean)
+  motion?: boolean;
 }
 }

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

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

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

@@ -0,0 +1,11 @@
+import { AssetResponseDto } from '../../asset';
+
+class SearchExploreItem {
+  value!: string;
+  data!: AssetResponseDto;
+}
+
+export class SearchExploreResponseDto {
+  fieldName!: string;
+  items!: SearchExploreItem[];
+}

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

@@ -17,6 +17,8 @@ export interface SearchFilter {
   model?: string;
   model?: string;
   objects?: string[];
   objects?: string[];
   tags?: string[];
   tags?: string[];
+  recent?: boolean;
+  motion?: boolean;
 }
 }
 
 
 export interface SearchResult<T> {
 export interface SearchResult<T> {
@@ -39,6 +41,14 @@ export interface SearchFacet {
   }>;
   }>;
 }
 }
 
 
+export interface SearchExploreItem<T> {
+  fieldName: string;
+  items: Array<{
+    value: string;
+    data: T;
+  }>;
+}
+
 export type SearchCollectionIndexStatus = Record<SearchCollection, boolean>;
 export type SearchCollectionIndexStatus = Record<SearchCollection, boolean>;
 
 
 export const ISearchRepository = 'ISearchRepository';
 export const ISearchRepository = 'ISearchRepository';
@@ -57,4 +67,6 @@ export interface ISearchRepository {
 
 
   search(collection: SearchCollection.ASSETS, query: string, filters: SearchFilter): Promise<SearchResult<AssetEntity>>;
   search(collection: SearchCollection.ASSETS, query: string, filters: SearchFilter): Promise<SearchResult<AssetEntity>>;
   search(collection: SearchCollection.ALBUMS, query: string, filters: SearchFilter): Promise<SearchResult<AlbumEntity>>;
   search(collection: SearchCollection.ALBUMS, query: string, filters: SearchFilter): Promise<SearchResult<AlbumEntity>>;
+
+  explore(userId: string): Promise<SearchExploreItem<AssetEntity>[]>;
 }
 }

+ 19 - 4
server/libs/domain/src/search/search.service.ts

@@ -1,3 +1,4 @@
+import { AssetEntity } from '@app/infra/db/entities';
 import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
 import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
 import { ConfigService } from '@nestjs/config';
 import { ConfigService } from '@nestjs/config';
 import { IAlbumRepository } from '../album/album.repository';
 import { IAlbumRepository } from '../album/album.repository';
@@ -6,7 +7,7 @@ import { AuthUserDto } from '../auth';
 import { IAlbumJob, IAssetJob, IDeleteJob, IJobRepository, JobName } from '../job';
 import { IAlbumJob, IAssetJob, IDeleteJob, IJobRepository, JobName } from '../job';
 import { SearchDto } from './dto';
 import { SearchDto } from './dto';
 import { SearchConfigResponseDto, SearchResponseDto } from './response-dto';
 import { SearchConfigResponseDto, SearchResponseDto } from './response-dto';
-import { ISearchRepository, SearchCollection } from './search.repository';
+import { ISearchRepository, SearchCollection, SearchExploreItem } from './search.repository';
 
 
 @Injectable()
 @Injectable()
 export class SearchService {
 export class SearchService {
@@ -52,10 +53,13 @@ export class SearchService {
     }
     }
   }
   }
 
 
+  async getExploreData(authUser: AuthUserDto): Promise<SearchExploreItem<AssetEntity>[]> {
+    this.assertEnabled();
+    return this.searchRepository.explore(authUser.id);
+  }
+
   async search(authUser: AuthUserDto, dto: SearchDto): Promise<SearchResponseDto> {
   async search(authUser: AuthUserDto, dto: SearchDto): Promise<SearchResponseDto> {
-    if (!this.enabled) {
-      throw new BadRequestException('Search is disabled');
-    }
+    this.assertEnabled();
 
 
     const query = dto.query || '*';
     const query = dto.query || '*';
 
 
@@ -83,6 +87,7 @@ export class SearchService {
 
 
       this.logger.log(`Indexing ${assets.length} assets`);
       this.logger.log(`Indexing ${assets.length} assets`);
       await this.searchRepository.import(SearchCollection.ASSETS, assets, true);
       await this.searchRepository.import(SearchCollection.ASSETS, assets, true);
+      this.logger.debug('Finished re-indexing all assets');
     } catch (error: any) {
     } catch (error: any) {
       this.logger.error(`Unable to index all assets`, error?.stack);
       this.logger.error(`Unable to index all assets`, error?.stack);
     }
     }
@@ -94,6 +99,9 @@ export class SearchService {
     }
     }
 
 
     const { asset } = data;
     const { asset } = data;
+    if (!asset.isVisible) {
+      return;
+    }
 
 
     try {
     try {
       await this.searchRepository.index(SearchCollection.ASSETS, asset);
       await this.searchRepository.index(SearchCollection.ASSETS, asset);
@@ -111,6 +119,7 @@ export class SearchService {
       const albums = await this.albumRepository.getAll();
       const albums = await this.albumRepository.getAll();
       this.logger.log(`Indexing ${albums.length} albums`);
       this.logger.log(`Indexing ${albums.length} albums`);
       await this.searchRepository.import(SearchCollection.ALBUMS, albums, true);
       await this.searchRepository.import(SearchCollection.ALBUMS, albums, true);
+      this.logger.debug('Finished re-indexing all albums');
     } catch (error: any) {
     } catch (error: any) {
       this.logger.error(`Unable to index all albums`, error?.stack);
       this.logger.error(`Unable to index all albums`, error?.stack);
     }
     }
@@ -151,4 +160,10 @@ export class SearchService {
       this.logger.error(`Unable to remove ${collection}: ${id}`, error?.stack);
       this.logger.error(`Unable to remove ${collection}: ${id}`, error?.stack);
     }
     }
   }
   }
+
+  private assertEnabled() {
+    if (!this.enabled) {
+      throw new BadRequestException('Search is disabled');
+    }
+  }
 }
 }

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

@@ -8,5 +8,6 @@ export const newSearchRepositoryMock = (): jest.Mocked<ISearchRepository> => {
     import: jest.fn(),
     import: jest.fn(),
     search: jest.fn(),
     search: jest.fn(),
     delete: jest.fn(),
     delete: jest.fn(),
+    explore: jest.fn(),
   };
   };
 };
 };

+ 5 - 2
server/libs/infra/src/search/schemas/asset.schema.ts

@@ -1,6 +1,6 @@
 import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
 import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
 
 
-export const assetSchemaVersion = 1;
+export const assetSchemaVersion = 2;
 export const assetSchema: CollectionCreateSchema = {
 export const assetSchema: CollectionCreateSchema = {
   name: `assets-v${assetSchemaVersion}`,
   name: `assets-v${assetSchemaVersion}`,
   fields: [
   fields: [
@@ -22,7 +22,6 @@ export const assetSchema: CollectionCreateSchema = {
     { name: 'exifInfo.state', 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.description', type: 'string', facet: false, optional: true },
     { name: 'exifInfo.imageName', 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.make', type: 'string', facet: true, optional: true },
     { name: 'exifInfo.model', type: 'string', facet: true, optional: true },
     { name: 'exifInfo.model', type: 'string', facet: true, optional: true },
     { name: 'exifInfo.orientation', type: 'string', optional: true },
     { name: 'exifInfo.orientation', type: 'string', optional: true },
@@ -30,6 +29,10 @@ export const assetSchema: CollectionCreateSchema = {
     // smart info
     // smart info
     { name: 'smartInfo.objects', type: 'string[]', facet: true, optional: true },
     { name: 'smartInfo.objects', type: 'string[]', facet: true, optional: true },
     { name: 'smartInfo.tags', type: 'string[]', facet: true, optional: true },
     { name: 'smartInfo.tags', type: 'string[]', facet: true, optional: true },
+
+    // computed
+    { name: 'geo', type: 'geopoint', facet: false, optional: true },
+    { name: 'motion', type: 'bool', facet: true },
   ],
   ],
   token_separators: ['.'],
   token_separators: ['.'],
   enable_nested_fields: true,
   enable_nested_fields: true,

+ 79 - 8
server/libs/infra/src/search/typesense.repository.ts

@@ -2,11 +2,13 @@ import {
   ISearchRepository,
   ISearchRepository,
   SearchCollection,
   SearchCollection,
   SearchCollectionIndexStatus,
   SearchCollectionIndexStatus,
+  SearchExploreItem,
   SearchFilter,
   SearchFilter,
   SearchResult,
   SearchResult,
 } from '@app/domain';
 } from '@app/domain';
 import { Injectable, Logger } from '@nestjs/common';
 import { Injectable, Logger } from '@nestjs/common';
 import _, { Dictionary } from 'lodash';
 import _, { Dictionary } from 'lodash';
+import { filter, firstValueFrom, from, map, mergeMap, toArray } from 'rxjs';
 import { Client } from 'typesense';
 import { Client } from 'typesense';
 import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
 import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
 import { DocumentSchema, SearchResponse } from 'typesense/lib/Typesense/Documents';
 import { DocumentSchema, SearchResponse } from 'typesense/lib/Typesense/Documents';
@@ -14,8 +16,9 @@ import { AlbumEntity, AssetEntity } from '../db';
 import { albumSchema } from './schemas/album.schema';
 import { albumSchema } from './schemas/album.schema';
 import { assetSchema } from './schemas/asset.schema';
 import { assetSchema } from './schemas/asset.schema';
 
 
-interface GeoAssetEntity extends AssetEntity {
+interface CustomAssetEntity extends AssetEntity {
   geo?: [number, number];
   geo?: [number, number];
+  motion?: boolean;
 }
 }
 
 
 function removeNil<T extends Dictionary<any>>(item: T): Partial<T> {
 function removeNil<T extends Dictionary<any>>(item: T): Partial<T> {
@@ -85,6 +88,12 @@ export class TypesenseRepository implements ISearchRepository {
   }
   }
 
 
   async setup(): Promise<void> {
   async setup(): Promise<void> {
+    const collections = await this.client.collections().retrieve();
+    for (const collection of collections) {
+      this.logger.debug(`${collection.name} => ${collection.num_documents}`);
+      // await this.client.collections(collection.name).delete();
+    }
+
     // upsert collections
     // upsert collections
     for (const [collectionName, schema] of schemas) {
     for (const [collectionName, schema] of schemas) {
       const collection = await this.client
       const collection = await this.client
@@ -172,6 +181,59 @@ export class TypesenseRepository implements ISearchRepository {
     }
     }
   }
   }
 
 
+  async explore(userId: string): Promise<SearchExploreItem<AssetEntity>[]> {
+    const alias = await this.client.aliases(SearchCollection.ASSETS).retrieve();
+
+    const common = {
+      q: '*',
+      filter_by: `ownerId:${userId}`,
+      per_page: 100,
+    };
+
+    const asset$ = this.client.collections<AssetEntity>(alias.collection_name).documents();
+
+    const { facet_counts: facets } = await asset$.search({
+      ...common,
+      query_by: 'exifInfo.imageName',
+      facet_by: this.getFacetFieldNames(SearchCollection.ASSETS),
+      max_facet_values: 50,
+    });
+
+    return firstValueFrom(
+      from(facets || []).pipe(
+        mergeMap(
+          (facet) =>
+            from(facet.counts).pipe(
+              mergeMap(
+                (count) =>
+                  from(
+                    asset$.search({
+                      ...common,
+                      query_by: 'exifInfo.imageName',
+                      filter_by: `${facet.field_name}:${count.value}`,
+                    }),
+                  ).pipe(
+                    map((result) => ({
+                      value: count.value,
+                      data: result.hits?.[0]?.document as AssetEntity,
+                    })),
+                    filter((item) => !!item.data),
+                  ),
+                5,
+              ),
+              toArray(),
+              map((items) => ({
+                fieldName: facet.field_name as string,
+                items,
+              })),
+            ),
+          3,
+        ),
+        toArray(),
+      ),
+    );
+  }
+
   search(collection: SearchCollection.ASSETS, query: string, filter: SearchFilter): Promise<SearchResult<AssetEntity>>;
   search(collection: SearchCollection.ASSETS, query: string, filter: SearchFilter): Promise<SearchResult<AssetEntity>>;
   search(collection: SearchCollection.ALBUMS, query: string, filter: SearchFilter): Promise<SearchResult<AlbumEntity>>;
   search(collection: SearchCollection.ALBUMS, query: string, filter: SearchFilter): Promise<SearchResult<AlbumEntity>>;
   async search(collection: SearchCollection, query: string, filters: SearchFilter) {
   async search(collection: SearchCollection, query: string, filters: SearchFilter) {
@@ -213,10 +275,8 @@ export class TypesenseRepository implements ISearchRepository {
           ].join(','),
           ].join(','),
           filter_by: _filters.join(' && '),
           filter_by: _filters.join(' && '),
           per_page: 250,
           per_page: 250,
-          facet_by: (assetSchema.fields || [])
-            .filter((field) => field.facet)
-            .map((field) => field.name)
-            .join(','),
+          sort_by: filters.recent ? 'createdAt:desc' : undefined,
+          facet_by: this.getFacetFieldNames(SearchCollection.ASSETS),
         });
         });
 
 
       return this.asResponse(results);
       return this.asResponse(results);
@@ -313,13 +373,24 @@ export class TypesenseRepository implements ISearchRepository {
     }
     }
   }
   }
 
 
-  private patchAsset(asset: AssetEntity): GeoAssetEntity {
+  private patchAsset(asset: AssetEntity): CustomAssetEntity {
+    let custom = asset as CustomAssetEntity;
+
     const lat = asset.exifInfo?.latitude;
     const lat = asset.exifInfo?.latitude;
     const lng = asset.exifInfo?.longitude;
     const lng = asset.exifInfo?.longitude;
     if (lat && lng && lat !== 0 && lng !== 0) {
     if (lat && lng && lat !== 0 && lng !== 0) {
-      return { ...asset, geo: [lat, lng] };
+      custom = { ...custom, geo: [lat, lng] };
     }
     }
 
 
-    return asset;
+    custom = { ...custom, motion: !!asset.livePhotoVideoId };
+
+    return custom;
+  }
+
+  private getFacetFieldNames(collection: SearchCollection) {
+    return (schemaMap[collection].fields || [])
+      .filter((field) => field.facet)
+      .map((field) => field.name)
+      .join(',');
   }
   }
 }
 }

+ 123 - 7
web/src/api/open-api/api.ts

@@ -1539,6 +1539,44 @@ export interface SearchConfigResponseDto {
      */
      */
     'enabled': boolean;
     'enabled': boolean;
 }
 }
+/**
+ * 
+ * @export
+ * @interface SearchExploreItem
+ */
+export interface SearchExploreItem {
+    /**
+     * 
+     * @type {string}
+     * @memberof SearchExploreItem
+     */
+    'value': string;
+    /**
+     * 
+     * @type {AssetResponseDto}
+     * @memberof SearchExploreItem
+     */
+    'data': AssetResponseDto;
+}
+/**
+ * 
+ * @export
+ * @interface SearchExploreResponseDto
+ */
+export interface SearchExploreResponseDto {
+    /**
+     * 
+     * @type {string}
+     * @memberof SearchExploreResponseDto
+     */
+    'fieldName': string;
+    /**
+     * 
+     * @type {Array<SearchExploreItem>}
+     * @memberof SearchExploreResponseDto
+     */
+    'items': Array<SearchExploreItem>;
+}
 /**
 /**
  * 
  * 
  * @export
  * @export
@@ -6629,6 +6667,41 @@ export class OAuthApi extends BaseAPI {
  */
  */
 export const SearchApiAxiosParamCreator = function (configuration?: Configuration) {
 export const SearchApiAxiosParamCreator = function (configuration?: Configuration) {
     return {
     return {
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        getExploreData: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            const localVarPath = `/search/explore`;
+            // 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 {*} [options] Override http request option.
          * @param {*} [options] Override http request option.
@@ -6676,10 +6749,12 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio
          * @param {string} [exifInfoModel] 
          * @param {string} [exifInfoModel] 
          * @param {Array<string>} [smartInfoObjects] 
          * @param {Array<string>} [smartInfoObjects] 
          * @param {Array<string>} [smartInfoTags] 
          * @param {Array<string>} [smartInfoTags] 
+         * @param {boolean} [recent] 
+         * @param {boolean} [motion] 
          * @param {*} [options] Override http request option.
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          * @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> => {
+        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>, recent?: boolean, motion?: boolean, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
             const localVarPath = `/search`;
             const localVarPath = `/search`;
             // use dummy base URL string because the URL constructor only accepts absolute URLs.
             // use dummy base URL string because the URL constructor only accepts absolute URLs.
             const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
             const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@@ -6738,6 +6813,14 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio
                 localVarQueryParameter['smartInfo.tags'] = smartInfoTags;
                 localVarQueryParameter['smartInfo.tags'] = smartInfoTags;
             }
             }
 
 
+            if (recent !== undefined) {
+                localVarQueryParameter['recent'] = recent;
+            }
+
+            if (motion !== undefined) {
+                localVarQueryParameter['motion'] = motion;
+            }
+
 
 
     
     
             setSearchParams(localVarUrlObj, localVarQueryParameter);
             setSearchParams(localVarUrlObj, localVarQueryParameter);
@@ -6759,6 +6842,15 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio
 export const SearchApiFp = function(configuration?: Configuration) {
 export const SearchApiFp = function(configuration?: Configuration) {
     const localVarAxiosParamCreator = SearchApiAxiosParamCreator(configuration)
     const localVarAxiosParamCreator = SearchApiAxiosParamCreator(configuration)
     return {
     return {
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async getExploreData(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<SearchExploreResponseDto>>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.getExploreData(options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
         /**
         /**
          * 
          * 
          * @param {*} [options] Override http request option.
          * @param {*} [options] Override http request option.
@@ -6780,11 +6872,13 @@ export const SearchApiFp = function(configuration?: Configuration) {
          * @param {string} [exifInfoModel] 
          * @param {string} [exifInfoModel] 
          * @param {Array<string>} [smartInfoObjects] 
          * @param {Array<string>} [smartInfoObjects] 
          * @param {Array<string>} [smartInfoTags] 
          * @param {Array<string>} [smartInfoTags] 
+         * @param {boolean} [recent] 
+         * @param {boolean} [motion] 
          * @param {*} [options] Override http request option.
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          * @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);
+        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>, recent?: boolean, motion?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SearchResponseDto>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.search(query, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, recent, motion, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
         },
     }
     }
@@ -6797,6 +6891,14 @@ export const SearchApiFp = function(configuration?: Configuration) {
 export const SearchApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
 export const SearchApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
     const localVarFp = SearchApiFp(configuration)
     const localVarFp = SearchApiFp(configuration)
     return {
     return {
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        getExploreData(options?: any): AxiosPromise<Array<SearchExploreResponseDto>> {
+            return localVarFp.getExploreData(options).then((request) => request(axios, basePath));
+        },
         /**
         /**
          * 
          * 
          * @param {*} [options] Override http request option.
          * @param {*} [options] Override http request option.
@@ -6817,11 +6919,13 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat
          * @param {string} [exifInfoModel] 
          * @param {string} [exifInfoModel] 
          * @param {Array<string>} [smartInfoObjects] 
          * @param {Array<string>} [smartInfoObjects] 
          * @param {Array<string>} [smartInfoTags] 
          * @param {Array<string>} [smartInfoTags] 
+         * @param {boolean} [recent] 
+         * @param {boolean} [motion] 
          * @param {*} [options] Override http request option.
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          * @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));
+        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>, recent?: boolean, motion?: boolean, options?: any): AxiosPromise<SearchResponseDto> {
+            return localVarFp.search(query, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, recent, motion, options).then((request) => request(axios, basePath));
         },
         },
     };
     };
 };
 };
@@ -6833,6 +6937,16 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat
  * @extends {BaseAPI}
  * @extends {BaseAPI}
  */
  */
 export class SearchApi extends BaseAPI {
 export class SearchApi extends BaseAPI {
+    /**
+     * 
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof SearchApi
+     */
+    public getExploreData(options?: AxiosRequestConfig) {
+        return SearchApiFp(this.configuration).getExploreData(options).then((request) => request(this.axios, this.basePath));
+    }
+
     /**
     /**
      * 
      * 
      * @param {*} [options] Override http request option.
      * @param {*} [options] Override http request option.
@@ -6855,12 +6969,14 @@ export class SearchApi extends BaseAPI {
      * @param {string} [exifInfoModel] 
      * @param {string} [exifInfoModel] 
      * @param {Array<string>} [smartInfoObjects] 
      * @param {Array<string>} [smartInfoObjects] 
      * @param {Array<string>} [smartInfoTags] 
      * @param {Array<string>} [smartInfoTags] 
+     * @param {boolean} [recent] 
+     * @param {boolean} [motion] 
      * @param {*} [options] Override http request option.
      * @param {*} [options] Override http request option.
      * @throws {RequiredError}
      * @throws {RequiredError}
      * @memberof SearchApi
      * @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));
+    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>, recent?: boolean, motion?: boolean, options?: AxiosRequestConfig) {
+        return SearchApiFp(this.configuration).search(query, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, recent, motion, options).then((request) => request(this.axios, this.basePath));
     }
     }
 }
 }
 
 

+ 4 - 2
web/src/lib/components/shared-components/immich-thumbnail.svelte

@@ -19,6 +19,7 @@
 	export let format: ThumbnailFormat = ThumbnailFormat.Webp;
 	export let format: ThumbnailFormat = ThumbnailFormat.Webp;
 	export let selected = false;
 	export let selected = false;
 	export let disabled = false;
 	export let disabled = false;
+	export let readonly = false;
 	export let publicSharedKey = '';
 	export let publicSharedKey = '';
 	export let isRoundedCorner = false;
 	export let isRoundedCorner = false;
 
 
@@ -56,6 +57,7 @@
 	};
 	};
 
 
 	const parseVideoDuration = (duration: string) => {
 	const parseVideoDuration = (duration: string) => {
+		duration = duration || '0:00:00.00000';
 		const timePart = duration.split(':');
 		const timePart = duration.split(':');
 		const hours = timePart[0];
 		const hours = timePart[0];
 		const minutes = timePart[1];
 		const minutes = timePart[1];
@@ -118,7 +120,7 @@
 		} else if (disabled) {
 		} else if (disabled) {
 			return 'border-[20px] border-gray-300';
 			return 'border-[20px] border-gray-300';
 		} else if (isRoundedCorner) {
 		} else if (isRoundedCorner) {
-			return 'rounded-[20px]';
+			return 'rounded-lg';
 		} else {
 		} else {
 			return '';
 			return '';
 		}
 		}
@@ -157,7 +159,7 @@
 		on:click={thumbnailClickedHandler}
 		on:click={thumbnailClickedHandler}
 		on:keydown={thumbnailClickedHandler}
 		on:keydown={thumbnailClickedHandler}
 	>
 	>
-		{#if mouseOver || selected || disabled}
+		{#if (mouseOver || selected || disabled) && !readonly}
 			<div
 			<div
 				in:fade={{ duration: 200 }}
 				in:fade={{ duration: 200 }}
 				class={`w-full ${getOverlaySelectorIconStyle()} via-white/0 to-white/0 absolute p-2 z-10`}
 				class={`w-full ${getOverlaySelectorIconStyle()} via-white/0 to-white/0 absolute p-2 z-10`}

+ 13 - 0
web/src/lib/components/shared-components/side-bar/side-bar.svelte

@@ -4,6 +4,7 @@
 	import AccountMultipleOutline from 'svelte-material-icons/AccountMultipleOutline.svelte';
 	import AccountMultipleOutline from 'svelte-material-icons/AccountMultipleOutline.svelte';
 	import ImageAlbum from 'svelte-material-icons/ImageAlbum.svelte';
 	import ImageAlbum from 'svelte-material-icons/ImageAlbum.svelte';
 	import ImageOutline from 'svelte-material-icons/ImageOutline.svelte';
 	import ImageOutline from 'svelte-material-icons/ImageOutline.svelte';
+	import Magnify from 'svelte-material-icons/Magnify.svelte';
 	import StarOutline from 'svelte-material-icons/StarOutline.svelte';
 	import StarOutline from 'svelte-material-icons/StarOutline.svelte';
 	import { AppRoute } from '../../../constants';
 	import { AppRoute } from '../../../constants';
 	import LoadingSpinner from '../loading-spinner.svelte';
 	import LoadingSpinner from '../loading-spinner.svelte';
@@ -62,6 +63,18 @@
 			</svelte:fragment>
 			</svelte:fragment>
 		</SideBarButton>
 		</SideBarButton>
 	</a>
 	</a>
+	<a
+		data-sveltekit-preload-data="hover"
+		data-sveltekit-noscroll
+		href={AppRoute.EXPLORE}
+		draggable="false"
+	>
+		<SideBarButton
+			title="Explore"
+			logo={Magnify}
+			isSelected={$page.route.id === '/(user)/explore'}
+		/>
+	</a>
 	<a data-sveltekit-preload-data="hover" href={AppRoute.SHARING} draggable="false">
 	<a data-sveltekit-preload-data="hover" href={AppRoute.SHARING} draggable="false">
 		<SideBarButton
 		<SideBarButton
 			title="Sharing"
 			title="Sharing"

+ 1 - 1
web/src/lib/constants.ts

@@ -10,7 +10,7 @@ export enum AppRoute {
 	ALBUMS = '/albums',
 	ALBUMS = '/albums',
 	FAVORITES = '/favorites',
 	FAVORITES = '/favorites',
 	PHOTOS = '/photos',
 	PHOTOS = '/photos',
+	EXPLORE = '/explore',
 	SHARING = '/sharing',
 	SHARING = '/sharing',
-
 	AUTH_LOGIN = '/auth/login'
 	AUTH_LOGIN = '/auth/login'
 }
 }

+ 13 - 0
web/src/routes/(user)/explore/+page.server.ts

@@ -0,0 +1,13 @@
+import { redirect } from '@sveltejs/kit';
+import type { PageServerLoad } from './$types';
+
+export const load = (async ({ locals, parent }) => {
+	const { user } = await parent();
+	if (!user) {
+		throw redirect(302, '/auth/login');
+	}
+
+	const { data: items } = await locals.api.searchApi.getExploreData();
+
+	return { user, items };
+}) satisfies PageServerLoad;

+ 173 - 0
web/src/routes/(user)/explore/+page.svelte

@@ -0,0 +1,173 @@
+<script lang="ts">
+	import ImmichThumbnail from '$lib/components/shared-components/immich-thumbnail.svelte';
+	import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
+	import SideBar from '$lib/components/shared-components/side-bar/side-bar.svelte';
+	import { AppRoute } from '$lib/constants';
+	import { AssetTypeEnum, SearchExploreItem } from '@api';
+	import ClockOutline from 'svelte-material-icons/ClockOutline.svelte';
+	import MotionPlayOutline from 'svelte-material-icons/MotionPlayOutline.svelte';
+	import PlayCircleOutline from 'svelte-material-icons/PlayCircleOutline.svelte';
+	import StarOutline from 'svelte-material-icons/StarOutline.svelte';
+	import type { PageData } from './$types';
+
+	export let data: PageData;
+
+	enum Field {
+		CITY = 'exifInfo.city',
+		TAGS = 'smartInfo.tags',
+		OBJECTS = 'smartInfo.objects'
+	}
+
+	const MAX_ITEMS = 12;
+
+	let things: SearchExploreItem[] = [];
+	let places: SearchExploreItem[] = [];
+
+	for (const item of data.items) {
+		switch (item.fieldName) {
+			case Field.OBJECTS:
+				things = item.items;
+				break;
+
+			case Field.CITY:
+				places = item.items;
+				break;
+		}
+	}
+
+	things = things.slice(0, MAX_ITEMS);
+	places = places.slice(0, MAX_ITEMS);
+</script>
+
+<section>
+	<NavigationBar user={data.user} shouldShowUploadButton={false} />
+</section>
+
+<section
+	class="grid grid-cols-[250px_auto] relative pt-[72px] h-screen bg-immich-bg dark:bg-immich-dark-bg"
+>
+	<SideBar />
+
+	<section class="overflow-y-auto relative immich-scrollbar">
+		<section
+			id="album-content"
+			class="relative pt-8 pl-4 mb-12 bg-immich-bg dark:bg-immich-dark-bg"
+		>
+			<!-- Main Section -->
+			<div class="px-4 flex justify-between place-items-center dark:text-immich-dark-fg">
+				<div>
+					<p class="font-medium">Explore</p>
+				</div>
+			</div>
+
+			<div class="my-4">
+				<hr class="dark:border-immich-dark-gray" />
+			</div>
+
+			<div class="mx-4 flex flex-col">
+				{#if places.length > 0}
+					<div class="mb-6 mt-2">
+						<div>
+							<p class="mb-4 dark:text-immich-dark-fg font-medium">Places</p>
+						</div>
+						<div class="flex flex-row flex-wrap gap-4">
+							{#each places as item}
+								<a class="relative" href="/search?{Field.CITY}={item.value}" draggable="false">
+									<div class="filter brightness-75 rounded-xl overflow-hidden">
+										<ImmichThumbnail
+											isRoundedCorner={true}
+											thumbnailSize={156}
+											asset={item.data}
+											readonly={true}
+										/>
+									</div>
+									<span
+										class="capitalize absolute bottom-2 w-full text-center text-sm font-medium text-white text-ellipsis w-100 px-1 hover:cursor-pointer backdrop-blur-[1px]"
+									>
+										{item.value}
+									</span>
+								</a>
+							{/each}
+						</div>
+					</div>
+				{/if}
+
+				{#if things.length > 0}
+					<div class="mb-6 mt-2">
+						<div>
+							<p class="mb-4 dark:text-immich-dark-fg font-medium">Things</p>
+						</div>
+						<div class="flex flex-row flex-wrap gap-4">
+							{#each things as item}
+								<a class="relative" href="/search?{Field.OBJECTS}={item.value}" draggable="false">
+									<div class="filter brightness-75 rounded-xl overflow-hidden">
+										<ImmichThumbnail
+											isRoundedCorner={true}
+											thumbnailSize={156}
+											asset={item.data}
+											readonly={true}
+										/>
+									</div>
+									<span
+										class="capitalize absolute bottom-2 w-full text-center text-sm font-medium text-white text-ellipsis w-100 px-1 hover:cursor-pointer backdrop-blur-[1px]"
+									>
+										{item.value}
+									</span>
+								</a>
+							{/each}
+						</div>
+					</div>
+				{/if}
+
+				<hr class="dark:border-immich-dark-gray mb-4" />
+
+				<div
+					class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-8"
+				>
+					<div class="flex flex-col gap-6 dark:text-immich-dark-fg">
+						<p class="text-sm">YOUR ACTIVITY</p>
+						<div class="flex flex-col gap-4 dark:text-immich-dark-fg/80">
+							<a
+								href={AppRoute.FAVORITES}
+								class="w-full flex text-sm font-medium hover:text-immich-primary dark:hover:text-immich-dark-primary content-center gap-2"
+								draggable="false"
+							>
+								<StarOutline size={24} />
+								<span>Favorites</span>
+							</a>
+							<a
+								href="/search?recent=true"
+								class="w-full flex text-sm font-medium hover:text-immich-primary dark:hover:text-immich-dark-primary content-center gap-2"
+								draggable="false"
+							>
+								<ClockOutline size={24} />
+								<span>Recently added</span>
+							</a>
+						</div>
+					</div>
+					<div class="flex flex-col gap-6 dark:text-immich-dark-fg">
+						<p class="text-sm">CATEGORIES</p>
+						<div class="flex flex-col gap-4 dark:text-immich-dark-fg/80">
+							<a
+								href="/search?type={AssetTypeEnum.Video}"
+								class="w-full flex text-sm font-medium hover:text-immich-primary dark:hover:text-immich-dark-primary items-center gap-2"
+							>
+								<PlayCircleOutline size={24} />
+								<span>Videos</span>
+							</a>
+							<div>
+								<a
+									href="/search?motion=true"
+									class="w-full flex text-sm font-medium hover:text-immich-primary dark:hover:text-immich-dark-primary items-center gap-2"
+								>
+									<MotionPlayOutline size={24} />
+									<span>Motion photos</span>
+								</a>
+							</div>
+						</div>
+					</div>
+				</div>
+			</div>
+		</section>
+	</section>
+</section>

+ 2 - 1
web/src/routes/(user)/search/+page.server.ts

@@ -8,7 +8,6 @@ export const load = (async ({ locals, parent, url }) => {
 	}
 	}
 
 
 	const term = url.searchParams.get('q') || undefined;
 	const term = url.searchParams.get('q') || undefined;
-
 	const { data: results } = await locals.api.searchApi.search(
 	const { data: results } = await locals.api.searchApi.search(
 		term,
 		term,
 		undefined,
 		undefined,
@@ -20,6 +19,8 @@ export const load = (async ({ locals, parent, url }) => {
 		undefined,
 		undefined,
 		undefined,
 		undefined,
 		undefined,
 		undefined,
+		undefined,
+		undefined,
 		{ params: url.searchParams }
 		{ params: url.searchParams }
 	);
 	);
 	return { user, term, results };
 	return { user, term, results };

+ 30 - 4
web/src/routes/(user)/search/+page.svelte

@@ -1,16 +1,34 @@
 <script lang="ts">
 <script lang="ts">
 	import { page } from '$app/stores';
 	import { page } from '$app/stores';
+	import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
 	import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
 	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';
 	import type { PageData } from './$types';
+	import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
+	import ImageOffOutline from 'svelte-material-icons/ImageOffOutline.svelte';
+	import { afterNavigate, goto } from '$app/navigation';
 
 
 	export let data: PageData;
 	export let data: PageData;
+	const term = $page.url.searchParams.get('q') || data.term || '';
 
 
-	const term = $page.url.searchParams.get('q') || '';
+	let goBackRoute = '/explore';
+	afterNavigate((r) => {
+		if (r.from) {
+			goBackRoute = r.from.url.href;
+		}
+	});
 </script>
 </script>
 
 
 <section>
 <section>
-	<NavigationBar {term} user={data.user} shouldShowUploadButton={false} />
+	<ControlAppBar on:close-button-click={() => goto(goBackRoute)} backIcon={ArrowLeft}>
+		<svelte:fragment slot="leading">
+			<p class="text-xl capitalize">
+				Search
+				{#if term}
+					- {term}
+				{/if}
+			</p>
+		</svelte:fragment>
+	</ControlAppBar>
 </section>
 </section>
 
 
 <section class="relative pt-[72px] h-screen bg-immich-bg  dark:bg-immich-dark-bg">
 <section class="relative pt-[72px] h-screen bg-immich-bg  dark:bg-immich-dark-bg">
@@ -19,8 +37,16 @@
 			id="search-content"
 			id="search-content"
 			class="relative pt-8 pl-4 mb-12 bg-immich-bg dark:bg-immich-dark-bg"
 			class="relative pt-8 pl-4 mb-12 bg-immich-bg dark:bg-immich-dark-bg"
 		>
 		>
-			{#if data.results?.assets?.items}
+			{#if data.results?.assets?.items.length != 0}
 				<GalleryViewer assets={data.results.assets.items} />
 				<GalleryViewer assets={data.results.assets.items} />
+			{:else}
+				<div class="w-full text-center dark:text-white ">
+					<div class="mt-60 flex flex-col place-content-center place-items-center">
+						<ImageOffOutline size="56" />
+						<p class="font-medium text-3xl mt-5">No results</p>
+						<p class="text-base font-normal">Try a synonym or more general keyword</p>
+					</div>
+				</div>
 			{/if}
 			{/if}
 		</section>
 		</section>
 	</section>
 	</section>