Merge pull request #48 from hbmartin/error-404
Browse files- Pipfile +3 -0
- pytube/__main__.py +29 -24
- pytube/extract.py +56 -54
- pytube/monostate.py +7 -1
- pytube/query.py +15 -4
- pytube/streams.py +19 -9
- tests/conftest.py +1 -1
- tests/generate_fixture.py +1 -0
- tests/mocks/yt-video-irauhITDrsE.json.gz +0 -0
- tests/mocks/yt-video-zRbsm3e2ltw-1507777044.json.gz +0 -0
- tests/test_extract.py +12 -15
- tests/test_query.py +8 -0
- tests/test_streams.py +17 -13
Pipfile
CHANGED
@@ -21,11 +21,14 @@ flake8-executable = "*"
|
|
21 |
flake8-if-expr = "*"
|
22 |
flake8-isort = "*"
|
23 |
flake8-logging-format = "*"
|
|
|
|
|
24 |
flake8-print = "*"
|
25 |
flake8-pytest = "*"
|
26 |
flake8-pytest-style = "*"
|
27 |
flake8-quotes = "*"
|
28 |
flake8-return = "*"
|
|
|
29 |
flake8-string-format = "*"
|
30 |
mypy = "*"
|
31 |
pep8-naming = "*"
|
|
|
21 |
flake8-if-expr = "*"
|
22 |
flake8-isort = "*"
|
23 |
flake8-logging-format = "*"
|
24 |
+
flake8-mock = "*"
|
25 |
+
flake8-mutable = "*"
|
26 |
flake8-print = "*"
|
27 |
flake8-pytest = "*"
|
28 |
flake8-pytest-style = "*"
|
29 |
flake8-quotes = "*"
|
30 |
flake8-return = "*"
|
31 |
+
flake8-strict = "*"
|
32 |
flake8-string-format = "*"
|
33 |
mypy = "*"
|
34 |
pep8-naming = "*"
|
pytube/__main__.py
CHANGED
@@ -20,7 +20,7 @@ from pytube import extract
|
|
20 |
from pytube import request
|
21 |
from pytube import Stream
|
22 |
from pytube import StreamQuery
|
23 |
-
from pytube.extract import apply_descrambler, apply_signature
|
24 |
from pytube.helpers import install_proxy
|
25 |
from pytube.exceptions import VideoUnavailable
|
26 |
from pytube.monostate import OnProgress, OnComplete, Monostate
|
@@ -76,12 +76,10 @@ class YouTube:
|
|
76 |
# video_id part of /watch?v=<video_id>
|
77 |
self.video_id = extract.video_id(url)
|
78 |
|
79 |
-
|
80 |
-
self.
|
81 |
|
82 |
-
|
83 |
-
# A dictionary shared between all instances of :class:`Stream <Stream>`
|
84 |
-
# (Borg pattern). Boooooo.
|
85 |
self.stream_monostate = Monostate(
|
86 |
on_progress=on_progress_callback, on_complete=on_complete_callback
|
87 |
)
|
@@ -111,9 +109,7 @@ class YouTube:
|
|
111 |
self.player_config_args = self.vid_info
|
112 |
else:
|
113 |
assert self.watch_html is not None
|
114 |
-
self.player_config_args =
|
115 |
-
"args"
|
116 |
-
]
|
117 |
|
118 |
# Fix for KeyError: 'title' issue #434
|
119 |
if "title" not in self.player_config_args: # type: ignore
|
@@ -140,8 +136,9 @@ class YouTube:
|
|
140 |
self.player_config_args, fmt, self.js # type: ignore
|
141 |
)
|
142 |
except TypeError:
|
143 |
-
|
144 |
-
|
|
|
145 |
self.js = request.get(self.js_url)
|
146 |
assert self.js is not None
|
147 |
apply_signature(self.player_config_args, fmt, self.js)
|
@@ -152,6 +149,8 @@ class YouTube:
|
|
152 |
# load the player_response object (contains subtitle information)
|
153 |
self.player_response = json.loads(self.player_config_args["player_response"])
|
154 |
del self.player_config_args["player_response"]
|
|
|
|
|
155 |
|
156 |
logger.info("init finished successfully")
|
157 |
|
@@ -166,23 +165,29 @@ class YouTube:
|
|
166 |
|
167 |
"""
|
168 |
self.watch_html = request.get(url=self.watch_url)
|
169 |
-
if
|
170 |
-
self.
|
171 |
-
|
|
|
|
|
|
|
172 |
):
|
173 |
raise VideoUnavailable(video_id=self.video_id)
|
174 |
|
175 |
-
|
176 |
-
|
177 |
-
|
178 |
-
|
179 |
-
|
180 |
-
|
181 |
-
|
182 |
-
|
|
|
|
|
|
|
183 |
self.vid_info_raw = request.get(self.vid_info_url)
|
184 |
if not self.age_restricted:
|
185 |
-
self.js_url = extract.js_url(self.watch_html
|
186 |
self.js = request.get(self.js_url)
|
187 |
|
188 |
def initialize_stream_objects(self, fmt: str) -> None:
|
@@ -275,7 +280,7 @@ class YouTube:
|
|
275 |
"""
|
276 |
return self.player_response.get("videoDetails", {}).get(
|
277 |
"shortDescription"
|
278 |
-
) or extract.
|
279 |
|
280 |
@property
|
281 |
def rating(self) -> float:
|
|
|
20 |
from pytube import request
|
21 |
from pytube import Stream
|
22 |
from pytube import StreamQuery
|
23 |
+
from pytube.extract import apply_descrambler, apply_signature, get_ytplayer_config
|
24 |
from pytube.helpers import install_proxy
|
25 |
from pytube.exceptions import VideoUnavailable
|
26 |
from pytube.monostate import OnProgress, OnComplete, Monostate
|
|
|
76 |
# video_id part of /watch?v=<video_id>
|
77 |
self.video_id = extract.video_id(url)
|
78 |
|
79 |
+
self.watch_url = f"https://youtube.com/watch?v={self.video_id}"
|
80 |
+
self.embed_url = f"https://www.youtube.com/embed/{self.video_id}"
|
81 |
|
82 |
+
# Shared between all instances of `Stream` (Borg pattern).
|
|
|
|
|
83 |
self.stream_monostate = Monostate(
|
84 |
on_progress=on_progress_callback, on_complete=on_complete_callback
|
85 |
)
|
|
|
109 |
self.player_config_args = self.vid_info
|
110 |
else:
|
111 |
assert self.watch_html is not None
|
112 |
+
self.player_config_args = get_ytplayer_config(self.watch_html)["args"]
|
|
|
|
|
113 |
|
114 |
# Fix for KeyError: 'title' issue #434
|
115 |
if "title" not in self.player_config_args: # type: ignore
|
|
|
136 |
self.player_config_args, fmt, self.js # type: ignore
|
137 |
)
|
138 |
except TypeError:
|
139 |
+
if not self.embed_html:
|
140 |
+
self.embed_html = request.get(url=self.embed_url)
|
141 |
+
self.js_url = extract.js_url(self.embed_html)
|
142 |
self.js = request.get(self.js_url)
|
143 |
assert self.js is not None
|
144 |
apply_signature(self.player_config_args, fmt, self.js)
|
|
|
149 |
# load the player_response object (contains subtitle information)
|
150 |
self.player_response = json.loads(self.player_config_args["player_response"])
|
151 |
del self.player_config_args["player_response"]
|
152 |
+
self.stream_monostate.title = self.title
|
153 |
+
self.stream_monostate.duration = self.length
|
154 |
|
155 |
logger.info("init finished successfully")
|
156 |
|
|
|
165 |
|
166 |
"""
|
167 |
self.watch_html = request.get(url=self.watch_url)
|
168 |
+
if self.watch_html is None:
|
169 |
+
raise VideoUnavailable(video_id=self.video_id)
|
170 |
+
self.age_restricted = extract.is_age_restricted(self.watch_html)
|
171 |
+
if not self.age_restricted and (
|
172 |
+
"yt-badge-live" in self.watch_html
|
173 |
+
or "This video is private" in self.watch_html
|
174 |
):
|
175 |
raise VideoUnavailable(video_id=self.video_id)
|
176 |
|
177 |
+
if self.age_restricted:
|
178 |
+
if not self.embed_html:
|
179 |
+
self.embed_html = request.get(url=self.embed_url)
|
180 |
+
self.vid_info_url = extract.video_info_url_age_restricted(
|
181 |
+
self.video_id, self.watch_url
|
182 |
+
)
|
183 |
+
else:
|
184 |
+
self.vid_info_url = extract.video_info_url(
|
185 |
+
video_id=self.video_id, watch_url=self.watch_url
|
186 |
+
)
|
187 |
+
|
188 |
self.vid_info_raw = request.get(self.vid_info_url)
|
189 |
if not self.age_restricted:
|
190 |
+
self.js_url = extract.js_url(self.watch_html)
|
191 |
self.js = request.get(self.js_url)
|
192 |
|
193 |
def initialize_stream_objects(self, fmt: str) -> None:
|
|
|
280 |
"""
|
281 |
return self.player_response.get("videoDetails", {}).get(
|
282 |
"shortDescription"
|
283 |
+
) or extract._get_vid_descr(self.watch_html)
|
284 |
|
285 |
@property
|
286 |
def rating(self) -> float:
|
pytube/extract.py
CHANGED
@@ -77,66 +77,58 @@ def video_id(url: str) -> str:
|
|
77 |
return regex_search(r"(?:v=|\/)([0-9A-Za-z_-]{11}).*", url, group=1)
|
78 |
|
79 |
|
80 |
-
def
|
81 |
-
"""Construct
|
82 |
|
83 |
:param str video_id:
|
84 |
A YouTube video identifier.
|
|
|
|
|
85 |
:rtype: str
|
86 |
:returns:
|
87 |
-
|
|
|
88 |
"""
|
89 |
-
|
90 |
-
|
91 |
-
|
92 |
-
|
93 |
-
|
94 |
-
|
95 |
-
|
96 |
-
|
97 |
-
|
|
|
98 |
|
99 |
|
100 |
-
def
|
101 |
-
video_id: str, watch_url: str, embed_html: Optional[str], age_restricted: bool,
|
102 |
-
) -> str:
|
103 |
"""Construct the video_info url.
|
104 |
|
105 |
:param str video_id:
|
106 |
A YouTube video identifier.
|
107 |
-
:param str watch_url:
|
108 |
-
A YouTube watch url.
|
109 |
:param str embed_html:
|
110 |
The html contents of the embed page (for age restricted videos).
|
111 |
-
:param bool age_restricted:
|
112 |
-
Is video age restricted.
|
113 |
:rtype: str
|
114 |
:returns:
|
115 |
:samp:`https://youtube.com/get_video_info` with necessary GET
|
116 |
parameters.
|
117 |
"""
|
118 |
-
|
119 |
-
assert embed_html is not None
|
120 |
sts = regex_search(r'"sts"\s*:\s*(\d+)', embed_html, group=1)
|
121 |
-
|
122 |
-
|
123 |
-
|
124 |
-
|
125 |
-
|
126 |
-
|
127 |
-
|
128 |
-
|
129 |
-
|
130 |
-
|
131 |
-
("ps", "default"),
|
132 |
-
("eurl", quote(watch_url)),
|
133 |
-
("hl", "en_US"),
|
134 |
-
]
|
135 |
-
)
|
136 |
return "https://youtube.com/get_video_info?" + urlencode(params)
|
137 |
|
138 |
|
139 |
-
def js_url(html: str
|
140 |
"""Get the base JavaScript url.
|
141 |
|
142 |
Construct the base JavaScript url, which contains the decipher
|
@@ -144,12 +136,8 @@ def js_url(html: str, age_restricted: Optional[bool] = False) -> str:
|
|
144 |
|
145 |
:param str html:
|
146 |
The html contents of the watch page.
|
147 |
-
:param bool age_restricted:
|
148 |
-
Is video age restricted.
|
149 |
-
|
150 |
"""
|
151 |
-
|
152 |
-
base_js = ytplayer_config["assets"]["js"]
|
153 |
return "https://youtube.com" + base_js
|
154 |
|
155 |
|
@@ -180,7 +168,7 @@ def mime_type_codec(mime_type_codec: str) -> Tuple[str, List[str]]:
|
|
180 |
return mime_type, [c.strip() for c in codecs.split(",")]
|
181 |
|
182 |
|
183 |
-
def get_ytplayer_config(html: str
|
184 |
"""Get the YouTube player configuration data from the watch html.
|
185 |
|
186 |
Extract the ``ytplayer_config``, which is json data embedded within the
|
@@ -189,21 +177,29 @@ def get_ytplayer_config(html: str, age_restricted: bool = False) -> Any:
|
|
189 |
|
190 |
:param str html:
|
191 |
The html contents of the watch page.
|
192 |
-
:param bool age_restricted:
|
193 |
-
Is video age restricted.
|
194 |
:rtype: str
|
195 |
:returns:
|
196 |
Substring of the html containing the encoded manifest data.
|
197 |
"""
|
198 |
-
|
199 |
-
|
200 |
-
|
201 |
-
|
202 |
-
|
203 |
-
|
204 |
-
|
205 |
-
|
206 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
207 |
html_parser = PytubeHTMLParser()
|
208 |
if html:
|
209 |
html_parser.feed(html)
|
@@ -278,6 +274,8 @@ def apply_descrambler(stream_data: Dict, key: str) -> None:
|
|
278 |
{'foo': [{'bar': '1', 'var': 'test'}, {'em': '5', 't': 'url encoded'}]}
|
279 |
|
280 |
"""
|
|
|
|
|
281 |
if key == "url_encoded_fmt_stream_map" and not stream_data.get(
|
282 |
"url_encoded_fmt_stream_map"
|
283 |
):
|
@@ -294,6 +292,8 @@ def apply_descrambler(stream_data: Dict, key: str) -> None:
|
|
294 |
"type": format_item["mimeType"],
|
295 |
"quality": format_item["quality"],
|
296 |
"itag": format_item["itag"],
|
|
|
|
|
297 |
}
|
298 |
for format_item in formats
|
299 |
]
|
@@ -308,6 +308,8 @@ def apply_descrambler(stream_data: Dict, key: str) -> None:
|
|
308 |
"type": format_item["mimeType"],
|
309 |
"quality": format_item["quality"],
|
310 |
"itag": format_item["itag"],
|
|
|
|
|
311 |
}
|
312 |
for i, format_item in enumerate(formats)
|
313 |
]
|
|
|
77 |
return regex_search(r"(?:v=|\/)([0-9A-Za-z_-]{11}).*", url, group=1)
|
78 |
|
79 |
|
80 |
+
def video_info_url(video_id: str, watch_url: str) -> str:
|
81 |
+
"""Construct the video_info url.
|
82 |
|
83 |
:param str video_id:
|
84 |
A YouTube video identifier.
|
85 |
+
:param str watch_url:
|
86 |
+
A YouTube watch url.
|
87 |
:rtype: str
|
88 |
:returns:
|
89 |
+
:samp:`https://youtube.com/get_video_info` with necessary GET
|
90 |
+
parameters.
|
91 |
"""
|
92 |
+
params = OrderedDict(
|
93 |
+
[
|
94 |
+
("video_id", video_id),
|
95 |
+
("el", "$el"),
|
96 |
+
("ps", "default"),
|
97 |
+
("eurl", quote(watch_url)),
|
98 |
+
("hl", "en_US"),
|
99 |
+
]
|
100 |
+
)
|
101 |
+
return _video_info_url(params)
|
102 |
|
103 |
|
104 |
+
def video_info_url_age_restricted(video_id: str, embed_html: str) -> str:
|
|
|
|
|
105 |
"""Construct the video_info url.
|
106 |
|
107 |
:param str video_id:
|
108 |
A YouTube video identifier.
|
|
|
|
|
109 |
:param str embed_html:
|
110 |
The html contents of the embed page (for age restricted videos).
|
|
|
|
|
111 |
:rtype: str
|
112 |
:returns:
|
113 |
:samp:`https://youtube.com/get_video_info` with necessary GET
|
114 |
parameters.
|
115 |
"""
|
116 |
+
try:
|
|
|
117 |
sts = regex_search(r'"sts"\s*:\s*(\d+)', embed_html, group=1)
|
118 |
+
except RegexMatchError:
|
119 |
+
sts = ""
|
120 |
+
# Here we use ``OrderedDict`` so that the output is consistent between
|
121 |
+
# Python 2.7+.
|
122 |
+
eurl = f"https://youtube.googleapis.com/v/{video_id}"
|
123 |
+
params = OrderedDict([("video_id", video_id), ("eurl", eurl), ("sts", sts),])
|
124 |
+
return _video_info_url(params)
|
125 |
+
|
126 |
+
|
127 |
+
def _video_info_url(params: OrderedDict) -> str:
|
|
|
|
|
|
|
|
|
|
|
128 |
return "https://youtube.com/get_video_info?" + urlencode(params)
|
129 |
|
130 |
|
131 |
+
def js_url(html: str) -> str:
|
132 |
"""Get the base JavaScript url.
|
133 |
|
134 |
Construct the base JavaScript url, which contains the decipher
|
|
|
136 |
|
137 |
:param str html:
|
138 |
The html contents of the watch page.
|
|
|
|
|
|
|
139 |
"""
|
140 |
+
base_js = get_ytplayer_config(html)["assets"]["js"]
|
|
|
141 |
return "https://youtube.com" + base_js
|
142 |
|
143 |
|
|
|
168 |
return mime_type, [c.strip() for c in codecs.split(",")]
|
169 |
|
170 |
|
171 |
+
def get_ytplayer_config(html: str) -> Any:
|
172 |
"""Get the YouTube player configuration data from the watch html.
|
173 |
|
174 |
Extract the ``ytplayer_config``, which is json data embedded within the
|
|
|
177 |
|
178 |
:param str html:
|
179 |
The html contents of the watch page.
|
|
|
|
|
180 |
:rtype: str
|
181 |
:returns:
|
182 |
Substring of the html containing the encoded manifest data.
|
183 |
"""
|
184 |
+
config_patterns = [
|
185 |
+
r";ytplayer\.config\s*=\s*({.*?});",
|
186 |
+
r";ytplayer\.config\s*=\s*({.+?});ytplayer",
|
187 |
+
r";yt\.setConfig\(\{'PLAYER_CONFIG':\s*({.*})}\);",
|
188 |
+
r";yt\.setConfig\(\{'PLAYER_CONFIG':\s*({.*})(,'EXPERIMENT_FLAGS'|;)", # noqa: E501
|
189 |
+
]
|
190 |
+
logger.debug("finding initial function name")
|
191 |
+
for pattern in config_patterns:
|
192 |
+
regex = re.compile(pattern)
|
193 |
+
function_match = regex.search(html)
|
194 |
+
if function_match:
|
195 |
+
logger.debug("finished regex search, matched: %s", pattern)
|
196 |
+
yt_player_config = function_match.group(1)
|
197 |
+
return json.loads(yt_player_config)
|
198 |
+
|
199 |
+
raise RegexMatchError(caller="get_ytplayer_config", pattern="config_patterns")
|
200 |
+
|
201 |
+
|
202 |
+
def _get_vid_descr(html: Optional[str]) -> str:
|
203 |
html_parser = PytubeHTMLParser()
|
204 |
if html:
|
205 |
html_parser.feed(html)
|
|
|
274 |
{'foo': [{'bar': '1', 'var': 'test'}, {'em': '5', 't': 'url encoded'}]}
|
275 |
|
276 |
"""
|
277 |
+
otf_type = "FORMAT_STREAM_TYPE_OTF"
|
278 |
+
|
279 |
if key == "url_encoded_fmt_stream_map" and not stream_data.get(
|
280 |
"url_encoded_fmt_stream_map"
|
281 |
):
|
|
|
292 |
"type": format_item["mimeType"],
|
293 |
"quality": format_item["quality"],
|
294 |
"itag": format_item["itag"],
|
295 |
+
"bitrate": format_item.get("bitrate"),
|
296 |
+
"is_otf": (format_item.get("type") == otf_type),
|
297 |
}
|
298 |
for format_item in formats
|
299 |
]
|
|
|
308 |
"type": format_item["mimeType"],
|
309 |
"quality": format_item["quality"],
|
310 |
"itag": format_item["itag"],
|
311 |
+
"bitrate": format_item.get("bitrate"),
|
312 |
+
"is_otf": (format_item.get("type") == otf_type),
|
313 |
}
|
314 |
for i, format_item in enumerate(formats)
|
315 |
]
|
pytube/monostate.py
CHANGED
@@ -52,7 +52,13 @@ class OnComplete(Protocol):
|
|
52 |
|
53 |
class Monostate:
|
54 |
def __init__(
|
55 |
-
self,
|
|
|
|
|
|
|
|
|
56 |
):
|
57 |
self.on_progress = on_progress
|
58 |
self.on_complete = on_complete
|
|
|
|
|
|
52 |
|
53 |
class Monostate:
|
54 |
def __init__(
|
55 |
+
self,
|
56 |
+
on_progress: Optional[OnProgress],
|
57 |
+
on_complete: Optional[OnComplete],
|
58 |
+
title: Optional[str] = None,
|
59 |
+
duration: Optional[int] = None,
|
60 |
):
|
61 |
self.on_progress = on_progress
|
62 |
self.on_complete = on_complete
|
63 |
+
self.title = title
|
64 |
+
self.duration = duration
|
pytube/query.py
CHANGED
@@ -1,7 +1,7 @@
|
|
1 |
# -*- coding: utf-8 -*-
|
2 |
|
3 |
"""This module provides a query interface for media streams and captions."""
|
4 |
-
from typing import List, Optional
|
5 |
|
6 |
from pytube import Stream, Caption
|
7 |
|
@@ -168,9 +168,12 @@ class StreamQuery:
|
|
168 |
if is_dash is not None:
|
169 |
filters.append(lambda s: s.is_dash == is_dash)
|
170 |
|
|
|
|
|
|
|
171 |
fmt_streams = self.fmt_streams
|
172 |
-
for
|
173 |
-
fmt_streams = filter(
|
174 |
return StreamQuery(list(fmt_streams))
|
175 |
|
176 |
def order_by(self, attribute_name: str) -> "StreamQuery":
|
@@ -281,10 +284,18 @@ class StreamQuery:
|
|
281 |
:returns:
|
282 |
The :class:`Stream <Stream>` matching the given itag or None if
|
283 |
not found.
|
284 |
-
|
285 |
"""
|
286 |
return self.filter(only_audio=True, subtype=subtype).order_by("abr").last()
|
287 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
288 |
def first(self) -> Optional[Stream]:
|
289 |
"""Get the first :class:`Stream <Stream>` in the results.
|
290 |
|
|
|
1 |
# -*- coding: utf-8 -*-
|
2 |
|
3 |
"""This module provides a query interface for media streams and captions."""
|
4 |
+
from typing import List, Optional, Callable
|
5 |
|
6 |
from pytube import Stream, Caption
|
7 |
|
|
|
168 |
if is_dash is not None:
|
169 |
filters.append(lambda s: s.is_dash == is_dash)
|
170 |
|
171 |
+
return self._filter(filters)
|
172 |
+
|
173 |
+
def _filter(self, filters: List[Callable]) -> "StreamQuery":
|
174 |
fmt_streams = self.fmt_streams
|
175 |
+
for filter_lambda in filters:
|
176 |
+
fmt_streams = filter(filter_lambda, fmt_streams)
|
177 |
return StreamQuery(list(fmt_streams))
|
178 |
|
179 |
def order_by(self, attribute_name: str) -> "StreamQuery":
|
|
|
284 |
:returns:
|
285 |
The :class:`Stream <Stream>` matching the given itag or None if
|
286 |
not found.
|
|
|
287 |
"""
|
288 |
return self.filter(only_audio=True, subtype=subtype).order_by("abr").last()
|
289 |
|
290 |
+
def otf(self, is_otf: bool = False) -> "StreamQuery":
|
291 |
+
"""Filter stream by OTF, useful if some streams have 404 URLs
|
292 |
+
|
293 |
+
:param bool is_otf: Set to False to retrieve only non-OTF streams
|
294 |
+
:rtype: :class:`StreamQuery <StreamQuery>`
|
295 |
+
:returns: A StreamQuery object with otf filtered streams
|
296 |
+
"""
|
297 |
+
return self._filter([lambda s: s.is_otf == is_otf])
|
298 |
+
|
299 |
def first(self) -> Optional[Stream]:
|
300 |
"""Get the first :class:`Stream <Stream>` in the results.
|
301 |
|
pytube/streams.py
CHANGED
@@ -58,6 +58,9 @@ class Stream:
|
|
58 |
# streams return NoneType for audio/video depending.
|
59 |
self.video_codec, self.audio_codec = self.parse_codecs()
|
60 |
|
|
|
|
|
|
|
61 |
self._filesize: Optional[int] = None # filesize in bytes
|
62 |
|
63 |
# Additional information about the stream format, such as resolution,
|
@@ -152,15 +155,22 @@ class Stream:
|
|
152 |
:returns:
|
153 |
Youtube video title
|
154 |
"""
|
155 |
-
return
|
156 |
-
|
157 |
-
|
158 |
-
|
159 |
-
|
160 |
-
|
161 |
-
|
162 |
-
|
163 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
164 |
|
165 |
@property
|
166 |
def default_filename(self) -> str:
|
|
|
58 |
# streams return NoneType for audio/video depending.
|
59 |
self.video_codec, self.audio_codec = self.parse_codecs()
|
60 |
|
61 |
+
self.is_otf: bool = stream["is_otf"]
|
62 |
+
self.bitrate: Optional[int] = stream["bitrate"]
|
63 |
+
|
64 |
self._filesize: Optional[int] = None # filesize in bytes
|
65 |
|
66 |
# Additional information about the stream format, such as resolution,
|
|
|
155 |
:returns:
|
156 |
Youtube video title
|
157 |
"""
|
158 |
+
return self._monostate.title or "Unknown YouTube Video Title"
|
159 |
+
|
160 |
+
@property
|
161 |
+
def filesize_approx(self) -> int:
|
162 |
+
"""Get approximate filesize of the video
|
163 |
+
|
164 |
+
Falls back to HTTP call if there is not sufficient information to approximate
|
165 |
+
|
166 |
+
:rtype: int
|
167 |
+
:returns: size of video in bytes
|
168 |
+
"""
|
169 |
+
if self._monostate.duration and self.bitrate:
|
170 |
+
bits_in_byte = 8
|
171 |
+
return int((self._monostate.duration * self.bitrate) / bits_in_byte)
|
172 |
+
|
173 |
+
return self.filesize
|
174 |
|
175 |
@property
|
176 |
def default_filename(self) -> str:
|
tests/conftest.py
CHANGED
@@ -48,7 +48,7 @@ def presigned_video():
|
|
48 |
@pytest.fixture
|
49 |
def age_restricted():
|
50 |
"""Youtube instance initialized with video id zRbsm3e2ltw."""
|
51 |
-
filename = "yt-video-
|
52 |
return load_playback_file(filename)
|
53 |
|
54 |
|
|
|
48 |
@pytest.fixture
|
49 |
def age_restricted():
|
50 |
"""Youtube instance initialized with video id zRbsm3e2ltw."""
|
51 |
+
filename = "yt-video-irauhITDrsE.json.gz"
|
52 |
return load_playback_file(filename)
|
53 |
|
54 |
|
tests/generate_fixture.py
CHANGED
@@ -19,6 +19,7 @@ output = {
|
|
19 |
"watch_html": yt.watch_html,
|
20 |
"video_info": yt.vid_info,
|
21 |
"js": yt.js,
|
|
|
22 |
}
|
23 |
|
24 |
outpath = path.join(currentdir, "mocks", "yt-video-" + yt.video_id + ".json")
|
|
|
19 |
"watch_html": yt.watch_html,
|
20 |
"video_info": yt.vid_info,
|
21 |
"js": yt.js,
|
22 |
+
"embed_html": yt.embed_html,
|
23 |
}
|
24 |
|
25 |
outpath = path.join(currentdir, "mocks", "yt-video-" + yt.video_id + ".json")
|
tests/mocks/yt-video-irauhITDrsE.json.gz
ADDED
Binary file (38.5 kB). View file
|
|
tests/mocks/yt-video-zRbsm3e2ltw-1507777044.json.gz
DELETED
Binary file (20.6 kB)
|
|
tests/test_extract.py
CHANGED
@@ -12,18 +12,20 @@ def test_extract_video_id():
|
|
12 |
assert video_id == "9bZkp7q19f0"
|
13 |
|
14 |
|
15 |
-
def
|
16 |
-
|
17 |
-
|
18 |
-
|
|
|
|
|
|
|
|
|
|
|
19 |
|
20 |
|
21 |
-
def
|
22 |
video_info_url = extract.video_info_url(
|
23 |
-
video_id=cipher_signature.video_id,
|
24 |
-
watch_url=cipher_signature.watch_url,
|
25 |
-
embed_html="",
|
26 |
-
age_restricted=False,
|
27 |
)
|
28 |
expected = (
|
29 |
"https://youtube.com/get_video_info?video_id=9bZkp7q19f0&el=%24el"
|
@@ -63,12 +65,7 @@ def test_get_vid_desc(cipher_signature):
|
|
63 |
"http://sptfy.com/PSY\n"
|
64 |
"http://weibo.com/psyoppa"
|
65 |
)
|
66 |
-
assert extract.
|
67 |
-
|
68 |
-
|
69 |
-
def test_eurl():
|
70 |
-
url = extract.eurl("videoid")
|
71 |
-
assert url == "https://youtube.googleapis.com/v/videoid"
|
72 |
|
73 |
|
74 |
def test_mime_type_codec():
|
|
|
12 |
assert video_id == "9bZkp7q19f0"
|
13 |
|
14 |
|
15 |
+
def test_info_url(age_restricted):
|
16 |
+
video_info_url = extract.video_info_url_age_restricted(
|
17 |
+
video_id="QRS8MkLhQmM", embed_html=age_restricted["embed_html"],
|
18 |
+
)
|
19 |
+
expected = (
|
20 |
+
"https://youtube.com/get_video_info?video_id=QRS8MkLhQmM&eurl"
|
21 |
+
"=https%3A%2F%2Fyoutube.googleapis.com%2Fv%2FQRS8MkLhQmM&sts="
|
22 |
+
)
|
23 |
+
assert video_info_url == expected
|
24 |
|
25 |
|
26 |
+
def test_info_url_age_restricted(cipher_signature):
|
27 |
video_info_url = extract.video_info_url(
|
28 |
+
video_id=cipher_signature.video_id, watch_url=cipher_signature.watch_url
|
|
|
|
|
|
|
29 |
)
|
30 |
expected = (
|
31 |
"https://youtube.com/get_video_info?video_id=9bZkp7q19f0&el=%24el"
|
|
|
65 |
"http://sptfy.com/PSY\n"
|
66 |
"http://weibo.com/psyoppa"
|
67 |
)
|
68 |
+
assert extract._get_vid_descr(cipher_signature.watch_html) == expected
|
|
|
|
|
|
|
|
|
|
|
69 |
|
70 |
|
71 |
def test_mime_type_codec():
|
tests/test_query.py
CHANGED
@@ -162,3 +162,11 @@ def test_get_audio_only(cipher_signature):
|
|
162 |
|
163 |
def test_get_audio_only_with_subtype(cipher_signature):
|
164 |
assert cipher_signature.streams.get_audio_only(subtype="webm").itag == 251
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
162 |
|
163 |
def test_get_audio_only_with_subtype(cipher_signature):
|
164 |
assert cipher_signature.streams.get_audio_only(subtype="webm").itag == 251
|
165 |
+
|
166 |
+
|
167 |
+
def test_otf(cipher_signature):
|
168 |
+
non_otf = cipher_signature.streams.otf().all()
|
169 |
+
assert len(non_otf) == 22
|
170 |
+
|
171 |
+
otf = cipher_signature.streams.otf(True).all()
|
172 |
+
assert len(otf) == 0
|
tests/test_streams.py
CHANGED
@@ -14,6 +14,15 @@ def test_filesize(cipher_signature, mocker):
|
|
14 |
assert cipher_signature.streams.first().filesize == 6796391
|
15 |
|
16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
17 |
def test_default_filename(cipher_signature):
|
18 |
expected = "PSY - GANGNAM STYLE(강남스타일) MV.mp4"
|
19 |
stream = cipher_signature.streams.first()
|
@@ -21,19 +30,14 @@ def test_default_filename(cipher_signature):
|
|
21 |
|
22 |
|
23 |
def test_title(cipher_signature):
|
24 |
-
expected = "
|
25 |
-
|
26 |
-
assert
|
27 |
-
|
28 |
-
expected = "
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
assert stream.title == expected
|
33 |
-
|
34 |
-
expected = "Unknown YouTube Video Title"
|
35 |
-
stream.player_config_args = {}
|
36 |
-
assert stream.title == expected
|
37 |
|
38 |
|
39 |
def test_caption_tracks(presigned_video):
|
|
|
14 |
assert cipher_signature.streams.first().filesize == 6796391
|
15 |
|
16 |
|
17 |
+
def test_filesize_approx(cipher_signature, mocker):
|
18 |
+
mocker.patch.object(request, "head")
|
19 |
+
request.head.return_value = {"content-length": "123"}
|
20 |
+
stream = cipher_signature.streams.first()
|
21 |
+
assert stream.filesize_approx == 22350604
|
22 |
+
stream.bitrate = None
|
23 |
+
assert stream.filesize_approx == 123
|
24 |
+
|
25 |
+
|
26 |
def test_default_filename(cipher_signature):
|
27 |
expected = "PSY - GANGNAM STYLE(강남스타일) MV.mp4"
|
28 |
stream = cipher_signature.streams.first()
|
|
|
30 |
|
31 |
|
32 |
def test_title(cipher_signature):
|
33 |
+
expected = "title"
|
34 |
+
cipher_signature.player_config_args["title"] = expected
|
35 |
+
assert cipher_signature.title == expected
|
36 |
+
|
37 |
+
expected = "title2"
|
38 |
+
del cipher_signature.player_config_args["title"]
|
39 |
+
cipher_signature.player_response = {"videoDetails": {"title": expected}}
|
40 |
+
assert cipher_signature.title == expected
|
|
|
|
|
|
|
|
|
|
|
41 |
|
42 |
|
43 |
def test_caption_tracks(presigned_video):
|