Video streaming con Nest JS

elbernv

Elber

Posted on September 20, 2022

Video streaming con Nest JS

Hace poco, en un proyecto en el que me encontraba trabajando existía un modulo de vídeos, el cual consistía en simplemente en subir el vídeo a un servicio de terceros (Youtube, Twitch, etc...) y luego guardar la URL generada por este servicio en la base de datos. Cuando el cliente solicitaba este vídeo la API simplemente retornaba la URL que apuntaba al respectivo servicio donde se subió el vídeo. Todo esto funcionaba bien hasta que se decidió almacenar los vídeos dentro del servidor donde estaba alojada la aplicación y servirlos desde ahí, es decir, tenia que crear un servicio de streaming para servir los vídeos como lo haría Youtube o Twitch. En este ejemplo lo haremos desde un proyecto NestsJS desde cero, Let's go!!!

Iniciación del proyecto

Primero que nada, crearemos el proyecto Nest desde cero, como lo explica la documentación:

$ nest new nestjs-video-streaming
Enter fullscreen mode Exit fullscreen mode

Seleccionamos nuestro gestor de paquetes favoritos, en mi caso npm:

gestor de paquetes

Una vez instaladas todas las dependencias ya tendremos el proyecto iniciado y listo para codear, ahora arrancamos el servidor de desarrollo:

$ cd nestjs-video-streaming
$ npm run start:dev
Enter fullscreen mode Exit fullscreen mode

Por defecto, la aplicación abrirá en el puerto 3000, pero puedes cambiarlo si así deseas, yo lo dejaré en ese mismo puerto.

Estructura del proyecto

Para este caso, utilizaré los archivos app.controller.ts y app.service.ts que se generan al iniciar el proyecto, pero tú podrás adaptarlo a tu proyecto como mejor te convenga.

Luego, crearé una carpeta en la raíz del proyecto llamada videos y agregaré 3 vídeos de prueba. Debería quedar algo más o menos así:

project structure

Ruta para obtener todos los vídeos

En app.controller.ts crearemos la ruta para listar todos los vídeos disponibles en la API, quedando así:

import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get('all-videos')
  getAllVideos(): Array<{
    name: string;
    url: string;
  }> {
    return this.appService.getAllVideos();
  }
}

Enter fullscreen mode Exit fullscreen mode

Y nuestro servicio quedaría así:

import { Injectable } from '@nestjs/common';

import { readdirSync } from 'fs';

@Injectable()
export class AppService {
  public getAllVideos(): Array<{
    name: string;
    url: string;
  }> {
    const baseUrl: string = 'http://localhost:3000';

    const allFiles: Array<string> = readdirSync('videos/');

    const finalResponse: Array<{
      name: string;
      url: string;
    }> = [];

    for (const file of allFiles) {
      finalResponse.push({ name: `${file}`, url: `${baseUrl}/video/${file}` });
    }

    return finalResponse;
  }
}

Enter fullscreen mode Exit fullscreen mode

Con esto ya podemos listar todos los vídeos que están disponibles en la API con su respectiva URL, probemos con postman para ver la respuesta:

postman test1

Un detalle bastante importante es que en la respuesta, la url de todos los vídeos apuntan al servidor local más el nombre del vídeo, esto es así para que podamos buscar el vídeo por su nombre y servirlo, en caso de recibir un nombre de vídeo que no exista lanzaremos un BadRequestException. la constante baseUrl la puedes cambiar para que apunte a tu servidor de producción, lo recomendable seria tenerlo en una variable de entorno:

const baseUrl: string = 'http://localhost:3000';
Enter fullscreen mode Exit fullscreen mode

Ahora bien, nos faltaría los mas importante, la ruta de streaming de vídeo, creamos la ruta en AppController:

import { Controller, Get, Param, Response, Req } from '@nestjs/common';
import { Response as IResponse, Request } from 'express';

import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get('all-videos')
  getAllVideos(): Array<{
    name: string;
    url: string;
  }> {
    return this.appService.getAllVideos();
  }

  @Get('video/:name')
  streamVideo(
    @Param('name') name: string,
    @Response() response: IResponse,
    @Req() request: Request,
  ) {
    this.appService.streamVideo(name, response, request);
  }
}


Enter fullscreen mode Exit fullscreen mode

Y ahora lo más importante, el servicio:

import { Injectable, NotFoundException } from '@nestjs/common';
import { Response, Request } from 'express';

import { createReadStream, readdirSync, statSync } from 'fs';

