dev: throttle images

This commit is contained in:
midzelis
2026-02-14 19:50:25 +00:00
parent 3467897113
commit 6ea7235e3c
2 changed files with 98 additions and 4 deletions

View File

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

View File

@@ -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<boolean> {
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> | ImmichFileResponse,
logger: LoggingRepository,
durationSeconds: number = THROTTLE_TRANSFER_DURATION_SECONDS,
): Promise<void> => {
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);
}
};