Merge branch 'master' into added-tests-cli
Browse files- Pipfile +3 -0
- README.md +12 -14
- pytube/__main__.py +35 -34
- pytube/cli.py +2 -2
- pytube/contrib/playlist.py +13 -3
- pytube/exceptions.py +9 -0
- pytube/extract.py +58 -61
- pytube/monostate.py +7 -1
- pytube/query.py +53 -14
- pytube/streams.py +19 -9
- tests/conftest.py +1 -1
- tests/contrib/test_playlist.py +33 -0
- 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_captions.py +11 -2
- tests/test_cli.py +25 -18
- tests/test_exceptions.py +9 -1
- tests/test_extract.py +17 -15
- tests/test_mixins.py +0 -3
- tests/test_query.py +32 -23
- 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 = "*"
|
README.md
CHANGED
@@ -36,14 +36,12 @@ $ pip install pytube3 --upgrade
|
|
36 |
## Quick start
|
37 |
```python
|
38 |
>>> from pytube import YouTube
|
39 |
-
>>> YouTube('https://youtu.be/9bZkp7q19f0').streams.
|
40 |
>>>
|
41 |
>>> yt = YouTube('http://youtube.com/watch?v=9bZkp7q19f0')
|
42 |
>>> yt.streams
|
43 |
... .filter(progressive=True, file_extension='mp4')
|
44 |
-
... .order_by('resolution')
|
45 |
-
... .desc()
|
46 |
-
... .first()
|
47 |
... .download()
|
48 |
```
|
49 |
|
@@ -64,7 +62,7 @@ Let's begin with showing how easy it is to download a video with pytube:
|
|
64 |
|
65 |
```python
|
66 |
>>> from pytube import YouTube
|
67 |
-
>>> YouTube('http://youtube.com/watch?v=9bZkp7q19f0').streams.
|
68 |
```
|
69 |
This example will download the highest quality progressive download stream available.
|
70 |
|
@@ -72,7 +70,7 @@ Next, let's explore how we would view what video streams are available:
|
|
72 |
|
73 |
```python
|
74 |
>>> yt = YouTube('http://youtube.com/watch?v=9bZkp7q19f0')
|
75 |
-
>>> yt.streams
|
76 |
[<Stream: itag="22" mime_type="video/mp4" res="720p" fps="30fps" vcodec="avc1.64001F" acodec="mp4a.40.2">,
|
77 |
<Stream: itag="43" mime_type="video/webm" res="360p" fps="30fps" vcodec="vp8.0" acodec="vorbis">,
|
78 |
<Stream: itag="18" mime_type="video/mp4" res="360p" fps="30fps" vcodec="avc1.42001E" acodec="mp4a.40.2">,
|
@@ -108,7 +106,7 @@ The legacy streams that contain the audio and video in a single file (referred t
|
|
108 |
To only view these progressive download streams:
|
109 |
|
110 |
```python
|
111 |
-
>>> yt.streams.filter(progressive=True)
|
112 |
[<Stream: itag="22" mime_type="video/mp4" res="720p" fps="30fps" vcodec="avc1.64001F" acodec="mp4a.40.2">,
|
113 |
<Stream: itag="43" mime_type="video/webm" res="360p" fps="30fps" vcodec="vp8.0" acodec="vorbis">,
|
114 |
<Stream: itag="18" mime_type="video/mp4" res="360p" fps="30fps" vcodec="avc1.42001E" acodec="mp4a.40.2">,
|
@@ -119,7 +117,7 @@ To only view these progressive download streams:
|
|
119 |
Conversely, if you only want to see the DASH streams (also referred to as "adaptive") you can do:
|
120 |
|
121 |
```python
|
122 |
-
>>> yt.streams.filter(adaptive=True)
|
123 |
[<Stream: itag="137" mime_type="video/mp4" res="1080p" fps="30fps" vcodec="avc1.640028">,
|
124 |
<Stream: itag="248" mime_type="video/webm" res="1080p" fps="30fps" vcodec="vp9">,
|
125 |
<Stream: itag="136" mime_type="video/mp4" res="720p" fps="30fps" vcodec="avc1.4d401f">,
|
@@ -146,7 +144,7 @@ You can also download a complete Youtube playlist:
|
|
146 |
```python
|
147 |
>>> from pytube import Playlist
|
148 |
>>> playlist = Playlist("https://www.youtube.com/playlist?list=PLynhp4cZEpTbRs_PYISQ8v_uwO0_mDg_X")
|
149 |
-
>>> for video in playlist
|
150 |
>>> video.streams.get_highest_resolution().download()
|
151 |
```
|
152 |
This will download the highest progressive stream available (generally 720p) from the given playlist.
|
@@ -158,7 +156,7 @@ Pytube allows you to filter on every property available (see the documentation f
|
|
158 |
To list the audio only streams:
|
159 |
|
160 |
```python
|
161 |
-
>>> yt.streams.filter(only_audio=True)
|
162 |
[<Stream: itag="140" mime_type="audio/mp4" abr="128kbps" acodec="mp4a.40.2">,
|
163 |
<Stream: itag="171" mime_type="audio/webm" abr="128kbps" acodec="vorbis">,
|
164 |
<Stream: itag="249" mime_type="audio/webm" abr="50kbps" acodec="opus">,
|
@@ -169,7 +167,7 @@ To list the audio only streams:
|
|
169 |
To list only ``mp4`` streams:
|
170 |
|
171 |
```python
|
172 |
-
>>> yt.streams.filter(subtype='mp4')
|
173 |
[<Stream: itag="22" mime_type="video/mp4" res="720p" fps="30fps" vcodec="avc1.64001F" acodec="mp4a.40.2">,
|
174 |
<Stream: itag="18" mime_type="video/mp4" res="360p" fps="30fps" vcodec="avc1.42001E" acodec="mp4a.40.2">,
|
175 |
<Stream: itag="137" mime_type="video/mp4" res="1080p" fps="30fps" vcodec="avc1.640028">,
|
@@ -184,9 +182,9 @@ To list only ``mp4`` streams:
|
|
184 |
Multiple filters can also be specified:
|
185 |
|
186 |
```python
|
187 |
-
>>> yt.streams.filter(subtype='mp4', progressive=True)
|
188 |
>>> # this can also be expressed as:
|
189 |
-
>>> yt.streams.filter(subtype='mp4').filter(progressive=True)
|
190 |
[<Stream: itag="22" mime_type="video/mp4" res="720p" fps="30fps" vcodec="avc1.64001F" acodec="mp4a.40.2">,
|
191 |
<Stream: itag="18" mime_type="video/mp4" res="360p" fps="30fps" vcodec="avc1.42001E" acodec="mp4a.40.2">]
|
192 |
```
|
@@ -200,7 +198,7 @@ You also have an interface to select streams by their itag, without needing to f
|
|
200 |
If you need to optimize for a specific feature, such as the "highest resolution" or "lowest average bitrate":
|
201 |
|
202 |
```python
|
203 |
-
>>> yt.streams.filter(progressive=True).order_by('resolution').desc()
|
204 |
```
|
205 |
Note: Using ``order_by`` on a given attribute will filter out all streams missing that attribute.
|
206 |
|
|
|
36 |
## Quick start
|
37 |
```python
|
38 |
>>> from pytube import YouTube
|
39 |
+
>>> YouTube('https://youtu.be/9bZkp7q19f0').streams[0].download()
|
40 |
>>>
|
41 |
>>> yt = YouTube('http://youtube.com/watch?v=9bZkp7q19f0')
|
42 |
>>> yt.streams
|
43 |
... .filter(progressive=True, file_extension='mp4')
|
44 |
+
... .order_by('resolution')[-1]
|
|
|
|
|
45 |
... .download()
|
46 |
```
|
47 |
|
|
|
62 |
|
63 |
```python
|
64 |
>>> from pytube import YouTube
|
65 |
+
>>> YouTube('http://youtube.com/watch?v=9bZkp7q19f0').streams[0].download()
|
66 |
```
|
67 |
This example will download the highest quality progressive download stream available.
|
68 |
|
|
|
70 |
|
71 |
```python
|
72 |
>>> yt = YouTube('http://youtube.com/watch?v=9bZkp7q19f0')
|
73 |
+
>>> print(yt.streams)
|
74 |
[<Stream: itag="22" mime_type="video/mp4" res="720p" fps="30fps" vcodec="avc1.64001F" acodec="mp4a.40.2">,
|
75 |
<Stream: itag="43" mime_type="video/webm" res="360p" fps="30fps" vcodec="vp8.0" acodec="vorbis">,
|
76 |
<Stream: itag="18" mime_type="video/mp4" res="360p" fps="30fps" vcodec="avc1.42001E" acodec="mp4a.40.2">,
|
|
|
106 |
To only view these progressive download streams:
|
107 |
|
108 |
```python
|
109 |
+
>>> yt.streams.filter(progressive=True)
|
110 |
[<Stream: itag="22" mime_type="video/mp4" res="720p" fps="30fps" vcodec="avc1.64001F" acodec="mp4a.40.2">,
|
111 |
<Stream: itag="43" mime_type="video/webm" res="360p" fps="30fps" vcodec="vp8.0" acodec="vorbis">,
|
112 |
<Stream: itag="18" mime_type="video/mp4" res="360p" fps="30fps" vcodec="avc1.42001E" acodec="mp4a.40.2">,
|
|
|
117 |
Conversely, if you only want to see the DASH streams (also referred to as "adaptive") you can do:
|
118 |
|
119 |
```python
|
120 |
+
>>> yt.streams.filter(adaptive=True)
|
121 |
[<Stream: itag="137" mime_type="video/mp4" res="1080p" fps="30fps" vcodec="avc1.640028">,
|
122 |
<Stream: itag="248" mime_type="video/webm" res="1080p" fps="30fps" vcodec="vp9">,
|
123 |
<Stream: itag="136" mime_type="video/mp4" res="720p" fps="30fps" vcodec="avc1.4d401f">,
|
|
|
144 |
```python
|
145 |
>>> from pytube import Playlist
|
146 |
>>> playlist = Playlist("https://www.youtube.com/playlist?list=PLynhp4cZEpTbRs_PYISQ8v_uwO0_mDg_X")
|
147 |
+
>>> for video in playlist:
|
148 |
>>> video.streams.get_highest_resolution().download()
|
149 |
```
|
150 |
This will download the highest progressive stream available (generally 720p) from the given playlist.
|
|
|
156 |
To list the audio only streams:
|
157 |
|
158 |
```python
|
159 |
+
>>> yt.streams.filter(only_audio=True)
|
160 |
[<Stream: itag="140" mime_type="audio/mp4" abr="128kbps" acodec="mp4a.40.2">,
|
161 |
<Stream: itag="171" mime_type="audio/webm" abr="128kbps" acodec="vorbis">,
|
162 |
<Stream: itag="249" mime_type="audio/webm" abr="50kbps" acodec="opus">,
|
|
|
167 |
To list only ``mp4`` streams:
|
168 |
|
169 |
```python
|
170 |
+
>>> yt.streams.filter(subtype='mp4')
|
171 |
[<Stream: itag="22" mime_type="video/mp4" res="720p" fps="30fps" vcodec="avc1.64001F" acodec="mp4a.40.2">,
|
172 |
<Stream: itag="18" mime_type="video/mp4" res="360p" fps="30fps" vcodec="avc1.42001E" acodec="mp4a.40.2">,
|
173 |
<Stream: itag="137" mime_type="video/mp4" res="1080p" fps="30fps" vcodec="avc1.640028">,
|
|
|
182 |
Multiple filters can also be specified:
|
183 |
|
184 |
```python
|
185 |
+
>>> yt.streams.filter(subtype='mp4', progressive=True)
|
186 |
>>> # this can also be expressed as:
|
187 |
+
>>> yt.streams.filter(subtype='mp4').filter(progressive=True)
|
188 |
[<Stream: itag="22" mime_type="video/mp4" res="720p" fps="30fps" vcodec="avc1.64001F" acodec="mp4a.40.2">,
|
189 |
<Stream: itag="18" mime_type="video/mp4" res="360p" fps="30fps" vcodec="avc1.42001E" acodec="mp4a.40.2">]
|
190 |
```
|
|
|
198 |
If you need to optimize for a specific feature, such as the "highest resolution" or "lowest average bitrate":
|
199 |
|
200 |
```python
|
201 |
+
>>> yt.streams.filter(progressive=True).order_by('resolution').desc()
|
202 |
```
|
203 |
Note: Using ``order_by`` on a given attribute will filter out all streams missing that attribute.
|
204 |
|
pytube/__main__.py
CHANGED
@@ -20,9 +20,9 @@ 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
|
27 |
|
28 |
logger = logging.getLogger(__name__)
|
@@ -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
|
@@ -135,16 +131,13 @@ class YouTube:
|
|
135 |
apply_descrambler(self.vid_info, fmt)
|
136 |
apply_descrambler(self.player_config_args, fmt)
|
137 |
|
138 |
-
|
139 |
-
|
140 |
-
self.
|
141 |
-
)
|
142 |
-
except TypeError:
|
143 |
-
assert self.embed_html is not None
|
144 |
-
self.js_url = extract.js_url(self.embed_html, self.age_restricted)
|
145 |
self.js = request.get(self.js_url)
|
146 |
-
|
147 |
-
|
148 |
|
149 |
# build instances of :class:`Stream <Stream>`
|
150 |
self.initialize_stream_objects(fmt)
|
@@ -152,6 +145,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 |
|
@@ -163,26 +158,32 @@ class YouTube:
|
|
163 |
which blocks for long periods of time.
|
164 |
|
165 |
:rtype: None
|
166 |
-
|
167 |
"""
|
168 |
self.watch_html = request.get(url=self.watch_url)
|
169 |
-
if
|
170 |
-
self.watch_html is None
|
171 |
-
or '<img class="icon meh" src="/yts/img' not in self.watch_html
|
172 |
-
):
|
173 |
raise VideoUnavailable(video_id=self.video_id)
|
174 |
-
|
175 |
-
self.embed_html = request.get(url=self.embed_url)
|
176 |
self.age_restricted = extract.is_age_restricted(self.watch_html)
|
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 +276,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, LiveStreamError
|
26 |
from pytube.monostate import OnProgress, OnComplete, Monostate
|
27 |
|
28 |
logger = logging.getLogger(__name__)
|
|
|
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
|
|
|
131 |
apply_descrambler(self.vid_info, fmt)
|
132 |
apply_descrambler(self.player_config_args, fmt)
|
133 |
|
134 |
+
if not self.js:
|
135 |
+
if not self.embed_html:
|
136 |
+
self.embed_html = request.get(url=self.embed_url)
|
137 |
+
self.js_url = extract.js_url(self.embed_html)
|
|
|
|
|
|
|
138 |
self.js = request.get(self.js_url)
|
139 |
+
|
140 |
+
apply_signature(self.player_config_args, fmt, self.js)
|
141 |
|
142 |
# build instances of :class:`Stream <Stream>`
|
143 |
self.initialize_stream_objects(fmt)
|
|
|
145 |
# load the player_response object (contains subtitle information)
|
146 |
self.player_response = json.loads(self.player_config_args["player_response"])
|
147 |
del self.player_config_args["player_response"]
|
148 |
+
self.stream_monostate.title = self.title
|
149 |
+
self.stream_monostate.duration = self.length
|
150 |
|
151 |
logger.info("init finished successfully")
|
152 |
|
|
|
158 |
which blocks for long periods of time.
|
159 |
|
160 |
:rtype: None
|
|
|
161 |
"""
|
162 |
self.watch_html = request.get(url=self.watch_url)
|
163 |
+
if self.watch_html is None:
|
|
|
|
|
|
|
164 |
raise VideoUnavailable(video_id=self.video_id)
|
|
|
|
|
165 |
self.age_restricted = extract.is_age_restricted(self.watch_html)
|
166 |
+
|
167 |
+
if not self.age_restricted:
|
168 |
+
if "yt-badge-live" in self.watch_html:
|
169 |
+
raise LiveStreamError(self.video_id)
|
170 |
+
if "This video is private" in self.watch_html:
|
171 |
+
raise VideoUnavailable(video_id=self.video_id)
|
172 |
+
|
173 |
+
if self.age_restricted:
|
174 |
+
if not self.embed_html:
|
175 |
+
self.embed_html = request.get(url=self.embed_url)
|
176 |
+
self.vid_info_url = extract.video_info_url_age_restricted(
|
177 |
+
self.video_id, self.watch_url
|
178 |
+
)
|
179 |
+
else:
|
180 |
+
self.vid_info_url = extract.video_info_url(
|
181 |
+
video_id=self.video_id, watch_url=self.watch_url
|
182 |
+
)
|
183 |
+
|
184 |
self.vid_info_raw = request.get(self.vid_info_url)
|
185 |
if not self.age_restricted:
|
186 |
+
self.js_url = extract.js_url(self.watch_html)
|
187 |
self.js = request.get(self.js_url)
|
188 |
|
189 |
def initialize_stream_objects(self, fmt: str) -> None:
|
|
|
276 |
"""
|
277 |
return self.player_response.get("videoDetails", {}).get(
|
278 |
"shortDescription"
|
279 |
+
) or extract._get_vid_descr(self.watch_html)
|
280 |
|
281 |
@property
|
282 |
def rating(self) -> float:
|
pytube/cli.py
CHANGED
@@ -400,12 +400,12 @@ def display_streams(youtube: YouTube) -> None:
|
|
400 |
A valid YouTube watch URL.
|
401 |
|
402 |
"""
|
403 |
-
for stream in youtube.streams
|
404 |
print(stream)
|
405 |
|
406 |
|
407 |
def _print_available_captions(captions: CaptionQuery) -> None:
|
408 |
-
print(f"Available caption codes are: {', '.join(c.code for c in captions
|
409 |
|
410 |
|
411 |
def download_caption(
|
|
|
400 |
A valid YouTube watch URL.
|
401 |
|
402 |
"""
|
403 |
+
for stream in youtube.streams:
|
404 |
print(stream)
|
405 |
|
406 |
|
407 |
def _print_available_captions(captions: CaptionQuery) -> None:
|
408 |
+
print(f"Available caption codes are: {', '.join(c.code for c in captions)}")
|
409 |
|
410 |
|
411 |
def download_caption(
|
pytube/contrib/playlist.py
CHANGED
@@ -6,8 +6,9 @@ import json
|
|
6 |
import logging
|
7 |
import re
|
8 |
from datetime import date, datetime
|
9 |
-
from typing import List, Optional, Iterable, Dict
|
10 |
from urllib.parse import parse_qs
|
|
|
11 |
|
12 |
from pytube import request, YouTube
|
13 |
from pytube.helpers import cache, deprecated, install_proxy, uniqueify
|
@@ -15,7 +16,7 @@ from pytube.helpers import cache, deprecated, install_proxy, uniqueify
|
|
15 |
logger = logging.getLogger(__name__)
|
16 |
|
17 |
|
18 |
-
class Playlist:
|
19 |
"""Load a YouTube playlist with URL or ID"""
|
20 |
|
21 |
def __init__(self, url: str, proxies: Optional[Dict[str, str]] = None):
|
@@ -142,10 +143,19 @@ class Playlist:
|
|
142 |
"""
|
143 |
yield from (YouTube(url) for url in self.video_urls)
|
144 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
145 |
@deprecated(
|
146 |
"This call is unnecessary, you can directly access .video_urls or .videos"
|
147 |
)
|
148 |
-
def populate_video_urls(self) -> List[str]:
|
149 |
"""Complete links of all the videos in playlist
|
150 |
|
151 |
:rtype: List[str]
|
|
|
6 |
import logging
|
7 |
import re
|
8 |
from datetime import date, datetime
|
9 |
+
from typing import List, Optional, Iterable, Dict, Union
|
10 |
from urllib.parse import parse_qs
|
11 |
+
from collections.abc import Sequence
|
12 |
|
13 |
from pytube import request, YouTube
|
14 |
from pytube.helpers import cache, deprecated, install_proxy, uniqueify
|
|
|
16 |
logger = logging.getLogger(__name__)
|
17 |
|
18 |
|
19 |
+
class Playlist(Sequence):
|
20 |
"""Load a YouTube playlist with URL or ID"""
|
21 |
|
22 |
def __init__(self, url: str, proxies: Optional[Dict[str, str]] = None):
|
|
|
143 |
"""
|
144 |
yield from (YouTube(url) for url in self.video_urls)
|
145 |
|
146 |
+
def __getitem__(self, i: Union[slice, int]) -> Union[str, List[str]]:
|
147 |
+
return self.video_urls[i]
|
148 |
+
|
149 |
+
def __len__(self) -> int:
|
150 |
+
return len(self.video_urls)
|
151 |
+
|
152 |
+
def __repr__(self) -> str:
|
153 |
+
return f"{self.video_urls}"
|
154 |
+
|
155 |
@deprecated(
|
156 |
"This call is unnecessary, you can directly access .video_urls or .videos"
|
157 |
)
|
158 |
+
def populate_video_urls(self) -> List[str]: # pragma: no cover
|
159 |
"""Complete links of all the videos in playlist
|
160 |
|
161 |
:rtype: List[str]
|
pytube/exceptions.py
CHANGED
@@ -35,6 +35,15 @@ class RegexMatchError(ExtractError):
|
|
35 |
class LiveStreamError(ExtractError):
|
36 |
"""Video is a live stream."""
|
37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
38 |
|
39 |
class VideoUnavailable(PytubeError):
|
40 |
"""Video is unavailable."""
|
|
|
35 |
class LiveStreamError(ExtractError):
|
36 |
"""Video is a live stream."""
|
37 |
|
38 |
+
def __init__(self, video_id: str):
|
39 |
+
"""
|
40 |
+
:param str video_id:
|
41 |
+
A YouTube video identifier.
|
42 |
+
"""
|
43 |
+
super().__init__(f"{video_id} is streaming live and cannot be loaded")
|
44 |
+
|
45 |
+
self.video_id = video_id
|
46 |
+
|
47 |
|
48 |
class VideoUnavailable(PytubeError):
|
49 |
"""Video is unavailable."""
|
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)
|
@@ -235,7 +231,7 @@ def apply_signature(config_args: Dict, fmt: str, js: str) -> None:
|
|
235 |
url: str = stream["url"]
|
236 |
except KeyError:
|
237 |
if live_stream:
|
238 |
-
raise LiveStreamError("
|
239 |
# 403 Forbidden fix.
|
240 |
if "signature" in url or (
|
241 |
"s" not in stream and ("&sig=" in url or "&lsig=" in url)
|
@@ -246,12 +242,7 @@ def apply_signature(config_args: Dict, fmt: str, js: str) -> None:
|
|
246 |
logger.debug("signature found, skip decipher")
|
247 |
continue
|
248 |
|
249 |
-
|
250 |
-
signature = cipher.get_signature(ciphered_signature=stream["s"])
|
251 |
-
else:
|
252 |
-
# signature not present in url (line 33), need js to descramble
|
253 |
-
# TypeError caught in __main__
|
254 |
-
raise TypeError("JS is None")
|
255 |
|
256 |
logger.debug("finished descrambling signature for itag=%s", stream["itag"])
|
257 |
# 403 forbidden fix
|
@@ -278,6 +269,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 +287,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 +303,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)
|
|
|
231 |
url: str = stream["url"]
|
232 |
except KeyError:
|
233 |
if live_stream:
|
234 |
+
raise LiveStreamError("UNKNOWN")
|
235 |
# 403 Forbidden fix.
|
236 |
if "signature" in url or (
|
237 |
"s" not in stream and ("&sig=" in url or "&lsig=" in url)
|
|
|
242 |
logger.debug("signature found, skip decipher")
|
243 |
continue
|
244 |
|
245 |
+
signature = cipher.get_signature(ciphered_signature=stream["s"])
|
|
|
|
|
|
|
|
|
|
|
246 |
|
247 |
logger.debug("finished descrambling signature for itag=%s", stream["itag"])
|
248 |
# 403 forbidden fix
|
|
|
269 |
{'foo': [{'bar': '1', 'var': 'test'}, {'em': '5', 't': 'url encoded'}]}
|
270 |
|
271 |
"""
|
272 |
+
otf_type = "FORMAT_STREAM_TYPE_OTF"
|
273 |
+
|
274 |
if key == "url_encoded_fmt_stream_map" and not stream_data.get(
|
275 |
"url_encoded_fmt_stream_map"
|
276 |
):
|
|
|
287 |
"type": format_item["mimeType"],
|
288 |
"quality": format_item["quality"],
|
289 |
"itag": format_item["itag"],
|
290 |
+
"bitrate": format_item.get("bitrate"),
|
291 |
+
"is_otf": (format_item.get("type") == otf_type),
|
292 |
}
|
293 |
for format_item in formats
|
294 |
]
|
|
|
303 |
"type": format_item["mimeType"],
|
304 |
"quality": format_item["quality"],
|
305 |
"itag": format_item["itag"],
|
306 |
+
"bitrate": format_item.get("bitrate"),
|
307 |
+
"is_otf": (format_item.get("type") == otf_type),
|
308 |
}
|
309 |
for i, format_item in enumerate(formats)
|
310 |
]
|
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,12 +1,14 @@
|
|
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 |
|
8 |
|
9 |
-
class StreamQuery:
|
10 |
"""Interface for querying the available media streams."""
|
11 |
|
12 |
def __init__(self, fmt_streams):
|
@@ -168,9 +170,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 +286,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 |
|
@@ -313,15 +326,19 @@ class StreamQuery:
|
|
313 |
except IndexError:
|
314 |
pass
|
315 |
|
316 |
-
|
317 |
-
|
|
|
318 |
|
319 |
:rtype: int
|
320 |
-
|
321 |
"""
|
322 |
-
|
|
|
|
|
|
|
323 |
|
324 |
-
|
|
|
325 |
"""Get all the results represented by this query as a list.
|
326 |
|
327 |
:rtype: list
|
@@ -329,8 +346,17 @@ class StreamQuery:
|
|
329 |
"""
|
330 |
return self.fmt_streams
|
331 |
|
|
|
|
|
332 |
|
333 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
334 |
"""Interface for querying the available captions."""
|
335 |
|
336 |
def __init__(self, captions: List[Caption]):
|
@@ -340,9 +366,9 @@ class CaptionQuery:
|
|
340 |
list of :class:`Caption <Caption>` instances.
|
341 |
|
342 |
"""
|
343 |
-
self.captions = captions
|
344 |
self.lang_code_index = {c.code: c for c in captions}
|
345 |
|
|
|
346 |
def get_by_language_code(self, lang_code: str) -> Optional[Caption]:
|
347 |
"""Get the :class:`Caption <Caption>` for a given ``lang_code``.
|
348 |
|
@@ -355,10 +381,23 @@ class CaptionQuery:
|
|
355 |
"""
|
356 |
return self.lang_code_index.get(lang_code)
|
357 |
|
358 |
-
|
|
|
359 |
"""Get all the results represented by this query as a list.
|
360 |
|
361 |
:rtype: list
|
362 |
|
363 |
"""
|
364 |
-
return self.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
# -*- coding: utf-8 -*-
|
2 |
|
3 |
"""This module provides a query interface for media streams and captions."""
|
4 |
+
from typing import Callable, List, Optional, Union
|
5 |
+
from collections.abc import Mapping, Sequence
|
6 |
|
7 |
from pytube import Stream, Caption
|
8 |
+
from pytube.helpers import deprecated
|
9 |
|
10 |
|
11 |
+
class StreamQuery(Sequence):
|
12 |
"""Interface for querying the available media streams."""
|
13 |
|
14 |
def __init__(self, fmt_streams):
|
|
|
170 |
if is_dash is not None:
|
171 |
filters.append(lambda s: s.is_dash == is_dash)
|
172 |
|
173 |
+
return self._filter(filters)
|
174 |
+
|
175 |
+
def _filter(self, filters: List[Callable]) -> "StreamQuery":
|
176 |
fmt_streams = self.fmt_streams
|
177 |
+
for filter_lambda in filters:
|
178 |
+
fmt_streams = filter(filter_lambda, fmt_streams)
|
179 |
return StreamQuery(list(fmt_streams))
|
180 |
|
181 |
def order_by(self, attribute_name: str) -> "StreamQuery":
|
|
|
286 |
:returns:
|
287 |
The :class:`Stream <Stream>` matching the given itag or None if
|
288 |
not found.
|
|
|
289 |
"""
|
290 |
return self.filter(only_audio=True, subtype=subtype).order_by("abr").last()
|
291 |
|
292 |
+
def otf(self, is_otf: bool = False) -> "StreamQuery":
|
293 |
+
"""Filter stream by OTF, useful if some streams have 404 URLs
|
294 |
+
|
295 |
+
:param bool is_otf: Set to False to retrieve only non-OTF streams
|
296 |
+
:rtype: :class:`StreamQuery <StreamQuery>`
|
297 |
+
:returns: A StreamQuery object with otf filtered streams
|
298 |
+
"""
|
299 |
+
return self._filter([lambda s: s.is_otf == is_otf])
|
300 |
+
|
301 |
def first(self) -> Optional[Stream]:
|
302 |
"""Get the first :class:`Stream <Stream>` in the results.
|
303 |
|
|
|
326 |
except IndexError:
|
327 |
pass
|
328 |
|
329 |
+
@deprecated("Get the size of this list directly using len()")
|
330 |
+
def count(self, value: Optional[str] = None) -> int: # pragma: no cover
|
331 |
+
"""Get the count of items in the list.
|
332 |
|
333 |
:rtype: int
|
|
|
334 |
"""
|
335 |
+
if value:
|
336 |
+
return self.fmt_streams.count(value)
|
337 |
+
|
338 |
+
return len(self)
|
339 |
|
340 |
+
@deprecated("This object can be treated as a list, all() is useless")
|
341 |
+
def all(self) -> List[Stream]: # pragma: no cover
|
342 |
"""Get all the results represented by this query as a list.
|
343 |
|
344 |
:rtype: list
|
|
|
346 |
"""
|
347 |
return self.fmt_streams
|
348 |
|
349 |
+
def __getitem__(self, i: Union[slice, int]):
|
350 |
+
return self.fmt_streams[i]
|
351 |
|
352 |
+
def __len__(self) -> int:
|
353 |
+
return len(self.fmt_streams)
|
354 |
+
|
355 |
+
def __repr__(self) -> str:
|
356 |
+
return f"{self.fmt_streams}"
|
357 |
+
|
358 |
+
|
359 |
+
class CaptionQuery(Mapping):
|
360 |
"""Interface for querying the available captions."""
|
361 |
|
362 |
def __init__(self, captions: List[Caption]):
|
|
|
366 |
list of :class:`Caption <Caption>` instances.
|
367 |
|
368 |
"""
|
|
|
369 |
self.lang_code_index = {c.code: c for c in captions}
|
370 |
|
371 |
+
@deprecated("This object can be treated as a dictionary, i.e. captions['en']")
|
372 |
def get_by_language_code(self, lang_code: str) -> Optional[Caption]:
|
373 |
"""Get the :class:`Caption <Caption>` for a given ``lang_code``.
|
374 |
|
|
|
381 |
"""
|
382 |
return self.lang_code_index.get(lang_code)
|
383 |
|
384 |
+
@deprecated("This object can be treated as a dictionary")
|
385 |
+
def all(self) -> List[Caption]: # pragma: no cover
|
386 |
"""Get all the results represented by this query as a list.
|
387 |
|
388 |
:rtype: list
|
389 |
|
390 |
"""
|
391 |
+
return list(self.lang_code_index.values())
|
392 |
+
|
393 |
+
def __getitem__(self, i: str):
|
394 |
+
return self.lang_code_index[i]
|
395 |
+
|
396 |
+
def __len__(self) -> int:
|
397 |
+
return len(self.lang_code_index)
|
398 |
+
|
399 |
+
def __iter__(self):
|
400 |
+
return iter(self.lang_code_index)
|
401 |
+
|
402 |
+
def __repr__(self) -> str:
|
403 |
+
return f"{self.lang_code_index}"
|
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/contrib/test_playlist.py
CHANGED
@@ -81,6 +81,39 @@ def test_video_urls(request_get, playlist_html):
|
|
81 |
]
|
82 |
|
83 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
84 |
@mock.patch("pytube.contrib.playlist.request.get")
|
85 |
@mock.patch("pytube.cli.YouTube.__init__", return_value=None)
|
86 |
def test_videos(youtube, request_get, playlist_html):
|
|
|
81 |
]
|
82 |
|
83 |
|
84 |
+
@mock.patch("pytube.contrib.playlist.request.get")
|
85 |
+
def test_repr(request_get, playlist_html):
|
86 |
+
url = "https://www.fakeurl.com/playlist?list=whatever"
|
87 |
+
request_get.return_value = playlist_html
|
88 |
+
playlist = Playlist(url)
|
89 |
+
playlist._find_load_more_url = MagicMock(return_value=None)
|
90 |
+
request_get.assert_called()
|
91 |
+
assert (
|
92 |
+
repr(playlist) == "['https://www.youtube.com/watch?v=ujTCoH21GlA', "
|
93 |
+
"'https://www.youtube.com/watch?v=45ryDIPHdGg', "
|
94 |
+
"'https://www.youtube.com/watch?v=1BYu65vLKdA', "
|
95 |
+
"'https://www.youtube.com/watch?v=3AQ_74xrch8', "
|
96 |
+
"'https://www.youtube.com/watch?v=ddqQUz9mZaM', "
|
97 |
+
"'https://www.youtube.com/watch?v=vwLT6bZrHEE', "
|
98 |
+
"'https://www.youtube.com/watch?v=TQKI0KE-JYY', "
|
99 |
+
"'https://www.youtube.com/watch?v=dNBvQ38MlT8', "
|
100 |
+
"'https://www.youtube.com/watch?v=JHxyrMgOUWI', "
|
101 |
+
"'https://www.youtube.com/watch?v=l2I8NycJMCY', "
|
102 |
+
"'https://www.youtube.com/watch?v=g1Zbuk1gAfk', "
|
103 |
+
"'https://www.youtube.com/watch?v=zixd-si9Q-o']"
|
104 |
+
)
|
105 |
+
|
106 |
+
|
107 |
+
@mock.patch("pytube.contrib.playlist.request.get")
|
108 |
+
def test_sequence(request_get, playlist_html):
|
109 |
+
url = "https://www.fakeurl.com/playlist?list=whatever"
|
110 |
+
request_get.return_value = playlist_html
|
111 |
+
playlist = Playlist(url)
|
112 |
+
playlist._find_load_more_url = MagicMock(return_value=None)
|
113 |
+
assert playlist[0] == "https://www.youtube.com/watch?v=ujTCoH21GlA"
|
114 |
+
assert len(playlist) == 12
|
115 |
+
|
116 |
+
|
117 |
@mock.patch("pytube.contrib.playlist.request.get")
|
118 |
@mock.patch("pytube.cli.YouTube.__init__", return_value=None)
|
119 |
def test_videos(youtube, request_get, playlist_html):
|
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_captions.py
CHANGED
@@ -2,6 +2,8 @@
|
|
2 |
from unittest import mock
|
3 |
from unittest.mock import patch, mock_open, MagicMock
|
4 |
|
|
|
|
|
5 |
from pytube import Caption, CaptionQuery, captions
|
6 |
|
7 |
|
@@ -12,7 +14,7 @@ def test_float_to_srt_time_format():
|
|
12 |
assert caption1.float_to_srt_time_format(3.89) == "00:00:03,890"
|
13 |
|
14 |
|
15 |
-
def
|
16 |
caption1 = Caption(
|
17 |
{"url": "url1", "name": {"simpleText": "name1"}, "languageCode": "en"}
|
18 |
)
|
@@ -20,7 +22,11 @@ def test_caption_query_all():
|
|
20 |
{"url": "url2", "name": {"simpleText": "name2"}, "languageCode": "fr"}
|
21 |
)
|
22 |
caption_query = CaptionQuery(captions=[caption1, caption2])
|
23 |
-
assert caption_query
|
|
|
|
|
|
|
|
|
24 |
|
25 |
|
26 |
def test_caption_query_get_by_language_code_when_exists():
|
@@ -101,6 +107,9 @@ def test_repr():
|
|
101 |
)
|
102 |
assert str(caption) == '<Caption lang="name1" code="en">'
|
103 |
|
|
|
|
|
|
|
104 |
|
105 |
@mock.patch("pytube.request.get")
|
106 |
def test_xml_captions(request_get):
|
|
|
2 |
from unittest import mock
|
3 |
from unittest.mock import patch, mock_open, MagicMock
|
4 |
|
5 |
+
import pytest
|
6 |
+
|
7 |
from pytube import Caption, CaptionQuery, captions
|
8 |
|
9 |
|
|
|
14 |
assert caption1.float_to_srt_time_format(3.89) == "00:00:03,890"
|
15 |
|
16 |
|
17 |
+
def test_caption_query_sequence():
|
18 |
caption1 = Caption(
|
19 |
{"url": "url1", "name": {"simpleText": "name1"}, "languageCode": "en"}
|
20 |
)
|
|
|
22 |
{"url": "url2", "name": {"simpleText": "name2"}, "languageCode": "fr"}
|
23 |
)
|
24 |
caption_query = CaptionQuery(captions=[caption1, caption2])
|
25 |
+
assert len(caption_query) == 2
|
26 |
+
assert caption_query["en"] == caption1
|
27 |
+
assert caption_query["fr"] == caption2
|
28 |
+
with pytest.raises(KeyError):
|
29 |
+
caption_query["nada"]
|
30 |
|
31 |
|
32 |
def test_caption_query_get_by_language_code_when_exists():
|
|
|
107 |
)
|
108 |
assert str(caption) == '<Caption lang="name1" code="en">'
|
109 |
|
110 |
+
caption_query = CaptionQuery(captions=[caption])
|
111 |
+
assert repr(caption_query) == '{\'en\': <Caption lang="name1" code="en">}'
|
112 |
+
|
113 |
|
114 |
@mock.patch("pytube.request.get")
|
115 |
def test_xml_captions(request_get):
|
tests/test_cli.py
CHANGED
@@ -19,14 +19,18 @@ def test_main_invalid_url(_parse_args):
|
|
19 |
cli.main()
|
20 |
|
21 |
|
|
|
22 |
@mock.patch("pytube.cli.YouTube")
|
23 |
-
def test_download_when_itag_not_found(youtube):
|
|
|
24 |
youtube.streams = mock.Mock()
|
25 |
-
youtube.streams.all.return_value = []
|
26 |
youtube.streams.get_by_itag.return_value = None
|
|
|
27 |
with pytest.raises(SystemExit):
|
28 |
cli.download_by_itag(youtube, 123)
|
|
|
29 |
youtube.streams.get_by_itag.assert_called_with(123)
|
|
|
30 |
|
31 |
|
32 |
@mock.patch("pytube.cli.YouTube")
|
@@ -46,26 +50,28 @@ def test_download_when_itag_is_found(youtube, stream):
|
|
46 |
@mock.patch("pytube.cli.YouTube")
|
47 |
@mock.patch("pytube.Stream")
|
48 |
def test_display_stream(youtube, stream):
|
|
|
49 |
stream.itag = 123
|
50 |
stream.__repr__ = MagicMock(return_value="")
|
51 |
youtube.streams = StreamQuery([stream])
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
|
57 |
|
|
|
58 |
@mock.patch("pytube.cli.YouTube")
|
59 |
-
def test_download_caption_with_none(youtube):
|
|
|
60 |
caption = Caption(
|
61 |
{"url": "url1", "name": {"simpleText": "name1"}, "languageCode": "en"}
|
62 |
)
|
63 |
youtube.captions = CaptionQuery([caption])
|
64 |
-
|
65 |
-
|
66 |
-
|
67 |
-
|
68 |
-
wrapped_all.assert_called()
|
69 |
|
70 |
|
71 |
@mock.patch("pytube.cli.YouTube")
|
@@ -80,17 +86,18 @@ def test_download_caption_with_language_found(youtube):
|
|
80 |
caption.download.assert_called_with(title="video title", output_path=None)
|
81 |
|
82 |
|
|
|
83 |
@mock.patch("pytube.cli.YouTube")
|
84 |
-
def
|
|
|
85 |
caption = Caption(
|
86 |
{"url": "url1", "name": {"simpleText": "name1"}, "languageCode": "en"}
|
87 |
)
|
88 |
youtube.captions = CaptionQuery([caption])
|
89 |
-
|
90 |
-
|
91 |
-
|
92 |
-
|
93 |
-
wrapped_all.assert_called()
|
94 |
|
95 |
|
96 |
def test_display_progress_bar(capsys):
|
|
|
19 |
cli.main()
|
20 |
|
21 |
|
22 |
+
@mock.patch("pytube.cli.display_streams")
|
23 |
@mock.patch("pytube.cli.YouTube")
|
24 |
+
def test_download_when_itag_not_found(youtube, display_streams):
|
25 |
+
# Given
|
26 |
youtube.streams = mock.Mock()
|
|
|
27 |
youtube.streams.get_by_itag.return_value = None
|
28 |
+
# When
|
29 |
with pytest.raises(SystemExit):
|
30 |
cli.download_by_itag(youtube, 123)
|
31 |
+
# Then
|
32 |
youtube.streams.get_by_itag.assert_called_with(123)
|
33 |
+
display_streams.assert_called_with(youtube)
|
34 |
|
35 |
|
36 |
@mock.patch("pytube.cli.YouTube")
|
|
|
50 |
@mock.patch("pytube.cli.YouTube")
|
51 |
@mock.patch("pytube.Stream")
|
52 |
def test_display_stream(youtube, stream):
|
53 |
+
# Given
|
54 |
stream.itag = 123
|
55 |
stream.__repr__ = MagicMock(return_value="")
|
56 |
youtube.streams = StreamQuery([stream])
|
57 |
+
# When
|
58 |
+
cli.display_streams(youtube)
|
59 |
+
# Then
|
60 |
+
stream.__repr__.assert_called()
|
61 |
|
62 |
|
63 |
+
@mock.patch("pytube.cli._print_available_captions")
|
64 |
@mock.patch("pytube.cli.YouTube")
|
65 |
+
def test_download_caption_with_none(youtube, print_available):
|
66 |
+
# Given
|
67 |
caption = Caption(
|
68 |
{"url": "url1", "name": {"simpleText": "name1"}, "languageCode": "en"}
|
69 |
)
|
70 |
youtube.captions = CaptionQuery([caption])
|
71 |
+
# When
|
72 |
+
cli.download_caption(youtube, None)
|
73 |
+
# Then
|
74 |
+
print_available.assert_called_with(youtube.captions)
|
|
|
75 |
|
76 |
|
77 |
@mock.patch("pytube.cli.YouTube")
|
|
|
86 |
caption.download.assert_called_with(title="video title", output_path=None)
|
87 |
|
88 |
|
89 |
+
@mock.patch("pytube.cli._print_available_captions")
|
90 |
@mock.patch("pytube.cli.YouTube")
|
91 |
+
def test_download_caption_with_lang_not_found(youtube, print_available):
|
92 |
+
# Given
|
93 |
caption = Caption(
|
94 |
{"url": "url1", "name": {"simpleText": "name1"}, "languageCode": "en"}
|
95 |
)
|
96 |
youtube.captions = CaptionQuery([caption])
|
97 |
+
# When
|
98 |
+
cli.download_caption(youtube, "blah")
|
99 |
+
# Then
|
100 |
+
print_available.assert_called_with(youtube.captions)
|
|
|
101 |
|
102 |
|
103 |
def test_display_progress_bar(capsys):
|
tests/test_exceptions.py
CHANGED
@@ -1,5 +1,5 @@
|
|
1 |
# -*- coding: utf-8 -*-
|
2 |
-
from pytube.exceptions import VideoUnavailable, RegexMatchError
|
3 |
|
4 |
|
5 |
def test_video_unavailable():
|
@@ -15,3 +15,11 @@ def test_regex_match_error():
|
|
15 |
raise RegexMatchError(caller="hello", pattern="*")
|
16 |
except RegexMatchError as e:
|
17 |
assert str(e) == "hello: could not find match for *"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
# -*- coding: utf-8 -*-
|
2 |
+
from pytube.exceptions import VideoUnavailable, RegexMatchError, LiveStreamError
|
3 |
|
4 |
|
5 |
def test_video_unavailable():
|
|
|
15 |
raise RegexMatchError(caller="hello", pattern="*")
|
16 |
except RegexMatchError as e:
|
17 |
assert str(e) == "hello: could not find match for *"
|
18 |
+
|
19 |
+
|
20 |
+
def test_live_stream_error():
|
21 |
+
try:
|
22 |
+
raise LiveStreamError(video_id="YLnZklYFe7E")
|
23 |
+
except LiveStreamError as e:
|
24 |
+
assert e.video_id == "YLnZklYFe7E"
|
25 |
+
assert str(e) == "YLnZklYFe7E is streaming live and cannot be loaded"
|
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():
|
@@ -80,3 +77,8 @@ def test_mime_type_codec():
|
|
80 |
def test_mime_type_codec_with_no_match_should_error():
|
81 |
with pytest.raises(RegexMatchError):
|
82 |
extract.mime_type_codec("audio/webm")
|
|
|
|
|
|
|
|
|
|
|
|
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():
|
|
|
77 |
def test_mime_type_codec_with_no_match_should_error():
|
78 |
with pytest.raises(RegexMatchError):
|
79 |
extract.mime_type_codec("audio/webm")
|
80 |
+
|
81 |
+
|
82 |
+
def test_get_ytplayer_config_with_no_match_should_error():
|
83 |
+
with pytest.raises(RegexMatchError):
|
84 |
+
extract.get_ytplayer_config("")
|
tests/test_mixins.py
DELETED
@@ -1,3 +0,0 @@
|
|
1 |
-
# -*- coding: utf-8 -*-
|
2 |
-
def test_pre_signed_video(presigned_video):
|
3 |
-
assert presigned_video.streams.count() == 12
|
|
|
|
|
|
|
|
tests/test_query.py
CHANGED
@@ -3,11 +3,6 @@
|
|
3 |
import pytest
|
4 |
|
5 |
|
6 |
-
def test_count(cipher_signature):
|
7 |
-
"""Ensure :meth:`~pytube.StreamQuery.count` returns an accurate amount."""
|
8 |
-
assert cipher_signature.streams.count() == 22
|
9 |
-
|
10 |
-
|
11 |
@pytest.mark.parametrize(
|
12 |
("test_input", "expected"),
|
13 |
[
|
@@ -30,7 +25,7 @@ def test_count(cipher_signature):
|
|
30 |
)
|
31 |
def test_filters(test_input, expected, cipher_signature):
|
32 |
"""Ensure filters produce the expected results."""
|
33 |
-
result = [s.itag for s in cipher_signature.streams.filter(**test_input)
|
34 |
assert result == expected
|
35 |
|
36 |
|
@@ -64,8 +59,7 @@ def test_order_by(cipher_signature):
|
|
64 |
:class:`Stream <Stream>` instances in the expected order.
|
65 |
"""
|
66 |
itags = [
|
67 |
-
s.itag
|
68 |
-
for s in cipher_signature.streams.filter(type="audio").order_by("itag").all()
|
69 |
]
|
70 |
assert itags == [140, 249, 250, 251]
|
71 |
|
@@ -77,10 +71,7 @@ def test_order_by_descending(cipher_signature):
|
|
77 |
# numerical values
|
78 |
itags = [
|
79 |
s.itag
|
80 |
-
for s in cipher_signature.streams.filter(type="audio")
|
81 |
-
.order_by("itag")
|
82 |
-
.desc()
|
83 |
-
.all()
|
84 |
]
|
85 |
assert itags == [251, 250, 249, 140]
|
86 |
|
@@ -91,7 +82,6 @@ def test_order_by_non_numerical(cipher_signature):
|
|
91 |
for s in cipher_signature.streams.filter(res="360p")
|
92 |
.order_by("mime_type")
|
93 |
.desc()
|
94 |
-
.all()
|
95 |
]
|
96 |
assert mime_types == ["video/webm", "video/mp4", "video/mp4"]
|
97 |
|
@@ -103,10 +93,7 @@ def test_order_by_ascending(cipher_signature):
|
|
103 |
# numerical values
|
104 |
itags = [
|
105 |
s.itag
|
106 |
-
for s in cipher_signature.streams.filter(type="audio")
|
107 |
-
.order_by("itag")
|
108 |
-
.asc()
|
109 |
-
.all()
|
110 |
]
|
111 |
assert itags == [140, 249, 250, 251]
|
112 |
|
@@ -114,16 +101,13 @@ def test_order_by_ascending(cipher_signature):
|
|
114 |
def test_order_by_non_numerical_ascending(cipher_signature):
|
115 |
mime_types = [
|
116 |
s.mime_type
|
117 |
-
for s in cipher_signature.streams.filter(res="360p")
|
118 |
-
.order_by("mime_type")
|
119 |
-
.asc()
|
120 |
-
.all()
|
121 |
]
|
122 |
assert mime_types == ["video/mp4", "video/mp4", "video/webm"]
|
123 |
|
124 |
|
125 |
def test_order_by_with_none_values(cipher_signature):
|
126 |
-
abrs = [s.abr for s in cipher_signature.streams.order_by("abr").asc()
|
127 |
assert abrs == ["50kbps", "70kbps", "96kbps", "128kbps", "160kbps"]
|
128 |
|
129 |
|
@@ -151,7 +135,7 @@ def test_get_highest_resolution(cipher_signature):
|
|
151 |
|
152 |
|
153 |
def test_filter_is_dash(cipher_signature):
|
154 |
-
streams = cipher_signature.streams.filter(is_dash=False)
|
155 |
itags = [s.itag for s in streams]
|
156 |
assert itags == [18, 398, 397, 396, 395, 394]
|
157 |
|
@@ -162,3 +146,28 @@ 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
3 |
import pytest
|
4 |
|
5 |
|
|
|
|
|
|
|
|
|
|
|
6 |
@pytest.mark.parametrize(
|
7 |
("test_input", "expected"),
|
8 |
[
|
|
|
25 |
)
|
26 |
def test_filters(test_input, expected, cipher_signature):
|
27 |
"""Ensure filters produce the expected results."""
|
28 |
+
result = [s.itag for s in cipher_signature.streams.filter(**test_input)]
|
29 |
assert result == expected
|
30 |
|
31 |
|
|
|
59 |
:class:`Stream <Stream>` instances in the expected order.
|
60 |
"""
|
61 |
itags = [
|
62 |
+
s.itag for s in cipher_signature.streams.filter(type="audio").order_by("itag")
|
|
|
63 |
]
|
64 |
assert itags == [140, 249, 250, 251]
|
65 |
|
|
|
71 |
# numerical values
|
72 |
itags = [
|
73 |
s.itag
|
74 |
+
for s in cipher_signature.streams.filter(type="audio").order_by("itag").desc()
|
|
|
|
|
|
|
75 |
]
|
76 |
assert itags == [251, 250, 249, 140]
|
77 |
|
|
|
82 |
for s in cipher_signature.streams.filter(res="360p")
|
83 |
.order_by("mime_type")
|
84 |
.desc()
|
|
|
85 |
]
|
86 |
assert mime_types == ["video/webm", "video/mp4", "video/mp4"]
|
87 |
|
|
|
93 |
# numerical values
|
94 |
itags = [
|
95 |
s.itag
|
96 |
+
for s in cipher_signature.streams.filter(type="audio").order_by("itag").asc()
|
|
|
|
|
|
|
97 |
]
|
98 |
assert itags == [140, 249, 250, 251]
|
99 |
|
|
|
101 |
def test_order_by_non_numerical_ascending(cipher_signature):
|
102 |
mime_types = [
|
103 |
s.mime_type
|
104 |
+
for s in cipher_signature.streams.filter(res="360p").order_by("mime_type").asc()
|
|
|
|
|
|
|
105 |
]
|
106 |
assert mime_types == ["video/mp4", "video/mp4", "video/webm"]
|
107 |
|
108 |
|
109 |
def test_order_by_with_none_values(cipher_signature):
|
110 |
+
abrs = [s.abr for s in cipher_signature.streams.order_by("abr").asc()]
|
111 |
assert abrs == ["50kbps", "70kbps", "96kbps", "128kbps", "160kbps"]
|
112 |
|
113 |
|
|
|
135 |
|
136 |
|
137 |
def test_filter_is_dash(cipher_signature):
|
138 |
+
streams = cipher_signature.streams.filter(is_dash=False)
|
139 |
itags = [s.itag for s in streams]
|
140 |
assert itags == [18, 398, 397, 396, 395, 394]
|
141 |
|
|
|
146 |
|
147 |
def test_get_audio_only_with_subtype(cipher_signature):
|
148 |
assert cipher_signature.streams.get_audio_only(subtype="webm").itag == 251
|
149 |
+
|
150 |
+
|
151 |
+
def test_sequence(cipher_signature):
|
152 |
+
assert len(cipher_signature.streams) == 22
|
153 |
+
assert cipher_signature.streams[0] is not None
|
154 |
+
|
155 |
+
|
156 |
+
def test_otf(cipher_signature):
|
157 |
+
non_otf = cipher_signature.streams.otf().all()
|
158 |
+
assert len(non_otf) == 22
|
159 |
+
|
160 |
+
otf = cipher_signature.streams.otf(True).all()
|
161 |
+
assert len(otf) == 0
|
162 |
+
|
163 |
+
|
164 |
+
def test_repr(cipher_signature):
|
165 |
+
assert repr(
|
166 |
+
cipher_signature.streams.filter(
|
167 |
+
progressive=True, subtype="mp4", resolution="360p"
|
168 |
+
)
|
169 |
+
) == (
|
170 |
+
'[<Stream: itag="18" mime_type="video/mp4" '
|
171 |
+
'res="360p" fps="30fps" vcodec="avc1.42001E" '
|
172 |
+
'acodec="mp4a.40.2" progressive="True" type="video">]'
|
173 |
+
)
|
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 = "PSY - GANGNAM STYLE(๊ฐ๋จ์คํ์ผ)"
|
29 |
-
stream.player_config_args = {
|
30 |
-
"player_response": {"videoDetails": {"title": expected}},
|
31 |
-
}
|
32 |
-
assert stream.title == expected
|
33 |
|
34 |
-
expected = "
|
35 |
-
|
36 |
-
|
|
|
37 |
|
38 |
|
39 |
def test_caption_tracks(presigned_video):
|
@@ -41,7 +45,7 @@ def test_caption_tracks(presigned_video):
|
|
41 |
|
42 |
|
43 |
def test_captions(presigned_video):
|
44 |
-
assert len(presigned_video.captions
|
45 |
|
46 |
|
47 |
def test_description(cipher_signature):
|
|
|
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):
|
|
|
45 |
|
46 |
|
47 |
def test_captions(presigned_video):
|
48 |
+
assert len(presigned_video.captions) == 13
|
49 |
|
50 |
|
51 |
def test_description(cipher_signature):
|