mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +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 { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
import { AssetMediaService } from 'src/services/asset-media.service';
|
import { AssetMediaService } from 'src/services/asset-media.service';
|
||||||
import { UploadFiles } from 'src/types';
|
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';
|
import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation';
|
||||||
|
|
||||||
@ApiTags(ApiTag.Assets)
|
@ApiTags(ApiTag.Assets)
|
||||||
@@ -109,7 +109,7 @@ export class AssetMediaController {
|
|||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
@Next() next: NextFunction,
|
@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')
|
@Put(':id/original')
|
||||||
@@ -162,7 +162,14 @@ export class AssetMediaController {
|
|||||||
const viewThumbnailRes = await this.service.viewThumbnail(auth, id, dto);
|
const viewThumbnailRes = await this.service.viewThumbnail(auth, id, dto);
|
||||||
|
|
||||||
if (viewThumbnailRes instanceof ImmichFileResponse) {
|
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 {
|
} else {
|
||||||
// viewThumbnailRes is a AssetMediaRedirectResponse
|
// viewThumbnailRes is a AssetMediaRedirectResponse
|
||||||
// which redirects to the original asset or a specific size to make better use of caching
|
// 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 { HttpException, StreamableFile } from '@nestjs/common';
|
||||||
import { NextFunction, Response } from 'express';
|
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 { 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 { promisify } from 'node:util';
|
||||||
|
import sharp from 'sharp';
|
||||||
import { CacheControl } from 'src/enum';
|
import { CacheControl } from 'src/enum';
|
||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
import { ImmichReadStream } from 'src/repositories/storage.repository';
|
import { ImmichReadStream } from 'src/repositories/storage.repository';
|
||||||
import { isConnectionAborted } from 'src/utils/misc';
|
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 {
|
export function getFileNameWithoutExtension(path: string): string {
|
||||||
return basename(path, extname(path));
|
return basename(path, extname(path));
|
||||||
}
|
}
|
||||||
@@ -20,6 +48,11 @@ export function getLivePhotoMotionFilename(stillName: string, motionName: string
|
|||||||
return getFileNameWithoutExtension(stillName) + extname(motionName);
|
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 {
|
export class ImmichFileResponse {
|
||||||
public readonly path!: string;
|
public readonly path!: string;
|
||||||
public readonly contentType!: string;
|
public readonly contentType!: string;
|
||||||
@@ -85,3 +118,57 @@ export const sendFile = async (
|
|||||||
export const asStreamableFile = ({ stream, type, length }: ImmichReadStream) => {
|
export const asStreamableFile = ({ stream, type, length }: ImmichReadStream) => {
|
||||||
return new StreamableFile(stream, { type, length });
|
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