mirror of
https://github.com/sakaljurgis/kodi-api-server.git
synced 2026-07-08 20:37:41 +00:00
start with streamer
This commit is contained in:
@@ -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?')
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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<TitleEntity>,
|
||||
@InjectRepository(FileEntity)
|
||||
private fileRepository: Repository<FileEntity>,
|
||||
private readonly streamerFacade: StreamerFacade,
|
||||
) {}
|
||||
|
||||
getMenu(): ApiResponse {
|
||||
@@ -125,4 +128,19 @@ export class AllFilesService {
|
||||
|
||||
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);
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user