Télécharger des vidéos Youtube avec React, Redux et Immutable

Dans ma quêtes d’apprentissage des librairies React et Redux, j’ai du créer plein de petites applications pour bien maitriser le processus de développement au tour de ces librairies, et aujourdhui je souhaite partager avec vous une petite application qui a pour but de télécharger des vidéos youtube au format MP4 sur votre pc.

Objectif

On va créer une application isomorphe autour de React et Redux, du coup première question, qu’est ce qu’une application isomorphe ??

Dans une application SPA classique, le serveur n’est utilisé que pour faire des appels REST pour lire ou écrire des informations, cependant le désavantage est le temps de chargement initial de l’application, ce qui peut donner une latence de quelques secondes à l’ouverture de l’application ( pas bon ).

Pour pallier à ça React nous donne la possibilité de s’exécuter coté serveur, ce qui fait que notre application aura un temps de chargement initial très réduit en plus de l’expérience utilisateur qu’on trouve dans une SPA normale, pour plus de détails je vous invite à lire cet article sur le sujet.

Création du projet

On va commencer par créer un projet, voici le package.json correspondant

{
  "name": "youtube-downloader",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "start": "node .",
    "build": "webpack --progress --color -p --config webpack.prod.config.js"
  },
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "webpack-dev-middleware": "^1.9.0",
    "webpack-hot-middleware": "^2.15.0"
  },
  "dependencies": {
    "axios": "^0.15.3",
    "babel": "^6.3.13",
    "babel-core": "^6.3.21",
    "babel-loader": "^6.2.0",
    "babel-plugin-react-transform": "^2.0.0-beta1",
    "babel-plugin-transform-class-properties": "^6.3.13",
    "babel-plugin-transform-decorators-legacy": "^1.3.4",
    "babel-plugin-transform-object-rest-spread": "^6.3.13",
    "babel-polyfill": "^6.3.14",
    "babel-preset-es2015": "^6.3.13",
    "babel-preset-react": "^6.3.13",
    "body-parser": "^1.15.2",
    "express": "^4.14.0",
    "immutable": "^3.8.1",
    "react": "^15.4.1",
    "react-addons-pure-render-mixin": "^15.4.1",
    "react-addons-test-utils": "^15.4.1",
    "react-dom": "^15.4.1",
    "react-redux": "^4.0.2",
    "react-transform-hmr": "^1.0.4",
    "redux": "^3.0.5",
    "webpack": "^1.14.0",
    "youtube-dl": "^1.11.1"
  }
}

Voici les dépendances qu’on va utiliser :

axios : Pour effectuer des requêtes http.

babel : Pour utiliser la version ES6 de Javascript ainsi que la syntaxe JSX de React.

react : Les dépendances de la librairie React

webpack : pour transpiler le Javascript à utiliser dans la partie front

youtube-dl : pour télécharger les vidéos depuis youtube

Ensuite on ça créer l’arborescence du projet :

arborescence application redux

dans l’arborescence ci dessus on remarque 3 dossiers principaux :

shared : contient l’application React, ce code sera utilisé par le client et le serveur.
client : le code nécessaire pour monter l’application React pour le client.
server : le code exécuté coté serveur, sera executé pour servir la page

On va maintenant voir en détail ce qui se passe dans chaque partie :

Coté serveur

Dans cette partie on va executer le code React coté serveur puis servir le resultat en réponse au client

src/server/index.js

import express from 'express'
import { renderToString } from 'react-dom/server'
import React from 'react';
import { Provider } from 'react-redux';

import webpackMiddleware from '../../webpack.dev'

import downloaderRoutes from './routes'

import {initStore} from '../shared/store'
import {App} from '../shared/components/App'

var app = express();

downloaderRoutes(app)

if (process.env.NODE_ENV !== 'production') {
    webpackMiddleware(app);
}

app.get('/', function (req, res) {
    res.end(renderPage())
});

function renderPage(){

    const store = initStore()

    const componentHTML = renderToString(<Provider store={store}>
                                        <App />
                                    </Provider>);
    return `
      <!DOCTYPE html>
      <html>
        <head>
          <meta charset="utf-8">
          <title>Youtube downloader example</title>

          <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/latest/css/bootstrap.min.css" />
          
        </head>
        <body>
          <div id="app">${componentHTML}</div>
          <script type="application/javascript" src="/bundle.js"></script>
        </body>
      </html>
      `;

}

export default app

Le plus important à retenir dans ce bout de code est la fonction renderToString qui va executer le code React et renvoyer du code HTML, ce code sera alors concaténé au code html renvoyé par le serveur.

En plus de l’initialisation de React, ce code fait appel à une fonction qu’on va appeler downloaderRoutes, dans cette dernière on va spécifier les routes qu’on va utiliser dans notre application pour lancer le téléchargement d’une vidéo youtube :

src/server/routes.js


import fs from 'fs'
import path from 'path'
import mime from 'mime'

import bodyParser from 'body-parser'
import youtubeDownload from './downloader'

