Browse Source

feat(web,server): server features (#3756)

* feat: server features

* chore: open api

* icon size

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
Jason Rasmussen 1 year ago
parent
commit
2b839088c7
40 changed files with 805 additions and 187 deletions
  1. 100 7
      cli/src/api/open-api/api.ts
  2. 2 2
      cli/src/cli/base-command.ts
  3. 2 2
      mobile/lib/shared/models/server_info_state.model.dart
  4. 2 2
      mobile/lib/shared/providers/server_info.provider.dart
  5. 1 1
      mobile/lib/shared/services/server_info.service.dart
  6. 6 3
      mobile/openapi/.openapi-generator/FILES
  7. 3 1
      mobile/openapi/README.md
  8. 19 0
      mobile/openapi/doc/ServerFeaturesDto.md
  9. 40 2
      mobile/openapi/doc/ServerInfoApi.md
  10. 1 1
      mobile/openapi/doc/ServerVersionResponseDto.md
  11. 2 1
      mobile/openapi/lib/api.dart
  12. 43 2
      mobile/openapi/lib/api/server_info_api.dart
  13. 4 2
      mobile/openapi/lib/api_client.dart
  14. 130 0
      mobile/openapi/lib/model/server_features_dto.dart
  15. 18 18
      mobile/openapi/lib/model/server_version_response_dto.dart
  16. 47 0
      mobile/openapi/test/server_features_dto_test.dart
  17. 6 1
      mobile/openapi/test/server_info_api_test.dart
  18. 3 3
      mobile/openapi/test/server_version_response_dto_test.dart
  19. 50 2
      server/immich-openapi-specs.json
  20. 2 0
      server/src/domain/domain.constant.ts
  21. 1 1
      server/src/domain/server-info/index.ts
  22. 0 5
      server/src/domain/server-info/response-dto/index.ts
  23. 0 19
      server/src/domain/server-info/response-dto/server-info-response.dto.ts
  24. 0 10
      server/src/domain/server-info/response-dto/server-ping-response.dto.ts
  25. 0 33
      server/src/domain/server-info/response-dto/server-stats-response.dto.ts
  26. 0 11
      server/src/domain/server-info/response-dto/server-version-response.dto.ts
  27. 0 16
      server/src/domain/server-info/response-dto/usage-by-user-response.dto.ts
  28. 89 0
      server/src/domain/server-info/server-info.dto.ts
  29. 18 2
      server/src/domain/server-info/server-info.service.spec.ts
  30. 25 4
      server/src/domain/server-info/server-info.service.ts
  31. 13 6
      server/src/immich/controllers/server-info.controller.ts
  32. 100 7
      web/src/api/open-api/api.ts
  33. 8 2
      web/src/lib/components/admin-page/jobs/job-tile-button.svelte
  34. 11 1
      web/src/lib/components/admin-page/jobs/job-tile.svelte
  35. 9 4
      web/src/lib/components/admin-page/jobs/jobs-panel.svelte
  36. 13 8
      web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte
  37. 6 3
      web/src/lib/components/shared-components/side-bar/side-bar.svelte
  38. 3 3
      web/src/lib/components/shared-components/version-announcement-box.svelte
  39. 17 0
      web/src/lib/stores/feature-flags.store.ts
  40. 11 2
      web/src/routes/+layout.svelte

+ 100 - 7
cli/src/api/open-api/api.ts

@@ -2087,6 +2087,43 @@ export interface SearchResponseDto {
      */
     'assets': SearchAssetResponseDto;
 }
+/**
+ * 
+ * @export
+ * @interface ServerFeaturesDto
+ */
+export interface ServerFeaturesDto {
+    /**
+     * 
+     * @type {boolean}
+     * @memberof ServerFeaturesDto
+     */
+    'machineLearning': boolean;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof ServerFeaturesDto
+     */
+    'oauth': boolean;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof ServerFeaturesDto
+     */
+    'oauthAutoLaunch': boolean;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof ServerFeaturesDto
+     */
+    'passwordLogin': boolean;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof ServerFeaturesDto
+     */
+    'search': boolean;
+}
 /**
  * 
  * @export
@@ -2208,25 +2245,25 @@ export interface ServerStatsResponseDto {
 /**
  * 
  * @export
- * @interface ServerVersionReponseDto
+ * @interface ServerVersionResponseDto
  */
-export interface ServerVersionReponseDto {
+export interface ServerVersionResponseDto {
     /**
      * 
      * @type {number}
-     * @memberof ServerVersionReponseDto
+     * @memberof ServerVersionResponseDto
      */
     'major': number;
     /**
      * 
      * @type {number}
-     * @memberof ServerVersionReponseDto
+     * @memberof ServerVersionResponseDto
      */
     'minor': number;
     /**
      * 
      * @type {number}
-     * @memberof ServerVersionReponseDto
+     * @memberof ServerVersionResponseDto
      */
     'patch': number;
 }
@@ -10156,6 +10193,35 @@ export class SearchApi extends BaseAPI {
  */
 export const ServerInfoApiAxiosParamCreator = function (configuration?: Configuration) {
     return {
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        getServerFeatures: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            const localVarPath = `/server-info/features`;
+            // 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;
+
+
+    
+            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.
@@ -10329,6 +10395,15 @@ export const ServerInfoApiAxiosParamCreator = function (configuration?: Configur
 export const ServerInfoApiFp = function(configuration?: Configuration) {
     const localVarAxiosParamCreator = ServerInfoApiAxiosParamCreator(configuration)
     return {
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async getServerFeatures(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<ServerFeaturesDto>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.getServerFeatures(options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
         /**
          * 
          * @param {*} [options] Override http request option.
@@ -10343,7 +10418,7 @@ export const ServerInfoApiFp = function(configuration?: Configuration) {
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          */
-        async getServerVersion(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<ServerVersionReponseDto>> {
+        async getServerVersion(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<ServerVersionResponseDto>> {
             const localVarAxiosArgs = await localVarAxiosParamCreator.getServerVersion(options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
@@ -10384,6 +10459,14 @@ export const ServerInfoApiFp = function(configuration?: Configuration) {
 export const ServerInfoApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
     const localVarFp = ServerInfoApiFp(configuration)
     return {
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        getServerFeatures(options?: AxiosRequestConfig): AxiosPromise<ServerFeaturesDto> {
+            return localVarFp.getServerFeatures(options).then((request) => request(axios, basePath));
+        },
         /**
          * 
          * @param {*} [options] Override http request option.
@@ -10397,7 +10480,7 @@ export const ServerInfoApiFactory = function (configuration?: Configuration, bas
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          */
-        getServerVersion(options?: AxiosRequestConfig): AxiosPromise<ServerVersionReponseDto> {
+        getServerVersion(options?: AxiosRequestConfig): AxiosPromise<ServerVersionResponseDto> {
             return localVarFp.getServerVersion(options).then((request) => request(axios, basePath));
         },
         /**
@@ -10434,6 +10517,16 @@ export const ServerInfoApiFactory = function (configuration?: Configuration, bas
  * @extends {BaseAPI}
  */
 export class ServerInfoApi extends BaseAPI {
+    /**
+     * 
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof ServerInfoApi
+     */
+    public getServerFeatures(options?: AxiosRequestConfig) {
+        return ServerInfoApiFp(this.configuration).getServerFeatures(options).then((request) => request(this.axios, this.basePath));
+    }
+
     /**
      * 
      * @param {*} [options] Override http request option.

+ 2 - 2
cli/src/cli/base-command.ts

@@ -4,14 +4,14 @@ import { SessionService } from '../services/session.service';
 import { LoginError } from '../cores/errors/login-error';
 import { exit } from 'node:process';
 import os from 'os';
-import { ServerVersionReponseDto, UserResponseDto } from 'src/api/open-api';
+import { ServerVersionResponseDto, UserResponseDto } from 'src/api/open-api';
 
 export abstract class BaseCommand {
   protected sessionService!: SessionService;
   protected immichApi!: ImmichApi;
   protected deviceId!: string;
   protected user!: UserResponseDto;
-  protected serverVersion!: ServerVersionReponseDto;
+  protected serverVersion!: ServerVersionResponseDto;
 
   protected configDir;
   protected authPath;

+ 2 - 2
mobile/lib/shared/models/server_info_state.model.dart

@@ -1,7 +1,7 @@
 import 'package:openapi/api.dart';
 
 class ServerInfoState {
-  final ServerVersionReponseDto serverVersion;
+  final ServerVersionResponseDto serverVersion;
   final bool isVersionMismatch;
   final String versionMismatchErrorMessage;
 
@@ -12,7 +12,7 @@ class ServerInfoState {
   });
 
   ServerInfoState copyWith({
-    ServerVersionReponseDto? serverVersion,
+    ServerVersionResponseDto? serverVersion,
     bool? isVersionMismatch,
     String? versionMismatchErrorMessage,
   }) {

+ 2 - 2
mobile/lib/shared/providers/server_info.provider.dart

@@ -10,7 +10,7 @@ class ServerInfoNotifier extends StateNotifier<ServerInfoState> {
   ServerInfoNotifier(this._serverInfoService)
       : super(
           ServerInfoState(
-            serverVersion: ServerVersionReponseDto(
+            serverVersion: ServerVersionResponseDto(
               major: 0,
               patch_: 0,
               minor: 0,
@@ -23,7 +23,7 @@ class ServerInfoNotifier extends StateNotifier<ServerInfoState> {
   final ServerInfoService _serverInfoService;
 
   getServerVersion() async {
-    ServerVersionReponseDto? serverVersion =
+    ServerVersionResponseDto? serverVersion =
         await _serverInfoService.getServerVersion();
 
     if (serverVersion == null) {

+ 1 - 1
mobile/lib/shared/services/server_info.service.dart

@@ -24,7 +24,7 @@ class ServerInfoService {
     }
   }
 
-  Future<ServerVersionReponseDto?> getServerVersion() async {
+  Future<ServerVersionResponseDto?> getServerVersion() async {
     try {
       return await _apiService.serverInfoApi.getServerVersion();
     } catch (e) {

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

@@ -85,12 +85,13 @@ doc/SearchExploreResponseDto.md
 doc/SearchFacetCountResponseDto.md
 doc/SearchFacetResponseDto.md
 doc/SearchResponseDto.md
+doc/ServerFeaturesDto.md
 doc/ServerInfoApi.md
 doc/ServerInfoResponseDto.md
 doc/ServerMediaTypesResponseDto.md
 doc/ServerPingResponse.md
 doc/ServerStatsResponseDto.md
-doc/ServerVersionReponseDto.md
+doc/ServerVersionResponseDto.md
 doc/SharedLinkApi.md
 doc/SharedLinkCreateDto.md
 doc/SharedLinkEditDto.md
@@ -223,11 +224,12 @@ lib/model/search_explore_response_dto.dart
 lib/model/search_facet_count_response_dto.dart
 lib/model/search_facet_response_dto.dart
 lib/model/search_response_dto.dart
+lib/model/server_features_dto.dart
 lib/model/server_info_response_dto.dart
 lib/model/server_media_types_response_dto.dart
 lib/model/server_ping_response.dart
 lib/model/server_stats_response_dto.dart
-lib/model/server_version_reponse_dto.dart
+lib/model/server_version_response_dto.dart
 lib/model/shared_link_create_dto.dart
 lib/model/shared_link_edit_dto.dart
 lib/model/shared_link_response_dto.dart
@@ -342,12 +344,13 @@ test/search_explore_response_dto_test.dart
 test/search_facet_count_response_dto_test.dart
 test/search_facet_response_dto_test.dart
 test/search_response_dto_test.dart
+test/server_features_dto_test.dart
 test/server_info_api_test.dart
 test/server_info_response_dto_test.dart
 test/server_media_types_response_dto_test.dart
 test/server_ping_response_test.dart
 test/server_stats_response_dto_test.dart
-test/server_version_reponse_dto_test.dart
+test/server_version_response_dto_test.dart
 test/shared_link_api_test.dart
 test/shared_link_create_dto_test.dart
 test/shared_link_edit_dto_test.dart

+ 3 - 1
mobile/openapi/README.md

@@ -140,6 +140,7 @@ Class | Method | HTTP request | Description
 *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 | 
 *ServerInfoApi* | [**getServerVersion**](doc//ServerInfoApi.md#getserverversion) | **GET** /server-info/version | 
 *ServerInfoApi* | [**getStats**](doc//ServerInfoApi.md#getstats) | **GET** /server-info/stats | 
@@ -252,11 +253,12 @@ Class | Method | HTTP request | Description
  - [SearchFacetCountResponseDto](doc//SearchFacetCountResponseDto.md)
  - [SearchFacetResponseDto](doc//SearchFacetResponseDto.md)
  - [SearchResponseDto](doc//SearchResponseDto.md)
+ - [ServerFeaturesDto](doc//ServerFeaturesDto.md)
  - [ServerInfoResponseDto](doc//ServerInfoResponseDto.md)
  - [ServerMediaTypesResponseDto](doc//ServerMediaTypesResponseDto.md)
  - [ServerPingResponse](doc//ServerPingResponse.md)
  - [ServerStatsResponseDto](doc//ServerStatsResponseDto.md)
- - [ServerVersionReponseDto](doc//ServerVersionReponseDto.md)
+ - [ServerVersionResponseDto](doc//ServerVersionResponseDto.md)
  - [SharedLinkCreateDto](doc//SharedLinkCreateDto.md)
  - [SharedLinkEditDto](doc//SharedLinkEditDto.md)
  - [SharedLinkResponseDto](doc//SharedLinkResponseDto.md)

+ 19 - 0
mobile/openapi/doc/ServerFeaturesDto.md

@@ -0,0 +1,19 @@
+# openapi.model.ServerFeaturesDto
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**machineLearning** | **bool** |  | 
+**oauth** | **bool** |  | 
+**oauthAutoLaunch** | **bool** |  | 
+**passwordLogin** | **bool** |  | 
+**search** | **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)
+
+

+ 40 - 2
mobile/openapi/doc/ServerInfoApi.md

@@ -9,6 +9,7 @@ All URIs are relative to */api*
 
 Method | HTTP request | Description
 ------------- | ------------- | -------------
+[**getServerFeatures**](ServerInfoApi.md#getserverfeatures) | **GET** /server-info/features | 
 [**getServerInfo**](ServerInfoApi.md#getserverinfo) | **GET** /server-info | 
 [**getServerVersion**](ServerInfoApi.md#getserverversion) | **GET** /server-info/version | 
 [**getStats**](ServerInfoApi.md#getstats) | **GET** /server-info/stats | 
@@ -16,6 +17,43 @@ Method | HTTP request | Description
 [**pingServer**](ServerInfoApi.md#pingserver) | **GET** /server-info/ping | 
 
 
+# **getServerFeatures**
+> ServerFeaturesDto getServerFeatures()
+
+
+
+### Example
+```dart
+import 'package:openapi/api.dart';
+
+final api_instance = ServerInfoApi();
+
+try {
+    final result = api_instance.getServerFeatures();
+    print(result);
+} catch (e) {
+    print('Exception when calling ServerInfoApi->getServerFeatures: $e\n');
+}
+```
+
+### Parameters
+This endpoint does not need any parameter.
+
+### Return type
+
+[**ServerFeaturesDto**](ServerFeaturesDto.md)
+
+### Authorization
+
+No authorization required
+
+### 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)
+
 # **getServerInfo**
 > ServerInfoResponseDto getServerInfo()
 
@@ -68,7 +106,7 @@ This endpoint does not need any parameter.
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 
 # **getServerVersion**
-> ServerVersionReponseDto getServerVersion()
+> ServerVersionResponseDto getServerVersion()
 
 
 
@@ -91,7 +129,7 @@ This endpoint does not need any parameter.
 
 ### Return type
 
-[**ServerVersionReponseDto**](ServerVersionReponseDto.md)
+[**ServerVersionResponseDto**](ServerVersionResponseDto.md)
 
 ### Authorization
 

+ 1 - 1
mobile/openapi/doc/ServerVersionReponseDto.md → mobile/openapi/doc/ServerVersionResponseDto.md

@@ -1,4 +1,4 @@
-# openapi.model.ServerVersionReponseDto
+# openapi.model.ServerVersionResponseDto
 
 ## Load the model package
 ```dart

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

@@ -116,11 +116,12 @@ part 'model/search_explore_response_dto.dart';
 part 'model/search_facet_count_response_dto.dart';
 part 'model/search_facet_response_dto.dart';
 part 'model/search_response_dto.dart';
+part 'model/server_features_dto.dart';
 part 'model/server_info_response_dto.dart';
 part 'model/server_media_types_response_dto.dart';
 part 'model/server_ping_response.dart';
 part 'model/server_stats_response_dto.dart';
-part 'model/server_version_reponse_dto.dart';
+part 'model/server_version_response_dto.dart';
 part 'model/shared_link_create_dto.dart';
 part 'model/shared_link_edit_dto.dart';
 part 'model/shared_link_response_dto.dart';

+ 43 - 2
mobile/openapi/lib/api/server_info_api.dart

@@ -16,6 +16,47 @@ class ServerInfoApi {
 
   final ApiClient apiClient;
 
+  /// Performs an HTTP 'GET /server-info/features' operation and returns the [Response].
+  Future<Response> getServerFeaturesWithHttpInfo() async {
+    // ignore: prefer_const_declarations
+    final path = r'/server-info/features';
+
+    // ignore: prefer_final_locals
+    Object? postBody;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>[];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'GET',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  Future<ServerFeaturesDto?> getServerFeatures() async {
+    final response = await getServerFeaturesWithHttpInfo();
+    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), 'ServerFeaturesDto',) as ServerFeaturesDto;
+    
+    }
+    return null;
+  }
+
   /// Performs an HTTP 'GET /server-info' operation and returns the [Response].
   Future<Response> getServerInfoWithHttpInfo() async {
     // ignore: prefer_const_declarations
@@ -83,7 +124,7 @@ class ServerInfoApi {
     );
   }
 
-  Future<ServerVersionReponseDto?> getServerVersion() async {
+  Future<ServerVersionResponseDto?> getServerVersion() async {
     final response = await getServerVersionWithHttpInfo();
     if (response.statusCode >= HttpStatus.badRequest) {
       throw ApiException(response.statusCode, await _decodeBodyBytes(response));
@@ -92,7 +133,7 @@ class ServerInfoApi {
     // 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), 'ServerVersionReponseDto',) as ServerVersionReponseDto;
+      return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'ServerVersionResponseDto',) as ServerVersionResponseDto;
     
     }
     return null;

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

@@ -327,6 +327,8 @@ class ApiClient {
           return SearchFacetResponseDto.fromJson(value);
         case 'SearchResponseDto':
           return SearchResponseDto.fromJson(value);
+        case 'ServerFeaturesDto':
+          return ServerFeaturesDto.fromJson(value);
         case 'ServerInfoResponseDto':
           return ServerInfoResponseDto.fromJson(value);
         case 'ServerMediaTypesResponseDto':
@@ -335,8 +337,8 @@ class ApiClient {
           return ServerPingResponse.fromJson(value);
         case 'ServerStatsResponseDto':
           return ServerStatsResponseDto.fromJson(value);
-        case 'ServerVersionReponseDto':
-          return ServerVersionReponseDto.fromJson(value);
+        case 'ServerVersionResponseDto':
+          return ServerVersionResponseDto.fromJson(value);
         case 'SharedLinkCreateDto':
           return SharedLinkCreateDto.fromJson(value);
         case 'SharedLinkEditDto':

+ 130 - 0
mobile/openapi/lib/model/server_features_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 ServerFeaturesDto {
+  /// Returns a new [ServerFeaturesDto] instance.
+  ServerFeaturesDto({
+    required this.machineLearning,
+    required this.oauth,
+    required this.oauthAutoLaunch,
+    required this.passwordLogin,
+    required this.search,
+  });
+
+  bool machineLearning;
+
+  bool oauth;
+
+  bool oauthAutoLaunch;
+
+  bool passwordLogin;
+
+  bool search;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is ServerFeaturesDto &&
+     other.machineLearning == machineLearning &&
+     other.oauth == oauth &&
+     other.oauthAutoLaunch == oauthAutoLaunch &&
+     other.passwordLogin == passwordLogin &&
+     other.search == search;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (machineLearning.hashCode) +
+    (oauth.hashCode) +
+    (oauthAutoLaunch.hashCode) +
+    (passwordLogin.hashCode) +
+    (search.hashCode);
+
+  @override
+  String toString() => 'ServerFeaturesDto[machineLearning=$machineLearning, oauth=$oauth, oauthAutoLaunch=$oauthAutoLaunch, passwordLogin=$passwordLogin, search=$search]';
+
+  Map<String, dynamic> toJson() {
+    final json = <String, dynamic>{};
+      json[r'machineLearning'] = this.machineLearning;
+      json[r'oauth'] = this.oauth;
+      json[r'oauthAutoLaunch'] = this.oauthAutoLaunch;
+      json[r'passwordLogin'] = this.passwordLogin;
+      json[r'search'] = this.search;
+    return json;
+  }
+
+  /// Returns a new [ServerFeaturesDto] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static ServerFeaturesDto? fromJson(dynamic value) {
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      return ServerFeaturesDto(
+        machineLearning: mapValueOfType<bool>(json, r'machineLearning')!,
+        oauth: mapValueOfType<bool>(json, r'oauth')!,
+        oauthAutoLaunch: mapValueOfType<bool>(json, r'oauthAutoLaunch')!,
+        passwordLogin: mapValueOfType<bool>(json, r'passwordLogin')!,
+        search: mapValueOfType<bool>(json, r'search')!,
+      );
+    }
+    return null;
+  }
+
+  static List<ServerFeaturesDto> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <ServerFeaturesDto>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = ServerFeaturesDto.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, ServerFeaturesDto> mapFromJson(dynamic json) {
+    final map = <String, ServerFeaturesDto>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = ServerFeaturesDto.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of ServerFeaturesDto-objects as value to a dart map
+  static Map<String, List<ServerFeaturesDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<ServerFeaturesDto>>{};
+    if (json is Map && json.isNotEmpty) {
+      // ignore: parameter_assignments
+      json = json.cast<String, dynamic>();
+      for (final entry in json.entries) {
+        map[entry.key] = ServerFeaturesDto.listFromJson(entry.value, growable: growable,);
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'machineLearning',
+    'oauth',
+    'oauthAutoLaunch',
+    'passwordLogin',
+    'search',
+  };
+}
+

+ 18 - 18
mobile/openapi/lib/model/server_version_reponse_dto.dart → mobile/openapi/lib/model/server_version_response_dto.dart

@@ -10,9 +10,9 @@
 
 part of openapi.api;
 
-class ServerVersionReponseDto {
-  /// Returns a new [ServerVersionReponseDto] instance.
-  ServerVersionReponseDto({
+class ServerVersionResponseDto {
+  /// Returns a new [ServerVersionResponseDto] instance.
+  ServerVersionResponseDto({
     required this.major,
     required this.minor,
     required this.patch_,
@@ -25,7 +25,7 @@ class ServerVersionReponseDto {
   int patch_;
 
   @override
-  bool operator ==(Object other) => identical(this, other) || other is ServerVersionReponseDto &&
+  bool operator ==(Object other) => identical(this, other) || other is ServerVersionResponseDto &&
      other.major == major &&
      other.minor == minor &&
      other.patch_ == patch_;
@@ -38,7 +38,7 @@ class ServerVersionReponseDto {
     (patch_.hashCode);
 
   @override
-  String toString() => 'ServerVersionReponseDto[major=$major, minor=$minor, patch_=$patch_]';
+  String toString() => 'ServerVersionResponseDto[major=$major, minor=$minor, patch_=$patch_]';
 
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
@@ -48,14 +48,14 @@ class ServerVersionReponseDto {
     return json;
   }
 
-  /// Returns a new [ServerVersionReponseDto] instance and imports its values from
+  /// Returns a new [ServerVersionResponseDto] instance and imports its values from
   /// [value] if it's a [Map], null otherwise.
   // ignore: prefer_constructors_over_static_methods
-  static ServerVersionReponseDto? fromJson(dynamic value) {
+  static ServerVersionResponseDto? fromJson(dynamic value) {
     if (value is Map) {
       final json = value.cast<String, dynamic>();
 
-      return ServerVersionReponseDto(
+      return ServerVersionResponseDto(
         major: mapValueOfType<int>(json, r'major')!,
         minor: mapValueOfType<int>(json, r'minor')!,
         patch_: mapValueOfType<int>(json, r'patch')!,
@@ -64,11 +64,11 @@ class ServerVersionReponseDto {
     return null;
   }
 
-  static List<ServerVersionReponseDto> listFromJson(dynamic json, {bool growable = false,}) {
-    final result = <ServerVersionReponseDto>[];
+  static List<ServerVersionResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <ServerVersionResponseDto>[];
     if (json is List && json.isNotEmpty) {
       for (final row in json) {
-        final value = ServerVersionReponseDto.fromJson(row);
+        final value = ServerVersionResponseDto.fromJson(row);
         if (value != null) {
           result.add(value);
         }
@@ -77,12 +77,12 @@ class ServerVersionReponseDto {
     return result.toList(growable: growable);
   }
 
-  static Map<String, ServerVersionReponseDto> mapFromJson(dynamic json) {
-    final map = <String, ServerVersionReponseDto>{};
+  static Map<String, ServerVersionResponseDto> mapFromJson(dynamic json) {
+    final map = <String, ServerVersionResponseDto>{};
     if (json is Map && json.isNotEmpty) {
       json = json.cast<String, dynamic>(); // ignore: parameter_assignments
       for (final entry in json.entries) {
-        final value = ServerVersionReponseDto.fromJson(entry.value);
+        final value = ServerVersionResponseDto.fromJson(entry.value);
         if (value != null) {
           map[entry.key] = value;
         }
@@ -91,14 +91,14 @@ class ServerVersionReponseDto {
     return map;
   }
 
-  // maps a json object with a list of ServerVersionReponseDto-objects as value to a dart map
-  static Map<String, List<ServerVersionReponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
-    final map = <String, List<ServerVersionReponseDto>>{};
+  // maps a json object with a list of ServerVersionResponseDto-objects as value to a dart map
+  static Map<String, List<ServerVersionResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<ServerVersionResponseDto>>{};
     if (json is Map && json.isNotEmpty) {
       // ignore: parameter_assignments
       json = json.cast<String, dynamic>();
       for (final entry in json.entries) {
-        map[entry.key] = ServerVersionReponseDto.listFromJson(entry.value, growable: growable,);
+        map[entry.key] = ServerVersionResponseDto.listFromJson(entry.value, growable: growable,);
       }
     }
     return map;

+ 47 - 0
mobile/openapi/test/server_features_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 ServerFeaturesDto
+void main() {
+  // final instance = ServerFeaturesDto();
+
+  group('test ServerFeaturesDto', () {
+    // bool machineLearning
+    test('to test the property `machineLearning`', () async {
+      // TODO
+    });
+
+    // bool oauth
+    test('to test the property `oauth`', () async {
+      // TODO
+    });
+
+    // bool oauthAutoLaunch
+    test('to test the property `oauthAutoLaunch`', () async {
+      // TODO
+    });
+
+    // bool passwordLogin
+    test('to test the property `passwordLogin`', () async {
+      // TODO
+    });
+
+    // bool search
+    test('to test the property `search`', () async {
+      // TODO
+    });
+
+
+  });
+
+}

+ 6 - 1
mobile/openapi/test/server_info_api_test.dart

@@ -17,12 +17,17 @@ void main() {
   // final instance = ServerInfoApi();
 
   group('tests for ServerInfoApi', () {
+    //Future<ServerFeaturesDto> getServerFeatures() async
+    test('test getServerFeatures', () async {
+      // TODO
+    });
+
     //Future<ServerInfoResponseDto> getServerInfo() async
     test('test getServerInfo', () async {
       // TODO
     });
 
-    //Future<ServerVersionReponseDto> getServerVersion() async
+    //Future<ServerVersionResponseDto> getServerVersion() async
     test('test getServerVersion', () async {
       // TODO
     });

+ 3 - 3
mobile/openapi/test/server_version_reponse_dto_test.dart → mobile/openapi/test/server_version_response_dto_test.dart

@@ -11,11 +11,11 @@
 import 'package:openapi/api.dart';
 import 'package:test/test.dart';
 
-// tests for ServerVersionReponseDto
+// tests for ServerVersionResponseDto
 void main() {
-  // final instance = ServerVersionReponseDto();
+  // final instance = ServerVersionResponseDto();
 
-  group('test ServerVersionReponseDto', () {
+  group('test ServerVersionResponseDto', () {
     // int major
     test('to test the property `major`', () async {
       // TODO

+ 50 - 2
server/immich-openapi-specs.json

@@ -3248,6 +3248,27 @@
         ]
       }
     },
+    "/server-info/features": {
+      "get": {
+        "operationId": "getServerFeatures",
+        "parameters": [],
+        "responses": {
+          "200": {
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/ServerFeaturesDto"
+                }
+              }
+            },
+            "description": ""
+          }
+        },
+        "tags": [
+          "Server Info"
+        ]
+      }
+    },
     "/server-info/media-types": {
       "get": {
         "operationId": "getSupportedMediaTypes",
@@ -3331,7 +3352,7 @@
             "content": {
               "application/json": {
                 "schema": {
-                  "$ref": "#/components/schemas/ServerVersionReponseDto"
+                  "$ref": "#/components/schemas/ServerVersionResponseDto"
                 }
               }
             },
@@ -6331,6 +6352,33 @@
         ],
         "type": "object"
       },
+      "ServerFeaturesDto": {
+        "properties": {
+          "machineLearning": {
+            "type": "boolean"
+          },
+          "oauth": {
+            "type": "boolean"
+          },
+          "oauthAutoLaunch": {
+            "type": "boolean"
+          },
+          "passwordLogin": {
+            "type": "boolean"
+          },
+          "search": {
+            "type": "boolean"
+          }
+        },
+        "required": [
+          "machineLearning",
+          "search",
+          "oauth",
+          "oauthAutoLaunch",
+          "passwordLogin"
+        ],
+        "type": "object"
+      },
       "ServerInfoResponseDto": {
         "properties": {
           "diskAvailable": {
@@ -6450,7 +6498,7 @@
         ],
         "type": "object"
       },
-      "ServerVersionReponseDto": {
+      "ServerVersionResponseDto": {
         "properties": {
           "major": {
             "type": "integer"

+ 2 - 0
server/src/domain/domain.constant.ts

@@ -21,6 +21,8 @@ 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';
 

+ 1 - 1
server/src/domain/server-info/index.ts

@@ -1,2 +1,2 @@
-export * from './response-dto';
+export * from './server-info.dto';
 export * from './server-info.service';

+ 0 - 5
server/src/domain/server-info/response-dto/index.ts

@@ -1,5 +0,0 @@
-export * from './server-info-response.dto';
-export * from './server-ping-response.dto';
-export * from './server-stats-response.dto';
-export * from './server-version-response.dto';
-export * from './usage-by-user-response.dto';

+ 0 - 19
server/src/domain/server-info/response-dto/server-info-response.dto.ts

@@ -1,19 +0,0 @@
-import { ApiProperty } from '@nestjs/swagger';
-
-export class ServerInfoResponseDto {
-  diskSize!: string;
-  diskUse!: string;
-  diskAvailable!: string;
-
-  @ApiProperty({ type: 'integer', format: 'int64' })
-  diskSizeRaw!: number;
-
-  @ApiProperty({ type: 'integer', format: 'int64' })
-  diskUseRaw!: number;
-
-  @ApiProperty({ type: 'integer', format: 'int64' })
-  diskAvailableRaw!: number;
-
-  @ApiProperty({ type: 'number', format: 'float' })
-  diskUsagePercentage!: number;
-}

+ 0 - 10
server/src/domain/server-info/response-dto/server-ping-response.dto.ts

@@ -1,10 +0,0 @@
-import { ApiResponseProperty } from '@nestjs/swagger';
-
-export class ServerPingResponse {
-  constructor(res: string) {
-    this.res = res;
-  }
-
-  @ApiResponseProperty({ type: String, example: 'pong' })
-  res!: string;
-}

+ 0 - 33
server/src/domain/server-info/response-dto/server-stats-response.dto.ts

@@ -1,33 +0,0 @@
-import { ApiProperty } from '@nestjs/swagger';
-import { UsageByUserDto } from './usage-by-user-response.dto';
-
-export class ServerStatsResponseDto {
-  @ApiProperty({ type: 'integer' })
-  photos = 0;
-
-  @ApiProperty({ type: 'integer' })
-  videos = 0;
-
-  @ApiProperty({ type: 'integer', format: 'int64' })
-  usage = 0;
-
-  @ApiProperty({
-    isArray: true,
-    type: UsageByUserDto,
-    title: 'Array of usage for each user',
-    example: [
-      {
-        photos: 1,
-        videos: 1,
-        diskUsageRaw: 1,
-      },
-    ],
-  })
-  usageByUser: UsageByUserDto[] = [];
-}
-
-export class ServerMediaTypesResponseDto {
-  video!: string[];
-  image!: string[];
-  sidecar!: string[];
-}

+ 0 - 11
server/src/domain/server-info/response-dto/server-version-response.dto.ts

@@ -1,11 +0,0 @@
-import { IServerVersion } from '@app/domain';
-import { ApiProperty } from '@nestjs/swagger';
-
-export class ServerVersionReponseDto implements IServerVersion {
-  @ApiProperty({ type: 'integer' })
-  major!: number;
-  @ApiProperty({ type: 'integer' })
-  minor!: number;
-  @ApiProperty({ type: 'integer' })
-  patch!: number;
-}

+ 0 - 16
server/src/domain/server-info/response-dto/usage-by-user-response.dto.ts

@@ -1,16 +0,0 @@
-import { ApiProperty } from '@nestjs/swagger';
-
-export class UsageByUserDto {
-  @ApiProperty({ type: 'string' })
-  userId!: string;
-  @ApiProperty({ type: 'string' })
-  userFirstName!: string;
-  @ApiProperty({ type: 'string' })
-  userLastName!: string;
-  @ApiProperty({ type: 'integer' })
-  photos!: number;
-  @ApiProperty({ type: 'integer' })
-  videos!: number;
-  @ApiProperty({ type: 'integer', format: 'int64' })
-  usage!: number;
-}

+ 89 - 0
server/src/domain/server-info/server-info.dto.ts

@@ -0,0 +1,89 @@
+import { IServerVersion } from '@app/domain';
+import { ApiProperty, ApiResponseProperty } from '@nestjs/swagger';
+
+export class ServerPingResponse {
+  @ApiResponseProperty({ type: String, example: 'pong' })
+  res!: string;
+}
+
+export class ServerInfoResponseDto {
+  diskSize!: string;
+  diskUse!: string;
+  diskAvailable!: string;
+
+  @ApiProperty({ type: 'integer', format: 'int64' })
+  diskSizeRaw!: number;
+
+  @ApiProperty({ type: 'integer', format: 'int64' })
+  diskUseRaw!: number;
+
+  @ApiProperty({ type: 'integer', format: 'int64' })
+  diskAvailableRaw!: number;
+
+  @ApiProperty({ type: 'number', format: 'float' })
+  diskUsagePercentage!: number;
+}
+
+export class ServerVersionResponseDto implements IServerVersion {
+  @ApiProperty({ type: 'integer' })
+  major!: number;
+  @ApiProperty({ type: 'integer' })
+  minor!: number;
+  @ApiProperty({ type: 'integer' })
+  patch!: number;
+}
+
+export class UsageByUserDto {
+  @ApiProperty({ type: 'string' })
+  userId!: string;
+  @ApiProperty({ type: 'string' })
+  userFirstName!: string;
+  @ApiProperty({ type: 'string' })
+  userLastName!: string;
+  @ApiProperty({ type: 'integer' })
+  photos!: number;
+  @ApiProperty({ type: 'integer' })
+  videos!: number;
+  @ApiProperty({ type: 'integer', format: 'int64' })
+  usage!: number;
+}
+
+export class ServerStatsResponseDto {
+  @ApiProperty({ type: 'integer' })
+  photos = 0;
+
+  @ApiProperty({ type: 'integer' })
+  videos = 0;
+
+  @ApiProperty({ type: 'integer', format: 'int64' })
+  usage = 0;
+
+  @ApiProperty({
+    isArray: true,
+    type: UsageByUserDto,
+    title: 'Array of usage for each user',
+    example: [
+      {
+        photos: 1,
+        videos: 1,
+        diskUsageRaw: 1,
+      },
+    ],
+  })
+  usageByUser: UsageByUserDto[] = [];
+}
+
+export class ServerMediaTypesResponseDto {
+  video!: string[];
+  image!: string[];
+  sidecar!: string[];
+}
+
+export class ServerFeaturesDto {
+  machineLearning!: boolean;
+  search!: boolean;
+
+  oauth!: boolean;
+  oauthAutoLaunch!: boolean;
+  passwordLogin!: boolean;
+}

+ 18 - 2
server/src/domain/server-info/server-info.service.spec.ts

@@ -1,19 +1,22 @@
-import { newStorageRepositoryMock, newUserRepositoryMock } from '@test';
+import { newStorageRepositoryMock, newSystemConfigRepositoryMock, newUserRepositoryMock } from '@test';
 import { serverVersion } from '../domain.constant';
+import { ISystemConfigRepository } from '../index';
 import { IStorageRepository } from '../storage';
 import { IUserRepository } from '../user';
 import { ServerInfoService } from './server-info.service';
 
 describe(ServerInfoService.name, () => {
   let sut: ServerInfoService;
+  let configMock: jest.Mocked<ISystemConfigRepository>;
   let storageMock: jest.Mocked<IStorageRepository>;
   let userMock: jest.Mocked<IUserRepository>;
 
   beforeEach(() => {
+    configMock = newSystemConfigRepositoryMock();
     storageMock = newStorageRepositoryMock();
     userMock = newUserRepositoryMock();
 
-    sut = new ServerInfoService(userMock, storageMock);
+    sut = new ServerInfoService(configMock, userMock, storageMock);
   });
 
   it('should work', () => {
@@ -140,6 +143,19 @@ describe(ServerInfoService.name, () => {
     it('should respond the server version', () => {
       expect(sut.getVersion()).toEqual(serverVersion);
     });
+
+    describe('getFeatures', () => {
+      it('should respond the server features', async () => {
+        await expect(sut.getFeatures()).resolves.toEqual({
+          machineLearning: true,
+          oauth: false,
+          oauthAutoLaunch: false,
+          passwordLogin: true,
+          search: true,
+        });
+        expect(configMock.load).toHaveBeenCalled();
+      });
+    });
   });
 
   describe('getStats', () => {

+ 25 - 4
server/src/domain/server-info/server-info.service.ts

@@ -1,24 +1,31 @@
 import { Inject, Injectable } from '@nestjs/common';
-import { mimeTypes, serverVersion } from '../domain.constant';
+import { MACHINE_LEARNING_ENABLED, mimeTypes, SEARCH_ENABLED, 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 { IUserRepository, UserStatsQueryResponse } from '../user';
 import {
+  ServerFeaturesDto,
   ServerInfoResponseDto,
   ServerMediaTypesResponseDto,
   ServerPingResponse,
   ServerStatsResponseDto,
   UsageByUserDto,
-} from './response-dto';
+} from './server-info.dto';
 
 @Injectable()
 export class ServerInfoService {
   private storageCore = new StorageCore();
+  private configCore: SystemConfigCore;
 
   constructor(
+    @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
     @Inject(IUserRepository) private userRepository: IUserRepository,
     @Inject(IStorageRepository) private storageRepository: IStorageRepository,
-  ) {}
+  ) {
+    this.configCore = new SystemConfigCore(configRepository);
+  }
 
   async getInfo(): Promise<ServerInfoResponseDto> {
     const libraryBase = this.storageCore.getBaseFolder(StorageFolder.LIBRARY);
@@ -38,13 +45,27 @@ export class ServerInfoService {
   }
 
   ping(): ServerPingResponse {
-    return new ServerPingResponse('pong');
+    return { res: 'pong' };
   }
 
   getVersion() {
     return serverVersion;
   }
 
+  async getFeatures(): Promise<ServerFeaturesDto> {
+    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,
+    };
+  }
+
   async getStats(): Promise<ServerStatsResponseDto> {
     const userStats: UserStatsQueryResponse[] = await this.userRepository.getUserStats();
     const serverStats = new ServerStatsResponseDto();

+ 13 - 6
server/src/immich/controllers/server-info.controller.ts

@@ -1,10 +1,11 @@
 import {
+  ServerFeaturesDto,
   ServerInfoResponseDto,
   ServerInfoService,
   ServerMediaTypesResponseDto,
   ServerPingResponse,
   ServerStatsResponseDto,
-  ServerVersionReponseDto,
+  ServerVersionResponseDto,
 } from '@app/domain';
 import { Controller, Get } from '@nestjs/common';
 import { ApiTags } from '@nestjs/swagger';
@@ -24,25 +25,31 @@ export class ServerInfoController {
   }
 
   @PublicRoute()
-  @Get('/ping')
+  @Get('ping')
   pingServer(): ServerPingResponse {
     return this.service.ping();
   }
 
   @PublicRoute()
-  @Get('/version')
-  getServerVersion(): ServerVersionReponseDto {
+  @Get('version')
+  getServerVersion(): ServerVersionResponseDto {
     return this.service.getVersion();
   }
 
+  @PublicRoute()
+  @Get('features')
+  getServerFeatures(): Promise<ServerFeaturesDto> {
+    return this.service.getFeatures();
+  }
+
   @AdminRoute()
-  @Get('/stats')
+  @Get('stats')
   getStats(): Promise<ServerStatsResponseDto> {
     return this.service.getStats();
   }
 
   @PublicRoute()
-  @Get('/media-types')
+  @Get('media-types')
   getSupportedMediaTypes(): ServerMediaTypesResponseDto {
     return this.service.getSupportedMediaTypes();
   }

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

@@ -2087,6 +2087,43 @@ export interface SearchResponseDto {
      */
     'assets': SearchAssetResponseDto;
 }
+/**
+ * 
+ * @export
+ * @interface ServerFeaturesDto
+ */
+export interface ServerFeaturesDto {
+    /**
+     * 
+     * @type {boolean}
+     * @memberof ServerFeaturesDto
+     */
+    'machineLearning': boolean;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof ServerFeaturesDto
+     */
+    'oauth': boolean;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof ServerFeaturesDto
+     */
+    'oauthAutoLaunch': boolean;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof ServerFeaturesDto
+     */
+    'passwordLogin': boolean;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof ServerFeaturesDto
+     */
+    'search': boolean;
+}
 /**
  * 
  * @export
@@ -2208,25 +2245,25 @@ export interface ServerStatsResponseDto {
 /**
  * 
  * @export
- * @interface ServerVersionReponseDto
+ * @interface ServerVersionResponseDto
  */
-export interface ServerVersionReponseDto {
+export interface ServerVersionResponseDto {
     /**
      * 
      * @type {number}
-     * @memberof ServerVersionReponseDto
+     * @memberof ServerVersionResponseDto
      */
     'major': number;
     /**
      * 
      * @type {number}
-     * @memberof ServerVersionReponseDto
+     * @memberof ServerVersionResponseDto
      */
     'minor': number;
     /**
      * 
      * @type {number}
-     * @memberof ServerVersionReponseDto
+     * @memberof ServerVersionResponseDto
      */
     'patch': number;
 }
@@ -10156,6 +10193,35 @@ export class SearchApi extends BaseAPI {
  */
 export const ServerInfoApiAxiosParamCreator = function (configuration?: Configuration) {
     return {
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        getServerFeatures: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            const localVarPath = `/server-info/features`;
+            // 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;
+
+
+    
+            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.
@@ -10329,6 +10395,15 @@ export const ServerInfoApiAxiosParamCreator = function (configuration?: Configur
 export const ServerInfoApiFp = function(configuration?: Configuration) {
     const localVarAxiosParamCreator = ServerInfoApiAxiosParamCreator(configuration)
     return {
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async getServerFeatures(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<ServerFeaturesDto>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.getServerFeatures(options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
         /**
          * 
          * @param {*} [options] Override http request option.
@@ -10343,7 +10418,7 @@ export const ServerInfoApiFp = function(configuration?: Configuration) {
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          */
-        async getServerVersion(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<ServerVersionReponseDto>> {
+        async getServerVersion(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<ServerVersionResponseDto>> {
             const localVarAxiosArgs = await localVarAxiosParamCreator.getServerVersion(options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
@@ -10384,6 +10459,14 @@ export const ServerInfoApiFp = function(configuration?: Configuration) {
 export const ServerInfoApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
     const localVarFp = ServerInfoApiFp(configuration)
     return {
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        getServerFeatures(options?: AxiosRequestConfig): AxiosPromise<ServerFeaturesDto> {
+            return localVarFp.getServerFeatures(options).then((request) => request(axios, basePath));
+        },
         /**
          * 
          * @param {*} [options] Override http request option.
@@ -10397,7 +10480,7 @@ export const ServerInfoApiFactory = function (configuration?: Configuration, bas
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          */
-        getServerVersion(options?: AxiosRequestConfig): AxiosPromise<ServerVersionReponseDto> {
+        getServerVersion(options?: AxiosRequestConfig): AxiosPromise<ServerVersionResponseDto> {
             return localVarFp.getServerVersion(options).then((request) => request(axios, basePath));
         },
         /**
@@ -10434,6 +10517,16 @@ export const ServerInfoApiFactory = function (configuration?: Configuration, bas
  * @extends {BaseAPI}
  */
 export class ServerInfoApi extends BaseAPI {
+    /**
+     * 
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof ServerInfoApi
+     */
+    public getServerFeatures(options?: AxiosRequestConfig) {
+        return ServerInfoApiFp(this.configuration).getServerFeatures(options).then((request) => request(this.axios, this.basePath));
+    }
+
     /**
      * 
      * @param {*} [options] Override http request option.

+ 8 - 2
web/src/lib/components/admin-page/jobs/job-tile-button.svelte

@@ -4,17 +4,23 @@
 
 <script lang="ts">
   export let color: Colors;
+  export let disabled = false;
 
   const colorClasses: Record<Colors, string> = {
     'light-gray': 'bg-gray-300/90 dark:bg-gray-600/90',
     gray: 'bg-gray-300 dark:bg-gray-600',
   };
+
+  const hoverClasses = disabled
+    ? 'cursor-not-allowed'
+    : 'hover:bg-immich-primary hover:text-white dark:hover:bg-immich-dark-primary dark:hover:text-black';
 </script>
 
 <button
-  class="flex h-full w-full flex-col place-content-center place-items-center gap-2 px-8 py-2 text-xs text-gray-600 transition-colors hover:bg-immich-primary hover:text-white dark:text-gray-200 dark:hover:bg-immich-dark-primary dark:hover:text-black {colorClasses[
+  {disabled}
+  class="flex h-full w-full flex-col place-content-center place-items-center gap-2 px-8 py-2 text-xs text-gray-600 transition-colors dark:text-gray-200 {colorClasses[
     color
-  ]}"
+  ]} {hoverClasses}"
   on:click
 >
   <slot />

+ 11 - 1
web/src/lib/components/admin-page/jobs/job-tile.svelte

@@ -6,6 +6,7 @@
   import FastForward from 'svelte-material-icons/FastForward.svelte';
   import AllInclusive from 'svelte-material-icons/AllInclusive.svelte';
   import Close from 'svelte-material-icons/Close.svelte';
+  import AlertCircle from 'svelte-material-icons/AlertCircle.svelte';
   import { locale } from '$lib/stores/preferences.store';
   import { createEventDispatcher } from 'svelte';
   import { JobCommand, JobCommandDto, JobCountsDto, QueueStatusDto } from '@api';
@@ -19,6 +20,7 @@
   export let queueStatus: QueueStatusDto;
   export let allowForceCommand = true;
   export let icon: typeof Icon;
+  export let disabled = false;
 
   export let allText: string;
   export let missingText: string;
@@ -94,7 +96,15 @@
     </div>
   </div>
   <div class="flex w-full flex-row overflow-hidden sm:w-32 sm:flex-col">
-    {#if !isIdle}
+    {#if disabled}
+      <JobTileButton
+        disabled={true}
+        color="light-gray"
+        on:click={() => dispatch('command', { command: JobCommand.Start, force: false })}
+      >
+        <AlertCircle size="36" /> DISABLED
+      </JobTileButton>
+    {:else if !isIdle}
       {#if waitingCount > 0}
         <JobTileButton color="gray" on:click={() => dispatch('command', { command: JobCommand.Empty, force: false })}>
           <Close size="24" /> CLEAR

+ 9 - 4
web/src/lib/components/admin-page/jobs/jobs-panel.svelte

@@ -4,6 +4,7 @@
     NotificationType,
   } from '$lib/components/shared-components/notification/notification';
   import { AppRoute } from '$lib/constants';
+  import { featureFlags } from '$lib/stores/feature-flags.store';
   import { handleError } from '$lib/utils/handle-error';
   import { AllJobStatusResponseDto, api, JobCommand, JobCommandDto, JobName } from '@api';
   import type { ComponentType } from 'svelte';
@@ -29,6 +30,7 @@
     subtitle?: string;
     allText?: string;
     missingText?: string;
+    disabled?: boolean;
     icon: typeof Icon;
     allowForceCommand?: boolean;
     component?: ComponentType;
@@ -51,7 +53,7 @@
     handleCommand(JobName.RecognizeFaces, { command: JobCommand.Start, force: true });
   };
 
-  const jobDetails: Partial<Record<JobName, JobDetails>> = {
+  $: jobDetails = <Partial<Record<JobName, JobDetails>>>{
     [JobName.ThumbnailGeneration]: {
       icon: FileJpgBox,
       title: api.getJobName(JobName.ThumbnailGeneration),
@@ -73,17 +75,20 @@
       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,
     },
     [JobName.ClipEncoding]: {
       icon: VectorCircle,
       title: api.getJobName(JobName.ClipEncoding),
       subtitle: 'Run machine learning to generate clip embeddings',
+      disabled: !$featureFlags.machineLearning,
     },
     [JobName.RecognizeFaces]: {
       icon: FaceRecognition,
       title: api.getJobName(JobName.RecognizeFaces),
       subtitle: 'Run machine learning to recognize faces',
       handleCommand: handleFaceCommand,
+      disabled: !$featureFlags.machineLearning,
     },
     [JobName.VideoConversion]: {
       icon: Video,
@@ -97,8 +102,7 @@
       component: StorageMigrationDescription,
     },
   };
-
-  const jobDetailsArray = Object.entries(jobDetails) as [JobName, JobDetails][];
+  $: jobList = Object.entries(jobDetails) as [JobName, JobDetails][];
 
   async function handleCommand(jobId: JobName, jobCommand: JobCommandDto) {
     const title = jobDetails[jobId]?.title;
@@ -138,11 +142,12 @@
       </Button>
     </a>
   </div>
-  {#each jobDetailsArray as [jobName, { title, subtitle, allText, missingText, allowForceCommand, icon, component, handleCommand: handleCommandOverride }]}
+  {#each jobList as [jobName, { title, subtitle, disabled, allText, missingText, allowForceCommand, icon, component, handleCommand: handleCommandOverride }]}
     {@const { jobCounts, queueStatus } = jobs[jobName]}
     <JobTile
       {icon}
       {title}
+      {disabled}
       {subtitle}
       allText={allText || 'ALL'}
       missingText={missingText || 'MISSING'}

+ 13 - 8
web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte

@@ -16,6 +16,7 @@
   import IconButton from '$lib/components/elements/buttons/icon-button.svelte';
   import Cog from 'svelte-material-icons/Cog.svelte';
   import UserAvatar from '../user-avatar.svelte';
+  import { featureFlags } from '$lib/stores/feature-flags.store';
   export let user: UserResponseDto;
   export let showUploadButton = true;
 
@@ -45,17 +46,21 @@
     </a>
     <div class="flex justify-between gap-16 pr-6">
       <div class="hidden w-full max-w-5xl flex-1 pl-4 sm:block">
-        <SearchBar grayTheme={true} />
+        {#if $featureFlags.search}
+          <SearchBar grayTheme={true} />
+        {/if}
       </div>
 
       <section class="flex place-items-center justify-end gap-4 max-sm:w-full">
-        <a href={AppRoute.SEARCH} id="search-button" class="pl-4 sm:hidden">
-          <IconButton title="Search">
-            <div class="flex gap-2">
-              <Magnify size="1.5em" />
-            </div>
-          </IconButton>
-        </a>
+        {#if $featureFlags.search}
+          <a href={AppRoute.SEARCH} id="search-button" class="pl-4 sm:hidden">
+            <IconButton title="Search">
+              <div class="flex gap-2">
+                <Magnify size="1.5em" />
+              </div>
+            </IconButton>
+          </a>
+        {/if}
 
         <ThemeButton />
 

+ 6 - 3
web/src/lib/components/shared-components/side-bar/side-bar.svelte

@@ -17,6 +17,7 @@
   import SideBarButton from './side-bar-button.svelte';
   import { locale } from '$lib/stores/preferences.store';
   import SideBarSection from './side-bar-section.svelte';
+  import { featureFlags } from '$lib/stores/feature-flags.store';
 
   const getStats = async (dto: AssetApiGetAssetStatsRequest) => {
     const { data: stats } = await api.assetApi.getAssetStats(dto);
@@ -56,9 +57,11 @@
       </svelte:fragment>
     </SideBarButton>
   </a>
-  <a data-sveltekit-preload-data="hover" data-sveltekit-noscroll href={AppRoute.EXPLORE} draggable="false">
-    <SideBarButton title="Explore" logo={Magnify} isSelected={$page.route.id === '/(user)/explore'} />
-  </a>
+  {#if $featureFlags.search}
+    <a data-sveltekit-preload-data="hover" data-sveltekit-noscroll href={AppRoute.EXPLORE} draggable="false">
+      <SideBarButton title="Explore" logo={Magnify} isSelected={$page.route.id === '/(user)/explore'} />
+    </a>
+  {/if}
   <a data-sveltekit-preload-data="hover" href={AppRoute.MAP} draggable="false">
     <SideBarButton title="Map" logo={Map} isSelected={$page.route.id === '/(user)/map'} />
   </a>

+ 3 - 3
web/src/lib/components/shared-components/version-announcement-box.svelte

@@ -2,16 +2,16 @@
   import { getGithubVersion } from '$lib/utils/get-github-version';
   import { onMount } from 'svelte';
   import FullScreenModal from './full-screen-modal.svelte';
-  import type { ServerVersionReponseDto } from '@api';
+  import type { ServerVersionResponseDto } from '@api';
   import Button from '../elements/buttons/button.svelte';
 
-  export let serverVersion: ServerVersionReponseDto;
+  export let serverVersion: ServerVersionResponseDto;
 
   let showModal = false;
   let githubVersion: string;
   $: serverVersionName = semverToName(serverVersion);
 
-  function semverToName({ major, minor, patch }: ServerVersionReponseDto) {
+  function semverToName({ major, minor, patch }: ServerVersionResponseDto) {
     return `v${major}.${minor}.${patch}`;
   }
 

+ 17 - 0
web/src/lib/stores/feature-flags.store.ts

@@ -0,0 +1,17 @@
+import { api, ServerFeaturesDto } from '@api';
+import { writable } from 'svelte/store';
+
+export type FeatureFlags = ServerFeaturesDto;
+
+export const featureFlags = writable<FeatureFlags>({
+  machineLearning: true,
+  search: true,
+  oauth: true,
+  oauthAutoLaunch: true,
+  passwordLogin: true,
+});
+
+export const loadFeatureFlags = async () => {
+  const { data } = await api.serverInfoApi.getServerFeatures();
+  featureFlags.update(() => data);
+};

+ 11 - 2
web/src/routes/+layout.svelte

@@ -1,6 +1,5 @@
 <script lang="ts">
   import '../app.css';
-
   import { page } from '$app/stores';
   import { afterNavigate, beforeNavigate } from '$app/navigation';
   import NavigationLoadingBar from '$lib/components/shared-components/navigation-loading-bar.svelte';
@@ -14,7 +13,9 @@
   import FullscreenContainer from '$lib/components/shared-components/fullscreen-container.svelte';
   import AppleHeader from '$lib/components/shared-components/apple-header.svelte';
   import FaviconHeader from '$lib/components/shared-components/favicon-header.svelte';
-
+  import { onMount } from 'svelte';
+  import { loadFeatureFlags } from '$lib/stores/feature-flags.store';
+  import { handleError } from '$lib/utils/handle-error';
   import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
 
   let showNavigationLoadingBar = false;
@@ -29,6 +30,14 @@
     showNavigationLoadingBar = false;
   });
 
+  onMount(async () => {
+    try {
+      await loadFeatureFlags();
+    } catch (error) {
+      handleError(error, 'Unable to load feature flags');
+    }
+  });
+
   const dropHandler = async ({ dataTransfer }: DragEvent) => {
     const files = dataTransfer?.files;
     if (!files) {