From 2fb78b61c47074d556dda45b9f06d96540404126 Mon Sep 17 00:00:00 2001 From: Jurgis Sakalauskas Date: Thu, 15 Dec 2022 11:00:15 +0200 Subject: [PATCH] streamer service: split to separate files --- src/KodiApi/AllFiles/all-files.controller.ts | 4 +- src/KodiApi/AllFiles/all-files.service.ts | 13 +- .../Dto/read-stream-creatable-file.dto.ts | 19 +++ .../read-stream-creatable.interface.ts | 8 ++ .../read-stream-options.interface.ts | 4 + .../RequestRange/request-range-bytes.class.ts | 6 +- .../RequestRange/request-range.service.ts | 8 ++ .../response-headers.service.ts | 28 +++++ .../ResponseSender/response-sender.service.ts | 21 ++++ .../StreamerService/file-streamer.service.ts | 34 ++++++ .../StreamerService/streamer.service.ts | 55 +++++++++ src/Streamer/streamer.facade.ts | 12 +- src/Streamer/streamer.module.ts | 8 +- src/Streamer/streamer.service.ts | 112 ------------------ 14 files changed, 204 insertions(+), 128 deletions(-) create mode 100644 src/Streamer/Dto/read-stream-creatable-file.dto.ts create mode 100644 src/Streamer/Interface/read-stream-creatable.interface.ts create mode 100644 src/Streamer/Interface/read-stream-options.interface.ts create mode 100644 src/Streamer/ResponseHeadersService/response-headers.service.ts create mode 100644 src/Streamer/ResponseSender/response-sender.service.ts create mode 100644 src/Streamer/StreamerService/file-streamer.service.ts create mode 100644 src/Streamer/StreamerService/streamer.service.ts delete mode 100644 src/Streamer/streamer.service.ts diff --git a/src/KodiApi/AllFiles/all-files.controller.ts b/src/KodiApi/AllFiles/all-files.controller.ts index 321769d..4f13643 100644 --- a/src/KodiApi/AllFiles/all-files.controller.ts +++ b/src/KodiApi/AllFiles/all-files.controller.ts @@ -29,8 +29,8 @@ export class AllFilesController { @Param('fileId') fileId: string, @Req() request: Request, @Res() response: Response, - ) { - this.allFilesService.play(fileId, request, response); + ): Promise { + return this.allFilesService.play(fileId, request, response); } @Get(':titleId/:seasonId?') diff --git a/src/KodiApi/AllFiles/all-files.service.ts b/src/KodiApi/AllFiles/all-files.service.ts index 103fb13..4792004 100644 --- a/src/KodiApi/AllFiles/all-files.service.ts +++ b/src/KodiApi/AllFiles/all-files.service.ts @@ -135,17 +135,16 @@ export class AllFilesService { response: Response, ): Promise { const id = parseInt(fileId); + if (isNaN(id)) { + throw new NotFoundException(`id #${fileId} not correct`); + } + const entity = await this.fileRepository.findOne({ where: { id: id } }); if (entity === null) { - response - .status(404) - .send('entity not found id: ' + fileId) - .end(); - - return; + throw new NotFoundException(`id #${fileId} not found`); } - this.streamerFacade.streamFile(request, response, entity.path); + return this.streamerFacade.streamFile(request, response, entity.path); } } diff --git a/src/Streamer/Dto/read-stream-creatable-file.dto.ts b/src/Streamer/Dto/read-stream-creatable-file.dto.ts new file mode 100644 index 0000000..d9ae651 --- /dev/null +++ b/src/Streamer/Dto/read-stream-creatable-file.dto.ts @@ -0,0 +1,19 @@ +import { ReadStreamCreatable } from '../Interface/read-stream-creatable.interface'; +import { createReadStream, ReadStream } from 'fs'; +import { ReadStreamOptions } from '../Interface/read-stream-options.interface'; + +export class ReadStreamCreatableFile implements ReadStreamCreatable { + constructor( + private readonly path: string, + readonly length: number, + readonly mimeType: string, + ) { + this.path = path; + this.length = length; + this.mimeType = mimeType; + } + + createReadStream(options: ReadStreamOptions): ReadStream { + return createReadStream(this.path, options); + } +} diff --git a/src/Streamer/Interface/read-stream-creatable.interface.ts b/src/Streamer/Interface/read-stream-creatable.interface.ts new file mode 100644 index 0000000..1e2f6d1 --- /dev/null +++ b/src/Streamer/Interface/read-stream-creatable.interface.ts @@ -0,0 +1,8 @@ +import { ReadStream } from 'fs'; +import { ReadStreamOptions } from './read-stream-options.interface'; + +export interface ReadStreamCreatable { + readonly mimeType: string; + readonly length: number; + createReadStream(options: ReadStreamOptions): ReadStream; +} diff --git a/src/Streamer/Interface/read-stream-options.interface.ts b/src/Streamer/Interface/read-stream-options.interface.ts new file mode 100644 index 0000000..da98d0f --- /dev/null +++ b/src/Streamer/Interface/read-stream-options.interface.ts @@ -0,0 +1,4 @@ +export class ReadStreamOptions { + start?: number; + end?: number; +} diff --git a/src/Streamer/RequestRange/request-range-bytes.class.ts b/src/Streamer/RequestRange/request-range-bytes.class.ts index a434754..654ae2d 100644 --- a/src/Streamer/RequestRange/request-range-bytes.class.ts +++ b/src/Streamer/RequestRange/request-range-bytes.class.ts @@ -1,6 +1,8 @@ -export default class RequestRangeBytes { - readonly end: number; +import { ReadStreamOptions } from '../Interface/read-stream-options.interface'; + +export default class RequestRangeBytes implements ReadStreamOptions { readonly start: number; + readonly end: number; constructor(start: number, end: number) { this.start = start; diff --git a/src/Streamer/RequestRange/request-range.service.ts b/src/Streamer/RequestRange/request-range.service.ts index 7ae6f3b..a967c3b 100644 --- a/src/Streamer/RequestRange/request-range.service.ts +++ b/src/Streamer/RequestRange/request-range.service.ts @@ -29,4 +29,12 @@ export class RequestRangeService { return new RequestRangeBytes(start, end); } + + isRangeSatisfiable(requestRange: RequestRangeBytes, size: number): boolean { + return ( + requestRange.start < size && + requestRange.end < size && + requestRange.end >= requestRange.start + ); + } } diff --git a/src/Streamer/ResponseHeadersService/response-headers.service.ts b/src/Streamer/ResponseHeadersService/response-headers.service.ts new file mode 100644 index 0000000..778aee9 --- /dev/null +++ b/src/Streamer/ResponseHeadersService/response-headers.service.ts @@ -0,0 +1,28 @@ +import RequestRangeBytes from '../RequestRange/request-range-bytes.class'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class ResponseHeadersService { + createResponseHeaders( + mimeType: string, + length: number, + requestRange: RequestRangeBytes | null, + ): Record { + const responseHeaders = {}; + responseHeaders['Content-Type'] = mimeType; + responseHeaders['Accept-Ranges'] = 'bytes'; + responseHeaders['Cache-Control'] = 'no-cache'; + responseHeaders['Content-Length'] = length; + + if (requestRange) { + responseHeaders[ + 'Content-Range' + ] = `bytes ${requestRange.start}-${requestRange.end}/${length}`; + + responseHeaders['Content-Length'] = + requestRange.end - requestRange.start + 1; + } + + return responseHeaders; + } +} diff --git a/src/Streamer/ResponseSender/response-sender.service.ts b/src/Streamer/ResponseSender/response-sender.service.ts new file mode 100644 index 0000000..f3c3ee9 --- /dev/null +++ b/src/Streamer/ResponseSender/response-sender.service.ts @@ -0,0 +1,21 @@ +import { Response } from 'express'; +import { ReadStream } from 'fs'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class ResponseSenderService { + send( + response: Response, + status: number, + headers: Record, + readable: ReadStream | null = null, + ): void { + response.writeHead(status, headers); + if (!readable) { + response.end(); + return; + } + + readable.pipe(response); + } +} diff --git a/src/Streamer/StreamerService/file-streamer.service.ts b/src/Streamer/StreamerService/file-streamer.service.ts new file mode 100644 index 0000000..f122e4e --- /dev/null +++ b/src/Streamer/StreamerService/file-streamer.service.ts @@ -0,0 +1,34 @@ +import { Request, Response } from 'express'; +import { Stats } from 'fs'; +import { stat } from 'fs/promises'; +import { Injectable, NotFoundException } from '@nestjs/common'; +import { ReadStreamCreatableFile } from '../Dto/read-stream-creatable-file.dto'; +import { StreamerService } from './streamer.service'; +import { MimeService } from '../Mime/mime.service'; + +@Injectable() +export class FileStreamerService { + constructor( + private readonly streamerService: StreamerService, + private readonly mimeService: MimeService, + ) {} + async streamFile( + request: Request, + response: Response, + filePath: string, + ): Promise { + const stats: Stats | false = await stat(filePath).catch(() => false); + if (stats === false) { + //todo - set the file to deleted, since no longer available. Events? + throw new NotFoundException('file not found'); + } + const mimeType = this.mimeService.getMime(filePath); + const streamCreatable = new ReadStreamCreatableFile( + filePath, + stats.size, + mimeType, + ); + + return this.streamerService.stream(request, response, streamCreatable); + } +} diff --git a/src/Streamer/StreamerService/streamer.service.ts b/src/Streamer/StreamerService/streamer.service.ts new file mode 100644 index 0000000..4bd95f7 --- /dev/null +++ b/src/Streamer/StreamerService/streamer.service.ts @@ -0,0 +1,55 @@ +import { ReadStreamCreatable } from '../Interface/read-stream-creatable.interface'; +import { RequestRangeService } from '../RequestRange/request-range.service'; +import { MimeService } from '../Mime/mime.service'; +import { Injectable } from '@nestjs/common'; +import { Request, Response } from 'express'; +import { ResponseHeadersService } from '../ResponseHeadersService/response-headers.service'; +import { ResponseSenderService } from '../ResponseSender/response-sender.service'; +import RequestRangeBytes from '../RequestRange/request-range-bytes.class'; + +@Injectable() +export class StreamerService { + constructor( + private readonly mimeService: MimeService, + private readonly requestRangeService: RequestRangeService, + private readonly responseHeadersService: ResponseHeadersService, + private readonly responseSender: ResponseSenderService, + ) {} + stream( + req: Request, + res: Response, + streamCreatable: ReadStreamCreatable, + ): void { + const size = streamCreatable.length; + const requestRange = this.getRange(req, size); + + if (!this.requestRangeService.isRangeSatisfiable(requestRange, size)) { + return this.handleUnsatisfiable(size, res); + } + + const responseHeaders = this.responseHeadersService.createResponseHeaders( + streamCreatable.mimeType, + size, + requestRange, + ); + + const status = req.headers.range ? 206 : 200; + const readable = + req.method === 'HEAD' + ? null + : streamCreatable.createReadStream(requestRange); + + return this.responseSender.send(res, status, responseHeaders, readable); + } + + private getRange(req: Request, size: number): RequestRangeBytes { + return this.requestRangeService.getRangeFromHeaders(req, size); + } + + private handleUnsatisfiable(size: number, res: Response) { + const responseHeaders = { 'Content-Range': 'bytes */' + size }; + this.responseSender.send(res, 416, responseHeaders, null); + + return; + } +} diff --git a/src/Streamer/streamer.facade.ts b/src/Streamer/streamer.facade.ts index 8156808..39726f7 100644 --- a/src/Streamer/streamer.facade.ts +++ b/src/Streamer/streamer.facade.ts @@ -1,12 +1,16 @@ import { Injectable } from '@nestjs/common'; import { Request, Response } from 'express'; -import { StreamerService } from './streamer.service'; +import { FileStreamerService } from './StreamerService/file-streamer.service'; @Injectable() export class StreamerFacade { - constructor(private readonly streamerService: StreamerService) {} + constructor(private readonly fileStreamerService: FileStreamerService) {} - streamFile(request: Request, response: Response, filePath: string): void { - this.streamerService.streamFile(request, response, filePath); + async streamFile( + request: Request, + response: Response, + filePath: string, + ): Promise { + return this.fileStreamerService.streamFile(request, response, filePath); } } diff --git a/src/Streamer/streamer.module.ts b/src/Streamer/streamer.module.ts index f5e68e2..f7fd620 100644 --- a/src/Streamer/streamer.module.ts +++ b/src/Streamer/streamer.module.ts @@ -2,13 +2,19 @@ import { Module } from '@nestjs/common'; import { StreamerFacade } from './streamer.facade'; import { MimeService } from './Mime/mime.service'; import { RequestRangeService } from './RequestRange/request-range.service'; -import { StreamerService } from './streamer.service'; +import { StreamerService } from './StreamerService/streamer.service'; +import { FileStreamerService } from './StreamerService/file-streamer.service'; +import { ResponseHeadersService } from './ResponseHeadersService/response-headers.service'; +import { ResponseSenderService } from './ResponseSender/response-sender.service'; @Module({ imports: [], providers: [ MimeService, RequestRangeService, + ResponseHeadersService, + ResponseSenderService, + FileStreamerService, StreamerService, StreamerFacade, ], diff --git a/src/Streamer/streamer.service.ts b/src/Streamer/streamer.service.ts deleted file mode 100644 index 5a84800..0000000 --- a/src/Streamer/streamer.service.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; -import { Request, Response } from 'express'; -import { MimeService } from './Mime/mime.service'; -import { RequestRangeService } from './RequestRange/request-range.service'; -import { stat } from 'fs/promises'; -import { createReadStream, ReadStream, Stats } from 'fs'; -import RequestRangeBytes from './RequestRange/request-range-bytes.class'; - -@Injectable() -export class StreamerService { - constructor( - readonly mimeService: MimeService, - readonly requestRangeService: RequestRangeService, - ) {} - - async streamFile( - request: Request, - response: Response, - filePath: string, - ): Promise { - const stats: Stats | false = await stat(filePath).catch(() => false); - if (stats === false) { - this.sendResponse(response, 404, {}); - - return; - } - - const requestRange = this.requestRangeService.getRangeFromHeaders( - request, - stats.size, - ); - console.log(requestRange, request.method); - - if (!this.isRangeSatisfiable(requestRange, stats.size)) { - const responseHeaders = { 'Content-Range': 'bytes */' + stats.size }; - this.sendResponse(response, 416, responseHeaders, null); - - return; - } - - const responseHeaders = this.createResponseHeaders( - filePath, - stats.size, - requestRange, - ); - - if (request.method == 'HEAD') { - this.sendResponse( - response, - request.headers.range ? 206 : 200, - responseHeaders, - null, - ); - } else { - this.sendResponse( - response, - request.headers.range ? 206 : 200, - responseHeaders, - createReadStream(filePath, requestRange), - ); - } - } - - private createResponseHeaders( - filePath: string, - contentSize: number, - requestRange: RequestRangeBytes | null, - ): Record { - const responseHeaders = {}; - responseHeaders['Content-Type'] = this.mimeService.getMime(filePath); - responseHeaders['Accept-Ranges'] = 'bytes'; - responseHeaders['Cache-Control'] = 'no-cache'; - responseHeaders['Content-Length'] = contentSize; - - if (requestRange) { - responseHeaders[ - 'Content-Range' - ] = `bytes ${requestRange.start}-${requestRange.end}/${contentSize}`; - - responseHeaders['Content-Length'] = - requestRange.end - requestRange.start + 1; - } - - return responseHeaders; - } - - private sendResponse( - response: Response, - status: number, - headers: Record, - readable: ReadStream | null = null, - ): void { - response.writeHead(status, headers); - if (!readable) { - response.end(); - return; - } - - readable.pipe(response); - } - - private isRangeSatisfiable( - requestRange: RequestRangeBytes, - fileSize: number, - ): boolean { - return ( - requestRange.start < fileSize && - requestRange.end < fileSize && - requestRange.end >= requestRange.start - ); - } -}