export default (app) => {

    app.use(bodyParser.json());
    app.use(bodyParser.urlencoded({ extended: false }));

    app.post('/download', function (req, res) {
        youtubeDownload(req.body.id, () => {
            res.status(200).json({id : req.body.id})
        })
    });

    app.get('/getfile', (req, res) => {
        var rootDir = path.dirname(require.main.filename);

        var file = rootDir + '/' + req.query.id + '.mp4';

        var filename = path.basename(file);
        var mimetype = mime.lookup(file);

        res.setHeader('Content-disposition', 'attachment; filename=' + filename);
        res.setHeader('Content-type', mimetype);

        var filestream = fs.createReadStream(file);
        filestream.on('end', function() {
            fs.unlink(file);
        });
        filestream.pipe(res);

    })

}

Notre objectif final étant de télécharger une vidéo depuis notre application en donnant l’illusion que le tout se fait en une seule requette AJAX, oui j’ai bien dit l’illusion, car on ne peux pas télécharger un fichier en AJAX, du coup plusieurs solutions sont envisageable, j’ai choisis de faire simple en divisant l’action sur 2 requettes :

  • /download : avec cette route on va déclencher le téléchargement d’une vidéo depuis youtube vers le serveur
  • /getfile : pour télécharger et supprimer une video

Dernière brique dans la partie Serveur est le code necessaire pour télécharger la vidéo de youtube vers le serveur :

src/server/downloader.js


var fs = require('fs');
var youtubedl = require('youtube-dl');
import path from 'path'

var rootDir = path.dirname(require.main.filename);


export default function download(videoId, onEnd) {
    var video = youtubedl('http://www.youtube.com/watch?v=' + videoId, ['--format=18'], {cwd: rootDir, maxBuffer: Infinity});

    video.on('info', function (info) {
        console.log('Download started');
        console.log('filename: ' + info._filename);
        console.log('size: ' + info.size);
    });

    var videoFile = rootDir + '/' + videoId + '.mp4';
    video.pipe(fs.createWriteStream(videoFile));

    video.on('end', () => {
        onEnd(videoFile)
    })
}


Ici on va utiliser le module youtube-dl pour télécharger la vidéo sur le serveur, on utiliser l’id de la vidéo comme nom pour le fichier téléchargé, après on va lancer la fonction passée en paramettre onEnd

Maintenant que notre serveur est prêt l’étape suivante est de mettre en place la partie Client de notre application.

Le client

Rappelez vous, l’application React se trouve dans le dossier shared, du coup pour l’utiliser dans le client on ne fait que monter l’application en question sur une balise, voyons ça de plus prêt.

src/client/client.js


import React                from 'react';
import { render }           from 'react-dom';
import { Provider }         from 'react-redux';

import {initStore} from '../shared/store';
import {App} from '../shared/components/App';

const store = initStore();

render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('app')
);


Dans ce code on fait principalement deux choses :

  • Initialisation du store Redux
  • Relier le store Redux et l’application ( représentée par la balise App) à l’aide du composant Provider fournit par Redux.

Maintenant que notre client est prêt, voyons ce qui se passe dans l’application en question présente dans src/shared/

L’application React

L’application en question utilise React et Redux, je ne vais pas m’attarder sur le fonctionnement de Redux, je vous conseille cet article si vous voulez en savoir plus.

L’important à savoir c’est que dans une application React + Redux, on a une notion très importante appelée le state ou état en français.

Un état immutable

L’état est un objet représentant à un instant T ce que vous devez voir sur votre écran, cela permet une meilleure maitrise de ce qui se passe sur votre application.

En prenant en compte cette information, on ajoute souvant au couple React / Redux une troisième brique appelée Immutable, ce dernier se charge de créer un nouveau Objet d’état à chaque modification, ce qui nous permet de garder une trace ét éventuellement ( à l’aide d’une extension chrome) revenir en arrière pour suivre le cheminement des actions effectuée, pratique pour traquer les bugs, je vous invite à lire cet article pour plus de détails

connecter l’application à Redux

Le plus important à garder en tête c’est qu’en Redux, on change l’état dans des Reducers suite au déclenchement d’une Action, Je sais que ça en fait trop, mais ça va devenir plus clair en voyant le code.

On va commencer par voir ce qui se passe dans le composant principale qu’on va appeler App.

src/shared/components/App.jsx


import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';

import {connect} from 'react-redux'
import * as actionCreators from '../actions'

import YoutubeForm from './YoutubeForm'

class AppCmp extends React.Component {
    constructor(props) {
        super(props);
        this.shouldComponentUpdate = PureRenderMixin.shouldComponentUpdate.bind(this);
    }

    render() {
        let downloadingMessage = ""
        let errorMessage = ""

        if(this.props.isDownloading){
            downloadingMessage = <h4>Downloading ...</h4>;
        }
        if(this.props.isError){
            errorMessage = <h4>An error occured</h4>;
        }

        return <div className="container">
            <div className="row">
                <div className="col-md-6 col-md-offset-3">
                    <h2>Enter a youtube URL</h2>
                    {errorMessage}

                    <YoutubeForm downloadVideo={this.props.downloadVideoAction} />

                    {downloadingMessage}
                </div>
            </div>
        </div>
    }
};

