浏览代码

feat(server) user-defined storage structure (#1098)

[Breaking] newly uploaded file will conform to the default structure of `{uploadLocation}/{userId}/year/year-month-day/filename.ext`
Alex 2 年之前
父节点
当前提交
c754c860fd
共有 59 个文件被更改,包括 1900 次插入181 次删除
  1. 6 0
      mobile/openapi/.openapi-generator/FILES
  2. 4 1
      mobile/openapi/README.md
  3. 44 0
      mobile/openapi/doc/SystemConfigApi.md
  4. 1 0
      mobile/openapi/doc/SystemConfigDto.md
  5. 15 0
      mobile/openapi/doc/SystemConfigStorageTemplateDto.md
  6. 21 0
      mobile/openapi/doc/SystemConfigTemplateStorageOptionDto.md
  7. 2 0
      mobile/openapi/lib/api.dart
  8. 41 0
      mobile/openapi/lib/api/system_config_api.dart
  9. 4 0
      mobile/openapi/lib/api_client.dart
  10. 11 3
      mobile/openapi/lib/model/system_config_dto.dart
  11. 111 0
      mobile/openapi/lib/model/system_config_storage_template_dto.dart
  12. 173 0
      mobile/openapi/lib/model/system_config_template_storage_option_dto.dart
  13. 5 0
      mobile/openapi/test/system_config_api_test.dart
  14. 5 0
      mobile/openapi/test/system_config_dto_test.dart
  15. 27 0
      mobile/openapi/test/system_config_storage_template_dto_test.dart
  16. 57 0
      mobile/openapi/test/system_config_template_storage_option_dto_test.dart
  17. 10 0
      notes.md
  18. 2 0
      server/apps/immich/src/api-v1/asset/asset.module.ts
  19. 4 1
      server/apps/immich/src/api-v1/asset/asset.service.spec.ts
  20. 10 3
      server/apps/immich/src/api-v1/asset/asset.service.ts
  21. 7 0
      server/apps/immich/src/api-v1/system-config/dto/system-config-storage-template.dto.ts
  22. 4 0
      server/apps/immich/src/api-v1/system-config/dto/system-config.dto.ts
  23. 9 0
      server/apps/immich/src/api-v1/system-config/response-dto/system-config-template-storage-option.dto.ts
  24. 6 0
      server/apps/immich/src/api-v1/system-config/system-config.controller.ts
  25. 26 2
      server/apps/immich/src/api-v1/system-config/system-config.service.ts
  26. 9 9
      server/apps/immich/src/api-v1/user/user.service.spec.ts
  27. 2 3
      server/apps/microservices/src/processors/thumbnail.processor.ts
  28. 99 2
      server/immich-openapi-specs.json
  29. 4 0
      server/libs/database/src/entities/system-config.entity.ts
  30. 16 3
      server/libs/immich-config/src/immich-config.module.ts
  31. 33 2
      server/libs/immich-config/src/immich-config.service.ts
  32. 20 0
      server/libs/storage/src/constants/supported-datetime-template.ts
  33. 2 0
      server/libs/storage/src/index.ts
  34. 6 0
      server/libs/storage/src/interfaces/immich-storage.interface.ts
  35. 13 0
      server/libs/storage/src/storage.module.ts
  36. 153 0
      server/libs/storage/src/storage.service.ts
  37. 9 0
      server/libs/storage/tsconfig.lib.json
  38. 10 1
      server/nest-cli.json
  39. 176 4
      server/package-lock.json
  40. 5 1
      server/package.json
  41. 36 10
      server/tsconfig.json
  42. 100 6
      web/package-lock.json
  43. 3 0
      web/package.json
  44. 129 1
      web/src/api/open-api/api.ts
  45. 1 1
      web/src/api/open-api/base.ts
  46. 1 1
      web/src/api/open-api/common.ts
  47. 1 1
      web/src/api/open-api/configuration.ts
  48. 1 1
      web/src/api/open-api/index.ts
  49. 2 2
      web/src/app.css
  50. 58 54
      web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte
  51. 59 56
      web/src/lib/components/admin-page/settings/oauth/oauth-settings.svelte
  52. 3 3
      web/src/lib/components/admin-page/settings/setting-buttons-row.svelte
  53. 7 7
      web/src/lib/components/admin-page/settings/setting-input-field.svelte
  54. 1 1
      web/src/lib/components/admin-page/settings/setting-switch.svelte
  55. 227 0
      web/src/lib/components/admin-page/settings/storate-template/storage-template-settings.svelte
  56. 78 0
      web/src/lib/components/admin-page/settings/storate-template/supported-datetime-panel.svelte
  57. 21 0
      web/src/lib/components/admin-page/settings/storate-template/supported-variables-panel.svelte
  58. 10 1
      web/src/routes/admin/settings/+page.svelte
  59. 0 1
      web/src/routes/admin/user-management/+page.svelte

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

@@ -64,6 +64,8 @@ doc/SystemConfigApi.md
 doc/SystemConfigDto.md
 doc/SystemConfigFFmpegDto.md
 doc/SystemConfigOAuthDto.md
+doc/SystemConfigStorageTemplateDto.md
+doc/SystemConfigTemplateStorageOptionDto.md
 doc/TagApi.md
 doc/TagResponseDto.md
 doc/TagTypeEnum.md
@@ -152,6 +154,8 @@ lib/model/smart_info_response_dto.dart
 lib/model/system_config_dto.dart
 lib/model/system_config_f_fmpeg_dto.dart
 lib/model/system_config_o_auth_dto.dart
+lib/model/system_config_storage_template_dto.dart
+lib/model/system_config_template_storage_option_dto.dart
 lib/model/tag_response_dto.dart
 lib/model/tag_type_enum.dart
 lib/model/thumbnail_format.dart
@@ -227,6 +231,8 @@ test/system_config_api_test.dart
 test/system_config_dto_test.dart
 test/system_config_f_fmpeg_dto_test.dart
 test/system_config_o_auth_dto_test.dart
+test/system_config_storage_template_dto_test.dart
+test/system_config_template_storage_option_dto_test.dart
 test/tag_api_test.dart
 test/tag_response_dto_test.dart
 test/tag_type_enum_test.dart

+ 4 - 1
mobile/openapi/README.md

@@ -3,7 +3,7 @@ Immich API
 
 This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
 
-- API version: 1.38.0
+- API version: 1.38.2
 - Build package: org.openapitools.codegen.languages.DartClientCodegen
 
 ## Requirements
@@ -113,6 +113,7 @@ Class | Method | HTTP request | Description
 *ServerInfoApi* | [**pingServer**](doc//ServerInfoApi.md#pingserver) | **GET** /server-info/ping | 
 *SystemConfigApi* | [**getConfig**](doc//SystemConfigApi.md#getconfig) | **GET** /system-config | 
 *SystemConfigApi* | [**getDefaults**](doc//SystemConfigApi.md#getdefaults) | **GET** /system-config/defaults | 
+*SystemConfigApi* | [**getStorageTemplateOptions**](doc//SystemConfigApi.md#getstoragetemplateoptions) | **GET** /system-config/storage-template-options | 
 *SystemConfigApi* | [**updateConfig**](doc//SystemConfigApi.md#updateconfig) | **PUT** /system-config | 
 *TagApi* | [**create**](doc//TagApi.md#create) | **POST** /tag | 
 *TagApi* | [**delete**](doc//TagApi.md#delete) | **DELETE** /tag/{id} | 
@@ -186,6 +187,8 @@ Class | Method | HTTP request | Description
  - [SystemConfigDto](doc//SystemConfigDto.md)
  - [SystemConfigFFmpegDto](doc//SystemConfigFFmpegDto.md)
  - [SystemConfigOAuthDto](doc//SystemConfigOAuthDto.md)
+ - [SystemConfigStorageTemplateDto](doc//SystemConfigStorageTemplateDto.md)
+ - [SystemConfigTemplateStorageOptionDto](doc//SystemConfigTemplateStorageOptionDto.md)
  - [TagResponseDto](doc//TagResponseDto.md)
  - [TagTypeEnum](doc//TagTypeEnum.md)
  - [ThumbnailFormat](doc//ThumbnailFormat.md)

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

@@ -11,6 +11,7 @@ Method | HTTP request | Description
 ------------- | ------------- | -------------
 [**getConfig**](SystemConfigApi.md#getconfig) | **GET** /system-config | 
 [**getDefaults**](SystemConfigApi.md#getdefaults) | **GET** /system-config/defaults | 
+[**getStorageTemplateOptions**](SystemConfigApi.md#getstoragetemplateoptions) | **GET** /system-config/storage-template-options | 
 [**updateConfig**](SystemConfigApi.md#updateconfig) | **PUT** /system-config | 
 
 
@@ -100,6 +101,49 @@ 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)
 
+# **getStorageTemplateOptions**
+> SystemConfigTemplateStorageOptionDto getStorageTemplateOptions()
+
+
+
+### 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.getStorageTemplateOptions();
+    print(result);
+} catch (e) {
+    print('Exception when calling SystemConfigApi->getStorageTemplateOptions: $e\n');
+}
+```
+
+### Parameters
+This endpoint does not need any parameter.
+
+### Return type
+
+[**SystemConfigTemplateStorageOptionDto**](SystemConfigTemplateStorageOptionDto.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**
 > SystemConfigDto updateConfig(systemConfigDto)
 

+ 1 - 0
mobile/openapi/doc/SystemConfigDto.md

@@ -10,6 +10,7 @@ Name | Type | Description | Notes
 ------------ | ------------- | ------------- | -------------
 **ffmpeg** | [**SystemConfigFFmpegDto**](SystemConfigFFmpegDto.md) |  | 
 **oauth** | [**SystemConfigOAuthDto**](SystemConfigOAuthDto.md) |  | 
+**storageTemplate** | [**SystemConfigStorageTemplateDto**](SystemConfigStorageTemplateDto.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)
 

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

@@ -0,0 +1,15 @@
+# openapi.model.SystemConfigStorageTemplateDto
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**template** | **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)
+
+

+ 21 - 0
mobile/openapi/doc/SystemConfigTemplateStorageOptionDto.md

@@ -0,0 +1,21 @@
+# openapi.model.SystemConfigTemplateStorageOptionDto
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**yearOptions** | **List<String>** |  | [default to const []]
+**monthOptions** | **List<String>** |  | [default to const []]
+**dayOptions** | **List<String>** |  | [default to const []]
+**hourOptions** | **List<String>** |  | [default to const []]
+**minuteOptions** | **List<String>** |  | [default to const []]
+**secondOptions** | **List<String>** |  | [default to const []]
+**presetOptions** | **List<String>** |  | [default to const []]
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
+

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

@@ -91,6 +91,8 @@ part 'model/smart_info_response_dto.dart';
 part 'model/system_config_dto.dart';
 part 'model/system_config_f_fmpeg_dto.dart';
 part 'model/system_config_o_auth_dto.dart';
+part 'model/system_config_storage_template_dto.dart';
+part 'model/system_config_template_storage_option_dto.dart';
 part 'model/tag_response_dto.dart';
 part 'model/tag_type_enum.dart';
 part 'model/thumbnail_format.dart';

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

@@ -98,6 +98,47 @@ class SystemConfigApi {
     return null;
   }
 
+  /// Performs an HTTP 'GET /system-config/storage-template-options' operation and returns the [Response].
+  Future<Response> getStorageTemplateOptionsWithHttpInfo() async {
+    // ignore: prefer_const_declarations
+    final path = r'/system-config/storage-template-options';
+
+    // 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<SystemConfigTemplateStorageOptionDto?> getStorageTemplateOptions() async {
+    final response = await getStorageTemplateOptionsWithHttpInfo();
+    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), 'SystemConfigTemplateStorageOptionDto',) as SystemConfigTemplateStorageOptionDto;
+    
+    }
+    return null;
+  }
+
   /// Performs an HTTP 'PUT /system-config' operation and returns the [Response].
   /// Parameters:
   ///

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

@@ -298,6 +298,10 @@ class ApiClient {
           return SystemConfigFFmpegDto.fromJson(value);
         case 'SystemConfigOAuthDto':
           return SystemConfigOAuthDto.fromJson(value);
+        case 'SystemConfigStorageTemplateDto':
+          return SystemConfigStorageTemplateDto.fromJson(value);
+        case 'SystemConfigTemplateStorageOptionDto':
+          return SystemConfigTemplateStorageOptionDto.fromJson(value);
         case 'TagResponseDto':
           return TagResponseDto.fromJson(value);
         case 'TagTypeEnum':

+ 11 - 3
mobile/openapi/lib/model/system_config_dto.dart

@@ -15,30 +15,36 @@ class SystemConfigDto {
   SystemConfigDto({
     required this.ffmpeg,
     required this.oauth,
+    required this.storageTemplate,
   });
 
   SystemConfigFFmpegDto ffmpeg;
 
   SystemConfigOAuthDto oauth;
 
+  SystemConfigStorageTemplateDto storageTemplate;
+
   @override
   bool operator ==(Object other) => identical(this, other) || other is SystemConfigDto &&
      other.ffmpeg == ffmpeg &&
-     other.oauth == oauth;
+     other.oauth == oauth &&
+     other.storageTemplate == storageTemplate;
 
   @override
   int get hashCode =>
     // ignore: unnecessary_parenthesis
     (ffmpeg.hashCode) +
-    (oauth.hashCode);
+    (oauth.hashCode) +
+    (storageTemplate.hashCode);
 
   @override
-  String toString() => 'SystemConfigDto[ffmpeg=$ffmpeg, oauth=$oauth]';
+  String toString() => 'SystemConfigDto[ffmpeg=$ffmpeg, oauth=$oauth, storageTemplate=$storageTemplate]';
 
   Map<String, dynamic> toJson() {
     final _json = <String, dynamic>{};
       _json[r'ffmpeg'] = ffmpeg;
       _json[r'oauth'] = oauth;
+      _json[r'storageTemplate'] = storageTemplate;
     return _json;
   }
 
@@ -63,6 +69,7 @@ class SystemConfigDto {
       return SystemConfigDto(
         ffmpeg: SystemConfigFFmpegDto.fromJson(json[r'ffmpeg'])!,
         oauth: SystemConfigOAuthDto.fromJson(json[r'oauth'])!,
+        storageTemplate: SystemConfigStorageTemplateDto.fromJson(json[r'storageTemplate'])!,
       );
     }
     return null;
@@ -114,6 +121,7 @@ class SystemConfigDto {
   static const requiredKeys = <String>{
     'ffmpeg',
     'oauth',
+    'storageTemplate',
   };
 }
 

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

+ 173 - 0
mobile/openapi/lib/model/system_config_template_storage_option_dto.dart

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

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

@@ -27,6 +27,11 @@ void main() {
       // TODO
     });
 
+    //Future<SystemConfigTemplateStorageOptionDto> getStorageTemplateOptions() async
+    test('test getStorageTemplateOptions', () async {
+      // TODO
+    });
+
     //Future<SystemConfigDto> updateConfig(SystemConfigDto systemConfigDto) async
     test('test updateConfig', () async {
       // TODO

+ 5 - 0
mobile/openapi/test/system_config_dto_test.dart

@@ -26,6 +26,11 @@ void main() {
       // TODO
     });
 
+    // SystemConfigStorageTemplateDto storageTemplate
+    test('to test the property `storageTemplate`', () async {
+      // TODO
+    });
+
 
   });
 

+ 27 - 0
mobile/openapi/test/system_config_storage_template_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 SystemConfigStorageTemplateDto
+void main() {
+  // final instance = SystemConfigStorageTemplateDto();
+
+  group('test SystemConfigStorageTemplateDto', () {
+    // String template
+    test('to test the property `template`', () async {
+      // TODO
+    });
+
+
+  });
+
+}

+ 57 - 0
mobile/openapi/test/system_config_template_storage_option_dto_test.dart

@@ -0,0 +1,57 @@
+//
+// 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 SystemConfigTemplateStorageOptionDto
+void main() {
+  // final instance = SystemConfigTemplateStorageOptionDto();
+
+  group('test SystemConfigTemplateStorageOptionDto', () {
+    // List<String> yearOptions (default value: const [])
+    test('to test the property `yearOptions`', () async {
+      // TODO
+    });
+
+    // List<String> monthOptions (default value: const [])
+    test('to test the property `monthOptions`', () async {
+      // TODO
+    });
+
+    // List<String> dayOptions (default value: const [])
+    test('to test the property `dayOptions`', () async {
+      // TODO
+    });
+
+    // List<String> hourOptions (default value: const [])
+    test('to test the property `hourOptions`', () async {
+      // TODO
+    });
+
+    // List<String> minuteOptions (default value: const [])
+    test('to test the property `minuteOptions`', () async {
+      // TODO
+    });
+
+    // List<String> secondOptions (default value: const [])
+    test('to test the property `secondOptions`', () async {
+      // TODO
+    });
+
+    // List<String> presetOptions (default value: const [])
+    test('to test the property `presetOptions`', () async {
+      // TODO
+    });
+
+
+  });
+
+}

+ 10 - 0
notes.md

@@ -0,0 +1,10 @@
+# User defined storage structure
+
+# Folder structure
+* Year is the top level
+  * Different parsing sequence will be the second level 
+
+# Filename
+* Filename will always be appended by a unique ID. Maybe use https://github.com/ai/nanoid
+  * Example: `notes.md` -> `notes-1234567890.md`
+* Filename will be unique in the same folder

+ 2 - 0
server/apps/immich/src/api-v1/asset/asset.module.ts

@@ -13,6 +13,7 @@ import { DownloadModule } from '../../modules/download/download.module';
 import { TagModule } from '../tag/tag.module';
 import { AlbumModule } from '../album/album.module';
 import { UserModule } from '../user/user.module';
+import { StorageModule } from '@app/storage';
 
 const ASSET_REPOSITORY_PROVIDER = {
   provide: ASSET_REPOSITORY,
@@ -28,6 +29,7 @@ const ASSET_REPOSITORY_PROVIDER = {
     UserModule,
     AlbumModule,
     TagModule,
+    StorageModule,
     forwardRef(() => AlbumModule),
     BullModule.registerQueue({
       name: QueueNameEnum.ASSET_UPLOADED,

+ 4 - 1
server/apps/immich/src/api-v1/asset/asset.service.spec.ts

@@ -11,7 +11,8 @@ import { DownloadService } from '../../modules/download/download.service';
 import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
 import { IAssetUploadedJob, IVideoTranscodeJob } from '@app/job';
 import { Queue } from 'bull';
-import { IAlbumRepository } from "../album/album-repository";
+import { IAlbumRepository } from '../album/album-repository';
+import { StorageService } from '@app/storage';
 
 describe('AssetService', () => {
   let sui: AssetService;
@@ -22,6 +23,7 @@ describe('AssetService', () => {
   let backgroundTaskServiceMock: jest.Mocked<BackgroundTaskService>;
   let assetUploadedQueueMock: jest.Mocked<Queue<IAssetUploadedJob>>;
   let videoConversionQueueMock: jest.Mocked<Queue<IVideoTranscodeJob>>;
+  let storageSeriveMock: jest.Mocked<StorageService>;
   const authUser: AuthUserDto = Object.freeze({
     id: 'user_id_1',
     email: 'auth@test.com',
@@ -139,6 +141,7 @@ describe('AssetService', () => {
       assetUploadedQueueMock,
       videoConversionQueueMock,
       downloadServiceMock as DownloadService,
+      storageSeriveMock,
     );
   });
 

+ 10 - 3
server/apps/immich/src/api-v1/asset/asset.service.ts

@@ -55,6 +55,7 @@ import { Queue } from 'bull';
 import { DownloadService } from '../../modules/download/download.service';
 import { DownloadDto } from './dto/download-library.dto';
 import { ALBUM_REPOSITORY, IAlbumRepository } from '../album/album-repository';
+import { StorageService } from '@app/storage';
 
 const fileInfo = promisify(stat);
 
@@ -79,6 +80,8 @@ export class AssetService {
     private videoConversionQueue: Queue<IVideoTranscodeJob>,
 
     private downloadService: DownloadService,
+
+    private storageService: StorageService,
   ) {}
 
   public async handleUploadedAsset(
@@ -113,6 +116,8 @@ export class AssetService {
           throw new BadRequestException('Asset not created');
         }
 
+        await this.storageService.moveAsset(livePhotoAssetEntity, originalAssetData.originalname);
+
         await this.videoConversionQueue.add(
           mp4ConversionProcessorName,
           { asset: livePhotoAssetEntity },
@@ -139,13 +144,15 @@ export class AssetService {
         throw new BadRequestException('Asset not created');
       }
 
+      const movedAsset = await this.storageService.moveAsset(assetEntity, originalAssetData.originalname);
+
       await this.assetUploadedQueue.add(
         assetUploadedProcessorName,
-        { asset: assetEntity, fileName: originalAssetData.originalname },
-        { jobId: assetEntity.id },
+        { asset: movedAsset, fileName: originalAssetData.originalname },
+        { jobId: movedAsset.id },
       );
 
-      return new AssetFileUploadResponseDto(assetEntity.id);
+      return new AssetFileUploadResponseDto(movedAsset.id);
     } catch (err) {
       await this.backgroundTaskService.deleteFileOnDisk([
         {

+ 7 - 0
server/apps/immich/src/api-v1/system-config/dto/system-config-storage-template.dto.ts

@@ -0,0 +1,7 @@
+import { IsNotEmpty, IsString } from 'class-validator';
+
+export class SystemConfigStorageTemplateDto {
+  @IsNotEmpty()
+  @IsString()
+  template!: string;
+}

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

@@ -2,6 +2,7 @@ import { SystemConfig } from '@app/database/entities/system-config.entity';
 import { ValidateNested } from 'class-validator';
 import { SystemConfigFFmpegDto } from './system-config-ffmpeg.dto';
 import { SystemConfigOAuthDto } from './system-config-oauth.dto';
+import { SystemConfigStorageTemplateDto } from './system-config-storage-template.dto';
 
 export class SystemConfigDto {
   @ValidateNested()
@@ -9,6 +10,9 @@ export class SystemConfigDto {
 
   @ValidateNested()
   oauth!: SystemConfigOAuthDto;
+
+  @ValidateNested()
+  storageTemplate!: SystemConfigStorageTemplateDto;
 }
 
 export function mapConfig(config: SystemConfig): SystemConfigDto {

+ 9 - 0
server/apps/immich/src/api-v1/system-config/response-dto/system-config-template-storage-option.dto.ts

@@ -0,0 +1,9 @@
+export class SystemConfigTemplateStorageOptionDto {
+  yearOptions!: string[];
+  monthOptions!: string[];
+  dayOptions!: string[];
+  hourOptions!: string[];
+  minuteOptions!: string[];
+  secondOptions!: string[];
+  presetOptions!: string[];
+}

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

@@ -1,6 +1,7 @@
 import { Body, Controller, Get, Put, ValidationPipe } from '@nestjs/common';
 import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
 import { Authenticated } from '../../decorators/authenticated.decorator';
+import { SystemConfigTemplateStorageOptionDto } from './response-dto/system-config-template-storage-option.dto';
 import { SystemConfigDto } from './dto/system-config.dto';
 import { SystemConfigService } from './system-config.service';
 
@@ -25,4 +26,9 @@ export class SystemConfigController {
   public updateConfig(@Body(ValidationPipe) dto: SystemConfigDto): Promise<SystemConfigDto> {
     return this.systemConfigService.updateConfig(dto);
   }
+
+  @Get('storage-template-options')
+  public getStorageTemplateOptions(): SystemConfigTemplateStorageOptionDto {
+    return this.systemConfigService.getStorageTemplateOptions();
+  }
 }

+ 26 - 2
server/apps/immich/src/api-v1/system-config/system-config.service.ts

@@ -1,6 +1,16 @@
+import {
+  supportedDayTokens,
+  supportedHourTokens,
+  supportedMinuteTokens,
+  supportedMonthTokens,
+  supportedPresetTokens,
+  supportedSecondTokens,
+  supportedYearTokens,
+} from '@app/storage/constants/supported-datetime-template';
 import { Injectable } from '@nestjs/common';
 import { ImmichConfigService } from 'libs/immich-config/src';
 import { mapConfig, SystemConfigDto } from './dto/system-config.dto';
+import { SystemConfigTemplateStorageOptionDto } from './response-dto/system-config-template-storage-option.dto';
 
 @Injectable()
 export class SystemConfigService {
@@ -17,7 +27,21 @@ export class SystemConfigService {
   }
 
   public async updateConfig(dto: SystemConfigDto): Promise<SystemConfigDto> {
-    await this.immichConfigService.updateConfig(dto);
-    return this.getConfig();
+    const config = await this.immichConfigService.updateConfig(dto);
+    return mapConfig(config);
+  }
+
+  public getStorageTemplateOptions(): SystemConfigTemplateStorageOptionDto {
+    const options = new SystemConfigTemplateStorageOptionDto();
+
+    options.dayOptions = supportedDayTokens;
+    options.monthOptions = supportedMonthTokens;
+    options.yearOptions = supportedYearTokens;
+    options.hourOptions = supportedHourTokens;
+    options.minuteOptions = supportedMinuteTokens;
+    options.secondOptions = supportedSecondTokens;
+    options.presetOptions = supportedPresetTokens;
+
+    return options;
   }
 }

+ 9 - 9
server/apps/immich/src/api-v1/user/user.service.spec.ts

@@ -19,7 +19,7 @@ describe('UserService', () => {
     email: 'immich@test.com',
   });
 
-  const adminUser: UserEntity = Object.freeze({
+  const adminUser: UserEntity = {
     id: 'admin_id',
     email: 'admin@test.com',
     password: 'admin_password',
@@ -32,9 +32,9 @@ describe('UserService', () => {
     profileImagePath: '',
     createdAt: '2021-01-01',
     tags: [],
-  });
+  };
 
-  const immichUser: UserEntity = Object.freeze({
+  const immichUser: UserEntity = {
     id: 'immich_id',
     email: 'immich@test.com',
     password: 'immich_password',
@@ -47,9 +47,9 @@ describe('UserService', () => {
     profileImagePath: '',
     createdAt: '2021-01-01',
     tags: [],
-  });
+  };
 
-  const updatedImmichUser: UserEntity = Object.freeze({
+  const updatedImmichUser: UserEntity = {
     id: 'immich_id',
     email: 'immich@test.com',
     password: 'immich_password',
@@ -62,7 +62,7 @@ describe('UserService', () => {
     profileImagePath: '',
     createdAt: '2021-01-01',
     tags: [],
-  });
+  };
 
   beforeAll(() => {
     userRepositoryMock = newUserRepositoryMock();
@@ -75,7 +75,7 @@ describe('UserService', () => {
   });
 
   describe('Update user', () => {
-    it('should update user', () => {
+    it('should update user', async () => {
       const requestor = immichAuthUser;
       const userToUpdate = immichUser;
 
@@ -83,11 +83,11 @@ describe('UserService', () => {
       userRepositoryMock.get.mockImplementationOnce(() => Promise.resolve(userToUpdate));
       userRepositoryMock.update.mockImplementationOnce(() => Promise.resolve(updatedImmichUser));
 
-      const result = sui.updateUser(requestor, {
+      const result = await sui.updateUser(requestor, {
         id: userToUpdate.id,
         shouldChangePassword: true,
       });
-      expect(result).resolves.toBeDefined();
+      expect(result.shouldChangePassword).toEqual(true);
     });
 
     it('user can only update its information', () => {

+ 2 - 3
server/apps/microservices/src/processors/thumbnail.processor.ts

@@ -44,6 +44,7 @@ export class ThumbnailGeneratorProcessor {
     private configService: ConfigService,
   ) {
     this.logLevel = this.configService.get('LOG_LEVEL') || ImmichLogLevel.SIMPLE;
+    // TODO - Add observable paterrn to listen to the config change
   }
 
   @Process({ name: generateJPEGThumbnailProcessorName, concurrency: 3 })
@@ -59,9 +60,7 @@ export class ThumbnailGeneratorProcessor {
       mkdirSync(resizePath, { recursive: true });
     }
 
-    const temp = asset.originalPath.split('/');
-    const originalFilename = temp[temp.length - 1].split('.')[0];
-    const jpegThumbnailPath = join(resizePath, `${originalFilename}.jpeg`);
+    const jpegThumbnailPath = join(resizePath, `${asset.id}.jpeg`);
 
     if (asset.type == AssetType.IMAGE) {
       try {

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

@@ -2169,12 +2169,38 @@
           }
         ]
       }
+    },
+    "/system-config/storage-template-options": {
+      "get": {
+        "operationId": "getStorageTemplateOptions",
+        "parameters": [],
+        "responses": {
+          "200": {
+            "description": "",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/SystemConfigTemplateStorageOptionDto"
+                }
+              }
+            }
+          }
+        },
+        "tags": [
+          "System Config"
+        ],
+        "security": [
+          {
+            "bearer": []
+          }
+        ]
+      }
     }
   },
   "info": {
     "title": "Immich",
     "description": "Immich API",
-    "version": "1.38.0",
+    "version": "1.38.2",
     "contact": {}
   },
   "tags": [],
@@ -3664,6 +3690,17 @@
           "autoRegister"
         ]
       },
+      "SystemConfigStorageTemplateDto": {
+        "type": "object",
+        "properties": {
+          "template": {
+            "type": "string"
+          }
+        },
+        "required": [
+          "template"
+        ]
+      },
       "SystemConfigDto": {
         "type": "object",
         "properties": {
@@ -3672,11 +3709,71 @@
           },
           "oauth": {
             "$ref": "#/components/schemas/SystemConfigOAuthDto"
+          },
+          "storageTemplate": {
+            "$ref": "#/components/schemas/SystemConfigStorageTemplateDto"
           }
         },
         "required": [
           "ffmpeg",
-          "oauth"
+          "oauth",
+          "storageTemplate"
+        ]
+      },
+      "SystemConfigTemplateStorageOptionDto": {
+        "type": "object",
+        "properties": {
+          "yearOptions": {
+            "type": "array",
+            "items": {
+              "type": "string"
+            }
+          },
+          "monthOptions": {
+            "type": "array",
+            "items": {
+              "type": "string"
+            }
+          },
+          "dayOptions": {
+            "type": "array",
+            "items": {
+              "type": "string"
+            }
+          },
+          "hourOptions": {
+            "type": "array",
+            "items": {
+              "type": "string"
+            }
+          },
+          "minuteOptions": {
+            "type": "array",
+            "items": {
+              "type": "string"
+            }
+          },
+          "secondOptions": {
+            "type": "array",
+            "items": {
+              "type": "string"
+            }
+          },
+          "presetOptions": {
+            "type": "array",
+            "items": {
+              "type": "string"
+            }
+          }
+        },
+        "required": [
+          "yearOptions",
+          "monthOptions",
+          "dayOptions",
+          "hourOptions",
+          "minuteOptions",
+          "secondOptions",
+          "presetOptions"
         ]
       }
     }

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

@@ -25,6 +25,7 @@ export enum SystemConfigKey {
   OAUTH_SCOPE = 'oauth.scope',
   OAUTH_BUTTON_TEXT = 'oauth.buttonText',
   OAUTH_AUTO_REGISTER = 'oauth.autoRegister',
+  STORAGE_TEMPLATE = 'storageTemplate.template',
 }
 
 export interface SystemConfig {
@@ -44,4 +45,7 @@ export interface SystemConfig {
     buttonText: string;
     autoRegister: boolean;
   };
+  storageTemplate: {
+    template: string;
+  };
 }

+ 16 - 3
server/libs/immich-config/src/immich-config.module.ts

@@ -1,11 +1,24 @@
 import { SystemConfigEntity } from '@app/database/entities/system-config.entity';
-import { Module } from '@nestjs/common';
+import { Module, Provider } from '@nestjs/common';
 import { TypeOrmModule } from '@nestjs/typeorm';
 import { ImmichConfigService } from './immich-config.service';
 
+export const INITIAL_SYSTEM_CONFIG = 'INITIAL_SYSTEM_CONFIG';
+
+const providers: Provider[] = [
+  ImmichConfigService,
+  {
+    provide: INITIAL_SYSTEM_CONFIG,
+    inject: [ImmichConfigService],
+    useFactory: async (configService: ImmichConfigService) => {
+      return configService.getConfig();
+    },
+  },
+];
+
 @Module({
   imports: [TypeOrmModule.forFeature([SystemConfigEntity])],
-  providers: [ImmichConfigService],
-  exports: [ImmichConfigService],
+  providers: [...providers],
+  exports: [...providers],
 })
 export class ImmichConfigModule {}

+ 33 - 2
server/libs/immich-config/src/immich-config.service.ts

@@ -1,9 +1,12 @@
 import { SystemConfig, SystemConfigEntity, SystemConfigKey } from '@app/database/entities/system-config.entity';
-import { Injectable } from '@nestjs/common';
+import { BadRequestException, Injectable, Logger } from '@nestjs/common';
 import { InjectRepository } from '@nestjs/typeorm';
 import * as _ from 'lodash';
+import { Subject } from 'rxjs';
 import { DeepPartial, In, Repository } from 'typeorm';
 
+export type SystemConfigValidator = (config: SystemConfig) => void | Promise<void>;
+
 const defaults: SystemConfig = Object.freeze({
   ffmpeg: {
     crf: '23',
@@ -21,10 +24,19 @@ const defaults: SystemConfig = Object.freeze({
     buttonText: 'Login with OAuth',
     autoRegister: true,
   },
+
+  storageTemplate: {
+    template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
+  },
 });
 
 @Injectable()
 export class ImmichConfigService {
+  private logger = new Logger(ImmichConfigService.name);
+  private validators: SystemConfigValidator[] = [];
+
+  public config$ = new Subject<SystemConfig>();
+
   constructor(
     @InjectRepository(SystemConfigEntity)
     private systemConfigRepository: Repository<SystemConfigEntity>,
@@ -34,6 +46,10 @@ export class ImmichConfigService {
     return defaults;
   }
 
+  public addValidator(validator: SystemConfigValidator) {
+    this.validators.push(validator);
+  }
+
   public async getConfig() {
     const overrides = await this.systemConfigRepository.find();
     const config: DeepPartial<SystemConfig> = {};
@@ -45,7 +61,16 @@ export class ImmichConfigService {
     return _.defaultsDeep(config, defaults) as SystemConfig;
   }
 
-  public async updateConfig(config: DeepPartial<SystemConfig> | null): Promise<void> {
+  public async updateConfig(config: SystemConfig): Promise<SystemConfig> {
+    try {
+      for (const validator of this.validators) {
+        await validator(config);
+      }
+    } catch (e) {
+      this.logger.warn(`Unable to save system config due to a validation error: ${e}`);
+      throw new BadRequestException(e instanceof Error ? e.message : e);
+    }
+
     const updates: SystemConfigEntity[] = [];
     const deletes: SystemConfigEntity[] = [];
 
@@ -70,5 +95,11 @@ export class ImmichConfigService {
     if (deletes.length > 0) {
       await this.systemConfigRepository.delete({ key: In(deletes.map((item) => item.key)) });
     }
+
+    const newConfig = await this.getConfig();
+
+    this.config$.next(newConfig);
+
+    return newConfig;
   }
 }

+ 20 - 0
server/libs/storage/src/constants/supported-datetime-template.ts

@@ -0,0 +1,20 @@
+export const supportedYearTokens = ['y', 'yy'];
+export const supportedMonthTokens = ['M', 'MM', 'MMM', 'MMMM'];
+export const supportedDayTokens = ['d', 'dd'];
+export const supportedHourTokens = ['h', 'hh', 'H', 'HH'];
+export const supportedMinuteTokens = ['m', 'mm'];
+export const supportedSecondTokens = ['s', 'ss'];
+export const supportedPresetTokens = [
+  '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
+  '{{y}}/{{MM}}-{{dd}}/{{filename}}',
+  '{{y}}/{{MMMM}}-{{dd}}/{{filename}}',
+  '{{y}}/{{MM}}/{{filename}}',
+  '{{y}}/{{MMM}}/{{filename}}',
+  '{{y}}/{{MMMM}}/{{filename}}',
+  '{{y}}/{{MM}}/{{dd}}/{{filename}}',
+  '{{y}}/{{MMMM}}/{{dd}}/{{filename}}',
+  '{{y}}/{{y}}-{{MM}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
+  '{{y}}-{{MM}}-{{dd}}/{{filename}}',
+  '{{y}}-{{MMM}}-{{dd}}/{{filename}}',
+  '{{y}}-{{MMMM}}-{{dd}}/{{filename}}',
+];

+ 2 - 0
server/libs/storage/src/index.ts

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

+ 6 - 0
server/libs/storage/src/interfaces/immich-storage.interface.ts

@@ -0,0 +1,6 @@
+export interface IImmichStorage {
+  write(): Promise<void>;
+  read(): Promise<void>;
+}
+
+export enum IStorageType {}

+ 13 - 0
server/libs/storage/src/storage.module.ts

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

+ 153 - 0
server/libs/storage/src/storage.service.ts

@@ -0,0 +1,153 @@
+import { APP_UPLOAD_LOCATION } from '@app/common';
+import { AssetEntity } from '@app/database/entities/asset.entity';
+import { SystemConfig } from '@app/database/entities/system-config.entity';
+import { ImmichConfigService, INITIAL_SYSTEM_CONFIG } from '@app/immich-config';
+import { Inject, Injectable, Logger } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import fsPromise from 'fs/promises';
+import handlebar from 'handlebars';
+import * as luxon from 'luxon';
+import mv from 'mv';
+import { constants } from 'node:fs';
+import path from 'node:path';
+import { promisify } from 'node:util';
+import sanitize from 'sanitize-filename';
+import { Repository } from 'typeorm';
+import {
+  supportedDayTokens,
+  supportedHourTokens,
+  supportedMinuteTokens,
+  supportedMonthTokens,
+  supportedSecondTokens,
+  supportedYearTokens,
+} from './constants/supported-datetime-template';
+
+const moveFile = promisify<string, string, mv.Options>(mv);
+
+@Injectable()
+export class StorageService {
+  readonly log = new Logger(StorageService.name);
+
+  private storageTemplate: HandlebarsTemplateDelegate<any>;
+
+  constructor(
+    @InjectRepository(AssetEntity)
+    private assetRepository: Repository<AssetEntity>,
+    private immichConfigService: ImmichConfigService,
+    @Inject(INITIAL_SYSTEM_CONFIG) config: SystemConfig,
+  ) {
+    this.storageTemplate = this.compile(config.storageTemplate.template);
+
+    this.immichConfigService.addValidator((config) => this.validateConfig(config));
+
+    this.immichConfigService.config$.subscribe((config) => {
+      this.log.debug(`Received new config, recompiling storage template: ${config.storageTemplate.template}`);
+      this.storageTemplate = this.compile(config.storageTemplate.template);
+    });
+  }
+
+  public async moveAsset(asset: AssetEntity, filename: string): Promise<AssetEntity> {
+    try {
+      const source = asset.originalPath;
+      const ext = path.extname(source).split('.').pop() as string;
+      const sanitized = sanitize(path.basename(filename, `.${ext}`));
+      const rootPath = path.join(APP_UPLOAD_LOCATION, asset.userId);
+      const storagePath = this.render(this.storageTemplate, asset, sanitized, ext);
+      const fullPath = path.normalize(path.join(rootPath, storagePath));
+
+      if (!fullPath.startsWith(rootPath)) {
+        this.log.warn(`Skipped attempt to access an invalid path: ${fullPath}. Path should start with ${rootPath}`);
+        return asset;
+      }
+
+      let duplicateCount = 0;
+      let destination = `${fullPath}.${ext}`;
+
+      while (true) {
+        const exists = await this.checkFileExist(destination);
+        if (!exists) {
+          break;
+        }
+
+        duplicateCount++;
+        destination = `${fullPath}_${duplicateCount}.${ext}`;
+      }
+
+      await this.safeMove(source, destination);
+
+      asset.originalPath = destination;
+      return await this.assetRepository.save(asset);
+    } catch (error: any) {
+      this.log.error(error, error.stack);
+      return asset;
+    }
+  }
+
+  private safeMove(source: string, destination: string): Promise<void> {
+    return moveFile(source, destination, { mkdirp: true, clobber: false });
+  }
+
+  private async checkFileExist(path: string): Promise<boolean> {
+    try {
+      await fsPromise.access(path, constants.F_OK);
+      return true;
+    } catch (_) {
+      return false;
+    }
+  }
+
+  private validateConfig(config: SystemConfig) {
+    this.validateStorageTemplate(config.storageTemplate.template);
+  }
+
+  private validateStorageTemplate(templateString: string) {
+    try {
+      const template = this.compile(templateString);
+
+      // test render an asset
+      this.render(
+        template,
+        {
+          createdAt: new Date().toISOString(),
+          originalPath: '/upload/test/IMG_123.jpg',
+        } as AssetEntity,
+        'IMG_123',
+        'jpg',
+      );
+    } catch (e) {
+      this.log.warn(`Storage template validation failed: ${e}`);
+      throw new Error(`Invalid storage template: ${e}`);
+    }
+  }
+
+  private compile(template: string) {
+    return handlebar.compile(template, {
+      knownHelpers: undefined,
+      strict: true,
+    });
+  }
+
+  private render(template: HandlebarsTemplateDelegate<any>, asset: AssetEntity, filename: string, ext: string) {
+    const substitutions: Record<string, string> = {
+      filename,
+      ext,
+    };
+
+    const dt = luxon.DateTime.fromISO(new Date(asset.createdAt).toISOString());
+
+    const dateTokens = [
+      ...supportedYearTokens,
+      ...supportedMonthTokens,
+      ...supportedDayTokens,
+      ...supportedHourTokens,
+      ...supportedMinuteTokens,
+      ...supportedSecondTokens,
+    ];
+
+    for (const token of dateTokens) {
+      substitutions[token] = dt.toFormat(token);
+    }
+
+    return template(substitutions);
+  }
+}

+ 9 - 0
server/libs/storage/tsconfig.lib.json

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

+ 10 - 1
server/nest-cli.json

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

+ 176 - 4
server/package-lock.json

@@ -36,11 +36,13 @@
         "fdir": "^5.3.0",
         "fluent-ffmpeg": "^2.1.2",
         "geo-tz": "^7.0.2",
+        "handlebars": "^4.7.7",
         "i18n-iso-countries": "^7.5.0",
         "joi": "^17.5.0",
         "local-reverse-geocoder": "^0.12.5",
         "lodash": "^4.17.21",
         "luxon": "^3.0.3",
+        "mv": "^2.1.1",
         "nest-commander": "^3.3.0",
         "openid-client": "^5.2.1",
         "passport": "^0.6.0",
@@ -76,6 +78,7 @@
         "@types/jest": "27.0.2",
         "@types/lodash": "^4.14.178",
         "@types/multer": "^1.4.7",
+        "@types/mv": "^2.1.2",
         "@types/node": "^16.0.0",
         "@types/passport-jwt": "^3.0.6",
         "@types/sharp": "^0.30.2",
@@ -2544,6 +2547,12 @@
         "@types/express": "*"
       }
     },
+    "node_modules/@types/mv": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/@types/mv/-/mv-2.1.2.tgz",
+      "integrity": "sha512-IvAjPuiQ2exDicnTrMidt1m+tj3gZ60BM0PaoRsU0m9Cn+lrOyemuO9Tf8CvHFmXlxMjr1TVCfadi9sfwbSuKg==",
+      "dev": true
+    },
     "node_modules/@types/node": {
       "version": "16.11.21",
       "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.21.tgz",
@@ -6168,6 +6177,34 @@
       "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.9.tgz",
       "integrity": "sha512-NtNxqUcXgpW2iMrfqSfR73Glt39K+BLwWsPs94yR63v45T0Wbej7eRmL5cWfwEgqXnmjQp3zaJTshdRW/qC2ZQ=="
     },
+    "node_modules/handlebars": {
+      "version": "4.7.7",
+      "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz",
+      "integrity": "sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==",
+      "dependencies": {
+        "minimist": "^1.2.5",
+        "neo-async": "^2.6.0",
+        "source-map": "^0.6.1",
+        "wordwrap": "^1.0.0"
+      },
+      "bin": {
+        "handlebars": "bin/handlebars"
+      },
+      "engines": {
+        "node": ">=0.4.7"
+      },
+      "optionalDependencies": {
+        "uglify-js": "^3.1.4"
+      }
+    },
+    "node_modules/handlebars/node_modules/source-map": {
+      "version": "0.6.1",
+      "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+      "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
     "node_modules/har-schema": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz",
@@ -8178,6 +8215,45 @@
       "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz",
       "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA=="
     },
+    "node_modules/mv": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/mv/-/mv-2.1.1.tgz",
+      "integrity": "sha512-at/ZndSy3xEGJ8i0ygALh8ru9qy7gWW1cmkaqBN29JmMlIvM//MEO9y1sk/avxuwnPcfhkejkLsuPxH81BrkSg==",
+      "dependencies": {
+        "mkdirp": "~0.5.1",
+        "ncp": "~2.0.0",
+        "rimraf": "~2.4.0"
+      },
+      "engines": {
+        "node": ">=0.8.0"
+      }
+    },
+    "node_modules/mv/node_modules/glob": {
+      "version": "6.0.4",
+      "resolved": "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz",
+      "integrity": "sha512-MKZeRNyYZAVVVG1oZeLaWie1uweH40m9AZwIwxyPbTSX4hHrVYSzLg0Ro5Z5R7XKkIX+Cc6oD1rqeDJnwsB8/A==",
+      "dependencies": {
+        "inflight": "^1.0.4",
+        "inherits": "2",
+        "minimatch": "2 || 3",
+        "once": "^1.3.0",
+        "path-is-absolute": "^1.0.0"
+      },
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/mv/node_modules/rimraf": {
+      "version": "2.4.5",
+      "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.4.5.tgz",
+      "integrity": "sha512-J5xnxTyqaiw06JjMftq7L9ouA448dw/E7dKghkP9WpKNuwmARNNg+Gk8/u5ryb9N/Yo2+z3MCwuqFK/+qPOPfQ==",
+      "dependencies": {
+        "glob": "^6.0.1"
+      },
+      "bin": {
+        "rimraf": "bin.js"
+      }
+    },
     "node_modules/mz": {
       "version": "2.7.0",
       "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
@@ -8204,6 +8280,14 @@
       "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=",
       "dev": true
     },
+    "node_modules/ncp": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz",
+      "integrity": "sha512-zIdGUrPRFTUELUvr3Gmc7KZ2Sw/h1PiVM0Af/oHB6zgnV1ikqSfRk+TOufi79aHYCW3NiOXmr1BP5nWbzojLaA==",
+      "bin": {
+        "ncp": "bin/ncp"
+      }
+    },
     "node_modules/negotiator": {
       "version": "0.6.3",
       "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -8215,8 +8299,7 @@
     "node_modules/neo-async": {
       "version": "2.6.2",
       "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
-      "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
-      "dev": true
+      "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="
     },
     "node_modules/nest-commander": {
       "version": "3.3.0",
@@ -11006,6 +11089,18 @@
         "node": ">=4.2.0"
       }
     },
+    "node_modules/uglify-js": {
+      "version": "3.17.4",
+      "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz",
+      "integrity": "sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==",
+      "optional": true,
+      "bin": {
+        "uglifyjs": "bin/uglifyjs"
+      },
+      "engines": {
+        "node": ">=0.8.0"
+      }
+    },
     "node_modules/uid2": {
       "version": "0.0.3",
       "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.3.tgz",
@@ -11329,6 +11424,11 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/wordwrap": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
+      "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="
+    },
     "node_modules/wrap-ansi": {
       "version": "7.0.0",
       "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
@@ -13393,6 +13493,12 @@
         "@types/express": "*"
       }
     },
+    "@types/mv": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/@types/mv/-/mv-2.1.2.tgz",
+      "integrity": "sha512-IvAjPuiQ2exDicnTrMidt1m+tj3gZ60BM0PaoRsU0m9Cn+lrOyemuO9Tf8CvHFmXlxMjr1TVCfadi9sfwbSuKg==",
+      "dev": true
+    },
     "@types/node": {
       "version": "16.11.21",
       "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.21.tgz",
@@ -16213,6 +16319,25 @@
       "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.9.tgz",
       "integrity": "sha512-NtNxqUcXgpW2iMrfqSfR73Glt39K+BLwWsPs94yR63v45T0Wbej7eRmL5cWfwEgqXnmjQp3zaJTshdRW/qC2ZQ=="
     },
+    "handlebars": {
+      "version": "4.7.7",
+      "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz",
+      "integrity": "sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==",
+      "requires": {
+        "minimist": "^1.2.5",
+        "neo-async": "^2.6.0",
+        "source-map": "^0.6.1",
+        "uglify-js": "^3.1.4",
+        "wordwrap": "^1.0.0"
+      },
+      "dependencies": {
+        "source-map": {
+          "version": "0.6.1",
+          "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+          "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
+        }
+      }
+    },
     "har-schema": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz",
@@ -17773,6 +17898,38 @@
       "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz",
       "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA=="
     },
+    "mv": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/mv/-/mv-2.1.1.tgz",
+      "integrity": "sha512-at/ZndSy3xEGJ8i0ygALh8ru9qy7gWW1cmkaqBN29JmMlIvM//MEO9y1sk/avxuwnPcfhkejkLsuPxH81BrkSg==",
+      "requires": {
+        "mkdirp": "~0.5.1",
+        "ncp": "~2.0.0",
+        "rimraf": "~2.4.0"
+      },
+      "dependencies": {
+        "glob": {
+          "version": "6.0.4",
+          "resolved": "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz",
+          "integrity": "sha512-MKZeRNyYZAVVVG1oZeLaWie1uweH40m9AZwIwxyPbTSX4hHrVYSzLg0Ro5Z5R7XKkIX+Cc6oD1rqeDJnwsB8/A==",
+          "requires": {
+            "inflight": "^1.0.4",
+            "inherits": "2",
+            "minimatch": "2 || 3",
+            "once": "^1.3.0",
+            "path-is-absolute": "^1.0.0"
+          }
+        },
+        "rimraf": {
+          "version": "2.4.5",
+          "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.4.5.tgz",
+          "integrity": "sha512-J5xnxTyqaiw06JjMftq7L9ouA448dw/E7dKghkP9WpKNuwmARNNg+Gk8/u5ryb9N/Yo2+z3MCwuqFK/+qPOPfQ==",
+          "requires": {
+            "glob": "^6.0.1"
+          }
+        }
+      }
+    },
     "mz": {
       "version": "2.7.0",
       "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
@@ -17799,6 +17956,11 @@
       "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=",
       "dev": true
     },
+    "ncp": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz",
+      "integrity": "sha512-zIdGUrPRFTUELUvr3Gmc7KZ2Sw/h1PiVM0Af/oHB6zgnV1ikqSfRk+TOufi79aHYCW3NiOXmr1BP5nWbzojLaA=="
+    },
     "negotiator": {
       "version": "0.6.3",
       "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -17807,8 +17969,7 @@
     "neo-async": {
       "version": "2.6.2",
       "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
-      "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
-      "dev": true
+      "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="
     },
     "nest-commander": {
       "version": "3.3.0",
@@ -19794,6 +19955,12 @@
       "integrity": "sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==",
       "devOptional": true
     },
+    "uglify-js": {
+      "version": "3.17.4",
+      "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz",
+      "integrity": "sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==",
+      "optional": true
+    },
     "uid2": {
       "version": "0.0.3",
       "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.3.tgz",
@@ -20049,6 +20216,11 @@
       "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
       "dev": true
     },
+    "wordwrap": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
+      "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="
+    },
     "wrap-ansi": {
       "version": "7.0.0",
       "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",

+ 5 - 1
server/package.json

@@ -59,11 +59,13 @@
     "fdir": "^5.3.0",
     "fluent-ffmpeg": "^2.1.2",
     "geo-tz": "^7.0.2",
+    "handlebars": "^4.7.7",
     "i18n-iso-countries": "^7.5.0",
     "joi": "^17.5.0",
     "local-reverse-geocoder": "^0.12.5",
     "lodash": "^4.17.21",
     "luxon": "^3.0.3",
+    "mv": "^2.1.1",
     "nest-commander": "^3.3.0",
     "openid-client": "^5.2.1",
     "passport": "^0.6.0",
@@ -96,6 +98,7 @@
     "@types/jest": "27.0.2",
     "@types/lodash": "^4.14.178",
     "@types/multer": "^1.4.7",
+    "@types/mv": "^2.1.2",
     "@types/node": "^16.0.0",
     "@types/passport-jwt": "^3.0.6",
     "@types/sharp": "^0.30.2",
@@ -142,7 +145,8 @@
       "@app/database/config": "<rootDir>/libs/database/src/config",
       "@app/common": "<rootDir>/libs/common/src",
       "^@app/job(|/.*)$": "<rootDir>/libs/job/src/$1",
-      "^@app/immich-config(|/.*)$": "<rootDir>/libs/immich-config/src/$1"
+      "^@app/immich-config(|/.*)$": "<rootDir>/libs/immich-config/src/$1",
+      "^@app/storage(|/.*)$": "<rootDir>/libs/storage/src/$1"
     }
   }
 }

+ 36 - 10
server/tsconfig.json

@@ -16,15 +16,41 @@
     "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/immich-config": ["libs/immich-config/src"],
-      "@app/immich-config/*": ["libs/immich-config/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/immich-config": [
+        "libs/immich-config/src"
+      ],
+      "@app/immich-config/*": [
+        "libs/immich-config/src/*"
+      ],
+      "@app/storage": [
+        "libs/storage/src"
+      ],
+      "@app/storage/*": [
+        "libs/storage/src/*"
+      ]
     }
   },
-  "exclude": ["dist", "node_modules", "upload"]
-}
+  "exclude": [
+    "dist",
+    "node_modules",
+    "upload"
+  ]
+}

+ 100 - 6
web/package-lock.json

@@ -12,9 +12,11 @@
 				"cookie": "^0.4.2",
 				"copy-image-clipboard": "^2.1.2",
 				"exifr": "^7.1.3",
+				"handlebars": "^4.7.7",
 				"leaflet": "^1.8.0",
 				"lodash": "^4.17.21",
 				"lodash-es": "^4.17.21",
+				"luxon": "^3.1.1",
 				"socket.io-client": "^4.5.1",
 				"svelte-keydown": "^0.5.0",
 				"svelte-material-icons": "^2.0.2"
@@ -34,6 +36,7 @@
 				"@types/leaflet": "^1.7.10",
 				"@types/lodash": "^4.14.182",
 				"@types/lodash-es": "^4.17.6",
+				"@types/luxon": "^3.1.0",
 				"@types/socket.io-client": "^3.0.0",
 				"@typescript-eslint/eslint-plugin": "^5.27.0",
 				"@typescript-eslint/parser": "^5.27.0",
@@ -3319,6 +3322,12 @@
 				"@types/lodash": "*"
 			}
 		},
+		"node_modules/@types/luxon": {
+			"version": "3.1.0",
+			"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.1.0.tgz",
+			"integrity": "sha512-gCd/HcCgjqSxfMrgtqxCgYk/22NBQfypwFUG7ZAyG/4pqs51WLTcUzVp1hqTbieDYeHS3WoVEh2Yv/2l+7B0Vg==",
+			"dev": true
+		},
 		"node_modules/@types/node": {
 			"version": "18.11.11",
 			"resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.11.tgz",
@@ -6149,6 +6158,26 @@
 			"integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==",
 			"dev": true
 		},
+		"node_modules/handlebars": {
+			"version": "4.7.7",
+			"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz",
+			"integrity": "sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==",
+			"dependencies": {
+				"minimist": "^1.2.5",
+				"neo-async": "^2.6.0",
+				"source-map": "^0.6.1",
+				"wordwrap": "^1.0.0"
+			},
+			"bin": {
+				"handlebars": "bin/handlebars"
+			},
+			"engines": {
+				"node": ">=0.4.7"
+			},
+			"optionalDependencies": {
+				"uglify-js": "^3.1.4"
+			}
+		},
 		"node_modules/has": {
 			"version": "1.0.3",
 			"resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
@@ -8976,6 +9005,14 @@
 				"node": ">=10"
 			}
 		},
+		"node_modules/luxon": {
+			"version": "3.1.1",
+			"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.1.1.tgz",
+			"integrity": "sha512-Ah6DloGmvseB/pX1cAmjbFvyU/pKuwQMQqz7d0yvuDlVYLTs2WeDHQMpC8tGjm1da+BriHROW/OEIT/KfYg6xw==",
+			"engines": {
+				"node": ">=12"
+			}
+		},
 		"node_modules/lz-string": {
 			"version": "1.4.4",
 			"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz",
@@ -9114,7 +9151,6 @@
 			"version": "1.2.7",
 			"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.7.tgz",
 			"integrity": "sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==",
-			"dev": true,
 			"funding": {
 				"url": "https://github.com/sponsors/ljharb"
 			}
@@ -9178,6 +9214,11 @@
 			"integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==",
 			"dev": true
 		},
+		"node_modules/neo-async": {
+			"version": "2.6.2",
+			"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
+			"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="
+		},
 		"node_modules/node-int64": {
 			"version": "0.4.0",
 			"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
@@ -10280,7 +10321,6 @@
 			"version": "0.6.1",
 			"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
 			"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
-			"dev": true,
 			"engines": {
 				"node": ">=0.10.0"
 			}
@@ -10835,6 +10875,18 @@
 				"node": ">=4.2.0"
 			}
 		},
+		"node_modules/uglify-js": {
+			"version": "3.17.4",
+			"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz",
+			"integrity": "sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==",
+			"optional": true,
+			"bin": {
+				"uglifyjs": "bin/uglifyjs"
+			},
+			"engines": {
+				"node": ">=0.8.0"
+			}
+		},
 		"node_modules/undici": {
 			"version": "5.13.0",
 			"resolved": "https://registry.npmjs.org/undici/-/undici-5.13.0.tgz",
@@ -11163,6 +11215,11 @@
 				"node": ">=0.10.0"
 			}
 		},
+		"node_modules/wordwrap": {
+			"version": "1.0.0",
+			"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
+			"integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="
+		},
 		"node_modules/wrap-ansi": {
 			"version": "7.0.0",
 			"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
@@ -13726,6 +13783,12 @@
 				"@types/lodash": "*"
 			}
 		},
+		"@types/luxon": {
+			"version": "3.1.0",
+			"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.1.0.tgz",
+			"integrity": "sha512-gCd/HcCgjqSxfMrgtqxCgYk/22NBQfypwFUG7ZAyG/4pqs51WLTcUzVp1hqTbieDYeHS3WoVEh2Yv/2l+7B0Vg==",
+			"dev": true
+		},
 		"@types/node": {
 			"version": "18.11.11",
 			"resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.11.tgz",
@@ -15703,6 +15766,18 @@
 			"integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==",
 			"dev": true
 		},
+		"handlebars": {
+			"version": "4.7.7",
+			"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz",
+			"integrity": "sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==",
+			"requires": {
+				"minimist": "^1.2.5",
+				"neo-async": "^2.6.0",
+				"source-map": "^0.6.1",
+				"uglify-js": "^3.1.4",
+				"wordwrap": "^1.0.0"
+			}
+		},
 		"has": {
 			"version": "1.0.3",
 			"resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
@@ -17789,6 +17864,11 @@
 				"yallist": "^4.0.0"
 			}
 		},
+		"luxon": {
+			"version": "3.1.1",
+			"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.1.1.tgz",
+			"integrity": "sha512-Ah6DloGmvseB/pX1cAmjbFvyU/pKuwQMQqz7d0yvuDlVYLTs2WeDHQMpC8tGjm1da+BriHROW/OEIT/KfYg6xw=="
+		},
 		"lz-string": {
 			"version": "1.4.4",
 			"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz",
@@ -17887,8 +17967,7 @@
 		"minimist": {
 			"version": "1.2.7",
 			"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.7.tgz",
-			"integrity": "sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==",
-			"dev": true
+			"integrity": "sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g=="
 		},
 		"mkdirp": {
 			"version": "0.5.6",
@@ -17934,6 +18013,11 @@
 			"integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==",
 			"dev": true
 		},
+		"neo-async": {
+			"version": "2.6.2",
+			"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
+			"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="
+		},
 		"node-int64": {
 			"version": "0.4.0",
 			"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
@@ -18708,8 +18792,7 @@
 		"source-map": {
 			"version": "0.6.1",
 			"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
-			"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
-			"dev": true
+			"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
 		},
 		"source-map-js": {
 			"version": "1.0.2",
@@ -19092,6 +19175,12 @@
 			"integrity": "sha512-CIfGzTelbKNEnLpLdGFgdyKhG23CKdKgQPOBc+OUNrkJ2vr+KSzsSV5kq5iWhEQbok+quxgGzrAtGWCyU7tHnA==",
 			"dev": true
 		},
+		"uglify-js": {
+			"version": "3.17.4",
+			"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz",
+			"integrity": "sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==",
+			"optional": true
+		},
 		"undici": {
 			"version": "5.13.0",
 			"resolved": "https://registry.npmjs.org/undici/-/undici-5.13.0.tgz",
@@ -19304,6 +19393,11 @@
 			"integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
 			"dev": true
 		},
+		"wordwrap": {
+			"version": "1.0.0",
+			"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
+			"integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="
+		},
 		"wrap-ansi": {
 			"version": "7.0.0",
 			"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",

+ 3 - 0
web/package.json

@@ -32,6 +32,7 @@
 		"@types/leaflet": "^1.7.10",
 		"@types/lodash": "^4.14.182",
 		"@types/lodash-es": "^4.17.6",
+		"@types/luxon": "^3.1.0",
 		"@types/socket.io-client": "^3.0.0",
 		"@typescript-eslint/eslint-plugin": "^5.27.0",
 		"@typescript-eslint/parser": "^5.27.0",
@@ -62,9 +63,11 @@
 		"cookie": "^0.4.2",
 		"copy-image-clipboard": "^2.1.2",
 		"exifr": "^7.1.3",
+		"handlebars": "^4.7.7",
 		"leaflet": "^1.8.0",
 		"lodash": "^4.17.21",
 		"lodash-es": "^4.17.21",
+		"luxon": "^3.1.1",
 		"socket.io-client": "^4.5.1",
 		"svelte-keydown": "^0.5.0",
 		"svelte-material-icons": "^2.0.2"

+ 129 - 1
web/src/api/open-api/api.ts

@@ -4,7 +4,7 @@
  * Immich
  * Immich API
  *
- * The version of the OpenAPI document: 1.38.0
+ * The version of the OpenAPI document: 1.38.2
  * 
  *
  * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
@@ -1443,6 +1443,12 @@ export interface SystemConfigDto {
      * @memberof SystemConfigDto
      */
     'oauth': SystemConfigOAuthDto;
+    /**
+     * 
+     * @type {SystemConfigStorageTemplateDto}
+     * @memberof SystemConfigDto
+     */
+    'storageTemplate': SystemConfigStorageTemplateDto;
 }
 /**
  * 
@@ -1530,6 +1536,68 @@ export interface SystemConfigOAuthDto {
      */
     'autoRegister': boolean;
 }
+/**
+ * 
+ * @export
+ * @interface SystemConfigStorageTemplateDto
+ */
+export interface SystemConfigStorageTemplateDto {
+    /**
+     * 
+     * @type {string}
+     * @memberof SystemConfigStorageTemplateDto
+     */
+    'template': string;
+}
+/**
+ * 
+ * @export
+ * @interface SystemConfigTemplateStorageOptionDto
+ */
+export interface SystemConfigTemplateStorageOptionDto {
+    /**
+     * 
+     * @type {Array<string>}
+     * @memberof SystemConfigTemplateStorageOptionDto
+     */
+    'yearOptions': Array<string>;
+    /**
+     * 
+     * @type {Array<string>}
+     * @memberof SystemConfigTemplateStorageOptionDto
+     */
+    'monthOptions': Array<string>;
+    /**
+     * 
+     * @type {Array<string>}
+     * @memberof SystemConfigTemplateStorageOptionDto
+     */
+    'dayOptions': Array<string>;
+    /**
+     * 
+     * @type {Array<string>}
+     * @memberof SystemConfigTemplateStorageOptionDto
+     */
+    'hourOptions': Array<string>;
+    /**
+     * 
+     * @type {Array<string>}
+     * @memberof SystemConfigTemplateStorageOptionDto
+     */
+    'minuteOptions': Array<string>;
+    /**
+     * 
+     * @type {Array<string>}
+     * @memberof SystemConfigTemplateStorageOptionDto
+     */
+    'secondOptions': Array<string>;
+    /**
+     * 
+     * @type {Array<string>}
+     * @memberof SystemConfigTemplateStorageOptionDto
+     */
+    'presetOptions': Array<string>;
+}
 /**
  * 
  * @export
@@ -5312,6 +5380,39 @@ export const SystemConfigApiAxiosParamCreator = function (configuration?: Config
 
 
     
+            setSearchParams(localVarUrlObj, localVarQueryParameter);
+            let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
+            localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
+
+            return {
+                url: toPathString(localVarUrlObj),
+                options: localVarRequestOptions,
+            };
+        },
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        getStorageTemplateOptions: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            const localVarPath = `/system-config/storage-template-options`;
+            // 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};
@@ -5388,6 +5489,15 @@ export const SystemConfigApiFp = function(configuration?: Configuration) {
             const localVarAxiosArgs = await localVarAxiosParamCreator.getDefaults(options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        async getStorageTemplateOptions(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SystemConfigTemplateStorageOptionDto>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.getStorageTemplateOptions(options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
         /**
          * 
          * @param {SystemConfigDto} systemConfigDto 
@@ -5424,6 +5534,14 @@ export const SystemConfigApiFactory = function (configuration?: Configuration, b
         getDefaults(options?: any): AxiosPromise<SystemConfigDto> {
             return localVarFp.getDefaults(options).then((request) => request(axios, basePath));
         },
+        /**
+         * 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        getStorageTemplateOptions(options?: any): AxiosPromise<SystemConfigTemplateStorageOptionDto> {
+            return localVarFp.getStorageTemplateOptions(options).then((request) => request(axios, basePath));
+        },
         /**
          * 
          * @param {SystemConfigDto} systemConfigDto 
@@ -5463,6 +5581,16 @@ export class SystemConfigApi extends BaseAPI {
         return SystemConfigApiFp(this.configuration).getDefaults(options).then((request) => request(this.axios, this.basePath));
     }
 
+    /**
+     * 
+     * @param {*} [options] Override http request option.
+     * @throws {RequiredError}
+     * @memberof SystemConfigApi
+     */
+    public getStorageTemplateOptions(options?: AxiosRequestConfig) {
+        return SystemConfigApiFp(this.configuration).getStorageTemplateOptions(options).then((request) => request(this.axios, this.basePath));
+    }
+
     /**
      * 
      * @param {SystemConfigDto} systemConfigDto 

+ 1 - 1
web/src/api/open-api/base.ts

@@ -4,7 +4,7 @@
  * Immich
  * Immich API
  *
- * The version of the OpenAPI document: 1.38.0
+ * The version of the OpenAPI document: 1.38.2
  * 
  *
  * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

+ 1 - 1
web/src/api/open-api/common.ts

@@ -4,7 +4,7 @@
  * Immich
  * Immich API
  *
- * The version of the OpenAPI document: 1.38.0
+ * The version of the OpenAPI document: 1.38.2
  * 
  *
  * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

+ 1 - 1
web/src/api/open-api/configuration.ts

@@ -4,7 +4,7 @@
  * Immich
  * Immich API
  *
- * The version of the OpenAPI document: 1.38.0
+ * The version of the OpenAPI document: 1.38.2
  * 
  *
  * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

+ 1 - 1
web/src/api/open-api/index.ts

@@ -4,7 +4,7 @@
  * Immich
  * Immich API
  *
- * The version of the OpenAPI document: 1.38.0
+ * The version of the OpenAPI document: 1.38.2
  * 
  *
  * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

+ 2 - 2
web/src/app.css

@@ -59,11 +59,11 @@ input:focus-visible {
 
 @layer utilities {
 	.immich-form-input {
-		@apply bg-slate-100 p-2 rounded-md focus:border-immich-primary text-sm dark:bg-gray-600 dark:text-immich-dark-fg disabled:bg-gray-500 dark:disabled:bg-gray-900 disabled:cursor-not-allowed;
+		@apply bg-slate-200 p-2 rounded-lg focus:border-immich-primary text-sm dark:bg-gray-600 dark:text-immich-dark-fg disabled:bg-gray-400 dark:disabled:bg-gray-800 disabled:cursor-not-allowed disabled:text-gray-200;
 	}
 
 	.immich-form-label {
-		@apply font-medium text-sm text-gray-500 dark:text-gray-300;
+		@apply font-medium text-gray-500 dark:text-gray-300;
 	}
 
 	.immich-btn-primary {

+ 58 - 54
web/src/lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte

@@ -25,12 +25,12 @@
 			const { data: configs } = await api.systemConfigApi.getConfig();
 
 			const result = await api.systemConfigApi.updateConfig({
-				ffmpeg: ffmpegConfig,
-				oauth: configs.oauth
+				...configs,
+				ffmpeg: ffmpegConfig
 			});
 
-			ffmpegConfig = result.data.ffmpeg;
-			savedConfig = result.data.ffmpeg;
+			ffmpegConfig = { ...result.data.ffmpeg };
+			savedConfig = { ...result.data.ffmpeg };
 
 			notificationController.show({
 				message: 'FFmpeg settings saved',
@@ -48,8 +48,8 @@
 	async function reset() {
 		const { data: resetConfig } = await api.systemConfigApi.getConfig();
 
-		ffmpegConfig = resetConfig.ffmpeg;
-		savedConfig = resetConfig.ffmpeg;
+		ffmpegConfig = { ...resetConfig.ffmpeg };
+		savedConfig = { ...resetConfig.ffmpeg };
 
 		notificationController.show({
 			message: 'Reset FFmpeg settings to the recent saved settings',
@@ -60,8 +60,8 @@
 	async function resetToDefault() {
 		const { data: configs } = await api.systemConfigApi.getDefaults();
 
-		ffmpegConfig = configs.ffmpeg;
-		defaultConfig = configs.ffmpeg;
+		ffmpegConfig = { ...configs.ffmpeg };
+		defaultConfig = { ...configs.ffmpeg };
 
 		notificationController.show({
 			message: 'Reset FFmpeg settings to default',
@@ -74,52 +74,56 @@
 	{#await getConfigs() then}
 		<div in:fade={{ duration: 500 }}>
 			<form autocomplete="off" on:submit|preventDefault>
-				<SettingInputField
-					inputType={SettingInputFieldType.NUMBER}
-					label="CRF"
-					bind:value={ffmpegConfig.crf}
-					required={true}
-					isEdited={!(ffmpegConfig.crf == savedConfig.crf)}
-				/>
-
-				<SettingInputField
-					inputType={SettingInputFieldType.TEXT}
-					label="PRESET"
-					bind:value={ffmpegConfig.preset}
-					required={true}
-					isEdited={!(ffmpegConfig.preset == savedConfig.preset)}
-				/>
-
-				<SettingInputField
-					inputType={SettingInputFieldType.TEXT}
-					label="AUDIO CODEC"
-					bind:value={ffmpegConfig.targetAudioCodec}
-					required={true}
-					isEdited={!(ffmpegConfig.targetAudioCodec == savedConfig.targetAudioCodec)}
-				/>
-
-				<SettingInputField
-					inputType={SettingInputFieldType.TEXT}
-					label="VIDEO CODEC"
-					bind:value={ffmpegConfig.targetVideoCodec}
-					required={true}
-					isEdited={!(ffmpegConfig.targetVideoCodec == savedConfig.targetVideoCodec)}
-				/>
-
-				<SettingInputField
-					inputType={SettingInputFieldType.TEXT}
-					label="SCALING"
-					bind:value={ffmpegConfig.targetScaling}
-					required={true}
-					isEdited={!(ffmpegConfig.targetScaling == savedConfig.targetScaling)}
-				/>
-
-				<SettingButtonsRow
-					on:reset={reset}
-					on:save={saveSetting}
-					on:reset-to-default={resetToDefault}
-					showResetToDefault={!_.isEqual(savedConfig, defaultConfig)}
-				/>
+				<div class="flex flex-col gap-4 ml-4 mt-4">
+					<SettingInputField
+						inputType={SettingInputFieldType.NUMBER}
+						label="CRF"
+						bind:value={ffmpegConfig.crf}
+						required={true}
+						isEdited={!(ffmpegConfig.crf == savedConfig.crf)}
+					/>
+
+					<SettingInputField
+						inputType={SettingInputFieldType.TEXT}
+						label="PRESET"
+						bind:value={ffmpegConfig.preset}
+						required={true}
+						isEdited={!(ffmpegConfig.preset == savedConfig.preset)}
+					/>
+
+					<SettingInputField
+						inputType={SettingInputFieldType.TEXT}
+						label="AUDIO CODEC"
+						bind:value={ffmpegConfig.targetAudioCodec}
+						required={true}
+						isEdited={!(ffmpegConfig.targetAudioCodec == savedConfig.targetAudioCodec)}
+					/>
+
+					<SettingInputField
+						inputType={SettingInputFieldType.TEXT}
+						label="VIDEO CODEC"
+						bind:value={ffmpegConfig.targetVideoCodec}
+						required={true}
+						isEdited={!(ffmpegConfig.targetVideoCodec == savedConfig.targetVideoCodec)}
+					/>
+
+					<SettingInputField
+						inputType={SettingInputFieldType.TEXT}
+						label="SCALING"
+						bind:value={ffmpegConfig.targetScaling}
+						required={true}
+						isEdited={!(ffmpegConfig.targetScaling == savedConfig.targetScaling)}
+					/>
+				</div>
+
+				<div class="ml-4">
+					<SettingButtonsRow
+						on:reset={reset}
+						on:save={saveSetting}
+						on:reset-to-default={resetToDefault}
+						showResetToDefault={!_.isEqual(savedConfig, defaultConfig)}
+					/>
+				</div>
 			</form>
 		</div>
 	{/await}

+ 59 - 56
web/src/lib/components/admin-page/settings/oauth/oauth-settings.svelte

@@ -25,8 +25,8 @@
 	async function reset() {
 		const { data: resetConfig } = await api.systemConfigApi.getConfig();
 
-		oauthConfig = resetConfig.oauth;
-		savedConfig = resetConfig.oauth;
+		oauthConfig = { ...resetConfig.oauth };
+		savedConfig = { ...resetConfig.oauth };
 
 		notificationController.show({
 			message: 'Reset OAuth settings to the last saved settings',
@@ -39,12 +39,12 @@
 			const { data: currentConfig } = await api.systemConfigApi.getConfig();
 
 			const result = await api.systemConfigApi.updateConfig({
-				ffmpeg: currentConfig.ffmpeg,
+				...currentConfig,
 				oauth: oauthConfig
 			});
 
-			oauthConfig = result.data.oauth;
-			savedConfig = result.data.oauth;
+			oauthConfig = { ...result.data.oauth };
+			savedConfig = { ...result.data.oauth };
 
 			notificationController.show({
 				message: 'OAuth settings saved',
@@ -62,7 +62,7 @@
 	async function resetToDefault() {
 		const { data: defaultConfig } = await api.systemConfigApi.getDefaults();
 
-		oauthConfig = defaultConfig.oauth;
+		oauthConfig = { ...defaultConfig.oauth };
 
 		notificationController.show({
 			message: 'Reset OAuth settings to default',
@@ -80,51 +80,52 @@
 				</div>
 
 				<hr class="m-4" />
+				<div class="flex flex-col gap-4 ml-4">
+					<SettingInputField
+						inputType={SettingInputFieldType.TEXT}
+						label="ISSUER URL"
+						bind:value={oauthConfig.issuerUrl}
+						required={true}
+						disabled={!oauthConfig.enabled}
+						isEdited={!(oauthConfig.issuerUrl == savedConfig.issuerUrl)}
+					/>
+
+					<SettingInputField
+						inputType={SettingInputFieldType.TEXT}
+						label="CLIENT ID"
+						bind:value={oauthConfig.clientId}
+						required={true}
+						disabled={!oauthConfig.enabled}
+						isEdited={!(oauthConfig.clientId == savedConfig.clientId)}
+					/>
+
+					<SettingInputField
+						inputType={SettingInputFieldType.TEXT}
+						label="CLIENT SECRET"
+						bind:value={oauthConfig.clientSecret}
+						required={true}
+						disabled={!oauthConfig.enabled}
+						isEdited={!(oauthConfig.clientSecret == savedConfig.clientSecret)}
+					/>
 
-				<SettingInputField
-					inputType={SettingInputFieldType.TEXT}
-					label="ISSUER URL"
-					bind:value={oauthConfig.issuerUrl}
-					required={true}
-					disabled={!oauthConfig.enabled}
-					isEdited={!(oauthConfig.issuerUrl == savedConfig.issuerUrl)}
-				/>
-
-				<SettingInputField
-					inputType={SettingInputFieldType.TEXT}
-					label="CLIENT ID"
-					bind:value={oauthConfig.clientId}
-					required={true}
-					disabled={!oauthConfig.enabled}
-					isEdited={!(oauthConfig.clientId == savedConfig.clientId)}
-				/>
-
-				<SettingInputField
-					inputType={SettingInputFieldType.TEXT}
-					label="CLIENT SECRET"
-					bind:value={oauthConfig.clientSecret}
-					required={true}
-					disabled={!oauthConfig.enabled}
-					isEdited={!(oauthConfig.clientSecret == savedConfig.clientSecret)}
-				/>
-
-				<SettingInputField
-					inputType={SettingInputFieldType.TEXT}
-					label="SCOPE"
-					bind:value={oauthConfig.scope}
-					required={true}
-					disabled={!oauthConfig.enabled}
-					isEdited={!(oauthConfig.scope == savedConfig.scope)}
-				/>
-
-				<SettingInputField
-					inputType={SettingInputFieldType.TEXT}
-					label="BUTTON TEXT"
-					bind:value={oauthConfig.buttonText}
-					required={false}
-					disabled={!oauthConfig.enabled}
-					isEdited={!(oauthConfig.buttonText == savedConfig.buttonText)}
-				/>
+					<SettingInputField
+						inputType={SettingInputFieldType.TEXT}
+						label="SCOPE"
+						bind:value={oauthConfig.scope}
+						required={true}
+						disabled={!oauthConfig.enabled}
+						isEdited={!(oauthConfig.scope == savedConfig.scope)}
+					/>
+
+					<SettingInputField
+						inputType={SettingInputFieldType.TEXT}
+						label="BUTTON TEXT"
+						bind:value={oauthConfig.buttonText}
+						required={false}
+						disabled={!oauthConfig.enabled}
+						isEdited={!(oauthConfig.buttonText == savedConfig.buttonText)}
+					/>
+				</div>
 
 				<div class="mt-4">
 					<SettingSwitch
@@ -135,12 +136,14 @@
 					/>
 				</div>
 
-				<SettingButtonsRow
-					on:reset={reset}
-					on:save={saveSetting}
-					on:reset-to-default={resetToDefault}
-					showResetToDefault={!_.isEqual(savedConfig, defaultConfig)}
-				/>
+				<div class="ml-4">
+					<SettingButtonsRow
+						on:reset={reset}
+						on:save={saveSetting}
+						on:reset-to-default={resetToDefault}
+						showResetToDefault={!_.isEqual(savedConfig, defaultConfig)}
+					/>
+				</div>
 			</form>
 		</div>
 	{/await}

+ 3 - 3
web/src/lib/components/admin-page/settings/setting-buttons-row.svelte

@@ -6,11 +6,11 @@
 	export let showResetToDefault = true;
 </script>
 
-<div class="flex justify-between gap-2 mx-4 mt-8">
+<div class="flex justify-between gap-2  mt-8">
 	<div class="left">
 		{#if showResetToDefault}
 			<button
-				on:click|preventDefault={() => dispatch('reset-to-default')}
+				on:click={() => dispatch('reset-to-default')}
 				class="text-sm dark:text-immich-dark-primary hover:dark:text-immich-dark-primary/75 text-immich-primary hover:text-immich-primary/75 font-medium bg-none"
 			>
 				Reset to default
@@ -20,7 +20,7 @@
 
 	<div class="right">
 		<button
-			on:click|preventDefault={() => dispatch('reset')}
+			on:click={() => dispatch('reset')}
 			class="text-sm bg-gray-500 dark:bg-gray-200 hover:bg-gray-500/75 dark:hover:bg-gray-200/80 px-4 py-2 text-white dark:text-immich-dark-gray rounded-full shadow-md font-medium disabled:opacity-50 disabled:cursor-not-allowed"
 			>Reset
 		</button>

+ 7 - 7
web/src/lib/components/admin-page/settings/setting-input-field.svelte

@@ -12,19 +12,19 @@
 
 	export let inputType: SettingInputFieldType;
 	export let value: string;
-	export let label: string;
+	export let label = '';
 	export let required = false;
 	export let disabled = false;
-	export let isEdited: boolean;
+	export let isEdited = false;
 
 	const handleInput = (e: Event) => {
 		value = (e.target as HTMLInputElement).value;
 	};
 </script>
 
-<div class="m-4 flex flex-col gap-2">
-	<div class="flex place-items-center gap-1">
-		<label class="immich-form-label" for={label}>{label.toUpperCase()} </label>
+<div class="w-full">
+	<div class={`flex place-items-center gap-1 h-[26px]`}>
+		<label class={`immich-form-label text-xs`} for={label}>{label.toUpperCase()} </label>
 		{#if required}
 			<div class="text-red-400">*</div>
 		{/if}
@@ -32,14 +32,14 @@
 		{#if isEdited}
 			<div
 				transition:fly={{ x: 10, duration: 200, easing: quintOut }}
-				class="text-gray-500 text-xs italic"
+				class="bg-orange-100 px-2 rounded-full text-orange-900 text-[10px]"
 			>
 				Unsaved change
 			</div>
 		{/if}
 	</div>
 	<input
-		class="immich-form-input"
+		class="immich-form-input w-full"
 		id={label}
 		name={label}
 		type={inputType}

+ 1 - 1
web/src/lib/components/admin-page/settings/setting-switch.svelte

@@ -7,7 +7,7 @@
 
 <div class="flex justify-between mx-4 place-items-center">
 	<div>
-		<h2 class="immich-form-label">
+		<h2 class="immich-form-label text-sm">
 			{title.toUpperCase()}
 		</h2>
 

+ 227 - 0
web/src/lib/components/admin-page/settings/storate-template/storage-template-settings.svelte

@@ -0,0 +1,227 @@
+<script lang="ts">
+	import {
+		api,
+		SystemConfigStorageTemplateDto,
+		SystemConfigTemplateStorageOptionDto,
+		UserResponseDto
+	} from '@api';
+	import * as luxon from 'luxon';
+	import handlebar from 'handlebars';
+	import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
+	import { fade } from 'svelte/transition';
+	import SupportedDatetimePanel from './supported-datetime-panel.svelte';
+	import SupportedVariablesPanel from './supported-variables-panel.svelte';
+	import SettingButtonsRow from '../setting-buttons-row.svelte';
+	import _ from 'lodash';
+	import {
+		notificationController,
+		NotificationType
+	} from '$lib/components/shared-components/notification/notification';
+	import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
+
+	export let storageConfig: SystemConfigStorageTemplateDto;
+	export let user: UserResponseDto;
+
+	let savedConfig: SystemConfigStorageTemplateDto;
+	let defaultConfig: SystemConfigStorageTemplateDto;
+	let templateOptions: SystemConfigTemplateStorageOptionDto;
+	let selectedPreset = '';
+
+	async function getConfigs() {
+		[savedConfig, defaultConfig, templateOptions] = await Promise.all([
+			api.systemConfigApi.getConfig().then((res) => res.data.storageTemplate),
+			api.systemConfigApi.getDefaults().then((res) => res.data.storageTemplate),
+			api.systemConfigApi.getStorageTemplateOptions().then((res) => res.data)
+		]);
+
+		selectedPreset = templateOptions.presetOptions[0];
+	}
+
+	const getSupportDateTimeFormat = async () => {
+		const { data } = await api.systemConfigApi.getStorageTemplateOptions();
+		return data;
+	};
+
+	$: parsedTemplate = () => {
+		try {
+			return renderTemplate(storageConfig.template);
+		} catch (error) {
+			return 'error';
+		}
+	};
+
+	const renderTemplate = (templateString: string) => {
+		const template = handlebar.compile(templateString, {
+			knownHelpers: undefined
+		});
+
+		const substitutions: Record<string, string> = {
+			filename: 'IMG_10041123',
+			ext: 'jpeg'
+		};
+
+		const dt = luxon.DateTime.fromISO(new Date('2022-09-04T20:03:05.250').toISOString());
+
+		const dateTokens = [
+			...templateOptions.yearOptions,
+			...templateOptions.monthOptions,
+			...templateOptions.dayOptions,
+			...templateOptions.hourOptions,
+			...templateOptions.minuteOptions,
+			...templateOptions.secondOptions
+		];
+
+		for (const token of dateTokens) {
+			substitutions[token] = dt.toFormat(token);
+		}
+
+		return template(substitutions);
+	};
+
+	async function reset() {
+		const { data: resetConfig } = await api.systemConfigApi.getConfig();
+
+		storageConfig.template = resetConfig.storageTemplate.template;
+		savedConfig.template = resetConfig.storageTemplate.template;
+
+		notificationController.show({
+			message: 'Reset storage template settings to the recent saved settings',
+			type: NotificationType.Info
+		});
+	}
+
+	async function saveSetting() {
+		try {
+			const { data: currentConfig } = await api.systemConfigApi.getConfig();
+
+			const result = await api.systemConfigApi.updateConfig({
+				...currentConfig,
+				storageTemplate: storageConfig
+			});
+
+			storageConfig.template = result.data.storageTemplate.template;
+			savedConfig.template = result.data.storageTemplate.template;
+
+			notificationController.show({
+				message: 'Storage template saved',
+				type: NotificationType.Info
+			});
+		} catch (e) {
+			console.error('Error [storage-template-settings] [saveSetting]', e);
+			notificationController.show({
+				message: 'Unable to save settings',
+				type: NotificationType.Error
+			});
+		}
+	}
+
+	async function resetToDefault() {
+		const { data: defaultConfig } = await api.systemConfigApi.getDefaults();
+
+		storageConfig.template = defaultConfig.storageTemplate.template;
+
+		notificationController.show({
+			message: 'Reset storage template to default',
+			type: NotificationType.Info
+		});
+	}
+
+	const handlePresetSelection = () => {
+		storageConfig.template = selectedPreset;
+	};
+</script>
+
+<section class="dark:text-immich-dark-fg">
+	{#await getConfigs() then}
+		<div id="directory-path-builder" class="m-4">
+			<h3 class="font-medium text-immich-primary dark:text-immich-dark-primary text-base">
+				Variables
+			</h3>
+
+			<section class="support-date">
+				{#await getSupportDateTimeFormat()}
+					<LoadingSpinner />
+				{:then options}
+					<div transition:fade={{ duration: 200 }}>
+						<SupportedDatetimePanel {options} />
+					</div>
+				{/await}
+			</section>
+
+			<section class="support-date">
+				<SupportedVariablesPanel />
+			</section>
+
+			<div class="mt-4 flex flex-col">
+				<h3 class="font-medium text-immich-primary dark:text-immich-dark-primary text-base">
+					Template
+				</h3>
+
+				<div class="text-xs my-2">
+					<h4>PREVIEW</h4>
+				</div>
+
+				<p class="text-xs">
+					Approximately path length limit : <span
+						class="font-semibold text-immich-primary dark:text-immich-dark-primary"
+						>{parsedTemplate().length + user.id.length + 'UPLOAD_LOCATION'.length}</span
+					>/260
+				</p>
+
+				<p class="text-xs">
+					{user.id} is the user's ID
+				</p>
+
+				<p
+					class="text-xs p-4 bg-gray-200 dark:bg-gray-700 dark:text-immich-dark-fg py-2 rounded-lg mt-2"
+				>
+					<span class="text-immich-fg/25 dark:text-immich-dark-fg/50"
+						>UPLOAD_LOCATION/{user.id}</span
+					>/{parsedTemplate()}.jpeg
+				</p>
+
+				<form autocomplete="off" class="flex flex-col" on:submit|preventDefault>
+					<div class="flex flex-col my-2">
+						<label class="text-xs" for="presets">PRESET</label>
+						<select
+							class="text-sm bg-slate-200 p-2 rounded-lg mt-2 dark:bg-gray-600 hover:cursor-pointer"
+							name="presets"
+							id="preset-select"
+							bind:value={selectedPreset}
+							on:change={handlePresetSelection}
+						>
+							{#each templateOptions.presetOptions as preset}
+								<option value={preset}>{renderTemplate(preset)}</option>
+							{/each}
+						</select>
+					</div>
+					<div class="flex gap-2 align-bottom">
+						<SettingInputField
+							label="template"
+							required
+							inputType={SettingInputFieldType.TEXT}
+							bind:value={storageConfig.template}
+							isEdited={!(storageConfig.template === savedConfig.template)}
+						/>
+
+						<div class="flex-0">
+							<SettingInputField
+								label="Extension"
+								inputType={SettingInputFieldType.TEXT}
+								value={'.jpeg'}
+								disabled
+							/>
+						</div>
+					</div>
+
+					<SettingButtonsRow
+						on:reset={reset}
+						on:save={saveSetting}
+						on:reset-to-default={resetToDefault}
+						showResetToDefault={!_.isEqual(savedConfig, defaultConfig)}
+					/>
+				</form>
+			</div>
+		</div>
+	{/await}
+</section>

+ 78 - 0
web/src/lib/components/admin-page/settings/storate-template/supported-datetime-panel.svelte

@@ -0,0 +1,78 @@
+<script lang="ts">
+	import { SystemConfigTemplateStorageOptionDto } from '@api';
+	import * as luxon from 'luxon';
+
+	export let options: SystemConfigTemplateStorageOptionDto;
+
+	const getLuxonExample = (format: string) => {
+		return luxon.DateTime.fromISO(new Date('2022-09-04T20:03:05.250').toISOString()).toFormat(
+			format
+		);
+	};
+</script>
+
+<div class="text-xs mt-2">
+	<h4>DATE & TIME</h4>
+</div>
+
+<div class="text-xs bg-gray-200 dark:bg-gray-700 dark:text-immich-dark-fg p-4 mt-2 rounded-lg">
+	<div class="mb-2 text-gray-600 dark:text-immich-dark-fg">
+		<p>Asset's creation timestamp is used for the datetime information</p>
+		<p>Sample time 2022-09-04T20:03:05.250</p>
+	</div>
+	<div class="flex gap-[50px]">
+		<div>
+			<p class="text-immich-primary font-medium dark:text-immich-dark-primary">YEAR</p>
+			<ul>
+				{#each options.yearOptions as yearFormat}
+					<li>{'{{'}{yearFormat}{'}}'} - {getLuxonExample(yearFormat)}</li>
+				{/each}
+			</ul>
+		</div>
+
+		<div>
+			<p class="text-immich-primary font-medium dark:text-immich-dark-primary">MONTH</p>
+			<ul>
+				{#each options.monthOptions as monthFormat}
+					<li>{'{{'}{monthFormat}{'}}'} - {getLuxonExample(monthFormat)}</li>
+				{/each}
+			</ul>
+		</div>
+
+		<div>
+			<p class="text-immich-primary font-medium dark:text-immich-dark-primary">DAY</p>
+			<ul>
+				{#each options.dayOptions as dayFormat}
+					<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
+				{/each}
+			</ul>
+		</div>
+
+		<div>
+			<p class="text-immich-primary font-medium dark:text-immich-dark-primary">HOUR</p>
+			<ul>
+				{#each options.hourOptions as dayFormat}
+					<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
+				{/each}
+			</ul>
+		</div>
+
+		<div>
+			<p class="text-immich-primary font-medium dark:text-immich-dark-primary">MINUTE</p>
+			<ul>
+				{#each options.minuteOptions as dayFormat}
+					<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
+				{/each}
+			</ul>
+		</div>
+
+		<div>
+			<p class="text-immich-primary font-medium dark:text-immich-dark-primary">SECOND</p>
+			<ul>
+				{#each options.secondOptions as dayFormat}
+					<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
+				{/each}
+			</ul>
+		</div>
+	</div>
+</div>

+ 21 - 0
web/src/lib/components/admin-page/settings/storate-template/supported-variables-panel.svelte

@@ -0,0 +1,21 @@
+<div class="text-xs mt-4">
+	<h4>OTHER VARIABLES</h4>
+</div>
+
+<div class="text-xs bg-gray-200 dark:bg-gray-700 dark:text-immich-dark-fg p-4 mt-2 rounded-lg">
+	<div class="flex gap-[50px]">
+		<div>
+			<p class="text-immich-primary font-medium dark:text-immich-dark-primary">FILE NAME</p>
+			<ul>
+				<li>{`{{filename}}`}</li>
+			</ul>
+		</div>
+
+		<div>
+			<p class="text-immich-primary font-medium dark:text-immich-dark-primary">FILE EXTENSION</p>
+			<ul>
+				<li>{`{{ext}}`}</li>
+			</ul>
+		</div>
+	</div>
+</div>

+ 10 - 1
web/src/routes/admin/settings/+page.svelte

@@ -2,11 +2,13 @@
 	import FFmpegSettings from '$lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte';
 	import OAuthSettings from '$lib/components/admin-page/settings/oauth/oauth-settings.svelte';
 	import SettingAccordion from '$lib/components/admin-page/settings/setting-accordion.svelte';
+	import StorageTemplateSettings from '$lib/components/admin-page/settings/storate-template/storage-template-settings.svelte';
 	import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
 	import { api, SystemConfigDto } from '@api';
+	import type { PageData } from './$types';
 
 	let systemConfig: SystemConfigDto;
-
+	export let data: PageData;
 	const getConfig = async () => {
 		const { data } = await api.systemConfigApi.getConfig();
 		systemConfig = data;
@@ -33,5 +35,12 @@
 		<SettingAccordion title="OAuth Settings" subtitle="Manage the OAuth integration to Immich app">
 			<OAuthSettings oauthConfig={configs.oauth} />
 		</SettingAccordion>
+
+		<SettingAccordion
+			title="Storage Template"
+			subtitle="Manage the folder structure and file name of the upload asset"
+		>
+			<StorageTemplateSettings storageConfig={configs.storageTemplate} user={data.user} />
+		</SettingAccordion>
 	{/await}
 </section>

+ 0 - 1
web/src/routes/admin/user-management/+page.svelte

@@ -22,7 +22,6 @@
 
 	onMount(() => {
 		allUsers = $page.data.allUsers;
-		console.log('getting all users', allUsers);
 	});
 
 	const isDeleted = (user: UserResponseDto): boolean => {