فهرست منبع

feat(web/server): webp thumbnail size configurable (#3598)

* feat(server/web): webp thumbnail size configurable

* update api

* add ui and fix test

* lint

* setting for jpeg size

* feat: coerce to number

* api

* jpeg resolution

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
Alex 1 سال پیش
والد
کامیت
ddd4ec2d9e
26فایلهای تغییر یافته به همراه429 افزوده شده و 18 حذف شده
  1. 25 0
      cli/src/api/open-api/api.ts
  2. 3 0
      mobile/openapi/.openapi-generator/FILES
  3. 1 0
      mobile/openapi/README.md
  4. 1 0
      mobile/openapi/doc/SystemConfigDto.md
  5. 16 0
      mobile/openapi/doc/SystemConfigThumbnailDto.md
  6. 1 0
      mobile/openapi/lib/api.dart
  7. 2 0
      mobile/openapi/lib/api_client.dart
  8. 11 3
      mobile/openapi/lib/model/system_config_dto.dart
  9. 106 0
      mobile/openapi/lib/model/system_config_thumbnail_dto.dart
  10. 5 0
      mobile/openapi/test/system_config_dto_test.dart
  11. 32 0
      mobile/openapi/test/system_config_thumbnail_dto_test.dart
  12. 20 1
      server/immich-openapi-specs.json
  13. 0 2
      server/src/domain/media/media.constant.ts
  14. 5 4
      server/src/domain/media/media.service.ts
  15. 1 0
      server/src/domain/system-config/dto/index.ts
  16. 15 0
      server/src/domain/system-config/dto/system-config-thumbnail.dto.ts
  17. 6 0
      server/src/domain/system-config/dto/system-config.dto.ts
  18. 5 0
      server/src/domain/system-config/system-config.core.ts
  19. 4 0
      server/src/domain/system-config/system-config.service.spec.ts
  20. 7 0
      server/src/infra/entities/system-config.entity.ts
  21. 2 2
      server/src/infra/repositories/media.repository.ts
  22. 25 0
      web/src/api/open-api/api.ts
  23. 2 2
      web/src/lib/components/admin-page/settings/setting-input-field.svelte
  24. 8 4
      web/src/lib/components/admin-page/settings/setting-select.svelte
  25. 121 0
      web/src/lib/components/admin-page/settings/thumbnail/thumbnail-settings.svelte
  26. 5 0
      web/src/routes/admin/system-settings/+page.svelte

+ 25 - 0
cli/src/api/open-api/api.ts

@@ -2425,6 +2425,12 @@ export interface SystemConfigDto {
      * @memberof SystemConfigDto
      * @memberof SystemConfigDto
      */
      */
     'storageTemplate': SystemConfigStorageTemplateDto;
     'storageTemplate': SystemConfigStorageTemplateDto;
+    /**
+     * 
+     * @type {SystemConfigThumbnailDto}
+     * @memberof SystemConfigDto
+     */
+    'thumbnail': SystemConfigThumbnailDto;
 }
 }
 /**
 /**
  * 
  * 
@@ -2716,6 +2722,25 @@ export interface SystemConfigTemplateStorageOptionDto {
      */
      */
     'yearOptions': Array<string>;
     'yearOptions': Array<string>;
 }
 }
