├── Aptfile ├── Procfile ├── .eslintrc ├── .travis.yml ├── .buildpacks ├── .gitignore ├── sources.list ├── src ├── ascii-art.txt ├── test │ ├── index.js │ ├── server.js │ ├── metadata.js │ ├── search.js │ ├── search-set-key.js │ ├── download.js │ └── set-folder.js ├── index.html ├── bin.js ├── downloader.js ├── index.js └── youtube.js ├── Dockerfile ├── .editorconfig ├── package.json ├── CHANGELOG.md └── README.md /Aptfile: -------------------------------------------------------------------------------- 1 | ffmpeg 2 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: node src/bin.js 2 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "standard" 3 | } 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "6" -------------------------------------------------------------------------------- /.buildpacks: -------------------------------------------------------------------------------- 1 | https://github.com/heroku/heroku-buildpack-apt 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | *.json 4 | *.mp3 5 | !package.json 6 | !package-lock.json 7 | -------------------------------------------------------------------------------- /sources.list: -------------------------------------------------------------------------------- 1 | deb http://ppa.launchpad.net/openjdk-r/ppa/ubuntu trusty main 2 | deb-src http://ppa.launchpad.net/openjdk-r/ppa/ubuntu trusty main -------------------------------------------------------------------------------- /src/ascii-art.txt: -------------------------------------------------------------------------------- 1 | _ _ __ _ ___ 2 | | | | |/ _` / __| 3 | | |_| | (_| \__ \ 4 | \__, |\__,_|___/ 5 | |___/ 6 | 7 | youtube-audio-server 8 | 9 | -------------------------------------------------------------------------------- /src/test/index.js: -------------------------------------------------------------------------------- 1 | require('./download') 2 | require('./metadata') 3 | require('./search-set-key') 4 | require('./search') 5 | require('./set-folder') 6 | require('./server') 7 | -------------------------------------------------------------------------------- /src/test/server.js: -------------------------------------------------------------------------------- 1 | const yas = require('../index') 2 | 3 | // Start listener (REST API). 4 | const port = 7331 5 | yas.listen(port, () => { 6 | console.log(`Listening on port http://localhost:${port}.`) 7 | }) 8 | -------------------------------------------------------------------------------- /src/test/metadata.js: -------------------------------------------------------------------------------- 1 | const yas = require('../index') 2 | 3 | // Get metadata. 4 | yas.get('HQmmM_qwG4k', (err, data) => { 5 | console.log('-'.repeat(80)) 6 | console.log('GOT METADATA for HQmmM_qwG4k:', data || err) 7 | }) 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM jrottenberg/ffmpeg:3.4-alpine 2 | FROM node:10-alpine 3 | 4 | # copy ffmpeg bins from first image 5 | COPY --from=0 / / 6 | 7 | WORKDIR /usr/src/yas 8 | RUN npm i -g youtube-audio-server 9 | EXPOSE 80 10 | CMD yas 11 | -------------------------------------------------------------------------------- /src/test/search.js: -------------------------------------------------------------------------------- 1 | const yas = require('../index') 2 | 3 | // Search. 4 | yas.search( 5 | { 6 | query: 'led zeppelin', 7 | page: null 8 | }, 9 | (err, data) => { 10 | console.log('-'.repeat(80)) 11 | if (err) { 12 | console.log('ERROR:', err) 13 | return 14 | } 15 | 16 | console.log(`FIRST SEARCH RESULT of ${data.items.length}:`, data.items[0]) 17 | } 18 | ) 19 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | end_of_line = lf 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | 13 | [*.md] 14 | max_line_length = 0 15 | trim_trailing_whitespace = false 16 | 17 | # Indentation override 18 | #[lib/**.js] 19 | #[{package.json,.travis.yml}] 20 | #[**/**.js] 21 | -------------------------------------------------------------------------------- /src/test/search-set-key.js: -------------------------------------------------------------------------------- 1 | const yas = require('../index') 2 | 3 | // Programatic search. 4 | const apiKey = 'YOUR-KEY-HERE' 5 | yas.setKey(apiKey) 6 | yas.search( 7 | { 8 | query: 'led zeppelin', 9 | page: null 10 | }, 11 | (err, data) => { 12 | console.log('-'.repeat(80)) 13 | if (err) { 14 | console.log('ERROR:', err) 15 | return 16 | } 17 | 18 | console.log(`FIRST SEARCH RESULT of ${data.items.length}:`, data.items[0]) 19 | } 20 | ) 21 | -------------------------------------------------------------------------------- /src/test/download.js: -------------------------------------------------------------------------------- 1 | const yas = require('../index') 2 | 3 | // Download audio. 4 | const id = 'HQmmM_qwG4k' // "Whole Lotta Love" by Led Zeppelin. 5 | const file = 'whole-lotta-love.mp3' 6 | console.log(`Downloading ${id} into ${file}...`) 7 | yas.downloader 8 | .onSuccess(({ id, file }) => { 9 | console.log('-'.repeat(80)) 10 | console.log(`Yay! Audio from ${id} downloaded successfully into "${file}"!`) 11 | }) 12 | .onError(({ id, file, error }) => { 13 | console.log('-'.repeat(80)) 14 | console.error(`Sorry, an error ocurred when trying to download ${id}`, error) 15 | }) 16 | .download({ id, file }) 17 | -------------------------------------------------------------------------------- /src/test/set-folder.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const yas = require('../index') 4 | 5 | // Download audio on set folder. 6 | const id = 'HQmmM_qwG4k' // "Whole Lotta Love" by Led Zeppelin. 7 | const folder = path.resolve(__dirname, 'temp-audio') 8 | const file = `${folder}/whole-lotta-love.mp3` 9 | console.log(`Downloading ${id} into ${file}...`) 10 | 11 | yas.downloader 12 | .setFolder(folder) 13 | .onSuccess(({ id, file }) => { 14 | console.log('-'.repeat(80)) 15 | console.log(`Yay! Audio from ${id} downloaded successfully into "${file}"!`) 16 | }) 17 | .onError(({ id, file, error }) => { 18 | console.log('-'.repeat(80)) 19 | console.error(`Sorry, an error ocurred when trying to download ${id}`, error) 20 | }) 21 | .download({ id, file }) 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "youtube-audio-server", 3 | "version": "2.8.2", 4 | "description": "Easily stream and download audio from YouTube.", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "start": "./src/bin.js", 8 | "test": "standard", 9 | "test-focus": "standard-focus", 10 | "test-run": "node src/test/index.js" 11 | }, 12 | "bin": { 13 | "yas": "./src/bin.js" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/codealchemist/youtube-audio-server.git" 18 | }, 19 | "author": "Alberto Miranda", 20 | "license": "MIT", 21 | "bugs": { 22 | "url": "https://github.com/codealchemist/youtube-audio-server/issues" 23 | }, 24 | "homepage": "https://github.com/codealchemist/youtube-audio-server#readme", 25 | "dependencies": { 26 | "chalk": "^1.1.3", 27 | "download": "^8.0.0", 28 | "express": "^4.17.1", 29 | "express-no-favicons": "0.0.1", 30 | "fluent-ffmpeg": "^2.1.2", 31 | "minimist": "^1.2.0", 32 | "mkdirp": "^0.5.1", 33 | "ora": "^5.4.1", 34 | "sanitize-filename": "^1.6.3", 35 | "through2": "^2.0.3", 36 | "youtube-node": "^1.3.3", 37 | "ytdl-core": "^4.9.1" 38 | }, 39 | "devDependencies": { 40 | "standard-focus": "^1.1.2" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # youtube-audio-server changelog 2 | 3 | ## v2.8.2 4 | 5 | ### Removed 6 | 7 | - Removed engines from package.json. 8 | 9 | ## v2.8.1 10 | 11 | ### Fixed 12 | 13 | - Using folder configured with `setFolder` to save temp files. 14 | 15 | ## v2.8.0 16 | 17 | ### Added 18 | 19 | - `setFolder` to optionally set the folder were content will be downloaded. 20 | 21 | ## v2.7.1 22 | 23 | ### Fixed 24 | 25 | - Handling temp file errors 26 | 27 | ## v2.7.0 28 | 29 | ### Added 30 | 31 | - `onMetadata` callback for `downloader` 32 | - Returning `filename` on `onSuccess` response 33 | 34 | ## v2.6.1 35 | 36 | ### Fixed 37 | 38 | - Avoid saving empty metadata 39 | 40 | ## v2.6.0 41 | 42 | ### Added 43 | 44 | - metadata support 45 | 46 | ## v2.3.0 47 | 48 | ### Added 49 | 50 | - /cache/[videoId] endpoint: Returns the same stream for requested audio 51 | until processing finishes. 52 | - `/chunk/[videoId]`: Saves mp3 file to disk and returns a stream to it 53 | with chunks supports. 54 | 55 | ### Fixed 56 | 57 | - Killing ffmpeg when processing finishes. 58 | 59 | ## v2.2.0 60 | 61 | ### Added 62 | 63 | - setKey method to allow setting YouTube API key programatically. 64 | 65 | ## v2.1.5 66 | 67 | ### Fixed 68 | 69 | - Bug #12: Downloading video file instead of just audio. 70 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | YouTube Audio Server 6 | 7 | 8 | 9 | 13 | 14 | 49 | 50 | 51 |

