Nowadays, video streaming is one of the main ways of consuming and sharing content. In this article, we explore the fundamental concepts of building a REST API for uploading videos to the server and streaming them using NestJS and Prisma.
Check out this repository if you want to see the full code from this article.
Uploading videos
NestJS makes it very straightforward to store the files on the server with the FileInterceptor.
1import {
2 Controller,
3 Post,
4 UseInterceptors,
5 UploadedFile,
6} from '@nestjs/common';
7import { Express } from 'express';
8import { VideosService } from './videos.service';
9import { FileInterceptor } from '@nestjs/platform-express';
10import { diskStorage } from 'multer';
11
12@Controller('videos')
13export default class VideosController {
14 constructor(private readonly videosService: VideosService) {}
15
16 @Post()
17 @UseInterceptors(
18 FileInterceptor('file', {
19 storage: diskStorage({
20 destination: './uploadedFiles/videos',
21 }),
22 }),
23 )
24 async addVideo(@UploadedFile() file: Express.Multer.File) {
25 return this.videosService.create({
26 filename: file.originalname,
27 path: file.path,
28 mimetype: file.mimetype,
29 });
30 }
31}Whenever we make a valid POST request to the API, NestJS stores the uploaded videos in the ./uploadedFiles/videos directory.
In one of the previous parts of this series, we created a custom interceptor that allows us to avoid repeating some parts of our configuration whenever we need more than one endpoint that accepts files. It also allows us to use environment variables to determine where to store files on the server.
1import { FileInterceptor } from '@nestjs/platform-express';
2import { Injectable, mixin, NestInterceptor, Type } from '@nestjs/common';
3import { ConfigService } from '@nestjs/config';
4import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
5import { diskStorage } from 'multer';
6
7interface LocalFilesInterceptorOptions {
8 fieldName: string;
9 path?: string;
10 fileFilter?: MulterOptions['fileFilter'];
11}
12
13function LocalFilesInterceptor(
14 options: LocalFilesInterceptorOptions,
15): Type<NestInterceptor> {
16 @Injectable()
17 class Interceptor implements NestInterceptor {
18 fileInterceptor: NestInterceptor;
19 constructor(configService: ConfigService) {
20 const filesDestination = configService.get('UPLOADED_FILES_DESTINATION');
21
22 const destination = `${filesDestination}${options.path}`;
23
24 const multerOptions: MulterOptions = {
25 storage: diskStorage({
26 destination,
27 }),
28 fileFilter: options.fileFilter,
29 };
30
31 this.fileInterceptor = new (FileInterceptor(
32 options.fieldName,
33 multerOptions,
34 ))();
35 }
36
37 intercept(...args: Parameters<NestInterceptor['intercept']>) {
38 return this.fileInterceptor.intercept(...args);
39 }
40 }
41 return mixin(Interceptor);
42}
43
44export default LocalFilesInterceptor;Above, we are using the mixin pattern. If you want to know more, check out API with NestJS #57. Composing classes with the mixin pattern
To use our custom interceptor, we need to add UPLOADED_FILES_DESTINATION to our environment variables.
1import { Module } from '@nestjs/common';
2import { ConfigModule } from '@nestjs/config';
3import * as Joi from 'joi';
4import { VideosModule } from './videos/videos.module';
5
6@Module({
7 imports: [
8 // ...
9 ConfigModule.forRoot({
10 validationSchema: Joi.object({
11 // ...
12 UPLOADED_FILES_DESTINATION: Joi.string().required(),
13 }),
14 }),
15 VideosModule,
16 ],
17 controllers: [],
18 providers: [],
19})
20export class AppModule {}1# ...
2
3UPLOADED_FILES_DESTINATION=./uploadedFilesThanks to all of the above, we can now take advantage of our custom interceptor in the videos controller.
1import {
2 Controller,
3 Post,
4 UseInterceptors,
5 UploadedFile,
6 BadRequestException,
7} from '@nestjs/common';
8import { Express } from 'express';
9import LocalFilesInterceptor from '../utils/localFiles.interceptor';
10import { VideosService } from './videos.service';
11
12@Controller('videos')
13export default class VideosController {
14 constructor(private readonly videosService: VideosService) {}
15
16 @Post()
17 @UseInterceptors(
18 LocalFilesInterceptor({
19 fieldName: 'file',
20 path: '/videos',
21 fileFilter: (request, file, callback) => {
22 if (!file.mimetype.includes('video')) {
23 return callback(
24 new BadRequestException('Provide a valid video'),
25 false,
26 );
27 }
28 callback(null, true);
29 },
30 }),
31 )
32 addVideo(@UploadedFile() file: Express.Multer.File) {
33 return this.videosService.create({
34 filename: file.originalname,
35 path: file.path,
36 mimetype: file.mimetype,
37 });
38 }
39}Storing the information in the database
Once we have the file saved on our server, we need to store the appropriate information in our database, such as the path to the file. To do that, let’s create a new table.
1model Video {
2 id Int @id @default(autoincrement())
3 filename String
4 path String
5 mimetype String
6}We also need to create the appropriate SQL migration.
1npx prisma migrate dev --name add-video-tableIf you want to know more about migrations with Prisma, go to API with NestJS #115. Database migrations with Prisma
Thanks to defining the new table with Prisma, we can now store the information about a particular video in the database.
1import { Injectable } from '@nestjs/common';
2import { PrismaService } from '../prisma/prisma.service';
3import { VideoDto } from './dto/video.dto';
4
5@Injectable()
6export class VideosService {
7 constructor(private readonly prismaService: PrismaService) {}
8
9 create({ path, mimetype, filename }: VideoDto) {
10 return this.prismaService.video.create({
11 data: {
12 path,
13 filename,
14 mimetype,
15 },
16 });
17 }
18}Streaming videos
The most straightforward way to stream files is to create a readable stream using the path to our file and the StreamableFile class.
1import {
2 Injectable,
3 NotFoundException,
4 StreamableFile,
5} from '@nestjs/common';
6import { PrismaService } from '../prisma/prisma.service';a
7import { createReadStream } from 'fs';
8import { join } from 'path';
9
10@Injectable()
11export class VideosService {
12 constructor(private readonly prismaService: PrismaService) {}
13
14 async getVideoMetadata(id: number) {
15 const videoMetadata = await this.prismaService.video.findUnique({
16 where: {
17 id,
18 },
19 });
20
21 if (!videoMetadata) {
22 throw new NotFoundException();
23 }
24
25 return videoMetadata;
26 }
27
28 async getVideoStreamById(id: number) {
29 const videoMetadata = await this.getVideoMetadata(id);
30
31 const stream = createReadStream(join(process.cwd(), videoMetadata.path));
32
33 return new StreamableFile(stream, {
34 disposition: `inline; filename="${videoMetadata.filename}"`,
35 type: videoMetadata.mimetype,
36 });
37 }
38
39 // ...
40}If you want to know more about the StreamableFile class, check the following articles: API with NestJS #54. Storing files inside a PostgreSQL database API with NestJS #55. Uploading files to the server
1import { Controller, Get, Param } from '@nestjs/common';
2import { VideosService } from './videos.service';
3import { FindOneParams } from '../utils/findOneParams';
4
5@Controller('videos')
6export default class VideosController {
7 constructor(private readonly videosService: VideosService) {}
8
9 // ...
10
11 @Get(':id')
12 streamVideo(@Param() { id }: FindOneParams) {
13 return this.videosService.getVideoStreamById(id);
14 }
15}In our frontend application, we need to use the <video /> tag and provide the URL of a video with a particular id.
1<video controls src="http://localhost:3000/videos/1" />Improving the user experience
While the above approach works, it is far from ideal. Its main drawback is that it does not allow the user to forward a video instead of watching it from start to finish. The first step in improving this is sending the Accept-Ranges response header.
By sending the Accept-Ranges header to the browser, we indicate that we support serving parts of a file. A good example is when the user tries to start the video in the middle.
The browser then sends us the Range header that indicates what fragment of our file it needs. It supports specifying multiple different portions of a file, such as:
1Range: bytes=200-1000, 2000-6576, 19000-The numbers specify the ranges using bytes. While we could write the logic of parsing the Range header ourselves, there is a popular library that can do that for us.
1npm install range-parser @types/range-parserTo calculate the precise range of the file we need to serve, the range-parser library needs the maximum size of the resource. To get this information, we use the stat function built into Node.js.
1import { BadRequestException, Injectable } from '@nestjs/common';
2import { PrismaService } from '../prisma/prisma.service';
3import { stat } from 'fs/promises';
4import * as rangeParser from 'range-parser';
5
6@Injectable()
7export class VideosService {
8 constructor(private readonly prismaService: PrismaService) {}
9
10 parseRange(range: string, fileSize: number) {
11 const parseResult = rangeParser(fileSize, range);
12 if (parseResult === -1 || parseResult === -2 || parseResult.length !== 1) {
13 throw new BadRequestException();
14 }
15 return parseResult[0];
16 }
17
18 async getFileSize(path: string) {
19 const status = await stat(path);
20
21 return status.size;
22 }
23
24 // ...
25}The range-parser library returns -1 or -2 when something went wrong with parsing. We can use that to throw the BadRequestException error. In our streaming functionality we only support a single range of video, so we want to throw an error when someone requests more than one range through the Range header.
The last piece of information we need to send to the browser is the Content-Range header. It tells the browser what fragment of the video we are sending. To create this header, we need the information parsed by the range-parser library.
1import { Injectable } from '@nestjs/common';
2import { PrismaService } from '../prisma/prisma.service';
3
4@Injectable()
5export class VideosService {
6 constructor(private readonly prismaService: PrismaService) {}
7
8 getContentRange(rangeStart: number, rangeEnd: number, fileSize: number) {
9 return `bytes ${rangeStart}-${rangeEnd}/${fileSize}`;
10 }
11
12 // ...
13}Thanks to creating all of the above methods, we can now create a function that uses all of them.
1import { Injectable, StreamableFile } from '@nestjs/common';
2import { PrismaService } from '../prisma/prisma.service';
3import { createReadStream } from 'fs';
4import { join } from 'path';
5
6@Injectable()
7export class VideosService {
8 constructor(private readonly prismaService: PrismaService) {}
9
10 // ...
11
12 async getPartialVideoStream(id: number, range: string) {
13 const videoMetadata = await this.getVideoMetadata(id);
14 const videoPath = join(process.cwd(), videoMetadata.path);
15 const fileSize = await this.getFileSize(videoPath);
16
17 const { start, end } = this.parseRange(range, fileSize);
18
19 const stream = createReadStream(videoPath, { start, end });
20
21 const streamableFile = new StreamableFile(stream, {
22 disposition: `inline; filename="${videoMetadata.filename}"`,
23 type: videoMetadata.mimetype,
24 });
25
26 const contentRange = this.getContentRange(start, end, fileSize);
27
28 return {
29 streamableFile,
30 contentRange,
31 };
32 }
33}We need to respond with the 206 Partial Content status code to indicate that the response contains the requested data ranges.
1import { Controller, Get, Param, Header, Headers, Res } from '@nestjs/common';
2import { Response } from 'express';
3import { VideosService } from './videos.service';
4import { FindOneParams } from '../utils/findOneParams';
5
6@Controller('videos')
7export default class VideosController {
8 constructor(private readonly videosService: VideosService) {}
9
10 @Get(':id')
11 @Header('Accept-Ranges', 'bytes')
12 async streamVideo(
13 @Param() { id }: FindOneParams,
14 @Headers('range') range: string,
15 @Res({ passthrough: true }) response: Response,
16 ) {
17 if (!range) {
18 return this.videosService.getVideoStreamById(id);
19 }
20 const { streamableFile, contentRange } =
21 await this.videosService.getPartialVideoStream(id, range);
22
23 response.status(206);
24
25 response.set({
26 'Content-Range': contentRange,
27 });
28
29 return streamableFile;
30 }
31
32 // ...
33}Summary
Thanks to the above approach, we increased the user experience of our video streaming. Whenever the user clicks on the video player, the browser sends a new GET request to our API with a different Range header. We then use this information to serve a stream of the requested fragment of the video. This allows the user to fast-forward or rewind the recording, which is an essential feature of any video streaming service.