+/**
+ * 
+ * @export
+ * @interface SystemConfigThumbnailDto
+ */
+export interface SystemConfigThumbnailDto {
+    /**
+     * 
+     * @type {number}
+     * @memberof SystemConfigThumbnailDto
+     */
+    'jpegSize': number;
+    /**
+     * 
+     * @type {number}
+     * @memberof SystemConfigThumbnailDto
+     */
+    'webpSize': number;
+}
 /**
 /**
  * 
  * 
  * @export
  * @export

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

@@ -104,6 +104,7 @@ doc/SystemConfigOAuthDto.md
 doc/SystemConfigPasswordLoginDto.md
 doc/SystemConfigPasswordLoginDto.md
 doc/SystemConfigStorageTemplateDto.md
 doc/SystemConfigStorageTemplateDto.md
 doc/SystemConfigTemplateStorageOptionDto.md
 doc/SystemConfigTemplateStorageOptionDto.md
+doc/SystemConfigThumbnailDto.md
 doc/TagApi.md
 doc/TagApi.md
 doc/TagResponseDto.md
 doc/TagResponseDto.md
 doc/TagTypeEnum.md
 doc/TagTypeEnum.md
@@ -236,6 +237,7 @@ lib/model/system_config_o_auth_dto.dart
 lib/model/system_config_password_login_dto.dart
 lib/model/system_config_password_login_dto.dart
 lib/model/system_config_storage_template_dto.dart
 lib/model/system_config_storage_template_dto.dart
 lib/model/system_config_template_storage_option_dto.dart
 lib/model/system_config_template_storage_option_dto.dart
+lib/model/system_config_thumbnail_dto.dart
 lib/model/tag_response_dto.dart
 lib/model/tag_response_dto.dart
 lib/model/tag_type_enum.dart
 lib/model/tag_type_enum.dart
 lib/model/thumbnail_format.dart
 lib/model/thumbnail_format.dart
@@ -355,6 +357,7 @@ test/system_config_o_auth_dto_test.dart
 test/system_config_password_login_dto_test.dart
 test/system_config_password_login_dto_test.dart
 test/system_config_storage_template_dto_test.dart
 test/system_config_storage_template_dto_test.dart
 test/system_config_template_storage_option_dto_test.dart
 test/system_config_template_storage_option_dto_test.dart
+test/system_config_thumbnail_dto_test.dart
 test/tag_api_test.dart
 test/tag_api_test.dart
 test/tag_response_dto_test.dart
 test/tag_response_dto_test.dart
 test/tag_type_enum_test.dart
 test/tag_type_enum_test.dart

+ 1 - 0
mobile/openapi/README.md

@@ -267,6 +267,7 @@ Class | Method | HTTP request | Description
  - [SystemConfigPasswordLoginDto](doc//SystemConfigPasswordLoginDto.md)
  - [SystemConfigPasswordLoginDto](doc//SystemConfigPasswordLoginDto.md)
  - [SystemConfigStorageTemplateDto](doc//SystemConfigStorageTemplateDto.md)
  - [SystemConfigStorageTemplateDto](doc//SystemConfigStorageTemplateDto.md)
  - [SystemConfigTemplateStorageOptionDto](doc//SystemConfigTemplateStorageOptionDto.md)
  - [SystemConfigTemplateStorageOptionDto](doc//SystemConfigTemplateStorageOptionDto.md)
+ - [SystemConfigThumbnailDto](doc//SystemConfigThumbnailDto.md)
  - [TagResponseDto](doc//TagResponseDto.md)
  - [TagResponseDto](doc//TagResponseDto.md)
  - [TagTypeEnum](doc//TagTypeEnum.md)
  - [TagTypeEnum](doc//TagTypeEnum.md)
  - [ThumbnailFormat](doc//ThumbnailFormat.md)
  - [ThumbnailFormat](doc//ThumbnailFormat.md)

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

@@ -13,6 +13,7 @@ Name | Type | Description | Notes
 **oauth** | [**SystemConfigOAuthDto**](SystemConfigOAuthDto.md) |  | 
 **oauth** | [**SystemConfigOAuthDto**](SystemConfigOAuthDto.md) |  | 
 **passwordLogin** | [**SystemConfigPasswordLoginDto**](SystemConfigPasswordLoginDto.md) |  | 
 **passwordLogin** | [**SystemConfigPasswordLoginDto**](SystemConfigPasswordLoginDto.md) |  | 
 **storageTemplate** | [**SystemConfigStorageTemplateDto**](SystemConfigStorageTemplateDto.md) |  | 
 **storageTemplate** | [**SystemConfigStorageTemplateDto**](SystemConfigStorageTemplateDto.md) |  | 
+**thumbnail** | [**SystemConfigThumbnailDto**](SystemConfigThumbnailDto.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)
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 
 

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

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

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

@@ -132,6 +132,7 @@ part 'model/system_config_o_auth_dto.dart';
 part 'model/system_config_password_login_dto.dart';
 part 'model/system_config_password_login_dto.dart';
 part 'model/system_config_storage_template_dto.dart';
 part 'model/system_config_storage_template_dto.dart';
 part 'model/system_config_template_storage_option_dto.dart';
 part 'model/system_config_template_storage_option_dto.dart';
+part 'model/system_config_thumbnail_dto.dart';
 part 'model/tag_response_dto.dart';
 part 'model/tag_response_dto.dart';
 part 'model/tag_type_enum.dart';
 part 'model/tag_type_enum.dart';
 part 'model/thumbnail_format.dart';
 part 'model/thumbnail_format.dart';

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

@@ -359,6 +359,8 @@ class ApiClient {
           return SystemConfigStorageTemplateDto.fromJson(value);
           return SystemConfigStorageTemplateDto.fromJson(value);
         case 'SystemConfigTemplateStorageOptionDto':
         case 'SystemConfigTemplateStorageOptionDto':
           return SystemConfigTemplateStorageOptionDto.fromJson(value);
           return SystemConfigTemplateStorageOptionDto.fromJson(value);
+        case 'SystemConfigThumbnailDto':
+          return SystemConfigThumbnailDto.fromJson(value);
         case 'TagResponseDto':
         case 'TagResponseDto':
           return TagResponseDto.fromJson(value);
           return TagResponseDto.fromJson(value);
         case 'TagTypeEnum':
         case 'TagTypeEnum':

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

@@ -18,6 +18,7 @@ class SystemConfigDto {
     required this.oauth,
     required this.oauth,
     required this.passwordLogin,
     required this.passwordLogin,
     required this.storageTemplate,
     required this.storageTemplate,
+    required this.thumbnail,
   });
   });
 
 
   SystemConfigFFmpegDto ffmpeg;
   SystemConfigFFmpegDto ffmpeg;
@@ -30,13 +31,16 @@ class SystemConfigDto {
 
 
   SystemConfigStorageTemplateDto storageTemplate;
   SystemConfigStorageTemplateDto storageTemplate;
 
 
+  SystemConfigThumbnailDto thumbnail;
+
   @override
   @override
   bool operator ==(Object other) => identical(this, other) || other is SystemConfigDto &&
   bool operator ==(Object other) => identical(this, other) || other is SystemConfigDto &&
      other.ffmpeg == ffmpeg &&
      other.ffmpeg == ffmpeg &&
      other.job == job &&
      other.job == job &&
      other.oauth == oauth &&
      other.oauth == oauth &&
      other.passwordLogin == passwordLogin &&
      other.passwordLogin == passwordLogin &&
-     other.storageTemplate == storageTemplate;
+     other.storageTemplate == storageTemplate &&
+     other.thumbnail == thumbnail;
 
 
   @override
   @override
   int get hashCode =>
   int get hashCode =>
@@ -45,10 +49,11 @@ class SystemConfigDto {
     (job.hashCode) +
     (job.hashCode) +
     (oauth.hashCode) +
     (oauth.hashCode) +
     (passwordLogin.hashCode) +
     (passwordLogin.hashCode) +
-    (storageTemplate.hashCode);
+    (storageTemplate.hashCode) +
+    (thumbnail.hashCode);
 
 
   @override
   @override
-  String toString() => 'SystemConfigDto[ffmpeg=$ffmpeg, job=$job, oauth=$oauth, passwordLogin=$passwordLogin, storageTemplate=$storageTemplate]';
+  String toString() => 'SystemConfigDto[ffmpeg=$ffmpeg, job=$job, oauth=$oauth, passwordLogin=$passwordLogin, storageTemplate=$storageTemplate, thumbnail=$thumbnail]';
 
 
   Map<String, dynamic> toJson() {
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
     final json = <String, dynamic>{};
@@ -57,6 +62,7 @@ class SystemConfigDto {
       json[r'oauth'] = this.oauth;
       json[r'oauth'] = this.oauth;
       json[r'passwordLogin'] = this.passwordLogin;
       json[r'passwordLogin'] = this.passwordLogin;
       json[r'storageTemplate'] = this.storageTemplate;
       json[r'storageTemplate'] = this.storageTemplate;
+      json[r'thumbnail'] = this.thumbnail;
     return json;
     return json;
   }
   }
 
 
@@ -73,6 +79,7 @@ class SystemConfigDto {
         oauth: SystemConfigOAuthDto.fromJson(json[r'oauth'])!,
         oauth: SystemConfigOAuthDto.fromJson(json[r'oauth'])!,
         passwordLogin: SystemConfigPasswordLoginDto.fromJson(json[r'passwordLogin'])!,
         passwordLogin: SystemConfigPasswordLoginDto.fromJson(json[r'passwordLogin'])!,
         storageTemplate: SystemConfigStorageTemplateDto.fromJson(json[r'storageTemplate'])!,
         storageTemplate: SystemConfigStorageTemplateDto.fromJson(json[r'storageTemplate'])!,
+        thumbnail: SystemConfigThumbnailDto.fromJson(json[r'thumbnail'])!,
       );
       );
     }
     }
     return null;
     return null;
@@ -125,6 +132,7 @@ class SystemConfigDto {
     'oauth',
     'oauth',
     'passwordLogin',
     'passwordLogin',
     'storageTemplate',
     'storageTemplate',
+    'thumbnail',
   };
   };
 }
 }
 
 

+ 106 - 0
mobile/openapi/lib/model/system_config_thumbnail_dto.dart

@@ -0,0 +1,106 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class SystemConfigThumbnailDto {
+  /// Returns a new [SystemConfigThumbnailDto] instance.
+  SystemConfigThumbnailDto({
+    required this.jpegSize,
+    required this.webpSize,
+  });
+
+  int jpegSize;
+
+  int webpSize;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is SystemConfigThumbnailDto &&
+     other.jpegSize == jpegSize &&
+     other.webpSize == webpSize;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (jpegSize.hashCode) +
+    (webpSize.hashCode);
+
+  @override
+  String toString() => 'SystemConfigThumbnailDto[jpegSize=$jpegSize, webpSize=$webpSize]';
+
+  Map<String, dynamic> toJson() {
+    final json = <String, dynamic>{};
+      json[r'jpegSize'] = this.jpegSize;
+      json[r'webpSize'] = this.webpSize;
+    return json;
+  }
+
+  /// Returns a new [SystemConfigThumbnailDto] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static SystemConfigThumbnailDto? fromJson(dynamic value) {
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      return SystemConfigThumbnailDto(
+        jpegSize: mapValueOfType<int>(json, r'jpegSize')!,
+        webpSize: mapValueOfType<int>(json, r'webpSize')!,
+      );
+    }
+    return null;
+  }
+
+  static List<SystemConfigThumbnailDto> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <SystemConfigThumbnailDto>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = SystemConfigThumbnailDto.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, SystemConfigThumbnailDto> mapFromJson(dynamic json) {
+    final map = <String, SystemConfigThumbnailDto>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = SystemConfigThumbnailDto.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of SystemConfigThumbnailDto-objects as value to a dart map
+  static Map<String, List<SystemConfigThumbnailDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<SystemConfigThumbnailDto>>{};
+    if (json is Map && json.isNotEmpty) {
+      // ignore: parameter_assignments
+      json = json.cast<String, dynamic>();
+      for (final entry in json.entries) {
+        map[entry.key] = SystemConfigThumbnailDto.listFromJson(entry.value, growable: growable,);
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'jpegSize',
+    'webpSize',
+  };
+}
+

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

@@ -41,6 +41,11 @@ void main() {
       // TODO
       // TODO
     });
     });
 
 
+    // SystemConfigThumbnailDto thumbnail
+    test('to test the property `thumbnail`', () async {
+      // TODO
+    });
+
 
 
   });
   });
 
 

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

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

+ 20 - 1
server/immich-openapi-specs.json

@@ -6590,6 +6590,9 @@
           },
           },
           "storageTemplate": {
           "storageTemplate": {
             "$ref": "#/components/schemas/SystemConfigStorageTemplateDto"
             "$ref": "#/components/schemas/SystemConfigStorageTemplateDto"
+          },
+          "thumbnail": {
+            "$ref": "#/components/schemas/SystemConfigThumbnailDto"
           }
           }
         },
         },
         "required": [
         "required": [
@@ -6597,7 +6600,8 @@
           "oauth",
           "oauth",
           "passwordLogin",
           "passwordLogin",
           "storageTemplate",
           "storageTemplate",
-          "job"
+          "job",
+          "thumbnail"
         ],
         ],
         "type": "object"
         "type": "object"
       },
       },
@@ -6828,6 +6832,21 @@
         ],
         ],
         "type": "object"
         "type": "object"
       },
       },
+      "SystemConfigThumbnailDto": {
+        "properties": {
+          "jpegSize": {
+            "type": "integer"
+          },
+          "webpSize": {
+            "type": "integer"
+          }
+        },
+        "required": [
+          "webpSize",
+          "jpegSize"
+        ],
+        "type": "object"
+      },
       "TagResponseDto": {
       "TagResponseDto": {
         "properties": {
         "properties": {
           "id": {
           "id": {

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

@@ -1,3 +1 @@
-export const JPEG_THUMBNAIL_SIZE = 1440;
-export const WEBP_THUMBNAIL_SIZE = 250;
 export const FACE_THUMBNAIL_SIZE = 250;
 export const FACE_THUMBNAIL_SIZE = 250;

+ 5 - 4
server/src/domain/media/media.service.ts

@@ -7,7 +7,6 @@ import { IBaseJob, IEntityJob, IJobRepository, JobName, JOBS_ASSET_PAGINATION_SI
 import { IStorageRepository, StorageCore, StorageFolder } from '../storage';
 import { IStorageRepository, StorageCore, StorageFolder } from '../storage';
 import { ISystemConfigRepository, SystemConfigFFmpegDto } from '../system-config';
 import { ISystemConfigRepository, SystemConfigFFmpegDto } from '../system-config';
 import { SystemConfigCore } from '../system-config/system-config.core';
 import { SystemConfigCore } from '../system-config/system-config.core';
-import { JPEG_THUMBNAIL_SIZE, WEBP_THUMBNAIL_SIZE } from './media.constant';
 import { AudioStreamInfo, IMediaRepository, VideoCodecHWConfig, VideoStreamInfo } from './media.repository';
 import { AudioStreamInfo, IMediaRepository, VideoCodecHWConfig, VideoStreamInfo } from './media.repository';
 import { H264Config, HEVCConfig, NVENCConfig, QSVConfig, ThumbnailConfig, VAAPIConfig, VP9Config } from './media.util';
 import { H264Config, HEVCConfig, NVENCConfig, QSVConfig, ThumbnailConfig, VAAPIConfig, VP9Config } from './media.util';
 
 
@@ -63,11 +62,12 @@ export class MediaService {
     const resizePath = this.storageCore.getFolderLocation(StorageFolder.THUMBNAILS, asset.ownerId);
     const resizePath = this.storageCore.getFolderLocation(StorageFolder.THUMBNAILS, asset.ownerId);
     this.storageRepository.mkdirSync(resizePath);
     this.storageRepository.mkdirSync(resizePath);
     const jpegThumbnailPath = join(resizePath, `${asset.id}.jpeg`);
     const jpegThumbnailPath = join(resizePath, `${asset.id}.jpeg`);
+    const { thumbnail } = await this.configCore.getConfig();
 
 
     switch (asset.type) {
     switch (asset.type) {
       case AssetType.IMAGE:
       case AssetType.IMAGE:
         await this.mediaRepository.resize(asset.originalPath, jpegThumbnailPath, {
         await this.mediaRepository.resize(asset.originalPath, jpegThumbnailPath, {
-          size: JPEG_THUMBNAIL_SIZE,
+          size: thumbnail.jpegSize,
           format: 'jpeg',
           format: 'jpeg',
         });
         });
         this.logger.log(`Successfully generated image thumbnail ${asset.id}`);
         this.logger.log(`Successfully generated image thumbnail ${asset.id}`);
@@ -80,7 +80,7 @@ export class MediaService {
           return false;
           return false;
         }
         }
         const { ffmpeg } = await this.configCore.getConfig();
         const { ffmpeg } = await this.configCore.getConfig();
-        const config = { ...ffmpeg, targetResolution: JPEG_THUMBNAIL_SIZE.toString(), twoPass: false };
+        const config = { ...ffmpeg, targetResolution: thumbnail.jpegSize.toString(), twoPass: false };
         const options = new ThumbnailConfig(config).getOptions(mainVideoStream);
         const options = new ThumbnailConfig(config).getOptions(mainVideoStream);
         await this.mediaRepository.transcode(asset.originalPath, jpegThumbnailPath, options);
         await this.mediaRepository.transcode(asset.originalPath, jpegThumbnailPath, options);
         this.logger.log(`Successfully generated video thumbnail ${asset.id}`);
         this.logger.log(`Successfully generated video thumbnail ${asset.id}`);
@@ -100,7 +100,8 @@ export class MediaService {
 
 
     const webpPath = asset.resizePath.replace('jpeg', 'webp').replace('jpg', 'webp');
     const webpPath = asset.resizePath.replace('jpeg', 'webp').replace('jpg', 'webp');
 
 
-    await this.mediaRepository.resize(asset.resizePath, webpPath, { size: WEBP_THUMBNAIL_SIZE, format: 'webp' });
+    const { thumbnail } = await this.configCore.getConfig();
+    await this.mediaRepository.resize(asset.resizePath, webpPath, { size: thumbnail.webpSize, format: 'webp' });
     await this.assetRepository.save({ id: asset.id, webpPath });
     await this.assetRepository.save({ id: asset.id, webpPath });
 
 
     return true;
     return true;

+ 1 - 0
server/src/domain/system-config/dto/index.ts

@@ -2,4 +2,5 @@ export * from './system-config-ffmpeg.dto';
 export * from './system-config-oauth.dto';
 export * from './system-config-oauth.dto';
 export * from './system-config-password-login.dto';
 export * from './system-config-password-login.dto';
 export * from './system-config-storage-template.dto';
 export * from './system-config-storage-template.dto';
+export * from './system-config-thumbnail.dto';
 export * from './system-config.dto';
 export * from './system-config.dto';

+ 15 - 0
server/src/domain/system-config/dto/system-config-thumbnail.dto.ts

@@ -0,0 +1,15 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { Type } from 'class-transformer';
+import { IsInt } from 'class-validator';
+
+export class SystemConfigThumbnailDto {
+  @IsInt()
+  @Type(() => Number)
+  @ApiProperty({ type: 'integer' })
+  webpSize!: number;
+
+  @IsInt()
+  @Type(() => Number)
+  @ApiProperty({ type: 'integer' })
+  jpegSize!: number;
+}

+ 6 - 0
server/src/domain/system-config/dto/system-config.dto.ts

@@ -1,3 +1,4 @@
+import { SystemConfigThumbnailDto } from '@app/domain/system-config';
 import { SystemConfig } from '@app/infra/entities';
 import { SystemConfig } from '@app/infra/entities';
 import { Type } from 'class-transformer';
 import { Type } from 'class-transformer';
 import { IsObject, ValidateNested } from 'class-validator';
 import { IsObject, ValidateNested } from 'class-validator';
@@ -32,6 +33,11 @@ export class SystemConfigDto {
   @ValidateNested()
   @ValidateNested()
   @IsObject()
   @IsObject()
   job!: SystemConfigJobDto;
   job!: SystemConfigJobDto;
+
+  @Type(() => SystemConfigThumbnailDto)
+  @ValidateNested()
+  @IsObject()
+  thumbnail!: SystemConfigThumbnailDto;
 }
 }
 
 
 export function mapConfig(config: SystemConfig): SystemConfigDto {
 export function mapConfig(config: SystemConfig): SystemConfigDto {

+ 5 - 0
server/src/domain/system-config/system-config.core.ts

@@ -64,6 +64,11 @@ export const defaults = Object.freeze<SystemConfig>({
   storageTemplate: {
   storageTemplate: {
     template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
     template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
   },
   },
+
+  thumbnail: {
+    webpSize: 250,
+    jpegSize: 1440,
+  },
 });
 });
 
 
 const singleton = new Subject<SystemConfig>();
 const singleton = new Subject<SystemConfig>();

+ 4 - 0
server/src/domain/system-config/system-config.service.spec.ts

@@ -65,6 +65,10 @@ const updatedConfig = Object.freeze<SystemConfig>({
   storageTemplate: {
   storageTemplate: {
     template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
     template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
   },
   },
+  thumbnail: {
+    webpSize: 250,
+    jpegSize: 1440,
+  },
 });
 });
 
 
 describe(SystemConfigService.name, () => {
 describe(SystemConfigService.name, () => {

+ 7 - 0
server/src/infra/entities/system-config.entity.ts

@@ -52,6 +52,9 @@ export enum SystemConfigKey {
   PASSWORD_LOGIN_ENABLED = 'passwordLogin.enabled',
   PASSWORD_LOGIN_ENABLED = 'passwordLogin.enabled',
 
 
   STORAGE_TEMPLATE = 'storageTemplate.template',
   STORAGE_TEMPLATE = 'storageTemplate.template',
+
+  THUMBNAIL_WEBP_SIZE = 'thumbnail.webpSize',
+  THUMBNAIL_JPEG_SIZE = 'thumbnail.jpegSize',
 }
 }
 
 
 export enum TranscodePolicy {
 export enum TranscodePolicy {
@@ -121,4 +124,8 @@ export interface SystemConfig {
   storageTemplate: {
   storageTemplate: {
     template: string;
     template: string;
   };
   };
+  thumbnail: {
+    webpSize: number;
+    jpegSize: number;
+  };
 }
 }

+ 2 - 2
server/src/infra/repositories/media.repository.ts

@@ -12,7 +12,7 @@ export class MediaRepository implements IMediaRepository {
   private logger = new Logger(MediaRepository.name);
   private logger = new Logger(MediaRepository.name);
 
 
   crop(input: string, options: CropOptions): Promise<Buffer> {
   crop(input: string, options: CropOptions): Promise<Buffer> {
-    return sharp(input, { failOnError: false })
+    return sharp(input, { failOn: 'none' })
       .extract({
       .extract({
         left: options.left,
         left: options.left,
         top: options.top,
         top: options.top,
@@ -23,7 +23,7 @@ export class MediaRepository implements IMediaRepository {
   }
   }
 
 
   async resize(input: string | Buffer, output: string, options: ResizeOptions): Promise<void> {
   async resize(input: string | Buffer, output: string, options: ResizeOptions): Promise<void> {
-    await sharp(input, { failOnError: false })
+    await sharp(input, { failOn: 'none' })
       .resize(options.size, options.size, { fit: 'outside', withoutEnlargement: true })
       .resize(options.size, options.size, { fit: 'outside', withoutEnlargement: true })
       .rotate()
       .rotate()
       .toFormat(options.format)
       .toFormat(options.format)

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

@@ -2425,6 +2425,12 @@ export interface SystemConfigDto {
      * @memberof SystemConfigDto
      * @memberof SystemConfigDto
      */
      */
     'storageTemplate': SystemConfigStorageTemplateDto;
     'storageTemplate': SystemConfigStorageTemplateDto;
