diff --git a/e2e/src/api/specs/user-admin.e2e-spec.ts b/e2e/src/api/specs/user-admin.e2e-spec.ts index 4acc0664f..3b3f2cd90 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 ccf7d6dd3..b1ef4f2f8 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 e7906fe38..e0ffdd537 100644 Binary files a/mobile/openapi/README.md and b/mobile/openapi/README.md differ diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 2591de491..84f465f54 100644 Binary files a/mobile/openapi/lib/api.dart and b/mobile/openapi/lib/api.dart differ diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index dbfec53b9..1b3c6aed8 100644 Binary files a/mobile/openapi/lib/api_client.dart and b/mobile/openapi/lib/api_client.dart differ diff --git a/mobile/openapi/lib/model/download_response.dart b/mobile/openapi/lib/model/download_response.dart new file mode 100644 index 000000000..8973e17eb Binary files /dev/null and b/mobile/openapi/lib/model/download_response.dart differ diff --git a/mobile/openapi/lib/model/download_update.dart b/mobile/openapi/lib/model/download_update.dart new file mode 100644 index 000000000..162970641 Binary files /dev/null and b/mobile/openapi/lib/model/download_update.dart differ diff --git a/mobile/openapi/lib/model/user_preferences_response_dto.dart b/mobile/openapi/lib/model/user_preferences_response_dto.dart index 4db710432..63fdfd49a 100644 Binary files a/mobile/openapi/lib/model/user_preferences_response_dto.dart and b/mobile/openapi/lib/model/user_preferences_response_dto.dart differ diff --git a/mobile/openapi/lib/model/user_preferences_update_dto.dart b/mobile/openapi/lib/model/user_preferences_update_dto.dart index 21da7c7ba..ed1a77989 100644 Binary files a/mobile/openapi/lib/model/user_preferences_update_dto.dart and b/mobile/openapi/lib/model/user_preferences_update_dto.dart differ diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index c80cb1473..e884b4fc2 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 f07c264f3..43e24e939 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 64120be22..009908bb5 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 b10945531..6ee460196 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 000000000..c93eaf63c --- /dev/null +++ b/web/src/lib/components/user-settings-page/download-settings.svelte @@ -0,0 +1,51 @@ + + + + + + + + + + + handleSave()}>{$t('save')} + + + + + 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 64c5420b4..f6dc61ef0 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 8120ab0ee..95bad059d 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 a100b709a..a99f3ba1b 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 648b66e36..9d45acbb6 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 9fc5eb647..f855efa74 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') {