diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index 48fbad95d0126..7ffb1f7b6fb17 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -2066,19 +2066,6 @@ export interface SearchAssetResponseDto { */ 'total': number; } -/** - * - * @export - * @interface SearchConfigResponseDto - */ -export interface SearchConfigResponseDto { - /** - * - * @type {boolean} - * @memberof SearchConfigResponseDto - */ - 'enabled': boolean; -} /** * * @export @@ -2185,7 +2172,13 @@ export interface ServerFeaturesDto { * @type {boolean} * @memberof ServerFeaturesDto */ - 'machineLearning': boolean; + 'clipEncode': boolean; + /** + * + * @type {boolean} + * @memberof ServerFeaturesDto + */ + 'facialRecognition': boolean; /** * * @type {boolean} @@ -2210,6 +2203,18 @@ export interface ServerFeaturesDto { * @memberof ServerFeaturesDto */ 'search': boolean; + /** + * + * @type {boolean} + * @memberof ServerFeaturesDto + */ + 'sidecar': boolean; + /** + * + * @type {boolean} + * @memberof ServerFeaturesDto + */ + 'tagImage': boolean; } /** * @@ -2611,6 +2616,12 @@ export interface SystemConfigDto { * @memberof SystemConfigDto */ 'job': SystemConfigJobDto; + /** + * + * @type {SystemConfigMachineLearningDto} + * @memberof SystemConfigDto + */ + 'machineLearning': SystemConfigMachineLearningDto; /** * * @type {SystemConfigOAuthDto} @@ -2778,6 +2789,43 @@ export interface SystemConfigJobDto { */ 'videoConversion': JobSettingsDto; } +/** + * + * @export + * @interface SystemConfigMachineLearningDto + */ +export interface SystemConfigMachineLearningDto { + /** + * + * @type {boolean} + * @memberof SystemConfigMachineLearningDto + */ + 'clipEncodeEnabled': boolean; + /** + * + * @type {boolean} + * @memberof SystemConfigMachineLearningDto + */ + 'enabled': boolean; + /** + * + * @type {boolean} + * @memberof SystemConfigMachineLearningDto + */ + 'facialRecognitionEnabled': boolean; + /** + * + * @type {boolean} + * @memberof SystemConfigMachineLearningDto + */ + 'tagImageEnabled': boolean; + /** + * + * @type {string} + * @memberof SystemConfigMachineLearningDto + */ + 'url': string; +} /** * * @export @@ -10106,44 +10154,6 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio - 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. - * @throws {RequiredError} - */ - getSearchConfig: async (options: AxiosRequestConfig = {}): Promise => { - const localVarPath = `/search/config`; - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - // authentication cookie required - - // authentication api_key required - await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) - - // authentication bearer required - // http bearer authentication required - await setBearerAuthToObject(localVarHeaderParameter, configuration) - - - setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -10290,15 +10300,6 @@ export const SearchApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.getExploreData(options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, - /** - * - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async getSearchConfig(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.getSearchConfig(options); - return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); - }, /** * * @param {string} [q] @@ -10342,14 +10343,6 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat getExploreData(options?: AxiosRequestConfig): AxiosPromise> { return localVarFp.getExploreData(options).then((request) => request(axios, basePath)); }, - /** - * - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - getSearchConfig(options?: AxiosRequestConfig): AxiosPromise { - return localVarFp.getSearchConfig(options).then((request) => request(axios, basePath)); - }, /** * * @param {SearchApiSearchRequest} requestParameters Request parameters. @@ -10498,16 +10491,6 @@ export class SearchApi extends BaseAPI { return SearchApiFp(this.configuration).getExploreData(options).then((request) => request(this.axios, this.basePath)); } - /** - * - * @param {*} [options] Override http request option. - * @throws {RequiredError} - * @memberof SearchApi - */ - public getSearchConfig(options?: AxiosRequestConfig) { - return SearchApiFp(this.configuration).getSearchConfig(options).then((request) => request(this.axios, this.basePath)); - } - /** * * @param {SearchApiSearchRequest} requestParameters Request parameters. diff --git a/docs/docs/FAQ.md b/docs/docs/FAQ.md index c0aff99ab6103..af43f919246ee 100644 --- a/docs/docs/FAQ.md +++ b/docs/docs/FAQ.md @@ -39,7 +39,7 @@ This often happens when using a reverse proxy or cloudflare tunnel in front of I ### Why is Immich slow on low-memory systems like the Raspberry Pi? -Immich uses optional machine-learning features to enhance search results. This feature, however, can be too heavy to run on a Raspberry Pi. To disable machine learning, comment out the `immich-machine-learning` section of your docker-compose.yml and set `IMMICH_MACHINE_LEARNING_URL=false` in your .env file. +Immich uses optional machine-learning features to enhance search results. This feature, however, can be too heavy to run on a Raspberry Pi. To disable machine learning, comment out the `immich-machine-learning` section of your docker-compose.yml and set `IMMICH_MACHINE_LEARNING_ENABLED=false` in your .env file. ### How to disable machine-learning and TypeSense? @@ -47,7 +47,7 @@ Immich uses optional machine-learning features to enhance search results. This f Disabling both will result in poor search experience and typesense utilizes CLIP embeddings which are generated by machine-learning. ::: -These features can be disabled by commenting out `immich-typesense` and `immich-machine-learning` sections of the docker-compose.yml and setting `IMMICH_MACHINE_LEARNING_URL=false` & `TYPESENSE_ENABLED=false` in your .env file. +These features can be disabled by commenting out `immich-typesense` and `immich-machine-learning` sections of the docker-compose.yml and setting `IMMICH_MACHINE_LEARNING_ENABLED=false` & `TYPESENSE_ENABLED=false` in your .env file. ### What happens to existing files after I choose a new [Storage Template](/docs/administration/storage-template.mdx)? diff --git a/docs/docs/install/docker-compose.md b/docs/docs/install/docker-compose.md index dca8c0211c7c8..d09ba531fc1f5 100644 --- a/docs/docs/install/docker-compose.md +++ b/docs/docs/install/docker-compose.md @@ -132,7 +132,6 @@ PUBLIC_LOGIN_PAGE_MESSAGE="My Family Photos and Videos Backup Server" IMMICH_WEB_URL=http://immich-web:3000 IMMICH_SERVER_URL=http://immich-server:3001 -IMMICH_MACHINE_LEARNING_URL=http://immich-machine-learning:3003 #################################################################################### # Alternative API's External Address - Optional diff --git a/docs/docs/install/environment-variables.md b/docs/docs/install/environment-variables.md index 09a4305167d87..f0ecbbb97082c 100644 --- a/docs/docs/install/environment-variables.md +++ b/docs/docs/install/environment-variables.md @@ -50,13 +50,14 @@ These environment variables are used by the `docker-compose.yml` file and do **N ## URLs -| Variable | Description | Default | Services | -| :---------------------------- | :------------------------------------------------------- | :-----------------------------------: | :-------------------- | -| `IMMICH_WEB_URL` | Immich Web URL | `http://immich-web:3000` | proxy | -| `IMMICH_SERVER_URL` | Immich Server URL | `http://immich-server:3001` | web, proxy | -| `IMMICH_MACHINE_LEARNING_URL` | Immich Machine Learning URL, set `"false"` to disable ML | `http://immich-machine-learning:3003` | server, microservices | -| `PUBLIC_IMMICH_SERVER_URL` | Public Immich URL | `http://immich-server:3001` | web | -| `IMMICH_API_URL_EXTERNAL` | Immich API URL External | `/api` | web | +| Variable | Description | Default | Services | +| :-------------------------------- | :--------------------------- | :-----------------------------------: | :-------------------- | +| `IMMICH_WEB_URL` | Immich Web URL | `http://immich-web:3000` | proxy | +| `IMMICH_SERVER_URL` | Immich Server URL | `http://immich-server:3001` | web, proxy | +| `IMMICH_MACHINE_LEARNING_ENABLED` | Enabled machine learning | `true` | server, microservices | +| `IMMICH_MACHINE_LEARNING_URL` | Immich Machine Learning URL, | `http://immich-machine-learning:3003` | server, microservices | +| `PUBLIC_IMMICH_SERVER_URL` | Public Immich URL | `http://immich-server:3001` | web | +| `IMMICH_API_URL_EXTERNAL` | Immich API URL External | `/api` | web | :::info diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 506dfbf6a732f..e62417987a7bd 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -84,7 +84,6 @@ doc/SearchAlbumResponseDto.md doc/SearchApi.md doc/SearchAssetDto.md doc/SearchAssetResponseDto.md -doc/SearchConfigResponseDto.md doc/SearchExploreItem.md doc/SearchExploreResponseDto.md doc/SearchFacetCountResponseDto.md @@ -108,6 +107,7 @@ doc/SystemConfigApi.md doc/SystemConfigDto.md doc/SystemConfigFFmpegDto.md doc/SystemConfigJobDto.md +doc/SystemConfigMachineLearningDto.md doc/SystemConfigOAuthDto.md doc/SystemConfigPasswordLoginDto.md doc/SystemConfigStorageTemplateDto.md @@ -228,7 +228,6 @@ lib/model/queue_status_dto.dart lib/model/search_album_response_dto.dart lib/model/search_asset_dto.dart lib/model/search_asset_response_dto.dart -lib/model/search_config_response_dto.dart lib/model/search_explore_item.dart lib/model/search_explore_response_dto.dart lib/model/search_facet_count_response_dto.dart @@ -249,6 +248,7 @@ lib/model/smart_info_response_dto.dart lib/model/system_config_dto.dart lib/model/system_config_f_fmpeg_dto.dart lib/model/system_config_job_dto.dart +lib/model/system_config_machine_learning_dto.dart lib/model/system_config_o_auth_dto.dart lib/model/system_config_password_login_dto.dart lib/model/system_config_storage_template_dto.dart @@ -353,7 +353,6 @@ test/search_album_response_dto_test.dart test/search_api_test.dart test/search_asset_dto_test.dart test/search_asset_response_dto_test.dart -test/search_config_response_dto_test.dart test/search_explore_item_test.dart test/search_explore_response_dto_test.dart test/search_facet_count_response_dto_test.dart @@ -377,6 +376,7 @@ test/system_config_api_test.dart test/system_config_dto_test.dart test/system_config_f_fmpeg_dto_test.dart test/system_config_job_dto_test.dart +test/system_config_machine_learning_dto_test.dart test/system_config_o_auth_dto_test.dart test/system_config_password_login_dto_test.dart test/system_config_storage_template_dto_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 188c09f5f7c70..52a19da527cd6 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -140,7 +140,6 @@ Class | Method | HTTP request | Description *PersonApi* | [**updatePeople**](doc//PersonApi.md#updatepeople) | **PUT** /person | *PersonApi* | [**updatePerson**](doc//PersonApi.md#updateperson) | **PUT** /person/{id} | *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* | [**getServerFeatures**](doc//ServerInfoApi.md#getserverfeatures) | **GET** /server-info/features | *ServerInfoApi* | [**getServerInfo**](doc//ServerInfoApi.md#getserverinfo) | **GET** /server-info | @@ -253,7 +252,6 @@ Class | Method | HTTP request | Description - [SearchAlbumResponseDto](doc//SearchAlbumResponseDto.md) - [SearchAssetDto](doc//SearchAssetDto.md) - [SearchAssetResponseDto](doc//SearchAssetResponseDto.md) - - [SearchConfigResponseDto](doc//SearchConfigResponseDto.md) - [SearchExploreItem](doc//SearchExploreItem.md) - [SearchExploreResponseDto](doc//SearchExploreResponseDto.md) - [SearchFacetCountResponseDto](doc//SearchFacetCountResponseDto.md) @@ -274,6 +272,7 @@ Class | Method | HTTP request | Description - [SystemConfigDto](doc//SystemConfigDto.md) - [SystemConfigFFmpegDto](doc//SystemConfigFFmpegDto.md) - [SystemConfigJobDto](doc//SystemConfigJobDto.md) + - [SystemConfigMachineLearningDto](doc//SystemConfigMachineLearningDto.md) - [SystemConfigOAuthDto](doc//SystemConfigOAuthDto.md) - [SystemConfigPasswordLoginDto](doc//SystemConfigPasswordLoginDto.md) - [SystemConfigStorageTemplateDto](doc//SystemConfigStorageTemplateDto.md) diff --git a/mobile/openapi/doc/SearchApi.md b/mobile/openapi/doc/SearchApi.md index 74840a73cda5b..5cc36956a0ff7 100644 --- a/mobile/openapi/doc/SearchApi.md +++ b/mobile/openapi/doc/SearchApi.md @@ -10,7 +10,6 @@ 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 | @@ -65,57 +64,6 @@ 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) -# **getSearchConfig** -> SearchConfigResponseDto getSearchConfig() - - - -### Example -```dart -import 'package:openapi/api.dart'; -// 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'; -// TODO Configure API key authorization: api_key -//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; -// uncomment below to setup prefix (e.g. Bearer) for API key, if needed -//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; -// 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); - -final api_instance = SearchApi(); - -try { - final result = api_instance.getSearchConfig(); - print(result); -} catch (e) { - print('Exception when calling SearchApi->getSearchConfig: $e\n'); -} -``` - -### Parameters -This endpoint does not need any parameter. - -### Return type - -[**SearchConfigResponseDto**](SearchConfigResponseDto.md) - -### Authorization - -[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) - -### HTTP request headers - - - **Content-Type**: Not defined - - **Accept**: application/json - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - # **search** > SearchResponseDto search(q, query, clip, type, isFavorite, isArchived, exifInfoPeriodCity, exifInfoPeriodState, exifInfoPeriodCountry, exifInfoPeriodMake, exifInfoPeriodModel, exifInfoPeriodProjectionType, smartInfoPeriodObjects, smartInfoPeriodTags, recent, motion) diff --git a/mobile/openapi/doc/ServerFeaturesDto.md b/mobile/openapi/doc/ServerFeaturesDto.md index 303b89f18833d..9abd465140d0d 100644 --- a/mobile/openapi/doc/ServerFeaturesDto.md +++ b/mobile/openapi/doc/ServerFeaturesDto.md @@ -8,11 +8,14 @@ import 'package:openapi/api.dart'; ## Properties Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- -**machineLearning** | **bool** | | +**clipEncode** | **bool** | | +**facialRecognition** | **bool** | | **oauth** | **bool** | | **oauthAutoLaunch** | **bool** | | **passwordLogin** | **bool** | | **search** | **bool** | | +**sidecar** | **bool** | | +**tagImage** | **bool** | | [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/mobile/openapi/doc/SystemConfigDto.md b/mobile/openapi/doc/SystemConfigDto.md index ebccc31ebe0e3..6fc9808528d8d 100644 --- a/mobile/openapi/doc/SystemConfigDto.md +++ b/mobile/openapi/doc/SystemConfigDto.md @@ -10,6 +10,7 @@ Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- **ffmpeg** | [**SystemConfigFFmpegDto**](SystemConfigFFmpegDto.md) | | **job** | [**SystemConfigJobDto**](SystemConfigJobDto.md) | | +**machineLearning** | [**SystemConfigMachineLearningDto**](SystemConfigMachineLearningDto.md) | | **oauth** | [**SystemConfigOAuthDto**](SystemConfigOAuthDto.md) | | **passwordLogin** | [**SystemConfigPasswordLoginDto**](SystemConfigPasswordLoginDto.md) | | **storageTemplate** | [**SystemConfigStorageTemplateDto**](SystemConfigStorageTemplateDto.md) | | diff --git a/mobile/openapi/doc/SearchConfigResponseDto.md b/mobile/openapi/doc/SystemConfigMachineLearningDto.md similarity index 65% rename from mobile/openapi/doc/SearchConfigResponseDto.md rename to mobile/openapi/doc/SystemConfigMachineLearningDto.md index 25020ea756675..9b2c596e2e4e4 100644 --- a/mobile/openapi/doc/SearchConfigResponseDto.md +++ b/mobile/openapi/doc/SystemConfigMachineLearningDto.md @@ -1,4 +1,4 @@ -# openapi.model.SearchConfigResponseDto +# openapi.model.SystemConfigMachineLearningDto ## Load the model package ```dart @@ -8,7 +8,11 @@ import 'package:openapi/api.dart'; ## Properties Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- +**clipEncodeEnabled** | **bool** | | **enabled** | **bool** | | +**facialRecognitionEnabled** | **bool** | | +**tagImageEnabled** | **bool** | | +**url** | **String** | | [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index ec7655c28743f..d1b51ea8e687b 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -115,7 +115,6 @@ part 'model/queue_status_dto.dart'; part 'model/search_album_response_dto.dart'; part 'model/search_asset_dto.dart'; part 'model/search_asset_response_dto.dart'; -part 'model/search_config_response_dto.dart'; part 'model/search_explore_item.dart'; part 'model/search_explore_response_dto.dart'; part 'model/search_facet_count_response_dto.dart'; @@ -136,6 +135,7 @@ part 'model/smart_info_response_dto.dart'; part 'model/system_config_dto.dart'; part 'model/system_config_f_fmpeg_dto.dart'; part 'model/system_config_job_dto.dart'; +part 'model/system_config_machine_learning_dto.dart'; part 'model/system_config_o_auth_dto.dart'; part 'model/system_config_password_login_dto.dart'; part 'model/system_config_storage_template_dto.dart'; diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart index 8178395e07747..9393b5c61dcf6 100644 --- a/mobile/openapi/lib/api/search_api.dart +++ b/mobile/openapi/lib/api/search_api.dart @@ -60,47 +60,6 @@ class SearchApi { return null; } - /// Performs an HTTP 'GET /search/config' operation and returns the [Response]. - Future getSearchConfigWithHttpInfo() async { - // ignore: prefer_const_declarations - final path = r'/search/config'; - - // 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 getSearchConfig() async { - final response = await getSearchConfigWithHttpInfo(); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. - if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'SearchConfigResponseDto',) as SearchConfigResponseDto; - - } - return null; - } - /// Performs an HTTP 'GET /search' operation and returns the [Response]. /// Parameters: /// diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 702f89432a5cd..3e819692032a1 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -323,8 +323,6 @@ class ApiClient { return SearchAssetDto.fromJson(value); case 'SearchAssetResponseDto': return SearchAssetResponseDto.fromJson(value); - case 'SearchConfigResponseDto': - return SearchConfigResponseDto.fromJson(value); case 'SearchExploreItem': return SearchExploreItem.fromJson(value); case 'SearchExploreResponseDto': @@ -365,6 +363,8 @@ class ApiClient { return SystemConfigFFmpegDto.fromJson(value); case 'SystemConfigJobDto': return SystemConfigJobDto.fromJson(value); + case 'SystemConfigMachineLearningDto': + return SystemConfigMachineLearningDto.fromJson(value); case 'SystemConfigOAuthDto': return SystemConfigOAuthDto.fromJson(value); case 'SystemConfigPasswordLoginDto': diff --git a/mobile/openapi/lib/model/search_config_response_dto.dart b/mobile/openapi/lib/model/search_config_response_dto.dart deleted file mode 100644 index 31927662c4bc0..0000000000000 --- a/mobile/openapi/lib/model/search_config_response_dto.dart +++ /dev/null @@ -1,98 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.12 - -// ignore_for_file: unused_element, unused_import -// ignore_for_file: always_put_required_named_parameters_first -// ignore_for_file: constant_identifier_names -// ignore_for_file: lines_longer_than_80_chars - -part of openapi.api; - -class SearchConfigResponseDto { - /// Returns a new [SearchConfigResponseDto] instance. - SearchConfigResponseDto({ - required this.enabled, - }); - - bool enabled; - - @override - bool operator ==(Object other) => identical(this, other) || other is SearchConfigResponseDto && - other.enabled == enabled; - - @override - int get hashCode => - // ignore: unnecessary_parenthesis - (enabled.hashCode); - - @override - String toString() => 'SearchConfigResponseDto[enabled=$enabled]'; - - Map toJson() { - final json = {}; - json[r'enabled'] = this.enabled; - return json; - } - - /// Returns a new [SearchConfigResponseDto] instance and imports its values from - /// [value] if it's a [Map], null otherwise. - // ignore: prefer_constructors_over_static_methods - static SearchConfigResponseDto? fromJson(dynamic value) { - if (value is Map) { - final json = value.cast(); - - return SearchConfigResponseDto( - enabled: mapValueOfType(json, r'enabled')!, - ); - } - 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 = SearchConfigResponseDto.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 = SearchConfigResponseDto.fromJson(entry.value); - if (value != null) { - map[entry.key] = value; - } - } - } - return map; - } - - // maps a json object with a list of SearchConfigResponseDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; - if (json is Map && json.isNotEmpty) { - // ignore: parameter_assignments - json = json.cast(); - for (final entry in json.entries) { - map[entry.key] = SearchConfigResponseDto.listFromJson(entry.value, growable: growable,); - } - } - return map; - } - - /// The list of required keys that must be present in a JSON. - static const requiredKeys = { - 'enabled', - }; -} - diff --git a/mobile/openapi/lib/model/server_features_dto.dart b/mobile/openapi/lib/model/server_features_dto.dart index 0c83fa79210cc..7d08844ede5d1 100644 --- a/mobile/openapi/lib/model/server_features_dto.dart +++ b/mobile/openapi/lib/model/server_features_dto.dart @@ -13,14 +13,19 @@ part of openapi.api; class ServerFeaturesDto { /// Returns a new [ServerFeaturesDto] instance. ServerFeaturesDto({ - required this.machineLearning, + required this.clipEncode, + required this.facialRecognition, required this.oauth, required this.oauthAutoLaunch, required this.passwordLogin, required this.search, + required this.sidecar, + required this.tagImage, }); - bool machineLearning; + bool clipEncode; + + bool facialRecognition; bool oauth; @@ -30,33 +35,46 @@ class ServerFeaturesDto { bool search; + bool sidecar; + + bool tagImage; + @override bool operator ==(Object other) => identical(this, other) || other is ServerFeaturesDto && - other.machineLearning == machineLearning && + other.clipEncode == clipEncode && + other.facialRecognition == facialRecognition && other.oauth == oauth && other.oauthAutoLaunch == oauthAutoLaunch && other.passwordLogin == passwordLogin && - other.search == search; + other.search == search && + other.sidecar == sidecar && + other.tagImage == tagImage; @override int get hashCode => // ignore: unnecessary_parenthesis - (machineLearning.hashCode) + + (clipEncode.hashCode) + + (facialRecognition.hashCode) + (oauth.hashCode) + (oauthAutoLaunch.hashCode) + (passwordLogin.hashCode) + - (search.hashCode); + (search.hashCode) + + (sidecar.hashCode) + + (tagImage.hashCode); @override - String toString() => 'ServerFeaturesDto[machineLearning=$machineLearning, oauth=$oauth, oauthAutoLaunch=$oauthAutoLaunch, passwordLogin=$passwordLogin, search=$search]'; + String toString() => 'ServerFeaturesDto[clipEncode=$clipEncode, facialRecognition=$facialRecognition, oauth=$oauth, oauthAutoLaunch=$oauthAutoLaunch, passwordLogin=$passwordLogin, search=$search, sidecar=$sidecar, tagImage=$tagImage]'; Map toJson() { final json = {}; - json[r'machineLearning'] = this.machineLearning; + json[r'clipEncode'] = this.clipEncode; + json[r'facialRecognition'] = this.facialRecognition; json[r'oauth'] = this.oauth; json[r'oauthAutoLaunch'] = this.oauthAutoLaunch; json[r'passwordLogin'] = this.passwordLogin; json[r'search'] = this.search; + json[r'sidecar'] = this.sidecar; + json[r'tagImage'] = this.tagImage; return json; } @@ -68,11 +86,14 @@ class ServerFeaturesDto { final json = value.cast(); return ServerFeaturesDto( - machineLearning: mapValueOfType(json, r'machineLearning')!, + clipEncode: mapValueOfType(json, r'clipEncode')!, + facialRecognition: mapValueOfType(json, r'facialRecognition')!, oauth: mapValueOfType(json, r'oauth')!, oauthAutoLaunch: mapValueOfType(json, r'oauthAutoLaunch')!, passwordLogin: mapValueOfType(json, r'passwordLogin')!, search: mapValueOfType(json, r'search')!, + sidecar: mapValueOfType(json, r'sidecar')!, + tagImage: mapValueOfType(json, r'tagImage')!, ); } return null; @@ -120,11 +141,14 @@ class ServerFeaturesDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { - 'machineLearning', + 'clipEncode', + 'facialRecognition', 'oauth', 'oauthAutoLaunch', 'passwordLogin', 'search', + 'sidecar', + 'tagImage', }; } diff --git a/mobile/openapi/lib/model/system_config_dto.dart b/mobile/openapi/lib/model/system_config_dto.dart index aefc97d1ba42d..da0500ebf8ddf 100644 --- a/mobile/openapi/lib/model/system_config_dto.dart +++ b/mobile/openapi/lib/model/system_config_dto.dart @@ -15,6 +15,7 @@ class SystemConfigDto { SystemConfigDto({ required this.ffmpeg, required this.job, + required this.machineLearning, required this.oauth, required this.passwordLogin, required this.storageTemplate, @@ -25,6 +26,8 @@ class SystemConfigDto { SystemConfigJobDto job; + SystemConfigMachineLearningDto machineLearning; + SystemConfigOAuthDto oauth; SystemConfigPasswordLoginDto passwordLogin; @@ -37,6 +40,7 @@ class SystemConfigDto { bool operator ==(Object other) => identical(this, other) || other is SystemConfigDto && other.ffmpeg == ffmpeg && other.job == job && + other.machineLearning == machineLearning && other.oauth == oauth && other.passwordLogin == passwordLogin && other.storageTemplate == storageTemplate && @@ -47,18 +51,20 @@ class SystemConfigDto { // ignore: unnecessary_parenthesis (ffmpeg.hashCode) + (job.hashCode) + + (machineLearning.hashCode) + (oauth.hashCode) + (passwordLogin.hashCode) + (storageTemplate.hashCode) + (thumbnail.hashCode); @override - String toString() => 'SystemConfigDto[ffmpeg=$ffmpeg, job=$job, oauth=$oauth, passwordLogin=$passwordLogin, storageTemplate=$storageTemplate, thumbnail=$thumbnail]'; + String toString() => 'SystemConfigDto[ffmpeg=$ffmpeg, job=$job, machineLearning=$machineLearning, oauth=$oauth, passwordLogin=$passwordLogin, storageTemplate=$storageTemplate, thumbnail=$thumbnail]'; Map toJson() { final json = {}; json[r'ffmpeg'] = this.ffmpeg; json[r'job'] = this.job; + json[r'machineLearning'] = this.machineLearning; json[r'oauth'] = this.oauth; json[r'passwordLogin'] = this.passwordLogin; json[r'storageTemplate'] = this.storageTemplate; @@ -76,6 +82,7 @@ class SystemConfigDto { return SystemConfigDto( ffmpeg: SystemConfigFFmpegDto.fromJson(json[r'ffmpeg'])!, job: SystemConfigJobDto.fromJson(json[r'job'])!, + machineLearning: SystemConfigMachineLearningDto.fromJson(json[r'machineLearning'])!, oauth: SystemConfigOAuthDto.fromJson(json[r'oauth'])!, passwordLogin: SystemConfigPasswordLoginDto.fromJson(json[r'passwordLogin'])!, storageTemplate: SystemConfigStorageTemplateDto.fromJson(json[r'storageTemplate'])!, @@ -129,6 +136,7 @@ class SystemConfigDto { static const requiredKeys = { 'ffmpeg', 'job', + 'machineLearning', 'oauth', 'passwordLogin', 'storageTemplate', diff --git a/mobile/openapi/lib/model/system_config_machine_learning_dto.dart b/mobile/openapi/lib/model/system_config_machine_learning_dto.dart new file mode 100644 index 0000000000000..c8d70d92cf43c --- /dev/null +++ b/mobile/openapi/lib/model/system_config_machine_learning_dto.dart @@ -0,0 +1,130 @@ +// +// 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 SystemConfigMachineLearningDto { + /// Returns a new [SystemConfigMachineLearningDto] instance. + SystemConfigMachineLearningDto({ + required this.clipEncodeEnabled, + required this.enabled, + required this.facialRecognitionEnabled, + required this.tagImageEnabled, + required this.url, + }); + + bool clipEncodeEnabled; + + bool enabled; + + bool facialRecognitionEnabled; + + bool tagImageEnabled; + + String url; + + @override + bool operator ==(Object other) => identical(this, other) || other is SystemConfigMachineLearningDto && + other.clipEncodeEnabled == clipEncodeEnabled && + other.enabled == enabled && + other.facialRecognitionEnabled == facialRecognitionEnabled && + other.tagImageEnabled == tagImageEnabled && + other.url == url; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (clipEncodeEnabled.hashCode) + + (enabled.hashCode) + + (facialRecognitionEnabled.hashCode) + + (tagImageEnabled.hashCode) + + (url.hashCode); + + @override + String toString() => 'SystemConfigMachineLearningDto[clipEncodeEnabled=$clipEncodeEnabled, enabled=$enabled, facialRecognitionEnabled=$facialRecognitionEnabled, tagImageEnabled=$tagImageEnabled, url=$url]'; + + Map toJson() { + final json = {}; + json[r'clipEncodeEnabled'] = this.clipEncodeEnabled; + json[r'enabled'] = this.enabled; + json[r'facialRecognitionEnabled'] = this.facialRecognitionEnabled; + json[r'tagImageEnabled'] = this.tagImageEnabled; + json[r'url'] = this.url; + return json; + } + + /// Returns a new [SystemConfigMachineLearningDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SystemConfigMachineLearningDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return SystemConfigMachineLearningDto( + clipEncodeEnabled: mapValueOfType(json, r'clipEncodeEnabled')!, + enabled: mapValueOfType(json, r'enabled')!, + facialRecognitionEnabled: mapValueOfType(json, r'facialRecognitionEnabled')!, + tagImageEnabled: mapValueOfType(json, r'tagImageEnabled')!, + url: mapValueOfType(json, r'url')!, + ); + } + 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 = SystemConfigMachineLearningDto.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 = SystemConfigMachineLearningDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SystemConfigMachineLearningDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SystemConfigMachineLearningDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'clipEncodeEnabled', + 'enabled', + 'facialRecognitionEnabled', + 'tagImageEnabled', + 'url', + }; +} + diff --git a/mobile/openapi/test/search_api_test.dart b/mobile/openapi/test/search_api_test.dart index a6bbacfa2a9bd..8365eff0762ed 100644 --- a/mobile/openapi/test/search_api_test.dart +++ b/mobile/openapi/test/search_api_test.dart @@ -22,11 +22,6 @@ void main() { // TODO }); - //Future getSearchConfig() async - test('test getSearchConfig', () async { - // TODO - }); - //Future search({ String q, String query, bool clip, String type, bool isFavorite, bool isArchived, String exifInfoPeriodCity, String exifInfoPeriodState, String exifInfoPeriodCountry, String exifInfoPeriodMake, String exifInfoPeriodModel, String exifInfoPeriodProjectionType, List smartInfoPeriodObjects, List smartInfoPeriodTags, bool recent, bool motion }) async test('test search', () async { // TODO diff --git a/mobile/openapi/test/search_config_response_dto_test.dart b/mobile/openapi/test/search_config_response_dto_test.dart deleted file mode 100644 index 2a49752011abe..0000000000000 --- a/mobile/openapi/test/search_config_response_dto_test.dart +++ /dev/null @@ -1,27 +0,0 @@ -// -// AUTO-GENERATED FILE, DO NOT MODIFY! -// -// @dart=2.12 - -// ignore_for_file: unused_element, unused_import -// ignore_for_file: always_put_required_named_parameters_first -// ignore_for_file: constant_identifier_names -// ignore_for_file: lines_longer_than_80_chars - -import 'package:openapi/api.dart'; -import 'package:test/test.dart'; - -// tests for SearchConfigResponseDto -void main() { - // final instance = SearchConfigResponseDto(); - - group('test SearchConfigResponseDto', () { - // bool enabled - test('to test the property `enabled`', () async { - // TODO - }); - - - }); - -} diff --git a/mobile/openapi/test/server_features_dto_test.dart b/mobile/openapi/test/server_features_dto_test.dart index d2ae364c4b70f..f143b31c8e5b8 100644 --- a/mobile/openapi/test/server_features_dto_test.dart +++ b/mobile/openapi/test/server_features_dto_test.dart @@ -16,8 +16,13 @@ void main() { // final instance = ServerFeaturesDto(); group('test ServerFeaturesDto', () { - // bool machineLearning - test('to test the property `machineLearning`', () async { + // bool clipEncode + test('to test the property `clipEncode`', () async { + // TODO + }); + + // bool facialRecognition + test('to test the property `facialRecognition`', () async { // TODO }); @@ -41,6 +46,16 @@ void main() { // TODO }); + // bool sidecar + test('to test the property `sidecar`', () async { + // TODO + }); + + // bool tagImage + test('to test the property `tagImage`', () async { + // TODO + }); + }); diff --git a/mobile/openapi/test/system_config_dto_test.dart b/mobile/openapi/test/system_config_dto_test.dart index 946324282e7eb..4ea0b98bde8bc 100644 --- a/mobile/openapi/test/system_config_dto_test.dart +++ b/mobile/openapi/test/system_config_dto_test.dart @@ -26,6 +26,11 @@ void main() { // TODO }); + // SystemConfigMachineLearningDto machineLearning + test('to test the property `machineLearning`', () async { + // TODO + }); + // SystemConfigOAuthDto oauth test('to test the property `oauth`', () async { // TODO diff --git a/mobile/openapi/test/system_config_machine_learning_dto_test.dart b/mobile/openapi/test/system_config_machine_learning_dto_test.dart new file mode 100644 index 0000000000000..e8003763fc52d --- /dev/null +++ b/mobile/openapi/test/system_config_machine_learning_dto_test.dart @@ -0,0 +1,47 @@ +// +// 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 SystemConfigMachineLearningDto +void main() { + // final instance = SystemConfigMachineLearningDto(); + + group('test SystemConfigMachineLearningDto', () { + // bool clipEncodeEnabled + test('to test the property `clipEncodeEnabled`', () async { + // TODO + }); + + // bool enabled + test('to test the property `enabled`', () async { + // TODO + }); + + // bool facialRecognitionEnabled + test('to test the property `facialRecognitionEnabled`', () async { + // TODO + }); + + // bool tagImageEnabled + test('to test the property `tagImageEnabled`', () async { + // TODO + }); + + // String url + test('to test the property `url`', () async { + // TODO + }); + + + }); + +} diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index e12c50da4677f..e244c31a7f2e6 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -3243,38 +3243,6 @@ ] } }, - "/search/config": { - "get": { - "operationId": "getSearchConfig", - "parameters": [], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SearchConfigResponseDto" - } - } - }, - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Search" - ] - } - }, "/search/explore": { "get": { "operationId": "getExploreData", @@ -6424,17 +6392,6 @@ ], "type": "object" }, - "SearchConfigResponseDto": { - "properties": { - "enabled": { - "type": "boolean" - } - }, - "required": [ - "enabled" - ], - "type": "object" - }, "SearchExploreItem": { "properties": { "data": { @@ -6518,7 +6475,10 @@ }, "ServerFeaturesDto": { "properties": { - "machineLearning": { + "clipEncode": { + "type": "boolean" + }, + "facialRecognition": { "type": "boolean" }, "oauth": { @@ -6532,11 +6492,20 @@ }, "search": { "type": "boolean" + }, + "sidecar": { + "type": "boolean" + }, + "tagImage": { + "type": "boolean" } }, "required": [ - "machineLearning", + "clipEncode", + "facialRecognition", + "sidecar", "search", + "tagImage", "oauth", "oauthAutoLaunch", "passwordLogin" @@ -6868,6 +6837,9 @@ "job": { "$ref": "#/components/schemas/SystemConfigJobDto" }, + "machineLearning": { + "$ref": "#/components/schemas/SystemConfigMachineLearningDto" + }, "oauth": { "$ref": "#/components/schemas/SystemConfigOAuthDto" }, @@ -6883,6 +6855,7 @@ }, "required": [ "ffmpeg", + "machineLearning", "oauth", "passwordLogin", "storageTemplate", @@ -6989,6 +6962,33 @@ ], "type": "object" }, + "SystemConfigMachineLearningDto": { + "properties": { + "clipEncodeEnabled": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "facialRecognitionEnabled": { + "type": "boolean" + }, + "tagImageEnabled": { + "type": "boolean" + }, + "url": { + "type": "string" + } + }, + "required": [ + "enabled", + "url", + "clipEncodeEnabled", + "facialRecognitionEnabled", + "tagImageEnabled" + ], + "type": "object" + }, "SystemConfigOAuthDto": { "properties": { "autoLaunch": { diff --git a/server/src/domain/domain.constant.ts b/server/src/domain/domain.constant.ts index 04b8309760ac2..2e076ad217b26 100644 --- a/server/src/domain/domain.constant.ts +++ b/server/src/domain/domain.constant.ts @@ -1,5 +1,4 @@ import { AssetType } from '@app/infra/entities'; -import { BadRequestException } from '@nestjs/common'; import { Duration } from 'luxon'; import { extname } from 'node:path'; import pkg from 'src/../../package.json'; @@ -24,17 +23,6 @@ export const SERVER_VERSION = `${serverVersion.major}.${serverVersion.minor}.${s export const APP_MEDIA_LOCATION = process.env.IMMICH_MEDIA_LOCATION || './upload'; -export const SEARCH_ENABLED = process.env.TYPESENSE_ENABLED !== 'false'; - -export const MACHINE_LEARNING_URL = process.env.IMMICH_MACHINE_LEARNING_URL || 'http://immich-machine-learning:3003'; -export const MACHINE_LEARNING_ENABLED = MACHINE_LEARNING_URL !== 'false'; - -export function assertMachineLearningEnabled() { - if (!MACHINE_LEARNING_ENABLED) { - throw new BadRequestException('Machine learning is not enabled.'); - } -} - const image: Record = { '.3fr': ['image/3fr', 'image/x-hasselblad-3fr'], '.ari': ['image/ari', 'image/x-arriflex-ari'], diff --git a/server/src/domain/facial-recognition/facial-recognition.service.spec.ts b/server/src/domain/facial-recognition/facial-recognition.service.spec.ts index 537d5e5fe6f1a..3f57dc9bf6dbe 100644 --- a/server/src/domain/facial-recognition/facial-recognition.service.spec.ts +++ b/server/src/domain/facial-recognition/facial-recognition.service.spec.ts @@ -9,6 +9,7 @@ import { newPersonRepositoryMock, newSearchRepositoryMock, newStorageRepositoryMock, + newSystemConfigRepositoryMock, personStub, } from '@test'; import { IAssetRepository, WithoutProperty } from '../asset'; @@ -18,6 +19,7 @@ import { IPersonRepository } from '../person'; import { ISearchRepository } from '../search'; import { IMachineLearningRepository } from '../smart-info'; import { IStorageRepository } from '../storage'; +import { ISystemConfigRepository } from '../system-config'; import { IFaceRepository } from './face.repository'; import { FacialRecognitionService } from './facial-recognition.services'; @@ -94,6 +96,7 @@ const faceSearch = { describe(FacialRecognitionService.name, () => { let sut: FacialRecognitionService; let assetMock: jest.Mocked; + let configMock: jest.Mocked; let faceMock: jest.Mocked; let jobMock: jest.Mocked; let machineLearningMock: jest.Mocked; @@ -104,6 +107,7 @@ describe(FacialRecognitionService.name, () => { beforeEach(async () => { assetMock = newAssetRepositoryMock(); + configMock = newSystemConfigRepositoryMock(); faceMock = newFaceRepositoryMock(); jobMock = newJobRepositoryMock(); machineLearningMock = newMachineLearningRepositoryMock(); @@ -116,6 +120,7 @@ describe(FacialRecognitionService.name, () => { sut = new FacialRecognitionService( assetMock, + configMock, faceMock, jobMock, machineLearningMock, @@ -174,7 +179,7 @@ describe(FacialRecognitionService.name, () => { machineLearningMock.detectFaces.mockResolvedValue([]); assetMock.getByIds.mockResolvedValue([assetStub.image]); await sut.handleRecognizeFaces({ id: assetStub.image.id }); - expect(machineLearningMock.detectFaces).toHaveBeenCalledWith({ + expect(machineLearningMock.detectFaces).toHaveBeenCalledWith('http://immich-machine-learning:3003', { imagePath: assetStub.image.resizePath, }); expect(faceMock.create).not.toHaveBeenCalled(); diff --git a/server/src/domain/facial-recognition/facial-recognition.services.ts b/server/src/domain/facial-recognition/facial-recognition.services.ts index 90dd4a646f2c0..68886d1f2b635 100644 --- a/server/src/domain/facial-recognition/facial-recognition.services.ts +++ b/server/src/domain/facial-recognition/facial-recognition.services.ts @@ -1,7 +1,6 @@ import { Inject, Logger } from '@nestjs/common'; import { join } from 'path'; import { IAssetRepository, WithoutProperty } from '../asset'; -import { MACHINE_LEARNING_ENABLED } from '../domain.constant'; import { usePagination } from '../domain.util'; import { IBaseJob, IEntityJob, IFaceThumbnailJob, IJobRepository, JobName, JOBS_ASSET_PAGINATION_SIZE } from '../job'; import { CropOptions, FACE_THUMBNAIL_SIZE, IMediaRepository } from '../media'; @@ -9,14 +8,17 @@ import { IPersonRepository } from '../person/person.repository'; import { ISearchRepository } from '../search/search.repository'; import { IMachineLearningRepository } from '../smart-info'; import { IStorageRepository, StorageCore, StorageFolder } from '../storage'; +import { ISystemConfigRepository, SystemConfigCore } from '../system-config'; import { AssetFaceId, IFaceRepository } from './face.repository'; export class FacialRecognitionService { private logger = new Logger(FacialRecognitionService.name); private storageCore = new StorageCore(); + private configCore: SystemConfigCore; constructor( @Inject(IAssetRepository) private assetRepository: IAssetRepository, + @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, @Inject(IFaceRepository) private faceRepository: IFaceRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository, @@ -24,9 +26,16 @@ export class FacialRecognitionService { @Inject(IPersonRepository) private personRepository: IPersonRepository, @Inject(ISearchRepository) private searchRepository: ISearchRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, - ) {} + ) { + this.configCore = new SystemConfigCore(configRepository); + } async handleQueueRecognizeFaces({ force }: IBaseJob) { + const { machineLearning } = await this.configCore.getConfig(); + if (!machineLearning.enabled || !machineLearning.facialRecognitionEnabled) { + return true; + } + const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { return force ? this.assetRepository.getAll(pagination, { order: 'DESC' }) @@ -49,12 +58,17 @@ export class FacialRecognitionService { } async handleRecognizeFaces({ id }: IEntityJob) { + const { machineLearning } = await this.configCore.getConfig(); + if (!machineLearning.enabled || !machineLearning.facialRecognitionEnabled) { + return true; + } + const [asset] = await this.assetRepository.getByIds([id]); - if (!asset || !MACHINE_LEARNING_ENABLED || !asset.resizePath) { + if (!asset || !asset.resizePath) { return false; } - const faces = await this.machineLearning.detectFaces({ imagePath: asset.resizePath }); + const faces = await this.machineLearning.detectFaces(machineLearning.url, { imagePath: asset.resizePath }); this.logger.debug(`${faces.length} faces detected in ${asset.resizePath}`); this.logger.verbose(faces.map((face) => ({ ...face, embedding: `float[${face.embedding.length}]` }))); @@ -100,6 +114,11 @@ export class FacialRecognitionService { } async handleGenerateFaceThumbnail(data: IFaceThumbnailJob) { + const { machineLearning } = await this.configCore.getConfig(); + if (!machineLearning.enabled || !machineLearning.facialRecognitionEnabled) { + return true; + } + const { assetId, personId, boundingBox, imageWidth, imageHeight } = data; const [asset] = await this.assetRepository.getByIds([assetId]); diff --git a/server/src/domain/job/job.service.ts b/server/src/domain/job/job.service.ts index cc90e4ccd3a94..7f151689f8d60 100644 --- a/server/src/domain/job/job.service.ts +++ b/server/src/domain/job/job.service.ts @@ -2,8 +2,7 @@ import { AssetType } from '@app/infra/entities'; import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common'; import { IAssetRepository, mapAsset } from '../asset'; import { CommunicationEvent, ICommunicationRepository } from '../communication'; -import { assertMachineLearningEnabled } from '../domain.constant'; -import { ISystemConfigRepository } from '../system-config'; +import { FeatureFlag, ISystemConfigRepository } from '../system-config'; import { SystemConfigCore } from '../system-config/system-config.core'; import { JobCommand, JobName, QueueName } from './job.constants'; import { AllJobStatusResponseDto, JobCommandDto, JobStatusDto } from './job.dto'; @@ -78,23 +77,25 @@ export class JobService { return this.jobRepository.queue({ name: JobName.STORAGE_TEMPLATE_MIGRATION }); case QueueName.OBJECT_TAGGING: - assertMachineLearningEnabled(); + await this.configCore.requireFeature(FeatureFlag.TAG_IMAGE); return this.jobRepository.queue({ name: JobName.QUEUE_OBJECT_TAGGING, data: { force } }); case QueueName.CLIP_ENCODING: - assertMachineLearningEnabled(); + await this.configCore.requireFeature(FeatureFlag.CLIP_ENCODE); return this.jobRepository.queue({ name: JobName.QUEUE_ENCODE_CLIP, data: { force } }); case QueueName.METADATA_EXTRACTION: return this.jobRepository.queue({ name: JobName.QUEUE_METADATA_EXTRACTION, data: { force } }); case QueueName.SIDECAR: + await this.configCore.requireFeature(FeatureFlag.SIDECAR); return this.jobRepository.queue({ name: JobName.QUEUE_SIDECAR, data: { force } }); case QueueName.THUMBNAIL_GENERATION: return this.jobRepository.queue({ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force } }); case QueueName.RECOGNIZE_FACES: + await this.configCore.requireFeature(FeatureFlag.FACIAL_RECOGNITION); return this.jobRepository.queue({ name: JobName.QUEUE_RECOGNIZE_FACES, data: { force } }); default: diff --git a/server/src/domain/search/response-dto/index.ts b/server/src/domain/search/response-dto/index.ts index e74cc29b3732b..f48856bca88c3 100644 --- a/server/src/domain/search/response-dto/index.ts +++ b/server/src/domain/search/response-dto/index.ts @@ -1,3 +1,2 @@ -export * from './search-config-response.dto'; export * from './search-explore.response.dto'; export * from './search-response.dto'; diff --git a/server/src/domain/search/response-dto/search-config-response.dto.ts b/server/src/domain/search/response-dto/search-config-response.dto.ts deleted file mode 100644 index 9f2f3795877a5..0000000000000 --- a/server/src/domain/search/response-dto/search-config-response.dto.ts +++ /dev/null @@ -1,3 +0,0 @@ -export class SearchConfigResponseDto { - enabled!: boolean; -} diff --git a/server/src/domain/search/search.service.spec.ts b/server/src/domain/search/search.service.spec.ts index 4ffec5832c87f..d73c269ca49c6 100644 --- a/server/src/domain/search/search.service.spec.ts +++ b/server/src/domain/search/search.service.spec.ts @@ -1,5 +1,3 @@ -import { BadRequestException } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; import { albumStub, assetStub, @@ -12,12 +10,14 @@ import { newJobRepositoryMock, newMachineLearningRepositoryMock, newSearchRepositoryMock, + newSystemConfigRepositoryMock, searchStub, } from '@test'; import { plainToInstance } from 'class-transformer'; import { IAlbumRepository } from '../album/album.repository'; import { IAssetRepository } from '../asset/asset.repository'; import { IFaceRepository } from '../facial-recognition'; +import { ISystemConfigRepository } from '../index'; import { JobName } from '../job'; import { IJobRepository } from '../job/job.repository'; import { IMachineLearningRepository } from '../smart-info'; @@ -31,29 +31,26 @@ describe(SearchService.name, () => { let sut: SearchService; let albumMock: jest.Mocked; let assetMock: jest.Mocked; + let configMock: jest.Mocked; let faceMock: jest.Mocked; let jobMock: jest.Mocked; let machineMock: jest.Mocked; let searchMock: jest.Mocked; - let configMock: jest.Mocked; - const makeSut = (value?: string) => { - if (value) { - configMock.get.mockReturnValue(value); - } - return new SearchService(albumMock, assetMock, faceMock, jobMock, machineMock, searchMock, configMock); - }; - - beforeEach(() => { + beforeEach(async () => { albumMock = newAlbumRepositoryMock(); assetMock = newAssetRepositoryMock(); + configMock = newSystemConfigRepositoryMock(); faceMock = newFaceRepositoryMock(); jobMock = newJobRepositoryMock(); machineMock = newMachineLearningRepositoryMock(); searchMock = newSearchRepositoryMock(); - configMock = { get: jest.fn() } as unknown as jest.Mocked; - sut = makeSut(); + sut = new SearchService(albumMock, assetMock, configMock, faceMock, jobMock, machineMock, searchMock); + + searchMock.checkMigrationStatus.mockResolvedValue({ assets: false, albums: false, faces: false }); + + await sut.init(); }); afterEach(() => { @@ -86,45 +83,18 @@ describe(SearchService.name, () => { }); }); - describe('isEnabled', () => { - it('should be enabled by default', () => { - expect(sut.isEnabled()).toBe(true); - }); - - it('should be disabled via an env variable', () => { - const sut = makeSut('false'); - - expect(sut.isEnabled()).toBe(false); - }); - }); - - describe('getConfig', () => { - it('should return the config', () => { - expect(sut.getConfig()).toEqual({ enabled: true }); - }); - - it('should return the config when search is disabled', () => { - const sut = makeSut('false'); - - expect(sut.getConfig()).toEqual({ enabled: false }); - }); - }); - describe(`init`, () => { - it('should skip when search is disabled', async () => { - const sut = makeSut('false'); + // it('should skip when search is disabled', async () => { + // await sut.init(); - await sut.init(); + // expect(searchMock.setup).not.toHaveBeenCalled(); + // expect(searchMock.checkMigrationStatus).not.toHaveBeenCalled(); + // expect(jobMock.queue).not.toHaveBeenCalled(); - expect(searchMock.setup).not.toHaveBeenCalled(); - expect(searchMock.checkMigrationStatus).not.toHaveBeenCalled(); - expect(jobMock.queue).not.toHaveBeenCalled(); - - sut.teardown(); - }); + // sut.teardown(); + // }); it('should skip schema migration if not needed', async () => { - searchMock.checkMigrationStatus.mockResolvedValue({ assets: false, albums: false, faces: false }); await sut.init(); expect(searchMock.setup).toHaveBeenCalled(); @@ -145,14 +115,14 @@ describe(SearchService.name, () => { }); describe('search', () => { - it('should throw an error is search is disabled', async () => { - const sut = makeSut('false'); + // it('should throw an error is search is disabled', async () => { + // sut['enabled'] = false; - await expect(sut.search(authStub.admin, {})).rejects.toBeInstanceOf(BadRequestException); + // await expect(sut.search(authStub.admin, {})).rejects.toBeInstanceOf(BadRequestException); - expect(searchMock.searchAlbums).not.toHaveBeenCalled(); - expect(searchMock.searchAssets).not.toHaveBeenCalled(); - }); + // expect(searchMock.searchAlbums).not.toHaveBeenCalled(); + // expect(searchMock.searchAssets).not.toHaveBeenCalled(); + // }); it('should search assets and albums', async () => { searchMock.searchAssets.mockResolvedValue(searchStub.emptyResults); @@ -205,7 +175,7 @@ describe(SearchService.name, () => { }); it('should skip if search is disabled', async () => { - const sut = makeSut('false'); + sut['enabled'] = false; await sut.handleIndexAssets(); @@ -216,7 +186,7 @@ describe(SearchService.name, () => { describe('handleIndexAsset', () => { it('should skip if search is disabled', () => { - const sut = makeSut('false'); + sut['enabled'] = false; sut.handleIndexAsset({ ids: [assetStub.image.id] }); }); @@ -227,7 +197,7 @@ describe(SearchService.name, () => { describe('handleIndexAlbums', () => { it('should skip if search is disabled', () => { - const sut = makeSut('false'); + sut['enabled'] = false; sut.handleIndexAlbums(); }); @@ -242,7 +212,7 @@ describe(SearchService.name, () => { describe('handleIndexAlbum', () => { it('should skip if search is disabled', () => { - const sut = makeSut('false'); + sut['enabled'] = false; sut.handleIndexAlbum({ ids: [albumStub.empty.id] }); }); @@ -253,7 +223,7 @@ describe(SearchService.name, () => { describe('handleRemoveAlbum', () => { it('should skip if search is disabled', () => { - const sut = makeSut('false'); + sut['enabled'] = false; sut.handleRemoveAlbum({ ids: ['album1'] }); }); @@ -264,7 +234,7 @@ describe(SearchService.name, () => { describe('handleRemoveAsset', () => { it('should skip if search is disabled', () => { - const sut = makeSut('false'); + sut['enabled'] = false; sut.handleRemoveAsset({ ids: ['asset1'] }); }); @@ -305,7 +275,7 @@ describe(SearchService.name, () => { }); it('should skip if search is disabled', async () => { - const sut = makeSut('false'); + sut['enabled'] = false; await sut.handleIndexFaces(); @@ -315,7 +285,7 @@ describe(SearchService.name, () => { describe('handleIndexAsset', () => { it('should skip if search is disabled', () => { - const sut = makeSut('false'); + sut['enabled'] = false; sut.handleIndexFace({ assetId: 'asset-1', personId: 'person-1' }); expect(searchMock.importFaces).not.toHaveBeenCalled(); @@ -333,7 +303,7 @@ describe(SearchService.name, () => { describe('handleRemoveFace', () => { it('should skip if search is disabled', () => { - const sut = makeSut('false'); + sut['enabled'] = false; sut.handleRemoveFace({ assetId: 'asset-1', personId: 'person-1' }); }); diff --git a/server/src/domain/search/search.service.ts b/server/src/domain/search/search.service.ts index 80236b8d456d2..66dd6ffb0ff92 100644 --- a/server/src/domain/search/search.service.ts +++ b/server/src/domain/search/search.service.ts @@ -1,18 +1,17 @@ import { AlbumEntity, AssetEntity, AssetFaceEntity } from '@app/infra/entities'; -import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; +import { Inject, Injectable, Logger } from '@nestjs/common'; import { mapAlbumWithAssets } from '../album'; import { IAlbumRepository } from '../album/album.repository'; import { AssetResponseDto, mapAsset } from '../asset'; import { IAssetRepository } from '../asset/asset.repository'; import { AuthUserDto } from '../auth'; -import { MACHINE_LEARNING_ENABLED } from '../domain.constant'; import { usePagination } from '../domain.util'; import { AssetFaceId, IFaceRepository } from '../facial-recognition'; import { IAssetFaceJob, IBulkEntityJob, IJobRepository, JobName, JOBS_ASSET_PAGINATION_SIZE } from '../job'; import { IMachineLearningRepository } from '../smart-info'; +import { FeatureFlag, ISystemConfigRepository, SystemConfigCore } from '../system-config'; import { SearchDto } from './dto'; -import { SearchConfigResponseDto, SearchResponseDto } from './response-dto'; +import { SearchResponseDto } from './response-dto'; import { ISearchRepository, OwnedFaceEntity, @@ -30,8 +29,9 @@ interface SyncQueue { @Injectable() export class SearchService { private logger = new Logger(SearchService.name); - private enabled: boolean; + private enabled = false; private timer: NodeJS.Timer | null = null; + private configCore: SystemConfigCore; private albumQueue: SyncQueue = { upsert: new Set(), @@ -51,16 +51,13 @@ export class SearchService { constructor( @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository, + @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, @Inject(IFaceRepository) private faceRepository: IFaceRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository, @Inject(ISearchRepository) private searchRepository: ISearchRepository, - configService: ConfigService, ) { - this.enabled = configService.get('TYPESENSE_ENABLED') !== 'false'; - if (this.enabled) { - this.timer = setInterval(() => this.flush(), 5_000); - } + this.configCore = new SystemConfigCore(configRepository); } teardown() { @@ -70,17 +67,8 @@ export class SearchService { } } - isEnabled() { - return this.enabled; - } - - getConfig(): SearchConfigResponseDto { - return { - enabled: this.enabled, - }; - } - async init() { + this.enabled = await this.configCore.hasFeature(FeatureFlag.SEARCH); if (!this.enabled) { return; } @@ -101,10 +89,13 @@ export class SearchService { this.logger.debug('Queueing job to re-index all faces'); await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_FACES }); } + + this.timer = setInterval(() => this.flush(), 5_000); } async getExploreData(authUser: AuthUserDto): Promise[]> { - this.assertEnabled(); + await this.configCore.requireFeature(FeatureFlag.SEARCH); + const results = await this.searchRepository.explore(authUser.id); const lookup = await this.getLookupMap( results.reduce( @@ -126,16 +117,18 @@ export class SearchService { } async search(authUser: AuthUserDto, dto: SearchDto): Promise { - this.assertEnabled(); + const { machineLearning } = await this.configCore.getConfig(); + await this.configCore.requireFeature(FeatureFlag.SEARCH); const query = dto.q || dto.query || '*'; - const strategy = dto.clip && MACHINE_LEARNING_ENABLED ? SearchStrategy.CLIP : SearchStrategy.TEXT; + const hasClip = machineLearning.enabled && machineLearning.clipEncodeEnabled; + const strategy = dto.clip && hasClip ? SearchStrategy.CLIP : SearchStrategy.TEXT; const filters = { userId: authUser.id, ...dto }; let assets: SearchResult; switch (strategy) { case SearchStrategy.CLIP: - const clip = await this.machineLearning.encodeText(query); + const clip = await this.machineLearning.encodeText(machineLearning.url, query); assets = await this.searchRepository.vectorSearch(clip, filters); break; case SearchStrategy.TEXT: @@ -333,12 +326,6 @@ export class SearchService { } } - private assertEnabled() { - if (!this.enabled) { - throw new BadRequestException('Search is disabled'); - } - } - private async idsToAlbums(ids: string[]): Promise { const entities = await this.albumRepository.getByIds(ids); return this.patchAlbums(entities); diff --git a/server/src/domain/server-info/server-info.dto.ts b/server/src/domain/server-info/server-info.dto.ts index ea0699aa6f3a8..1256f12241a86 100644 --- a/server/src/domain/server-info/server-info.dto.ts +++ b/server/src/domain/server-info/server-info.dto.ts @@ -1,4 +1,4 @@ -import { IServerVersion } from '@app/domain'; +import { FeatureFlags, IServerVersion } from '@app/domain'; import { ApiProperty, ApiResponseProperty } from '@nestjs/swagger'; export class ServerPingResponse { @@ -79,10 +79,14 @@ export class ServerMediaTypesResponseDto { sidecar!: string[]; } -export class ServerFeaturesDto { - machineLearning!: boolean; +export class ServerFeaturesDto implements FeatureFlags { + clipEncode!: boolean; + facialRecognition!: boolean; + sidecar!: boolean; search!: boolean; + tagImage!: boolean; + // TODO: use these instead of `POST oauth/config` oauth!: boolean; oauthAutoLaunch!: boolean; passwordLogin!: boolean; diff --git a/server/src/domain/server-info/server-info.service.spec.ts b/server/src/domain/server-info/server-info.service.spec.ts index 764e1c88913a7..fefebead85774 100644 --- a/server/src/domain/server-info/server-info.service.spec.ts +++ b/server/src/domain/server-info/server-info.service.spec.ts @@ -147,11 +147,14 @@ describe(ServerInfoService.name, () => { describe('getFeatures', () => { it('should respond the server features', async () => { await expect(sut.getFeatures()).resolves.toEqual({ - machineLearning: true, + clipEncode: true, + facialRecognition: true, oauth: false, oauthAutoLaunch: false, passwordLogin: true, search: true, + sidecar: true, + tagImage: true, }); expect(configMock.load).toHaveBeenCalled(); }); diff --git a/server/src/domain/server-info/server-info.service.ts b/server/src/domain/server-info/server-info.service.ts index e628d12bad19b..655b21603ef90 100644 --- a/server/src/domain/server-info/server-info.service.ts +++ b/server/src/domain/server-info/server-info.service.ts @@ -1,9 +1,8 @@ import { Inject, Injectable } from '@nestjs/common'; -import { MACHINE_LEARNING_ENABLED, mimeTypes, SEARCH_ENABLED, serverVersion } from '../domain.constant'; +import { mimeTypes, serverVersion } from '../domain.constant'; import { asHumanReadable } from '../domain.util'; import { IStorageRepository, StorageCore, StorageFolder } from '../storage'; -import { ISystemConfigRepository } from '../system-config'; -import { SystemConfigCore } from '../system-config/system-config.core'; +import { ISystemConfigRepository, SystemConfigCore } from '../system-config'; import { IUserRepository, UserStatsQueryResponse } from '../user'; import { ServerFeaturesDto, @@ -52,18 +51,8 @@ export class ServerInfoService { return serverVersion; } - async getFeatures(): Promise { - const config = await this.configCore.getConfig(); - - return { - machineLearning: MACHINE_LEARNING_ENABLED, - search: SEARCH_ENABLED, - - // TODO: use these instead of `POST oauth/config` - oauth: config.oauth.enabled, - oauthAutoLaunch: config.oauth.autoLaunch, - passwordLogin: config.passwordLogin.enabled, - }; + getFeatures(): Promise { + return this.configCore.getFeatures(); } async getStats(): Promise { diff --git a/server/src/domain/smart-info/machine-learning.interface.ts b/server/src/domain/smart-info/machine-learning.interface.ts index 3f7b9b2d82756..7c431fd5f3887 100644 --- a/server/src/domain/smart-info/machine-learning.interface.ts +++ b/server/src/domain/smart-info/machine-learning.interface.ts @@ -20,8 +20,8 @@ export interface DetectFaceResult { } export interface IMachineLearningRepository { - classifyImage(input: MachineLearningInput): Promise; - encodeImage(input: MachineLearningInput): Promise; - encodeText(input: string): Promise; - detectFaces(input: MachineLearningInput): Promise; + classifyImage(url: string, input: MachineLearningInput): Promise; + encodeImage(url: string, input: MachineLearningInput): Promise; + encodeText(url: string, input: string): Promise; + detectFaces(url: string, input: MachineLearningInput): Promise; } diff --git a/server/src/domain/smart-info/smart-info.service.spec.ts b/server/src/domain/smart-info/smart-info.service.spec.ts index f6464cb021bb0..7461058e292db 100644 --- a/server/src/domain/smart-info/smart-info.service.spec.ts +++ b/server/src/domain/smart-info/smart-info.service.spec.ts @@ -5,9 +5,11 @@ import { newJobRepositoryMock, newMachineLearningRepositoryMock, newSmartInfoRepositoryMock, + newSystemConfigRepositoryMock, } from '@test'; import { IAssetRepository, WithoutProperty } from '../asset'; import { IJobRepository, JobName } from '../job'; +import { ISystemConfigRepository } from '../system-config'; import { IMachineLearningRepository } from './machine-learning.interface'; import { ISmartInfoRepository } from './smart-info.repository'; import { SmartInfoService } from './smart-info.service'; @@ -20,16 +22,18 @@ const asset = { describe(SmartInfoService.name, () => { let sut: SmartInfoService; let assetMock: jest.Mocked; + let configMock: jest.Mocked; let jobMock: jest.Mocked; let smartMock: jest.Mocked; let machineMock: jest.Mocked; beforeEach(async () => { assetMock = newAssetRepositoryMock(); + configMock = newSystemConfigRepositoryMock(); smartMock = newSmartInfoRepositoryMock(); jobMock = newJobRepositoryMock(); machineMock = newMachineLearningRepositoryMock(); - sut = new SmartInfoService(assetMock, jobMock, smartMock, machineMock); + sut = new SmartInfoService(assetMock, configMock, jobMock, smartMock, machineMock); assetMock.getByIds.mockResolvedValue([asset]); }); @@ -80,7 +84,9 @@ describe(SmartInfoService.name, () => { await sut.handleClassifyImage({ id: asset.id }); - expect(machineMock.classifyImage).toHaveBeenCalledWith({ imagePath: 'path/to/resize.ext' }); + expect(machineMock.classifyImage).toHaveBeenCalledWith('http://immich-machine-learning:3003', { + imagePath: 'path/to/resize.ext', + }); expect(smartMock.upsert).toHaveBeenCalledWith({ assetId: 'asset-1', tags: ['tag1', 'tag2', 'tag3'], @@ -139,7 +145,9 @@ describe(SmartInfoService.name, () => { await sut.handleEncodeClip({ id: asset.id }); - expect(machineMock.encodeImage).toHaveBeenCalledWith({ imagePath: 'path/to/resize.ext' }); + expect(machineMock.encodeImage).toHaveBeenCalledWith('http://immich-machine-learning:3003', { + imagePath: 'path/to/resize.ext', + }); expect(smartMock.upsert).toHaveBeenCalledWith({ assetId: 'asset-1', clipEmbedding: [0.01, 0.02, 0.03], diff --git a/server/src/domain/smart-info/smart-info.service.ts b/server/src/domain/smart-info/smart-info.service.ts index c1341a04b6021..2512c4c32dd3f 100644 --- a/server/src/domain/smart-info/smart-info.service.ts +++ b/server/src/domain/smart-info/smart-info.service.ts @@ -1,23 +1,31 @@ -import { Inject, Injectable, Logger } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import { IAssetRepository, WithoutProperty } from '../asset'; -import { MACHINE_LEARNING_ENABLED } from '../domain.constant'; import { usePagination } from '../domain.util'; import { IBaseJob, IEntityJob, IJobRepository, JobName, JOBS_ASSET_PAGINATION_SIZE } from '../job'; +import { ISystemConfigRepository, SystemConfigCore } from '../system-config'; import { IMachineLearningRepository } from './machine-learning.interface'; import { ISmartInfoRepository } from './smart-info.repository'; @Injectable() export class SmartInfoService { - private logger = new Logger(SmartInfoService.name); + private configCore: SystemConfigCore; constructor( @Inject(IAssetRepository) private assetRepository: IAssetRepository, + @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(ISmartInfoRepository) private repository: ISmartInfoRepository, @Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository, - ) {} + ) { + this.configCore = new SystemConfigCore(configRepository); + } async handleQueueObjectTagging({ force }: IBaseJob) { + const { machineLearning } = await this.configCore.getConfig(); + if (!machineLearning.enabled || !machineLearning.tagImageEnabled) { + return true; + } + const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { return force ? this.assetRepository.getAll(pagination) @@ -34,19 +42,28 @@ export class SmartInfoService { } async handleClassifyImage({ id }: IEntityJob) { - const [asset] = await this.assetRepository.getByIds([id]); + const { machineLearning } = await this.configCore.getConfig(); + if (!machineLearning.enabled || !machineLearning.tagImageEnabled) { + return true; + } - if (!MACHINE_LEARNING_ENABLED || !asset.resizePath) { + const [asset] = await this.assetRepository.getByIds([id]); + if (!asset.resizePath) { return false; } - const tags = await this.machineLearning.classifyImage({ imagePath: asset.resizePath }); + const tags = await this.machineLearning.classifyImage(machineLearning.url, { imagePath: asset.resizePath }); await this.repository.upsert({ assetId: asset.id, tags }); return true; } async handleQueueEncodeClip({ force }: IBaseJob) { + const { machineLearning } = await this.configCore.getConfig(); + if (!machineLearning.enabled || !machineLearning.clipEncodeEnabled) { + return true; + } + const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { return force ? this.assetRepository.getAll(pagination) @@ -63,13 +80,17 @@ export class SmartInfoService { } async handleEncodeClip({ id }: IEntityJob) { - const [asset] = await this.assetRepository.getByIds([id]); + const { machineLearning } = await this.configCore.getConfig(); + if (!machineLearning.enabled || !machineLearning.clipEncodeEnabled) { + return true; + } - if (!MACHINE_LEARNING_ENABLED || !asset.resizePath) { + const [asset] = await this.assetRepository.getByIds([id]); + if (!asset.resizePath) { return false; } - const clipEmbedding = await this.machineLearning.encodeImage({ imagePath: asset.resizePath }); + const clipEmbedding = await this.machineLearning.encodeImage(machineLearning.url, { imagePath: asset.resizePath }); await this.repository.upsert({ assetId: asset.id, clipEmbedding: clipEmbedding }); return true; diff --git a/server/src/domain/system-config/dto/system-config-machine-learning.dto.ts b/server/src/domain/system-config/dto/system-config-machine-learning.dto.ts new file mode 100644 index 0000000000000..b4063669d3859 --- /dev/null +++ b/server/src/domain/system-config/dto/system-config-machine-learning.dto.ts @@ -0,0 +1,19 @@ +import { IsBoolean, IsUrl, ValidateIf } from 'class-validator'; + +export class SystemConfigMachineLearningDto { + @IsBoolean() + enabled!: boolean; + + @IsUrl({ require_tld: false }) + @ValidateIf((dto) => dto.enabled) + url!: string; + + @IsBoolean() + clipEncodeEnabled!: boolean; + + @IsBoolean() + facialRecognitionEnabled!: boolean; + + @IsBoolean() + tagImageEnabled!: boolean; +} diff --git a/server/src/domain/system-config/dto/system-config.dto.ts b/server/src/domain/system-config/dto/system-config.dto.ts index f34ebf7100f2e..c089da3df38c6 100644 --- a/server/src/domain/system-config/dto/system-config.dto.ts +++ b/server/src/domain/system-config/dto/system-config.dto.ts @@ -4,16 +4,22 @@ import { Type } from 'class-transformer'; import { IsObject, ValidateNested } from 'class-validator'; import { SystemConfigFFmpegDto } from './system-config-ffmpeg.dto'; import { SystemConfigJobDto } from './system-config-job.dto'; +import { SystemConfigMachineLearningDto } from './system-config-machine-learning.dto'; import { SystemConfigOAuthDto } from './system-config-oauth.dto'; import { SystemConfigPasswordLoginDto } from './system-config-password-login.dto'; import { SystemConfigStorageTemplateDto } from './system-config-storage-template.dto'; -export class SystemConfigDto { +export class SystemConfigDto implements SystemConfig { @Type(() => SystemConfigFFmpegDto) @ValidateNested() @IsObject() ffmpeg!: SystemConfigFFmpegDto; + @Type(() => SystemConfigMachineLearningDto) + @ValidateNested() + @IsObject() + machineLearning!: SystemConfigMachineLearningDto; + @Type(() => SystemConfigOAuthDto) @ValidateNested() @IsObject() diff --git a/server/src/domain/system-config/index.ts b/server/src/domain/system-config/index.ts index e5a685a30f4a1..da270886b059e 100644 --- a/server/src/domain/system-config/index.ts +++ b/server/src/domain/system-config/index.ts @@ -1,5 +1,6 @@ export * from './dto'; export * from './response-dto'; export * from './system-config.constants'; +export * from './system-config.core'; export * from './system-config.repository'; export * from './system-config.service'; diff --git a/server/src/domain/system-config/system-config.core.ts b/server/src/domain/system-config/system-config.core.ts index 80f650571cfd8..0b76228f5bf34 100644 --- a/server/src/domain/system-config/system-config.core.ts +++ b/server/src/domain/system-config/system-config.core.ts @@ -9,7 +9,7 @@ import { TranscodePolicy, VideoCodec, } from '@app/infra/entities'; -import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { BadRequestException, ForbiddenException, Injectable, Logger } from '@nestjs/common'; import * as _ from 'lodash'; import { Subject } from 'rxjs'; import { DeepPartial } from 'typeorm'; @@ -44,6 +44,13 @@ export const defaults = Object.freeze({ [QueueName.THUMBNAIL_GENERATION]: { concurrency: 5 }, [QueueName.VIDEO_CONVERSION]: { concurrency: 1 }, }, + machineLearning: { + enabled: process.env.IMMICH_MACHINE_LEARNING_ENABLED !== 'false', + url: process.env.IMMICH_MACHINE_LEARNING_URL || 'http://immich-machine-learning:3003', + facialRecognitionEnabled: true, + tagImageEnabled: true, + clipEncodeEnabled: true, + }, oauth: { enabled: false, issuerUrl: '', @@ -71,6 +78,19 @@ export const defaults = Object.freeze({ }, }); +export enum FeatureFlag { + CLIP_ENCODE = 'clipEncode', + FACIAL_RECOGNITION = 'facialRecognition', + TAG_IMAGE = 'tagImage', + SIDECAR = 'sidecar', + SEARCH = 'search', + OAUTH = 'oauth', + OAUTH_AUTO_LAUNCH = 'oauthAutoLaunch', + PASSWORD_LOGIN = 'passwordLogin', +} + +export type FeatureFlags = Record; + const singleton = new Subject(); @Injectable() @@ -82,6 +102,53 @@ export class SystemConfigCore { constructor(private repository: ISystemConfigRepository) {} + async requireFeature(feature: FeatureFlag) { + const hasFeature = await this.hasFeature(feature); + if (!hasFeature) { + switch (feature) { + case FeatureFlag.CLIP_ENCODE: + throw new BadRequestException('Clip encoding is not enabled'); + case FeatureFlag.FACIAL_RECOGNITION: + throw new BadRequestException('Facial recognition is not enabled'); + case FeatureFlag.TAG_IMAGE: + throw new BadRequestException('Image tagging is not enabled'); + case FeatureFlag.SIDECAR: + throw new BadRequestException('Sidecar is not enabled'); + case FeatureFlag.SEARCH: + throw new BadRequestException('Search is not enabled'); + case FeatureFlag.OAUTH: + throw new BadRequestException('OAuth is not enabled'); + case FeatureFlag.PASSWORD_LOGIN: + throw new BadRequestException('Password login is not enabled'); + default: + throw new ForbiddenException(`Missing required feature: ${feature}`); + } + } + } + + async hasFeature(feature: FeatureFlag) { + const features = await this.getFeatures(); + return features[feature] ?? false; + } + + async getFeatures(): Promise { + const config = await this.getConfig(); + const mlEnabled = config.machineLearning.enabled; + + return { + [FeatureFlag.CLIP_ENCODE]: mlEnabled && config.machineLearning.clipEncodeEnabled, + [FeatureFlag.FACIAL_RECOGNITION]: mlEnabled && config.machineLearning.facialRecognitionEnabled, + [FeatureFlag.TAG_IMAGE]: mlEnabled && config.machineLearning.tagImageEnabled, + [FeatureFlag.SIDECAR]: true, + [FeatureFlag.SEARCH]: process.env.TYPESENSE_ENABLED !== 'false', + + // TODO: use these instead of `POST oauth/config` + [FeatureFlag.OAUTH]: config.oauth.enabled, + [FeatureFlag.OAUTH_AUTO_LAUNCH]: config.oauth.autoLaunch, + [FeatureFlag.PASSWORD_LOGIN]: config.passwordLogin.enabled, + }; + } + public getDefaults(): SystemConfig { return defaults; } diff --git a/server/src/domain/system-config/system-config.service.spec.ts b/server/src/domain/system-config/system-config.service.spec.ts index bb510c05b3bc2..6735b17bc90c3 100644 --- a/server/src/domain/system-config/system-config.service.spec.ts +++ b/server/src/domain/system-config/system-config.service.spec.ts @@ -46,6 +46,13 @@ const updatedConfig = Object.freeze({ accel: TranscodeHWAccel.DISABLED, tonemap: ToneMapping.HABLE, }, + machineLearning: { + enabled: true, + url: 'http://immich-machine-learning:3003', + facialRecognitionEnabled: true, + tagImageEnabled: true, + clipEncodeEnabled: true, + }, oauth: { autoLaunch: true, autoRegister: true, diff --git a/server/src/immich/app.service.ts b/server/src/immich/app.service.ts index aee680b28d5c1..9e7b149ab2f8f 100644 --- a/server/src/immich/app.service.ts +++ b/server/src/immich/app.service.ts @@ -1,4 +1,4 @@ -import { JobService, MACHINE_LEARNING_ENABLED, SearchService, StorageService } from '@app/domain'; +import { JobService, SearchService, ServerInfoService, StorageService } from '@app/domain'; import { Injectable, Logger } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; @@ -10,6 +10,7 @@ export class AppService { private jobService: JobService, private searchService: SearchService, private storageService: StorageService, + private serverService: ServerInfoService, ) {} @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) @@ -20,8 +21,6 @@ export class AppService { async init() { this.storageService.init(); await this.searchService.init(); - - this.logger.log(`Machine learning is ${MACHINE_LEARNING_ENABLED ? 'enabled' : 'disabled'}`); - this.logger.log(`Search is ${this.searchService.isEnabled() ? 'enabled' : 'disabled'}`); + this.logger.log(`Feature Flags: ${JSON.stringify(await this.serverService.getFeatures(), null, 2)}`); } } diff --git a/server/src/immich/controllers/search.controller.ts b/server/src/immich/controllers/search.controller.ts index bbc10d9bbecaf..a36d1b30533bb 100644 --- a/server/src/immich/controllers/search.controller.ts +++ b/server/src/immich/controllers/search.controller.ts @@ -1,11 +1,4 @@ -import { - AuthUserDto, - SearchConfigResponseDto, - SearchDto, - SearchExploreResponseDto, - SearchResponseDto, - SearchService, -} from '@app/domain'; +import { AuthUserDto, SearchDto, SearchExploreResponseDto, SearchResponseDto, SearchService } from '@app/domain'; import { Controller, Get, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Authenticated, AuthUser } from '../app.guard'; @@ -23,11 +16,6 @@ export class SearchController { return this.service.search(authUser, dto); } - @Get('config') - getSearchConfig(): SearchConfigResponseDto { - return this.service.getConfig(); - } - @Get('explore') getExploreData(@AuthUser() authUser: AuthUserDto): Promise { return this.service.getExploreData(authUser) as Promise; diff --git a/server/src/infra/entities/system-config.entity.ts b/server/src/infra/entities/system-config.entity.ts index ddfad682a7d86..642f40c16c48a 100644 --- a/server/src/infra/entities/system-config.entity.ts +++ b/server/src/infra/entities/system-config.entity.ts @@ -37,6 +37,12 @@ export enum SystemConfigKey { JOB_SEARCH_CONCURRENCY = 'job.search.concurrency', JOB_SIDECAR_CONCURRENCY = 'job.sidecar.concurrency', + MACHINE_LEARNING_ENABLED = 'machineLearning.enabled', + MACHINE_LEARNING_URL = 'machineLearning.url', + MACHINE_LEARNING_FACIAL_RECOGNITION_ENABLED = 'machineLearning.facialRecognitionEnabled', + MACHINE_LEARNING_TAG_IMAGE_ENABLED = 'machineLearning.tagImageEnabled', + MACHINE_LEARNING_CLIP_ENCODE_ENABLED = 'machineLearning.clipEncodeEnabled', + OAUTH_ENABLED = 'oauth.enabled', OAUTH_ISSUER_URL = 'oauth.issuerUrl', OAUTH_CLIENT_ID = 'oauth.clientId', @@ -105,6 +111,13 @@ export interface SystemConfig { tonemap: ToneMapping; }; job: Record; + machineLearning: { + enabled: boolean; + url: string; + clipEncodeEnabled: boolean; + facialRecognitionEnabled: boolean; + tagImageEnabled: boolean; + }; oauth: { enabled: boolean; issuerUrl: string; diff --git a/server/src/infra/repositories/machine-learning.repository.ts b/server/src/infra/repositories/machine-learning.repository.ts index 40398445a09ec..3d3e22449abf3 100644 --- a/server/src/infra/repositories/machine-learning.repository.ts +++ b/server/src/infra/repositories/machine-learning.repository.ts @@ -1,9 +1,9 @@ -import { DetectFaceResult, IMachineLearningRepository, MachineLearningInput, MACHINE_LEARNING_URL } from '@app/domain'; +import { DetectFaceResult, IMachineLearningRepository, MachineLearningInput } from '@app/domain'; import { Injectable } from '@nestjs/common'; import axios from 'axios'; import { createReadStream } from 'fs'; -const client = axios.create({ baseURL: MACHINE_LEARNING_URL }); +const client = axios.create(); @Injectable() export class MachineLearningRepository implements IMachineLearningRepository { @@ -11,19 +11,19 @@ export class MachineLearningRepository implements IMachineLearningRepository { return client.post(endpoint, createReadStream(input.imagePath)).then((res) => res.data); } - classifyImage(input: MachineLearningInput): Promise { - return this.post(input, '/image-classifier/tag-image'); + classifyImage(url: string, input: MachineLearningInput): Promise { + return this.post(input, `${url}/image-classifier/tag-image`); } - detectFaces(input: MachineLearningInput): Promise { - return this.post(input, '/facial-recognition/detect-faces'); + detectFaces(url: string, input: MachineLearningInput): Promise { + return this.post(input, `${url}/facial-recognition/detect-faces`); } - encodeImage(input: MachineLearningInput): Promise { - return this.post(input, '/sentence-transformer/encode-image'); + encodeImage(url: string, input: MachineLearningInput): Promise { + return this.post(input, `${url}/sentence-transformer/encode-image`); } - encodeText(input: string): Promise { - return client.post('/sentence-transformer/encode-text', { text: input }).then((res) => res.data); + encodeText(url: string, input: string): Promise { + return client.post(`${url}/sentence-transformer/encode-text`, { text: input }).then((res) => res.data); } } diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 48fbad95d0126..7ffb1f7b6fb17 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -2066,19 +2066,6 @@ export interface SearchAssetResponseDto { */ 'total': number; } -/** - * - * @export - * @interface SearchConfigResponseDto - */ -export interface SearchConfigResponseDto { - /** - * - * @type {boolean} - * @memberof SearchConfigResponseDto - */ - 'enabled': boolean; -} /** * * @export @@ -2185,7 +2172,13 @@ export interface ServerFeaturesDto { * @type {boolean} * @memberof ServerFeaturesDto */ - 'machineLearning': boolean; + 'clipEncode': boolean; + /** + * + * @type {boolean} + * @memberof ServerFeaturesDto + */ + 'facialRecognition': boolean; /** * * @type {boolean} @@ -2210,6 +2203,18 @@ export interface ServerFeaturesDto { * @memberof ServerFeaturesDto */ 'search': boolean; + /** + * + * @type {boolean} + * @memberof ServerFeaturesDto + */ + 'sidecar': boolean; + /** + * + * @type {boolean} + * @memberof ServerFeaturesDto + */ + 'tagImage': boolean; } /** * @@ -2611,6 +2616,12 @@ export interface SystemConfigDto { * @memberof SystemConfigDto */ 'job': SystemConfigJobDto; + /** + * + * @type {SystemConfigMachineLearningDto} + * @memberof SystemConfigDto + */ + 'machineLearning': SystemConfigMachineLearningDto; /** * * @type {SystemConfigOAuthDto} @@ -2778,6 +2789,43 @@ export interface SystemConfigJobDto { */ 'videoConversion': JobSettingsDto; } +/** + * + * @export + * @interface SystemConfigMachineLearningDto + */ +export interface SystemConfigMachineLearningDto { + /** + * + * @type {boolean} + * @memberof SystemConfigMachineLearningDto + */ + 'clipEncodeEnabled': boolean; + /** + * + * @type {boolean} + * @memberof SystemConfigMachineLearningDto + */ + 'enabled': boolean; + /** + * + * @type {boolean} + * @memberof SystemConfigMachineLearningDto + */ + 'facialRecognitionEnabled': boolean; + /** + * + * @type {boolean} + * @memberof SystemConfigMachineLearningDto + */ + 'tagImageEnabled': boolean; + /** + * + * @type {string} + * @memberof SystemConfigMachineLearningDto + */ + 'url': string; +} /** * * @export @@ -10106,44 +10154,6 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio - 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. - * @throws {RequiredError} - */ - getSearchConfig: async (options: AxiosRequestConfig = {}): Promise => { - const localVarPath = `/search/config`; - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - // authentication cookie required - - // authentication api_key required - await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) - - // authentication bearer required - // http bearer authentication required - await setBearerAuthToObject(localVarHeaderParameter, configuration) - - - setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -10290,15 +10300,6 @@ export const SearchApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.getExploreData(options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, - /** - * - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async getSearchConfig(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.getSearchConfig(options); - return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); - }, /** * * @param {string} [q] @@ -10342,14 +10343,6 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat getExploreData(options?: AxiosRequestConfig): AxiosPromise> { return localVarFp.getExploreData(options).then((request) => request(axios, basePath)); }, - /** - * - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - getSearchConfig(options?: AxiosRequestConfig): AxiosPromise { - return localVarFp.getSearchConfig(options).then((request) => request(axios, basePath)); - }, /** * * @param {SearchApiSearchRequest} requestParameters Request parameters. @@ -10498,16 +10491,6 @@ export class SearchApi extends BaseAPI { return SearchApiFp(this.configuration).getExploreData(options).then((request) => request(this.axios, this.basePath)); } - /** - * - * @param {*} [options] Override http request option. - * @throws {RequiredError} - * @memberof SearchApi - */ - public getSearchConfig(options?: AxiosRequestConfig) { - return SearchApiFp(this.configuration).getSearchConfig(options).then((request) => request(this.axios, this.basePath)); - } - /** * * @param {SearchApiSearchRequest} requestParameters Request parameters. diff --git a/web/src/lib/components/admin-page/jobs/jobs-panel.svelte b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte index 40c8a69280743..441210fc2a700 100644 --- a/web/src/lib/components/admin-page/jobs/jobs-panel.svelte +++ b/web/src/lib/components/admin-page/jobs/jobs-panel.svelte @@ -70,25 +70,26 @@ subtitle: 'Discover or synchronize sidecar metadata from the filesystem', allText: 'SYNC', missingText: 'DISCOVER', + disabled: !$featureFlags.sidecar, }, [JobName.ObjectTagging]: { icon: TagMultiple, title: api.getJobName(JobName.ObjectTagging), subtitle: 'Run machine learning to tag objects\nNote that some assets may not have any objects detected', - disabled: !$featureFlags.machineLearning, + disabled: !$featureFlags.tagImage, }, [JobName.ClipEncoding]: { icon: VectorCircle, title: api.getJobName(JobName.ClipEncoding), subtitle: 'Run machine learning to generate clip embeddings', - disabled: !$featureFlags.machineLearning, + disabled: !$featureFlags.clipEncode, }, [JobName.RecognizeFaces]: { icon: FaceRecognition, title: api.getJobName(JobName.RecognizeFaces), subtitle: 'Run machine learning to recognize faces', handleCommand: handleFaceCommand, - disabled: !$featureFlags.machineLearning, + disabled: !$featureFlags.facialRecognition, }, [JobName.VideoConversion]: { icon: Video, diff --git a/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte b/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte new file mode 100644 index 0000000000000..4b10e8535fcd1 --- /dev/null +++ b/web/src/lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte @@ -0,0 +1,104 @@ + + +
+ {#await refreshConfig() then} +
+
+ + +
+ + + + + + + + + + + +
+ {/await} +
diff --git a/web/src/lib/components/admin-page/settings/setting-switch.svelte b/web/src/lib/components/admin-page/settings/setting-switch.svelte index 4fd0f67b0fe28..6cc4166e961e3 100644 --- a/web/src/lib/components/admin-page/settings/setting-switch.svelte +++ b/web/src/lib/components/admin-page/settings/setting-switch.svelte @@ -32,9 +32,9 @@ {#if disabled} - + {:else} - + {/if} @@ -43,7 +43,6 @@ .slider, .slider-disable { position: absolute; - cursor: pointer; top: 0; left: 0; right: 0; diff --git a/web/src/lib/stores/feature-flags.store.ts b/web/src/lib/stores/feature-flags.store.ts index 119ecd557ed5a..57a8f33cd5f22 100644 --- a/web/src/lib/stores/feature-flags.store.ts +++ b/web/src/lib/stores/feature-flags.store.ts @@ -4,7 +4,10 @@ import { writable } from 'svelte/store'; export type FeatureFlags = ServerFeaturesDto; export const featureFlags = writable({ - machineLearning: true, + clipEncode: true, + facialRecognition: true, + sidecar: true, + tagImage: true, search: true, oauth: true, oauthAutoLaunch: true, diff --git a/web/src/routes/admin/system-settings/+page.svelte b/web/src/routes/admin/system-settings/+page.svelte index 59390b42d9bac..5da12e1862a7c 100644 --- a/web/src/routes/admin/system-settings/+page.svelte +++ b/web/src/routes/admin/system-settings/+page.svelte @@ -2,11 +2,12 @@ import { page } from '$app/stores'; import FFmpegSettings from '$lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte'; import JobSettings from '$lib/components/admin-page/settings/job-settings/job-settings.svelte'; - import ThumbnailSettings from '$lib/components/admin-page/settings/thumbnail/thumbnail-settings.svelte'; + import MachineLearningSettings from '$lib/components/admin-page/settings/machine-learning-settings/machine-learning-settings.svelte'; import OAuthSettings from '$lib/components/admin-page/settings/oauth/oauth-settings.svelte'; import PasswordLoginSettings from '$lib/components/admin-page/settings/password-login/password-login-settings.svelte'; import SettingAccordion from '$lib/components/admin-page/settings/setting-accordion.svelte'; import StorageTemplateSettings from '$lib/components/admin-page/settings/storage-template/storage-template-settings.svelte'; + import ThumbnailSettings from '$lib/components/admin-page/settings/thumbnail/thumbnail-settings.svelte'; import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; import { api } from '@api'; import type { PageData } from './$types'; @@ -50,6 +51,10 @@ + + + +