Welcome to

52 |

YouTube Audio Server

53 | 56 | 57 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /src/bin.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const fs = require('fs') 3 | const path = require('path') 4 | const yas = require('./index') 5 | const args = require('minimist')(process.argv.slice(2)) 6 | const { bold, blue, white, red, gray } = require('chalk') 7 | const port = args.p || args.port || process.env.PORT || 80 8 | 9 | // print ascii art 10 | var artFile = path.join(__dirname, './ascii-art.txt') 11 | var art = fs.readFileSync(artFile, 'utf8') 12 | console.log(art) 13 | 14 | function download ({ id, file, h, help }) { 15 | // Display usage. 16 | if (help || h) { 17 | console.info(yas.downloader.help()) 18 | process.exit() 19 | } 20 | 21 | // Nothing to download. 22 | if (!file && !id) return false 23 | 24 | // Validations. 25 | console.log('-'.repeat(80)) 26 | if (!id) { 27 | console.error(red('Missing param:'), gray('--id [youtube-video-id]')) 28 | process.exit() 29 | } 30 | 31 | file = file || `./youtube-audio.mp3` 32 | console.log(`${bold(white('DOWNLOAD:'))} ${blue(id)}`) 33 | yas.downloader 34 | .onSuccess(() => process.exit()) 35 | .onError(error => { 36 | console.error(error) 37 | process.exit() 38 | }) 39 | .download(args) 40 | 41 | return true 42 | } 43 | 44 | function run () { 45 | // Run downloader. 46 | // If file download was specified using arguments: 47 | // yas --video [youtube-video-id] [--file [./sample.mp3]] 48 | // Will download the file and exit. 49 | if (download(args)) return 50 | 51 | // Start youtube-audio-server. 52 | yas.listen(port, () => { 53 | console.log(' 🔈 Listening on ', blue(`http://localhost:${port}`)) 54 | console.log('-'.repeat(80)) 55 | }) 56 | } 57 | 58 | run() 59 | -------------------------------------------------------------------------------- /src/downloader.js: -------------------------------------------------------------------------------- 1 | const youtube = require('./youtube') 2 | 3 | class Downloader { 4 | help () { 5 | return ` 6 | USAGE: 7 | yas --id [youtube-video-id] [--file [./sample.mp3]] 8 | 9 | EXAMPLES: 10 | yas --id 2zYDMN4h2hY --file ~/Downloads/Music/sample.mp3 11 | yas --id 2zYDMN4h2hY 12 | 13 | FILE defaults to ./youtube-audio.mp3 when not set. 14 | ` 15 | } 16 | 17 | handleError (params) { 18 | if (typeof this.onErrorCallback === 'function') { 19 | this.onErrorCallback(params) 20 | } 21 | } 22 | 23 | download ({ id, file, c, cache, m, metadata }) { 24 | youtube.download( 25 | { 26 | id, 27 | file, 28 | useCache: c || cache, 29 | addMetadata: m || metadata, 30 | onMetadata: this.onMetadataCallback 31 | }, 32 | (error, data) => { 33 | if (error) { 34 | this.handleError({ id, file, error }) 35 | return 36 | } 37 | 38 | if (typeof this.onSuccessCallback === 'function') { 39 | this.onSuccessCallback(data) 40 | } 41 | } 42 | ) 43 | 44 | return this 45 | } 46 | 47 | onSuccess (callback) { 48 | if (typeof callback === 'function') this.onSuccessCallback = callback 49 | return this 50 | } 51 | 52 | onError (callback) { 53 | if (typeof callback === 'function') this.onErrorCallback = callback 54 | return this 55 | } 56 | 57 | onMetadata (callback) { 58 | if (typeof callback === 'function') this.onMetadataCallback = callback 59 | return this 60 | } 61 | 62 | setFolder(folder) { 63 | youtube.setFolder(folder) 64 | return this 65 | } 66 | } 67 | 68 | const downloader = new Downloader() 69 | module.exports = downloader 70 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const path = require('path') 3 | const express = require('express') 4 | const nofavicon = require('express-no-favicons') 5 | const { yellow, green, gray, blue } = require('chalk') 6 | const youtube = require('./youtube') 7 | const downloader = require('./downloader') 8 | const app = express() 9 | 10 | function listen (port, callback = () => {}) { 11 | app.use(nofavicon()) 12 | 13 | app.get('/', (req, res) => { 14 | log('Sending test page') 15 | const file = path.resolve(__dirname, 'index.html') 16 | res.sendFile(file) 17 | }) 18 | 19 | app.get('/chunk/:videoId', (req, res) => { 20 | const videoId = req.params.videoId 21 | 22 | try { 23 | log(`Sending chunk ${blue(videoId)}`) 24 | youtube.download({ id: videoId }, (err, { id, file }) => { 25 | if (err) return res.sendStatus(500, err) 26 | res.sendFile(file) 27 | }) 28 | } catch (e) { 29 | log(e) 30 | res.sendStatus(500, e) 31 | } 32 | }) 33 | 34 | app.get('/:videoId', (req, res) => { 35 | const videoId = req.params.videoId 36 | 37 | try { 38 | log(`Streaming ${yellow(videoId)}`) 39 | youtube.stream(videoId).pipe(res) 40 | } catch (e) { 41 | log(e) 42 | res.sendStatus(500, e) 43 | } 44 | }) 45 | 46 | app.get('/cache/:videoId', (req, res) => { 47 | const videoId = req.params.videoId 48 | 49 | try { 50 | log(`Streaming cached ${green(videoId)}`) 51 | youtube.stream(videoId, true).pipe(res) 52 | } catch (e) { 53 | log(e) 54 | res.sendStatus(500, e) 55 | } 56 | }) 57 | 58 | app.get('/search/:query/:page?', (req, res) => { 59 | const { query, page } = req.params 60 | const pageStr = page ? gray(` #${page}`) : '' 61 | log(`Searching ${yellow(query)}`, pageStr) 62 | youtube.search({ query, page }, (err, data) => { 63 | if (err) { 64 | log(err) 65 | res.sendStatus(500, err) 66 | return 67 | } 68 | 69 | res.json(data) 70 | }) 71 | }) 72 | 73 | app.get('/get/:id', (req, res) => { 74 | const id = req.params.id 75 | 76 | youtube.get(id, (err, data) => { 77 | if (err) { 78 | log(err) 79 | res.sendStatus(500, err) 80 | return 81 | } 82 | 83 | res.json(data) 84 | }) 85 | }) 86 | 87 | app.use((req, res) => { 88 | res.sendStatus(404) 89 | }) 90 | 91 | app.listen(port, callback) 92 | } 93 | 94 | function log () { 95 | const now = new Date() 96 | console.log(gray(now.toISOString()), ...arguments) 97 | } 98 | 99 | module.exports = { 100 | listen, 101 | downloader, 102 | get: (id, callback) => youtube.get(id, callback), 103 | search: ({ query, page }, callback) => 104 | youtube.search({ query, page }, callback), 105 | setKey: key => youtube.setKey(key) 106 | } 107 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # youtube-audio-server 2 | 3 | Easily stream and download audio from YouTube. 4 | 5 | [![Build Status](https://travis-ci.org/codealchemist/youtube-audio-server.svg?branch=master)](https://travis-ci.org/codealchemist/youtube-audio-server) 6 | 7 | [![JavaScript Style Guide](https://cdn.rawgit.com/feross/standard/master/badge.svg)](https://github.com/feross/standard) 8 | 9 | Buy Me A Coffee 10 | 11 | ## Install 12 | 13 | `npm install -g youtube-audio-server` 14 | 15 | Or: 16 | 17 | `npm install --save youtube-audio-server` 18 | 19 | ## Docker image 20 | 21 | https://hub.docker.com/r/codealchemist/youtube-audio-server 22 | 23 | ## Search and metadata 24 | 25 | **IMPORTANT:** To be able to search and get video metadata you need to start the app passing your 26 | Google App KEY. 27 | 28 | Your Google App needs to have the YouTube API enabled. 29 | 30 | Login at https://console.cloud.google.com to get this data. 31 | 32 | To support this features, _YAS_ should be started like this: 33 | 34 | `KEY=[YOUR-APP-KEY] yas` 35 | 36 | If you use **YAS** programmatically you need to ensure the `KEY` environment var 37 | is set, or since version 2.2.0 you can also set it using the `setKey` method: 38 | 39 | ``` 40 | const yas = require('youtube-audio-server') 41 | yas.setKey('YOUR-KEY') 42 | ``` 43 | 44 | ## Running on Heroku 45 | 46 | To be able to run **YAS** on Heroku you need to install the **ffmpeg** buildpack: 47 | 48 | `heroku buildpacks:add https://github.com/jonathanong/heroku-buildpack-ffmpeg-latest.git` 49 | 50 | ## Command line usage 51 | 52 | ### REST API 53 | 54 | Start **YAS** with `yas`. 55 | 56 | #### Audio stream 57 | 58 | Just hit the server passing a YouTube video id, like: 59 | 60 | http://yourServerAddress:port/[videoId] 61 | 62 | For example: 63 | 64 | http://localhost:4000/HQmmM_qwG4k 65 | 66 | This will stream the requested video's audio. 67 | 68 | You can play it on an HTML5 audio tag or however you like. 69 | 70 | **Other endpoints:** 71 | 72 | - `/cache/[videoId]`: Returns the same stream for requested audio 73 | until processing finishes. Useful to avoid multiple requests from creating 74 | zombie instances of ffmpeg. This happens in Chrome, which makes a document 75 | request first and then a media request. The document request makes ffmpeg 76 | to start processing but never finishes. 77 | Firefox properly loads the audio with just one request and allows seeking. 78 | - `/chunk/[videoId]`: Saves mp3 file to disk and returns a stream to it. 79 | This allows data chunks to be sent to the client, which will be able to seek 80 | across the file. Enables Chrome and VLC, for example, to do seeking. 81 | 82 | #### Get metadata 83 | 84 | Use: http://yourServerAddress:port/get/[videoId] 85 | 86 | #### Search 87 | 88 | Use: http://yourServerAddress:port/search/[query]/[[pageToken]] 89 | 90 | To navigate pages you need to use `pageToken` which is provided in the results on the 91 | root level property `nextPageToken`. 92 | 93 | ### Change port: 94 | 95 | Default is 80. 96 | 97 | You can easily change it by starting **YAS** like: 98 | 99 | `PORT=8080 yas` 100 | 101 | Or, you can set the port using args: 102 | 103 | `yas -p 8080` or `yas --port 8080` 104 | 105 | ### Download audio 106 | 107 | **YAS** can also be used to easliy download audio. 108 | 109 | In this mode, the server is not started. 110 | 111 | **Usage:** 112 | 113 | `yas --id [youtube-video-id|youtube-video-url] [--file [./sample.mp3]]` 114 | 115 | **With metadata:** 116 | 117 | `yas --id 2zYDMN4h2hY -m` 118 | 119 | Use `-m` or `--metadata` to retrieve and persist metadata as ID3 tags, naming your file with the video title by default. 120 | 121 | Saved ID3 tags: 122 | 123 | - title 124 | - description 125 | - artist 126 | - album 127 | - comment: video URL 128 | 129 | **Other examples:** 130 | 131 | ``` 132 | yas --id 2zYDMN4h2hY --file ~/Downloads/Music/sample.mp3 133 | yas --id 2zYDMN4h2hY 134 | yas --id https://www.youtube.com/watch?v=2zYDMN4h2hY 135 | ``` 136 | 137 | **NOTE:** 138 | 139 | FILE defaults to `./[videoId].mp3` when not set. 140 | 141 | **Alternative method:** 142 | 143 | If you have a server instance running and you want to use it to download audio, 144 | you can do this: 145 | 146 | `curl [your-server-url]/[youtube-video-id] > sample.mp3` 147 | 148 | ## Programatic usage 149 | 150 | Yeah, you can also include **YAS** in your project and use it programatically! 151 | 152 | ### REST API 153 | 154 | ``` 155 | const yas = require('youtube-audio-server') 156 | 157 | // Start listener (REST API). 158 | const port = 7331 159 | yas.listen(port, () => { 160 | console.log(`Listening on port http://localhost:${port}.`) 161 | }) 162 | 163 | ``` 164 | 165 | ### Download audio 166 | 167 | ``` 168 | const yas = require('youtube-audio-server') 169 | 170 | const id = 'HQmmM_qwG4k' // "Whole Lotta Love" by Led Zeppelin. 171 | const file = 'whole-lotta-love.mp3' 172 | console.log(`Downloading ${id} into ${file}...`) 173 | yas.downloader 174 | .setFolder('some/folder') // Optionally set a folder for downloaded content. 175 | .onSuccess(({id, file}) => { 176 | console.log(`Yay! Audio (${id}) downloaded successfully into "${file}"!`) 177 | }) 178 | .onError(({ id, file, error }) => { 179 | console.error(`Sorry, an error ocurred when trying to download ${id}`, error) 180 | }) 181 | .download({ id, file, cache, metadata }) 182 | ``` 183 | 184 | Params: 185 | 186 | - `id`: Video ID or URL (`HQmmM_qwG4k` or `https://www.youtube.com/watch?v=HQmmM_qwG4k`) 187 | - `file`: Output file; defaults to video id or title when `metadata` is true 188 | - `cache`: Use cache 189 | - `metadata`: Retrieve and set metadata as ID3 tags 190 | 191 | ### Get video metadata 192 | 193 | ``` 194 | const yas = require('youtube-audio-server') 195 | 196 | yas.get('HQmmM_qwG4k', (err, data) => { 197 | console.log('GOT METADATA for HQmmM_qwG4k:', data || err) 198 | }) 199 | ``` 200 | 201 | ### Search 202 | 203 | ``` 204 | const yas = require('youtube-audio-server') 205 | 206 | yas.search({ 207 | query: 'led zeppelin', 208 | page: null 209 | }, 210 | (err, data) => { 211 | console.log('RESULTS:', data || err) 212 | }) 213 | ``` 214 | 215 | To navigate pages you need to use `pageToken` which is provided in the results on the 216 | root level property `data.nextPageToken`. 217 | 218 | ## Dependencies 219 | 220 | The key dependency for _youtube-audio-server_ is 221 | [youtube-audio-stream](https://github.com/JamesKyburz/youtube-audio-stream), 222 | which depends on `ffmpeg`, which must be installed at system level, it's not 223 | a node dependency! 224 | 225 | ### Install ffmpeg on OSX 226 | 227 | `brew install ffmpeg` 228 | 229 | ### Install ffmpeg on Debian Linux 230 | 231 | `sudo apt-get install ffmpeg` 232 | 233 | ## Testing 234 | 235 | Just open the URL of your server instance without specifying a video id. 236 | 237 | This will load a test page with an HTML5 audio element that will stream a test video id. 238 | 239 | Run `npm test` to lint everything using [StandardJS](https://standardjs.com). 240 | 241 | To start the listener and download an audio file use `npm run test-run`. 242 | 243 | You can open the shown URL to test the REST API works as expected. 244 | 245 | You can also use `npm run test-focus` to concentrate on one linting 246 | issue at a time with the help of [standard-focus](https://www.npmjs.com/package/standard-focus). 247 | 248 | Enjoy! 249 | -------------------------------------------------------------------------------- /src/youtube.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const { white, yellow, gray, red } = require('chalk') 4 | const ytdl = require('ytdl-core') 5 | const YtNode = require('youtube-node') 6 | const through2 = require('through2') 7 | const Ffmpeg = require('fluent-ffmpeg') 8 | const download = require('download') 9 | const sanitize = require('sanitize-filename') 10 | const ora = require('ora') 11 | const spinner = ora() 12 | const mkdirp = require('mkdirp') 13 | const cache = {} 14 | 15 | class YouTube { 16 | constructor () { 17 | this.pageSize = 10 18 | this.audioFolder = path.resolve('.') 19 | 20 | const envApiKey = process.env.KEY 21 | if (envApiKey) this.setKey(envApiKey) 22 | } 23 | 24 | setFolder(folder) { 25 | try { 26 | mkdirp.sync(folder) 27 | console.log('Using audio folder:', folder) 28 | this.audioFolder = folder 29 | } catch (error) { 30 | console.log(`Error creating folder: ${folder}`, error) 31 | } 32 | } 33 | 34 | setKey (apiKey) { 35 | this.ytNode = new YtNode() 36 | this.ytNode.setKey(apiKey) 37 | } 38 | 39 | streamDownloaded (id, callback) { 40 | const video = ytdl(id) 41 | const ffmpeg = new Ffmpeg(video) 42 | let sent = false 43 | 44 | try { 45 | const file = `${this.audioFolder}/${id}.mp3` 46 | ffmpeg 47 | .format('mp3') 48 | .on('end', () => { 49 | ffmpeg.kill() 50 | }) 51 | .on('data', () => { 52 | if (sent) return 53 | sent = true 54 | callback(fs.createReadStream(file)) 55 | }) 56 | .save(file) 57 | } catch (e) { 58 | throw e 59 | } 60 | } 61 | 62 | async getMetadata (id) { 63 | const { videoDetails } = await ytdl.getBasicInfo(id) 64 | const { 65 | videoId, 66 | title, 67 | description, 68 | // thumbnails, 69 | video_url, 70 | media 71 | } = videoDetails 72 | // const imgUrl = thumbnails?.length ? thumbnails[0]?.url : '' 73 | const imgUrl = `https://img.youtube.com/vi/${videoId}/0.jpg` 74 | const { song, category, artist, album } = media || {} 75 | console.log(`${white('ᐧ Title:')} ${yellow(title)}`) 76 | 77 | return { 78 | videoId, 79 | title, 80 | description, 81 | song, 82 | category, 83 | artist, 84 | album, 85 | videoUrl: video_url, 86 | imgUrl 87 | } 88 | } 89 | 90 | setNonEmptyMetadataProp (ffmpeg, prop, value) { 91 | if (!value) return 92 | ffmpeg.outputOptions('-metadata', `${prop}="${value}"`) 93 | } 94 | 95 | setMetadata ({ file, id, metadata }) { 96 | return new Promise(async (resolve, reject) => { 97 | const ffmpeg = new Ffmpeg(file) 98 | const tmpFile = `${file}.tmp.mp3` 99 | const { 100 | videoId, 101 | title, 102 | description, 103 | artist, 104 | album, 105 | videoUrl, 106 | imgUrl 107 | } = metadata 108 | 109 | this.setNonEmptyMetadataProp(ffmpeg, 'title', title) 110 | this.setNonEmptyMetadataProp(ffmpeg, 'description', description) 111 | this.setNonEmptyMetadataProp(ffmpeg, 'artist', artist) 112 | this.setNonEmptyMetadataProp(ffmpeg, 'album', album) 113 | this.setNonEmptyMetadataProp(ffmpeg, 'comment', videoUrl) 114 | 115 | // Save and set art. 116 | const imgFile = `${this.audioFolder}/${videoId}.jpg` 117 | if (imgUrl) { 118 | try { 119 | spinner.start('Download art') 120 | await this.writeFile({ 121 | file: imgFile, 122 | stream: download(imgUrl, { retry: 3 }) 123 | }) 124 | spinner.succeed('Art downloaded') 125 | spinner.start('Set art metadata') 126 | ffmpeg 127 | .addInput(imgFile) 128 | .outputOptions('-map', '0:0') 129 | .outputOptions('-map', '1:0') 130 | .outputOptions('-codec', 'copy') 131 | .outputOptions('-id3v2_version', '3') 132 | .save(tmpFile) 133 | } catch (error) { 134 | const errMessage = 'Error setting art metadata' 135 | spinner.fail(errMessage, error) 136 | reject(errMessage) 137 | } 138 | } 139 | 140 | ffmpeg.on('end', () => { 141 | try { 142 | fs.unlinkSync(file) 143 | fs.unlinkSync(imgFile) 144 | fs.renameSync(tmpFile, file) 145 | spinner.succeed('Art saved as metadata') 146 | resolve(ffmpeg) 147 | } catch (error) { 148 | const errMessage = 'Error removing temp files' 149 | spinner.fail(errMessage, error) 150 | reject(errMessage) 151 | } 152 | }) 153 | }) 154 | } 155 | 156 | stream (id, useCache) { 157 | if (useCache) { 158 | const cached = cache[id] 159 | if (cached) return cached 160 | } 161 | 162 | const video = ytdl(id) 163 | const ffmpeg = new Ffmpeg(video) 164 | const stream = through2() 165 | 166 | try { 167 | const ffmpegObj = ffmpeg.format('mp3').on('end', () => { 168 | cache[id] = null 169 | ffmpeg.kill() 170 | }) 171 | ffmpegObj.pipe(stream, { end: true }) 172 | 173 | cache[id] = stream 174 | return stream 175 | } catch (e) { 176 | throw e 177 | } 178 | } 179 | 180 | writeFile ({ file, stream }) { 181 | return new Promise((resolve, reject) => { 182 | const fileWriter = fs.createWriteStream(file) 183 | fileWriter.on('finish', () => { 184 | fileWriter.end() 185 | resolve() 186 | }) 187 | 188 | fileWriter.on('error', error => { 189 | fileWriter.end() 190 | reject(error) 191 | }) 192 | stream.pipe(fileWriter) 193 | }) 194 | } 195 | 196 | async download ({ id, file, useCache, addMetadata, onMetadata }, callback) { 197 | // With metadata. 198 | if (addMetadata) { 199 | this.downloadWithMetadata({ id, file, useCache, addMetadata, onMetadata }, callback) 200 | return 201 | } 202 | 203 | // Without metadata. 204 | file = 205 | file || id.match(/^http.*/) 206 | ? `${this.audioFolder}/youtube-audio.mp3` 207 | : `${this.audioFolder}/${id}.mp3` 208 | spinner.start('Save audio') 209 | try { 210 | await this.writeFile({ 211 | file, 212 | stream: await this.stream(id, useCache, addMetadata) 213 | }) 214 | spinner.succeed('Audio saved') 215 | console.log(` ${gray(file)}`) 216 | callback(null, { id, file }) 217 | } catch (error) { 218 | spinner.fail('Error saving audio', error.toString()) 219 | callback(error) 220 | } 221 | } 222 | 223 | async downloadWithMetadata ({ id, file, useCache, addMetadata, onMetadata }, callback) { 224 | let metadata 225 | let filename 226 | try { 227 | metadata = await this.getMetadata(id) 228 | if (typeof onMetadata === 'function') { 229 | onMetadata({ id, ...metadata }) 230 | } 231 | } catch (error) { 232 | callback(error) 233 | return 234 | } 235 | 236 | try { 237 | const { videoId, title } = metadata 238 | filename = sanitize(title || videoId) 239 | file = file || `${this.audioFolder}/${filename}.mp3` 240 | 241 | spinner.start('Save audio') 242 | await this.writeFile({ 243 | file, 244 | stream: await this.stream(id, useCache, addMetadata) 245 | }) 246 | spinner.succeed('Audio saved') 247 | } catch (error) { 248 | spinner.fail('Error saving audio', error.toString()) 249 | callback(error) 250 | return 251 | } 252 | 253 | try { 254 | console.log(` ${gray(file)}`) 255 | await this.setMetadata({ file, id, metadata }) 256 | callback(null, { id, file, filename }) 257 | } catch (error) { 258 | callback(error) 259 | return 260 | } 261 | } 262 | 263 | search ({ query, page }, callback) { 264 | if (!this.ytNode) { 265 | console.log(red('YouTube KEY required and not set')) 266 | callback() 267 | return 268 | } 269 | 270 | if (page) { 271 | this.ytNode.addParam('pageToken', page) 272 | } 273 | 274 | this.ytNode.search(query, this.pageSize, callback) 275 | } 276 | 277 | get (id, callback) { 278 | if (!this.ytNode) { 279 | const error = 'YouTube KEY required and not set' 280 | console.log(red(error)) 281 | callback(error) 282 | return 283 | } 284 | this.ytNode.getById(id, callback) 285 | } 286 | } 287 | 288 | module.exports = new YouTube() 289 | --------------------------------------------------------------------------------