@Injectable()
export class AppService {
  public getAllVideos(): Array<{
    name: string;
    url: string;
  }> {
    const baseUrl: string = 'http://localhost:3000';

    const allFiles: Array<string> = readdirSync('videos/');

    const finalResponse: Array<{
      name: string;
      url: string;
    }> = [];

    for (const file of allFiles) {
      finalResponse.push({ name: `${file}`, url: `${baseUrl}/video/${file}` });
    }

    return finalResponse;
  }

  public streamVideo(name: string, response: Response, request: Request) {
    const isValidVideo = this.isValidVideo(name);

    if (!isValidVideo) {
      throw new NotFoundException('El video no existe');
    }

    const { range } = request.headers;

    if (!range) {
      throw new NotFoundException('range not found');
    }

    const path = `videos/${name}`;
    const videoSize = statSync(path).size;
    const chunksize = 1 * 1e6;
    const start = Number(range.replace(/\D/g, ''));
    const end = Math.min(start + chunksize, videoSize - 1);
    const contentLength = end - start + 1;

    const headers = {
      'Content-Range': `bytes ${start}-${end}/${videoSize}`,
      'Accept-Ranges': 'bytes',
      'Content-Length': contentLength,
      'Content-Type': 'video/mp4',
    };

    response.writeHead(206, headers);

    const stream = createReadStream(path, { start, end });

    stream.pipe(response);
  }

  private isValidVideo(name: string) {
    const allFiles: Array<string> = readdirSync('videos/');

    return allFiles.includes(name);
  }
}


Enter fullscreen mode Exit fullscreen mode

Muy bien, ahora explicaré detalladamente el método streamVideo y isValidVideo. En primer lugar, el método isValidVideo se encarga de decir si el vídeo existe dentro de la aplicación retornando un booleano, y en el método streamVideo lanzamos la excepción si no existe el vídeo.

const isValidVideo = this.isValidVideo(name);

if (!isValidVideo) {
  throw new NotFoundException('El video no existe');
}
Enter fullscreen mode Exit fullscreen mode

Tenemos que asegurarnos de que existe el parámetro range en el header de la petición. De lo contrario, no sabremos que parte del vídeo debemos mandar. Las declaraciones if se encargan de esto, devolviendo un error 404 alertando al cliente de que necesita una cabecera de rango:

const { range } = request.headers;

if (!range) {
  throw new NotFoundException('range not found');
}
Enter fullscreen mode Exit fullscreen mode

También necesitamos proporcionar la ruta y el tamaño del vídeo:

const path = `videos/${name}`;
const videoSize = statSync(path).size;
Enter fullscreen mode Exit fullscreen mode

El chunk size vendria siendo la cantidad de datos que enviará el servidor por partes, en este caso, se enviará de 1mb en 1mb:

const chunksize = 1 * 1e6;
Enter fullscreen mode Exit fullscreen mode

Luego, se define el inicio desde donde se enviaran los datos del video, el final además del tamaño del video:

const start = Number(range.replace(/\D/g, ''));
const end = Math.min(start + chunksize, videoSize - 1);
const contentLength = end - start + 1;
Enter fullscreen mode Exit fullscreen mode

A continuación definimos los encabezados de respuestas:

const headers = {
  'Content-Range': `bytes ${start}-${end}/${videoSize}`,
  'Accept-Ranges': 'bytes',
  'Content-Length': contentLength,
  'Content-Type': 'video/mp4',
};
Enter fullscreen mode Exit fullscreen mode

Y por ultimo, creamos el objeto stream y madamos la respuesta:

response.writeHead(206, headers);

const stream = createReadStream(path, { start, end });

stream.pipe(response);
Enter fullscreen mode Exit fullscreen mode

Con esto tendríamos terminado el servicio de streaming, ahora para probarlo con postman tenemos que setear el rango en el encabezado de la petición, de la siguiente manera:

postman2

Ejecutamos la petición y listo!

postman3

En el postman aparece un reproductor con nuestro video!

En este tutorial, hemos aprendido a construir nuestro propio servidor de streaming de vídeo utilizando Nestjs. Siguiendo los pasos de este tutorial, puedes construir tu propio servidor de streaming de vídeo que puedes integrar en tu propia aplicación. Espero que hayas disfrutado de este artículo!.

El repositorio lo puedes conseguir aquí.

💖 💪 🙅 🚩
elbernv
Elber

Posted on September 20, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Video streaming con Nest JS
spanish Video streaming con Nest JS

September 20, 2022