├── test ├── corrupted.wav ├── test.wav ├── integrationTest.js ├── EncoderTest.js └── curl_test.bash ├── uploads └── index.js ├── docker-compose.yml ├── app ├── config.js ├── routes │ └── routes.js └── encoder.js ├── audio └── index.js ├── .gitignore ├── app.js ├── package.json ├── Dockerfile └── README.md /test/corrupted.wav: -------------------------------------------------------------------------------- 1 | corrupted audio -------------------------------------------------------------------------------- /test/test.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benmangold/ffmpeg-service/HEAD/test/test.wav -------------------------------------------------------------------------------- /uploads/index.js: -------------------------------------------------------------------------------- 1 | /* Uploaded files are stored in this folder, and then deleted upon encoding */ -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | app: 4 | build: . 5 | command: npm run dev 6 | ports: 7 | - "3000:3000" 8 | -------------------------------------------------------------------------------- /app/config.js: -------------------------------------------------------------------------------- 1 | exports.MP3_CODEC = 'libmp3lame'; 2 | exports.M4A_CODEC = 'libfdk_aac'; 3 | exports.FFMPEG_ERROR = 'FFMPEG Runtime Error'; 4 | exports.FILE_LIMIT = '200mb'; 5 | -------------------------------------------------------------------------------- /audio/index.js: -------------------------------------------------------------------------------- 1 | /* this folder is targeted by test/curl_test.bash */ 2 | 3 | /* fill it with audio files input/a.wav, input/b.wav, input/c.wav ... input/i.wav to run test/curl_test.bash yourself */ -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.avi 2 | *.mp3 3 | *.m4a 4 | *.zip 5 | 6 | .dockerignore 7 | .ebextensions 8 | 9 | public 10 | package-lock.json 11 | input 12 | 13 | node-ffmpeg.zip 14 | 15 | /node_modules 16 | 17 | # Elastic Beanstalk Files 18 | .elasticbeanstalk/* 19 | !.elasticbeanstalk/*.cfg.yml 20 | !.elasticbeanstalk/*.global.yml 21 | 22 | eslist 23 | 24 | # files used by test/curl_test.bash 25 | /audio/input/*.wav 26 | 27 | # target directory for test/curl_test.bash 28 | /audio/output/* -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const app = express(); 3 | 4 | /* Winston Logger Configuration */ 5 | const winston = require('winston'); 6 | winston.remove(winston.transports.Console); 7 | winston.add(winston.transports.Console, { timestamp: true }); 8 | 9 | /* Audio Conversion Routes - MP3, M4A */ 10 | const encoderRoutes = require(`${__dirname}/app/routes/routes`); 11 | app.use('/', encoderRoutes); 12 | 13 | /* Expose README.md to appropriate GET routes */ 14 | require('express-readme')(app, { 15 | filename: 'README.md', 16 | routes: ['/', '/readme'], 17 | }); 18 | 19 | /* Initialize Server */ 20 | app.listen(3000, function() { 21 | winston.info('Launching App localhost:3000'); 22 | }); 23 | 24 | module.exports = app; 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-ffmpeg", 3 | "version": "1.0.0", 4 | "description": "audio utilities", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "mocha", 8 | "dev": "nodemon app.js" 9 | }, 10 | "author": "", 11 | "license": "ISC", 12 | "dependencies": { 13 | "body-parser": "^1.17.1", 14 | "chai": "^3.5.0", 15 | "express": "^4.15.2", 16 | "express-readme": "0.0.5", 17 | "ffmpeg": "0.0.4", 18 | "file-system": "^2.2.2", 19 | "fluent-ffmpeg": "^2.1.2", 20 | "formidable": "^1.1.1", 21 | "fs": "0.0.1-security", 22 | "http-server": "^0.10.0", 23 | "mocha": "^3.3.0", 24 | "path": "^0.12.7", 25 | "winston": "^2.3.1" 26 | }, 27 | "devDependencies": { 28 | "mocha": "^3.3.0", 29 | "nodemon": "^1.18.9", 30 | "supertest": "^3.0.0" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /test/integrationTest.js: -------------------------------------------------------------------------------- 1 | const app = require('../app'); 2 | const chai = require('chai'); 3 | const request = require('supertest'); 4 | 5 | const expect = chai.expect; 6 | 7 | describe('README API Tests', function() { 8 | it('should return 200 status', function(done) { 9 | request(app) 10 | .get('/') 11 | .end(function(err, res) { 12 | expect(res.statusCode).to.equal(200); 13 | done(); 14 | }); 15 | }); 16 | }); 17 | 18 | describe('README API Tests', function() { 19 | it('should return 200 status', function(done) { 20 | request(app) 21 | .get('/readme') 22 | .end(function(err, res) { 23 | expect(res.statusCode).to.equal(200); 24 | done(); 25 | }); 26 | }); 27 | }); 28 | 29 | describe('README API Tests', function() { 30 | it('should return 500 status m4a', function(done) { 31 | request(app) 32 | .post('/m4a') 33 | .end(function(err, res) { 34 | expect(res.statusCode).to.equal(500); 35 | done(); 36 | }); 37 | }); 38 | }); 39 | 40 | describe('README API Tests', function() { 41 | it('should return 500 status mp3', function(done) { 42 | request(app) 43 | .post('/mp3') 44 | .end(function(err, res) { 45 | expect(res.statusCode).to.equal(500); 46 | done(); 47 | }); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /test/EncoderTest.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const fs = require('fs'); 3 | 4 | const encoder = require('../app/encoder.js'); 5 | const consts = require('../app/config.js'); 6 | 7 | describe('Encoder', function() { 8 | describe('#encodeMP3', function() { 9 | it('should return the encoded filename', function(done) { 10 | encoder.encode( 11 | fs.readFileSync(__dirname + '/test.wav'), 12 | consts.MP3_CODEC, 13 | '123', 14 | function(err, filename) { 15 | err ? assert.equal(0,1) : null 16 | assert.equal(filename, 'output123.mp3'); 17 | fs.unlinkSync(filename); 18 | done(); 19 | } 20 | ); 21 | }); 22 | }); 23 | 24 | describe('#encodeM4A', function() { 25 | it('should return the encoded filename', function(done) { 26 | encoder.encode( 27 | fs.readFileSync(__dirname + '/test.wav'), 28 | consts.M4A_CODEC, 29 | '123', 30 | function(err, filename) { 31 | err ? assert.equal(0,1) : null 32 | assert.equal(filename, 'output123.m4a'); 33 | fs.unlinkSync(filename); 34 | done(); 35 | } 36 | ); 37 | }); 38 | }); 39 | 40 | describe('#encoderError', function() { 41 | it('should return consts.FFMPEG_ERROR', function(done) { 42 | encoder.encode( 43 | fs.readFileSync(__dirname + '/corrupted.wav'), 44 | consts.M4A_CODEC, 45 | '123', 46 | function(err, val) { 47 | err ? assert.equal(1,1) : null 48 | assert.equal(err.indexOf(consts.FFMPEG_ERROR), 0); 49 | value = val; 50 | done(); 51 | } 52 | ); 53 | }); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ampervue/ffmpeg 2 | 3 | # https://github.com/ampervue/docker-fluent-ffmpeg 4 | # https://hub.docker.com/r/dkarchmervue/fluent-ffmpeg/ 5 | 6 | MAINTAINER David Karchmer 7 | 8 | ##################################################################### 9 | # 10 | # A Docker image with everything needed to run Moviepy scripts 11 | # 12 | # Image based on ampervue/ffmpeg (Ubuntu 14.04) 13 | # 14 | # with 15 | # - Latest Python 3.4 16 | # - Latest FFMPEG (built) 17 | # - NodeJS 18 | # - fluent-ffmpeg 19 | # 20 | # For more on Fluent-FFMPEG, see 21 | # 22 | # https://github.com/fluent-ffmpeg/node-fluent-ffmpeg 23 | # 24 | # plus a bunch of build/web essentials 25 | # 26 | ##################################################################### 27 | 28 | # Add the following two dependencies for nodejs 29 | RUN curl -sL https://deb.nodesource.com/setup_7.x | sudo -E bash - 30 | RUN apt-get update -qq && apt-get install -y --force-yes \ 31 | nodejs; \ 32 | apt-get clean 33 | 34 | WORKDIR /usr/local/src 35 | 36 | # Custom Builds go here 37 | RUN npm install -g fluent-ffmpeg 38 | 39 | # Remove all tmpfile and cleanup 40 | # ================================= 41 | WORKDIR /usr/local/ 42 | RUN rm -rf /usr/local/src 43 | RUN apt-get autoremove -y; apt-get clean -y 44 | # ================================= 45 | 46 | # Setup a working directory to allow for 47 | # docker run --rm -ti -v ${PWD}:/work ... 48 | # ======================================= 49 | WORKDIR /work 50 | 51 | 52 | # Let's make sure the app built correctly 53 | RUN ffmpeg -buildconf 54 | 55 | # Make sure Node.js is installed 56 | RUN node -v 57 | RUN npm -v 58 | 59 | #Create app dir 60 | RUN mkdir -p /usr/src/app 61 | WORKDIR /usr/src/app 62 | 63 | #Install Dependencies 64 | COPY package.json /usr/src/app 65 | RUN npm install 66 | 67 | #Bundle app source 68 | COPY . /usr/src/app 69 | 70 | EXPOSE 3000 71 | CMD [ "node", "app.js" ] -------------------------------------------------------------------------------- /app/routes/routes.js: -------------------------------------------------------------------------------- 1 | const { 2 | MP3_CODEC, 3 | M4A_CODEC, 4 | FILE_LIMIT, 5 | } = require('../config.js'); 6 | 7 | const express = require('express'); 8 | const router = express.Router(); 9 | const fs = require('fs'); 10 | 11 | /* ffmpeg encoder module */ 12 | const encoder = require('../encoder.js'); 13 | 14 | /* Media Files will be uploaded as Binary Blobs of Bytes */ 15 | const bodyParser = require('body-parser'); 16 | const rawBodyParser = bodyParser.raw({ type: '*/*', limit: FILE_LIMIT }); 17 | 18 | /* Winston Logger - Configured in app.js */ 19 | const winston = require('winston'); 20 | 21 | /* MP3 Route */ 22 | router.post('/mp3', rawBodyParser, function(req, res) { 23 | winston.info('Request Recieved - MP3'); 24 | encodeAndDownload(MP3_CODEC, req.body, res); 25 | }); 26 | 27 | /* M4A Route */ 28 | router.post('/m4a', rawBodyParser, function(req, res) { 29 | winston.info('Request Recieved - M4A'); 30 | encodeAndDownload(M4A_CODEC, req.body, res); 31 | }); 32 | 33 | const generateId = () => parseInt(Math.random() * 1000000000) 34 | 35 | /** Encodes a file, sending a file download response to the clietn 36 | * @param {string} codec - Audio Codec Enum value (from constants.js) 37 | * @param {file} file - Unencoded audio file 38 | * @param {res} res - express response for download or error 39 | */ 40 | function encodeAndDownload(codec, file, res) { 41 | winston.info(`Launching ${codec} Encoding Job`); 42 | encoder.encode(file, codec, generateId(), (err, output) => { 43 | if (err) { 44 | winston.error(`Encoder Error ${err}`); 45 | res.status(500).send(); 46 | } else { 47 | winston.info(`Downloading Encoded ${codec} File ${output}`); 48 | res.download(output, 'output', (err, res) => { 49 | if (err) { 50 | winston.error(`Download Error ${err}`); 51 | res.status(500).send(); 52 | } else { 53 | fs.unlink(output, (err, res) => { 54 | if (err) { 55 | winston.error(`File Deletion Error${err}`); 56 | res.status(500).send(); 57 | } 58 | winston.info(`Deleting encoded file ${output}`); 59 | }); 60 | } 61 | }); 62 | } 63 | }); 64 | } 65 | 66 | module.exports = router; 67 | -------------------------------------------------------------------------------- /test/curl_test.bash: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # a local load test 4 | 5 | # this script depends on .wav files i have ignored in audio/ 6 | # run from multiple terminals for best results 7 | 8 | curl --request POST --data-binary "@test/test.wav" 127.0.0.1:3000/mp3 -o audio/output/file.mp3 9 | curl --request POST --data-binary "@test/test.wav" 127.0.0.1:3000/m4a -o audio/output/file.m4a 10 | 11 | # include a.wav - i.wav in audio/input/ and uncomment the lines below to easily run your own audio 12 | 13 | # curl --request POST --data-binary "@audio/input/a.wav" 127.0.0.1:3000/mp3 -o audio/output/a.mp3 14 | # curl --request POST --data-binary "@audio/input/a.wav" 127.0.0.1:3000/m4a -o audio/output/a.m4a 15 | # curl --request POST --data-binary "@audio/input/b.wav" 127.0.0.1:3000/mp3 -o audio/output/b.mp3 16 | # curl --request POST --data-binary "@audio/input/b.wav" 127.0.0.1:3000/m4a -o audio/output/b.m4a 17 | # curl --request POST --data-binary "@audio/input/c.wav" 127.0.0.1:3000/mp3 -o audio/output/c.mp3 18 | # curl --request POST --data-binary "@audio/input/c.wav" 127.0.0.1:3000/m4a -o audio/output/c.m4a 19 | # curl --request POST --data-binary "@audio/input/d.wav" 127.0.0.1:3000/mp3 -o audio/output/d.mp3 20 | # curl --request POST --data-binary "@audio/input/d.wav" 127.0.0.1:3000/m4a -o audio/output/d.m4a 21 | # curl --request POST --data-binary "@audio/input/e.wav" 127.0.0.1:3000/mp3 -o audio/output/e.mp3 22 | # curl --request POST --data-binary "@audio/input/e.wav" 127.0.0.1:3000/m4a -o audio/output/e.m4a 23 | # curl --request POST --data-binary "@audio/input/f.wav" 127.0.0.1:3000/mp3 -o audio/output/f.mp3 24 | # curl --request POST --data-binary "@audio/input/f.wav" 127.0.0.1:3000/m4a -o audio/output/f.m4a 25 | # curl --request POST --data-binary "@audio/input/g.wav" 127.0.0.1:3000/mp3 -o audio/output/g.mp3 26 | # curl --request POST --data-binary "@audio/input/g.wav" 127.0.0.1:3000/m4a -o audio/output/g.m4a 27 | # curl --request POST --data-binary "@audio/input/h.wav" 127.0.0.1:3000/mp3 -o audio/output/h.mp3 28 | # curl --request POST --data-binary "@audio/input/h.wav" 127.0.0.1:3000/m4a -o audio/output/h.m4a 29 | # curl --request POST --data-binary "@audio/input/i.wav" 127.0.0.1:3000/mp3 -o audio/output/i.mp3 30 | # curl --request POST --data-binary "@audio/input/i.wav" 127.0.0.1:3000/m4a -o audio/output/i.m4a 31 | -------------------------------------------------------------------------------- /app/encoder.js: -------------------------------------------------------------------------------- 1 | const { MP3_CODEC, M4A_CODEC, FFMPEG_ERROR } = require(__dirname + 2 | '/config.js'); 3 | 4 | const ffmpeg = require('fluent-ffmpeg'); 5 | const fs = require('fs'); 6 | 7 | const inputPath = `uploads/upload`; 8 | 9 | /* Winston Logger - Configured in app.js */ 10 | const winston = require('winston'); 11 | 12 | /** 13 | * write a file to disk, then encode an to specified format. callback with path of encoded file 14 | * @param {file} file audio file as bytes 15 | * @param {string} format format for encoding 16 | * @param {function} callback called upon completion 17 | * @param {string} fileId appended to filename for testing 18 | */ 19 | exports.encode = function(file, format, fileId, callback) { 20 | let outputPath = gatherOutputPath(format, fileId); 21 | winston.info(`Encoding file ${outputPath}`); 22 | writeInputFile(file, fileId, (err, result) => { 23 | if (err) { 24 | callback(err); 25 | } 26 | ffmpegCall(format, outputPath, fileId, (err, outputPath) => { 27 | winston.info(`ffmpeg call completed ${outputPath}`); 28 | if (err) { 29 | callback(err, null); 30 | } else if (outputPath == null) { 31 | callback('FFMPEG_ERROR ', null); 32 | } else if (outputPath.indexOf(FFMPEG_ERROR) !== -1) { 33 | winston.error('error', outputPath); 34 | callback(FFMPEG_ERROR + outputPath, null); 35 | } else { 36 | callback(null, outputPath); 37 | } 38 | }); 39 | }); 40 | }; 41 | 42 | /* Construct output path with unique id and specified format */ 43 | function gatherOutputPath(format, id) { 44 | let outputExtension = ''; 45 | outputPath = 'output'; 46 | if (format == MP3_CODEC) { 47 | outputExtension = '.mp3'; 48 | } 49 | if (format == M4A_CODEC) { 50 | outputExtension = '.m4a'; 51 | } 52 | return outputPath + id + outputExtension; 53 | } 54 | 55 | /** Writes unencoded file to disk 56 | * @param {string} file - Unencoded file 57 | * @param {function} callback - Function called upon completed writing 58 | */ 59 | function writeInputFile(file, fileId, callback) { 60 | try { 61 | fs.writeFile(inputPath + fileId, file, '', res => { 62 | callback(null, res); 63 | }); 64 | } catch (e) { 65 | callback(e, null); 66 | } 67 | } 68 | 69 | /** Constructs and executes ffmpeg conversion cmd. Returns encoded filename 70 | * @param {string} format - Target audio format 71 | * @param {string} outputPath - Path for output file on local disk 72 | * @param {string} callback - Function called upon completed conversion 73 | */ 74 | function ffmpegCall(format, outputPath, fileId, callback) { 75 | ffmpegConvertCommand = ffmpeg(inputPath + fileId) 76 | .audioCodec(format) 77 | .on('error', err => { 78 | winston.error(`FFMPEG ERROR ${err}`); 79 | fs.unlink(inputPath + fileId, (err, res) => { 80 | callback(`${FFMPEG_ERROR} ${err}`, null); 81 | }); 82 | }) 83 | .on('end', () => { 84 | fs.unlink(inputPath + fileId, (err, res) => { 85 | callback(null, outputPath); 86 | }); 87 | }) 88 | .save(outputPath); 89 | } 90 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ffmpeg web service 2 | 3 | A web service for converting audio files with ffmpeg 4 | 5 | Node.js, Express, FFMPEG, Docker 6 | 7 | ## Deployed Service 8 | 9 | Deployed dev branch on a $5 Digital Ocean droplet (1 GB Memory / 25 GB Disk / SFO2 - Ubuntu Docker 18.06.1~ce~3 on 18.04) 10 | 11 | [157.230.129.167:3000](http://157.230.129.167:3000) 12 | 13 | ## Continued Development 14 | 15 | [Task Board](https://trello.com/b/I5Eh8JnX/ff-ffmpeg-service) 16 | 17 | ## API 18 | 19 | `POST /mp3` - Convert audio file in request body to mp3 and return result for download 20 | 21 | `POST /m4a` - Convert audio file in request body to mp3 and return result for download 22 | 23 | `GET /`, `GET /readme` - Web Service Readme 24 | 25 | ### POST /mp3, POST /m4a 26 | 27 | POST to /mp3 or /m4a using Postman or a Curl command: 28 | 29 | Include audio file as binary in request body 30 | 31 | Postman Ex: 32 | 33 | https://www.dropbox.com/s/5exywmaj5o7cdn3/postMp3%20Postman%20Usage.png?dl=0 34 | 35 | Curl Ex: 36 | 37 | ```bash 38 | curl --request POST --data-binary "@file.wav" 127.0.0.1:3000/mp3 -o file.mp3 39 | ``` 40 | 41 | see test/curl_test.bash for an example use via bash script 42 | 43 | ## Installation 44 | 45 | Requires local Node and FFMPEG installation. 46 | 47 | 1. Install FFMPEG https://ffmpeg.org/download.html 48 | 49 | 2. Install node https://nodejs.org/en/download/ 50 | Using homebrew: 51 | 52 | ```bash 53 | $ brew install node 54 | ``` 55 | 56 | ## Running Service Locally 57 | 58 | Navigate to project directory and: 59 | 60 | Install dependencies: 61 | 62 | ```bash 63 | $ npm install 64 | ``` 65 | 66 | Start app: 67 | 68 | ```bash 69 | $ node app.js 70 | ``` 71 | 72 | Run unit tests with Mocha: 73 | 74 | ```bash 75 | $ npm run test 76 | ``` 77 | 78 | or 79 | 80 | ```bash 81 | $ ./node_modules/.bin/mocha 82 | ``` 83 | 84 | ## Running Service in a Docker Container Locally 85 | 86 | Requires Docker 87 | 88 | Install Docker 89 | 90 | ``` 91 | https://www.docker.com 92 | ``` 93 | 94 | Build Docker Image from Dockerfile with a set image tag. ex: bm/ffmpeg 95 | 96 | ```bash 97 | $ docker build -t / . 98 | ``` 99 | 100 | Launch Docker Container from Docker Image, exposing port 49160 101 | 102 | ```bash 103 | $ docker run -p 49160:3000 -d ''/'' 104 | ``` 105 | 106 | ### Developing Service In Running Container via Docker-Compose 107 | 108 | ```bash 109 | docker-compose up -d --build 110 | 111 | docker ps # note 112 | 113 | # enter bash prompt for docker container 114 | docker exec -it /bin/bash 115 | 116 | vi README.md # edit file in vi, ex. README.md 117 | ``` 118 | 119 | ## Deploying Service on Digital Ocean Droplet with Docker 120 | 121 | ``` 122 | $ - local machine 123 | # - ssh-ed machine 124 | ``` 125 | 126 | I am using a 4GB RAM, 2 vCPU Droplet for test deployments 127 | I use the Digital Ocean Docker App Preset Droplet 128 | 129 | Create your Droplet 130 | 131 | SSH into your Droplet 132 | 133 | ```bash 134 | $ ssh root@ 135 | ``` 136 | 137 | Clone this repo 138 | 139 | ```bash 140 | # git clone https://github.com/benmangold/ffmpeg-service.git 141 | ``` 142 | 143 | ```bash 144 | # cd ffmpeg-service 145 | ``` 146 | 147 | Build Docker Image from Dockerfile with a set image tag. ex: bm/ffmpeg 148 | 149 | ```bash 150 | # docker build -t / . 151 | ``` 152 | 153 | Launch Docker Container from Docker Image, exposing port 49160 154 | 155 | ```bash 156 | # docker run -p 3000:3000 -d ''/'' 157 | ``` 158 | --------------------------------------------------------------------------------