diff --git a/server/src/immich/api-v1/asset/asset.service.ts b/server/src/immich/api-v1/asset/asset.service.ts index 9a275c66d1..0a1ee8e0e2 100644 --- a/server/src/immich/api-v1/asset/asset.service.ts +++ b/server/src/immich/api-v1/asset/asset.service.ts @@ -21,16 +21,15 @@ import { InternalServerErrorException, Logger, NotFoundException, - StreamableFile, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Response as Res } from 'express'; -import { constants, createReadStream, stat } from 'fs'; +import { constants, createReadStream } from 'fs'; import fs from 'fs/promises'; import mime from 'mime-types'; import path from 'path'; +import { pipeline } from 'stream/promises'; import { QueryFailedError, Repository } from 'typeorm'; -import { promisify } from 'util'; import { IAssetRepository } from './asset-repository'; import { AssetCore } from './asset.core'; import { AssetBulkUploadCheckDto } from './dto/asset-check.dto'; @@ -63,8 +62,6 @@ import { CuratedLocationsResponseDto } from './response-dto/curated-locations-re import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto'; import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto'; -const fileInfo = promisify(stat); - interface ServableFile { filepath: string; contentType: string; @@ -263,7 +260,7 @@ export class AssetService { return this.streamFile(thumbnailPath, res, headers); } catch (e) { res.header('Cache-Control', 'none'); - Logger.error(`Cannot create read stream for asset ${asset.id}`, 'getAssetThumbnail'); + this.logger.error(`Cannot create read stream for asset ${asset.id}`, 'getAssetThumbnail'); throw new InternalServerErrorException( `Cannot read thumbnail file for asset ${asset.id} - contact your administrator`, { cause: e as Error }, @@ -294,7 +291,7 @@ export class AssetService { const { filepath, contentType } = this.getServePath(asset, query, allowOriginalFile); return this.streamFile(filepath, res, headers, contentType); } catch (e) { - Logger.error(`Cannot create read stream for asset ${asset.id} ${JSON.stringify(e)}`, 'serveFile[IMAGE]'); + this.logger.error(`Cannot create read stream for asset ${asset.id} ${JSON.stringify(e)}`, 'serveFile[IMAGE]'); throw new InternalServerErrorException( e, `Cannot read thumbnail file for asset ${asset.id} - contact your administrator`, @@ -302,56 +299,8 @@ export class AssetService { } } else { try { - // Handle Video - let videoPath = asset.originalPath; - let mimeType = asset.mimeType; - - await fs.access(videoPath, constants.R_OK); - - if (asset.encodedVideoPath) { - videoPath = asset.encodedVideoPath == '' ? String(asset.originalPath) : String(asset.encodedVideoPath); - mimeType = asset.encodedVideoPath == '' ? asset.mimeType : 'video/mp4'; - } - - const { size } = await fileInfo(videoPath); - const range = headers.range; - - if (range) { - /** Extracting Start and End value from Range Header */ - const [startStr, endStr] = range.replace(/bytes=/, '').split('-'); - let start = parseInt(startStr, 10); - let end = endStr ? parseInt(endStr, 10) : size - 1; - - if (!isNaN(start) && isNaN(end)) { - start = start; - end = size - 1; - } - if (isNaN(start) && !isNaN(end)) { - start = size - end; - end = size - 1; - } - - // Handle unavailable range request - if (start >= size || end >= size) { - console.error('Bad Request'); - // Return the 416 Range Not Satisfiable. - res.status(416).set({ 'Content-Range': `bytes */${size}` }); - - throw new BadRequestException('Bad Request Range'); - } - - /** Sending Partial Content With HTTP Code 206 */ - res.status(206).set({ - 'Content-Range': `bytes ${start}-${end}/${size}`, - 'Accept-Ranges': 'bytes', - 'Content-Length': end - start + 1, - 'Content-Type': mimeType, - }); - - const videoStream = createReadStream(videoPath, { start, end }); - - return new StreamableFile(videoStream); - } + const videoPath = asset.encodedVideoPath ? asset.encodedVideoPath : asset.originalPath; + const mimeType = asset.encodedVideoPath ? 'video/mp4' : asset.mimeType; return this.streamFile(videoPath, res, headers, mimeType); } catch (e: Error | any) { @@ -618,12 +567,16 @@ export class AssetService { } private async streamFile(filepath: string, res: Res, headers: Record, contentType?: string | null) { + await fs.access(filepath, constants.R_OK); + const { size, mtimeNs } = await fs.stat(filepath, { bigint: true }); + if (contentType) { res.header('Content-Type', contentType); } + const range = this.setResRange(res, headers, Number(size)); + // etag - const { size, mtimeNs } = await fs.stat(filepath, { bigint: true }); const etag = `W/"${size}-${mtimeNs}"`; res.setHeader('ETag', etag); if (etag === headers['if-none-match']) { @@ -631,8 +584,48 @@ export class AssetService { return; } - await fs.access(filepath, constants.R_OK); + const stream = createReadStream(filepath, range); + return await pipeline(stream, res).catch((err) => { + if (err.code !== 'ERR_STREAM_PREMATURE_CLOSE') { + this.logger.error(err); + } + }); + } - return new StreamableFile(createReadStream(filepath)); + private setResRange(res: Res, headers: Record, size: number) { + if (!headers.range) { + return {}; + } + + /** Extracting Start and End value from Range Header */ + const [startStr, endStr] = headers.range.replace(/bytes=/, '').split('-'); + let start = parseInt(startStr, 10); + let end = endStr ? parseInt(endStr, 10) : size - 1; + + if (!isNaN(start) && isNaN(end)) { + start = start; + end = size - 1; + } + + if (isNaN(start) && !isNaN(end)) { + start = size - end; + end = size - 1; + } + + // Handle unavailable range request + if (start >= size || end >= size) { + console.error('Bad Request'); + res.status(416).set({ 'Content-Range': `bytes */${size}` }); + + throw new BadRequestException('Bad Request Range'); + } + + res.status(206).set({ + 'Content-Range': `bytes ${start}-${end}/${size}`, + 'Accept-Ranges': 'bytes', + 'Content-Length': end - start + 1, + }); + + return { start, end }; } }