From dddc06c3b222724ecf3f3388c3574cdf26e82cae Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Fri, 14 Jun 2024 17:27:12 +0200 Subject: [PATCH] feat: user preferences for archive download size (#10296) * feat: user preferences for archive download size * chore: open api * chore: clean up --------- Co-authored-by: Jason Rasmussen --- e2e/src/api/specs/user-admin.e2e-spec.ts | 25 ++-- e2e/src/api/specs/user.e2e-spec.ts | 39 +++++++ mobile/openapi/README.md | 2 + mobile/openapi/lib/api.dart | 2 + mobile/openapi/lib/api_client.dart | 4 + .../openapi/lib/model/download_response.dart | 98 ++++++++++++++++ mobile/openapi/lib/model/download_update.dart | 108 ++++++++++++++++++ .../model/user_preferences_response_dto.dart | 10 +- .../model/user_preferences_update_dto.dart | 19 ++- open-api/immich-openapi-specs.json | 27 +++++ open-api/typescript-sdk/src/fetch-client.ts | 8 ++ server/src/dtos/user-preferences.dto.ts | 21 +++- server/src/entities/user-metadata.entity.ts | 7 ++ .../download-settings.svelte | 51 +++++++++ .../user-settings-list.svelte | 9 ++ web/src/lib/i18n/en.json | 4 + web/src/lib/utils.ts | 9 ++ web/src/lib/utils/asset-utils.ts | 19 +-- web/src/lib/utils/byte-converter.ts | 4 +- 19 files changed, 442 insertions(+), 24 deletions(-) create mode 100644 mobile/openapi/lib/model/download_response.dart create mode 100644 mobile/openapi/lib/model/download_update.dart create mode 100644 web/src/lib/components/user-settings-page/download-settings.svelte diff --git a/e2e/src/api/specs/user-admin.e2e-spec.ts b/e2e/src/api/specs/user-admin.e2e-spec.ts index 4acc0664fb..3b3f2cd90c 100644 --- a/e2e/src/api/specs/user-admin.e2e-spec.ts +++ b/e2e/src/api/specs/user-admin.e2e-spec.ts @@ -250,18 +250,23 @@ describe('/admin/users', () => { .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); - expect(body).toEqual({ - avatar: { color: 'orange' }, - memories: { enabled: false }, - emailNotifications: { enabled: true, albumInvite: true, albumUpdate: true }, - }); + expect(body).toMatchObject({ avatar: { color: 'orange' } }); const after = await getUserPreferencesAdmin({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) }); - expect(after).toEqual({ - avatar: { color: 'orange' }, - memories: { enabled: false }, - emailNotifications: { enabled: true, albumInvite: true, albumUpdate: true }, - }); + expect(after).toMatchObject({ avatar: { color: 'orange' } }); + }); + + it('should update download archive size', async () => { + const { status, body } = await request(app) + .put(`/admin/users/${admin.userId}/preferences`) + .send({ download: { archiveSize: 1_234_567 } }) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body).toMatchObject({ download: { archiveSize: 1_234_567 } }); + + const after = await getUserPreferencesAdmin({ id: admin.userId }, { headers: asBearerAuth(admin.accessToken) }); + expect(after).toMatchObject({ download: { archiveSize: 1_234_567 } }); }); }); diff --git a/e2e/src/api/specs/user.e2e-spec.ts b/e2e/src/api/specs/user.e2e-spec.ts index ccf7d6dd3a..b1ef4f2f86 100644 --- a/e2e/src/api/specs/user.e2e-spec.ts +++ b/e2e/src/api/specs/user.e2e-spec.ts @@ -173,6 +173,45 @@ describe('/users', () => { const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) }); expect(after).toMatchObject({ memories: { enabled: false } }); }); + + it('should update avatar color', async () => { + const { status, body } = await request(app) + .put(`/users/me/preferences`) + .send({ avatar: { color: 'blue' } }) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body).toMatchObject({ avatar: { color: 'blue' } }); + + const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) }); + expect(after).toMatchObject({ avatar: { color: 'blue' } }); + }); + + it('should require an integer for download archive size', async () => { + const { status, body } = await request(app) + .put(`/users/me/preferences`) + .send({ download: { archiveSize: 1_234_567.89 } }) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['download.archiveSize must be an integer number'])); + }); + + it('should update download archive size', async () => { + const before = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) }); + expect(before).toMatchObject({ download: { archiveSize: 4 * 2 ** 30 } }); + + const { status, body } = await request(app) + .put(`/users/me/preferences`) + .send({ download: { archiveSize: 1_234_567 } }) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body).toMatchObject({ download: { archiveSize: 1_234_567 } }); + + const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) }); + expect(after).toMatchObject({ download: { archiveSize: 1_234_567 } }); + }); }); describe('GET /users/:id', () => { diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index e7906fe38c..e0ffdd5377 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -291,7 +291,9 @@ Class | Method | HTTP request | Description - [CreateTagDto](doc//CreateTagDto.md) - [DownloadArchiveInfo](doc//DownloadArchiveInfo.md) - [DownloadInfoDto](doc//DownloadInfoDto.md) + - [DownloadResponse](doc//DownloadResponse.md) - [DownloadResponseDto](doc//DownloadResponseDto.md) + - [DownloadUpdate](doc//DownloadUpdate.md) - [DuplicateDetectionConfig](doc//DuplicateDetectionConfig.md) - [DuplicateResponseDto](doc//DuplicateResponseDto.md) - [EmailNotificationsResponse](doc//EmailNotificationsResponse.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 2591de491a..84f465f542 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -118,7 +118,9 @@ part 'model/create_profile_image_response_dto.dart'; part 'model/create_tag_dto.dart'; part 'model/download_archive_info.dart'; part 'model/download_info_dto.dart'; +part 'model/download_response.dart'; part 'model/download_response_dto.dart'; +part 'model/download_update.dart'; part 'model/duplicate_detection_config.dart'; part 'model/duplicate_response_dto.dart'; part 'model/email_notifications_response.dart'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index dbfec53b93..1b3c6aed87 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -298,8 +298,12 @@ class ApiClient { return DownloadArchiveInfo.fromJson(value); case 'DownloadInfoDto': return DownloadInfoDto.fromJson(value); + case 'DownloadResponse': + return DownloadResponse.fromJson(value); case 'DownloadResponseDto': return DownloadResponseDto.fromJson(value); + case 'DownloadUpdate': + return DownloadUpdate.fromJson(value); case 'DuplicateDetectionConfig': return DuplicateDetectionConfig.fromJson(value); case 'DuplicateResponseDto': diff --git a/mobile/openapi/lib/model/download_response.dart b/mobile/openapi/lib/model/download_response.dart new file mode 100644 index 0000000000..8973e17ebe --- /dev/null +++ b/mobile/openapi/lib/model/download_response.dart @@ -0,0 +1,98 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// 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 DownloadResponse { + /// Returns a new [DownloadResponse] instance. + DownloadResponse({ + required this.archiveSize, + }); + + int archiveSize; + + @override + bool operator ==(Object other) => identical(this, other) || other is DownloadResponse && + other.archiveSize == archiveSize; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (archiveSize.hashCode); + + @override + String toString() => 'DownloadResponse[archiveSize=$archiveSize]'; + + Map toJson() { + final json = {}; + json[r'archiveSize'] = this.archiveSize; + return json; + } + + /// Returns a new [DownloadResponse] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static DownloadResponse? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return DownloadResponse( + archiveSize: mapValueOfType(json, r'archiveSize')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = DownloadResponse.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = DownloadResponse.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of DownloadResponse-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = DownloadResponse.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'archiveSize', + }; +} + diff --git a/mobile/openapi/lib/model/download_update.dart b/mobile/openapi/lib/model/download_update.dart new file mode 100644 index 0000000000..1629706415 --- /dev/null +++ b/mobile/openapi/lib/model/download_update.dart @@ -0,0 +1,108 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// 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 DownloadUpdate { + /// Returns a new [DownloadUpdate] instance. + DownloadUpdate({ + this.archiveSize, + }); + + /// Minimum value: 1 + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + int? archiveSize; + + @override + bool operator ==(Object other) => identical(this, other) || other is DownloadUpdate && + other.archiveSize == archiveSize; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (archiveSize == null ? 0 : archiveSize!.hashCode); + + @override + String toString() => 'DownloadUpdate[archiveSize=$archiveSize]'; + + Map toJson() { + final json = {}; + if (this.archiveSize != null) { + json[r'archiveSize'] = this.archiveSize; + } else { + // json[r'archiveSize'] = null; + } + return json; + } + + /// Returns a new [DownloadUpdate] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static DownloadUpdate? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return DownloadUpdate( + archiveSize: mapValueOfType(json, r'archiveSize'), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = DownloadUpdate.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = DownloadUpdate.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of DownloadUpdate-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = DownloadUpdate.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + }; +} + diff --git a/mobile/openapi/lib/model/user_preferences_response_dto.dart b/mobile/openapi/lib/model/user_preferences_response_dto.dart index 4db7104325..63fdfd49a7 100644 --- a/mobile/openapi/lib/model/user_preferences_response_dto.dart +++ b/mobile/openapi/lib/model/user_preferences_response_dto.dart @@ -14,12 +14,15 @@ class UserPreferencesResponseDto { /// Returns a new [UserPreferencesResponseDto] instance. UserPreferencesResponseDto({ required this.avatar, + required this.download, required this.emailNotifications, required this.memories, }); AvatarResponse avatar; + DownloadResponse download; + EmailNotificationsResponse emailNotifications; MemoryResponse memories; @@ -27,6 +30,7 @@ class UserPreferencesResponseDto { @override bool operator ==(Object other) => identical(this, other) || other is UserPreferencesResponseDto && other.avatar == avatar && + other.download == download && other.emailNotifications == emailNotifications && other.memories == memories; @@ -34,15 +38,17 @@ class UserPreferencesResponseDto { int get hashCode => // ignore: unnecessary_parenthesis (avatar.hashCode) + + (download.hashCode) + (emailNotifications.hashCode) + (memories.hashCode); @override - String toString() => 'UserPreferencesResponseDto[avatar=$avatar, emailNotifications=$emailNotifications, memories=$memories]'; + String toString() => 'UserPreferencesResponseDto[avatar=$avatar, download=$download, emailNotifications=$emailNotifications, memories=$memories]'; Map toJson() { final json = {}; json[r'avatar'] = this.avatar; + json[r'download'] = this.download; json[r'emailNotifications'] = this.emailNotifications; json[r'memories'] = this.memories; return json; @@ -57,6 +63,7 @@ class UserPreferencesResponseDto { return UserPreferencesResponseDto( avatar: AvatarResponse.fromJson(json[r'avatar'])!, + download: DownloadResponse.fromJson(json[r'download'])!, emailNotifications: EmailNotificationsResponse.fromJson(json[r'emailNotifications'])!, memories: MemoryResponse.fromJson(json[r'memories'])!, ); @@ -107,6 +114,7 @@ class UserPreferencesResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { 'avatar', + 'download', 'emailNotifications', 'memories', }; diff --git a/mobile/openapi/lib/model/user_preferences_update_dto.dart b/mobile/openapi/lib/model/user_preferences_update_dto.dart index 21da7c7bac..ed1a779894 100644 --- a/mobile/openapi/lib/model/user_preferences_update_dto.dart +++ b/mobile/openapi/lib/model/user_preferences_update_dto.dart @@ -14,6 +14,7 @@ class UserPreferencesUpdateDto { /// Returns a new [UserPreferencesUpdateDto] instance. UserPreferencesUpdateDto({ this.avatar, + this.download, this.emailNotifications, this.memories, }); @@ -26,6 +27,14 @@ class UserPreferencesUpdateDto { /// AvatarUpdate? avatar; + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + DownloadUpdate? download; + /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated @@ -45,6 +54,7 @@ class UserPreferencesUpdateDto { @override bool operator ==(Object other) => identical(this, other) || other is UserPreferencesUpdateDto && other.avatar == avatar && + other.download == download && other.emailNotifications == emailNotifications && other.memories == memories; @@ -52,11 +62,12 @@ class UserPreferencesUpdateDto { int get hashCode => // ignore: unnecessary_parenthesis (avatar == null ? 0 : avatar!.hashCode) + + (download == null ? 0 : download!.hashCode) + (emailNotifications == null ? 0 : emailNotifications!.hashCode) + (memories == null ? 0 : memories!.hashCode); @override - String toString() => 'UserPreferencesUpdateDto[avatar=$avatar, emailNotifications=$emailNotifications, memories=$memories]'; + String toString() => 'UserPreferencesUpdateDto[avatar=$avatar, download=$download, emailNotifications=$emailNotifications, memories=$memories]'; Map toJson() { final json = {}; @@ -65,6 +76,11 @@ class UserPreferencesUpdateDto { } else { // json[r'avatar'] = null; } + if (this.download != null) { + json[r'download'] = this.download; + } else { + // json[r'download'] = null; + } if (this.emailNotifications != null) { json[r'emailNotifications'] = this.emailNotifications; } else { @@ -87,6 +103,7 @@ class UserPreferencesUpdateDto { return UserPreferencesUpdateDto( avatar: AvatarUpdate.fromJson(json[r'avatar']), + download: DownloadUpdate.fromJson(json[r'download']), emailNotifications: EmailNotificationsUpdate.fromJson(json[r'emailNotifications']), memories: MemoryUpdate.fromJson(json[r'memories']), ); diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index c80cb1473a..e884b4fc29 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -8125,6 +8125,17 @@ }, "type": "object" }, + "DownloadResponse": { + "properties": { + "archiveSize": { + "type": "integer" + } + }, + "required": [ + "archiveSize" + ], + "type": "object" + }, "DownloadResponseDto": { "properties": { "archives": { @@ -8143,6 +8154,15 @@ ], "type": "object" }, + "DownloadUpdate": { + "properties": { + "archiveSize": { + "minimum": 1, + "type": "integer" + } + }, + "type": "object" + }, "DuplicateDetectionConfig": { "properties": { "enabled": { @@ -11255,6 +11275,9 @@ "avatar": { "$ref": "#/components/schemas/AvatarResponse" }, + "download": { + "$ref": "#/components/schemas/DownloadResponse" + }, "emailNotifications": { "$ref": "#/components/schemas/EmailNotificationsResponse" }, @@ -11264,6 +11287,7 @@ }, "required": [ "avatar", + "download", "emailNotifications", "memories" ], @@ -11274,6 +11298,9 @@ "avatar": { "$ref": "#/components/schemas/AvatarUpdate" }, + "download": { + "$ref": "#/components/schemas/DownloadUpdate" + }, "emailNotifications": { "$ref": "#/components/schemas/EmailNotificationsUpdate" }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index f07c264f3c..43e24e939b 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -78,6 +78,9 @@ export type UserAdminUpdateDto = { export type AvatarResponse = { color: UserAvatarColor; }; +export type DownloadResponse = { + archiveSize: number; +}; export type EmailNotificationsResponse = { albumInvite: boolean; albumUpdate: boolean; @@ -88,12 +91,16 @@ export type MemoryResponse = { }; export type UserPreferencesResponseDto = { avatar: AvatarResponse; + download: DownloadResponse; emailNotifications: EmailNotificationsResponse; memories: MemoryResponse; }; export type AvatarUpdate = { color?: UserAvatarColor; }; +export type DownloadUpdate = { + archiveSize?: number; +}; export type EmailNotificationsUpdate = { albumInvite?: boolean; albumUpdate?: boolean; @@ -104,6 +111,7 @@ export type MemoryUpdate = { }; export type UserPreferencesUpdateDto = { avatar?: AvatarUpdate; + download?: DownloadUpdate; emailNotifications?: EmailNotificationsUpdate; memories?: MemoryUpdate; }; diff --git a/server/src/dtos/user-preferences.dto.ts b/server/src/dtos/user-preferences.dto.ts index 64120be22b..009908bb52 100644 --- a/server/src/dtos/user-preferences.dto.ts +++ b/server/src/dtos/user-preferences.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsEnum, ValidateNested } from 'class-validator'; +import { IsEnum, IsInt, IsPositive, ValidateNested } from 'class-validator'; import { UserAvatarColor, UserPreferences } from 'src/entities/user-metadata.entity'; import { Optional, ValidateBoolean } from 'src/validation'; @@ -27,6 +27,14 @@ class EmailNotificationsUpdate { albumUpdate?: boolean; } +class DownloadUpdate { + @Optional() + @IsInt() + @IsPositive() + @ApiProperty({ type: 'integer' }) + archiveSize?: number; +} + export class UserPreferencesUpdateDto { @Optional() @ValidateNested() @@ -42,6 +50,11 @@ export class UserPreferencesUpdateDto { @ValidateNested() @Type(() => EmailNotificationsUpdate) emailNotifications?: EmailNotificationsUpdate; + + @Optional() + @ValidateNested() + @Type(() => DownloadUpdate) + download?: DownloadUpdate; } class AvatarResponse { @@ -59,10 +72,16 @@ class EmailNotificationsResponse { albumUpdate!: boolean; } +class DownloadResponse { + @ApiProperty({ type: 'integer' }) + archiveSize!: number; +} + export class UserPreferencesResponseDto implements UserPreferences { memories!: MemoryResponse; avatar!: AvatarResponse; emailNotifications!: EmailNotificationsResponse; + download!: DownloadResponse; } export const mapPreferences = (preferences: UserPreferences): UserPreferencesResponseDto => { diff --git a/server/src/entities/user-metadata.entity.ts b/server/src/entities/user-metadata.entity.ts index b109455310..6ee4601963 100644 --- a/server/src/entities/user-metadata.entity.ts +++ b/server/src/entities/user-metadata.entity.ts @@ -1,4 +1,5 @@ import { UserEntity } from 'src/entities/user.entity'; +import { HumanReadableSize } from 'src/utils/bytes'; import { Column, DeepPartial, Entity, ManyToOne, PrimaryColumn } from 'typeorm'; @Entity('user_metadata') @@ -41,6 +42,9 @@ export interface UserPreferences { albumInvite: boolean; albumUpdate: boolean; }; + download: { + archiveSize: number; + }; } export const getDefaultPreferences = (user: { email: string }): UserPreferences => { @@ -61,6 +65,9 @@ export const getDefaultPreferences = (user: { email: string }): UserPreferences albumInvite: true, albumUpdate: true, }, + download: { + archiveSize: HumanReadableSize.GiB * 4, + }, }; }; diff --git a/web/src/lib/components/user-settings-page/download-settings.svelte b/web/src/lib/components/user-settings-page/download-settings.svelte new file mode 100644 index 0000000000..c93eaf63c1 --- /dev/null +++ b/web/src/lib/components/user-settings-page/download-settings.svelte @@ -0,0 +1,51 @@ + + +
+
+
+
+
+ +
+
+ +
+
+
+
+
diff --git a/web/src/lib/components/user-settings-page/user-settings-list.svelte b/web/src/lib/components/user-settings-page/user-settings-list.svelte index 64c5420b40..f6dc61ef04 100644 --- a/web/src/lib/components/user-settings-page/user-settings-list.svelte +++ b/web/src/lib/components/user-settings-page/user-settings-list.svelte @@ -17,6 +17,7 @@ import UserProfileSettings from './user-profile-settings.svelte'; import NotificationsSettings from '$lib/components/user-settings-page/notifications-settings.svelte'; import { t } from 'svelte-i18n'; + import DownloadSettings from '$lib/components/user-settings-page/download-settings.svelte'; export let keys: ApiKeyResponseDto[] = []; export let sessions: SessionResponseDto[] = []; @@ -43,6 +44,14 @@ + + + + diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 8120ab0eed..95bad059d1 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -312,6 +312,8 @@ "appears_in": "Appears in", "archive": "Archive", "archive_or_unarchive_photo": "Archive or unarchive photo", + "archive_size": "Archive Size", + "archive_size_description": "Configure the archive size for downloads (in GiB)", "archived": "Archived", "asset_offline": "Asset offline", "assets": "Assets", @@ -413,6 +415,8 @@ "display_original_photos_setting_description": "Prefer to display the original photo when viewing an asset rather than thumbnails when the original asset is web-compatible. This may result in slower photo display speeds.", "done": "Done", "download": "Download", + "download_settings": "Download", + "download_settings_description": "Manage settings related to asset download", "downloading": "Downloading", "duplicates": "Duplicates", "duration": "Duration", diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index a100b709ae..a99f3ba1b0 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -301,3 +301,12 @@ export const handlePromiseError = (promise: Promise): void => { export const s = (count: number) => (count === 1 ? '' : 's'); export const memoryLaneTitle = (yearsAgo: number) => `${yearsAgo} year${s(yearsAgo)} ago`; + +export const withError = async (fn: () => Promise): Promise<[undefined, T] | [unknown, undefined]> => { + try { + const result = await fn(); + return [undefined, result]; + } catch (error) { + return [error, undefined]; + } +}; diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index 648b66e361..9d45acbb6d 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -5,7 +5,8 @@ import type { AssetInteractionStore } from '$lib/stores/asset-interaction.store' import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { BucketPosition, isSelectingAllAssets, type AssetStore } from '$lib/stores/assets.store'; import { downloadManager } from '$lib/stores/download'; -import { downloadRequest, getKey, s } from '$lib/utils'; +import { preferences } from '$lib/stores/user.store'; +import { downloadRequest, getKey, s, withError } from '$lib/utils'; import { createAlbum } from '$lib/utils/album-utils'; import { asByteUnitString } from '$lib/utils/byte-units'; import { encodeHTMLSpecialChars } from '$lib/utils/string-utils'; @@ -20,7 +21,6 @@ import { type AssetResponseDto, type AssetTypeEnum, type DownloadInfoDto, - type DownloadResponseDto, type UserResponseDto, } from '@immich/sdk'; import { DateTime } from 'luxon'; @@ -94,18 +94,19 @@ export const downloadBlob = (data: Blob, filename: string) => { URL.revokeObjectURL(url); }; -export const downloadArchive = async (fileName: string, options: DownloadInfoDto) => { - let downloadInfo: DownloadResponseDto | null = null; +export const downloadArchive = async (fileName: string, options: Omit) => { + const $preferences = get(preferences); + const dto = { ...options, archiveSize: $preferences.download.archiveSize }; - try { - downloadInfo = await getDownloadInfo({ downloadInfoDto: options, key: getKey() }); - } catch (error) { + const [error, downloadInfo] = await withError(() => getDownloadInfo({ downloadInfoDto: dto, key: getKey() })); + if (error) { handleError(error, 'Unable to download files'); return; } - // TODO: prompt for big download - // const total = downloadInfo.totalSize; + if (!downloadInfo) { + return; + } for (let index = 0; index < downloadInfo.archives.length; index++) { const archive = downloadInfo.archives[index]; diff --git a/web/src/lib/utils/byte-converter.ts b/web/src/lib/utils/byte-converter.ts index 9fc5eb6471..f855efa74c 100644 --- a/web/src/lib/utils/byte-converter.ts +++ b/web/src/lib/utils/byte-converter.ts @@ -7,7 +7,7 @@ * @param unit unit to convert from * @returns bytes (number) */ -export function convertToBytes(size: number, unit: string): number { +export function convertToBytes(size: number, unit: 'GiB'): number { let bytes = 0; if (unit === 'GiB') { @@ -26,7 +26,7 @@ export function convertToBytes(size: number, unit: string): number { * @param unit unit to convert to * @returns bytes (number) */ -export function convertFromBytes(bytes: number, unit: string): number { +export function convertFromBytes(bytes: number, unit: 'GiB'): number { let size = 0; if (unit === 'GiB') {