linting
Browse files- .deepsource.toml +1 -1
- .idea/dictionaries/haroldmartin.xml +1 -1
- .idea/inspectionProfiles/profiles_settings.xml +1 -1
- .idea/misc.xml +1 -1
- .idea/vcs.xml +1 -1
- docs/conf.py +38 -33
- pytube/__main__.py +47 -17
- pytube/captions.py +6 -3
- pytube/cipher.py +14 -5
- pytube/cli.py +73 -23
- pytube/contrib/playlist.py +31 -13
- pytube/exceptions.py +2 -2
- pytube/extract.py +26 -8
- pytube/helpers.py +6 -2
- pytube/monostate.py +5 -2
- pytube/query.py +35 -13
- pytube/request.py +9 -4
- pytube/streams.py +35 -14
- setup.py +1 -0
- tests/conftest.py +6 -3
- tests/contrib/test_playlist.py +7 -2
- tests/generate_fixture.py +4 -5
- tests/test_captions.py +38 -9
- tests/test_cli.py +46 -15
- tests/test_exceptions.py +3 -1
- tests/test_extract.py +9 -4
- tests/test_helpers.py +4 -1
- tests/test_query.py +11 -4
- tests/test_streams.py +5 -2
.deepsource.toml
CHANGED
@@ -13,4 +13,4 @@ name = "python"
|
|
13 |
enabled = true
|
14 |
|
15 |
[analyzers.meta]
|
16 |
-
runtime_version = "3.x.x"
|
|
|
13 |
enabled = true
|
14 |
|
15 |
[analyzers.meta]
|
16 |
+
runtime_version = "3.x.x"
|
.idea/dictionaries/haroldmartin.xml
CHANGED
@@ -46,4 +46,4 @@
|
|
46 |
<w>ytplayer</w>
|
47 |
</words>
|
48 |
</dictionary>
|
49 |
-
</component>
|
|
|
46 |
<w>ytplayer</w>
|
47 |
</words>
|
48 |
</dictionary>
|
49 |
+
</component>
|
.idea/inspectionProfiles/profiles_settings.xml
CHANGED
@@ -3,4 +3,4 @@
|
|
3 |
<option name="USE_PROJECT_PROFILE" value="false" />
|
4 |
<version value="1.0" />
|
5 |
</settings>
|
6 |
-
</component>
|
|
|
3 |
<option name="USE_PROJECT_PROFILE" value="false" />
|
4 |
<version value="1.0" />
|
5 |
</settings>
|
6 |
+
</component>
|
.idea/misc.xml
CHANGED
@@ -1,4 +1,4 @@
|
|
1 |
<?xml version="1.0" encoding="UTF-8"?>
|
2 |
<project version="4">
|
3 |
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.7" project-jdk-type="Python SDK" />
|
4 |
-
</project>
|
|
|
1 |
<?xml version="1.0" encoding="UTF-8"?>
|
2 |
<project version="4">
|
3 |
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.7" project-jdk-type="Python SDK" />
|
4 |
+
</project>
|
.idea/vcs.xml
CHANGED
@@ -3,4 +3,4 @@
|
|
3 |
<component name="VcsDirectoryMappings">
|
4 |
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
5 |
</component>
|
6 |
-
</project>
|
|
|
3 |
<component name="VcsDirectoryMappings">
|
4 |
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
5 |
</component>
|
6 |
+
</project>
|
docs/conf.py
CHANGED
@@ -5,38 +5,39 @@ import os
|
|
5 |
import sys
|
6 |
|
7 |
import sphinx_rtd_theme
|
8 |
-
|
|
|
9 |
|
10 |
from pytube import __version__ # noqa
|
11 |
|
12 |
# -- General configuration ------------------------------------------------
|
13 |
|
14 |
extensions = [
|
15 |
-
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
|
20 |
]
|
21 |
|
22 |
autosummary_generate = True
|
23 |
|
24 |
# Add any paths that contain templates here, relative to this directory.
|
25 |
-
templates_path = [
|
26 |
|
27 |
# The suffix(es) of source filenames.
|
28 |
# You can specify multiple suffix as a list of string:
|
29 |
#
|
30 |
# source_suffix = ['.rst', '.md']
|
31 |
-
source_suffix =
|
32 |
|
33 |
# The master toctree document.
|
34 |
-
master_doc =
|
35 |
|
36 |
# General information about the project.
|
37 |
-
project =
|
38 |
-
copyright =
|
39 |
-
author =
|
40 |
|
41 |
# The version info for the project you're documenting, acts as replacement for
|
42 |
# |version| and |release|, also used in various other places throughout the
|
@@ -57,16 +58,16 @@ language = None
|
|
57 |
# List of patterns, relative to source directory, that match files and
|
58 |
# directories to ignore when looking for source files.
|
59 |
# This patterns also effect to html_static_path and html_extra_path
|
60 |
-
exclude_patterns = [
|
61 |
|
62 |
# The name of the Pygments (syntax highlighting) style to use.
|
63 |
-
pygments_style =
|
64 |
|
65 |
# If true, `todo` and `todoList` produce output, else they produce nothing.
|
66 |
todo_include_todos = True
|
67 |
|
68 |
intersphinx_mapping = {
|
69 |
-
|
70 |
}
|
71 |
|
72 |
|
@@ -75,7 +76,7 @@ intersphinx_mapping = {
|
|
75 |
# The theme to use for HTML and HTML Help pages. See the documentation for
|
76 |
# a list of builtin themes.
|
77 |
#
|
78 |
-
html_theme =
|
79 |
html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
|
80 |
|
81 |
# Theme options are theme-specific and customize the look and feel of a theme
|
@@ -87,7 +88,7 @@ html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
|
|
87 |
# Add any paths that contain custom static files (such as style sheets) here,
|
88 |
# relative to this directory. They are copied after the builtin static files,
|
89 |
# so a file named "default.css" will overwrite the builtin "default.css".
|
90 |
-
html_static_path = [
|
91 |
|
92 |
# Custom sidebar templates, must be a dictionary that maps document names
|
93 |
# to template names.
|
@@ -95,12 +96,12 @@ html_static_path = ['_static']
|
|
95 |
# This is required for the alabaster theme
|
96 |
# refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars
|
97 |
html_sidebars = {
|
98 |
-
|
99 |
-
|
100 |
-
|
101 |
-
|
102 |
-
|
103 |
-
|
104 |
],
|
105 |
}
|
106 |
|
@@ -108,7 +109,7 @@ html_sidebars = {
|
|
108 |
# -- Options for HTMLHelp output ------------------------------------------
|
109 |
|
110 |
# Output file base name for HTML help builder.
|
111 |
-
htmlhelp_basename =
|
112 |
|
113 |
|
114 |
# -- Options for LaTeX output ---------------------------------------------
|
@@ -120,8 +121,11 @@ latex_elements = {}
|
|
120 |
# author, documentclass [howto, manual, or own class]).
|
121 |
latex_documents = [
|
122 |
(
|
123 |
-
master_doc,
|
124 |
-
|
|
|
|
|
|
|
125 |
),
|
126 |
]
|
127 |
|
@@ -131,10 +135,7 @@ latex_documents = [
|
|
131 |
# One entry per manual page. List of tuples
|
132 |
# (source start file, name, description, authors, manual section).
|
133 |
man_pages = [
|
134 |
-
(
|
135 |
-
master_doc, 'pytube3', 'pytube3 Documentation',
|
136 |
-
[author], 1,
|
137 |
-
),
|
138 |
]
|
139 |
|
140 |
|
@@ -145,8 +146,12 @@ man_pages = [
|
|
145 |
# dir menu entry, description, category)
|
146 |
texinfo_documents = [
|
147 |
(
|
148 |
-
master_doc,
|
149 |
-
|
150 |
-
|
|
|
|
|
|
|
|
|
151 |
),
|
152 |
]
|
|
|
5 |
import sys
|
6 |
|
7 |
import sphinx_rtd_theme
|
8 |
+
|
9 |
+
sys.path.insert(0, os.path.abspath("../"))
|
10 |
|
11 |
from pytube import __version__ # noqa
|
12 |
|
13 |
# -- General configuration ------------------------------------------------
|
14 |
|
15 |
extensions = [
|
16 |
+
"sphinx.ext.autodoc",
|
17 |
+
"sphinx.ext.autosummary",
|
18 |
+
"sphinx.ext.todo",
|
19 |
+
"sphinx.ext.intersphinx",
|
20 |
+
"sphinx.ext.viewcode",
|
21 |
]
|
22 |
|
23 |
autosummary_generate = True
|
24 |
|
25 |
# Add any paths that contain templates here, relative to this directory.
|
26 |
+
templates_path = ["_templates"]
|
27 |
|
28 |
# The suffix(es) of source filenames.
|
29 |
# You can specify multiple suffix as a list of string:
|
30 |
#
|
31 |
# source_suffix = ['.rst', '.md']
|
32 |
+
source_suffix = ".rst"
|
33 |
|
34 |
# The master toctree document.
|
35 |
+
master_doc = "index"
|
36 |
|
37 |
# General information about the project.
|
38 |
+
project = "pytube3"
|
39 |
+
copyright = "2019, Nick Ficano"
|
40 |
+
author = "Nick Ficano, Harold Martin"
|
41 |
|
42 |
# The version info for the project you're documenting, acts as replacement for
|
43 |
# |version| and |release|, also used in various other places throughout the
|
|
|
58 |
# List of patterns, relative to source directory, that match files and
|
59 |
# directories to ignore when looking for source files.
|
60 |
# This patterns also effect to html_static_path and html_extra_path
|
61 |
+
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
|
62 |
|
63 |
# The name of the Pygments (syntax highlighting) style to use.
|
64 |
+
pygments_style = "sphinx"
|
65 |
|
66 |
# If true, `todo` and `todoList` produce output, else they produce nothing.
|
67 |
todo_include_todos = True
|
68 |
|
69 |
intersphinx_mapping = {
|
70 |
+
"python": ("https://docs.python.org/3/", None),
|
71 |
}
|
72 |
|
73 |
|
|
|
76 |
# The theme to use for HTML and HTML Help pages. See the documentation for
|
77 |
# a list of builtin themes.
|
78 |
#
|
79 |
+
html_theme = "sphinx_rtd_theme"
|
80 |
html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
|
81 |
|
82 |
# Theme options are theme-specific and customize the look and feel of a theme
|
|
|
88 |
# Add any paths that contain custom static files (such as style sheets) here,
|
89 |
# relative to this directory. They are copied after the builtin static files,
|
90 |
# so a file named "default.css" will overwrite the builtin "default.css".
|
91 |
+
html_static_path = ["_static"]
|
92 |
|
93 |
# Custom sidebar templates, must be a dictionary that maps document names
|
94 |
# to template names.
|
|
|
96 |
# This is required for the alabaster theme
|
97 |
# refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars
|
98 |
html_sidebars = {
|
99 |
+
"**": [
|
100 |
+
"about.html",
|
101 |
+
"navigation.html",
|
102 |
+
"relations.html", # needs 'show_related': True theme option to display
|
103 |
+
"searchbox.html",
|
104 |
+
"donate.html",
|
105 |
],
|
106 |
}
|
107 |
|
|
|
109 |
# -- Options for HTMLHelp output ------------------------------------------
|
110 |
|
111 |
# Output file base name for HTML help builder.
|
112 |
+
htmlhelp_basename = "pytube3doc"
|
113 |
|
114 |
|
115 |
# -- Options for LaTeX output ---------------------------------------------
|
|
|
121 |
# author, documentclass [howto, manual, or own class]).
|
122 |
latex_documents = [
|
123 |
(
|
124 |
+
master_doc,
|
125 |
+
"pytube3.tex",
|
126 |
+
"pytube3 Documentation",
|
127 |
+
"Nick Ficano",
|
128 |
+
"manual",
|
129 |
),
|
130 |
]
|
131 |
|
|
|
135 |
# One entry per manual page. List of tuples
|
136 |
# (source start file, name, description, authors, manual section).
|
137 |
man_pages = [
|
138 |
+
(master_doc, "pytube3", "pytube3 Documentation", [author], 1,),
|
|
|
|
|
|
|
139 |
]
|
140 |
|
141 |
|
|
|
146 |
# dir menu entry, description, category)
|
147 |
texinfo_documents = [
|
148 |
(
|
149 |
+
master_doc,
|
150 |
+
"pytube3",
|
151 |
+
"pytube3 Documentation",
|
152 |
+
author,
|
153 |
+
"pytube3",
|
154 |
+
"One line description of project.",
|
155 |
+
"Miscellaneous",
|
156 |
),
|
157 |
]
|
pytube/__main__.py
CHANGED
@@ -7,12 +7,13 @@ exclusively on the developer interface. Pytube offloads the heavy lifting to
|
|
7 |
smaller peripheral modules and functions.
|
8 |
|
9 |
"""
|
10 |
-
|
11 |
import json
|
12 |
import logging
|
13 |
-
from typing import Optional, Dict, List
|
14 |
-
from urllib.parse import parse_qsl
|
15 |
from html import unescape
|
|
|
|
|
|
|
|
|
16 |
|
17 |
from pytube import Caption
|
18 |
from pytube import CaptionQuery
|
@@ -20,10 +21,14 @@ 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, get_ytplayer_config
|
24 |
-
from pytube.helpers import install_proxy
|
25 |
from pytube.exceptions import VideoUnavailable
|
26 |
-
from pytube.
|
|
|
|
|
|
|
|
|
|
|
|
|
27 |
|
28 |
logger = logging.getLogger(__name__)
|
29 |
|
@@ -54,17 +59,23 @@ class YouTube:
|
|
54 |
|
55 |
"""
|
56 |
self.js: Optional[str] = None # js fetched by js_url
|
57 |
-
self.js_url: Optional[
|
|
|
|
|
58 |
|
59 |
# note: vid_info may eventually be removed. It sounds like it once had
|
60 |
# additional formats, but that doesn't appear to still be the case.
|
61 |
|
62 |
# the url to vid info, parsed from watch html
|
63 |
self.vid_info_url: Optional[str] = None
|
64 |
-
self.vid_info_raw: Optional[
|
|
|
|
|
65 |
self.vid_info: Optional[Dict] = None # parsed content of vid_info_raw
|
66 |
|
67 |
-
self.watch_html: Optional[
|
|
|
|
|
68 |
self.embed_html: Optional[str] = None
|
69 |
self.player_config_args: Dict = {} # inline js in the html containing
|
70 |
self.player_response: Dict = {}
|
@@ -109,11 +120,15 @@ class YouTube:
|
|
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)[
|
|
|
|
|
113 |
|
114 |
# Fix for KeyError: 'title' issue #434
|
115 |
if "title" not in self.player_config_args: # type: ignore
|
116 |
-
i_start = self.watch_html.lower().index("<title>") + len(
|
|
|
|
|
117 |
i_end = self.watch_html.lower().index("</title>")
|
118 |
title = self.watch_html[i_start:i_end].strip()
|
119 |
index = title.lower().rfind(" - youtube")
|
@@ -143,7 +158,9 @@ class YouTube:
|
|
143 |
self.initialize_stream_objects(fmt)
|
144 |
|
145 |
# load the player_response object (contains subtitle information)
|
146 |
-
self.player_response = json.loads(
|
|
|
|
|
147 |
del self.player_config_args["player_response"]
|
148 |
self.stream_monostate.title = self.title
|
149 |
self.stream_monostate.duration = self.length
|
@@ -164,7 +181,10 @@ class YouTube:
|
|
164 |
raise VideoUnavailable(video_id=self.video_id)
|
165 |
self.age_restricted = extract.is_age_restricted(self.watch_html)
|
166 |
|
167 |
-
if
|
|
|
|
|
|
|
168 |
raise VideoUnavailable(video_id=self.video_id)
|
169 |
|
170 |
if self.age_restricted:
|
@@ -282,7 +302,9 @@ class YouTube:
|
|
282 |
:rtype: float
|
283 |
|
284 |
"""
|
285 |
-
return self.player_response.get("videoDetails", {}).get(
|
|
|
|
|
286 |
|
287 |
@property
|
288 |
def length(self) -> int:
|
@@ -293,7 +315,11 @@ class YouTube:
|
|
293 |
"""
|
294 |
return int(
|
295 |
self.player_config_args.get("length_seconds")
|
296 |
-
or (
|
|
|
|
|
|
|
|
|
297 |
)
|
298 |
|
299 |
@property
|
@@ -303,14 +329,18 @@ class YouTube:
|
|
303 |
:rtype: str
|
304 |
|
305 |
"""
|
306 |
-
return int(
|
|
|
|
|
307 |
|
308 |
@property
|
309 |
def author(self) -> str:
|
310 |
"""Get the video author.
|
311 |
:rtype: str
|
312 |
"""
|
313 |
-
return self.player_response.get("videoDetails", {}).get(
|
|
|
|
|
314 |
|
315 |
def register_on_progress_callback(self, func: OnProgress):
|
316 |
"""Register a download progress callback function post initialization.
|
|
|
7 |
smaller peripheral modules and functions.
|
8 |
|
9 |
"""
|
|
|
10 |
import json
|
11 |
import logging
|
|
|
|
|
12 |
from html import unescape
|
13 |
+
from typing import Dict
|
14 |
+
from typing import List
|
15 |
+
from typing import Optional
|
16 |
+
from urllib.parse import parse_qsl
|
17 |
|
18 |
from pytube import Caption
|
19 |
from pytube import CaptionQuery
|
|
|
21 |
from pytube import request
|
22 |
from pytube import Stream
|
23 |
from pytube import StreamQuery
|
|
|
|
|
24 |
from pytube.exceptions import VideoUnavailable
|
25 |
+
from pytube.extract import apply_descrambler
|
26 |
+
from pytube.extract import apply_signature
|
27 |
+
from pytube.extract import get_ytplayer_config
|
28 |
+
from pytube.helpers import install_proxy
|
29 |
+
from pytube.monostate import Monostate
|
30 |
+
from pytube.monostate import OnComplete
|
31 |
+
from pytube.monostate import OnProgress
|
32 |
|
33 |
logger = logging.getLogger(__name__)
|
34 |
|
|
|
59 |
|
60 |
"""
|
61 |
self.js: Optional[str] = None # js fetched by js_url
|
62 |
+
self.js_url: Optional[
|
63 |
+
str
|
64 |
+
] = None # the url to the js, parsed from watch html
|
65 |
|
66 |
# note: vid_info may eventually be removed. It sounds like it once had
|
67 |
# additional formats, but that doesn't appear to still be the case.
|
68 |
|
69 |
# the url to vid info, parsed from watch html
|
70 |
self.vid_info_url: Optional[str] = None
|
71 |
+
self.vid_info_raw: Optional[
|
72 |
+
str
|
73 |
+
] = None # content fetched by vid_info_url
|
74 |
self.vid_info: Optional[Dict] = None # parsed content of vid_info_raw
|
75 |
|
76 |
+
self.watch_html: Optional[
|
77 |
+
str
|
78 |
+
] = None # the html of /watch?v=<video_id>
|
79 |
self.embed_html: Optional[str] = None
|
80 |
self.player_config_args: Dict = {} # inline js in the html containing
|
81 |
self.player_response: Dict = {}
|
|
|
120 |
self.player_config_args = self.vid_info
|
121 |
else:
|
122 |
assert self.watch_html is not None
|
123 |
+
self.player_config_args = get_ytplayer_config(self.watch_html)[
|
124 |
+
"args"
|
125 |
+
]
|
126 |
|
127 |
# Fix for KeyError: 'title' issue #434
|
128 |
if "title" not in self.player_config_args: # type: ignore
|
129 |
+
i_start = self.watch_html.lower().index("<title>") + len(
|
130 |
+
"<title>"
|
131 |
+
)
|
132 |
i_end = self.watch_html.lower().index("</title>")
|
133 |
title = self.watch_html[i_start:i_end].strip()
|
134 |
index = title.lower().rfind(" - youtube")
|
|
|
158 |
self.initialize_stream_objects(fmt)
|
159 |
|
160 |
# load the player_response object (contains subtitle information)
|
161 |
+
self.player_response = json.loads(
|
162 |
+
self.player_config_args["player_response"]
|
163 |
+
)
|
164 |
del self.player_config_args["player_response"]
|
165 |
self.stream_monostate.title = self.title
|
166 |
self.stream_monostate.duration = self.length
|
|
|
181 |
raise VideoUnavailable(video_id=self.video_id)
|
182 |
self.age_restricted = extract.is_age_restricted(self.watch_html)
|
183 |
|
184 |
+
if (
|
185 |
+
not self.age_restricted
|
186 |
+
and "This video is private" in self.watch_html
|
187 |
+
):
|
188 |
raise VideoUnavailable(video_id=self.video_id)
|
189 |
|
190 |
if self.age_restricted:
|
|
|
302 |
:rtype: float
|
303 |
|
304 |
"""
|
305 |
+
return self.player_response.get("videoDetails", {}).get(
|
306 |
+
"averageRating"
|
307 |
+
)
|
308 |
|
309 |
@property
|
310 |
def length(self) -> int:
|
|
|
315 |
"""
|
316 |
return int(
|
317 |
self.player_config_args.get("length_seconds")
|
318 |
+
or (
|
319 |
+
self.player_response.get("videoDetails", {}).get(
|
320 |
+
"lengthSeconds"
|
321 |
+
)
|
322 |
+
)
|
323 |
)
|
324 |
|
325 |
@property
|
|
|
329 |
:rtype: str
|
330 |
|
331 |
"""
|
332 |
+
return int(
|
333 |
+
self.player_response.get("videoDetails", {}).get("viewCount")
|
334 |
+
)
|
335 |
|
336 |
@property
|
337 |
def author(self) -> str:
|
338 |
"""Get the video author.
|
339 |
:rtype: str
|
340 |
"""
|
341 |
+
return self.player_response.get("videoDetails", {}).get(
|
342 |
+
"author", "unknown"
|
343 |
+
)
|
344 |
|
345 |
def register_on_progress_callback(self, func: OnProgress):
|
346 |
"""Register a download progress callback function post initialization.
|
pytube/captions.py
CHANGED
@@ -3,10 +3,13 @@ import math
|
|
3 |
import os
|
4 |
import time
|
5 |
import xml.etree.ElementTree as ElementTree
|
6 |
-
from typing import Dict, Optional
|
7 |
-
from pytube import request
|
8 |
from html import unescape
|
9 |
-
from
|
|
|
|
|
|
|
|
|
|
|
10 |
|
11 |
|
12 |
class Caption:
|
|
|
3 |
import os
|
4 |
import time
|
5 |
import xml.etree.ElementTree as ElementTree
|
|
|
|
|
6 |
from html import unescape
|
7 |
+
from typing import Dict
|
8 |
+
from typing import Optional
|
9 |
+
|
10 |
+
from pytube import request
|
11 |
+
from pytube.helpers import safe_filename
|
12 |
+
from pytube.helpers import target_directory
|
13 |
|
14 |
|
15 |
class Caption:
|
pytube/cipher.py
CHANGED
@@ -1,5 +1,4 @@
|
|
1 |
# -*- coding: utf-8 -*-
|
2 |
-
|
3 |
"""
|
4 |
This module contains all logic necessary to decipher the signature.
|
5 |
|
@@ -17,10 +16,16 @@ signature and decoding it.
|
|
17 |
import logging
|
18 |
import re
|
19 |
from itertools import chain
|
20 |
-
from typing import
|
|
|
|
|
|
|
|
|
|
|
21 |
|
22 |
from pytube.exceptions import RegexMatchError
|
23 |
-
from pytube.helpers import
|
|
|
24 |
|
25 |
logger = logging.getLogger(__name__)
|
26 |
|
@@ -84,7 +89,9 @@ class Cipher:
|
|
84 |
logger.debug("parsing transform function")
|
85 |
parse_match = self.js_func_regex.search(js_func)
|
86 |
if not parse_match:
|
87 |
-
raise RegexMatchError(
|
|
|
|
|
88 |
fn_name, fn_arg = parse_match.groups()
|
89 |
return fn_name, int(fn_arg)
|
90 |
|
@@ -120,7 +127,9 @@ def get_initial_function_name(js: str) -> str:
|
|
120 |
logger.debug("finished regex search, matched: %s", pattern)
|
121 |
return function_match.group(1)
|
122 |
|
123 |
-
raise RegexMatchError(
|
|
|
|
|
124 |
|
125 |
|
126 |
def get_transform_plan(js: str) -> List[str]:
|
|
|
1 |
# -*- coding: utf-8 -*-
|
|
|
2 |
"""
|
3 |
This module contains all logic necessary to decipher the signature.
|
4 |
|
|
|
16 |
import logging
|
17 |
import re
|
18 |
from itertools import chain
|
19 |
+
from typing import Any
|
20 |
+
from typing import Callable
|
21 |
+
from typing import Dict
|
22 |
+
from typing import List
|
23 |
+
from typing import Optional
|
24 |
+
from typing import Tuple
|
25 |
|
26 |
from pytube.exceptions import RegexMatchError
|
27 |
+
from pytube.helpers import cache
|
28 |
+
from pytube.helpers import regex_search
|
29 |
|
30 |
logger = logging.getLogger(__name__)
|
31 |
|
|
|
89 |
logger.debug("parsing transform function")
|
90 |
parse_match = self.js_func_regex.search(js_func)
|
91 |
if not parse_match:
|
92 |
+
raise RegexMatchError(
|
93 |
+
caller="parse_function", pattern="js_func_regex"
|
94 |
+
)
|
95 |
fn_name, fn_arg = parse_match.groups()
|
96 |
return fn_name, int(fn_arg)
|
97 |
|
|
|
127 |
logger.debug("finished regex search, matched: %s", pattern)
|
128 |
return function_match.group(1)
|
129 |
|
130 |
+
raise RegexMatchError(
|
131 |
+
caller="get_initial_function_name", pattern="multiple"
|
132 |
+
)
|
133 |
|
134 |
|
135 |
def get_transform_plan(js: str) -> List[str]:
|
pytube/cli.py
CHANGED
@@ -1,7 +1,6 @@
|
|
1 |
#!/usr/bin/env python3
|
2 |
# -*- coding: utf-8 -*-
|
3 |
"""A simple command line application to download youtube videos."""
|
4 |
-
|
5 |
import argparse
|
6 |
import datetime as dt
|
7 |
import gzip
|
@@ -9,14 +8,19 @@ import json
|
|
9 |
import logging
|
10 |
import os
|
11 |
import shutil
|
12 |
-
import sys
|
13 |
import subprocess # nosec
|
14 |
-
|
|
|
|
|
15 |
|
16 |
-
from pytube import __version__
|
|
|
|
|
|
|
17 |
from pytube import YouTube
|
18 |
from pytube.exceptions import PytubeError
|
19 |
-
from pytube.helpers import safe_filename
|
|
|
20 |
|
21 |
|
22 |
def main():
|
@@ -49,7 +53,9 @@ def main():
|
|
49 |
_perform_args_on_youtube(youtube, args)
|
50 |
|
51 |
|
52 |
-
def _perform_args_on_youtube(
|
|
|
|
|
53 |
if args.list:
|
54 |
display_streams(youtube)
|
55 |
if args.build_playback_report:
|
@@ -65,15 +71,21 @@ def _perform_args_on_youtube(youtube: YouTube, args: argparse.Namespace) -> None
|
|
65 |
youtube=youtube, resolution=args.resolution, target=args.target
|
66 |
)
|
67 |
if args.audio:
|
68 |
-
download_audio(
|
|
|
|
|
69 |
if args.ffmpeg:
|
70 |
-
ffmpeg_process(
|
|
|
|
|
71 |
|
72 |
|
73 |
def _parse_args(
|
74 |
parser: argparse.ArgumentParser, args: Optional[List] = None
|
75 |
) -> argparse.Namespace:
|
76 |
-
parser.add_argument(
|
|
|
|
|
77 |
parser.add_argument(
|
78 |
"--version", action="version", version="%(prog)s " + __version__,
|
79 |
)
|
@@ -81,7 +93,10 @@ def _parse_args(
|
|
81 |
"--itag", type=int, help="The itag for the desired stream",
|
82 |
)
|
83 |
parser.add_argument(
|
84 |
-
"-r",
|
|
|
|
|
|
|
85 |
)
|
86 |
parser.add_argument(
|
87 |
"-l",
|
@@ -218,7 +233,9 @@ def on_progress(
|
|
218 |
|
219 |
|
220 |
def _download(
|
221 |
-
stream: Stream,
|
|
|
|
|
222 |
) -> None:
|
223 |
filesize_megabytes = stream.filesize // 1048576
|
224 |
print(f"{filename or stream.default_filename} | {filesize_megabytes} MB")
|
@@ -271,7 +288,9 @@ def ffmpeg_process(
|
|
271 |
|
272 |
if resolution == "best":
|
273 |
highest_quality_stream = (
|
274 |
-
youtube.streams.filter(progressive=False)
|
|
|
|
|
275 |
)
|
276 |
mp4_stream = (
|
277 |
youtube.streams.filter(progressive=False, subtype="mp4")
|
@@ -298,7 +317,9 @@ def ffmpeg_process(
|
|
298 |
|
299 |
audio_stream = youtube.streams.get_audio_only(video_stream.subtype)
|
300 |
if not audio_stream:
|
301 |
-
audio_stream =
|
|
|
|
|
302 |
if not audio_stream:
|
303 |
print("Could not find an audio only stream")
|
304 |
sys.exit()
|
@@ -307,7 +328,9 @@ def ffmpeg_process(
|
|
307 |
)
|
308 |
|
309 |
|
310 |
-
def _ffmpeg_downloader(
|
|
|
|
|
311 |
"""
|
312 |
Given a YouTube Stream object, finds the correct audio stream, downloads them both
|
313 |
giving them a unique name, them uses ffmpeg to create a new file with the audio
|
@@ -322,29 +345,50 @@ def _ffmpeg_downloader(audio_stream: Stream, video_stream: Stream, target: str)
|
|
322 |
A valid Path object
|
323 |
"""
|
324 |
video_unique_name = _unique_name(
|
325 |
-
safe_filename(video_stream.title),
|
|
|
|
|
|
|
326 |
)
|
327 |
audio_unique_name = _unique_name(
|
328 |
-
safe_filename(video_stream.title),
|
|
|
|
|
|
|
329 |
)
|
330 |
_download(stream=video_stream, target=target, filename=video_unique_name)
|
331 |
print("Loading audio...")
|
332 |
_download(stream=audio_stream, target=target, filename=audio_unique_name)
|
333 |
|
334 |
-
video_path = os.path.join(
|
335 |
-
|
|
|
|
|
|
|
|
|
336 |
final_path = os.path.join(
|
337 |
target, f"{safe_filename(video_stream.title)}.{video_stream.subtype}"
|
338 |
)
|
339 |
|
340 |
subprocess.run( # nosec
|
341 |
-
[
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
342 |
)
|
343 |
os.unlink(video_path)
|
344 |
os.unlink(audio_path)
|
345 |
|
346 |
|
347 |
-
def download_by_itag(
|
|
|
|
|
348 |
"""Start downloading a YouTube video.
|
349 |
|
350 |
:param YouTube youtube:
|
@@ -409,7 +453,9 @@ def display_streams(youtube: YouTube) -> None:
|
|
409 |
|
410 |
|
411 |
def _print_available_captions(captions: CaptionQuery) -> None:
|
412 |
-
print(
|
|
|
|
|
413 |
|
414 |
|
415 |
def download_caption(
|
@@ -432,7 +478,9 @@ def download_caption(
|
|
432 |
|
433 |
try:
|
434 |
caption = youtube.captions[lang_code]
|
435 |
-
downloaded_path = caption.download(
|
|
|
|
|
436 |
print(f"Saved caption file to: {downloaded_path}")
|
437 |
except KeyError:
|
438 |
print(f"Unable to find caption with code: {lang_code}")
|
@@ -454,7 +502,9 @@ def download_audio(
|
|
454 |
Target directory for download
|
455 |
"""
|
456 |
audio = (
|
457 |
-
youtube.streams.filter(only_audio=True, subtype=filetype)
|
|
|
|
|
458 |
)
|
459 |
|
460 |
if audio is None:
|
|
|
1 |
#!/usr/bin/env python3
|
2 |
# -*- coding: utf-8 -*-
|
3 |
"""A simple command line application to download youtube videos."""
|
|
|
4 |
import argparse
|
5 |
import datetime as dt
|
6 |
import gzip
|
|
|
8 |
import logging
|
9 |
import os
|
10 |
import shutil
|
|
|
11 |
import subprocess # nosec
|
12 |
+
import sys
|
13 |
+
from typing import List
|
14 |
+
from typing import Optional
|
15 |
|
16 |
+
from pytube import __version__
|
17 |
+
from pytube import CaptionQuery
|
18 |
+
from pytube import Playlist
|
19 |
+
from pytube import Stream
|
20 |
from pytube import YouTube
|
21 |
from pytube.exceptions import PytubeError
|
22 |
+
from pytube.helpers import safe_filename
|
23 |
+
from pytube.helpers import setup_logger
|
24 |
|
25 |
|
26 |
def main():
|
|
|
53 |
_perform_args_on_youtube(youtube, args)
|
54 |
|
55 |
|
56 |
+
def _perform_args_on_youtube(
|
57 |
+
youtube: YouTube, args: argparse.Namespace
|
58 |
+
) -> None:
|
59 |
if args.list:
|
60 |
display_streams(youtube)
|
61 |
if args.build_playback_report:
|
|
|
71 |
youtube=youtube, resolution=args.resolution, target=args.target
|
72 |
)
|
73 |
if args.audio:
|
74 |
+
download_audio(
|
75 |
+
youtube=youtube, filetype=args.audio, target=args.target
|
76 |
+
)
|
77 |
if args.ffmpeg:
|
78 |
+
ffmpeg_process(
|
79 |
+
youtube=youtube, resolution=args.ffmpeg, target=args.target
|
80 |
+
)
|
81 |
|
82 |
|
83 |
def _parse_args(
|
84 |
parser: argparse.ArgumentParser, args: Optional[List] = None
|
85 |
) -> argparse.Namespace:
|
86 |
+
parser.add_argument(
|
87 |
+
"url", help="The YouTube /watch or /playlist url", nargs="?"
|
88 |
+
)
|
89 |
parser.add_argument(
|
90 |
"--version", action="version", version="%(prog)s " + __version__,
|
91 |
)
|
|
|
93 |
"--itag", type=int, help="The itag for the desired stream",
|
94 |
)
|
95 |
parser.add_argument(
|
96 |
+
"-r",
|
97 |
+
"--resolution",
|
98 |
+
type=str,
|
99 |
+
help="The resolution for the desired stream",
|
100 |
)
|
101 |
parser.add_argument(
|
102 |
"-l",
|
|
|
233 |
|
234 |
|
235 |
def _download(
|
236 |
+
stream: Stream,
|
237 |
+
target: Optional[str] = None,
|
238 |
+
filename: Optional[str] = None,
|
239 |
) -> None:
|
240 |
filesize_megabytes = stream.filesize // 1048576
|
241 |
print(f"{filename or stream.default_filename} | {filesize_megabytes} MB")
|
|
|
288 |
|
289 |
if resolution == "best":
|
290 |
highest_quality_stream = (
|
291 |
+
youtube.streams.filter(progressive=False)
|
292 |
+
.order_by("resolution")
|
293 |
+
.last()
|
294 |
)
|
295 |
mp4_stream = (
|
296 |
youtube.streams.filter(progressive=False, subtype="mp4")
|
|
|
317 |
|
318 |
audio_stream = youtube.streams.get_audio_only(video_stream.subtype)
|
319 |
if not audio_stream:
|
320 |
+
audio_stream = (
|
321 |
+
youtube.streams.filter(only_audio=True).order_by("abr").last()
|
322 |
+
)
|
323 |
if not audio_stream:
|
324 |
print("Could not find an audio only stream")
|
325 |
sys.exit()
|
|
|
328 |
)
|
329 |
|
330 |
|
331 |
+
def _ffmpeg_downloader(
|
332 |
+
audio_stream: Stream, video_stream: Stream, target: str
|
333 |
+
) -> None:
|
334 |
"""
|
335 |
Given a YouTube Stream object, finds the correct audio stream, downloads them both
|
336 |
giving them a unique name, them uses ffmpeg to create a new file with the audio
|
|
|
345 |
A valid Path object
|
346 |
"""
|
347 |
video_unique_name = _unique_name(
|
348 |
+
safe_filename(video_stream.title),
|
349 |
+
video_stream.subtype,
|
350 |
+
"video",
|
351 |
+
target=target,
|
352 |
)
|
353 |
audio_unique_name = _unique_name(
|
354 |
+
safe_filename(video_stream.title),
|
355 |
+
audio_stream.subtype,
|
356 |
+
"audio",
|
357 |
+
target=target,
|
358 |
)
|
359 |
_download(stream=video_stream, target=target, filename=video_unique_name)
|
360 |
print("Loading audio...")
|
361 |
_download(stream=audio_stream, target=target, filename=audio_unique_name)
|
362 |
|
363 |
+
video_path = os.path.join(
|
364 |
+
target, f"{video_unique_name}.{video_stream.subtype}"
|
365 |
+
)
|
366 |
+
audio_path = os.path.join(
|
367 |
+
target, f"{audio_unique_name}.{audio_stream.subtype}"
|
368 |
+
)
|
369 |
final_path = os.path.join(
|
370 |
target, f"{safe_filename(video_stream.title)}.{video_stream.subtype}"
|
371 |
)
|
372 |
|
373 |
subprocess.run( # nosec
|
374 |
+
[
|
375 |
+
"ffmpeg",
|
376 |
+
"-i",
|
377 |
+
video_path,
|
378 |
+
"-i",
|
379 |
+
audio_path,
|
380 |
+
"-codec",
|
381 |
+
"copy",
|
382 |
+
final_path,
|
383 |
+
]
|
384 |
)
|
385 |
os.unlink(video_path)
|
386 |
os.unlink(audio_path)
|
387 |
|
388 |
|
389 |
+
def download_by_itag(
|
390 |
+
youtube: YouTube, itag: int, target: Optional[str] = None
|
391 |
+
) -> None:
|
392 |
"""Start downloading a YouTube video.
|
393 |
|
394 |
:param YouTube youtube:
|
|
|
453 |
|
454 |
|
455 |
def _print_available_captions(captions: CaptionQuery) -> None:
|
456 |
+
print(
|
457 |
+
f"Available caption codes are: {', '.join(c.code for c in captions)}"
|
458 |
+
)
|
459 |
|
460 |
|
461 |
def download_caption(
|
|
|
478 |
|
479 |
try:
|
480 |
caption = youtube.captions[lang_code]
|
481 |
+
downloaded_path = caption.download(
|
482 |
+
title=youtube.title, output_path=target
|
483 |
+
)
|
484 |
print(f"Saved caption file to: {downloaded_path}")
|
485 |
except KeyError:
|
486 |
print(f"Unable to find caption with code: {lang_code}")
|
|
|
502 |
Target directory for download
|
503 |
"""
|
504 |
audio = (
|
505 |
+
youtube.streams.filter(only_audio=True, subtype=filetype)
|
506 |
+
.order_by("abr")
|
507 |
+
.last()
|
508 |
)
|
509 |
|
510 |
if audio is None:
|
pytube/contrib/playlist.py
CHANGED
@@ -1,17 +1,24 @@
|
|
1 |
# -*- coding: utf-8 -*-
|
2 |
-
|
3 |
"""Module to download a complete playlist from a youtube channel."""
|
4 |
-
|
5 |
import json
|
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
|
14 |
-
from pytube
|
|
|
|
|
|
|
|
|
15 |
|
16 |
logger = logging.getLogger(__name__)
|
17 |
|
@@ -28,7 +35,9 @@ class Playlist(Sequence):
|
|
28 |
except IndexError: # assume that url is just the id
|
29 |
self.playlist_id = url
|
30 |
|
31 |
-
self.playlist_url =
|
|
|
|
|
32 |
self.html = request.get(self.playlist_url)
|
33 |
|
34 |
# Needs testing with non-English
|
@@ -48,7 +57,8 @@ class Playlist(Sequence):
|
|
48 |
def _find_load_more_url(req: str) -> Optional[str]:
|
49 |
"""Given an html page or fragment, returns the "load more" url if found."""
|
50 |
match = re.search(
|
51 |
-
r"data-uix-load-more-href=\"(/browse_ajax\?"
|
|
|
52 |
req,
|
53 |
)
|
54 |
if match:
|
@@ -56,7 +66,9 @@ class Playlist(Sequence):
|
|
56 |
|
57 |
return None
|
58 |
|
59 |
-
@deprecated(
|
|
|
|
|
60 |
def parse_links(self) -> List[str]: # pragma: no cover
|
61 |
""" Deprecated function for returning list of URLs
|
62 |
|
@@ -64,7 +76,9 @@ class Playlist(Sequence):
|
|
64 |
"""
|
65 |
return self.video_urls
|
66 |
|
67 |
-
def _paginate(
|
|
|
|
|
68 |
"""Parse the video links from the page source, yields the /watch?v= part from video link
|
69 |
"""
|
70 |
req = self.html
|
@@ -94,7 +108,9 @@ class Playlist(Sequence):
|
|
94 |
videos_urls = self._extract_videos(html)
|
95 |
if until_watch_id:
|
96 |
try:
|
97 |
-
trim_index = videos_urls.index(
|
|
|
|
|
98 |
yield videos_urls[:trim_index]
|
99 |
return
|
100 |
except ValueError:
|
@@ -132,7 +148,9 @@ class Playlist(Sequence):
|
|
132 |
:returns: List of video URLs
|
133 |
"""
|
134 |
return [
|
135 |
-
self._video_url(video)
|
|
|
|
|
136 |
]
|
137 |
|
138 |
@property
|
|
|
1 |
# -*- coding: utf-8 -*-
|
|
|
2 |
"""Module to download a complete playlist from a youtube channel."""
|
|
|
3 |
import json
|
4 |
import logging
|
5 |
import re
|
|
|
|
|
|
|
6 |
from collections.abc import Sequence
|
7 |
+
from datetime import date
|
8 |
+
from datetime import datetime
|
9 |
+
from typing import Dict
|
10 |
+
from typing import Iterable
|
11 |
+
from typing import List
|
12 |
+
from typing import Optional
|
13 |
+
from typing import Union
|
14 |
+
from urllib.parse import parse_qs
|
15 |
|
16 |
+
from pytube import request
|
17 |
+
from pytube import YouTube
|
18 |
+
from pytube.helpers import cache
|
19 |
+
from pytube.helpers import deprecated
|
20 |
+
from pytube.helpers import install_proxy
|
21 |
+
from pytube.helpers import uniqueify
|
22 |
|
23 |
logger = logging.getLogger(__name__)
|
24 |
|
|
|
35 |
except IndexError: # assume that url is just the id
|
36 |
self.playlist_id = url
|
37 |
|
38 |
+
self.playlist_url = (
|
39 |
+
f"https://www.youtube.com/playlist?list={self.playlist_id}"
|
40 |
+
)
|
41 |
self.html = request.get(self.playlist_url)
|
42 |
|
43 |
# Needs testing with non-English
|
|
|
57 |
def _find_load_more_url(req: str) -> Optional[str]:
|
58 |
"""Given an html page or fragment, returns the "load more" url if found."""
|
59 |
match = re.search(
|
60 |
+
r"data-uix-load-more-href=\"(/browse_ajax\?"
|
61 |
+
'action_continuation=.*?)"',
|
62 |
req,
|
63 |
)
|
64 |
if match:
|
|
|
66 |
|
67 |
return None
|
68 |
|
69 |
+
@deprecated(
|
70 |
+
"This function will be removed in the future, please use .video_urls"
|
71 |
+
)
|
72 |
def parse_links(self) -> List[str]: # pragma: no cover
|
73 |
""" Deprecated function for returning list of URLs
|
74 |
|
|
|
76 |
"""
|
77 |
return self.video_urls
|
78 |
|
79 |
+
def _paginate(
|
80 |
+
self, until_watch_id: Optional[str] = None
|
81 |
+
) -> Iterable[List[str]]:
|
82 |
"""Parse the video links from the page source, yields the /watch?v= part from video link
|
83 |
"""
|
84 |
req = self.html
|
|
|
108 |
videos_urls = self._extract_videos(html)
|
109 |
if until_watch_id:
|
110 |
try:
|
111 |
+
trim_index = videos_urls.index(
|
112 |
+
f"/watch?v={until_watch_id}"
|
113 |
+
)
|
114 |
yield videos_urls[:trim_index]
|
115 |
return
|
116 |
except ValueError:
|
|
|
148 |
:returns: List of video URLs
|
149 |
"""
|
150 |
return [
|
151 |
+
self._video_url(video)
|
152 |
+
for page in list(self._paginate())
|
153 |
+
for video in page
|
154 |
]
|
155 |
|
156 |
@property
|
pytube/exceptions.py
CHANGED
@@ -1,7 +1,7 @@
|
|
1 |
# -*- coding: utf-8 -*-
|
2 |
-
|
3 |
"""Library specific exception definitions."""
|
4 |
-
from typing import
|
|
|
5 |
|
6 |
|
7 |
class PytubeError(Exception):
|
|
|
1 |
# -*- coding: utf-8 -*-
|
|
|
2 |
"""Library specific exception definitions."""
|
3 |
+
from typing import Pattern
|
4 |
+
from typing import Union
|
5 |
|
6 |
|
7 |
class PytubeError(Exception):
|
pytube/extract.py
CHANGED
@@ -5,12 +5,21 @@ import logging
|
|
5 |
import re
|
6 |
from collections import OrderedDict
|
7 |
from html.parser import HTMLParser
|
8 |
-
from typing import Any
|
9 |
-
from
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
10 |
from urllib.parse import urlencode
|
11 |
|
12 |
from pytube.cipher import Cipher
|
13 |
-
from pytube.exceptions import
|
|
|
|
|
14 |
from pytube.helpers import regex_search
|
15 |
|
16 |
logger = logging.getLogger(__name__)
|
@@ -123,7 +132,9 @@ def video_info_url_age_restricted(video_id: str, embed_html: str) -> str:
|
|
123 |
# Here we use ``OrderedDict`` so that the output is consistent between
|
124 |
# Python 2.7+.
|
125 |
eurl = f"https://youtube.googleapis.com/v/{video_id}"
|
126 |
-
params = OrderedDict(
|
|
|
|
|
127 |
return _video_info_url(params)
|
128 |
|
129 |
|
@@ -199,7 +210,9 @@ def get_ytplayer_config(html: str) -> Any:
|
|
199 |
yt_player_config = function_match.group(1)
|
200 |
return json.loads(yt_player_config)
|
201 |
|
202 |
-
raise RegexMatchError(
|
|
|
|
|
203 |
|
204 |
|
205 |
def _get_vid_descr(html: Optional[str]) -> str:
|
@@ -248,7 +261,9 @@ def apply_signature(config_args: Dict, fmt: str, js: str) -> None:
|
|
248 |
|
249 |
signature = cipher.get_signature(ciphered_signature=stream["s"])
|
250 |
|
251 |
-
logger.debug(
|
|
|
|
|
252 |
# 403 forbidden fix
|
253 |
stream_manifest[i]["url"] = url + "&sig=" + signature
|
254 |
|
@@ -278,7 +293,9 @@ def apply_descrambler(stream_data: Dict, key: str) -> None:
|
|
278 |
if key == "url_encoded_fmt_stream_map" and not stream_data.get(
|
279 |
"url_encoded_fmt_stream_map"
|
280 |
):
|
281 |
-
formats = json.loads(stream_data["player_response"])["streamingData"][
|
|
|
|
|
282 |
formats.extend(
|
283 |
json.loads(stream_data["player_response"])["streamingData"][
|
284 |
"adaptiveFormats"
|
@@ -298,7 +315,8 @@ def apply_descrambler(stream_data: Dict, key: str) -> None:
|
|
298 |
]
|
299 |
except KeyError:
|
300 |
cipher_url = [
|
301 |
-
parse_qs(formats[i]["cipher"])
|
|
|
302 |
]
|
303 |
stream_data[key] = [
|
304 |
{
|
|
|
5 |
import re
|
6 |
from collections import OrderedDict
|
7 |
from html.parser import HTMLParser
|
8 |
+
from typing import Any
|
9 |
+
from typing import Dict
|
10 |
+
from typing import List
|
11 |
+
from typing import Optional
|
12 |
+
from typing import Tuple
|
13 |
+
from urllib.parse import parse_qs
|
14 |
+
from urllib.parse import parse_qsl
|
15 |
+
from urllib.parse import quote
|
16 |
+
from urllib.parse import unquote
|
17 |
from urllib.parse import urlencode
|
18 |
|
19 |
from pytube.cipher import Cipher
|
20 |
+
from pytube.exceptions import HTMLParseError
|
21 |
+
from pytube.exceptions import LiveStreamError
|
22 |
+
from pytube.exceptions import RegexMatchError
|
23 |
from pytube.helpers import regex_search
|
24 |
|
25 |
logger = logging.getLogger(__name__)
|
|
|
132 |
# Here we use ``OrderedDict`` so that the output is consistent between
|
133 |
# Python 2.7+.
|
134 |
eurl = f"https://youtube.googleapis.com/v/{video_id}"
|
135 |
+
params = OrderedDict(
|
136 |
+
[("video_id", video_id), ("eurl", eurl), ("sts", sts),]
|
137 |
+
)
|
138 |
return _video_info_url(params)
|
139 |
|
140 |
|
|
|
210 |
yt_player_config = function_match.group(1)
|
211 |
return json.loads(yt_player_config)
|
212 |
|
213 |
+
raise RegexMatchError(
|
214 |
+
caller="get_ytplayer_config", pattern="config_patterns"
|
215 |
+
)
|
216 |
|
217 |
|
218 |
def _get_vid_descr(html: Optional[str]) -> str:
|
|
|
261 |
|
262 |
signature = cipher.get_signature(ciphered_signature=stream["s"])
|
263 |
|
264 |
+
logger.debug(
|
265 |
+
"finished descrambling signature for itag=%s", stream["itag"]
|
266 |
+
)
|
267 |
# 403 forbidden fix
|
268 |
stream_manifest[i]["url"] = url + "&sig=" + signature
|
269 |
|
|
|
293 |
if key == "url_encoded_fmt_stream_map" and not stream_data.get(
|
294 |
"url_encoded_fmt_stream_map"
|
295 |
):
|
296 |
+
formats = json.loads(stream_data["player_response"])["streamingData"][
|
297 |
+
"formats"
|
298 |
+
]
|
299 |
formats.extend(
|
300 |
json.loads(stream_data["player_response"])["streamingData"][
|
301 |
"adaptiveFormats"
|
|
|
315 |
]
|
316 |
except KeyError:
|
317 |
cipher_url = [
|
318 |
+
parse_qs(formats[i]["cipher"])
|
319 |
+
for i, data in enumerate(formats)
|
320 |
]
|
321 |
stream_data[key] = [
|
322 |
{
|
pytube/helpers.py
CHANGED
@@ -1,12 +1,16 @@
|
|
1 |
# -*- coding: utf-8 -*-
|
2 |
-
|
3 |
"""Various helper functions implemented by pytube."""
|
4 |
import functools
|
5 |
import logging
|
6 |
import os
|
7 |
import re
|
8 |
import warnings
|
9 |
-
from typing import
|
|
|
|
|
|
|
|
|
|
|
10 |
from urllib import request
|
11 |
|
12 |
from pytube.exceptions import RegexMatchError
|
|
|
1 |
# -*- coding: utf-8 -*-
|
|
|
2 |
"""Various helper functions implemented by pytube."""
|
3 |
import functools
|
4 |
import logging
|
5 |
import os
|
6 |
import re
|
7 |
import warnings
|
8 |
+
from typing import Any
|
9 |
+
from typing import Callable
|
10 |
+
from typing import Dict
|
11 |
+
from typing import List
|
12 |
+
from typing import Optional
|
13 |
+
from typing import TypeVar
|
14 |
from urllib import request
|
15 |
|
16 |
from pytube.exceptions import RegexMatchError
|
pytube/monostate.py
CHANGED
@@ -1,11 +1,14 @@
|
|
1 |
# -*- coding: utf-8 -*-
|
|
|
|
|
2 |
|
3 |
-
from typing import Any, Optional
|
4 |
from typing_extensions import Protocol
|
5 |
|
6 |
|
7 |
class OnProgress(Protocol):
|
8 |
-
def __call__(
|
|
|
|
|
9 |
"""On download progress callback function.
|
10 |
|
11 |
:param stream:
|
|
|
1 |
# -*- coding: utf-8 -*-
|
2 |
+
from typing import Any
|
3 |
+
from typing import Optional
|
4 |
|
|
|
5 |
from typing_extensions import Protocol
|
6 |
|
7 |
|
8 |
class OnProgress(Protocol):
|
9 |
+
def __call__(
|
10 |
+
self, stream: Any, chunk: bytes, bytes_remaining: int
|
11 |
+
) -> None:
|
12 |
"""On download progress callback function.
|
13 |
|
14 |
:param stream:
|
pytube/query.py
CHANGED
@@ -1,10 +1,14 @@
|
|
1 |
# -*- coding: utf-8 -*-
|
2 |
-
|
3 |
"""This module provides a query interface for media streams and captions."""
|
4 |
-
from
|
5 |
-
from collections.abc import
|
6 |
-
|
7 |
-
from
|
|
|
|
|
|
|
|
|
|
|
8 |
from pytube.helpers import deprecated
|
9 |
|
10 |
|
@@ -150,12 +154,16 @@ class StreamQuery(Sequence):
|
|
150 |
|
151 |
if only_audio:
|
152 |
filters.append(
|
153 |
-
lambda s: (
|
|
|
|
|
154 |
)
|
155 |
|
156 |
if only_video:
|
157 |
filters.append(
|
158 |
-
lambda s: (
|
|
|
|
|
159 |
)
|
160 |
|
161 |
if progressive:
|
@@ -185,10 +193,14 @@ class StreamQuery(Sequence):
|
|
185 |
The name of the attribute to sort by.
|
186 |
"""
|
187 |
has_attribute = [
|
188 |
-
s
|
|
|
|
|
189 |
]
|
190 |
# Check that the attributes have string values.
|
191 |
-
if has_attribute and isinstance(
|
|
|
|
|
192 |
# Try to return a StreamQuery sorted by the integer representations
|
193 |
# of the values.
|
194 |
try:
|
@@ -196,7 +208,9 @@ class StreamQuery(Sequence):
|
|
196 |
sorted(
|
197 |
has_attribute,
|
198 |
key=lambda s: int(
|
199 |
-
"".join(
|
|
|
|
|
200 |
), # type: ignore # noqa: E501
|
201 |
)
|
202 |
)
|
@@ -263,7 +277,9 @@ class StreamQuery(Sequence):
|
|
263 |
|
264 |
"""
|
265 |
return (
|
266 |
-
self.filter(progressive=True, subtype="mp4")
|
|
|
|
|
267 |
)
|
268 |
|
269 |
def get_highest_resolution(self) -> Optional[Stream]:
|
@@ -287,7 +303,11 @@ class StreamQuery(Sequence):
|
|
287 |
The :class:`Stream <Stream>` matching the given itag or None if
|
288 |
not found.
|
289 |
"""
|
290 |
-
return
|
|
|
|
|
|
|
|
|
291 |
|
292 |
def otf(self, is_otf: bool = False) -> "StreamQuery":
|
293 |
"""Filter stream by OTF, useful if some streams have 404 URLs
|
@@ -368,7 +388,9 @@ class CaptionQuery(Mapping):
|
|
368 |
"""
|
369 |
self.lang_code_index = {c.code: c for c in captions}
|
370 |
|
371 |
-
@deprecated(
|
|
|
|
|
372 |
def get_by_language_code(
|
373 |
self, lang_code: str
|
374 |
) -> Optional[Caption]: # pragma: no cover
|
|
|
1 |
# -*- coding: utf-8 -*-
|
|
|
2 |
"""This module provides a query interface for media streams and captions."""
|
3 |
+
from collections.abc import Mapping
|
4 |
+
from collections.abc import Sequence
|
5 |
+
from typing import Callable
|
6 |
+
from typing import List
|
7 |
+
from typing import Optional
|
8 |
+
from typing import Union
|
9 |
+
|
10 |
+
from pytube import Caption
|
11 |
+
from pytube import Stream
|
12 |
from pytube.helpers import deprecated
|
13 |
|
14 |
|
|
|
154 |
|
155 |
if only_audio:
|
156 |
filters.append(
|
157 |
+
lambda s: (
|
158 |
+
s.includes_audio_track and not s.includes_video_track
|
159 |
+
),
|
160 |
)
|
161 |
|
162 |
if only_video:
|
163 |
filters.append(
|
164 |
+
lambda s: (
|
165 |
+
s.includes_video_track and not s.includes_audio_track
|
166 |
+
),
|
167 |
)
|
168 |
|
169 |
if progressive:
|
|
|
193 |
The name of the attribute to sort by.
|
194 |
"""
|
195 |
has_attribute = [
|
196 |
+
s
|
197 |
+
for s in self.fmt_streams
|
198 |
+
if getattr(s, attribute_name) is not None
|
199 |
]
|
200 |
# Check that the attributes have string values.
|
201 |
+
if has_attribute and isinstance(
|
202 |
+
getattr(has_attribute[0], attribute_name), str
|
203 |
+
):
|
204 |
# Try to return a StreamQuery sorted by the integer representations
|
205 |
# of the values.
|
206 |
try:
|
|
|
208 |
sorted(
|
209 |
has_attribute,
|
210 |
key=lambda s: int(
|
211 |
+
"".join(
|
212 |
+
filter(str.isdigit, getattr(s, attribute_name))
|
213 |
+
)
|
214 |
), # type: ignore # noqa: E501
|
215 |
)
|
216 |
)
|
|
|
277 |
|
278 |
"""
|
279 |
return (
|
280 |
+
self.filter(progressive=True, subtype="mp4")
|
281 |
+
.order_by("resolution")
|
282 |
+
.first()
|
283 |
)
|
284 |
|
285 |
def get_highest_resolution(self) -> Optional[Stream]:
|
|
|
303 |
The :class:`Stream <Stream>` matching the given itag or None if
|
304 |
not found.
|
305 |
"""
|
306 |
+
return (
|
307 |
+
self.filter(only_audio=True, subtype=subtype)
|
308 |
+
.order_by("abr")
|
309 |
+
.last()
|
310 |
+
)
|
311 |
|
312 |
def otf(self, is_otf: bool = False) -> "StreamQuery":
|
313 |
"""Filter stream by OTF, useful if some streams have 404 URLs
|
|
|
388 |
"""
|
389 |
self.lang_code_index = {c.code: c for c in captions}
|
390 |
|
391 |
+
@deprecated(
|
392 |
+
"This object can be treated as a dictionary, i.e. captions['en']"
|
393 |
+
)
|
394 |
def get_by_language_code(
|
395 |
self, lang_code: str
|
396 |
) -> Optional[Caption]: # pragma: no cover
|
pytube/request.py
CHANGED
@@ -1,10 +1,11 @@
|
|
1 |
# -*- coding: utf-8 -*-
|
2 |
-
|
3 |
"""Implements a simple wrapper around urlopen."""
|
4 |
import logging
|
5 |
from functools import lru_cache
|
6 |
from http.client import HTTPResponse
|
7 |
-
from typing import
|
|
|
|
|
8 |
from urllib.request import Request
|
9 |
from urllib.request import urlopen
|
10 |
|
@@ -12,7 +13,9 @@ logger = logging.getLogger(__name__)
|
|
12 |
|
13 |
|
14 |
def _execute_request(
|
15 |
-
url: str,
|
|
|
|
|
16 |
) -> HTTPResponse:
|
17 |
base_headers = {"User-Agent": "Mozilla/5.0"}
|
18 |
if headers:
|
@@ -50,7 +53,9 @@ def stream(
|
|
50 |
while downloaded < file_size:
|
51 |
stop_pos = min(downloaded + range_size, file_size) - 1
|
52 |
range_header = f"bytes={downloaded}-{stop_pos}"
|
53 |
-
response = _execute_request(
|
|
|
|
|
54 |
if file_size == range_size:
|
55 |
try:
|
56 |
content_range = response.info()["Content-Range"]
|
|
|
1 |
# -*- coding: utf-8 -*-
|
|
|
2 |
"""Implements a simple wrapper around urlopen."""
|
3 |
import logging
|
4 |
from functools import lru_cache
|
5 |
from http.client import HTTPResponse
|
6 |
+
from typing import Dict
|
7 |
+
from typing import Iterable
|
8 |
+
from typing import Optional
|
9 |
from urllib.request import Request
|
10 |
from urllib.request import urlopen
|
11 |
|
|
|
13 |
|
14 |
|
15 |
def _execute_request(
|
16 |
+
url: str,
|
17 |
+
method: Optional[str] = None,
|
18 |
+
headers: Optional[Dict[str, str]] = None,
|
19 |
) -> HTTPResponse:
|
20 |
base_headers = {"User-Agent": "Mozilla/5.0"}
|
21 |
if headers:
|
|
|
53 |
while downloaded < file_size:
|
54 |
stop_pos = min(downloaded + range_size, file_size) - 1
|
55 |
range_header = f"bytes={downloaded}-{stop_pos}"
|
56 |
+
response = _execute_request(
|
57 |
+
url, method="GET", headers={"Range": range_header}
|
58 |
+
)
|
59 |
if file_size == range_size:
|
60 |
try:
|
61 |
content_range = response.info()["Content-Range"]
|
pytube/streams.py
CHANGED
@@ -1,5 +1,4 @@
|
|
1 |
# -*- coding: utf-8 -*-
|
2 |
-
|
3 |
"""
|
4 |
This module contains a container for stream manifest data.
|
5 |
|
@@ -8,16 +7,19 @@ combined). This was referred to as ``Video`` in the legacy pytube version, but
|
|
8 |
has been renamed to accommodate DASH (which serves the audio and video
|
9 |
separately).
|
10 |
"""
|
11 |
-
|
12 |
-
from datetime import datetime
|
13 |
import logging
|
14 |
import os
|
15 |
-
from
|
|
|
|
|
|
|
|
|
16 |
from urllib.parse import parse_qs
|
17 |
|
18 |
from pytube import extract
|
19 |
from pytube import request
|
20 |
-
from pytube.helpers import safe_filename
|
|
|
21 |
from pytube.itags import get_format_profile
|
22 |
from pytube.monostate import Monostate
|
23 |
|
@@ -27,7 +29,9 @@ logger = logging.getLogger(__name__)
|
|
27 |
class Stream:
|
28 |
"""Container for stream manifest data."""
|
29 |
|
30 |
-
def __init__(
|
|
|
|
|
31 |
"""Construct a :class:`Stream <Stream>`.
|
32 |
|
33 |
:param dict stream:
|
@@ -44,7 +48,9 @@ class Stream:
|
|
44 |
self._monostate = monostate
|
45 |
|
46 |
self.url = stream["url"] # signed download url
|
47 |
-
self.itag = int(
|
|
|
|
|
48 |
|
49 |
# set type and codec info
|
50 |
|
@@ -68,8 +74,12 @@ class Stream:
|
|
68 |
itag_profile = get_format_profile(self.itag)
|
69 |
self.is_dash = itag_profile["is_dash"]
|
70 |
self.abr = itag_profile["abr"] # average bitrate (audio streams only)
|
71 |
-
self.fps = itag_profile[
|
72 |
-
|
|
|
|
|
|
|
|
|
73 |
self.is_3d = itag_profile["is_3d"]
|
74 |
self.is_hdr = itag_profile["is_hdr"]
|
75 |
self.is_live = itag_profile["is_live"]
|
@@ -167,7 +177,9 @@ class Stream:
|
|
167 |
"""
|
168 |
if self._monostate.duration and self.bitrate:
|
169 |
bits_in_byte = 8
|
170 |
-
return int(
|
|
|
|
|
171 |
|
172 |
return self.filesize
|
173 |
|
@@ -220,7 +232,9 @@ class Stream:
|
|
220 |
|
221 |
"""
|
222 |
file_path = self.get_file_path(
|
223 |
-
filename=filename,
|
|
|
|
|
224 |
)
|
225 |
|
226 |
if skip_existing and self.exists_at_path(file_path):
|
@@ -230,7 +244,9 @@ class Stream:
|
|
230 |
|
231 |
bytes_remaining = self.filesize
|
232 |
logger.debug(
|
233 |
-
"downloading (%s total bytes) file to %s",
|
|
|
|
|
234 |
)
|
235 |
|
236 |
with open(file_path, "wb") as fh:
|
@@ -257,7 +273,10 @@ class Stream:
|
|
257 |
return os.path.join(target_directory(output_path), filename)
|
258 |
|
259 |
def exists_at_path(self, file_path: str) -> bool:
|
260 |
-
return
|
|
|
|
|
|
|
261 |
|
262 |
def stream_to_buffer(self, buffer: BinaryIO) -> None:
|
263 |
"""Write the media stream to buffer
|
@@ -276,7 +295,9 @@ class Stream:
|
|
276 |
self.on_progress(chunk, buffer, bytes_remaining)
|
277 |
self.on_complete(None)
|
278 |
|
279 |
-
def on_progress(
|
|
|
|
|
280 |
"""On progress callback function.
|
281 |
|
282 |
This function writes the binary data to the file, then checks if an
|
|
|
1 |
# -*- coding: utf-8 -*-
|
|
|
2 |
"""
|
3 |
This module contains a container for stream manifest data.
|
4 |
|
|
|
7 |
has been renamed to accommodate DASH (which serves the audio and video
|
8 |
separately).
|
9 |
"""
|
|
|
|
|
10 |
import logging
|
11 |
import os
|
12 |
+
from datetime import datetime
|
13 |
+
from typing import BinaryIO
|
14 |
+
from typing import Dict
|
15 |
+
from typing import Optional
|
16 |
+
from typing import Tuple
|
17 |
from urllib.parse import parse_qs
|
18 |
|
19 |
from pytube import extract
|
20 |
from pytube import request
|
21 |
+
from pytube.helpers import safe_filename
|
22 |
+
from pytube.helpers import target_directory
|
23 |
from pytube.itags import get_format_profile
|
24 |
from pytube.monostate import Monostate
|
25 |
|
|
|
29 |
class Stream:
|
30 |
"""Container for stream manifest data."""
|
31 |
|
32 |
+
def __init__(
|
33 |
+
self, stream: Dict, player_config_args: Dict, monostate: Monostate
|
34 |
+
):
|
35 |
"""Construct a :class:`Stream <Stream>`.
|
36 |
|
37 |
:param dict stream:
|
|
|
48 |
self._monostate = monostate
|
49 |
|
50 |
self.url = stream["url"] # signed download url
|
51 |
+
self.itag = int(
|
52 |
+
stream["itag"]
|
53 |
+
) # stream format id (youtube nomenclature)
|
54 |
|
55 |
# set type and codec info
|
56 |
|
|
|
74 |
itag_profile = get_format_profile(self.itag)
|
75 |
self.is_dash = itag_profile["is_dash"]
|
76 |
self.abr = itag_profile["abr"] # average bitrate (audio streams only)
|
77 |
+
self.fps = itag_profile[
|
78 |
+
"fps"
|
79 |
+
] # frames per second (video streams only)
|
80 |
+
self.resolution = itag_profile[
|
81 |
+
"resolution"
|
82 |
+
] # resolution (e.g.: "480p")
|
83 |
self.is_3d = itag_profile["is_3d"]
|
84 |
self.is_hdr = itag_profile["is_hdr"]
|
85 |
self.is_live = itag_profile["is_live"]
|
|
|
177 |
"""
|
178 |
if self._monostate.duration and self.bitrate:
|
179 |
bits_in_byte = 8
|
180 |
+
return int(
|
181 |
+
(self._monostate.duration * self.bitrate) / bits_in_byte
|
182 |
+
)
|
183 |
|
184 |
return self.filesize
|
185 |
|
|
|
232 |
|
233 |
"""
|
234 |
file_path = self.get_file_path(
|
235 |
+
filename=filename,
|
236 |
+
output_path=output_path,
|
237 |
+
filename_prefix=filename_prefix,
|
238 |
)
|
239 |
|
240 |
if skip_existing and self.exists_at_path(file_path):
|
|
|
244 |
|
245 |
bytes_remaining = self.filesize
|
246 |
logger.debug(
|
247 |
+
"downloading (%s total bytes) file to %s",
|
248 |
+
self.filesize,
|
249 |
+
file_path,
|
250 |
)
|
251 |
|
252 |
with open(file_path, "wb") as fh:
|
|
|
273 |
return os.path.join(target_directory(output_path), filename)
|
274 |
|
275 |
def exists_at_path(self, file_path: str) -> bool:
|
276 |
+
return (
|
277 |
+
os.path.isfile(file_path)
|
278 |
+
and os.path.getsize(file_path) == self.filesize
|
279 |
+
)
|
280 |
|
281 |
def stream_to_buffer(self, buffer: BinaryIO) -> None:
|
282 |
"""Write the media stream to buffer
|
|
|
295 |
self.on_progress(chunk, buffer, bytes_remaining)
|
296 |
self.on_complete(None)
|
297 |
|
298 |
+
def on_progress(
|
299 |
+
self, chunk: bytes, file_handler: BinaryIO, bytes_remaining: int
|
300 |
+
):
|
301 |
"""On progress callback function.
|
302 |
|
303 |
This function writes the binary data to the file, then checks if an
|
setup.py
CHANGED
@@ -3,6 +3,7 @@
|
|
3 |
"""This module contains setup instructions for pytube3."""
|
4 |
import codecs
|
5 |
import os
|
|
|
6 |
from setuptools import setup
|
7 |
|
8 |
here = os.path.abspath(os.path.dirname(__file__))
|
|
|
3 |
"""This module contains setup instructions for pytube3."""
|
4 |
import codecs
|
5 |
import os
|
6 |
+
|
7 |
from setuptools import setup
|
8 |
|
9 |
here = os.path.abspath(os.path.dirname(__file__))
|
tests/conftest.py
CHANGED
@@ -1,6 +1,5 @@
|
|
1 |
# -*- coding: utf-8 -*-
|
2 |
"""Reusable dependency injected testing components."""
|
3 |
-
|
4 |
import gzip
|
5 |
import json
|
6 |
import os
|
@@ -57,7 +56,9 @@ def playlist_html():
|
|
57 |
"""Youtube playlist HTML loaded on 2020-01-25 from
|
58 |
https://www.youtube.com/playlist?list=PLzMcBGfZo4-mP7qA9cagf68V06sko5otr"""
|
59 |
file_path = os.path.join(
|
60 |
-
os.path.dirname(os.path.realpath(__file__)),
|
|
|
|
|
61 |
)
|
62 |
with gzip.open(file_path, "rb") as f:
|
63 |
return f.read().decode("utf-8")
|
@@ -68,7 +69,9 @@ def playlist_long_html():
|
|
68 |
"""Youtube playlist HTML loaded on 2020-01-25 from
|
69 |
https://www.youtube.com/playlist?list=PLzMcBGfZo4-mP7qA9cagf68V06sko5otr"""
|
70 |
file_path = os.path.join(
|
71 |
-
os.path.dirname(os.path.realpath(__file__)),
|
|
|
|
|
72 |
)
|
73 |
with gzip.open(file_path, "rb") as f:
|
74 |
return f.read().decode("utf-8")
|
|
|
1 |
# -*- coding: utf-8 -*-
|
2 |
"""Reusable dependency injected testing components."""
|
|
|
3 |
import gzip
|
4 |
import json
|
5 |
import os
|
|
|
56 |
"""Youtube playlist HTML loaded on 2020-01-25 from
|
57 |
https://www.youtube.com/playlist?list=PLzMcBGfZo4-mP7qA9cagf68V06sko5otr"""
|
58 |
file_path = os.path.join(
|
59 |
+
os.path.dirname(os.path.realpath(__file__)),
|
60 |
+
"mocks",
|
61 |
+
"playlist.html.gz",
|
62 |
)
|
63 |
with gzip.open(file_path, "rb") as f:
|
64 |
return f.read().decode("utf-8")
|
|
|
69 |
"""Youtube playlist HTML loaded on 2020-01-25 from
|
70 |
https://www.youtube.com/playlist?list=PLzMcBGfZo4-mP7qA9cagf68V06sko5otr"""
|
71 |
file_path = os.path.join(
|
72 |
+
os.path.dirname(os.path.realpath(__file__)),
|
73 |
+
"mocks",
|
74 |
+
"playlist_long.html.gz",
|
75 |
)
|
76 |
with gzip.open(file_path, "rb") as f:
|
77 |
return f.read().decode("utf-8")
|
tests/contrib/test_playlist.py
CHANGED
@@ -15,7 +15,10 @@ def test_title(request_get):
|
|
15 |
url = "https://www.fakeurl.com/playlist?list=PLS1QulWo1RIaJECMeUT4LFwJ-ghgoSH6n"
|
16 |
pl = Playlist(url)
|
17 |
pl_title = pl.title()
|
18 |
-
assert
|
|
|
|
|
|
|
19 |
|
20 |
|
21 |
@mock.patch("pytube.contrib.playlist.request.get")
|
@@ -208,7 +211,9 @@ def test_trimmed_pagination(request_get, playlist_html, playlist_long_html):
|
|
208 |
|
209 |
|
210 |
@mock.patch("pytube.contrib.playlist.request.get")
|
211 |
-
def test_trimmed_pagination_not_found(
|
|
|
|
|
212 |
url = "https://www.fakeurl.com/playlist?list=whatever"
|
213 |
request_get.side_effect = [
|
214 |
playlist_long_html,
|
|
|
15 |
url = "https://www.fakeurl.com/playlist?list=PLS1QulWo1RIaJECMeUT4LFwJ-ghgoSH6n"
|
16 |
pl = Playlist(url)
|
17 |
pl_title = pl.title()
|
18 |
+
assert (
|
19 |
+
pl_title
|
20 |
+
== "(149) Python Tutorial for Beginners (For Absolute Beginners)"
|
21 |
+
)
|
22 |
|
23 |
|
24 |
@mock.patch("pytube.contrib.playlist.request.get")
|
|
|
211 |
|
212 |
|
213 |
@mock.patch("pytube.contrib.playlist.request.get")
|
214 |
+
def test_trimmed_pagination_not_found(
|
215 |
+
request_get, playlist_html, playlist_long_html
|
216 |
+
):
|
217 |
url = "https://www.fakeurl.com/playlist?list=whatever"
|
218 |
request_get.side_effect = [
|
219 |
playlist_long_html,
|
tests/generate_fixture.py
CHANGED
@@ -1,16 +1,15 @@
|
|
1 |
#!/usr/bin/env python3
|
2 |
-
|
3 |
# flake8: noqa: E402
|
4 |
-
|
5 |
-
from os import path
|
6 |
-
import sys
|
7 |
import json
|
|
|
|
|
|
|
|
|
8 |
|
9 |
currentdir = path.dirname(path.realpath(__file__))
|
10 |
parentdir = path.dirname(currentdir)
|
11 |
sys.path.append(parentdir)
|
12 |
|
13 |
-
from pytube import YouTube
|
14 |
|
15 |
yt = YouTube(sys.argv[1], defer_prefetch_init=True)
|
16 |
yt.prefetch()
|
|
|
1 |
#!/usr/bin/env python3
|
|
|
2 |
# flake8: noqa: E402
|
|
|
|
|
|
|
3 |
import json
|
4 |
+
import sys
|
5 |
+
from os import path
|
6 |
+
|
7 |
+
from pytube import YouTube
|
8 |
|
9 |
currentdir = path.dirname(path.realpath(__file__))
|
10 |
parentdir = path.dirname(currentdir)
|
11 |
sys.path.append(parentdir)
|
12 |
|
|
|
13 |
|
14 |
yt = YouTube(sys.argv[1], defer_prefetch_init=True)
|
15 |
yt.prefetch()
|
tests/test_captions.py
CHANGED
@@ -1,10 +1,14 @@
|
|
1 |
# -*- coding: utf-8 -*-
|
2 |
from unittest import mock
|
3 |
-
from unittest.mock import
|
|
|
|
|
4 |
|
5 |
import pytest
|
6 |
|
7 |
-
from pytube import Caption
|
|
|
|
|
8 |
|
9 |
|
10 |
def test_float_to_srt_time_format():
|
@@ -60,10 +64,17 @@ def test_download(srt):
|
|
60 |
with patch("builtins.open", open_mock):
|
61 |
srt.return_value = ""
|
62 |
caption = Caption(
|
63 |
-
{
|
|
|
|
|
|
|
|
|
64 |
)
|
65 |
caption.download("title")
|
66 |
-
assert
|
|
|
|
|
|
|
67 |
|
68 |
|
69 |
@mock.patch("pytube.captions.Caption.generate_srt_captions")
|
@@ -72,10 +83,17 @@ def test_download_with_prefix(srt):
|
|
72 |
with patch("builtins.open", open_mock):
|
73 |
srt.return_value = ""
|
74 |
caption = Caption(
|
75 |
-
{
|
|
|
|
|
|
|
|
|
76 |
)
|
77 |
caption.download("title", filename_prefix="1 ")
|
78 |
-
assert
|
|
|
|
|
|
|
79 |
|
80 |
|
81 |
@mock.patch("pytube.captions.Caption.generate_srt_captions")
|
@@ -85,7 +103,11 @@ def test_download_with_output_path(srt):
|
|
85 |
with patch("builtins.open", open_mock):
|
86 |
srt.return_value = ""
|
87 |
caption = Caption(
|
88 |
-
{
|
|
|
|
|
|
|
|
|
89 |
)
|
90 |
file_path = caption.download("title", output_path="blah")
|
91 |
assert file_path == "/target/title (en).srt"
|
@@ -98,10 +120,17 @@ def test_download_xml_and_trim_extension(xml):
|
|
98 |
with patch("builtins.open", open_mock):
|
99 |
xml.return_value = ""
|
100 |
caption = Caption(
|
101 |
-
{
|
|
|
|
|
|
|
|
|
102 |
)
|
103 |
caption.download("title.xml", srt=False)
|
104 |
-
assert
|
|
|
|
|
|
|
105 |
|
106 |
|
107 |
def test_repr():
|
|
|
1 |
# -*- coding: utf-8 -*-
|
2 |
from unittest import mock
|
3 |
+
from unittest.mock import MagicMock
|
4 |
+
from unittest.mock import mock_open
|
5 |
+
from unittest.mock import patch
|
6 |
|
7 |
import pytest
|
8 |
|
9 |
+
from pytube import Caption
|
10 |
+
from pytube import CaptionQuery
|
11 |
+
from pytube import captions
|
12 |
|
13 |
|
14 |
def test_float_to_srt_time_format():
|
|
|
64 |
with patch("builtins.open", open_mock):
|
65 |
srt.return_value = ""
|
66 |
caption = Caption(
|
67 |
+
{
|
68 |
+
"url": "url1",
|
69 |
+
"name": {"simpleText": "name1"},
|
70 |
+
"languageCode": "en",
|
71 |
+
}
|
72 |
)
|
73 |
caption.download("title")
|
74 |
+
assert (
|
75 |
+
open_mock.call_args_list[0][0][0].split("/")[-1]
|
76 |
+
== "title (en).srt"
|
77 |
+
)
|
78 |
|
79 |
|
80 |
@mock.patch("pytube.captions.Caption.generate_srt_captions")
|
|
|
83 |
with patch("builtins.open", open_mock):
|
84 |
srt.return_value = ""
|
85 |
caption = Caption(
|
86 |
+
{
|
87 |
+
"url": "url1",
|
88 |
+
"name": {"simpleText": "name1"},
|
89 |
+
"languageCode": "en",
|
90 |
+
}
|
91 |
)
|
92 |
caption.download("title", filename_prefix="1 ")
|
93 |
+
assert (
|
94 |
+
open_mock.call_args_list[0][0][0].split("/")[-1]
|
95 |
+
== "1 title (en).srt"
|
96 |
+
)
|
97 |
|
98 |
|
99 |
@mock.patch("pytube.captions.Caption.generate_srt_captions")
|
|
|
103 |
with patch("builtins.open", open_mock):
|
104 |
srt.return_value = ""
|
105 |
caption = Caption(
|
106 |
+
{
|
107 |
+
"url": "url1",
|
108 |
+
"name": {"simpleText": "name1"},
|
109 |
+
"languageCode": "en",
|
110 |
+
}
|
111 |
)
|
112 |
file_path = caption.download("title", output_path="blah")
|
113 |
assert file_path == "/target/title (en).srt"
|
|
|
120 |
with patch("builtins.open", open_mock):
|
121 |
xml.return_value = ""
|
122 |
caption = Caption(
|
123 |
+
{
|
124 |
+
"url": "url1",
|
125 |
+
"name": {"simpleText": "name1"},
|
126 |
+
"languageCode": "en",
|
127 |
+
}
|
128 |
)
|
129 |
caption.download("title.xml", srt=False)
|
130 |
+
assert (
|
131 |
+
open_mock.call_args_list[0][0][0].split("/")[-1]
|
132 |
+
== "title (en).xml"
|
133 |
+
)
|
134 |
|
135 |
|
136 |
def test_repr():
|
tests/test_cli.py
CHANGED
@@ -1,11 +1,15 @@
|
|
1 |
# -*- coding: utf-8 -*-
|
2 |
import argparse
|
3 |
from unittest import mock
|
4 |
-
from unittest.mock import MagicMock
|
|
|
5 |
|
6 |
import pytest
|
7 |
|
8 |
-
from pytube import
|
|
|
|
|
|
|
9 |
from pytube.exceptions import PytubeError
|
10 |
|
11 |
parse_args = cli._parse_args
|
@@ -179,7 +183,9 @@ def test_main_logging_setup(setup_logger):
|
|
179 |
@mock.patch("pytube.cli.YouTube", return_value=None)
|
180 |
def test_main_download_by_itag(youtube):
|
181 |
parser = argparse.ArgumentParser()
|
182 |
-
args = parse_args(
|
|
|
|
|
183 |
cli._parse_args = MagicMock(return_value=args)
|
184 |
cli.download_by_itag = MagicMock()
|
185 |
cli.main()
|
@@ -191,7 +197,8 @@ def test_main_download_by_itag(youtube):
|
|
191 |
def test_main_build_playback_report(youtube):
|
192 |
parser = argparse.ArgumentParser()
|
193 |
args = parse_args(
|
194 |
-
parser,
|
|
|
195 |
)
|
196 |
cli._parse_args = MagicMock(return_value=args)
|
197 |
cli.build_playback_report = MagicMock()
|
@@ -226,7 +233,9 @@ def test_main_download_caption(youtube):
|
|
226 |
@mock.patch("pytube.cli.download_by_resolution")
|
227 |
def test_download_by_resolution_flag(youtube, download_by_resolution):
|
228 |
parser = argparse.ArgumentParser()
|
229 |
-
args = parse_args(
|
|
|
|
|
230 |
cli._parse_args = MagicMock(return_value=args)
|
231 |
cli.main()
|
232 |
youtube.assert_called()
|
@@ -284,7 +293,9 @@ def test_download_by_resolution(download, stream, stream_query, youtube):
|
|
284 |
stream_query.get_by_resolution.return_value = stream
|
285 |
youtube.streams = stream_query
|
286 |
# When
|
287 |
-
cli.download_by_resolution(
|
|
|
|
|
288 |
# Then
|
289 |
download.assert_called_with(stream, target="test_target")
|
290 |
|
@@ -319,12 +330,16 @@ def test_download_stream_file_exists(stream, capsys):
|
|
319 |
def test_perform_args_should_ffmpeg_process(ffmpeg_process, youtube):
|
320 |
# Given
|
321 |
parser = argparse.ArgumentParser()
|
322 |
-
args = parse_args(
|
|
|
|
|
323 |
cli._parse_args = MagicMock(return_value=args)
|
324 |
# When
|
325 |
cli._perform_args_on_youtube(youtube, args)
|
326 |
# Then
|
327 |
-
ffmpeg_process.assert_called_with(
|
|
|
|
|
328 |
|
329 |
|
330 |
@mock.patch("pytube.cli.YouTube")
|
@@ -335,7 +350,9 @@ def test_ffmpeg_process_best_should_download(_ffmpeg_downloader, youtube):
|
|
335 |
streams = MagicMock()
|
336 |
youtube.streams = streams
|
337 |
video_stream = MagicMock()
|
338 |
-
streams.filter.return_value.order_by.return_value.last.return_value =
|
|
|
|
|
339 |
audio_stream = MagicMock()
|
340 |
streams.get_audio_only.return_value = audio_stream
|
341 |
# When
|
@@ -367,7 +384,9 @@ def test_ffmpeg_process_res_should_download(_ffmpeg_downloader, youtube):
|
|
367 |
|
368 |
@mock.patch("pytube.cli.YouTube")
|
369 |
@mock.patch("pytube.cli._ffmpeg_downloader")
|
370 |
-
def test_ffmpeg_process_res_none_should_not_download(
|
|
|
|
|
371 |
# Given
|
372 |
target = "/target"
|
373 |
streams = MagicMock()
|
@@ -392,7 +411,9 @@ def test_ffmpeg_process_audio_none_should_fallback_download(
|
|
392 |
streams = MagicMock()
|
393 |
youtube.streams = streams
|
394 |
stream = MagicMock()
|
395 |
-
streams.filter.return_value.order_by.return_value.last.return_value =
|
|
|
|
|
396 |
streams.get_audio_only.return_value = None
|
397 |
# When
|
398 |
cli.ffmpeg_process(youtube, "best", target)
|
@@ -404,7 +425,9 @@ def test_ffmpeg_process_audio_none_should_fallback_download(
|
|
404 |
|
405 |
@mock.patch("pytube.cli.YouTube")
|
406 |
@mock.patch("pytube.cli._ffmpeg_downloader")
|
407 |
-
def test_ffmpeg_process_audio_fallback_none_should_exit(
|
|
|
|
|
408 |
# Given
|
409 |
target = "/target"
|
410 |
streams = MagicMock()
|
@@ -463,7 +486,9 @@ def test_ffmpeg_downloader(unique_name, download, run, unlink):
|
|
463 |
def test_download_audio_args(youtube, download_audio):
|
464 |
# Given
|
465 |
parser = argparse.ArgumentParser()
|
466 |
-
args = parse_args(
|
|
|
|
|
467 |
cli._parse_args = MagicMock(return_value=args)
|
468 |
# When
|
469 |
cli.main()
|
@@ -515,10 +540,16 @@ def test_perform_args_on_youtube(youtube):
|
|
515 |
|
516 |
@mock.patch("pytube.cli.os.path.exists", return_value=False)
|
517 |
def test_unique_name(path_exists):
|
518 |
-
assert
|
|
|
|
|
|
|
519 |
|
520 |
|
521 |
@mock.patch("pytube.cli.os.path.exists")
|
522 |
def test_unique_name_counter(path_exists):
|
523 |
path_exists.side_effect = [True, False]
|
524 |
-
assert
|
|
|
|
|
|
|
|
1 |
# -*- coding: utf-8 -*-
|
2 |
import argparse
|
3 |
from unittest import mock
|
4 |
+
from unittest.mock import MagicMock
|
5 |
+
from unittest.mock import patch
|
6 |
|
7 |
import pytest
|
8 |
|
9 |
+
from pytube import Caption
|
10 |
+
from pytube import CaptionQuery
|
11 |
+
from pytube import cli
|
12 |
+
from pytube import StreamQuery
|
13 |
from pytube.exceptions import PytubeError
|
14 |
|
15 |
parse_args = cli._parse_args
|
|
|
183 |
@mock.patch("pytube.cli.YouTube", return_value=None)
|
184 |
def test_main_download_by_itag(youtube):
|
185 |
parser = argparse.ArgumentParser()
|
186 |
+
args = parse_args(
|
187 |
+
parser, ["http://youtube.com/watch?v=9bZkp7q19f0", "--itag=10"]
|
188 |
+
)
|
189 |
cli._parse_args = MagicMock(return_value=args)
|
190 |
cli.download_by_itag = MagicMock()
|
191 |
cli.main()
|
|
|
197 |
def test_main_build_playback_report(youtube):
|
198 |
parser = argparse.ArgumentParser()
|
199 |
args = parse_args(
|
200 |
+
parser,
|
201 |
+
["http://youtube.com/watch?v=9bZkp7q19f0", "--build-playback-report"],
|
202 |
)
|
203 |
cli._parse_args = MagicMock(return_value=args)
|
204 |
cli.build_playback_report = MagicMock()
|
|
|
233 |
@mock.patch("pytube.cli.download_by_resolution")
|
234 |
def test_download_by_resolution_flag(youtube, download_by_resolution):
|
235 |
parser = argparse.ArgumentParser()
|
236 |
+
args = parse_args(
|
237 |
+
parser, ["http://youtube.com/watch?v=9bZkp7q19f0", "-r", "320p"]
|
238 |
+
)
|
239 |
cli._parse_args = MagicMock(return_value=args)
|
240 |
cli.main()
|
241 |
youtube.assert_called()
|
|
|
293 |
stream_query.get_by_resolution.return_value = stream
|
294 |
youtube.streams = stream_query
|
295 |
# When
|
296 |
+
cli.download_by_resolution(
|
297 |
+
youtube=youtube, resolution="320p", target="test_target"
|
298 |
+
)
|
299 |
# Then
|
300 |
download.assert_called_with(stream, target="test_target")
|
301 |
|
|
|
330 |
def test_perform_args_should_ffmpeg_process(ffmpeg_process, youtube):
|
331 |
# Given
|
332 |
parser = argparse.ArgumentParser()
|
333 |
+
args = parse_args(
|
334 |
+
parser, ["http://youtube.com/watch?v=9bZkp7q19f0", "-f", "best"]
|
335 |
+
)
|
336 |
cli._parse_args = MagicMock(return_value=args)
|
337 |
# When
|
338 |
cli._perform_args_on_youtube(youtube, args)
|
339 |
# Then
|
340 |
+
ffmpeg_process.assert_called_with(
|
341 |
+
youtube=youtube, resolution="best", target=None
|
342 |
+
)
|
343 |
|
344 |
|
345 |
@mock.patch("pytube.cli.YouTube")
|
|
|
350 |
streams = MagicMock()
|
351 |
youtube.streams = streams
|
352 |
video_stream = MagicMock()
|
353 |
+
streams.filter.return_value.order_by.return_value.last.return_value = (
|
354 |
+
video_stream
|
355 |
+
)
|
356 |
audio_stream = MagicMock()
|
357 |
streams.get_audio_only.return_value = audio_stream
|
358 |
# When
|
|
|
384 |
|
385 |
@mock.patch("pytube.cli.YouTube")
|
386 |
@mock.patch("pytube.cli._ffmpeg_downloader")
|
387 |
+
def test_ffmpeg_process_res_none_should_not_download(
|
388 |
+
_ffmpeg_downloader, youtube
|
389 |
+
):
|
390 |
# Given
|
391 |
target = "/target"
|
392 |
streams = MagicMock()
|
|
|
411 |
streams = MagicMock()
|
412 |
youtube.streams = streams
|
413 |
stream = MagicMock()
|
414 |
+
streams.filter.return_value.order_by.return_value.last.return_value = (
|
415 |
+
stream
|
416 |
+
)
|
417 |
streams.get_audio_only.return_value = None
|
418 |
# When
|
419 |
cli.ffmpeg_process(youtube, "best", target)
|
|
|
425 |
|
426 |
@mock.patch("pytube.cli.YouTube")
|
427 |
@mock.patch("pytube.cli._ffmpeg_downloader")
|
428 |
+
def test_ffmpeg_process_audio_fallback_none_should_exit(
|
429 |
+
_ffmpeg_downloader, youtube
|
430 |
+
):
|
431 |
# Given
|
432 |
target = "/target"
|
433 |
streams = MagicMock()
|
|
|
486 |
def test_download_audio_args(youtube, download_audio):
|
487 |
# Given
|
488 |
parser = argparse.ArgumentParser()
|
489 |
+
args = parse_args(
|
490 |
+
parser, ["http://youtube.com/watch?v=9bZkp7q19f0", "-a", "mp4"]
|
491 |
+
)
|
492 |
cli._parse_args = MagicMock(return_value=args)
|
493 |
# When
|
494 |
cli.main()
|
|
|
540 |
|
541 |
@mock.patch("pytube.cli.os.path.exists", return_value=False)
|
542 |
def test_unique_name(path_exists):
|
543 |
+
assert (
|
544 |
+
cli._unique_name("base", "subtype", "video", "target")
|
545 |
+
== "base_video_0"
|
546 |
+
)
|
547 |
|
548 |
|
549 |
@mock.patch("pytube.cli.os.path.exists")
|
550 |
def test_unique_name_counter(path_exists):
|
551 |
path_exists.side_effect = [True, False]
|
552 |
+
assert (
|
553 |
+
cli._unique_name("base", "subtype", "video", "target")
|
554 |
+
== "base_video_1"
|
555 |
+
)
|
tests/test_exceptions.py
CHANGED
@@ -1,5 +1,7 @@
|
|
1 |
# -*- coding: utf-8 -*-
|
2 |
-
from pytube.exceptions import
|
|
|
|
|
3 |
|
4 |
|
5 |
def test_video_unavailable():
|
|
|
1 |
# -*- coding: utf-8 -*-
|
2 |
+
from pytube.exceptions import LiveStreamError
|
3 |
+
from pytube.exceptions import RegexMatchError
|
4 |
+
from pytube.exceptions import VideoUnavailable
|
5 |
|
6 |
|
7 |
def test_video_unavailable():
|
tests/test_extract.py
CHANGED
@@ -2,8 +2,8 @@
|
|
2 |
"""Unit tests for the :module:`extract <extract>` module."""
|
3 |
import pytest
|
4 |
|
5 |
-
from pytube.exceptions import RegexMatchError
|
6 |
from pytube import extract
|
|
|
7 |
|
8 |
|
9 |
def test_extract_video_id():
|
@@ -25,7 +25,8 @@ def test_info_url(age_restricted):
|
|
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,
|
|
|
29 |
)
|
30 |
expected = (
|
31 |
"https://youtube.com/get_video_info?video_id=9bZkp7q19f0&el=%24el"
|
@@ -36,7 +37,9 @@ def test_info_url_age_restricted(cipher_signature):
|
|
36 |
|
37 |
|
38 |
def test_js_url(cipher_signature):
|
39 |
-
expected =
|
|
|
|
|
40 |
result = extract.js_url(cipher_signature.watch_html)
|
41 |
assert expected == result
|
42 |
|
@@ -69,7 +72,9 @@ def test_get_vid_desc(cipher_signature):
|
|
69 |
|
70 |
|
71 |
def test_mime_type_codec():
|
72 |
-
mime_type, mime_subtype = extract.mime_type_codec(
|
|
|
|
|
73 |
assert mime_type == "audio/webm"
|
74 |
assert mime_subtype == ["opus"]
|
75 |
|
|
|
2 |
"""Unit tests for the :module:`extract <extract>` module."""
|
3 |
import pytest
|
4 |
|
|
|
5 |
from pytube import extract
|
6 |
+
from pytube.exceptions import RegexMatchError
|
7 |
|
8 |
|
9 |
def test_extract_video_id():
|
|
|
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,
|
29 |
+
watch_url=cipher_signature.watch_url,
|
30 |
)
|
31 |
expected = (
|
32 |
"https://youtube.com/get_video_info?video_id=9bZkp7q19f0&el=%24el"
|
|
|
37 |
|
38 |
|
39 |
def test_js_url(cipher_signature):
|
40 |
+
expected = (
|
41 |
+
"https://youtube.com/yts/jsbin/player_ias-vflWQEEag/en_US/base.js"
|
42 |
+
)
|
43 |
result = extract.js_url(cipher_signature.watch_html)
|
44 |
assert expected == result
|
45 |
|
|
|
72 |
|
73 |
|
74 |
def test_mime_type_codec():
|
75 |
+
mime_type, mime_subtype = extract.mime_type_codec(
|
76 |
+
'audio/webm; codecs="opus"'
|
77 |
+
)
|
78 |
assert mime_type == "audio/webm"
|
79 |
assert mime_subtype == ["opus"]
|
80 |
|
tests/test_helpers.py
CHANGED
@@ -5,7 +5,10 @@ import pytest
|
|
5 |
|
6 |
from pytube import helpers
|
7 |
from pytube.exceptions import RegexMatchError
|
8 |
-
from pytube.helpers import
|
|
|
|
|
|
|
9 |
|
10 |
|
11 |
def test_regex_search_no_match():
|
|
|
5 |
|
6 |
from pytube import helpers
|
7 |
from pytube.exceptions import RegexMatchError
|
8 |
+
from pytube.helpers import cache
|
9 |
+
from pytube.helpers import deprecated
|
10 |
+
from pytube.helpers import setup_logger
|
11 |
+
from pytube.helpers import target_directory
|
12 |
|
13 |
|
14 |
def test_regex_search_no_match():
|
tests/test_query.py
CHANGED
@@ -59,7 +59,8 @@ def test_order_by(cipher_signature):
|
|
59 |
:class:`Stream <Stream>` instances in the expected order.
|
60 |
"""
|
61 |
itags = [
|
62 |
-
s.itag
|
|
|
63 |
]
|
64 |
assert itags == [140, 249, 250, 251]
|
65 |
|
@@ -71,7 +72,9 @@ def test_order_by_descending(cipher_signature):
|
|
71 |
# numerical values
|
72 |
itags = [
|
73 |
s.itag
|
74 |
-
for s in cipher_signature.streams.filter(type="audio")
|
|
|
|
|
75 |
]
|
76 |
assert itags == [251, 250, 249, 140]
|
77 |
|
@@ -93,7 +96,9 @@ def test_order_by_ascending(cipher_signature):
|
|
93 |
# numerical values
|
94 |
itags = [
|
95 |
s.itag
|
96 |
-
for s in cipher_signature.streams.filter(type="audio")
|
|
|
|
|
97 |
]
|
98 |
assert itags == [140, 249, 250, 251]
|
99 |
|
@@ -101,7 +106,9 @@ def test_order_by_ascending(cipher_signature):
|
|
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")
|
|
|
|
|
105 |
]
|
106 |
assert mime_types == ["video/mp4", "video/mp4", "video/webm"]
|
107 |
|
|
|
59 |
:class:`Stream <Stream>` instances in the expected order.
|
60 |
"""
|
61 |
itags = [
|
62 |
+
s.itag
|
63 |
+
for s in cipher_signature.streams.filter(type="audio").order_by("itag")
|
64 |
]
|
65 |
assert itags == [140, 249, 250, 251]
|
66 |
|
|
|
72 |
# numerical values
|
73 |
itags = [
|
74 |
s.itag
|
75 |
+
for s in cipher_signature.streams.filter(type="audio")
|
76 |
+
.order_by("itag")
|
77 |
+
.desc()
|
78 |
]
|
79 |
assert itags == [251, 250, 249, 140]
|
80 |
|
|
|
96 |
# numerical values
|
97 |
itags = [
|
98 |
s.itag
|
99 |
+
for s in cipher_signature.streams.filter(type="audio")
|
100 |
+
.order_by("itag")
|
101 |
+
.asc()
|
102 |
]
|
103 |
assert itags == [140, 249, 250, 251]
|
104 |
|
|
|
106 |
def test_order_by_non_numerical_ascending(cipher_signature):
|
107 |
mime_types = [
|
108 |
s.mime_type
|
109 |
+
for s in cipher_signature.streams.filter(res="360p")
|
110 |
+
.order_by("mime_type")
|
111 |
+
.asc()
|
112 |
]
|
113 |
assert mime_types == ["video/mp4", "video/mp4", "video/webm"]
|
114 |
|
tests/test_streams.py
CHANGED
@@ -6,7 +6,8 @@ from unittest import mock
|
|
6 |
from unittest.mock import MagicMock
|
7 |
|
8 |
from pytube import request
|
9 |
-
from pytube import Stream
|
|
|
10 |
|
11 |
|
12 |
@mock.patch("pytube.streams.request")
|
@@ -61,7 +62,9 @@ def test_title(cipher_signature):
|
|
61 |
|
62 |
|
63 |
def test_expiration(cipher_signature):
|
64 |
-
assert cipher_signature.streams[0].expiration == datetime(
|
|
|
|
|
65 |
|
66 |
|
67 |
def test_caption_tracks(presigned_video):
|
|
|
6 |
from unittest.mock import MagicMock
|
7 |
|
8 |
from pytube import request
|
9 |
+
from pytube import Stream
|
10 |
+
from pytube import streams
|
11 |
|
12 |
|
13 |
@mock.patch("pytube.streams.request")
|
|
|
62 |
|
63 |
|
64 |
def test_expiration(cipher_signature):
|
65 |
+
assert cipher_signature.streams[0].expiration == datetime(
|
66 |
+
2020, 1, 16, 5, 12, 5
|
67 |
+
)
|
68 |
|
69 |
|
70 |
def test_caption_tracks(presigned_video):
|