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 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,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],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -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