1 | 'use strict'
|
2 |
|
3 | const { once, EventEmitter } = require('events')
|
4 | const path = require('path')
|
5 | const ytdl = require('ytdl-core')
|
6 | const ytpl = require('ytpl')
|
7 | const ffmpeg = require('fluent-ffmpeg')
|
8 |
|
9 | const Rxfrom = require('rxjs').from
|
10 | const { mergeMap, toArray } = require('rxjs/operators')
|
11 |
|
12 | const YOUTUBE_URL = 'http://youtube.com/watch?v='
|
13 |
|
14 | class DownloadYTAudio extends EventEmitter {
|
15 | constructor (opts = {}) {
|
16 | super()
|
17 | this.downloadPath = opts.outputPath || process.cwd()
|
18 | this.nameGenerator = opts.fileNameGenerator || (title => `${title.replace(/[\\/:*?"<>|]/g, '')}.mp3`)
|
19 | this.maxParallelDownload = opts.maxParallelDownload || 10
|
20 |
|
21 | if (opts.ffmpegPath) {
|
22 | process.nextTick(() => {
|
23 | ffmpeg.setFfmpegPath(opts.ffmpegPath)
|
24 | })
|
25 | }
|
26 | }
|
27 |
|
28 | |
29 |
|
30 |
|
31 |
|
32 |
|
33 |
|
34 | async download (videoId, fileName) {
|
35 | const url = `${YOUTUBE_URL}${videoId}`
|
36 | const videoStream = ytdl(url, {
|
37 | quality: 'highestaudio'
|
38 | })
|
39 |
|
40 |
|
41 |
|
42 |
|
43 | const [videoInfo, videoSetting] = await once(videoStream, 'info')
|
44 |
|
45 | if (!fileName) {
|
46 | fileName = this.nameGenerator(videoInfo.title)
|
47 | }
|
48 |
|
49 | const info = {
|
50 | id: videoId,
|
51 | fileName,
|
52 | path: this.downloadPath,
|
53 | filePath: path.join(this.downloadPath, fileName),
|
54 | ref: buildVideoRef(videoInfo)
|
55 | }
|
56 |
|
57 | this.emit('video-info', info, videoInfo)
|
58 | this.emit('video-setting', info, videoSetting)
|
59 |
|
60 | let theResolve
|
61 | let theReject
|
62 | const thePromise = new Promise((resolve, reject) => {
|
63 | theResolve = resolve
|
64 | theReject = reject
|
65 | })
|
66 |
|
67 | const command = ffmpeg(videoStream, {
|
68 | stdoutLines: 0
|
69 | })
|
70 | .format('mp3')
|
71 | .output(info.filePath)
|
72 |
|
73 | .on('start', () => { this.emit('start', info) })
|
74 | .on('end', () => {
|
75 | this.emit('complete', info)
|
76 | if (theResolve) {
|
77 | theResolve(info)
|
78 | theReject = null
|
79 | }
|
80 | })
|
81 | .on('error', (err) => {
|
82 | this.emit('error', err, info)
|
83 | if (theReject) {
|
84 | theReject(err)
|
85 | theResolve = null
|
86 | }
|
87 | })
|
88 | .on('progress', progress => {
|
89 | const update = { ...info, progress }
|
90 | this.emit('progress', update)
|
91 | })
|
92 |
|
93 |
|
94 | process.nextTick(() => { command.run() })
|
95 |
|
96 | return thePromise
|
97 | }
|
98 |
|
99 | |
100 |
|
101 |
|
102 |
|
103 |
|
104 | async downloadPlaylist (playlistId) {
|
105 | const playlistInfo = await this.getPlaylistInfo(playlistId)
|
106 |
|
107 | const downloadingSongs = async (song) => {
|
108 | try {
|
109 | const songFileName = this.nameGenerator(song.title)
|
110 | const songFile = await this.download(song.id, songFileName)
|
111 | return songFile
|
112 | } catch (error) {
|
113 | return {
|
114 | id: song.id,
|
115 | ref: song,
|
116 | error
|
117 | }
|
118 | }
|
119 | }
|
120 |
|
121 | const videoObservable = Rxfrom(playlistInfo.items)
|
122 |
|
123 | return videoObservable
|
124 | .pipe(mergeMap(video => downloadingSongs(video), this.maxParallelDownload))
|
125 | .pipe(toArray())
|
126 | .toPromise()
|
127 | }
|
128 |
|
129 | getPlaylistInfo (playlistId) {
|
130 | return ytpl(playlistId)
|
131 | }
|
132 |
|
133 | async getVideoInfo (videoId) {
|
134 | const url = `${YOUTUBE_URL}${videoId}`
|
135 | const advData = await ytdl.getBasicInfo(url)
|
136 | if (advData.formats.length === 0) {
|
137 |
|
138 | throw new Error('This video is unavailable')
|
139 | }
|
140 |
|
141 | return buildVideoRef(advData)
|
142 | }
|
143 | }
|
144 |
|
145 | module.exports = DownloadYTAudio
|
146 |
|
147 | function buildVideoRef (advData) {
|
148 | return {
|
149 | id: advData.video_id,
|
150 | url: advData.video_url,
|
151 | title: advData.title,
|
152 | thumbnail: advData.player_response.videoDetails.thumbnail.thumbnails.reduce(getBigger),
|
153 | duration: +(advData.player_response.videoDetails.lengthSeconds),
|
154 | author: advData.author
|
155 | }
|
156 | }
|
157 |
|
158 | function getBigger (a, b) {
|
159 | return (a.width > b.width) ? a : b
|
160 | }
|