Taylor Fox Dahlin
commited on
Commit
•
e573655
1
Parent(s):
181c88c
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()
|