From ad343b7b32f2fdd36186b7e54c85e9a2d8a39b9f Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 30 Jun 2023 12:24:28 -0400 Subject: [PATCH] refactor(server): download assets (#3032) * refactor: download assets * chore: open api * chore: finish tests, make size configurable * chore: defualt to 4GiB * chore: open api * fix: optional archive size * fix: bugs * chore: cleanup --- mobile/openapi/.openapi-generator/FILES | 9 +- mobile/openapi/README.md | Bin 17843 -> 17791 bytes mobile/openapi/doc/AlbumApi.md | Bin 23588 -> 21326 bytes mobile/openapi/doc/AssetApi.md | Bin 55854 -> 56109 bytes ...loadFilesDto.md => DownloadArchiveInfo.md} | Bin 441 -> 469 bytes mobile/openapi/doc/DownloadResponseDto.md | Bin 0 -> 513 bytes mobile/openapi/lib/api.dart | Bin 5606 -> 5650 bytes mobile/openapi/lib/api/album_api.dart | Bin 18911 -> 16818 bytes mobile/openapi/lib/api/asset_api.dart | Bin 50688 -> 51103 bytes mobile/openapi/lib/api_client.dart | Bin 17812 -> 17908 bytes ...es_dto.dart => download_archive_info.dart} | Bin 2917 -> 3165 bytes .../lib/model/download_response_dto.dart | Bin 0 -> 3152 bytes mobile/openapi/test/album_api_test.dart | Bin 2092 -> 1925 bytes mobile/openapi/test/asset_api_test.dart | Bin 5351 -> 5342 bytes ...t.dart => download_archive_info_test.dart} | Bin 604 -> 703 bytes .../test/download_response_dto_test.dart | Bin 0 -> 726 bytes server/immich-openapi-specs.json | 195 +++---- server/src/domain/access/access.core.ts | 11 + server/src/domain/asset/asset.repository.ts | 2 + server/src/domain/asset/asset.service.spec.ts | 233 +++++++- server/src/domain/asset/asset.service.ts | 117 +++- server/src/domain/asset/dto/download.dto.ts | 31 + server/src/domain/asset/dto/index.ts | 1 + .../src/domain/storage/storage.repository.ts | 16 +- .../immich/api-v1/album/album.controller.ts | 21 +- .../src/immich/api-v1/album/album.module.ts | 3 +- .../immich/api-v1/album/album.service.spec.ts | 8 +- .../src/immich/api-v1/album/album.service.ts | 30 +- .../immich/api-v1/asset/asset.controller.ts | 42 +- .../src/immich/api-v1/asset/asset.module.ts | 7 +- .../immich/api-v1/asset/asset.service.spec.ts | 37 +- .../src/immich/api-v1/asset/asset.service.ts | 49 -- .../api-v1/asset/dto/download-files.dto.ts | 12 - .../api-v1/asset/dto/download-library.dto.ts | 14 - server/src/immich/app.utils.ts | 20 +- .../immich/controllers/asset.controller.ts | 40 +- .../modules/download/download.module.ts | 8 - .../modules/download/download.service.ts | 63 -- .../infra/repositories/access.repository.ts | 4 +- .../infra/repositories/asset.repository.ts | 26 + .../infra/repositories/filesystem.provider.ts | 19 +- server/test/fixtures.ts | 24 +- .../repositories/asset.repository.mock.ts | 2 + .../repositories/storage.repository.mock.ts | 1 + web/src/api/open-api/api.ts | 551 ++++++++---------- .../components/album-page/album-viewer.svelte | 82 +-- .../asset-viewer/asset-viewer.svelte | 74 +-- .../actions/download-action.svelte | 18 +- .../individual-shared-viewer.svelte | 11 +- web/src/lib/stores/download.ts | 15 + web/src/lib/utils/asset-utils.ts | 149 +++-- web/src/lib/utils/handle-error.ts | 14 +- .../(user)/people/[personId]/+page.svelte | 2 +- 53 files changed, 985 insertions(+), 976 deletions(-) rename mobile/openapi/doc/{DownloadFilesDto.md => DownloadArchiveInfo.md} (86%) create mode 100644 mobile/openapi/doc/DownloadResponseDto.md rename mobile/openapi/lib/model/{download_files_dto.dart => download_archive_info.dart} (59%) create mode 100644 mobile/openapi/lib/model/download_response_dto.dart rename mobile/openapi/test/{download_files_dto_test.dart => download_archive_info_test.dart} (70%) create mode 100644 mobile/openapi/test/download_response_dto_test.dart create mode 100644 server/src/domain/asset/dto/download.dto.ts delete mode 100644 server/src/immich/api-v1/asset/dto/download-files.dto.ts delete mode 100644 server/src/immich/api-v1/asset/dto/download-library.dto.ts delete mode 100644 server/src/immich/modules/download/download.module.ts delete mode 100644 server/src/immich/modules/download/download.service.ts diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index e351e3c652..26eeb1c6b6 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -45,7 +45,8 @@ doc/CuratedObjectsResponseDto.md doc/DeleteAssetDto.md doc/DeleteAssetResponseDto.md doc/DeleteAssetStatus.md -doc/DownloadFilesDto.md +doc/DownloadArchiveInfo.md +doc/DownloadResponseDto.md doc/ExifResponseDto.md doc/GetAssetByTimeBucketDto.md doc/GetAssetCountByTimeBucketDto.md @@ -178,7 +179,8 @@ lib/model/curated_objects_response_dto.dart lib/model/delete_asset_dto.dart lib/model/delete_asset_response_dto.dart lib/model/delete_asset_status.dart -lib/model/download_files_dto.dart +lib/model/download_archive_info.dart +lib/model/download_response_dto.dart lib/model/exif_response_dto.dart lib/model/get_asset_by_time_bucket_dto.dart lib/model/get_asset_count_by_time_bucket_dto.dart @@ -282,7 +284,8 @@ test/curated_objects_response_dto_test.dart test/delete_asset_dto_test.dart test/delete_asset_response_dto_test.dart test/delete_asset_status_test.dart -test/download_files_dto_test.dart +test/download_archive_info_test.dart +test/download_response_dto_test.dart test/exif_response_dto_test.dart test/get_asset_by_time_bucket_dto_test.dart test/get_asset_count_by_time_bucket_dto_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 91bad1615128281102d5a82f2cfbf87c115aed69..606e4671fb8ff7772b2621dee49064cf5b72ef4e 100644 GIT binary patch delta 211 zcmdno&G^5Ial>`)&2~J`nJ3@p7M#o_!aaF`00&!QQF2CR+2lkCQI3@S^1Ph<#FWVe zLL!sv1b8NE3aC%!7U!EBE1)=efgsD~K*2~B9v7%8&%Ct!Ng@(FFkP8I{^UYYVW4Ub zpb6reSBZ8i1vr9@PX#HC)kw)t*4KyWN0QOYP0{31&{c?r$pxhr7v$#^r@EA&>zbTs LD6%=)B7zqH9Q{c6 delta 218 zcmez0#kjefal>`)$=moPCs%WE^QYvO=jG%lrX&_6XJnS8PLAgh=hTM@PL|`5*?gb- zKJ#Qn0qx12;=Gfi1QaJr3h*!%Pv#dBoooeWz2ui+(@o3FNiCi{QAh-CqEBX0QDRXg zNTCtTz#NE}rb3N^mX^C~h?bUueqwQPY6;v*U8oYEQ4*W$1tM8CTZwim$+&@xcPYt_ V)kw)t*4KA|x*9CJS=u6n7XSv`N_YSO diff --git a/mobile/openapi/doc/AlbumApi.md b/mobile/openapi/doc/AlbumApi.md index b4eba7916d78ebdcf9915aec90a091e56db76224..47c418096854ef806172efe867a3313b99429ab9 100644 GIT binary patch delta 23 fcmZ3ogYn!l#tke?nQHlIJLyFAX6_lML9jS1g0?)C_VWjvoPGG&D+_0RN(dr zSWELm?bb*Hnc$hSncG;0MIFj1b}7kMuvGvX?wJDOX=qw2=?ek zrggI8)h<#<|@=Ea7Alr!4&&sCKV+XRf3eW=Rnzwtil2?d0nXB<{eBMm^bfWb5hyNWqghu z%`!%9X)&<18Zdoeo=ZvoW&>jl7Bh5F1zQCdWY+=}YG_(3=2-ys=+=@&t1>7LbXP`}-3n-!mx@s~Z2!mnKKM0{~k1!bkuB diff --git a/mobile/openapi/doc/DownloadFilesDto.md b/mobile/openapi/doc/DownloadArchiveInfo.md similarity index 86% rename from mobile/openapi/doc/DownloadFilesDto.md rename to mobile/openapi/doc/DownloadArchiveInfo.md index 6b44eef05dcc2e83e3f5c0c4b6bd6fe8476a6f52..5ec8c668ffb667a0078a61928a5451e6ac35ef0f 100644 GIT binary patch delta 46 xcmdnVe3f~E1eaq`azMEN~p#hF#9T3QM<3R+s3c_m;LkTdbdLjZRH5GViu delta 20 ccmcc0ypwr?1iM>iPHM4B$wZ|+6TjaB08Z8j!~g&Q diff --git a/mobile/openapi/doc/DownloadResponseDto.md b/mobile/openapi/doc/DownloadResponseDto.md new file mode 100644 index 0000000000000000000000000000000000000000..2a7bbc9b19d9e882375027c7d81260bd4fe33838 GIT binary patch literal 513 zcma)2!Ab)$5WVLs0()p1*zH|KSlJ#5Z7J2;van$@wZYv?NT!P7$2-|ptRiR#B=60e zH_Xcx_i=(AlkMyI*_n~$3Sv& z=C97C^JJU^lj|fMowk9Cfhd8+90tazc@@@pgjKz3#AB2 z_xJVjfKR2D{?`j&#~P-($$fgyT$pb(h=a XKYKav9GvmUn|`x64g4{DCWQC^_5Gpu literal 0 HcmV?d00001 diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 9363e99b1d6bbf5d8cf023f2ca317e6902337a54..47cfa9aa2cef615a8c38807ce79642de36faa873 100644 GIT binary patch delta 36 scmaE+JxON+D>rvyQF2CRS!#S{UfSeFLD9*7xHurLeW=?AHDyzi7AsmTIEl^FUBF4mzbMs#igL2udfdw6|@wh z^Gb7L6^gSn3nouwm7HA5B*72U=U9}Sky(~HS)WT{@?Hfl0g!eb1)zF+uzH=z`xFv5 z5wH$)kn$|!|6%=a0WM-Oz2FTJ$nij%93aAFe zgR7aW%~Cm8SLWyU=rd~D@Yh@mQXQaQbe&@T}M+Pu~-2`Z1Mqlx6KQr Ggwz2dDT&|! diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index d8d03ca53383a2509dca8b72311de84383204ccb..a73ec3b1ec224aede71ed2ee04971bd69a7d4a17 100644 GIT binary patch delta 937 zcmZqZVV>X5ykVmJWM4Ir$qywwMI4KZQ%gKkid{PQo}P# zGCWF33Ow`D@->hY!`1d_sZ7>)vS&%jFVCBtyTzVM!4@XBN#j2wE(^>i2N+36LoLB! zuro{^#Su_PZho)T!Zi8)Ou5Z-}K|wpPm4_R9F1~ delta 682 zcmbQ=&)m?%ykVmJF?j+n-{dT1iOsQE4}g-E zLX#KF=9xTKo`X#{Ei)&zc=AIDFU9=wyqx^R6gQB7OG$pLLJFG5WW~)gaMSeF#k7mV zGfOf&N=gbm^V0G)T+o!G=$~AukPWj;V4F3+f-Q>lW<|ySj7sPx5O4y}L&{*+60}-n zvI3vD8j62VoQ^EB`L}Wlla_O7QBi7Mi9$(6X0ZYg<>i+sloqF^C}id-B<58rgnBBZ z=A`BVl}s)aOa_GtYhGe*s@3FFJtJ^v>#Gat`eY^*B^FhpSXHeMTvC*omu{~BQm>LrkGtiXQK+03X?$F$j2ce>l= IWwk#&0k#+U>Hq)$ diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 9deee81b7c8dbaf092e862624aa6192eeae159e4..7ba532835829027e1822d11015ec859b1a333364 100644 GIT binary patch delta 70 zcmbQz&G@C8al;vPF2|zejLfoB&%CtB6BR{;T=L8Fa`F>Xkc1hf6j?yRlN%M3A&m73 K`kN&*YGeVd@EI!r delta 42 tcmey;%{Zl-al;vPR=3QY)Z)p8GUB{0`Q>>z`H3kIkwhuw%@rCovH)Bq4~PH& diff --git a/mobile/openapi/lib/model/download_files_dto.dart b/mobile/openapi/lib/model/download_archive_info.dart similarity index 59% rename from mobile/openapi/lib/model/download_files_dto.dart rename to mobile/openapi/lib/model/download_archive_info.dart index bd7c3537f2d0dac00b472d177d21f50c5d7d585b..ff370f42311da4d3cb71b3ac267ad59c9a62e15a 100644 GIT binary patch delta 476 zcmaDVc2{D9J|nMVQF2CRS*mAVTE0T{CwwbEY5JwPf68O(4IVj$z1|Te>6yetqRy8g~<umn|R84rAZU3p3kJAvdvLvLl!1WIYa5xQbYgBXG7h=N35oC8sk#Tqjto y&*VlnhskwZGH^H8O^)L*oqU^XI$T!`_aWiL;^Nd2&y-?y9bh2TT65KMaRC6;!)-AD diff --git a/mobile/openapi/lib/model/download_response_dto.dart b/mobile/openapi/lib/model/download_response_dto.dart new file mode 100644 index 0000000000000000000000000000000000000000..89269c71a3390448310c14585de6e97fddb749a2 GIT binary patch literal 3152 zcmbVOZExE)5dQ98aRG{2##DLhr@@K62D3AyZDJtt1`LKFFfwJalSPfBVtA?l`|e0l zmK(Lr79fd4-5a0hxg&?e-f#$)zc1!*ew*FS?k*RzDO_EDoP{u*!|i+lH}mQB)t_f* zMwai^Oquc9=%=?mdKH^ODjug&#i_{o2&$sed>!+gZ+T%t|1P$rRHoB|#B{|a?rgB&0lCAH!E-@jW8VOH5fFWXV$4#XQ4Gapt zgJp;53WO~(;Su_D7z7jx!0#VSCUj(^VfnrIhllo~U1KWA#3R=*2D@=suITGwfRzGf zG0=F~m5)N33!m`PIbRp4oPdvH&ECmJt`s7OK^~;c2?NE9R4^U~^M_A7L1awE)d0p5 za7ZUC52&WlfBF2`J%Q-P>-B594`+1T9i})I4W~xFNV4(I&kCkpZ@7Vs>Figz=2)Ul z)X0-mS@48w&D5S$g+j@=7TTHx4PudxFn}N2C!h297FcxL@{ICqF! zi>2x}Y5Oa*KNZ;*roNb?rC)J|%8K3wriBB^@4q$!uwo5vGH4J+!z0Tp)OM=mwhp(n zOV~dt8uY2hmWazLgrdq(r6kFn(6+5wbeD>!*x$B8RF$BPbxaD6aZ(tO#5hzkxhDGE zLRU-vV#}~-*XXd!I>KzZ{aXuwcxVJrqj@rPS{*QflSCd?SNW5jI~yf;>>>gaNZGt`*2Gp2n^9clxCK-(VPBo0h_z z<0}n53zT$c@lKcwm#CfS#?ryX8)u1*^;pHD13l>WMgfnb@1}LkkfJ&pYeGKswNob{ zysUXKMq5E&&-Q#Ei)(8P9Y_~JTE6dF~zYcIU} zCpR+bYLpfgrRJ3=lw@QUD*#bmeu+Y9acYV}W}ZS~UZp~)r$TB@YHn)YL}!u<|-6tXBOy8KF6xV4pjuSNoVtRw&!e{ I3;6Qb0KW@C_y7O^ diff --git a/mobile/openapi/test/download_files_dto_test.dart b/mobile/openapi/test/download_archive_info_test.dart similarity index 70% rename from mobile/openapi/test/download_files_dto_test.dart rename to mobile/openapi/test/download_archive_info_test.dart index fcc46a6c323b32e2e51d5a3152254eb755446588..35f29ef99a5a79619a6e57cec449c40bdc6fe254 100644 GIT binary patch delta 89 zcmcb^vY&NBDkGO;QF2CRS*mAV+T<0iXlN?w8 delta 74 zcmdnbdWU60DkHmFW=?9cOUdMXMm-}aJ6}W7nu|+8A-yQSv_M0>B(=Ci!6m;uFDE}S S1*Sw@M?phVp?b1D(*yvUpBO6u diff --git a/mobile/openapi/test/download_response_dto_test.dart b/mobile/openapi/test/download_response_dto_test.dart new file mode 100644 index 0000000000000000000000000000000000000000..b823c1441ee14cc18d71918ef725f83142b5e93a GIT binary patch literal 726 zcma))PjAye5XJBQ6vHWr6l&-R^$#MFQxvJxQVE9&q3y^XCnGky%kDT;K>h9=ZwMDG z!NKdD)%(49vvye)WeJNf>-zP_<=ygrv0heiv$|c*p{n7oUc;xlTHQRnQrV#V&|;M9 z^Ru_-#ZdCb`ADYo(R6I-0(#dcY7JT1k(2qU=3VenMhUb}ct|GnVj}(MIV$dOr)$8d zZ=smfJf?BH@h%BEG2CiH?wDf6(`Ylxj*_w7ZB!;(bmrX~(`+%OqBv%RNxtox!gHK!J`O zTHriKLvTIDO=p*dsyEU1VLBOFjHxDb_-&hw*$Idjw4eDWDGfQB;y!Ocwge&SoKX(& z_(%D8w83<6P0l!wnsZ%Ob#{3^hRKJwoaE{>=pr^--qXr;{ypHaNHA@w!`=$)(f0a} X#`>16SLiPSD9doEr8CXnq; literal 0 HcmV?d00001 diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 07b8ad0fc5..7717e0ab1e 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -370,73 +370,6 @@ ] } }, - "/album/{id}/download": { - "get": { - "operationId": "downloadArchive", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "format": "uuid", - "type": "string" - } - }, - { - "name": "name", - "required": false, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "skip", - "required": false, - "in": "query", - "schema": { - "type": "number" - } - }, - { - "name": "key", - "required": false, - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/zip": { - "schema": { - "type": "string", - "format": "binary" - } - } - }, - "description": "" - } - }, - "tags": [ - "Album" - ], - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ] - } - }, "/album/{id}/user/{userId}": { "delete": { "operationId": "removeUserFromAlbum", @@ -1153,10 +1086,48 @@ ] } }, - "/asset/download-files": { - "post": { - "operationId": "downloadFiles", + "/asset/download": { + "get": { + "operationId": "getDownloadInfo", "parameters": [ + { + "name": "assetIds", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "albumId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "userId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "archiveSize", + "required": false, + "in": "query", + "schema": { + "type": "number" + } + }, { "name": "key", "required": false, @@ -1166,30 +1137,16 @@ } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DownloadFilesDto" - } - } - } - }, "responses": { "200": { + "description": "", "content": { - "application/octet-stream": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/DownloadResponseDto" } } - }, - "description": "" - }, - "201": { - "description": "" + } } }, "tags": [ @@ -1206,29 +1163,10 @@ "api_key": [] } ] - } - }, - "/asset/download-library": { - "get": { - "operationId": "downloadLibrary", - "description": "Current this is not used in any UI element", + }, + "post": { + "operationId": "downloadArchive", "parameters": [ - { - "name": "name", - "required": false, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "skip", - "required": false, - "in": "query", - "schema": { - "type": "number" - } - }, { "name": "key", "required": false, @@ -1238,6 +1176,16 @@ } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetIdsDto" + } + } + } + }, "responses": { "200": { "content": { @@ -1268,7 +1216,7 @@ } }, "/asset/download/{id}": { - "get": { + "post": { "operationId": "downloadFile", "parameters": [ { @@ -5341,11 +5289,13 @@ "FAILED" ] }, - "DownloadFilesDto": { + "DownloadArchiveInfo": { "type": "object", "properties": { + "size": { + "type": "integer" + }, "assetIds": { - "title": "Array of asset ids to be downloaded", "type": "array", "items": { "type": "string" @@ -5353,9 +5303,28 @@ } }, "required": [ + "size", "assetIds" ] }, + "DownloadResponseDto": { + "type": "object", + "properties": { + "totalSize": { + "type": "integer" + }, + "archives": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DownloadArchiveInfo" + } + } + }, + "required": [ + "totalSize", + "archives" + ] + }, "ExifResponseDto": { "type": "object", "properties": { diff --git a/server/src/domain/access/access.core.ts b/server/src/domain/access/access.core.ts index f730e1be9c..e4a2ed447e 100644 --- a/server/src/domain/access/access.core.ts +++ b/server/src/domain/access/access.core.ts @@ -16,6 +16,7 @@ export enum Permission { ALBUM_UPDATE = 'album.update', ALBUM_DELETE = 'album.delete', ALBUM_SHARE = 'album.share', + ALBUM_DOWNLOAD = 'album.download', LIBRARY_READ = 'library.read', LIBRARY_DOWNLOAD = 'library.download', @@ -68,6 +69,10 @@ export class AccessCore { // TODO: fix this to not use authUser.id for shared link access control return this.repository.asset.hasOwnerAccess(authUser.id, id); + case Permission.ALBUM_DOWNLOAD: { + return !!authUser.isAllowDownload && (await this.repository.album.hasSharedLinkAccess(sharedLinkId, id)); + } + // case Permission.ALBUM_READ: // return this.repository.album.hasSharedLinkAccess(sharedLinkId, id); @@ -122,6 +127,12 @@ export class AccessCore { case Permission.ALBUM_SHARE: return this.repository.album.hasOwnerAccess(authUser.id, id); + case Permission.ALBUM_DOWNLOAD: + return ( + (await this.repository.album.hasOwnerAccess(authUser.id, id)) || + (await this.repository.album.hasSharedAlbumAccess(authUser.id, id)) + ); + case Permission.LIBRARY_READ: return authUser.id === id || (await this.repository.library.hasPartnerAccess(authUser.id, id)); diff --git a/server/src/domain/asset/asset.repository.ts b/server/src/domain/asset/asset.repository.ts index 9479d3c120..9bd9c687aa 100644 --- a/server/src/domain/asset/asset.repository.ts +++ b/server/src/domain/asset/asset.repository.ts @@ -44,6 +44,8 @@ export const IAssetRepository = 'IAssetRepository'; export interface IAssetRepository { getByDate(ownerId: string, date: Date): Promise; getByIds(ids: string[]): Promise; + getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated; + getByUserId(pagination: PaginationOptions, userId: string): Paginated; getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated; getWith(pagination: PaginationOptions, property: WithProperty): Paginated; getFirstAssetForAlbumId(albumId: string): Promise; diff --git a/server/src/domain/asset/asset.service.spec.ts b/server/src/domain/asset/asset.service.spec.ts index b6f2531138..ed155c1487 100644 --- a/server/src/domain/asset/asset.service.spec.ts +++ b/server/src/domain/asset/asset.service.spec.ts @@ -1,21 +1,48 @@ -import { assetEntityStub, authStub, newAssetRepositoryMock } from '@test'; +import { BadRequestException } from '@nestjs/common'; +import { + assetEntityStub, + authStub, + IAccessRepositoryMock, + newAccessRepositoryMock, + newAssetRepositoryMock, + newStorageRepositoryMock, +} from '@test'; import { when } from 'jest-when'; -import { AssetService, IAssetRepository, mapAsset } from '.'; +import { Readable } from 'stream'; +import { IStorageRepository } from '../storage'; +import { IAssetRepository } from './asset.repository'; +import { AssetService } from './asset.service'; +import { DownloadResponseDto } from './index'; +import { mapAsset } from './response-dto'; + +const downloadResponse: DownloadResponseDto = { + totalSize: 105_000, + archives: [ + { + assetIds: ['asset-id', 'asset-id'], + size: 105_000, + }, + ], +}; describe(AssetService.name, () => { let sut: AssetService; + let accessMock: IAccessRepositoryMock; let assetMock: jest.Mocked; + let storageMock: jest.Mocked; it('should work', () => { expect(sut).toBeDefined(); }); beforeEach(async () => { + accessMock = newAccessRepositoryMock(); assetMock = newAssetRepositoryMock(); - sut = new AssetService(assetMock); + storageMock = newStorageRepositoryMock(); + sut = new AssetService(accessMock, assetMock, storageMock); }); - describe('get map markers', () => { + describe('getMapMarkers', () => { it('should get geo information of assets', async () => { assetMock.getMapMarkers.mockResolvedValue( [assetEntityStub.withLocation].map((asset) => ({ @@ -76,25 +103,191 @@ describe(AssetService.name, () => { [authStub.admin.id, new Date('2021-06-15T05:00:00.000Z')], ]); }); + + it('should set the title correctly', async () => { + when(assetMock.getByDate) + .calledWith(authStub.admin.id, new Date('2022-06-15T00:00:00.000Z')) + .mockResolvedValue([assetEntityStub.image]); + when(assetMock.getByDate) + .calledWith(authStub.admin.id, new Date('2021-06-15T00:00:00.000Z')) + .mockResolvedValue([assetEntityStub.video]); + + await expect(sut.getMemoryLane(authStub.admin, { timestamp: new Date(2023, 5, 15), years: 2 })).resolves.toEqual([ + { title: '1 year since...', assets: [mapAsset(assetEntityStub.image)] }, + { title: '2 years since...', assets: [mapAsset(assetEntityStub.video)] }, + ]); + + expect(assetMock.getByDate).toHaveBeenCalledTimes(2); + expect(assetMock.getByDate.mock.calls).toEqual([ + [authStub.admin.id, new Date('2022-06-15T00:00:00.000Z')], + [authStub.admin.id, new Date('2021-06-15T00:00:00.000Z')], + ]); + }); }); - it('should set the title correctly', async () => { - when(assetMock.getByDate) - .calledWith(authStub.admin.id, new Date('2022-06-15T00:00:00.000Z')) - .mockResolvedValue([assetEntityStub.image]); - when(assetMock.getByDate) - .calledWith(authStub.admin.id, new Date('2021-06-15T00:00:00.000Z')) - .mockResolvedValue([assetEntityStub.video]); + describe('downloadFile', () => { + it('should require the asset.download permission', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(false); + accessMock.asset.hasAlbumAccess.mockResolvedValue(false); + accessMock.asset.hasPartnerAccess.mockResolvedValue(false); - await expect(sut.getMemoryLane(authStub.admin, { timestamp: new Date(2023, 5, 15), years: 2 })).resolves.toEqual([ - { title: '1 year since...', assets: [mapAsset(assetEntityStub.image)] }, - { title: '2 years since...', assets: [mapAsset(assetEntityStub.video)] }, - ]); + await expect(sut.downloadFile(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException); - expect(assetMock.getByDate).toHaveBeenCalledTimes(2); - expect(assetMock.getByDate.mock.calls).toEqual([ - [authStub.admin.id, new Date('2022-06-15T00:00:00.000Z')], - [authStub.admin.id, new Date('2021-06-15T00:00:00.000Z')], - ]); + expect(accessMock.asset.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'asset-1'); + expect(accessMock.asset.hasAlbumAccess).toHaveBeenCalledWith(authStub.admin.id, 'asset-1'); + expect(accessMock.asset.hasPartnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'asset-1'); + }); + + it('should throw an error if the asset is not found', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + assetMock.getByIds.mockResolvedValue([]); + + await expect(sut.downloadFile(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException); + + expect(assetMock.getByIds).toHaveBeenCalledWith(['asset-1']); + }); + + it('should download a file', async () => { + const stream = new Readable(); + + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + assetMock.getByIds.mockResolvedValue([assetEntityStub.image]); + storageMock.createReadStream.mockResolvedValue({ stream }); + + await expect(sut.downloadFile(authStub.admin, 'asset-1')).resolves.toEqual({ stream }); + + expect(storageMock.createReadStream).toHaveBeenCalledWith( + assetEntityStub.image.originalPath, + assetEntityStub.image.mimeType, + ); + }); + + it('should download an archive', async () => { + const archiveMock = { + addFile: jest.fn(), + finalize: jest.fn(), + stream: new Readable(), + }; + + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + assetMock.getByIds.mockResolvedValue([assetEntityStub.noResizePath, assetEntityStub.noWebpPath]); + storageMock.createZipStream.mockReturnValue(archiveMock); + + await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({ + stream: archiveMock.stream, + }); + + expect(archiveMock.addFile).toHaveBeenCalledTimes(2); + expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, 'upload/library/IMG_123.jpg', 'IMG_123.jpg'); + expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, 'upload/library/IMG_456.jpg', 'IMG_456.jpg'); + }); + + it('should handle duplicate file names', async () => { + const archiveMock = { + addFile: jest.fn(), + finalize: jest.fn(), + stream: new Readable(), + }; + + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + assetMock.getByIds.mockResolvedValue([assetEntityStub.noResizePath, assetEntityStub.noResizePath]); + storageMock.createZipStream.mockReturnValue(archiveMock); + + await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({ + stream: archiveMock.stream, + }); + + expect(archiveMock.addFile).toHaveBeenCalledTimes(2); + expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, 'upload/library/IMG_123.jpg', 'IMG_123.jpg'); + expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, 'upload/library/IMG_123.jpg', 'IMG_123+1.jpg'); + }); + }); + + describe('getDownloadInfo', () => { + it('should throw an error for an invalid dto', async () => { + await expect(sut.getDownloadInfo(authStub.admin, {})).rejects.toBeInstanceOf(BadRequestException); + }); + + it('should return a list of archives (assetIds)', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + assetMock.getByIds.mockResolvedValue([assetEntityStub.image, assetEntityStub.video]); + + const assetIds = ['asset-1', 'asset-2']; + await expect(sut.getDownloadInfo(authStub.admin, { assetIds })).resolves.toEqual(downloadResponse); + + expect(assetMock.getByIds).toHaveBeenCalledWith(['asset-1', 'asset-2']); + }); + + it('should return a list of archives (albumId)', async () => { + accessMock.album.hasOwnerAccess.mockResolvedValue(true); + assetMock.getByAlbumId.mockResolvedValue({ + items: [assetEntityStub.image, assetEntityStub.video], + hasNextPage: false, + }); + + await expect(sut.getDownloadInfo(authStub.admin, { albumId: 'album-1' })).resolves.toEqual(downloadResponse); + + expect(accessMock.album.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'album-1'); + expect(assetMock.getByAlbumId).toHaveBeenCalledWith({ take: 2500, skip: 0 }, 'album-1'); + }); + + it('should return a list of archives (userId)', async () => { + assetMock.getByUserId.mockResolvedValue({ + items: [assetEntityStub.image, assetEntityStub.video], + hasNextPage: false, + }); + + await expect(sut.getDownloadInfo(authStub.admin, { userId: authStub.admin.id })).resolves.toEqual( + downloadResponse, + ); + + expect(assetMock.getByUserId).toHaveBeenCalledWith({ take: 2500, skip: 0 }, authStub.admin.id); + }); + + it('should split archives by size', async () => { + assetMock.getByUserId.mockResolvedValue({ + items: [ + { ...assetEntityStub.image, id: 'asset-1' }, + { ...assetEntityStub.video, id: 'asset-2' }, + { ...assetEntityStub.withLocation, id: 'asset-3' }, + { ...assetEntityStub.noWebpPath, id: 'asset-4' }, + ], + hasNextPage: false, + }); + + await expect( + sut.getDownloadInfo(authStub.admin, { + userId: authStub.admin.id, + archiveSize: 30_000, + }), + ).resolves.toEqual({ + totalSize: 251_456, + archives: [ + { assetIds: ['asset-1', 'asset-2'], size: 105_000 }, + { assetIds: ['asset-3', 'asset-4'], size: 146_456 }, + ], + }); + }); + + it('should include the video portion of a live photo', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + when(assetMock.getByIds) + .calledWith([assetEntityStub.livePhotoStillAsset.id]) + .mockResolvedValue([assetEntityStub.livePhotoStillAsset]); + when(assetMock.getByIds) + .calledWith([assetEntityStub.livePhotoMotionAsset.id]) + .mockResolvedValue([assetEntityStub.livePhotoMotionAsset]); + + const assetIds = [assetEntityStub.livePhotoStillAsset.id]; + await expect(sut.getDownloadInfo(authStub.admin, { assetIds })).resolves.toEqual({ + totalSize: 125_000, + archives: [ + { + assetIds: [assetEntityStub.livePhotoStillAsset.id, assetEntityStub.livePhotoMotionAsset.id], + size: 125_000, + }, + ], + }); + }); }); }); diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts index 230192e112..51d3afb8dc 100644 --- a/server/src/domain/asset/asset.service.ts +++ b/server/src/domain/asset/asset.service.ts @@ -1,14 +1,27 @@ -import { Inject } from '@nestjs/common'; +import { BadRequestException, Inject } from '@nestjs/common'; import { DateTime } from 'luxon'; +import { extname } from 'path'; +import { AssetEntity } from '../../infra/entities/asset.entity'; import { AuthUserDto } from '../auth'; +import { HumanReadableSize, usePagination } from '../domain.util'; +import { AccessCore, IAccessRepository, Permission } from '../index'; +import { ImmichReadStream, IStorageRepository } from '../storage'; import { IAssetRepository } from './asset.repository'; -import { MemoryLaneDto } from './dto'; +import { AssetIdsDto, DownloadArchiveInfo, DownloadDto, DownloadResponseDto, MemoryLaneDto } from './dto'; import { MapMarkerDto } from './dto/map-marker.dto'; import { mapAsset, MapMarkerResponseDto } from './response-dto'; import { MemoryLaneResponseDto } from './response-dto/memory-lane-response.dto'; export class AssetService { - constructor(@Inject(IAssetRepository) private assetRepository: IAssetRepository) {} + private access: AccessCore; + + constructor( + @Inject(IAccessRepository) accessRepository: IAccessRepository, + @Inject(IAssetRepository) private assetRepository: IAssetRepository, + @Inject(IStorageRepository) private storageRepository: IStorageRepository, + ) { + this.access = new AccessCore(accessRepository); + } getMapMarkers(authUser: AuthUserDto, options: MapMarkerDto): Promise { return this.assetRepository.getMapMarkers(authUser.id, options); @@ -32,4 +45,102 @@ export class AssetService { return Promise.all(requests).then((results) => results.filter((result) => result.assets.length > 0)); } + + async downloadFile(authUser: AuthUserDto, id: string): Promise { + await this.access.requirePermission(authUser, Permission.ASSET_DOWNLOAD, id); + + const [asset] = await this.assetRepository.getByIds([id]); + if (!asset) { + throw new BadRequestException('Asset not found'); + } + + return this.storageRepository.createReadStream(asset.originalPath, asset.mimeType); + } + + async getDownloadInfo(authUser: AuthUserDto, dto: DownloadDto): Promise { + const targetSize = dto.archiveSize || HumanReadableSize.GiB * 4; + const archives: DownloadArchiveInfo[] = []; + let archive: DownloadArchiveInfo = { size: 0, assetIds: [] }; + + const assetPagination = await this.getDownloadAssets(authUser, dto); + for await (const assets of assetPagination) { + // motion part of live photos + const motionIds = assets.map((asset) => asset.livePhotoVideoId).filter((id): id is string => !!id); + if (motionIds.length > 0) { + assets.push(...(await this.assetRepository.getByIds(motionIds))); + } + + for (const asset of assets) { + archive.size += Number(asset.exifInfo?.fileSizeInByte || 0); + archive.assetIds.push(asset.id); + + if (archive.size > targetSize) { + archives.push(archive); + archive = { size: 0, assetIds: [] }; + } + } + + if (archive.assetIds.length > 0) { + archives.push(archive); + } + } + + return { + totalSize: archives.reduce((total, item) => (total += item.size), 0), + archives, + }; + } + + async downloadArchive(authUser: AuthUserDto, dto: AssetIdsDto): Promise { + await this.access.requirePermission(authUser, Permission.ASSET_DOWNLOAD, dto.assetIds); + + const zip = this.storageRepository.createZipStream(); + const assets = await this.assetRepository.getByIds(dto.assetIds); + const paths: Record = {}; + + for (const { originalPath, originalFileName } of assets) { + const ext = extname(originalPath); + let filename = `${originalFileName}${ext}`; + for (let i = 0; i < 10_000; i++) { + if (!paths[filename]) { + break; + } + filename = `${originalFileName}+${i + 1}${ext}`; + } + + paths[filename] = true; + zip.addFile(originalPath, filename); + } + + zip.finalize(); + + return { stream: zip.stream }; + } + + private async getDownloadAssets(authUser: AuthUserDto, dto: DownloadDto): Promise> { + const PAGINATION_SIZE = 2500; + + if (dto.assetIds) { + const assetIds = dto.assetIds; + await this.access.requirePermission(authUser, Permission.ASSET_DOWNLOAD, assetIds); + const assets = await this.assetRepository.getByIds(assetIds); + return (async function* () { + yield assets; + })(); + } + + if (dto.albumId) { + const albumId = dto.albumId; + await this.access.requirePermission(authUser, Permission.ALBUM_DOWNLOAD, albumId); + return usePagination(PAGINATION_SIZE, (pagination) => this.assetRepository.getByAlbumId(pagination, albumId)); + } + + if (dto.userId) { + const userId = dto.userId; + await this.access.requirePermission(authUser, Permission.LIBRARY_DOWNLOAD, userId); + return usePagination(PAGINATION_SIZE, (pagination) => this.assetRepository.getByUserId(pagination, userId)); + } + + throw new BadRequestException('assetIds, albumId, or userId is required'); + } } diff --git a/server/src/domain/asset/dto/download.dto.ts b/server/src/domain/asset/dto/download.dto.ts new file mode 100644 index 0000000000..cb6b8f7dde --- /dev/null +++ b/server/src/domain/asset/dto/download.dto.ts @@ -0,0 +1,31 @@ +import { ValidateUUID } from '@app/immich/decorators/validate-uuid.decorator'; +import { ApiProperty } from '@nestjs/swagger'; +import { IsInt, IsOptional, IsPositive } from 'class-validator'; + +export class DownloadDto { + @ValidateUUID({ each: true, optional: true }) + assetIds?: string[]; + + @ValidateUUID({ optional: true }) + albumId?: string; + + @ValidateUUID({ optional: true }) + userId?: string; + + @IsInt() + @IsPositive() + @IsOptional() + archiveSize?: number; +} + +export class DownloadResponseDto { + @ApiProperty({ type: 'integer' }) + totalSize!: number; + archives!: DownloadArchiveInfo[]; +} + +export class DownloadArchiveInfo { + @ApiProperty({ type: 'integer' }) + size!: number; + assetIds!: string[]; +} diff --git a/server/src/domain/asset/dto/index.ts b/server/src/domain/asset/dto/index.ts index 130f28144a..9778a91221 100644 --- a/server/src/domain/asset/dto/index.ts +++ b/server/src/domain/asset/dto/index.ts @@ -1,3 +1,4 @@ export * from './asset-ids.dto'; +export * from './download.dto'; export * from './map-marker.dto'; export * from './memory-lane.dto'; diff --git a/server/src/domain/storage/storage.repository.ts b/server/src/domain/storage/storage.repository.ts index 4ff1b5c018..7d312c0752 100644 --- a/server/src/domain/storage/storage.repository.ts +++ b/server/src/domain/storage/storage.repository.ts @@ -1,9 +1,14 @@ -import { ReadStream } from 'fs'; +import { Readable } from 'stream'; export interface ImmichReadStream { - stream: ReadStream; - type: string; - length: number; + stream: Readable; + type?: string; + length?: number; +} + +export interface ImmichZipStream extends ImmichReadStream { + addFile: (inputPath: string, filename: string) => void; + finalize: () => Promise; } export interface DiskUsage { @@ -15,7 +20,8 @@ export interface DiskUsage { export const IStorageRepository = 'IStorageRepository'; export interface IStorageRepository { - createReadStream(filepath: string, mimeType: string): Promise; + createZipStream(): ImmichZipStream; + createReadStream(filepath: string, mimeType?: string | null): Promise; unlink(filepath: string): Promise; unlinkDir(folder: string, options?: { recursive?: boolean; force?: boolean }): Promise; removeEmptyDirs(folder: string): Promise; diff --git a/server/src/immich/api-v1/album/album.controller.ts b/server/src/immich/api-v1/album/album.controller.ts index 5349f5d654..021cc04ce6 100644 --- a/server/src/immich/api-v1/album/album.controller.ts +++ b/server/src/immich/api-v1/album/album.controller.ts @@ -1,13 +1,10 @@ import { AlbumResponseDto } from '@app/domain'; -import { Body, Controller, Delete, Get, Param, Put, Query, Response } from '@nestjs/common'; -import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; -import { Response as Res } from 'express'; -import { handleDownload } from '../../app.utils'; +import { Body, Controller, Delete, Get, Param, Put } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; import { UUIDParamDto } from '../../controllers/dto/uuid-param.dto'; import { AuthUser, AuthUserDto } from '../../decorators/auth-user.decorator'; import { Authenticated, SharedLinkRoute } from '../../decorators/authenticated.decorator'; import { UseValidation } from '../../decorators/use-validation.decorator'; -import { DownloadDto } from '../asset/dto/download-library.dto'; import { AlbumService } from './album.service'; import { AddAssetsDto } from './dto/add-assets.dto'; import { RemoveAssetsDto } from './dto/remove-assets.dto'; @@ -18,7 +15,7 @@ import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto'; @Authenticated() @UseValidation() export class AlbumController { - constructor(private readonly service: AlbumService) {} + constructor(private service: AlbumService) {} @SharedLinkRoute() @Put(':id/assets') @@ -46,16 +43,4 @@ export class AlbumController { ): Promise { return this.service.removeAssets(authUser, id, dto); } - - @SharedLinkRoute() - @Get(':id/download') - @ApiOkResponse({ content: { 'application/zip': { schema: { type: 'string', format: 'binary' } } } }) - downloadArchive( - @AuthUser() authUser: AuthUserDto, - @Param() { id }: UUIDParamDto, - @Query() dto: DownloadDto, - @Response({ passthrough: true }) res: Res, - ) { - return this.service.downloadArchive(authUser, id, dto).then((download) => handleDownload(download, res)); - } } diff --git a/server/src/immich/api-v1/album/album.module.ts b/server/src/immich/api-v1/album/album.module.ts index 3b09fd6ea8..e241f96359 100644 --- a/server/src/immich/api-v1/album/album.module.ts +++ b/server/src/immich/api-v1/album/album.module.ts @@ -1,13 +1,12 @@ import { AlbumEntity, AssetEntity } from '@app/infra/entities'; import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { DownloadModule } from '../../modules/download/download.module'; import { AlbumRepository, IAlbumRepository } from './album-repository'; import { AlbumController } from './album.controller'; import { AlbumService } from './album.service'; @Module({ - imports: [TypeOrmModule.forFeature([AlbumEntity, AssetEntity]), DownloadModule], + imports: [TypeOrmModule.forFeature([AlbumEntity, AssetEntity])], controllers: [AlbumController], providers: [AlbumService, { provide: IAlbumRepository, useClass: AlbumRepository }], }) diff --git a/server/src/immich/api-v1/album/album.service.spec.ts b/server/src/immich/api-v1/album/album.service.spec.ts index 77ccbb67ae..1215e69905 100644 --- a/server/src/immich/api-v1/album/album.service.spec.ts +++ b/server/src/immich/api-v1/album/album.service.spec.ts @@ -3,7 +3,6 @@ import { AlbumEntity, UserEntity } from '@app/infra/entities'; import { ForbiddenException, NotFoundException } from '@nestjs/common'; import { userEntityStub } from '@test'; import { AuthUserDto } from '../../decorators/auth-user.decorator'; -import { DownloadService } from '../../modules/download/download.service'; import { IAlbumRepository } from './album-repository'; import { AlbumService } from './album.service'; import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto'; @@ -11,7 +10,6 @@ import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto'; describe('Album service', () => { let sut: AlbumService; let albumRepositoryMock: jest.Mocked; - let downloadServiceMock: jest.Mocked>; const authUser: AuthUserDto = Object.freeze({ id: '1111', @@ -98,11 +96,7 @@ describe('Album service', () => { updateThumbnails: jest.fn(), }; - downloadServiceMock = { - downloadArchive: jest.fn(), - }; - - sut = new AlbumService(albumRepositoryMock, downloadServiceMock as DownloadService); + sut = new AlbumService(albumRepositoryMock); }); it('gets an owned album', async () => { diff --git a/server/src/immich/api-v1/album/album.service.ts b/server/src/immich/api-v1/album/album.service.ts index 7e5e551e0a..5f7fc834e6 100644 --- a/server/src/immich/api-v1/album/album.service.ts +++ b/server/src/immich/api-v1/album/album.service.ts @@ -2,8 +2,6 @@ import { AlbumResponseDto, mapAlbum } from '@app/domain'; import { AlbumEntity } from '@app/infra/entities'; import { BadRequestException, ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { AuthUserDto } from '../../decorators/auth-user.decorator'; -import { DownloadService } from '../../modules/download/download.service'; -import { DownloadDto } from '../asset/dto/download-library.dto'; import { IAlbumRepository } from './album-repository'; import { AddAssetsDto } from './dto/add-assets.dto'; import { RemoveAssetsDto } from './dto/remove-assets.dto'; @@ -13,10 +11,7 @@ import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto'; export class AlbumService { private logger = new Logger(AlbumService.name); - constructor( - @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, - private downloadService: DownloadService, - ) {} + constructor(@Inject(IAlbumRepository) private repository: IAlbumRepository) {} private async _getAlbum({ authUser, @@ -27,9 +22,9 @@ export class AlbumService { albumId: string; validateIsOwner?: boolean; }): Promise { - await this.albumRepository.updateThumbnails(); + await this.repository.updateThumbnails(); - const album = await this.albumRepository.get(albumId); + const album = await this.repository.get(albumId); if (!album) { throw new NotFoundException('Album Not Found'); } @@ -50,7 +45,7 @@ export class AlbumService { async removeAssets(authUser: AuthUserDto, albumId: string, dto: RemoveAssetsDto): Promise { const album = await this._getAlbum({ authUser, albumId }); - const deletedCount = await this.albumRepository.removeAssets(album, dto); + const deletedCount = await this.repository.removeAssets(album, dto); const newAlbum = await this._getAlbum({ authUser, albumId }); if (deletedCount !== dto.assetIds.length) { @@ -67,7 +62,7 @@ export class AlbumService { } const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false }); - const result = await this.albumRepository.addAssets(album, dto); + const result = await this.repository.addAssets(album, dto); const newAlbum = await this._getAlbum({ authUser, albumId, validateIsOwner: false }); return { @@ -75,19 +70,4 @@ export class AlbumService { album: mapAlbum(newAlbum), }; } - - async downloadArchive(authUser: AuthUserDto, albumId: string, dto: DownloadDto) { - this.checkDownloadAccess(authUser); - - const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false }); - const assets = (album.assets || []).map((asset) => asset).slice(dto.skip || 0); - - return this.downloadService.downloadArchive(album.albumName, assets); - } - - private checkDownloadAccess(authUser: AuthUserDto) { - if (authUser.isPublicUser && !authUser.isAllowDownload) { - throw new ForbiddenException(); - } - } } diff --git a/server/src/immich/api-v1/asset/asset.controller.ts b/server/src/immich/api-v1/asset/asset.controller.ts index e7cc8a4b17..53e323a0ba 100644 --- a/server/src/immich/api-v1/asset/asset.controller.ts +++ b/server/src/immich/api-v1/asset/asset.controller.ts @@ -1,4 +1,4 @@ -import { AssetResponseDto, ImmichReadStream } from '@app/domain'; +import { AssetResponseDto } from '@app/domain'; import { Body, Controller, @@ -14,7 +14,6 @@ import { Put, Query, Response, - StreamableFile, UploadedFiles, UseInterceptors, ValidationPipe, @@ -22,7 +21,6 @@ import { import { FileFieldsInterceptor } from '@nestjs/platform-express'; import { ApiBody, ApiConsumes, ApiHeader, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { Response as Res } from 'express'; -import { handleDownload } from '../../app.utils'; import { assetUploadOption, ImmichFile } from '../../config/asset-upload.config'; import { UUIDParamDto } from '../../controllers/dto/uuid-param.dto'; import { AuthUser, AuthUserDto } from '../../decorators/auth-user.decorator'; @@ -36,8 +34,6 @@ import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto'; import { CreateAssetDto, ImportAssetDto, mapToUploadFile } from './dto/create-asset.dto'; import { DeleteAssetDto } from './dto/delete-asset.dto'; import { DeviceIdDto } from './dto/device-id.dto'; -import { DownloadFilesDto } from './dto/download-files.dto'; -import { DownloadDto } from './dto/download-library.dto'; import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto'; import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto'; import { GetAssetThumbnailDto } from './dto/get-asset-thumbnail.dto'; @@ -54,10 +50,6 @@ import { CuratedLocationsResponseDto } from './response-dto/curated-locations-re import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto'; import { DeleteAssetResponseDto } from './response-dto/delete-asset-response.dto'; -function asStreamableFile({ stream, type, length }: ImmichReadStream) { - return new StreamableFile(stream, { type, length }); -} - interface UploadFiles { assetData: ImmichFile[]; livePhotoData?: ImmichFile[]; @@ -128,38 +120,6 @@ export class AssetController { return responseDto; } - @SharedLinkRoute() - @Get('/download/:id') - @ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } }) - downloadFile(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto) { - return this.assetService.downloadFile(authUser, id).then(asStreamableFile); - } - - @SharedLinkRoute() - @Post('/download-files') - @ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } }) - downloadFiles( - @AuthUser() authUser: AuthUserDto, - @Response({ passthrough: true }) res: Res, - @Body(new ValidationPipe()) dto: DownloadFilesDto, - ) { - return this.assetService.downloadFiles(authUser, dto).then((download) => handleDownload(download, res)); - } - - /** - * Current this is not used in any UI element - */ - @SharedLinkRoute() - @Get('/download-library') - @ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } }) - downloadLibrary( - @AuthUser() authUser: AuthUserDto, - @Query(new ValidationPipe({ transform: true })) dto: DownloadDto, - @Response({ passthrough: true }) res: Res, - ) { - return this.assetService.downloadLibrary(authUser, dto).then((download) => handleDownload(download, res)); - } - @SharedLinkRoute() @Get('/file/:id') @Header('Cache-Control', 'private, max-age=86400, no-transform') diff --git a/server/src/immich/api-v1/asset/asset.module.ts b/server/src/immich/api-v1/asset/asset.module.ts index 1f633d9554..2d9cdd4fe3 100644 --- a/server/src/immich/api-v1/asset/asset.module.ts +++ b/server/src/immich/api-v1/asset/asset.module.ts @@ -1,17 +1,12 @@ import { AssetEntity, ExifEntity } from '@app/infra/entities'; import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { DownloadModule } from '../../modules/download/download.module'; import { AssetRepository, IAssetRepository } from './asset-repository'; import { AssetController } from './asset.controller'; import { AssetService } from './asset.service'; @Module({ - imports: [ - // - TypeOrmModule.forFeature([AssetEntity, ExifEntity]), - DownloadModule, - ], + imports: [TypeOrmModule.forFeature([AssetEntity, ExifEntity])], controllers: [AssetController], providers: [AssetService, { provide: IAssetRepository, useClass: AssetRepository }], }) diff --git a/server/src/immich/api-v1/asset/asset.service.spec.ts b/server/src/immich/api-v1/asset/asset.service.spec.ts index 5963aa0a61..de236ca5fe 100644 --- a/server/src/immich/api-v1/asset/asset.service.spec.ts +++ b/server/src/immich/api-v1/asset/asset.service.spec.ts @@ -13,7 +13,6 @@ import { } from '@test'; import { when } from 'jest-when'; import { QueryFailedError, Repository } from 'typeorm'; -import { DownloadService } from '../../modules/download/download.service'; import { IAssetRepository } from './asset-repository'; import { AssetService } from './asset.service'; import { CreateAssetDto } from './dto/create-asset.dto'; @@ -124,7 +123,6 @@ describe('AssetService', () => { let accessMock: IAccessRepositoryMock; let assetRepositoryMock: jest.Mocked; let cryptoMock: jest.Mocked; - let downloadServiceMock: jest.Mocked>; let jobMock: jest.Mocked; let storageMock: jest.Mocked; @@ -152,24 +150,12 @@ describe('AssetService', () => { cryptoMock = newCryptoRepositoryMock(); - downloadServiceMock = { - downloadArchive: jest.fn(), - }; - accessMock = newAccessRepositoryMock(); cryptoMock = newCryptoRepositoryMock(); jobMock = newJobRepositoryMock(); storageMock = newStorageRepositoryMock(); - sut = new AssetService( - accessMock, - assetRepositoryMock, - a, - cryptoMock, - downloadServiceMock as DownloadService, - jobMock, - storageMock, - ); + sut = new AssetService(accessMock, assetRepositoryMock, a, cryptoMock, jobMock, storageMock); when(assetRepositoryMock.get) .calledWith(assetEntityStub.livePhotoStillAsset.id) @@ -398,27 +384,6 @@ describe('AssetService', () => { }); }); - // describe('checkDownloadAccess', () => { - // it('should validate download access', async () => { - // await sut.checkDownloadAccess(authStub.adminSharedLink); - // }); - - // it('should not allow when user is not allowed to download', async () => { - // expect(() => sut.checkDownloadAccess(authStub.readonlySharedLink)).toThrow(ForbiddenException); - // }); - // }); - - describe('downloadFile', () => { - it('should download a single file', async () => { - accessMock.asset.hasOwnerAccess.mockResolvedValue(true); - assetRepositoryMock.get.mockResolvedValue(_getAsset_1()); - - await sut.downloadFile(authStub.admin, 'id_1'); - - expect(storageMock.createReadStream).toHaveBeenCalledWith('fake_path/asset_1.jpeg', 'image/jpeg'); - }); - }); - describe('bulkUploadCheck', () => { it('should accept hex and base64 checksums', async () => { const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex'); diff --git a/server/src/immich/api-v1/asset/asset.service.ts b/server/src/immich/api-v1/asset/asset.service.ts index 53335ceaf2..1b1dd00ee7 100644 --- a/server/src/immich/api-v1/asset/asset.service.ts +++ b/server/src/immich/api-v1/asset/asset.service.ts @@ -6,7 +6,6 @@ import { IAccessRepository, ICryptoRepository, IJobRepository, - ImmichReadStream, isSupportedFileType, IStorageRepository, JobName, @@ -33,7 +32,6 @@ import mime from 'mime-types'; import path from 'path'; import { QueryFailedError, Repository } from 'typeorm'; import { promisify } from 'util'; -import { DownloadService } from '../../modules/download/download.service'; import { IAssetRepository } from './asset-repository'; import { AssetCore } from './asset.core'; import { AssetBulkUploadCheckDto } from './dto/asset-check.dto'; @@ -42,8 +40,6 @@ import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto'; import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto'; import { CreateAssetDto, ImportAssetDto, UploadFile } from './dto/create-asset.dto'; import { DeleteAssetDto } from './dto/delete-asset.dto'; -import { DownloadFilesDto } from './dto/download-files.dto'; -import { DownloadDto } from './dto/download-library.dto'; import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto'; import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto'; import { GetAssetThumbnailDto, GetAssetThumbnailFormatEnum } from './dto/get-asset-thumbnail.dto'; @@ -86,7 +82,6 @@ export class AssetService { @Inject(IAssetRepository) private _assetRepository: IAssetRepository, @InjectRepository(AssetEntity) private assetRepository: Repository, @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, - private downloadService: DownloadService, @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, ) { @@ -250,50 +245,6 @@ export class AssetService { return mapAsset(updatedAsset); } - public async downloadLibrary(authUser: AuthUserDto, dto: DownloadDto) { - await this.access.requirePermission(authUser, Permission.LIBRARY_DOWNLOAD, authUser.id); - - const assets = await this._assetRepository.getAllByUserId(authUser.id, dto); - - return this.downloadService.downloadArchive(dto.name || `library`, assets); - } - - public async downloadFiles(authUser: AuthUserDto, dto: DownloadFilesDto) { - await this.access.requirePermission(authUser, Permission.ASSET_DOWNLOAD, dto.assetIds); - - const assetToDownload = []; - - for (const assetId of dto.assetIds) { - const asset = await this._assetRepository.getById(assetId); - assetToDownload.push(asset); - - // Get live photo asset - if (asset.livePhotoVideoId) { - const livePhotoAsset = await this._assetRepository.getById(asset.livePhotoVideoId); - assetToDownload.push(livePhotoAsset); - } - } - - const now = new Date().toISOString(); - return this.downloadService.downloadArchive(`immich-${now}`, assetToDownload); - } - - public async downloadFile(authUser: AuthUserDto, assetId: string): Promise { - await this.access.requirePermission(authUser, Permission.ASSET_DOWNLOAD, assetId); - - try { - const asset = await this._assetRepository.get(assetId); - if (asset && asset.originalPath && asset.mimeType) { - return this.storageRepository.createReadStream(asset.originalPath, asset.mimeType); - } - } catch (e) { - Logger.error(`Error download asset ${e}`, 'downloadFile'); - throw new InternalServerErrorException(`Failed to download asset ${e}`, 'DownloadFile'); - } - - throw new NotFoundException(); - } - async getAssetThumbnail( authUser: AuthUserDto, assetId: string, diff --git a/server/src/immich/api-v1/asset/dto/download-files.dto.ts b/server/src/immich/api-v1/asset/dto/download-files.dto.ts deleted file mode 100644 index 557db73d57..0000000000 --- a/server/src/immich/api-v1/asset/dto/download-files.dto.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty } from 'class-validator'; - -export class DownloadFilesDto { - @IsNotEmpty() - @ApiProperty({ - isArray: true, - type: String, - title: 'Array of asset ids to be downloaded', - }) - assetIds!: string[]; -} diff --git a/server/src/immich/api-v1/asset/dto/download-library.dto.ts b/server/src/immich/api-v1/asset/dto/download-library.dto.ts deleted file mode 100644 index 7e1dfd12d7..0000000000 --- a/server/src/immich/api-v1/asset/dto/download-library.dto.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Type } from 'class-transformer'; -import { IsNumber, IsOptional, IsPositive, IsString } from 'class-validator'; - -export class DownloadDto { - @IsOptional() - @IsString() - name?: string; - - @IsOptional() - @IsPositive() - @IsNumber() - @Type(() => Number) - skip?: number; -} diff --git a/server/src/immich/app.utils.ts b/server/src/immich/app.utils.ts index ff130bf755..8355964a85 100644 --- a/server/src/immich/app.utils.ts +++ b/server/src/immich/app.utils.ts @@ -1,5 +1,11 @@ -import { IMMICH_ACCESS_COOKIE, IMMICH_API_KEY_HEADER, IMMICH_API_KEY_NAME, SERVER_VERSION } from '@app/domain'; -import { INestApplication } from '@nestjs/common'; +import { + ImmichReadStream, + IMMICH_ACCESS_COOKIE, + IMMICH_API_KEY_HEADER, + IMMICH_API_KEY_NAME, + SERVER_VERSION, +} from '@app/domain'; +import { INestApplication, StreamableFile } from '@nestjs/common'; import { DocumentBuilder, OpenAPIObject, @@ -7,18 +13,12 @@ import { SwaggerDocumentOptions, SwaggerModule, } from '@nestjs/swagger'; -import { Response } from 'express'; import { writeFileSync } from 'fs'; import path from 'path'; import { Metadata } from './decorators/authenticated.decorator'; -import { DownloadArchive } from './modules/download/download.service'; -export const handleDownload = (download: DownloadArchive, res: Response) => { - res.attachment(download.fileName); - res.setHeader('X-Immich-Content-Length-Hint', download.fileSize); - res.setHeader('X-Immich-Archive-File-Count', download.fileCount); - res.setHeader('X-Immich-Archive-Complete', `${download.complete}`); - return download.stream; +export const asStreamableFile = ({ stream, type, length }: ImmichReadStream) => { + return new StreamableFile(stream, { type, length }); }; function sortKeys(obj: T): T { diff --git a/server/src/immich/controllers/asset.controller.ts b/server/src/immich/controllers/asset.controller.ts index c6dbd22184..dfea0d5a7e 100644 --- a/server/src/immich/controllers/asset.controller.ts +++ b/server/src/immich/controllers/asset.controller.ts @@ -1,11 +1,21 @@ -import { AssetService, AuthUserDto, MapMarkerResponseDto, MemoryLaneDto } from '@app/domain'; +import { + AssetIdsDto, + AssetService, + AuthUserDto, + DownloadDto, + DownloadResponseDto, + MapMarkerResponseDto, + MemoryLaneDto, +} from '@app/domain'; import { MapMarkerDto } from '@app/domain/asset/dto/map-marker.dto'; import { MemoryLaneResponseDto } from '@app/domain/asset/response-dto/memory-lane-response.dto'; -import { Controller, Get, Query } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; +import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Query, StreamableFile } from '@nestjs/common'; +import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import { asStreamableFile } from '../app.utils'; import { AuthUser } from '../decorators/auth-user.decorator'; -import { Authenticated } from '../decorators/authenticated.decorator'; +import { Authenticated, SharedLinkRoute } from '../decorators/authenticated.decorator'; import { UseValidation } from '../decorators/use-validation.decorator'; +import { UUIDParamDto } from './dto/uuid-param.dto'; @ApiTags('Asset') @Controller('asset') @@ -23,4 +33,26 @@ export class AssetController { getMemoryLane(@AuthUser() authUser: AuthUserDto, @Query() dto: MemoryLaneDto): Promise { return this.service.getMemoryLane(authUser, dto); } + + @SharedLinkRoute() + @Get('download') + getDownloadInfo(@AuthUser() authUser: AuthUserDto, @Query() dto: DownloadDto): Promise { + return this.service.getDownloadInfo(authUser, dto); + } + + @SharedLinkRoute() + @Post('download') + @HttpCode(HttpStatus.OK) + @ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } }) + downloadArchive(@AuthUser() authUser: AuthUserDto, @Body() dto: AssetIdsDto): Promise { + return this.service.downloadArchive(authUser, dto).then(asStreamableFile); + } + + @SharedLinkRoute() + @Post('download/:id') + @HttpCode(HttpStatus.OK) + @ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } }) + downloadFile(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto) { + return this.service.downloadFile(authUser, id).then(asStreamableFile); + } } diff --git a/server/src/immich/modules/download/download.module.ts b/server/src/immich/modules/download/download.module.ts deleted file mode 100644 index 354982cc64..0000000000 --- a/server/src/immich/modules/download/download.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Module } from '@nestjs/common'; -import { DownloadService } from './download.service'; - -@Module({ - providers: [DownloadService], - exports: [DownloadService], -}) -export class DownloadModule {} diff --git a/server/src/immich/modules/download/download.service.ts b/server/src/immich/modules/download/download.service.ts deleted file mode 100644 index 65a460278c..0000000000 --- a/server/src/immich/modules/download/download.service.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { asHumanReadable, HumanReadableSize } from '@app/domain'; -import { AssetEntity } from '@app/infra/entities'; -import { BadRequestException, Injectable, InternalServerErrorException, Logger, StreamableFile } from '@nestjs/common'; -import archiver from 'archiver'; -import { extname } from 'path'; - -export interface DownloadArchive { - stream: StreamableFile; - fileName: string; - fileSize: number; - fileCount: number; - complete: boolean; -} - -@Injectable() -export class DownloadService { - private readonly logger = new Logger(DownloadService.name); - - public async downloadArchive(name: string, assets: AssetEntity[]): Promise { - if (!assets || assets.length === 0) { - throw new BadRequestException('No assets to download.'); - } - - try { - const archive = archiver('zip', { store: true }); - const stream = new StreamableFile(archive); - let totalSize = 0; - let fileCount = 0; - let complete = true; - - for (const { originalPath, exifInfo, originalFileName } of assets) { - const name = `${originalFileName}${extname(originalPath)}`; - archive.file(originalPath, { name }); - totalSize += Number(exifInfo?.fileSizeInByte || 0); - fileCount++; - - // for easier testing, can be changed before merging. - if (totalSize > HumanReadableSize.GiB * 20) { - complete = false; - this.logger.log( - `Archive size exceeded after ${fileCount} files, capping at ${totalSize} bytes (${asHumanReadable( - totalSize, - )})`, - ); - break; - } - } - - archive.finalize(); - - return { - stream, - fileName: `${name}.zip`, - fileSize: totalSize, - fileCount, - complete, - }; - } catch (error) { - this.logger.error(`Error creating download archive ${error}`); - throw new InternalServerErrorException(`Failed to download ${name}: ${error}`, 'DownloadArchive'); - } - } -} diff --git a/server/src/infra/repositories/access.repository.ts b/server/src/infra/repositories/access.repository.ts index d1d986e720..fe518807e6 100644 --- a/server/src/infra/repositories/access.repository.ts +++ b/server/src/infra/repositories/access.repository.ts @@ -140,7 +140,9 @@ export class AccessRepository implements IAccessRepository { return this.albumRepository.exist({ where: { id: albumId, - ownerId: userId, + sharedUsers: { + id: userId, + }, }, }); }, diff --git a/server/src/infra/repositories/asset.repository.ts b/server/src/infra/repositories/asset.repository.ts index 1139dbf11f..a237872521 100644 --- a/server/src/infra/repositories/asset.repository.ts +++ b/server/src/infra/repositories/asset.repository.ts @@ -72,6 +72,32 @@ export class AssetRepository implements IAssetRepository { await this.repository.delete({ ownerId }); } + getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated { + return paginate(this.repository, pagination, { + where: { + albums: { + id: albumId, + }, + }, + relations: { + albums: true, + exifInfo: true, + }, + }); + } + + getByUserId(pagination: PaginationOptions, userId: string): Paginated { + return paginate(this.repository, pagination, { + where: { + ownerId: userId, + isVisible: true, + }, + relations: { + exifInfo: true, + }, + }); + } + getAll(pagination: PaginationOptions, options: AssetSearchOptions = {}): Paginated { return paginate(this.repository, pagination, { where: { diff --git a/server/src/infra/repositories/filesystem.provider.ts b/server/src/infra/repositories/filesystem.provider.ts index dcb151b4d8..d82e776c82 100644 --- a/server/src/infra/repositories/filesystem.provider.ts +++ b/server/src/infra/repositories/filesystem.provider.ts @@ -1,4 +1,5 @@ -import { DiskUsage, ImmichReadStream, IStorageRepository } from '@app/domain'; +import { DiskUsage, ImmichReadStream, ImmichZipStream, IStorageRepository } from '@app/domain'; +import archiver from 'archiver'; import { constants, createReadStream, existsSync, mkdirSync } from 'fs'; import fs from 'fs/promises'; import mv from 'mv'; @@ -8,13 +9,25 @@ import path from 'path'; const moveFile = promisify(mv); export class FilesystemProvider implements IStorageRepository { - async createReadStream(filepath: string, mimeType: string): Promise { + createZipStream(): ImmichZipStream { + const archive = archiver('zip', { store: true }); + + const addFile = (input: string, filename: string) => { + archive.file(input, { name: filename }); + }; + + const finalize = () => archive.finalize(); + + return { stream: archive, addFile, finalize }; + } + + async createReadStream(filepath: string, mimeType?: string | null): Promise { const { size } = await fs.stat(filepath); await fs.access(filepath, constants.R_OK | constants.W_OK); return { stream: createReadStream(filepath), length: size, - type: mimeType, + type: mimeType || undefined, }; } diff --git a/server/test/fixtures.ts b/server/test/fixtures.ts index 970f152829..f8dc7a7581 100644 --- a/server/test/fixtures.ts +++ b/server/test/fixtures.ts @@ -203,14 +203,14 @@ export const fileStub = { export const assetEntityStub = { noResizePath: Object.freeze({ id: 'asset-id', - originalFileName: 'asset_1.jpeg', + originalFileName: 'IMG_123', deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), owner: userEntityStub.user1, ownerId: 'user-id', deviceId: 'device-id', - originalPath: 'upload/upload/path.ext', + originalPath: 'upload/library/IMG_123.jpg', resizePath: null, checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, @@ -240,7 +240,7 @@ export const assetEntityStub = { owner: userEntityStub.user1, ownerId: 'user-id', deviceId: 'device-id', - originalPath: '/original/path.ext', + originalPath: 'upload/library/IMG_456.jpg', resizePath: '/uploads/user-id/thumbs/path.ext', checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, @@ -258,10 +258,13 @@ export const assetEntityStub = { livePhotoVideoId: null, tags: [], sharedLinks: [], - originalFileName: 'asset-id.ext', + originalFileName: 'IMG_456', faces: [], sidecarPath: null, isReadOnly: false, + exifInfo: { + fileSizeInByte: 123_000, + } as ExifEntity, }), noThumbhash: Object.freeze({ id: 'asset-id', @@ -324,6 +327,9 @@ export const assetEntityStub = { originalFileName: 'asset-id.ext', faces: [], sidecarPath: null, + exifInfo: { + fileSizeInByte: 5_000, + } as ExifEntity, }), video: Object.freeze({ id: 'asset-id', @@ -355,6 +361,9 @@ export const assetEntityStub = { sharedLinks: [], faces: [], sidecarPath: null, + exifInfo: { + fileSizeInByte: 100_000, + } as ExifEntity, }), livePhotoMotionAsset: Object.freeze({ id: 'live-photo-motion-asset', @@ -364,6 +373,9 @@ export const assetEntityStub = { isVisible: false, fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'), fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), + exifInfo: { + fileSizeInByte: 100_000, + }, } as AssetEntity), livePhotoStillAsset: Object.freeze({ @@ -375,6 +387,9 @@ export const assetEntityStub = { isVisible: true, fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'), fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), + exifInfo: { + fileSizeInByte: 25_000, + }, } as AssetEntity), withLocation: Object.freeze({ @@ -410,6 +425,7 @@ export const assetEntityStub = { exifInfo: { latitude: 100, longitude: 100, + fileSizeInByte: 23_456, } as ExifEntity, }), sidecar: Object.freeze({ diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index 51dbb3a272..7e8a522626 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -4,6 +4,8 @@ export const newAssetRepositoryMock = (): jest.Mocked => { return { getByDate: jest.fn(), getByIds: jest.fn().mockResolvedValue([]), + getByAlbumId: jest.fn(), + getByUserId: jest.fn(), getWithout: jest.fn(), getWith: jest.fn(), getFirstAssetForAlbumId: jest.fn(), diff --git a/server/test/repositories/storage.repository.mock.ts b/server/test/repositories/storage.repository.mock.ts index 21b289f938..08556a0815 100644 --- a/server/test/repositories/storage.repository.mock.ts +++ b/server/test/repositories/storage.repository.mock.ts @@ -2,6 +2,7 @@ import { IStorageRepository } from '@app/domain'; export const newStorageRepositoryMock = (): jest.Mocked => { return { + createZipStream: jest.fn(), createReadStream: jest.fn(), unlink: jest.fn(), unlinkDir: jest.fn().mockResolvedValue(true), diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index e3b035b698..1393f5c208 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -1111,16 +1111,41 @@ export type DeleteAssetStatus = typeof DeleteAssetStatus[keyof typeof DeleteAsse /** * * @export - * @interface DownloadFilesDto + * @interface DownloadArchiveInfo */ -export interface DownloadFilesDto { +export interface DownloadArchiveInfo { + /** + * + * @type {number} + * @memberof DownloadArchiveInfo + */ + 'size': number; /** * * @type {Array} - * @memberof DownloadFilesDto + * @memberof DownloadArchiveInfo */ 'assetIds': Array; } +/** + * + * @export + * @interface DownloadResponseDto + */ +export interface DownloadResponseDto { + /** + * + * @type {number} + * @memberof DownloadResponseDto + */ + 'totalSize': number; + /** + * + * @type {Array} + * @memberof DownloadResponseDto + */ + 'archives': Array; +} /** * * @export @@ -3645,63 +3670,6 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, - /** - * - * @param {string} id - * @param {string} [name] - * @param {number} [skip] - * @param {string} [key] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - downloadArchive: async (id: string, name?: string, skip?: number, key?: string, options: AxiosRequestConfig = {}): Promise => { - // verify required parameter 'id' is not null or undefined - assertParamExists('downloadArchive', 'id', id) - const localVarPath = `/album/{id}/download` - .replace(`{${"id"}}`, encodeURIComponent(String(id))); - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - // authentication cookie required - - // authentication api_key required - await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) - - // authentication bearer required - // http bearer authentication required - await setBearerAuthToObject(localVarHeaderParameter, configuration) - - if (name !== undefined) { - localVarQueryParameter['name'] = name; - } - - if (skip !== undefined) { - localVarQueryParameter['skip'] = skip; - } - - if (key !== undefined) { - localVarQueryParameter['key'] = key; - } - - - setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -4039,19 +4007,6 @@ export const AlbumApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.deleteAlbum(id, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, - /** - * - * @param {string} id - * @param {string} [name] - * @param {number} [skip] - * @param {string} [key] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async downloadArchive(id: string, name?: string, skip?: number, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.downloadArchive(id, name, skip, key, options); - return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); - }, /** * * @param {*} [options] Override http request option. @@ -4165,18 +4120,6 @@ export const AlbumApiFactory = function (configuration?: Configuration, basePath deleteAlbum(id: string, options?: any): AxiosPromise { return localVarFp.deleteAlbum(id, options).then((request) => request(axios, basePath)); }, - /** - * - * @param {string} id - * @param {string} [name] - * @param {number} [skip] - * @param {string} [key] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - downloadArchive(id: string, name?: string, skip?: number, key?: string, options?: any): AxiosPromise { - return localVarFp.downloadArchive(id, name, skip, key, options).then((request) => request(axios, basePath)); - }, /** * * @param {*} [options] Override http request option. @@ -4315,41 +4258,6 @@ export interface AlbumApiDeleteAlbumRequest { readonly id: string } -/** - * Request parameters for downloadArchive operation in AlbumApi. - * @export - * @interface AlbumApiDownloadArchiveRequest - */ -export interface AlbumApiDownloadArchiveRequest { - /** - * - * @type {string} - * @memberof AlbumApiDownloadArchive - */ - readonly id: string - - /** - * - * @type {string} - * @memberof AlbumApiDownloadArchive - */ - readonly name?: string - - /** - * - * @type {number} - * @memberof AlbumApiDownloadArchive - */ - readonly skip?: number - - /** - * - * @type {string} - * @memberof AlbumApiDownloadArchive - */ - readonly key?: string -} - /** * Request parameters for getAlbumInfo operation in AlbumApi. * @export @@ -4506,17 +4414,6 @@ export class AlbumApi extends BaseAPI { return AlbumApiFp(this.configuration).deleteAlbum(requestParameters.id, options).then((request) => request(this.axios, this.basePath)); } - /** - * - * @param {AlbumApiDownloadArchiveRequest} requestParameters Request parameters. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - * @memberof AlbumApi - */ - public downloadArchive(requestParameters: AlbumApiDownloadArchiveRequest, options?: AxiosRequestConfig) { - return AlbumApiFp(this.configuration).downloadArchive(requestParameters.id, requestParameters.name, requestParameters.skip, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); - } - /** * * @param {*} [options] Override http request option. @@ -4773,62 +4670,15 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration }, /** * - * @param {string} id + * @param {AssetIdsDto} assetIdsDto * @param {string} [key] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - downloadFile: async (id: string, key?: string, options: AxiosRequestConfig = {}): Promise => { - // verify required parameter 'id' is not null or undefined - assertParamExists('downloadFile', 'id', id) - const localVarPath = `/asset/download/{id}` - .replace(`{${"id"}}`, encodeURIComponent(String(id))); - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - // authentication cookie required - - // authentication api_key required - await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) - - // authentication bearer required - // http bearer authentication required - await setBearerAuthToObject(localVarHeaderParameter, configuration) - - if (key !== undefined) { - localVarQueryParameter['key'] = key; - } - - - - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, - /** - * - * @param {DownloadFilesDto} downloadFilesDto - * @param {string} [key] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - downloadFiles: async (downloadFilesDto: DownloadFilesDto, key?: string, options: AxiosRequestConfig = {}): Promise => { - // verify required parameter 'downloadFilesDto' is not null or undefined - assertParamExists('downloadFiles', 'downloadFilesDto', downloadFilesDto) - const localVarPath = `/asset/download-files`; + downloadArchive: async (assetIdsDto: AssetIdsDto, key?: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'assetIdsDto' is not null or undefined + assertParamExists('downloadArchive', 'assetIdsDto', assetIdsDto) + const localVarPath = `/asset/download`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); let baseOptions; @@ -4860,7 +4710,7 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - localVarRequestOptions.data = serializeDataIfNeeded(downloadFilesDto, localVarRequestOptions, configuration) + localVarRequestOptions.data = serializeDataIfNeeded(assetIdsDto, localVarRequestOptions, configuration) return { url: toPathString(localVarUrlObj), @@ -4868,15 +4718,17 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration }; }, /** - * Current this is not used in any UI element - * @param {string} [name] - * @param {number} [skip] + * + * @param {string} id * @param {string} [key] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - downloadLibrary: async (name?: string, skip?: number, key?: string, options: AxiosRequestConfig = {}): Promise => { - const localVarPath = `/asset/download-library`; + downloadFile: async (id: string, key?: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('downloadFile', 'id', id) + const localVarPath = `/asset/download/{id}` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); let baseOptions; @@ -4884,7 +4736,7 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration baseOptions = configuration.baseOptions; } - const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; const localVarHeaderParameter = {} as any; const localVarQueryParameter = {} as any; @@ -4897,14 +4749,6 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration // http bearer authentication required await setBearerAuthToObject(localVarHeaderParameter, configuration) - if (name !== undefined) { - localVarQueryParameter['name'] = name; - } - - if (skip !== undefined) { - localVarQueryParameter['skip'] = skip; - } - if (key !== undefined) { localVarQueryParameter['key'] = key; } @@ -5356,6 +5200,69 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {Array} [assetIds] + * @param {string} [albumId] + * @param {string} [userId] + * @param {number} [archiveSize] + * @param {string} [key] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getDownloadInfo: async (assetIds?: Array, albumId?: string, userId?: string, archiveSize?: number, key?: string, options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/asset/download`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + if (assetIds) { + localVarQueryParameter['assetIds'] = assetIds; + } + + if (albumId !== undefined) { + localVarQueryParameter['albumId'] = albumId; + } + + if (userId !== undefined) { + localVarQueryParameter['userId'] = userId; + } + + if (archiveSize !== undefined) { + localVarQueryParameter['archiveSize'] = archiveSize; + } + + if (key !== undefined) { + localVarQueryParameter['key'] = key; + } + + + setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -5888,6 +5795,17 @@ export const AssetApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.deleteAsset(deleteAssetDto, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {AssetIdsDto} assetIdsDto + * @param {string} [key] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async downloadArchive(assetIdsDto: AssetIdsDto, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.downloadArchive(assetIdsDto, key, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {string} id @@ -5899,29 +5817,6 @@ export const AssetApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.downloadFile(id, key, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, - /** - * - * @param {DownloadFilesDto} downloadFilesDto - * @param {string} [key] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async downloadFiles(downloadFilesDto: DownloadFilesDto, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.downloadFiles(downloadFilesDto, key, options); - return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); - }, - /** - * Current this is not used in any UI element - * @param {string} [name] - * @param {number} [skip] - * @param {string} [key] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async downloadLibrary(name?: string, skip?: number, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.downloadLibrary(name, skip, key, options); - return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); - }, /** * Get all AssetEntity belong to the user * @param {string} [userId] @@ -6025,6 +5920,20 @@ export const AssetApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.getCuratedObjects(options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {Array} [assetIds] + * @param {string} [albumId] + * @param {string} [userId] + * @param {number} [archiveSize] + * @param {string} [key] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getDownloadInfo(assetIds?: Array, albumId?: string, userId?: string, archiveSize?: number, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getDownloadInfo(assetIds, albumId, userId, archiveSize, key, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {boolean} [isFavorite] @@ -6172,6 +6081,16 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath deleteAsset(deleteAssetDto: DeleteAssetDto, options?: any): AxiosPromise> { return localVarFp.deleteAsset(deleteAssetDto, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {AssetIdsDto} assetIdsDto + * @param {string} [key] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + downloadArchive(assetIdsDto: AssetIdsDto, key?: string, options?: any): AxiosPromise { + return localVarFp.downloadArchive(assetIdsDto, key, options).then((request) => request(axios, basePath)); + }, /** * * @param {string} id @@ -6182,27 +6101,6 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath downloadFile(id: string, key?: string, options?: any): AxiosPromise { return localVarFp.downloadFile(id, key, options).then((request) => request(axios, basePath)); }, - /** - * - * @param {DownloadFilesDto} downloadFilesDto - * @param {string} [key] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - downloadFiles(downloadFilesDto: DownloadFilesDto, key?: string, options?: any): AxiosPromise { - return localVarFp.downloadFiles(downloadFilesDto, key, options).then((request) => request(axios, basePath)); - }, - /** - * Current this is not used in any UI element - * @param {string} [name] - * @param {number} [skip] - * @param {string} [key] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - downloadLibrary(name?: string, skip?: number, key?: string, options?: any): AxiosPromise { - return localVarFp.downloadLibrary(name, skip, key, options).then((request) => request(axios, basePath)); - }, /** * Get all AssetEntity belong to the user * @param {string} [userId] @@ -6296,6 +6194,19 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath getCuratedObjects(options?: any): AxiosPromise> { return localVarFp.getCuratedObjects(options).then((request) => request(axios, basePath)); }, + /** + * + * @param {Array} [assetIds] + * @param {string} [albumId] + * @param {string} [userId] + * @param {number} [archiveSize] + * @param {string} [key] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getDownloadInfo(assetIds?: Array, albumId?: string, userId?: string, archiveSize?: number, key?: string, options?: any): AxiosPromise { + return localVarFp.getDownloadInfo(assetIds, albumId, userId, archiveSize, key, options).then((request) => request(axios, basePath)); + }, /** * * @param {boolean} [isFavorite] @@ -6454,6 +6365,27 @@ export interface AssetApiDeleteAssetRequest { readonly deleteAssetDto: DeleteAssetDto } +/** + * Request parameters for downloadArchive operation in AssetApi. + * @export + * @interface AssetApiDownloadArchiveRequest + */ +export interface AssetApiDownloadArchiveRequest { + /** + * + * @type {AssetIdsDto} + * @memberof AssetApiDownloadArchive + */ + readonly assetIdsDto: AssetIdsDto + + /** + * + * @type {string} + * @memberof AssetApiDownloadArchive + */ + readonly key?: string +} + /** * Request parameters for downloadFile operation in AssetApi. * @export @@ -6475,55 +6407,6 @@ export interface AssetApiDownloadFileRequest { readonly key?: string } -/** - * Request parameters for downloadFiles operation in AssetApi. - * @export - * @interface AssetApiDownloadFilesRequest - */ -export interface AssetApiDownloadFilesRequest { - /** - * - * @type {DownloadFilesDto} - * @memberof AssetApiDownloadFiles - */ - readonly downloadFilesDto: DownloadFilesDto - - /** - * - * @type {string} - * @memberof AssetApiDownloadFiles - */ - readonly key?: string -} - -/** - * Request parameters for downloadLibrary operation in AssetApi. - * @export - * @interface AssetApiDownloadLibraryRequest - */ -export interface AssetApiDownloadLibraryRequest { - /** - * - * @type {string} - * @memberof AssetApiDownloadLibrary - */ - readonly name?: string - - /** - * - * @type {number} - * @memberof AssetApiDownloadLibrary - */ - readonly skip?: number - - /** - * - * @type {string} - * @memberof AssetApiDownloadLibrary - */ - readonly key?: string -} - /** * Request parameters for getAllAssets operation in AssetApi. * @export @@ -6650,6 +6533,48 @@ export interface AssetApiGetAssetThumbnailRequest { readonly key?: string } +/** + * Request parameters for getDownloadInfo operation in AssetApi. + * @export + * @interface AssetApiGetDownloadInfoRequest + */ +export interface AssetApiGetDownloadInfoRequest { + /** + * + * @type {Array} + * @memberof AssetApiGetDownloadInfo + */ + readonly assetIds?: Array + + /** + * + * @type {string} + * @memberof AssetApiGetDownloadInfo + */ + readonly albumId?: string + + /** + * + * @type {string} + * @memberof AssetApiGetDownloadInfo + */ + readonly userId?: string + + /** + * + * @type {number} + * @memberof AssetApiGetDownloadInfo + */ + readonly archiveSize?: number + + /** + * + * @type {string} + * @memberof AssetApiGetDownloadInfo + */ + readonly key?: string +} + /** * Request parameters for getMapMarkers operation in AssetApi. * @export @@ -6953,6 +6878,17 @@ export class AssetApi extends BaseAPI { return AssetApiFp(this.configuration).deleteAsset(requestParameters.deleteAssetDto, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {AssetApiDownloadArchiveRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AssetApi + */ + public downloadArchive(requestParameters: AssetApiDownloadArchiveRequest, options?: AxiosRequestConfig) { + return AssetApiFp(this.configuration).downloadArchive(requestParameters.assetIdsDto, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {AssetApiDownloadFileRequest} requestParameters Request parameters. @@ -6964,28 +6900,6 @@ export class AssetApi extends BaseAPI { return AssetApiFp(this.configuration).downloadFile(requestParameters.id, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); } - /** - * - * @param {AssetApiDownloadFilesRequest} requestParameters Request parameters. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - * @memberof AssetApi - */ - public downloadFiles(requestParameters: AssetApiDownloadFilesRequest, options?: AxiosRequestConfig) { - return AssetApiFp(this.configuration).downloadFiles(requestParameters.downloadFilesDto, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); - } - - /** - * Current this is not used in any UI element - * @param {AssetApiDownloadLibraryRequest} requestParameters Request parameters. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - * @memberof AssetApi - */ - public downloadLibrary(requestParameters: AssetApiDownloadLibraryRequest = {}, options?: AxiosRequestConfig) { - return AssetApiFp(this.configuration).downloadLibrary(requestParameters.name, requestParameters.skip, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); - } - /** * Get all AssetEntity belong to the user * @param {AssetApiGetAllAssetsRequest} requestParameters Request parameters. @@ -7091,6 +7005,17 @@ export class AssetApi extends BaseAPI { return AssetApiFp(this.configuration).getCuratedObjects(options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {AssetApiGetDownloadInfoRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AssetApi + */ + public getDownloadInfo(requestParameters: AssetApiGetDownloadInfoRequest = {}, options?: AxiosRequestConfig) { + return AssetApiFp(this.configuration).getDownloadInfo(requestParameters.assetIds, requestParameters.albumId, requestParameters.userId, requestParameters.archiveSize, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {AssetApiGetMapMarkersRequest} requestParameters Request parameters. diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte index 1183af14d6..9d3933be6e 100644 --- a/web/src/lib/components/album-page/album-viewer.svelte +++ b/web/src/lib/components/album-page/album-viewer.svelte @@ -3,7 +3,6 @@ import { afterNavigate, goto } from '$app/navigation'; import { albumAssetSelectionStore } from '$lib/stores/album-asset-selection.store'; import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store'; - import { downloadAssets } from '$lib/stores/download'; import { locale } from '$lib/stores/preferences.store'; import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader'; import { @@ -45,6 +44,7 @@ import ThumbnailSelection from './thumbnail-selection.svelte'; import UserSelectionModal from './user-selection-modal.svelte'; import { handleError } from '../../utils/handle-error'; + import { downloadArchive } from '../../utils/asset-utils'; export let album: AlbumResponseDto; export let sharedLink: SharedLinkResponseDto | undefined = undefined; @@ -242,78 +242,12 @@ }; const downloadAlbum = async () => { - try { - let skip = 0; - let count = 0; - let done = false; - - while (!done) { - count++; - - const fileName = album.albumName + `${count === 1 ? '' : count}.zip`; - - $downloadAssets[fileName] = 0; - - let total = 0; - - const { data, status, headers } = await api.albumApi.downloadArchive( - { id: album.id, skip: skip || undefined, key: sharedLink?.key }, - { - responseType: 'blob', - onDownloadProgress: function (progressEvent) { - const request = this as XMLHttpRequest; - if (!total) { - total = Number(request.getResponseHeader('X-Immich-Content-Length-Hint')) || 0; - } - - if (total) { - const current = progressEvent.loaded; - $downloadAssets[fileName] = Math.floor((current / total) * 100); - } - } - } - ); - - const isNotComplete = headers['x-immich-archive-complete'] === 'false'; - const fileCount = Number(headers['x-immich-archive-file-count']) || 0; - if (isNotComplete && fileCount > 0) { - skip += fileCount; - } else { - done = true; - } - - if (!(data instanceof Blob)) { - return; - } - - if (status === 200) { - const fileUrl = URL.createObjectURL(data); - const anchor = document.createElement('a'); - anchor.href = fileUrl; - anchor.download = fileName; - - document.body.appendChild(anchor); - anchor.click(); - document.body.removeChild(anchor); - - URL.revokeObjectURL(fileUrl); - - // Remove item from download list - setTimeout(() => { - const copy = $downloadAssets; - delete copy[fileName]; - $downloadAssets = copy; - }, 2000); - } - } - } catch (e) { - $downloadAssets = {}; - console.error('Error downloading file ', e); - notificationController.show({ - type: NotificationType.Error, - message: 'Error downloading file, check console for more details.' - }); - } + await downloadArchive( + `${album.albumName}.zip`, + { albumId: album.id }, + undefined, + sharedLink?.key + ); }; const showAlbumOptionsMenu = ({ x, y }: MouseEvent) => { @@ -360,7 +294,7 @@ > {#if sharedLink?.allowDownload || !isPublicShared} - + {/if} {#if isOwned} diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 6192518ba7..0a073f63f9 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -1,6 +1,5 @@ diff --git a/web/src/lib/components/share-page/individual-shared-viewer.svelte b/web/src/lib/components/share-page/individual-shared-viewer.svelte index 65d0bcea55..97e123bf62 100644 --- a/web/src/lib/components/share-page/individual-shared-viewer.svelte +++ b/web/src/lib/components/share-page/individual-shared-viewer.svelte @@ -1,7 +1,7 @@