diff --git a/server/src/controllers/asset-media.controller.ts b/server/src/controllers/asset-media.controller.ts index ec6083cfa8..b90dd63306 100644 --- a/server/src/controllers/asset-media.controller.ts +++ b/server/src/controllers/asset-media.controller.ts @@ -42,7 +42,7 @@ import { FileUploadInterceptor, getFiles } from 'src/middleware/file-upload.inte import { LoggingRepository } from 'src/repositories/logging.repository'; import { AssetMediaService } from 'src/services/asset-media.service'; import { UploadFiles } from 'src/types'; -import { ImmichFileResponse, sendFile } from 'src/utils/file'; +import { ImmichFileResponse, sendFile, sendFileThrottled } from 'src/utils/file'; import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation'; @ApiTags(ApiTag.Assets) @@ -109,7 +109,7 @@ export class AssetMediaController { @Res() res: Response, @Next() next: NextFunction, ) { - await sendFile(res, next, () => this.service.downloadOriginal(auth, id, dto), this.logger); + await sendFileThrottled(res, next, () => this.service.downloadOriginal(auth, id, dto), this.logger, 10); } @Put(':id/original') @@ -162,7 +162,14 @@ export class AssetMediaController { const viewThumbnailRes = await this.service.viewThumbnail(auth, id, dto); if (viewThumbnailRes instanceof ImmichFileResponse) { - await sendFile(res, next, () => Promise.resolve(viewThumbnailRes), this.logger); + const size = dto.size ?? AssetMediaSize.THUMBNAIL; + let durationSeconds = 1; + if (size === AssetMediaSize.PREVIEW) { + durationSeconds = 4; + } else if (size === AssetMediaSize.FULLSIZE) { + durationSeconds = 4; + } + await sendFileThrottled(res, next, () => Promise.resolve(viewThumbnailRes), this.logger, durationSeconds); } else { // viewThumbnailRes is a AssetMediaRedirectResponse // which redirects to the original asset or a specific size to make better use of caching diff --git a/server/src/utils/file.ts b/server/src/utils/file.ts index 04f1ce48d9..624df300d4 100644 --- a/server/src/utils/file.ts +++ b/server/src/utils/file.ts @@ -1,13 +1,41 @@ import { HttpException, StreamableFile } from '@nestjs/common'; import { NextFunction, Response } from 'express'; -import { access, constants } from 'node:fs/promises'; +import { createReadStream } from 'node:fs'; +import { access, constants, stat } from 'node:fs/promises'; import { basename, extname } from 'node:path'; +import { Transform, TransformCallback } from 'node:stream'; +import { setTimeout as sleep } from 'node:timers/promises'; import { promisify } from 'node:util'; +import sharp from 'sharp'; import { CacheControl } from 'src/enum'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { ImmichReadStream } from 'src/repositories/storage.repository'; import { isConnectionAborted } from 'src/utils/misc'; +class ThrottleTransform extends Transform { + private bytesPerSecond: number; + private startTime: number; + private bytesSent: number; + + constructor(bytesPerSecond: number) { + super(); + this.bytesPerSecond = bytesPerSecond; + this.startTime = Date.now(); + this.bytesSent = 0; + } + + _transform(chunk: Buffer, _encoding: BufferEncoding, callback: TransformCallback): void { + this.bytesSent += chunk.length; + const targetTime = (this.bytesSent / this.bytesPerSecond) * 1000; + const elapsed = Date.now() - this.startTime; + const delay = Math.max(0, targetTime - elapsed); + + setTimeout(() => { + callback(null, chunk); + }, delay); + } +} + export function getFileNameWithoutExtension(path: string): string { return basename(path, extname(path)); } @@ -20,6 +48,11 @@ export function getLivePhotoMotionFilename(stillName: string, motionName: string return getFileNameWithoutExtension(stillName) + extname(motionName); } +export async function hasAlphaChannel(input: string | Buffer): Promise { + const metadata = await sharp(input).metadata(); + return metadata.hasAlpha === true; +} + export class ImmichFileResponse { public readonly path!: string; public readonly contentType!: string; @@ -85,3 +118,57 @@ export const sendFile = async ( export const asStreamableFile = ({ stream, type, length }: ImmichReadStream) => { return new StreamableFile(stream, { type, length }); }; + +const THROTTLE_TRANSFER_DURATION_SECONDS = 6; +const THROTTLE_INITIAL_DELAY_MS = 500; + +export const sendFileThrottled = async ( + res: Response, + next: NextFunction, + handler: () => Promise | ImmichFileResponse, + logger: LoggingRepository, + durationSeconds: number = THROTTLE_TRANSFER_DURATION_SECONDS, +): Promise => { + try { + const file = await handler(); + const cacheControlHeader = cacheControlHeaders[file.cacheControl]; + if (cacheControlHeader) { + res.set('Cache-Control', cacheControlHeader); + } + + res.header('Content-Type', file.contentType); + if (file.fileName) { + res.header('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(file.fileName)}`); + } + + const fileStat = await stat(file.path); + res.header('Content-Length', fileStat.size.toString()); + + await sleep(THROTTLE_INITIAL_DELAY_MS); + + const bytesPerSecond = fileStat.size / durationSeconds; + const readStream = createReadStream(file.path); + const throttle = new ThrottleTransform(bytesPerSecond); + + readStream.pipe(throttle).pipe(res); + + readStream.on('error', (error) => { + if (!isConnectionAborted(error) && !res.headersSent) { + logger.error(`Unable to send file: ${error}`, (error as Error).stack); + res.header('Cache-Control', 'none'); + next(error); + } + }); + } catch (error: Error | any) { + if (isConnectionAborted(error) || res.headersSent) { + return; + } + + if (error instanceof HttpException === false) { + logger.error(`Unable to send file: ${error}`, error.stack); + } + + res.header('Cache-Control', 'none'); + next(error); + } +};