start with streamer

This commit is contained in:
Jurgis Sakalauskas
2022-12-14 15:55:50 +02:00
parent d999897068
commit df6bf9465f
12 changed files with 263 additions and 38 deletions
+9 -3
View File
@@ -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 ApiResponse from '../Dto/api-response.dto';
import { AllFilesService } from './all-files.service'; import { AllFilesService } from './all-files.service';
import { TitleTypeEnum } from './Enum/title-type.enum'; import { TitleTypeEnum } from './Enum/title-type.enum';
import { Request, Response } from 'express';
@Controller('api/all') @Controller('api/all')
export class AllFilesController { export class AllFilesController {
@@ -23,8 +24,13 @@ export class AllFilesController {
} }
@Get('play/:fileId') @Get('play/:fileId')
play(@Param('fileId') fileId: string) { @Head('play/:fileId')
return { will: 'return a playable stream of ' + fileId }; play(
@Param('fileId') fileId: string,
@Req() request: Request,
@Res() response: Response,
) {
this.allFilesService.play(fileId, request, response);
} }
@Get(':titleId/:seasonId?') @Get(':titleId/:seasonId?')
+5 -1
View File
@@ -5,9 +5,13 @@ import { KodiApiResponseFactory } from '../kodi-api-response.factory';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { TitleEntity } from './Entity/title.entity'; import { TitleEntity } from './Entity/title.entity';
import { FileEntity } from './Entity/file.entity'; import { FileEntity } from './Entity/file.entity';
import { StreamerModule } from '../../Streamer/streamer.module';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([TitleEntity, FileEntity])], imports: [
TypeOrmModule.forFeature([TitleEntity, FileEntity]),
StreamerModule,
],
controllers: [AllFilesController], controllers: [AllFilesController],
providers: [AllFilesService, KodiApiResponseFactory], providers: [AllFilesService, KodiApiResponseFactory],
}) })
+19 -1
View File
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import ApiResponse from '../Dto/api-response.dto'; import ApiResponse from '../Dto/api-response.dto';
import { KodiApiResponseFactory } from '../kodi-api-response.factory'; import { KodiApiResponseFactory } from '../kodi-api-response.factory';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
@@ -6,6 +6,8 @@ import { Repository } from 'typeorm';
import { TitleEntity } from './Entity/title.entity'; import { TitleEntity } from './Entity/title.entity';
import { FileEntity } from './Entity/file.entity'; import { FileEntity } from './Entity/file.entity';
import { TitleTypeEnum } from './Enum/title-type.enum'; import { TitleTypeEnum } from './Enum/title-type.enum';
import { StreamerFacade } from '../../Streamer/streamer.facade';
import { Request, Response } from 'express';
@Injectable() @Injectable()
export class AllFilesService { export class AllFilesService {
@@ -15,6 +17,7 @@ export class AllFilesService {
private titleRepository: Repository<TitleEntity>, private titleRepository: Repository<TitleEntity>,
@InjectRepository(FileEntity) @InjectRepository(FileEntity)
private fileRepository: Repository<FileEntity>, private fileRepository: Repository<FileEntity>,
private readonly streamerFacade: StreamerFacade,
) {} ) {}
getMenu(): ApiResponse { getMenu(): ApiResponse {
@@ -125,4 +128,19 @@ export class AllFilesService {
return apiResponse; return apiResponse;
} }
async play(
fileId: string,
request: Request,
response: Response,
): Promise<void> {
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);
}
} }
@@ -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;
}
}
@@ -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);
},
);
+32
View File
@@ -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';
}
}
@@ -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;
}
}
@@ -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);
},
);
@@ -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);
}
}
+12
View File
@@ -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);
}
}
+17
View File
@@ -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 {}
+110
View File
@@ -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<void> {
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<string, string> {
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<string, string>,
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
);
}
}