From 2ca560ebf80aadf1f7904bd88d7447d9a2a59ed3 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Sun, 5 Mar 2023 15:44:31 -0500 Subject: [PATCH] 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 Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> --- mobile/openapi/.openapi-generator/FILES | 6 + mobile/openapi/README.md | 3 + mobile/openapi/doc/SearchApi.md | 58 +++++- mobile/openapi/doc/SearchExploreItem.md | 16 ++ .../openapi/doc/SearchExploreResponseDto.md | 16 ++ mobile/openapi/lib/api.dart | 2 + mobile/openapi/lib/api/search_api.dart | 67 ++++++- mobile/openapi/lib/api_client.dart | 4 + .../lib/model/search_explore_item.dart | 119 ++++++++++++ .../model/search_explore_response_dto.dart | 119 ++++++++++++ mobile/openapi/test/search_api_test.dart | 9 +- .../test/search_explore_item_test.dart | 32 ++++ .../search_explore_response_dto_test.dart | 32 ++++ .../src/controllers/search.controller.ts | 16 +- .../metadata-extraction.processor.ts | 6 +- server/immich-openapi-specs.json | 84 ++++++++- server/libs/domain/src/asset/asset.core.ts | 12 +- .../domain/src/asset/asset.service.spec.ts | 11 +- server/libs/domain/src/asset/asset.service.ts | 4 +- .../libs/domain/src/search/dto/search.dto.ts | 10 + .../domain/src/search/response-dto/index.ts | 1 + .../search-explore.response.dto.ts | 11 ++ .../domain/src/search/search.repository.ts | 12 ++ .../libs/domain/src/search/search.service.ts | 23 ++- .../domain/test/search.repository.mock.ts | 1 + .../infra/src/search/schemas/asset.schema.ts | 7 +- .../infra/src/search/typesense.repository.ts | 87 ++++++++- web/src/api/open-api/api.ts | 130 ++++++++++++- .../shared-components/immich-thumbnail.svelte | 6 +- .../side-bar/side-bar.svelte | 13 ++ web/src/lib/constants.ts | 2 +- web/src/routes/(user)/explore/+page.server.ts | 13 ++ web/src/routes/(user)/explore/+page.svelte | 173 ++++++++++++++++++ web/src/routes/(user)/search/+page.server.ts | 3 +- web/src/routes/(user)/search/+page.svelte | 34 +++- 35 files changed, 1079 insertions(+), 63 deletions(-) create mode 100644 mobile/openapi/doc/SearchExploreItem.md create mode 100644 mobile/openapi/doc/SearchExploreResponseDto.md create mode 100644 mobile/openapi/lib/model/search_explore_item.dart create mode 100644 mobile/openapi/lib/model/search_explore_response_dto.dart create mode 100644 mobile/openapi/test/search_explore_item_test.dart create mode 100644 mobile/openapi/test/search_explore_response_dto_test.dart create mode 100644 server/libs/domain/src/search/response-dto/search-explore.response.dto.ts create mode 100644 web/src/routes/(user)/explore/+page.server.ts create mode 100644 web/src/routes/(user)/explore/+page.svelte diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 7a37ef8886..f01b4603b8 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -66,6 +66,8 @@ doc/SearchApi.md doc/SearchAssetDto.md doc/SearchAssetResponseDto.md doc/SearchConfigResponseDto.md +doc/SearchExploreItem.md +doc/SearchExploreResponseDto.md doc/SearchFacetCountResponseDto.md doc/SearchFacetResponseDto.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_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_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_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_response_dto_test.dart test/search_response_dto_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 4717cf9704..98eabb4abc 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -121,6 +121,7 @@ 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* | [**getExploreData**](doc//SearchApi.md#getexploredata) | **GET** /search/explore | *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 | @@ -210,6 +211,8 @@ Class | Method | HTTP request | Description - [SearchAssetDto](doc//SearchAssetDto.md) - [SearchAssetResponseDto](doc//SearchAssetResponseDto.md) - [SearchConfigResponseDto](doc//SearchConfigResponseDto.md) + - [SearchExploreItem](doc//SearchExploreItem.md) + - [SearchExploreResponseDto](doc//SearchExploreResponseDto.md) - [SearchFacetCountResponseDto](doc//SearchFacetCountResponseDto.md) - [SearchFacetResponseDto](doc//SearchFacetResponseDto.md) - [SearchResponseDto](doc//SearchResponseDto.md) diff --git a/mobile/openapi/doc/SearchApi.md b/mobile/openapi/doc/SearchApi.md index ebf8c884e6..8faafbfabe 100644 --- a/mobile/openapi/doc/SearchApi.md +++ b/mobile/openapi/doc/SearchApi.md @@ -9,10 +9,60 @@ All URIs are relative to */api* Method | HTTP request | Description ------------- | ------------- | ------------- +[**getExploreData**](SearchApi.md#getexploredata) | **GET** /search/explore | [**getSearchConfig**](SearchApi.md#getsearchconfig) | **GET** /search/config | [**search**](SearchApi.md#search) | **GET** /search | +# **getExploreData** +> List getExploreData() + + + + + +### Example +```dart +import 'package:openapi/api.dart'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; + +final api_instance = SearchApi(); + +try { + final result = api_instance.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.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** > 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) # **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 smartInfoPeriodObjects = []; // List | final smartInfoPeriodTags = []; // List | +final recent = true; // bool | +final motion = true; // bool | 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); } catch (e) { print('Exception when calling SearchApi->search: $e\n'); @@ -117,6 +169,8 @@ Name | Type | Description | Notes **exifInfoPeriodModel** | **String**| | [optional] **smartInfoPeriodObjects** | [**List**](String.md)| | [optional] [default to const []] **smartInfoPeriodTags** | [**List**](String.md)| | [optional] [default to const []] + **recent** | **bool**| | [optional] + **motion** | **bool**| | [optional] ### Return type diff --git a/mobile/openapi/doc/SearchExploreItem.md b/mobile/openapi/doc/SearchExploreItem.md new file mode 100644 index 0000000000..75eaabd8b1 --- /dev/null +++ b/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) + + diff --git a/mobile/openapi/doc/SearchExploreResponseDto.md b/mobile/openapi/doc/SearchExploreResponseDto.md new file mode 100644 index 0000000000..0185b3651b --- /dev/null +++ b/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.md) | | [default to const []] + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 3f0c9efe45..d70ad04993 100644 --- a/mobile/openapi/lib/api.dart +++ b/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_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_response_dto.dart'; part 'model/search_response_dto.dart'; diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart index 652270ed9b..6e7560b311 100644 --- a/mobile/openapi/lib/api/search_api.dart +++ b/mobile/openapi/lib/api/search_api.dart @@ -16,6 +16,53 @@ class SearchApi { final ApiClient apiClient; + /// + /// + /// Note: This method returns the HTTP [Response]. + Future getExploreDataWithHttpInfo() async { + // ignore: prefer_const_declarations + final path = r'/search/explore'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// + Future?> 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') as List) + .cast() + .toList(); + + } + return null; + } + /// /// /// Note: This method returns the HTTP [Response]. @@ -85,7 +132,11 @@ class SearchApi { /// * [List] smartInfoPeriodObjects: /// /// * [List] smartInfoPeriodTags: - Future searchWithHttpInfo({ String? query, String? type, bool? isFavorite, String? exifInfoPeriodCity, String? exifInfoPeriodState, String? exifInfoPeriodCountry, String? exifInfoPeriodMake, String? exifInfoPeriodModel, List? smartInfoPeriodObjects, List? smartInfoPeriodTags, }) async { + /// + /// * [bool] recent: + /// + /// * [bool] motion: + Future searchWithHttpInfo({ String? query, String? type, bool? isFavorite, String? exifInfoPeriodCity, String? exifInfoPeriodState, String? exifInfoPeriodCountry, String? exifInfoPeriodMake, String? exifInfoPeriodModel, List? smartInfoPeriodObjects, List? smartInfoPeriodTags, bool? recent, bool? motion, }) async { // ignore: prefer_const_declarations final path = r'/search'; @@ -126,6 +177,12 @@ class SearchApi { if (smartInfoPeriodTags != null) { 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 = []; @@ -164,8 +221,12 @@ class SearchApi { /// * [List] smartInfoPeriodObjects: /// /// * [List] smartInfoPeriodTags: - Future search({ String? query, String? type, bool? isFavorite, String? exifInfoPeriodCity, String? exifInfoPeriodState, String? exifInfoPeriodCountry, String? exifInfoPeriodMake, String? exifInfoPeriodModel, List? smartInfoPeriodObjects, List? smartInfoPeriodTags, }) async { - final response = await searchWithHttpInfo( query: query, type: type, isFavorite: isFavorite, exifInfoPeriodCity: exifInfoPeriodCity, exifInfoPeriodState: exifInfoPeriodState, exifInfoPeriodCountry: exifInfoPeriodCountry, exifInfoPeriodMake: exifInfoPeriodMake, exifInfoPeriodModel: exifInfoPeriodModel, smartInfoPeriodObjects: smartInfoPeriodObjects, smartInfoPeriodTags: smartInfoPeriodTags, ); + /// + /// * [bool] recent: + /// + /// * [bool] motion: + Future search({ String? query, String? type, bool? isFavorite, String? exifInfoPeriodCity, String? exifInfoPeriodState, String? exifInfoPeriodCountry, String? exifInfoPeriodMake, String? exifInfoPeriodModel, List? smartInfoPeriodObjects, List? 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) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 676e09dd6f..3b2399f23e 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -302,6 +302,10 @@ class ApiClient { return SearchAssetResponseDto.fromJson(value); case 'SearchConfigResponseDto': return SearchConfigResponseDto.fromJson(value); + case 'SearchExploreItem': + return SearchExploreItem.fromJson(value); + case 'SearchExploreResponseDto': + return SearchExploreResponseDto.fromJson(value); case 'SearchFacetCountResponseDto': return SearchFacetCountResponseDto.fromJson(value); case 'SearchFacetResponseDto': diff --git a/mobile/openapi/lib/model/search_explore_item.dart b/mobile/openapi/lib/model/search_explore_item.dart new file mode 100644 index 0000000000..f7529496c5 --- /dev/null +++ b/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 toJson() { + final json = {}; + 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(); + + // 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(json, r'value')!, + data: AssetResponseDto.fromJson(json[r'data'])!, + ); + } + return null; + } + + static List? listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SearchExploreItem.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = 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> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = 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 = { + 'value', + 'data', + }; +} + diff --git a/mobile/openapi/lib/model/search_explore_response_dto.dart b/mobile/openapi/lib/model/search_explore_response_dto.dart new file mode 100644 index 0000000000..812aceecf7 --- /dev/null +++ b/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 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 toJson() { + final json = {}; + 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(); + + // 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(json, r'fieldName')!, + items: SearchExploreItem.listFromJson(json[r'items'])!, + ); + } + return null; + } + + static List? listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SearchExploreResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = 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> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = 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 = { + 'fieldName', + 'items', + }; +} + diff --git a/mobile/openapi/test/search_api_test.dart b/mobile/openapi/test/search_api_test.dart index 6286e048a2..8136969c91 100644 --- a/mobile/openapi/test/search_api_test.dart +++ b/mobile/openapi/test/search_api_test.dart @@ -17,6 +17,13 @@ void main() { // final instance = SearchApi(); group('tests for SearchApi', () { + // + // + //Future> getExploreData() async + test('test getExploreData', () async { + // TODO + }); + // // //Future getSearchConfig() async @@ -26,7 +33,7 @@ void main() { // // - //Future search({ String query, String type, bool isFavorite, String exifInfoPeriodCity, String exifInfoPeriodState, String exifInfoPeriodCountry, String exifInfoPeriodMake, String exifInfoPeriodModel, List smartInfoPeriodObjects, List smartInfoPeriodTags }) async + //Future search({ String query, String type, bool isFavorite, String exifInfoPeriodCity, String exifInfoPeriodState, String exifInfoPeriodCountry, String exifInfoPeriodMake, String exifInfoPeriodModel, List smartInfoPeriodObjects, List smartInfoPeriodTags, bool recent, bool motion }) async test('test search', () async { // TODO }); diff --git a/mobile/openapi/test/search_explore_item_test.dart b/mobile/openapi/test/search_explore_item_test.dart new file mode 100644 index 0000000000..d4fae1dbff --- /dev/null +++ b/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 + }); + + + }); + +} diff --git a/mobile/openapi/test/search_explore_response_dto_test.dart b/mobile/openapi/test/search_explore_response_dto_test.dart new file mode 100644 index 0000000000..ccc82a0d75 --- /dev/null +++ b/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 items (default value: const []) + test('to test the property `items`', () async { + // TODO + }); + + + }); + +} diff --git a/server/apps/immich/src/controllers/search.controller.ts b/server/apps/immich/src/controllers/search.controller.ts index 7f67927cf4..2c2248c3fc 100644 --- a/server/apps/immich/src/controllers/search.controller.ts +++ b/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 { ApiTags } from '@nestjs/swagger'; import { GetAuthUser } from '../decorators/auth-user.decorator'; @@ -10,7 +17,6 @@ import { Authenticated } from '../decorators/authenticated.decorator'; export class SearchController { constructor(private readonly searchService: SearchService) {} - @Authenticated() @Get() async search( @GetAuthUser() authUser: AuthUserDto, @@ -19,9 +25,13 @@ export class SearchController { return this.searchService.search(authUser, dto); } - @Authenticated() @Get('config') getSearchConfig(): SearchConfigResponseDto { return this.searchService.getConfig(); } + + @Get('explore') + getExploreData(@GetAuthUser() authUser: AuthUserDto): Promise { + return this.searchService.getExploreData(authUser) as Promise; + } } diff --git a/server/apps/microservices/src/processors/metadata-extraction.processor.ts b/server/apps/microservices/src/processors/metadata-extraction.processor.ts index 7fb1fc9da5..5bc8c4e786 100644 --- a/server/apps/microservices/src/processors/metadata-extraction.processor.ts +++ b/server/apps/microservices/src/processors/metadata-extraction.processor.ts @@ -2,8 +2,8 @@ import { AssetCore, IAssetRepository, IAssetUploadedJob, + IJobRepository, IReverseGeocodingJob, - ISearchRepository, JobName, QueueName, } from '@app/domain'; @@ -86,14 +86,14 @@ export class MetadataExtractionProcessor { constructor( @Inject(IAssetRepository) assetRepository: IAssetRepository, - @Inject(ISearchRepository) searchRepository: ISearchRepository, + @Inject(IJobRepository) jobRepository: IJobRepository, @InjectRepository(ExifEntity) private exifRepository: Repository, configService: ConfigService, ) { - this.assetCore = new AssetCore(assetRepository, searchRepository); + this.assetCore = new AssetCore(assetRepository, jobRepository); if (!configService.get('DISABLE_REVERSE_GEOCODING')) { this.logger.log('Initializing Reverse Geocoding'); diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index fdf2ac31ca..2c21d6214a 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -640,6 +640,22 @@ "type": "string" } } + }, + { + "name": "recent", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "motion", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } } ], "responses": { @@ -658,12 +674,6 @@ "Search" ], "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, { "bearer": [] }, @@ -699,7 +709,34 @@ }, { "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": [] }, @@ -4149,6 +4186,39 @@ "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": { "type": "string", "enum": [ diff --git a/server/libs/domain/src/asset/asset.core.ts b/server/libs/domain/src/asset/asset.core.ts index e923f29d95..46b4231ff4 100644 --- a/server/libs/domain/src/asset/asset.core.ts +++ b/server/libs/domain/src/asset/asset.core.ts @@ -1,21 +1,21 @@ 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'; export class AssetCore { - constructor(private repository: IAssetRepository, private searchRepository: ISearchRepository) {} + constructor(private assetRepository: IAssetRepository, private jobRepository: IJobRepository) {} getAll(options: AssetSearchOptions) { - return this.repository.getAll(options); + return this.assetRepository.getAll(options); } async save(asset: Partial) { - 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; } findLivePhotoMatch(livePhotoCID: string, otherAssetId: string, type: AssetType): Promise { - return this.repository.findLivePhotoMatch(livePhotoCID, otherAssetId, type); + return this.assetRepository.findLivePhotoMatch(livePhotoCID, otherAssetId, type); } } diff --git a/server/libs/domain/src/asset/asset.service.spec.ts b/server/libs/domain/src/asset/asset.service.spec.ts index bff4efa20c..536a0c148c 100644 --- a/server/libs/domain/src/asset/asset.service.spec.ts +++ b/server/libs/domain/src/asset/asset.service.spec.ts @@ -1,15 +1,12 @@ import { AssetEntity, AssetType } from '@app/infra/db/entities'; import { assetEntityStub, newAssetRepositoryMock, newJobRepositoryMock } from '../../test'; -import { newSearchRepositoryMock } from '../../test/search.repository.mock'; import { AssetService, IAssetRepository } from '../asset'; import { IJobRepository, JobName } from '../job'; -import { ISearchRepository } from '../search'; describe(AssetService.name, () => { let sut: AssetService; let assetMock: jest.Mocked; let jobMock: jest.Mocked; - let searchMock: jest.Mocked; it('should work', () => { expect(sut).toBeDefined(); @@ -18,8 +15,7 @@ describe(AssetService.name, () => { beforeEach(async () => { assetMock = newAssetRepositoryMock(); jobMock = newJobRepositoryMock(); - searchMock = newSearchRepositoryMock(); - sut = new AssetService(assetMock, jobMock, searchMock); + sut = new AssetService(assetMock, jobMock); }); describe(`handle asset upload`, () => { @@ -56,7 +52,10 @@ describe(AssetService.name, () => { await sut.save(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 }, + }); }); }); }); diff --git a/server/libs/domain/src/asset/asset.service.ts b/server/libs/domain/src/asset/asset.service.ts index 06e8c7aa96..22d6b4dc48 100644 --- a/server/libs/domain/src/asset/asset.service.ts +++ b/server/libs/domain/src/asset/asset.service.ts @@ -1,7 +1,6 @@ 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'; @@ -11,9 +10,8 @@ export class AssetService { constructor( @Inject(IAssetRepository) assetRepository: IAssetRepository, @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) { diff --git a/server/libs/domain/src/search/dto/search.dto.ts b/server/libs/domain/src/search/dto/search.dto.ts index c080ff5eac..1610e2e713 100644 --- a/server/libs/domain/src/search/dto/search.dto.ts +++ b/server/libs/domain/src/search/dto/search.dto.ts @@ -54,4 +54,14 @@ export class SearchDto { @IsOptional() @Transform(({ value }) => value.split(',')) 'smartInfo.tags'?: string[]; + + @IsBoolean() + @IsOptional() + @Transform(toBoolean) + recent?: boolean; + + @IsBoolean() + @IsOptional() + @Transform(toBoolean) + motion?: boolean; } diff --git a/server/libs/domain/src/search/response-dto/index.ts b/server/libs/domain/src/search/response-dto/index.ts index e55378686d..e74cc29b37 100644 --- a/server/libs/domain/src/search/response-dto/index.ts +++ b/server/libs/domain/src/search/response-dto/index.ts @@ -1,2 +1,3 @@ export * from './search-config-response.dto'; +export * from './search-explore.response.dto'; export * from './search-response.dto'; diff --git a/server/libs/domain/src/search/response-dto/search-explore.response.dto.ts b/server/libs/domain/src/search/response-dto/search-explore.response.dto.ts new file mode 100644 index 0000000000..37398d9dec --- /dev/null +++ b/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[]; +} diff --git a/server/libs/domain/src/search/search.repository.ts b/server/libs/domain/src/search/search.repository.ts index f288578502..4508b14514 100644 --- a/server/libs/domain/src/search/search.repository.ts +++ b/server/libs/domain/src/search/search.repository.ts @@ -17,6 +17,8 @@ export interface SearchFilter { model?: string; objects?: string[]; tags?: string[]; + recent?: boolean; + motion?: boolean; } export interface SearchResult { @@ -39,6 +41,14 @@ export interface SearchFacet { }>; } +export interface SearchExploreItem { + fieldName: string; + items: Array<{ + value: string; + data: T; + }>; +} + export type SearchCollectionIndexStatus = Record; export const ISearchRepository = 'ISearchRepository'; @@ -57,4 +67,6 @@ export interface ISearchRepository { search(collection: SearchCollection.ASSETS, query: string, filters: SearchFilter): Promise>; search(collection: SearchCollection.ALBUMS, query: string, filters: SearchFilter): Promise>; + + explore(userId: string): Promise[]>; } diff --git a/server/libs/domain/src/search/search.service.ts b/server/libs/domain/src/search/search.service.ts index 322644167b..f350e19b45 100644 --- a/server/libs/domain/src/search/search.service.ts +++ b/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 { ConfigService } from '@nestjs/config'; import { IAlbumRepository } from '../album/album.repository'; @@ -6,7 +7,7 @@ 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'; +import { ISearchRepository, SearchCollection, SearchExploreItem } from './search.repository'; @Injectable() export class SearchService { @@ -52,10 +53,13 @@ export class SearchService { } } + async getExploreData(authUser: AuthUserDto): Promise[]> { + this.assertEnabled(); + return this.searchRepository.explore(authUser.id); + } + async search(authUser: AuthUserDto, dto: SearchDto): Promise { - if (!this.enabled) { - throw new BadRequestException('Search is disabled'); - } + this.assertEnabled(); const query = dto.query || '*'; @@ -83,6 +87,7 @@ export class SearchService { this.logger.log(`Indexing ${assets.length} assets`); await this.searchRepository.import(SearchCollection.ASSETS, assets, true); + this.logger.debug('Finished re-indexing all assets'); } catch (error: any) { this.logger.error(`Unable to index all assets`, error?.stack); } @@ -94,6 +99,9 @@ export class SearchService { } const { asset } = data; + if (!asset.isVisible) { + return; + } try { await this.searchRepository.index(SearchCollection.ASSETS, asset); @@ -111,6 +119,7 @@ export class SearchService { const albums = await this.albumRepository.getAll(); this.logger.log(`Indexing ${albums.length} albums`); await this.searchRepository.import(SearchCollection.ALBUMS, albums, true); + this.logger.debug('Finished re-indexing all albums'); } catch (error: any) { 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); } } + + private assertEnabled() { + if (!this.enabled) { + throw new BadRequestException('Search is disabled'); + } + } } diff --git a/server/libs/domain/test/search.repository.mock.ts b/server/libs/domain/test/search.repository.mock.ts index b1918f3933..0ba2dd4f9c 100644 --- a/server/libs/domain/test/search.repository.mock.ts +++ b/server/libs/domain/test/search.repository.mock.ts @@ -8,5 +8,6 @@ export const newSearchRepositoryMock = (): jest.Mocked => { import: jest.fn(), search: jest.fn(), delete: jest.fn(), + explore: jest.fn(), }; }; diff --git a/server/libs/infra/src/search/schemas/asset.schema.ts b/server/libs/infra/src/search/schemas/asset.schema.ts index 962f4e9b2a..d379048c97 100644 --- a/server/libs/infra/src/search/schemas/asset.schema.ts +++ b/server/libs/infra/src/search/schemas/asset.schema.ts @@ -1,6 +1,6 @@ import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections'; -export const assetSchemaVersion = 1; +export const assetSchemaVersion = 2; export const assetSchema: CollectionCreateSchema = { name: `assets-v${assetSchemaVersion}`, fields: [ @@ -22,7 +22,6 @@ export const assetSchema: CollectionCreateSchema = { { 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 }, @@ -30,6 +29,10 @@ export const assetSchema: CollectionCreateSchema = { // smart info { name: 'smartInfo.objects', 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: ['.'], enable_nested_fields: true, diff --git a/server/libs/infra/src/search/typesense.repository.ts b/server/libs/infra/src/search/typesense.repository.ts index b24da06546..a656d4b24e 100644 --- a/server/libs/infra/src/search/typesense.repository.ts +++ b/server/libs/infra/src/search/typesense.repository.ts @@ -2,11 +2,13 @@ import { ISearchRepository, SearchCollection, SearchCollectionIndexStatus, + SearchExploreItem, SearchFilter, SearchResult, } from '@app/domain'; import { Injectable, Logger } from '@nestjs/common'; import _, { Dictionary } from 'lodash'; +import { filter, firstValueFrom, from, map, mergeMap, toArray } from 'rxjs'; import { Client } from 'typesense'; import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections'; import { DocumentSchema, SearchResponse } from 'typesense/lib/Typesense/Documents'; @@ -14,8 +16,9 @@ import { AlbumEntity, AssetEntity } from '../db'; import { albumSchema } from './schemas/album.schema'; import { assetSchema } from './schemas/asset.schema'; -interface GeoAssetEntity extends AssetEntity { +interface CustomAssetEntity extends AssetEntity { geo?: [number, number]; + motion?: boolean; } function removeNil>(item: T): Partial { @@ -85,6 +88,12 @@ export class TypesenseRepository implements ISearchRepository { } async setup(): Promise { + 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 for (const [collectionName, schema] of schemas) { const collection = await this.client @@ -172,6 +181,59 @@ export class TypesenseRepository implements ISearchRepository { } } + async explore(userId: string): Promise[]> { + const alias = await this.client.aliases(SearchCollection.ASSETS).retrieve(); + + const common = { + q: '*', + filter_by: `ownerId:${userId}`, + per_page: 100, + }; + + const asset$ = this.client.collections(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>; search(collection: SearchCollection.ALBUMS, query: string, filter: SearchFilter): Promise>; async search(collection: SearchCollection, query: string, filters: SearchFilter) { @@ -213,10 +275,8 @@ export class TypesenseRepository implements ISearchRepository { ].join(','), filter_by: _filters.join(' && '), 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); @@ -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 lng = asset.exifInfo?.longitude; 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(','); } } diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 586edb6df6..69a66a5679 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -1539,6 +1539,44 @@ export interface SearchConfigResponseDto { */ '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} + * @memberof SearchExploreResponseDto + */ + 'items': Array; +} /** * * @export @@ -6629,6 +6667,41 @@ export class OAuthApi extends BaseAPI { */ export const SearchApiAxiosParamCreator = function (configuration?: Configuration) { return { + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getExploreData: async (options: AxiosRequestConfig = {}): Promise => { + 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. @@ -6676,10 +6749,12 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio * @param {string} [exifInfoModel] * @param {Array} [smartInfoObjects] * @param {Array} [smartInfoTags] + * @param {boolean} [recent] + * @param {boolean} [motion] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - search: async (query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array, smartInfoTags?: Array, options: AxiosRequestConfig = {}): Promise => { + search: async (query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array, smartInfoTags?: Array, recent?: boolean, motion?: boolean, options: AxiosRequestConfig = {}): Promise => { const localVarPath = `/search`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); @@ -6738,6 +6813,14 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio localVarQueryParameter['smartInfo.tags'] = smartInfoTags; } + if (recent !== undefined) { + localVarQueryParameter['recent'] = recent; + } + + if (motion !== undefined) { + localVarQueryParameter['motion'] = motion; + } + setSearchParams(localVarUrlObj, localVarQueryParameter); @@ -6759,6 +6842,15 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio export const SearchApiFp = function(configuration?: Configuration) { const localVarAxiosParamCreator = SearchApiAxiosParamCreator(configuration) return { + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getExploreData(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getExploreData(options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {*} [options] Override http request option. @@ -6780,11 +6872,13 @@ export const SearchApiFp = function(configuration?: Configuration) { * @param {string} [exifInfoModel] * @param {Array} [smartInfoObjects] * @param {Array} [smartInfoTags] + * @param {boolean} [recent] + * @param {boolean} [motion] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async search(query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array, smartInfoTags?: Array, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.search(query, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, options); + async search(query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array, smartInfoTags?: Array, recent?: boolean, motion?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + 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); }, } @@ -6797,6 +6891,14 @@ export const SearchApiFp = function(configuration?: Configuration) { export const SearchApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { const localVarFp = SearchApiFp(configuration) return { + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getExploreData(options?: any): AxiosPromise> { + return localVarFp.getExploreData(options).then((request) => request(axios, basePath)); + }, /** * * @param {*} [options] Override http request option. @@ -6817,11 +6919,13 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat * @param {string} [exifInfoModel] * @param {Array} [smartInfoObjects] * @param {Array} [smartInfoTags] + * @param {boolean} [recent] + * @param {boolean} [motion] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - search(query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array, smartInfoTags?: Array, options?: any): AxiosPromise { - return localVarFp.search(query, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, options).then((request) => request(axios, basePath)); + search(query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array, smartInfoTags?: Array, recent?: boolean, motion?: boolean, options?: any): AxiosPromise { + 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} */ 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. @@ -6855,12 +6969,14 @@ export class SearchApi extends BaseAPI { * @param {string} [exifInfoModel] * @param {Array} [smartInfoObjects] * @param {Array} [smartInfoTags] + * @param {boolean} [recent] + * @param {boolean} [motion] * @param {*} [options] Override http request option. * @throws {RequiredError} * @memberof SearchApi */ - public search(query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array, smartInfoTags?: Array, options?: AxiosRequestConfig) { - return SearchApiFp(this.configuration).search(query, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, options).then((request) => request(this.axios, this.basePath)); + public search(query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array, smartInfoTags?: Array, 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)); } } diff --git a/web/src/lib/components/shared-components/immich-thumbnail.svelte b/web/src/lib/components/shared-components/immich-thumbnail.svelte index f2cb9b4ea5..5ef9ab6270 100644 --- a/web/src/lib/components/shared-components/immich-thumbnail.svelte +++ b/web/src/lib/components/shared-components/immich-thumbnail.svelte @@ -19,6 +19,7 @@ export let format: ThumbnailFormat = ThumbnailFormat.Webp; export let selected = false; export let disabled = false; + export let readonly = false; export let publicSharedKey = ''; export let isRoundedCorner = false; @@ -56,6 +57,7 @@ }; const parseVideoDuration = (duration: string) => { + duration = duration || '0:00:00.00000'; const timePart = duration.split(':'); const hours = timePart[0]; const minutes = timePart[1]; @@ -118,7 +120,7 @@ } else if (disabled) { return 'border-[20px] border-gray-300'; } else if (isRoundedCorner) { - return 'rounded-[20px]'; + return 'rounded-lg'; } else { return ''; } @@ -157,7 +159,7 @@ on:click={thumbnailClickedHandler} on:keydown={thumbnailClickedHandler} > - {#if mouseOver || selected || disabled} + {#if (mouseOver || selected || disabled) && !readonly}
+ + + { + const { user } = await parent(); + if (!user) { + throw redirect(302, '/auth/login'); + } + + const { data: items } = await locals.api.searchApi.getExploreData(); + + return { user, items }; +}) satisfies PageServerLoad; diff --git a/web/src/routes/(user)/explore/+page.svelte b/web/src/routes/(user)/explore/+page.svelte new file mode 100644 index 0000000000..c9cf4a48b9 --- /dev/null +++ b/web/src/routes/(user)/explore/+page.svelte @@ -0,0 +1,173 @@ + + +
+ +
+ +
+ + +
+
+ +
+
+

