Explorar o código

feat(server,web): system config for admin (#959)

* feat: add admin config module for user configured config, uses it for ffmpeg

* feat: add api endpoint to retrieve admin config settings and values

* feat: add settings panel to admin page on web (wip)

* feat: add api endpoint to update the admin config

* chore: re-generate openapi spec after rebase

* refactor: move from admin config to system config naming

* chore: move away from UseGuards to new @Authenticated decorator

* style: dark mode styling for lists and fix conflicting colors

* wip: 2 column design, no edit button

* refactor: system config

* chore: generate open api

* chore: rm broken test

* chore: cleanup types

* refactor: config module names

Co-authored-by: Zack Pollard <zackpollard@ymail.com>
Co-authored-by: Zack Pollard <zack.pollard@moonpig.com>
Jason Rasmussen %!s(int64=2) %!d(string=hai) anos
pai
achega
b5d75e2016
Modificáronse 52 ficheiros con 2061 adicións e 37 borrados
  1. 8 0
      mobile/openapi/.openapi-generator/FILES
  2. 5 0
      mobile/openapi/README.md
  3. 15 0
      mobile/openapi/doc/AdminConfigResponseDto.md
  4. 105 0
      mobile/openapi/doc/ConfigApi.md
  5. 105 0
      mobile/openapi/doc/SystemConfigApi.md
  6. 16 0
      mobile/openapi/doc/SystemConfigEntity.md
  7. 14 0
      mobile/openapi/doc/SystemConfigKey.md
  8. 15 0
      mobile/openapi/doc/SystemConfigResponseDto.md
  9. 18 0
      mobile/openapi/doc/SystemConfigResponseItem.md
  10. 4 0
      mobile/openapi/lib/api.dart
  11. 106 0
      mobile/openapi/lib/api/config_api.dart
  12. 106 0
      mobile/openapi/lib/api/system_config_api.dart
  13. 6 0
      mobile/openapi/lib/api_client.dart
  14. 3 0
      mobile/openapi/lib/api_helper.dart
  15. 111 0
      mobile/openapi/lib/model/admin_config_response_dto.dart
  16. 202 0
      mobile/openapi/lib/model/system_config_entity.dart
  17. 94 0
      mobile/openapi/lib/model/system_config_key.dart
  18. 111 0
      mobile/openapi/lib/model/system_config_response_dto.dart
  19. 135 0
      mobile/openapi/lib/model/system_config_response_item.dart
  20. 26 0
      mobile/openapi/test/admin_config_api_test.dart
  21. 27 0
      mobile/openapi/test/admin_config_response_dto_test.dart
  22. 31 0
      mobile/openapi/test/config_api_test.dart
  23. 31 0
      mobile/openapi/test/system_config_api_test.dart
  24. 32 0
      mobile/openapi/test/system_config_entity_test.dart
  25. 21 0
      mobile/openapi/test/system_config_key_test.dart
  26. 27 0
      mobile/openapi/test/system_config_response_dto_test.dart
  27. 37 0
      mobile/openapi/test/system_config_response_item_test.dart
  28. 6 1
      server/README.md
  29. 20 0
      server/apps/immich/src/api-v1/system-config/dto/update-system-config.ts
  30. 20 0
      server/apps/immich/src/api-v1/system-config/response-dto/system-config-response.dto.ts
  31. 24 0
      server/apps/immich/src/api-v1/system-config/system-config.controller.ts
  32. 14 0
      server/apps/immich/src/api-v1/system-config/system-config.module.ts
  33. 20 0
      server/apps/immich/src/api-v1/system-config/system-config.service.ts
  34. 3 0
      server/apps/immich/src/app.module.ts
  35. 3 2
      server/apps/microservices/src/microservices.module.ts
  36. 11 1
      server/apps/microservices/src/processors/video-transcode.processor.ts
  37. 0 0
      server/immich-openapi-specs.json
  38. 27 0
      server/libs/database/src/entities/system-config.entity.ts
  39. 15 0
      server/libs/database/src/migrations/1665540663419-CreateSystemConfigTable.ts
  40. 11 0
      server/libs/immich-config/src/immich-config.module.ts
  41. 97 0
      server/libs/immich-config/src/immich-config.service.ts
  42. 2 0
      server/libs/immich-config/src/index.ts
  43. 9 0
      server/libs/immich-config/tsconfig.lib.json
  44. 10 1
      server/nest-cli.json
  45. 4 2
      server/package.json
  46. 10 24
      server/tsconfig.json
  47. 3 0
      web/src/api/api.ts
  48. 228 0
      web/src/api/open-api/api.ts
  49. 1 1
      web/src/app.css
  50. 97 0
      web/src/lib/components/admin-page/settings/settings-panel.svelte
  51. 2 4
      web/src/routes/admin/+page.server.ts
  52. 13 1
      web/src/routes/admin/+page.svelte

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

@@ -59,6 +59,10 @@ doc/ServerStatsResponseDto.md
 doc/ServerVersionReponseDto.md
 doc/SignUpDto.md
 doc/SmartInfoResponseDto.md
+doc/SystemConfigApi.md
+doc/SystemConfigKey.md
+doc/SystemConfigResponseDto.md
+doc/SystemConfigResponseItem.md
 doc/ThumbnailFormat.md
 doc/TimeGroupEnum.md
 doc/UpdateAlbumDto.md
@@ -79,6 +83,7 @@ lib/api/device_info_api.dart
 lib/api/job_api.dart
 lib/api/o_auth_api.dart
 lib/api/server_info_api.dart
+lib/api/system_config_api.dart
 lib/api/user_api.dart
 lib/api_client.dart
 lib/api_exception.dart
@@ -138,6 +143,9 @@ lib/model/server_stats_response_dto.dart
 lib/model/server_version_reponse_dto.dart
 lib/model/sign_up_dto.dart
 lib/model/smart_info_response_dto.dart
+lib/model/system_config_key.dart
+lib/model/system_config_response_dto.dart
+lib/model/system_config_response_item.dart
 lib/model/thumbnail_format.dart
 lib/model/time_group_enum.dart
 lib/model/update_album_dto.dart

+ 5 - 0
mobile/openapi/README.md

@@ -109,6 +109,8 @@ Class | Method | HTTP request | Description
 *ServerInfoApi* | [**getServerVersion**](doc//ServerInfoApi.md#getserverversion) | **GET** /server-info/version | 
 *ServerInfoApi* | [**getStats**](doc//ServerInfoApi.md#getstats) | **GET** /server-info/stats | 
 *ServerInfoApi* | [**pingServer**](doc//ServerInfoApi.md#pingserver) | **GET** /server-info/ping | 
+*SystemConfigApi* | [**getConfig**](doc//SystemConfigApi.md#getconfig) | **GET** /system-config | 
+*SystemConfigApi* | [**updateConfig**](doc//SystemConfigApi.md#updateconfig) | **PUT** /system-config | 
 *UserApi* | [**createProfileImage**](doc//UserApi.md#createprofileimage) | **POST** /user/profile-image | 
 *UserApi* | [**createUser**](doc//UserApi.md#createuser) | **POST** /user | 
 *UserApi* | [**deleteUser**](doc//UserApi.md#deleteuser) | **DELETE** /user/{userId} | 
@@ -173,6 +175,9 @@ Class | Method | HTTP request | Description
  - [ServerVersionReponseDto](doc//ServerVersionReponseDto.md)
  - [SignUpDto](doc//SignUpDto.md)
  - [SmartInfoResponseDto](doc//SmartInfoResponseDto.md)
+ - [SystemConfigKey](doc//SystemConfigKey.md)
+ - [SystemConfigResponseDto](doc//SystemConfigResponseDto.md)
+ - [SystemConfigResponseItem](doc//SystemConfigResponseItem.md)
  - [ThumbnailFormat](doc//ThumbnailFormat.md)
  - [TimeGroupEnum](doc//TimeGroupEnum.md)
  - [UpdateAlbumDto](doc//UpdateAlbumDto.md)

+ 15 - 0
mobile/openapi/doc/AdminConfigResponseDto.md

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

+ 105 - 0
mobile/openapi/doc/ConfigApi.md

@@ -0,0 +1,105 @@
+# openapi.api.ConfigApi
+
+## Load the API package
+```dart
+import 'package:openapi/api.dart';
+```
+
+All URIs are relative to */api*
+
+Method | HTTP request | Description
+------------- | ------------- | -------------
+[**getSystemConfig**](ConfigApi.md#getsystemconfig) | **GET** /config/system | 
+[**updateSystemConfig**](ConfigApi.md#updatesystemconfig) | **PUT** /config/system | 
+
+
+# **getSystemConfig**
+> SystemConfigResponseDto getSystemConfig()
+
+
+
+### Example
+```dart
+import 'package:openapi/api.dart';
+// TODO Configure HTTP Bearer authorization: bearer
+// Case 1. Use String Token
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
+// Case 2. Use Function which generate token.
+// String yourTokenGeneratorFunction() { ... }
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
+
+final api_instance = ConfigApi();
+
+try {
+    final result = api_instance.getSystemConfig();
+    print(result);
+} catch (e) {
+    print('Exception when calling ConfigApi->getSystemConfig: $e\n');
+}
+```
+
+### Parameters
+This endpoint does not need any parameter.
+
+### Return type
+
+[**SystemConfigResponseDto**](SystemConfigResponseDto.md)
+
+### Authorization
+
+[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)
+
+# **updateSystemConfig**
+> SystemConfigResponseDto updateSystemConfig(body)
+
+
+
+### Example
+```dart
+import 'package:openapi/api.dart';
+// TODO Configure HTTP Bearer authorization: bearer
+// Case 1. Use String Token
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
+// Case 2. Use Function which generate token.
+// String yourTokenGeneratorFunction() { ... }
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
+
+final api_instance = ConfigApi();
+final body = Object(); // Object | 
+
+try {
+    final result = api_instance.updateSystemConfig(body);
+    print(result);
+} catch (e) {
+    print('Exception when calling ConfigApi->updateSystemConfig: $e\n');
+}
+```
+
+### Parameters
+
+Name | Type | Description  | Notes
+------------- | ------------- | ------------- | -------------
+ **body** | **Object**|  | 
+
+### Return type
+
+[**SystemConfigResponseDto**](SystemConfigResponseDto.md)
+
+### Authorization
+
+[bearer](../README.md#bearer)
+
+### HTTP request headers
+
+ - **Content-Type**: application/json
+ - **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)
+

+ 105 - 0
mobile/openapi/doc/SystemConfigApi.md

@@ -0,0 +1,105 @@
+# openapi.api.SystemConfigApi
+
+## Load the API package
+```dart
+import 'package:openapi/api.dart';
+```
+
+All URIs are relative to */api*
+
+Method | HTTP request | Description
+------------- | ------------- | -------------
+[**getConfig**](SystemConfigApi.md#getconfig) | **GET** /system-config | 
+[**updateConfig**](SystemConfigApi.md#updateconfig) | **PUT** /system-config | 
+
+
+# **getConfig**
+> SystemConfigResponseDto getConfig()
+
+
+
+### Example
+```dart
+import 'package:openapi/api.dart';
+// TODO Configure HTTP Bearer authorization: bearer
+// Case 1. Use String Token
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
+// Case 2. Use Function which generate token.
+// String yourTokenGeneratorFunction() { ... }
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
+
+final api_instance = SystemConfigApi();
+
+try {
+    final result = api_instance.getConfig();
+    print(result);
+} catch (e) {
+    print('Exception when calling SystemConfigApi->getConfig: $e\n');
+}
+```
+
+### Parameters
+This endpoint does not need any parameter.
+
+### Return type
+
+[**SystemConfigResponseDto**](SystemConfigResponseDto.md)
+
+### Authorization
+
+[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)
+
+# **updateConfig**
+> SystemConfigResponseDto updateConfig(body)
+
+
+
+### Example
+```dart
+import 'package:openapi/api.dart';
+// TODO Configure HTTP Bearer authorization: bearer
+// Case 1. Use String Token
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
+// Case 2. Use Function which generate token.
+// String yourTokenGeneratorFunction() { ... }
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
+
+final api_instance = SystemConfigApi();
+final body = Object(); // Object | 
+
+try {
+    final result = api_instance.updateConfig(body);
+    print(result);
+} catch (e) {
+    print('Exception when calling SystemConfigApi->updateConfig: $e\n');
+}
+```
+
+### Parameters
+
+Name | Type | Description  | Notes
+------------- | ------------- | ------------- | -------------
+ **body** | **Object**|  | 
+
+### Return type
+
+[**SystemConfigResponseDto**](SystemConfigResponseDto.md)
+
+### Authorization
+
+[bearer](../README.md#bearer)
+
+### HTTP request headers
+
+ - **Content-Type**: application/json
+ - **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)
+

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

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

+ 14 - 0
mobile/openapi/doc/SystemConfigKey.md

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

+ 15 - 0
mobile/openapi/doc/SystemConfigResponseDto.md

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

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

@@ -0,0 +1,18 @@
+# openapi.model.SystemConfigResponseItem
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**name** | **String** |  | 
+**key** | [**SystemConfigKey**](SystemConfigKey.md) |  | 
+**value** | **String** |  | 
+**defaultValue** | **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)
+
+

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

@@ -34,6 +34,7 @@ part 'api/device_info_api.dart';
 part 'api/job_api.dart';
 part 'api/o_auth_api.dart';
 part 'api/server_info_api.dart';
+part 'api/system_config_api.dart';
 part 'api/user_api.dart';
 
 part 'model/add_assets_dto.dart';
@@ -86,6 +87,9 @@ part 'model/server_stats_response_dto.dart';
 part 'model/server_version_reponse_dto.dart';
 part 'model/sign_up_dto.dart';
 part 'model/smart_info_response_dto.dart';
+part 'model/system_config_key.dart';
+part 'model/system_config_response_dto.dart';
+part 'model/system_config_response_item.dart';
 part 'model/thumbnail_format.dart';
 part 'model/time_group_enum.dart';
 part 'model/update_album_dto.dart';

+ 106 - 0
mobile/openapi/lib/api/config_api.dart

@@ -0,0 +1,106 @@
+//
+// 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 ConfigApi {
+  ConfigApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
+
+  final ApiClient apiClient;
+
+  /// Performs an HTTP 'GET /config/system' operation and returns the [Response].
+  Future<Response> getSystemConfigWithHttpInfo() async {
+    // ignore: prefer_const_declarations
+    final path = r'/config/system';
+
+    // 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<SystemConfigResponseDto?> getSystemConfig() async {
+    final response = await getSystemConfigWithHttpInfo();
+    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), 'SystemConfigResponseDto',) as SystemConfigResponseDto;
+    
+    }
+    return null;
+  }
+
+  /// Performs an HTTP 'PUT /config/system' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [Object] body (required):
+  Future<Response> updateSystemConfigWithHttpInfo(Object body,) async {
+    // ignore: prefer_const_declarations
+    final path = r'/config/system';
+
+    // ignore: prefer_final_locals
+    Object? postBody = body;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>['application/json'];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'PUT',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [Object] body (required):
+  Future<SystemConfigResponseDto?> updateSystemConfig(Object body,) async {
+    final response = await updateSystemConfigWithHttpInfo(body,);
+    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), 'SystemConfigResponseDto',) as SystemConfigResponseDto;
+    
+    }
+    return null;
+  }
+}

+ 106 - 0
mobile/openapi/lib/api/system_config_api.dart

@@ -0,0 +1,106 @@
+//
+// 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 SystemConfigApi {
+  SystemConfigApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
+
+  final ApiClient apiClient;
+
+  /// Performs an HTTP 'GET /system-config' operation and returns the [Response].
+  Future<Response> getConfigWithHttpInfo() async {
+    // ignore: prefer_const_declarations
+    final path = r'/system-config';
+
+    // ignore: prefer_final_locals
+    Object? postBody;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>[];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'GET',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  Future<SystemConfigResponseDto?> getConfig() async {
+    final response = await getConfigWithHttpInfo();
+    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), 'SystemConfigResponseDto',) as SystemConfigResponseDto;
+    
+    }
+    return null;
+  }
+
+  /// Performs an HTTP 'PUT /system-config' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [Object] body (required):
+  Future<Response> updateConfigWithHttpInfo(Object body,) async {
+    // ignore: prefer_const_declarations
+    final path = r'/system-config';
+
+    // ignore: prefer_final_locals
+    Object? postBody = body;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>['application/json'];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'PUT',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [Object] body (required):
+  Future<SystemConfigResponseDto?> updateConfig(Object body,) async {
+    final response = await updateConfigWithHttpInfo(body,);
+    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), 'SystemConfigResponseDto',) as SystemConfigResponseDto;
+    
+    }
+    return null;
+  }
+}

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

@@ -292,6 +292,12 @@ class ApiClient {
           return SignUpDto.fromJson(value);
         case 'SmartInfoResponseDto':
           return SmartInfoResponseDto.fromJson(value);
+        case 'SystemConfigKey':
+          return SystemConfigKeyTypeTransformer().decode(value);
+        case 'SystemConfigResponseDto':
+          return SystemConfigResponseDto.fromJson(value);
+        case 'SystemConfigResponseItem':
+          return SystemConfigResponseItem.fromJson(value);
         case 'ThumbnailFormat':
           return ThumbnailFormatTypeTransformer().decode(value);
         case 'TimeGroupEnum':

+ 3 - 0
mobile/openapi/lib/api_helper.dart

@@ -70,6 +70,9 @@ String parameterToString(dynamic value) {
   if (value is JobId) {
     return JobIdTypeTransformer().encode(value).toString();
   }
+  if (value is SystemConfigKey) {
+    return SystemConfigKeyTypeTransformer().encode(value).toString();
+  }
   if (value is ThumbnailFormat) {
     return ThumbnailFormatTypeTransformer().encode(value).toString();
   }

+ 111 - 0
mobile/openapi/lib/model/admin_config_response_dto.dart

@@ -0,0 +1,111 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class AdminConfigResponseDto {
+  /// Returns a new [AdminConfigResponseDto] instance.
+  AdminConfigResponseDto({
+    required this.config,
+  });
+
+  Object config;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is AdminConfigResponseDto &&
+     other.config == config;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (config.hashCode);
+
+  @override
+  String toString() => 'AdminConfigResponseDto[config=$config]';
+
+  Map<String, dynamic> toJson() {
+    final _json = <String, dynamic>{};
+      _json[r'config'] = config;
+    return _json;
+  }
+
+  /// Returns a new [AdminConfigResponseDto] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static AdminConfigResponseDto? fromJson(dynamic value) {
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      // Ensure that the map contains the required keys.
+      // Note 1: the values aren't checked for validity beyond being non-null.
+      // Note 2: this code is stripped in release mode!
+      assert(() {
+        requiredKeys.forEach((key) {
+          assert(json.containsKey(key), 'Required key "AdminConfigResponseDto[$key]" is missing from JSON.');
+          assert(json[key] != null, 'Required key "AdminConfigResponseDto[$key]" has a null value in JSON.');
+        });
+        return true;
+      }());
+
+      return AdminConfigResponseDto(
+        config: mapValueOfType<Object>(json, r'config')!,
+      );
+    }
+    return null;
+  }
+
+  static List<AdminConfigResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <AdminConfigResponseDto>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = AdminConfigResponseDto.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, AdminConfigResponseDto> mapFromJson(dynamic json) {
+    final map = <String, AdminConfigResponseDto>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = AdminConfigResponseDto.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of AdminConfigResponseDto-objects as value to a dart map
+  static Map<String, List<AdminConfigResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<AdminConfigResponseDto>>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = AdminConfigResponseDto.listFromJson(entry.value, growable: growable,);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'config',
+  };
+}
+

+ 202 - 0
mobile/openapi/lib/model/system_config_entity.dart

@@ -0,0 +1,202 @@
+//
+// 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 SystemConfigEntity {
+  /// Returns a new [SystemConfigEntity] instance.
+  SystemConfigEntity({
+    required this.key,
+    required this.value,
+  });
+
+  SystemConfigEntityKeyEnum key;
+
+  Object value;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is SystemConfigEntity &&
+     other.key == key &&
+     other.value == value;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (key.hashCode) +
+    (value.hashCode);
+
+  @override
+  String toString() => 'SystemConfigEntity[key=$key, value=$value]';
+
+  Map<String, dynamic> toJson() {
+    final _json = <String, dynamic>{};
+      _json[r'key'] = key;
+      _json[r'value'] = value;
+    return _json;
+  }
+
+  /// Returns a new [SystemConfigEntity] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static SystemConfigEntity? fromJson(dynamic value) {
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      // Ensure that the map contains the required keys.
+      // Note 1: the values aren't checked for validity beyond being non-null.
+      // Note 2: this code is stripped in release mode!
+      assert(() {
+        requiredKeys.forEach((key) {
+          assert(json.containsKey(key), 'Required key "SystemConfigEntity[$key]" is missing from JSON.');
+          assert(json[key] != null, 'Required key "SystemConfigEntity[$key]" has a null value in JSON.');
+        });
+        return true;
+      }());
+
+      return SystemConfigEntity(
+        key: SystemConfigEntityKeyEnum.fromJson(json[r'key'])!,
+        value: mapValueOfType<Object>(json, r'value')!,
+      );
+    }
+    return null;
+  }
+
+  static List<SystemConfigEntity>? listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <SystemConfigEntity>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = SystemConfigEntity.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, SystemConfigEntity> mapFromJson(dynamic json) {
+    final map = <String, SystemConfigEntity>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = SystemConfigEntity.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of SystemConfigEntity-objects as value to a dart map
+  static Map<String, List<SystemConfigEntity>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<SystemConfigEntity>>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = SystemConfigEntity.listFromJson(entry.value, growable: growable,);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'key',
+    'value',
+  };
+}
+
+
+class SystemConfigEntityKeyEnum {
+  /// Instantiate a new enum with the provided [value].
+  const SystemConfigEntityKeyEnum._(this.value);
+
+  /// The underlying value of this enum member.
+  final String value;
+
+  @override
+  String toString() => value;
+
+  String toJson() => value;
+
+  static const crf = SystemConfigEntityKeyEnum._(r'ffmpeg_crf');
+  static const preset = SystemConfigEntityKeyEnum._(r'ffmpeg_preset');
+  static const targetVideoCodec = SystemConfigEntityKeyEnum._(r'ffmpeg_target_video_codec');
+  static const targetAudioCodec = SystemConfigEntityKeyEnum._(r'ffmpeg_target_audio_codec');
+  static const targetScaling = SystemConfigEntityKeyEnum._(r'ffmpeg_target_scaling');
+
+  /// List of all possible values in this [enum][SystemConfigEntityKeyEnum].
+  static const values = <SystemConfigEntityKeyEnum>[
+    crf,
+    preset,
+    targetVideoCodec,
+    targetAudioCodec,
+    targetScaling,
+  ];
+
+  static SystemConfigEntityKeyEnum? fromJson(dynamic value) => SystemConfigEntityKeyEnumTypeTransformer().decode(value);
+
+  static List<SystemConfigEntityKeyEnum>? listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <SystemConfigEntityKeyEnum>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = SystemConfigEntityKeyEnum.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+}
+
+/// Transformation class that can [encode] an instance of [SystemConfigEntityKeyEnum] to String,
+/// and [decode] dynamic data back to [SystemConfigEntityKeyEnum].
+class SystemConfigEntityKeyEnumTypeTransformer {
+  factory SystemConfigEntityKeyEnumTypeTransformer() => _instance ??= const SystemConfigEntityKeyEnumTypeTransformer._();
+
+  const SystemConfigEntityKeyEnumTypeTransformer._();
+
+  String encode(SystemConfigEntityKeyEnum data) => data.value;
+
+  /// Decodes a [dynamic value][data] to a SystemConfigEntityKeyEnum.
+  ///
+  /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
+  /// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
+  /// cannot be decoded successfully, then an [UnimplementedError] is thrown.
+  ///
+  /// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
+  /// and users are still using an old app with the old code.
+  SystemConfigEntityKeyEnum? decode(dynamic data, {bool allowNull = true}) {
+    if (data != null) {
+      switch (data.toString()) {
+        case r'ffmpeg_crf': return SystemConfigEntityKeyEnum.crf;
+        case r'ffmpeg_preset': return SystemConfigEntityKeyEnum.preset;
+        case r'ffmpeg_target_video_codec': return SystemConfigEntityKeyEnum.targetVideoCodec;
+        case r'ffmpeg_target_audio_codec': return SystemConfigEntityKeyEnum.targetAudioCodec;
+        case r'ffmpeg_target_scaling': return SystemConfigEntityKeyEnum.targetScaling;
+        default:
+          if (!allowNull) {
+            throw ArgumentError('Unknown enum value to decode: $data');
+          }
+      }
+    }
+    return null;
+  }
+
+  /// Singleton [SystemConfigEntityKeyEnumTypeTransformer] instance.
+  static SystemConfigEntityKeyEnumTypeTransformer? _instance;
+}
+
+

+ 94 - 0
mobile/openapi/lib/model/system_config_key.dart

@@ -0,0 +1,94 @@
+//
+// 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 SystemConfigKey {
+  /// Instantiate a new enum with the provided [value].
+  const SystemConfigKey._(this.value);
+
+  /// The underlying value of this enum member.
+  final String value;
+
+  @override
+  String toString() => value;
+
+  String toJson() => value;
+
+  static const crf = SystemConfigKey._(r'ffmpeg_crf');
+  static const preset = SystemConfigKey._(r'ffmpeg_preset');
+  static const targetVideoCodec = SystemConfigKey._(r'ffmpeg_target_video_codec');
+  static const targetAudioCodec = SystemConfigKey._(r'ffmpeg_target_audio_codec');
+  static const targetScaling = SystemConfigKey._(r'ffmpeg_target_scaling');
+
+  /// List of all possible values in this [enum][SystemConfigKey].
+  static const values = <SystemConfigKey>[
+    crf,
+    preset,
+    targetVideoCodec,
+    targetAudioCodec,
+    targetScaling,
+  ];
+
+  static SystemConfigKey? fromJson(dynamic value) => SystemConfigKeyTypeTransformer().decode(value);
+
+  static List<SystemConfigKey>? listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <SystemConfigKey>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = SystemConfigKey.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+}
+
+/// Transformation class that can [encode] an instance of [SystemConfigKey] to String,
+/// and [decode] dynamic data back to [SystemConfigKey].
+class SystemConfigKeyTypeTransformer {
+  factory SystemConfigKeyTypeTransformer() => _instance ??= const SystemConfigKeyTypeTransformer._();
+
+  const SystemConfigKeyTypeTransformer._();
+
+  String encode(SystemConfigKey data) => data.value;
+
+  /// Decodes a [dynamic value][data] to a SystemConfigKey.
+  ///
+  /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
+  /// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
+  /// cannot be decoded successfully, then an [UnimplementedError] is thrown.
+  ///
+  /// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
+  /// and users are still using an old app with the old code.
+  SystemConfigKey? decode(dynamic data, {bool allowNull = true}) {
+    if (data != null) {
+      switch (data.toString()) {
+        case r'ffmpeg_crf': return SystemConfigKey.crf;
+        case r'ffmpeg_preset': return SystemConfigKey.preset;
+        case r'ffmpeg_target_video_codec': return SystemConfigKey.targetVideoCodec;
+        case r'ffmpeg_target_audio_codec': return SystemConfigKey.targetAudioCodec;
+        case r'ffmpeg_target_scaling': return SystemConfigKey.targetScaling;
+        default:
+          if (!allowNull) {
+            throw ArgumentError('Unknown enum value to decode: $data');
+          }
+      }
+    }
+    return null;
+  }
+
+  /// Singleton [SystemConfigKeyTypeTransformer] instance.
+  static SystemConfigKeyTypeTransformer? _instance;
+}
+

+ 111 - 0
mobile/openapi/lib/model/system_config_response_dto.dart

@@ -0,0 +1,111 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class SystemConfigResponseDto {
+  /// Returns a new [SystemConfigResponseDto] instance.
+  SystemConfigResponseDto({
+    this.config = const [],
+  });
+
+  List<SystemConfigResponseItem> config;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is SystemConfigResponseDto &&
+     other.config == config;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (config.hashCode);
+
+  @override
+  String toString() => 'SystemConfigResponseDto[config=$config]';
+
+  Map<String, dynamic> toJson() {
+    final _json = <String, dynamic>{};
+      _json[r'config'] = config;
+    return _json;
+  }
+
+  /// Returns a new [SystemConfigResponseDto] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static SystemConfigResponseDto? fromJson(dynamic value) {
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      // Ensure that the map contains the required keys.
+      // Note 1: the values aren't checked for validity beyond being non-null.
+      // Note 2: this code is stripped in release mode!
+      assert(() {
+        requiredKeys.forEach((key) {
+          assert(json.containsKey(key), 'Required key "SystemConfigResponseDto[$key]" is missing from JSON.');
+          assert(json[key] != null, 'Required key "SystemConfigResponseDto[$key]" has a null value in JSON.');
+        });
+        return true;
+      }());
+
+      return SystemConfigResponseDto(
+        config: SystemConfigResponseItem.listFromJson(json[r'config'])!,
+      );
+    }
+    return null;
+  }
+
+  static List<SystemConfigResponseDto>? listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <SystemConfigResponseDto>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = SystemConfigResponseDto.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, SystemConfigResponseDto> mapFromJson(dynamic json) {
+    final map = <String, SystemConfigResponseDto>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = SystemConfigResponseDto.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of SystemConfigResponseDto-objects as value to a dart map
+  static Map<String, List<SystemConfigResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<SystemConfigResponseDto>>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = SystemConfigResponseDto.listFromJson(entry.value, growable: growable,);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'config',
+  };
+}
+

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

@@ -0,0 +1,135 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class SystemConfigResponseItem {
+  /// Returns a new [SystemConfigResponseItem] instance.
+  SystemConfigResponseItem({
+    required this.name,
+    required this.key,
+    required this.value,
+    required this.defaultValue,
+  });
+
+  String name;
+
+  SystemConfigKey key;
+
+  String value;
+
+  String defaultValue;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is SystemConfigResponseItem &&
+     other.name == name &&
+     other.key == key &&
+     other.value == value &&
+     other.defaultValue == defaultValue;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (name.hashCode) +
+    (key.hashCode) +
+    (value.hashCode) +
+    (defaultValue.hashCode);
+
+  @override
+  String toString() => 'SystemConfigResponseItem[name=$name, key=$key, value=$value, defaultValue=$defaultValue]';
+
+  Map<String, dynamic> toJson() {
+    final _json = <String, dynamic>{};
+      _json[r'name'] = name;
+      _json[r'key'] = key;
+      _json[r'value'] = value;
+      _json[r'defaultValue'] = defaultValue;
+    return _json;
+  }
+
+  /// Returns a new [SystemConfigResponseItem] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static SystemConfigResponseItem? fromJson(dynamic value) {
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      // Ensure that the map contains the required keys.
+      // Note 1: the values aren't checked for validity beyond being non-null.
+      // Note 2: this code is stripped in release mode!
+      assert(() {
+        requiredKeys.forEach((key) {
+          assert(json.containsKey(key), 'Required key "SystemConfigResponseItem[$key]" is missing from JSON.');
+          assert(json[key] != null, 'Required key "SystemConfigResponseItem[$key]" has a null value in JSON.');
+        });
+        return true;
+      }());
+
+      return SystemConfigResponseItem(
+        name: mapValueOfType<String>(json, r'name')!,
+        key: SystemConfigKey.fromJson(json[r'key'])!,
+        value: mapValueOfType<String>(json, r'value')!,
+        defaultValue: mapValueOfType<String>(json, r'defaultValue')!,
+      );
+    }
+    return null;
+  }
+
+  static List<SystemConfigResponseItem>? listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <SystemConfigResponseItem>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = SystemConfigResponseItem.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, SystemConfigResponseItem> mapFromJson(dynamic json) {
+    final map = <String, SystemConfigResponseItem>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = SystemConfigResponseItem.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of SystemConfigResponseItem-objects as value to a dart map
+  static Map<String, List<SystemConfigResponseItem>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<SystemConfigResponseItem>>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = SystemConfigResponseItem.listFromJson(entry.value, growable: growable,);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'name',
+    'key',
+    'value',
+    'defaultValue',
+  };
+}
+

+ 26 - 0
mobile/openapi/test/admin_config_api_test.dart

@@ -0,0 +1,26 @@
+//
+// 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 AdminConfigApi
+void main() {
+  // final instance = AdminConfigApi();
+
+  group('tests for AdminConfigApi', () {
+    //Future<AdminConfigResponseDto> getAdminConfig() async
+    test('test getAdminConfig', () async {
+      // TODO
+    });
+
+  });
+}

+ 27 - 0
mobile/openapi/test/admin_config_response_dto_test.dart

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

+ 31 - 0
mobile/openapi/test/config_api_test.dart

@@ -0,0 +1,31 @@
+//
+// 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 ConfigApi
+void main() {
+  // final instance = ConfigApi();
+
+  group('tests for ConfigApi', () {
+    //Future<SystemConfigResponseDto> getSystemConfig() async
+    test('test getSystemConfig', () async {
+      // TODO
+    });
+
+    //Future<SystemConfigResponseDto> putSystemConfig(Object body) async
+    test('test putSystemConfig', () async {
+      // TODO
+    });
+
+  });
+}

+ 31 - 0
mobile/openapi/test/system_config_api_test.dart

@@ -0,0 +1,31 @@
+//
+// 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 SystemConfigApi
+void main() {
+  // final instance = SystemConfigApi();
+
+  group('tests for SystemConfigApi', () {
+    //Future<SystemConfigResponseDto> getConfig() async
+    test('test getConfig', () async {
+      // TODO
+    });
+
+    //Future<SystemConfigResponseDto> updateConfig(Object body) async
+    test('test updateConfig', () async {
+      // TODO
+    });
+
+  });
+}

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

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

+ 21 - 0
mobile/openapi/test/system_config_key_test.dart

@@ -0,0 +1,21 @@
+//
+// 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 SystemConfigKey
+void main() {
+
+  group('test SystemConfigKey', () {
+
+  });
+
+}

+ 27 - 0
mobile/openapi/test/system_config_response_dto_test.dart

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

+ 37 - 0
mobile/openapi/test/system_config_response_item_test.dart

@@ -0,0 +1,37 @@
+//
+// 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 SystemConfigResponseItem
+void main() {
+  // final instance = SystemConfigResponseItem();
+
+  group('test SystemConfigResponseItem', () {
+    // String name
+    test('to test the property `name`', () async {
+      // TODO
+    });
+
+    // String key
+    test('to test the property `key`', () async {
+      // TODO
+    });
+
+    // Object value
+    test('to test the property `value`', () async {
+      // TODO
+    });
+
+
+  });
+
+}

+ 6 - 1
server/README.md

@@ -1 +1,6 @@
-# Immich Server- NestJs
+## How to run migration
+
+1. Attached to the container shell
+2. Run `npm run typeorm -- migration:generate ./libs/database/src/<migration-name> -d libs/database/src/config/database.config.ts`
+3. Check if the migration file makes sense
+4. Move the migration file to folder `server/libs/database/src/migrations` in your code editor.

+ 20 - 0
server/apps/immich/src/api-v1/system-config/dto/update-system-config.ts

@@ -0,0 +1,20 @@
+import { SystemConfigKey, SystemConfigValue } from '@app/database/entities/system-config.entity';
+import { ApiProperty } from '@nestjs/swagger';
+import { IsEnum, IsNotEmpty, ValidateNested } from 'class-validator';
+
+export class UpdateSystemConfigDto {
+  @IsNotEmpty()
+  @ValidateNested({ each: true })
+  config!: SystemConfigItem[];
+}
+
+export class SystemConfigItem {
+  @IsNotEmpty()
+  @IsEnum(SystemConfigKey)
+  @ApiProperty({
+    enum: SystemConfigKey,
+    enumName: 'SystemConfigKey',
+  })
+  key!: SystemConfigKey;
+  value!: SystemConfigValue;
+}

+ 20 - 0
server/apps/immich/src/api-v1/system-config/response-dto/system-config-response.dto.ts

@@ -0,0 +1,20 @@
+import { SystemConfigKey, SystemConfigValue } from '@app/database/entities/system-config.entity';
+import { ApiProperty } from '@nestjs/swagger';
+
+export class SystemConfigResponseDto {
+  config!: SystemConfigResponseItem[];
+}
+
+export class SystemConfigResponseItem {
+  @ApiProperty({ type: 'string' })
+  name!: string;
+
+  @ApiProperty({ enumName: 'SystemConfigKey', enum: SystemConfigKey })
+  key!: SystemConfigKey;
+
+  @ApiProperty({ type: 'string' })
+  value!: SystemConfigValue;
+
+  @ApiProperty({ type: 'string' })
+  defaultValue!: SystemConfigValue;
+}

+ 24 - 0
server/apps/immich/src/api-v1/system-config/system-config.controller.ts

@@ -0,0 +1,24 @@
+import { Body, Controller, Get, Put, ValidationPipe } from '@nestjs/common';
+import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
+import { Authenticated } from '../../decorators/authenticated.decorator';
+import { UpdateSystemConfigDto } from './dto/update-system-config';
+import { SystemConfigResponseDto } from './response-dto/system-config-response.dto';
+import { SystemConfigService } from './system-config.service';
+
+@ApiTags('System Config')
+@ApiBearerAuth()
+@Authenticated({ admin: true })
+@Controller('system-config')
+export class SystemConfigController {
+  constructor(private readonly systemConfigService: SystemConfigService) {}
+
+  @Get()
+  getConfig(): Promise<SystemConfigResponseDto> {
+    return this.systemConfigService.getConfig();
+  }
+
+  @Put()
+  async updateConfig(@Body(ValidationPipe) dto: UpdateSystemConfigDto): Promise<SystemConfigResponseDto> {
+    return this.systemConfigService.updateConfig(dto);
+  }
+}

+ 14 - 0
server/apps/immich/src/api-v1/system-config/system-config.module.ts

@@ -0,0 +1,14 @@
+import { SystemConfigEntity } from '@app/database/entities/system-config.entity';
+import { Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { ImmichConfigModule } from 'libs/immich-config/src';
+import { ImmichJwtModule } from '../../modules/immich-jwt/immich-jwt.module';
+import { SystemConfigController } from './system-config.controller';
+import { SystemConfigService } from './system-config.service';
+
+@Module({
+  imports: [ImmichJwtModule, ImmichConfigModule, TypeOrmModule.forFeature([SystemConfigEntity])],
+  controllers: [SystemConfigController],
+  providers: [SystemConfigService],
+})
+export class SystemConfigModule {}

+ 20 - 0
server/apps/immich/src/api-v1/system-config/system-config.service.ts

@@ -0,0 +1,20 @@
+import { Injectable } from '@nestjs/common';
+import { ImmichConfigService } from 'libs/immich-config/src';
+import { UpdateSystemConfigDto } from './dto/update-system-config';
+import { SystemConfigResponseDto } from './response-dto/system-config-response.dto';
+
+@Injectable()
+export class SystemConfigService {
+  constructor(private immichConfigService: ImmichConfigService) {}
+
+  async getConfig(): Promise<SystemConfigResponseDto> {
+    const config = await this.immichConfigService.getSystemConfig();
+    return { config };
+  }
+
+  async updateConfig(dto: UpdateSystemConfigDto): Promise<SystemConfigResponseDto> {
+    await this.immichConfigService.updateSystemConfig(dto.config);
+    const config = await this.immichConfigService.getSystemConfig();
+    return { config };
+  }
+}

+ 3 - 0
server/apps/immich/src/app.module.ts

@@ -16,6 +16,7 @@ import { ScheduleModule } from '@nestjs/schedule';
 import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.module';
 import { DatabaseModule } from '@app/database';
 import { JobModule } from './api-v1/job/job.module';
+import { SystemConfigModule } from './api-v1/system-config/system-config.module';
 import { OAuthModule } from './api-v1/oauth/oauth.module';
 
 @Module({
@@ -60,6 +61,8 @@ import { OAuthModule } from './api-v1/oauth/oauth.module';
     ScheduleTasksModule,
 
     JobModule,
+
+    SystemConfigModule,
   ],
   controllers: [AppController],
   providers: [],

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

@@ -7,8 +7,9 @@ import { UserEntity } from '@app/database/entities/user.entity';
 import { QueueNameEnum } from '@app/job/constants/queue-name.constant';
 import { BullModule } from '@nestjs/bull';
 import { Module } from '@nestjs/common';
-import { ConfigModule, ConfigService } from '@nestjs/config';
+import { ConfigModule } from '@nestjs/config';
 import { TypeOrmModule } from '@nestjs/typeorm';
+import { ImmichConfigModule } from 'libs/immich-config/src';
 import { CommunicationModule } from '../../immich/src/api-v1/communication/communication.module';
 import { MicroservicesService } from './microservices.service';
 import { AssetUploadedProcessor } from './processors/asset-uploaded.processor';
@@ -22,6 +23,7 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
   imports: [
     ConfigModule.forRoot(immichAppConfig),
     DatabaseModule,
+    ImmichConfigModule,
     TypeOrmModule.forFeature([UserEntity, ExifEntity, AssetEntity, SmartInfoEntity]),
     BullModule.forRootAsync({
       useFactory: async () => ({
@@ -96,7 +98,6 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
     VideoTranscodeProcessor,
     GenerateChecksumProcessor,
     MachineLearningProcessor,
-    ConfigService,
   ],
   exports: [],
 })

+ 11 - 1
server/apps/microservices/src/processors/video-transcode.processor.ts

@@ -9,6 +9,7 @@ import { InjectRepository } from '@nestjs/typeorm';
 import { Job } from 'bull';
 import ffmpeg from 'fluent-ffmpeg';
 import { existsSync, mkdirSync } from 'fs';
+import { ImmichConfigService } from 'libs/immich-config/src';
 import { Repository } from 'typeorm';
 
 @Processor(QueueNameEnum.VIDEO_CONVERSION)
@@ -16,6 +17,7 @@ export class VideoTranscodeProcessor {
   constructor(
     @InjectRepository(AssetEntity)
     private assetRepository: Repository<AssetEntity>,
+    private immichConfigService: ImmichConfigService,
   ) {}
 
   @Process({ name: mp4ConversionProcessorName, concurrency: 1 })
@@ -40,9 +42,17 @@ export class VideoTranscodeProcessor {
   }
 
   async runFFMPEGPipeLine(asset: AssetEntity, savedEncodedPath: string): Promise<void> {
+    const config = await this.immichConfigService.getSystemConfigMap();
+
     return new Promise((resolve, reject) => {
       ffmpeg(asset.originalPath)
-        .outputOptions(['-crf 23', '-preset ultrafast', '-vcodec libx264', '-acodec mp3', '-vf scale=1280:-2'])
+        .outputOptions([
+          `-crf ${config.ffmpeg_crf}`,
+          `-preset ${config.ffmpeg_preset}`,
+          `-vcodec ${config.ffmpeg_target_video_codec}`,
+          `-acodec ${config.ffmpeg_target_audio_codec}`,
+          `-vf scale=${config.ffmpeg_target_scaling}`,
+        ])
         .output(savedEncodedPath)
         .on('start', () => {
           Logger.log('Start Converting Video', 'mp4Conversion');

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
server/immich-openapi-specs.json


+ 27 - 0
server/libs/database/src/entities/system-config.entity.ts

@@ -0,0 +1,27 @@
+import { Column, Entity, PrimaryColumn } from 'typeorm';
+
+@Entity('system_config')
+export class SystemConfigEntity {
+  @PrimaryColumn()
+  key!: SystemConfigKey;
+
+  @Column({ type: 'varchar', nullable: true })
+  value!: SystemConfigValue;
+}
+
+export type SystemConfig = SystemConfigEntity[];
+
+export enum SystemConfigKey {
+  FFMPEG_CRF = 'ffmpeg_crf',
+  FFMPEG_PRESET = 'ffmpeg_preset',
+  FFMPEG_TARGET_VIDEO_CODEC = 'ffmpeg_target_video_codec',
+  FFMPEG_TARGET_AUDIO_CODEC = 'ffmpeg_target_audio_codec',
+  FFMPEG_TARGET_SCALING = 'ffmpeg_target_scaling',
+}
+
+export type SystemConfigValue = string | null;
+
+export interface SystemConfigItem {
+  key: SystemConfigKey;
+  value: SystemConfigValue;
+}

+ 15 - 0
server/libs/database/src/migrations/1665540663419-CreateSystemConfigTable.ts

@@ -0,0 +1,15 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class CreateSystemConfigTable1665540663419 implements MigrationInterface {
+  name = 'CreateSystemConfigTable1665540663419';
+
+  public async up(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query(
+      `CREATE TABLE "system_config" ("key" character varying NOT NULL, "value" character varying, CONSTRAINT "PK_aab69295b445016f56731f4d535" PRIMARY KEY ("key"))`,
+    );
+  }
+
+  public async down(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query(`DROP TABLE "system_config"`);
+  }
+}

+ 11 - 0
server/libs/immich-config/src/immich-config.module.ts

@@ -0,0 +1,11 @@
+import { SystemConfigEntity } from '@app/database/entities/system-config.entity';
+import { Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { ImmichConfigService } from './immich-config.service';
+
+@Module({
+  imports: [TypeOrmModule.forFeature([SystemConfigEntity])],
+  providers: [ImmichConfigService],
+  exports: [ImmichConfigService],
+})
+export class ImmichConfigModule {}

+ 97 - 0
server/libs/immich-config/src/immich-config.service.ts

@@ -0,0 +1,97 @@
+import { SystemConfigEntity, SystemConfigKey, SystemConfigValue } from '@app/database/entities/system-config.entity';
+import { Injectable } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { In, Repository } from 'typeorm';
+
+type SystemConfigMap = Record<SystemConfigKey, SystemConfigValue>;
+
+const configDefaults: Record<SystemConfigKey, { name: string; value: SystemConfigValue }> = {
+  [SystemConfigKey.FFMPEG_CRF]: {
+    name: 'FFmpeg Constant Rate Factor (-crf)',
+    value: '23',
+  },
+  [SystemConfigKey.FFMPEG_PRESET]: {
+    name: 'FFmpeg preset (-preset)',
+    value: 'ultrafast',
+  },
+  [SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC]: {
+    name: 'FFmpeg target video codec (-vcodec)',
+    value: 'libx264',
+  },
+  [SystemConfigKey.FFMPEG_TARGET_AUDIO_CODEC]: {
+    name: 'FFmpeg target audio codec (-acodec)',
+    value: 'mp3',
+  },
+  [SystemConfigKey.FFMPEG_TARGET_SCALING]: {
+    name: 'FFmpeg target scaling (-vf scale=)',
+    value: '1280:-2',
+  },
+};
+
+@Injectable()
+export class ImmichConfigService {
+  constructor(
+    @InjectRepository(SystemConfigEntity)
+    private systemConfigRepository: Repository<SystemConfigEntity>,
+  ) {}
+
+  public async getSystemConfig() {
+    const items = this._getDefaults();
+
+    // override default values
+    const overrides = await this.systemConfigRepository.find();
+    for (const override of overrides) {
+      const item = items.find((_item) => _item.key === override.key);
+      if (item) {
+        item.value = override.value;
+      }
+    }
+
+    return items;
+  }
+
+  public async getSystemConfigMap(): Promise<SystemConfigMap> {
+    const items = await this.getSystemConfig();
+    const map: Partial<SystemConfigMap> = {};
+
+    for (const { key, value } of items) {
+      map[key] = value;
+    }
+
+    return map as SystemConfigMap;
+  }
+
+  public async updateSystemConfig(items: SystemConfigEntity[]): Promise<void> {
+    const deletes: SystemConfigEntity[] = [];
+    const updates: SystemConfigEntity[] = [];
+
+    for (const item of items) {
+      if (item.value === null || item.value === this._getDefaultValue(item.key)) {
+        deletes.push(item);
+        continue;
+      }
+
+      updates.push(item);
+    }
+
+    if (updates.length > 0) {
+      await this.systemConfigRepository.save(updates);
+    }
+
+    if (deletes.length > 0) {
+      await this.systemConfigRepository.delete({ key: In(deletes.map((item) => item.key)) });
+    }
+  }
+
+  private _getDefaults() {
+    return Object.values(SystemConfigKey).map((key) => ({
+      key,
+      defaultValue: configDefaults[key].value,
+      ...configDefaults[key],
+    }));
+  }
+
+  private _getDefaultValue(key: SystemConfigKey) {
+    return this._getDefaults().find((item) => item.key === key)?.value || null;
+  }
+}

+ 2 - 0
server/libs/immich-config/src/index.ts

@@ -0,0 +1,2 @@
+export * from './immich-config.module';
+export * from './immich-config.service';

+ 9 - 0
server/libs/immich-config/tsconfig.lib.json

@@ -0,0 +1,9 @@
+{
+  "extends": "../../tsconfig.json",
+  "compilerOptions": {
+    "declaration": true,
+    "outDir": "../../dist/libs/immich-config"
+  },
+  "include": ["src/**/*"],
+  "exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
+}

+ 10 - 1
server/nest-cli.json

@@ -70,6 +70,15 @@
       "compilerOptions": {
         "tsConfigPath": "libs/job/tsconfig.lib.json"
       }
+    },
+    "system-config": {
+      "type": "library",
+      "root": "libs/system-config",
+      "entryFile": "index",
+      "sourceRoot": "libs/system-config/src",
+      "compilerOptions": {
+        "tsConfigPath": "libs/system-config/tsconfig.lib.json"
+      }
     }
   }
-}
+}

+ 4 - 2
server/package.json

@@ -13,6 +13,7 @@
     "build": "nest build immich && nest build microservices && nest build cli",
     "format": "prettier --write \"apps/**/*.ts\" \"libs/**/*.ts\"",
     "start": "nest start",
+    "nest": "nest",
     "start:dev": "nest start --watch",
     "start:debug": "nest start --debug 0.0.0.0:9230 --watch",
     "start:prod": "node dist/main",
@@ -139,7 +140,8 @@
       "@app/database/config/(.*)": "<rootDir>/libs/database/src/config/$1",
       "@app/database/config": "<rootDir>/libs/database/src/config",
       "@app/common": "<rootDir>/libs/common/src",
-      "^@app/job(|/.*)$": "<rootDir>/libs/job/src/$1"
+      "^@app/job(|/.*)$": "<rootDir>/libs/job/src/$1",
+      "^@app/system-config(|/.*)$": "<rootDir>/libs/system-config/src/$1"
     }
   }
-}
+}

+ 10 - 24
server/tsconfig.json

@@ -16,29 +16,15 @@
     "esModuleInterop": true,
     "baseUrl": "./",
     "paths": {
-      "@app/common": [
-        "libs/common/src"
-      ],
-      "@app/common/*": [
-        "libs/common/src/*"
-      ],
-      "@app/database": [
-        "libs/database/src"
-      ],
-      "@app/database/*": [
-        "libs/database/src/*"
-      ],
-      "@app/job": [
-        "libs/job/src"
-      ],
-      "@app/job/*": [
-        "libs/job/src/*"
-      ]
+      "@app/common": ["libs/common/src"],
+      "@app/common/*": ["libs/common/src/*"],
+      "@app/database": ["libs/database/src"],
+      "@app/database/*": ["libs/database/src/*"],
+      "@app/job": ["libs/job/src"],
+      "@app/job/*": ["libs/job/src/*"],
+      "@app/system-config": ["libs/immich-config/src"],
+      "@app/system-config/*": ["libs/immich-config/src/*"]
     }
   },
-  "exclude": [
-    "dist",
-    "node_modules",
-    "upload"
-  ]
-}
+  "exclude": ["dist", "node_modules", "upload"]
+}

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

@@ -8,6 +8,7 @@ import {
 	JobApi,
 	OAuthApi,
 	ServerInfoApi,
+	SystemConfigApi,
 	UserApi
 } from './open-api';
 
@@ -20,6 +21,7 @@ class ImmichApi {
 	public deviceInfoApi: DeviceInfoApi;
 	public serverInfoApi: ServerInfoApi;
 	public jobApi: JobApi;
+	public systemConfigApi: SystemConfigApi;
 
 	private config = new Configuration({ basePath: '/api' });
 
@@ -32,6 +34,7 @@ class ImmichApi {
 		this.deviceInfoApi = new DeviceInfoApi(this.config);
 		this.serverInfoApi = new ServerInfoApi(this.config);
 		this.jobApi = new JobApi(this.config);
+		this.systemConfigApi = new SystemConfigApi(this.config);
 	}
 
 	public setAccessToken(accessToken: string) {

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

@@ -1407,6 +1407,67 @@ export interface SmartInfoResponseDto {
  * @enum {string}
  */
 
+export const SystemConfigKey = {
+    Crf: 'ffmpeg_crf',
+    Preset: 'ffmpeg_preset',
+    TargetVideoCodec: 'ffmpeg_target_video_codec',
+    TargetAudioCodec: 'ffmpeg_target_audio_codec',
+    TargetScaling: 'ffmpeg_target_scaling'
+} as const;
+
+export type SystemConfigKey = typeof SystemConfigKey[keyof typeof SystemConfigKey];
+
+
+/**
+ * 
+ * @export
+ * @interface SystemConfigResponseDto
+ */
+export interface SystemConfigResponseDto {
+    /**
+     * 
+     * @type {Array<SystemConfigResponseItem>}
+     * @memberof SystemConfigResponseDto
+     */
+    'config': Array<SystemConfigResponseItem>;
+}
+/**
+ * 
+ * @export
+ * @interface SystemConfigResponseItem
+ */
+export interface SystemConfigResponseItem {
+    /**
+     * 
+     * @type {string}
+     * @memberof SystemConfigResponseItem
+     */
+    'name': string;
+    /**
+     * 
+     * @type {SystemConfigKey}
+     * @memberof SystemConfigResponseItem
+     */
+    'key': SystemConfigKey;
+    /**
+     * 
+     * @type {string}
+     * @memberof SystemConfigResponseItem
+     */
+    'value': string;
+    /**
+     * 
+     * @type {string}
+     * @memberof SystemConfigResponseItem
+     */
+    'defaultValue': string;
+}
+/**
+ * 
+ * @export
+ * @enum {string}
+ */
+
 export const ThumbnailFormat = {
     Jpeg: 'JPEG',
     Webp: 'WEBP'
@@ -4946,6 +5007,173 @@ export class ServerInfoApi extends BaseAPI {
 }
 
 
+/**
+ * SystemConfigApi - axios parameter creator
+ * @export
+ */
+export const SystemConfigApiAxiosParamCreator = function (configuration?: Configuration) {
+    return {
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        getConfig: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            const localVarPath = `/system-config`;
+            // use dummy base URL string because the URL constructor only accepts absolute URLs.
+            const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
+            let baseOptions;
+            if (configuration) {
+                baseOptions = configuration.baseOptions;
+            }
+
+            const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
+            const localVarHeaderParameter = {} as any;
+            const localVarQueryParameter = {} as any;
+
+            // authentication bearer required
+            // http bearer authentication required
+            await setBearerAuthToObject(localVarHeaderParameter, configuration)
+
+
+    
+            setSearchParams(localVarUrlObj, localVarQueryParameter);
+            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
+        /**
+         * 
+         * @param {object} body 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        updateConfig: async (body: object, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            // verify required parameter 'body' is not null or undefined
+            assertParamExists('updateConfig', 'body', body)
+            const localVarPath = `/system-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: 'PUT', ...baseOptions, ...options};
+            const localVarHeaderParameter = {} as any;
+            const localVarQueryParameter = {} as any;
+
+            // authentication bearer required
+            // http bearer authentication required
+            await setBearerAuthToObject(localVarHeaderParameter, configuration)
+
+
+    
+            localVarHeaderParameter['Content-Type'] = 'application/json';
+
+            setSearchParams(localVarUrlObj, localVarQueryParameter);
+            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+            localVarRequestOptions.data = serializeDataIfNeeded(body, localVarRequestOptions, configuration)
+
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
+    }
+};
+
+/**
+ * SystemConfigApi - functional programming interface
+ * @export
+ */
+export const SystemConfigApiFp = function(configuration?: Configuration) {
+    const localVarAxiosParamCreator = SystemConfigApiAxiosParamCreator(configuration)
+    return {
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async getConfig(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SystemConfigResponseDto>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.getConfig(options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
+        /**
+         * 
+         * @param {object} body 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async updateConfig(body: object, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SystemConfigResponseDto>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.updateConfig(body, options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
+    }
+};
+
+/**
+ * SystemConfigApi - factory interface
+ * @export
+ */
+export const SystemConfigApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
+    const localVarFp = SystemConfigApiFp(configuration)
+    return {
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        getConfig(options?: any): AxiosPromise<SystemConfigResponseDto> {
+            return localVarFp.getConfig(options).then((request) => request(axios, basePath));
+        },
+        /**
+         * 
+         * @param {object} body 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        updateConfig(body: object, options?: any): AxiosPromise<SystemConfigResponseDto> {
+            return localVarFp.updateConfig(body, options).then((request) => request(axios, basePath));
+        },
+    };
+};
+
+/**
+ * SystemConfigApi - object-oriented interface
+ * @export
+ * @class SystemConfigApi
+ * @extends {BaseAPI}
+ */
+export class SystemConfigApi extends BaseAPI {
+    /**
+     * 
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof SystemConfigApi
+     */
+    public getConfig(options?: AxiosRequestConfig) {
+        return SystemConfigApiFp(this.configuration).getConfig(options).then((request) => request(this.axios, this.basePath));
+    }
+
+    /**
+     * 
+     * @param {object} body 
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof SystemConfigApi
+     */
+    public updateConfig(body: object, options?: AxiosRequestConfig) {
+        return SystemConfigApiFp(this.configuration).updateConfig(body, options).then((request) => request(this.axios, this.basePath));
+    }
+}
+
+
 /**
  * UserApi - axios parameter creator
  * @export

+ 1 - 1
web/src/app.css

@@ -59,7 +59,7 @@ input:focus-visible {
 
 @layer utilities {
 	.immich-form-input {
-		@apply bg-slate-100 p-2 rounded-md dark:text-immich-dark-bg focus:border-immich-primary text-sm;
+		@apply bg-slate-100 p-2 rounded-md dark:text-immich-dark-bg focus:border-immich-primary text-sm dark:bg-gray-600 dark:text-immich-dark-fg;
 	}
 
 	.immich-form-label {

+ 97 - 0
web/src/lib/components/admin-page/settings/settings-panel.svelte

@@ -0,0 +1,97 @@
+<script lang="ts">
+	import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
+	import {
+		notificationController,
+		NotificationType
+	} from '$lib/components/shared-components/notification/notification';
+	import { api, SystemConfigResponseItem } from '@api';
+	import { onMount } from 'svelte';
+
+	let isSaving = false;
+	let items: Array<SystemConfigResponseItem & { originalValue: string }> = [];
+
+	const refreshConfig = async () => {
+		const { data: systemConfig } = await api.systemConfigApi.getConfig();
+		items = systemConfig.config.map((item) => ({ ...item, originalValue: item.value }));
+	};
+
+	onMount(() => refreshConfig());
+
+	const handleSave = async () => {
+		try {
+			isSaving = true;
+			const updates = items
+				.filter((item) => item.value !== item.originalValue)
+				.map(({ key, value }) => ({ key, value: value || null }));
+			if (updates.length > 0) {
+				await api.systemConfigApi.updateConfig({ config: updates });
+				refreshConfig();
+			}
+
+			notificationController.show({
+				message: `Saved settings`,
+				type: NotificationType.Info
+			});
+		} catch (e) {
+			console.error('Error [updateSystemConfig]', e);
+			notificationController.show({
+				message: `Unable to save changes.`,
+				type: NotificationType.Error
+			});
+		} finally {
+			isSaving = false;
+		}
+	};
+</script>
+
+<section>
+	<table class="text-left my-4 w-full">
+		<thead
+			class="border rounded-md mb-4 bg-gray-50 flex text-immich-primary h-12 dark:bg-immich-dark-gray dark:text-immich-dark-primary dark:border-immich-dark-gray"
+		>
+			<tr class="flex w-full place-items-center">
+				<th class="text-center w-1/2 font-medium text-sm">Setting</th>
+				<th class="text-center w-1/2 font-medium text-sm">Value</th>
+			</tr>
+		</thead>
+		<tbody class="rounded-md block border dark:border-immich-dark-gray">
+			{#each items as item, i}
+				<tr
+					class={`text-center flex place-items-center w-full h-[80px] dark:text-immich-dark-fg ${
+						i % 2 == 0 ? 'bg-slate-50 dark:bg-[#181818]' : 'bg-immich-bg dark:bg-immich-dark-bg'
+					}`}
+				>
+					<td class="text-sm px-4 w-1/2 text-ellipsis">
+						{item.name}
+					</td>
+					<td class="text-sm px-4 w-1/2 text-ellipsis">
+						<input
+							style="text-align: center"
+							class="immich-form-input"
+							id={item.key}
+							disabled={isSaving}
+							name={item.key}
+							type="text"
+							bind:value={item.value}
+							placeholder={item.defaultValue + ''}
+						/>
+					</td>
+				</tr>
+			{/each}
+		</tbody>
+	</table>
+
+	<div class="flex justify-end">
+		<button
+			on:click={handleSave}
+			class="px-6 py-3 text-sm bg-immich-primary dark:bg-immich-dark-primary font-medium rounded-2xl hover:bg-immich-primary/50 transition-all hover:cursor-pointer disabled:cursor-not-allowed shadow-sm text-immich-bg dark:text-immich-dark-gray"
+			disabled={isSaving}
+		>
+			{#if isSaving}
+				<LoadingSpinner />
+			{:else}
+				Save
+			{/if}
+		</button>
+	</div>
+</section>

+ 2 - 4
web/src/routes/admin/+page.server.ts

@@ -12,8 +12,6 @@ export const load: PageServerLoad = async ({ parent }) => {
 	}
 
 	const { data: allUsers } = await serverApi.userApi.getAllUsers(false);
-	return {
-		user: user,
-		allUsers: allUsers
-	};
+
+	return { user, allUsers };
 };

+ 13 - 1
web/src/routes/admin/+page.svelte

@@ -4,6 +4,7 @@
 	import { AdminSideBarSelection } from '$lib/models/admin-sidebar-selection';
 	import SideBarButton from '$lib/components/shared-components/side-bar/side-bar-button.svelte';
 	import AccountMultipleOutline from 'svelte-material-icons/AccountMultipleOutline.svelte';
+	import Sync from 'svelte-material-icons/Sync.svelte';
 	import Cog from 'svelte-material-icons/Cog.svelte';
 	import Server from 'svelte-material-icons/Server.svelte';
 	import NavigationBar from '$lib/components/shared-components/navigation-bar.svelte';
@@ -16,6 +17,7 @@
 	import type { PageData } from './$types';
 	import { api, ServerStatsResponseDto, UserResponseDto } from '@api';
 	import JobsPanel from '$lib/components/admin-page/jobs/jobs-panel.svelte';
+	import SettingsPanel from '$lib/components/admin-page/settings/settings-panel.svelte';
 	import ServerStatsPanel from '$lib/components/admin-page/server-stats/server-stats-panel.svelte';
 	import RestoreDialoge from '$lib/components/admin-page/restore-dialoge.svelte';
 
@@ -190,11 +192,18 @@
 		/>
 		<SideBarButton
 			title="Jobs"
-			logo={Cog}
+			logo={Sync}
 			actionType={AdminSideBarSelection.JOBS}
 			isSelected={selectedAction === AdminSideBarSelection.JOBS}
 			on:selected={onButtonClicked}
 		/>
+		<SideBarButton
+			title="Settings"
+			logo={Cog}
+			actionType={AdminSideBarSelection.SETTINGS}
+			isSelected={selectedAction === AdminSideBarSelection.SETTINGS}
+			on:selected={onButtonClicked}
+		/>
 		<SideBarButton
 			title="Server Stats"
 			logo={Server}
@@ -228,6 +237,9 @@
 				{#if selectedAction === AdminSideBarSelection.JOBS}
 					<JobsPanel />
 				{/if}
+				{#if selectedAction === AdminSideBarSelection.SETTINGS}
+					<SettingsPanel />
+				{/if}
 				{#if selectedAction === AdminSideBarSelection.STATS && serverStat}
 					<ServerStatsPanel stats={serverStat} allUsers={data.allUsers} />
 				{/if}

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio