Merge pull request #27 from swiftyy-mage/master
Browse files- pytube/cli.py +191 -3
- tests/test_cli.py +47 -0
pytube/cli.py
CHANGED
@@ -1,5 +1,4 @@
|
|
1 |
#!/usr/bin/env python3
|
2 |
-
# -*- coding: utf-8 -*-
|
3 |
"""A simple command line application to download youtube videos."""
|
4 |
|
5 |
import argparse
|
@@ -10,6 +9,7 @@ import logging
|
|
10 |
import os
|
11 |
import shutil
|
12 |
import sys
|
|
|
13 |
from io import BufferedWriter
|
14 |
from typing import Any, Optional, List
|
15 |
|
@@ -64,6 +64,10 @@ def _perform_args_on_youtube(youtube: YouTube, args: argparse.Namespace) -> None
|
|
64 |
download_by_resolution(
|
65 |
youtube=youtube, resolution=args.resolution, target=args.target
|
66 |
)
|
|
|
|
|
|
|
|
|
67 |
|
68 |
|
69 |
def _parse_args(
|
@@ -120,6 +124,27 @@ def _parse_args(
|
|
120 |
"Default is current working directory"
|
121 |
),
|
122 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
123 |
|
124 |
return parser.parse_args(args)
|
125 |
|
@@ -192,13 +217,141 @@ def on_progress(
|
|
192 |
display_progress_bar(bytes_received, filesize)
|
193 |
|
194 |
|
195 |
-
def _download(
|
|
|
|
|
196 |
filesize_megabytes = stream.filesize // 1048576
|
197 |
print(f"{stream.default_filename} | {filesize_megabytes} MB")
|
198 |
-
stream.download(output_path=target)
|
199 |
sys.stdout.write("\n")
|
200 |
|
201 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
202 |
def download_by_itag(youtube: YouTube, itag: int, target: Optional[str] = None) -> None:
|
203 |
"""Start downloading a YouTube video.
|
204 |
|
@@ -236,6 +389,7 @@ def download_by_resolution(
|
|
236 |
:param str target:
|
237 |
Target directory for download
|
238 |
"""
|
|
|
239 |
stream = youtube.streams.get_by_resolution(resolution)
|
240 |
if stream is None:
|
241 |
print(f"Could not find a stream with resolution: {resolution}")
|
@@ -293,5 +447,39 @@ def download_caption(
|
|
293 |
_print_available_captions(youtube.captions)
|
294 |
|
295 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
296 |
if __name__ == "__main__":
|
297 |
main()
|
|
|
1 |
#!/usr/bin/env python3
|
|
|
2 |
"""A simple command line application to download youtube videos."""
|
3 |
|
4 |
import argparse
|
|
|
9 |
import os
|
10 |
import shutil
|
11 |
import sys
|
12 |
+
import subprocess # nosec
|
13 |
from io import BufferedWriter
|
14 |
from typing import Any, Optional, List
|
15 |
|
|
|
64 |
download_by_resolution(
|
65 |
youtube=youtube, resolution=args.resolution, target=args.target
|
66 |
)
|
67 |
+
if args.audio:
|
68 |
+
download_audio(youtube=youtube, filetype=args.audio, target=args.target)
|
69 |
+
if args.ffmpeg:
|
70 |
+
ffmpeg_process(youtube=youtube, resolution=args.ffmpeg, target=args.target)
|
71 |
|
72 |
|
73 |
def _parse_args(
|
|
|
124 |
"Default is current working directory"
|
125 |
),
|
126 |
)
|
127 |
+
parser.add_argument(
|
128 |
+
"-a",
|
129 |
+
"--audio",
|
130 |
+
const="mp4",
|
131 |
+
nargs="?",
|
132 |
+
help=(
|
133 |
+
"Download the audio for a given URL at the highest bitrate available"
|
134 |
+
"Defaults to mp4 format if none is specified"
|
135 |
+
),
|
136 |
+
)
|
137 |
+
parser.add_argument(
|
138 |
+
"-f",
|
139 |
+
"--ffmpeg",
|
140 |
+
const="best",
|
141 |
+
nargs="?",
|
142 |
+
help=(
|
143 |
+
"Downloads the audio and video stream for resolution provided"
|
144 |
+
"If no resolution is provided, downloads the best resolution"
|
145 |
+
"Runs the command line program ffmpeg to combine the audio and video"
|
146 |
+
),
|
147 |
+
)
|
148 |
|
149 |
return parser.parse_args(args)
|
150 |
|
|
|
217 |
display_progress_bar(bytes_received, filesize)
|
218 |
|
219 |
|
220 |
+
def _download(
|
221 |
+
stream: Stream, target: Optional[str] = None, filename: Optional[str] = None
|
222 |
+
) -> None:
|
223 |
filesize_megabytes = stream.filesize // 1048576
|
224 |
print(f"{stream.default_filename} | {filesize_megabytes} MB")
|
225 |
+
stream.download(output_path=target, filename=filename)
|
226 |
sys.stdout.write("\n")
|
227 |
|
228 |
|
229 |
+
def unique_name(
|
230 |
+
base: str, subtype: Optional[str], video_audio: str, target: str
|
231 |
+
) -> str:
|
232 |
+
"""
|
233 |
+
Given a base name, the file format, and the target directory, will generate
|
234 |
+
a filename unique for that directory and file format.
|
235 |
+
:param str base:
|
236 |
+
The given base-name.
|
237 |
+
:param str subtype:
|
238 |
+
The filetype of the video which will be downloaded.
|
239 |
+
:param Path target:
|
240 |
+
Target directory for download.
|
241 |
+
"""
|
242 |
+
counter = 0
|
243 |
+
while True:
|
244 |
+
name = f"{base}_{video_audio}_{counter}"
|
245 |
+
unique = os.path.join(target, f"{name}.{subtype}")
|
246 |
+
if not os.path.exists(unique):
|
247 |
+
return str(name)
|
248 |
+
counter += 1
|
249 |
+
|
250 |
+
|
251 |
+
def ffmpeg_process(
|
252 |
+
youtube: YouTube, resolution: str, target: Optional[str] = None
|
253 |
+
) -> None:
|
254 |
+
"""
|
255 |
+
Decides the correct video stream to download, then calls ffmpeg_downloader.
|
256 |
+
|
257 |
+
:param YouTube youtube:
|
258 |
+
A valid YouTube object.
|
259 |
+
:param str resolution:
|
260 |
+
YouTube video resolution.
|
261 |
+
:param str target:
|
262 |
+
Target directory for download
|
263 |
+
"""
|
264 |
+
youtube.register_on_progress_callback(on_progress)
|
265 |
+
if target is None:
|
266 |
+
target = os.getcwd()
|
267 |
+
|
268 |
+
if resolution == "best":
|
269 |
+
highest_quality = (
|
270 |
+
youtube.streams.filter(progressive=False)
|
271 |
+
.order_by("resolution")
|
272 |
+
.desc()
|
273 |
+
.first()
|
274 |
+
)
|
275 |
+
|
276 |
+
video_stream = (
|
277 |
+
youtube.streams.filter(progressive=False, subtype="mp4")
|
278 |
+
.order_by("resolution")
|
279 |
+
.desc()
|
280 |
+
.first()
|
281 |
+
)
|
282 |
+
|
283 |
+
if highest_quality.resolution == video_stream.resolution:
|
284 |
+
ffmpeg_downloader(youtube=youtube, stream=video_stream, target=target)
|
285 |
+
else:
|
286 |
+
ffmpeg_downloader(youtube=youtube, stream=highest_quality, target=target)
|
287 |
+
else:
|
288 |
+
video_stream = youtube.streams.filter(
|
289 |
+
progressive=False, resolution=resolution, subtype="mp4"
|
290 |
+
).first()
|
291 |
+
if video_stream is not None:
|
292 |
+
ffmpeg_downloader(youtube=youtube, stream=video_stream, target=target)
|
293 |
+
else:
|
294 |
+
video_stream = youtube.streams.filter(
|
295 |
+
progressive=False, resolution=resolution
|
296 |
+
).first()
|
297 |
+
if video_stream is None:
|
298 |
+
print(f"Could not find a stream with resolution: {resolution}")
|
299 |
+
print("Try one of these:")
|
300 |
+
display_streams(youtube)
|
301 |
+
sys.exit()
|
302 |
+
ffmpeg_downloader(youtube=youtube, stream=video_stream, target=target)
|
303 |
+
|
304 |
+
|
305 |
+
def ffmpeg_downloader(youtube: YouTube, stream: Stream, target: str) -> None:
|
306 |
+
"""
|
307 |
+
Given a YouTube Stream object, finds the correct audio stream, downloads them both
|
308 |
+
giving them a unique name, them uses ffmpeg to create a new file with the audio
|
309 |
+
and video from the previously downloaded files. Then deletes the original adaptive
|
310 |
+
streams, leaving the combination.
|
311 |
+
|
312 |
+
:param YouTube youtube:
|
313 |
+
A valid YouTube object
|
314 |
+
:param Stream stream:
|
315 |
+
A valid Stream object
|
316 |
+
:param Path target:
|
317 |
+
A valid Path object
|
318 |
+
"""
|
319 |
+
audio_stream = (
|
320 |
+
youtube.streams.filter(only_audio=True, subtype=stream.subtype)
|
321 |
+
.order_by("abr")
|
322 |
+
.desc()
|
323 |
+
.first()
|
324 |
+
)
|
325 |
+
|
326 |
+
video_unique_name = unique_name(
|
327 |
+
safe_filename(stream.title), stream.subtype, "video", target=target
|
328 |
+
)
|
329 |
+
audio_unique_name = unique_name(
|
330 |
+
safe_filename(stream.title), stream.subtype, "audio", target=target
|
331 |
+
)
|
332 |
+
_download(stream=stream, target=target, filename=video_unique_name)
|
333 |
+
_download(stream=audio_stream, target=target, filename=audio_unique_name)
|
334 |
+
|
335 |
+
video_path = os.path.join(target, f"{video_unique_name}.{stream.subtype}")
|
336 |
+
audio_path = os.path.join(target, f"{audio_unique_name}.{stream.subtype}")
|
337 |
+
final_path = os.path.join(target, f"{safe_filename(stream.title)}.{stream.subtype}")
|
338 |
+
|
339 |
+
subprocess.run( # nosec
|
340 |
+
[
|
341 |
+
"ffmpeg",
|
342 |
+
"-i",
|
343 |
+
f"{video_path}",
|
344 |
+
"-i",
|
345 |
+
f"{audio_path}",
|
346 |
+
"-codec",
|
347 |
+
"copy",
|
348 |
+
f"{final_path}",
|
349 |
+
]
|
350 |
+
)
|
351 |
+
os.unlink(video_path)
|
352 |
+
os.unlink(audio_path)
|
353 |
+
|
354 |
+
|
355 |
def download_by_itag(youtube: YouTube, itag: int, target: Optional[str] = None) -> None:
|
356 |
"""Start downloading a YouTube video.
|
357 |
|
|
|
389 |
:param str target:
|
390 |
Target directory for download
|
391 |
"""
|
392 |
+
# TODO(nficano): allow dash itags to be selected
|
393 |
stream = youtube.streams.get_by_resolution(resolution)
|
394 |
if stream is None:
|
395 |
print(f"Could not find a stream with resolution: {resolution}")
|
|
|
447 |
_print_available_captions(youtube.captions)
|
448 |
|
449 |
|
450 |
+
def download_audio(
|
451 |
+
youtube: YouTube, filetype: str, target: Optional[str] = None
|
452 |
+
) -> None:
|
453 |
+
"""
|
454 |
+
Given a filetype, downloads the highest quality available audio stream for a
|
455 |
+
YouTube video.
|
456 |
+
|
457 |
+
:param YouTube youtube:
|
458 |
+
A valid YouTube object.
|
459 |
+
:param str filetype:
|
460 |
+
Desired file format to download.
|
461 |
+
:param str target:
|
462 |
+
Target directory for download
|
463 |
+
"""
|
464 |
+
audio = (
|
465 |
+
youtube.streams.filter(only_audio=True, subtype=filetype)
|
466 |
+
.order_by("abr")
|
467 |
+
.desc()
|
468 |
+
.first()
|
469 |
+
)
|
470 |
+
|
471 |
+
if audio is None:
|
472 |
+
print("No audio only stream found. Try one of these:")
|
473 |
+
display_streams(youtube)
|
474 |
+
sys.exit()
|
475 |
+
|
476 |
+
youtube.register_on_progress_callback(on_progress)
|
477 |
+
|
478 |
+
try:
|
479 |
+
_download(audio, target=target)
|
480 |
+
except KeyboardInterrupt:
|
481 |
+
sys.exit()
|
482 |
+
|
483 |
+
|
484 |
if __name__ == "__main__":
|
485 |
main()
|
tests/test_cli.py
CHANGED
@@ -217,3 +217,50 @@ def test_download_by_resolution_not_exists(youtube, stream_query):
|
|
217 |
youtube=youtube, resolution="DOESNT EXIST", target="test_target"
|
218 |
)
|
219 |
cli._download.assert_not_called()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
217 |
youtube=youtube, resolution="DOESNT EXIST", target="test_target"
|
218 |
)
|
219 |
cli._download.assert_not_called()
|
220 |
+
|
221 |
+
|
222 |
+
@mock.patch("pytube.cli.YouTube")
|
223 |
+
@mock.patch("pytube.cli.Stream")
|
224 |
+
def test_ffmpeg_downloader(youtube, stream):
|
225 |
+
parser = argparse.ArgumentParser()
|
226 |
+
args = parse_args(parser, ["http://youtube.com/watch?v=9bZkp7q19f0", "-f", "best"])
|
227 |
+
cli._parse_args = MagicMock(return_value=args)
|
228 |
+
cli.safe_filename = MagicMock(return_value="PSY - GANGNAM STYLE(강남스타일) MV")
|
229 |
+
cli.subprocess.run = MagicMock()
|
230 |
+
cli.os.unlink = MagicMock()
|
231 |
+
cli.ffmpeg_downloader = MagicMock()
|
232 |
+
cli.main()
|
233 |
+
cli.ffmpeg_downloader.assert_called()
|
234 |
+
|
235 |
+
|
236 |
+
@mock.patch("pytube.cli.YouTube.__init__", return_value=None)
|
237 |
+
def test_download_audio(youtube):
|
238 |
+
parser = argparse.ArgumentParser()
|
239 |
+
args = parse_args(parser, ["http://youtube.com/watch?v=9bZkp7q19f0", "-a", "mp4"])
|
240 |
+
cli._parse_args = MagicMock(return_value=args)
|
241 |
+
cli.download_audio = MagicMock()
|
242 |
+
cli.main()
|
243 |
+
youtube.assert_called()
|
244 |
+
cli.download_audio.assert_called()
|
245 |
+
|
246 |
+
|
247 |
+
@mock.patch("pytube.cli.YouTube.__init__", return_value=None)
|
248 |
+
def test_ffmpeg_process(youtube):
|
249 |
+
parser = argparse.ArgumentParser()
|
250 |
+
args = parse_args(parser, ["http://youtube.com/watch?v=9bZkp7q19f0", "-f", "2160p"])
|
251 |
+
cli._parse_args = MagicMock(return_value=args)
|
252 |
+
cli.ffmpeg_process = MagicMock()
|
253 |
+
cli.main()
|
254 |
+
youtube.assert_called()
|
255 |
+
cli.ffmpeg_process.assert_called()
|
256 |
+
|
257 |
+
|
258 |
+
@mock.patch("pytube.cli.YouTube.__init__", return_value=None)
|
259 |
+
def test_perform_args_on_youtube(youtube):
|
260 |
+
parser = argparse.ArgumentParser()
|
261 |
+
args = parse_args(parser, ["http://youtube.com/watch?v=9bZkp7q19f0"])
|
262 |
+
cli._parse_args = MagicMock(return_value=args)
|
263 |
+
cli._perform_args_on_youtube = MagicMock()
|
264 |
+
cli.main()
|
265 |
+
youtube.assert_called()
|
266 |
+
cli._perform_args_on_youtube.assert_called()
|