Skip to content
This repository was archived by the owner on Sep 8, 2020. It is now read-only.

Commit 56edfef

Browse files
authored
Feature/mix audio video (#12)
* #8 add presets, #7 mix audio, WIP * refactoring * refactor menu * implement #11 * added some unit testing * improved menu output * make it more tolerant to missing fields * new demo
1 parent 268a712 commit 56edfef

File tree

10 files changed

+658
-78
lines changed

10 files changed

+658
-78
lines changed

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,7 @@ node_modules
33
.vscode
44
.DS_Store
55
*.mp4
6-
*.part
6+
*.part
7+
*.m4a
8+
*.mkv
9+
*.ytdl

cli.js

100644100755
Lines changed: 36 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,27 @@
33
const meow = require('meow')
44
const logSymbols = require('log-symbols')
55
const shell = require('shelljs')
6-
const ytdlApi = require('./ytdl-api')
76
const ora = require('ora')
87
const updateNotifier = require('update-notifier')
8+
const chalk = require('chalk')
9+
const ytdlApi = require('./ytdl-api')
910
const menu = require('./menu')
1011
const pkg = require('./package.json')
11-
const chalk = require('chalk')
1212

13-
const cli = meow(`
13+
const cli = meow(
14+
`
1415
Usage: youtube-dl-interactive URL
1516
1617
Options:
17-
--help, -h output usage information
18+
--help, -h output usage information
1819
--version output the version number
1920
--demo use sample data, no remote calls
2021
21-
`, {
22+
`,
23+
{
2224
flags: {
2325
demo: {
24-
type: 'boolean',
26+
type: 'boolean'
2527
}
2628
}
2729
}
@@ -34,17 +36,23 @@ async function init(args, flags) {
3436
shell.exit(1)
3537
}
3638

37-
updateNotifier({ pkg }).notify()
39+
updateNotifier({pkg}).notify()
3840

3941
if (flags.demo) {
40-
console.log(logSymbols.warning, chalk.bgYellowBright('Running demo with local data, not making remote calls'))
41-
await run(null, true);
42+
console.log(
43+
logSymbols.warning,
44+
chalk.bgYellowBright(
45+
'Running demo with sample data, not actually calling youtube-dl.'
46+
)
47+
)
48+
await run(null, true)
4249
} else {
4350
if (args.length !== 1) {
4451
cli.showHelp(1)
4552
}
53+
4654
const url = args[0]
47-
await run(url, false);
55+
await run(url, false)
4856
}
4957
}
5058

@@ -54,26 +62,33 @@ init(cli.input, cli.flags).catch(error => {
5462
})
5563

5664
async function run(url, isDemo) {
57-
let info = isDemo
65+
const info = isDemo
5866
? require('./test/samples/thankyousong.json')
5967
: await fetchInfo(url)
6068

6169
if (!info) {
6270
return
6371
}
72+
6473
console.log(chalk.bold('Title:', chalk.blue(info.title)))
65-
66-
const formats = info.formats
67-
const { formatString, extension } = await menu.formatMenu(formats);
68-
console.log(logSymbols.success, `OK, downloading format #${formatString}`);
69-
let options = ` -f '${formatString}' `;
70-
if (ytdlApi.supportsSubtitles(extension)) {
71-
options += ' --all-subs --embed-subs ';
74+
75+
const {formats} = info
76+
const {formatString, hasVideo, hasAudio} = await menu.formatMenu(formats)
77+
let options = ` -f '${formatString}' `
78+
79+
if (!hasVideo && hasAudio) {
80+
options += ' --extract-audio '
7281
}
82+
7383
if (isDemo) {
74-
console.log(logSymbols.warning, `End of demo. would now call: youtube-dl ${options} "${url}"`)
84+
console.log(
85+
logSymbols.warning,
86+
`End of demo. would now call: youtube-dl ${options} <url>"`
87+
)
7588
} else {
76-
shell.exec(`youtube-dl ${options} "${url}"`);
89+
const command = `youtube-dl ${options} "${url}"`
90+
console.log(logSymbols.success, `OK. Running: ${command}`)
91+
shell.exec(command)
7792
}
7893
}
7994

@@ -84,12 +99,8 @@ async function fetchInfo(url) {
8499
spinner.stop()
85100
return info
86101
} catch (error) {
87-
spinner.fail('can not load formats')
102+
spinner.fail('Error while loading metadata')
88103
console.error(error)
89104
return null
90105
}
91-
92-
93-
94106
}
95-

docs/demo.gif

-709 KB
Binary file not shown.

docs/demo.svg

Lines changed: 1 addition & 1 deletion
Loading

menu.js

Lines changed: 176 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,206 @@
11
const inquirer = require('inquirer')
22
const byteSize = require('byte-size')
33

4+
exports.formatMenu = async function(formats) {
5+
// Select video preset or resolution (it may include a audio track)
6+
let {
7+
finalFormatId,
8+
videoFormatId,
9+
audioFormatId,
10+
height
11+
} = await selectPresetOrResolution(formats)
412

5-
exports.formatMenu = async function (formats) {
6-
let remainingFormats = formats
13+
// FinalFormatId means no further selection is required
14+
if (!finalFormatId) {
15+
let videoFormat
16+
// Specifiy which video with this height
17+
if (height) {
18+
videoFormat = await exports.selectOneVideo(
19+
formats.filter(f => f.height === height)
20+
)
21+
videoFormatId = videoFormat.format_id
22+
}
723

8-
// By default, we ignore 'video only' files
9-
remainingFormats = remainingFormats.filter(f => f.acodec !== 'none')
24+
// Specify audio track
25+
audioFormatId = await selectAudio(formats, videoFormat)
26+
}
1027

11-
remainingFormats = await exports.filterByProperty(
12-
'Select resolution:',
13-
f => (f.resolution || 'audio only'),
14-
remainingFormats
15-
)
28+
if (finalFormatId) {
29+
return {formatString: finalFormatId, hasVideo: true, hasAudio: true}
30+
}
1631

17-
// remainingFormats = await exports.filterByProperty(
18-
// 'Select extension:',
19-
// f => f.extension,
20-
// remainingFormats
21-
// )
32+
if (videoFormatId && audioFormatId) {
33+
if (videoFormatId === audioFormatId) {
34+
return {formatString: videoFormatId, hasVideo: true, hasAudio: true}
35+
}
2236

23-
if (remainingFormats.length > 1) {
24-
remainingFormats = [await exports.selectOne(remainingFormats)]
25-
}
37+
return {
38+
formatString: `${videoFormatId}+${audioFormatId}`,
39+
hasVideo: true,
40+
hasAudio: true
41+
}
42+
}
2643

27-
const formatString = remainingFormats[0].format_id
28-
return {formatString, extension: remainingFormats[0].ext}
29-
}
44+
if (videoFormatId) {
45+
// The special case 'video only' has no audio
46+
return {formatString: videoFormatId, hasVideo: true, hasAudio: false}
47+
}
3048

49+
// The special case 'audio only' has no video data
50+
return {formatString: audioFormatId, hasVideo: false, hasAudio: true}
51+
}
3152

32-
exports.filterByProperty = async function(message, displayFun, list) {
53+
async function selectPresetOrResolution(formats) {
3354
const answers = await inquirer.prompt([
3455
{
3556
type: 'list',
3657
name: 'q',
37-
message,
38-
choices: [...new Set(list.map(displayFun))]
58+
message: 'What do you want?',
59+
pageSize: 10,
60+
choices: [
61+
{
62+
name: 'best video + best audio',
63+
value: {finalFormatId: 'bestvideo+bestaudio/best'}
64+
},
65+
{
66+
name: 'worst video + worst audio',
67+
value: {finalFormatId: 'worstvideo+worstaudio/worst'}
68+
},
69+
{
70+
name: '<480p mp4',
71+
value: {
72+
finalFormatId:
73+
'bestvideo[height<=480][ext=mp4]+bestaudio[ext=m4a]/best[height<=480][ext=mp4]'
74+
}
75+
},
76+
{name: 'audio only', value: {}},
77+
new inquirer.Separator('--- specify resolution: ---'),
78+
...getResolutions(formats).map(resolution => ({
79+
name: resolution + 'p',
80+
value: {height: resolution}
81+
}))
82+
]
3983
}
4084
])
41-
return list.filter(f => displayFun(f) === answers.q)
85+
86+
return answers.q
4287
}
4388

44-
exports.selectOne = async function(formats) {
89+
exports.selectOneVideo = async function(formats) {
4590
const answers = await inquirer.prompt([
4691
{
4792
type: 'list',
4893
name: 'q',
49-
message: 'Several matches. Select an option:',
50-
choices: formats.map(f => {
51-
return {name: exports.createDescription(f), value: f}
52-
})
94+
message: 'Select a video file:',
95+
choices: formats.map(f => ({
96+
name: exports.createVideoDescription(f),
97+
value: f
98+
}))
5399
}
54100
])
55101
return answers.q
56102
}
57-
58103

59-
exports.createDescription = function(f) {
60-
return `${f.ext} | ${f.format} (${byteSize(f.filesize, { units: 'iec' })})`;
104+
async function selectAudio(formats, selectedVideoFormat) {
105+
const choices = []
106+
107+
if (selectedVideoFormat && selectedVideoFormat.acodec !== 'none') {
108+
choices.push({
109+
name: `Use audio included in the video: ${exports.createAudioShortDescription(
110+
selectedVideoFormat
111+
)}`,
112+
value: selectedVideoFormat.format_id
113+
})
114+
choices.push(new inquirer.Separator())
115+
}
116+
117+
choices.push({name: 'best audio', value: 'bestaudio'})
118+
choices.push({name: 'worst audio', value: 'worstaudio'})
119+
choices.push(new inquirer.Separator('--- specify: ---'))
120+
choices.push(
121+
...getAudioFormats(formats).map(f => ({
122+
name: exports.createAudioDescription(f),
123+
value: f.format_id
124+
}))
125+
)
126+
127+
const audioAnswers = await inquirer.prompt([
128+
{
129+
message: 'Select audio:',
130+
type: 'list',
131+
name: 'q',
132+
pageSize: 10,
133+
choices
134+
}
135+
])
136+
return audioAnswers.q
137+
}
138+
139+
function getResolutions(formats) {
140+
const resolutions = formats.filter(f => Boolean(f.height)).map(f => f.height)
141+
142+
return [...new Set(resolutions)].sort(f => f.height).reverse()
143+
}
144+
145+
function getAudioFormats(formats) {
146+
return formats.filter(f => f.acodec && f.acodec !== 'none')
147+
}
148+
149+
exports.createVideoDescription = function(f) {
150+
return (
151+
paddingRight(f.ext, 4) +
152+
paddingRight(f.width ? f.width + 'x' + f.height : null, 9) +
153+
paddingRight(f.format_note, 10) +
154+
paddingRight(byteSize(f.filesize, {units: 'iec'}), 8) +
155+
exports.createAudioShortDescription(f, 'audio: ') +
156+
'(' +
157+
f.format_id +
158+
')'
159+
)
160+
}
161+
162+
const paddingRight = function(value, width) {
163+
if (!value) {
164+
value = ''
165+
}
166+
167+
// Value might be an object. Wrap it so we can call padEnd on it.
168+
value = String(value)
169+
return value.padEnd(width) + ' '
170+
}
171+
172+
const paddingLeft = function(value, width, suffix = '') {
173+
if (value) {
174+
value += suffix
175+
} else {
176+
value = ''
177+
}
178+
179+
// Value might be an object. Wrap it so we can call padEnd on it.
180+
value = String(value)
181+
return value.padStart(width) + ' '
61182
}
62183

184+
exports.createAudioDescription = function(f) {
185+
return (
186+
paddingRight(f.ext, 4) +
187+
paddingRight(f.acodec, 9) +
188+
'@ ' +
189+
paddingLeft(f.abr, 3, 'k') +
190+
paddingRight(f.format_note, 10) +
191+
paddingLeft(byteSize(f.filesize, {units: 'iec'}), 7) +
192+
'(' +
193+
f.format_id +
194+
')'
195+
)
196+
}
197+
198+
exports.createAudioShortDescription = function(f, prefix = '') {
199+
if (f.acodec && f.acodec !== 'none') {
200+
return (
201+
prefix + paddingRight(f.acodec, 9) + '@ ' + paddingLeft(f.abr, 3, 'k')
202+
)
203+
}
204+
205+
return ''
206+
}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"license": "MIT",
1515
"dependencies": {
1616
"byte-size": "^5.0.1",
17+
"chalk": "^2.4.2",
1718
"inquirer": "^6.3.1",
1819
"log-symbols": "^2.2.0",
1920
"meow": "^5.0.0",

0 commit comments

Comments
 (0)