2

API with NestJS #118. Uploading and streaming videos

 9 months ago
source link: https://wanago.io/2023/07/31/api-nestjs-streaming-videos/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

API with NestJS #118. Uploading and streaming videos

We value your privacy
We use cookies on our website. We would like to ask for your consent to store them on your device. We will not run optional cookies until you enable them. If you want to know more about how cookies work, please visit our Privacy Policy page.

API with NestJS #118. Uploading and streaming videos

NestJS

July 31, 2023
This entry is part 118 of 119 in the API with NestJS

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.

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.

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 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.

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 {}
UPLOADED_FILES_DESTINATION=./uploadedFiles

Thanks 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-table

If 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.

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: {
    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 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

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-parser

To 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-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.

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.

Series Navigation<< API with NestJS #117. CORS – Cross-Origin Resource SharingAPI with NestJS #119. Type-safe SQL queries with Kysely and PostgreSQL >>
Subscribe
guest
Label
1 Comment
Oldest
Andromadus Naruto

This was a good read. I also learnt a lot about streaming videos over a REST API. Thanks @Marcin

Reply

wpDiscuz


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK