diff --git a/mobile/lib/modules/asset_viewer/services/image_viewer.service.dart b/mobile/lib/modules/asset_viewer/services/image_viewer.service.dart index 45f053457e..0f68310b77 100644 --- a/mobile/lib/modules/asset_viewer/services/image_viewer.service.dart +++ b/mobile/lib/modules/asset_viewer/services/image_viewer.service.dart @@ -24,11 +24,11 @@ class ImageViewerService { try { // Download LivePhotos image and motion part if (asset.isImage && asset.livePhotoVideoId != null && Platform.isIOS) { - var imageResponse = await _apiService.assetApi.downloadFileWithHttpInfo( + var imageResponse = await _apiService.assetApi.downloadFileOldWithHttpInfo( asset.remoteId!, ); - var motionReponse = await _apiService.assetApi.downloadFileWithHttpInfo( + var motionReponse = await _apiService.assetApi.downloadFileOldWithHttpInfo( asset.livePhotoVideoId!, ); @@ -70,7 +70,7 @@ class ImageViewerService { return entity != null; } else { var res = await _apiService.assetApi - .downloadFileWithHttpInfo(asset.remoteId!); + .downloadFileOldWithHttpInfo(asset.remoteId!); if (res.statusCode != 200) { _log.severe( diff --git a/mobile/lib/modules/backup/services/backup_verification.service.dart b/mobile/lib/modules/backup/services/backup_verification.service.dart index 4b06dbbf77..1447abe90a 100644 --- a/mobile/lib/modules/backup/services/backup_verification.service.dart +++ b/mobile/lib/modules/backup/services/backup_verification.service.dart @@ -166,7 +166,7 @@ class BackupVerificationService { final Uint64List localImage = _fakeDecodeImg(local, await file.readAsBytes()); final res = await apiService.assetApi - .downloadFileWithHttpInfo(remote.remoteId!); + .downloadFileOldWithHttpInfo(remote.remoteId!); final Uint64List remoteImage = _fakeDecodeImg(remote, res.bodyBytes); final eq = const ListEquality().equals(remoteImage, localImage); diff --git a/mobile/lib/shared/services/share.service.dart b/mobile/lib/shared/services/share.service.dart index ab3a096aee..d503910797 100644 --- a/mobile/lib/shared/services/share.service.dart +++ b/mobile/lib/shared/services/share.service.dart @@ -32,7 +32,7 @@ class ShareService { final fileName = asset.fileName; final tempFile = await File('${tempDir.path}/$fileName').create(); final res = await _apiService.assetApi - .downloadFileWithHttpInfo(asset.remoteId!); + .downloadFileOldWithHttpInfo(asset.remoteId!); if (res.statusCode != 200) { _log.severe( diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 2eaa4a5bb7..7a8a69467d 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -58,6 +58,7 @@ doc/CreateTagDto.md doc/CreateUserDto.md doc/CuratedLocationsResponseDto.md doc/CuratedObjectsResponseDto.md +doc/DownloadApi.md doc/DownloadArchiveInfo.md doc/DownloadInfoDto.md doc/DownloadResponseDto.md @@ -186,6 +187,7 @@ lib/api/api_key_api.dart lib/api/asset_api.dart lib/api/audit_api.dart lib/api/authentication_api.dart +lib/api/download_api.dart lib/api/face_api.dart lib/api/job_api.dart lib/api/library_api.dart @@ -419,6 +421,7 @@ test/create_tag_dto_test.dart test/create_user_dto_test.dart test/curated_locations_response_dto_test.dart test/curated_objects_response_dto_test.dart +test/download_api_test.dart test/download_archive_info_test.dart test/download_info_dto_test.dart test/download_response_dto_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 65ec44b4e5..8f66455ded 100644 Binary files a/mobile/openapi/README.md and b/mobile/openapi/README.md differ diff --git a/mobile/openapi/doc/AssetApi.md b/mobile/openapi/doc/AssetApi.md index 09b3d6fbbc..d6ad217420 100644 Binary files a/mobile/openapi/doc/AssetApi.md and b/mobile/openapi/doc/AssetApi.md differ diff --git a/mobile/openapi/doc/DownloadApi.md b/mobile/openapi/doc/DownloadApi.md new file mode 100644 index 0000000000..cc5cde5c15 Binary files /dev/null and b/mobile/openapi/doc/DownloadApi.md differ diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 2bc825f457..991f0b2e54 100644 Binary files a/mobile/openapi/lib/api.dart and b/mobile/openapi/lib/api.dart differ diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index 878fa7734b..fbcc837f1d 100644 Binary files a/mobile/openapi/lib/api/asset_api.dart and b/mobile/openapi/lib/api/asset_api.dart differ diff --git a/mobile/openapi/lib/api/download_api.dart b/mobile/openapi/lib/api/download_api.dart new file mode 100644 index 0000000000..fa157366a8 Binary files /dev/null and b/mobile/openapi/lib/api/download_api.dart differ diff --git a/mobile/openapi/test/asset_api_test.dart b/mobile/openapi/test/asset_api_test.dart index e17104fd70..118b080329 100644 Binary files a/mobile/openapi/test/asset_api_test.dart and b/mobile/openapi/test/asset_api_test.dart differ diff --git a/mobile/openapi/test/download_api_test.dart b/mobile/openapi/test/download_api_test.dart new file mode 100644 index 0000000000..09ba5d5e40 Binary files /dev/null and b/mobile/openapi/test/download_api_test.dart differ diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 7ad0d3c199..f32ca7a855 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -1267,7 +1267,7 @@ }, "/asset/download/archive": { "post": { - "operationId": "downloadArchive", + "operationId": "downloadArchiveOld", "parameters": [ { "name": "key", @@ -1319,7 +1319,7 @@ }, "/asset/download/info": { "post": { - "operationId": "getDownloadInfo", + "operationId": "getDownloadInfoOld", "parameters": [ { "name": "key", @@ -1370,7 +1370,7 @@ }, "/asset/download/{id}": { "post": { - "operationId": "downloadFile", + "operationId": "downloadFileOld", "parameters": [ { "name": "id", @@ -3217,6 +3217,160 @@ ] } }, + "/download/archive": { + "post": { + "operationId": "downloadArchive", + "parameters": [ + { + "name": "key", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetIdsDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Download" + ] + } + }, + "/download/asset/{id}": { + "post": { + "operationId": "downloadFile", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "key", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Download" + ] + } + }, + "/download/info": { + "post": { + "operationId": "getDownloadInfo", + "parameters": [ + { + "name": "key", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DownloadInfoDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DownloadResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Download" + ] + } + }, "/face": { "get": { "operationId": "getFaces", diff --git a/open-api/typescript-sdk/client/api.ts b/open-api/typescript-sdk/client/api.ts index eb26e45fe8..08eccf7a07 100644 --- a/open-api/typescript-sdk/client/api.ts +++ b/open-api/typescript-sdk/client/api.ts @@ -6891,9 +6891,9 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration * @param {*} [options] Override http request option. * @throws {RequiredError} */ - downloadArchive: async (assetIdsDto: AssetIdsDto, key?: string, options: AxiosRequestConfig = {}): Promise => { + downloadArchiveOld: async (assetIdsDto: AssetIdsDto, key?: string, options: AxiosRequestConfig = {}): Promise => { // verify required parameter 'assetIdsDto' is not null or undefined - assertParamExists('downloadArchive', 'assetIdsDto', assetIdsDto) + assertParamExists('downloadArchiveOld', 'assetIdsDto', assetIdsDto) const localVarPath = `/asset/download/archive`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); @@ -6940,9 +6940,9 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration * @param {*} [options] Override http request option. * @throws {RequiredError} */ - downloadFile: async (id: string, key?: string, options: AxiosRequestConfig = {}): Promise => { + downloadFileOld: async (id: string, key?: string, options: AxiosRequestConfig = {}): Promise => { // verify required parameter 'id' is not null or undefined - assertParamExists('downloadFile', 'id', id) + assertParamExists('downloadFileOld', '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. @@ -7463,9 +7463,9 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getDownloadInfo: async (downloadInfoDto: DownloadInfoDto, key?: string, options: AxiosRequestConfig = {}): Promise => { + getDownloadInfoOld: async (downloadInfoDto: DownloadInfoDto, key?: string, options: AxiosRequestConfig = {}): Promise => { // verify required parameter 'downloadInfoDto' is not null or undefined - assertParamExists('getDownloadInfo', 'downloadInfoDto', downloadInfoDto) + assertParamExists('getDownloadInfoOld', 'downloadInfoDto', downloadInfoDto) const localVarPath = `/asset/download/info`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); @@ -8601,8 +8601,8 @@ export const AssetApiFp = function(configuration?: Configuration) { * @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); + async downloadArchiveOld(assetIdsDto: AssetIdsDto, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.downloadArchiveOld(assetIdsDto, key, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** @@ -8612,8 +8612,8 @@ export const AssetApiFp = function(configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async downloadFile(id: string, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.downloadFile(id, key, options); + async downloadFileOld(id: string, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.downloadFileOld(id, key, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** @@ -8733,8 +8733,8 @@ export const AssetApiFp = function(configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async getDownloadInfo(downloadInfoDto: DownloadInfoDto, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.getDownloadInfo(downloadInfoDto, key, options); + async getDownloadInfoOld(downloadInfoDto: DownloadInfoDto, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getDownloadInfoOld(downloadInfoDto, key, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** @@ -8996,21 +8996,21 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath }, /** * - * @param {AssetApiDownloadArchiveRequest} requestParameters Request parameters. + * @param {AssetApiDownloadArchiveOldRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - downloadArchive(requestParameters: AssetApiDownloadArchiveRequest, options?: AxiosRequestConfig): AxiosPromise { - return localVarFp.downloadArchive(requestParameters.assetIdsDto, requestParameters.key, options).then((request) => request(axios, basePath)); + downloadArchiveOld(requestParameters: AssetApiDownloadArchiveOldRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.downloadArchiveOld(requestParameters.assetIdsDto, requestParameters.key, options).then((request) => request(axios, basePath)); }, /** * - * @param {AssetApiDownloadFileRequest} requestParameters Request parameters. + * @param {AssetApiDownloadFileOldRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - downloadFile(requestParameters: AssetApiDownloadFileRequest, options?: AxiosRequestConfig): AxiosPromise { - return localVarFp.downloadFile(requestParameters.id, requestParameters.key, options).then((request) => request(axios, basePath)); + downloadFileOld(requestParameters: AssetApiDownloadFileOldRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.downloadFileOld(requestParameters.id, requestParameters.key, options).then((request) => request(axios, basePath)); }, /** * @@ -9101,12 +9101,12 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath }, /** * - * @param {AssetApiGetDownloadInfoRequest} requestParameters Request parameters. + * @param {AssetApiGetDownloadInfoOldRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getDownloadInfo(requestParameters: AssetApiGetDownloadInfoRequest, options?: AxiosRequestConfig): AxiosPromise { - return localVarFp.getDownloadInfo(requestParameters.downloadInfoDto, requestParameters.key, options).then((request) => request(axios, basePath)); + getDownloadInfoOld(requestParameters: AssetApiGetDownloadInfoOldRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.getDownloadInfoOld(requestParameters.downloadInfoDto, requestParameters.key, options).then((request) => request(axios, basePath)); }, /** * @@ -9279,43 +9279,43 @@ export interface AssetApiDeleteAssetsRequest { } /** - * Request parameters for downloadArchive operation in AssetApi. + * Request parameters for downloadArchiveOld operation in AssetApi. * @export - * @interface AssetApiDownloadArchiveRequest + * @interface AssetApiDownloadArchiveOldRequest */ -export interface AssetApiDownloadArchiveRequest { +export interface AssetApiDownloadArchiveOldRequest { /** * * @type {AssetIdsDto} - * @memberof AssetApiDownloadArchive + * @memberof AssetApiDownloadArchiveOld */ readonly assetIdsDto: AssetIdsDto /** * * @type {string} - * @memberof AssetApiDownloadArchive + * @memberof AssetApiDownloadArchiveOld */ readonly key?: string } /** - * Request parameters for downloadFile operation in AssetApi. + * Request parameters for downloadFileOld operation in AssetApi. * @export - * @interface AssetApiDownloadFileRequest + * @interface AssetApiDownloadFileOldRequest */ -export interface AssetApiDownloadFileRequest { +export interface AssetApiDownloadFileOldRequest { /** * * @type {string} - * @memberof AssetApiDownloadFile + * @memberof AssetApiDownloadFileOld */ readonly id: string /** * * @type {string} - * @memberof AssetApiDownloadFile + * @memberof AssetApiDownloadFileOld */ readonly key?: string } @@ -9496,22 +9496,22 @@ export interface AssetApiGetAssetThumbnailRequest { } /** - * Request parameters for getDownloadInfo operation in AssetApi. + * Request parameters for getDownloadInfoOld operation in AssetApi. * @export - * @interface AssetApiGetDownloadInfoRequest + * @interface AssetApiGetDownloadInfoOldRequest */ -export interface AssetApiGetDownloadInfoRequest { +export interface AssetApiGetDownloadInfoOldRequest { /** * * @type {DownloadInfoDto} - * @memberof AssetApiGetDownloadInfo + * @memberof AssetApiGetDownloadInfoOld */ readonly downloadInfoDto: DownloadInfoDto /** * * @type {string} - * @memberof AssetApiGetDownloadInfo + * @memberof AssetApiGetDownloadInfoOld */ readonly key?: string } @@ -10307,24 +10307,24 @@ export class AssetApi extends BaseAPI { /** * - * @param {AssetApiDownloadArchiveRequest} requestParameters Request parameters. + * @param {AssetApiDownloadArchiveOldRequest} 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)); + public downloadArchiveOld(requestParameters: AssetApiDownloadArchiveOldRequest, options?: AxiosRequestConfig) { + return AssetApiFp(this.configuration).downloadArchiveOld(requestParameters.assetIdsDto, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); } /** * - * @param {AssetApiDownloadFileRequest} requestParameters Request parameters. + * @param {AssetApiDownloadFileOldRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. * @throws {RequiredError} * @memberof AssetApi */ - public downloadFile(requestParameters: AssetApiDownloadFileRequest, options?: AxiosRequestConfig) { - return AssetApiFp(this.configuration).downloadFile(requestParameters.id, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); + public downloadFileOld(requestParameters: AssetApiDownloadFileOldRequest, options?: AxiosRequestConfig) { + return AssetApiFp(this.configuration).downloadFileOld(requestParameters.id, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); } /** @@ -10436,13 +10436,13 @@ export class AssetApi extends BaseAPI { /** * - * @param {AssetApiGetDownloadInfoRequest} requestParameters Request parameters. + * @param {AssetApiGetDownloadInfoOldRequest} 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.downloadInfoDto, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); + public getDownloadInfoOld(requestParameters: AssetApiGetDownloadInfoOldRequest, options?: AxiosRequestConfig) { + return AssetApiFp(this.configuration).getDownloadInfoOld(requestParameters.downloadInfoDto, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); } /** @@ -11628,6 +11628,345 @@ export class AuthenticationApi extends BaseAPI { } +/** + * DownloadApi - axios parameter creator + * @export + */ +export const DownloadApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * + * @param {AssetIdsDto} assetIdsDto + * @param {string} [key] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + downloadArchive: async (assetIdsDto: AssetIdsDto, key?: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'assetIdsDto' is not null or undefined + assertParamExists('downloadArchive', 'assetIdsDto', assetIdsDto) + const localVarPath = `/download/archive`; + // 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: 'POST', ...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; + } + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(assetIdsDto, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} id + * @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 = `/download/asset/{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: 'POST', ...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 {DownloadInfoDto} downloadInfoDto + * @param {string} [key] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getDownloadInfo: async (downloadInfoDto: DownloadInfoDto, key?: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'downloadInfoDto' is not null or undefined + assertParamExists('getDownloadInfo', 'downloadInfoDto', downloadInfoDto) + const localVarPath = `/download/info`; + // 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: 'POST', ...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; + } + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(downloadInfoDto, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * DownloadApi - functional programming interface + * @export + */ +export const DownloadApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = DownloadApiAxiosParamCreator(configuration) + return { + /** + * + * @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 + * @param {string} [key] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async downloadFile(id: string, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.downloadFile(id, key, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {DownloadInfoDto} downloadInfoDto + * @param {string} [key] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getDownloadInfo(downloadInfoDto: DownloadInfoDto, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getDownloadInfo(downloadInfoDto, key, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + } +}; + +/** + * DownloadApi - factory interface + * @export + */ +export const DownloadApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = DownloadApiFp(configuration) + return { + /** + * + * @param {DownloadApiDownloadArchiveRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + downloadArchive(requestParameters: DownloadApiDownloadArchiveRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.downloadArchive(requestParameters.assetIdsDto, requestParameters.key, options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {DownloadApiDownloadFileRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + downloadFile(requestParameters: DownloadApiDownloadFileRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.downloadFile(requestParameters.id, requestParameters.key, options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {DownloadApiGetDownloadInfoRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getDownloadInfo(requestParameters: DownloadApiGetDownloadInfoRequest, options?: AxiosRequestConfig): AxiosPromise { + return localVarFp.getDownloadInfo(requestParameters.downloadInfoDto, requestParameters.key, options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * Request parameters for downloadArchive operation in DownloadApi. + * @export + * @interface DownloadApiDownloadArchiveRequest + */ +export interface DownloadApiDownloadArchiveRequest { + /** + * + * @type {AssetIdsDto} + * @memberof DownloadApiDownloadArchive + */ + readonly assetIdsDto: AssetIdsDto + + /** + * + * @type {string} + * @memberof DownloadApiDownloadArchive + */ + readonly key?: string +} + +/** + * Request parameters for downloadFile operation in DownloadApi. + * @export + * @interface DownloadApiDownloadFileRequest + */ +export interface DownloadApiDownloadFileRequest { + /** + * + * @type {string} + * @memberof DownloadApiDownloadFile + */ + readonly id: string + + /** + * + * @type {string} + * @memberof DownloadApiDownloadFile + */ + readonly key?: string +} + +/** + * Request parameters for getDownloadInfo operation in DownloadApi. + * @export + * @interface DownloadApiGetDownloadInfoRequest + */ +export interface DownloadApiGetDownloadInfoRequest { + /** + * + * @type {DownloadInfoDto} + * @memberof DownloadApiGetDownloadInfo + */ + readonly downloadInfoDto: DownloadInfoDto + + /** + * + * @type {string} + * @memberof DownloadApiGetDownloadInfo + */ + readonly key?: string +} + +/** + * DownloadApi - object-oriented interface + * @export + * @class DownloadApi + * @extends {BaseAPI} + */ +export class DownloadApi extends BaseAPI { + /** + * + * @param {DownloadApiDownloadArchiveRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof DownloadApi + */ + public downloadArchive(requestParameters: DownloadApiDownloadArchiveRequest, options?: AxiosRequestConfig) { + return DownloadApiFp(this.configuration).downloadArchive(requestParameters.assetIdsDto, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {DownloadApiDownloadFileRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof DownloadApi + */ + public downloadFile(requestParameters: DownloadApiDownloadFileRequest, options?: AxiosRequestConfig) { + return DownloadApiFp(this.configuration).downloadFile(requestParameters.id, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {DownloadApiGetDownloadInfoRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof DownloadApi + */ + public getDownloadInfo(requestParameters: DownloadApiGetDownloadInfoRequest, options?: AxiosRequestConfig) { + return DownloadApiFp(this.configuration).getDownloadInfo(requestParameters.downloadInfoDto, requestParameters.key, options).then((request) => request(this.axios, this.basePath)); + } +} + + /** * FaceApi - axios parameter creator * @export diff --git a/server/src/domain/asset/asset.service.spec.ts b/server/src/domain/asset/asset.service.spec.ts index 100dc9a4ef..ac3e3d58d0 100644 --- a/server/src/domain/asset/asset.service.spec.ts +++ b/server/src/domain/asset/asset.service.spec.ts @@ -15,8 +15,6 @@ import { newUserRepositoryMock, } from '@test'; import { when } from 'jest-when'; -import { Readable } from 'stream'; -import { CacheControl, ImmichFileResponse } from '../domain.util'; import { JobName } from '../job'; import { AssetStats, @@ -32,19 +30,9 @@ import { TimeBucketSize, } from '../repositories'; import { AssetService, UploadFieldName } from './asset.service'; -import { AssetJobName, AssetStatsResponseDto, DownloadResponseDto } from './dto'; +import { AssetJobName, AssetStatsResponseDto } from './dto'; import { mapAsset } from './response-dto'; -const downloadResponse: DownloadResponseDto = { - totalSize: 105_000, - archives: [ - { - assetIds: ['asset-id', 'asset-id'], - size: 105_000, - }, - ], -}; - const stats: AssetStats = { [AssetType.IMAGE]: 10, [AssetType.VIDEO]: 23, @@ -460,172 +448,6 @@ describe(AssetService.name, () => { }); }); - describe('downloadFile', () => { - it('should require the asset.download permission', async () => { - await expect(sut.downloadFile(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException); - - expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); - expect(accessMock.asset.checkAlbumAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); - expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); - }); - - it('should throw an error if the asset is not found', async () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - 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 () => { - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - assetMock.getByIds.mockResolvedValue([assetStub.image]); - - await expect(sut.downloadFile(authStub.admin, 'asset-1')).resolves.toEqual( - new ImmichFileResponse({ - path: '/original/path.jpg', - contentType: 'image/jpeg', - cacheControl: CacheControl.NONE, - }), - ); - }); - - it('should download an archive', async () => { - const archiveMock = { - addFile: jest.fn(), - finalize: jest.fn(), - stream: new Readable(), - }; - - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); - assetMock.getByIds.mockResolvedValue([assetStub.noResizePath, assetStub.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.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); - assetMock.getByIds.mockResolvedValue([assetStub.noResizePath, assetStub.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.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); - assetMock.getByIds.mockResolvedValue([assetStub.image, assetStub.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.checkOwnerAccess.mockResolvedValue(new Set(['album-1'])); - assetMock.getByAlbumId.mockResolvedValue({ - items: [assetStub.image, assetStub.video], - hasNextPage: false, - }); - - await expect(sut.getDownloadInfo(authStub.admin, { albumId: 'album-1' })).resolves.toEqual(downloadResponse); - - expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['album-1'])); - expect(assetMock.getByAlbumId).toHaveBeenCalledWith({ take: 2500, skip: 0 }, 'album-1'); - }); - - it('should return a list of archives (userId)', async () => { - accessMock.library.checkOwnerAccess.mockResolvedValue(new Set([authStub.admin.user.id])); - assetMock.getByUserId.mockResolvedValue({ - items: [assetStub.image, assetStub.video], - hasNextPage: false, - }); - - await expect(sut.getDownloadInfo(authStub.admin, { userId: authStub.admin.user.id })).resolves.toEqual( - downloadResponse, - ); - - expect(assetMock.getByUserId).toHaveBeenCalledWith({ take: 2500, skip: 0 }, authStub.admin.user.id, { - isVisible: true, - }); - }); - - it('should split archives by size', async () => { - accessMock.library.checkOwnerAccess.mockResolvedValue(new Set([authStub.admin.user.id])); - - assetMock.getByUserId.mockResolvedValue({ - items: [ - { ...assetStub.image, id: 'asset-1' }, - { ...assetStub.video, id: 'asset-2' }, - { ...assetStub.withLocation, id: 'asset-3' }, - { ...assetStub.noWebpPath, id: 'asset-4' }, - ], - hasNextPage: false, - }); - - await expect( - sut.getDownloadInfo(authStub.admin, { - userId: authStub.admin.user.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 () => { - const assetIds = [assetStub.livePhotoStillAsset.id]; - - accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(assetIds)); - when(assetMock.getByIds) - .calledWith([assetStub.livePhotoStillAsset.id]) - .mockResolvedValue([assetStub.livePhotoStillAsset]); - when(assetMock.getByIds) - .calledWith([assetStub.livePhotoMotionAsset.id]) - .mockResolvedValue([assetStub.livePhotoMotionAsset]); - - await expect(sut.getDownloadInfo(authStub.admin, { assetIds })).resolves.toEqual({ - totalSize: 125_000, - archives: [ - { - assetIds: [assetStub.livePhotoStillAsset.id, assetStub.livePhotoMotionAsset.id], - size: 125_000, - }, - ], - }); - }); - }); - describe('getStatistics', () => { it('should get the statistics for a user, excluding archived assets', async () => { assetMock.getStatistics.mockResolvedValue(stats); diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts index 732ae7ad19..24297dce1c 100644 --- a/server/src/domain/asset/asset.service.ts +++ b/server/src/domain/asset/asset.service.ts @@ -8,7 +8,7 @@ import sanitize from 'sanitize-filename'; import { AccessCore, Permission } from '../access'; import { AuthDto } from '../auth'; import { mimeTypes } from '../domain.constant'; -import { CacheControl, HumanReadableSize, ImmichFileResponse, usePagination } from '../domain.util'; +import { usePagination } from '../domain.util'; import { IAssetDeletionJob, ISidecarWriteJob, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job'; import { ClientEvent, @@ -20,7 +20,6 @@ import { IStorageRepository, ISystemConfigRepository, IUserRepository, - ImmichReadStream, JobItem, TimeBucketOptions, } from '../repositories'; @@ -29,15 +28,11 @@ import { SystemConfigCore } from '../system-config'; import { AssetBulkDeleteDto, AssetBulkUpdateDto, - AssetIdsDto, AssetJobName, AssetJobsDto, AssetOrder, AssetSearchDto, AssetStatsDto, - DownloadArchiveInfo, - DownloadInfoDto, - DownloadResponseDto, MapMarkerDto, MemoryLaneDto, TimeBucketAssetDto, @@ -278,111 +273,6 @@ export class AssetService { return { ...options, userIds }; } - async downloadFile(auth: AuthDto, id: string): Promise { - await this.access.requirePermission(auth, Permission.ASSET_DOWNLOAD, id); - - const [asset] = await this.assetRepository.getByIds([id]); - if (!asset) { - throw new BadRequestException('Asset not found'); - } - - if (asset.isOffline) { - throw new BadRequestException('Asset is offline'); - } - - return new ImmichFileResponse({ - path: asset.originalPath, - contentType: mimeTypes.lookup(asset.originalPath), - cacheControl: CacheControl.NONE, - }); - } - - async getDownloadInfo(auth: AuthDto, dto: DownloadInfoDto): Promise { - const targetSize = dto.archiveSize || HumanReadableSize.GiB * 4; - const archives: DownloadArchiveInfo[] = []; - let archive: DownloadArchiveInfo = { size: 0, assetIds: [] }; - - const assetPagination = await this.getDownloadAssets(auth, 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(auth: AuthDto, dto: AssetIdsDto): Promise { - await this.access.requirePermission(auth, 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}`; - const count = paths[filename] || 0; - paths[filename] = count + 1; - if (count !== 0) { - filename = `${originalFileName}+${count}${ext}`; - } - - zip.addFile(originalPath, filename); - } - - void zip.finalize(); - - return { stream: zip.stream }; - } - - private async getDownloadAssets(auth: AuthDto, dto: DownloadInfoDto): Promise> { - const PAGINATION_SIZE = 2500; - - if (dto.assetIds) { - const assetIds = dto.assetIds; - await this.access.requirePermission(auth, 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(auth, 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(auth, Permission.TIMELINE_DOWNLOAD, userId); - return usePagination(PAGINATION_SIZE, (pagination) => - this.assetRepository.getByUserId(pagination, userId, { isVisible: true }), - ); - } - - throw new BadRequestException('assetIds, albumId, or userId is required'); - } async getStatistics(auth: AuthDto, dto: AssetStatsDto) { const stats = await this.assetRepository.getStatistics(auth.user.id, dto); diff --git a/server/src/domain/asset/dto/index.ts b/server/src/domain/asset/dto/index.ts index 281d924f32..bc7a100b94 100644 --- a/server/src/domain/asset/dto/index.ts +++ b/server/src/domain/asset/dto/index.ts @@ -2,7 +2,6 @@ export * from './asset-ids.dto'; export * from './asset-stack.dto'; export * from './asset-statistics.dto'; export * from './asset.dto'; -export * from './download.dto'; export * from './map-marker.dto'; export * from './memory-lane.dto'; export * from './time-bucket.dto'; diff --git a/server/src/domain/domain.module.ts b/server/src/domain/domain.module.ts index 1c53f70321..805664e11f 100644 --- a/server/src/domain/domain.module.ts +++ b/server/src/domain/domain.module.ts @@ -7,6 +7,7 @@ import { AssetService } from './asset'; import { AuditService } from './audit'; import { AuthService } from './auth'; import { DatabaseService } from './database'; +import { DownloadService } from './download'; import { JobService } from './job'; import { LibraryService } from './library'; import { MediaService } from './media'; @@ -31,6 +32,7 @@ const providers: Provider[] = [ AuditService, AuthService, DatabaseService, + DownloadService, ImmichLogger, JobService, LibraryService, diff --git a/server/src/domain/asset/dto/download.dto.ts b/server/src/domain/download/download.dto.ts similarity index 91% rename from server/src/domain/asset/dto/download.dto.ts rename to server/src/domain/download/download.dto.ts index d1f9f595e4..3785a9d432 100644 --- a/server/src/domain/asset/dto/download.dto.ts +++ b/server/src/domain/download/download.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { IsInt, IsPositive } from 'class-validator'; -import { Optional, ValidateUUID } from '../../domain.util'; +import { Optional, ValidateUUID } from '../domain.util'; export class DownloadInfoDto { @ValidateUUID({ each: true, optional: true }) diff --git a/server/src/domain/download/download.service.spec.ts b/server/src/domain/download/download.service.spec.ts new file mode 100644 index 0000000000..f59374d706 --- /dev/null +++ b/server/src/domain/download/download.service.spec.ts @@ -0,0 +1,219 @@ +import { BadRequestException } from '@nestjs/common'; +import { + IAccessRepositoryMock, + assetStub, + authStub, + newAccessRepositoryMock, + newAssetRepositoryMock, + newStorageRepositoryMock, +} from '@test'; +import { when } from 'jest-when'; +import { Readable } from 'typeorm/platform/PlatformTools.js'; +import { CacheControl, ImmichFileResponse } from '../domain.util'; +import { IAssetRepository, IStorageRepository } from '../repositories'; +import { DownloadResponseDto } from './download.dto'; +import { DownloadService } from './download.service'; + +const downloadResponse: DownloadResponseDto = { + totalSize: 105_000, + archives: [ + { + assetIds: ['asset-id', 'asset-id'], + size: 105_000, + }, + ], +}; + +describe(DownloadService.name, () => { + let sut: DownloadService; + let accessMock: IAccessRepositoryMock; + let assetMock: jest.Mocked; + let storageMock: jest.Mocked; + + it('should work', () => { + expect(sut).toBeDefined(); + }); + + beforeEach(async () => { + accessMock = newAccessRepositoryMock(); + assetMock = newAssetRepositoryMock(); + storageMock = newStorageRepositoryMock(); + + sut = new DownloadService(accessMock, assetMock, storageMock); + }); + + describe('downloadFile', () => { + it('should require the asset.download permission', async () => { + await expect(sut.downloadFile(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException); + + expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); + expect(accessMock.asset.checkAlbumAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); + expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1'])); + }); + + it('should throw an error if the asset is not found', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + assetMock.getByIds.mockResolvedValue([]); + + await expect(sut.downloadFile(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException); + + expect(assetMock.getByIds).toHaveBeenCalledWith(['asset-1']); + }); + + it('should throw an error if the asset is offline', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + assetMock.getByIds.mockResolvedValue([assetStub.offline]); + + await expect(sut.downloadFile(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException); + + expect(assetMock.getByIds).toHaveBeenCalledWith(['asset-1']); + }); + + it('should download a file', async () => { + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); + assetMock.getByIds.mockResolvedValue([assetStub.image]); + + await expect(sut.downloadFile(authStub.admin, 'asset-1')).resolves.toEqual( + new ImmichFileResponse({ + path: '/original/path.jpg', + contentType: 'image/jpeg', + cacheControl: CacheControl.NONE, + }), + ); + }); + + it('should download an archive', async () => { + const archiveMock = { + addFile: jest.fn(), + finalize: jest.fn(), + stream: new Readable(), + }; + + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); + assetMock.getByIds.mockResolvedValue([assetStub.noResizePath, assetStub.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.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); + assetMock.getByIds.mockResolvedValue([assetStub.noResizePath, assetStub.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.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); + assetMock.getByIds.mockResolvedValue([assetStub.image, assetStub.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.checkOwnerAccess.mockResolvedValue(new Set(['album-1'])); + assetMock.getByAlbumId.mockResolvedValue({ + items: [assetStub.image, assetStub.video], + hasNextPage: false, + }); + + await expect(sut.getDownloadInfo(authStub.admin, { albumId: 'album-1' })).resolves.toEqual(downloadResponse); + + expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['album-1'])); + expect(assetMock.getByAlbumId).toHaveBeenCalledWith({ take: 2500, skip: 0 }, 'album-1'); + }); + + it('should return a list of archives (userId)', async () => { + accessMock.library.checkOwnerAccess.mockResolvedValue(new Set([authStub.admin.user.id])); + assetMock.getByUserId.mockResolvedValue({ + items: [assetStub.image, assetStub.video], + hasNextPage: false, + }); + + await expect(sut.getDownloadInfo(authStub.admin, { userId: authStub.admin.user.id })).resolves.toEqual( + downloadResponse, + ); + + expect(assetMock.getByUserId).toHaveBeenCalledWith({ take: 2500, skip: 0 }, authStub.admin.user.id, { + isVisible: true, + }); + }); + + it('should split archives by size', async () => { + accessMock.library.checkOwnerAccess.mockResolvedValue(new Set([authStub.admin.user.id])); + + assetMock.getByUserId.mockResolvedValue({ + items: [ + { ...assetStub.image, id: 'asset-1' }, + { ...assetStub.video, id: 'asset-2' }, + { ...assetStub.withLocation, id: 'asset-3' }, + { ...assetStub.noWebpPath, id: 'asset-4' }, + ], + hasNextPage: false, + }); + + await expect( + sut.getDownloadInfo(authStub.admin, { + userId: authStub.admin.user.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 () => { + const assetIds = [assetStub.livePhotoStillAsset.id]; + + accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(assetIds)); + when(assetMock.getByIds) + .calledWith([assetStub.livePhotoStillAsset.id]) + .mockResolvedValue([assetStub.livePhotoStillAsset]); + when(assetMock.getByIds) + .calledWith([assetStub.livePhotoMotionAsset.id]) + .mockResolvedValue([assetStub.livePhotoMotionAsset]); + + await expect(sut.getDownloadInfo(authStub.admin, { assetIds })).resolves.toEqual({ + totalSize: 125_000, + archives: [ + { + assetIds: [assetStub.livePhotoStillAsset.id, assetStub.livePhotoMotionAsset.id], + size: 125_000, + }, + ], + }); + }); + }); +}); diff --git a/server/src/domain/download/download.service.ts b/server/src/domain/download/download.service.ts new file mode 100644 index 0000000000..0b28942705 --- /dev/null +++ b/server/src/domain/download/download.service.ts @@ -0,0 +1,129 @@ +import { AssetEntity } from '@app/infra/entities'; +import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { extname } from 'path'; +import { AccessCore, Permission } from '../access'; +import { AssetIdsDto } from '../asset'; +import { AuthDto } from '../auth'; +import { mimeTypes } from '../domain.constant'; +import { CacheControl, HumanReadableSize, ImmichFileResponse, usePagination } from '../domain.util'; +import { IAccessRepository, IAssetRepository, IStorageRepository, ImmichReadStream } from '../repositories'; +import { DownloadArchiveInfo, DownloadInfoDto, DownloadResponseDto } from './download.dto'; + +@Injectable() +export class DownloadService { + private access: AccessCore; + + constructor( + @Inject(IAccessRepository) accessRepository: IAccessRepository, + @Inject(IAssetRepository) private assetRepository: IAssetRepository, + @Inject(IStorageRepository) private storageRepository: IStorageRepository, + ) { + this.access = AccessCore.create(accessRepository); + } + + async downloadFile(auth: AuthDto, id: string): Promise { + await this.access.requirePermission(auth, Permission.ASSET_DOWNLOAD, id); + + const [asset] = await this.assetRepository.getByIds([id]); + if (!asset) { + throw new BadRequestException('Asset not found'); + } + + if (asset.isOffline) { + throw new BadRequestException('Asset is offline'); + } + + return new ImmichFileResponse({ + path: asset.originalPath, + contentType: mimeTypes.lookup(asset.originalPath), + cacheControl: CacheControl.NONE, + }); + } + + async getDownloadInfo(auth: AuthDto, dto: DownloadInfoDto): Promise { + const targetSize = dto.archiveSize || HumanReadableSize.GiB * 4; + const archives: DownloadArchiveInfo[] = []; + let archive: DownloadArchiveInfo = { size: 0, assetIds: [] }; + + const assetPagination = await this.getDownloadAssets(auth, 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(auth: AuthDto, dto: AssetIdsDto): Promise { + await this.access.requirePermission(auth, 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}`; + const count = paths[filename] || 0; + paths[filename] = count + 1; + if (count !== 0) { + filename = `${originalFileName}+${count}${ext}`; + } + + zip.addFile(originalPath, filename); + } + + void zip.finalize(); + + return { stream: zip.stream }; + } + + private async getDownloadAssets(auth: AuthDto, dto: DownloadInfoDto): Promise> { + const PAGINATION_SIZE = 2500; + + if (dto.assetIds) { + const assetIds = dto.assetIds; + await this.access.requirePermission(auth, 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(auth, 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(auth, Permission.TIMELINE_DOWNLOAD, userId); + return usePagination(PAGINATION_SIZE, (pagination) => + this.assetRepository.getByUserId(pagination, userId, { isVisible: true }), + ); + } + + throw new BadRequestException('assetIds, albumId, or userId is required'); + } +} diff --git a/server/src/domain/download/index.ts b/server/src/domain/download/index.ts new file mode 100644 index 0000000000..ab5c91ec97 --- /dev/null +++ b/server/src/domain/download/index.ts @@ -0,0 +1,2 @@ +export * from './download.dto'; +export * from './download.service'; diff --git a/server/src/domain/index.ts b/server/src/domain/index.ts index ca3d4ced46..341245c16a 100644 --- a/server/src/domain/index.ts +++ b/server/src/domain/index.ts @@ -10,6 +10,7 @@ export * from './domain.config'; export * from './domain.constant'; export * from './domain.module'; export * from './domain.util'; +export * from './download'; export * from './job'; export * from './library'; export * from './media'; diff --git a/server/src/immich/app.module.ts b/server/src/immich/app.module.ts index 364a3030bf..07a8183a39 100644 --- a/server/src/immich/app.module.ts +++ b/server/src/immich/app.module.ts @@ -19,6 +19,7 @@ import { AssetsController, AuditController, AuthController, + DownloadController, FaceController, JobController, LibraryController, @@ -52,6 +53,7 @@ import { ErrorInterceptor, FileUploadInterceptor } from './interceptors'; APIKeyController, AuditController, AuthController, + DownloadController, FaceController, JobController, LibraryController, diff --git a/server/src/immich/controllers/asset.controller.ts b/server/src/immich/controllers/asset.controller.ts index 10a5df3439..59685fb993 100644 --- a/server/src/immich/controllers/asset.controller.ts +++ b/server/src/immich/controllers/asset.controller.ts @@ -13,6 +13,7 @@ import { DeviceIdDto, DownloadInfoDto, DownloadResponseDto, + DownloadService, MapMarkerDto, MapMarkerResponseDto, MemoryLaneDto, @@ -65,7 +66,10 @@ export class AssetsController { @Authenticated() @UseValidation() export class AssetController { - constructor(private service: AssetService) {} + constructor( + private service: AssetService, + private downloadService: DownloadService, + ) {} @Get('map-marker') getMapMarkers(@Auth() auth: AuthDto, @Query() options: MapMarkerDto): Promise { @@ -82,31 +86,40 @@ export class AssetController { return this.service.getRandom(auth, dto.count ?? 1); } + /** + * @deprecated use `/download/info` + */ @SharedLinkRoute() @Post('download/info') - getDownloadInfo(@Auth() auth: AuthDto, @Body() dto: DownloadInfoDto): Promise { - return this.service.getDownloadInfo(auth, dto); + getDownloadInfoOld(@Auth() auth: AuthDto, @Body() dto: DownloadInfoDto): Promise { + return this.downloadService.getDownloadInfo(auth, dto); } + /** + * @deprecated use `/download/archive` + */ @SharedLinkRoute() @Post('download/archive') @HttpCode(HttpStatus.OK) @FileResponse() - downloadArchive(@Auth() auth: AuthDto, @Body() dto: AssetIdsDto): Promise { - return this.service.downloadArchive(auth, dto).then(asStreamableFile); + downloadArchiveOld(@Auth() auth: AuthDto, @Body() dto: AssetIdsDto): Promise { + return this.downloadService.downloadArchive(auth, dto).then(asStreamableFile); } + /** + * @deprecated use `/download/:id` + */ @SharedLinkRoute() @Post('download/:id') @HttpCode(HttpStatus.OK) @FileResponse() - async downloadFile( + async downloadFileOld( @Res() res: Response, @Next() next: NextFunction, @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, ) { - await sendFile(res, next, () => this.service.downloadFile(auth, id)); + await sendFile(res, next, () => this.downloadService.downloadFile(auth, id)); } /** diff --git a/server/src/immich/controllers/download.controller.ts b/server/src/immich/controllers/download.controller.ts new file mode 100644 index 0000000000..fcddac53d8 --- /dev/null +++ b/server/src/immich/controllers/download.controller.ts @@ -0,0 +1,42 @@ +import { AssetIdsDto, AuthDto, DownloadInfoDto, DownloadResponseDto, DownloadService } from '@app/domain'; +import { Body, Controller, HttpCode, HttpStatus, Next, Param, Post, Res, StreamableFile } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { NextFunction, Response } from 'express'; +import { Auth, Authenticated, FileResponse, SharedLinkRoute } from '../app.guard'; +import { UseValidation, asStreamableFile, sendFile } from '../app.utils'; +import { UUIDParamDto } from './dto/uuid-param.dto'; + +@ApiTags('Download') +@Controller('download') +@Authenticated() +@UseValidation() +export class DownloadController { + constructor(private service: DownloadService) {} + + @SharedLinkRoute() + @Post('info') + getDownloadInfo(@Auth() auth: AuthDto, @Body() dto: DownloadInfoDto): Promise { + return this.service.getDownloadInfo(auth, dto); + } + + @SharedLinkRoute() + @Post('archive') + @HttpCode(HttpStatus.OK) + @FileResponse() + downloadArchive(@Auth() auth: AuthDto, @Body() dto: AssetIdsDto): Promise { + return this.service.downloadArchive(auth, dto).then(asStreamableFile); + } + + @SharedLinkRoute() + @Post('asset/:id') + @HttpCode(HttpStatus.OK) + @FileResponse() + async downloadFile( + @Res() res: Response, + @Next() next: NextFunction, + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + ) { + await sendFile(res, next, () => this.service.downloadFile(auth, id)); + } +} diff --git a/server/src/immich/controllers/index.ts b/server/src/immich/controllers/index.ts index c177144c3d..d6e2938ef3 100644 --- a/server/src/immich/controllers/index.ts +++ b/server/src/immich/controllers/index.ts @@ -5,6 +5,7 @@ export * from './app.controller'; export * from './asset.controller'; export * from './audit.controller'; export * from './auth.controller'; +export * from './download.controller'; export * from './face.controller'; export * from './job.controller'; export * from './library.controller'; diff --git a/web/src/api/api.ts b/web/src/api/api.ts index 7dd20a5f67..cb43fa8f38 100644 --- a/web/src/api/api.ts +++ b/web/src/api/api.ts @@ -7,6 +7,7 @@ import { AssetJobName, AuditApi, AuthenticationApi, + DownloadApi, FaceApi, JobApi, JobName, @@ -29,6 +30,7 @@ import type { ApiParams } from './types'; class ImmichApi { public activityApi: ActivityApi; public albumApi: AlbumApi; + public downloadApi: DownloadApi; public libraryApi: LibraryApi; public assetApi: AssetApi; public auditApi: AuditApi; @@ -58,6 +60,7 @@ class ImmichApi { this.activityApi = new ActivityApi(this.config); this.albumApi = new AlbumApi(this.config); this.auditApi = new AuditApi(this.config); + this.downloadApi = new DownloadApi(this.config); this.libraryApi = new LibraryApi(this.config); this.assetApi = new AssetApi(this.config); this.authenticationApi = new AuthenticationApi(this.config); diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index a060e1a7f8..681434d4fd 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -47,7 +47,7 @@ export const downloadArchive = async (fileName: string, options: DownloadInfoDto let downloadInfo: DownloadResponseDto | null = null; try { - const { data } = await api.assetApi.getDownloadInfo({ downloadInfoDto: options, key: api.getKey() }); + const { data } = await api.downloadApi.getDownloadInfo({ downloadInfoDto: options, key: api.getKey() }); downloadInfo = data; } catch (error) { handleError(error, 'Unable to download files'); @@ -71,7 +71,7 @@ export const downloadArchive = async (fileName: string, options: DownloadInfoDto downloadManager.add(downloadKey, archive.size, abort); try { - const { data } = await api.assetApi.downloadArchive( + const { data } = await api.downloadApi.downloadArchive( { assetIdsDto: { assetIds: archive.assetIds }, key: api.getKey() }, { responseType: 'blob', @@ -121,7 +121,7 @@ export const downloadFile = async (asset: AssetResponseDto) => { const abort = new AbortController(); downloadManager.add(downloadKey, size, abort); - const { data } = await api.assetApi.downloadFile( + const { data } = await api.downloadApi.downloadFile( { id, key: api.getKey() }, { responseType: 'blob',