Taylor Fox Dahlin
commited on
Added new exception for region-locked videos. (#861)
Browse files- pytube/__main__.py +5 -0
- pytube/exceptions.py +12 -0
- pytube/extract.py +44 -7
- tests/conftest.py +7 -0
- tests/mocks/yt-video-hZpzr8TbF08-html.json.gz +0 -0
- tests/test_exceptions.py +28 -7
- tests/test_helpers.py +1 -1
pytube/__main__.py
CHANGED
@@ -25,6 +25,7 @@ from pytube.exceptions import MembersOnly
|
|
25 |
from pytube.exceptions import RecordingUnavailable
|
26 |
from pytube.exceptions import VideoUnavailable
|
27 |
from pytube.exceptions import VideoPrivate
|
|
|
28 |
from pytube.extract import apply_descrambler
|
29 |
from pytube.extract import apply_signature
|
30 |
from pytube.extract import get_ytplayer_config
|
@@ -113,6 +114,7 @@ class YouTube:
|
|
113 |
raise VideoUnavailable(video_id=self.video_id)
|
114 |
|
115 |
status, messages = extract.playability_status(self.watch_html)
|
|
|
116 |
for reason in messages:
|
117 |
if status == 'UNPLAYABLE':
|
118 |
if reason == (
|
@@ -123,6 +125,9 @@ class YouTube:
|
|
123 |
elif reason == 'This live stream recording is not available.':
|
124 |
raise RecordingUnavailable(video_id=self.video_id)
|
125 |
else:
|
|
|
|
|
|
|
126 |
raise VideoUnavailable(video_id=self.video_id)
|
127 |
elif status == 'LOGIN_REQUIRED':
|
128 |
if reason == (
|
|
|
25 |
from pytube.exceptions import RecordingUnavailable
|
26 |
from pytube.exceptions import VideoUnavailable
|
27 |
from pytube.exceptions import VideoPrivate
|
28 |
+
from pytube.exceptions import VideoRegionBlocked
|
29 |
from pytube.extract import apply_descrambler
|
30 |
from pytube.extract import apply_signature
|
31 |
from pytube.extract import get_ytplayer_config
|
|
|
114 |
raise VideoUnavailable(video_id=self.video_id)
|
115 |
|
116 |
status, messages = extract.playability_status(self.watch_html)
|
117 |
+
|
118 |
for reason in messages:
|
119 |
if status == 'UNPLAYABLE':
|
120 |
if reason == (
|
|
|
125 |
elif reason == 'This live stream recording is not available.':
|
126 |
raise RecordingUnavailable(video_id=self.video_id)
|
127 |
else:
|
128 |
+
if reason == 'Video unavailable':
|
129 |
+
if extract.is_region_blocked(self.watch_html):
|
130 |
+
raise VideoRegionBlocked(video_id=self.video_id)
|
131 |
raise VideoUnavailable(video_id=self.video_id)
|
132 |
elif status == 'LOGIN_REQUIRED':
|
133 |
if reason == (
|
pytube/exceptions.py
CHANGED
@@ -96,5 +96,17 @@ class MembersOnly(PytubeError):
|
|
96 |
self.video_id = video_id
|
97 |
|
98 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
99 |
class HTMLParseError(PytubeError):
|
100 |
"""HTML could not be parsed"""
|
|
|
96 |
self.video_id = video_id
|
97 |
|
98 |
|
99 |
+
class VideoRegionBlocked(ExtractError):
|
100 |
+
def __init__(self, video_id: str):
|
101 |
+
"""
|
102 |
+
:param str video_id:
|
103 |
+
A YouTube video identifier.
|
104 |
+
"""
|
105 |
+
super().__init__(
|
106 |
+
'%s is not available in your region' % video_id
|
107 |
+
)
|
108 |
+
self.video_id = video_id
|
109 |
+
|
110 |
+
|
111 |
class HTMLParseError(PytubeError):
|
112 |
"""HTML could not be parsed"""
|
pytube/extract.py
CHANGED
@@ -100,6 +100,34 @@ def is_age_restricted(watch_html: str) -> bool:
|
|
100 |
return True
|
101 |
|
102 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
103 |
def playability_status(watch_html: str) -> (str, str):
|
104 |
"""Return the playability status and status explanation of a video.
|
105 |
|
@@ -114,7 +142,7 @@ def playability_status(watch_html: str) -> (str, str):
|
|
114 |
:returns:
|
115 |
Playability status and reason of the video.
|
116 |
"""
|
117 |
-
player_response =
|
118 |
status_dict = player_response.get('playabilityStatus', {})
|
119 |
if 'status' in status_dict:
|
120 |
if 'reason' in status_dict:
|
@@ -467,7 +495,7 @@ def initial_data(watch_html: str) -> str:
|
|
467 |
except HTMLParseError:
|
468 |
pass
|
469 |
|
470 |
-
raise RegexMatchError(caller=
|
471 |
|
472 |
|
473 |
def initial_player_response(watch_html: str) -> str:
|
@@ -479,11 +507,20 @@ def initial_player_response(watch_html: str) -> str:
|
|
479 |
@param watch_html: Html of the watch page
|
480 |
@return:
|
481 |
"""
|
482 |
-
|
483 |
-
|
484 |
-
|
485 |
-
|
486 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
487 |
|
488 |
|
489 |
def metadata(initial_data) -> Optional[YouTubeMetadata]:
|
|
|
100 |
return True
|
101 |
|
102 |
|
103 |
+
def is_region_blocked(watch_html: str) -> bool:
|
104 |
+
"""Determine if a video is not available in the user's region.
|
105 |
+
|
106 |
+
:param str watch_html:
|
107 |
+
The html contents of the watch page.
|
108 |
+
:rtype: bool
|
109 |
+
:returns:
|
110 |
+
True if the video is blocked in the users region.
|
111 |
+
False if not, or if unknown.
|
112 |
+
"""
|
113 |
+
player_response = initial_player_response(watch_html)
|
114 |
+
country_code_patterns = [
|
115 |
+
r"gl\s*=\s*['\"](\w{2})['\"]", # gl="US"
|
116 |
+
r"['\"]gl['\"]\s*:\s*['\"](\w{2})['\"]" # "gl":"US"
|
117 |
+
]
|
118 |
+
for pattern in country_code_patterns:
|
119 |
+
try:
|
120 |
+
yt_detected_country = regex_search(pattern, watch_html, 1)
|
121 |
+
available_countries = player_response[
|
122 |
+
'microformat']['playerMicroformatRenderer']['availableCountries']
|
123 |
+
except (KeyError, RegexMatchError):
|
124 |
+
pass
|
125 |
+
else:
|
126 |
+
if yt_detected_country not in available_countries:
|
127 |
+
return True
|
128 |
+
return False
|
129 |
+
|
130 |
+
|
131 |
def playability_status(watch_html: str) -> (str, str):
|
132 |
"""Return the playability status and status explanation of a video.
|
133 |
|
|
|
142 |
:returns:
|
143 |
Playability status and reason of the video.
|
144 |
"""
|
145 |
+
player_response = initial_player_response(watch_html)
|
146 |
status_dict = player_response.get('playabilityStatus', {})
|
147 |
if 'status' in status_dict:
|
148 |
if 'reason' in status_dict:
|
|
|
495 |
except HTMLParseError:
|
496 |
pass
|
497 |
|
498 |
+
raise RegexMatchError(caller='initial_data', pattern='initial_data_pattern')
|
499 |
|
500 |
|
501 |
def initial_player_response(watch_html: str) -> str:
|
|
|
507 |
@param watch_html: Html of the watch page
|
508 |
@return:
|
509 |
"""
|
510 |
+
patterns = [
|
511 |
+
r"window\[['\"]ytInitialPlayerResponse['\"]]\s*=\s*",
|
512 |
+
r"ytInitialPlayerResponse\s*=\s*"
|
513 |
+
]
|
514 |
+
for pattern in patterns:
|
515 |
+
try:
|
516 |
+
return parse_for_object(watch_html, pattern)
|
517 |
+
except HTMLParseError:
|
518 |
+
pass
|
519 |
+
|
520 |
+
raise RegexMatchError(
|
521 |
+
caller='initial_player_response',
|
522 |
+
pattern='initial_player_response_pattern'
|
523 |
+
)
|
524 |
|
525 |
|
526 |
def metadata(initial_data) -> Optional[YouTubeMetadata]:
|
tests/conftest.py
CHANGED
@@ -72,6 +72,13 @@ def missing_recording():
|
|
72 |
return load_playback_file(filename)
|
73 |
|
74 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
75 |
@pytest.fixture
|
76 |
def playlist_html():
|
77 |
"""Youtube playlist HTML loaded on 2020-01-25 from
|
|
|
72 |
return load_playback_file(filename)
|
73 |
|
74 |
|
75 |
+
@pytest.fixture
|
76 |
+
def region_blocked():
|
77 |
+
"""Youtube instance initialized with video id hZpzr8TbF08."""
|
78 |
+
filename = "yt-video-hZpzr8TbF08-html.json.gz"
|
79 |
+
return load_playback_file(filename)
|
80 |
+
|
81 |
+
|
82 |
@pytest.fixture
|
83 |
def playlist_html():
|
84 |
"""Youtube playlist HTML loaded on 2020-01-25 from
|
tests/mocks/yt-video-hZpzr8TbF08-html.json.gz
ADDED
Binary file (96.7 kB). View file
|
|
tests/test_exceptions.py
CHANGED
@@ -8,6 +8,7 @@ from pytube.exceptions import RecordingUnavailable
|
|
8 |
from pytube.exceptions import RegexMatchError
|
9 |
from pytube.exceptions import VideoUnavailable
|
10 |
from pytube.exceptions import VideoPrivate
|
|
|
11 |
|
12 |
|
13 |
def test_video_unavailable():
|
@@ -27,18 +28,18 @@ def test_regex_match_error():
|
|
27 |
|
28 |
def test_live_stream_error():
|
29 |
try:
|
30 |
-
raise LiveStreamError(video_id=
|
31 |
except LiveStreamError as e:
|
32 |
-
assert e.video_id ==
|
33 |
-
assert str(e) ==
|
34 |
|
35 |
|
36 |
-
def
|
37 |
try:
|
38 |
-
raise RecordingUnavailable(video_id=
|
39 |
except RecordingUnavailable as e:
|
40 |
-
assert e.video_id ==
|
41 |
-
assert str(e) ==
|
42 |
|
43 |
|
44 |
def test_private_error():
|
@@ -49,6 +50,14 @@ def test_private_error():
|
|
49 |
assert str(e) == 'm8uHb5jIGN8 is a private video'
|
50 |
|
51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
52 |
def test_raises_video_private(private):
|
53 |
with mock.patch('pytube.request.urlopen') as mock_url_open:
|
54 |
# Mock the responses to YouTube
|
@@ -71,3 +80,15 @@ def test_raises_recording_unavailable(missing_recording):
|
|
71 |
mock_url_open.return_value = mock_url_open_object
|
72 |
with pytest.raises(RecordingUnavailable):
|
73 |
YouTube('https://youtube.com/watch?v=5YceQ8YqYMc')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
8 |
from pytube.exceptions import RegexMatchError
|
9 |
from pytube.exceptions import VideoUnavailable
|
10 |
from pytube.exceptions import VideoPrivate
|
11 |
+
from pytube.exceptions import VideoRegionBlocked
|
12 |
|
13 |
|
14 |
def test_video_unavailable():
|
|
|
28 |
|
29 |
def test_live_stream_error():
|
30 |
try:
|
31 |
+
raise LiveStreamError(video_id='YLnZklYFe7E')
|
32 |
except LiveStreamError as e:
|
33 |
+
assert e.video_id == 'YLnZklYFe7E' # noqa: PT017
|
34 |
+
assert str(e) == 'YLnZklYFe7E is streaming live and cannot be loaded'
|
35 |
|
36 |
|
37 |
+
def test_recording_unavailable_error():
|
38 |
try:
|
39 |
+
raise RecordingUnavailable(video_id='5YceQ8YqYMc')
|
40 |
except RecordingUnavailable as e:
|
41 |
+
assert e.video_id == '5YceQ8YqYMc' # noqa: PT017
|
42 |
+
assert str(e) == '5YceQ8YqYMc does not have a live stream recording available'
|
43 |
|
44 |
|
45 |
def test_private_error():
|
|
|
50 |
assert str(e) == 'm8uHb5jIGN8 is a private video'
|
51 |
|
52 |
|
53 |
+
def test_region_locked_error():
|
54 |
+
try:
|
55 |
+
raise VideoRegionBlocked('hZpzr8TbF08')
|
56 |
+
except VideoRegionBlocked as e:
|
57 |
+
assert e.video_id == 'hZpzr8TbF08' # noqa: PT017
|
58 |
+
assert str(e) == 'hZpzr8TbF08 is not available in your region'
|
59 |
+
|
60 |
+
|
61 |
def test_raises_video_private(private):
|
62 |
with mock.patch('pytube.request.urlopen') as mock_url_open:
|
63 |
# Mock the responses to YouTube
|
|
|
80 |
mock_url_open.return_value = mock_url_open_object
|
81 |
with pytest.raises(RecordingUnavailable):
|
82 |
YouTube('https://youtube.com/watch?v=5YceQ8YqYMc')
|
83 |
+
|
84 |
+
|
85 |
+
def test_raises_video_region_blocked(region_blocked):
|
86 |
+
with mock.patch('pytube.request.urlopen') as mock_url_open:
|
87 |
+
# Mock the responses to YouTube
|
88 |
+
mock_url_open_object = mock.Mock()
|
89 |
+
mock_url_open_object.read.side_effect = [
|
90 |
+
region_blocked['watch_html'].encode('utf-8')
|
91 |
+
]
|
92 |
+
mock_url_open.return_value = mock_url_open_object
|
93 |
+
with pytest.raises(VideoRegionBlocked):
|
94 |
+
YouTube('https://youtube.com/watch?v=hZpzr8TbF08')
|
tests/test_helpers.py
CHANGED
@@ -121,7 +121,7 @@ def test_create_mock_html_json(mock_url_open, mock_open):
|
|
121 |
# 2. vid_info_raw
|
122 |
# 3. js
|
123 |
mock_url_open_object.read.side_effect = [
|
124 |
-
(b'yt.setConfig({"PLAYER_CONFIG":{"args":[]}});ytInitialData = {}'
|
125 |
b'"jsUrl":"/s/player/13371337/player_ias.vflset/en_US/base.js"'),
|
126 |
b'vid_info_raw',
|
127 |
b'js_result',
|
|
|
121 |
# 2. vid_info_raw
|
122 |
# 3. js
|
123 |
mock_url_open_object.read.side_effect = [
|
124 |
+
(b'yt.setConfig({"PLAYER_CONFIG":{"args":[]}});ytInitialData = {};ytInitialPlayerResponse = {};' # noqa: E501
|
125 |
b'"jsUrl":"/s/player/13371337/player_ias.vflset/en_US/base.js"'),
|
126 |
b'vid_info_raw',
|
127 |
b'js_result',
|