mirror of
https://github.com/immich-app/immich.git
synced 2026-03-22 16:09:29 +03:00
dev: throttle images
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user