From df6bf9465f3a1eff4f82d9c73711366cc5c5bc7c Mon Sep 17 00:00:00 2001 From: Jurgis Sakalauskas Date: Wed, 14 Dec 2022 15:55:50 +0200 Subject: [PATCH] start with streamer --- src/KodiApi/AllFiles/all-files.controller.ts | 12 +- src/KodiApi/AllFiles/all-files.module.ts | 6 +- src/KodiApi/AllFiles/all-files.service.ts | 20 +++- src/RequestRange/request-range-bytes.class.ts | 9 -- src/RequestRange/request-range.decorator.ts | 24 ---- src/Streamer/Mime/mime.service.ts | 32 +++++ .../RequestRange/request-range-bytes.class.ts | 9 ++ .../RequestRange/request-range.decorator.ts | 18 +++ .../RequestRange/request-range.service.ts | 32 +++++ src/Streamer/streamer.facade.ts | 12 ++ src/Streamer/streamer.module.ts | 17 +++ src/Streamer/streamer.service.ts | 110 ++++++++++++++++++ 12 files changed, 263 insertions(+), 38 deletions(-) delete mode 100644 src/RequestRange/request-range-bytes.class.ts delete mode 100644 src/RequestRange/request-range.decorator.ts create mode 100644 src/Streamer/Mime/mime.service.ts create mode 100644 src/Streamer/RequestRange/request-range-bytes.class.ts create mode 100644 src/Streamer/RequestRange/request-range.decorator.ts create mode 100644 src/Streamer/RequestRange/request-range.service.ts create mode 100644 src/Streamer/streamer.facade.ts create mode 100644 src/Streamer/streamer.module.ts create 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 1a5cd5e..321769d 100644 --- a/src/KodiApi/AllFiles/all-files.controller.ts +++ b/src/KodiApi/AllFiles/all-files.controller.ts @@ -1,7 +1,8 @@ -import { Controller, Get, Param } from '@nestjs/common'; +import { Controller, Get, Head, Param, Req, Res } from '@nestjs/common'; import ApiResponse from '../Dto/api-response.dto'; import { AllFilesService } from './all-files.service'; import { TitleTypeEnum } from './Enum/title-type.enum'; +import { Request, Response } from 'express'; @Controller('api/all') export class AllFilesController { @@ -23,8 +24,13 @@ export class AllFilesController { } @Get('play/:fileId') - play(@Param('fileId') fileId: string) { - return { will: 'return a playable stream of ' + fileId }; + @Head('play/:fileId') + play( + @Param('fileId') fileId: string, + @Req() request: Request, + @Res() response: Response, + ) { + this.allFilesService.play(fileId, request, response); } @Get(':titleId/:seasonId?') diff --git a/src/KodiApi/AllFiles/all-files.module.ts b/src/KodiApi/AllFiles/all-files.module.ts index f565c63..dbcba83 100644 --- a/src/KodiApi/AllFiles/all-files.module.ts +++ b/src/KodiApi/AllFiles/all-files.module.ts @@ -5,9 +5,13 @@ import { KodiApiResponseFactory } from '../kodi-api-response.factory'; import { TypeOrmModule } from '@nestjs/typeorm'; import { TitleEntity } from './Entity/title.entity'; import { FileEntity } from './Entity/file.entity'; +import { StreamerModule } from '../../Streamer/streamer.module'; @Module({ - imports: [TypeOrmModule.forFeature([TitleEntity, FileEntity])], + imports: [ + TypeOrmModule.forFeature([TitleEntity, FileEntity]), + StreamerModule, + ], controllers: [AllFilesController], providers: [AllFilesService, KodiApiResponseFactory], }) diff --git a/src/KodiApi/AllFiles/all-files.service.ts b/src/KodiApi/AllFiles/all-files.service.ts index 2e80b6e..6df3f3f 100644 --- a/src/KodiApi/AllFiles/all-files.service.ts +++ b/src/KodiApi/AllFiles/all-files.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import ApiResponse from '../Dto/api-response.dto'; import { KodiApiResponseFactory } from '../kodi-api-response.factory'; import { InjectRepository } from '@nestjs/typeorm'; @@ -6,6 +6,8 @@ import { Repository } from 'typeorm'; import { TitleEntity } from './Entity/title.entity'; import { FileEntity } from './Entity/file.entity'; import { TitleTypeEnum } from './Enum/title-type.enum'; +import { StreamerFacade } from '../../Streamer/streamer.facade'; +import { Request, Response } from 'express'; @Injectable() export class AllFilesService { @@ -15,6 +17,7 @@ export class AllFilesService { private titleRepository: Repository, @InjectRepository(FileEntity) private fileRepository: Repository, + private readonly streamerFacade: StreamerFacade, ) {} getMenu(): ApiResponse { @@ -125,4 +128,19 @@ export class AllFilesService { return apiResponse; } + + async play( + fileId: string, + request: Request, + response: Response, + ): Promise { + const id = parseInt(fileId); + const entity = await this.fileRepository.findOne({ where: { id: id } }); + + if (entity === null) { + throw new NotFoundException('entity not found id: ' + fileId); + } + + this.streamerFacade.streamFile(request, response, entity.path); + } } diff --git a/src/RequestRange/request-range-bytes.class.ts b/src/RequestRange/request-range-bytes.class.ts deleted file mode 100644 index 61fe02a..0000000 --- a/src/RequestRange/request-range-bytes.class.ts +++ /dev/null @@ -1,9 +0,0 @@ -export default class RequestRangeBytes { - readonly end: number | null; - readonly start: number | null; - - constructor(start: number | null, end: number | null) { - this.start = start; - this.end = end; - } -} diff --git a/src/RequestRange/request-range.decorator.ts b/src/RequestRange/request-range.decorator.ts deleted file mode 100644 index 0637262..0000000 --- a/src/RequestRange/request-range.decorator.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { createParamDecorator, ExecutionContext } from '@nestjs/common'; -import RequestRangeBytes from './request-range-bytes.class'; - -export const RequestRange = createParamDecorator( - (data: string, ctx: ExecutionContext): RequestRangeBytes | null => { - const request = ctx.switchToHttp().getRequest().headers; - - const range = request['range']; - if (!range) { - return null; - } - - const array = range.split(/bytes=([0-9]*)-([0-9]*)/); - - let start = parseInt(array[1]); - let end = parseInt(array[2]); - - //both isNaN? -> incorrect request header, response with everything we have - start = isNaN(start) ? null : start; //isNaN? -> only num of end bytes requested - end = isNaN(end) ? null : end; //isNaN? -> everything from start to the end requested - - return new RequestRangeBytes(start, end); - }, -); diff --git a/src/Streamer/Mime/mime.service.ts b/src/Streamer/Mime/mime.service.ts new file mode 100644 index 0000000..5c5732c --- /dev/null +++ b/src/Streamer/Mime/mime.service.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@nestjs/common'; +import { extname } from 'path'; + +@Injectable() +export class MimeService { + private mimeNames = { + '.css': 'text/css', + '.html': 'text/html', + '.js': 'application/javascript', + '.mp3': 'audio/mpeg', + '.mp4': 'video/mp4', + '.ogg': 'application/ogg', + '.ogv': 'video/ogg', + '.oga': 'audio/ogg', + '.txt': 'text/plain', + '.wav': 'audio/x-wav', + '.webm': 'video/webm', + '.mkv': 'video/x-matroska', + '.m3u': 'application/vnd.apple.mpegurl', + '.ts': 'video/MP2T', + '.json': 'application/json', + '.avi': 'video/x-msvideo', + }; + + getMime(filePath: string): string { + return this.getMimeNameFromExt(extname(filePath)); + } + + private getMimeNameFromExt(ext): string { + return this.mimeNames[ext.toLowerCase()] ?? 'application/octet-stream'; + } +} diff --git a/src/Streamer/RequestRange/request-range-bytes.class.ts b/src/Streamer/RequestRange/request-range-bytes.class.ts new file mode 100644 index 0000000..a434754 --- /dev/null +++ b/src/Streamer/RequestRange/request-range-bytes.class.ts @@ -0,0 +1,9 @@ +export default class RequestRangeBytes { + readonly end: number; + readonly start: number; + + constructor(start: number, end: number) { + this.start = start; + this.end = end; + } +} diff --git a/src/Streamer/RequestRange/request-range.decorator.ts b/src/Streamer/RequestRange/request-range.decorator.ts new file mode 100644 index 0000000..050d2f2 --- /dev/null +++ b/src/Streamer/RequestRange/request-range.decorator.ts @@ -0,0 +1,18 @@ +import { + createParamDecorator, + ExecutionContext, + HttpException, +} from '@nestjs/common'; +import RequestRangeBytes from './request-range-bytes.class'; + +export const RequestRange = createParamDecorator( + (data: string, ctx: ExecutionContext): RequestRangeBytes | null => { + const headers = ctx.switchToHttp().getRequest().headers; + + if (headers['range']) { + throw new HttpException('not implemented', 501); + } + + return new RequestRangeBytes(null, null); + }, +); diff --git a/src/Streamer/RequestRange/request-range.service.ts b/src/Streamer/RequestRange/request-range.service.ts new file mode 100644 index 0000000..7ae6f3b --- /dev/null +++ b/src/Streamer/RequestRange/request-range.service.ts @@ -0,0 +1,32 @@ +import { Injectable } from '@nestjs/common'; +import RequestRangeBytes from './request-range-bytes.class'; +import { Request } from 'express'; + +@Injectable() +export class RequestRangeService { + getRangeFromHeaders(request: Request, fileSize: number): RequestRangeBytes { + const range = request.headers['range']; + if (!range || range.length === 0) { + return new RequestRangeBytes(0, fileSize - 1); + } + + const rangeArray = range.split(/bytes=([0-9]*)-([0-9]*)/); + + const start = parseInt(rangeArray[1]); + const end = parseInt(rangeArray[2]); + + if (isNaN(start) && isNaN(end)) { + return new RequestRangeBytes(0, fileSize - 1); + } + + if (!isNaN(start) && isNaN(end)) { + return new RequestRangeBytes(start, fileSize - 1); + } + + if (isNaN(start) && !isNaN(end)) { + return new RequestRangeBytes(fileSize - end, fileSize - 1); + } + + return new RequestRangeBytes(start, end); + } +} diff --git a/src/Streamer/streamer.facade.ts b/src/Streamer/streamer.facade.ts new file mode 100644 index 0000000..8156808 --- /dev/null +++ b/src/Streamer/streamer.facade.ts @@ -0,0 +1,12 @@ +import { Injectable } from '@nestjs/common'; +import { Request, Response } from 'express'; +import { StreamerService } from './streamer.service'; + +@Injectable() +export class StreamerFacade { + constructor(private readonly streamerService: StreamerService) {} + + streamFile(request: Request, response: Response, filePath: string): void { + this.streamerService.streamFile(request, response, filePath); + } +} diff --git a/src/Streamer/streamer.module.ts b/src/Streamer/streamer.module.ts new file mode 100644 index 0000000..f5e68e2 --- /dev/null +++ b/src/Streamer/streamer.module.ts @@ -0,0 +1,17 @@ +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'; + +@Module({ + imports: [], + providers: [ + MimeService, + RequestRangeService, + StreamerService, + StreamerFacade, + ], + exports: [StreamerFacade], +}) +export class StreamerModule {} diff --git a/src/Streamer/streamer.service.ts b/src/Streamer/streamer.service.ts new file mode 100644 index 0000000..e06e697 --- /dev/null +++ b/src/Streamer/streamer.service.ts @@ -0,0 +1,110 @@ +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) { + throw new NotFoundException('file not found'); + } + + 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 + ); + } +}