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
+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
);
}
}