Taylor Fox Dahlin commited on
Bugfixes (#1045)
Browse files* Removed special character from __author__ attribute.
* Changed -v CLI arg to have a single setting, rather than multiple.
* Add retry functionality for IncompleteRead errors.
* Extract contentLength from info where possible.
* Mock open in final streams test to prevent file from being written.
* Exception handling for accessing titles of private videos.
- pytube/__init__.py +1 -1
- pytube/__main__.py +14 -1
- pytube/cli.py +9 -7
- pytube/extract.py +2 -0
- pytube/request.py +4 -0
- pytube/streams.py +2 -2
- tests/test_cli.py +4 -3
- tests/test_streams.py +10 -19
pytube/__init__.py
CHANGED
|
@@ -4,7 +4,7 @@
|
|
| 4 |
Pytube: a very serious Python library for downloading YouTube Videos.
|
| 5 |
"""
|
| 6 |
__title__ = "pytube"
|
| 7 |
-
__author__ = "Ronnie
|
| 8 |
__license__ = "The Unlicense (Unlicense)"
|
| 9 |
__js__ = None
|
| 10 |
__js_url__ = None
|
|
|
|
| 4 |
Pytube: a very serious Python library for downloading YouTube Videos.
|
| 5 |
"""
|
| 6 |
__title__ = "pytube"
|
| 7 |
+
__author__ = "Ronnie Ghose, Taylor Fox Dahlin, Nick Ficano"
|
| 8 |
__license__ = "The Unlicense (Unlicense)"
|
| 9 |
__js__ = None
|
| 10 |
__js_url__ = None
|
pytube/__main__.py
CHANGED
|
@@ -351,7 +351,20 @@ class YouTube:
|
|
| 351 |
"""
|
| 352 |
if self._title:
|
| 353 |
return self._title
|
| 354 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 355 |
return self._title
|
| 356 |
|
| 357 |
@title.setter
|
|
|
|
| 351 |
"""
|
| 352 |
if self._title:
|
| 353 |
return self._title
|
| 354 |
+
|
| 355 |
+
try:
|
| 356 |
+
self._title = self.player_response['videoDetails']['title']
|
| 357 |
+
except KeyError:
|
| 358 |
+
# Check_availability will raise the correct exception in most cases
|
| 359 |
+
# if it doesn't, ask for a report.
|
| 360 |
+
self.check_availability()
|
| 361 |
+
raise exceptions.PytubeError(
|
| 362 |
+
(
|
| 363 |
+
f'Exception while accessing title of {self.watch_url}. '
|
| 364 |
+
'Please file a bug report at https://github.com/pytube/pytube'
|
| 365 |
+
)
|
| 366 |
+
)
|
| 367 |
+
|
| 368 |
return self._title
|
| 369 |
|
| 370 |
@title.setter
|
pytube/cli.py
CHANGED
|
@@ -17,17 +17,20 @@ from pytube import CaptionQuery, Playlist, Stream, YouTube
|
|
| 17 |
from pytube.helpers import safe_filename, setup_logger
|
| 18 |
|
| 19 |
|
|
|
|
|
|
|
|
|
|
| 20 |
def main():
|
| 21 |
"""Command line application to download youtube videos."""
|
| 22 |
# noinspection PyTypeChecker
|
| 23 |
parser = argparse.ArgumentParser(description=main.__doc__)
|
| 24 |
args = _parse_args(parser)
|
| 25 |
-
if args.
|
| 26 |
log_filename = None
|
| 27 |
-
log_level = min(args.verbosity, 4) * 10
|
| 28 |
if args.logfile:
|
| 29 |
log_filename = args.logfile
|
| 30 |
-
setup_logger(logging.
|
|
|
|
| 31 |
|
| 32 |
if not args.url or "youtu" not in args.url:
|
| 33 |
parser.print_help()
|
|
@@ -113,10 +116,9 @@ def _parse_args(
|
|
| 113 |
parser.add_argument(
|
| 114 |
"-v",
|
| 115 |
"--verbose",
|
| 116 |
-
action="
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
help="Verbosity level, use up to 4 to increase logging -vvvv",
|
| 120 |
)
|
| 121 |
parser.add_argument(
|
| 122 |
"--logfile",
|
|
|
|
| 17 |
from pytube.helpers import safe_filename, setup_logger
|
| 18 |
|
| 19 |
|
| 20 |
+
logger = logging.getLogger(__name__)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
def main():
|
| 24 |
"""Command line application to download youtube videos."""
|
| 25 |
# noinspection PyTypeChecker
|
| 26 |
parser = argparse.ArgumentParser(description=main.__doc__)
|
| 27 |
args = _parse_args(parser)
|
| 28 |
+
if args.verbose:
|
| 29 |
log_filename = None
|
|
|
|
| 30 |
if args.logfile:
|
| 31 |
log_filename = args.logfile
|
| 32 |
+
setup_logger(logging.DEBUG, log_filename=log_filename)
|
| 33 |
+
logger.debug(f'Pytube version: {__version__}')
|
| 34 |
|
| 35 |
if not args.url or "youtu" not in args.url:
|
| 36 |
parser.print_help()
|
|
|
|
| 116 |
parser.add_argument(
|
| 117 |
"-v",
|
| 118 |
"--verbose",
|
| 119 |
+
action="store_true",
|
| 120 |
+
dest="verbose",
|
| 121 |
+
help="Set logger output to verbose output.",
|
|
|
|
| 122 |
)
|
| 123 |
parser.add_argument(
|
| 124 |
"--logfile",
|
pytube/extract.py
CHANGED
|
@@ -532,6 +532,7 @@ def apply_descrambler(stream_data: Dict, key: str) -> None:
|
|
| 532 |
"fps": format_item["fps"] if 'video' in format_item["mimeType"] else None,
|
| 533 |
"bitrate": format_item.get("bitrate"),
|
| 534 |
"is_otf": (format_item.get("type") == otf_type),
|
|
|
|
| 535 |
}
|
| 536 |
for format_item in formats
|
| 537 |
]
|
|
@@ -554,6 +555,7 @@ def apply_descrambler(stream_data: Dict, key: str) -> None:
|
|
| 554 |
"fps": format_item["fps"] if 'video' in format_item["mimeType"] else None,
|
| 555 |
"bitrate": format_item.get("bitrate"),
|
| 556 |
"is_otf": (format_item.get("type") == otf_type),
|
|
|
|
| 557 |
}
|
| 558 |
for i, format_item in enumerate(formats)
|
| 559 |
]
|
|
|
|
| 532 |
"fps": format_item["fps"] if 'video' in format_item["mimeType"] else None,
|
| 533 |
"bitrate": format_item.get("bitrate"),
|
| 534 |
"is_otf": (format_item.get("type") == otf_type),
|
| 535 |
+
'content_length': int(format_item.get('contentLength', 0)),
|
| 536 |
}
|
| 537 |
for format_item in formats
|
| 538 |
]
|
|
|
|
| 555 |
"fps": format_item["fps"] if 'video' in format_item["mimeType"] else None,
|
| 556 |
"bitrate": format_item.get("bitrate"),
|
| 557 |
"is_otf": (format_item.get("type") == otf_type),
|
| 558 |
+
'content_length': int(format_item.get('contentLength', 0)),
|
| 559 |
}
|
| 560 |
for i, format_item in enumerate(formats)
|
| 561 |
]
|
pytube/request.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
"""Implements a simple wrapper around urlopen."""
|
|
|
|
| 2 |
import json
|
| 3 |
import logging
|
| 4 |
import re
|
|
@@ -166,6 +167,9 @@ def stream(
|
|
| 166 |
pass
|
| 167 |
else:
|
| 168 |
raise
|
|
|
|
|
|
|
|
|
|
| 169 |
else:
|
| 170 |
# On a successful request, break from loop
|
| 171 |
break
|
|
|
|
| 1 |
"""Implements a simple wrapper around urlopen."""
|
| 2 |
+
import http.client
|
| 3 |
import json
|
| 4 |
import logging
|
| 5 |
import re
|
|
|
|
| 167 |
pass
|
| 168 |
else:
|
| 169 |
raise
|
| 170 |
+
except http.client.IncompleteRead:
|
| 171 |
+
# Allow retries on IncompleteRead errors for unreliable connections
|
| 172 |
+
pass
|
| 173 |
else:
|
| 174 |
# On a successful request, break from loop
|
| 175 |
break
|
pytube/streams.py
CHANGED
|
@@ -62,7 +62,7 @@ class Stream:
|
|
| 62 |
self.is_otf: bool = stream["is_otf"]
|
| 63 |
self.bitrate: Optional[int] = stream["bitrate"]
|
| 64 |
|
| 65 |
-
self._filesize: Optional[int] =
|
| 66 |
|
| 67 |
# Additional information about the stream format, such as resolution,
|
| 68 |
# frame rate, and whether the stream is live (HLS) or 3D.
|
|
@@ -147,7 +147,7 @@ class Stream:
|
|
| 147 |
:returns:
|
| 148 |
Filesize (in bytes) of the stream.
|
| 149 |
"""
|
| 150 |
-
if self._filesize
|
| 151 |
try:
|
| 152 |
self._filesize = request.filesize(self.url)
|
| 153 |
except HTTPError as e:
|
|
|
|
| 62 |
self.is_otf: bool = stream["is_otf"]
|
| 63 |
self.bitrate: Optional[int] = stream["bitrate"]
|
| 64 |
|
| 65 |
+
self._filesize: Optional[int] = stream['content_length'] # filesize in bytes
|
| 66 |
|
| 67 |
# Additional information about the stream format, such as resolution,
|
| 68 |
# frame rate, and whether the stream is live (HLS) or 3D.
|
|
|
|
| 147 |
:returns:
|
| 148 |
Filesize (in bytes) of the stream.
|
| 149 |
"""
|
| 150 |
+
if self._filesize == 0:
|
| 151 |
try:
|
| 152 |
self._filesize = request.filesize(self.url)
|
| 153 |
except HTTPError as e:
|
tests/test_cli.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
import argparse
|
|
|
|
| 2 |
import os
|
| 3 |
from unittest import mock
|
| 4 |
from unittest.mock import MagicMock, patch
|
|
@@ -139,7 +140,7 @@ def test_parse_args_falsey():
|
|
| 139 |
assert args.build_playback_report is False
|
| 140 |
assert args.itag is None
|
| 141 |
assert args.list is False
|
| 142 |
-
assert args.
|
| 143 |
|
| 144 |
|
| 145 |
def test_parse_args_truthy():
|
|
@@ -160,7 +161,7 @@ def test_parse_args_truthy():
|
|
| 160 |
assert args.build_playback_report is True
|
| 161 |
assert args.itag == 10
|
| 162 |
assert args.list is True
|
| 163 |
-
assert args.
|
| 164 |
|
| 165 |
|
| 166 |
@mock.patch("pytube.cli.setup_logger", return_value=None)
|
|
@@ -173,7 +174,7 @@ def test_main_logging_setup(setup_logger):
|
|
| 173 |
with pytest.raises(SystemExit):
|
| 174 |
cli.main()
|
| 175 |
# Then
|
| 176 |
-
setup_logger.assert_called_with(
|
| 177 |
|
| 178 |
|
| 179 |
@mock.patch("pytube.cli.YouTube", return_value=None)
|
|
|
|
| 1 |
import argparse
|
| 2 |
+
import logging
|
| 3 |
import os
|
| 4 |
from unittest import mock
|
| 5 |
from unittest.mock import MagicMock, patch
|
|
|
|
| 140 |
assert args.build_playback_report is False
|
| 141 |
assert args.itag is None
|
| 142 |
assert args.list is False
|
| 143 |
+
assert args.verbose is False
|
| 144 |
|
| 145 |
|
| 146 |
def test_parse_args_truthy():
|
|
|
|
| 161 |
assert args.build_playback_report is True
|
| 162 |
assert args.itag == 10
|
| 163 |
assert args.list is True
|
| 164 |
+
assert args.verbose is True
|
| 165 |
|
| 166 |
|
| 167 |
@mock.patch("pytube.cli.setup_logger", return_value=None)
|
|
|
|
| 174 |
with pytest.raises(SystemExit):
|
| 175 |
cli.main()
|
| 176 |
# Then
|
| 177 |
+
setup_logger.assert_called_with(logging.DEBUG, log_filename=None)
|
| 178 |
|
| 179 |
|
| 180 |
@mock.patch("pytube.cli.YouTube", return_value=None)
|
tests/test_streams.py
CHANGED
|
@@ -27,22 +27,16 @@ def test_stream_to_buffer(mock_request, cipher_signature):
|
|
| 27 |
assert buffer.write.call_count == 3
|
| 28 |
|
| 29 |
|
| 30 |
-
@mock.patch(
|
| 31 |
-
"pytube.streams.request.head", MagicMock(return_value={"content-length": "6796391"})
|
| 32 |
-
)
|
| 33 |
def test_filesize(cipher_signature):
|
| 34 |
-
assert cipher_signature.streams[0].filesize ==
|
| 35 |
|
| 36 |
|
| 37 |
-
@mock.patch(
|
| 38 |
-
"pytube.streams.request.head", MagicMock(return_value={"content-length": "6796391"})
|
| 39 |
-
)
|
| 40 |
def test_filesize_approx(cipher_signature):
|
| 41 |
stream = cipher_signature.streams[0]
|
| 42 |
|
| 43 |
assert stream.filesize_approx == 28309811
|
| 44 |
stream.bitrate = None
|
| 45 |
-
assert stream.filesize_approx ==
|
| 46 |
|
| 47 |
|
| 48 |
def test_default_filename(cipher_signature):
|
|
@@ -345,14 +339,11 @@ def test_segmented_stream_on_404(cipher_signature):
|
|
| 345 |
]
|
| 346 |
|
| 347 |
# Request order for stream:
|
| 348 |
-
#
|
| 349 |
-
#
|
| 350 |
-
#
|
| 351 |
-
#
|
| 352 |
-
#
|
| 353 |
-
# 4. info(url) -> 404
|
| 354 |
-
# 5. get(url&sn=0)
|
| 355 |
-
# 6. get(url&sn=[1,2,3])
|
| 356 |
|
| 357 |
# Handle filesize requests
|
| 358 |
mock_head.side_effect = [
|
|
@@ -363,7 +354,6 @@ def test_segmented_stream_on_404(cipher_signature):
|
|
| 363 |
# Each response must be followed by None, to break iteration
|
| 364 |
# in the stream() function
|
| 365 |
mock_url_open_object.read.side_effect = [
|
| 366 |
-
responses[0], None,
|
| 367 |
responses[0], None,
|
| 368 |
responses[1], None,
|
| 369 |
responses[2], None,
|
|
@@ -394,5 +384,6 @@ def test_segmented_only_catches_404(cipher_signature):
|
|
| 394 |
stream = cipher_signature.streams.filter(adaptive=True)[0]
|
| 395 |
with mock.patch('pytube.request.head') as mock_head:
|
| 396 |
mock_head.side_effect = HTTPError('', 403, 'Forbidden', '', '')
|
| 397 |
-
with
|
| 398 |
-
|
|
|
|
|
|
| 27 |
assert buffer.write.call_count == 3
|
| 28 |
|
| 29 |
|
|
|
|
|
|
|
|
|
|
| 30 |
def test_filesize(cipher_signature):
|
| 31 |
+
assert cipher_signature.streams[0].filesize == 28282013
|
| 32 |
|
| 33 |
|
|
|
|
|
|
|
|
|
|
| 34 |
def test_filesize_approx(cipher_signature):
|
| 35 |
stream = cipher_signature.streams[0]
|
| 36 |
|
| 37 |
assert stream.filesize_approx == 28309811
|
| 38 |
stream.bitrate = None
|
| 39 |
+
assert stream.filesize_approx == 28282013
|
| 40 |
|
| 41 |
|
| 42 |
def test_default_filename(cipher_signature):
|
|
|
|
| 339 |
]
|
| 340 |
|
| 341 |
# Request order for stream:
|
| 342 |
+
# 1. get(url&sn=0)
|
| 343 |
+
# 2. head(url&sn=[1,2,3])
|
| 344 |
+
# 3. info(url) -> 404
|
| 345 |
+
# 4. get(url&sn=0)
|
| 346 |
+
# 5. get(url&sn=[1,2,3])
|
|
|
|
|
|
|
|
|
|
| 347 |
|
| 348 |
# Handle filesize requests
|
| 349 |
mock_head.side_effect = [
|
|
|
|
| 354 |
# Each response must be followed by None, to break iteration
|
| 355 |
# in the stream() function
|
| 356 |
mock_url_open_object.read.side_effect = [
|
|
|
|
| 357 |
responses[0], None,
|
| 358 |
responses[1], None,
|
| 359 |
responses[2], None,
|
|
|
|
| 384 |
stream = cipher_signature.streams.filter(adaptive=True)[0]
|
| 385 |
with mock.patch('pytube.request.head') as mock_head:
|
| 386 |
mock_head.side_effect = HTTPError('', 403, 'Forbidden', '', '')
|
| 387 |
+
with mock.patch("pytube.streams.open", mock.mock_open(), create=True):
|
| 388 |
+
with pytest.raises(HTTPError):
|
| 389 |
+
stream.download()
|