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 | Bin 28878 -> 28970 bytes mobile/openapi/lib/api.dart | Bin 10155 -> 10227 bytes mobile/openapi/lib/api_client.dart | Bin 26846 -> 27010 bytes .../openapi/lib/model/download_response.dart | Bin 0 -> 2843 bytes mobile/openapi/lib/model/download_update.dart | Bin 0 -> 3270 bytes .../model/user_preferences_response_dto.dart | Bin 3634 -> 3894 bytes .../model/user_preferences_update_dto.dart | Bin 4904 -> 5594 bytes 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, 201 insertions(+), 22 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 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 e7906fe38c04093d9411253d322ed79fda51ed28..e0ffdd5377aec9843b5ab9f1279581ba793b35b5 100644 GIT binary patch delta 74 zcmX^2ka5)`#tjpL#bY&6@{{%TUGmHGa`F>Xf>MhM^7D#QCszbJOqL5~mkupRNi0c4 NQw0&+ye?Ql763{t98~}S delta 18 acmZ4Wi1FM*#tjpLCm#$}-z*X$CJO*qvS)cqKNQ2<#UJ07WVX4*&oF diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index dbfec53b93318988b798b779e3fb755666b7b570..1b3c6aed870ac971affbf37e384324f8e422fdfd 100644 GIT binary patch delta 50 zcmcb2k+JDACiCfWOt!bwm^@i8nmxZfFDE}SW%5K#DX{oPO|{K$^*&kx E0O^Di9smFU delta 22 ecmZp=%y{o2Y@Ve5!4d#-y9s^( diff --git a/mobile/openapi/lib/model/download_response.dart b/mobile/openapi/lib/model/download_response.dart new file mode 100644 index 0000000000000000000000000000000000000000..8973e17ebe474b94ac40c4f4f7b40d7a24b0470f GIT binary patch literal 2843 zcmbVOYj4{&6#edBaRU^w0;uw~PeoFDEf#0!D+UsGz+e~xBT*JRS=2}>hL`%k?_N?; zXvdzB2 zBg=OwQ)c{2^z)kmJ&IK+6^}Ei;!G6$9O|;xJdJt5*Ss`g`z+R#ROYA$D>iJau=SJqu0O;(7K>$s5R3e8N;%J|Js zagsBo2LnuJKxQB-Ub0F=_;)cFBn8tNF6E{yBuj6&t}r2ohXKF{0l49&R;308CEvi} z&~6FBmY47d9S(N`iUQEK4JH>lVk*hSJ--$I@DR6mXIKPa7z5spU3(|*xl-6S?%zpS zQ00mlsbD+~W_RCsg8i7Bt1}o+z~PgyBA_&3yYv0K`vu}im#3$;KpVs_3==$A4OP41 z26CqJ-(<=$zYWwWhJ;e`glo;zmWW2~a;}B8W`Wmf)W&c)_&whmCCU{T>3#&Z&EX-) z3oqFCmG`h5S`@#q>a7b9Lb^reiDZH?KWbTGM8{AjN>;!(T!Jz5tRHq4wrsE#3pMnT z443F}$g49~Ryx*)7IsAQf07iiG6gqT8idhs&x)FB$dp_+u@-g-RhXf{kYu{RewQJX zb%A;zacqRP;`F$w6wh!z>}aVvK}G49v=-x}8Ii;|l(|faE4R=S_OG@E3tuzBl>w)xR#%bG*V0VamD#p25n?ISJ@iLOy3RagRY*#qdZJ@~7dCWY+DFU@{j(_njopjdUX_AsZ zK`gp<1B7kkBb`nbCF!oNjW9Vb(frW0e8|FUXNF$Q!H0)7n$!CUMZ;-5q6kt^e~c#N zKwC$(5yH#n6l3_R`9Z4xqn#JZ;w2Jm&NS}~cveB!*FMRIOhiOc2c0LRup86Ifekx# zIdSYwJErnPF@kx{JN~=3B$S%QE0rP5HA>@-1Kw8})6y6)_;@?JrkAaTeS3*@uUMbR Vx4Xh84;GHS``W&9T@3I@`4`T2pt=A6 literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/download_update.dart b/mobile/openapi/lib/model/download_update.dart new file mode 100644 index 0000000000000000000000000000000000000000..1629706415de300f201710cc39c550b6d6be3b3d GIT binary patch literal 3270 zcmbVPYfl?F6#brGaj8}%NJD7%Q$?H5s=%t@BiL={ief@>RKBs(}}U^L>2OoN?kgcr?QYUseMvEtJ&OGzx9MD7Gmkr zdFfLtf0oMPxE3>f&V|KaFRg=f>$V@yjCNjVpQ;=I)kH}fj=QbF3Z3!|@L=BGlZ zZ(gO@R9M&PAe<*NAv2d+%vBG+Z#$i=5YExDS?I!u{M$SiUeaxc=m6z^mA zG-_L2kqW70vIq0_UY9)(`KfX}VY8{akr(P;nP7Fbhp5s0nbK-j&ge!IC9r)Rm#fXc z3Mrfb0ZzQMhRI_<`Na=aw5vY$7HBmI;F3JlNZ&_;fr+!dkIa=Lf%N51e0s2r3G+# z<=&anB8AMr^Q?59CZZ^4EV3Wu4d>0i0cw>p`QG%CI;AgRwCM>jSkIOZ|R#IQ}j(s0Lu#J?alAsxT8p zm;ER8&aYqb8zE*}3H0(M98=tGY60#a%>~KQwe&#CO+PY_{XuZF?n-V(Ei>tyuuE<$_taQ74CDtm%ke(P1;{POo+!RoMjN!rLGL6Tfsr{k!ezMvW-_-x?2By8+xY zF@G12CzLPI_9`2Iq0i1}_=`<4QCbxA9ZS@wd)BwBx1k<2p#XUc!kt)G?TLbyk=<=1 zvU>%un|&Bvu1FT>u$Tp4edshpSDgr{*f14>H7~E~c7EoxAfa=L1JjZ6E_R%wi8V90 z;~p4=iGTq!fx#}%pAnF}N+OFvjSJ-}WmiM`5Zx&!7<*x2+A@QLUCQE4F+jb_Qq0<^ z$ypw8VMD1$A%j3oMODnKMwb}~d9lcmPQ6YTEDZ*ONiP%5-{Z0yMaJz=Vc1qvH^9r= z4FV5oCgy+hgd9#TF(=-kF$P_hOG0g?_jtVW55n-DRrstT)g%VCs`|euB-u`3Km`_0 zK}8FQ816RAhihvVJZPaSj5Q~dRgFZ%!Ai$-6k6VJJ-Ui{F_7$#apV**B7&HD%AFg3 zGMoEl?Ge~w5OpiZX2ElhkKXOq=tpMy8=Ur7RG|_iUBSZ8+;MrYg?Kxb(8C0dj5kh3 zP-}2o5Lc(X;i9@VOovo|3tpP|H>bk}R!sU<7V63)B(Q1@jSVNw*--_;PZ2*O~CqRLj;t|3@>=l*4nRRZXx)*FUKoRMn=p%|VZ; zS;N*4P_k~!yB1B>s*0j$^)~`F#BhL1sa*43U+oDJb9mnh5X=|9vE ebH0#OyoYxc#!dXc;7>lgz5@(yVTZRrCdPk>4kp9^ literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/model/user_preferences_response_dto.dart b/mobile/openapi/lib/model/user_preferences_response_dto.dart index 4db7104325f17de0615094e3a4d976c0231a8269..63fdfd49a7e2f113db623e6cd13ae46aaa9ef6e3 100644 GIT binary patch delta 193 zcmdlavrTTpDMpTz{PMh<{KS;W+ZfX&U7(zx)Z&8tyy8>^xU2-zD-jq+!PXWb@rCIi z!i>etvxH!(Y*k>a$<{2prC=QOSOr^!l8nq^J($Sm=PY_maBWr!E-)8avrEE_-ptPa Mj}6Y7{Fm1h06fr1NdN!< delta 49 zcmV-10M7rm9ogB*x8KS?riZU_uJE zws7gmPuZ@^BTM9!=Hw{YD;OwPA=Ip7zbph(W2*vVZLZ?rWweIz6clY0KzcP5s<{-v zAgee(FS 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 @@ + + +
+
+
+
+
+ +
+
+ +
+
+
+
+
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') {