Uploading and streaming videos
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…
July 31, 2023
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.
videos.controller.ts#
import { Controller, Post, UseInterceptors, UploadedFile } from "@nestjs/common"
import { Express } from "express"
import { VideosService } from "./videos.service"
import { FileInterceptor } from "@nestjs/platform-express"
import { diskStorage } from "multer"
@Controller("videos")
export default class VideosController {
constructor(private readonly videosService: VideosService) {}
@Post()
@UseInterceptors(
FileInterceptor("file", {
storage: diskStorage({
destination: "./uploadedFiles/videos",
}),
}),
)
async addVideo(@UploadedFile() file: Express.Multer.File) {
return this.videosService.create({
filename: file.originalname,
path: file.path,
mimetype: file.mimetype,
})
}
}Whenever we make a valid POST request to the API, NestJS stores the uploaded videos in the ./uploadedFiles/videos directory.
Previously, 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.
videos.controller.ts#
import { FileInterceptor } from "@nestjs/platform-express"
import { Injectable, mixin, NestInterceptor, Type } from "@nestjs/common"
import { ConfigService } from "@nestjs/config"
import { MulterOptions } from "@nestjs/platform-express/multer/interfaces/multer-options.interface"
import { diskStorage } from "multer"
interface LocalFilesInterceptorOptions {
fieldName: string
path?: string
fileFilter?: MulterOptions["fileFilter"]
}
function LocalFilesInterceptor(options: LocalFilesInterceptorOptions): Type<NestInterceptor> {
@Injectable()
class Interceptor implements NestInterceptor {
fileInterceptor: NestInterceptor
constructor(configService: ConfigService) {
const filesDestination = configService.get("UPLOADED_FILES_DESTINATION")
const destination = `${filesDestination}${options.path}`
const multerOptions: MulterOptions = {
storage: diskStorage({
destination,
}),
fileFilter: options.fileFilter,
}
this.fileInterceptor = new (FileInterceptor(options.fieldName, multerOptions))()
}
intercept(...args: Parameters<NestInterceptor["intercept"]>) {
return this.fileInterceptor.intercept(...args)
}
}
return mixin(Interceptor)
}
export default LocalFilesInterceptorTo use our custom interceptor, we need to add UPLOADED_FILES_DESTINATION to our environment variables.
app.module.ts#
import { Module } from "@nestjs/common"
import { ConfigModule } from "@nestjs/config"
import * as Joi from "joi"
import { VideosModule } from "./videos/videos.module"
@Module({
imports: [
// ...
ConfigModule.forRoot({
validationSchema: Joi.object({
// ...
UPLOADED_FILES_DESTINATION: Joi.string().required(),
}),
}),
VideosModule,
],
controllers: [],
providers: [],
})
export class AppModule {}.env#
# ...
UPLOADED_FILES_DESTINATION=./uploadedFilesThanks to all of the above, we can now take advantage of our custom interceptor in the videos controller.
videos.controller.ts#
import {
Controller,
Post,
UseInterceptors,
UploadedFile,
BadRequestException,
} from "@nestjs/common"
import { Express } from "express"
import LocalFilesInterceptor from "../utils/localFiles.interceptor"
import { VideosService } from "./videos.service"
@Controller("videos")
export default class VideosController {
constructor(private readonly videosService: VideosService) {}
@Post()
@UseInterceptors(
LocalFilesInterceptor({
fieldName: "file",
path: "/videos",
fileFilter: (request, file, callback) => {
if (!file.mimetype.includes("video")) {
return callback(new BadRequestException("Provide a valid video"), false)
}
callback(null, true)
},
}),
)
addVideo(@UploadedFile() file: Express.Multer.File) {
return this.videosService.create({
filename: file.originalname,
path: file.path,
mimetype: file.mimetype,
})
}
}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.
videoSchema.prisma#
model Video {
id Int @id @default(autoincrement())
filename String
path String
mimetype String
}We also need to create the appropriate SQL migration.
npx prisma migrate dev --name add-video-tableThanks to defining the new table with Prisma, we can now store the information about a particular video in the database.
videos.service.ts#
import { Injectable } from "@nestjs/common"
import { PrismaService } from "../prisma/prisma.service"
import { VideoDto } from "./dto/video.dto"
@Injectable()
export class VideosService {
constructor(private readonly prismaService: PrismaService) {}
create({ path, mimetype, filename }: VideoDto) {
return this.prismaService.video.create({
data: {
path,
filename,
mimetype,
},
})
}
}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.
videos.service.ts#
import { Injectable, NotFoundException, StreamableFile } from "@nestjs/common"
import { PrismaService } from "../prisma/prisma.service"
a
import { createReadStream } from "fs"
import { join } from "path"
@Injectable()
export class VideosService {
constructor(private readonly prismaService: PrismaService) {}
async getVideoMetadata(id: number) {
const videoMetadata = await this.prismaService.video.findUnique({
where: {
id,
},
})
if (!videoMetadata) {
throw new NotFoundException()
}
return videoMetadata
}
async getVideoStreamById(id: number) {
const videoMetadata = await this.getVideoMetadata(id)
const stream = createReadStream(join(process.cwd(), videoMetadata.path))
return new StreamableFile(stream, {
disposition: `inline; filename="${videoMetadata.filename}"`,
type: videoMetadata.mimetype,
})
} // ...
}If you want to know more about the
StreamableFileclass, check the following articles:
videos.controller.ts#
import { Controller, Get, Param } from "@nestjs/common"
import { VideosService } from "./videos.service"
import { FindOneParams } from "../utils/findOneParams"
@Controller("videos")
export default class VideosController {
constructor(private readonly videosService: VideosService) {} // ...
@Get(":id")
streamVideo(@Param() { id }: FindOneParams) {
return this.videosService.getVideoStreamById(id)
}
}In our frontend application, we need to use the <video /> tag and provide the URL of a video with a particular id.
<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:
Range: 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.
npm 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.
videos.service.ts#
import { BadRequestException, Injectable } from "@nestjs/common"
import { PrismaService } from "../prisma/prisma.service"
import { stat } from "fs/promises"
import * as rangeParser from "range-parser"
@Injectable()
export class VideosService {
constructor(private readonly prismaService: PrismaService) {}
parseRange(range: string, fileSize: number) {
const parseResult = rangeParser(fileSize, range)
if (parseResult === -1 || parseResult === -2 || parseResult.length !== 1) {
throw new BadRequestException()
}
return parseResult[0]
}
async getFileSize(path: string) {
const status = await stat(path)
return status.size
} // ...
}The
range-parserlibrary returns-1or-2when something went wrong with parsing. We can use that to throw theBadRequestExceptionerror. 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 theRangeheader.
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.
videos.service.ts#
import { Injectable } from "@nestjs/common"
import { PrismaService } from "../prisma/prisma.service"
@Injectable()
export class VideosService {
constructor(private readonly prismaService: PrismaService) {}
getContentRange(rangeStart: number, rangeEnd: number, fileSize: number) {
return `bytes ${rangeStart}-${rangeEnd}/${fileSize}`
} // ...
}Thanks to creating all of the above methods, we can now create a function that uses all of them.
videos.service.ts#
import { Injectable, StreamableFile } from "@nestjs/common"
import { PrismaService } from "../prisma/prisma.service"
import { createReadStream } from "fs"
import { join } from "path"
@Injectable()
export class VideosService {
constructor(private readonly prismaService: PrismaService) {} // ...
async getPartialVideoStream(id: number, range: string) {
const videoMetadata = await this.getVideoMetadata(id)
const videoPath = join(process.cwd(), videoMetadata.path)
const fileSize = await this.getFileSize(videoPath)
const { start, end } = this.parseRange(range, fileSize)
const stream = createReadStream(videoPath, { start, end })
const streamableFile = new StreamableFile(stream, {
disposition: `inline; filename="${videoMetadata.filename}"`,
type: videoMetadata.mimetype,
})
const contentRange = this.getContentRange(start, end, fileSize)
return {
streamableFile,
contentRange,
}
}
}We need to respond with the 206 Partial Content status code to indicate that the response contains the requested data ranges.
videos.controller.ts#
import { Controller, Get, Param, Header, Headers, Res } from "@nestjs/common"
import { Response } from "express"
import { VideosService } from "./videos.service"
import { FindOneParams } from "../utils/findOneParams"
@Controller("videos")
export default class VideosController {
constructor(private readonly videosService: VideosService) {}
@Get(":id")
@Header("Accept-Ranges", "bytes")
async streamVideo(
@Param() { id }: FindOneParams,
@Headers("range") range: string,
@Res({ passthrough: true }) response: Response,
) {
if (!range) {
return this.videosService.getVideoStreamById(id)
}
const { streamableFile, contentRange } = await this.videosService.getPartialVideoStream(
id,
range,
)
response.status(206)
response.set({
"Content-Range": contentRange,
})
return streamableFile
} // ...
}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.