+    /**
+     * 
+     * @type {SystemConfigThumbnailDto}
+     * @memberof SystemConfigDto
+     */
+    'thumbnail': SystemConfigThumbnailDto;
 }
 }
 /**
 /**
  * 
  * 
@@ -2716,6 +2722,25 @@ export interface SystemConfigTemplateStorageOptionDto {
      */
      */
     'yearOptions': Array<string>;
     'yearOptions': Array<string>;
 }
 }
+/**
+ * 
+ * @export
+ * @interface SystemConfigThumbnailDto
+ */
+export interface SystemConfigThumbnailDto {
+    /**
+     * 
+     * @type {number}
+     * @memberof SystemConfigThumbnailDto
+     */
+    'jpegSize': number;
+    /**
+     * 
+     * @type {number}
+     * @memberof SystemConfigThumbnailDto
+     */
+    'webpSize': number;
+}
 /**
 /**
  * 
  * 
  * @export
  * @export

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

@@ -27,7 +27,7 @@
   };
   };
 </script>
 </script>
 
 
-<div class="w-full">
+<div class="mb-4 w-full">
   <div class={`flex h-[26px] place-items-center gap-1`}>
   <div class={`flex h-[26px] place-items-center gap-1`}>
     <label class={`immich-form-label text-sm`} for={label}>{label}</label>
     <label class={`immich-form-label text-sm`} for={label}>{label}</label>
     {#if required}
     {#if required}
@@ -45,7 +45,7 @@
   </div>
   </div>
 
 
   {#if desc}
   {#if desc}
-    <p class="immich-form-label pb-2 text-xs" id="{label}-desc">
+    <p class="immich-form-label pb-2 text-sm" id="{label}-desc">
       {desc}
       {desc}
     </p>
     </p>
   {/if}
   {/if}

+ 8 - 4
web/src/lib/components/admin-page/settings/setting-select.svelte

@@ -2,19 +2,23 @@
   import { quintOut } from 'svelte/easing';
   import { quintOut } from 'svelte/easing';
   import { fly } from 'svelte/transition';
   import { fly } from 'svelte/transition';
 
 
-  export let value: string;
-  export let options: { value: string; text: string }[];
+  export let value: string | number;
+  export let options: { value: string | number; text: string }[];
   export let label = '';
   export let label = '';
   export let desc = '';
   export let desc = '';
   export let name = '';
   export let name = '';
   export let isEdited = false;
   export let isEdited = false;
+  export let number = false;
 
 
   const handleChange = (e: Event) => {
   const handleChange = (e: Event) => {
     value = (e.target as HTMLInputElement).value;
     value = (e.target as HTMLInputElement).value;
+    if (number) {
+      value = parseInt(value);
+    }
   };
   };
 </script>
 </script>
 
 
-<div class="w-full">
+<div class="mb-4 w-full">
   <div class={`flex h-[26px] place-items-center gap-1`}>
   <div class={`flex h-[26px] place-items-center gap-1`}>
     <label class={`immich-form-label text-sm`} for="{name}-select">{label}</label>
     <label class={`immich-form-label text-sm`} for="{name}-select">{label}</label>
 
 
@@ -29,7 +33,7 @@
   </div>
   </div>
 
 
   {#if desc}
   {#if desc}
-    <p class="immich-form-label pb-2 text-xs" id="{name}-desc">
+    <p class="immich-form-label pb-2 text-sm" id="{name}-desc">
       {desc}
       {desc}
     </p>
     </p>
   {/if}
   {/if}

+ 121 - 0
web/src/lib/components/admin-page/settings/thumbnail/thumbnail-settings.svelte

@@ -0,0 +1,121 @@
+<script lang="ts">
+  import SettingSelect from '$lib/components/admin-page/settings/setting-select.svelte';
+  import { api, SystemConfigThumbnailDto } from '@api';
+  import { fade } from 'svelte/transition';
+  import { isEqual } from 'lodash-es';
+  import SettingButtonsRow from '$lib/components/admin-page/settings/setting-buttons-row.svelte';
+  import {
+    notificationController,
+    NotificationType,
+  } from '$lib/components/shared-components/notification/notification';
+
+  export let thumbnailConfig: SystemConfigThumbnailDto; // this is the config that is being edited
+
+  let savedConfig: SystemConfigThumbnailDto;
+  let defaultConfig: SystemConfigThumbnailDto;
+
+  async function getConfigs() {
+    [savedConfig, defaultConfig] = await Promise.all([
+      api.systemConfigApi.getConfig().then((res) => res.data.thumbnail),
+      api.systemConfigApi.getDefaults().then((res) => res.data.thumbnail),
+    ]);
+  }
+
+  async function reset() {
+    const { data: resetConfig } = await api.systemConfigApi.getConfig();
+
+    thumbnailConfig = { ...resetConfig.thumbnail };
+    savedConfig = { ...resetConfig.thumbnail };
+
+    notificationController.show({
+      message: 'Reset thumbnail settings to the recent saved settings',
+      type: NotificationType.Info,
+    });
+  }
+
+  async function resetToDefault() {
+    const { data: configs } = await api.systemConfigApi.getDefaults();
+
+    thumbnailConfig = { ...configs.thumbnail };
+    defaultConfig = { ...configs.thumbnail };
+
+    notificationController.show({
+      message: 'Reset thumbnail settings to default',
+      type: NotificationType.Info,
+    });
+  }
+
+  async function saveSetting() {
+    try {
+      const { data: configs } = await api.systemConfigApi.getConfig();
+
+      const result = await api.systemConfigApi.updateConfig({
+        systemConfigDto: {
+          ...configs,
+          thumbnail: thumbnailConfig,
+        },
+      });
+
+      thumbnailConfig = { ...result.data.thumbnail };
+      savedConfig = { ...result.data.thumbnail };
+
+      notificationController.show({
+        message: 'Thumbnail settings saved',
+        type: NotificationType.Info,
+      });
+    } catch (e) {
+      console.error('Error [thumbnail-settings] [saveSetting]', e);
+      notificationController.show({
+        message: 'Unable to save settings',
+        type: NotificationType.Error,
+      });
+    }
+  }
+</script>
+
+<div>
+  {#await getConfigs() then}
+    <div in:fade={{ duration: 500 }}>
+      <form autocomplete="off" on:submit|preventDefault>
+        <div class="ml-4 mt-4 flex flex-col gap-4">
+          <SettingSelect
+            label="WEBP RESOLUTION"
+            desc="Higher resolutions can preserve more detail but take longer to encode, have larger file sizes, and can reduce app responsiveness."
+            number
+            bind:value={thumbnailConfig.webpSize}
+            options={[
+              { value: 1080, text: '1080p' },
+              { value: 720, text: '720p' },
+              { value: 480, text: '480p' },
+              { value: 250, text: '250p' },
+            ]}
+            name="resolution"
+            isEdited={!(thumbnailConfig.webpSize === savedConfig.webpSize)}
+          />
+
+          <SettingSelect
+            label="JPEG RESOLUTION"
+            desc="Higher resolutions can preserve more detail but take longer to encode, have larger file sizes, and can reduce app responsiveness."
+            number
+            bind:value={thumbnailConfig.jpegSize}
+            options={[
+              { value: 2160, text: '4K' },
+              { value: 1440, text: '1440p' },
+            ]}
+            name="resolution"
+            isEdited={!(thumbnailConfig.jpegSize === savedConfig.jpegSize)}
+          />
+        </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}
+</div>

+ 5 - 0
web/src/routes/admin/system-settings/+page.svelte

@@ -2,6 +2,7 @@
   import { page } from '$app/stores';
   import { page } from '$app/stores';
   import FFmpegSettings from '$lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte';
   import FFmpegSettings from '$lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte';
   import JobSettings from '$lib/components/admin-page/settings/job-settings/job-settings.svelte';
   import JobSettings from '$lib/components/admin-page/settings/job-settings/job-settings.svelte';
+  import ThumbnailSettings from '$lib/components/admin-page/settings/thumbnail/thumbnail-settings.svelte';
   import OAuthSettings from '$lib/components/admin-page/settings/oauth/oauth-settings.svelte';
   import OAuthSettings from '$lib/components/admin-page/settings/oauth/oauth-settings.svelte';
   import PasswordLoginSettings from '$lib/components/admin-page/settings/password-login/password-login-settings.svelte';
   import PasswordLoginSettings from '$lib/components/admin-page/settings/password-login/password-login-settings.svelte';
   import SettingAccordion from '$lib/components/admin-page/settings/setting-accordion.svelte';
   import SettingAccordion from '$lib/components/admin-page/settings/setting-accordion.svelte';
@@ -22,6 +23,10 @@
   {#await getConfig()}
   {#await getConfig()}
     <LoadingSpinner />
     <LoadingSpinner />
   {:then configs}
   {:then configs}
+    <SettingAccordion title="Thumbnail Settings" subtitle="Manage the resolution of thumbnail sizes">
+      <ThumbnailSettings thumbnailConfig={configs.thumbnail} />
+    </SettingAccordion>
+
     <SettingAccordion
     <SettingAccordion
       title="FFmpeg Settings"
       title="FFmpeg Settings"
       subtitle="Manage the resolution and encoding information of the video files"
       subtitle="Manage the resolution and encoding information of the video files"