hbmartin commited on
Commit
de50ba8
ยท
2 Parent(s): 0d5b7aa e856a54

Merge branch 'master' into added-tests-cli

Browse files
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.first().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')
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.first().download()
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.all()
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).all()
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).all()
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.videos:
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).all()
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').all()
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).all()
188
  >>> # this can also be expressed as:
189
- >>> yt.streams.filter(subtype='mp4').filter(progressive=True).all()
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().all()
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
- # https://www.youtube.com/watch?v=<video_id>
80
- self.watch_url = extract.watch_url(self.video_id)
81
 
82
- self.embed_url = extract.embed_url(self.video_id)
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 = extract.get_ytplayer_config(self.watch_html,)[
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
- try:
139
- apply_signature(
140
- self.player_config_args, fmt, self.js # type: ignore
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
- assert self.js is not None
147
- apply_signature(self.player_config_args, fmt, self.js)
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
- self.vid_info_url = extract.video_info_url(
178
- video_id=self.video_id,
179
- watch_url=self.watch_url,
180
- embed_html=self.embed_html,
181
- age_restricted=self.age_restricted,
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, self.age_restricted)
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.get_vid_descr(self.watch_html)
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.all():
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.all())}")
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 watch_url(video_id: str) -> str:
81
- """Construct a sanitized YouTube watch url, given a video id.
82
 
83
  :param str video_id:
84
  A YouTube video identifier.
 
 
85
  :rtype: str
86
  :returns:
87
- Sanitized YouTube watch url.
 
88
  """
89
- return "https://youtube.com/watch?v=" + video_id
90
-
91
-
92
- def embed_url(video_id: str) -> str:
93
- return f"https://www.youtube.com/embed/{video_id}"
94
-
95
-
96
- def eurl(video_id: str) -> str:
97
- return f"https://youtube.googleapis.com/v/{video_id}"
 
98
 
99
 
100
- def video_info_url(
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
- if age_restricted:
119
- assert embed_html is not None
120
  sts = regex_search(r'"sts"\s*:\s*(\d+)', embed_html, group=1)
121
- # Here we use ``OrderedDict`` so that the output is consistent between
122
- # Python 2.7+.
123
- params = OrderedDict(
124
- [("video_id", video_id), ("eurl", eurl(video_id)), ("sts", sts),]
125
- )
126
- else:
127
- params = OrderedDict(
128
- [
129
- ("video_id", video_id),
130
- ("el", "$el"),
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, age_restricted: Optional[bool] = False) -> 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
- ytplayer_config = get_ytplayer_config(html, age_restricted or False)
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, age_restricted: bool = False) -> Any:
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
- if age_restricted:
199
- pattern = r";yt\.setConfig\(\{'PLAYER_CONFIG':\s*({.*})(,'EXPERIMENT_FLAGS'|;)" # noqa: E501
200
- else:
201
- pattern = r";ytplayer\.config\s*=\s*({.*?});"
202
- yt_player_config = regex_search(pattern, html, group=1)
203
- return json.loads(yt_player_config)
204
-
205
-
206
- def get_vid_descr(html: Optional[str]) -> str:
 
 
 
 
 
 
 
 
 
 
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("Video is currently being streamed live")
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
- if js is not None:
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, on_progress: Optional[OnProgress], on_complete: Optional[OnComplete]
 
 
 
 
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 fn in filters:
173
- fmt_streams = filter(fn, fmt_streams)
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
- def count(self) -> int:
317
- """Get the count the query would return.
 
318
 
319
  :rtype: int
320
-
321
  """
322
- return len(self.fmt_streams)
 
 
 
323
 
324
- def all(self) -> List[Stream]:
 
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
- class CaptionQuery:
 
 
 
 
 
 
 
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
- def all(self) -> List[Caption]:
 
359
  """Get all the results represented by this query as a list.
360
 
361
  :rtype: list
362
 
363
  """
364
- return self.captions
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- self.player_config_args.get("title")
157
- or (
158
- self.player_config_args.get("player_response", {})
159
- .get("videoDetails", {})
160
- .get("title")
161
- )
162
- or "Unknown YouTube Video Title"
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-zRbsm3e2ltw-1507777044.json.gz"
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 test_caption_query_all():
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.captions == [caption1, caption2]
 
 
 
 
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
- with patch.object(youtube.streams, "all", wraps=youtube.streams.all) as wrapped_all:
53
- cli.display_streams(youtube)
54
- wrapped_all.assert_called()
55
- stream.__repr__.assert_called()
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
- with patch.object(
65
- youtube.captions, "all", wraps=youtube.captions.all
66
- ) as wrapped_all:
67
- cli.download_caption(youtube, None)
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 test_download_caption_with_language_not_found(youtube):
 
85
  caption = Caption(
86
  {"url": "url1", "name": {"simpleText": "name1"}, "languageCode": "en"}
87
  )
88
  youtube.captions = CaptionQuery([caption])
89
- with patch.object(
90
- youtube.captions, "all", wraps=youtube.captions.all
91
- ) as wrapped_all:
92
- cli.download_caption(youtube, "blah")
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 test_extract_watch_url():
16
- video_id = "9bZkp7q19f0"
17
- watch_url = extract.watch_url(video_id)
18
- assert watch_url == "https://youtube.com/watch?v=9bZkp7q19f0"
 
 
 
 
 
19
 
20
 
21
- def test_info_url(cipher_signature):
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.get_vid_descr(cipher_signature.watch_html) == expected
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).all()]
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().all()]
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).all()
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 = "PSY - GANGNAM STYLE(๊ฐ•๋‚จ์Šคํƒ€์ผ) M/V"
25
- stream = cipher_signature.streams.first()
26
- assert stream.title == expected
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 = "Unknown YouTube Video Title"
35
- stream.player_config_args = {}
36
- assert stream.title == expected
 
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.all()) == 13
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):