Explore

+
+
+ +
+
+
+ +
+ {#if places.length > 0} + + {/if} + + {#if things.length > 0} +
+
+

Things

+
+
+ {#each things as item} + +
+ +
+ + {item.value} + +
+ {/each} +
+
+ {/if} + +
+ +
+ +
+

CATEGORIES

+ +
+
+
+
+
+
diff --git a/web/src/routes/(user)/search/+page.server.ts b/web/src/routes/(user)/search/+page.server.ts index 26eefac329..1abb5294dc 100644 --- a/web/src/routes/(user)/search/+page.server.ts +++ b/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 { data: results } = await locals.api.searchApi.search( term, undefined, @@ -20,6 +19,8 @@ export const load = (async ({ locals, parent, url }) => { undefined, undefined, undefined, + undefined, + undefined, { params: url.searchParams } ); return { user, term, results }; diff --git a/web/src/routes/(user)/search/+page.svelte b/web/src/routes/(user)/search/+page.svelte index 6bcf8f9568..8f38109516 100644 --- a/web/src/routes/(user)/search/+page.svelte +++ b/web/src/routes/(user)/search/+page.svelte @@ -1,16 +1,34 @@
- + goto(goBackRoute)} backIcon={ArrowLeft}> + +

+ Search + {#if term} + - {term} + {/if} +

+
+
@@ -19,8 +37,16 @@ id="search-content" 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} + {:else} +
+
+ +

No results

+

Try a synonym or more general keyword

+
+
{/if}