Explorar o código

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
Jason Rasmussen %!s(int64=2) %!d(string=hai) anos
pai
achega
ad343b7b32
Modificáronse 53 ficheiros con 1348 adicións e 1296 borrados
  1. 6 3
      mobile/openapi/.openapi-generator/FILES
  2. 5 5
      mobile/openapi/README.md
  3. 0 62
      mobile/openapi/doc/AlbumApi.md
  4. 78 76
      mobile/openapi/doc/AssetApi.md
  5. 2 1
      mobile/openapi/doc/DownloadArchiveInfo.md
  6. 16 0
      mobile/openapi/doc/DownloadResponseDto.md
  7. 2 1
      mobile/openapi/lib/api.dart
  8. 0 70
      mobile/openapi/lib/api/album_api.dart
  9. 96 87
      mobile/openapi/lib/api/asset_api.dart
  10. 4 2
      mobile/openapi/lib/api_client.dart
  11. 26 18
      mobile/openapi/lib/model/download_archive_info.dart
  12. 106 0
      mobile/openapi/lib/model/download_response_dto.dart
  13. 0 5
      mobile/openapi/test/album_api_test.dart
  14. 9 11
      mobile/openapi/test/asset_api_test.dart
  15. 8 3
      mobile/openapi/test/download_archive_info_test.dart
  16. 32 0
      mobile/openapi/test/download_response_dto_test.dart
  17. 82 113
      server/immich-openapi-specs.json
  18. 11 0
      server/src/domain/access/access.core.ts
  19. 2 0
      server/src/domain/asset/asset.repository.ts
  20. 215 22
      server/src/domain/asset/asset.service.spec.ts
  21. 114 3
      server/src/domain/asset/asset.service.ts
  22. 31 0
      server/src/domain/asset/dto/download.dto.ts
  23. 1 0
      server/src/domain/asset/dto/index.ts
  24. 11 5
      server/src/domain/storage/storage.repository.ts
  25. 3 18
      server/src/immich/api-v1/album/album.controller.ts
  26. 1 2
      server/src/immich/api-v1/album/album.module.ts
  27. 1 7
      server/src/immich/api-v1/album/album.service.spec.ts
  28. 5 25
      server/src/immich/api-v1/album/album.service.ts
  29. 1 41
      server/src/immich/api-v1/asset/asset.controller.ts
  30. 1 6
      server/src/immich/api-v1/asset/asset.module.ts
  31. 1 36
      server/src/immich/api-v1/asset/asset.service.spec.ts
  32. 0 49
      server/src/immich/api-v1/asset/asset.service.ts
  33. 0 12
      server/src/immich/api-v1/asset/dto/download-files.dto.ts
  34. 0 14
      server/src/immich/api-v1/asset/dto/download-library.dto.ts
  35. 11 11
      server/src/immich/app.utils.ts
  36. 36 4
      server/src/immich/controllers/asset.controller.ts
  37. 0 8
      server/src/immich/modules/download/download.module.ts
  38. 0 63
      server/src/immich/modules/download/download.service.ts
  39. 3 1
      server/src/infra/repositories/access.repository.ts
  40. 26 0
      server/src/infra/repositories/asset.repository.ts
  41. 16 3
      server/src/infra/repositories/filesystem.provider.ts
  42. 20 4
      server/test/fixtures.ts
  43. 2 0
      server/test/repositories/asset.repository.mock.ts
  44. 1 0
      server/test/repositories/storage.repository.mock.ts
  45. 216 291
      web/src/api/open-api/api.ts
  46. 8 74
      web/src/lib/components/album-page/album-viewer.svelte
  47. 2 72
      web/src/lib/components/asset-viewer/asset-viewer.svelte
  48. 15 3
      web/src/lib/components/photos-page/actions/download-action.svelte
  49. 8 3
      web/src/lib/components/share-page/individual-shared-viewer.svelte
  50. 15 0
      web/src/lib/stores/download.ts
  51. 86 59
      web/src/lib/utils/asset-utils.ts
  52. 12 2
      web/src/lib/utils/handle-error.ts
  53. 1 1
      web/src/routes/(user)/people/[personId]/+page.svelte

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

@@ -45,7 +45,8 @@ doc/CuratedObjectsResponseDto.md
 doc/DeleteAssetDto.md
 doc/DeleteAssetDto.md
 doc/DeleteAssetResponseDto.md
 doc/DeleteAssetResponseDto.md
 doc/DeleteAssetStatus.md
 doc/DeleteAssetStatus.md
-doc/DownloadFilesDto.md
+doc/DownloadArchiveInfo.md
+doc/DownloadResponseDto.md
 doc/ExifResponseDto.md
 doc/ExifResponseDto.md
 doc/GetAssetByTimeBucketDto.md
 doc/GetAssetByTimeBucketDto.md
 doc/GetAssetCountByTimeBucketDto.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_dto.dart
 lib/model/delete_asset_response_dto.dart
 lib/model/delete_asset_response_dto.dart
 lib/model/delete_asset_status.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/exif_response_dto.dart
 lib/model/get_asset_by_time_bucket_dto.dart
 lib/model/get_asset_by_time_bucket_dto.dart
 lib/model/get_asset_count_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_dto_test.dart
 test/delete_asset_response_dto_test.dart
 test/delete_asset_response_dto_test.dart
 test/delete_asset_status_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/exif_response_dto_test.dart
 test/get_asset_by_time_bucket_dto_test.dart
 test/get_asset_by_time_bucket_dto_test.dart
 test/get_asset_count_by_time_bucket_dto_test.dart
 test/get_asset_count_by_time_bucket_dto_test.dart

+ 5 - 5
mobile/openapi/README.md

