From ad343b7b32f2fdd36186b7e54c85e9a2d8a39b9f Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 30 Jun 2023 12:24:28 -0400 Subject: [PATCH] refactor(server): download assets (#3032) * refactor: download assets * chore: open api * chore: finish tests, make size configurable * chore: defualt to 4GiB * chore: open api * fix: optional archive size * fix: bugs * chore: cleanup --- mobile/openapi/.openapi-generator/FILES | 9 +- mobile/openapi/README.md | 10 +- mobile/openapi/doc/AlbumApi.md | 62 -- mobile/openapi/doc/AssetApi.md | 244 ++++---- ...loadFilesDto.md => DownloadArchiveInfo.md} | 3 +- mobile/openapi/doc/DownloadResponseDto.md | 16 + mobile/openapi/lib/api.dart | 3 +- mobile/openapi/lib/api/album_api.dart | 70 --- mobile/openapi/lib/api/asset_api.dart | 263 +++++---- mobile/openapi/lib/api_client.dart | 6 +- ...es_dto.dart => download_archive_info.dart} | 44 +- .../lib/model/download_response_dto.dart | 106 ++++ mobile/openapi/test/album_api_test.dart | 5 - mobile/openapi/test/asset_api_test.dart | 22 +- ...t.dart => download_archive_info_test.dart} | 11 +- .../test/download_response_dto_test.dart | 32 + server/immich-openapi-specs.json | 195 +++---- server/src/domain/access/access.core.ts | 11 + server/src/domain/asset/asset.repository.ts | 2 + server/src/domain/asset/asset.service.spec.ts | 233 +++++++- server/src/domain/asset/asset.service.ts | 117 +++- server/src/domain/asset/dto/download.dto.ts | 31 + server/src/domain/asset/dto/index.ts | 1 + .../src/domain/storage/storage.repository.ts | 16 +- .../immich/api-v1/album/album.controller.ts | 21 +- .../src/immich/api-v1/album/album.module.ts | 3 +- .../immich/api-v1/album/album.service.spec.ts | 8 +- .../src/immich/api-v1/album/album.service.ts | 30 +- .../immich/api-v1/asset/asset.controller.ts | 42 +- .../src/immich/api-v1/asset/asset.module.ts | 7 +- .../immich/api-v1/asset/asset.service.spec.ts | 37 +- .../src/immich/api-v1/asset/asset.service.ts | 49 -- .../api-v1/asset/dto/download-files.dto.ts | 12 - .../api-v1/asset/dto/download-library.dto.ts | 14 - server/src/immich/app.utils.ts | 20 +- .../immich/controllers/asset.controller.ts | 40 +- .../modules/download/download.module.ts | 8 - .../modules/download/download.service.ts | 63 -- .../infra/repositories/access.repository.ts | 4 +- .../infra/repositories/asset.repository.ts | 26 + .../infra/repositories/filesystem.provider.ts | 19 +- server/test/fixtures.ts | 24 +- .../repositories/asset.repository.mock.ts | 2 + .../repositories/storage.repository.mock.ts | 1 + web/src/api/open-api/api.ts | 551 ++++++++---------- .../components/album-page/album-viewer.svelte | 82 +-- .../asset-viewer/asset-viewer.svelte | 74 +-- .../actions/download-action.svelte | 18 +- .../individual-shared-viewer.svelte | 11 +- web/src/lib/stores/download.ts | 15 + web/src/lib/utils/asset-utils.ts | 149 +++-- web/src/lib/utils/handle-error.ts | 14 +- .../(user)/people/[personId]/+page.svelte | 2 +- 53 files changed, 1455 insertions(+), 1403 deletions(-) rename mobile/openapi/doc/{DownloadFilesDto.md => DownloadArchiveInfo.md} (86%) create mode 100644 mobile/openapi/doc/DownloadResponseDto.md rename mobile/openapi/lib/model/{download_files_dto.dart => download_archive_info.dart} (59%) create mode 100644 mobile/openapi/lib/model/download_response_dto.dart rename mobile/openapi/test/{download_files_dto_test.dart => download_archive_info_test.dart} (70%) create mode 100644 mobile/openapi/test/download_response_dto_test.dart create mode 100644 server/src/domain/asset/dto/download.dto.ts delete mode 100644 server/src/immich/api-v1/asset/dto/download-files.dto.ts delete mode 100644 server/src/immich/api-v1/asset/dto/download-library.dto.ts delete mode 100644 server/src/immich/modules/download/download.module.ts delete mode 100644 server/src/immich/modules/download/download.service.ts diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index e351e3c65..26eeb1c6b 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -45,7 +45,8 @@ doc/CuratedObjectsResponseDto.md doc/DeleteAssetDto.md doc/DeleteAssetResponseDto.md doc/DeleteAssetStatus.md -doc/DownloadFilesDto.md +doc/DownloadArchiveInfo.md +doc/DownloadResponseDto.md doc/ExifResponseDto.md doc/GetAssetByTimeBucketDto.md doc/GetAssetCountByTimeBucketDto.md @@ -178,7 +179,8 @@ lib/model/curated_objects_response_dto.dart lib/model/delete_asset_dto.dart lib/model/delete_asset_response_dto.dart lib/model/delete_asset_status.dart -lib/model/download_files_dto.dart +lib/model/download_archive_info.dart +lib/model/download_response_dto.dart lib/model/exif_response_dto.dart lib/model/get_asset_by_time_bucket_dto.dart lib/model/get_asset_count_by_time_bucket_dto.dart @@ -282,7 +284,8 @@ test/curated_objects_response_dto_test.dart test/delete_asset_dto_test.dart test/delete_asset_response_dto_test.dart test/delete_asset_status_test.dart -test/download_files_dto_test.dart +test/download_archive_info_test.dart +test/download_response_dto_test.dart test/exif_response_dto_test.dart test/get_asset_by_time_bucket_dto_test.dart test/get_asset_count_by_time_bucket_dto_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 91bad1615..606e4671f 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -81,7 +81,6 @@ Class | Method | HTTP request | Description *AlbumApi* | [**addUsersToAlbum**](doc//AlbumApi.md#adduserstoalbum) | **PUT** /album/{id}/users | *AlbumApi* | [**createAlbum**](doc//AlbumApi.md#createalbum) | **POST** /album | *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* | [**getAlbumInfo**](doc//AlbumApi.md#getalbuminfo) | **GET** /album/{id} | *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* | [**checkExistingAssets**](doc//AssetApi.md#checkexistingassets) | **POST** /asset/exist | *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* | [**getArchivedAssetCountByUserId**](doc//AssetApi.md#getarchivedassetcountbyuserid) | **GET** /asset/stat/archive | *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* | [**getCuratedLocations**](doc//AssetApi.md#getcuratedlocations) | **GET** /asset/curated-locations | *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* | [**getMemoryLane**](doc//AssetApi.md#getmemorylane) | **GET** /asset/memory-lane | *AssetApi* | [**getUserAssetsByDeviceId**](doc//AssetApi.md#getuserassetsbydeviceid) | **GET** /asset/{deviceId} | @@ -215,7 +214,8 @@ Class | Method | HTTP request | Description - [DeleteAssetDto](doc//DeleteAssetDto.md) - [DeleteAssetResponseDto](doc//DeleteAssetResponseDto.md) - [DeleteAssetStatus](doc//DeleteAssetStatus.md) - - [DownloadFilesDto](doc//DownloadFilesDto.md) + - [DownloadArchiveInfo](doc//DownloadArchiveInfo.md) + - [DownloadResponseDto](doc//DownloadResponseDto.md) - [ExifResponseDto](doc//ExifResponseDto.md) - [GetAssetByTimeBucketDto](doc//GetAssetByTimeBucketDto.md) - [GetAssetCountByTimeBucketDto](doc//GetAssetCountByTimeBucketDto.md) diff --git a/mobile/openapi/doc/AlbumApi.md b/mobile/openapi/doc/AlbumApi.md index b4eba7916..47c418096 100644 --- a/mobile/openapi/doc/AlbumApi.md +++ b/mobile/openapi/doc/AlbumApi.md @@ -13,7 +13,6 @@ Method | HTTP request | Description [**addUsersToAlbum**](AlbumApi.md#adduserstoalbum) | **PUT** /album/{id}/users | [**createAlbum**](AlbumApi.md#createalbum) | **POST** /album | [**deleteAlbum**](AlbumApi.md#deletealbum) | **DELETE** /album/{id} | -[**downloadArchive**](AlbumApi.md#downloadarchive) | **GET** /album/{id}/download | [**getAlbumCount**](AlbumApi.md#getalbumcount) | **GET** /album/count | [**getAlbumInfo**](AlbumApi.md#getalbuminfo) | **GET** /album/{id} | [**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) -# **downloadArchive** -> MultipartFile downloadArchive(id, name, skip, key) - - - -### Example -```dart -import 'package:openapi/api.dart'; -// TODO Configure API key authorization: cookie -//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; -// uncomment below to setup prefix (e.g. Bearer) for API key, if needed -//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; -// TODO Configure API key authorization: api_key -//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; -// uncomment below to setup prefix (e.g. Bearer) for API key, if needed -//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; -// TODO Configure HTTP Bearer authorization: bearer -// Case 1. Use String Token -//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); -// Case 2. Use Function which generate token. -// String yourTokenGeneratorFunction() { ... } -//defaultApiClient.getAuthentication('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** > AlbumCountResponseDto getAlbumCount() diff --git a/mobile/openapi/doc/AssetApi.md b/mobile/openapi/doc/AssetApi.md index ef3610b00..319deb2ac 100644 --- a/mobile/openapi/doc/AssetApi.md +++ b/mobile/openapi/doc/AssetApi.md @@ -13,9 +13,8 @@ Method | HTTP request | Description [**checkDuplicateAsset**](AssetApi.md#checkduplicateasset) | **POST** /asset/check | [**checkExistingAssets**](AssetApi.md#checkexistingassets) | **POST** /asset/exist | [**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 | [**getArchivedAssetCountByUserId**](AssetApi.md#getarchivedassetcountbyuserid) | **GET** /asset/stat/archive | [**getAssetById**](AssetApi.md#getassetbyid) | **GET** /asset/assetById/{id} | @@ -26,6 +25,7 @@ Method | HTTP request | Description [**getAssetThumbnail**](AssetApi.md#getassetthumbnail) | **GET** /asset/thumbnail/{id} | [**getCuratedLocations**](AssetApi.md#getcuratedlocations) | **GET** /asset/curated-locations | [**getCuratedObjects**](AssetApi.md#getcuratedobjects) | **GET** /asset/curated-objects | +[**getDownloadInfo**](AssetApi.md#getdownloadinfo) | **GET** /asset/download | [**getMapMarkers**](AssetApi.md#getmapmarkers) | **GET** /asset/map-marker | [**getMemoryLane**](AssetApi.md#getmemorylane) | **GET** /asset/memory-lane | [**getUserAssetsByDeviceId**](AssetApi.md#getuserassetsbydeviceid) | **GET** /asset/{deviceId} | @@ -264,6 +264,63 @@ 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) +# **downloadArchive** +> MultipartFile downloadArchive(assetIdsDto, key) + + + +### Example +```dart +import 'package:openapi/api.dart'; +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure API key authorization: api_key +//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); + +final api_instance = AssetApi(); +final assetIdsDto = AssetIdsDto(); // AssetIdsDto | +final key = key_example; // String | + +try { + final result = api_instance.downloadArchive(assetIdsDto, key); + print(result); +} catch (e) { + print('Exception when calling AssetApi->downloadArchive: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **assetIdsDto** | [**AssetIdsDto**](AssetIdsDto.md)| | + **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**: application/json + - **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) + # **downloadFile** > MultipartFile downloadFile(id, key) @@ -321,124 +378,6 @@ 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) -# **downloadFiles** -> MultipartFile downloadFiles(downloadFilesDto, key) - - - -### Example -```dart -import 'package:openapi/api.dart'; -// TODO Configure API key authorization: cookie -//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; -// uncomment below to setup prefix (e.g. Bearer) for API key, if needed -//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; -// TODO Configure API key authorization: api_key -//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; -// uncomment below to setup prefix (e.g. Bearer) for API key, if needed -//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; -// TODO Configure HTTP Bearer authorization: bearer -// Case 1. Use String Token -//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); -// Case 2. Use Function which generate token. -// String yourTokenGeneratorFunction() { ... } -//defaultApiClient.getAuthentication('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)| | - **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**: application/json - - **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) - -# **downloadLibrary** -> MultipartFile downloadLibrary(name, skip, key) - - - -Current this is not used in any UI element - -### Example -```dart -import 'package:openapi/api.dart'; -// TODO Configure API key authorization: cookie -//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; -// uncomment below to setup prefix (e.g. Bearer) for API key, if needed -//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; -// TODO Configure API key authorization: api_key -//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; -// uncomment below to setup prefix (e.g. Bearer) for API key, if needed -//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; -// TODO Configure HTTP Bearer authorization: bearer -// Case 1. Use String Token -//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); -// Case 2. Use Function which generate token. -// String yourTokenGeneratorFunction() { ... } -//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); - -final api_instance = AssetApi(); -final name = name_example; // String | -final skip = 8.14; // num | -final key = key_example; // String | - -try { - final result = api_instance.downloadLibrary(name, skip, key); - print(result); -} catch (e) { - print('Exception when calling AssetApi->downloadLibrary: $e\n'); -} -``` - -### Parameters - -Name | Type | Description | Notes -------------- | ------------- | ------------- | ------------- - **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/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) - # **getAllAssets** > List getAllAssets(userId, isFavorite, isArchived, withoutThumbs, skip, ifNoneMatch) @@ -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) +# **getDownloadInfo** +> DownloadResponseDto getDownloadInfo(assetIds, albumId, userId, archiveSize, key) + + + +### Example +```dart +import 'package:openapi/api.dart'; +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure API key authorization: api_key +//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); + +final api_instance = AssetApi(); +final assetIds = []; // List | +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.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** > List getMapMarkers(isFavorite, fileCreatedAfter, fileCreatedBefore) diff --git a/mobile/openapi/doc/DownloadFilesDto.md b/mobile/openapi/doc/DownloadArchiveInfo.md similarity index 86% rename from mobile/openapi/doc/DownloadFilesDto.md rename to mobile/openapi/doc/DownloadArchiveInfo.md index 6b44eef05..5ec8c668f 100644 --- a/mobile/openapi/doc/DownloadFilesDto.md +++ b/mobile/openapi/doc/DownloadArchiveInfo.md @@ -1,4 +1,4 @@ -# openapi.model.DownloadFilesDto +# openapi.model.DownloadArchiveInfo ## Load the model package ```dart @@ -8,6 +8,7 @@ import 'package:openapi/api.dart'; ## Properties Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- +**size** | **int** | | **assetIds** | **List** | | [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) diff --git a/mobile/openapi/doc/DownloadResponseDto.md b/mobile/openapi/doc/DownloadResponseDto.md new file mode 100644 index 000000000..2a7bbc9b1 --- /dev/null +++ b/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.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) + + diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 9363e99b1..47cfa9aa2 100644 --- a/mobile/openapi/lib/api.dart +++ b/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_response_dto.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/get_asset_by_time_bucket_dto.dart'; part 'model/get_asset_count_by_time_bucket_dto.dart'; diff --git a/mobile/openapi/lib/api/album_api.dart b/mobile/openapi/lib/api/album_api.dart index 37490881d..1f5bd7b58 100644 --- a/mobile/openapi/lib/api/album_api.dart +++ b/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 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 = []; - final headerParams = {}; - final formParams = {}; - - 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 = []; - - - 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 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]. Future getAlbumCountWithHttpInfo() async { // ignore: prefer_const_declarations diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index d8d03ca53..a73ec3b1e 100644 --- a/mobile/openapi/lib/api/asset_api.dart +++ b/mobile/openapi/lib/api/asset_api.dart @@ -230,7 +230,62 @@ class AssetApi { 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: + /// + /// * [AssetIdsDto] assetIdsDto (required): + /// + /// * [String] key: + Future downloadArchiveWithHttpInfo(AssetIdsDto assetIdsDto, { String? key, }) async { + // ignore: prefer_const_declarations + final path = r'/asset/download'; + + // ignore: prefer_final_locals + Object? postBody = assetIdsDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + if (key != null) { + queryParams.addAll(_queryParams('', 'key', key)); + } + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [AssetIdsDto] assetIdsDto (required): + /// + /// * [String] key: + Future downloadArchive(AssetIdsDto assetIdsDto, { String? key, }) async { + final response = await downloadArchiveWithHttpInfo(assetIdsDto, 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/{id}' operation and returns the [Response]. /// Parameters: /// /// * [String] id (required): @@ -257,7 +312,7 @@ class AssetApi { return apiClient.invokeAPI( path, - 'GET', + 'POST', queryParams, postBody, headerParams, @@ -286,131 +341,6 @@ class AssetApi { return null; } - /// Performs an HTTP 'POST /asset/download-files' operation and returns the [Response]. - /// Parameters: - /// - /// * [DownloadFilesDto] downloadFilesDto (required): - /// - /// * [String] key: - Future downloadFilesWithHttpInfo(DownloadFilesDto downloadFilesDto, { String? key, }) async { - // ignore: prefer_const_declarations - final path = r'/asset/download-files'; - - // ignore: prefer_final_locals - Object? postBody = downloadFilesDto; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - if (key != null) { - queryParams.addAll(_queryParams('', 'key', key)); - } - - const contentTypes = ['application/json']; - - - return apiClient.invokeAPI( - path, - 'POST', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// Parameters: - /// - /// * [DownloadFilesDto] downloadFilesDto (required): - /// - /// * [String] key: - Future downloadFiles(DownloadFilesDto downloadFilesDto, { String? key, }) async { - final response = await downloadFilesWithHttpInfo(downloadFilesDto, 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; - } - - /// Current this is not used in any UI element - /// - /// Note: This method returns the HTTP [Response]. - /// - /// Parameters: - /// - /// * [String] name: - /// - /// * [num] skip: - /// - /// * [String] key: - Future downloadLibraryWithHttpInfo({ String? name, num? skip, String? key, }) async { - // ignore: prefer_const_declarations - final path = r'/asset/download-library'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - 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 = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// Current this is not used in any UI element - /// - /// Parameters: - /// - /// * [String] name: - /// - /// * [num] skip: - /// - /// * [String] key: - Future downloadLibrary({ String? name, num? skip, String? key, }) async { - final response = await downloadLibraryWithHttpInfo( 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; - } - /// Get all AssetEntity belong to the user /// /// Note: This method returns the HTTP [Response]. @@ -945,6 +875,85 @@ class AssetApi { return null; } + /// Performs an HTTP 'GET /asset/download' operation and returns the [Response]. + /// Parameters: + /// + /// * [List] assetIds: + /// + /// * [String] albumId: + /// + /// * [String] userId: + /// + /// * [num] archiveSize: + /// + /// * [String] key: + Future getDownloadInfoWithHttpInfo({ List? 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 = []; + final headerParams = {}; + final formParams = {}; + + 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 = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [List] assetIds: + /// + /// * [String] albumId: + /// + /// * [String] userId: + /// + /// * [num] archiveSize: + /// + /// * [String] key: + Future getDownloadInfo({ List? 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]. /// Parameters: /// diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 9deee81b7..7ba532835 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -257,8 +257,10 @@ class ApiClient { return DeleteAssetResponseDto.fromJson(value); case 'DeleteAssetStatus': return DeleteAssetStatusTypeTransformer().decode(value); - case 'DownloadFilesDto': - return DownloadFilesDto.fromJson(value); + case 'DownloadArchiveInfo': + return DownloadArchiveInfo.fromJson(value); + case 'DownloadResponseDto': + return DownloadResponseDto.fromJson(value); case 'ExifResponseDto': return ExifResponseDto.fromJson(value); case 'GetAssetByTimeBucketDto': diff --git a/mobile/openapi/lib/model/download_files_dto.dart b/mobile/openapi/lib/model/download_archive_info.dart similarity index 59% rename from mobile/openapi/lib/model/download_files_dto.dart rename to mobile/openapi/lib/model/download_archive_info.dart index bd7c3537f..ff370f423 100644 --- a/mobile/openapi/lib/model/download_files_dto.dart +++ b/mobile/openapi/lib/model/download_archive_info.dart @@ -10,40 +10,47 @@ 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 [], }); + int size; + List assetIds; @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; @override int get hashCode => // ignore: unnecessary_parenthesis + (size.hashCode) + (assetIds.hashCode); @override - String toString() => 'DownloadFilesDto[assetIds=$assetIds]'; + String toString() => 'DownloadArchiveInfo[size=$size, assetIds=$assetIds]'; Map toJson() { final json = {}; + json[r'size'] = this.size; json[r'assetIds'] = this.assetIds; 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. // ignore: prefer_constructors_over_static_methods - static DownloadFilesDto? fromJson(dynamic value) { + static DownloadArchiveInfo? fromJson(dynamic value) { if (value is Map) { final json = value.cast(); - return DownloadFilesDto( + return DownloadArchiveInfo( + size: mapValueOfType(json, r'size')!, assetIds: json[r'assetIds'] is Iterable ? (json[r'assetIds'] as Iterable).cast().toList(growable: false) : const [], @@ -52,11 +59,11 @@ class DownloadFilesDto { return null; } - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = DownloadFilesDto.fromJson(row); + final value = DownloadArchiveInfo.fromJson(row); if (value != null) { result.add(value); } @@ -65,12 +72,12 @@ class DownloadFilesDto { return result.toList(growable: growable); } - static Map mapFromJson(dynamic json) { - final map = {}; + static Map mapFromJson(dynamic json) { + final map = {}; if (json is Map && json.isNotEmpty) { json = json.cast(); // ignore: parameter_assignments for (final entry in json.entries) { - final value = DownloadFilesDto.fromJson(entry.value); + final value = DownloadArchiveInfo.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -79,14 +86,14 @@ class DownloadFilesDto { return map; } - // maps a json object with a list of DownloadFilesDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; + // maps a json object with a list of DownloadArchiveInfo-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; if (json is Map && json.isNotEmpty) { // ignore: parameter_assignments json = json.cast(); for (final entry in json.entries) { - map[entry.key] = DownloadFilesDto.listFromJson(entry.value, growable: growable,); + map[entry.key] = DownloadArchiveInfo.listFromJson(entry.value, growable: growable,); } } return map; @@ -94,6 +101,7 @@ class DownloadFilesDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { + 'size', 'assetIds', }; } diff --git a/mobile/openapi/lib/model/download_response_dto.dart b/mobile/openapi/lib/model/download_response_dto.dart new file mode 100644 index 000000000..89269c71a --- /dev/null +++ b/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 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 toJson() { + final json = {}; + 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(); + + return DownloadResponseDto( + totalSize: mapValueOfType(json, r'totalSize')!, + archives: DownloadArchiveInfo.listFromJson(json[r'archives']), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = DownloadResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = 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> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = DownloadResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'totalSize', + 'archives', + }; +} + diff --git a/mobile/openapi/test/album_api_test.dart b/mobile/openapi/test/album_api_test.dart index 28c93deb4..5c2331fa9 100644 --- a/mobile/openapi/test/album_api_test.dart +++ b/mobile/openapi/test/album_api_test.dart @@ -37,11 +37,6 @@ void main() { // TODO }); - //Future downloadArchive(String id, { String name, num skip, String key }) async - test('test downloadArchive', () async { - // TODO - }); - //Future getAlbumCount() async test('test getAlbumCount', () async { // TODO diff --git a/mobile/openapi/test/asset_api_test.dart b/mobile/openapi/test/asset_api_test.dart index 1a2e510cf..1c5f08536 100644 --- a/mobile/openapi/test/asset_api_test.dart +++ b/mobile/openapi/test/asset_api_test.dart @@ -43,23 +43,16 @@ void main() { // TODO }); + //Future downloadArchive(AssetIdsDto assetIdsDto, { String key }) async + test('test downloadArchive', () async { + // TODO + }); + //Future downloadFile(String id, { String key }) async test('test downloadFile', () async { // TODO }); - //Future downloadFiles(DownloadFilesDto downloadFilesDto, { String key }) async - test('test downloadFiles', () async { - // TODO - }); - - // Current this is not used in any UI element - // - //Future downloadLibrary({ String name, num skip, String key }) async - test('test downloadLibrary', () async { - // TODO - }); - // Get all AssetEntity belong to the user // //Future> getAllAssets({ String userId, bool isFavorite, bool isArchived, bool withoutThumbs, num skip, String ifNoneMatch }) async @@ -114,6 +107,11 @@ void main() { // TODO }); + //Future getDownloadInfo({ List assetIds, String albumId, String userId, num archiveSize, String key }) async + test('test getDownloadInfo', () async { + // TODO + }); + //Future> getMapMarkers({ bool isFavorite, DateTime fileCreatedAfter, DateTime fileCreatedBefore }) async test('test getMapMarkers', () async { // TODO diff --git a/mobile/openapi/test/download_files_dto_test.dart b/mobile/openapi/test/download_archive_info_test.dart similarity index 70% rename from mobile/openapi/test/download_files_dto_test.dart rename to mobile/openapi/test/download_archive_info_test.dart index fcc46a6c3..35f29ef99 100644 --- a/mobile/openapi/test/download_files_dto_test.dart +++ b/mobile/openapi/test/download_archive_info_test.dart @@ -11,11 +11,16 @@ import 'package:openapi/api.dart'; import 'package:test/test.dart'; -// tests for DownloadFilesDto +// tests for DownloadArchiveInfo 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 assetIds (default value: const []) test('to test the property `assetIds`', () async { // TODO diff --git a/mobile/openapi/test/download_response_dto_test.dart b/mobile/openapi/test/download_response_dto_test.dart new file mode 100644 index 000000000..b823c1441 --- /dev/null +++ b/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 archives (default value: const []) + test('to test the property `archives`', () async { + // TODO + }); + + + }); + +} diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 07b8ad0fc..7717e0ab1 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -370,73 +370,6 @@ ] } }, - "/album/{id}/download": { - "get": { - "operationId": "downloadArchive", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "format": "uuid", - "type": "string" - } - }, - { - "name": "name", - "required": false, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "skip", - "required": false, - "in": "query", - "schema": { - "type": "number" - } - }, - { - "name": "key", - "required": false, - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/zip": { - "schema": { - "type": "string", - "format": "binary" - } - } - }, - "description": "" - } - }, - "tags": [ - "Album" - ], - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ] - } - }, "/album/{id}/user/{userId}": { "delete": { "operationId": "removeUserFromAlbum", @@ -1153,10 +1086,48 @@ ] } }, - "/asset/download-files": { - "post": { - "operationId": "downloadFiles", + "/asset/download": { + "get": { + "operationId": "getDownloadInfo", "parameters": [ + { + "name": "assetIds", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "albumId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "userId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "archiveSize", + "required": false, + "in": "query", + "schema": { + "type": "number" + } + }, { "name": "key", "required": false, @@ -1166,30 +1137,16 @@ } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DownloadFilesDto" - } - } - } - }, "responses": { "200": { + "description": "", "content": { - "application/octet-stream": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/DownloadResponseDto" } } - }, - "description": "" - }, - "201": { - "description": "" + } } }, "tags": [ @@ -1206,29 +1163,10 @@ "api_key": [] } ] - } - }, - "/asset/download-library": { - "get": { - "operationId": "downloadLibrary", - "description": "Current this is not used in any UI element", + }, + "post": { + "operationId": "downloadArchive", "parameters": [ - { - "name": "name", - "required": false, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "skip", - "required": false, - "in": "query", - "schema": { - "type": "number" - } - }, { "name": "key", "required": false, @@ -1238,6 +1176,16 @@ } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetIdsDto" + } + } + } + }, "responses": { "200": { "content": { @@ -1268,7 +1216,7 @@ } }, "/asset/download/{id}": { - "get": { + "post": { "operationId": "downloadFile", "parameters": [ { @@ -5341,11 +5289,13 @@ "FAILED" ] }, - "DownloadFilesDto": { + "DownloadArchiveInfo": { "type": "object", "properties": { + "size": { + "type": "integer" + }, "assetIds": { - "title": "Array of asset ids to be downloaded", "type": "array", "items": { "type": "string" @@ -5353,9 +5303,28 @@ } }, "required": [ + "size", "assetIds" ] }, + "DownloadResponseDto": { + "type": "object", + "properties": { + "totalSize": { + "type": "integer" + }, + "archives": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DownloadArchiveInfo" + } + } + }, + "required": [ + "totalSize", + "archives" + ] + }, "ExifResponseDto": { "type": "object", "properties": { diff --git a/server/src/domain/access/access.core.ts b/server/src/domain/access/access.core.ts index f730e1be9..e4a2ed447 100644 --- a/server/src/domain/access/access.core.ts +++ b/server/src/domain/access/access.core.ts @@ -16,6 +16,7 @@ export enum Permission { ALBUM_UPDATE = 'album.update', ALBUM_DELETE = 'album.delete', ALBUM_SHARE = 'album.share', + ALBUM_DOWNLOAD = 'album.download', LIBRARY_READ = 'library.read', LIBRARY_DOWNLOAD = 'library.download', @@ -68,6 +69,10 @@ export class AccessCore { // TODO: fix this to not use authUser.id for shared link access control return this.repository.asset.hasOwnerAccess(authUser.id, id); + case Permission.ALBUM_DOWNLOAD: { + return !!authUser.isAllowDownload && (await this.repository.album.hasSharedLinkAccess(sharedLinkId, id)); + } + // case Permission.ALBUM_READ: // return this.repository.album.hasSharedLinkAccess(sharedLinkId, id); @@ -122,6 +127,12 @@ export class AccessCore { case Permission.ALBUM_SHARE: return this.repository.album.hasOwnerAccess(authUser.id, id); + case Permission.ALBUM_DOWNLOAD: + return ( + (await this.repository.album.hasOwnerAccess(authUser.id, id)) || + (await this.repository.album.hasSharedAlbumAccess(authUser.id, id)) + ); + case Permission.LIBRARY_READ: return authUser.id === id || (await this.repository.library.hasPartnerAccess(authUser.id, id)); diff --git a/server/src/domain/asset/asset.repository.ts b/server/src/domain/asset/asset.repository.ts index 9479d3c12..9bd9c687a 100644 --- a/server/src/domain/asset/asset.repository.ts +++ b/server/src/domain/asset/asset.repository.ts @@ -44,6 +44,8 @@ export const IAssetRepository = 'IAssetRepository'; export interface IAssetRepository { getByDate(ownerId: string, date: Date): Promise; getByIds(ids: string[]): Promise; + getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated; + getByUserId(pagination: PaginationOptions, userId: string): Paginated; getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated; getWith(pagination: PaginationOptions, property: WithProperty): Paginated; getFirstAssetForAlbumId(albumId: string): Promise; diff --git a/server/src/domain/asset/asset.service.spec.ts b/server/src/domain/asset/asset.service.spec.ts index b6f253113..ed155c148 100644 --- a/server/src/domain/asset/asset.service.spec.ts +++ b/server/src/domain/asset/asset.service.spec.ts @@ -1,21 +1,48 @@ -import { assetEntityStub, authStub, newAssetRepositoryMock } from '@test'; +import { BadRequestException } from '@nestjs/common'; +import { + assetEntityStub, + authStub, + IAccessRepositoryMock, + newAccessRepositoryMock, + newAssetRepositoryMock, + newStorageRepositoryMock, +} from '@test'; import { when } from 'jest-when'; -import { AssetService, IAssetRepository, mapAsset } from '.'; +import { Readable } from 'stream'; +import { IStorageRepository } from '../storage'; +import { IAssetRepository } from './asset.repository'; +import { AssetService } from './asset.service'; +import { DownloadResponseDto } from './index'; +import { mapAsset } from './response-dto'; + +const downloadResponse: DownloadResponseDto = { + totalSize: 105_000, + archives: [ + { + assetIds: ['asset-id', 'asset-id'], + size: 105_000, + }, + ], +}; describe(AssetService.name, () => { let sut: AssetService; + let accessMock: IAccessRepositoryMock; let assetMock: jest.Mocked; + let storageMock: jest.Mocked; it('should work', () => { expect(sut).toBeDefined(); }); beforeEach(async () => { + accessMock = newAccessRepositoryMock(); assetMock = newAssetRepositoryMock(); - sut = new AssetService(assetMock); + storageMock = newStorageRepositoryMock(); + sut = new AssetService(accessMock, assetMock, storageMock); }); - describe('get map markers', () => { + describe('getMapMarkers', () => { it('should get geo information of assets', async () => { assetMock.getMapMarkers.mockResolvedValue( [assetEntityStub.withLocation].map((asset) => ({ @@ -76,25 +103,191 @@ describe(AssetService.name, () => { [authStub.admin.id, new Date('2021-06-15T05:00:00.000Z')], ]); }); + + it('should set the title correctly', async () => { + when(assetMock.getByDate) + .calledWith(authStub.admin.id, new Date('2022-06-15T00:00:00.000Z')) + .mockResolvedValue([assetEntityStub.image]); + when(assetMock.getByDate) + .calledWith(authStub.admin.id, new Date('2021-06-15T00:00:00.000Z')) + .mockResolvedValue([assetEntityStub.video]); + + await expect(sut.getMemoryLane(authStub.admin, { timestamp: new Date(2023, 5, 15), years: 2 })).resolves.toEqual([ + { title: '1 year since...', assets: [mapAsset(assetEntityStub.image)] }, + { title: '2 years since...', assets: [mapAsset(assetEntityStub.video)] }, + ]); + + expect(assetMock.getByDate).toHaveBeenCalledTimes(2); + expect(assetMock.getByDate.mock.calls).toEqual([ + [authStub.admin.id, new Date('2022-06-15T00:00:00.000Z')], + [authStub.admin.id, new Date('2021-06-15T00:00:00.000Z')], + ]); + }); }); - it('should set the title correctly', async () => { - when(assetMock.getByDate) - .calledWith(authStub.admin.id, new Date('2022-06-15T00:00:00.000Z')) - .mockResolvedValue([assetEntityStub.image]); - when(assetMock.getByDate) - .calledWith(authStub.admin.id, new Date('2021-06-15T00:00:00.000Z')) - .mockResolvedValue([assetEntityStub.video]); + describe('downloadFile', () => { + it('should require the asset.download permission', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(false); + accessMock.asset.hasAlbumAccess.mockResolvedValue(false); + accessMock.asset.hasPartnerAccess.mockResolvedValue(false); - await expect(sut.getMemoryLane(authStub.admin, { timestamp: new Date(2023, 5, 15), years: 2 })).resolves.toEqual([ - { title: '1 year since...', assets: [mapAsset(assetEntityStub.image)] }, - { title: '2 years since...', assets: [mapAsset(assetEntityStub.video)] }, - ]); + await expect(sut.downloadFile(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException); - expect(assetMock.getByDate).toHaveBeenCalledTimes(2); - expect(assetMock.getByDate.mock.calls).toEqual([ - [authStub.admin.id, new Date('2022-06-15T00:00:00.000Z')], - [authStub.admin.id, new Date('2021-06-15T00:00:00.000Z')], - ]); + expect(accessMock.asset.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'asset-1'); + expect(accessMock.asset.hasAlbumAccess).toHaveBeenCalledWith(authStub.admin.id, 'asset-1'); + expect(accessMock.asset.hasPartnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'asset-1'); + }); + + it('should throw an error if the asset is not found', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + assetMock.getByIds.mockResolvedValue([]); + + await expect(sut.downloadFile(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException); + + expect(assetMock.getByIds).toHaveBeenCalledWith(['asset-1']); + }); + + it('should download a file', async () => { + const stream = new Readable(); + + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + assetMock.getByIds.mockResolvedValue([assetEntityStub.image]); + storageMock.createReadStream.mockResolvedValue({ stream }); + + await expect(sut.downloadFile(authStub.admin, 'asset-1')).resolves.toEqual({ stream }); + + expect(storageMock.createReadStream).toHaveBeenCalledWith( + assetEntityStub.image.originalPath, + assetEntityStub.image.mimeType, + ); + }); + + it('should download an archive', async () => { + const archiveMock = { + addFile: jest.fn(), + finalize: jest.fn(), + stream: new Readable(), + }; + + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + assetMock.getByIds.mockResolvedValue([assetEntityStub.noResizePath, assetEntityStub.noWebpPath]); + storageMock.createZipStream.mockReturnValue(archiveMock); + + await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({ + stream: archiveMock.stream, + }); + + expect(archiveMock.addFile).toHaveBeenCalledTimes(2); + expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, 'upload/library/IMG_123.jpg', 'IMG_123.jpg'); + expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, 'upload/library/IMG_456.jpg', 'IMG_456.jpg'); + }); + + it('should handle duplicate file names', async () => { + const archiveMock = { + addFile: jest.fn(), + finalize: jest.fn(), + stream: new Readable(), + }; + + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + assetMock.getByIds.mockResolvedValue([assetEntityStub.noResizePath, assetEntityStub.noResizePath]); + storageMock.createZipStream.mockReturnValue(archiveMock); + + await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({ + stream: archiveMock.stream, + }); + + expect(archiveMock.addFile).toHaveBeenCalledTimes(2); + expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, 'upload/library/IMG_123.jpg', 'IMG_123.jpg'); + expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, 'upload/library/IMG_123.jpg', 'IMG_123+1.jpg'); + }); + }); + + describe('getDownloadInfo', () => { + it('should throw an error for an invalid dto', async () => { + await expect(sut.getDownloadInfo(authStub.admin, {})).rejects.toBeInstanceOf(BadRequestException); + }); + + it('should return a list of archives (assetIds)', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + assetMock.getByIds.mockResolvedValue([assetEntityStub.image, assetEntityStub.video]); + + const assetIds = ['asset-1', 'asset-2']; + await expect(sut.getDownloadInfo(authStub.admin, { assetIds })).resolves.toEqual(downloadResponse); + + expect(assetMock.getByIds).toHaveBeenCalledWith(['asset-1', 'asset-2']); + }); + + it('should return a list of archives (albumId)', async () => { + accessMock.album.hasOwnerAccess.mockResolvedValue(true); + assetMock.getByAlbumId.mockResolvedValue({ + items: [assetEntityStub.image, assetEntityStub.video], + hasNextPage: false, + }); + + await expect(sut.getDownloadInfo(authStub.admin, { albumId: 'album-1' })).resolves.toEqual(downloadResponse); + + expect(accessMock.album.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'album-1'); + expect(assetMock.getByAlbumId).toHaveBeenCalledWith({ take: 2500, skip: 0 }, 'album-1'); + }); + + it('should return a list of archives (userId)', async () => { + assetMock.getByUserId.mockResolvedValue({ + items: [assetEntityStub.image, assetEntityStub.video], + hasNextPage: false, + }); + + await expect(sut.getDownloadInfo(authStub.admin, { userId: authStub.admin.id })).resolves.toEqual( + downloadResponse, + ); + + expect(assetMock.getByUserId).toHaveBeenCalledWith({ take: 2500, skip: 0 }, authStub.admin.id); + }); + + it('should split archives by size', async () => { + assetMock.getByUserId.mockResolvedValue({ + items: [ + { ...assetEntityStub.image, id: 'asset-1' }, + { ...assetEntityStub.video, id: 'asset-2' }, + { ...assetEntityStub.withLocation, id: 'asset-3' }, + { ...assetEntityStub.noWebpPath, id: 'asset-4' }, + ], + hasNextPage: false, + }); + + await expect( + sut.getDownloadInfo(authStub.admin, { + userId: authStub.admin.id, + archiveSize: 30_000, + }), + ).resolves.toEqual({ + totalSize: 251_456, + archives: [ + { assetIds: ['asset-1', 'asset-2'], size: 105_000 }, + { assetIds: ['asset-3', 'asset-4'], size: 146_456 }, + ], + }); + }); + + it('should include the video portion of a live photo', async () => { + accessMock.asset.hasOwnerAccess.mockResolvedValue(true); + when(assetMock.getByIds) + .calledWith([assetEntityStub.livePhotoStillAsset.id]) + .mockResolvedValue([assetEntityStub.livePhotoStillAsset]); + when(assetMock.getByIds) + .calledWith([assetEntityStub.livePhotoMotionAsset.id]) + .mockResolvedValue([assetEntityStub.livePhotoMotionAsset]); + + const assetIds = [assetEntityStub.livePhotoStillAsset.id]; + await expect(sut.getDownloadInfo(authStub.admin, { assetIds })).resolves.toEqual({ + totalSize: 125_000, + archives: [ + { + assetIds: [assetEntityStub.livePhotoStillAsset.id, assetEntityStub.livePhotoMotionAsset.id], + size: 125_000, + }, + ], + }); + }); }); }); diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts index 230192e11..51d3afb8d 100644 --- a/server/src/domain/asset/asset.service.ts +++ b/server/src/domain/asset/asset.service.ts @@ -1,14 +1,27 @@ -import { Inject } from '@nestjs/common'; +import { BadRequestException, Inject } from '@nestjs/common'; import { DateTime } from 'luxon'; +import { extname } from 'path'; +import { AssetEntity } from '../../infra/entities/asset.entity'; import { AuthUserDto } from '../auth'; +import { HumanReadableSize, usePagination } from '../domain.util'; +import { AccessCore, IAccessRepository, Permission } from '../index'; +import { ImmichReadStream, IStorageRepository } from '../storage'; import { IAssetRepository } from './asset.repository'; -import { MemoryLaneDto } from './dto'; +import { AssetIdsDto, DownloadArchiveInfo, DownloadDto, DownloadResponseDto, MemoryLaneDto } from './dto'; import { MapMarkerDto } from './dto/map-marker.dto'; import { mapAsset, MapMarkerResponseDto } from './response-dto'; import { MemoryLaneResponseDto } from './response-dto/memory-lane-response.dto'; export class AssetService { - constructor(@Inject(IAssetRepository) private assetRepository: IAssetRepository) {} + private access: AccessCore; + + constructor( + @Inject(IAccessRepository) accessRepository: IAccessRepository, + @Inject(IAssetRepository) private assetRepository: IAssetRepository, + @Inject(IStorageRepository) private storageRepository: IStorageRepository, + ) { + this.access = new AccessCore(accessRepository); + } getMapMarkers(authUser: AuthUserDto, options: MapMarkerDto): Promise { return this.assetRepository.getMapMarkers(authUser.id, options); @@ -32,4 +45,102 @@ export class AssetService { return Promise.all(requests).then((results) => results.filter((result) => result.assets.length > 0)); } + + async downloadFile(authUser: AuthUserDto, id: string): Promise { + await this.access.requirePermission(authUser, Permission.ASSET_DOWNLOAD, id); + + const [asset] = await this.assetRepository.getByIds([id]); + if (!asset) { + throw new BadRequestException('Asset not found'); + } + + return this.storageRepository.createReadStream(asset.originalPath, asset.mimeType); + } + + async getDownloadInfo(authUser: AuthUserDto, dto: DownloadDto): Promise { + const targetSize = dto.archiveSize || HumanReadableSize.GiB * 4; + const archives: DownloadArchiveInfo[] = []; + let archive: DownloadArchiveInfo = { size: 0, assetIds: [] }; + + const assetPagination = await this.getDownloadAssets(authUser, dto); + for await (const assets of assetPagination) { + // motion part of live photos + const motionIds = assets.map((asset) => asset.livePhotoVideoId).filter((id): id is string => !!id); + if (motionIds.length > 0) { + assets.push(...(await this.assetRepository.getByIds(motionIds))); + } + + for (const asset of assets) { + archive.size += Number(asset.exifInfo?.fileSizeInByte || 0); + archive.assetIds.push(asset.id); + + if (archive.size > targetSize) { + archives.push(archive); + archive = { size: 0, assetIds: [] }; + } + } + + if (archive.assetIds.length > 0) { + archives.push(archive); + } + } + + return { + totalSize: archives.reduce((total, item) => (total += item.size), 0), + archives, + }; + } + + async downloadArchive(authUser: AuthUserDto, dto: AssetIdsDto): Promise { + await this.access.requirePermission(authUser, Permission.ASSET_DOWNLOAD, dto.assetIds); + + const zip = this.storageRepository.createZipStream(); + const assets = await this.assetRepository.getByIds(dto.assetIds); + const paths: Record = {}; + + for (const { originalPath, originalFileName } of assets) { + const ext = extname(originalPath); + let filename = `${originalFileName}${ext}`; + for (let i = 0; i < 10_000; i++) { + if (!paths[filename]) { + break; + } + filename = `${originalFileName}+${i + 1}${ext}`; + } + + paths[filename] = true; + zip.addFile(originalPath, filename); + } + + zip.finalize(); + + return { stream: zip.stream }; + } + + private async getDownloadAssets(authUser: AuthUserDto, dto: DownloadDto): Promise> { + const PAGINATION_SIZE = 2500; + + if (dto.assetIds) { + const assetIds = dto.assetIds; + await this.access.requirePermission(authUser, Permission.ASSET_DOWNLOAD, assetIds); + const assets = await this.assetRepository.getByIds(assetIds); + return (async function* () { + yield assets; + })(); + } + + if (dto.albumId) { + const albumId = dto.albumId; + await this.access.requirePermission(authUser, Permission.ALBUM_DOWNLOAD, albumId); + return usePagination(PAGINATION_SIZE, (pagination) => this.assetRepository.getByAlbumId(pagination, albumId)); + } + + if (dto.userId) { + const userId = dto.userId; + await this.access.requirePermission(authUser, Permission.LIBRARY_DOWNLOAD, userId); + return usePagination(PAGINATION_SIZE, (pagination) => this.assetRepository.getByUserId(pagination, userId)); + } + + throw new BadRequestException('assetIds, albumId, or userId is required'); + } } diff --git a/server/src/domain/asset/dto/download.dto.ts b/server/src/domain/asset/dto/download.dto.ts new file mode 100644 index 000000000..cb6b8f7dd --- /dev/null +++ b/server/src/domain/asset/dto/download.dto.ts @@ -0,0 +1,31 @@ +import { ValidateUUID } from '@app/immich/decorators/validate-uuid.decorator'; +import { ApiProperty } from '@nestjs/swagger'; +import { IsInt, IsOptional, IsPositive } from 'class-validator'; + +export class DownloadDto { + @ValidateUUID({ each: true, optional: true }) + assetIds?: string[]; + + @ValidateUUID({ optional: true }) + albumId?: string; + + @ValidateUUID({ optional: true }) + userId?: string; + + @IsInt() + @IsPositive() + @IsOptional() + archiveSize?: number; +} + +export class DownloadResponseDto { + @ApiProperty({ type: 'integer' }) + totalSize!: number; + archives!: DownloadArchiveInfo[]; +} + +export class DownloadArchiveInfo { + @ApiProperty({ type: 'integer' }) + size!: number; + assetIds!: string[]; +} diff --git a/server/src/domain/asset/dto/index.ts b/server/src/domain/asset/dto/index.ts index 130f28144..9778a9122 100644 --- a/server/src/domain/asset/dto/index.ts +++ b/server/src/domain/asset/dto/index.ts @@ -1,3 +1,4 @@ export * from './asset-ids.dto'; +export * from './download.dto'; export * from './map-marker.dto'; export * from './memory-lane.dto'; diff --git a/server/src/domain/storage/storage.repository.ts b/server/src/domain/storage/storage.repository.ts index 4ff1b5c01..7d312c075 100644 --- a/server/src/domain/storage/storage.repository.ts +++ b/server/src/domain/storage/storage.repository.ts @@ -1,9 +1,14 @@ -import { ReadStream } from 'fs'; +import { Readable } from 'stream'; export interface ImmichReadStream { - stream: ReadStream; - type: string; - length: number; + stream: Readable; + type?: string; + length?: number; +} + +export interface ImmichZipStream extends ImmichReadStream { + addFile: (inputPath: string, filename: string) => void; + finalize: () => Promise; } export interface DiskUsage { @@ -15,7 +20,8 @@ export interface DiskUsage { export const IStorageRepository = 'IStorageRepository'; export interface IStorageRepository { - createReadStream(filepath: string, mimeType: string): Promise; + createZipStream(): ImmichZipStream; + createReadStream(filepath: string, mimeType?: string | null): Promise; unlink(filepath: string): Promise; unlinkDir(folder: string, options?: { recursive?: boolean; force?: boolean }): Promise; removeEmptyDirs(folder: string): Promise; diff --git a/server/src/immich/api-v1/album/album.controller.ts b/server/src/immich/api-v1/album/album.controller.ts index 5349f5d65..021cc04ce 100644 --- a/server/src/immich/api-v1/album/album.controller.ts +++ b/server/src/immich/api-v1/album/album.controller.ts @@ -1,13 +1,10 @@ import { AlbumResponseDto } from '@app/domain'; -import { Body, Controller, Delete, Get, Param, Put, Query, Response } from '@nestjs/common'; -import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; -import { Response as Res } from 'express'; -import { handleDownload } from '../../app.utils'; +import { Body, Controller, Delete, Get, Param, Put } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; import { UUIDParamDto } from '../../controllers/dto/uuid-param.dto'; import { AuthUser, AuthUserDto } from '../../decorators/auth-user.decorator'; import { Authenticated, SharedLinkRoute } from '../../decorators/authenticated.decorator'; import { UseValidation } from '../../decorators/use-validation.decorator'; -import { DownloadDto } from '../asset/dto/download-library.dto'; import { AlbumService } from './album.service'; import { AddAssetsDto } from './dto/add-assets.dto'; import { RemoveAssetsDto } from './dto/remove-assets.dto'; @@ -18,7 +15,7 @@ import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto'; @Authenticated() @UseValidation() export class AlbumController { - constructor(private readonly service: AlbumService) {} + constructor(private service: AlbumService) {} @SharedLinkRoute() @Put(':id/assets') @@ -46,16 +43,4 @@ export class AlbumController { ): Promise { return this.service.removeAssets(authUser, id, dto); } - - @SharedLinkRoute() - @Get(':id/download') - @ApiOkResponse({ content: { 'application/zip': { schema: { type: 'string', format: 'binary' } } } }) - downloadArchive( - @AuthUser() authUser: AuthUserDto, - @Param() { id }: UUIDParamDto, - @Query() dto: DownloadDto, - @Response({ passthrough: true }) res: Res, - ) { - return this.service.downloadArchive(authUser, id, dto).then((download) => handleDownload(download, res)); - } } diff --git a/server/src/immich/api-v1/album/album.module.ts b/server/src/immich/api-v1/album/album.module.ts index 3b09fd6ea..e241f9635 100644 --- a/server/src/immich/api-v1/album/album.module.ts +++ b/server/src/immich/api-v1/album/album.module.ts @@ -1,13 +1,12 @@ import { AlbumEntity, AssetEntity } from '@app/infra/entities'; import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { DownloadModule } from '../../modules/download/download.module'; import { AlbumRepository, IAlbumRepository } from './album-repository'; import { AlbumController } from './album.controller'; import { AlbumService } from './album.service'; @Module({ - imports: [TypeOrmModule.forFeature([AlbumEntity, AssetEntity]), DownloadModule], + imports: [TypeOrmModule.forFeature([AlbumEntity, AssetEntity])], controllers: [AlbumController], providers: [AlbumService, { provide: IAlbumRepository, useClass: AlbumRepository }], }) diff --git a/server/src/immich/api-v1/album/album.service.spec.ts b/server/src/immich/api-v1/album/album.service.spec.ts index 77ccbb67a..1215e6990 100644 --- a/server/src/immich/api-v1/album/album.service.spec.ts +++ b/server/src/immich/api-v1/album/album.service.spec.ts @@ -3,7 +3,6 @@ import { AlbumEntity, UserEntity } from '@app/infra/entities'; import { ForbiddenException, NotFoundException } from '@nestjs/common'; import { userEntityStub } from '@test'; import { AuthUserDto } from '../../decorators/auth-user.decorator'; -import { DownloadService } from '../../modules/download/download.service'; import { IAlbumRepository } from './album-repository'; import { AlbumService } from './album.service'; import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto'; @@ -11,7 +10,6 @@ import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto'; describe('Album service', () => { let sut: AlbumService; let albumRepositoryMock: jest.Mocked; - let downloadServiceMock: jest.Mocked>; const authUser: AuthUserDto = Object.freeze({ id: '1111', @@ -98,11 +96,7 @@ describe('Album service', () => { updateThumbnails: jest.fn(), }; - downloadServiceMock = { - downloadArchive: jest.fn(), - }; - - sut = new AlbumService(albumRepositoryMock, downloadServiceMock as DownloadService); + sut = new AlbumService(albumRepositoryMock); }); it('gets an owned album', async () => { diff --git a/server/src/immich/api-v1/album/album.service.ts b/server/src/immich/api-v1/album/album.service.ts index 7e5e551e0..5f7fc834e 100644 --- a/server/src/immich/api-v1/album/album.service.ts +++ b/server/src/immich/api-v1/album/album.service.ts @@ -2,8 +2,6 @@ import { AlbumResponseDto, mapAlbum } from '@app/domain'; import { AlbumEntity } from '@app/infra/entities'; import { BadRequestException, ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { AuthUserDto } from '../../decorators/auth-user.decorator'; -import { DownloadService } from '../../modules/download/download.service'; -import { DownloadDto } from '../asset/dto/download-library.dto'; import { IAlbumRepository } from './album-repository'; import { AddAssetsDto } from './dto/add-assets.dto'; import { RemoveAssetsDto } from './dto/remove-assets.dto'; @@ -13,10 +11,7 @@ import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto'; export class AlbumService { private logger = new Logger(AlbumService.name); - constructor( - @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, - private downloadService: DownloadService, - ) {} + constructor(@Inject(IAlbumRepository) private repository: IAlbumRepository) {} private async _getAlbum({ authUser, @@ -27,9 +22,9 @@ export class AlbumService { albumId: string; validateIsOwner?: boolean; }): Promise { - await this.albumRepository.updateThumbnails(); + await this.repository.updateThumbnails(); - const album = await this.albumRepository.get(albumId); + const album = await this.repository.get(albumId); if (!album) { throw new NotFoundException('Album Not Found'); } @@ -50,7 +45,7 @@ export class AlbumService { async removeAssets(authUser: AuthUserDto, albumId: string, dto: RemoveAssetsDto): Promise { const album = await this._getAlbum({ authUser, albumId }); - const deletedCount = await this.albumRepository.removeAssets(album, dto); + const deletedCount = await this.repository.removeAssets(album, dto); const newAlbum = await this._getAlbum({ authUser, albumId }); if (deletedCount !== dto.assetIds.length) { @@ -67,7 +62,7 @@ export class AlbumService { } const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false }); - const result = await this.albumRepository.addAssets(album, dto); + const result = await this.repository.addAssets(album, dto); const newAlbum = await this._getAlbum({ authUser, albumId, validateIsOwner: false }); return { @@ -75,19 +70,4 @@ export class AlbumService { album: mapAlbum(newAlbum), }; } - - async downloadArchive(authUser: AuthUserDto, albumId: string, dto: DownloadDto) { - this.checkDownloadAccess(authUser); - - const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false }); - const assets = (album.assets || []).map((asset) => asset).slice(dto.skip || 0); - - return this.downloadService.downloadArchive(album.albumName, assets); - } - - private checkDownloadAccess(authUser: AuthUserDto) { - if (authUser.isPublicUser && !authUser.isAllowDownload) { - throw new ForbiddenException(); - } - } } diff --git a/server/src/immich/api-v1/asset/asset.controller.ts b/server/src/immich/api-v1/asset/asset.controller.ts index e7cc8a4b1..53e323a0b 100644 --- a/server/src/immich/api-v1/asset/asset.controller.ts +++ b/server/src/immich/api-v1/asset/asset.controller.ts @@ -1,4 +1,4 @@ -import { AssetResponseDto, ImmichReadStream } from '@app/domain'; +import { AssetResponseDto } from '@app/domain'; import { Body, Controller, @@ -14,7 +14,6 @@ import { Put, Query, Response, - StreamableFile, UploadedFiles, UseInterceptors, ValidationPipe, @@ -22,7 +21,6 @@ import { import { FileFieldsInterceptor } from '@nestjs/platform-express'; import { ApiBody, ApiConsumes, ApiHeader, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { Response as Res } from 'express'; -import { handleDownload } from '../../app.utils'; import { assetUploadOption, ImmichFile } from '../../config/asset-upload.config'; import { UUIDParamDto } from '../../controllers/dto/uuid-param.dto'; import { AuthUser, AuthUserDto } from '../../decorators/auth-user.decorator'; @@ -36,8 +34,6 @@ import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto'; import { CreateAssetDto, ImportAssetDto, mapToUploadFile } from './dto/create-asset.dto'; import { DeleteAssetDto } from './dto/delete-asset.dto'; import { DeviceIdDto } from './dto/device-id.dto'; -import { DownloadFilesDto } from './dto/download-files.dto'; -import { DownloadDto } from './dto/download-library.dto'; import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto'; import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto'; import { GetAssetThumbnailDto } from './dto/get-asset-thumbnail.dto'; @@ -54,10 +50,6 @@ import { CuratedLocationsResponseDto } from './response-dto/curated-locations-re import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto'; import { DeleteAssetResponseDto } from './response-dto/delete-asset-response.dto'; -function asStreamableFile({ stream, type, length }: ImmichReadStream) { - return new StreamableFile(stream, { type, length }); -} - interface UploadFiles { assetData: ImmichFile[]; livePhotoData?: ImmichFile[]; @@ -128,38 +120,6 @@ export class AssetController { return responseDto; } - @SharedLinkRoute() - @Get('/download/:id') - @ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } }) - downloadFile(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto) { - return this.assetService.downloadFile(authUser, id).then(asStreamableFile); - } - - @SharedLinkRoute() - @Post('/download-files') - @ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } }) - downloadFiles( - @AuthUser() authUser: AuthUserDto, - @Response({ passthrough: true }) res: Res, - @Body(new ValidationPipe()) dto: DownloadFilesDto, - ) { - return this.assetService.downloadFiles(authUser, dto).then((download) => handleDownload(download, res)); - } - - /** - * Current this is not used in any UI element - */ - @SharedLinkRoute() - @Get('/download-library') - @ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } }) - downloadLibrary( - @AuthUser() authUser: AuthUserDto, - @Query(new ValidationPipe({ transform: true })) dto: DownloadDto, - @Response({ passthrough: true }) res: Res, - ) { - return this.assetService.downloadLibrary(authUser, dto).then((download) => handleDownload(download, res)); - } - @SharedLinkRoute() @Get('/file/:id') @Header('Cache-Control', 'private, max-age=86400, no-transform') diff --git a/server/src/immich/api-v1/asset/asset.module.ts b/server/src/immich/api-v1/asset/asset.module.ts index 1f633d955..2d9cdd4fe 100644 --- a/server/src/immich/api-v1/asset/asset.module.ts +++ b/server/src/immich/api-v1/asset/asset.module.ts @@ -1,17 +1,12 @@ import { AssetEntity, ExifEntity } from '@app/infra/entities'; import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { DownloadModule } from '../../modules/download/download.module'; import { AssetRepository, IAssetRepository } from './asset-repository'; import { AssetController } from './asset.controller'; import { AssetService } from './asset.service'; @Module({ - imports: [ - // - TypeOrmModule.forFeature([AssetEntity, ExifEntity]), - DownloadModule, - ], + imports: [TypeOrmModule.forFeature([AssetEntity, ExifEntity])], controllers: [AssetController], providers: [AssetService, { provide: IAssetRepository, useClass: AssetRepository }], }) diff --git a/server/src/immich/api-v1/asset/asset.service.spec.ts b/server/src/immich/api-v1/asset/asset.service.spec.ts index 5963aa0a6..de236ca5f 100644 --- a/server/src/immich/api-v1/asset/asset.service.spec.ts +++ b/server/src/immich/api-v1/asset/asset.service.spec.ts @@ -13,7 +13,6 @@ import { } from '@test'; import { when } from 'jest-when'; import { QueryFailedError, Repository } from 'typeorm'; -import { DownloadService } from '../../modules/download/download.service'; import { IAssetRepository } from './asset-repository'; import { AssetService } from './asset.service'; import { CreateAssetDto } from './dto/create-asset.dto'; @@ -124,7 +123,6 @@ describe('AssetService', () => { let accessMock: IAccessRepositoryMock; let assetRepositoryMock: jest.Mocked; let cryptoMock: jest.Mocked; - let downloadServiceMock: jest.Mocked>; let jobMock: jest.Mocked; let storageMock: jest.Mocked; @@ -152,24 +150,12 @@ describe('AssetService', () => { cryptoMock = newCryptoRepositoryMock(); - downloadServiceMock = { - downloadArchive: jest.fn(), - }; - accessMock = newAccessRepositoryMock(); cryptoMock = newCryptoRepositoryMock(); jobMock = newJobRepositoryMock(); storageMock = newStorageRepositoryMock(); - sut = new AssetService( - accessMock, - assetRepositoryMock, - a, - cryptoMock, - downloadServiceMock as DownloadService, - jobMock, - storageMock, - ); + sut = new AssetService(accessMock, assetRepositoryMock, a, cryptoMock, jobMock, storageMock); when(assetRepositoryMock.get) .calledWith(assetEntityStub.livePhotoStillAsset.id) @@ -398,27 +384,6 @@ describe('AssetService', () => { }); }); - // describe('checkDownloadAccess', () => { - // it('should validate download access', async () => { - // await sut.checkDownloadAccess(authStub.adminSharedLink); - // }); - - // it('should not allow when user is not allowed to download', async () => { - // expect(() => sut.checkDownloadAccess(authStub.readonlySharedLink)).toThrow(ForbiddenException); - // }); - // }); - - describe('downloadFile', () => { - it('should download a single file', async () => { - accessMock.asset.hasOwnerAccess.mockResolvedValue(true); - assetRepositoryMock.get.mockResolvedValue(_getAsset_1()); - - await sut.downloadFile(authStub.admin, 'id_1'); - - expect(storageMock.createReadStream).toHaveBeenCalledWith('fake_path/asset_1.jpeg', 'image/jpeg'); - }); - }); - describe('bulkUploadCheck', () => { it('should accept hex and base64 checksums', async () => { const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex'); diff --git a/server/src/immich/api-v1/asset/asset.service.ts b/server/src/immich/api-v1/asset/asset.service.ts index 53335ceaf..1b1dd00ee 100644 --- a/server/src/immich/api-v1/asset/asset.service.ts +++ b/server/src/immich/api-v1/asset/asset.service.ts @@ -6,7 +6,6 @@ import { IAccessRepository, ICryptoRepository, IJobRepository, - ImmichReadStream, isSupportedFileType, IStorageRepository, JobName, @@ -33,7 +32,6 @@ import mime from 'mime-types'; import path from 'path'; import { QueryFailedError, Repository } from 'typeorm'; import { promisify } from 'util'; -import { DownloadService } from '../../modules/download/download.service'; import { IAssetRepository } from './asset-repository'; import { AssetCore } from './asset.core'; import { AssetBulkUploadCheckDto } from './dto/asset-check.dto'; @@ -42,8 +40,6 @@ import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto'; import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto'; import { CreateAssetDto, ImportAssetDto, UploadFile } from './dto/create-asset.dto'; import { DeleteAssetDto } from './dto/delete-asset.dto'; -import { DownloadFilesDto } from './dto/download-files.dto'; -import { DownloadDto } from './dto/download-library.dto'; import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto'; import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto'; import { GetAssetThumbnailDto, GetAssetThumbnailFormatEnum } from './dto/get-asset-thumbnail.dto'; @@ -86,7 +82,6 @@ export class AssetService { @Inject(IAssetRepository) private _assetRepository: IAssetRepository, @InjectRepository(AssetEntity) private assetRepository: Repository, @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, - private downloadService: DownloadService, @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, ) { @@ -250,50 +245,6 @@ export class AssetService { return mapAsset(updatedAsset); } - public async downloadLibrary(authUser: AuthUserDto, dto: DownloadDto) { - await this.access.requirePermission(authUser, Permission.LIBRARY_DOWNLOAD, authUser.id); - - const assets = await this._assetRepository.getAllByUserId(authUser.id, dto); - - return this.downloadService.downloadArchive(dto.name || `library`, assets); - } - - public async downloadFiles(authUser: AuthUserDto, dto: DownloadFilesDto) { - await this.access.requirePermission(authUser, Permission.ASSET_DOWNLOAD, dto.assetIds); - - const assetToDownload = []; - - for (const assetId of dto.assetIds) { - const asset = await this._assetRepository.getById(assetId); - assetToDownload.push(asset); - - // Get live photo asset - if (asset.livePhotoVideoId) { - const livePhotoAsset = await this._assetRepository.getById(asset.livePhotoVideoId); - assetToDownload.push(livePhotoAsset); - } - } - - const now = new Date().toISOString(); - return this.downloadService.downloadArchive(`immich-${now}`, assetToDownload); - } - - public async downloadFile(authUser: AuthUserDto, assetId: string): Promise { - await this.access.requirePermission(authUser, Permission.ASSET_DOWNLOAD, assetId); - - try { - const asset = await this._assetRepository.get(assetId); - if (asset && asset.originalPath && asset.mimeType) { - return this.storageRepository.createReadStream(asset.originalPath, asset.mimeType); - } - } catch (e) { - Logger.error(`Error download asset ${e}`, 'downloadFile'); - throw new InternalServerErrorException(`Failed to download asset ${e}`, 'DownloadFile'); - } - - throw new NotFoundException(); - } - async getAssetThumbnail( authUser: AuthUserDto, assetId: string, diff --git a/server/src/immich/api-v1/asset/dto/download-files.dto.ts b/server/src/immich/api-v1/asset/dto/download-files.dto.ts deleted file mode 100644 index 557db73d5..000000000 --- a/server/src/immich/api-v1/asset/dto/download-files.dto.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty } from 'class-validator'; - -export class DownloadFilesDto { - @IsNotEmpty() - @ApiProperty({ - isArray: true, - type: String, - title: 'Array of asset ids to be downloaded', - }) - assetIds!: string[]; -} diff --git a/server/src/immich/api-v1/asset/dto/download-library.dto.ts b/server/src/immich/api-v1/asset/dto/download-library.dto.ts deleted file mode 100644 index 7e1dfd12d..000000000 --- a/server/src/immich/api-v1/asset/dto/download-library.dto.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Type } from 'class-transformer'; -import { IsNumber, IsOptional, IsPositive, IsString } from 'class-validator'; - -export class DownloadDto { - @IsOptional() - @IsString() - name?: string; - - @IsOptional() - @IsPositive() - @IsNumber() - @Type(() => Number) - skip?: number; -} diff --git a/server/src/immich/app.utils.ts b/server/src/immich/app.utils.ts index ff130bf75..8355964a8 100644 --- a/server/src/immich/app.utils.ts +++ b/server/src/immich/app.utils.ts @@ -1,5 +1,11 @@ -import { IMMICH_ACCESS_COOKIE, IMMICH_API_KEY_HEADER, IMMICH_API_KEY_NAME, SERVER_VERSION } from '@app/domain'; -import { INestApplication } from '@nestjs/common'; +import { + ImmichReadStream, + IMMICH_ACCESS_COOKIE, + IMMICH_API_KEY_HEADER, + IMMICH_API_KEY_NAME, + SERVER_VERSION, +} from '@app/domain'; +import { INestApplication, StreamableFile } from '@nestjs/common'; import { DocumentBuilder, OpenAPIObject, @@ -7,18 +13,12 @@ import { SwaggerDocumentOptions, SwaggerModule, } from '@nestjs/swagger'; -import { Response } from 'express'; import { writeFileSync } from 'fs'; import path from 'path'; import { Metadata } from './decorators/authenticated.decorator'; -import { DownloadArchive } from './modules/download/download.service'; -export const handleDownload = (download: DownloadArchive, res: Response) => { - res.attachment(download.fileName); - res.setHeader('X-Immich-Content-Length-Hint', download.fileSize); - res.setHeader('X-Immich-Archive-File-Count', download.fileCount); - res.setHeader('X-Immich-Archive-Complete', `${download.complete}`); - return download.stream; +export const asStreamableFile = ({ stream, type, length }: ImmichReadStream) => { + return new StreamableFile(stream, { type, length }); }; function sortKeys(obj: T): T { diff --git a/server/src/immich/controllers/asset.controller.ts b/server/src/immich/controllers/asset.controller.ts index c6dbd2218..dfea0d5a7 100644 --- a/server/src/immich/controllers/asset.controller.ts +++ b/server/src/immich/controllers/asset.controller.ts @@ -1,11 +1,21 @@ -import { AssetService, AuthUserDto, MapMarkerResponseDto, MemoryLaneDto } from '@app/domain'; +import { + AssetIdsDto, + AssetService, + AuthUserDto, + DownloadDto, + DownloadResponseDto, + MapMarkerResponseDto, + MemoryLaneDto, +} from '@app/domain'; import { MapMarkerDto } from '@app/domain/asset/dto/map-marker.dto'; import { MemoryLaneResponseDto } from '@app/domain/asset/response-dto/memory-lane-response.dto'; -import { Controller, Get, Query } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; +import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Query, StreamableFile } from '@nestjs/common'; +import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import { asStreamableFile } from '../app.utils'; import { AuthUser } from '../decorators/auth-user.decorator'; -import { Authenticated } from '../decorators/authenticated.decorator'; +import { Authenticated, SharedLinkRoute } from '../decorators/authenticated.decorator'; import { UseValidation } from '../decorators/use-validation.decorator'; +import { UUIDParamDto } from './dto/uuid-param.dto'; @ApiTags('Asset') @Controller('asset') @@ -23,4 +33,26 @@ export class AssetController { getMemoryLane(@AuthUser() authUser: AuthUserDto, @Query() dto: MemoryLaneDto): Promise { return this.service.getMemoryLane(authUser, dto); } + + @SharedLinkRoute() + @Get('download') + getDownloadInfo(@AuthUser() authUser: AuthUserDto, @Query() dto: DownloadDto): Promise { + return this.service.getDownloadInfo(authUser, dto); + } + + @SharedLinkRoute() + @Post('download') + @HttpCode(HttpStatus.OK) + @ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } }) + downloadArchive(@AuthUser() authUser: AuthUserDto, @Body() dto: AssetIdsDto): Promise { + return this.service.downloadArchive(authUser, dto).then(asStreamableFile); + } + + @SharedLinkRoute() + @Post('download/:id') + @HttpCode(HttpStatus.OK) + @ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } }) + downloadFile(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto) { + return this.service.downloadFile(authUser, id).then(asStreamableFile); + } } diff --git a/server/src/immich/modules/download/download.module.ts b/server/src/immich/modules/download/download.module.ts deleted file mode 100644 index 354982cc6..000000000 --- a/server/src/immich/modules/download/download.module.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Module } from '@nestjs/common'; -import { DownloadService } from './download.service'; - -@Module({ - providers: [DownloadService], - exports: [DownloadService], -}) -export class DownloadModule {} diff --git a/server/src/immich/modules/download/download.service.ts b/server/src/immich/modules/download/download.service.ts deleted file mode 100644 index 65a460278..000000000 --- a/server/src/immich/modules/download/download.service.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { asHumanReadable, HumanReadableSize } from '@app/domain'; -import { AssetEntity } from '@app/infra/entities'; -import { BadRequestException, Injectable, InternalServerErrorException, Logger, StreamableFile } from '@nestjs/common'; -import archiver from 'archiver'; -import { extname } from 'path'; - -export interface DownloadArchive { - stream: StreamableFile; - fileName: string; - fileSize: number; - fileCount: number; - complete: boolean; -} - -@Injectable() -export class DownloadService { - private readonly logger = new Logger(DownloadService.name); - - public async downloadArchive(name: string, assets: AssetEntity[]): Promise { - if (!assets || assets.length === 0) { - throw new BadRequestException('No assets to download.'); - } - - try { - const archive = archiver('zip', { store: true }); - const stream = new StreamableFile(archive); - let totalSize = 0; - let fileCount = 0; - let complete = true; - - for (const { originalPath, exifInfo, originalFileName } of assets) { - const name = `${originalFileName}${extname(originalPath)}`; - archive.file(originalPath, { name }); - totalSize += Number(exifInfo?.fileSizeInByte || 0); - fileCount++; - - // for easier testing, can be changed before merging. - if (totalSize > HumanReadableSize.GiB * 20) { - complete = false; - this.logger.log( - `Archive size exceeded after ${fileCount} files, capping at ${totalSize} bytes (${asHumanReadable( - totalSize, - )})`, - ); - break; - } - } - - archive.finalize(); - - return { - stream, - fileName: `${name}.zip`, - fileSize: totalSize, - fileCount, - complete, - }; - } catch (error) { - this.logger.error(`Error creating download archive ${error}`); - throw new InternalServerErrorException(`Failed to download ${name}: ${error}`, 'DownloadArchive'); - } - } -} diff --git a/server/src/infra/repositories/access.repository.ts b/server/src/infra/repositories/access.repository.ts index d1d986e72..fe518807e 100644 --- a/server/src/infra/repositories/access.repository.ts +++ b/server/src/infra/repositories/access.repository.ts @@ -140,7 +140,9 @@ export class AccessRepository implements IAccessRepository { return this.albumRepository.exist({ where: { id: albumId, - ownerId: userId, + sharedUsers: { + id: userId, + }, }, }); }, diff --git a/server/src/infra/repositories/asset.repository.ts b/server/src/infra/repositories/asset.repository.ts index 1139dbf11..a23787252 100644 --- a/server/src/infra/repositories/asset.repository.ts +++ b/server/src/infra/repositories/asset.repository.ts @@ -72,6 +72,32 @@ export class AssetRepository implements IAssetRepository { await this.repository.delete({ ownerId }); } + getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated { + return paginate(this.repository, pagination, { + where: { + albums: { + id: albumId, + }, + }, + relations: { + albums: true, + exifInfo: true, + }, + }); + } + + getByUserId(pagination: PaginationOptions, userId: string): Paginated { + return paginate(this.repository, pagination, { + where: { + ownerId: userId, + isVisible: true, + }, + relations: { + exifInfo: true, + }, + }); + } + getAll(pagination: PaginationOptions, options: AssetSearchOptions = {}): Paginated { return paginate(this.repository, pagination, { where: { diff --git a/server/src/infra/repositories/filesystem.provider.ts b/server/src/infra/repositories/filesystem.provider.ts index dcb151b4d..d82e776c8 100644 --- a/server/src/infra/repositories/filesystem.provider.ts +++ b/server/src/infra/repositories/filesystem.provider.ts @@ -1,4 +1,5 @@ -import { DiskUsage, ImmichReadStream, IStorageRepository } from '@app/domain'; +import { DiskUsage, ImmichReadStream, ImmichZipStream, IStorageRepository } from '@app/domain'; +import archiver from 'archiver'; import { constants, createReadStream, existsSync, mkdirSync } from 'fs'; import fs from 'fs/promises'; import mv from 'mv'; @@ -8,13 +9,25 @@ import path from 'path'; const moveFile = promisify(mv); export class FilesystemProvider implements IStorageRepository { - async createReadStream(filepath: string, mimeType: string): Promise { + createZipStream(): ImmichZipStream { + const archive = archiver('zip', { store: true }); + + const addFile = (input: string, filename: string) => { + archive.file(input, { name: filename }); + }; + + const finalize = () => archive.finalize(); + + return { stream: archive, addFile, finalize }; + } + + async createReadStream(filepath: string, mimeType?: string | null): Promise { const { size } = await fs.stat(filepath); await fs.access(filepath, constants.R_OK | constants.W_OK); return { stream: createReadStream(filepath), length: size, - type: mimeType, + type: mimeType || undefined, }; } diff --git a/server/test/fixtures.ts b/server/test/fixtures.ts index 970f15282..f8dc7a758 100644 --- a/server/test/fixtures.ts +++ b/server/test/fixtures.ts @@ -203,14 +203,14 @@ export const fileStub = { export const assetEntityStub = { noResizePath: Object.freeze({ id: 'asset-id', - originalFileName: 'asset_1.jpeg', + originalFileName: 'IMG_123', deviceAssetId: 'device-asset-id', fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), owner: userEntityStub.user1, ownerId: 'user-id', deviceId: 'device-id', - originalPath: 'upload/upload/path.ext', + originalPath: 'upload/library/IMG_123.jpg', resizePath: null, checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, @@ -240,7 +240,7 @@ export const assetEntityStub = { owner: userEntityStub.user1, ownerId: 'user-id', deviceId: 'device-id', - originalPath: '/original/path.ext', + originalPath: 'upload/library/IMG_456.jpg', resizePath: '/uploads/user-id/thumbs/path.ext', checksum: Buffer.from('file hash', 'utf8'), type: AssetType.IMAGE, @@ -258,10 +258,13 @@ export const assetEntityStub = { livePhotoVideoId: null, tags: [], sharedLinks: [], - originalFileName: 'asset-id.ext', + originalFileName: 'IMG_456', faces: [], sidecarPath: null, isReadOnly: false, + exifInfo: { + fileSizeInByte: 123_000, + } as ExifEntity, }), noThumbhash: Object.freeze({ id: 'asset-id', @@ -324,6 +327,9 @@ export const assetEntityStub = { originalFileName: 'asset-id.ext', faces: [], sidecarPath: null, + exifInfo: { + fileSizeInByte: 5_000, + } as ExifEntity, }), video: Object.freeze({ id: 'asset-id', @@ -355,6 +361,9 @@ export const assetEntityStub = { sharedLinks: [], faces: [], sidecarPath: null, + exifInfo: { + fileSizeInByte: 100_000, + } as ExifEntity, }), livePhotoMotionAsset: Object.freeze({ id: 'live-photo-motion-asset', @@ -364,6 +373,9 @@ export const assetEntityStub = { isVisible: false, fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'), fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), + exifInfo: { + fileSizeInByte: 100_000, + }, } as AssetEntity), livePhotoStillAsset: Object.freeze({ @@ -375,6 +387,9 @@ export const assetEntityStub = { isVisible: true, fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'), fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), + exifInfo: { + fileSizeInByte: 25_000, + }, } as AssetEntity), withLocation: Object.freeze({ @@ -410,6 +425,7 @@ export const assetEntityStub = { exifInfo: { latitude: 100, longitude: 100, + fileSizeInByte: 23_456, } as ExifEntity, }), sidecar: Object.freeze({ diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index 51dbb3a27..7e8a52262 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -4,6 +4,8 @@ export const newAssetRepositoryMock = (): jest.Mocked => { return { getByDate: jest.fn(), getByIds: jest.fn().mockResolvedValue([]), + getByAlbumId: jest.fn(), + getByUserId: jest.fn(), getWithout: jest.fn(), getWith: jest.fn(), getFirstAssetForAlbumId: jest.fn(), diff --git a/server/test/repositories/storage.repository.mock.ts b/server/test/repositories/storage.repository.mock.ts index 21b289f93..08556a081 100644 --- a/server/test/repositories/storage.repository.mock.ts +++ b/server/test/repositories/storage.repository.mock.ts @@ -2,6 +2,7 @@ import { IStorageRepository } from '@app/domain'; export const newStorageRepositoryMock = (): jest.Mocked => { return { + createZipStream: jest.fn(), createReadStream: jest.fn(), unlink: jest.fn(), unlinkDir: jest.fn().mockResolvedValue(true), diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index e3b035b69..1393f5c20 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -1111,16 +1111,41 @@ export type DeleteAssetStatus = typeof DeleteAssetStatus[keyof typeof DeleteAsse /** * * @export - * @interface DownloadFilesDto + * @interface DownloadArchiveInfo */ -export interface DownloadFilesDto { +export interface DownloadArchiveInfo { + /** + * + * @type {number} + * @memberof DownloadArchiveInfo + */ + 'size': number; /** * * @type {Array} - * @memberof DownloadFilesDto + * @memberof DownloadArchiveInfo */ 'assetIds': Array; } +/** + * + * @export + * @interface DownloadResponseDto + */ +export interface DownloadResponseDto { + /** + * + * @type {number} + * @memberof DownloadResponseDto + */ + 'totalSize': number; + /** + * + * @type {Array} + * @memberof DownloadResponseDto + */ + 'archives': Array; +} /** * * @export @@ -3645,63 +3670,6 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, - /** - * - * @param {string} id - * @param {string} [name] - * @param {number} [skip] - * @param {string} [key] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - downloadArchive: async (id: string, name?: string, skip?: number, key?: string, options: AxiosRequestConfig = {}): Promise => { - // verify required parameter 'id' is not null or undefined - assertParamExists('downloadArchive', 'id', id) - const localVarPath = `/album/{id}/download` - .replace(`{${"id"}}`, encodeURIComponent(String(id))); - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - // authentication cookie required - - // authentication api_key required - await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) - - // authentication bearer required - // http bearer authentication required - await setBearerAuthToObject(localVarHeaderParameter, configuration) - - if (name !== undefined) { - localVarQueryParameter['name'] = name; - } - - if (skip !== undefined) { - localVarQueryParameter['skip'] = skip; - } - - if (key !== undefined) { - localVarQueryParameter['key'] = key; - } - - - setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -4039,19 +4007,6 @@ export const AlbumApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.deleteAlbum(id, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, - /** - * - * @param {string} id - * @param {string} [name] - * @param {number} [skip] - * @param {string} [key] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async downloadArchive(id: string, name?: string, skip?: number, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.downloadArchive(id, name, skip, key, options); - return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); - }, /** * * @param {*} [options] Override http request option. @@ -4165,18 +4120,6 @@ export const AlbumApiFactory = function (configuration?: Configuration, basePath deleteAlbum(id: string, options?: any): AxiosPromise { return localVarFp.deleteAlbum(id, options).then((request) => request(axios, basePath)); }, - /** - * - * @param {string} id - * @param {string} [name] - * @param {number} [skip] - * @param {string} [key] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - downloadArchive(id: string, name?: string, skip?: number, key?: string, options?: any): AxiosPromise { - return localVarFp.downloadArchive(id, name, skip, key, options).then((request) => request(axios, basePath)); - }, /** * * @param {*} [options] Override http request option. @@ -4315,41 +4258,6 @@ export interface AlbumApiDeleteAlbumRequest { readonly id: string } -/** - * Request parameters for downloadArchive operation in AlbumApi. - * @export - * @interface AlbumApiDownloadArchiveRequest - */ -export interface AlbumApiDownloadArchiveRequest { - /** - * - * @type {string} - * @memberof AlbumApiDownloadArchive - */ - readonly id: string - - /** - * - * @type {string} - * @memberof AlbumApiDownloadArchive - */ - readonly name?: string - - /** - * - * @type {number} - * @memberof AlbumApiDownloadArchive - */ - readonly skip?: number - - /** - * - * @type {string} - * @memberof AlbumApiDownloadArchive - */ - readonly key?: string -} - /** * Request parameters for getAlbumInfo operation in AlbumApi. * @export @@ -4506,17 +4414,6 @@ export class AlbumApi extends BaseAPI { return AlbumApiFp(this.configuration).deleteAlbum(requestParameters.id, options).then((request) => request(this.axios, this.basePath)); } - /** - * - * @param {AlbumApiDownloadArchiveRequest} requestParameters Request parameters. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - * @memberof AlbumApi - */ - public downloadArchive(requestParameters: AlbumApiDownloadArchiveRequest, options?: AxiosRequestConfig) { - return AlbumApiFp(this.configuration).downloadArchive(requestParameters.id, requestParameters.name, requestParameters.skip, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); - } - /** * * @param {*} [options] Override http request option. @@ -4773,62 +4670,15 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration }, /** * - * @param {string} id + * @param {AssetIdsDto} assetIdsDto * @param {string} [key] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - downloadFile: async (id: string, key?: string, options: AxiosRequestConfig = {}): Promise => { - // verify required parameter 'id' is not null or undefined - assertParamExists('downloadFile', 'id', id) - const localVarPath = `/asset/download/{id}` - .replace(`{${"id"}}`, encodeURIComponent(String(id))); - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - // authentication cookie required - - // authentication api_key required - await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) - - // authentication bearer required - // http bearer authentication required - await setBearerAuthToObject(localVarHeaderParameter, configuration) - - if (key !== undefined) { - localVarQueryParameter['key'] = key; - } - - - - setSearchParams(localVarUrlObj, localVarQueryParameter); - let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, - /** - * - * @param {DownloadFilesDto} downloadFilesDto - * @param {string} [key] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - downloadFiles: async (downloadFilesDto: DownloadFilesDto, key?: string, options: AxiosRequestConfig = {}): Promise => { - // verify required parameter 'downloadFilesDto' is not null or undefined - assertParamExists('downloadFiles', 'downloadFilesDto', downloadFilesDto) - const localVarPath = `/asset/download-files`; + downloadArchive: async (assetIdsDto: AssetIdsDto, key?: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'assetIdsDto' is not null or undefined + assertParamExists('downloadArchive', 'assetIdsDto', assetIdsDto) + const localVarPath = `/asset/download`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); let baseOptions; @@ -4860,7 +4710,7 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - localVarRequestOptions.data = serializeDataIfNeeded(downloadFilesDto, localVarRequestOptions, configuration) + localVarRequestOptions.data = serializeDataIfNeeded(assetIdsDto, localVarRequestOptions, configuration) return { url: toPathString(localVarUrlObj), @@ -4868,15 +4718,17 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration }; }, /** - * Current this is not used in any UI element - * @param {string} [name] - * @param {number} [skip] + * + * @param {string} id * @param {string} [key] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - downloadLibrary: async (name?: string, skip?: number, key?: string, options: AxiosRequestConfig = {}): Promise => { - const localVarPath = `/asset/download-library`; + downloadFile: async (id: string, key?: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('downloadFile', 'id', id) + const localVarPath = `/asset/download/{id}` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); let baseOptions; @@ -4884,7 +4736,7 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration baseOptions = configuration.baseOptions; } - const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; const localVarHeaderParameter = {} as any; const localVarQueryParameter = {} as any; @@ -4897,14 +4749,6 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration // http bearer authentication required await setBearerAuthToObject(localVarHeaderParameter, configuration) - if (name !== undefined) { - localVarQueryParameter['name'] = name; - } - - if (skip !== undefined) { - localVarQueryParameter['skip'] = skip; - } - if (key !== undefined) { localVarQueryParameter['key'] = key; } @@ -5356,6 +5200,69 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {Array} [assetIds] + * @param {string} [albumId] + * @param {string} [userId] + * @param {number} [archiveSize] + * @param {string} [key] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getDownloadInfo: async (assetIds?: Array, albumId?: string, userId?: string, archiveSize?: number, key?: string, options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/asset/download`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication cookie required + + // authentication api_key required + await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) + + // authentication bearer required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration) + + if (assetIds) { + localVarQueryParameter['assetIds'] = assetIds; + } + + if (albumId !== undefined) { + localVarQueryParameter['albumId'] = albumId; + } + + if (userId !== undefined) { + localVarQueryParameter['userId'] = userId; + } + + if (archiveSize !== undefined) { + localVarQueryParameter['archiveSize'] = archiveSize; + } + + if (key !== undefined) { + localVarQueryParameter['key'] = key; + } + + + setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; @@ -5888,6 +5795,17 @@ export const AssetApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.deleteAsset(deleteAssetDto, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {AssetIdsDto} assetIdsDto + * @param {string} [key] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async downloadArchive(assetIdsDto: AssetIdsDto, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.downloadArchive(assetIdsDto, key, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {string} id @@ -5899,29 +5817,6 @@ export const AssetApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.downloadFile(id, key, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, - /** - * - * @param {DownloadFilesDto} downloadFilesDto - * @param {string} [key] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async downloadFiles(downloadFilesDto: DownloadFilesDto, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.downloadFiles(downloadFilesDto, key, options); - return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); - }, - /** - * Current this is not used in any UI element - * @param {string} [name] - * @param {number} [skip] - * @param {string} [key] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async downloadLibrary(name?: string, skip?: number, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.downloadLibrary(name, skip, key, options); - return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); - }, /** * Get all AssetEntity belong to the user * @param {string} [userId] @@ -6025,6 +5920,20 @@ export const AssetApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.getCuratedObjects(options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {Array} [assetIds] + * @param {string} [albumId] + * @param {string} [userId] + * @param {number} [archiveSize] + * @param {string} [key] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getDownloadInfo(assetIds?: Array, albumId?: string, userId?: string, archiveSize?: number, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getDownloadInfo(assetIds, albumId, userId, archiveSize, key, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {boolean} [isFavorite] @@ -6172,6 +6081,16 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath deleteAsset(deleteAssetDto: DeleteAssetDto, options?: any): AxiosPromise> { return localVarFp.deleteAsset(deleteAssetDto, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {AssetIdsDto} assetIdsDto + * @param {string} [key] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + downloadArchive(assetIdsDto: AssetIdsDto, key?: string, options?: any): AxiosPromise { + return localVarFp.downloadArchive(assetIdsDto, key, options).then((request) => request(axios, basePath)); + }, /** * * @param {string} id @@ -6182,27 +6101,6 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath downloadFile(id: string, key?: string, options?: any): AxiosPromise { return localVarFp.downloadFile(id, key, options).then((request) => request(axios, basePath)); }, - /** - * - * @param {DownloadFilesDto} downloadFilesDto - * @param {string} [key] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - downloadFiles(downloadFilesDto: DownloadFilesDto, key?: string, options?: any): AxiosPromise { - return localVarFp.downloadFiles(downloadFilesDto, key, options).then((request) => request(axios, basePath)); - }, - /** - * Current this is not used in any UI element - * @param {string} [name] - * @param {number} [skip] - * @param {string} [key] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - downloadLibrary(name?: string, skip?: number, key?: string, options?: any): AxiosPromise { - return localVarFp.downloadLibrary(name, skip, key, options).then((request) => request(axios, basePath)); - }, /** * Get all AssetEntity belong to the user * @param {string} [userId] @@ -6296,6 +6194,19 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath getCuratedObjects(options?: any): AxiosPromise> { return localVarFp.getCuratedObjects(options).then((request) => request(axios, basePath)); }, + /** + * + * @param {Array} [assetIds] + * @param {string} [albumId] + * @param {string} [userId] + * @param {number} [archiveSize] + * @param {string} [key] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getDownloadInfo(assetIds?: Array, albumId?: string, userId?: string, archiveSize?: number, key?: string, options?: any): AxiosPromise { + return localVarFp.getDownloadInfo(assetIds, albumId, userId, archiveSize, key, options).then((request) => request(axios, basePath)); + }, /** * * @param {boolean} [isFavorite] @@ -6454,6 +6365,27 @@ export interface AssetApiDeleteAssetRequest { readonly deleteAssetDto: DeleteAssetDto } +/** + * Request parameters for downloadArchive operation in AssetApi. + * @export + * @interface AssetApiDownloadArchiveRequest + */ +export interface AssetApiDownloadArchiveRequest { + /** + * + * @type {AssetIdsDto} + * @memberof AssetApiDownloadArchive + */ + readonly assetIdsDto: AssetIdsDto + + /** + * + * @type {string} + * @memberof AssetApiDownloadArchive + */ + readonly key?: string +} + /** * Request parameters for downloadFile operation in AssetApi. * @export @@ -6475,55 +6407,6 @@ export interface AssetApiDownloadFileRequest { readonly key?: string } -/** - * Request parameters for downloadFiles operation in AssetApi. - * @export - * @interface AssetApiDownloadFilesRequest - */ -export interface AssetApiDownloadFilesRequest { - /** - * - * @type {DownloadFilesDto} - * @memberof AssetApiDownloadFiles - */ - readonly downloadFilesDto: DownloadFilesDto - - /** - * - * @type {string} - * @memberof AssetApiDownloadFiles - */ - readonly key?: string -} - -/** - * Request parameters for downloadLibrary operation in AssetApi. - * @export - * @interface AssetApiDownloadLibraryRequest - */ -export interface AssetApiDownloadLibraryRequest { - /** - * - * @type {string} - * @memberof AssetApiDownloadLibrary - */ - readonly name?: string - - /** - * - * @type {number} - * @memberof AssetApiDownloadLibrary - */ - readonly skip?: number - - /** - * - * @type {string} - * @memberof AssetApiDownloadLibrary - */ - readonly key?: string -} - /** * Request parameters for getAllAssets operation in AssetApi. * @export @@ -6650,6 +6533,48 @@ export interface AssetApiGetAssetThumbnailRequest { readonly key?: string } +/** + * Request parameters for getDownloadInfo operation in AssetApi. + * @export + * @interface AssetApiGetDownloadInfoRequest + */ +export interface AssetApiGetDownloadInfoRequest { + /** + * + * @type {Array} + * @memberof AssetApiGetDownloadInfo + */ + readonly assetIds?: Array + + /** + * + * @type {string} + * @memberof AssetApiGetDownloadInfo + */ + readonly albumId?: string + + /** + * + * @type {string} + * @memberof AssetApiGetDownloadInfo + */ + readonly userId?: string + + /** + * + * @type {number} + * @memberof AssetApiGetDownloadInfo + */ + readonly archiveSize?: number + + /** + * + * @type {string} + * @memberof AssetApiGetDownloadInfo + */ + readonly key?: string +} + /** * Request parameters for getMapMarkers operation in AssetApi. * @export @@ -6953,6 +6878,17 @@ export class AssetApi extends BaseAPI { return AssetApiFp(this.configuration).deleteAsset(requestParameters.deleteAssetDto, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {AssetApiDownloadArchiveRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AssetApi + */ + public downloadArchive(requestParameters: AssetApiDownloadArchiveRequest, options?: AxiosRequestConfig) { + return AssetApiFp(this.configuration).downloadArchive(requestParameters.assetIdsDto, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {AssetApiDownloadFileRequest} requestParameters Request parameters. @@ -6964,28 +6900,6 @@ export class AssetApi extends BaseAPI { return AssetApiFp(this.configuration).downloadFile(requestParameters.id, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); } - /** - * - * @param {AssetApiDownloadFilesRequest} requestParameters Request parameters. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - * @memberof AssetApi - */ - public downloadFiles(requestParameters: AssetApiDownloadFilesRequest, options?: AxiosRequestConfig) { - return AssetApiFp(this.configuration).downloadFiles(requestParameters.downloadFilesDto, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); - } - - /** - * Current this is not used in any UI element - * @param {AssetApiDownloadLibraryRequest} requestParameters Request parameters. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - * @memberof AssetApi - */ - public downloadLibrary(requestParameters: AssetApiDownloadLibraryRequest = {}, options?: AxiosRequestConfig) { - return AssetApiFp(this.configuration).downloadLibrary(requestParameters.name, requestParameters.skip, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); - } - /** * Get all AssetEntity belong to the user * @param {AssetApiGetAllAssetsRequest} requestParameters Request parameters. @@ -7091,6 +7005,17 @@ export class AssetApi extends BaseAPI { return AssetApiFp(this.configuration).getCuratedObjects(options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {AssetApiGetDownloadInfoRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AssetApi + */ + public getDownloadInfo(requestParameters: AssetApiGetDownloadInfoRequest = {}, options?: AxiosRequestConfig) { + return AssetApiFp(this.configuration).getDownloadInfo(requestParameters.assetIds, requestParameters.albumId, requestParameters.userId, requestParameters.archiveSize, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {AssetApiGetMapMarkersRequest} requestParameters Request parameters. diff --git a/web/src/lib/components/album-page/album-viewer.svelte b/web/src/lib/components/album-page/album-viewer.svelte index 1183af14d..9d3933be6 100644 --- a/web/src/lib/components/album-page/album-viewer.svelte +++ b/web/src/lib/components/album-page/album-viewer.svelte @@ -3,7 +3,6 @@ import { afterNavigate, goto } from '$app/navigation'; import { albumAssetSelectionStore } from '$lib/stores/album-asset-selection.store'; import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store'; - import { downloadAssets } from '$lib/stores/download'; import { locale } from '$lib/stores/preferences.store'; import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader'; import { @@ -45,6 +44,7 @@ import ThumbnailSelection from './thumbnail-selection.svelte'; import UserSelectionModal from './user-selection-modal.svelte'; import { handleError } from '../../utils/handle-error'; + import { downloadArchive } from '../../utils/asset-utils'; export let album: AlbumResponseDto; export let sharedLink: SharedLinkResponseDto | undefined = undefined; @@ -242,78 +242,12 @@ }; const downloadAlbum = async () => { - try { - let skip = 0; - let count = 0; - let done = false; - - while (!done) { - count++; - - const fileName = album.albumName + `${count === 1 ? '' : count}.zip`; - - $downloadAssets[fileName] = 0; - - let total = 0; - - const { data, status, headers } = await api.albumApi.downloadArchive( - { id: album.id, skip: skip || undefined, key: sharedLink?.key }, - { - responseType: 'blob', - onDownloadProgress: function (progressEvent) { - const request = this as XMLHttpRequest; - if (!total) { - total = Number(request.getResponseHeader('X-Immich-Content-Length-Hint')) || 0; - } - - if (total) { - const current = progressEvent.loaded; - $downloadAssets[fileName] = Math.floor((current / total) * 100); - } - } - } - ); - - const isNotComplete = headers['x-immich-archive-complete'] === 'false'; - const fileCount = Number(headers['x-immich-archive-file-count']) || 0; - if (isNotComplete && fileCount > 0) { - skip += fileCount; - } else { - done = true; - } - - if (!(data instanceof Blob)) { - return; - } - - if (status === 200) { - const fileUrl = URL.createObjectURL(data); - const anchor = document.createElement('a'); - anchor.href = fileUrl; - anchor.download = fileName; - - document.body.appendChild(anchor); - anchor.click(); - document.body.removeChild(anchor); - - URL.revokeObjectURL(fileUrl); - - // Remove item from download list - setTimeout(() => { - const copy = $downloadAssets; - delete copy[fileName]; - $downloadAssets = copy; - }, 2000); - } - } - } catch (e) { - $downloadAssets = {}; - console.error('Error downloading file ', e); - notificationController.show({ - type: NotificationType.Error, - message: 'Error downloading file, check console for more details.' - }); - } + await downloadArchive( + `${album.albumName}.zip`, + { albumId: album.id }, + undefined, + sharedLink?.key + ); }; const showAlbumOptionsMenu = ({ x, y }: MouseEvent) => { @@ -360,7 +294,7 @@ > {#if sharedLink?.allowDownload || !isPublicShared} - + {/if} {#if isOwned} diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 6192518ba..0a073f63f 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -1,6 +1,5 @@ diff --git a/web/src/lib/components/share-page/individual-shared-viewer.svelte b/web/src/lib/components/share-page/individual-shared-viewer.svelte index 65d0bcea5..97e123bf6 100644 --- a/web/src/lib/components/share-page/individual-shared-viewer.svelte +++ b/web/src/lib/components/share-page/individual-shared-viewer.svelte @@ -1,7 +1,7 @@