1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

refactor(server): download endpoints (#6653)

* refactor(server): download controller

* chore: open api

* chore: fix mobile references
This commit is contained in:
Jason Rasmussen 2024-01-26 09:19:13 -05:00 committed by GitHub
parent de47a6a330
commit 7ea55c7236
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 976 additions and 355 deletions

View File

@ -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(

View File

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

View File

@ -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(

View File

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

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/doc/DownloadApi.md generated Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/lib/api/download_api.dart generated Normal file

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/test/download_api_test.dart generated Normal file

Binary file not shown.

View File

@ -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",

View File

@ -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<RequestArgs> => {
downloadArchiveOld: async (assetIdsDto: AssetIdsDto, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// 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<RequestArgs> => {
downloadFileOld: async (id: string, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// 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<RequestArgs> => {
getDownloadInfoOld: async (downloadInfoDto: DownloadInfoDto, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// 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<File>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.downloadArchive(assetIdsDto, key, options);
async downloadArchiveOld(assetIdsDto: AssetIdsDto, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<File>> {
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<File>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.downloadFile(id, key, options);
async downloadFileOld(id: string, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<File>> {
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<DownloadResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getDownloadInfo(downloadInfoDto, key, options);
async getDownloadInfoOld(downloadInfoDto: DownloadInfoDto, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<DownloadResponseDto>> {
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<File> {
return localVarFp.downloadArchive(requestParameters.assetIdsDto, requestParameters.key, options).then((request) => request(axios, basePath));
downloadArchiveOld(requestParameters: AssetApiDownloadArchiveOldRequest, options?: AxiosRequestConfig): AxiosPromise<File> {
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<File> {
return localVarFp.downloadFile(requestParameters.id, requestParameters.key, options).then((request) => request(axios, basePath));
downloadFileOld(requestParameters: AssetApiDownloadFileOldRequest, options?: AxiosRequestConfig): AxiosPromise<File> {
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<DownloadResponseDto> {
return localVarFp.getDownloadInfo(requestParameters.downloadInfoDto, requestParameters.key, options).then((request) => request(axios, basePath));
getDownloadInfoOld(requestParameters: AssetApiGetDownloadInfoOldRequest, options?: AxiosRequestConfig): AxiosPromise<DownloadResponseDto> {
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<RequestArgs> => {
// 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<RequestArgs> => {
// 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<RequestArgs> => {
// 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<File>> {
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<File>> {
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<DownloadResponseDto>> {
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<File> {
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<File> {
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<DownloadResponseDto> {
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

View File

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

View File

@ -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<ImmichFileResponse> {
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<DownloadResponseDto> {
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<string>((id): id is string => !!id);
if (motionIds.length > 0) {
assets.push(...(await this.assetRepository.getByIds(motionIds)));
}
for (const asset of assets) {
archive.size += Number(asset.exifInfo?.fileSizeInByte || 0);
archive.assetIds.push(asset.id);
if (archive.size > targetSize) {
archives.push(archive);
archive = { size: 0, assetIds: [] };
}
}
if (archive.assetIds.length > 0) {
archives.push(archive);
}
}
return {
totalSize: archives.reduce((total, item) => (total += item.size), 0),
archives,
};
}
async downloadArchive(auth: AuthDto, dto: AssetIdsDto): Promise<ImmichReadStream> {
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<string, number> = {};
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<AsyncGenerator<AssetEntity[]>> {
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);

View File

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

View File

@ -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,

View File

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

View File

@ -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<IAssetRepository>;
let storageMock: jest.Mocked<IStorageRepository>;
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,
},
],
});
});
});
});

View File

@ -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<ImmichFileResponse> {
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<DownloadResponseDto> {
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<string>((id): id is string => !!id);
if (motionIds.length > 0) {
assets.push(...(await this.assetRepository.getByIds(motionIds)));
}
for (const asset of assets) {
archive.size += Number(asset.exifInfo?.fileSizeInByte || 0);
archive.assetIds.push(asset.id);
if (archive.size > targetSize) {
archives.push(archive);
archive = { size: 0, assetIds: [] };
}
}
if (archive.assetIds.length > 0) {
archives.push(archive);
}
}
return {
totalSize: archives.reduce((total, item) => (total += item.size), 0),
archives,
};
}
async downloadArchive(auth: AuthDto, dto: AssetIdsDto): Promise<ImmichReadStream> {
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<string, number> = {};
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<AsyncGenerator<AssetEntity[]>> {
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');
}
}

View File

@ -0,0 +1,2 @@
export * from './download.dto';
export * from './download.service';

View File

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

View File

@ -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,

View File

@ -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<MapMarkerResponseDto[]> {
@ -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<DownloadResponseDto> {
return this.service.getDownloadInfo(auth, dto);
getDownloadInfoOld(@Auth() auth: AuthDto, @Body() dto: DownloadInfoDto): Promise<DownloadResponseDto> {
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<StreamableFile> {
return this.service.downloadArchive(auth, dto).then(asStreamableFile);
downloadArchiveOld(@Auth() auth: AuthDto, @Body() dto: AssetIdsDto): Promise<StreamableFile> {
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));
}
/**

View File

@ -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<DownloadResponseDto> {
return this.service.getDownloadInfo(auth, dto);
}
@SharedLinkRoute()
@Post('archive')
@HttpCode(HttpStatus.OK)
@FileResponse()
downloadArchive(@Auth() auth: AuthDto, @Body() dto: AssetIdsDto): Promise<StreamableFile> {
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));
}
}

View File

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

View File

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

View File

@ -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',