Quellcode durchsuchen

fix(server): Premature stream close error when viewing videos in web (#3093)

* suppress 'ERR_STREAM_PREMATURE_CLOSE'

* refactor stream range logic
Mert vor 2 Jahren
Ursprung
Commit
2099b04057
1 geänderte Dateien mit 53 neuen und 60 gelöschten Zeilen
  1. 53 60
      server/src/immich/api-v1/asset/asset.service.ts

+ 53 - 60
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<string, string>, 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);
+      }
+    });
+  }
+
+  private setResRange(res: Res, headers: Record<string, string>, 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 new StreamableFile(createReadStream(filepath));
+    return { start, end };
   }
 }