@@ -81,7 +81,6 @@ Class | Method | HTTP request | Description
 *AlbumApi* | [**addUsersToAlbum**](doc//AlbumApi.md#adduserstoalbum) | **PUT** /album/{id}/users | 
 *AlbumApi* | [**addUsersToAlbum**](doc//AlbumApi.md#adduserstoalbum) | **PUT** /album/{id}/users | 
 *AlbumApi* | [**createAlbum**](doc//AlbumApi.md#createalbum) | **POST** /album | 
 *AlbumApi* | [**createAlbum**](doc//AlbumApi.md#createalbum) | **POST** /album | 
 *AlbumApi* | [**deleteAlbum**](doc//AlbumApi.md#deletealbum) | **DELETE** /album/{id} | 
 *AlbumApi* | [**deleteAlbum**](doc//AlbumApi.md#deletealbum) | **DELETE** /album/{id} | 
-*AlbumApi* | [**downloadArchive**](doc//AlbumApi.md#downloadarchive) | **GET** /album/{id}/download | 
 *AlbumApi* | [**getAlbumCount**](doc//AlbumApi.md#getalbumcount) | **GET** /album/count | 
 *AlbumApi* | [**getAlbumCount**](doc//AlbumApi.md#getalbumcount) | **GET** /album/count | 
 *AlbumApi* | [**getAlbumInfo**](doc//AlbumApi.md#getalbuminfo) | **GET** /album/{id} | 
 *AlbumApi* | [**getAlbumInfo**](doc//AlbumApi.md#getalbuminfo) | **GET** /album/{id} | 
 *AlbumApi* | [**getAllAlbums**](doc//AlbumApi.md#getallalbums) | **GET** /album | 
 *AlbumApi* | [**getAllAlbums**](doc//AlbumApi.md#getallalbums) | **GET** /album | 
@@ -92,9 +91,8 @@ Class | Method | HTTP request | Description
 *AssetApi* | [**checkDuplicateAsset**](doc//AssetApi.md#checkduplicateasset) | **POST** /asset/check | 
 *AssetApi* | [**checkDuplicateAsset**](doc//AssetApi.md#checkduplicateasset) | **POST** /asset/check | 
 *AssetApi* | [**checkExistingAssets**](doc//AssetApi.md#checkexistingassets) | **POST** /asset/exist | 
 *AssetApi* | [**checkExistingAssets**](doc//AssetApi.md#checkexistingassets) | **POST** /asset/exist | 
 *AssetApi* | [**deleteAsset**](doc//AssetApi.md#deleteasset) | **DELETE** /asset | 
 *AssetApi* | [**deleteAsset**](doc//AssetApi.md#deleteasset) | **DELETE** /asset | 
-*AssetApi* | [**downloadFile**](doc//AssetApi.md#downloadfile) | **GET** /asset/download/{id} | 
-*AssetApi* | [**downloadFiles**](doc//AssetApi.md#downloadfiles) | **POST** /asset/download-files | 
-*AssetApi* | [**downloadLibrary**](doc//AssetApi.md#downloadlibrary) | **GET** /asset/download-library | 
+*AssetApi* | [**downloadArchive**](doc//AssetApi.md#downloadarchive) | **POST** /asset/download | 
+*AssetApi* | [**downloadFile**](doc//AssetApi.md#downloadfile) | **POST** /asset/download/{id} | 
 *AssetApi* | [**getAllAssets**](doc//AssetApi.md#getallassets) | **GET** /asset | 
 *AssetApi* | [**getAllAssets**](doc//AssetApi.md#getallassets) | **GET** /asset | 
 *AssetApi* | [**getArchivedAssetCountByUserId**](doc//AssetApi.md#getarchivedassetcountbyuserid) | **GET** /asset/stat/archive | 
 *AssetApi* | [**getArchivedAssetCountByUserId**](doc//AssetApi.md#getarchivedassetcountbyuserid) | **GET** /asset/stat/archive | 
 *AssetApi* | [**getAssetById**](doc//AssetApi.md#getassetbyid) | **GET** /asset/assetById/{id} | 
 *AssetApi* | [**getAssetById**](doc//AssetApi.md#getassetbyid) | **GET** /asset/assetById/{id} | 
@@ -105,6 +103,7 @@ Class | Method | HTTP request | Description
 *AssetApi* | [**getAssetThumbnail**](doc//AssetApi.md#getassetthumbnail) | **GET** /asset/thumbnail/{id} | 
 *AssetApi* | [**getAssetThumbnail**](doc//AssetApi.md#getassetthumbnail) | **GET** /asset/thumbnail/{id} | 
 *AssetApi* | [**getCuratedLocations**](doc//AssetApi.md#getcuratedlocations) | **GET** /asset/curated-locations | 
 *AssetApi* | [**getCuratedLocations**](doc//AssetApi.md#getcuratedlocations) | **GET** /asset/curated-locations | 
 *AssetApi* | [**getCuratedObjects**](doc//AssetApi.md#getcuratedobjects) | **GET** /asset/curated-objects | 
 *AssetApi* | [**getCuratedObjects**](doc//AssetApi.md#getcuratedobjects) | **GET** /asset/curated-objects | 
+*AssetApi* | [**getDownloadInfo**](doc//AssetApi.md#getdownloadinfo) | **GET** /asset/download | 
 *AssetApi* | [**getMapMarkers**](doc//AssetApi.md#getmapmarkers) | **GET** /asset/map-marker | 
 *AssetApi* | [**getMapMarkers**](doc//AssetApi.md#getmapmarkers) | **GET** /asset/map-marker | 
 *AssetApi* | [**getMemoryLane**](doc//AssetApi.md#getmemorylane) | **GET** /asset/memory-lane | 
 *AssetApi* | [**getMemoryLane**](doc//AssetApi.md#getmemorylane) | **GET** /asset/memory-lane | 
 *AssetApi* | [**getUserAssetsByDeviceId**](doc//AssetApi.md#getuserassetsbydeviceid) | **GET** /asset/{deviceId} | 
 *AssetApi* | [**getUserAssetsByDeviceId**](doc//AssetApi.md#getuserassetsbydeviceid) | **GET** /asset/{deviceId} | 
@@ -215,7 +214,8 @@ Class | Method | HTTP request | Description
  - [DeleteAssetDto](doc//DeleteAssetDto.md)
  - [DeleteAssetDto](doc//DeleteAssetDto.md)
  - [DeleteAssetResponseDto](doc//DeleteAssetResponseDto.md)
  - [DeleteAssetResponseDto](doc//DeleteAssetResponseDto.md)
  - [DeleteAssetStatus](doc//DeleteAssetStatus.md)
  - [DeleteAssetStatus](doc//DeleteAssetStatus.md)
- - [DownloadFilesDto](doc//DownloadFilesDto.md)
+ - [DownloadArchiveInfo](doc//DownloadArchiveInfo.md)
+ - [DownloadResponseDto](doc//DownloadResponseDto.md)
  - [ExifResponseDto](doc//ExifResponseDto.md)
  - [ExifResponseDto](doc//ExifResponseDto.md)
  - [GetAssetByTimeBucketDto](doc//GetAssetByTimeBucketDto.md)
  - [GetAssetByTimeBucketDto](doc//GetAssetByTimeBucketDto.md)
  - [GetAssetCountByTimeBucketDto](doc//GetAssetCountByTimeBucketDto.md)
  - [GetAssetCountByTimeBucketDto](doc//GetAssetCountByTimeBucketDto.md)

+ 0 - 62
mobile/openapi/doc/AlbumApi.md

@@ -13,7 +13,6 @@ Method | HTTP request | Description
 [**addUsersToAlbum**](AlbumApi.md#adduserstoalbum) | **PUT** /album/{id}/users | 
 [**addUsersToAlbum**](AlbumApi.md#adduserstoalbum) | **PUT** /album/{id}/users | 
 [**createAlbum**](AlbumApi.md#createalbum) | **POST** /album | 
 [**createAlbum**](AlbumApi.md#createalbum) | **POST** /album | 
 [**deleteAlbum**](AlbumApi.md#deletealbum) | **DELETE** /album/{id} | 
 [**deleteAlbum**](AlbumApi.md#deletealbum) | **DELETE** /album/{id} | 
-[**downloadArchive**](AlbumApi.md#downloadarchive) | **GET** /album/{id}/download | 
 [**getAlbumCount**](AlbumApi.md#getalbumcount) | **GET** /album/count | 
 [**getAlbumCount**](AlbumApi.md#getalbumcount) | **GET** /album/count | 
 [**getAlbumInfo**](AlbumApi.md#getalbuminfo) | **GET** /album/{id} | 
 [**getAlbumInfo**](AlbumApi.md#getalbuminfo) | **GET** /album/{id} | 
 [**getAllAlbums**](AlbumApi.md#getallalbums) | **GET** /album | 
 [**getAllAlbums**](AlbumApi.md#getallalbums) | **GET** /album | 
@@ -247,67 +246,6 @@ void (empty response body)
 
 
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 
 
-# **downloadArchive**
-> MultipartFile downloadArchive(id, name, skip, key)
-
-
-
-### Example
-```dart
-import 'package:openapi/api.dart';
-// TODO Configure API key authorization: cookie
-//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
-// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
-//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
-// TODO Configure API key authorization: api_key
-//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
-// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
-//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
-// TODO Configure HTTP Bearer authorization: bearer
-// Case 1. Use String Token
-//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
-// Case 2. Use Function which generate token.
-// String yourTokenGeneratorFunction() { ... }
-//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
-
-final api_instance = AlbumApi();
-final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | 
-final name = name_example; // String | 
-final skip = 8.14; // num | 
-final key = key_example; // String | 
-
-try {
-    final result = api_instance.downloadArchive(id, name, skip, key);
-    print(result);
-} catch (e) {
-    print('Exception when calling AlbumApi->downloadArchive: $e\n');
-}
-```
-
-### Parameters
-
-Name | Type | Description  | Notes
-------------- | ------------- | ------------- | -------------
- **id** | **String**|  | 
- **name** | **String**|  | [optional] 
- **skip** | **num**|  | [optional] 
- **key** | **String**|  | [optional] 
-
-### Return type
-
-[**MultipartFile**](MultipartFile.md)
-
-### Authorization
-
-[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
-
-### HTTP request headers
-
- - **Content-Type**: Not defined
- - **Accept**: application/zip
-
-[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
-
 # **getAlbumCount**
 # **getAlbumCount**
 > AlbumCountResponseDto getAlbumCount()
 > AlbumCountResponseDto getAlbumCount()
 
 

+ 78 - 76
mobile/openapi/doc/AssetApi.md

@@ -13,9 +13,8 @@ Method | HTTP request | Description
 [**checkDuplicateAsset**](AssetApi.md#checkduplicateasset) | **POST** /asset/check | 
 [**checkDuplicateAsset**](AssetApi.md#checkduplicateasset) | **POST** /asset/check | 
 [**checkExistingAssets**](AssetApi.md#checkexistingassets) | **POST** /asset/exist | 
 [**checkExistingAssets**](AssetApi.md#checkexistingassets) | **POST** /asset/exist | 
 [**deleteAsset**](AssetApi.md#deleteasset) | **DELETE** /asset | 
 [**deleteAsset**](AssetApi.md#deleteasset) | **DELETE** /asset | 
-[**downloadFile**](AssetApi.md#downloadfile) | **GET** /asset/download/{id} | 
-[**downloadFiles**](AssetApi.md#downloadfiles) | **POST** /asset/download-files | 
-[**downloadLibrary**](AssetApi.md#downloadlibrary) | **GET** /asset/download-library | 
+[**downloadArchive**](AssetApi.md#downloadarchive) | **POST** /asset/download | 
+[**downloadFile**](AssetApi.md#downloadfile) | **POST** /asset/download/{id} | 
 [**getAllAssets**](AssetApi.md#getallassets) | **GET** /asset | 
 [**getAllAssets**](AssetApi.md#getallassets) | **GET** /asset | 
 [**getArchivedAssetCountByUserId**](AssetApi.md#getarchivedassetcountbyuserid) | **GET** /asset/stat/archive | 
 [**getArchivedAssetCountByUserId**](AssetApi.md#getarchivedassetcountbyuserid) | **GET** /asset/stat/archive | 
 [**getAssetById**](AssetApi.md#getassetbyid) | **GET** /asset/assetById/{id} | 
 [**getAssetById**](AssetApi.md#getassetbyid) | **GET** /asset/assetById/{id} | 
@@ -26,6 +25,7 @@ Method | HTTP request | Description
 [**getAssetThumbnail**](AssetApi.md#getassetthumbnail) | **GET** /asset/thumbnail/{id} | 
 [**getAssetThumbnail**](AssetApi.md#getassetthumbnail) | **GET** /asset/thumbnail/{id} | 
 [**getCuratedLocations**](AssetApi.md#getcuratedlocations) | **GET** /asset/curated-locations | 
 [**getCuratedLocations**](AssetApi.md#getcuratedlocations) | **GET** /asset/curated-locations | 
 [**getCuratedObjects**](AssetApi.md#getcuratedobjects) | **GET** /asset/curated-objects | 
 [**getCuratedObjects**](AssetApi.md#getcuratedobjects) | **GET** /asset/curated-objects | 
+[**getDownloadInfo**](AssetApi.md#getdownloadinfo) | **GET** /asset/download | 
 [**getMapMarkers**](AssetApi.md#getmapmarkers) | **GET** /asset/map-marker | 
 [**getMapMarkers**](AssetApi.md#getmapmarkers) | **GET** /asset/map-marker | 
 [**getMemoryLane**](AssetApi.md#getmemorylane) | **GET** /asset/memory-lane | 
 [**getMemoryLane**](AssetApi.md#getmemorylane) | **GET** /asset/memory-lane | 
 [**getUserAssetsByDeviceId**](AssetApi.md#getuserassetsbydeviceid) | **GET** /asset/{deviceId} | 
 [**getUserAssetsByDeviceId**](AssetApi.md#getuserassetsbydeviceid) | **GET** /asset/{deviceId} | 
@@ -264,8 +264,8 @@ Name | Type | Description  | Notes
 
 
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 
 
-# **downloadFile**
-> MultipartFile downloadFile(id, key)
+# **downloadArchive**
+> MultipartFile downloadArchive(assetIdsDto, key)
 
 
 
 
 
 
@@ -288,14 +288,14 @@ import 'package:openapi/api.dart';
 //defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
 //defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
 
 
 final api_instance = AssetApi();
 final api_instance = AssetApi();
-final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | 
+final assetIdsDto = AssetIdsDto(); // AssetIdsDto | 
 final key = key_example; // String | 
 final key = key_example; // String | 
 
 
 try {
 try {
-    final result = api_instance.downloadFile(id, key);
+    final result = api_instance.downloadArchive(assetIdsDto, key);
     print(result);
     print(result);
 } catch (e) {
 } catch (e) {
-    print('Exception when calling AssetApi->downloadFile: $e\n');
+    print('Exception when calling AssetApi->downloadArchive: $e\n');
 }
 }
 ```
 ```
 
 
@@ -303,64 +303,7 @@ try {
 
 
 Name | Type | Description  | Notes
 Name | Type | Description  | Notes
 ------------- | ------------- | ------------- | -------------
 ------------- | ------------- | ------------- | -------------
- **id** | **String**|  | 
- **key** | **String**|  | [optional] 
-
-### Return type
-
-[**MultipartFile**](MultipartFile.md)
-
-### Authorization
-
-[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
-
-### HTTP request headers
-
- - **Content-Type**: Not defined
- - **Accept**: application/octet-stream
-
-[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
-
-# **downloadFiles**
-> MultipartFile downloadFiles(downloadFilesDto, key)
-
-
-
-### Example
-```dart
-import 'package:openapi/api.dart';
-// TODO Configure API key authorization: cookie
-//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
-// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
-//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
-// TODO Configure API key authorization: api_key
-//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
-// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
-//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
-// TODO Configure HTTP Bearer authorization: bearer
-// Case 1. Use String Token
-//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
-// Case 2. Use Function which generate token.
-// String yourTokenGeneratorFunction() { ... }
-//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
-
-final api_instance = AssetApi();
-final downloadFilesDto = DownloadFilesDto(); // DownloadFilesDto | 
-final key = key_example; // String | 
-
-try {
-    final result = api_instance.downloadFiles(downloadFilesDto, key);
-    print(result);
-} catch (e) {
-    print('Exception when calling AssetApi->downloadFiles: $e\n');
-}
-```
-
-### Parameters
-
-Name | Type | Description  | Notes
-------------- | ------------- | ------------- | -------------
- **downloadFilesDto** | [**DownloadFilesDto**](DownloadFilesDto.md)|  | 
+ **assetIdsDto** | [**AssetIdsDto**](AssetIdsDto.md)|  | 
  **key** | **String**|  | [optional] 
  **key** | **String**|  | [optional] 
 
 
 ### Return type
 ### Return type
@@ -378,12 +321,10 @@ Name | Type | Description  | Notes
 
 
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 
 
-# **downloadLibrary**
-> MultipartFile downloadLibrary(name, skip, key)
-
+# **downloadFile**
+> MultipartFile downloadFile(id, key)
 
 
 
 
-Current this is not used in any UI element
 
 
 ### Example
 ### Example
 ```dart
 ```dart
@@ -404,15 +345,14 @@ import 'package:openapi/api.dart';
 //defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
 //defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
 
 
 final api_instance = AssetApi();
 final api_instance = AssetApi();
-final name = name_example; // String | 
-final skip = 8.14; // num | 
+final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | 
 final key = key_example; // String | 
 final key = key_example; // String | 
 
 
 try {
 try {
-    final result = api_instance.downloadLibrary(name, skip, key);
+    final result = api_instance.downloadFile(id, key);
     print(result);
     print(result);
 } catch (e) {
 } catch (e) {
-    print('Exception when calling AssetApi->downloadLibrary: $e\n');
+    print('Exception when calling AssetApi->downloadFile: $e\n');
 }
 }
 ```
 ```
 
 
@@ -420,8 +360,7 @@ try {
 
 
 Name | Type | Description  | Notes
 Name | Type | Description  | Notes
 ------------- | ------------- | ------------- | -------------
 ------------- | ------------- | ------------- | -------------
- **name** | **String**|  | [optional] 
- **skip** | **num**|  | [optional] 
+ **id** | **String**|  | 
  **key** | **String**|  | [optional] 
  **key** | **String**|  | [optional] 
 
 
 ### Return type
 ### Return type
@@ -989,6 +928,69 @@ This endpoint does not need any parameter.
 
 
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 
 
+# **getDownloadInfo**
+> DownloadResponseDto getDownloadInfo(assetIds, albumId, userId, archiveSize, key)
+
+
+
+### Example
+```dart
+import 'package:openapi/api.dart';
+// TODO Configure API key authorization: cookie
+//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
+// TODO Configure API key authorization: api_key
+//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
+// TODO Configure HTTP Bearer authorization: bearer
+// Case 1. Use String Token
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
+// Case 2. Use Function which generate token.
+// String yourTokenGeneratorFunction() { ... }
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
+
+final api_instance = AssetApi();
+final assetIds = []; // List<String> | 
+final albumId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | 
+final userId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | 
+final archiveSize = 8.14; // num | 
+final key = key_example; // String | 
+
+try {
+    final result = api_instance.getDownloadInfo(assetIds, albumId, userId, archiveSize, key);
+    print(result);
+} catch (e) {
+    print('Exception when calling AssetApi->getDownloadInfo: $e\n');
+}
+```
+
+### Parameters
+
+Name | Type | Description  | Notes
+------------- | ------------- | ------------- | -------------
+ **assetIds** | [**List<String>**](String.md)|  | [optional] [default to const []]
+ **albumId** | **String**|  | [optional] 
+ **userId** | **String**|  | [optional] 
+ **archiveSize** | **num**|  | [optional] 
+ **key** | **String**|  | [optional] 
+
+### Return type
+
+[**DownloadResponseDto**](DownloadResponseDto.md)
+
+### Authorization
+
+[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
+
+### HTTP request headers
+
+ - **Content-Type**: Not defined
+ - **Accept**: application/json
+
+[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
+
 # **getMapMarkers**
 # **getMapMarkers**
 > List<MapMarkerResponseDto> getMapMarkers(isFavorite, fileCreatedAfter, fileCreatedBefore)
 > List<MapMarkerResponseDto> getMapMarkers(isFavorite, fileCreatedAfter, fileCreatedBefore)
 
 

+ 2 - 1
mobile/openapi/doc/DownloadFilesDto.md → mobile/openapi/doc/DownloadArchiveInfo.md

@@ -1,4 +1,4 @@
-# openapi.model.DownloadFilesDto
+# openapi.model.DownloadArchiveInfo
 
 
 ## Load the model package
 ## Load the model package
 ```dart
 ```dart
@@ -8,6 +8,7 @@ import 'package:openapi/api.dart';
 ## Properties
 ## Properties
 Name | Type | Description | Notes
 Name | Type | Description | Notes
 ------------ | ------------- | ------------- | -------------
 ------------ | ------------- | ------------- | -------------
+**size** | **int** |  | 
 **assetIds** | **List<String>** |  | [default to const []]
 **assetIds** | **List<String>** |  | [default to const []]
 
 
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

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

@@ -0,0 +1,16 @@
+# openapi.model.DownloadResponseDto
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**totalSize** | **int** |  | 
+**archives** | [**List<DownloadArchiveInfo>**](DownloadArchiveInfo.md) |  | [default to const []]
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
+

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

@@ -81,7 +81,8 @@ part 'model/curated_objects_response_dto.dart';
 part 'model/delete_asset_dto.dart';
 part 'model/delete_asset_dto.dart';
 part 'model/delete_asset_response_dto.dart';
 part 'model/delete_asset_response_dto.dart';
 part 'model/delete_asset_status.dart';
 part 'model/delete_asset_status.dart';
-part 'model/download_files_dto.dart';
+part 'model/download_archive_info.dart';
+part 'model/download_response_dto.dart';
 part 'model/exif_response_dto.dart';
 part 'model/exif_response_dto.dart';
 part 'model/get_asset_by_time_bucket_dto.dart';
 part 'model/get_asset_by_time_bucket_dto.dart';
 part 'model/get_asset_count_by_time_bucket_dto.dart';
 part 'model/get_asset_count_by_time_bucket_dto.dart';

+ 0 - 70
mobile/openapi/lib/api/album_api.dart

@@ -215,76 +215,6 @@ class AlbumApi {
     }
     }
   }
   }
 
 
-  /// Performs an HTTP 'GET /album/{id}/download' operation and returns the [Response].
-  /// Parameters:
-  ///
-  /// * [String] id (required):
-  ///
-  /// * [String] name:
-  ///
-  /// * [num] skip:
-  ///
-  /// * [String] key:
-  Future<Response> downloadArchiveWithHttpInfo(String id, { String? name, num? skip, String? key, }) async {
-    // ignore: prefer_const_declarations
-    final path = r'/album/{id}/download'
-      .replaceAll('{id}', id);
-
-    // ignore: prefer_final_locals
-    Object? postBody;
-
-    final queryParams = <QueryParam>[];
-    final headerParams = <String, String>{};
-    final formParams = <String, String>{};
-
-    if (name != null) {
-      queryParams.addAll(_queryParams('', 'name', name));
-    }
-    if (skip != null) {
-      queryParams.addAll(_queryParams('', 'skip', skip));
-    }
-    if (key != null) {
-      queryParams.addAll(_queryParams('', 'key', key));
-    }
-
-    const contentTypes = <String>[];
-
-
-    return apiClient.invokeAPI(
-      path,
-      'GET',
-      queryParams,
-      postBody,
-      headerParams,
-      formParams,
-      contentTypes.isEmpty ? null : contentTypes.first,
-    );
-  }
-
-  /// Parameters:
-  ///
-  /// * [String] id (required):
-  ///
-  /// * [String] name:
-  ///
-  /// * [num] skip:
-  ///
-  /// * [String] key:
-  Future<MultipartFile?> downloadArchive(String id, { String? name, num? skip, String? key, }) async {
-    final response = await downloadArchiveWithHttpInfo(id,  name: name, skip: skip, key: key, );
-    if (response.statusCode >= HttpStatus.badRequest) {
-      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
-    }
-    // When a remote server returns no body with a status of 204, we shall not decode it.
-    // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
-    // FormatException when trying to decode an empty string.
-    if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
-      return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MultipartFile',) as MultipartFile;
-    
-    }
-    return null;
-  }
-
   /// Performs an HTTP 'GET /album/count' operation and returns the [Response].
   /// Performs an HTTP 'GET /album/count' operation and returns the [Response].
   Future<Response> getAlbumCountWithHttpInfo() async {
   Future<Response> getAlbumCountWithHttpInfo() async {
     // ignore: prefer_const_declarations
     // ignore: prefer_const_declarations

+ 96 - 87
mobile/openapi/lib/api/asset_api.dart

@@ -230,74 +230,18 @@ class AssetApi {
     return null;
     return null;
   }
   }
 
 
-  /// Performs an HTTP 'GET /asset/download/{id}' operation and returns the [Response].
+  /// Performs an HTTP 'POST /asset/download' operation and returns the [Response].
   /// Parameters:
   /// Parameters:
   ///
   ///
-  /// * [String] id (required):
+  /// * [AssetIdsDto] assetIdsDto (required):
   ///
   ///
   /// * [String] key:
   /// * [String] key:
-  Future<Response> downloadFileWithHttpInfo(String id, { String? key, }) async {
+  Future<Response> downloadArchiveWithHttpInfo(AssetIdsDto assetIdsDto, { String? key, }) async {
     // ignore: prefer_const_declarations
     // ignore: prefer_const_declarations
-    final path = r'/asset/download/{id}'
-      .replaceAll('{id}', id);
+    final path = r'/asset/download';
 
 
     // ignore: prefer_final_locals
     // ignore: prefer_final_locals
-    Object? postBody;
-
-    final queryParams = <QueryParam>[];
-    final headerParams = <String, String>{};
-    final formParams = <String, String>{};
-
-    if (key != null) {
-      queryParams.addAll(_queryParams('', 'key', key));
-    }
-
-    const contentTypes = <String>[];
-
-
-    return apiClient.invokeAPI(
-      path,
-      'GET',
-      queryParams,
-      postBody,
-      headerParams,
-      formParams,
-      contentTypes.isEmpty ? null : contentTypes.first,
-    );
-  }
-
-  /// Parameters:
-  ///
-  /// * [String] id (required):
-  ///
-  /// * [String] key:
-  Future<MultipartFile?> downloadFile(String id, { String? key, }) async {
-    final response = await downloadFileWithHttpInfo(id,  key: key, );
-    if (response.statusCode >= HttpStatus.badRequest) {
-      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
-    }
-    // When a remote server returns no body with a status of 204, we shall not decode it.
-    // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
-    // FormatException when trying to decode an empty string.
-    if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
-      return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MultipartFile',) as MultipartFile;
-    
-    }
-    return null;
-  }
-
-  /// Performs an HTTP 'POST /asset/download-files' operation and returns the [Response].
-  /// Parameters:
-  ///
-  /// * [DownloadFilesDto] downloadFilesDto (required):
-  ///
-  /// * [String] key:
-  Future<Response> downloadFilesWithHttpInfo(DownloadFilesDto downloadFilesDto, { String? key, }) async {
-    // ignore: prefer_const_declarations
-    final path = r'/asset/download-files';
-
-    // ignore: prefer_final_locals
-    Object? postBody = downloadFilesDto;
+    Object? postBody = assetIdsDto;
 
 
     final queryParams = <QueryParam>[];
     final queryParams = <QueryParam>[];
     final headerParams = <String, String>{};
     final headerParams = <String, String>{};
@@ -323,11 +267,11 @@ class AssetApi {
 
 
   /// Parameters:
   /// Parameters:
   ///
   ///
-  /// * [DownloadFilesDto] downloadFilesDto (required):
+  /// * [AssetIdsDto] assetIdsDto (required):
   ///
   ///
   /// * [String] key:
   /// * [String] key:
-  Future<MultipartFile?> downloadFiles(DownloadFilesDto downloadFilesDto, { String? key, }) async {
-    final response = await downloadFilesWithHttpInfo(downloadFilesDto,  key: key, );
+  Future<MultipartFile?> downloadArchive(AssetIdsDto assetIdsDto, { String? key, }) async {
+    final response = await downloadArchiveWithHttpInfo(assetIdsDto,  key: key, );
     if (response.statusCode >= HttpStatus.badRequest) {
     if (response.statusCode >= HttpStatus.badRequest) {
       throw ApiException(response.statusCode, await _decodeBodyBytes(response));
       throw ApiException(response.statusCode, await _decodeBodyBytes(response));
     }
     }
@@ -341,20 +285,16 @@ class AssetApi {
     return null;
     return null;
   }
   }
 
 
-  /// Current this is not used in any UI element
-  ///
-  /// Note: This method returns the HTTP [Response].
-  ///
+  /// Performs an HTTP 'POST /asset/download/{id}' operation and returns the [Response].
   /// Parameters:
   /// Parameters:
   ///
   ///
-  /// * [String] name:
-  ///
-  /// * [num] skip:
+  /// * [String] id (required):
   ///
   ///
   /// * [String] key:
   /// * [String] key:
-  Future<Response> downloadLibraryWithHttpInfo({ String? name, num? skip, String? key, }) async {
+  Future<Response> downloadFileWithHttpInfo(String id, { String? key, }) async {
     // ignore: prefer_const_declarations
     // ignore: prefer_const_declarations
-    final path = r'/asset/download-library';
+    final path = r'/asset/download/{id}'
+      .replaceAll('{id}', id);
 
 
     // ignore: prefer_final_locals
     // ignore: prefer_final_locals
     Object? postBody;
     Object? postBody;
@@ -363,12 +303,6 @@ class AssetApi {
     final headerParams = <String, String>{};
     final headerParams = <String, String>{};
     final formParams = <String, String>{};
     final formParams = <String, String>{};
 
 
-    if (name != null) {
-      queryParams.addAll(_queryParams('', 'name', name));
-    }
-    if (skip != null) {
-      queryParams.addAll(_queryParams('', 'skip', skip));
-    }
     if (key != null) {
     if (key != null) {
       queryParams.addAll(_queryParams('', 'key', key));
       queryParams.addAll(_queryParams('', 'key', key));
     }
     }
@@ -378,7 +312,7 @@ class AssetApi {
 
 
     return apiClient.invokeAPI(
     return apiClient.invokeAPI(
       path,
       path,
-      'GET',
+      'POST',
       queryParams,
       queryParams,
       postBody,
       postBody,
       headerParams,
       headerParams,
@@ -387,17 +321,13 @@ class AssetApi {
     );
     );
   }
   }
 
 
-  /// Current this is not used in any UI element
-  ///
   /// Parameters:
   /// Parameters:
   ///
   ///
-  /// * [String] name:
-  ///
-  /// * [num] skip:
+  /// * [String] id (required):
   ///
   ///
   /// * [String] key:
   /// * [String] key:
-  Future<MultipartFile?> downloadLibrary({ String? name, num? skip, String? key, }) async {
-    final response = await downloadLibraryWithHttpInfo( name: name, skip: skip, key: key, );
+  Future<MultipartFile?> downloadFile(String id, { String? key, }) async {
+    final response = await downloadFileWithHttpInfo(id,  key: key, );
     if (response.statusCode >= HttpStatus.badRequest) {
     if (response.statusCode >= HttpStatus.badRequest) {
       throw ApiException(response.statusCode, await _decodeBodyBytes(response));
       throw ApiException(response.statusCode, await _decodeBodyBytes(response));
     }
     }
@@ -945,6 +875,85 @@ class AssetApi {
     return null;
     return null;
   }
   }
 
 
+  /// Performs an HTTP 'GET /asset/download' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [List<String>] assetIds:
+  ///
+  /// * [String] albumId:
+  ///
+  /// * [String] userId:
+  ///
+  /// * [num] archiveSize:
+  ///
+  /// * [String] key:
+  Future<Response> getDownloadInfoWithHttpInfo({ List<String>? assetIds, String? albumId, String? userId, num? archiveSize, String? key, }) async {
+    // ignore: prefer_const_declarations
+    final path = r'/asset/download';
+
+    // ignore: prefer_final_locals
+    Object? postBody;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    if (assetIds != null) {
+      queryParams.addAll(_queryParams('multi', 'assetIds', assetIds));
+    }
+    if (albumId != null) {
+      queryParams.addAll(_queryParams('', 'albumId', albumId));
+    }
+    if (userId != null) {
+      queryParams.addAll(_queryParams('', 'userId', userId));
+    }
+    if (archiveSize != null) {
+      queryParams.addAll(_queryParams('', 'archiveSize', archiveSize));
+    }
+    if (key != null) {
+      queryParams.addAll(_queryParams('', 'key', key));
+    }
+
+    const contentTypes = <String>[];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'GET',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [List<String>] assetIds:
+  ///
+  /// * [String] albumId:
+  ///
+  /// * [String] userId:
+  ///
+  /// * [num] archiveSize:
+  ///
+  /// * [String] key:
+  Future<DownloadResponseDto?> getDownloadInfo({ List<String>? assetIds, String? albumId, String? userId, num? archiveSize, String? key, }) async {
+    final response = await getDownloadInfoWithHttpInfo( assetIds: assetIds, albumId: albumId, userId: userId, archiveSize: archiveSize, key: key, );
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+    // When a remote server returns no body with a status of 204, we shall not decode it.
+    // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
+    // FormatException when trying to decode an empty string.
+    if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
+      return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'DownloadResponseDto',) as DownloadResponseDto;
+    
+    }
+    return null;
+  }
+
   /// Performs an HTTP 'GET /asset/map-marker' operation and returns the [Response].
   /// Performs an HTTP 'GET /asset/map-marker' operation and returns the [Response].
   /// Parameters:
   /// Parameters:
   ///
   ///

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

@@ -257,8 +257,10 @@ class ApiClient {
           return DeleteAssetResponseDto.fromJson(value);
           return DeleteAssetResponseDto.fromJson(value);
         case 'DeleteAssetStatus':
         case 'DeleteAssetStatus':
           return DeleteAssetStatusTypeTransformer().decode(value);
           return DeleteAssetStatusTypeTransformer().decode(value);
-        case 'DownloadFilesDto':
-          return DownloadFilesDto.fromJson(value);
+        case 'DownloadArchiveInfo':
+          return DownloadArchiveInfo.fromJson(value);
+        case 'DownloadResponseDto':
+          return DownloadResponseDto.fromJson(value);
         case 'ExifResponseDto':
         case 'ExifResponseDto':
           return ExifResponseDto.fromJson(value);
           return ExifResponseDto.fromJson(value);
         case 'GetAssetByTimeBucketDto':
         case 'GetAssetByTimeBucketDto':

+ 26 - 18
mobile/openapi/lib/model/download_files_dto.dart → mobile/openapi/lib/model/download_archive_info.dart

@@ -10,40 +10,47 @@
 
 
 part of openapi.api;
 part of openapi.api;
 
 
-class DownloadFilesDto {
-  /// Returns a new [DownloadFilesDto] instance.
-  DownloadFilesDto({
+class DownloadArchiveInfo {
+  /// Returns a new [DownloadArchiveInfo] instance.
+  DownloadArchiveInfo({
+    required this.size,
     this.assetIds = const [],
     this.assetIds = const [],
   });
   });
 
 
+  int size;
+
   List<String> assetIds;
   List<String> assetIds;
 
 
   @override
   @override
-  bool operator ==(Object other) => identical(this, other) || other is DownloadFilesDto &&
+  bool operator ==(Object other) => identical(this, other) || other is DownloadArchiveInfo &&
+     other.size == size &&
      other.assetIds == assetIds;
      other.assetIds == assetIds;
 
 
   @override
   @override
   int get hashCode =>
   int get hashCode =>
     // ignore: unnecessary_parenthesis
     // ignore: unnecessary_parenthesis
+    (size.hashCode) +
     (assetIds.hashCode);
     (assetIds.hashCode);
 
 
   @override
   @override
-  String toString() => 'DownloadFilesDto[assetIds=$assetIds]';
+  String toString() => 'DownloadArchiveInfo[size=$size, assetIds=$assetIds]';
 
 
   Map<String, dynamic> toJson() {
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
     final json = <String, dynamic>{};
+      json[r'size'] = this.size;
       json[r'assetIds'] = this.assetIds;
       json[r'assetIds'] = this.assetIds;
     return json;
     return json;
   }
   }
 
 
-  /// Returns a new [DownloadFilesDto] instance and imports its values from
+  /// Returns a new [DownloadArchiveInfo] instance and imports its values from
   /// [value] if it's a [Map], null otherwise.
   /// [value] if it's a [Map], null otherwise.
   // ignore: prefer_constructors_over_static_methods
   // ignore: prefer_constructors_over_static_methods
-  static DownloadFilesDto? fromJson(dynamic value) {
+  static DownloadArchiveInfo? fromJson(dynamic value) {
     if (value is Map) {
     if (value is Map) {
       final json = value.cast<String, dynamic>();
       final json = value.cast<String, dynamic>();
 
 
-      return DownloadFilesDto(
+      return DownloadArchiveInfo(
+        size: mapValueOfType<int>(json, r'size')!,
         assetIds: json[r'assetIds'] is Iterable
         assetIds: json[r'assetIds'] is Iterable
             ? (json[r'assetIds'] as Iterable).cast<String>().toList(growable: false)
             ? (json[r'assetIds'] as Iterable).cast<String>().toList(growable: false)
             : const [],
             : const [],
@@ -52,11 +59,11 @@ class DownloadFilesDto {
     return null;
     return null;
   }
   }
 
 
-  static List<DownloadFilesDto> listFromJson(dynamic json, {bool growable = false,}) {
-    final result = <DownloadFilesDto>[];
+  static List<DownloadArchiveInfo> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <DownloadArchiveInfo>[];
     if (json is List && json.isNotEmpty) {
     if (json is List && json.isNotEmpty) {
       for (final row in json) {
       for (final row in json) {
-        final value = DownloadFilesDto.fromJson(row);
+        final value = DownloadArchiveInfo.fromJson(row);
         if (value != null) {
         if (value != null) {
           result.add(value);
           result.add(value);
         }
         }
@@ -65,12 +72,12 @@ class DownloadFilesDto {
     return result.toList(growable: growable);
     return result.toList(growable: growable);
   }
   }
 
 
-  static Map<String, DownloadFilesDto> mapFromJson(dynamic json) {
-    final map = <String, DownloadFilesDto>{};
+  static Map<String, DownloadArchiveInfo> mapFromJson(dynamic json) {
+    final map = <String, DownloadArchiveInfo>{};
     if (json is Map && json.isNotEmpty) {
     if (json is Map && json.isNotEmpty) {
       json = json.cast<String, dynamic>(); // ignore: parameter_assignments
       json = json.cast<String, dynamic>(); // ignore: parameter_assignments
       for (final entry in json.entries) {
       for (final entry in json.entries) {
-        final value = DownloadFilesDto.fromJson(entry.value);
+        final value = DownloadArchiveInfo.fromJson(entry.value);
         if (value != null) {
         if (value != null) {
           map[entry.key] = value;
           map[entry.key] = value;
         }
         }
@@ -79,14 +86,14 @@ class DownloadFilesDto {
     return map;
     return map;
   }
   }
 
 
-  // maps a json object with a list of DownloadFilesDto-objects as value to a dart map
-  static Map<String, List<DownloadFilesDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
-    final map = <String, List<DownloadFilesDto>>{};
+  // maps a json object with a list of DownloadArchiveInfo-objects as value to a dart map
+  static Map<String, List<DownloadArchiveInfo>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<DownloadArchiveInfo>>{};
     if (json is Map && json.isNotEmpty) {
     if (json is Map && json.isNotEmpty) {
       // ignore: parameter_assignments
       // ignore: parameter_assignments
       json = json.cast<String, dynamic>();
       json = json.cast<String, dynamic>();
       for (final entry in json.entries) {
       for (final entry in json.entries) {
-        map[entry.key] = DownloadFilesDto.listFromJson(entry.value, growable: growable,);
+        map[entry.key] = DownloadArchiveInfo.listFromJson(entry.value, growable: growable,);
       }
       }
     }
     }
     return map;
     return map;
@@ -94,6 +101,7 @@ class DownloadFilesDto {
 
 
   /// The list of required keys that must be present in a JSON.
   /// The list of required keys that must be present in a JSON.
   static const requiredKeys = <String>{
   static const requiredKeys = <String>{
+    'size',
     'assetIds',
     'assetIds',
   };
   };
 }
 }

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

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

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

@@ -37,11 +37,6 @@ void main() {
       // TODO
       // TODO
     });
     });
 
 
-    //Future<MultipartFile> downloadArchive(String id, { String name, num skip, String key }) async
-    test('test downloadArchive', () async {
-      // TODO
-    });
-
     //Future<AlbumCountResponseDto> getAlbumCount() async
     //Future<AlbumCountResponseDto> getAlbumCount() async
     test('test getAlbumCount', () async {
     test('test getAlbumCount', () async {
       // TODO
       // TODO

+ 9 - 11
mobile/openapi/test/asset_api_test.dart

@@ -43,20 +43,13 @@ void main() {
       // TODO
       // TODO
     });
     });
 
 
-    //Future<MultipartFile> downloadFile(String id, { String key }) async
-    test('test downloadFile', () async {
-      // TODO
-    });
-
-    //Future<MultipartFile> downloadFiles(DownloadFilesDto downloadFilesDto, { String key }) async
-    test('test downloadFiles', () async {
+    //Future<MultipartFile> downloadArchive(AssetIdsDto assetIdsDto, { String key }) async
+    test('test downloadArchive', () async {
       // TODO
       // TODO
     });
     });
 
 
-    // Current this is not used in any UI element
-    //
-    //Future<MultipartFile> downloadLibrary({ String name, num skip, String key }) async
-    test('test downloadLibrary', () async {
+    //Future<MultipartFile> downloadFile(String id, { String key }) async
+    test('test downloadFile', () async {
       // TODO
       // TODO
     });
     });
 
 
@@ -114,6 +107,11 @@ void main() {
       // TODO
       // TODO
     });
     });
 
 
+    //Future<DownloadResponseDto> getDownloadInfo({ List<String> assetIds, String albumId, String userId, num archiveSize, String key }) async
+    test('test getDownloadInfo', () async {
+      // TODO
+    });
+
     //Future<List<MapMarkerResponseDto>> getMapMarkers({ bool isFavorite, DateTime fileCreatedAfter, DateTime fileCreatedBefore }) async
     //Future<List<MapMarkerResponseDto>> getMapMarkers({ bool isFavorite, DateTime fileCreatedAfter, DateTime fileCreatedBefore }) async
     test('test getMapMarkers', () async {
     test('test getMapMarkers', () async {
       // TODO
       // TODO

+ 8 - 3
mobile/openapi/test/download_files_dto_test.dart → mobile/openapi/test/download_archive_info_test.dart

@@ -11,11 +11,16 @@
 import 'package:openapi/api.dart';
 import 'package:openapi/api.dart';
 import 'package:test/test.dart';
 import 'package:test/test.dart';
 
 
-// tests for DownloadFilesDto
+// tests for DownloadArchiveInfo
 void main() {
 void main() {
-  // final instance = DownloadFilesDto();
+  // final instance = DownloadArchiveInfo();
+
+  group('test DownloadArchiveInfo', () {
+    // int size
+    test('to test the property `size`', () async {
+      // TODO
+    });
 
 
-  group('test DownloadFilesDto', () {
     // List<String> assetIds (default value: const [])
     // List<String> assetIds (default value: const [])
     test('to test the property `assetIds`', () async {
     test('to test the property `assetIds`', () async {
       // TODO
       // TODO

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

@@ -0,0 +1,32 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+import 'package:openapi/api.dart';
+import 'package:test/test.dart';
+
+// tests for DownloadResponseDto
+void main() {
+  // final instance = DownloadResponseDto();
+
+  group('test DownloadResponseDto', () {
+    // int totalSize
+    test('to test the property `totalSize`', () async {
+      // TODO
+    });
+
+    // List<DownloadArchiveInfo> archives (default value: const [])
+    test('to test the property `archives`', () async {
+      // TODO
+    });
+
+
+  });
+
+}

+ 82 - 113
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}": {
     "/album/{id}/user/{userId}": {
       "delete": {
       "delete": {
         "operationId": "removeUserFromAlbum",
         "operationId": "removeUserFromAlbum",
@@ -1153,10 +1086,48 @@
         ]
         ]
       }
       }
     },
     },
-    "/asset/download-files": {
-      "post": {
-        "operationId": "downloadFiles",
+    "/asset/download": {
+      "get": {
+        "operationId": "getDownloadInfo",
         "parameters": [
         "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",
             "name": "key",
             "required": false,
             "required": false,
@@ -1166,30 +1137,16 @@
             }
             }
           }
           }
         ],
         ],
-        "requestBody": {
-          "required": true,
-          "content": {
-            "application/json": {
-              "schema": {
-                "$ref": "#/components/schemas/DownloadFilesDto"
-              }
-            }
-          }
-        },
         "responses": {
         "responses": {
           "200": {
           "200": {
+            "description": "",
             "content": {
             "content": {
-              "application/octet-stream": {
+              "application/json": {
                 "schema": {
                 "schema": {
-                  "type": "string",
-                  "format": "binary"
+                  "$ref": "#/components/schemas/DownloadResponseDto"
                 }
                 }
               }
               }
-            },
-            "description": ""
-          },
-          "201": {
-            "description": ""
+            }
           }
           }
         },
         },
         "tags": [
         "tags": [
@@ -1206,29 +1163,10 @@
             "api_key": []
             "api_key": []
           }
           }
         ]
         ]
-      }
-    },
-    "/asset/download-library": {
-      "get": {
-        "operationId": "downloadLibrary",
-        "description": "Current this is not used in any UI element",
+      },
+      "post": {
+        "operationId": "downloadArchive",
         "parameters": [
         "parameters": [
-          {
-            "name": "name",
-            "required": false,
-            "in": "query",
-            "schema": {
-              "type": "string"
-            }
-          },
-          {
-            "name": "skip",
-            "required": false,
-            "in": "query",
-            "schema": {
-              "type": "number"
-            }
-          },
           {
           {
             "name": "key",
             "name": "key",
             "required": false,
             "required": false,
@@ -1238,6 +1176,16 @@
             }
             }
           }
           }
         ],
         ],
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/AssetIdsDto"
+              }
+            }
+          }
+        },
         "responses": {
         "responses": {
           "200": {
           "200": {
             "content": {
             "content": {
@@ -1268,7 +1216,7 @@
       }
       }
     },
     },
     "/asset/download/{id}": {
     "/asset/download/{id}": {
-      "get": {
+      "post": {
         "operationId": "downloadFile",
         "operationId": "downloadFile",
         "parameters": [
         "parameters": [
           {
           {
@@ -5341,11 +5289,13 @@
           "FAILED"
           "FAILED"
         ]
         ]
       },
       },
-      "DownloadFilesDto": {
+      "DownloadArchiveInfo": {
         "type": "object",
         "type": "object",
         "properties": {
         "properties": {
+          "size": {
+            "type": "integer"
+          },
           "assetIds": {
           "assetIds": {
-            "title": "Array of asset ids to be downloaded",
             "type": "array",
             "type": "array",
             "items": {
             "items": {
               "type": "string"
               "type": "string"
@@ -5353,9 +5303,28 @@
           }
           }
         },
         },
         "required": [
         "required": [
+          "size",
           "assetIds"
           "assetIds"
         ]
         ]
       },
       },
+      "DownloadResponseDto": {
+        "type": "object",
+        "properties": {
+          "totalSize": {
+            "type": "integer"
+          },
+          "archives": {
+            "type": "array",
+            "items": {
+              "$ref": "#/components/schemas/DownloadArchiveInfo"
+            }
+          }
+        },
+        "required": [
+          "totalSize",
+          "archives"
+        ]
+      },
       "ExifResponseDto": {
       "ExifResponseDto": {
         "type": "object",
         "type": "object",
         "properties": {
         "properties": {

+ 11 - 0
server/src/domain/access/access.core.ts

@@ -16,6 +16,7 @@ export enum Permission {
   ALBUM_UPDATE = 'album.update',
   ALBUM_UPDATE = 'album.update',
   ALBUM_DELETE = 'album.delete',
   ALBUM_DELETE = 'album.delete',
   ALBUM_SHARE = 'album.share',
   ALBUM_SHARE = 'album.share',
+  ALBUM_DOWNLOAD = 'album.download',
 
 
   LIBRARY_READ = 'library.read',
   LIBRARY_READ = 'library.read',
   LIBRARY_DOWNLOAD = 'library.download',
   LIBRARY_DOWNLOAD = 'library.download',
@@ -68,6 +69,10 @@ export class AccessCore {
         // TODO: fix this to not use authUser.id for shared link access control
         // TODO: fix this to not use authUser.id for shared link access control
         return this.repository.asset.hasOwnerAccess(authUser.id, id);
         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:
       // case Permission.ALBUM_READ:
       //   return this.repository.album.hasSharedLinkAccess(sharedLinkId, id);
       //   return this.repository.album.hasSharedLinkAccess(sharedLinkId, id);
 
 
@@ -122,6 +127,12 @@ export class AccessCore {
       case Permission.ALBUM_SHARE:
       case Permission.ALBUM_SHARE:
         return this.repository.album.hasOwnerAccess(authUser.id, id);
         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:
       case Permission.LIBRARY_READ:
         return authUser.id === id || (await this.repository.library.hasPartnerAccess(authUser.id, id));
         return authUser.id === id || (await this.repository.library.hasPartnerAccess(authUser.id, id));
 
 

+ 2 - 0
server/src/domain/asset/asset.repository.ts

@@ -44,6 +44,8 @@ export const IAssetRepository = 'IAssetRepository';
 export interface IAssetRepository {
 export interface IAssetRepository {
   getByDate(ownerId: string, date: Date): Promise<AssetEntity[]>;
   getByDate(ownerId: string, date: Date): Promise<AssetEntity[]>;
   getByIds(ids: string[]): Promise<AssetEntity[]>;
   getByIds(ids: string[]): Promise<AssetEntity[]>;
+  getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated<AssetEntity>;
+  getByUserId(pagination: PaginationOptions, userId: string): Paginated<AssetEntity>;
   getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated<AssetEntity>;
   getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated<AssetEntity>;
   getWith(pagination: PaginationOptions, property: WithProperty): Paginated<AssetEntity>;
   getWith(pagination: PaginationOptions, property: WithProperty): Paginated<AssetEntity>;
   getFirstAssetForAlbumId(albumId: string): Promise<AssetEntity | null>;
   getFirstAssetForAlbumId(albumId: string): Promise<AssetEntity | null>;

+ 215 - 22
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 { 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, () => {
 describe(AssetService.name, () => {
   let sut: AssetService;
   let sut: AssetService;
+  let accessMock: IAccessRepositoryMock;
   let assetMock: jest.Mocked<IAssetRepository>;
   let assetMock: jest.Mocked<IAssetRepository>;
+  let storageMock: jest.Mocked<IStorageRepository>;
 
 
   it('should work', () => {
   it('should work', () => {
     expect(sut).toBeDefined();
     expect(sut).toBeDefined();
   });
   });
 
 
   beforeEach(async () => {
   beforeEach(async () => {
+    accessMock = newAccessRepositoryMock();
     assetMock = newAssetRepositoryMock();
     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 () => {
     it('should get geo information of assets', async () => {
       assetMock.getMapMarkers.mockResolvedValue(
       assetMock.getMapMarkers.mockResolvedValue(
         [assetEntityStub.withLocation].map((asset) => ({
         [assetEntityStub.withLocation].map((asset) => ({
@@ -76,25 +103,191 @@ describe(AssetService.name, () => {
         [authStub.admin.id, new Date('2021-06-15T05:00:00.000Z')],
         [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')],
+      ]);
+    });
+  });
+
+  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.downloadFile(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException);
+
+      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');
+    });
   });
   });
 
 
-  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')],
-    ]);
+  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,
+          },
+        ],
+      });
+    });
   });
   });
 });
 });

+ 114 - 3
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 { DateTime } from 'luxon';
+import { extname } from 'path';
+import { AssetEntity } from '../../infra/entities/asset.entity';
 import { AuthUserDto } from '../auth';
 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 { IAssetRepository } from './asset.repository';
-import { MemoryLaneDto } from './dto';
+import { AssetIdsDto, DownloadArchiveInfo, DownloadDto, DownloadResponseDto, MemoryLaneDto } from './dto';
 import { MapMarkerDto } from './dto/map-marker.dto';
 import { MapMarkerDto } from './dto/map-marker.dto';
 import { mapAsset, MapMarkerResponseDto } from './response-dto';
 import { mapAsset, MapMarkerResponseDto } from './response-dto';
 import { MemoryLaneResponseDto } from './response-dto/memory-lane-response.dto';
 import { MemoryLaneResponseDto } from './response-dto/memory-lane-response.dto';
 
 
 export class AssetService {
 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<MapMarkerResponseDto[]> {
   getMapMarkers(authUser: AuthUserDto, options: MapMarkerDto): Promise<MapMarkerResponseDto[]> {
     return this.assetRepository.getMapMarkers(authUser.id, options);
     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));
     return Promise.all(requests).then((results) => results.filter((result) => result.assets.length > 0));
   }
   }
+
+  async downloadFile(authUser: AuthUserDto, id: string): Promise<ImmichReadStream> {
+    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<DownloadResponseDto> {
+    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<string>((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<ImmichReadStream> {
+    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<string, boolean> = {};
+
+    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<AsyncGenerator<AssetEntity[]>> {
+    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');
+  }
 }
 }

+ 31 - 0
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[];
+}

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

@@ -1,3 +1,4 @@
 export * from './asset-ids.dto';
 export * from './asset-ids.dto';
+export * from './download.dto';
 export * from './map-marker.dto';
 export * from './map-marker.dto';
 export * from './memory-lane.dto';
 export * from './memory-lane.dto';

+ 11 - 5
server/src/domain/storage/storage.repository.ts

@@ -1,9 +1,14 @@
-import { ReadStream } from 'fs';
+import { Readable } from 'stream';
 
 
 export interface ImmichReadStream {
 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<void>;
 }
 }
 
 
 export interface DiskUsage {
 export interface DiskUsage {
@@ -15,7 +20,8 @@ export interface DiskUsage {
 export const IStorageRepository = 'IStorageRepository';
 export const IStorageRepository = 'IStorageRepository';
 
 
 export interface IStorageRepository {
 export interface IStorageRepository {
-  createReadStream(filepath: string, mimeType: string): Promise<ImmichReadStream>;
+  createZipStream(): ImmichZipStream;
+  createReadStream(filepath: string, mimeType?: string | null): Promise<ImmichReadStream>;
   unlink(filepath: string): Promise<void>;
   unlink(filepath: string): Promise<void>;
   unlinkDir(folder: string, options?: { recursive?: boolean; force?: boolean }): Promise<void>;
   unlinkDir(folder: string, options?: { recursive?: boolean; force?: boolean }): Promise<void>;
   removeEmptyDirs(folder: string): Promise<void>;
   removeEmptyDirs(folder: string): Promise<void>;

+ 3 - 18
server/src/immich/api-v1/album/album.controller.ts

@@ -1,13 +1,10 @@
 import { AlbumResponseDto } from '@app/domain';
 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 { UUIDParamDto } from '../../controllers/dto/uuid-param.dto';
 import { AuthUser, AuthUserDto } from '../../decorators/auth-user.decorator';
 import { AuthUser, AuthUserDto } from '../../decorators/auth-user.decorator';
 import { Authenticated, SharedLinkRoute } from '../../decorators/authenticated.decorator';
 import { Authenticated, SharedLinkRoute } from '../../decorators/authenticated.decorator';
 import { UseValidation } from '../../decorators/use-validation.decorator';
 import { UseValidation } from '../../decorators/use-validation.decorator';
-import { DownloadDto } from '../asset/dto/download-library.dto';
 import { AlbumService } from './album.service';
 import { AlbumService } from './album.service';
 import { AddAssetsDto } from './dto/add-assets.dto';
 import { AddAssetsDto } from './dto/add-assets.dto';
 import { RemoveAssetsDto } from './dto/remove-assets.dto';
 import { RemoveAssetsDto } from './dto/remove-assets.dto';
@@ -18,7 +15,7 @@ import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
 @Authenticated()
 @Authenticated()
 @UseValidation()
 @UseValidation()
 export class AlbumController {
 export class AlbumController {
-  constructor(private readonly service: AlbumService) {}
+  constructor(private service: AlbumService) {}
 
 
   @SharedLinkRoute()
   @SharedLinkRoute()
   @Put(':id/assets')
   @Put(':id/assets')
@@ -46,16 +43,4 @@ export class AlbumController {
   ): Promise<AlbumResponseDto> {
   ): Promise<AlbumResponseDto> {
     return this.service.removeAssets(authUser, id, dto);
     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));
-  }
 }
 }

+ 1 - 2
server/src/immich/api-v1/album/album.module.ts

@@ -1,13 +1,12 @@
 import { AlbumEntity, AssetEntity } from '@app/infra/entities';
 import { AlbumEntity, AssetEntity } from '@app/infra/entities';
 import { Module } from '@nestjs/common';
 import { Module } from '@nestjs/common';
 import { TypeOrmModule } from '@nestjs/typeorm';
 import { TypeOrmModule } from '@nestjs/typeorm';
-import { DownloadModule } from '../../modules/download/download.module';
 import { AlbumRepository, IAlbumRepository } from './album-repository';
 import { AlbumRepository, IAlbumRepository } from './album-repository';
 import { AlbumController } from './album.controller';
 import { AlbumController } from './album.controller';
 import { AlbumService } from './album.service';
 import { AlbumService } from './album.service';
 
 
 @Module({
 @Module({
-  imports: [TypeOrmModule.forFeature([AlbumEntity, AssetEntity]), DownloadModule],
+  imports: [TypeOrmModule.forFeature([AlbumEntity, AssetEntity])],
   controllers: [AlbumController],
   controllers: [AlbumController],
   providers: [AlbumService, { provide: IAlbumRepository, useClass: AlbumRepository }],
   providers: [AlbumService, { provide: IAlbumRepository, useClass: AlbumRepository }],
 })
 })

+ 1 - 7
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 { ForbiddenException, NotFoundException } from '@nestjs/common';
 import { userEntityStub } from '@test';
 import { userEntityStub } from '@test';
 import { AuthUserDto } from '../../decorators/auth-user.decorator';
 import { AuthUserDto } from '../../decorators/auth-user.decorator';
-import { DownloadService } from '../../modules/download/download.service';
 import { IAlbumRepository } from './album-repository';
 import { IAlbumRepository } from './album-repository';
 import { AlbumService } from './album.service';
 import { AlbumService } from './album.service';
 import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
 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', () => {
 describe('Album service', () => {
   let sut: AlbumService;
   let sut: AlbumService;
   let albumRepositoryMock: jest.Mocked<IAlbumRepository>;
   let albumRepositoryMock: jest.Mocked<IAlbumRepository>;
-  let downloadServiceMock: jest.Mocked<Partial<DownloadService>>;
 
 
   const authUser: AuthUserDto = Object.freeze({
   const authUser: AuthUserDto = Object.freeze({
     id: '1111',
     id: '1111',
@@ -98,11 +96,7 @@ describe('Album service', () => {
       updateThumbnails: jest.fn(),
       updateThumbnails: jest.fn(),
     };
     };
 
 
-    downloadServiceMock = {
-      downloadArchive: jest.fn(),
-    };
-
-    sut = new AlbumService(albumRepositoryMock, downloadServiceMock as DownloadService);
+    sut = new AlbumService(albumRepositoryMock);
   });
   });
 
 
   it('gets an owned album', async () => {
   it('gets an owned album', async () => {

+ 5 - 25
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 { AlbumEntity } from '@app/infra/entities';
 import { BadRequestException, ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
 import { BadRequestException, ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
 import { AuthUserDto } from '../../decorators/auth-user.decorator';
 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 { IAlbumRepository } from './album-repository';
 import { AddAssetsDto } from './dto/add-assets.dto';
 import { AddAssetsDto } from './dto/add-assets.dto';
 import { RemoveAssetsDto } from './dto/remove-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 {
 export class AlbumService {
   private logger = new Logger(AlbumService.name);
   private logger = new Logger(AlbumService.name);
 
 
-  constructor(
-    @Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
-    private downloadService: DownloadService,
-  ) {}
+  constructor(@Inject(IAlbumRepository) private repository: IAlbumRepository) {}
 
 
   private async _getAlbum({
   private async _getAlbum({
     authUser,
     authUser,
@@ -27,9 +22,9 @@ export class AlbumService {
     albumId: string;
     albumId: string;
     validateIsOwner?: boolean;
     validateIsOwner?: boolean;
   }): Promise<AlbumEntity> {
   }): Promise<AlbumEntity> {
-    await this.albumRepository.updateThumbnails();
+    await this.repository.updateThumbnails();
 
 
-    const album = await this.albumRepository.get(albumId);
+    const album = await this.repository.get(albumId);
     if (!album) {
     if (!album) {
       throw new NotFoundException('Album Not Found');
       throw new NotFoundException('Album Not Found');
     }
     }
@@ -50,7 +45,7 @@ export class AlbumService {
 
 
   async removeAssets(authUser: AuthUserDto, albumId: string, dto: RemoveAssetsDto): Promise<AlbumResponseDto> {
   async removeAssets(authUser: AuthUserDto, albumId: string, dto: RemoveAssetsDto): Promise<AlbumResponseDto> {
     const album = await this._getAlbum({ authUser, albumId });
     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 });
     const newAlbum = await this._getAlbum({ authUser, albumId });
 
 
     if (deletedCount !== dto.assetIds.length) {
     if (deletedCount !== dto.assetIds.length) {
@@ -67,7 +62,7 @@ export class AlbumService {
     }
     }
 
 
     const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false });
     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 });
     const newAlbum = await this._getAlbum({ authUser, albumId, validateIsOwner: false });
 
 
     return {
     return {
@@ -75,19 +70,4 @@ export class AlbumService {
       album: mapAlbum(newAlbum),
       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();
-    }
-  }
 }
 }

+ 1 - 41
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 {
 import {
   Body,
   Body,
   Controller,
   Controller,
@@ -14,7 +14,6 @@ import {
   Put,
   Put,
   Query,
   Query,
   Response,
   Response,
-  StreamableFile,
   UploadedFiles,
   UploadedFiles,
   UseInterceptors,
   UseInterceptors,
   ValidationPipe,
   ValidationPipe,
@@ -22,7 +21,6 @@ import {
 import { FileFieldsInterceptor } from '@nestjs/platform-express';
 import { FileFieldsInterceptor } from '@nestjs/platform-express';
 import { ApiBody, ApiConsumes, ApiHeader, ApiOkResponse, ApiTags } from '@nestjs/swagger';
 import { ApiBody, ApiConsumes, ApiHeader, ApiOkResponse, ApiTags } from '@nestjs/swagger';
 import { Response as Res } from 'express';
 import { Response as Res } from 'express';
-import { handleDownload } from '../../app.utils';
 import { assetUploadOption, ImmichFile } from '../../config/asset-upload.config';
 import { assetUploadOption, ImmichFile } from '../../config/asset-upload.config';
 import { UUIDParamDto } from '../../controllers/dto/uuid-param.dto';
 import { UUIDParamDto } from '../../controllers/dto/uuid-param.dto';
 import { AuthUser, AuthUserDto } from '../../decorators/auth-user.decorator';
 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 { CreateAssetDto, ImportAssetDto, mapToUploadFile } from './dto/create-asset.dto';
 import { DeleteAssetDto } from './dto/delete-asset.dto';
 import { DeleteAssetDto } from './dto/delete-asset.dto';
 import { DeviceIdDto } from './dto/device-id.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 { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto';
 import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto';
 import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto';
 import { GetAssetThumbnailDto } from './dto/get-asset-thumbnail.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 { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
 import { DeleteAssetResponseDto } from './response-dto/delete-asset-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 {
 interface UploadFiles {
   assetData: ImmichFile[];
   assetData: ImmichFile[];
   livePhotoData?: ImmichFile[];
   livePhotoData?: ImmichFile[];
@@ -128,38 +120,6 @@ export class AssetController {
     return responseDto;
     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()
   @SharedLinkRoute()
   @Get('/file/:id')
   @Get('/file/:id')
   @Header('Cache-Control', 'private, max-age=86400, no-transform')
   @Header('Cache-Control', 'private, max-age=86400, no-transform')

+ 1 - 6
server/src/immich/api-v1/asset/asset.module.ts

@@ -1,17 +1,12 @@
 import { AssetEntity, ExifEntity } from '@app/infra/entities';
 import { AssetEntity, ExifEntity } from '@app/infra/entities';
 import { Module } from '@nestjs/common';
 import { Module } from '@nestjs/common';
 import { TypeOrmModule } from '@nestjs/typeorm';
 import { TypeOrmModule } from '@nestjs/typeorm';
-import { DownloadModule } from '../../modules/download/download.module';
 import { AssetRepository, IAssetRepository } from './asset-repository';
 import { AssetRepository, IAssetRepository } from './asset-repository';
 import { AssetController } from './asset.controller';
 import { AssetController } from './asset.controller';
 import { AssetService } from './asset.service';
 import { AssetService } from './asset.service';
 
 
 @Module({
 @Module({
-  imports: [
-    //
-    TypeOrmModule.forFeature([AssetEntity, ExifEntity]),
-    DownloadModule,
-  ],
+  imports: [TypeOrmModule.forFeature([AssetEntity, ExifEntity])],
   controllers: [AssetController],
   controllers: [AssetController],
   providers: [AssetService, { provide: IAssetRepository, useClass: AssetRepository }],
   providers: [AssetService, { provide: IAssetRepository, useClass: AssetRepository }],
 })
 })

+ 1 - 36
server/src/immich/api-v1/asset/asset.service.spec.ts

@@ -13,7 +13,6 @@ import {
 } from '@test';
 } from '@test';
 import { when } from 'jest-when';
 import { when } from 'jest-when';
 import { QueryFailedError, Repository } from 'typeorm';
 import { QueryFailedError, Repository } from 'typeorm';
-import { DownloadService } from '../../modules/download/download.service';
 import { IAssetRepository } from './asset-repository';
 import { IAssetRepository } from './asset-repository';
 import { AssetService } from './asset.service';
 import { AssetService } from './asset.service';
 import { CreateAssetDto } from './dto/create-asset.dto';
 import { CreateAssetDto } from './dto/create-asset.dto';
@@ -124,7 +123,6 @@ describe('AssetService', () => {
   let accessMock: IAccessRepositoryMock;
   let accessMock: IAccessRepositoryMock;
   let assetRepositoryMock: jest.Mocked<IAssetRepository>;
   let assetRepositoryMock: jest.Mocked<IAssetRepository>;
   let cryptoMock: jest.Mocked<ICryptoRepository>;
   let cryptoMock: jest.Mocked<ICryptoRepository>;
-  let downloadServiceMock: jest.Mocked<Partial<DownloadService>>;
   let jobMock: jest.Mocked<IJobRepository>;
   let jobMock: jest.Mocked<IJobRepository>;
   let storageMock: jest.Mocked<IStorageRepository>;
   let storageMock: jest.Mocked<IStorageRepository>;
 
 
@@ -152,24 +150,12 @@ describe('AssetService', () => {
 
 
     cryptoMock = newCryptoRepositoryMock();
     cryptoMock = newCryptoRepositoryMock();
 
 
-    downloadServiceMock = {
-      downloadArchive: jest.fn(),
-    };
-
     accessMock = newAccessRepositoryMock();
     accessMock = newAccessRepositoryMock();
     cryptoMock = newCryptoRepositoryMock();
     cryptoMock = newCryptoRepositoryMock();
     jobMock = newJobRepositoryMock();
     jobMock = newJobRepositoryMock();
     storageMock = newStorageRepositoryMock();
     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)
     when(assetRepositoryMock.get)
       .calledWith(assetEntityStub.livePhotoStillAsset.id)
       .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', () => {
   describe('bulkUploadCheck', () => {
     it('should accept hex and base64 checksums', async () => {
     it('should accept hex and base64 checksums', async () => {
       const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');
       const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');

+ 0 - 49
server/src/immich/api-v1/asset/asset.service.ts

@@ -6,7 +6,6 @@ import {
   IAccessRepository,
   IAccessRepository,
   ICryptoRepository,
   ICryptoRepository,
   IJobRepository,
   IJobRepository,
-  ImmichReadStream,
   isSupportedFileType,
   isSupportedFileType,
   IStorageRepository,
   IStorageRepository,
   JobName,
   JobName,
@@ -33,7 +32,6 @@ import mime from 'mime-types';
 import path from 'path';
 import path from 'path';
 import { QueryFailedError, Repository } from 'typeorm';
 import { QueryFailedError, Repository } from 'typeorm';
 import { promisify } from 'util';
 import { promisify } from 'util';
-import { DownloadService } from '../../modules/download/download.service';
 import { IAssetRepository } from './asset-repository';
 import { IAssetRepository } from './asset-repository';
 import { AssetCore } from './asset.core';
 import { AssetCore } from './asset.core';
 import { AssetBulkUploadCheckDto } from './dto/asset-check.dto';
 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 { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
 import { CreateAssetDto, ImportAssetDto, UploadFile } from './dto/create-asset.dto';
 import { CreateAssetDto, ImportAssetDto, UploadFile } from './dto/create-asset.dto';
 import { DeleteAssetDto } from './dto/delete-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 { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto';
 import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto';
 import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto';
 import { GetAssetThumbnailDto, GetAssetThumbnailFormatEnum } from './dto/get-asset-thumbnail.dto';
 import { GetAssetThumbnailDto, GetAssetThumbnailFormatEnum } from './dto/get-asset-thumbnail.dto';
@@ -86,7 +82,6 @@ export class AssetService {
     @Inject(IAssetRepository) private _assetRepository: IAssetRepository,
     @Inject(IAssetRepository) private _assetRepository: IAssetRepository,
     @InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
     @InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
     @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
     @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
-    private downloadService: DownloadService,
     @Inject(IJobRepository) private jobRepository: IJobRepository,
     @Inject(IJobRepository) private jobRepository: IJobRepository,
     @Inject(IStorageRepository) private storageRepository: IStorageRepository,
     @Inject(IStorageRepository) private storageRepository: IStorageRepository,
   ) {
   ) {
@@ -250,50 +245,6 @@ export class AssetService {
     return mapAsset(updatedAsset);
     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<ImmichReadStream> {
-    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(
   async getAssetThumbnail(
     authUser: AuthUserDto,
     authUser: AuthUserDto,
     assetId: string,
     assetId: string,

+ 0 - 12
server/src/immich/api-v1/asset/dto/download-files.dto.ts

@@ -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[];
-}

+ 0 - 14
server/src/immich/api-v1/asset/dto/download-library.dto.ts

@@ -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;
-}

+ 11 - 11
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 {
 import {
   DocumentBuilder,
   DocumentBuilder,
   OpenAPIObject,
   OpenAPIObject,
@@ -7,18 +13,12 @@ import {
   SwaggerDocumentOptions,
   SwaggerDocumentOptions,
   SwaggerModule,
   SwaggerModule,
 } from '@nestjs/swagger';
 } from '@nestjs/swagger';
-import { Response } from 'express';
 import { writeFileSync } from 'fs';
 import { writeFileSync } from 'fs';
 import path from 'path';
 import path from 'path';
 import { Metadata } from './decorators/authenticated.decorator';
 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<T extends object>(obj: T): T {
 function sortKeys<T extends object>(obj: T): T {

+ 36 - 4
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 { MapMarkerDto } from '@app/domain/asset/dto/map-marker.dto';
 import { MemoryLaneResponseDto } from '@app/domain/asset/response-dto/memory-lane-response.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 { 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 { UseValidation } from '../decorators/use-validation.decorator';
+import { UUIDParamDto } from './dto/uuid-param.dto';
 
 
 @ApiTags('Asset')
 @ApiTags('Asset')
 @Controller('asset')
 @Controller('asset')
@@ -23,4 +33,26 @@ export class AssetController {
   getMemoryLane(@AuthUser() authUser: AuthUserDto, @Query() dto: MemoryLaneDto): Promise<MemoryLaneResponseDto[]> {
   getMemoryLane(@AuthUser() authUser: AuthUserDto, @Query() dto: MemoryLaneDto): Promise<MemoryLaneResponseDto[]> {
     return this.service.getMemoryLane(authUser, dto);
     return this.service.getMemoryLane(authUser, dto);
   }
   }
+
+  @SharedLinkRoute()
+  @Get('download')
+  getDownloadInfo(@AuthUser() authUser: AuthUserDto, @Query() dto: DownloadDto): Promise<DownloadResponseDto> {
+    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<StreamableFile> {
+    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);
+  }
 }
 }

+ 0 - 8
server/src/immich/modules/download/download.module.ts

@@ -1,8 +0,0 @@
-import { Module } from '@nestjs/common';
-import { DownloadService } from './download.service';
-
-@Module({
-  providers: [DownloadService],
-  exports: [DownloadService],
-})
-export class DownloadModule {}

+ 0 - 63
server/src/immich/modules/download/download.service.ts

@@ -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<DownloadArchive> {
-    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');
-    }
-  }
-}

+ 3 - 1
server/src/infra/repositories/access.repository.ts

@@ -140,7 +140,9 @@ export class AccessRepository implements IAccessRepository {
       return this.albumRepository.exist({
       return this.albumRepository.exist({
         where: {
         where: {
           id: albumId,
           id: albumId,
-          ownerId: userId,
+          sharedUsers: {
+            id: userId,
+          },
         },
         },
       });
       });
     },
     },

+ 26 - 0
server/src/infra/repositories/asset.repository.ts

@@ -72,6 +72,32 @@ export class AssetRepository implements IAssetRepository {
     await this.repository.delete({ ownerId });
     await this.repository.delete({ ownerId });
   }
   }
 
 
+  getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated<AssetEntity> {
+    return paginate(this.repository, pagination, {
+      where: {
+        albums: {
+          id: albumId,
+        },
+      },
+      relations: {
+        albums: true,
+        exifInfo: true,
+      },
+    });
+  }
+
+  getByUserId(pagination: PaginationOptions, userId: string): Paginated<AssetEntity> {
+    return paginate(this.repository, pagination, {
+      where: {
+        ownerId: userId,
+        isVisible: true,
+      },
+      relations: {
+        exifInfo: true,
+      },
+    });
+  }
+
   getAll(pagination: PaginationOptions, options: AssetSearchOptions = {}): Paginated<AssetEntity> {
   getAll(pagination: PaginationOptions, options: AssetSearchOptions = {}): Paginated<AssetEntity> {
     return paginate(this.repository, pagination, {
     return paginate(this.repository, pagination, {
       where: {
       where: {

+ 16 - 3
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 { constants, createReadStream, existsSync, mkdirSync } from 'fs';
 import fs from 'fs/promises';
 import fs from 'fs/promises';
 import mv from 'mv';
 import mv from 'mv';
@@ -8,13 +9,25 @@ import path from 'path';
 const moveFile = promisify<string, string, mv.Options>(mv);
 const moveFile = promisify<string, string, mv.Options>(mv);
 
 
 export class FilesystemProvider implements IStorageRepository {
 export class FilesystemProvider implements IStorageRepository {
-  async createReadStream(filepath: string, mimeType: string): Promise<ImmichReadStream> {
+  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<ImmichReadStream> {
     const { size } = await fs.stat(filepath);
     const { size } = await fs.stat(filepath);
     await fs.access(filepath, constants.R_OK | constants.W_OK);
     await fs.access(filepath, constants.R_OK | constants.W_OK);
     return {
     return {
       stream: createReadStream(filepath),
       stream: createReadStream(filepath),
       length: size,
       length: size,
-      type: mimeType,
+      type: mimeType || undefined,
     };
     };
   }
   }
 
 

+ 20 - 4
server/test/fixtures.ts

@@ -203,14 +203,14 @@ export const fileStub = {
 export const assetEntityStub = {
 export const assetEntityStub = {
   noResizePath: Object.freeze<AssetEntity>({
   noResizePath: Object.freeze<AssetEntity>({
     id: 'asset-id',
     id: 'asset-id',
-    originalFileName: 'asset_1.jpeg',
+    originalFileName: 'IMG_123',
     deviceAssetId: 'device-asset-id',
     deviceAssetId: 'device-asset-id',
     fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
     fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
     fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
     fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
     owner: userEntityStub.user1,
     owner: userEntityStub.user1,
     ownerId: 'user-id',
     ownerId: 'user-id',
     deviceId: 'device-id',
     deviceId: 'device-id',
-    originalPath: 'upload/upload/path.ext',
+    originalPath: 'upload/library/IMG_123.jpg',
     resizePath: null,
     resizePath: null,
     checksum: Buffer.from('file hash', 'utf8'),
     checksum: Buffer.from('file hash', 'utf8'),
     type: AssetType.IMAGE,
     type: AssetType.IMAGE,
@@ -240,7 +240,7 @@ export const assetEntityStub = {
     owner: userEntityStub.user1,
     owner: userEntityStub.user1,
     ownerId: 'user-id',
     ownerId: 'user-id',
     deviceId: 'device-id',
     deviceId: 'device-id',
-    originalPath: '/original/path.ext',
+    originalPath: 'upload/library/IMG_456.jpg',
     resizePath: '/uploads/user-id/thumbs/path.ext',
     resizePath: '/uploads/user-id/thumbs/path.ext',
     checksum: Buffer.from('file hash', 'utf8'),
     checksum: Buffer.from('file hash', 'utf8'),
     type: AssetType.IMAGE,
     type: AssetType.IMAGE,
@@ -258,10 +258,13 @@ export const assetEntityStub = {
     livePhotoVideoId: null,
     livePhotoVideoId: null,
     tags: [],
     tags: [],
     sharedLinks: [],
     sharedLinks: [],
-    originalFileName: 'asset-id.ext',
+    originalFileName: 'IMG_456',
     faces: [],
     faces: [],
     sidecarPath: null,
     sidecarPath: null,
     isReadOnly: false,
     isReadOnly: false,
+    exifInfo: {
+      fileSizeInByte: 123_000,
+    } as ExifEntity,
   }),
   }),
   noThumbhash: Object.freeze<AssetEntity>({
   noThumbhash: Object.freeze<AssetEntity>({
     id: 'asset-id',
     id: 'asset-id',
@@ -324,6 +327,9 @@ export const assetEntityStub = {
     originalFileName: 'asset-id.ext',
     originalFileName: 'asset-id.ext',
     faces: [],
     faces: [],
     sidecarPath: null,
     sidecarPath: null,
+    exifInfo: {
+      fileSizeInByte: 5_000,
+    } as ExifEntity,
   }),
   }),
   video: Object.freeze<AssetEntity>({
   video: Object.freeze<AssetEntity>({
     id: 'asset-id',
     id: 'asset-id',
@@ -355,6 +361,9 @@ export const assetEntityStub = {
     sharedLinks: [],
     sharedLinks: [],
     faces: [],
     faces: [],
     sidecarPath: null,
     sidecarPath: null,
+    exifInfo: {
+      fileSizeInByte: 100_000,
+    } as ExifEntity,
   }),
   }),
   livePhotoMotionAsset: Object.freeze({
   livePhotoMotionAsset: Object.freeze({
     id: 'live-photo-motion-asset',
     id: 'live-photo-motion-asset',
@@ -364,6 +373,9 @@ export const assetEntityStub = {
     isVisible: false,
     isVisible: false,
     fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'),
     fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'),
     fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
     fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
+    exifInfo: {
+      fileSizeInByte: 100_000,
+    },
   } as AssetEntity),
   } as AssetEntity),
 
 
   livePhotoStillAsset: Object.freeze({
   livePhotoStillAsset: Object.freeze({
@@ -375,6 +387,9 @@ export const assetEntityStub = {
     isVisible: true,
     isVisible: true,
     fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'),
     fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'),
     fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
     fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
+    exifInfo: {
+      fileSizeInByte: 25_000,
+    },
   } as AssetEntity),
   } as AssetEntity),
 
 
   withLocation: Object.freeze<AssetEntity>({
   withLocation: Object.freeze<AssetEntity>({
@@ -410,6 +425,7 @@ export const assetEntityStub = {
     exifInfo: {
     exifInfo: {
       latitude: 100,
       latitude: 100,
       longitude: 100,
       longitude: 100,
+      fileSizeInByte: 23_456,
     } as ExifEntity,
     } as ExifEntity,
   }),
   }),
   sidecar: Object.freeze<AssetEntity>({
   sidecar: Object.freeze<AssetEntity>({

+ 2 - 0
server/test/repositories/asset.repository.mock.ts

@@ -4,6 +4,8 @@ export const newAssetRepositoryMock = (): jest.Mocked<IAssetRepository> => {
   return {
   return {
     getByDate: jest.fn(),
     getByDate: jest.fn(),
     getByIds: jest.fn().mockResolvedValue([]),
     getByIds: jest.fn().mockResolvedValue([]),
+    getByAlbumId: jest.fn(),
+    getByUserId: jest.fn(),
     getWithout: jest.fn(),
     getWithout: jest.fn(),
     getWith: jest.fn(),
     getWith: jest.fn(),
     getFirstAssetForAlbumId: jest.fn(),
     getFirstAssetForAlbumId: jest.fn(),

+ 1 - 0
server/test/repositories/storage.repository.mock.ts

@@ -2,6 +2,7 @@ import { IStorageRepository } from '@app/domain';
 
 
 export const newStorageRepositoryMock = (): jest.Mocked<IStorageRepository> => {
 export const newStorageRepositoryMock = (): jest.Mocked<IStorageRepository> => {
   return {
   return {
+    createZipStream: jest.fn(),
     createReadStream: jest.fn(),
     createReadStream: jest.fn(),
     unlink: jest.fn(),
     unlink: jest.fn(),
     unlinkDir: jest.fn().mockResolvedValue(true),
     unlinkDir: jest.fn().mockResolvedValue(true),

+ 216 - 291
web/src/api/open-api/api.ts

@@ -1111,16 +1111,41 @@ export type DeleteAssetStatus = typeof DeleteAssetStatus[keyof typeof DeleteAsse
 /**
 /**
  * 
  * 
  * @export
  * @export
- * @interface DownloadFilesDto
+ * @interface DownloadArchiveInfo
  */
  */
-export interface DownloadFilesDto {
+export interface DownloadArchiveInfo {
+    /**
+     * 
+     * @type {number}
+     * @memberof DownloadArchiveInfo
+     */
+    'size': number;
     /**
     /**
      * 
      * 
      * @type {Array<string>}
      * @type {Array<string>}
-     * @memberof DownloadFilesDto
+     * @memberof DownloadArchiveInfo
      */
      */
     'assetIds': Array<string>;
     'assetIds': Array<string>;
 }
 }
+/**
+ * 
+ * @export
+ * @interface DownloadResponseDto
+ */
+export interface DownloadResponseDto {
+    /**
+     * 
+     * @type {number}
+     * @memberof DownloadResponseDto
+     */
+    'totalSize': number;
+    /**
+     * 
+     * @type {Array<DownloadArchiveInfo>}
+     * @memberof DownloadResponseDto
+     */
+    'archives': Array<DownloadArchiveInfo>;
+}
 /**
 /**
  * 
  * 
  * @export
  * @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<RequestArgs> => {
-            // 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);
             setSearchParams(localVarUrlObj, localVarQueryParameter);
             let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
             let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
             localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
             localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@@ -4039,19 +4007,6 @@ export const AlbumApiFp = function(configuration?: Configuration) {
             const localVarAxiosArgs = await localVarAxiosParamCreator.deleteAlbum(id, options);
             const localVarAxiosArgs = await localVarAxiosParamCreator.deleteAlbum(id, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
             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<File>> {
-            const localVarAxiosArgs = await localVarAxiosParamCreator.downloadArchive(id, name, skip, key, options);
-            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
-        },
         /**
         /**
          * 
          * 
          * @param {*} [options] Override http request option.
          * @param {*} [options] Override http request option.
@@ -4165,18 +4120,6 @@ export const AlbumApiFactory = function (configuration?: Configuration, basePath
         deleteAlbum(id: string, options?: any): AxiosPromise<void> {
         deleteAlbum(id: string, options?: any): AxiosPromise<void> {
             return localVarFp.deleteAlbum(id, options).then((request) => request(axios, basePath));
             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<File> {
-            return localVarFp.downloadArchive(id, name, skip, key, options).then((request) => request(axios, basePath));
-        },
         /**
         /**
          * 
          * 
          * @param {*} [options] Override http request option.
          * @param {*} [options] Override http request option.
@@ -4315,41 +4258,6 @@ export interface AlbumApiDeleteAlbumRequest {
     readonly id: string
     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.
  * Request parameters for getAlbumInfo operation in AlbumApi.
  * @export
  * @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));
         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.
      * @param {*} [options] Override http request option.
@@ -4773,62 +4670,15 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
         },
         },
         /**
         /**
          * 
          * 
-         * @param {string} id 
-         * @param {string} [key] 
-         * @param {*} [options] Override http request option.
-         * @throws {RequiredError}
-         */
-        downloadFile: async (id: string, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
-            // 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 {AssetIdsDto} assetIdsDto 
          * @param {string} [key] 
          * @param {string} [key] 
          * @param {*} [options] Override http request option.
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          * @throws {RequiredError}
          */
          */
-        downloadFiles: async (downloadFilesDto: DownloadFilesDto, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
-            // 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<RequestArgs> => {
+            // 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.
             // use dummy base URL string because the URL constructor only accepts absolute URLs.
             const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
             const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
             let baseOptions;
             let baseOptions;
@@ -4860,7 +4710,7 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
             setSearchParams(localVarUrlObj, localVarQueryParameter);
             setSearchParams(localVarUrlObj, localVarQueryParameter);
             let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
             let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
             localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
             localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
-            localVarRequestOptions.data = serializeDataIfNeeded(downloadFilesDto, localVarRequestOptions, configuration)
+            localVarRequestOptions.data = serializeDataIfNeeded(assetIdsDto, localVarRequestOptions, configuration)
 
 
             return {
             return {
                 url: toPathString(localVarUrlObj),
                 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 {string} [key] 
          * @param {*} [options] Override http request option.
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          * @throws {RequiredError}
          */
          */
-        downloadLibrary: async (name?: string, skip?: number, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
-            const localVarPath = `/asset/download-library`;
+        downloadFile: async (id: string, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            // 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.
             // use dummy base URL string because the URL constructor only accepts absolute URLs.
             const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
             const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
             let baseOptions;
             let baseOptions;
@@ -4884,7 +4736,7 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
                 baseOptions = configuration.baseOptions;
                 baseOptions = configuration.baseOptions;
             }
             }
 
 
-            const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
+            const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
             const localVarHeaderParameter = {} as any;
             const localVarHeaderParameter = {} as any;
             const localVarQueryParameter = {} as any;
             const localVarQueryParameter = {} as any;
 
 
@@ -4897,14 +4749,6 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
             // http bearer authentication required
             // http bearer authentication required
             await setBearerAuthToObject(localVarHeaderParameter, configuration)
             await setBearerAuthToObject(localVarHeaderParameter, configuration)
 
 
-            if (name !== undefined) {
-                localVarQueryParameter['name'] = name;
-            }
-
-            if (skip !== undefined) {
-                localVarQueryParameter['skip'] = skip;
-            }
-
             if (key !== undefined) {
             if (key !== undefined) {
                 localVarQueryParameter['key'] = key;
                 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<string>} [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<string>, albumId?: string, userId?: string, archiveSize?: number, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
+            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);
             setSearchParams(localVarUrlObj, localVarQueryParameter);
             let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
             let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
             localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
             localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@@ -5890,36 +5797,24 @@ export const AssetApiFp = function(configuration?: Configuration) {
         },
         },
         /**
         /**
          * 
          * 
-         * @param {string} id 
+         * @param {AssetIdsDto} assetIdsDto 
          * @param {string} [key] 
          * @param {string} [key] 
          * @param {*} [options] Override http request option.
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          * @throws {RequiredError}
          */
          */
-        async downloadFile(id: string, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<File>> {
-            const localVarAxiosArgs = await localVarAxiosParamCreator.downloadFile(id, key, options);
+        async downloadArchive(assetIdsDto: AssetIdsDto, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<File>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.downloadArchive(assetIdsDto, key, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
             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<File>> {
-            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} id 
          * @param {string} [key] 
          * @param {string} [key] 
          * @param {*} [options] Override http request option.
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          * @throws {RequiredError}
          */
          */
-        async downloadLibrary(name?: string, skip?: number, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<File>> {
-            const localVarAxiosArgs = await localVarAxiosParamCreator.downloadLibrary(name, skip, key, options);
+        async downloadFile(id: string, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<File>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.downloadFile(id, key, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
         },
         /**
         /**
@@ -6025,6 +5920,20 @@ export const AssetApiFp = function(configuration?: Configuration) {
             const localVarAxiosArgs = await localVarAxiosParamCreator.getCuratedObjects(options);
             const localVarAxiosArgs = await localVarAxiosParamCreator.getCuratedObjects(options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
         },
+        /**
+         * 
+         * @param {Array<string>} [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<string>, albumId?: string, userId?: string, archiveSize?: number, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<DownloadResponseDto>> {
+            const localVarAxiosArgs = await localVarAxiosParamCreator.getDownloadInfo(assetIds, albumId, userId, archiveSize, key, options);
+            return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
+        },
         /**
         /**
          * 
          * 
          * @param {boolean} [isFavorite] 
          * @param {boolean} [isFavorite] 
@@ -6174,34 +6083,23 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
         },
         },
         /**
         /**
          * 
          * 
-         * @param {string} id 
+         * @param {AssetIdsDto} assetIdsDto 
          * @param {string} [key] 
          * @param {string} [key] 
          * @param {*} [options] Override http request option.
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          * @throws {RequiredError}
          */
          */
-        downloadFile(id: string, key?: string, options?: any): AxiosPromise<File> {
-            return localVarFp.downloadFile(id, key, options).then((request) => request(axios, basePath));
+        downloadArchive(assetIdsDto: AssetIdsDto, key?: string, options?: any): AxiosPromise<File> {
+            return localVarFp.downloadArchive(assetIdsDto, 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<File> {
-            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} id 
          * @param {string} [key] 
          * @param {string} [key] 
          * @param {*} [options] Override http request option.
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          * @throws {RequiredError}
          */
          */
-        downloadLibrary(name?: string, skip?: number, key?: string, options?: any): AxiosPromise<File> {
-            return localVarFp.downloadLibrary(name, skip, key, options).then((request) => request(axios, basePath));
+        downloadFile(id: string, key?: string, options?: any): AxiosPromise<File> {
+            return localVarFp.downloadFile(id, key, options).then((request) => request(axios, basePath));
         },
         },
         /**
         /**
          * Get all AssetEntity belong to the user
          * Get all AssetEntity belong to the user
@@ -6296,6 +6194,19 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
         getCuratedObjects(options?: any): AxiosPromise<Array<CuratedObjectsResponseDto>> {
         getCuratedObjects(options?: any): AxiosPromise<Array<CuratedObjectsResponseDto>> {
             return localVarFp.getCuratedObjects(options).then((request) => request(axios, basePath));
             return localVarFp.getCuratedObjects(options).then((request) => request(axios, basePath));
         },
         },
+        /**
+         * 
+         * @param {Array<string>} [assetIds] 
+         * @param {string} [albumId] 
+         * @param {string} [userId] 
+         * @param {number} [archiveSize] 
+         * @param {string} [key] 
+         * @param {*} [options] Override http request option.
+         * @throws {RequiredError}
+         */
+        getDownloadInfo(assetIds?: Array<string>, albumId?: string, userId?: string, archiveSize?: number, key?: string, options?: any): AxiosPromise<DownloadResponseDto> {
+            return localVarFp.getDownloadInfo(assetIds, albumId, userId, archiveSize, key, options).then((request) => request(axios, basePath));
+        },
         /**
         /**
          * 
          * 
          * @param {boolean} [isFavorite] 
          * @param {boolean} [isFavorite] 
@@ -6455,71 +6366,43 @@ export interface AssetApiDeleteAssetRequest {
 }
 }
 
 
 /**
 /**
- * Request parameters for downloadFile operation in AssetApi.
+ * Request parameters for downloadArchive operation in AssetApi.
  * @export
  * @export
- * @interface AssetApiDownloadFileRequest
+ * @interface AssetApiDownloadArchiveRequest
  */
  */
-export interface AssetApiDownloadFileRequest {
-    /**
-     * 
-     * @type {string}
-     * @memberof AssetApiDownloadFile
-     */
-    readonly id: string
-
+export interface AssetApiDownloadArchiveRequest {
     /**
     /**
      * 
      * 
-     * @type {string}
-     * @memberof AssetApiDownloadFile
-     */
-    readonly key?: string
-}
-
-/**
- * Request parameters for downloadFiles operation in AssetApi.
- * @export
- * @interface AssetApiDownloadFilesRequest
- */
-export interface AssetApiDownloadFilesRequest {
-    /**
-     * 
-     * @type {DownloadFilesDto}
-     * @memberof AssetApiDownloadFiles
+     * @type {AssetIdsDto}
+     * @memberof AssetApiDownloadArchive
      */
      */
-    readonly downloadFilesDto: DownloadFilesDto
+    readonly assetIdsDto: AssetIdsDto
 
 
     /**
     /**
      * 
      * 
      * @type {string}
      * @type {string}
-     * @memberof AssetApiDownloadFiles
+     * @memberof AssetApiDownloadArchive
      */
      */
     readonly key?: string
     readonly key?: string
 }
 }
 
 
 /**
 /**
- * Request parameters for downloadLibrary operation in AssetApi.
+ * Request parameters for downloadFile operation in AssetApi.
  * @export
  * @export
- * @interface AssetApiDownloadLibraryRequest
+ * @interface AssetApiDownloadFileRequest
  */
  */
-export interface AssetApiDownloadLibraryRequest {
+export interface AssetApiDownloadFileRequest {
     /**
     /**
      * 
      * 
      * @type {string}
      * @type {string}
-     * @memberof AssetApiDownloadLibrary
-     */
-    readonly name?: string
-
-    /**
-     * 
-     * @type {number}
-     * @memberof AssetApiDownloadLibrary
+     * @memberof AssetApiDownloadFile
      */
      */
-    readonly skip?: number
+    readonly id: string
 
 
     /**
     /**
      * 
      * 
      * @type {string}
      * @type {string}
-     * @memberof AssetApiDownloadLibrary
+     * @memberof AssetApiDownloadFile
      */
      */
     readonly key?: string
     readonly key?: string
 }
 }
@@ -6650,6 +6533,48 @@ export interface AssetApiGetAssetThumbnailRequest {
     readonly key?: string
     readonly key?: string
 }
 }
 
 
+/**
+ * Request parameters for getDownloadInfo operation in AssetApi.
+ * @export
+ * @interface AssetApiGetDownloadInfoRequest
+ */
+export interface AssetApiGetDownloadInfoRequest {
+    /**
+     * 
+     * @type {Array<string>}
+     * @memberof AssetApiGetDownloadInfo
+     */
+    readonly assetIds?: Array<string>
+
+    /**
+     * 
+     * @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.
  * Request parameters for getMapMarkers operation in AssetApi.
  * @export
  * @export
@@ -6955,35 +6880,24 @@ export class AssetApi extends BaseAPI {
 
 
     /**
     /**
      * 
      * 
-     * @param {AssetApiDownloadFileRequest} requestParameters Request parameters.
+     * @param {AssetApiDownloadArchiveRequest} requestParameters Request parameters.
      * @param {*} [options] Override http request option.
      * @param {*} [options] Override http request option.
      * @throws {RequiredError}
      * @throws {RequiredError}
      * @memberof AssetApi
      * @memberof AssetApi
      */
      */
-    public downloadFile(requestParameters: AssetApiDownloadFileRequest, options?: AxiosRequestConfig) {
-        return AssetApiFp(this.configuration).downloadFile(requestParameters.id, requestParameters.key, options).then((request) => request(this.axios, this.basePath));
+    public downloadArchive(requestParameters: AssetApiDownloadArchiveRequest, options?: AxiosRequestConfig) {
+        return AssetApiFp(this.configuration).downloadArchive(requestParameters.assetIdsDto, 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 {AssetApiDownloadFileRequest} requestParameters Request parameters.
      * @param {*} [options] Override http request option.
      * @param {*} [options] Override http request option.
      * @throws {RequiredError}
      * @throws {RequiredError}
      * @memberof AssetApi
      * @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));
+    public downloadFile(requestParameters: AssetApiDownloadFileRequest, options?: AxiosRequestConfig) {
+        return AssetApiFp(this.configuration).downloadFile(requestParameters.id, requestParameters.key, options).then((request) => request(this.axios, this.basePath));
     }
     }
 
 
     /**
     /**
@@ -7091,6 +7005,17 @@ export class AssetApi extends BaseAPI {
         return AssetApiFp(this.configuration).getCuratedObjects(options).then((request) => request(this.axios, this.basePath));
         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.
      * @param {AssetApiGetMapMarkersRequest} requestParameters Request parameters.

+ 8 - 74
web/src/lib/components/album-page/album-viewer.svelte

@@ -3,7 +3,6 @@
 	import { afterNavigate, goto } from '$app/navigation';
 	import { afterNavigate, goto } from '$app/navigation';
 	import { albumAssetSelectionStore } from '$lib/stores/album-asset-selection.store';
 	import { albumAssetSelectionStore } from '$lib/stores/album-asset-selection.store';
 	import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.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 { locale } from '$lib/stores/preferences.store';
 	import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
 	import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
 	import {
 	import {
@@ -45,6 +44,7 @@
 	import ThumbnailSelection from './thumbnail-selection.svelte';
 	import ThumbnailSelection from './thumbnail-selection.svelte';
 	import UserSelectionModal from './user-selection-modal.svelte';
 	import UserSelectionModal from './user-selection-modal.svelte';
 	import { handleError } from '../../utils/handle-error';
 	import { handleError } from '../../utils/handle-error';
+	import { downloadArchive } from '../../utils/asset-utils';
 
 
 	export let album: AlbumResponseDto;
 	export let album: AlbumResponseDto;
 	export let sharedLink: SharedLinkResponseDto | undefined = undefined;
 	export let sharedLink: SharedLinkResponseDto | undefined = undefined;
@@ -242,78 +242,12 @@
 	};
 	};
 
 
 	const downloadAlbum = async () => {
 	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) => {
 	const showAlbumOptionsMenu = ({ x, y }: MouseEvent) => {
@@ -360,7 +294,7 @@
 		>
 		>
 			<CircleIconButton title="Select all" logo={SelectAll} on:click={handleSelectAll} />
 			<CircleIconButton title="Select all" logo={SelectAll} on:click={handleSelectAll} />
 			{#if sharedLink?.allowDownload || !isPublicShared}
 			{#if sharedLink?.allowDownload || !isPublicShared}
-				<DownloadAction filename={album.albumName} sharedLinkKey={sharedLink?.key} />
+				<DownloadAction filename="{album.albumName}.zip" sharedLinkKey={sharedLink?.key} />
 			{/if}
 			{/if}
 			{#if isOwned}
 			{#if isOwned}
 				<RemoveFromAlbum bind:album />
 				<RemoveFromAlbum bind:album />

+ 2 - 72
web/src/lib/components/asset-viewer/asset-viewer.svelte

@@ -1,6 +1,5 @@
 <script lang="ts">
 <script lang="ts">
 	import { goto } from '$app/navigation';
 	import { goto } from '$app/navigation';
-	import { downloadAssets } from '$lib/stores/download';
 	import {
 	import {
 		AlbumResponseDto,
 		AlbumResponseDto,
 		api,
 		api,
@@ -25,7 +24,7 @@
 
 
 	import { assetStore } from '$lib/stores/assets.store';
 	import { assetStore } from '$lib/stores/assets.store';
 	import { isShowDetail } from '$lib/stores/preferences.store';
 	import { isShowDetail } from '$lib/stores/preferences.store';
-	import { addAssetsToAlbum, getFilenameExtension } from '$lib/utils/asset-utils';
+	import { addAssetsToAlbum, downloadFile } from '$lib/utils/asset-utils';
 	import { browser } from '$app/environment';
 	import { browser } from '$app/environment';
 
 
 	export let asset: AssetResponseDto;
 	export let asset: AssetResponseDto;
@@ -115,75 +114,6 @@
 		$isShowDetail = !$isShowDetail;
 		$isShowDetail = !$isShowDetail;
 	};
 	};
 
 
-	const handleDownload = () => {
-		if (asset.livePhotoVideoId) {
-			downloadFile(asset.livePhotoVideoId, true, publicSharedKey);
-			downloadFile(asset.id, false, publicSharedKey);
-			return;
-		}
-
-		downloadFile(asset.id, false, publicSharedKey);
-	};
-
-	const downloadFile = async (assetId: string, isLivePhoto: boolean, key: string) => {
-		try {
-			const imageExtension = isLivePhoto ? 'mov' : getFilenameExtension(asset.originalPath);
-			const imageFileName = asset.originalFileName + '.' + imageExtension;
-
-			// If assets is already download -> return;
-			if ($downloadAssets[imageFileName]) {
-				return;
-			}
-
-			$downloadAssets[imageFileName] = 0;
-
-			const { data, status } = await api.assetApi.downloadFile(
-				{ id: assetId, key },
-				{
-					responseType: 'blob',
-					onDownloadProgress: (progressEvent) => {
-						if (progressEvent.lengthComputable) {
-							const total = progressEvent.total;
-							const current = progressEvent.loaded;
-							$downloadAssets[imageFileName] = Math.floor((current / total) * 100);
-						}
-					}
-				}
-			);
-
-			if (!(data instanceof Blob)) {
-				return;
-			}
-
-			if (status === 200) {
-				const fileUrl = URL.createObjectURL(data);
-				const anchor = document.createElement('a');
-				anchor.href = fileUrl;
-				anchor.download = imageFileName;
-
-				document.body.appendChild(anchor);
-				anchor.click();
-				document.body.removeChild(anchor);
-
-				URL.revokeObjectURL(fileUrl);
-
-				// Remove item from download list
-				setTimeout(() => {
-					const copy = $downloadAssets;
-					delete copy[imageFileName];
-					$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.'
-			});
-		}
-	};
-
 	const deleteAsset = async () => {
 	const deleteAsset = async () => {
 		try {
 		try {
 			if (
 			if (
@@ -313,7 +243,7 @@
 			showDownloadButton={shouldShowDownloadButton}
 			showDownloadButton={shouldShowDownloadButton}
 			on:goBack={closeViewer}
 			on:goBack={closeViewer}
 			on:showDetail={showDetailInfoHandler}
 			on:showDetail={showDetailInfoHandler}
-			on:download={handleDownload}
+			on:download={() => downloadFile(asset, publicSharedKey)}
 			on:delete={deleteAsset}
 			on:delete={deleteAsset}
 			on:favorite={toggleFavorite}
 			on:favorite={toggleFavorite}
 			on:addToAlbum={() => openAlbumPicker(false)}
 			on:addToAlbum={() => openAlbumPicker(false)}

+ 15 - 3
web/src/lib/components/photos-page/actions/download-action.svelte

@@ -1,18 +1,30 @@
 <script lang="ts">
 <script lang="ts">
 	import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
 	import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
-	import { bulkDownload } from '$lib/utils/asset-utils';
+	import { downloadArchive, downloadFile } from '$lib/utils/asset-utils';
 	import CloudDownloadOutline from 'svelte-material-icons/CloudDownloadOutline.svelte';
 	import CloudDownloadOutline from 'svelte-material-icons/CloudDownloadOutline.svelte';
 	import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
 	import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
 	import { getAssetControlContext } from '../asset-select-control-bar.svelte';
 	import { getAssetControlContext } from '../asset-select-control-bar.svelte';
 
 
-	export let filename = 'immich';
+	export let filename = 'immich.zip';
 	export let sharedLinkKey: string | undefined = undefined;
 	export let sharedLinkKey: string | undefined = undefined;
 	export let menuItem = false;
 	export let menuItem = false;
 
 
 	const { getAssets, clearSelect } = getAssetControlContext();
 	const { getAssets, clearSelect } = getAssetControlContext();
 
 
 	const handleDownloadFiles = async () => {
 	const handleDownloadFiles = async () => {
-		await bulkDownload(filename, Array.from(getAssets()), clearSelect, sharedLinkKey);
+		const assets = Array.from(getAssets());
+		if (assets.length === 1) {
+			await downloadFile(assets[0], sharedLinkKey);
+			clearSelect();
+			return;
+		}
+
+		await downloadArchive(
+			filename,
+			{ assetIds: assets.map((asset) => asset.id) },
+			clearSelect,
+			sharedLinkKey
+		);
 	};
 	};
 </script>
 </script>
 
 

+ 8 - 3
web/src/lib/components/share-page/individual-shared-viewer.svelte

@@ -1,7 +1,7 @@
 <script lang="ts">
 <script lang="ts">
 	import { goto } from '$app/navigation';
 	import { goto } from '$app/navigation';
-	import { bulkDownload } from '$lib/utils/asset-utils';
 	import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
 	import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
+	import { downloadArchive } from '$lib/utils/asset-utils';
 	import { api, AssetResponseDto, SharedLinkResponseDto } from '@api';
 	import { api, AssetResponseDto, SharedLinkResponseDto } from '@api';
 	import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
 	import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
 	import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
 	import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
@@ -38,7 +38,12 @@
 	});
 	});
 
 
 	const downloadAssets = async () => {
 	const downloadAssets = async () => {
-		await bulkDownload('immich-shared', assets, undefined, sharedLink.key);
+		await downloadArchive(
+			`immich-shared.zip`,
+			{ assetIds: assets.map((asset) => asset.id) },
+			undefined,
+			sharedLink.key
+		);
 	};
 	};
 
 
 	const handleUploadAssets = async (files: File[] = []) => {
 	const handleUploadAssets = async (files: File[] = []) => {
@@ -78,7 +83,7 @@
 		<AssetSelectControlBar assets={selectedAssets} clearSelect={() => (selectedAssets = new Set())}>
 		<AssetSelectControlBar assets={selectedAssets} clearSelect={() => (selectedAssets = new Set())}>
 			<CircleIconButton title="Select all" logo={SelectAll} on:click={handleSelectAll} />
 			<CircleIconButton title="Select all" logo={SelectAll} on:click={handleSelectAll} />
 			{#if sharedLink?.allowDownload}
 			{#if sharedLink?.allowDownload}
-				<DownloadAction filename="immich-shared" sharedLinkKey={sharedLink.key} />
+				<DownloadAction filename="immich-shared.zip" sharedLinkKey={sharedLink.key} />
 			{/if}
 			{/if}
 			{#if isOwned}
 			{#if isOwned}
 				<RemoveFromSharedLink bind:sharedLink />
 				<RemoveFromSharedLink bind:sharedLink />

+ 15 - 0
web/src/lib/stores/download.ts

@@ -9,3 +9,18 @@ export const isDownloading = derived(downloadAssets, ($downloadAssets) => {
 
 
 	return true;
 	return true;
 });
 });
+
+const update = (key: string, value: number | null) => {
+	downloadAssets.update((state) => {
+		const newState = { ...state };
+		if (value === null) {
+			delete newState[key];
+		} else {
+			newState[key] = value;
+		}
+		return newState;
+	});
+};
+
+export const clearDownload = (key: string) => update(key, null);
+export const updateDownload = (key: string, value: number) => update(key, value);

+ 86 - 59
web/src/lib/utils/asset-utils.ts

@@ -1,9 +1,16 @@
-import { api, AddAssetsResponseDto, AssetResponseDto } from '@api';
 import {
 import {
 	notificationController,
 	notificationController,
 	NotificationType
 	NotificationType
 } from '$lib/components/shared-components/notification/notification';
 } from '$lib/components/shared-components/notification/notification';
-import { downloadAssets } from '$lib/stores/download';
+import { clearDownload, updateDownload } from '$lib/stores/download';
+import {
+	AddAssetsResponseDto,
+	api,
+	AssetApiGetDownloadInfoRequest,
+	AssetResponseDto,
+	DownloadResponseDto
+} from '@api';
+import { handleError } from './handle-error';
 
 
 export const addAssetsToAlbum = async (
 export const addAssetsToAlbum = async (
 	albumId: string,
 	albumId: string,
@@ -24,84 +31,104 @@ export const addAssetsToAlbum = async (
 			return dto;
 			return dto;
 		});
 		});
 
 
-export async function bulkDownload(
+const downloadBlob = (data: Blob, filename: string) => {
+	const url = URL.createObjectURL(data);
+
+	const anchor = document.createElement('a');
+	anchor.href = url;
+	anchor.download = filename;
+
+	document.body.appendChild(anchor);
+	anchor.click();
+	document.body.removeChild(anchor);
+
+	URL.revokeObjectURL(url);
+};
+
+export const downloadArchive = async (
 	fileName: string,
 	fileName: string,
-	assets: AssetResponseDto[],
+	options: Omit<AssetApiGetDownloadInfoRequest, 'key'>,
 	onDone?: () => void,
 	onDone?: () => void,
 	key?: string
 	key?: string
-) {
-	const assetIds = assets.map((asset) => asset.id);
+) => {
+	let downloadInfo: DownloadResponseDto | null = null;
 
 
 	try {
 	try {
-		// let skip = 0;
-		let count = 0;
-		let done = false;
+		const { data } = await api.assetApi.getDownloadInfo({ ...options, key });
+		downloadInfo = data;
+	} catch (error) {
+		handleError(error, 'Unable to download files');
+		return;
+	}
 
 
-		while (!done) {
-			count++;
+	// TODO: prompt for big download
+	// const total = downloadInfo.totalSize;
 
 
-			const downloadFileName = fileName + `${count === 1 ? '' : count}.zip`;
-			downloadAssets.set({ [downloadFileName]: 0 });
+	for (let i = 0; i < downloadInfo.archives.length; i++) {
+		const archive = downloadInfo.archives[i];
+		const suffix = downloadInfo.archives.length === 1 ? '' : `+${i + 1}`;
+		const archiveName = fileName.replace('.zip', `${suffix}.zip`);
 
 
-			let total = 0;
+		let downloadKey = `${archiveName}`;
+		if (downloadInfo.archives.length > 1) {
+			downloadKey = `${archiveName} (${i + 1}/${downloadInfo.archives.length})`;
+		}
 
 
-			const { data, status, headers } = await api.assetApi.downloadFiles(
-				{ downloadFilesDto: { assetIds }, key },
+		updateDownload(downloadKey, 0);
+
+		try {
+			const { data } = await api.assetApi.downloadArchive(
+				{ assetIdsDto: { assetIds: archive.assetIds }, key },
 				{
 				{
 					responseType: 'blob',
 					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.set({ [downloadFileName]: Math.floor((current / total) * 100) });
-						}
-					}
+					onDownloadProgress: (event) =>
+						updateDownload(downloadKey, Math.floor((event.loaded / archive.size) * 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 {
-				onDone?.();
-				done = true;
-			}
+			downloadBlob(data, archiveName);
+		} catch (e) {
+			handleError(e, 'Unable to download files');
+			clearDownload(downloadKey);
+			return;
+		} finally {
+			setTimeout(() => clearDownload(downloadKey), 3_000);
+		}
+	}
 
 
-			if (!(data instanceof Blob)) {
-				return;
-			}
+	onDone?.();
+};
 
 
-			if (status === 201) {
-				const fileUrl = URL.createObjectURL(data);
-				const anchor = document.createElement('a');
-				anchor.href = fileUrl;
-				anchor.download = downloadFileName;
+export const downloadFile = async (asset: AssetResponseDto, key?: string) => {
+	const filenames = [`${asset.originalFileName}.${getFilenameExtension(asset.originalPath)}`];
+	if (asset.livePhotoVideoId) {
+		filenames.push(`${asset.originalFileName}.mov`);
+	}
 
 
-				document.body.appendChild(anchor);
-				anchor.click();
-				document.body.removeChild(anchor);
+	for (const filename of filenames) {
+		try {
+			updateDownload(filename, 0);
 
 
-				URL.revokeObjectURL(fileUrl);
+			const { data } = await api.assetApi.downloadFile(
+				{ id: asset.id, key },
+				{
+					responseType: 'blob',
+					onDownloadProgress: (event: ProgressEvent) => {
+						if (event.lengthComputable) {
+							updateDownload(filename, Math.floor((event.loaded / event.total) * 100));
+						}
+					}
+				}
+			);
 
 
-				// Remove item from download list
-				setTimeout(() => {
-					downloadAssets.set({});
-				}, 2000);
-			}
+			downloadBlob(data, filename);
+		} catch (e) {
+			handleError(e, `Error downloading ${filename}`);
+		} finally {
+			setTimeout(() => clearDownload(filename), 3_000);
 		}
 		}
-	} catch (e) {
-		console.error('Error downloading file ', e);
-		notificationController.show({
-			type: NotificationType.Error,
-			message: 'Error downloading file, check console for more details.'
-		});
 	}
 	}
-}
+};
 
 
 /**
 /**
  * Returns the lowercase filename extension without a dot (.) and
  * Returns the lowercase filename extension without a dot (.) and

+ 12 - 2
web/src/lib/utils/handle-error.ts

@@ -4,10 +4,20 @@ import {
 	NotificationType
 	NotificationType
 } from '../components/shared-components/notification/notification';
 } from '../components/shared-components/notification/notification';
 
 
-export function handleError(error: unknown, message: string) {
+export async function handleError(error: unknown, message: string) {
 	console.error(`[handleError]: ${message}`, error);
 	console.error(`[handleError]: ${message}`, error);
 
 
-	let serverMessage = (error as ApiError)?.response?.data?.message;
+	let data = (error as ApiError)?.response?.data;
+	if (data instanceof Blob) {
+		const response = await data.text();
+		try {
+			data = JSON.parse(response);
+		} catch {
+			data = { message: response };
+		}
+	}
+
+	let serverMessage = data?.message;
 	if (serverMessage) {
 	if (serverMessage) {
 		serverMessage = `${String(serverMessage).slice(0, 75)}\n(Immich Server Error)`;
 		serverMessage = `${String(serverMessage).slice(0, 75)}\n(Immich Server Error)`;
 	}
 	}

+ 1 - 1
web/src/routes/(user)/people/[personId]/+page.svelte

@@ -67,7 +67,7 @@
 		</AssetSelectContextMenu>
 		</AssetSelectContextMenu>
 		<DeleteAssets {onAssetDelete} />
 		<DeleteAssets {onAssetDelete} />
 		<AssetSelectContextMenu icon={DotsVertical} title="Add">
 		<AssetSelectContextMenu icon={DotsVertical} title="Add">
-			<DownloadAction menuItem />
+			<DownloadAction menuItem filename="{data.person.name || 'immich'}.zip" />
 			<FavoriteAction menuItem removeFavorite={isAllFavorite} />
 			<FavoriteAction menuItem removeFavorite={isAllFavorite} />
 			<ArchiveAction
 			<ArchiveAction
 				menuItem
 				menuItem