Files
immich/server/src/services/download.service.ts
2025-03-05 08:38:23 -05:00

122 lines
4.2 KiB
TypeScript

import { BadRequestException, Injectable } from '@nestjs/common';
import { parse } from 'node:path';
import { StorageCore } from 'src/cores/storage.core';
import { AssetIdsDto } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { DownloadArchiveInfo, DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto';
import { Permission } from 'src/enum';
import { ImmichReadStream } from 'src/repositories/storage.repository';
import { BaseService } from 'src/services/base.service';
import { HumanReadableSize } from 'src/utils/bytes';
import { getPreferences } from 'src/utils/preferences';
@Injectable()
export class DownloadService extends BaseService {
async getDownloadInfo(auth: AuthDto, dto: DownloadInfoDto): Promise<DownloadResponseDto> {
let assets;
if (dto.assetIds) {
const assetIds = dto.assetIds;
await this.requireAccess({ auth, permission: Permission.ASSET_DOWNLOAD, ids: assetIds });
assets = this.downloadRepository.downloadAssetIds(assetIds);
} else if (dto.albumId) {
const albumId = dto.albumId;
await this.requireAccess({ auth, permission: Permission.ALBUM_DOWNLOAD, ids: [albumId] });
assets = this.downloadRepository.downloadAlbumId(albumId);
} else if (dto.userId) {
const userId = dto.userId;
await this.requireAccess({ auth, permission: Permission.TIMELINE_DOWNLOAD, ids: [userId] });
assets = this.downloadRepository.downloadUserId(userId);
} else {
throw new BadRequestException('assetIds, albumId, or userId is required');
}
const targetSize = dto.archiveSize || HumanReadableSize.GiB * 4;
const metadata = await this.userRepository.getMetadata(auth.user.id);
const preferences = getPreferences(auth.user.email, metadata);
const motionIds = new Set<string>();
const archives: DownloadArchiveInfo[] = [];
let archive: DownloadArchiveInfo = { size: 0, assetIds: [] };
const addToArchive = ({ id, size }: { id: string; size: number | null }) => {
archive.assetIds.push(id);
archive.size += Number(size || 0);
if (archive.size > targetSize) {
archives.push(archive);
archive = { size: 0, assetIds: [] };
}
};
for await (const asset of assets) {
// motion part of live photos
if (asset.livePhotoVideoId) {
motionIds.add(asset.livePhotoVideoId);
}
addToArchive(asset);
}
if (motionIds.size > 0) {
const motionAssets = this.downloadRepository.downloadMotionAssetIds([...motionIds]);
for await (const motionAsset of motionAssets) {
if (StorageCore.isAndroidMotionPath(motionAsset.originalPath) && !preferences.download.includeEmbeddedVideos) {
continue;
}
addToArchive(motionAsset);
}
}
if (archive.assetIds.length > 0) {
archives.push(archive);
}
let totalSize = 0;
for (const archive of archives) {
totalSize += archive.size;
}
return { totalSize, archives };
}
async downloadArchive(auth: AuthDto, dto: AssetIdsDto): Promise<ImmichReadStream> {
await this.requireAccess({ auth, permission: Permission.ASSET_DOWNLOAD, ids: dto.assetIds });
const zip = this.storageRepository.createZipStream();
const assets = await this.assetRepository.getByIds(dto.assetIds);
const assetMap = new Map(assets.map((asset) => [asset.id, asset]));
const paths: Record<string, number> = {};
for (const assetId of dto.assetIds) {
const asset = assetMap.get(assetId);
if (!asset) {
continue;
}
const { originalPath, originalFileName } = asset;
let filename = originalFileName;
const count = paths[filename] || 0;
paths[filename] = count + 1;
if (count !== 0) {
const parsedFilename = parse(originalFileName);
filename = `${parsedFilename.name}+${count}${parsedFilename.ext}`;
}
let realpath = originalPath;
try {
realpath = await this.storageRepository.realpath(originalPath);
} catch {
this.logger.warn('Unable to resolve realpath', { originalPath });
}
zip.addFile(realpath, filename);
}
void zip.finalize();
return { stream: zip.stream };
}
}