mirror of https://github.com/immich-app/immich.git
feat(server): multi archive downloads (#956)
parent
b5d75e2016
commit
f2f255e6e6
@ -0,0 +1,14 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsNumber, IsOptional, IsPositive, IsString } from 'class-validator';
|
||||
|
||||
export class DownloadDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name = '';
|
||||
|
||||
@IsOptional()
|
||||
@IsPositive()
|
||||
@IsNumber()
|
||||
@Type(() => Number)
|
||||
skip?: number;
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
export const IMMICH_CONTENT_LENGTH_HINT = 'X-Immich-Content-Length-Hint';
|
||||
export const IMMICH_ARCHIVE_FILE_COUNT = 'X-Immich-Archive-File-Count';
|
||||
export const IMMICH_ARCHIVE_COMPLETE = 'X-Immich-Archive-Complete';
|
||||
@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DownloadService } from './download.service';
|
||||
|
||||
@Module({
|
||||
providers: [DownloadService],
|
||||
exports: [DownloadService],
|
||||
})
|
||||
export class DownloadModule {}
|
||||
@ -0,0 +1,63 @@
|
||||
import { AssetEntity } from '@app/database/entities/asset.entity';
|
||||
import { BadRequestException, Injectable, InternalServerErrorException, Logger, StreamableFile } from '@nestjs/common';
|
||||
import archiver from 'archiver';
|
||||
import { extname } from 'path';
|
||||
import { asHumanReadable, HumanReadableSize } from '../../utils/human-readable.util';
|
||||
|
||||
export interface DownloadArchive {
|
||||
stream: StreamableFile;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
fileCount: number;
|
||||
complete: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DownloadService {
|
||||
private readonly logger = new Logger(DownloadService.name);
|
||||
|
||||
public async downloadArchive(name: string, assets: AssetEntity[]): Promise<DownloadArchive> {
|
||||
if (!assets || assets.length === 0) {
|
||||
throw new BadRequestException('No assets to download.');
|
||||
}
|
||||
|
||||
try {
|
||||
const archive = archiver('zip', { store: true });
|
||||
const stream = new StreamableFile(archive);
|
||||
let totalSize = 0;
|
||||
let fileCount = 0;
|
||||
let complete = true;
|
||||
|
||||
for (const { id, originalPath, exifInfo } of assets) {
|
||||
const name = `${exifInfo?.imageName || id}${extname(originalPath)}`;
|
||||
archive.file(originalPath, { name });
|
||||
totalSize += Number(exifInfo?.fileSizeInByte || 0);
|
||||
fileCount++;
|
||||
|
||||
// for easier testing, can be changed before merging.
|
||||
if (totalSize > HumanReadableSize.GB * 20) {
|
||||
complete = false;
|
||||
this.logger.log(
|
||||
`Archive size exceeded after ${fileCount} files, capping at ${totalSize} bytes (${asHumanReadable(
|
||||
totalSize,
|
||||
)})`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
archive.finalize();
|
||||
|
||||
return {
|
||||
stream,
|
||||
fileName: `${name}.zip`,
|
||||
fileSize: totalSize,
|
||||
fileCount,
|
||||
complete,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Error creating download archive ${error}`);
|
||||
throw new InternalServerErrorException(`Failed to download ${name}: ${error}`, 'DownloadArchive');
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
const KB = 1000;
|
||||
const MB = KB * 1000;
|
||||
const GB = MB * 1000;
|
||||
const TB = GB * 1000;
|
||||
const PB = TB * 1000;
|
||||
|
||||
export const HumanReadableSize = { KB, MB, GB, TB, PB };
|
||||
|
||||
export function asHumanReadable(bytes: number, precision = 1) {
|
||||
if (bytes >= PB) {
|
||||
return `${(bytes / PB).toFixed(precision)}PB`;
|
||||
}
|
||||
|
||||
if (bytes >= TB) {
|
||||
return `${(bytes / TB).toFixed(precision)}TB`;
|
||||
}
|
||||
|
||||
if (bytes >= GB) {
|
||||
return `${(bytes / GB).toFixed(precision)}GB`;
|
||||
}
|
||||
|
||||
if (bytes >= MB) {
|
||||
return `${(bytes / MB).toFixed(precision)}MB`;
|
||||
}
|
||||
|
||||
if (bytes >= KB) {
|
||||
return `${(bytes / KB).toFixed(precision)}KB`;
|
||||
}
|
||||
|
||||
return `${bytes}B`;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Loading…
Reference in New Issue