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()
|