function mapStateToProps(state) {
    return {
        isDownloading: state.downloader.get('isDownloading'),
        isError: state.downloader.get('isError')
    }
}

export const App = connect(mapStateToProps, actionCreators)(AppCmp)

Ici on va faire principalement deux choses :

  • Poser la structure de l’html final
  • Appeler un composant YoutubeForm qui contiendra notre formulaire
  • Connecter le store Redux au composant App

Dans notre application les composant sont en « lecture seule », ce que j’entends par ça c’est qu’on ne change jamais l’état depuis un composant, on ne fait que lire des informations, ceci est fait en utilisant des props ( plus de détails ici )

Du coup en connectant un composant à Redux on a fait ici deux choses :

  • On a mappé des informations du state dans des props à l’aide de la fonction mapStateToProps
  • On a mappé les actionCreators ( ou créateurs d’actions) présentes dans src/shared/actions/index.js (voir le deuxième paramètre de la fonction connect

finalement on expose le composant connecté.

Maintenant qu’on a connecté le composant principal, on va voir ce qui se passe dans le composant contenant le formulaire.

src/shared/components/YoutubeForm.jsx


import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';

export default class YoutubeForm extends React.Component {
    constructor(props) {
        super(props);
        this.shouldComponentUpdate = PureRenderMixin.shouldComponentUpdate.bind(this);
    }

    _handleSubmit(e){
        e.preventDefault()
        let url = this.refs.videoId.value
        var urlParts = url.split('?v=')
        var videoId = urlParts.length > 1 ? urlParts[1] : url
        this.props.downloadVideo(videoId)
    }

    render() {
        return <form onSubmit={this._handleSubmit.bind(this)}>
                <div className="form-group">
                    <input ref="videoId"
                           className="form-control"
                           placeholder="https://youtube.com..." />
                </div>
            </form>

    }
};


Une chose à remarquer ici c’est que ce composant n’est pas directement connecté à Redux, en effet il ne sait même pas qu’on utilise Redux, remarquez que durant le submit du formulaire, il ne fait qu’appeler la fonction this.props.downloadVideo, cette dernière lui a été fournie par le composant principal App, qui à son tour l’a pioché dans les actionCreactors durant le processus de connexion à Redux.

Regardons donc le code de notre action creator:

src/shared/actions/index.js


import request from 'axios';

export function downloadVideoAction(videoId){
    return function(dispatch){
        dispatch({ type: 'BEGIN_DOWNLOAD' })

        return request.post('/download', {id: videoId})
            .then(function(res){
                dispatch({ type: 'END_DOWNLOAD'})
                window.location = '/getfile?id=' + res.data.id
            })
            .catch(function(response){
                dispatch({ type: 'ERROR_DOWNLOAD' })
            })
    }

}

Ici on va notifier Redux des actions qu’on va faire afin de permettre au Reducers de décider des impacts de ces actions sur le state, étant donné qu’on souhaite effectuer une requette asynchronne, on déclenche 3 actions :

  • BEGIN_DOWNLOAD : notifier du début de la requette
  • END_DOWNLOAD : notifier que la requette s’est effectuée avec succès
  • BEGIN_DOWNLOAD : notifier qu’il y a eu une erreur durant la requette

Les actions creators ne sont donc qu’un médiateur entre le composant et les Reducers, dans notre cas on n’a qu’un seul reducers, voici le code correspondant :

import {Map} from 'immutable';

export default function(state = Map(), action){
    switch(action.type){
        case 'BEGIN_DOWNLOAD':
            return state.merge({
                isDownloading: true,
                isError: false
            })
        case 'ERROR_DOWNLOAD':
            return state.merge({
                isDownloading: false,
                isError: false
            });
        case 'END_DOWNLOAD':
            return state.merge({
                isDownloading: false,
                isError: false
            })
    }
    return state
}

Comme vous le voyez le Reducer ne fait que décider de ce qui va changer dans le state de l’application après une action donnée, après quoi il retourne un nouvel objet state, il va ensuite notifier le composant connecté ( App dans notre cas), du changement, qui va à son tour piocher les informations du states et les mettre dans ces props, puis les props des composants enfants et ainsi de suite..

Resultat final

Maintenant que tout est en place, il suffit de tester sur votre navigateur, pour cela mettez dans le champ texte l’url vers la vidéo youtube que vous souhaitez suivie de la touche entré.

Le code source sur GitHub

Le code est disponible sur github, pour tester sur votre poste clonez le repo puis lancez les commandes :

  npm install
  npm start
  

Conclusion

React / Redux nous proposent une nouvelle façon de voir le développement d’applications web, j’ai bien apprécié le fait que les composants React soient complétement découplée du state de l’application, ça ça permet de garder le code bien isolé et du coup facilement testable.

Alors il est vrai que ça peut paraitre déroutant au début, surtout quand on est biaisé par un autre framework ( Angular dans mon cas), mais je vous invite à prendre le temps d’apprendre et de tester sur des petits projets ne serait-ce que pour votre culture générale.

N’hésitez pas à poser vos question dans la zone de commentaires si vous trouvez des zones d’ombre dans l’article.

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *