Merge remote-tracking branch 'repo-a/master'
Browse files* repo-a/master: (289 commits)
update callback documentation
gitter chat link
update pycharm dictionary
update Pipfile.lock
version bump
expiration in UTC
stream test
stream expiration property
better defaults, avoid unnecessary head call
version bump
added test_stream_to_buffer
docstrings and linting
multi range file streaming
update makefile
Update install.rst
update README
added tests
added test
fixes for logging setup
print cli message if file already downloaded
...
This view is limited to 50 files because it contains too many changes.
See raw diff
- .coveragerc +2 -0
- .deepsource.toml +16 -0
- .flake8 +5 -0
- .gitattributes +3 -0
- .gitignore +99 -16
- .idea/dictionaries/haroldmartin.xml +49 -0
- .idea/inspectionProfiles/profiles_settings.xml +6 -0
- .idea/misc.xml +4 -0
- .idea/vcs.xml +6 -0
- .readthedocs.yml +19 -0
- .travis.yml +6 -9
- CODE_OF_CONDUCT.md +0 -46
- MANIFEST.in +1 -1
- Makefile +31 -3
- Pipfile +30 -14
- Pipfile.lock +597 -204
- README.md +137 -62
- docs/api.rst +0 -6
- docs/conf.py +7 -7
- docs/index.rst +9 -15
- docs/requirements.txt +1 -0
- docs/user/install.rst +4 -4
- images/Github Social.sketch +0 -0
- images/pytube.png +0 -0
- pytube/__init__.py +9 -13
- pytube/__main__.py +137 -137
- pytube/captions.py +86 -34
- pytube/cipher.py +118 -110
- pytube/cli.py +381 -95
- pytube/compat.py +0 -70
- pytube/contrib/playlist.py +187 -130
- pytube/exceptions.py +34 -16
- pytube/extract.py +202 -83
- pytube/helpers.py +131 -86
- pytube/itags.py +120 -91
- pytube/logging.py +0 -25
- pytube/mixins.py +0 -101
- pytube/monostate.py +52 -0
- pytube/query.py +171 -61
- pytube/request.py +78 -36
- pytube/streams.py +126 -134
- pytube/version.py +6 -0
- setup.cfg +0 -23
- setup.py +41 -75
- tests/conftest.py +33 -12
- tests/contrib/__pycache__/tmpgekc8jvs +0 -0
- tests/contrib/test_playlist.py +214 -4
- tests/generate_fixture.py +28 -0
- tests/mocks/playlist.html.gz +0 -0
- tests/mocks/playlist_long.html.gz +0 -0
.coveragerc
ADDED
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
1 |
+
[run]
|
2 |
+
source = pytube
|
.deepsource.toml
ADDED
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
version = 1
|
2 |
+
|
3 |
+
test_patterns = [
|
4 |
+
"tests/**"
|
5 |
+
]
|
6 |
+
|
7 |
+
exclude_patterns = [
|
8 |
+
"setup.py"
|
9 |
+
]
|
10 |
+
|
11 |
+
[[analyzers]]
|
12 |
+
name = "python"
|
13 |
+
enabled = true
|
14 |
+
|
15 |
+
[analyzers.meta]
|
16 |
+
runtime_version = "3.x.x"
|
.flake8
ADDED
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
[flake8]
|
2 |
+
ignore = E231,E203,W503,Q000,WPS111,WPS305,WPS348,WPS602,D400,DAR201,S101,DAR101,C812,D104,I001,WPS306,WPS214,D401,WPS229,WPS420,WPS230,WPS414,WPS114,WPS226,WPS442,C819,WPS601,T001,RST304,WPS410,WPS428,A003,A002,I003,WPS221,WPS326,WPS201,S405,DAR301,WPS210,WPS202,WPS213,WPS301,P103,WPS407,WPS432,WPS211,S314,S310,S001,IF100,PT001
|
3 |
+
max-line-length = 95
|
4 |
+
|
5 |
+
[isort]
|
.gitattributes
CHANGED
@@ -1 +1,4 @@
|
|
1 |
tests/mock_data/* linguist-vendored
|
|
|
|
|
|
|
|
1 |
tests/mock_data/* linguist-vendored
|
2 |
+
/docs export-ignore
|
3 |
+
.travis.yml export-ignore
|
4 |
+
*.sh text eol=lf
|
.gitignore
CHANGED
@@ -1,4 +1,10 @@
|
|
1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
2 |
|
3 |
# Temp files
|
4 |
*~
|
@@ -7,18 +13,52 @@
|
|
7 |
\#*
|
8 |
.#*
|
9 |
*#
|
10 |
-
dist
|
11 |
-
.DS_Store
|
12 |
|
13 |
-
#
|
14 |
-
|
15 |
-
|
16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
17 |
*.egg
|
18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
19 |
|
20 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
21 |
.pytest_cache/
|
|
|
|
|
|
|
|
|
|
|
22 |
|
23 |
# Debian Files
|
24 |
debian/files
|
@@ -30,8 +70,6 @@ doc/_build
|
|
30 |
# Generated man page
|
31 |
doc/aws_hostname.1
|
32 |
|
33 |
-
.coverage
|
34 |
-
.cache
|
35 |
_run.py
|
36 |
_devfiles/*
|
37 |
|
@@ -41,9 +79,54 @@ _templates
|
|
41 |
_autosummary
|
42 |
.pytest_cache*
|
43 |
|
44 |
-
# IDE Files
|
45 |
-
.idea/
|
46 |
-
#Pycharm stuff
|
47 |
-
.idea/*
|
48 |
-
|
49 |
.vscode/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Byte-compiled / optimized / DLL files
|
2 |
+
__pycache__/
|
3 |
+
*.py[cod]
|
4 |
+
*$py.class
|
5 |
+
|
6 |
+
# C extensions
|
7 |
+
*.so
|
8 |
|
9 |
# Temp files
|
10 |
*~
|
|
|
13 |
\#*
|
14 |
.#*
|
15 |
*#
|
|
|
|
|
16 |
|
17 |
+
# Distribution / packaging
|
18 |
+
.Python
|
19 |
+
build/
|
20 |
+
develop-eggs/
|
21 |
+
dist/
|
22 |
+
downloads/
|
23 |
+
eggs/
|
24 |
+
.eggs/
|
25 |
+
lib/
|
26 |
+
lib64/
|
27 |
+
parts/
|
28 |
+
sdist/
|
29 |
+
var/
|
30 |
+
wheels/
|
31 |
+
*.egg-info/
|
32 |
+
.installed.cfg
|
33 |
*.egg
|
34 |
+
MANIFEST
|
35 |
+
|
36 |
+
# PyInstaller
|
37 |
+
# Usually these files are written by a python script from a template
|
38 |
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
39 |
+
*.manifest
|
40 |
+
*.spec
|
41 |
|
42 |
+
# Installer logs
|
43 |
+
pip-log.txt
|
44 |
+
pip-delete-this-directory.txt
|
45 |
+
|
46 |
+
# Unit test / coverage reports
|
47 |
+
htmlcov/
|
48 |
+
.tox/
|
49 |
+
.coverage
|
50 |
+
.coverage.*
|
51 |
+
.cache
|
52 |
+
nosetests.xml
|
53 |
+
coverage.xml
|
54 |
+
*.cover
|
55 |
+
.hypothesis/
|
56 |
.pytest_cache/
|
57 |
+
*.mp4
|
58 |
+
|
59 |
+
# Performance profiling
|
60 |
+
prof/
|
61 |
+
*.cprof
|
62 |
|
63 |
# Debian Files
|
64 |
debian/files
|
|
|
70 |
# Generated man page
|
71 |
doc/aws_hostname.1
|
72 |
|
|
|
|
|
73 |
_run.py
|
74 |
_devfiles/*
|
75 |
|
|
|
79 |
_autosummary
|
80 |
.pytest_cache*
|
81 |
|
|
|
|
|
|
|
|
|
|
|
82 |
.vscode/
|
83 |
+
|
84 |
+
# mkdocs documentation
|
85 |
+
/site
|
86 |
+
|
87 |
+
# mypy
|
88 |
+
.mypy_cache/
|
89 |
+
|
90 |
+
# Mac
|
91 |
+
.DS_Store
|
92 |
+
.AppleDouble
|
93 |
+
.LSOverride
|
94 |
+
|
95 |
+
# Icon must end with two \r
|
96 |
+
Icon?
|
97 |
+
Icon
|
98 |
+
|
99 |
+
|
100 |
+
# Thumbnails
|
101 |
+
._*
|
102 |
+
|
103 |
+
# Files that might appear in the root of a volume
|
104 |
+
.DocumentRevisions-V100
|
105 |
+
.fseventsd
|
106 |
+
.Spotlight-V100
|
107 |
+
.TemporaryItems
|
108 |
+
.Trashes
|
109 |
+
.VolumeIcon.icns
|
110 |
+
.com.apple.timemachine.donotpresent
|
111 |
+
|
112 |
+
# Directories potentially created on remote AFP share
|
113 |
+
.AppleDB
|
114 |
+
.AppleDesktop
|
115 |
+
Network Trash Folder
|
116 |
+
Temporary Items
|
117 |
+
.apdisk
|
118 |
+
|
119 |
+
.dropbox
|
120 |
+
|
121 |
+
# Generated
|
122 |
+
test/**/*.xml
|
123 |
+
/*.gv
|
124 |
+
/*.dot
|
125 |
+
/*.xml
|
126 |
+
|
127 |
+
# PyCharm
|
128 |
+
.idea/workspace.xml
|
129 |
+
.idea/usage.statistics.xml
|
130 |
+
.idea/tasks.xml
|
131 |
+
.idea/modules.xml
|
132 |
+
.idea/*.iml
|
.idea/dictionaries/haroldmartin.xml
ADDED
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<component name="ProjectDictionaryState">
|
2 |
+
<dictionary name="haroldmartin">
|
3 |
+
<words>
|
4 |
+
<w>acodec</w>
|
5 |
+
<w>akamaized</w>
|
6 |
+
<w>bitrate</w>
|
7 |
+
<w>capsys</w>
|
8 |
+
<w>descr</w>
|
9 |
+
<w>descramble</w>
|
10 |
+
<w>descrambler</w>
|
11 |
+
<w>descrambles</w>
|
12 |
+
<w>descrambling</w>
|
13 |
+
<w>eurl</w>
|
14 |
+
<w>ffmpeg</w>
|
15 |
+
<w>ficano</w>
|
16 |
+
<w>filenames</w>
|
17 |
+
<w>filesize</w>
|
18 |
+
<w>filetype</w>
|
19 |
+
<w>gangnam</w>
|
20 |
+
<w>itag</w>
|
21 |
+
<w>itags</w>
|
22 |
+
<w>lsig</w>
|
23 |
+
<w>maxresdefault</w>
|
24 |
+
<w>meth</w>
|
25 |
+
<w>monostate</w>
|
26 |
+
<w>mypy</w>
|
27 |
+
<w>nficano</w>
|
28 |
+
<w>noqa</w>
|
29 |
+
<w>noreorder</w>
|
30 |
+
<w>nosec</w>
|
31 |
+
<w>ntfs</w>
|
32 |
+
<w>prog</w>
|
33 |
+
<w>pylint</w>
|
34 |
+
<w>pytube</w>
|
35 |
+
<w>recompiles</w>
|
36 |
+
<w>samp</w>
|
37 |
+
<w>scodecs</w>
|
38 |
+
<w>streamability</w>
|
39 |
+
<w>stty</w>
|
40 |
+
<w>tracklist</w>
|
41 |
+
<w>uniqueify</w>
|
42 |
+
<w>vcodec</w>
|
43 |
+
<w>vorbis</w>
|
44 |
+
<w>webm</w>
|
45 |
+
<w>youtu</w>
|
46 |
+
<w>ytplayer</w>
|
47 |
+
</words>
|
48 |
+
</dictionary>
|
49 |
+
</component>
|
.idea/inspectionProfiles/profiles_settings.xml
ADDED
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<component name="InspectionProjectProfileManager">
|
2 |
+
<settings>
|
3 |
+
<option name="USE_PROJECT_PROFILE" value="false" />
|
4 |
+
<version value="1.0" />
|
5 |
+
</settings>
|
6 |
+
</component>
|
.idea/misc.xml
ADDED
@@ -0,0 +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>
|
.idea/vcs.xml
ADDED
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<?xml version="1.0" encoding="UTF-8"?>
|
2 |
+
<project version="4">
|
3 |
+
<component name="VcsDirectoryMappings">
|
4 |
+
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
5 |
+
</component>
|
6 |
+
</project>
|
.readthedocs.yml
ADDED
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# .readthedocs.yml
|
2 |
+
# Read the Docs configuration file
|
3 |
+
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
|
4 |
+
|
5 |
+
# Required
|
6 |
+
version: 2
|
7 |
+
|
8 |
+
# Build documentation in the docs/ directory with Sphinx
|
9 |
+
sphinx:
|
10 |
+
configuration: docs/conf.py
|
11 |
+
|
12 |
+
# Optionally build your docs in additional formats such as PDF and ePub
|
13 |
+
formats: all
|
14 |
+
|
15 |
+
# Optionally set the version of Python and requirements required to build your docs
|
16 |
+
python:
|
17 |
+
version: 3.7
|
18 |
+
install:
|
19 |
+
- requirements: docs/requirements.txt
|
.travis.yml
CHANGED
@@ -3,20 +3,17 @@ cache:
|
|
3 |
- apt
|
4 |
- pip
|
5 |
python:
|
6 |
-
- "2.7"
|
7 |
-
- "3.4"
|
8 |
-
- "3.5"
|
9 |
-
- "3.5-dev" # 3.5 development branch
|
10 |
- "3.6"
|
11 |
-
- "3.
|
12 |
-
- "3.
|
13 |
install: "make"
|
14 |
script:
|
15 |
- make ci
|
16 |
before_install:
|
17 |
-
- pip install pipenv
|
18 |
sudo: false
|
19 |
after_success:
|
20 |
-
|
|
|
21 |
notifications:
|
22 |
-
slack: watchcloud:rNoT5kJJakPqwLSKuev6oa4C
|
|
|
3 |
- apt
|
4 |
- pip
|
5 |
python:
|
|
|
|
|
|
|
|
|
6 |
- "3.6"
|
7 |
+
- "3.7"
|
8 |
+
- "3.8"
|
9 |
install: "make"
|
10 |
script:
|
11 |
- make ci
|
12 |
before_install:
|
13 |
+
- pip install pipenv --upgrade
|
14 |
sudo: false
|
15 |
after_success:
|
16 |
+
- codecov
|
17 |
+
- coveralls
|
18 |
notifications:
|
19 |
+
# slack: watchcloud:rNoT5kJJakPqwLSKuev6oa4C
|
CODE_OF_CONDUCT.md
DELETED
@@ -1,46 +0,0 @@
|
|
1 |
-
# Contributor Covenant Code of Conduct
|
2 |
-
|
3 |
-
## Our Pledge
|
4 |
-
|
5 |
-
In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
|
6 |
-
|
7 |
-
## Our Standards
|
8 |
-
|
9 |
-
Examples of behavior that contributes to creating a positive environment include:
|
10 |
-
|
11 |
-
* Using welcoming and inclusive language
|
12 |
-
* Being respectful of differing viewpoints and experiences
|
13 |
-
* Gracefully accepting constructive criticism
|
14 |
-
* Focusing on what is best for the community
|
15 |
-
* Showing empathy towards other community members
|
16 |
-
|
17 |
-
Examples of unacceptable behavior by participants include:
|
18 |
-
|
19 |
-
* The use of sexualized language or imagery and unwelcome sexual attention or advances
|
20 |
-
* Trolling, insulting/derogatory comments, and personal or political attacks
|
21 |
-
* Public or private harassment
|
22 |
-
* Publishing others' private information, such as a physical or electronic address, without explicit permission
|
23 |
-
* Other conduct which could reasonably be considered inappropriate in a professional setting
|
24 |
-
|
25 |
-
## Our Responsibilities
|
26 |
-
|
27 |
-
Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
|
28 |
-
|
29 |
-
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
|
30 |
-
|
31 |
-
## Scope
|
32 |
-
|
33 |
-
This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
|
34 |
-
|
35 |
-
## Enforcement
|
36 |
-
|
37 |
-
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at [email protected]. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
|
38 |
-
|
39 |
-
Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
|
40 |
-
|
41 |
-
## Attribution
|
42 |
-
|
43 |
-
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version]
|
44 |
-
|
45 |
-
[homepage]: http://contributor-covenant.org
|
46 |
-
[version]: http://contributor-covenant.org/version/1/4/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
MANIFEST.in
CHANGED
@@ -1,2 +1,2 @@
|
|
1 |
-
include README.
|
2 |
recursive-include tests *.py
|
|
|
1 |
+
include README.md LICENSE
|
2 |
recursive-include tests *.py
|
Makefile
CHANGED
@@ -4,11 +4,19 @@ help:
|
|
4 |
@echo "clean-pyc - remove Python file artifacts"
|
5 |
@echo "install - install the package to the active Python's site-packages"
|
6 |
|
7 |
-
|
8 |
pip install pipenv
|
9 |
pipenv install --dev
|
10 |
-
|
11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
12 |
|
13 |
clean: clean-build clean-pyc
|
14 |
|
@@ -26,6 +34,26 @@ clean-pyc:
|
|
26 |
find . -name '*~' -exec rm -f {} +
|
27 |
find . -name '__pycache__' -exec rm -fr {} +
|
28 |
find . -name '.pytest_cache' -exec rm -fr {} +
|
|
|
29 |
|
30 |
install: clean
|
31 |
python setup.py install
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
4 |
@echo "clean-pyc - remove Python file artifacts"
|
5 |
@echo "install - install the package to the active Python's site-packages"
|
6 |
|
7 |
+
pipenv:
|
8 |
pip install pipenv
|
9 |
pipenv install --dev
|
10 |
+
|
11 |
+
test:
|
12 |
+
pipenv run flake8 pytube/
|
13 |
+
pipenv run flake8 tests/
|
14 |
+
pipenv run black pytube --check
|
15 |
+
pipenv run black tests --check
|
16 |
+
pipenv run mypy pytube
|
17 |
+
pipenv run pytest --cov-report term-missing --cov=pytube
|
18 |
+
|
19 |
+
ci: pipenv test
|
20 |
|
21 |
clean: clean-build clean-pyc
|
22 |
|
|
|
34 |
find . -name '*~' -exec rm -f {} +
|
35 |
find . -name '__pycache__' -exec rm -fr {} +
|
36 |
find . -name '.pytest_cache' -exec rm -fr {} +
|
37 |
+
find . -name '.mypy_cache' -exec rm -fr {} +
|
38 |
|
39 |
install: clean
|
40 |
python setup.py install
|
41 |
+
|
42 |
+
package: clean
|
43 |
+
pipenv run python setup.py sdist bdist_wheel
|
44 |
+
|
45 |
+
upload:
|
46 |
+
twine upload dist/*
|
47 |
+
|
48 |
+
tag:
|
49 |
+
git diff-index --quiet HEAD -- # checks for unstaged/uncomitted files
|
50 |
+
git tag "v`pipenv run python pytube/version.py`"
|
51 |
+
git push --tags
|
52 |
+
|
53 |
+
check-master:
|
54 |
+
if [[ `git rev-parse --abbrev-ref HEAD` != "master" ]]; then exit 1; fi
|
55 |
+
|
56 |
+
pull:
|
57 |
+
git pull
|
58 |
+
|
59 |
+
release: check-master pull clean test tag package upload
|
Pipfile
CHANGED
@@ -4,21 +4,37 @@ verify_ssl = true
|
|
4 |
name = "pypi"
|
5 |
|
6 |
[packages]
|
|
|
7 |
|
8 |
[dev-packages]
|
9 |
-
|
10 |
-
|
11 |
-
|
12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
13 |
pre-commit = "*"
|
14 |
-
|
15 |
pytest-cov = "*"
|
16 |
-
|
17 |
-
|
18 |
-
|
19 |
-
coveralls = "*"
|
20 |
-
twine = "*"
|
21 |
-
more-itertools = "==5.0.0"
|
22 |
-
|
23 |
-
[requires]
|
24 |
-
python_version = "3.6"
|
|
|
4 |
name = "pypi"
|
5 |
|
6 |
[packages]
|
7 |
+
typing_extensions = "*"
|
8 |
|
9 |
[dev-packages]
|
10 |
+
black = "==19.10b0"
|
11 |
+
codecov = "*"
|
12 |
+
coveralls = "*"
|
13 |
+
flake8 = "*"
|
14 |
+
flake8-breakpoint = "*"
|
15 |
+
flake8-broken-line = "*"
|
16 |
+
flake8-bugbear ="*"
|
17 |
+
flake8-builtins = "*"
|
18 |
+
flake8-comprehensions ="*"
|
19 |
+
flake8-eradicate = "*"
|
20 |
+
flake8-executable = "*"
|
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 = "*"
|
35 |
pre-commit = "*"
|
36 |
+
pytest = "*"
|
37 |
pytest-cov = "*"
|
38 |
+
pytest-mock = "*"
|
39 |
+
pytest-profiling = "*"
|
40 |
+
sphinx_rtd_theme = "*"
|
|
|
|
|
|
|
|
|
|
|
|
Pipfile.lock
CHANGED
@@ -1,12 +1,10 @@
|
|
1 |
{
|
2 |
"_meta": {
|
3 |
"hash": {
|
4 |
-
"sha256": "
|
5 |
},
|
6 |
"pipfile-spec": 6,
|
7 |
-
"requires": {
|
8 |
-
"python_version": "3.6"
|
9 |
-
},
|
10 |
"sources": [
|
11 |
{
|
12 |
"name": "pypi",
|
@@ -15,57 +13,74 @@
|
|
15 |
}
|
16 |
]
|
17 |
},
|
18 |
-
"default": {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
19 |
"develop": {
|
20 |
-
"
|
21 |
"hashes": [
|
22 |
-
"sha256:
|
23 |
-
"sha256:
|
24 |
],
|
25 |
-
"version": "==
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
26 |
},
|
27 |
-
"
|
28 |
"hashes": [
|
29 |
-
"sha256:
|
30 |
-
"sha256:
|
31 |
],
|
32 |
"version": "==1.3.0"
|
33 |
},
|
34 |
"attrs": {
|
35 |
"hashes": [
|
36 |
-
"sha256:
|
37 |
-
"sha256:
|
38 |
],
|
39 |
-
"version": "==19.
|
40 |
},
|
41 |
-
"
|
42 |
"hashes": [
|
43 |
-
"sha256:
|
44 |
-
"sha256:
|
45 |
],
|
46 |
-
"version": "==
|
47 |
},
|
48 |
-
"
|
49 |
"hashes": [
|
50 |
-
"sha256:
|
51 |
-
"sha256:
|
52 |
],
|
53 |
"index": "pypi",
|
54 |
-
"version": "==
|
55 |
},
|
56 |
"certifi": {
|
57 |
"hashes": [
|
58 |
-
"sha256:
|
59 |
-
"sha256:
|
60 |
],
|
61 |
-
"version": "==2019.
|
62 |
},
|
63 |
"cfgv": {
|
64 |
"hashes": [
|
65 |
-
"sha256:
|
66 |
-
"sha256:
|
67 |
],
|
68 |
-
"version": "==
|
69 |
},
|
70 |
"chardet": {
|
71 |
"hashes": [
|
@@ -74,49 +89,70 @@
|
|
74 |
],
|
75 |
"version": "==3.0.4"
|
76 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
77 |
"coverage": {
|
78 |
"hashes": [
|
79 |
-
"sha256:
|
80 |
-
"sha256:
|
81 |
-
"sha256:
|
82 |
-
"sha256:
|
83 |
-
"sha256:
|
84 |
-
"sha256:
|
85 |
-
"sha256:
|
86 |
-
"sha256:
|
87 |
-
"sha256:
|
88 |
-
"sha256:
|
89 |
-
"sha256:
|
90 |
-
"sha256:
|
91 |
-
"sha256:
|
92 |
-
"sha256:
|
93 |
-
"sha256:
|
94 |
-
"sha256:
|
95 |
-
"sha256:
|
96 |
-
"sha256:
|
97 |
-
"sha256:
|
98 |
-
"sha256:
|
99 |
-
"sha256:
|
100 |
-
"sha256:
|
101 |
-
"sha256:
|
102 |
-
"sha256:
|
103 |
-
"sha256:
|
104 |
-
"sha256:
|
105 |
-
"sha256:
|
106 |
-
"sha256:
|
107 |
-
"sha256:
|
108 |
-
"sha256:
|
109 |
-
"sha256:
|
110 |
-
],
|
111 |
-
"version": "==
|
112 |
},
|
113 |
"coveralls": {
|
114 |
"hashes": [
|
115 |
-
"sha256:
|
116 |
-
"sha256:
|
117 |
],
|
118 |
"index": "pypi",
|
119 |
-
"version": "==1.
|
|
|
|
|
|
|
|
|
|
|
|
|
120 |
},
|
121 |
"docopt": {
|
122 |
"hashes": [
|
@@ -126,11 +162,10 @@
|
|
126 |
},
|
127 |
"docutils": {
|
128 |
"hashes": [
|
129 |
-
"sha256:
|
130 |
-
"sha256:
|
131 |
-
"sha256:7a4bd47eaf6596e1295ecb11361139febe29b084a87bf005bf899f9a42edc3c6"
|
132 |
],
|
133 |
-
"version": "==0.
|
134 |
},
|
135 |
"entrypoints": {
|
136 |
"hashes": [
|
@@ -139,31 +174,209 @@
|
|
139 |
],
|
140 |
"version": "==0.3"
|
141 |
},
|
142 |
-
"
|
143 |
"hashes": [
|
144 |
-
"sha256:
|
145 |
-
"sha256:
|
146 |
-
"sha256:6bd0f6ad48ec2aa117d3d141940d484deccda84d4fcd884f5c3d93c23ecd8c79",
|
147 |
-
"sha256:8ad8c4783bf61ded74527bffb48ed9b54166685e4230386a9ed9b1279e2df5b1"
|
148 |
],
|
149 |
-
"
|
150 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
151 |
},
|
152 |
"flake8": {
|
153 |
"hashes": [
|
154 |
-
"sha256:
|
155 |
-
"sha256:
|
156 |
],
|
157 |
"index": "pypi",
|
158 |
-
"version": "==3.7.
|
159 |
},
|
160 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
161 |
"hashes": [
|
162 |
-
"sha256:
|
163 |
-
"sha256:
|
164 |
],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
165 |
"version": "==1.4.2"
|
166 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
167 |
"idna": {
|
168 |
"hashes": [
|
169 |
"sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407",
|
@@ -171,20 +384,75 @@
|
|
171 |
],
|
172 |
"version": "==2.8"
|
173 |
},
|
174 |
-
"
|
175 |
"hashes": [
|
176 |
-
"sha256:
|
177 |
-
"sha256:
|
178 |
],
|
179 |
-
"version": "==0
|
180 |
},
|
181 |
-
"importlib-
|
182 |
"hashes": [
|
183 |
-
"sha256:
|
184 |
-
"sha256:
|
185 |
-
],
|
186 |
-
"markers": "python_version < '3.
|
187 |
-
"version": "==1.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
188 |
},
|
189 |
"mccabe": {
|
190 |
"hashes": [
|
@@ -193,72 +461,89 @@
|
|
193 |
],
|
194 |
"version": "==0.6.1"
|
195 |
},
|
196 |
-
"
|
197 |
"hashes": [
|
198 |
-
"sha256:
|
199 |
-
"sha256:
|
200 |
],
|
201 |
-
"
|
202 |
-
"version": "==2.0.0"
|
203 |
},
|
204 |
-
"
|
205 |
"hashes": [
|
206 |
-
"sha256:
|
207 |
-
"sha256:
|
208 |
-
"sha256:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
209 |
],
|
210 |
"index": "pypi",
|
211 |
-
"version": "==
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
212 |
},
|
213 |
"nodeenv": {
|
214 |
"hashes": [
|
215 |
-
"sha256:
|
216 |
],
|
217 |
-
"version": "==1.3.
|
218 |
},
|
219 |
-
"
|
220 |
"hashes": [
|
221 |
-
"sha256:
|
222 |
-
"sha256:
|
223 |
],
|
224 |
-
"
|
225 |
-
"version": "==2.3.3"
|
226 |
},
|
227 |
-
"
|
228 |
"hashes": [
|
229 |
-
"sha256:
|
230 |
-
"sha256:
|
231 |
],
|
232 |
-
"version": "==
|
233 |
},
|
234 |
-
"
|
235 |
"hashes": [
|
236 |
-
"sha256:
|
237 |
-
"sha256:
|
238 |
],
|
239 |
-
"
|
|
|
240 |
},
|
241 |
"pluggy": {
|
242 |
"hashes": [
|
243 |
-
"sha256:
|
244 |
-
"sha256:
|
245 |
],
|
246 |
-
"version": "==0.
|
247 |
},
|
248 |
"pre-commit": {
|
249 |
"hashes": [
|
250 |
-
"sha256:
|
251 |
-
"sha256:
|
252 |
],
|
253 |
"index": "pypi",
|
254 |
-
"version": "==
|
255 |
},
|
256 |
"py": {
|
257 |
"hashes": [
|
258 |
-
"sha256:
|
259 |
-
"sha256:
|
260 |
],
|
261 |
-
"version": "==1.8.
|
262 |
},
|
263 |
"pycodestyle": {
|
264 |
"hashes": [
|
@@ -276,145 +561,253 @@
|
|
276 |
},
|
277 |
"pygments": {
|
278 |
"hashes": [
|
279 |
-
"sha256:
|
280 |
-
"sha256:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
281 |
],
|
282 |
-
"version": "==2.
|
283 |
},
|
284 |
"pytest": {
|
285 |
"hashes": [
|
286 |
-
"sha256:
|
287 |
-
"sha256:
|
288 |
],
|
289 |
"index": "pypi",
|
290 |
-
"version": "==
|
291 |
},
|
292 |
"pytest-cov": {
|
293 |
"hashes": [
|
294 |
-
"sha256:
|
295 |
-
"sha256:
|
296 |
],
|
297 |
"index": "pypi",
|
298 |
-
"version": "==2.
|
299 |
},
|
300 |
"pytest-mock": {
|
301 |
"hashes": [
|
302 |
-
"sha256:
|
303 |
-
"sha256:
|
304 |
],
|
305 |
"index": "pypi",
|
306 |
-
"version": "==
|
307 |
},
|
308 |
-
"
|
309 |
"hashes": [
|
310 |
-
"sha256:
|
311 |
-
"sha256:
|
312 |
-
"sha256:460a5a4248763f6f37ea225d19d5c205677d8d525f6a83357ca622ed541830c2",
|
313 |
-
"sha256:5a22a9c84653debfbf198d02fe592c176ea548cccce47553f35f466e15cf2fd4",
|
314 |
-
"sha256:7a5d3f26b89d688db27822343dfa25c599627bc92093e788956372285c6298ad",
|
315 |
-
"sha256:9372b04a02080752d9e6f990179a4ab840227c6e2ce15b95e1278456664cf2ba",
|
316 |
-
"sha256:a5dcbebee834eaddf3fa7366316b880ff4062e4bcc9787b78c7fbb4a26ff2dd1",
|
317 |
-
"sha256:aee5bab92a176e7cd034e57f46e9df9a9862a71f8f37cad167c6fc74c65f5b4e",
|
318 |
-
"sha256:c51f642898c0bacd335fc119da60baae0824f2cde95b0330b56c0553439f0673",
|
319 |
-
"sha256:c68ea4d3ba1705da1e0d85da6684ac657912679a649e8868bd850d2c299cce13",
|
320 |
-
"sha256:e23d0cc5299223dcc37885dae624f382297717e459ea24053709675a976a3e19"
|
321 |
],
|
322 |
-
"
|
|
|
323 |
},
|
324 |
-
"
|
325 |
"hashes": [
|
326 |
-
"sha256:
|
327 |
-
"sha256:
|
328 |
],
|
329 |
-
"version": "==
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
330 |
},
|
331 |
"requests": {
|
332 |
"hashes": [
|
333 |
-
"sha256:
|
334 |
-
"sha256:
|
335 |
],
|
336 |
-
"version": "==2.
|
337 |
},
|
338 |
-
"
|
339 |
"hashes": [
|
340 |
-
"sha256:
|
341 |
-
"sha256:
|
342 |
],
|
343 |
-
"version": "==
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
344 |
},
|
345 |
-
"
|
346 |
"hashes": [
|
347 |
-
"sha256:
|
348 |
-
"sha256:
|
349 |
-
|
350 |
-
|
351 |
-
|
352 |
-
|
353 |
-
|
354 |
-
"sha256:
|
355 |
-
"sha256:
|
356 |
-
"sha256:b24086f2375c4a094a6b51e78b4cf7ca16c721dcee2eddd7aa6494b42d6d519d",
|
357 |
-
"sha256:cb925555f43060a1745d0a321cca94bcea927c50114b623d73179189a4e100ac"
|
358 |
],
|
359 |
"index": "pypi",
|
360 |
-
"version": "==
|
361 |
},
|
362 |
-
"
|
363 |
"hashes": [
|
364 |
-
"sha256:
|
365 |
-
"sha256:
|
366 |
],
|
367 |
-
"version": "==1.
|
368 |
},
|
369 |
-
"
|
370 |
"hashes": [
|
371 |
-
"sha256:
|
372 |
-
"sha256:
|
373 |
],
|
374 |
-
"version": "==0.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
375 |
},
|
376 |
-
"
|
377 |
"hashes": [
|
378 |
-
"sha256:
|
379 |
-
"sha256:
|
380 |
],
|
381 |
-
"version": "==
|
382 |
},
|
383 |
-
"
|
384 |
"hashes": [
|
385 |
-
"sha256:
|
386 |
-
"sha256:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
387 |
],
|
388 |
"index": "pypi",
|
389 |
-
"version": "==
|
390 |
},
|
391 |
"urllib3": {
|
392 |
"hashes": [
|
393 |
-
"sha256:
|
394 |
-
"sha256:
|
395 |
],
|
396 |
-
"version": "==1.
|
397 |
},
|
398 |
"virtualenv": {
|
399 |
"hashes": [
|
400 |
-
"sha256:
|
401 |
-
"sha256:
|
402 |
],
|
403 |
-
"version": "==
|
404 |
},
|
405 |
-
"
|
406 |
"hashes": [
|
407 |
-
"sha256:
|
408 |
-
"sha256:
|
409 |
],
|
410 |
-
"version": "==0.
|
411 |
},
|
412 |
"zipp": {
|
413 |
"hashes": [
|
414 |
-
"sha256:
|
415 |
-
"sha256:
|
416 |
],
|
417 |
-
"version": "==
|
418 |
}
|
419 |
}
|
420 |
}
|
|
|
1 |
{
|
2 |
"_meta": {
|
3 |
"hash": {
|
4 |
+
"sha256": "67eed8580be32eb9e1c105500479ae3464231ce063bb0b404cc29a43e20262da"
|
5 |
},
|
6 |
"pipfile-spec": 6,
|
7 |
+
"requires": {},
|
|
|
|
|
8 |
"sources": [
|
9 |
{
|
10 |
"name": "pypi",
|
|
|
13 |
}
|
14 |
]
|
15 |
},
|
16 |
+
"default": {
|
17 |
+
"typing-extensions": {
|
18 |
+
"hashes": [
|
19 |
+
"sha256:091ecc894d5e908ac75209f10d5b4f118fbdb2eb1ede6a63544054bb1edb41f2",
|
20 |
+
"sha256:910f4656f54de5993ad9304959ce9bb903f90aadc7c67a0bef07e678014e892d",
|
21 |
+
"sha256:cf8b63fedea4d89bab840ecbb93e75578af28f76f66c35889bd7065f5af88575"
|
22 |
+
],
|
23 |
+
"index": "pypi",
|
24 |
+
"version": "==3.7.4.1"
|
25 |
+
}
|
26 |
+
},
|
27 |
"develop": {
|
28 |
+
"alabaster": {
|
29 |
"hashes": [
|
30 |
+
"sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359",
|
31 |
+
"sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02"
|
32 |
],
|
33 |
+
"version": "==0.7.12"
|
34 |
+
},
|
35 |
+
"appdirs": {
|
36 |
+
"hashes": [
|
37 |
+
"sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92",
|
38 |
+
"sha256:d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e"
|
39 |
+
],
|
40 |
+
"version": "==1.4.3"
|
41 |
},
|
42 |
+
"aspy.yaml": {
|
43 |
"hashes": [
|
44 |
+
"sha256:463372c043f70160a9ec950c3f1e4c3a82db5fca01d334b6bc89c7164d744bdc",
|
45 |
+
"sha256:e7c742382eff2caed61f87a39d13f99109088e5e93f04d76eb8d4b28aa143f45"
|
46 |
],
|
47 |
"version": "==1.3.0"
|
48 |
},
|
49 |
"attrs": {
|
50 |
"hashes": [
|
51 |
+
"sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c",
|
52 |
+
"sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"
|
53 |
],
|
54 |
+
"version": "==19.3.0"
|
55 |
},
|
56 |
+
"babel": {
|
57 |
"hashes": [
|
58 |
+
"sha256:1aac2ae2d0d8ea368fa90906567f5c08463d98ade155c0c4bfedd6a0f7160e38",
|
59 |
+
"sha256:d670ea0b10f8b723672d3a6abeb87b565b244da220d76b4dba1b66269ec152d4"
|
60 |
],
|
61 |
+
"version": "==2.8.0"
|
62 |
},
|
63 |
+
"black": {
|
64 |
"hashes": [
|
65 |
+
"sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b",
|
66 |
+
"sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"
|
67 |
],
|
68 |
"index": "pypi",
|
69 |
+
"version": "==19.10b0"
|
70 |
},
|
71 |
"certifi": {
|
72 |
"hashes": [
|
73 |
+
"sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3",
|
74 |
+
"sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f"
|
75 |
],
|
76 |
+
"version": "==2019.11.28"
|
77 |
},
|
78 |
"cfgv": {
|
79 |
"hashes": [
|
80 |
+
"sha256:04b093b14ddf9fd4d17c53ebfd55582d27b76ed30050193c14e560770c5360eb",
|
81 |
+
"sha256:f22b426ed59cd2ab2b54ff96608d846c33dfb8766a67f0b4a6ce130ce244414f"
|
82 |
],
|
83 |
+
"version": "==3.0.0"
|
84 |
},
|
85 |
"chardet": {
|
86 |
"hashes": [
|
|
|
89 |
],
|
90 |
"version": "==3.0.4"
|
91 |
},
|
92 |
+
"click": {
|
93 |
+
"hashes": [
|
94 |
+
"sha256:2335065e6395b9e67ca716de5f7526736bfa6ceead690adf616d925bdc622b13",
|
95 |
+
"sha256:5b94b49521f6456670fdb30cd82a4eca9412788a93fa6dd6df72c94d5a8ff2d7"
|
96 |
+
],
|
97 |
+
"version": "==7.0"
|
98 |
+
},
|
99 |
+
"codecov": {
|
100 |
+
"hashes": [
|
101 |
+
"sha256:8ed8b7c6791010d359baed66f84f061bba5bd41174bf324c31311e8737602788",
|
102 |
+
"sha256:ae00d68e18d8a20e9c3288ba3875ae03db3a8e892115bf9b83ef20507732bed4"
|
103 |
+
],
|
104 |
+
"index": "pypi",
|
105 |
+
"version": "==2.0.15"
|
106 |
+
},
|
107 |
"coverage": {
|
108 |
"hashes": [
|
109 |
+
"sha256:15cf13a6896048d6d947bf7d222f36e4809ab926894beb748fc9caa14605d9c3",
|
110 |
+
"sha256:1daa3eceed220f9fdb80d5ff950dd95112cd27f70d004c7918ca6dfc6c47054c",
|
111 |
+
"sha256:1e44a022500d944d42f94df76727ba3fc0a5c0b672c358b61067abb88caee7a0",
|
112 |
+
"sha256:25dbf1110d70bab68a74b4b9d74f30e99b177cde3388e07cc7272f2168bd1477",
|
113 |
+
"sha256:3230d1003eec018ad4a472d254991e34241e0bbd513e97a29727c7c2f637bd2a",
|
114 |
+
"sha256:3dbb72eaeea5763676a1a1efd9b427a048c97c39ed92e13336e726117d0b72bf",
|
115 |
+
"sha256:5012d3b8d5a500834783689a5d2292fe06ec75dc86ee1ccdad04b6f5bf231691",
|
116 |
+
"sha256:51bc7710b13a2ae0c726f69756cf7ffd4362f4ac36546e243136187cfcc8aa73",
|
117 |
+
"sha256:527b4f316e6bf7755082a783726da20671a0cc388b786a64417780b90565b987",
|
118 |
+
"sha256:722e4557c8039aad9592c6a4213db75da08c2cd9945320220634f637251c3894",
|
119 |
+
"sha256:76e2057e8ffba5472fd28a3a010431fd9e928885ff480cb278877c6e9943cc2e",
|
120 |
+
"sha256:77afca04240c40450c331fa796b3eab6f1e15c5ecf8bf2b8bee9706cd5452fef",
|
121 |
+
"sha256:7afad9835e7a651d3551eab18cbc0fdb888f0a6136169fbef0662d9cdc9987cf",
|
122 |
+
"sha256:9bea19ac2f08672636350f203db89382121c9c2ade85d945953ef3c8cf9d2a68",
|
123 |
+
"sha256:a8b8ac7876bc3598e43e2603f772d2353d9931709345ad6c1149009fd1bc81b8",
|
124 |
+
"sha256:b0840b45187699affd4c6588286d429cd79a99d509fe3de0f209594669bb0954",
|
125 |
+
"sha256:b26aaf69713e5674efbde4d728fb7124e429c9466aeaf5f4a7e9e699b12c9fe2",
|
126 |
+
"sha256:b63dd43f455ba878e5e9f80ba4f748c0a2156dde6e0e6e690310e24d6e8caf40",
|
127 |
+
"sha256:be18f4ae5a9e46edae3f329de2191747966a34a3d93046dbdf897319923923bc",
|
128 |
+
"sha256:c312e57847db2526bc92b9bfa78266bfbaabac3fdcd751df4d062cd4c23e46dc",
|
129 |
+
"sha256:c60097190fe9dc2b329a0eb03393e2e0829156a589bd732e70794c0dd804258e",
|
130 |
+
"sha256:c62a2143e1313944bf4a5ab34fd3b4be15367a02e9478b0ce800cb510e3bbb9d",
|
131 |
+
"sha256:cc1109f54a14d940b8512ee9f1c3975c181bbb200306c6d8b87d93376538782f",
|
132 |
+
"sha256:cd60f507c125ac0ad83f05803063bed27e50fa903b9c2cfee3f8a6867ca600fc",
|
133 |
+
"sha256:d513cc3db248e566e07a0da99c230aca3556d9b09ed02f420664e2da97eac301",
|
134 |
+
"sha256:d649dc0bcace6fcdb446ae02b98798a856593b19b637c1b9af8edadf2b150bea",
|
135 |
+
"sha256:d7008a6796095a79544f4da1ee49418901961c97ca9e9d44904205ff7d6aa8cb",
|
136 |
+
"sha256:da93027835164b8223e8e5af2cf902a4c80ed93cb0909417234f4a9df3bcd9af",
|
137 |
+
"sha256:e69215621707119c6baf99bda014a45b999d37602cb7043d943c76a59b05bf52",
|
138 |
+
"sha256:ea9525e0fef2de9208250d6c5aeeee0138921057cd67fcef90fbed49c4d62d37",
|
139 |
+
"sha256:fca1669d464f0c9831fd10be2eef6b86f5ebd76c724d1e0706ebdff86bb4adf0"
|
140 |
+
],
|
141 |
+
"version": "==5.0.3"
|
142 |
},
|
143 |
"coveralls": {
|
144 |
"hashes": [
|
145 |
+
"sha256:4b6bfc2a2a77b890f556bc631e35ba1ac21193c356393b66c84465c06218e135",
|
146 |
+
"sha256:67188c7ec630c5f708c31552f2bcdac4580e172219897c4136504f14b823132f"
|
147 |
],
|
148 |
"index": "pypi",
|
149 |
+
"version": "==1.11.1"
|
150 |
+
},
|
151 |
+
"distlib": {
|
152 |
+
"hashes": [
|
153 |
+
"sha256:2e166e231a26b36d6dfe35a48c4464346620f8645ed0ace01ee31822b288de21"
|
154 |
+
],
|
155 |
+
"version": "==0.3.0"
|
156 |
},
|
157 |
"docopt": {
|
158 |
"hashes": [
|
|
|
162 |
},
|
163 |
"docutils": {
|
164 |
"hashes": [
|
165 |
+
"sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af",
|
166 |
+
"sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc"
|
|
|
167 |
],
|
168 |
+
"version": "==0.16"
|
169 |
},
|
170 |
"entrypoints": {
|
171 |
"hashes": [
|
|
|
174 |
],
|
175 |
"version": "==0.3"
|
176 |
},
|
177 |
+
"enum-compat": {
|
178 |
"hashes": [
|
179 |
+
"sha256:3677daabed56a6f724451d585662253d8fb4e5569845aafa8bb0da36b1a8751e",
|
180 |
+
"sha256:88091b617c7fc3bbbceae50db5958023c48dc40b50520005aa3bf27f8f7ea157"
|
|
|
|
|
181 |
],
|
182 |
+
"version": "==0.0.3"
|
183 |
+
},
|
184 |
+
"eradicate": {
|
185 |
+
"hashes": [
|
186 |
+
"sha256:4ffda82aae6fd49dfffa777a857cb758d77502a1f2e0f54c9ac5155a39d2d01a"
|
187 |
+
],
|
188 |
+
"version": "==1.0"
|
189 |
+
},
|
190 |
+
"filelock": {
|
191 |
+
"hashes": [
|
192 |
+
"sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59",
|
193 |
+
"sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"
|
194 |
+
],
|
195 |
+
"version": "==3.0.12"
|
196 |
},
|
197 |
"flake8": {
|
198 |
"hashes": [
|
199 |
+
"sha256:45681a117ecc81e870cbf1262835ae4af5e7a8b08e40b944a8a6e6b895914cfb",
|
200 |
+
"sha256:49356e766643ad15072a789a20915d3c91dc89fd313ccd71802303fd67e4deca"
|
201 |
],
|
202 |
"index": "pypi",
|
203 |
+
"version": "==3.7.9"
|
204 |
},
|
205 |
+
"flake8-breakpoint": {
|
206 |
+
"hashes": [
|
207 |
+
"sha256:27e0cb132647f9ef348b4a3c3126e7350bedbb22e8e221cd11712a223855ea0b",
|
208 |
+
"sha256:5bc70d478f0437a3655d094e1d2fca81ddacabaa84d99db45ad3630bf2004064"
|
209 |
+
],
|
210 |
+
"index": "pypi",
|
211 |
+
"version": "==1.1.0"
|
212 |
+
},
|
213 |
+
"flake8-broken-line": {
|
214 |
+
"hashes": [
|
215 |
+
"sha256:30378a3749911e453d0a9e03204156cbbd35bcc03fb89f12e6a5206e5baf3537",
|
216 |
+
"sha256:7721725dce3aeee1df371a252822f1fcecfaf2766dcf5bac54ee1b3f779ee9d1"
|
217 |
+
],
|
218 |
+
"index": "pypi",
|
219 |
+
"version": "==0.1.1"
|
220 |
+
},
|
221 |
+
"flake8-bugbear": {
|
222 |
"hashes": [
|
223 |
+
"sha256:a3ddc03ec28ba2296fc6f89444d1c946a6b76460f859795b35b77d4920a51b63",
|
224 |
+
"sha256:bd02e4b009fb153fe6072c31c52aeab5b133d508095befb2ffcf3b41c4823162"
|
225 |
],
|
226 |
+
"index": "pypi",
|
227 |
+
"version": "==20.1.4"
|
228 |
+
},
|
229 |
+
"flake8-builtins": {
|
230 |
+
"hashes": [
|
231 |
+
"sha256:29bc0f7e68af481d088f5c96f8aeb02520abdfc900500484e3af969f42a38a5f",
|
232 |
+
"sha256:c44415fb19162ef3737056e700d5b99d48c3612a533943b4e16419a5d3de3a64"
|
233 |
+
],
|
234 |
+
"index": "pypi",
|
235 |
"version": "==1.4.2"
|
236 |
},
|
237 |
+
"flake8-comprehensions": {
|
238 |
+
"hashes": [
|
239 |
+
"sha256:d08323aa801aef33477cd33f2f5ce3acb1aafd26803ab0d171d85d514c1273a2",
|
240 |
+
"sha256:e7db586bb6eb95afdfd87ed244c90e57ae1352db8ef0ad3012fca0200421e5df"
|
241 |
+
],
|
242 |
+
"index": "pypi",
|
243 |
+
"version": "==3.2.2"
|
244 |
+
},
|
245 |
+
"flake8-eradicate": {
|
246 |
+
"hashes": [
|
247 |
+
"sha256:b0bcdbb70a489fb799f9ee11fefc57bd0d3251e1ea9bdc5bf454443cccfd620c",
|
248 |
+
"sha256:b693e9dfe6da42dbc7fb75af8486495b9414d1ab0372d15efcf85a2ac85fd368"
|
249 |
+
],
|
250 |
+
"index": "pypi",
|
251 |
+
"version": "==0.2.4"
|
252 |
+
},
|
253 |
+
"flake8-executable": {
|
254 |
+
"hashes": [
|
255 |
+
"sha256:968618c475a23a538ced9b957a741b818d37610838f99f6abcea249e4de7c9ec",
|
256 |
+
"sha256:a636ff78b14b63b1245d1c4d509db2f6ea0f2e27a86ee7eb848f3827bef7e16d"
|
257 |
+
],
|
258 |
+
"index": "pypi",
|
259 |
+
"version": "==2.0.3"
|
260 |
+
},
|
261 |
+
"flake8-if-expr": {
|
262 |
+
"hashes": [
|
263 |
+
"sha256:173f6ceefdecbff532180aafe0360f6d1dd4da8b4a9b10193ddc1781291d580e",
|
264 |
+
"sha256:890c5bd0103c864492e7088bfaf4f9f5a987c336b03b2b285178456d08db3025"
|
265 |
+
],
|
266 |
+
"index": "pypi",
|
267 |
+
"version": "==1.0.0"
|
268 |
+
},
|
269 |
+
"flake8-isort": {
|
270 |
+
"hashes": [
|
271 |
+
"sha256:64454d1f154a303cfe23ee715aca37271d4f1d299b2f2663f45b73bff14e36a9",
|
272 |
+
"sha256:aa0c4d004e6be47e74f122f5b7f36554d0d78ad8bf99b497a460dedccaa7cce9"
|
273 |
+
],
|
274 |
+
"index": "pypi",
|
275 |
+
"version": "==2.8.0"
|
276 |
+
},
|
277 |
+
"flake8-logging-format": {
|
278 |
+
"hashes": [
|
279 |
+
"sha256:ca5f2b7fc31c3474a0aa77d227e022890f641a025f0ba664418797d979a779f8"
|
280 |
+
],
|
281 |
+
"index": "pypi",
|
282 |
+
"version": "==0.6.0"
|
283 |
+
},
|
284 |
+
"flake8-mock": {
|
285 |
+
"hashes": [
|
286 |
+
"sha256:2fa775e7589f4e1ad74f35d60953eb20937f5d7355235e54bf852c6837f2bede"
|
287 |
+
],
|
288 |
+
"index": "pypi",
|
289 |
+
"version": "==0.3"
|
290 |
+
},
|
291 |
+
"flake8-mutable": {
|
292 |
+
"hashes": [
|
293 |
+
"sha256:38fd9dadcbcda6550a916197bc40ed76908119dabb37fbcca30873666c31d2d5",
|
294 |
+
"sha256:ee9b77111b867d845177bbc289d87d541445ffcc6029a0c5c65865b42b18c6a6"
|
295 |
+
],
|
296 |
+
"index": "pypi",
|
297 |
+
"version": "==1.2.0"
|
298 |
+
},
|
299 |
+
"flake8-plugin-utils": {
|
300 |
+
"hashes": [
|
301 |
+
"sha256:1ac5eb19773d5c7fdde60b0d901ae86be9c751bf697c61fdb6609b86872f3c6e",
|
302 |
+
"sha256:24b4a3b216ad588951d3d7adef4645dcb3b32a33b878e03baa790b5a66bf3a73"
|
303 |
+
],
|
304 |
+
"version": "==1.0.0"
|
305 |
+
},
|
306 |
+
"flake8-polyfill": {
|
307 |
+
"hashes": [
|
308 |
+
"sha256:12be6a34ee3ab795b19ca73505e7b55826d5f6ad7230d31b18e106400169b9e9",
|
309 |
+
"sha256:e44b087597f6da52ec6393a709e7108b2905317d0c0b744cdca6208e670d8eda"
|
310 |
+
],
|
311 |
+
"version": "==1.0.2"
|
312 |
+
},
|
313 |
+
"flake8-print": {
|
314 |
+
"hashes": [
|
315 |
+
"sha256:324f9e59a522518daa2461bacd7f82da3c34eb26a4314c2a54bd493f8b394a68"
|
316 |
+
],
|
317 |
+
"index": "pypi",
|
318 |
+
"version": "==3.1.4"
|
319 |
+
},
|
320 |
+
"flake8-pytest": {
|
321 |
+
"hashes": [
|
322 |
+
"sha256:61686128a79e1513db575b2bcac351081d5a293811ddce2d5dfc25e8c762d33e",
|
323 |
+
"sha256:b4d6703f7d7b646af1e2660809e795886dd349df11843613dbe6515efa82c0f3"
|
324 |
+
],
|
325 |
+
"index": "pypi",
|
326 |
+
"version": "==1.3"
|
327 |
+
},
|
328 |
+
"flake8-pytest-style": {
|
329 |
+
"hashes": [
|
330 |
+
"sha256:1c2303998c509cd65c3fb047cd536787ddf953e8113bc7f086c0cd7468db4b1f",
|
331 |
+
"sha256:820503cb50b7f6aa13a9889f4c47ba35bbd666877a72ed138ae5682a9bccaf9d"
|
332 |
+
],
|
333 |
+
"index": "pypi",
|
334 |
+
"version": "==0.1.3"
|
335 |
+
},
|
336 |
+
"flake8-quotes": {
|
337 |
+
"hashes": [
|
338 |
+
"sha256:11a15d30c92ca5f04c2791bd7019cf62b6f9d3053eb050d02a135557eb118bfc"
|
339 |
+
],
|
340 |
+
"index": "pypi",
|
341 |
+
"version": "==2.1.1"
|
342 |
+
},
|
343 |
+
"flake8-return": {
|
344 |
+
"hashes": [
|
345 |
+
"sha256:03b920cf2784370af4447a754fb7133ce165a6ecf6d4f506a95c4032ece48d8a",
|
346 |
+
"sha256:a219b619cdca3cd07dae150772f21083a11ce5280e2198acbac82bd9be0f574f"
|
347 |
+
],
|
348 |
+
"index": "pypi",
|
349 |
+
"version": "==1.1.1"
|
350 |
+
},
|
351 |
+
"flake8-strict": {
|
352 |
+
"hashes": [
|
353 |
+
"sha256:2ef66f75f9215c2084ae7d1b18e158a3c392141a5621ecab28858256ea75d41e",
|
354 |
+
"sha256:75d5c11babe3f3b2bc5349e645112571a1d80d6183bda99afe5ffdfc70192d10"
|
355 |
+
],
|
356 |
+
"index": "pypi",
|
357 |
+
"version": "==0.2.1"
|
358 |
+
},
|
359 |
+
"flake8-string-format": {
|
360 |
+
"hashes": [
|
361 |
+
"sha256:65f3da786a1461ef77fca3780b314edb2853c377f2e35069723348c8917deaa2",
|
362 |
+
"sha256:812ff431f10576a74c89be4e85b8e075a705be39bc40c4b4278b5b13e2afa9af"
|
363 |
+
],
|
364 |
+
"index": "pypi",
|
365 |
+
"version": "==0.3.0"
|
366 |
+
},
|
367 |
+
"gprof2dot": {
|
368 |
+
"hashes": [
|
369 |
+
"sha256:b43fe04ebb3dfe181a612bbfc69e90555b8957022ad6a466f0308ed9c7f22e99"
|
370 |
+
],
|
371 |
+
"version": "==2019.11.30"
|
372 |
+
},
|
373 |
+
"identify": {
|
374 |
+
"hashes": [
|
375 |
+
"sha256:1222b648251bdcb8deb240b294f450fbf704c7984e08baa92507e4ea10b436d5",
|
376 |
+
"sha256:d824ebe21f38325c771c41b08a95a761db1982f1fc0eee37c6c97df3f1636b96"
|
377 |
+
],
|
378 |
+
"version": "==1.4.11"
|
379 |
+
},
|
380 |
"idna": {
|
381 |
"hashes": [
|
382 |
"sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407",
|
|
|
384 |
],
|
385 |
"version": "==2.8"
|
386 |
},
|
387 |
+
"imagesize": {
|
388 |
"hashes": [
|
389 |
+
"sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1",
|
390 |
+
"sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1"
|
391 |
],
|
392 |
+
"version": "==1.2.0"
|
393 |
},
|
394 |
+
"importlib-metadata": {
|
395 |
"hashes": [
|
396 |
+
"sha256:06f5b3a99029c7134207dd882428a66992a9de2bef7c2b699b5641f9886c3302",
|
397 |
+
"sha256:b97607a1a18a5100839aec1dc26a1ea17ee0d93b20b0f008d80a5a050afb200b"
|
398 |
+
],
|
399 |
+
"markers": "python_version < '3.8'",
|
400 |
+
"version": "==1.5.0"
|
401 |
+
},
|
402 |
+
"isort": {
|
403 |
+
"extras": [
|
404 |
+
"pyproject"
|
405 |
+
],
|
406 |
+
"hashes": [
|
407 |
+
"sha256:54da7e92468955c4fceacd0c86bd0ec997b0e1ee80d97f67c35a78b719dccab1",
|
408 |
+
"sha256:6e811fcb295968434526407adb8796944f1988c5b65e8139058f2014cbe100fd"
|
409 |
+
],
|
410 |
+
"version": "==4.3.21"
|
411 |
+
},
|
412 |
+
"jinja2": {
|
413 |
+
"hashes": [
|
414 |
+
"sha256:93187ffbc7808079673ef52771baa950426fd664d3aad1d0fa3e95644360e250",
|
415 |
+
"sha256:b0eaf100007721b5c16c1fc1eecb87409464edc10469ddc9a22a27a99123be49"
|
416 |
+
],
|
417 |
+
"version": "==2.11.1"
|
418 |
+
},
|
419 |
+
"markupsafe": {
|
420 |
+
"hashes": [
|
421 |
+
"sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473",
|
422 |
+
"sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161",
|
423 |
+
"sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235",
|
424 |
+
"sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5",
|
425 |
+
"sha256:13d3144e1e340870b25e7b10b98d779608c02016d5184cfb9927a9f10c689f42",
|
426 |
+
"sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff",
|
427 |
+
"sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b",
|
428 |
+
"sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1",
|
429 |
+
"sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e",
|
430 |
+
"sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183",
|
431 |
+
"sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66",
|
432 |
+
"sha256:596510de112c685489095da617b5bcbbac7dd6384aeebeda4df6025d0256a81b",
|
433 |
+
"sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1",
|
434 |
+
"sha256:6788b695d50a51edb699cb55e35487e430fa21f1ed838122d722e0ff0ac5ba15",
|
435 |
+
"sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1",
|
436 |
+
"sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e",
|
437 |
+
"sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b",
|
438 |
+
"sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905",
|
439 |
+
"sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735",
|
440 |
+
"sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d",
|
441 |
+
"sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e",
|
442 |
+
"sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d",
|
443 |
+
"sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c",
|
444 |
+
"sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21",
|
445 |
+
"sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2",
|
446 |
+
"sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5",
|
447 |
+
"sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b",
|
448 |
+
"sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6",
|
449 |
+
"sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f",
|
450 |
+
"sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f",
|
451 |
+
"sha256:cdb132fc825c38e1aeec2c8aa9338310d29d337bebbd7baa06889d09a60a1fa2",
|
452 |
+
"sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7",
|
453 |
+
"sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be"
|
454 |
+
],
|
455 |
+
"version": "==1.1.1"
|
456 |
},
|
457 |
"mccabe": {
|
458 |
"hashes": [
|
|
|
461 |
],
|
462 |
"version": "==0.6.1"
|
463 |
},
|
464 |
+
"more-itertools": {
|
465 |
"hashes": [
|
466 |
+
"sha256:5dd8bcf33e5f9513ffa06d5ad33d78f31e1931ac9a18f33d37e77a180d393a7c",
|
467 |
+
"sha256:b1ddb932186d8a6ac451e1d95844b382f55e12686d51ca0c68b6f61f2ab7a507"
|
468 |
],
|
469 |
+
"version": "==8.2.0"
|
|
|
470 |
},
|
471 |
+
"mypy": {
|
472 |
"hashes": [
|
473 |
+
"sha256:0a9a45157e532da06fe56adcfef8a74629566b607fa2c1ac0122d1ff995c748a",
|
474 |
+
"sha256:2c35cae79ceb20d47facfad51f952df16c2ae9f45db6cb38405a3da1cf8fc0a7",
|
475 |
+
"sha256:4b9365ade157794cef9685791032521233729cb00ce76b0ddc78749abea463d2",
|
476 |
+
"sha256:53ea810ae3f83f9c9b452582261ea859828a9ed666f2e1ca840300b69322c474",
|
477 |
+
"sha256:634aef60b4ff0f650d3e59d4374626ca6153fcaff96ec075b215b568e6ee3cb0",
|
478 |
+
"sha256:7e396ce53cacd5596ff6d191b47ab0ea18f8e0ec04e15d69728d530e86d4c217",
|
479 |
+
"sha256:7eadc91af8270455e0d73565b8964da1642fe226665dd5c9560067cd64d56749",
|
480 |
+
"sha256:7f672d02fffcbace4db2b05369142e0506cdcde20cea0e07c7c2171c4fd11dd6",
|
481 |
+
"sha256:85baab8d74ec601e86134afe2bcccd87820f79d2f8d5798c889507d1088287bf",
|
482 |
+
"sha256:87c556fb85d709dacd4b4cb6167eecc5bbb4f0a9864b69136a0d4640fdc76a36",
|
483 |
+
"sha256:a6bd44efee4dc8c3324c13785a9dc3519b3ee3a92cada42d2b57762b7053b49b",
|
484 |
+
"sha256:c6d27bd20c3ba60d5b02f20bd28e20091d6286a699174dfad515636cb09b5a72",
|
485 |
+
"sha256:e2bb577d10d09a2d8822a042a23b8d62bc3b269667c9eb8e60a6edfa000211b1",
|
486 |
+
"sha256:f97a605d7c8bc2c6d1172c2f0d5a65b24142e11a58de689046e62c2d632ca8c1"
|
487 |
],
|
488 |
"index": "pypi",
|
489 |
+
"version": "==0.761"
|
490 |
+
},
|
491 |
+
"mypy-extensions": {
|
492 |
+
"hashes": [
|
493 |
+
"sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d",
|
494 |
+
"sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"
|
495 |
+
],
|
496 |
+
"version": "==0.4.3"
|
497 |
},
|
498 |
"nodeenv": {
|
499 |
"hashes": [
|
500 |
+
"sha256:5b2438f2e42af54ca968dd1b374d14a1194848955187b0e5e4be1f73813a5212"
|
501 |
],
|
502 |
+
"version": "==1.3.5"
|
503 |
},
|
504 |
+
"packaging": {
|
505 |
"hashes": [
|
506 |
+
"sha256:170748228214b70b672c581a3dd610ee51f733018650740e98c7df862a583f73",
|
507 |
+
"sha256:e665345f9eef0c621aa0bf2f8d78cf6d21904eef16a93f020240b704a57f1334"
|
508 |
],
|
509 |
+
"version": "==20.1"
|
|
|
510 |
},
|
511 |
+
"pathspec": {
|
512 |
"hashes": [
|
513 |
+
"sha256:163b0632d4e31cef212976cf57b43d9fd6b0bac6e67c26015d611a647d5e7424",
|
514 |
+
"sha256:562aa70af2e0d434367d9790ad37aed893de47f1693e4201fd1d3dca15d19b96"
|
515 |
],
|
516 |
+
"version": "==0.7.0"
|
517 |
},
|
518 |
+
"pep8-naming": {
|
519 |
"hashes": [
|
520 |
+
"sha256:45f330db8fcfb0fba57458c77385e288e7a3be1d01e8ea4268263ef677ceea5f",
|
521 |
+
"sha256:a33d38177056321a167decd6ba70b890856ba5025f0a8eca6a3eda607da93caf"
|
522 |
],
|
523 |
+
"index": "pypi",
|
524 |
+
"version": "==0.9.1"
|
525 |
},
|
526 |
"pluggy": {
|
527 |
"hashes": [
|
528 |
+
"sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0",
|
529 |
+
"sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"
|
530 |
],
|
531 |
+
"version": "==0.13.1"
|
532 |
},
|
533 |
"pre-commit": {
|
534 |
"hashes": [
|
535 |
+
"sha256:0385479a0fe0765b1d32241f6b5358668cb4b6496a09aaf9c79acc6530489dbb",
|
536 |
+
"sha256:bf80d9dd58bea4f45d5d71845456fdcb78c1027eda9ed562db6fa2bd7a680c3a"
|
537 |
],
|
538 |
"index": "pypi",
|
539 |
+
"version": "==2.0.1"
|
540 |
},
|
541 |
"py": {
|
542 |
"hashes": [
|
543 |
+
"sha256:5e27081401262157467ad6e7f851b7aa402c5852dbcb3dae06768434de5752aa",
|
544 |
+
"sha256:c20fdd83a5dbc0af9efd622bee9a5564e278f6380fffcacc43ba6f43db2813b0"
|
545 |
],
|
546 |
+
"version": "==1.8.1"
|
547 |
},
|
548 |
"pycodestyle": {
|
549 |
"hashes": [
|
|
|
561 |
},
|
562 |
"pygments": {
|
563 |
"hashes": [
|
564 |
+
"sha256:2a3fe295e54a20164a9df49c75fa58526d3be48e14aceba6d6b1e8ac0bfd6f1b",
|
565 |
+
"sha256:98c8aa5a9f778fcd1026a17361ddaf7330d1b7c62ae97c3bb0ae73e0b9b6b0fe"
|
566 |
+
],
|
567 |
+
"version": "==2.5.2"
|
568 |
+
},
|
569 |
+
"pyparsing": {
|
570 |
+
"hashes": [
|
571 |
+
"sha256:4c830582a84fb022400b85429791bc551f1f4871c33f23e44f353119e92f969f",
|
572 |
+
"sha256:c342dccb5250c08d45fd6f8b4a559613ca603b57498511740e65cd11a2e7dcec"
|
573 |
],
|
574 |
+
"version": "==2.4.6"
|
575 |
},
|
576 |
"pytest": {
|
577 |
"hashes": [
|
578 |
+
"sha256:0d5fe9189a148acc3c3eb2ac8e1ac0742cb7618c084f3d228baaec0c254b318d",
|
579 |
+
"sha256:ff615c761e25eb25df19edddc0b970302d2a9091fbce0e7213298d85fb61fef6"
|
580 |
],
|
581 |
"index": "pypi",
|
582 |
+
"version": "==5.3.5"
|
583 |
},
|
584 |
"pytest-cov": {
|
585 |
"hashes": [
|
586 |
+
"sha256:cc6742d8bac45070217169f5f72ceee1e0e55b0221f54bcf24845972d3a47f2b",
|
587 |
+
"sha256:cdbdef4f870408ebdbfeb44e63e07eb18bb4619fae852f6e760645fa36172626"
|
588 |
],
|
589 |
"index": "pypi",
|
590 |
+
"version": "==2.8.1"
|
591 |
},
|
592 |
"pytest-mock": {
|
593 |
"hashes": [
|
594 |
+
"sha256:b35eb281e93aafed138db25c8772b95d3756108b601947f89af503f8c629413f",
|
595 |
+
"sha256:cb67402d87d5f53c579263d37971a164743dc33c159dfb4fb4a86f37c5552307"
|
596 |
],
|
597 |
"index": "pypi",
|
598 |
+
"version": "==2.0.0"
|
599 |
},
|
600 |
+
"pytest-profiling": {
|
601 |
"hashes": [
|
602 |
+
"sha256:93938f147662225d2b8bd5af89587b979652426a8a6ffd7e73ec4a23e24b7f29",
|
603 |
+
"sha256:999cc9ac94f2e528e3f5d43465da277429984a1c237ae9818f8cfd0b06acb019"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
604 |
],
|
605 |
+
"index": "pypi",
|
606 |
+
"version": "==1.7.0"
|
607 |
},
|
608 |
+
"pytz": {
|
609 |
"hashes": [
|
610 |
+
"sha256:1c557d7d0e871de1f5ccd5833f60fb2550652da6be2693c1e02300743d21500d",
|
611 |
+
"sha256:b02c06db6cf09c12dd25137e563b31700d3b80fcc4ad23abb7a315f2789819be"
|
612 |
],
|
613 |
+
"version": "==2019.3"
|
614 |
+
},
|
615 |
+
"pyyaml": {
|
616 |
+
"hashes": [
|
617 |
+
"sha256:059b2ee3194d718896c0ad077dd8c043e5e909d9180f387ce42012662a4946d6",
|
618 |
+
"sha256:1cf708e2ac57f3aabc87405f04b86354f66799c8e62c28c5fc5f88b5521b2dbf",
|
619 |
+
"sha256:24521fa2890642614558b492b473bee0ac1f8057a7263156b02e8b14c88ce6f5",
|
620 |
+
"sha256:4fee71aa5bc6ed9d5f116327c04273e25ae31a3020386916905767ec4fc5317e",
|
621 |
+
"sha256:70024e02197337533eef7b85b068212420f950319cc8c580261963aefc75f811",
|
622 |
+
"sha256:74782fbd4d4f87ff04159e986886931456a1894c61229be9eaf4de6f6e44b99e",
|
623 |
+
"sha256:940532b111b1952befd7db542c370887a8611660d2b9becff75d39355303d82d",
|
624 |
+
"sha256:cb1f2f5e426dc9f07a7681419fe39cee823bb74f723f36f70399123f439e9b20",
|
625 |
+
"sha256:dbbb2379c19ed6042e8f11f2a2c66d39cceb8aeace421bfc29d085d93eda3689",
|
626 |
+
"sha256:e3a057b7a64f1222b56e47bcff5e4b94c4f61faac04c7c4ecb1985e18caa3994",
|
627 |
+
"sha256:e9f45bd5b92c7974e59bcd2dcc8631a6b6cc380a904725fce7bc08872e691615"
|
628 |
+
],
|
629 |
+
"version": "==5.3"
|
630 |
+
},
|
631 |
+
"regex": {
|
632 |
+
"hashes": [
|
633 |
+
"sha256:07b39bf943d3d2fe63d46281d8504f8df0ff3fe4c57e13d1656737950e53e525",
|
634 |
+
"sha256:0932941cdfb3afcbc26cc3bcf7c3f3d73d5a9b9c56955d432dbf8bbc147d4c5b",
|
635 |
+
"sha256:0e182d2f097ea8549a249040922fa2b92ae28be4be4895933e369a525ba36576",
|
636 |
+
"sha256:10671601ee06cf4dc1bc0b4805309040bb34c9af423c12c379c83d7895622bb5",
|
637 |
+
"sha256:23e2c2c0ff50f44877f64780b815b8fd2e003cda9ce817a7fd00dea5600c84a0",
|
638 |
+
"sha256:26ff99c980f53b3191d8931b199b29d6787c059f2e029b2b0c694343b1708c35",
|
639 |
+
"sha256:27429b8d74ba683484a06b260b7bb00f312e7c757792628ea251afdbf1434003",
|
640 |
+
"sha256:3e77409b678b21a056415da3a56abfd7c3ad03da71f3051bbcdb68cf44d3c34d",
|
641 |
+
"sha256:4e8f02d3d72ca94efc8396f8036c0d3bcc812aefc28ec70f35bb888c74a25161",
|
642 |
+
"sha256:4eae742636aec40cf7ab98171ab9400393360b97e8f9da67b1867a9ee0889b26",
|
643 |
+
"sha256:6a6ae17bf8f2d82d1e8858a47757ce389b880083c4ff2498dba17c56e6c103b9",
|
644 |
+
"sha256:6a6ba91b94427cd49cd27764679024b14a96874e0dc638ae6bdd4b1a3ce97be1",
|
645 |
+
"sha256:7bcd322935377abcc79bfe5b63c44abd0b29387f267791d566bbb566edfdd146",
|
646 |
+
"sha256:98b8ed7bb2155e2cbb8b76f627b2fd12cf4b22ab6e14873e8641f266e0fb6d8f",
|
647 |
+
"sha256:bd25bb7980917e4e70ccccd7e3b5740614f1c408a642c245019cff9d7d1b6149",
|
648 |
+
"sha256:d0f424328f9822b0323b3b6f2e4b9c90960b24743d220763c7f07071e0778351",
|
649 |
+
"sha256:d58e4606da2a41659c84baeb3cfa2e4c87a74cec89a1e7c56bee4b956f9d7461",
|
650 |
+
"sha256:e3cd21cc2840ca67de0bbe4071f79f031c81418deb544ceda93ad75ca1ee9f7b",
|
651 |
+
"sha256:e6c02171d62ed6972ca8631f6f34fa3281d51db8b326ee397b9c83093a6b7242",
|
652 |
+
"sha256:e7c7661f7276507bce416eaae22040fd91ca471b5b33c13f8ff21137ed6f248c",
|
653 |
+
"sha256:ecc6de77df3ef68fee966bb8cb4e067e84d4d1f397d0ef6fce46913663540d77"
|
654 |
+
],
|
655 |
+
"version": "==2020.1.8"
|
656 |
},
|
657 |
"requests": {
|
658 |
"hashes": [
|
659 |
+
"sha256:11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4",
|
660 |
+
"sha256:9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31"
|
661 |
],
|
662 |
+
"version": "==2.22.0"
|
663 |
},
|
664 |
+
"six": {
|
665 |
"hashes": [
|
666 |
+
"sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a",
|
667 |
+
"sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"
|
668 |
],
|
669 |
+
"version": "==1.14.0"
|
670 |
+
},
|
671 |
+
"snowballstemmer": {
|
672 |
+
"hashes": [
|
673 |
+
"sha256:209f257d7533fdb3cb73bdbd24f436239ca3b2fa67d56f6ff88e86be08cc5ef0",
|
674 |
+
"sha256:df3bac3df4c2c01363f3dd2cfa78cce2840a79b9f1c2d2de9ce8d31683992f52"
|
675 |
+
],
|
676 |
+
"version": "==2.0.0"
|
677 |
},
|
678 |
+
"sphinx": {
|
679 |
"hashes": [
|
680 |
+
"sha256:5024a67f065fe60d9db2005580074d81f22a02dd8f00a5b1ec3d5f4d42bc88d8",
|
681 |
+
"sha256:f929b72e0cfe45fa581b8964d54457117863a6a6c9369ecc1a65b8827abd3bf2"
|
682 |
+
],
|
683 |
+
"version": "==2.4.1"
|
684 |
+
},
|
685 |
+
"sphinx-rtd-theme": {
|
686 |
+
"hashes": [
|
687 |
+
"sha256:00cf895504a7895ee433807c62094cf1e95f065843bf3acd17037c3e9a2becd4",
|
688 |
+
"sha256:728607e34d60456d736cc7991fd236afb828b21b82f956c5ea75f94c8414040a"
|
|
|
|
|
689 |
],
|
690 |
"index": "pypi",
|
691 |
+
"version": "==0.4.3"
|
692 |
},
|
693 |
+
"sphinxcontrib-applehelp": {
|
694 |
"hashes": [
|
695 |
+
"sha256:edaa0ab2b2bc74403149cb0209d6775c96de797dfd5b5e2a71981309efab3897",
|
696 |
+
"sha256:fb8dee85af95e5c30c91f10e7eb3c8967308518e0f7488a2828ef7bc191d0d5d"
|
697 |
],
|
698 |
+
"version": "==1.0.1"
|
699 |
},
|
700 |
+
"sphinxcontrib-devhelp": {
|
701 |
"hashes": [
|
702 |
+
"sha256:6c64b077937330a9128a4da74586e8c2130262f014689b4b89e2d08ee7294a34",
|
703 |
+
"sha256:9512ecb00a2b0821a146736b39f7aeb90759834b07e81e8cc23a9c70bacb9981"
|
704 |
],
|
705 |
+
"version": "==1.0.1"
|
706 |
+
},
|
707 |
+
"sphinxcontrib-htmlhelp": {
|
708 |
+
"hashes": [
|
709 |
+
"sha256:4670f99f8951bd78cd4ad2ab962f798f5618b17675c35c5ac3b2132a14ea8422",
|
710 |
+
"sha256:d4fd39a65a625c9df86d7fa8a2d9f3cd8299a3a4b15db63b50aac9e161d8eff7"
|
711 |
+
],
|
712 |
+
"version": "==1.0.2"
|
713 |
+
},
|
714 |
+
"sphinxcontrib-jsmath": {
|
715 |
+
"hashes": [
|
716 |
+
"sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178",
|
717 |
+
"sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"
|
718 |
+
],
|
719 |
+
"version": "==1.0.1"
|
720 |
+
},
|
721 |
+
"sphinxcontrib-qthelp": {
|
722 |
+
"hashes": [
|
723 |
+
"sha256:513049b93031beb1f57d4daea74068a4feb77aa5630f856fcff2e50de14e9a20",
|
724 |
+
"sha256:79465ce11ae5694ff165becda529a600c754f4bc459778778c7017374d4d406f"
|
725 |
+
],
|
726 |
+
"version": "==1.0.2"
|
727 |
+
},
|
728 |
+
"sphinxcontrib-serializinghtml": {
|
729 |
+
"hashes": [
|
730 |
+
"sha256:c0efb33f8052c04fd7a26c0a07f1678e8512e0faec19f4aa8f2473a8b81d5227",
|
731 |
+
"sha256:db6615af393650bf1151a6cd39120c29abaf93cc60db8c48eb2dddbfdc3a9768"
|
732 |
+
],
|
733 |
+
"version": "==1.1.3"
|
734 |
},
|
735 |
+
"testfixtures": {
|
736 |
"hashes": [
|
737 |
+
"sha256:0a8a369dba5e01fe6b8da9300379d60fe62094536c8d971b559ec8167ab1fce3",
|
738 |
+
"sha256:fb42846633b159e38f2c7ef2056818e9f15ee9689f5b0a8a88b4775957853048"
|
739 |
],
|
740 |
+
"version": "==6.12.1"
|
741 |
},
|
742 |
+
"toml": {
|
743 |
"hashes": [
|
744 |
+
"sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c",
|
745 |
+
"sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e"
|
746 |
+
],
|
747 |
+
"version": "==0.10.0"
|
748 |
+
},
|
749 |
+
"typed-ast": {
|
750 |
+
"hashes": [
|
751 |
+
"sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355",
|
752 |
+
"sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919",
|
753 |
+
"sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa",
|
754 |
+
"sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652",
|
755 |
+
"sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75",
|
756 |
+
"sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01",
|
757 |
+
"sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d",
|
758 |
+
"sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1",
|
759 |
+
"sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907",
|
760 |
+
"sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c",
|
761 |
+
"sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3",
|
762 |
+
"sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b",
|
763 |
+
"sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614",
|
764 |
+
"sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb",
|
765 |
+
"sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b",
|
766 |
+
"sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41",
|
767 |
+
"sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6",
|
768 |
+
"sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34",
|
769 |
+
"sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe",
|
770 |
+
"sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4",
|
771 |
+
"sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"
|
772 |
+
],
|
773 |
+
"version": "==1.4.1"
|
774 |
+
},
|
775 |
+
"typing-extensions": {
|
776 |
+
"hashes": [
|
777 |
+
"sha256:091ecc894d5e908ac75209f10d5b4f118fbdb2eb1ede6a63544054bb1edb41f2",
|
778 |
+
"sha256:910f4656f54de5993ad9304959ce9bb903f90aadc7c67a0bef07e678014e892d",
|
779 |
+
"sha256:cf8b63fedea4d89bab840ecbb93e75578af28f76f66c35889bd7065f5af88575"
|
780 |
],
|
781 |
"index": "pypi",
|
782 |
+
"version": "==3.7.4.1"
|
783 |
},
|
784 |
"urllib3": {
|
785 |
"hashes": [
|
786 |
+
"sha256:2f3db8b19923a873b3e5256dc9c2dedfa883e33d87c690d9c7913e1f40673cdc",
|
787 |
+
"sha256:87716c2d2a7121198ebcb7ce7cccf6ce5e9ba539041cfbaeecfb641dc0bf6acc"
|
788 |
],
|
789 |
+
"version": "==1.25.8"
|
790 |
},
|
791 |
"virtualenv": {
|
792 |
"hashes": [
|
793 |
+
"sha256:08f3623597ce73b85d6854fb26608a6f39ee9d055c81178dc6583803797f8994",
|
794 |
+
"sha256:de2cbdd5926c48d7b84e0300dea9e8f276f61d186e8e49223d71d91250fbaebd"
|
795 |
],
|
796 |
+
"version": "==20.0.4"
|
797 |
},
|
798 |
+
"wcwidth": {
|
799 |
"hashes": [
|
800 |
+
"sha256:8fd29383f539be45b20bd4df0dc29c20ba48654a41e661925e612311e9f3c603",
|
801 |
+
"sha256:f28b3e8a6483e5d49e7f8949ac1a78314e740333ae305b4ba5defd3e74fb37a8"
|
802 |
],
|
803 |
+
"version": "==0.1.8"
|
804 |
},
|
805 |
"zipp": {
|
806 |
"hashes": [
|
807 |
+
"sha256:5c56e330306215cd3553342cfafc73dda2c60792384117893f3a83f8a1209f50",
|
808 |
+
"sha256:d65287feb793213ffe11c0f31b81602be31448f38aeb8ffc2eb286c4f6f6657e"
|
809 |
],
|
810 |
+
"version": "==2.2.0"
|
811 |
}
|
812 |
}
|
813 |
}
|
README.md
CHANGED
@@ -1,69 +1,68 @@
|
|
1 |
|
2 |
<div align="center">
|
3 |
-
<p>
|
4 |
-
<img src="https://github.com/nficano/pytube/blob/master/images/pytube.png?raw=true" width="350" height="328" alt="pytube logo" />
|
5 |
-
</p>
|
6 |
<p align="center">
|
7 |
-
<img src="https://img.shields.io/pypi/v/
|
8 |
-
<a href="https://
|
9 |
-
<a href="
|
10 |
-
<a href=
|
11 |
-
|
12 |
-
<a href="https://
|
|
|
13 |
</p>
|
14 |
</div>
|
15 |
|
16 |
-
#
|
17 |
-
*pytube* is a very serious, lightweight, dependency-free Python library (and command-line utility) for downloading YouTube Videos.
|
18 |
|
19 |
-
##
|
20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
21 |
|
22 |
-
|
23 |
|
24 |
-
|
25 |
|
26 |
-
|
|
|
|
|
|
|
27 |
|
28 |
-
### Behold, a perfect balance of simplicity versus flexibility:
|
29 |
|
|
|
30 |
```python
|
31 |
-
>>> YouTube
|
|
|
|
|
32 |
>>> yt = YouTube('http://youtube.com/watch?v=9bZkp7q19f0')
|
33 |
>>> yt.streams
|
34 |
... .filter(progressive=True, file_extension='mp4')
|
35 |
-
... .order_by('resolution')
|
36 |
-
... .desc()
|
37 |
-
... .first()
|
38 |
... .download()
|
39 |
```
|
|
|
40 |
|
41 |
## Features
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
##
|
53 |
-
|
54 |
-
Download using pip via pypi.
|
55 |
-
|
56 |
-
```bash
|
57 |
-
$ pip install pytube
|
58 |
-
```
|
59 |
-
|
60 |
-
## Getting started
|
61 |
|
62 |
Let's begin with showing how easy it is to download a video with pytube:
|
63 |
|
64 |
```python
|
65 |
>>> from pytube import YouTube
|
66 |
-
>>> YouTube('http://youtube.com/watch?v=9bZkp7q19f0').streams.
|
67 |
```
|
68 |
This example will download the highest quality progressive download stream available.
|
69 |
|
@@ -71,7 +70,7 @@ Next, let's explore how we would view what video streams are available:
|
|
71 |
|
72 |
```python
|
73 |
>>> yt = YouTube('http://youtube.com/watch?v=9bZkp7q19f0')
|
74 |
-
>>> yt.streams
|
75 |
[<Stream: itag="22" mime_type="video/mp4" res="720p" fps="30fps" vcodec="avc1.64001F" acodec="mp4a.40.2">,
|
76 |
<Stream: itag="43" mime_type="video/webm" res="360p" fps="30fps" vcodec="vp8.0" acodec="vorbis">,
|
77 |
<Stream: itag="18" mime_type="video/mp4" res="360p" fps="30fps" vcodec="avc1.42001E" acodec="mp4a.40.2">,
|
@@ -95,6 +94,9 @@ Next, let's explore how we would view what video streams are available:
|
|
95 |
<Stream: itag="250" mime_type="audio/webm" abr="70kbps" acodec="opus">,
|
96 |
<Stream: itag="251" mime_type="audio/webm" abr="160kbps" acodec="opus">]
|
97 |
```
|
|
|
|
|
|
|
98 |
You may notice that some streams listed have both a video codec and audio codec, while others have just video or just audio, this is a result of YouTube supporting a streaming technique called Dynamic Adaptive Streaming over HTTP (DASH).
|
99 |
|
100 |
In the context of pytube, the implications are for the highest quality streams; you now need to download both the audio and video tracks and then post-process them with software like FFmpeg to merge them.
|
@@ -104,7 +106,7 @@ The legacy streams that contain the audio and video in a single file (referred t
|
|
104 |
To only view these progressive download streams:
|
105 |
|
106 |
```python
|
107 |
-
>>> yt.streams.filter(progressive=True)
|
108 |
[<Stream: itag="22" mime_type="video/mp4" res="720p" fps="30fps" vcodec="avc1.64001F" acodec="mp4a.40.2">,
|
109 |
<Stream: itag="43" mime_type="video/webm" res="360p" fps="30fps" vcodec="vp8.0" acodec="vorbis">,
|
110 |
<Stream: itag="18" mime_type="video/mp4" res="360p" fps="30fps" vcodec="avc1.42001E" acodec="mp4a.40.2">,
|
@@ -115,7 +117,7 @@ To only view these progressive download streams:
|
|
115 |
Conversely, if you only want to see the DASH streams (also referred to as "adaptive") you can do:
|
116 |
|
117 |
```python
|
118 |
-
>>> yt.streams.filter(adaptive=True)
|
119 |
[<Stream: itag="137" mime_type="video/mp4" res="1080p" fps="30fps" vcodec="avc1.640028">,
|
120 |
<Stream: itag="248" mime_type="video/webm" res="1080p" fps="30fps" vcodec="vp9">,
|
121 |
<Stream: itag="136" mime_type="video/mp4" res="720p" fps="30fps" vcodec="avc1.4d401f">,
|
@@ -135,24 +137,26 @@ Conversely, if you only want to see the DASH streams (also referred to as "adapt
|
|
135 |
<Stream: itag="251" mime_type="audio/webm" abr="160kbps" acodec="opus">]
|
136 |
```
|
137 |
|
|
|
|
|
138 |
You can also download a complete Youtube playlist:
|
139 |
|
140 |
```python
|
141 |
>>> from pytube import Playlist
|
142 |
-
>>>
|
143 |
-
>>>
|
144 |
-
>>>
|
145 |
-
>>> pl.download_all('/path/to/directory/')
|
146 |
```
|
147 |
-
This will download the highest progressive stream available (generally 720p) from the given playlist.
|
148 |
-
|
|
|
149 |
|
150 |
Pytube allows you to filter on every property available (see the documentation for the complete list), let's take a look at some of the most useful ones.
|
151 |
|
152 |
To list the audio only streams:
|
153 |
|
154 |
```python
|
155 |
-
>>> yt.streams.filter(only_audio=True)
|
156 |
[<Stream: itag="140" mime_type="audio/mp4" abr="128kbps" acodec="mp4a.40.2">,
|
157 |
<Stream: itag="171" mime_type="audio/webm" abr="128kbps" acodec="vorbis">,
|
158 |
<Stream: itag="249" mime_type="audio/webm" abr="50kbps" acodec="opus">,
|
@@ -163,7 +167,7 @@ To list the audio only streams:
|
|
163 |
To list only ``mp4`` streams:
|
164 |
|
165 |
```python
|
166 |
-
>>> yt.streams.filter(subtype='mp4')
|
167 |
[<Stream: itag="22" mime_type="video/mp4" res="720p" fps="30fps" vcodec="avc1.64001F" acodec="mp4a.40.2">,
|
168 |
<Stream: itag="18" mime_type="video/mp4" res="360p" fps="30fps" vcodec="avc1.42001E" acodec="mp4a.40.2">,
|
169 |
<Stream: itag="137" mime_type="video/mp4" res="1080p" fps="30fps" vcodec="avc1.640028">,
|
@@ -178,9 +182,9 @@ To list only ``mp4`` streams:
|
|
178 |
Multiple filters can also be specified:
|
179 |
|
180 |
```python
|
181 |
-
>>> yt.streams.filter(subtype='mp4', progressive=True)
|
182 |
>>> # this can also be expressed as:
|
183 |
-
>>> yt.streams.filter(subtype='mp4').filter(progressive=True)
|
184 |
[<Stream: itag="22" mime_type="video/mp4" res="720p" fps="30fps" vcodec="avc1.64001F" acodec="mp4a.40.2">,
|
185 |
<Stream: itag="18" mime_type="video/mp4" res="360p" fps="30fps" vcodec="avc1.42001E" acodec="mp4a.40.2">]
|
186 |
```
|
@@ -194,14 +198,16 @@ You also have an interface to select streams by their itag, without needing to f
|
|
194 |
If you need to optimize for a specific feature, such as the "highest resolution" or "lowest average bitrate":
|
195 |
|
196 |
```python
|
197 |
-
>>> yt.streams.filter(progressive=True).order_by('resolution').desc()
|
198 |
```
|
199 |
-
Note
|
|
|
|
|
200 |
|
201 |
If your application requires post-processing logic, pytube allows you to specify an "on download complete" callback function:
|
202 |
|
203 |
```python
|
204 |
-
>>> def convert_to_aac(stream,
|
205 |
return # do work
|
206 |
|
207 |
>>> yt.register_on_complete_callback(convert_to_aac)
|
@@ -210,7 +216,7 @@ If your application requires post-processing logic, pytube allows you to specify
|
|
210 |
Similarly, if your application requires on-download progress logic, pytube exposes a callback for this as well:
|
211 |
|
212 |
```python
|
213 |
-
>>> def show_progress_bar(stream, chunk
|
214 |
return # do work
|
215 |
|
216 |
>>> yt.register_on_progress_callback(show_progress_bar)
|
@@ -218,17 +224,86 @@ Similarly, if your application requires on-download progress logic, pytube expos
|
|
218 |
|
219 |
## Command-line interface
|
220 |
|
221 |
-
|
222 |
|
223 |
Let's start with downloading:
|
224 |
|
225 |
```bash
|
226 |
-
$
|
227 |
```
|
228 |
To view available streams:
|
229 |
|
230 |
```bash
|
231 |
-
$
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
232 |
```
|
233 |
|
234 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
|
2 |
<div align="center">
|
|
|
|
|
|
|
3 |
<p align="center">
|
4 |
+
<a href="https://pypi.org/project/pytube3/"><img src="https://img.shields.io/pypi/v/pytube3.svg" alt="pypi"></a>
|
5 |
+
<a href="https://pypi.python.org/pypi/pytube3/"><img src="https://img.shields.io/pypi/pyversions/pytube3.svg" /></a>
|
6 |
+
<a href="https://travis-ci.com/hbmartin/pytube3/"><img src="https://travis-ci.org/hbmartin/pytube3.svg?branch=master" /></a>
|
7 |
+
<a href='https://pytube3.readthedocs.io/en/latest/?badge=latest'><img src='https://readthedocs.org/projects/pytube3/badge/?version=latest' alt='Documentation Status' /></a>
|
8 |
+
<a href="https://codecov.io/gh/hbmartin/pytube3"><img src="https://codecov.io/gh/hbmartin/pytube3/branch/master/graph/badge.svg" /></a>
|
9 |
+
<a href="https://www.codefactor.io/repository/github/hbmartin/pytube3/overview/master"><img src="https://www.codefactor.io/repository/github/hbmartin/pytube3/badge/master" alt="CodeFactor" /></a>
|
10 |
+
<a href="https://gitter.im/pytube3/community"><img src="https://img.shields.io/badge/chat-gitter-lightgrey" /></a>
|
11 |
</p>
|
12 |
</div>
|
13 |
|
14 |
+
# pytube3
|
|
|
15 |
|
16 |
+
## Table of Contents
|
17 |
+
* [Installation](#installation)
|
18 |
+
* [Quick start](#quick-start)
|
19 |
+
* [Features](#features)
|
20 |
+
* [Usage](#usage)
|
21 |
+
* [Command-line interface](#command-line-interface)
|
22 |
+
* [Development](#development)
|
23 |
+
* [GUIs and other libraries](#guis-and-other-libraries)
|
24 |
|
25 |
+
## Installation
|
26 |
|
27 |
+
Download using pip via pypi.
|
28 |
|
29 |
+
```bash
|
30 |
+
$ pip install pytube3 --upgrade
|
31 |
+
```
|
32 |
+
(Mac/homebrew users may need to use ``pip3``)
|
33 |
|
|
|
34 |
|
35 |
+
## Quick start
|
36 |
```python
|
37 |
+
>>> from pytube import YouTube
|
38 |
+
>>> YouTube('https://youtu.be/9bZkp7q19f0').streams.get_highest_resolution().download()
|
39 |
+
>>>
|
40 |
>>> yt = YouTube('http://youtube.com/watch?v=9bZkp7q19f0')
|
41 |
>>> yt.streams
|
42 |
... .filter(progressive=True, file_extension='mp4')
|
43 |
+
... .order_by('resolution')[-1]
|
|
|
|
|
44 |
... .download()
|
45 |
```
|
46 |
+
A GUI frontend for pytube3 is available at [YouTubeDownload](https://github.com/YouTubeDownload/YouTubeDownload)
|
47 |
|
48 |
## Features
|
49 |
+
* Support for Both Progressive & DASH Streams
|
50 |
+
* Support for downloading complete playlist
|
51 |
+
* Easily Register ``on_download_progress`` & ``on_download_complete`` callbacks
|
52 |
+
* Command-line Interfaced Included
|
53 |
+
* Caption Track Support
|
54 |
+
* Outputs Caption Tracks to .srt format (SubRip Subtitle)
|
55 |
+
* Ability to Capture Thumbnail URL.
|
56 |
+
* Extensively Documented Source Code
|
57 |
+
* No Third-Party Dependencies
|
58 |
+
|
59 |
+
## Usage
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
60 |
|
61 |
Let's begin with showing how easy it is to download a video with pytube:
|
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">,
|
|
|
94 |
<Stream: itag="250" mime_type="audio/webm" abr="70kbps" acodec="opus">,
|
95 |
<Stream: itag="251" mime_type="audio/webm" abr="160kbps" acodec="opus">]
|
96 |
```
|
97 |
+
|
98 |
+
### Selecting an itag
|
99 |
+
|
100 |
You may notice that some streams listed have both a video codec and audio codec, while others have just video or just audio, this is a result of YouTube supporting a streaming technique called Dynamic Adaptive Streaming over HTTP (DASH).
|
101 |
|
102 |
In the context of pytube, the implications are for the highest quality streams; you now need to download both the audio and video tracks and then post-process them with software like FFmpeg to merge them.
|
|
|
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">,
|
|
|
137 |
<Stream: itag="251" mime_type="audio/webm" abr="160kbps" acodec="opus">]
|
138 |
```
|
139 |
|
140 |
+
### Playlists
|
141 |
+
|
142 |
You can also download a complete Youtube playlist:
|
143 |
|
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.
|
151 |
+
|
152 |
+
### Filtering
|
153 |
|
154 |
Pytube allows you to filter on every property available (see the documentation for the complete list), let's take a look at some of the most useful ones.
|
155 |
|
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 |
+
|
205 |
+
### Callbacks
|
206 |
|
207 |
If your application requires post-processing logic, pytube allows you to specify an "on download complete" callback function:
|
208 |
|
209 |
```python
|
210 |
+
>>> def convert_to_aac(stream: Stream, file_path: str):
|
211 |
return # do work
|
212 |
|
213 |
>>> yt.register_on_complete_callback(convert_to_aac)
|
|
|
216 |
Similarly, if your application requires on-download progress logic, pytube exposes a callback for this as well:
|
217 |
|
218 |
```python
|
219 |
+
>>> def show_progress_bar(stream: Stream, chunk: bytes, bytes_remaining: int):
|
220 |
return # do work
|
221 |
|
222 |
>>> yt.register_on_progress_callback(show_progress_bar)
|
|
|
224 |
|
225 |
## Command-line interface
|
226 |
|
227 |
+
pytube3 ships with a simple CLI interface for downloading videos, playlists, and captions.
|
228 |
|
229 |
Let's start with downloading:
|
230 |
|
231 |
```bash
|
232 |
+
$ pytube3 http://youtube.com/watch?v=9bZkp7q19f0 --itag=18
|
233 |
```
|
234 |
To view available streams:
|
235 |
|
236 |
```bash
|
237 |
+
$ pytube3 http://youtube.com/watch?v=9bZkp7q19f0 --list
|
238 |
+
```
|
239 |
+
|
240 |
+
The complete set of flags are:
|
241 |
+
|
242 |
+
```
|
243 |
+
usage: pytube3 [-h] [--version] [--itag ITAG] [-r RESOLUTION] [-l] [-v]
|
244 |
+
[--build-playback-report] [-c [CAPTION_CODE]] [-t TARGET]
|
245 |
+
[-a [AUDIO]] [-f [FFMPEG]]
|
246 |
+
[url]
|
247 |
+
|
248 |
+
Command line application to download youtube videos.
|
249 |
+
|
250 |
+
positional arguments:
|
251 |
+
url The YouTube /watch or /playlist url
|
252 |
+
|
253 |
+
optional arguments:
|
254 |
+
-h, --help show this help message and exit
|
255 |
+
--version show program's version number and exit
|
256 |
+
--itag ITAG The itag for the desired stream
|
257 |
+
-r RESOLUTION, --resolution RESOLUTION
|
258 |
+
The resolution for the desired stream
|
259 |
+
-l, --list The list option causes pytube cli to return a list of
|
260 |
+
streams available to download
|
261 |
+
-v, --verbose Verbosity level, use up to 4 to increase logging -vvvv
|
262 |
+
--build-playback-report
|
263 |
+
Save the html and js to disk
|
264 |
+
-c [CAPTION_CODE], --caption-code [CAPTION_CODE]
|
265 |
+
Download srt captions for given language code. Prints
|
266 |
+
available language codes if no argument given
|
267 |
+
-t TARGET, --target TARGET
|
268 |
+
The output directory for the downloaded stream.
|
269 |
+
Default is current working directory
|
270 |
+
-a [AUDIO], --audio [AUDIO]
|
271 |
+
Download the audio for a given URL at the highest
|
272 |
+
bitrate availableDefaults to mp4 format if none is
|
273 |
+
specified
|
274 |
+
-f [FFMPEG], --ffmpeg [FFMPEG]
|
275 |
+
Downloads the audio and video stream for resolution
|
276 |
+
providedIf no resolution is provided, downloads the
|
277 |
+
best resolutionRuns the command line program ffmpeg to
|
278 |
+
combine the audio and video
|
279 |
```
|
280 |
|
281 |
+
|
282 |
+
## Development
|
283 |
+
|
284 |
+
<a href="https://deepsource.io/gh/hbmartin/pytube3/?ref=repository-badge" target="_blank"><img alt="DeepSource" title="DeepSource" src="https://static.deepsource.io/deepsource-badge-light-mini.svg"></a>
|
285 |
+
<a href="https://www.codacy.com/manual/hbmartin/pytube3?utm_source=github.com&utm_medium=referral&utm_content=hbmartin/pytube3&utm_campaign=Badge_Grade"><img src="https://api.codacy.com/project/badge/Grade/53794f06983a46829620b3284c6a5596"/></a>
|
286 |
+
<a href="https://github.com/ambv/black"><img src="https://img.shields.io/badge/code%20style-black-000000.svg" /></a>
|
287 |
+
|
288 |
+
Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change.
|
289 |
+
|
290 |
+
To run code checking before a PR use ``make test``
|
291 |
+
|
292 |
+
#### Virtual environment
|
293 |
+
|
294 |
+
Virtual environment is setup with [pipenv](https://pipenv-fork.readthedocs.io/en/latest/) and can be automatically activated with [direnv](https://direnv.net/docs/installation.html)
|
295 |
+
|
296 |
+
#### Code Formatting
|
297 |
+
|
298 |
+
This project is linted with [pyflakes](https://github.com/PyCQA/pyflakes), formatted with [black](https://github.com/ambv/black), and typed with [mypy](https://mypy.readthedocs.io/en/latest/introduction.html)
|
299 |
+
|
300 |
+
|
301 |
+
#### Code of Conduct
|
302 |
+
|
303 |
+
Treat other people with helpfulness, gratitude, and consideration! See the [Python Community Code of Conduct](https://www.python.org/psf/codeofconduct/).
|
304 |
+
|
305 |
+
## GUIs and other libraries
|
306 |
+
* [YouTubeDownload](https://github.com/YouTubeDownload/YouTubeDownload) - Featured GUI frontend for pytube3
|
307 |
+
* [Pytube-GUI](https://github.com/GAO23/Pytube-GUI) - Simple GUI frontend for pytube3
|
308 |
+
* [StackOverflow questions](https://stackoverflow.com/questions/tagged/pytube)
|
309 |
+
* [PySlackers](https://pyslackers.com/web) - Python Slack group
|
docs/api.rst
CHANGED
@@ -65,12 +65,6 @@ Mixins
|
|
65 |
.. automodule:: pytube.mixins
|
66 |
:members:
|
67 |
|
68 |
-
Compat
|
69 |
-
------
|
70 |
-
|
71 |
-
.. automodule:: pytube.compat
|
72 |
-
:members:
|
73 |
-
|
74 |
|
75 |
Helpers
|
76 |
-------
|
|
|
65 |
.. automodule:: pytube.mixins
|
66 |
:members:
|
67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
68 |
|
69 |
Helpers
|
70 |
-------
|
docs/conf.py
CHANGED
@@ -34,9 +34,9 @@ source_suffix = '.rst'
|
|
34 |
master_doc = 'index'
|
35 |
|
36 |
# General information about the project.
|
37 |
-
project = '
|
38 |
copyright = '2019, Nick Ficano'
|
39 |
-
author = 'Nick Ficano'
|
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
|
@@ -108,7 +108,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,7 +120,7 @@ latex_elements = {}
|
|
120 |
# author, documentclass [howto, manual, or own class]).
|
121 |
latex_documents = [
|
122 |
(
|
123 |
-
master_doc, '
|
124 |
'Nick Ficano', 'manual',
|
125 |
),
|
126 |
]
|
@@ -132,7 +132,7 @@ latex_documents = [
|
|
132 |
# (source start file, name, description, authors, manual section).
|
133 |
man_pages = [
|
134 |
(
|
135 |
-
master_doc, '
|
136 |
[author], 1,
|
137 |
),
|
138 |
]
|
@@ -145,8 +145,8 @@ man_pages = [
|
|
145 |
# dir menu entry, description, category)
|
146 |
texinfo_documents = [
|
147 |
(
|
148 |
-
master_doc, '
|
149 |
-
author, '
|
150 |
'Miscellaneous',
|
151 |
),
|
152 |
]
|
|
|
34 |
master_doc = 'index'
|
35 |
|
36 |
# General information about the project.
|
37 |
+
project = 'pytube3'
|
38 |
copyright = '2019, Nick Ficano'
|
39 |
+
author = 'Nick Ficano, Harold Martin'
|
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
|
|
|
108 |
# -- Options for HTMLHelp output ------------------------------------------
|
109 |
|
110 |
# Output file base name for HTML help builder.
|
111 |
+
htmlhelp_basename = 'pytube3doc'
|
112 |
|
113 |
|
114 |
# -- Options for LaTeX output ---------------------------------------------
|
|
|
120 |
# author, documentclass [howto, manual, or own class]).
|
121 |
latex_documents = [
|
122 |
(
|
123 |
+
master_doc, 'pytube3.tex', 'pytube3 Documentation',
|
124 |
'Nick Ficano', 'manual',
|
125 |
),
|
126 |
]
|
|
|
132 |
# (source start file, name, description, authors, manual section).
|
133 |
man_pages = [
|
134 |
(
|
135 |
+
master_doc, 'pytube3', 'pytube3 Documentation',
|
136 |
[author], 1,
|
137 |
),
|
138 |
]
|
|
|
145 |
# dir menu entry, description, category)
|
146 |
texinfo_documents = [
|
147 |
(
|
148 |
+
master_doc, 'pytube3', 'pytube3 Documentation',
|
149 |
+
author, 'pytube3', 'One line description of project.',
|
150 |
'Miscellaneous',
|
151 |
),
|
152 |
]
|
docs/index.rst
CHANGED
@@ -1,31 +1,24 @@
|
|
1 |
-
..
|
2 |
-
sphinx-quickstart on Mon Oct 9 02:11:41 2017.
|
3 |
-
You can adapt this file completely to your liking, but it should at least
|
4 |
-
contain the root `toctree` directive.
|
5 |
|
6 |
-
|
7 |
======
|
8 |
Release v\ |version|. (:ref:`Installation <install>`)
|
9 |
|
10 |
-
.. image:: https://img.shields.io/pypi/v/
|
11 |
:alt: Pypi
|
12 |
-
:target: https://pypi.python.org/pypi/
|
13 |
|
14 |
-
.. image:: https://travis-ci.org/
|
15 |
:alt: Build status
|
16 |
-
:target: https://travis-ci.org/
|
17 |
-
|
18 |
-
.. image:: https://readthedocs.org/projects/python-pytube/badge/?version=latest
|
19 |
-
:target: http://python-pytube.readthedocs.io/en/latest/?badge=latest
|
20 |
-
:alt: Documentation Status
|
21 |
|
22 |
.. image:: https://coveralls.io/repos/github/nficano/pytube/badge.svg?branch=master
|
23 |
:alt: Coverage
|
24 |
:target: https://coveralls.io/github/nficano/pytube?branch=master
|
25 |
|
26 |
-
.. image:: https://img.shields.io/pypi/pyversions/
|
27 |
:alt: Python Versions
|
28 |
-
:target: https://pypi.python.org/pypi/
|
29 |
|
30 |
**pytube** is a lightweight, Pythonic, dependency-free, library (and command-line utility) for downloading YouTube Videos.
|
31 |
|
@@ -33,6 +26,7 @@ Release v\ |version|. (:ref:`Installation <install>`)
|
|
33 |
|
34 |
**Behold, a perfect balance of simplicity versus flexibility**::
|
35 |
|
|
|
36 |
>>> YouTube('https://youtu.be/9bZkp7q19f0').streams.first().download()
|
37 |
>>> yt = YouTube('http://youtube.com/watch?v=9bZkp7q19f0')
|
38 |
>>> yt.streams
|
|
|
1 |
+
.. pytube3 documentation master file, created by sphinx-quickstart on Mon Oct 9 02:11:41 2017.
|
|
|
|
|
|
|
2 |
|
3 |
+
pytube3
|
4 |
======
|
5 |
Release v\ |version|. (:ref:`Installation <install>`)
|
6 |
|
7 |
+
.. image:: https://img.shields.io/pypi/v/pytube3.svg
|
8 |
:alt: Pypi
|
9 |
+
:target: https://pypi.python.org/pypi/pytube3/
|
10 |
|
11 |
+
.. image:: https://travis-ci.org/hbmartin/pytube3.svg?branch=master
|
12 |
:alt: Build status
|
13 |
+
:target: https://travis-ci.org/hbmartin/pytube3
|
|
|
|
|
|
|
|
|
14 |
|
15 |
.. image:: https://coveralls.io/repos/github/nficano/pytube/badge.svg?branch=master
|
16 |
:alt: Coverage
|
17 |
:target: https://coveralls.io/github/nficano/pytube?branch=master
|
18 |
|
19 |
+
.. image:: https://img.shields.io/pypi/pyversions/pytube3.svg
|
20 |
:alt: Python Versions
|
21 |
+
:target: https://pypi.python.org/pypi/pytube3/
|
22 |
|
23 |
**pytube** is a lightweight, Pythonic, dependency-free, library (and command-line utility) for downloading YouTube Videos.
|
24 |
|
|
|
26 |
|
27 |
**Behold, a perfect balance of simplicity versus flexibility**::
|
28 |
|
29 |
+
>>> from pytube import YouTube
|
30 |
>>> YouTube('https://youtu.be/9bZkp7q19f0').streams.first().download()
|
31 |
>>> yt = YouTube('http://youtube.com/watch?v=9bZkp7q19f0')
|
32 |
>>> yt.streams
|
docs/requirements.txt
ADDED
@@ -0,0 +1 @@
|
|
|
|
|
1 |
+
typing_extensions
|
docs/user/install.rst
CHANGED
@@ -7,20 +7,20 @@ This part of the documentation covers the installation of pytube.
|
|
7 |
|
8 |
To install pytube, run the following command in your terminal::
|
9 |
|
10 |
-
$ pip install
|
11 |
|
12 |
Get the Source Code
|
13 |
-------------------
|
14 |
|
15 |
-
pytube is actively developed on GitHub, where the source is `available <https://github.com/
|
16 |
|
17 |
You can either clone the public repository::
|
18 |
|
19 |
$ git clone git://github.com/nficano/pytube.git
|
20 |
|
21 |
-
Or, download the `tarball <https://github.com/
|
22 |
|
23 |
-
$ curl -OL https://github.com/
|
24 |
# optionally, zipball is also available (for Windows users).
|
25 |
|
26 |
Once you have a copy of the source, you can embed it in your Python package, or install it into your site-packages by running::
|
|
|
7 |
|
8 |
To install pytube, run the following command in your terminal::
|
9 |
|
10 |
+
$ pip install pytube3
|
11 |
|
12 |
Get the Source Code
|
13 |
-------------------
|
14 |
|
15 |
+
pytube is actively developed on GitHub, where the source is `available <https://github.com/hbmartin/pytube3>`_.
|
16 |
|
17 |
You can either clone the public repository::
|
18 |
|
19 |
$ git clone git://github.com/nficano/pytube.git
|
20 |
|
21 |
+
Or, download the `tarball <https://github.com/hbmartin/pytube3/tarball/master>`_::
|
22 |
|
23 |
+
$ curl -OL https://github.com/hbmartin/pytube3/tarball/master
|
24 |
# optionally, zipball is also available (for Windows users).
|
25 |
|
26 |
Once you have a copy of the source, you can embed it in your Python package, or install it into your site-packages by running::
|
images/Github Social.sketch
DELETED
Binary file (654 kB)
|
|
images/pytube.png
DELETED
Binary file (372 kB)
|
|
pytube/__init__.py
CHANGED
@@ -1,22 +1,18 @@
|
|
1 |
# -*- coding: utf-8 -*-
|
2 |
-
# flake8: noqa
|
3 |
# noreorder
|
4 |
"""
|
5 |
Pytube: a very serious Python library for downloading YouTube Videos.
|
6 |
"""
|
7 |
-
__title__ =
|
8 |
-
|
9 |
-
|
10 |
-
|
11 |
-
__copyright__ = 'Copyright 2019 Nick Ficano'
|
12 |
|
13 |
-
from pytube.
|
14 |
-
from pytube.query import CaptionQuery
|
15 |
-
from pytube.query import StreamQuery
|
16 |
from pytube.streams import Stream
|
17 |
from pytube.captions import Caption
|
18 |
-
from pytube.
|
|
|
19 |
from pytube.__main__ import YouTube
|
20 |
-
|
21 |
-
logger = create_logger()
|
22 |
-
logger.info('%s v%s', __title__, __version__)
|
|
|
1 |
# -*- coding: utf-8 -*-
|
2 |
+
# flake8: noqa: F401
|
3 |
# noreorder
|
4 |
"""
|
5 |
Pytube: a very serious Python library for downloading YouTube Videos.
|
6 |
"""
|
7 |
+
__title__ = "pytube3"
|
8 |
+
__author__ = "Nick Ficano, Harold Martin"
|
9 |
+
__license__ = "MIT License"
|
10 |
+
__copyright__ = "Copyright 2019 Nick Ficano"
|
|
|
11 |
|
12 |
+
from pytube.version import __version__
|
|
|
|
|
13 |
from pytube.streams import Stream
|
14 |
from pytube.captions import Caption
|
15 |
+
from pytube.query import CaptionQuery
|
16 |
+
from pytube.query import StreamQuery
|
17 |
from pytube.__main__ import YouTube
|
18 |
+
from pytube.contrib.playlist import Playlist
|
|
|
|
pytube/__main__.py
CHANGED
@@ -7,38 +7,43 @@ exclusively on the developer interface. Pytube offloads the heavy lifting to
|
|
7 |
smaller peripheral modules and functions.
|
8 |
|
9 |
"""
|
10 |
-
from __future__ import absolute_import
|
11 |
|
12 |
import json
|
13 |
import logging
|
|
|
|
|
|
|
14 |
|
15 |
from pytube import Caption
|
16 |
from pytube import CaptionQuery
|
17 |
from pytube import extract
|
18 |
-
from pytube import mixins
|
19 |
from pytube import request
|
20 |
from pytube import Stream
|
21 |
from pytube import StreamQuery
|
22 |
-
from pytube.
|
23 |
-
from pytube.
|
24 |
from pytube.exceptions import VideoUnavailable
|
25 |
-
from pytube.
|
26 |
|
27 |
logger = logging.getLogger(__name__)
|
28 |
|
29 |
|
30 |
-
class YouTube
|
31 |
"""Core developer interface for pytube."""
|
32 |
|
33 |
def __init__(
|
34 |
-
self,
|
35 |
-
|
|
|
|
|
|
|
|
|
36 |
):
|
37 |
"""Construct a :class:`YouTube <YouTube>`.
|
38 |
|
39 |
:param str url:
|
40 |
A valid YouTube watch URL.
|
41 |
-
:param bool
|
42 |
Defers executing any network requests.
|
43 |
:param func on_progress_callback:
|
44 |
(Optional) User defined callback function for stream download
|
@@ -48,55 +53,45 @@ class YouTube(object):
|
|
48 |
complete events.
|
49 |
|
50 |
"""
|
51 |
-
self.js = None
|
52 |
-
self.js_url = None # the url to the js, parsed from watch html
|
53 |
|
54 |
# note: vid_info may eventually be removed. It sounds like it once had
|
55 |
# additional formats, but that doesn't appear to still be the case.
|
56 |
|
57 |
-
|
58 |
-
self.vid_info_url = None
|
|
|
|
|
59 |
|
60 |
-
self.watch_html = None
|
61 |
-
self.embed_html = None
|
62 |
-
self.player_config_args =
|
|
|
63 |
# streams
|
64 |
-
self.age_restricted = None
|
65 |
|
66 |
-
self.fmt_streams = []
|
67 |
-
self.caption_tracks = []
|
68 |
|
69 |
# video_id part of /watch?v=<video_id>
|
70 |
self.video_id = extract.video_id(url)
|
71 |
|
72 |
-
|
73 |
-
self.
|
74 |
|
75 |
-
|
76 |
-
|
77 |
-
|
78 |
-
|
79 |
-
# user defined callback functions.
|
80 |
-
'on_progress': on_progress_callback,
|
81 |
-
'on_complete': on_complete_callback,
|
82 |
-
}
|
83 |
|
84 |
if proxies:
|
85 |
install_proxy(proxies)
|
86 |
|
87 |
if not defer_prefetch_init:
|
88 |
-
self.
|
89 |
-
|
90 |
-
def prefetch_init(self):
|
91 |
-
"""Download data, descramble it, and build Stream instances.
|
92 |
-
|
93 |
-
:rtype: None
|
94 |
|
95 |
-
|
96 |
-
self.prefetch()
|
97 |
-
self.init()
|
98 |
-
|
99 |
-
def init(self):
|
100 |
"""Descramble the stream data and build Stream instances.
|
101 |
|
102 |
The initialization process takes advantage of Python's
|
@@ -107,60 +102,55 @@ class YouTube(object):
|
|
107 |
:rtype: None
|
108 |
|
109 |
"""
|
110 |
-
logger.info(
|
111 |
|
112 |
-
self.vid_info =
|
113 |
if self.age_restricted:
|
114 |
self.player_config_args = self.vid_info
|
115 |
else:
|
116 |
-
self.
|
117 |
-
|
118 |
-
)['args']
|
119 |
|
120 |
# Fix for KeyError: 'title' issue #434
|
121 |
-
if
|
122 |
-
i_start = (
|
123 |
-
|
124 |
-
.lower()
|
125 |
-
.index('<title>') + len('<title>')
|
126 |
-
)
|
127 |
-
i_end = self.watch_html.lower().index('</title>')
|
128 |
title = self.watch_html[i_start:i_end].strip()
|
129 |
-
index = title.lower().rfind(
|
130 |
title = title[:index] if index > 0 else title
|
131 |
-
self.player_config_args[
|
132 |
|
133 |
-
self.vid_descr = extract.get_vid_descr(self.watch_html)
|
134 |
# https://github.com/nficano/pytube/issues/165
|
135 |
-
stream_maps = [
|
136 |
-
if
|
137 |
-
stream_maps.append(
|
138 |
|
139 |
# unscramble the progressive and adaptive stream manifests.
|
140 |
for fmt in stream_maps:
|
141 |
if not self.age_restricted and fmt in self.vid_info:
|
142 |
-
|
143 |
-
|
144 |
-
|
145 |
-
|
146 |
-
|
147 |
-
|
148 |
-
self.js_url = extract.js_url(
|
149 |
-
self.embed_html, self.age_restricted,
|
150 |
-
)
|
151 |
self.js = request.get(self.js_url)
|
152 |
-
|
|
|
153 |
|
154 |
# build instances of :class:`Stream <Stream>`
|
155 |
self.initialize_stream_objects(fmt)
|
156 |
|
157 |
# load the player_response object (contains subtitle information)
|
158 |
-
|
|
|
|
|
|
|
159 |
|
160 |
-
|
161 |
-
logger.info('init finished successfully')
|
162 |
|
163 |
-
def prefetch(self):
|
164 |
"""Eagerly download all necessary data.
|
165 |
|
166 |
Eagerly executes all necessary network requests so all other
|
@@ -168,26 +158,32 @@ class YouTube(object):
|
|
168 |
which blocks for long periods of time.
|
169 |
|
170 |
:rtype: None
|
171 |
-
|
172 |
"""
|
173 |
self.watch_html = request.get(url=self.watch_url)
|
174 |
-
if
|
175 |
-
raise VideoUnavailable(
|
176 |
-
self.embed_html = request.get(url=self.embed_url)
|
177 |
self.age_restricted = extract.is_age_restricted(self.watch_html)
|
178 |
-
|
179 |
-
|
180 |
-
|
181 |
-
|
182 |
-
|
183 |
-
|
184 |
-
|
185 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
186 |
if not self.age_restricted:
|
187 |
-
self.js_url = extract.js_url(self.watch_html
|
188 |
self.js = request.get(self.js_url)
|
189 |
|
190 |
-
def initialize_stream_objects(self, fmt):
|
191 |
"""Convert manifest data to instances of :class:`Stream <Stream>`.
|
192 |
|
193 |
Take the unscrambled stream data and uses it to initialize
|
@@ -210,127 +206,131 @@ class YouTube(object):
|
|
210 |
)
|
211 |
self.fmt_streams.append(video)
|
212 |
|
213 |
-
|
214 |
-
|
215 |
-
|
216 |
-
Take the unscrambled player response data, and use it to initialize
|
217 |
-
instances of :class:`Caption <Caption>`.
|
218 |
-
|
219 |
-
:rtype: None
|
220 |
|
|
|
221 |
"""
|
222 |
-
|
223 |
-
|
224 |
-
|
225 |
-
|
226 |
-
self.player_config_args
|
227 |
-
.get('player_response', {})
|
228 |
-
.get('captions', {})
|
229 |
-
.get('playerCaptionsTracklistRenderer', {})
|
230 |
-
.get('captionTracks', [])
|
231 |
)
|
232 |
-
for
|
233 |
-
self.caption_tracks.append(Caption(caption_track))
|
234 |
|
235 |
@property
|
236 |
-
def captions(self):
|
237 |
"""Interface to query caption tracks.
|
238 |
|
239 |
:rtype: :class:`CaptionQuery <CaptionQuery>`.
|
240 |
"""
|
241 |
-
return CaptionQuery(
|
242 |
|
243 |
@property
|
244 |
-
def streams(self):
|
245 |
"""Interface to query both adaptive (DASH) and progressive streams.
|
246 |
|
247 |
:rtype: :class:`StreamQuery <StreamQuery>`.
|
248 |
"""
|
249 |
-
return StreamQuery(
|
250 |
|
251 |
@property
|
252 |
-
def thumbnail_url(self):
|
253 |
"""Get the thumbnail url image.
|
254 |
|
255 |
:rtype: str
|
256 |
|
257 |
"""
|
258 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
259 |
|
260 |
@property
|
261 |
-
def title(self):
|
262 |
"""Get the video title.
|
263 |
|
264 |
:rtype: str
|
265 |
|
266 |
"""
|
267 |
-
return self.player_config_args
|
|
|
|
|
268 |
|
269 |
@property
|
270 |
-
def description(self):
|
271 |
"""Get the video description.
|
272 |
|
273 |
:rtype: str
|
274 |
|
275 |
"""
|
276 |
-
return self.
|
|
|
|
|
277 |
|
278 |
@property
|
279 |
-
def rating(self):
|
280 |
"""Get the video average rating.
|
281 |
|
282 |
-
:rtype:
|
283 |
|
284 |
"""
|
285 |
-
return (
|
286 |
-
self.player_config_args
|
287 |
-
.get('player_response', {})
|
288 |
-
.get('videoDetails', {})
|
289 |
-
.get('averageRating')
|
290 |
-
)
|
291 |
|
292 |
@property
|
293 |
-
def length(self):
|
294 |
"""Get the video length in seconds.
|
295 |
|
296 |
:rtype: str
|
297 |
|
298 |
"""
|
299 |
-
return
|
|
|
|
|
|
|
300 |
|
301 |
@property
|
302 |
-
def views(self):
|
303 |
"""Get the number of the times the video has been viewed.
|
304 |
|
305 |
:rtype: str
|
306 |
|
307 |
"""
|
308 |
-
return (
|
309 |
-
|
310 |
-
|
311 |
-
|
312 |
-
|
313 |
-
|
|
|
|
|
314 |
|
315 |
-
def register_on_progress_callback(self, func):
|
316 |
"""Register a download progress callback function post initialization.
|
317 |
|
318 |
:param callable func:
|
319 |
A callback function that takes ``stream``, ``chunk``,
|
320 |
-
|
321 |
|
322 |
:rtype: None
|
323 |
|
324 |
"""
|
325 |
-
self.stream_monostate
|
326 |
|
327 |
-
def register_on_complete_callback(self, func):
|
328 |
"""Register a download complete callback function post initialization.
|
329 |
|
330 |
:param callable func:
|
331 |
-
A callback function that takes ``stream`` and ``
|
332 |
|
333 |
:rtype: None
|
334 |
|
335 |
"""
|
336 |
-
self.stream_monostate
|
|
|
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
|
19 |
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.monostate import OnProgress, OnComplete, Monostate
|
27 |
|
28 |
logger = logging.getLogger(__name__)
|
29 |
|
30 |
|
31 |
+
class YouTube:
|
32 |
"""Core developer interface for pytube."""
|
33 |
|
34 |
def __init__(
|
35 |
+
self,
|
36 |
+
url: str,
|
37 |
+
defer_prefetch_init: bool = False,
|
38 |
+
on_progress_callback: Optional[OnProgress] = None,
|
39 |
+
on_complete_callback: Optional[OnComplete] = None,
|
40 |
+
proxies: Dict[str, str] = None,
|
41 |
):
|
42 |
"""Construct a :class:`YouTube <YouTube>`.
|
43 |
|
44 |
:param str url:
|
45 |
A valid YouTube watch URL.
|
46 |
+
:param bool defer_prefetch_init:
|
47 |
Defers executing any network requests.
|
48 |
:param func on_progress_callback:
|
49 |
(Optional) User defined callback function for stream download
|
|
|
53 |
complete events.
|
54 |
|
55 |
"""
|
56 |
+
self.js: Optional[str] = None # js fetched by js_url
|
57 |
+
self.js_url: Optional[str] = None # the url to the js, parsed from watch html
|
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[str] = None # content fetched by vid_info_url
|
65 |
+
self.vid_info: Optional[Dict] = None # parsed content of vid_info_raw
|
66 |
|
67 |
+
self.watch_html: Optional[str] = None # the html of /watch?v=<video_id>
|
68 |
+
self.embed_html: Optional[str] = None
|
69 |
+
self.player_config_args: Dict = {} # inline js in the html containing
|
70 |
+
self.player_response: Dict = {}
|
71 |
# streams
|
72 |
+
self.age_restricted: Optional[bool] = None
|
73 |
|
74 |
+
self.fmt_streams: List[Stream] = []
|
|
|
75 |
|
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 |
+
)
|
|
|
|
|
|
|
|
|
86 |
|
87 |
if proxies:
|
88 |
install_proxy(proxies)
|
89 |
|
90 |
if not defer_prefetch_init:
|
91 |
+
self.prefetch()
|
92 |
+
self.descramble()
|
|
|
|
|
|
|
|
|
93 |
|
94 |
+
def descramble(self) -> None:
|
|
|
|
|
|
|
|
|
95 |
"""Descramble the stream data and build Stream instances.
|
96 |
|
97 |
The initialization process takes advantage of Python's
|
|
|
102 |
:rtype: None
|
103 |
|
104 |
"""
|
105 |
+
logger.info("init started")
|
106 |
|
107 |
+
self.vid_info = dict(parse_qsl(self.vid_info_raw))
|
108 |
if self.age_restricted:
|
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
|
116 |
+
i_start = self.watch_html.lower().index("<title>") + len("<title>")
|
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")
|
120 |
title = title[:index] if index > 0 else title
|
121 |
+
self.player_config_args["title"] = unescape(title)
|
122 |
|
|
|
123 |
# https://github.com/nficano/pytube/issues/165
|
124 |
+
stream_maps = ["url_encoded_fmt_stream_map"]
|
125 |
+
if "adaptive_fmts" in self.player_config_args:
|
126 |
+
stream_maps.append("adaptive_fmts")
|
127 |
|
128 |
# unscramble the progressive and adaptive stream manifests.
|
129 |
for fmt in stream_maps:
|
130 |
if not self.age_restricted and fmt in self.vid_info:
|
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)
|
144 |
|
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 |
|
153 |
+
def prefetch(self) -> None:
|
154 |
"""Eagerly download all necessary data.
|
155 |
|
156 |
Eagerly executes all necessary network requests so all other
|
|
|
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 and "This video is private" in self.watch_html:
|
168 |
+
raise VideoUnavailable(video_id=self.video_id)
|
169 |
+
|
170 |
+
if self.age_restricted:
|
171 |
+
if not self.embed_html:
|
172 |
+
self.embed_html = request.get(url=self.embed_url)
|
173 |
+
self.vid_info_url = extract.video_info_url_age_restricted(
|
174 |
+
self.video_id, self.watch_url
|
175 |
+
)
|
176 |
+
else:
|
177 |
+
self.vid_info_url = extract.video_info_url(
|
178 |
+
video_id=self.video_id, watch_url=self.watch_url
|
179 |
+
)
|
180 |
+
|
181 |
+
self.vid_info_raw = request.get(self.vid_info_url)
|
182 |
if not self.age_restricted:
|
183 |
+
self.js_url = extract.js_url(self.watch_html)
|
184 |
self.js = request.get(self.js_url)
|
185 |
|
186 |
+
def initialize_stream_objects(self, fmt: str) -> None:
|
187 |
"""Convert manifest data to instances of :class:`Stream <Stream>`.
|
188 |
|
189 |
Take the unscrambled stream data and uses it to initialize
|
|
|
206 |
)
|
207 |
self.fmt_streams.append(video)
|
208 |
|
209 |
+
@property
|
210 |
+
def caption_tracks(self) -> List[Caption]:
|
211 |
+
"""Get a list of :class:`Caption <Caption>`.
|
|
|
|
|
|
|
|
|
212 |
|
213 |
+
:rtype: List[Caption]
|
214 |
"""
|
215 |
+
raw_tracks = (
|
216 |
+
self.player_response.get("captions", {})
|
217 |
+
.get("playerCaptionsTracklistRenderer", {})
|
218 |
+
.get("captionTracks", [])
|
|
|
|
|
|
|
|
|
|
|
219 |
)
|
220 |
+
return [Caption(track) for track in raw_tracks]
|
|
|
221 |
|
222 |
@property
|
223 |
+
def captions(self) -> CaptionQuery:
|
224 |
"""Interface to query caption tracks.
|
225 |
|
226 |
:rtype: :class:`CaptionQuery <CaptionQuery>`.
|
227 |
"""
|
228 |
+
return CaptionQuery(self.caption_tracks)
|
229 |
|
230 |
@property
|
231 |
+
def streams(self) -> StreamQuery:
|
232 |
"""Interface to query both adaptive (DASH) and progressive streams.
|
233 |
|
234 |
:rtype: :class:`StreamQuery <StreamQuery>`.
|
235 |
"""
|
236 |
+
return StreamQuery(self.fmt_streams)
|
237 |
|
238 |
@property
|
239 |
+
def thumbnail_url(self) -> str:
|
240 |
"""Get the thumbnail url image.
|
241 |
|
242 |
:rtype: str
|
243 |
|
244 |
"""
|
245 |
+
thumbnail_details = (
|
246 |
+
self.player_response.get("videoDetails", {})
|
247 |
+
.get("thumbnail", {})
|
248 |
+
.get("thumbnails")
|
249 |
+
)
|
250 |
+
if thumbnail_details:
|
251 |
+
thumbnail_details = thumbnail_details[-1] # last item has max size
|
252 |
+
return thumbnail_details["url"]
|
253 |
+
|
254 |
+
return f"https://img.youtube.com/vi/{self.video_id}/maxresdefault.jpg"
|
255 |
|
256 |
@property
|
257 |
+
def title(self) -> str:
|
258 |
"""Get the video title.
|
259 |
|
260 |
:rtype: str
|
261 |
|
262 |
"""
|
263 |
+
return self.player_config_args.get("title") or (
|
264 |
+
self.player_response.get("videoDetails", {}).get("title")
|
265 |
+
)
|
266 |
|
267 |
@property
|
268 |
+
def description(self) -> str:
|
269 |
"""Get the video description.
|
270 |
|
271 |
:rtype: str
|
272 |
|
273 |
"""
|
274 |
+
return self.player_response.get("videoDetails", {}).get(
|
275 |
+
"shortDescription"
|
276 |
+
) or extract._get_vid_descr(self.watch_html)
|
277 |
|
278 |
@property
|
279 |
+
def rating(self) -> float:
|
280 |
"""Get the video average rating.
|
281 |
|
282 |
+
:rtype: float
|
283 |
|
284 |
"""
|
285 |
+
return self.player_response.get("videoDetails", {}).get("averageRating")
|
|
|
|
|
|
|
|
|
|
|
286 |
|
287 |
@property
|
288 |
+
def length(self) -> int:
|
289 |
"""Get the video length in seconds.
|
290 |
|
291 |
:rtype: str
|
292 |
|
293 |
"""
|
294 |
+
return int(
|
295 |
+
self.player_config_args.get("length_seconds")
|
296 |
+
or (self.player_response.get("videoDetails", {}).get("lengthSeconds"))
|
297 |
+
)
|
298 |
|
299 |
@property
|
300 |
+
def views(self) -> int:
|
301 |
"""Get the number of the times the video has been viewed.
|
302 |
|
303 |
:rtype: str
|
304 |
|
305 |
"""
|
306 |
+
return int(self.player_response.get("videoDetails", {}).get("viewCount"))
|
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("author", "unknown")
|
314 |
|
315 |
+
def register_on_progress_callback(self, func: OnProgress):
|
316 |
"""Register a download progress callback function post initialization.
|
317 |
|
318 |
:param callable func:
|
319 |
A callback function that takes ``stream``, ``chunk``,
|
320 |
+
and ``bytes_remaining`` as parameters.
|
321 |
|
322 |
:rtype: None
|
323 |
|
324 |
"""
|
325 |
+
self.stream_monostate.on_progress = func
|
326 |
|
327 |
+
def register_on_complete_callback(self, func: OnComplete):
|
328 |
"""Register a download complete callback function post initialization.
|
329 |
|
330 |
:param callable func:
|
331 |
+
A callback function that takes ``stream`` and ``file_path``.
|
332 |
|
333 |
:rtype: None
|
334 |
|
335 |
"""
|
336 |
+
self.stream_monostate.on_complete = func
|
pytube/captions.py
CHANGED
@@ -1,32 +1,33 @@
|
|
1 |
# -*- coding: utf-8 -*-
|
2 |
-
"""This module contrains a container for caption tracks."""
|
3 |
import math
|
|
|
4 |
import time
|
5 |
import xml.etree.ElementTree as ElementTree
|
6 |
-
|
7 |
from pytube import request
|
8 |
-
from
|
|
|
9 |
|
10 |
|
11 |
class Caption:
|
12 |
"""Container for caption tracks."""
|
13 |
|
14 |
-
def __init__(self, caption_track):
|
15 |
"""Construct a :class:`Caption <Caption>`.
|
16 |
|
17 |
:param dict caption_track:
|
18 |
Caption track data extracted from ``watch_html``.
|
19 |
"""
|
20 |
-
self.url = caption_track.get(
|
21 |
-
self.name = caption_track[
|
22 |
-
self.code = caption_track[
|
23 |
|
24 |
@property
|
25 |
-
def xml_captions(self):
|
26 |
"""Download the xml caption tracks."""
|
27 |
return request.get(self.url)
|
28 |
|
29 |
-
def generate_srt_captions(self):
|
30 |
"""Generate "SubRip Subtitle" captions.
|
31 |
|
32 |
Takes the xml captions from :meth:`~pytube.Caption.xml_captions` and
|
@@ -34,22 +35,22 @@ class Caption:
|
|
34 |
"""
|
35 |
return self.xml_caption_to_srt(self.xml_captions)
|
36 |
|
37 |
-
|
|
|
38 |
"""Convert decimal durations into proper srt format.
|
39 |
|
40 |
:rtype: str
|
41 |
:returns:
|
42 |
SubRip Subtitle (str) formatted time duration.
|
43 |
|
44 |
-
|
45 |
-
'00:00:03,890'
|
46 |
"""
|
47 |
-
|
48 |
-
time_fmt = time.strftime(
|
49 |
-
ms =
|
50 |
return time_fmt + ms
|
51 |
|
52 |
-
def xml_caption_to_srt(self, xml_captions):
|
53 |
"""Convert xml caption tracks to "SubRip Subtitle (srt)".
|
54 |
|
55 |
:param str xml_captions:
|
@@ -57,28 +58,79 @@ class Caption:
|
|
57 |
"""
|
58 |
segments = []
|
59 |
root = ElementTree.fromstring(xml_captions)
|
60 |
-
for i, child in enumerate(root
|
61 |
-
text = child.text or
|
62 |
-
caption = unescape(
|
63 |
-
|
64 |
-
|
65 |
-
.replace(' ', ' '),
|
66 |
-
)
|
67 |
-
duration = float(child.attrib['dur'])
|
68 |
-
start = float(child.attrib['start'])
|
69 |
end = start + duration
|
70 |
sequence_number = i + 1 # convert from 0-indexed to 1.
|
71 |
-
line = (
|
72 |
-
|
73 |
-
|
74 |
-
|
75 |
-
|
76 |
-
text=caption,
|
77 |
-
)
|
78 |
)
|
79 |
segments.append(line)
|
80 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
81 |
|
82 |
def __repr__(self):
|
83 |
"""Printable object representation."""
|
84 |
-
return'<Caption lang="{s.name}" code="{s.code}">'.format(s=self)
|
|
|
1 |
# -*- coding: utf-8 -*-
|
|
|
2 |
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 pytube.helpers import safe_filename, target_directory
|
10 |
|
11 |
|
12 |
class Caption:
|
13 |
"""Container for caption tracks."""
|
14 |
|
15 |
+
def __init__(self, caption_track: Dict):
|
16 |
"""Construct a :class:`Caption <Caption>`.
|
17 |
|
18 |
:param dict caption_track:
|
19 |
Caption track data extracted from ``watch_html``.
|
20 |
"""
|
21 |
+
self.url = caption_track.get("baseUrl")
|
22 |
+
self.name = caption_track["name"]["simpleText"]
|
23 |
+
self.code = caption_track["languageCode"]
|
24 |
|
25 |
@property
|
26 |
+
def xml_captions(self) -> str:
|
27 |
"""Download the xml caption tracks."""
|
28 |
return request.get(self.url)
|
29 |
|
30 |
+
def generate_srt_captions(self) -> str:
|
31 |
"""Generate "SubRip Subtitle" captions.
|
32 |
|
33 |
Takes the xml captions from :meth:`~pytube.Caption.xml_captions` and
|
|
|
35 |
"""
|
36 |
return self.xml_caption_to_srt(self.xml_captions)
|
37 |
|
38 |
+
@staticmethod
|
39 |
+
def float_to_srt_time_format(d: float) -> str:
|
40 |
"""Convert decimal durations into proper srt format.
|
41 |
|
42 |
:rtype: str
|
43 |
:returns:
|
44 |
SubRip Subtitle (str) formatted time duration.
|
45 |
|
46 |
+
float_to_srt_time_format(3.89) -> '00:00:03,890'
|
|
|
47 |
"""
|
48 |
+
fraction, whole = math.modf(d)
|
49 |
+
time_fmt = time.strftime("%H:%M:%S,", time.gmtime(whole))
|
50 |
+
ms = f"{fraction:.3f}".replace("0.", "")
|
51 |
return time_fmt + ms
|
52 |
|
53 |
+
def xml_caption_to_srt(self, xml_captions: str) -> str:
|
54 |
"""Convert xml caption tracks to "SubRip Subtitle (srt)".
|
55 |
|
56 |
:param str xml_captions:
|
|
|
58 |
"""
|
59 |
segments = []
|
60 |
root = ElementTree.fromstring(xml_captions)
|
61 |
+
for i, child in enumerate(list(root)):
|
62 |
+
text = child.text or ""
|
63 |
+
caption = unescape(text.replace("\n", " ").replace(" ", " "),)
|
64 |
+
duration = float(child.attrib["dur"])
|
65 |
+
start = float(child.attrib["start"])
|
|
|
|
|
|
|
|
|
66 |
end = start + duration
|
67 |
sequence_number = i + 1 # convert from 0-indexed to 1.
|
68 |
+
line = "{seq}\n{start} --> {end}\n{text}\n".format(
|
69 |
+
seq=sequence_number,
|
70 |
+
start=self.float_to_srt_time_format(start),
|
71 |
+
end=self.float_to_srt_time_format(end),
|
72 |
+
text=caption,
|
|
|
|
|
73 |
)
|
74 |
segments.append(line)
|
75 |
+
return "\n".join(segments).strip()
|
76 |
+
|
77 |
+
def download(
|
78 |
+
self,
|
79 |
+
title: str,
|
80 |
+
srt: bool = True,
|
81 |
+
output_path: Optional[str] = None,
|
82 |
+
filename_prefix: Optional[str] = None,
|
83 |
+
) -> str:
|
84 |
+
"""Write the media stream to disk.
|
85 |
+
|
86 |
+
:param title:
|
87 |
+
Output filename (stem only) for writing media file.
|
88 |
+
If one is not specified, the default filename is used.
|
89 |
+
:type title: str
|
90 |
+
:param srt:
|
91 |
+
Set to True to download srt, false to download xml. Defaults to True.
|
92 |
+
:type srt bool
|
93 |
+
:param output_path:
|
94 |
+
(optional) Output path for writing media file. If one is not
|
95 |
+
specified, defaults to the current working directory.
|
96 |
+
:type output_path: str or None
|
97 |
+
:param filename_prefix:
|
98 |
+
(optional) A string that will be prepended to the filename.
|
99 |
+
For example a number in a playlist or the name of a series.
|
100 |
+
If one is not specified, nothing will be prepended
|
101 |
+
This is separate from filename so you can use the default
|
102 |
+
filename but still add a prefix.
|
103 |
+
:type filename_prefix: str or None
|
104 |
+
|
105 |
+
:rtype: str
|
106 |
+
"""
|
107 |
+
if title.endswith(".srt") or title.endswith(".xml"):
|
108 |
+
filename = ".".join(title.split(".")[:-1])
|
109 |
+
else:
|
110 |
+
filename = title
|
111 |
+
|
112 |
+
if filename_prefix:
|
113 |
+
filename = f"{safe_filename(filename_prefix)}{filename}"
|
114 |
+
|
115 |
+
filename = safe_filename(filename)
|
116 |
+
|
117 |
+
filename += f" ({self.code})"
|
118 |
+
|
119 |
+
if srt:
|
120 |
+
filename += ".srt"
|
121 |
+
else:
|
122 |
+
filename += ".xml"
|
123 |
+
|
124 |
+
file_path = os.path.join(target_directory(output_path), filename)
|
125 |
+
|
126 |
+
with open(file_path, "w", encoding="utf-8") as file_handle:
|
127 |
+
if srt:
|
128 |
+
file_handle.write(self.generate_srt_captions())
|
129 |
+
else:
|
130 |
+
file_handle.write(self.xml_captions)
|
131 |
+
|
132 |
+
return file_path
|
133 |
|
134 |
def __repr__(self):
|
135 |
"""Printable object representation."""
|
136 |
+
return '<Caption lang="{s.name}" code="{s.code}">'.format(s=self)
|
pytube/cipher.py
CHANGED
@@ -1,6 +1,7 @@
|
|
1 |
# -*- coding: utf-8 -*-
|
|
|
2 |
"""
|
3 |
-
This module
|
4 |
|
5 |
YouTube's strategy to restrict downloading videos is to send a ciphered version
|
6 |
of the signature to the client, along with the decryption algorithm obfuscated
|
@@ -13,48 +14,116 @@ functions" (2) maps them to Python equivalents and (3) taking the ciphered
|
|
13 |
signature and decoding it.
|
14 |
|
15 |
"""
|
16 |
-
from __future__ import absolute_import
|
17 |
-
|
18 |
import logging
|
19 |
-
import pprint
|
20 |
import re
|
21 |
from itertools import chain
|
|
|
22 |
|
23 |
from pytube.exceptions import RegexMatchError
|
24 |
-
from pytube.helpers import regex_search
|
25 |
-
|
26 |
|
27 |
logger = logging.getLogger(__name__)
|
28 |
|
29 |
|
30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
31 |
"""Extract the name of the function responsible for computing the signature.
|
32 |
-
|
33 |
:param str js:
|
34 |
The contents of the base.js asset file.
|
35 |
-
|
|
|
|
|
36 |
"""
|
37 |
-
# c&&d.set("signature", EE(c));
|
38 |
|
39 |
-
|
40 |
-
r
|
41 |
-
r
|
|
|
42 |
r'(?P<sig>[a-zA-Z0-9$]+)\s*=\s*function\(\s*a\s*\)\s*{\s*a\s*=\s*a\.split\(\s*""\s*\)', # noqa: E501
|
43 |
r'(["\'])signature\1\s*,\s*(?P<sig>[a-zA-Z0-9$]+)\(',
|
44 |
-
r
|
45 |
-
r
|
46 |
-
r
|
47 |
-
r
|
48 |
-
r
|
49 |
-
r
|
50 |
-
r
|
51 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
52 |
|
53 |
-
|
54 |
-
return regex_search(pattern, js, group=1)
|
55 |
|
56 |
|
57 |
-
def get_transform_plan(js):
|
58 |
"""Extract the "transform plan".
|
59 |
|
60 |
The "transform plan" is the functions that the ciphered signature is
|
@@ -65,7 +134,6 @@ def get_transform_plan(js):
|
|
65 |
|
66 |
**Example**:
|
67 |
|
68 |
-
>>> get_transform_plan(js)
|
69 |
['DE.AJ(a,15)',
|
70 |
'DE.VR(a,3)',
|
71 |
'DE.AJ(a,51)',
|
@@ -76,12 +144,12 @@ def get_transform_plan(js):
|
|
76 |
'DE.kT(a,21)']
|
77 |
"""
|
78 |
name = re.escape(get_initial_function_name(js))
|
79 |
-
pattern = r
|
80 |
-
logger.debug(
|
81 |
-
return regex_search(pattern, js, group=1).split(
|
82 |
|
83 |
|
84 |
-
def get_transform_object(js, var):
|
85 |
"""Extract the "transform object".
|
86 |
|
87 |
The "transform object" contains the function definitions referenced in the
|
@@ -103,16 +171,17 @@ def get_transform_object(js, var):
|
|
103 |
'kT:function(a,b){var c=a[0];a[0]=a[b%a.length];a[b]=c}']
|
104 |
|
105 |
"""
|
106 |
-
pattern = r
|
107 |
-
logger.debug(
|
108 |
-
|
109 |
-
|
110 |
-
|
111 |
-
|
112 |
-
)
|
113 |
|
|
|
114 |
|
115 |
-
|
|
|
116 |
"""Build a transform function lookup.
|
117 |
|
118 |
Build a lookup table of obfuscated JavaScript function names to the
|
@@ -129,13 +198,13 @@ def get_transform_map(js, var):
|
|
129 |
mapper = {}
|
130 |
for obj in transform_object:
|
131 |
# AJ:function(a){a.reverse()} => AJ, function(a){a.reverse()}
|
132 |
-
name, function = obj.split(
|
133 |
fn = map_functions(function)
|
134 |
mapper[name] = fn
|
135 |
return mapper
|
136 |
|
137 |
|
138 |
-
def reverse(arr,
|
139 |
"""Reverse elements in a list.
|
140 |
|
141 |
This function is equivalent to:
|
@@ -155,7 +224,7 @@ def reverse(arr, b):
|
|
155 |
return arr[::-1]
|
156 |
|
157 |
|
158 |
-
def splice(arr, b):
|
159 |
"""Add/remove items to/from a list.
|
160 |
|
161 |
This function is equivalent to:
|
@@ -169,10 +238,10 @@ def splice(arr, b):
|
|
169 |
>>> splice([1, 2, 3, 4], 2)
|
170 |
[1, 2]
|
171 |
"""
|
172 |
-
return arr[:b] + arr[b * 2:]
|
173 |
|
174 |
|
175 |
-
def swap(arr, b):
|
176 |
"""Swap positions at b modulus the list length.
|
177 |
|
178 |
This function is equivalent to:
|
@@ -187,10 +256,10 @@ def swap(arr, b):
|
|
187 |
[3, 2, 1, 4]
|
188 |
"""
|
189 |
r = b % len(arr)
|
190 |
-
return list(chain([arr[r]], arr[1:r], [arr[0]], arr[r + 1:]))
|
191 |
|
192 |
|
193 |
-
def map_functions(js_func):
|
194 |
"""For a given JavaScript transform function, return the Python equivalent.
|
195 |
|
196 |
:param str js_func:
|
@@ -199,80 +268,19 @@ def map_functions(js_func):
|
|
199 |
"""
|
200 |
mapper = (
|
201 |
# function(a){a.reverse()}
|
202 |
-
(
|
203 |
# function(a,b){a.splice(0,b)}
|
204 |
-
(
|
205 |
# function(a,b){var c=a[0];a[0]=a[b%a.length];a[b]=c}
|
206 |
-
(
|
207 |
# function(a,b){var c=a[0];a[0]=a[b%a.length];a[b%a.length]=c}
|
208 |
(
|
209 |
-
|
210 |
-
|
211 |
),
|
212 |
)
|
213 |
|
214 |
for pattern, fn in mapper:
|
215 |
if re.search(pattern, js_func):
|
216 |
return fn
|
217 |
-
raise RegexMatchError(
|
218 |
-
'could not find python equivalent function for: ',
|
219 |
-
js_func,
|
220 |
-
)
|
221 |
-
|
222 |
-
|
223 |
-
def parse_function(js_func):
|
224 |
-
"""Parse the Javascript transform function.
|
225 |
-
|
226 |
-
Break a JavaScript transform function down into a two element ``tuple``
|
227 |
-
containing the function name and some integer-based argument.
|
228 |
-
|
229 |
-
:param str js_func:
|
230 |
-
The JavaScript version of the transform function.
|
231 |
-
:rtype: tuple
|
232 |
-
:returns:
|
233 |
-
two element tuple containing the function name and an argument.
|
234 |
-
|
235 |
-
**Example**:
|
236 |
-
|
237 |
-
>>> parse_function('DE.AJ(a,15)')
|
238 |
-
('AJ', 15)
|
239 |
-
|
240 |
-
"""
|
241 |
-
logger.debug('parsing transform function')
|
242 |
-
return regex_search(r'\w+\.(\w+)\(\w,(\d+)\)', js_func, groups=True)
|
243 |
-
|
244 |
-
|
245 |
-
def get_signature(js, ciphered_signature):
|
246 |
-
"""Decipher the signature.
|
247 |
-
|
248 |
-
Taking the ciphered signature, applies the transform functions.
|
249 |
-
|
250 |
-
:param str js:
|
251 |
-
The contents of the base.js asset file.
|
252 |
-
:param str ciphered_signature:
|
253 |
-
The ciphered signature sent in the ``player_config``.
|
254 |
-
:rtype: str
|
255 |
-
:returns:
|
256 |
-
Decrypted signature required to download the media content.
|
257 |
-
|
258 |
-
"""
|
259 |
-
tplan = get_transform_plan(js)
|
260 |
-
# DE.AJ(a,15) => DE, AJ(a,15)
|
261 |
-
var, _ = tplan[0].split('.')
|
262 |
-
tmap = get_transform_map(js, var)
|
263 |
-
signature = [s for s in ciphered_signature]
|
264 |
-
|
265 |
-
for js_func in tplan:
|
266 |
-
name, argument = parse_function(js_func)
|
267 |
-
signature = tmap[name](signature, int(argument))
|
268 |
-
logger.debug(
|
269 |
-
'applied transform function\n%s', pprint.pformat(
|
270 |
-
{
|
271 |
-
'output': ''.join(signature),
|
272 |
-
'js_function': name,
|
273 |
-
'argument': int(argument),
|
274 |
-
'function': tmap[name],
|
275 |
-
}, indent=2,
|
276 |
-
),
|
277 |
-
)
|
278 |
-
return ''.join(signature)
|
|
|
1 |
# -*- coding: utf-8 -*-
|
2 |
+
|
3 |
"""
|
4 |
+
This module contains all logic necessary to decipher the signature.
|
5 |
|
6 |
YouTube's strategy to restrict downloading videos is to send a ciphered version
|
7 |
of the signature to the client, along with the decryption algorithm obfuscated
|
|
|
14 |
signature and decoding it.
|
15 |
|
16 |
"""
|
|
|
|
|
17 |
import logging
|
|
|
18 |
import re
|
19 |
from itertools import chain
|
20 |
+
from typing import List, Tuple, Dict, Callable, Any, Optional
|
21 |
|
22 |
from pytube.exceptions import RegexMatchError
|
23 |
+
from pytube.helpers import regex_search, cache
|
|
|
24 |
|
25 |
logger = logging.getLogger(__name__)
|
26 |
|
27 |
|
28 |
+
class Cipher:
|
29 |
+
def __init__(self, js: str):
|
30 |
+
self.transform_plan: List[str] = get_transform_plan(js)
|
31 |
+
var, _ = self.transform_plan[0].split(".")
|
32 |
+
self.transform_map = get_transform_map(js, var)
|
33 |
+
self.js_func_regex = re.compile(r"\w+\.(\w+)\(\w,(\d+)\)")
|
34 |
+
|
35 |
+
def get_signature(self, ciphered_signature: str) -> str:
|
36 |
+
"""Decipher the signature.
|
37 |
+
|
38 |
+
Taking the ciphered signature, applies the transform functions.
|
39 |
+
|
40 |
+
:param str ciphered_signature:
|
41 |
+
The ciphered signature sent in the ``player_config``.
|
42 |
+
:rtype: str
|
43 |
+
:returns:
|
44 |
+
Decrypted signature required to download the media content.
|
45 |
+
"""
|
46 |
+
signature = list(ciphered_signature)
|
47 |
+
|
48 |
+
for js_func in self.transform_plan:
|
49 |
+
name, argument = self.parse_function(js_func) # type: ignore
|
50 |
+
signature = self.transform_map[name](signature, argument)
|
51 |
+
logger.debug(
|
52 |
+
"applied transform function\n"
|
53 |
+
"output: %s\n"
|
54 |
+
"js_function: %s\n"
|
55 |
+
"argument: %d\n"
|
56 |
+
"function: %s",
|
57 |
+
"".join(signature),
|
58 |
+
name,
|
59 |
+
argument,
|
60 |
+
self.transform_map[name],
|
61 |
+
)
|
62 |
+
|
63 |
+
return "".join(signature)
|
64 |
+
|
65 |
+
@cache
|
66 |
+
def parse_function(self, js_func: str) -> Tuple[str, int]:
|
67 |
+
"""Parse the Javascript transform function.
|
68 |
+
|
69 |
+
Break a JavaScript transform function down into a two element ``tuple``
|
70 |
+
containing the function name and some integer-based argument.
|
71 |
+
|
72 |
+
:param str js_func:
|
73 |
+
The JavaScript version of the transform function.
|
74 |
+
:rtype: tuple
|
75 |
+
:returns:
|
76 |
+
two element tuple containing the function name and an argument.
|
77 |
+
|
78 |
+
**Example**:
|
79 |
+
|
80 |
+
parse_function('DE.AJ(a,15)')
|
81 |
+
('AJ', 15)
|
82 |
+
|
83 |
+
"""
|
84 |
+
logger.debug("parsing transform function")
|
85 |
+
parse_match = self.js_func_regex.search(js_func)
|
86 |
+
if not parse_match:
|
87 |
+
raise RegexMatchError(caller="parse_function", pattern="js_func_regex")
|
88 |
+
fn_name, fn_arg = parse_match.groups()
|
89 |
+
return fn_name, int(fn_arg)
|
90 |
+
|
91 |
+
|
92 |
+
def get_initial_function_name(js: str) -> str:
|
93 |
"""Extract the name of the function responsible for computing the signature.
|
|
|
94 |
:param str js:
|
95 |
The contents of the base.js asset file.
|
96 |
+
:rtype: str
|
97 |
+
:returns:
|
98 |
+
Function name from regex match
|
99 |
"""
|
|
|
100 |
|
101 |
+
function_patterns = [
|
102 |
+
r"\b[cs]\s*&&\s*[adf]\.set\([^,]+\s*,\s*encodeURIComponent\s*\(\s*(?P<sig>[a-zA-Z0-9$]+)\(", # noqa: E501
|
103 |
+
r"\b[a-zA-Z0-9]+\s*&&\s*[a-zA-Z0-9]+\.set\([^,]+\s*,\s*encodeURIComponent\s*\(\s*(?P<sig>[a-zA-Z0-9$]+)\(", # noqa: E501
|
104 |
+
r'\b(?P<sig>[a-zA-Z0-9$]{2})\s*=\s*function\(\s*a\s*\)\s*{\s*a\s*=\s*a\.split\(\s*""\s*\)', # noqa: E501
|
105 |
r'(?P<sig>[a-zA-Z0-9$]+)\s*=\s*function\(\s*a\s*\)\s*{\s*a\s*=\s*a\.split\(\s*""\s*\)', # noqa: E501
|
106 |
r'(["\'])signature\1\s*,\s*(?P<sig>[a-zA-Z0-9$]+)\(',
|
107 |
+
r"\.sig\|\|(?P<sig>[a-zA-Z0-9$]+)\(",
|
108 |
+
r"yt\.akamaized\.net/\)\s*\|\|\s*.*?\s*[cs]\s*&&\s*[adf]\.set\([^,]+\s*,\s*(?:encodeURIComponent\s*\()?\s*(?P<sig>[a-zA-Z0-9$]+)\(", # noqa: E501
|
109 |
+
r"\b[cs]\s*&&\s*[adf]\.set\([^,]+\s*,\s*(?P<sig>[a-zA-Z0-9$]+)\(", # noqa: E501
|
110 |
+
r"\b[a-zA-Z0-9]+\s*&&\s*[a-zA-Z0-9]+\.set\([^,]+\s*,\s*(?P<sig>[a-zA-Z0-9$]+)\(", # noqa: E501
|
111 |
+
r"\bc\s*&&\s*a\.set\([^,]+\s*,\s*\([^)]*\)\s*\(\s*(?P<sig>[a-zA-Z0-9$]+)\(", # noqa: E501
|
112 |
+
r"\bc\s*&&\s*[a-zA-Z0-9]+\.set\([^,]+\s*,\s*\([^)]*\)\s*\(\s*(?P<sig>[a-zA-Z0-9$]+)\(", # noqa: E501
|
113 |
+
r"\bc\s*&&\s*[a-zA-Z0-9]+\.set\([^,]+\s*,\s*\([^)]*\)\s*\(\s*(?P<sig>[a-zA-Z0-9$]+)\(", # noqa: E501
|
114 |
]
|
115 |
+
logger.debug("finding initial function name")
|
116 |
+
for pattern in function_patterns:
|
117 |
+
regex = re.compile(pattern)
|
118 |
+
function_match = regex.search(js)
|
119 |
+
if function_match:
|
120 |
+
logger.debug("finished regex search, matched: %s", pattern)
|
121 |
+
return function_match.group(1)
|
122 |
|
123 |
+
raise RegexMatchError(caller="get_initial_function_name", pattern="multiple")
|
|
|
124 |
|
125 |
|
126 |
+
def get_transform_plan(js: str) -> List[str]:
|
127 |
"""Extract the "transform plan".
|
128 |
|
129 |
The "transform plan" is the functions that the ciphered signature is
|
|
|
134 |
|
135 |
**Example**:
|
136 |
|
|
|
137 |
['DE.AJ(a,15)',
|
138 |
'DE.VR(a,3)',
|
139 |
'DE.AJ(a,51)',
|
|
|
144 |
'DE.kT(a,21)']
|
145 |
"""
|
146 |
name = re.escape(get_initial_function_name(js))
|
147 |
+
pattern = r"%s=function\(\w\){[a-z=\.\(\"\)]*;(.*);(?:.+)}" % name
|
148 |
+
logger.debug("getting transform plan")
|
149 |
+
return regex_search(pattern, js, group=1).split(";")
|
150 |
|
151 |
|
152 |
+
def get_transform_object(js: str, var: str) -> List[str]:
|
153 |
"""Extract the "transform object".
|
154 |
|
155 |
The "transform object" contains the function definitions referenced in the
|
|
|
171 |
'kT:function(a,b){var c=a[0];a[0]=a[b%a.length];a[b]=c}']
|
172 |
|
173 |
"""
|
174 |
+
pattern = r"var %s={(.*?)};" % re.escape(var)
|
175 |
+
logger.debug("getting transform object")
|
176 |
+
regex = re.compile(pattern, flags=re.DOTALL)
|
177 |
+
transform_match = regex.search(js)
|
178 |
+
if not transform_match:
|
179 |
+
raise RegexMatchError(caller="get_transform_object", pattern=pattern)
|
|
|
180 |
|
181 |
+
return transform_match.group(1).replace("\n", " ").split(", ")
|
182 |
|
183 |
+
|
184 |
+
def get_transform_map(js: str, var: str) -> Dict:
|
185 |
"""Build a transform function lookup.
|
186 |
|
187 |
Build a lookup table of obfuscated JavaScript function names to the
|
|
|
198 |
mapper = {}
|
199 |
for obj in transform_object:
|
200 |
# AJ:function(a){a.reverse()} => AJ, function(a){a.reverse()}
|
201 |
+
name, function = obj.split(":", 1)
|
202 |
fn = map_functions(function)
|
203 |
mapper[name] = fn
|
204 |
return mapper
|
205 |
|
206 |
|
207 |
+
def reverse(arr: List, _: Optional[Any]):
|
208 |
"""Reverse elements in a list.
|
209 |
|
210 |
This function is equivalent to:
|
|
|
224 |
return arr[::-1]
|
225 |
|
226 |
|
227 |
+
def splice(arr: List, b: int):
|
228 |
"""Add/remove items to/from a list.
|
229 |
|
230 |
This function is equivalent to:
|
|
|
238 |
>>> splice([1, 2, 3, 4], 2)
|
239 |
[1, 2]
|
240 |
"""
|
241 |
+
return arr[:b] + arr[b * 2 :]
|
242 |
|
243 |
|
244 |
+
def swap(arr: List, b: int):
|
245 |
"""Swap positions at b modulus the list length.
|
246 |
|
247 |
This function is equivalent to:
|
|
|
256 |
[3, 2, 1, 4]
|
257 |
"""
|
258 |
r = b % len(arr)
|
259 |
+
return list(chain([arr[r]], arr[1:r], [arr[0]], arr[r + 1 :]))
|
260 |
|
261 |
|
262 |
+
def map_functions(js_func: str) -> Callable:
|
263 |
"""For a given JavaScript transform function, return the Python equivalent.
|
264 |
|
265 |
:param str js_func:
|
|
|
268 |
"""
|
269 |
mapper = (
|
270 |
# function(a){a.reverse()}
|
271 |
+
(r"{\w\.reverse\(\)}", reverse),
|
272 |
# function(a,b){a.splice(0,b)}
|
273 |
+
(r"{\w\.splice\(0,\w\)}", splice),
|
274 |
# function(a,b){var c=a[0];a[0]=a[b%a.length];a[b]=c}
|
275 |
+
(r"{var\s\w=\w\[0\];\w\[0\]=\w\[\w\%\w.length\];\w\[\w\]=\w}", swap),
|
276 |
# function(a,b){var c=a[0];a[0]=a[b%a.length];a[b%a.length]=c}
|
277 |
(
|
278 |
+
r"{var\s\w=\w\[0\];\w\[0\]=\w\[\w\%\w.length\];\w\[\w\%\w.length\]=\w}",
|
279 |
+
swap,
|
280 |
),
|
281 |
)
|
282 |
|
283 |
for pattern, fn in mapper:
|
284 |
if re.search(pattern, js_func):
|
285 |
return fn
|
286 |
+
raise RegexMatchError(caller="map_functions", pattern="multiple")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
pytube/cli.py
CHANGED
@@ -1,7 +1,6 @@
|
|
|
|
1 |
# -*- coding: utf-8 -*-
|
2 |
"""A simple command line application to download youtube videos."""
|
3 |
-
from __future__ import absolute_import
|
4 |
-
from __future__ import print_function
|
5 |
|
6 |
import argparse
|
7 |
import datetime as dt
|
@@ -9,97 +8,176 @@ import gzip
|
|
9 |
import json
|
10 |
import logging
|
11 |
import os
|
|
|
12 |
import sys
|
|
|
|
|
13 |
|
14 |
-
from pytube import __version__
|
15 |
from pytube import YouTube
|
16 |
-
|
17 |
-
|
18 |
-
logger = logging.getLogger(__name__)
|
19 |
|
20 |
|
21 |
def main():
|
22 |
"""Command line application to download youtube videos."""
|
|
|
23 |
parser = argparse.ArgumentParser(description=main.__doc__)
|
24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
25 |
parser.add_argument(
|
26 |
-
|
27 |
-
version='%(prog)s ' + __version__,
|
28 |
)
|
29 |
parser.add_argument(
|
30 |
-
|
31 |
-
|
|
|
|
|
|
|
|
|
32 |
),
|
33 |
)
|
34 |
parser.add_argument(
|
35 |
-
|
36 |
-
|
37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
38 |
),
|
39 |
)
|
40 |
parser.add_argument(
|
41 |
-
|
42 |
-
|
|
|
|
|
|
|
|
|
43 |
)
|
44 |
parser.add_argument(
|
45 |
-
|
46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
47 |
),
|
48 |
)
|
49 |
|
50 |
-
|
51 |
-
logging.getLogger().setLevel(max(3 - args.verbosity, 0) * 10)
|
52 |
-
|
53 |
-
if not args.url:
|
54 |
-
parser.print_help()
|
55 |
-
sys.exit(1)
|
56 |
-
|
57 |
-
if args.list:
|
58 |
-
display_streams(args.url)
|
59 |
-
|
60 |
-
elif args.build_playback_report:
|
61 |
-
build_playback_report(args.url)
|
62 |
-
|
63 |
-
elif args.itag:
|
64 |
-
download(args.url, args.itag)
|
65 |
|
66 |
|
67 |
-
def build_playback_report(
|
68 |
"""Serialize the request data to json for offline debugging.
|
69 |
|
70 |
-
:param
|
71 |
-
A
|
72 |
"""
|
73 |
-
yt = YouTube(url)
|
74 |
ts = int(dt.datetime.utcnow().timestamp())
|
75 |
-
fp = os.path.join(
|
76 |
-
os.getcwd(),
|
77 |
-
'yt-video-{yt.video_id}-{ts}.json.gz'.format(yt=yt, ts=ts),
|
78 |
-
)
|
79 |
|
80 |
-
js =
|
81 |
-
watch_html =
|
82 |
-
vid_info =
|
83 |
|
84 |
-
with gzip.open(fp,
|
85 |
fh.write(
|
86 |
-
json.dumps(
|
87 |
-
|
88 |
-
|
89 |
-
|
90 |
-
|
91 |
-
|
92 |
-
|
|
|
93 |
)
|
94 |
|
95 |
|
96 |
-
def
|
97 |
-
|
98 |
-
|
99 |
-
return int(rows), int(columns)
|
100 |
-
|
101 |
-
|
102 |
-
def display_progress_bar(bytes_received, filesize, ch='█', scale=0.55):
|
103 |
"""Display a simple, pretty progress bar.
|
104 |
|
105 |
Example:
|
@@ -112,77 +190,285 @@ def display_progress_bar(bytes_received, filesize, ch='█', scale=0.55):
|
|
112 |
written to disk.
|
113 |
:param int filesize:
|
114 |
File size of the media stream in bytes.
|
115 |
-
:param ch
|
116 |
Character to use for presenting progress segment.
|
117 |
:param float scale:
|
118 |
-
Scale
|
119 |
|
120 |
"""
|
121 |
-
|
122 |
max_width = int(columns * scale)
|
123 |
|
124 |
filled = int(round(max_width * bytes_received / float(filesize)))
|
125 |
remaining = max_width - filled
|
126 |
-
|
127 |
percent = round(100.0 * bytes_received / float(filesize), 1)
|
128 |
-
text =
|
129 |
sys.stdout.write(text)
|
130 |
sys.stdout.flush()
|
131 |
|
132 |
|
133 |
-
|
134 |
-
|
135 |
-
|
136 |
-
|
137 |
-
An instance of :class:`Stream <Stream>` being downloaded.
|
138 |
-
:param file_handle:
|
139 |
-
The file handle where the media is being written to.
|
140 |
-
:type file_handle:
|
141 |
-
:py:class:`io.BufferedWriter`
|
142 |
-
:param int bytes_remaining:
|
143 |
-
How many bytes have been downloaded.
|
144 |
-
|
145 |
-
"""
|
146 |
filesize = stream.filesize
|
147 |
bytes_received = filesize - bytes_remaining
|
148 |
display_progress_bar(bytes_received, filesize)
|
149 |
|
150 |
|
151 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
152 |
"""Start downloading a YouTube video.
|
153 |
|
154 |
-
:param
|
155 |
-
A valid YouTube
|
156 |
-
:param
|
157 |
YouTube format identifier code.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
158 |
|
|
|
|
|
|
|
|
|
|
|
|
|
159 |
"""
|
160 |
-
# TODO(nficano): allow download target to be specified
|
161 |
# TODO(nficano): allow dash itags to be selected
|
162 |
-
|
163 |
-
stream
|
164 |
-
|
165 |
-
|
166 |
-
|
167 |
-
|
|
|
|
|
|
|
168 |
try:
|
169 |
-
stream
|
170 |
-
sys.stdout.write('\n')
|
171 |
except KeyboardInterrupt:
|
172 |
sys.exit()
|
173 |
|
174 |
|
175 |
-
def display_streams(
|
176 |
"""Probe YouTube video and lists its available formats.
|
177 |
|
178 |
-
:param
|
179 |
A valid YouTube watch URL.
|
180 |
|
181 |
"""
|
182 |
-
|
183 |
-
for stream in yt.streams.all():
|
184 |
print(stream)
|
185 |
|
186 |
|
187 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
188 |
main()
|
|
|
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
|
|
|
8 |
import json
|
9 |
import logging
|
10 |
import os
|
11 |
+
import shutil
|
12 |
import sys
|
13 |
+
import subprocess # nosec
|
14 |
+
from typing import List, Optional
|
15 |
|
16 |
+
from pytube import __version__, CaptionQuery, Stream, Playlist
|
17 |
from pytube import YouTube
|
18 |
+
from pytube.exceptions import PytubeError
|
19 |
+
from pytube.helpers import safe_filename, setup_logger
|
|
|
20 |
|
21 |
|
22 |
def main():
|
23 |
"""Command line application to download youtube videos."""
|
24 |
+
# noinspection PyTypeChecker
|
25 |
parser = argparse.ArgumentParser(description=main.__doc__)
|
26 |
+
args = _parse_args(parser)
|
27 |
+
if args.verbosity:
|
28 |
+
log_level = min(args.verbosity, 4) * 10
|
29 |
+
setup_logger(logging.FATAL - log_level)
|
30 |
+
|
31 |
+
if not args.url or "youtu" not in args.url:
|
32 |
+
parser.print_help()
|
33 |
+
sys.exit(1)
|
34 |
+
|
35 |
+
if "/playlist" in args.url:
|
36 |
+
print("Loading playlist...")
|
37 |
+
playlist = Playlist(args.url)
|
38 |
+
if not args.target:
|
39 |
+
args.target = safe_filename(playlist.title())
|
40 |
+
for youtube_video in playlist.videos:
|
41 |
+
try:
|
42 |
+
_perform_args_on_youtube(youtube_video, args)
|
43 |
+
except PytubeError as e:
|
44 |
+
print(f"There was an error with video: {youtube_video}")
|
45 |
+
print(e)
|
46 |
+
else:
|
47 |
+
print("Loading video...")
|
48 |
+
youtube = YouTube(args.url)
|
49 |
+
_perform_args_on_youtube(youtube, args)
|
50 |
+
|
51 |
+
|
52 |
+
def _perform_args_on_youtube(youtube: YouTube, args: argparse.Namespace) -> None:
|
53 |
+
if args.list:
|
54 |
+
display_streams(youtube)
|
55 |
+
if args.build_playback_report:
|
56 |
+
build_playback_report(youtube)
|
57 |
+
if args.itag:
|
58 |
+
download_by_itag(youtube=youtube, itag=args.itag, target=args.target)
|
59 |
+
if hasattr(args, "caption_code"):
|
60 |
+
download_caption(
|
61 |
+
youtube=youtube, lang_code=args.caption_code, target=args.target
|
62 |
+
)
|
63 |
+
if args.resolution:
|
64 |
+
download_by_resolution(
|
65 |
+
youtube=youtube, resolution=args.resolution, target=args.target
|
66 |
+
)
|
67 |
+
if args.audio:
|
68 |
+
download_audio(youtube=youtube, filetype=args.audio, target=args.target)
|
69 |
+
if args.ffmpeg:
|
70 |
+
ffmpeg_process(youtube=youtube, resolution=args.ffmpeg, target=args.target)
|
71 |
+
|
72 |
+
|
73 |
+
def _parse_args(
|
74 |
+
parser: argparse.ArgumentParser, args: Optional[List] = None
|
75 |
+
) -> argparse.Namespace:
|
76 |
+
parser.add_argument("url", help="The YouTube /watch or /playlist url", nargs="?")
|
77 |
+
parser.add_argument(
|
78 |
+
"--version", action="version", version="%(prog)s " + __version__,
|
79 |
+
)
|
80 |
+
parser.add_argument(
|
81 |
+
"--itag", type=int, help="The itag for the desired stream",
|
82 |
+
)
|
83 |
parser.add_argument(
|
84 |
+
"-r", "--resolution", type=str, help="The resolution for the desired stream",
|
|
|
85 |
)
|
86 |
parser.add_argument(
|
87 |
+
"-l",
|
88 |
+
"--list",
|
89 |
+
action="store_true",
|
90 |
+
help=(
|
91 |
+
"The list option causes pytube cli to return a list of streams "
|
92 |
+
"available to download"
|
93 |
),
|
94 |
)
|
95 |
parser.add_argument(
|
96 |
+
"-v",
|
97 |
+
"--verbose",
|
98 |
+
action="count",
|
99 |
+
default=0,
|
100 |
+
dest="verbosity",
|
101 |
+
help="Verbosity level, use up to 4 to increase logging -vvvv",
|
102 |
+
)
|
103 |
+
parser.add_argument(
|
104 |
+
"--build-playback-report",
|
105 |
+
action="store_true",
|
106 |
+
help="Save the html and js to disk",
|
107 |
+
)
|
108 |
+
parser.add_argument(
|
109 |
+
"-c",
|
110 |
+
"--caption-code",
|
111 |
+
type=str,
|
112 |
+
default=argparse.SUPPRESS,
|
113 |
+
nargs="?",
|
114 |
+
help=(
|
115 |
+
"Download srt captions for given language code. "
|
116 |
+
"Prints available language codes if no argument given"
|
117 |
),
|
118 |
)
|
119 |
parser.add_argument(
|
120 |
+
"-t",
|
121 |
+
"--target",
|
122 |
+
help=(
|
123 |
+
"The output directory for the downloaded stream. "
|
124 |
+
"Default is current working directory"
|
125 |
+
),
|
126 |
)
|
127 |
parser.add_argument(
|
128 |
+
"-a",
|
129 |
+
"--audio",
|
130 |
+
const="mp4",
|
131 |
+
nargs="?",
|
132 |
+
help=(
|
133 |
+
"Download the audio for a given URL at the highest bitrate available"
|
134 |
+
"Defaults to mp4 format if none is specified"
|
135 |
+
),
|
136 |
+
)
|
137 |
+
parser.add_argument(
|
138 |
+
"-f",
|
139 |
+
"--ffmpeg",
|
140 |
+
const="best",
|
141 |
+
nargs="?",
|
142 |
+
help=(
|
143 |
+
"Downloads the audio and video stream for resolution provided"
|
144 |
+
"If no resolution is provided, downloads the best resolution"
|
145 |
+
"Runs the command line program ffmpeg to combine the audio and video"
|
146 |
),
|
147 |
)
|
148 |
|
149 |
+
return parser.parse_args(args)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
150 |
|
151 |
|
152 |
+
def build_playback_report(youtube: YouTube) -> None:
|
153 |
"""Serialize the request data to json for offline debugging.
|
154 |
|
155 |
+
:param YouTube youtube:
|
156 |
+
A YouTube object.
|
157 |
"""
|
|
|
158 |
ts = int(dt.datetime.utcnow().timestamp())
|
159 |
+
fp = os.path.join(os.getcwd(), f"yt-video-{youtube.video_id}-{ts}.json.gz")
|
|
|
|
|
|
|
160 |
|
161 |
+
js = youtube.js
|
162 |
+
watch_html = youtube.watch_html
|
163 |
+
vid_info = youtube.vid_info
|
164 |
|
165 |
+
with gzip.open(fp, "wb") as fh:
|
166 |
fh.write(
|
167 |
+
json.dumps(
|
168 |
+
{
|
169 |
+
"url": youtube.watch_url,
|
170 |
+
"js": js,
|
171 |
+
"watch_html": watch_html,
|
172 |
+
"video_info": vid_info,
|
173 |
+
}
|
174 |
+
).encode("utf8"),
|
175 |
)
|
176 |
|
177 |
|
178 |
+
def display_progress_bar(
|
179 |
+
bytes_received: int, filesize: int, ch: str = "█", scale: float = 0.55
|
180 |
+
) -> None:
|
|
|
|
|
|
|
|
|
181 |
"""Display a simple, pretty progress bar.
|
182 |
|
183 |
Example:
|
|
|
190 |
written to disk.
|
191 |
:param int filesize:
|
192 |
File size of the media stream in bytes.
|
193 |
+
:param str ch:
|
194 |
Character to use for presenting progress segment.
|
195 |
:param float scale:
|
196 |
+
Scale multiplier to reduce progress bar size.
|
197 |
|
198 |
"""
|
199 |
+
columns = shutil.get_terminal_size().columns
|
200 |
max_width = int(columns * scale)
|
201 |
|
202 |
filled = int(round(max_width * bytes_received / float(filesize)))
|
203 |
remaining = max_width - filled
|
204 |
+
progress_bar = ch * filled + " " * remaining
|
205 |
percent = round(100.0 * bytes_received / float(filesize), 1)
|
206 |
+
text = f" ↳ |{progress_bar}| {percent}%\r"
|
207 |
sys.stdout.write(text)
|
208 |
sys.stdout.flush()
|
209 |
|
210 |
|
211 |
+
# noinspection PyUnusedLocal
|
212 |
+
def on_progress(
|
213 |
+
stream: Stream, chunk: bytes, bytes_remaining: int
|
214 |
+
) -> None: # pylint: disable=W0613
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
215 |
filesize = stream.filesize
|
216 |
bytes_received = filesize - bytes_remaining
|
217 |
display_progress_bar(bytes_received, filesize)
|
218 |
|
219 |
|
220 |
+
def _download(
|
221 |
+
stream: Stream, target: Optional[str] = None, filename: Optional[str] = None
|
222 |
+
) -> None:
|
223 |
+
filesize_megabytes = stream.filesize // 1048576
|
224 |
+
print(f"{filename or stream.default_filename} | {filesize_megabytes} MB")
|
225 |
+
file_path = stream.get_file_path(filename=filename, output_path=target)
|
226 |
+
if stream.exists_at_path(file_path):
|
227 |
+
print(f"Already downloaded at:\n{file_path}")
|
228 |
+
return
|
229 |
+
|
230 |
+
stream.download(output_path=target, filename=filename)
|
231 |
+
sys.stdout.write("\n")
|
232 |
+
|
233 |
+
|
234 |
+
def _unique_name(base: str, subtype: str, media_type: str, target: str) -> str:
|
235 |
+
"""
|
236 |
+
Given a base name, the file format, and the target directory, will generate
|
237 |
+
a filename unique for that directory and file format.
|
238 |
+
:param str base:
|
239 |
+
The given base-name.
|
240 |
+
:param str subtype:
|
241 |
+
The filetype of the video which will be downloaded.
|
242 |
+
:param str media_type:
|
243 |
+
The media_type of the file, ie. "audio" or "video"
|
244 |
+
:param Path target:
|
245 |
+
Target directory for download.
|
246 |
+
"""
|
247 |
+
counter = 0
|
248 |
+
while True:
|
249 |
+
file_name = f"{base}_{media_type}_{counter}"
|
250 |
+
file_path = os.path.join(target, f"{file_name}.{subtype}")
|
251 |
+
if not os.path.exists(file_path):
|
252 |
+
return file_name
|
253 |
+
counter += 1
|
254 |
+
|
255 |
+
|
256 |
+
def ffmpeg_process(
|
257 |
+
youtube: YouTube, resolution: str, target: Optional[str] = None
|
258 |
+
) -> None:
|
259 |
+
"""
|
260 |
+
Decides the correct video stream to download, then calls _ffmpeg_downloader.
|
261 |
+
|
262 |
+
:param YouTube youtube:
|
263 |
+
A valid YouTube object.
|
264 |
+
:param str resolution:
|
265 |
+
YouTube video resolution.
|
266 |
+
:param str target:
|
267 |
+
Target directory for download
|
268 |
+
"""
|
269 |
+
youtube.register_on_progress_callback(on_progress)
|
270 |
+
target = target or os.getcwd()
|
271 |
+
|
272 |
+
if resolution == "best":
|
273 |
+
highest_quality_stream = (
|
274 |
+
youtube.streams.filter(progressive=False).order_by("resolution").last()
|
275 |
+
)
|
276 |
+
mp4_stream = (
|
277 |
+
youtube.streams.filter(progressive=False, subtype="mp4")
|
278 |
+
.order_by("resolution")
|
279 |
+
.last()
|
280 |
+
)
|
281 |
+
if highest_quality_stream.resolution == mp4_stream.resolution:
|
282 |
+
video_stream = mp4_stream
|
283 |
+
else:
|
284 |
+
video_stream = highest_quality_stream
|
285 |
+
else:
|
286 |
+
video_stream = youtube.streams.filter(
|
287 |
+
progressive=False, resolution=resolution, subtype="mp4"
|
288 |
+
).first()
|
289 |
+
if not video_stream:
|
290 |
+
video_stream = youtube.streams.filter(
|
291 |
+
progressive=False, resolution=resolution
|
292 |
+
).first()
|
293 |
+
if video_stream is None:
|
294 |
+
print(f"Could not find a stream with resolution: {resolution}")
|
295 |
+
print("Try one of these:")
|
296 |
+
display_streams(youtube)
|
297 |
+
sys.exit()
|
298 |
+
|
299 |
+
audio_stream = youtube.streams.get_audio_only(video_stream.subtype)
|
300 |
+
if not audio_stream:
|
301 |
+
audio_stream = youtube.streams.filter(only_audio=True).order_by("abr").last()
|
302 |
+
if not audio_stream:
|
303 |
+
print("Could not find an audio only stream")
|
304 |
+
sys.exit()
|
305 |
+
_ffmpeg_downloader(
|
306 |
+
audio_stream=audio_stream, video_stream=video_stream, target=target
|
307 |
+
)
|
308 |
+
|
309 |
+
|
310 |
+
def _ffmpeg_downloader(audio_stream: Stream, video_stream: Stream, target: str) -> None:
|
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
|
314 |
+
and video from the previously downloaded files. Then deletes the original adaptive
|
315 |
+
streams, leaving the combination.
|
316 |
+
|
317 |
+
:param Stream audio_stream:
|
318 |
+
A valid Stream object representing the audio to download
|
319 |
+
:param Stream video_stream:
|
320 |
+
A valid Stream object representing the video to download
|
321 |
+
:param Path target:
|
322 |
+
A valid Path object
|
323 |
+
"""
|
324 |
+
video_unique_name = _unique_name(
|
325 |
+
safe_filename(video_stream.title), video_stream.subtype, "video", target=target
|
326 |
+
)
|
327 |
+
audio_unique_name = _unique_name(
|
328 |
+
safe_filename(video_stream.title), audio_stream.subtype, "audio", target=target
|
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(target, f"{video_unique_name}.{video_stream.subtype}")
|
335 |
+
audio_path = os.path.join(target, f"{audio_unique_name}.{audio_stream.subtype}")
|
336 |
+
final_path = os.path.join(
|
337 |
+
target, f"{safe_filename(video_stream.title)}.{video_stream.subtype}"
|
338 |
+
)
|
339 |
+
|
340 |
+
subprocess.run( # nosec
|
341 |
+
["ffmpeg", "-i", video_path, "-i", audio_path, "-codec", "copy", final_path,]
|
342 |
+
)
|
343 |
+
os.unlink(video_path)
|
344 |
+
os.unlink(audio_path)
|
345 |
+
|
346 |
+
|
347 |
+
def download_by_itag(youtube: YouTube, itag: int, target: Optional[str] = None) -> None:
|
348 |
"""Start downloading a YouTube video.
|
349 |
|
350 |
+
:param YouTube youtube:
|
351 |
+
A valid YouTube object.
|
352 |
+
:param int itag:
|
353 |
YouTube format identifier code.
|
354 |
+
:param str target:
|
355 |
+
Target directory for download
|
356 |
+
"""
|
357 |
+
stream = youtube.streams.get_by_itag(itag)
|
358 |
+
if stream is None:
|
359 |
+
print(f"Could not find a stream with itag: {itag}")
|
360 |
+
print("Try one of these:")
|
361 |
+
display_streams(youtube)
|
362 |
+
sys.exit()
|
363 |
+
|
364 |
+
youtube.register_on_progress_callback(on_progress)
|
365 |
+
|
366 |
+
try:
|
367 |
+
_download(stream, target=target)
|
368 |
+
except KeyboardInterrupt:
|
369 |
+
sys.exit()
|
370 |
+
|
371 |
+
|
372 |
+
def download_by_resolution(
|
373 |
+
youtube: YouTube, resolution: str, target: Optional[str] = None
|
374 |
+
) -> None:
|
375 |
+
"""Start downloading a YouTube video.
|
376 |
|
377 |
+
:param YouTube youtube:
|
378 |
+
A valid YouTube object.
|
379 |
+
:param str resolution:
|
380 |
+
YouTube video resolution.
|
381 |
+
:param str target:
|
382 |
+
Target directory for download
|
383 |
"""
|
|
|
384 |
# TODO(nficano): allow dash itags to be selected
|
385 |
+
stream = youtube.streams.get_by_resolution(resolution)
|
386 |
+
if stream is None:
|
387 |
+
print(f"Could not find a stream with resolution: {resolution}")
|
388 |
+
print("Try one of these:")
|
389 |
+
display_streams(youtube)
|
390 |
+
sys.exit()
|
391 |
+
|
392 |
+
youtube.register_on_progress_callback(on_progress)
|
393 |
+
|
394 |
try:
|
395 |
+
_download(stream, target=target)
|
|
|
396 |
except KeyboardInterrupt:
|
397 |
sys.exit()
|
398 |
|
399 |
|
400 |
+
def display_streams(youtube: YouTube) -> None:
|
401 |
"""Probe YouTube video and lists its available formats.
|
402 |
|
403 |
+
:param YouTube youtube:
|
404 |
A valid YouTube watch URL.
|
405 |
|
406 |
"""
|
407 |
+
for stream in youtube.streams:
|
|
|
408 |
print(stream)
|
409 |
|
410 |
|
411 |
+
def _print_available_captions(captions: CaptionQuery) -> None:
|
412 |
+
print(f"Available caption codes are: {', '.join(c.code for c in captions)}")
|
413 |
+
|
414 |
+
|
415 |
+
def download_caption(
|
416 |
+
youtube: YouTube, lang_code: Optional[str], target: Optional[str] = None
|
417 |
+
) -> None:
|
418 |
+
"""Download a caption for the YouTube video.
|
419 |
+
|
420 |
+
:param YouTube youtube:
|
421 |
+
A valid YouTube object.
|
422 |
+
:param str lang_code:
|
423 |
+
Language code desired for caption file.
|
424 |
+
Prints available codes if the value is None
|
425 |
+
or the desired code is not available.
|
426 |
+
:param str target:
|
427 |
+
Target directory for download
|
428 |
+
"""
|
429 |
+
if lang_code is None:
|
430 |
+
_print_available_captions(youtube.captions)
|
431 |
+
return
|
432 |
+
|
433 |
+
try:
|
434 |
+
caption = youtube.captions[lang_code]
|
435 |
+
downloaded_path = caption.download(title=youtube.title, output_path=target)
|
436 |
+
print(f"Saved caption file to: {downloaded_path}")
|
437 |
+
except KeyError:
|
438 |
+
print(f"Unable to find caption with code: {lang_code}")
|
439 |
+
_print_available_captions(youtube.captions)
|
440 |
+
|
441 |
+
|
442 |
+
def download_audio(
|
443 |
+
youtube: YouTube, filetype: str, target: Optional[str] = None
|
444 |
+
) -> None:
|
445 |
+
"""
|
446 |
+
Given a filetype, downloads the highest quality available audio stream for a
|
447 |
+
YouTube video.
|
448 |
+
|
449 |
+
:param YouTube youtube:
|
450 |
+
A valid YouTube object.
|
451 |
+
:param str filetype:
|
452 |
+
Desired file format to download.
|
453 |
+
:param str target:
|
454 |
+
Target directory for download
|
455 |
+
"""
|
456 |
+
audio = (
|
457 |
+
youtube.streams.filter(only_audio=True, subtype=filetype).order_by("abr").last()
|
458 |
+
)
|
459 |
+
|
460 |
+
if audio is None:
|
461 |
+
print("No audio only stream found. Try one of these:")
|
462 |
+
display_streams(youtube)
|
463 |
+
sys.exit()
|
464 |
+
|
465 |
+
youtube.register_on_progress_callback(on_progress)
|
466 |
+
|
467 |
+
try:
|
468 |
+
_download(audio, target=target)
|
469 |
+
except KeyboardInterrupt:
|
470 |
+
sys.exit()
|
471 |
+
|
472 |
+
|
473 |
+
if __name__ == "__main__":
|
474 |
main()
|
pytube/compat.py
DELETED
@@ -1,70 +0,0 @@
|
|
1 |
-
#!/usr/bin/env python
|
2 |
-
# -*- coding: utf-8 -*-
|
3 |
-
# flake8: noqa
|
4 |
-
"""Python 2/3 compatibility support."""
|
5 |
-
import sys
|
6 |
-
|
7 |
-
|
8 |
-
PY2 = sys.version_info[0] == 2
|
9 |
-
PY3 = sys.version_info[0] == 3
|
10 |
-
PY33 = sys.version_info[0:2] >= (3, 3)
|
11 |
-
|
12 |
-
if PY2:
|
13 |
-
reload(sys)
|
14 |
-
sys.setdefaultencoding('utf8')
|
15 |
-
import urllib2
|
16 |
-
from urllib import urlencode
|
17 |
-
from urllib2 import URLError
|
18 |
-
from urllib2 import quote
|
19 |
-
from urllib2 import unquote
|
20 |
-
from urllib2 import urlopen
|
21 |
-
from urlparse import parse_qsl
|
22 |
-
from HTMLParser import HTMLParser
|
23 |
-
|
24 |
-
def install_proxy(proxy_handler):
|
25 |
-
"""
|
26 |
-
install global proxy.
|
27 |
-
:param proxy_handler:
|
28 |
-
:samp:`{"http":"http://my.proxy.com:1234", "https":"https://my.proxy.com:1234"}`
|
29 |
-
:return:
|
30 |
-
"""
|
31 |
-
proxy_support = urllib2.ProxyHandler(proxy_handler)
|
32 |
-
opener = urllib2.build_opener(proxy_support)
|
33 |
-
urllib2.install_opener(opener)
|
34 |
-
|
35 |
-
def unescape(s):
|
36 |
-
"""Strip HTML entries from a string."""
|
37 |
-
html_parser = HTMLParser()
|
38 |
-
return html_parser.unescape(s)
|
39 |
-
|
40 |
-
def unicode(s):
|
41 |
-
"""Encode a string to utf-8."""
|
42 |
-
return s.encode('utf-8')
|
43 |
-
|
44 |
-
elif PY3:
|
45 |
-
from urllib.error import URLError
|
46 |
-
from urllib.parse import parse_qsl
|
47 |
-
from urllib.parse import quote
|
48 |
-
from urllib.parse import unquote
|
49 |
-
from urllib.parse import urlencode
|
50 |
-
from urllib.request import urlopen
|
51 |
-
from urllib import request
|
52 |
-
|
53 |
-
def install_proxy(proxy_handler):
|
54 |
-
proxy_support = request.ProxyHandler(proxy_handler)
|
55 |
-
opener = request.build_opener(proxy_support)
|
56 |
-
request.install_opener(opener)
|
57 |
-
|
58 |
-
def unicode(s):
|
59 |
-
"""No-op."""
|
60 |
-
return s
|
61 |
-
|
62 |
-
if PY33:
|
63 |
-
from html.parser import HTMLParser
|
64 |
-
|
65 |
-
def unescape(s):
|
66 |
-
"""Strip HTML entries from a string."""
|
67 |
-
html_parser = HTMLParser()
|
68 |
-
return html_parser.unescape(s)
|
69 |
-
else:
|
70 |
-
from html import unescape
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
pytube/contrib/playlist.py
CHANGED
@@ -1,107 +1,172 @@
|
|
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
|
|
|
|
|
|
|
9 |
|
10 |
-
from pytube import request
|
11 |
-
from pytube.
|
12 |
|
13 |
logger = logging.getLogger(__name__)
|
14 |
|
15 |
|
16 |
-
class Playlist(
|
17 |
-
"""
|
18 |
-
playlist
|
19 |
-
"""
|
20 |
-
|
21 |
-
def __init__(self, url, suppress_exception=False):
|
22 |
-
self.playlist_url = url
|
23 |
-
self.video_urls = []
|
24 |
-
self.suppress_exception = suppress_exception
|
25 |
|
26 |
-
def
|
27 |
-
|
28 |
-
|
29 |
-
is preferable to work with the later one.
|
30 |
-
|
31 |
-
:return: playlist url
|
32 |
-
"""
|
33 |
|
34 |
-
if 'watch?v=' in self.playlist_url:
|
35 |
-
base_url = 'https://www.youtube.com/playlist?list='
|
36 |
-
playlist_code = self.playlist_url.split('&list=')[1]
|
37 |
-
return base_url + playlist_code
|
38 |
-
|
39 |
-
# url is already in the desired format, so just return it
|
40 |
-
return self.playlist_url
|
41 |
-
|
42 |
-
def _load_more_url(self, req):
|
43 |
-
"""Given an html page or a fragment thereof, looks for
|
44 |
-
and returns the "load more" url if found.
|
45 |
-
"""
|
46 |
try:
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
59 |
"""
|
|
|
60 |
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
67 |
|
68 |
# The above only returns 100 or fewer links
|
69 |
# Simulating a browser request for the load more link
|
70 |
-
load_more_url = self.
|
71 |
-
|
72 |
-
|
|
|
73 |
req = request.get(load_more_url)
|
74 |
load_more = json.loads(req)
|
75 |
-
|
76 |
-
|
77 |
-
|
78 |
-
|
79 |
-
|
80 |
-
|
81 |
-
|
82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
83 |
)
|
84 |
|
85 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
86 |
|
87 |
-
|
88 |
-
|
89 |
-
|
90 |
|
91 |
-
:
|
92 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
93 |
|
94 |
-
|
95 |
-
|
96 |
|
97 |
-
|
98 |
-
|
99 |
-
|
|
|
|
|
100 |
|
101 |
-
|
|
|
102 |
"""
|
103 |
-
|
104 |
-
|
|
|
|
|
|
|
|
|
105 |
If the number of digits required to name a file,is less than is
|
106 |
required to name the last file,it prepends 0s.
|
107 |
So if you have a playlist of 100 videos it will number them like:
|
@@ -116,17 +181,17 @@ class Playlist(object):
|
|
116 |
start, stop, step = (1, len(self.video_urls) + 1, 1)
|
117 |
return (str(i).zfill(digits) for i in range(start, stop, step))
|
118 |
|
|
|
|
|
|
|
119 |
def download_all(
|
120 |
self,
|
121 |
-
download_path=None,
|
122 |
-
prefix_number=True,
|
123 |
-
reverse_numbering=False,
|
124 |
-
|
125 |
-
|
126 |
-
|
127 |
-
should be added to download resolution of choice
|
128 |
-
|
129 |
-
TODO(nficano): Add option to download resolution of user's choice
|
130 |
|
131 |
:param download_path:
|
132 |
(optional) Output path for the playlist If one is not
|
@@ -139,57 +204,49 @@ class Playlist(object):
|
|
139 |
:type prefix_number: bool
|
140 |
:param reverse_numbering:
|
141 |
(optional) Lets you number playlists in reverse, since some
|
142 |
-
playlists are ordered newest ->
|
143 |
:type reverse_numbering: bool
|
|
|
|
|
|
|
144 |
"""
|
145 |
-
|
146 |
-
|
147 |
-
logger.debug('total videos found: %d', len(self.video_urls))
|
148 |
-
logger.debug('starting download')
|
149 |
|
150 |
prefix_gen = self._path_num_prefix_generator(reverse_numbering)
|
151 |
|
152 |
for link in self.video_urls:
|
153 |
-
|
154 |
-
|
155 |
-
|
156 |
-
|
157 |
-
|
158 |
-
|
159 |
-
|
160 |
-
|
|
|
|
|
|
|
|
|
161 |
else:
|
162 |
-
|
163 |
-
|
164 |
-
|
165 |
-
|
166 |
-
|
167 |
-
|
168 |
-
|
169 |
-
|
170 |
-
|
171 |
-
logger.debug('file prefix is: %s', prefix)
|
172 |
-
dl_stream.download(download_path, filename_prefix=prefix)
|
173 |
-
else:
|
174 |
-
dl_stream.download(download_path)
|
175 |
-
logger.debug('download complete')
|
176 |
-
|
177 |
-
def title(self):
|
178 |
-
"""return playlist title (name)
|
179 |
"""
|
180 |
-
|
181 |
-
|
182 |
-
|
183 |
-
|
184 |
-
end_tag = '</title>'
|
185 |
-
matchresult = re.compile(open_tag + '(.+?)' + end_tag)
|
186 |
-
matchresult = matchresult.search(req).group()
|
187 |
-
matchresult = matchresult.replace(open_tag, '')
|
188 |
-
matchresult = matchresult.replace(end_tag, '')
|
189 |
-
matchresult = matchresult.replace('- YouTube', '')
|
190 |
-
matchresult = matchresult.strip()
|
191 |
-
|
192 |
-
return matchresult
|
193 |
-
except Exception as e:
|
194 |
-
logger.debug(e)
|
195 |
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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, YouTube
|
14 |
+
from pytube.helpers import cache, deprecated, install_proxy, uniqueify
|
15 |
|
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):
|
23 |
+
if proxies:
|
24 |
+
install_proxy(proxies)
|
|
|
|
|
|
|
|
|
25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
26 |
try:
|
27 |
+
self.playlist_id: str = parse_qs(url.split("?")[1])["list"][0]
|
28 |
+
except IndexError: # assume that url is just the id
|
29 |
+
self.playlist_id = url
|
30 |
+
|
31 |
+
self.playlist_url = f"https://www.youtube.com/playlist?list={self.playlist_id}"
|
32 |
+
self.html = request.get(self.playlist_url)
|
33 |
+
|
34 |
+
# Needs testing with non-English
|
35 |
+
self.last_update: Optional[date] = None
|
36 |
+
date_match = re.search(
|
37 |
+
r"<li>Last updated on (\w{3}) (\d{1,2}), (\d{4})</li>", self.html
|
38 |
+
)
|
39 |
+
if date_match:
|
40 |
+
month, day, year = date_match.groups()
|
41 |
+
self.last_update = datetime.strptime(
|
42 |
+
f"{month} {day:0>2} {year}", "%b %d %Y"
|
43 |
+
).date()
|
44 |
+
|
45 |
+
self._video_regex = re.compile(r"href=\"(/watch\?v=[\w-]*)")
|
46 |
+
|
47 |
+
@staticmethod
|
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\?" 'action_continuation=.*?)"',
|
52 |
+
req,
|
53 |
+
)
|
54 |
+
if match:
|
55 |
+
return f"https://www.youtube.com{match.group(1)}"
|
56 |
+
|
57 |
+
return None
|
58 |
+
|
59 |
+
@deprecated("This function will be removed in the future, please use .video_urls")
|
60 |
+
def parse_links(self) -> List[str]: # pragma: no cover
|
61 |
+
""" Deprecated function for returning list of URLs
|
62 |
+
|
63 |
+
:return: List[str]
|
64 |
"""
|
65 |
+
return self.video_urls
|
66 |
|
67 |
+
def _paginate(self, until_watch_id: Optional[str] = None) -> Iterable[List[str]]:
|
68 |
+
"""Parse the video links from the page source, yields the /watch?v= part from video link
|
69 |
+
"""
|
70 |
+
req = self.html
|
71 |
+
videos_urls = self._extract_videos(req)
|
72 |
+
if until_watch_id:
|
73 |
+
try:
|
74 |
+
trim_index = videos_urls.index(f"/watch?v={until_watch_id}")
|
75 |
+
yield videos_urls[:trim_index]
|
76 |
+
return
|
77 |
+
except ValueError:
|
78 |
+
pass
|
79 |
+
yield videos_urls
|
80 |
|
81 |
# The above only returns 100 or fewer links
|
82 |
# Simulating a browser request for the load more link
|
83 |
+
load_more_url = self._find_load_more_url(req)
|
84 |
+
|
85 |
+
while load_more_url: # there is an url found
|
86 |
+
logger.debug("load more url: %s", load_more_url)
|
87 |
req = request.get(load_more_url)
|
88 |
load_more = json.loads(req)
|
89 |
+
try:
|
90 |
+
html = load_more["content_html"]
|
91 |
+
except KeyError:
|
92 |
+
logger.debug("Could not find content_html")
|
93 |
+
return
|
94 |
+
videos_urls = self._extract_videos(html)
|
95 |
+
if until_watch_id:
|
96 |
+
try:
|
97 |
+
trim_index = videos_urls.index(f"/watch?v={until_watch_id}")
|
98 |
+
yield videos_urls[:trim_index]
|
99 |
+
return
|
100 |
+
except ValueError:
|
101 |
+
pass
|
102 |
+
yield videos_urls
|
103 |
+
|
104 |
+
load_more_url = self._find_load_more_url(
|
105 |
+
load_more["load_more_widget_html"],
|
106 |
)
|
107 |
|
108 |
+
return
|
109 |
+
|
110 |
+
def _extract_videos(self, html: str) -> List[str]:
|
111 |
+
return uniqueify(self._video_regex.findall(html))
|
112 |
+
|
113 |
+
def trimmed(self, video_id: str) -> Iterable[str]:
|
114 |
+
"""Retrieve a list of YouTube video URLs trimmed at the given video ID
|
115 |
+
|
116 |
+
i.e. if the playlist has video IDs 1,2,3,4 calling trimmed(3) returns [1,2]
|
117 |
+
:type video_id: str
|
118 |
+
video ID to trim the returned list of playlist URLs at
|
119 |
+
:rtype: List[str]
|
120 |
+
:returns:
|
121 |
+
List of video URLs from the playlist trimmed at the given ID
|
122 |
+
"""
|
123 |
+
for page in self._paginate(until_watch_id=video_id):
|
124 |
+
yield from (self._video_url(watch_path) for watch_path in page)
|
125 |
+
|
126 |
+
@property # type: ignore
|
127 |
+
@cache
|
128 |
+
def video_urls(self) -> List[str]:
|
129 |
+
"""Complete links of all the videos in playlist
|
130 |
+
|
131 |
+
:rtype: List[str]
|
132 |
+
:returns: List of video URLs
|
133 |
+
"""
|
134 |
+
return [
|
135 |
+
self._video_url(video) for page in list(self._paginate()) for video in page
|
136 |
+
]
|
137 |
|
138 |
+
@property
|
139 |
+
def videos(self) -> Iterable[YouTube]:
|
140 |
+
"""Yields YouTube objects of videos in this playlist
|
141 |
|
142 |
+
:Yields: YouTube
|
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]
|
162 |
+
:returns: List of video URLs
|
163 |
"""
|
164 |
+
return self.video_urls
|
165 |
+
|
166 |
+
@deprecated("This function will be removed in the future.")
|
167 |
+
def _path_num_prefix_generator(self, reverse=False): # pragma: no cover
|
168 |
+
"""Generate number prefixes for the items in the playlist.
|
169 |
+
|
170 |
If the number of digits required to name a file,is less than is
|
171 |
required to name the last file,it prepends 0s.
|
172 |
So if you have a playlist of 100 videos it will number them like:
|
|
|
181 |
start, stop, step = (1, len(self.video_urls) + 1, 1)
|
182 |
return (str(i).zfill(digits) for i in range(start, stop, step))
|
183 |
|
184 |
+
@deprecated(
|
185 |
+
"This function will be removed in the future. Please iterate through .videos"
|
186 |
+
)
|
187 |
def download_all(
|
188 |
self,
|
189 |
+
download_path: Optional[str] = None,
|
190 |
+
prefix_number: bool = True,
|
191 |
+
reverse_numbering: bool = False,
|
192 |
+
resolution: str = "720p",
|
193 |
+
) -> None: # pragma: no cover
|
194 |
+
"""Download all the videos in the the playlist.
|
|
|
|
|
|
|
195 |
|
196 |
:param download_path:
|
197 |
(optional) Output path for the playlist If one is not
|
|
|
204 |
:type prefix_number: bool
|
205 |
:param reverse_numbering:
|
206 |
(optional) Lets you number playlists in reverse, since some
|
207 |
+
playlists are ordered newest -> oldest.
|
208 |
:type reverse_numbering: bool
|
209 |
+
:param resolution:
|
210 |
+
Video resolution i.e. "720p", "480p", "360p", "240p", "144p"
|
211 |
+
:type resolution: str
|
212 |
"""
|
213 |
+
logger.debug("total videos found: %d", len(self.video_urls))
|
214 |
+
logger.debug("starting download")
|
|
|
|
|
215 |
|
216 |
prefix_gen = self._path_num_prefix_generator(reverse_numbering)
|
217 |
|
218 |
for link in self.video_urls:
|
219 |
+
youtube = YouTube(link)
|
220 |
+
dl_stream = (
|
221 |
+
youtube.streams.get_by_resolution(resolution=resolution)
|
222 |
+
or youtube.streams.get_lowest_resolution()
|
223 |
+
)
|
224 |
+
assert dl_stream is not None
|
225 |
+
|
226 |
+
logger.debug("download path: %s", download_path)
|
227 |
+
if prefix_number:
|
228 |
+
prefix = next(prefix_gen)
|
229 |
+
logger.debug("file prefix is: %s", prefix)
|
230 |
+
dl_stream.download(download_path, filename_prefix=prefix)
|
231 |
else:
|
232 |
+
dl_stream.download(download_path)
|
233 |
+
logger.debug("download complete")
|
234 |
+
|
235 |
+
@cache
|
236 |
+
def title(self) -> Optional[str]:
|
237 |
+
"""Extract playlist title
|
238 |
+
|
239 |
+
:return: playlist title (name)
|
240 |
+
:rtype: Optional[str]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
241 |
"""
|
242 |
+
pattern = re.compile("<title>(.+?)</title>")
|
243 |
+
match = pattern.search(self.html)
|
244 |
+
|
245 |
+
if match is None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
246 |
return None
|
247 |
+
|
248 |
+
return match.group(1).replace("- YouTube", "").strip()
|
249 |
+
|
250 |
+
@staticmethod
|
251 |
+
def _video_url(watch_path: str):
|
252 |
+
return f"https://www.youtube.com{watch_path}"
|
pytube/exceptions.py
CHANGED
@@ -1,6 +1,7 @@
|
|
1 |
# -*- coding: utf-8 -*-
|
|
|
2 |
"""Library specific exception definitions."""
|
3 |
-
import
|
4 |
|
5 |
|
6 |
class PytubeError(Exception):
|
@@ -15,30 +16,47 @@ class PytubeError(Exception):
|
|
15 |
class ExtractError(PytubeError):
|
16 |
"""Data extraction based exception."""
|
17 |
|
18 |
-
def __init__(self, msg, video_id=None):
|
19 |
-
"""Construct an instance of a :class:`ExtractError <ExtractError>`.
|
20 |
|
21 |
-
|
22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
23 |
:param str video_id:
|
24 |
A YouTube video identifier.
|
25 |
"""
|
26 |
-
|
27 |
-
msg = '{video_id}: {msg}'.format(video_id=video_id, msg=msg)
|
28 |
-
|
29 |
-
super(ExtractError, self).__init__(msg)
|
30 |
|
31 |
-
self.exc_info = sys.exc_info()
|
32 |
self.video_id = video_id
|
33 |
|
34 |
|
35 |
-
class
|
36 |
-
"""
|
37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
38 |
|
39 |
-
|
40 |
-
"""Video is a live stream."""
|
41 |
|
42 |
|
43 |
-
class
|
44 |
-
"""
|
|
|
1 |
# -*- coding: utf-8 -*-
|
2 |
+
|
3 |
"""Library specific exception definitions."""
|
4 |
+
from typing import Union, Pattern
|
5 |
|
6 |
|
7 |
class PytubeError(Exception):
|
|
|
16 |
class ExtractError(PytubeError):
|
17 |
"""Data extraction based exception."""
|
18 |
|
|
|
|
|
19 |
|
20 |
+
class RegexMatchError(ExtractError):
|
21 |
+
"""Regex pattern did not return any matches."""
|
22 |
+
|
23 |
+
def __init__(self, caller: str, pattern: Union[str, Pattern]):
|
24 |
+
"""
|
25 |
+
:param str caller:
|
26 |
+
Calling function
|
27 |
+
:param str pattern:
|
28 |
+
Pattern that failed to match
|
29 |
+
"""
|
30 |
+
super().__init__(f"{caller}: could not find match for {pattern}")
|
31 |
+
self.caller = caller
|
32 |
+
self.pattern = pattern
|
33 |
+
|
34 |
+
|
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."""
|
50 |
|
51 |
+
def __init__(self, video_id: str):
|
52 |
+
"""
|
53 |
+
:param str video_id:
|
54 |
+
A YouTube video identifier.
|
55 |
+
"""
|
56 |
+
super().__init__(f"{video_id} is unavailable")
|
57 |
|
58 |
+
self.video_id = video_id
|
|
|
59 |
|
60 |
|
61 |
+
class HTMLParseError(PytubeError):
|
62 |
+
"""HTML could not be parsed"""
|
pytube/extract.py
CHANGED
@@ -1,43 +1,52 @@
|
|
1 |
# -*- coding: utf-8 -*-
|
2 |
"""This module contains all non-cipher related data extraction logic."""
|
3 |
import json
|
|
|
|
|
4 |
from collections import OrderedDict
|
|
|
|
|
|
|
|
|
5 |
|
6 |
-
from pytube.
|
7 |
-
from pytube.
|
8 |
-
from pytube.compat import urlencode
|
9 |
-
from pytube.exceptions import RegexMatchError
|
10 |
from pytube.helpers import regex_search
|
11 |
|
|
|
|
|
12 |
|
13 |
class PytubeHTMLParser(HTMLParser):
|
14 |
in_vid_descr = False
|
15 |
in_vid_descr_br = False
|
16 |
-
vid_descr =
|
17 |
|
18 |
def handle_starttag(self, tag, attrs):
|
19 |
-
if tag ==
|
20 |
for attr in attrs:
|
21 |
-
if attr[0] ==
|
22 |
self.in_vid_descr = True
|
23 |
|
24 |
def handle_endtag(self, tag):
|
25 |
-
if self.in_vid_descr and tag ==
|
26 |
self.in_vid_descr = False
|
27 |
|
28 |
def handle_startendtag(self, tag, attrs):
|
29 |
-
if self.in_vid_descr and tag ==
|
30 |
self.in_vid_descr_br = True
|
31 |
|
32 |
def handle_data(self, data):
|
33 |
if self.in_vid_descr_br:
|
34 |
-
self.vid_descr +=
|
35 |
self.in_vid_descr_br = False
|
36 |
elif self.in_vid_descr:
|
37 |
self.vid_descr += data
|
38 |
|
|
|
|
|
|
|
39 |
|
40 |
-
def is_age_restricted(watch_html):
|
41 |
"""Check if content is age restricted.
|
42 |
|
43 |
:param str watch_html:
|
@@ -47,13 +56,13 @@ def is_age_restricted(watch_html):
|
|
47 |
Whether or not the content is age restricted.
|
48 |
"""
|
49 |
try:
|
50 |
-
regex_search(r
|
51 |
except RegexMatchError:
|
52 |
return False
|
53 |
return True
|
54 |
|
55 |
|
56 |
-
def video_id(url):
|
57 |
"""Extract the ``video_id`` from a YouTube url.
|
58 |
|
59 |
This function supports the following patterns:
|
@@ -68,88 +77,74 @@ def video_id(url):
|
|
68 |
:returns:
|
69 |
YouTube video id.
|
70 |
"""
|
71 |
-
return regex_search(r
|
72 |
|
73 |
|
74 |
-
def
|
75 |
-
"""Construct
|
76 |
|
77 |
:param str video_id:
|
78 |
A YouTube video identifier.
|
|
|
|
|
79 |
:rtype: str
|
80 |
:returns:
|
81 |
-
|
|
|
82 |
"""
|
83 |
-
|
84 |
-
|
85 |
-
|
86 |
-
|
87 |
-
|
88 |
-
|
89 |
-
|
90 |
-
|
91 |
-
|
92 |
-
|
93 |
-
|
94 |
-
|
95 |
-
|
96 |
-
age_restricted,
|
97 |
-
):
|
98 |
"""Construct the video_info url.
|
99 |
|
100 |
:param str video_id:
|
101 |
A YouTube video identifier.
|
102 |
-
:param str watch_url:
|
103 |
-
A YouTube watch url.
|
104 |
-
:param str watch_html:
|
105 |
-
The html contents of the watch page.
|
106 |
:param str embed_html:
|
107 |
The html contents of the embed page (for age restricted videos).
|
108 |
-
:param bool age_restricted:
|
109 |
-
Is video age restricted.
|
110 |
:rtype: str
|
111 |
:returns:
|
112 |
:samp:`https://youtube.com/get_video_info` with necessary GET
|
113 |
parameters.
|
114 |
"""
|
115 |
-
|
116 |
sts = regex_search(r'"sts"\s*:\s*(\d+)', embed_html, group=1)
|
117 |
-
|
118 |
-
|
119 |
-
|
120 |
-
|
121 |
-
|
122 |
-
|
123 |
-
|
124 |
-
else:
|
125 |
-
params = OrderedDict([
|
126 |
-
('video_id', video_id),
|
127 |
-
('el', '$el'),
|
128 |
-
('ps', 'default'),
|
129 |
-
('eurl', quote(watch_url)),
|
130 |
-
('hl', 'en_US'),
|
131 |
-
])
|
132 |
-
return 'https://youtube.com/get_video_info?' + urlencode(params)
|
133 |
|
134 |
|
135 |
-
def
|
|
|
|
|
|
|
|
|
136 |
"""Get the base JavaScript url.
|
137 |
|
138 |
Construct the base JavaScript url, which contains the decipher
|
139 |
"transforms".
|
140 |
|
141 |
-
:param str
|
142 |
The html contents of the watch page.
|
143 |
-
:param bool age_restricted:
|
144 |
-
Is video age restricted.
|
145 |
-
|
146 |
"""
|
147 |
-
|
148 |
-
|
149 |
-
return 'https://youtube.com' + base_js
|
150 |
|
151 |
|
152 |
-
def mime_type_codec(mime_type_codec):
|
153 |
"""Parse the type data.
|
154 |
|
155 |
Breaks up the data in the ``type`` key of the manifest, which contains the
|
@@ -158,8 +153,7 @@ def mime_type_codec(mime_type_codec):
|
|
158 |
|
159 |
**Example**:
|
160 |
|
161 |
-
|
162 |
-
('audio/webm', ['opus'])
|
163 |
|
164 |
:param str mime_type_codec:
|
165 |
String containing mime type and codecs.
|
@@ -168,35 +162,160 @@ def mime_type_codec(mime_type_codec):
|
|
168 |
The mime type and a list of codecs.
|
169 |
|
170 |
"""
|
171 |
-
pattern = r
|
172 |
-
|
173 |
-
|
|
|
|
|
|
|
|
|
174 |
|
175 |
|
176 |
-
def get_ytplayer_config(html
|
177 |
"""Get the YouTube player configuration data from the watch html.
|
178 |
|
179 |
Extract the ``ytplayer_config``, which is json data embedded within the
|
180 |
watch html and serves as the primary source of obtaining the stream
|
181 |
manifest data.
|
182 |
|
183 |
-
:param str
|
184 |
The html contents of the watch page.
|
185 |
-
:param bool age_restricted:
|
186 |
-
Is video age restricted.
|
187 |
:rtype: str
|
188 |
:returns:
|
189 |
Substring of the html containing the encoded manifest data.
|
190 |
"""
|
191 |
-
|
192 |
-
|
193 |
-
|
194 |
-
|
195 |
-
|
196 |
-
|
197 |
-
|
198 |
-
|
199 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
200 |
html_parser = PytubeHTMLParser()
|
201 |
-
|
|
|
202 |
return html_parser.vid_descr
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
# -*- coding: utf-8 -*-
|
2 |
"""This module contains all non-cipher related data extraction logic."""
|
3 |
import json
|
4 |
+
import logging
|
5 |
+
import re
|
6 |
from collections import OrderedDict
|
7 |
+
from html.parser import HTMLParser
|
8 |
+
from typing import Any, Optional, Tuple, List, Dict
|
9 |
+
from urllib.parse import quote, parse_qs, unquote, parse_qsl
|
10 |
+
from urllib.parse import urlencode
|
11 |
|
12 |
+
from pytube.cipher import Cipher
|
13 |
+
from pytube.exceptions import RegexMatchError, HTMLParseError, LiveStreamError
|
|
|
|
|
14 |
from pytube.helpers import regex_search
|
15 |
|
16 |
+
logger = logging.getLogger(__name__)
|
17 |
+
|
18 |
|
19 |
class PytubeHTMLParser(HTMLParser):
|
20 |
in_vid_descr = False
|
21 |
in_vid_descr_br = False
|
22 |
+
vid_descr = ""
|
23 |
|
24 |
def handle_starttag(self, tag, attrs):
|
25 |
+
if tag == "p":
|
26 |
for attr in attrs:
|
27 |
+
if attr[0] == "id" and attr[1] == "eow-description":
|
28 |
self.in_vid_descr = True
|
29 |
|
30 |
def handle_endtag(self, tag):
|
31 |
+
if self.in_vid_descr and tag == "p":
|
32 |
self.in_vid_descr = False
|
33 |
|
34 |
def handle_startendtag(self, tag, attrs):
|
35 |
+
if self.in_vid_descr and tag == "br":
|
36 |
self.in_vid_descr_br = True
|
37 |
|
38 |
def handle_data(self, data):
|
39 |
if self.in_vid_descr_br:
|
40 |
+
self.vid_descr += f"\n{data}"
|
41 |
self.in_vid_descr_br = False
|
42 |
elif self.in_vid_descr:
|
43 |
self.vid_descr += data
|
44 |
|
45 |
+
def error(self, message):
|
46 |
+
raise HTMLParseError(message)
|
47 |
+
|
48 |
|
49 |
+
def is_age_restricted(watch_html: str) -> bool:
|
50 |
"""Check if content is age restricted.
|
51 |
|
52 |
:param str watch_html:
|
|
|
56 |
Whether or not the content is age restricted.
|
57 |
"""
|
58 |
try:
|
59 |
+
regex_search(r"og:restrictions:age", watch_html, group=0)
|
60 |
except RegexMatchError:
|
61 |
return False
|
62 |
return True
|
63 |
|
64 |
|
65 |
+
def video_id(url: str) -> str:
|
66 |
"""Extract the ``video_id`` from a YouTube url.
|
67 |
|
68 |
This function supports the following patterns:
|
|
|
77 |
:returns:
|
78 |
YouTube video id.
|
79 |
"""
|
80 |
+
return regex_search(r"(?:v=|\/)([0-9A-Za-z_-]{11}).*", url, group=1)
|
81 |
|
82 |
|
83 |
+
def video_info_url(video_id: str, watch_url: str) -> str:
|
84 |
+
"""Construct the video_info url.
|
85 |
|
86 |
:param str video_id:
|
87 |
A YouTube video identifier.
|
88 |
+
:param str watch_url:
|
89 |
+
A YouTube watch url.
|
90 |
:rtype: str
|
91 |
:returns:
|
92 |
+
:samp:`https://youtube.com/get_video_info` with necessary GET
|
93 |
+
parameters.
|
94 |
"""
|
95 |
+
params = OrderedDict(
|
96 |
+
[
|
97 |
+
("video_id", video_id),
|
98 |
+
("el", "$el"),
|
99 |
+
("ps", "default"),
|
100 |
+
("eurl", quote(watch_url)),
|
101 |
+
("hl", "en_US"),
|
102 |
+
]
|
103 |
+
)
|
104 |
+
return _video_info_url(params)
|
105 |
+
|
106 |
+
|
107 |
+
def video_info_url_age_restricted(video_id: str, embed_html: str) -> str:
|
|
|
|
|
108 |
"""Construct the video_info url.
|
109 |
|
110 |
:param str video_id:
|
111 |
A YouTube video identifier.
|
|
|
|
|
|
|
|
|
112 |
:param str embed_html:
|
113 |
The html contents of the embed page (for age restricted videos).
|
|
|
|
|
114 |
:rtype: str
|
115 |
:returns:
|
116 |
:samp:`https://youtube.com/get_video_info` with necessary GET
|
117 |
parameters.
|
118 |
"""
|
119 |
+
try:
|
120 |
sts = regex_search(r'"sts"\s*:\s*(\d+)', embed_html, group=1)
|
121 |
+
except RegexMatchError:
|
122 |
+
sts = ""
|
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([("video_id", video_id), ("eurl", eurl), ("sts", sts),])
|
127 |
+
return _video_info_url(params)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
128 |
|
129 |
|
130 |
+
def _video_info_url(params: OrderedDict) -> str:
|
131 |
+
return "https://youtube.com/get_video_info?" + urlencode(params)
|
132 |
+
|
133 |
+
|
134 |
+
def js_url(html: str) -> str:
|
135 |
"""Get the base JavaScript url.
|
136 |
|
137 |
Construct the base JavaScript url, which contains the decipher
|
138 |
"transforms".
|
139 |
|
140 |
+
:param str html:
|
141 |
The html contents of the watch page.
|
|
|
|
|
|
|
142 |
"""
|
143 |
+
base_js = get_ytplayer_config(html)["assets"]["js"]
|
144 |
+
return "https://youtube.com" + base_js
|
|
|
145 |
|
146 |
|
147 |
+
def mime_type_codec(mime_type_codec: str) -> Tuple[str, List[str]]:
|
148 |
"""Parse the type data.
|
149 |
|
150 |
Breaks up the data in the ``type`` key of the manifest, which contains the
|
|
|
153 |
|
154 |
**Example**:
|
155 |
|
156 |
+
mime_type_codec('audio/webm; codecs="opus"') -> ('audio/webm', ['opus'])
|
|
|
157 |
|
158 |
:param str mime_type_codec:
|
159 |
String containing mime type and codecs.
|
|
|
162 |
The mime type and a list of codecs.
|
163 |
|
164 |
"""
|
165 |
+
pattern = r"(\w+\/\w+)\;\scodecs=\"([a-zA-Z-0-9.,\s]*)\""
|
166 |
+
regex = re.compile(pattern)
|
167 |
+
results = regex.search(mime_type_codec)
|
168 |
+
if not results:
|
169 |
+
raise RegexMatchError(caller="mime_type_codec", pattern=pattern)
|
170 |
+
mime_type, codecs = results.groups()
|
171 |
+
return mime_type, [c.strip() for c in codecs.split(",")]
|
172 |
|
173 |
|
174 |
+
def get_ytplayer_config(html: str) -> Any:
|
175 |
"""Get the YouTube player configuration data from the watch html.
|
176 |
|
177 |
Extract the ``ytplayer_config``, which is json data embedded within the
|
178 |
watch html and serves as the primary source of obtaining the stream
|
179 |
manifest data.
|
180 |
|
181 |
+
:param str html:
|
182 |
The html contents of the watch page.
|
|
|
|
|
183 |
:rtype: str
|
184 |
:returns:
|
185 |
Substring of the html containing the encoded manifest data.
|
186 |
"""
|
187 |
+
config_patterns = [
|
188 |
+
r";ytplayer\.config\s*=\s*({.*?});",
|
189 |
+
r";ytplayer\.config\s*=\s*({.+?});ytplayer",
|
190 |
+
r";yt\.setConfig\(\{'PLAYER_CONFIG':\s*({.*})}\);",
|
191 |
+
r";yt\.setConfig\(\{'PLAYER_CONFIG':\s*({.*})(,'EXPERIMENT_FLAGS'|;)", # noqa: E501
|
192 |
+
]
|
193 |
+
logger.debug("finding initial function name")
|
194 |
+
for pattern in config_patterns:
|
195 |
+
regex = re.compile(pattern)
|
196 |
+
function_match = regex.search(html)
|
197 |
+
if function_match:
|
198 |
+
logger.debug("finished regex search, matched: %s", pattern)
|
199 |
+
yt_player_config = function_match.group(1)
|
200 |
+
return json.loads(yt_player_config)
|
201 |
+
|
202 |
+
raise RegexMatchError(caller="get_ytplayer_config", pattern="config_patterns")
|
203 |
+
|
204 |
+
|
205 |
+
def _get_vid_descr(html: Optional[str]) -> str:
|
206 |
html_parser = PytubeHTMLParser()
|
207 |
+
if html:
|
208 |
+
html_parser.feed(html)
|
209 |
return html_parser.vid_descr
|
210 |
+
|
211 |
+
|
212 |
+
def apply_signature(config_args: Dict, fmt: str, js: str) -> None:
|
213 |
+
"""Apply the decrypted signature to the stream manifest.
|
214 |
+
|
215 |
+
:param dict config_args:
|
216 |
+
Details of the media streams available.
|
217 |
+
:param str fmt:
|
218 |
+
Key in stream manifests (``ytplayer_config``) containing progressive
|
219 |
+
download or adaptive streams (e.g.: ``url_encoded_fmt_stream_map`` or
|
220 |
+
``adaptive_fmts``).
|
221 |
+
:param str js:
|
222 |
+
The contents of the base.js asset file.
|
223 |
+
|
224 |
+
"""
|
225 |
+
cipher = Cipher(js=js)
|
226 |
+
stream_manifest = config_args[fmt]
|
227 |
+
|
228 |
+
for i, stream in enumerate(stream_manifest):
|
229 |
+
try:
|
230 |
+
url: str = stream["url"]
|
231 |
+
except KeyError:
|
232 |
+
live_stream = (
|
233 |
+
json.loads(config_args["player_response"])
|
234 |
+
.get("playabilityStatus", {},)
|
235 |
+
.get("liveStreamability")
|
236 |
+
)
|
237 |
+
if live_stream:
|
238 |
+
raise LiveStreamError("UNKNOWN")
|
239 |
+
# 403 Forbidden fix.
|
240 |
+
if "signature" in url or (
|
241 |
+
"s" not in stream and ("&sig=" in url or "&lsig=" in url)
|
242 |
+
):
|
243 |
+
# For certain videos, YouTube will just provide them pre-signed, in
|
244 |
+
# which case there's no real magic to download them and we can skip
|
245 |
+
# the whole signature descrambling entirely.
|
246 |
+
logger.debug("signature found, skip decipher")
|
247 |
+
continue
|
248 |
+
|
249 |
+
signature = cipher.get_signature(ciphered_signature=stream["s"])
|
250 |
+
|
251 |
+
logger.debug("finished descrambling signature for itag=%s", stream["itag"])
|
252 |
+
# 403 forbidden fix
|
253 |
+
stream_manifest[i]["url"] = url + "&sig=" + signature
|
254 |
+
|
255 |
+
|
256 |
+
def apply_descrambler(stream_data: Dict, key: str) -> None:
|
257 |
+
"""Apply various in-place transforms to YouTube's media stream data.
|
258 |
+
|
259 |
+
Creates a ``list`` of dictionaries by string splitting on commas, then
|
260 |
+
taking each list item, parsing it as a query string, converting it to a
|
261 |
+
``dict`` and unquoting the value.
|
262 |
+
|
263 |
+
:param dict stream_data:
|
264 |
+
Dictionary containing query string encoded values.
|
265 |
+
:param str key:
|
266 |
+
Name of the key in dictionary.
|
267 |
+
|
268 |
+
**Example**:
|
269 |
+
|
270 |
+
>>> d = {'foo': 'bar=1&var=test,em=5&t=url%20encoded'}
|
271 |
+
>>> apply_descrambler(d, 'foo')
|
272 |
+
>>> print(d)
|
273 |
+
{'foo': [{'bar': '1', 'var': 'test'}, {'em': '5', 't': 'url encoded'}]}
|
274 |
+
|
275 |
+
"""
|
276 |
+
otf_type = "FORMAT_STREAM_TYPE_OTF"
|
277 |
+
|
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"]["formats"]
|
282 |
+
formats.extend(
|
283 |
+
json.loads(stream_data["player_response"])["streamingData"][
|
284 |
+
"adaptiveFormats"
|
285 |
+
]
|
286 |
+
)
|
287 |
+
try:
|
288 |
+
stream_data[key] = [
|
289 |
+
{
|
290 |
+
"url": format_item["url"],
|
291 |
+
"type": format_item["mimeType"],
|
292 |
+
"quality": format_item["quality"],
|
293 |
+
"itag": format_item["itag"],
|
294 |
+
"bitrate": format_item.get("bitrate"),
|
295 |
+
"is_otf": (format_item.get("type") == otf_type),
|
296 |
+
}
|
297 |
+
for format_item in formats
|
298 |
+
]
|
299 |
+
except KeyError:
|
300 |
+
cipher_url = [
|
301 |
+
parse_qs(formats[i]["cipher"]) for i, data in enumerate(formats)
|
302 |
+
]
|
303 |
+
stream_data[key] = [
|
304 |
+
{
|
305 |
+
"url": cipher_url[i]["url"][0],
|
306 |
+
"s": cipher_url[i]["s"][0],
|
307 |
+
"type": format_item["mimeType"],
|
308 |
+
"quality": format_item["quality"],
|
309 |
+
"itag": format_item["itag"],
|
310 |
+
"bitrate": format_item.get("bitrate"),
|
311 |
+
"is_otf": (format_item.get("type") == otf_type),
|
312 |
+
}
|
313 |
+
for i, format_item in enumerate(formats)
|
314 |
+
]
|
315 |
+
else:
|
316 |
+
stream_data[key] = [
|
317 |
+
{k: unquote(v) for k, v in parse_qsl(i)}
|
318 |
+
for i in stream_data[key].split(",")
|
319 |
+
]
|
320 |
+
|
321 |
+
logger.debug("applying descrambler")
|
pytube/helpers.py
CHANGED
@@ -1,107 +1,44 @@
|
|
1 |
# -*- coding: utf-8 -*-
|
2 |
-
"""Various helper functions implemented by pytube."""
|
3 |
-
from __future__ import absolute_import
|
4 |
|
|
|
|
|
5 |
import logging
|
6 |
-
import
|
7 |
import re
|
|
|
|
|
|
|
8 |
|
9 |
-
from pytube.compat import unicode
|
10 |
from pytube.exceptions import RegexMatchError
|
11 |
|
12 |
-
|
13 |
logger = logging.getLogger(__name__)
|
14 |
|
15 |
|
16 |
-
def regex_search(pattern, string
|
17 |
"""Shortcut method to search a string for a given pattern.
|
18 |
|
19 |
:param str pattern:
|
20 |
A regular expression pattern.
|
21 |
:param str string:
|
22 |
A target string to search.
|
23 |
-
:param bool groups:
|
24 |
-
Should the return value be ``.groups()``.
|
25 |
:param int group:
|
26 |
Index of group to return.
|
27 |
-
:param int flags:
|
28 |
-
Expression behavior modifiers.
|
29 |
:rtype:
|
30 |
str or tuple
|
31 |
:returns:
|
32 |
Substring pattern matches.
|
33 |
"""
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
|
42 |
-
)
|
43 |
-
else:
|
44 |
-
logger.debug(
|
45 |
-
'finished regex search: %s',
|
46 |
-
pprint.pformat(
|
47 |
-
{
|
48 |
-
'pattern': p,
|
49 |
-
'results': results.group(0),
|
50 |
-
}, indent=2,
|
51 |
-
),
|
52 |
-
)
|
53 |
-
if groups:
|
54 |
-
return results.groups()
|
55 |
-
elif group is not None:
|
56 |
-
return results.group(group)
|
57 |
-
else:
|
58 |
-
return results
|
59 |
-
else:
|
60 |
-
regex = re.compile(pattern, flags)
|
61 |
-
results = regex.search(string)
|
62 |
-
if not results:
|
63 |
-
raise RegexMatchError(
|
64 |
-
'regex pattern ({pattern}) had zero matches'
|
65 |
-
.format(pattern=pattern),
|
66 |
-
)
|
67 |
-
else:
|
68 |
-
logger.debug(
|
69 |
-
'finished regex search: %s',
|
70 |
-
pprint.pformat(
|
71 |
-
{
|
72 |
-
'pattern': pattern,
|
73 |
-
'results': results.group(0),
|
74 |
-
}, indent=2,
|
75 |
-
),
|
76 |
-
)
|
77 |
-
if groups:
|
78 |
-
return results.groups()
|
79 |
-
elif group is not None:
|
80 |
-
return results.group(group)
|
81 |
-
else:
|
82 |
-
return results
|
83 |
-
|
84 |
-
|
85 |
-
def apply_mixin(dct, key, func, *args, **kwargs):
|
86 |
-
r"""Apply in-place data mutation to a dictionary.
|
87 |
-
|
88 |
-
:param dict dct:
|
89 |
-
Dictionary to apply mixin function to.
|
90 |
-
:param str key:
|
91 |
-
Key within dictionary to apply mixin function to.
|
92 |
-
:param callable func:
|
93 |
-
Transform function to apply to ``dct[key]``.
|
94 |
-
:param \*args:
|
95 |
-
(optional) positional arguments that ``func`` takes.
|
96 |
-
:param \*\*kwargs:
|
97 |
-
(optional) keyword arguments that ``func`` takes.
|
98 |
-
:rtype:
|
99 |
-
None
|
100 |
-
"""
|
101 |
-
dct[key] = func(dct[key], *args, **kwargs)
|
102 |
|
103 |
|
104 |
-
def safe_filename(s, max_length=255):
|
105 |
"""Sanitize a string making it safe to use as a filename.
|
106 |
|
107 |
This function was based off the limitations outlined here:
|
@@ -116,12 +53,120 @@ def safe_filename(s, max_length=255):
|
|
116 |
A sanitized string.
|
117 |
"""
|
118 |
# Characters in range 0-31 (0x00-0x1F) are not allowed in ntfs filenames.
|
119 |
-
|
120 |
-
|
121 |
-
'
|
122 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
123 |
]
|
124 |
-
pattern =
|
125 |
regex = re.compile(pattern, re.UNICODE)
|
126 |
-
filename = regex.sub(
|
127 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 TypeVar, Callable, Optional, Dict, List, Any
|
10 |
+
from urllib import request
|
11 |
|
|
|
12 |
from pytube.exceptions import RegexMatchError
|
13 |
|
|
|
14 |
logger = logging.getLogger(__name__)
|
15 |
|
16 |
|
17 |
+
def regex_search(pattern: str, string: str, group: int) -> str:
|
18 |
"""Shortcut method to search a string for a given pattern.
|
19 |
|
20 |
:param str pattern:
|
21 |
A regular expression pattern.
|
22 |
:param str string:
|
23 |
A target string to search.
|
|
|
|
|
24 |
:param int group:
|
25 |
Index of group to return.
|
|
|
|
|
26 |
:rtype:
|
27 |
str or tuple
|
28 |
:returns:
|
29 |
Substring pattern matches.
|
30 |
"""
|
31 |
+
regex = re.compile(pattern)
|
32 |
+
results = regex.search(string)
|
33 |
+
if not results:
|
34 |
+
raise RegexMatchError(caller="regex_search", pattern=pattern)
|
35 |
+
|
36 |
+
logger.debug("matched regex search: %s", pattern)
|
37 |
+
|
38 |
+
return results.group(group)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
39 |
|
40 |
|
41 |
+
def safe_filename(s: str, max_length: int = 255) -> str:
|
42 |
"""Sanitize a string making it safe to use as a filename.
|
43 |
|
44 |
This function was based off the limitations outlined here:
|
|
|
53 |
A sanitized string.
|
54 |
"""
|
55 |
# Characters in range 0-31 (0x00-0x1F) are not allowed in ntfs filenames.
|
56 |
+
ntfs_characters = [chr(i) for i in range(0, 31)]
|
57 |
+
characters = [
|
58 |
+
r'"',
|
59 |
+
r"\#",
|
60 |
+
r"\$",
|
61 |
+
r"\%",
|
62 |
+
r"'",
|
63 |
+
r"\*",
|
64 |
+
r"\,",
|
65 |
+
r"\.",
|
66 |
+
r"\/",
|
67 |
+
r"\:",
|
68 |
+
r'"',
|
69 |
+
r"\;",
|
70 |
+
r"\<",
|
71 |
+
r"\>",
|
72 |
+
r"\?",
|
73 |
+
r"\\",
|
74 |
+
r"\^",
|
75 |
+
r"\|",
|
76 |
+
r"\~",
|
77 |
+
r"\\\\",
|
78 |
]
|
79 |
+
pattern = "|".join(ntfs_characters + characters)
|
80 |
regex = re.compile(pattern, re.UNICODE)
|
81 |
+
filename = regex.sub("", s)
|
82 |
+
return filename[:max_length].rsplit(" ", 0)[0]
|
83 |
+
|
84 |
+
|
85 |
+
def setup_logger(level: int = logging.ERROR):
|
86 |
+
"""Create a configured instance of logger.
|
87 |
+
|
88 |
+
:param int level:
|
89 |
+
Describe the severity level of the logs to handle.
|
90 |
+
"""
|
91 |
+
fmt = "[%(asctime)s] %(levelname)s in %(module)s: %(message)s"
|
92 |
+
date_fmt = "%H:%M:%S"
|
93 |
+
formatter = logging.Formatter(fmt, datefmt=date_fmt)
|
94 |
+
|
95 |
+
handler = logging.StreamHandler()
|
96 |
+
handler.setFormatter(formatter)
|
97 |
+
|
98 |
+
# https://github.com/nficano/pytube/issues/163
|
99 |
+
logger = logging.getLogger("pytube")
|
100 |
+
logger.addHandler(handler)
|
101 |
+
logger.setLevel(level)
|
102 |
+
|
103 |
+
|
104 |
+
GenericType = TypeVar("GenericType")
|
105 |
+
|
106 |
+
|
107 |
+
def cache(func: Callable[..., GenericType]) -> GenericType:
|
108 |
+
""" mypy compatible annotation wrapper for lru_cache"""
|
109 |
+
return functools.lru_cache()(func) # type: ignore
|
110 |
+
|
111 |
+
|
112 |
+
def deprecated(reason: str) -> Callable:
|
113 |
+
"""
|
114 |
+
This is a decorator which can be used to mark functions
|
115 |
+
as deprecated. It will result in a warning being emitted
|
116 |
+
when the function is used.
|
117 |
+
"""
|
118 |
+
|
119 |
+
def decorator(func1):
|
120 |
+
message = "Call to deprecated function {name} ({reason})."
|
121 |
+
|
122 |
+
@functools.wraps(func1)
|
123 |
+
def new_func1(*args, **kwargs):
|
124 |
+
warnings.simplefilter("always", DeprecationWarning)
|
125 |
+
warnings.warn(
|
126 |
+
message.format(name=func1.__name__, reason=reason),
|
127 |
+
category=DeprecationWarning,
|
128 |
+
stacklevel=2,
|
129 |
+
)
|
130 |
+
warnings.simplefilter("default", DeprecationWarning)
|
131 |
+
return func1(*args, **kwargs)
|
132 |
+
|
133 |
+
return new_func1
|
134 |
+
|
135 |
+
return decorator
|
136 |
+
|
137 |
+
|
138 |
+
def target_directory(output_path: Optional[str] = None) -> str:
|
139 |
+
"""
|
140 |
+
Function for determining target directory of a download.
|
141 |
+
Returns an absolute path (if relative one given) or the current
|
142 |
+
path (if none given). Makes directory if it does not exist.
|
143 |
+
|
144 |
+
:type output_path: str
|
145 |
+
:rtype: str
|
146 |
+
:returns:
|
147 |
+
An absolute directory path as a string.
|
148 |
+
"""
|
149 |
+
if output_path:
|
150 |
+
if not os.path.isabs(output_path):
|
151 |
+
output_path = os.path.join(os.getcwd(), output_path)
|
152 |
+
else:
|
153 |
+
output_path = os.getcwd()
|
154 |
+
os.makedirs(output_path, exist_ok=True)
|
155 |
+
return output_path
|
156 |
+
|
157 |
+
|
158 |
+
def install_proxy(proxy_handler: Dict[str, str]) -> None:
|
159 |
+
proxy_support = request.ProxyHandler(proxy_handler)
|
160 |
+
opener = request.build_opener(proxy_support)
|
161 |
+
request.install_opener(opener)
|
162 |
+
|
163 |
+
|
164 |
+
def uniqueify(duped_list: List) -> List:
|
165 |
+
seen: Dict[Any, bool] = {}
|
166 |
+
result = []
|
167 |
+
for item in duped_list:
|
168 |
+
if item in seen:
|
169 |
+
continue
|
170 |
+
seen[item] = True
|
171 |
+
result.append(item)
|
172 |
+
return result
|
pytube/itags.py
CHANGED
@@ -1,92 +1,91 @@
|
|
1 |
# -*- coding: utf-8 -*-
|
2 |
"""This module contains a lookup table of YouTube's itag values."""
|
|
|
3 |
|
4 |
ITAGS = {
|
5 |
-
5: (
|
6 |
-
6: (
|
7 |
-
13: (
|
8 |
-
17: (
|
9 |
-
18: (
|
10 |
-
22: (
|
11 |
-
34: (
|
12 |
-
35: (
|
13 |
-
36: (
|
14 |
-
37: (
|
15 |
-
38: (
|
16 |
-
43: (
|
17 |
-
44: (
|
18 |
-
45: (
|
19 |
-
46: (
|
20 |
-
59: (
|
21 |
-
78: (
|
22 |
-
82: (
|
23 |
-
83: (
|
24 |
-
84: (
|
25 |
-
85: (
|
26 |
-
91: (
|
27 |
-
92: (
|
28 |
-
93: (
|
29 |
-
94: (
|
30 |
-
95: (
|
31 |
-
96: (
|
32 |
-
100: (
|
33 |
-
101: (
|
34 |
-
102: (
|
35 |
-
132: (
|
36 |
-
151: (
|
37 |
-
|
38 |
# DASH Video
|
39 |
-
133: (
|
40 |
-
134: (
|
41 |
-
135: (
|
42 |
-
136: (
|
43 |
-
137: (
|
44 |
-
138: (
|
45 |
-
160: (
|
46 |
-
167: (
|
47 |
-
168: (
|
48 |
-
169: (
|
49 |
-
170: (
|
50 |
-
212: (
|
51 |
-
218: (
|
52 |
-
219: (
|
53 |
-
242: (
|
54 |
-
243: (
|
55 |
-
244: (
|
56 |
-
245: (
|
57 |
-
246: (
|
58 |
-
247: (
|
59 |
-
248: (
|
60 |
-
264: (
|
61 |
-
266: (
|
62 |
-
271: (
|
63 |
-
272: (
|
64 |
-
278: (
|
65 |
-
298: (
|
66 |
-
299: (
|
67 |
-
302: (
|
68 |
-
303: (
|
69 |
-
308: (
|
70 |
-
313: (
|
71 |
-
315: (
|
72 |
-
330: (
|
73 |
-
331: (
|
74 |
-
332: (
|
75 |
-
333: (
|
76 |
-
334: (
|
77 |
-
335: (
|
78 |
-
336: (
|
79 |
-
337: (
|
80 |
-
|
81 |
# DASH Audio
|
82 |
-
139: (None,
|
83 |
-
140: (None,
|
84 |
-
141: (None,
|
85 |
-
171: (None,
|
86 |
-
172: (None,
|
87 |
-
249: (None,
|
88 |
-
250: (None,
|
89 |
-
251: (None,
|
90 |
256: (None, None),
|
91 |
258: (None, None),
|
92 |
325: (None, None),
|
@@ -97,10 +96,36 @@ HDR = [330, 331, 332, 333, 334, 335, 336, 337]
|
|
97 |
_60FPS = [298, 299, 302, 303, 308, 315] + HDR
|
98 |
_3D = [82, 83, 84, 85, 100, 101, 102]
|
99 |
LIVE = [91, 92, 93, 94, 95, 96, 132, 151]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
100 |
|
101 |
|
102 |
-
def get_format_profile(itag):
|
103 |
-
"""Get
|
104 |
|
105 |
:param str itag:
|
106 |
YouTube format identifier code.
|
@@ -111,10 +136,14 @@ def get_format_profile(itag):
|
|
111 |
else:
|
112 |
res, bitrate = None, None
|
113 |
return {
|
114 |
-
|
115 |
-
|
116 |
-
|
117 |
-
|
118 |
-
|
119 |
-
|
|
|
|
|
|
|
|
|
120 |
}
|
|
|
1 |
# -*- coding: utf-8 -*-
|
2 |
"""This module contains a lookup table of YouTube's itag values."""
|
3 |
+
from typing import Dict
|
4 |
|
5 |
ITAGS = {
|
6 |
+
5: ("240p", "64kbps"),
|
7 |
+
6: ("270p", "64kbps"),
|
8 |
+
13: ("144p", None),
|
9 |
+
17: ("144p", "24kbps"),
|
10 |
+
18: ("360p", "96kbps"),
|
11 |
+
22: ("720p", "192kbps"),
|
12 |
+
34: ("360p", "128kbps"),
|
13 |
+
35: ("480p", "128kbps"),
|
14 |
+
36: ("240p", None),
|
15 |
+
37: ("1080p", "192kbps"),
|
16 |
+
38: ("3072p", "192kbps"),
|
17 |
+
43: ("360p", "128kbps"),
|
18 |
+
44: ("480p", "128kbps"),
|
19 |
+
45: ("720p", "192kbps"),
|
20 |
+
46: ("1080p", "192kbps"),
|
21 |
+
59: ("480p", "128kbps"),
|
22 |
+
78: ("480p", "128kbps"),
|
23 |
+
82: ("360p", "128kbps"),
|
24 |
+
83: ("480p", "128kbps"),
|
25 |
+
84: ("720p", "192kbps"),
|
26 |
+
85: ("1080p", "192kbps"),
|
27 |
+
91: ("144p", "48kbps"),
|
28 |
+
92: ("240p", "48kbps"),
|
29 |
+
93: ("360p", "128kbps"),
|
30 |
+
94: ("480p", "128kbps"),
|
31 |
+
95: ("720p", "256kbps"),
|
32 |
+
96: ("1080p", "256kbps"),
|
33 |
+
100: ("360p", "128kbps"),
|
34 |
+
101: ("480p", "192kbps"),
|
35 |
+
102: ("720p", "192kbps"),
|
36 |
+
132: ("240p", "48kbps"),
|
37 |
+
151: ("720p", "24kbps"),
|
|
|
38 |
# DASH Video
|
39 |
+
133: ("240p", None),
|
40 |
+
134: ("360p", None),
|
41 |
+
135: ("480p", None),
|
42 |
+
136: ("720p", None),
|
43 |
+
137: ("1080p", None),
|
44 |
+
138: ("2160p", None),
|
45 |
+
160: ("144p", None),
|
46 |
+
167: ("360p", None),
|
47 |
+
168: ("480p", None),
|
48 |
+
169: ("720p", None),
|
49 |
+
170: ("1080p", None),
|
50 |
+
212: ("480p", None),
|
51 |
+
218: ("480p", None),
|
52 |
+
219: ("480p", None),
|
53 |
+
242: ("240p", None),
|
54 |
+
243: ("360p", None),
|
55 |
+
244: ("480p", None),
|
56 |
+
245: ("480p", None),
|
57 |
+
246: ("480p", None),
|
58 |
+
247: ("720p", None),
|
59 |
+
248: ("1080p", None),
|
60 |
+
264: ("1440p", None),
|
61 |
+
266: ("2160p", None),
|
62 |
+
271: ("1440p", None),
|
63 |
+
272: ("2160p", None),
|
64 |
+
278: ("144p", None),
|
65 |
+
298: ("720p", None),
|
66 |
+
299: ("1080p", None),
|
67 |
+
302: ("720p", None),
|
68 |
+
303: ("1080p", None),
|
69 |
+
308: ("1440p", None),
|
70 |
+
313: ("2160p", None),
|
71 |
+
315: ("2160p", None),
|
72 |
+
330: ("144p", None),
|
73 |
+
331: ("240p", None),
|
74 |
+
332: ("360p", None),
|
75 |
+
333: ("480p", None),
|
76 |
+
334: ("720p", None),
|
77 |
+
335: ("1080p", None),
|
78 |
+
336: ("1440p", None),
|
79 |
+
337: ("2160p", None),
|
|
|
80 |
# DASH Audio
|
81 |
+
139: (None, "48kbps"),
|
82 |
+
140: (None, "128kbps"),
|
83 |
+
141: (None, "256kbps"),
|
84 |
+
171: (None, "128kbps"),
|
85 |
+
172: (None, "256kbps"),
|
86 |
+
249: (None, "50kbps"),
|
87 |
+
250: (None, "70kbps"),
|
88 |
+
251: (None, "160kbps"),
|
89 |
256: (None, None),
|
90 |
258: (None, None),
|
91 |
325: (None, None),
|
|
|
96 |
_60FPS = [298, 299, 302, 303, 308, 315] + HDR
|
97 |
_3D = [82, 83, 84, 85, 100, 101, 102]
|
98 |
LIVE = [91, 92, 93, 94, 95, 96, 132, 151]
|
99 |
+
DASH_MP4_VIDEO = [133, 134, 135, 136, 137, 138, 160, 212, 264, 266, 298, 299]
|
100 |
+
DASH_MP4_AUDIO = [139, 140, 141, 256, 258, 325, 328]
|
101 |
+
DASH_WEBM_VIDEO = [
|
102 |
+
167,
|
103 |
+
168,
|
104 |
+
169,
|
105 |
+
170,
|
106 |
+
218,
|
107 |
+
219,
|
108 |
+
278,
|
109 |
+
242,
|
110 |
+
243,
|
111 |
+
244,
|
112 |
+
245,
|
113 |
+
246,
|
114 |
+
247,
|
115 |
+
248,
|
116 |
+
271,
|
117 |
+
272,
|
118 |
+
302,
|
119 |
+
303,
|
120 |
+
308,
|
121 |
+
313,
|
122 |
+
315,
|
123 |
+
]
|
124 |
+
DASH_WEBM_AUDIO = [171, 172, 249, 250, 251]
|
125 |
|
126 |
|
127 |
+
def get_format_profile(itag: int) -> Dict:
|
128 |
+
"""Get additional format information for a given itag.
|
129 |
|
130 |
:param str itag:
|
131 |
YouTube format identifier code.
|
|
|
136 |
else:
|
137 |
res, bitrate = None, None
|
138 |
return {
|
139 |
+
"resolution": res,
|
140 |
+
"abr": bitrate,
|
141 |
+
"is_live": itag in LIVE,
|
142 |
+
"is_3d": itag in _3D,
|
143 |
+
"is_hdr": itag in HDR,
|
144 |
+
"fps": 60 if itag in _60FPS else 30,
|
145 |
+
"is_dash": itag in DASH_MP4_VIDEO
|
146 |
+
or itag in DASH_MP4_AUDIO
|
147 |
+
or itag in DASH_WEBM_VIDEO
|
148 |
+
or itag in DASH_WEBM_AUDIO,
|
149 |
}
|
pytube/logging.py
DELETED
@@ -1,25 +0,0 @@
|
|
1 |
-
# -*- coding: utf-8 -*-
|
2 |
-
"""This module implements a log factory."""
|
3 |
-
from __future__ import absolute_import
|
4 |
-
|
5 |
-
import logging
|
6 |
-
|
7 |
-
|
8 |
-
def create_logger(level=logging.ERROR):
|
9 |
-
"""Create a configured instance of logger.
|
10 |
-
|
11 |
-
:param int level:
|
12 |
-
Describe the severity level of the logs to handle.
|
13 |
-
"""
|
14 |
-
fmt = '[%(asctime)s] %(levelname)s in %(module)s: %(message)s'
|
15 |
-
date_fmt = '%H:%M:%S'
|
16 |
-
formatter = logging.Formatter(fmt, datefmt=date_fmt)
|
17 |
-
|
18 |
-
handler = logging.StreamHandler()
|
19 |
-
handler.setFormatter(formatter)
|
20 |
-
|
21 |
-
# https://github.com/nficano/pytube/issues/163
|
22 |
-
logger = logging.getLogger('pytube')
|
23 |
-
logger.addHandler(handler)
|
24 |
-
logger.setLevel(level)
|
25 |
-
return logger
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
pytube/mixins.py
DELETED
@@ -1,101 +0,0 @@
|
|
1 |
-
# -*- coding: utf-8 -*-
|
2 |
-
"""Applies in-place data mutations."""
|
3 |
-
from __future__ import absolute_import
|
4 |
-
|
5 |
-
import json
|
6 |
-
import logging
|
7 |
-
import pprint
|
8 |
-
|
9 |
-
from pytube import cipher
|
10 |
-
from pytube.compat import parse_qsl
|
11 |
-
from pytube.compat import unquote
|
12 |
-
from pytube.exceptions import LiveStreamError
|
13 |
-
|
14 |
-
|
15 |
-
logger = logging.getLogger(__name__)
|
16 |
-
|
17 |
-
|
18 |
-
def apply_signature(config_args, fmt, js):
|
19 |
-
"""Apply the decrypted signature to the stream manifest.
|
20 |
-
|
21 |
-
:param dict config_args:
|
22 |
-
Details of the media streams available.
|
23 |
-
:param str fmt:
|
24 |
-
Key in stream manifests (``ytplayer_config``) containing progressive
|
25 |
-
download or adaptive streams (e.g.: ``url_encoded_fmt_stream_map`` or
|
26 |
-
``adaptive_fmts``).
|
27 |
-
:param str js:
|
28 |
-
The contents of the base.js asset file.
|
29 |
-
|
30 |
-
"""
|
31 |
-
stream_manifest = config_args[fmt]
|
32 |
-
live_stream = json.loads(config_args['player_response']).get(
|
33 |
-
'playabilityStatus', {},
|
34 |
-
).get('liveStreamability')
|
35 |
-
for i, stream in enumerate(stream_manifest):
|
36 |
-
if 'url' in stream:
|
37 |
-
url = stream['url']
|
38 |
-
elif live_stream:
|
39 |
-
raise LiveStreamError('Video is currently being streamed live')
|
40 |
-
# 403 Forbidden fix.
|
41 |
-
if (
|
42 |
-
'signature' in url or (
|
43 |
-
's' not in stream and (
|
44 |
-
'&sig=' in url or '&lsig=' in url
|
45 |
-
)
|
46 |
-
)
|
47 |
-
):
|
48 |
-
# For certain videos, YouTube will just provide them pre-signed, in
|
49 |
-
# which case there's no real magic to download them and we can skip
|
50 |
-
# the whole signature descrambling entirely.
|
51 |
-
logger.debug('signature found, skip decipher')
|
52 |
-
continue
|
53 |
-
|
54 |
-
if js is not None:
|
55 |
-
signature = cipher.get_signature(js, stream['s'])
|
56 |
-
else:
|
57 |
-
# signature not present in url (line 33), need js to descramble
|
58 |
-
# TypeError caught in __main__
|
59 |
-
raise TypeError('JS is None')
|
60 |
-
|
61 |
-
logger.debug(
|
62 |
-
'finished descrambling signature for itag=%s\n%s',
|
63 |
-
stream['itag'], pprint.pformat(
|
64 |
-
{
|
65 |
-
's': stream['s'],
|
66 |
-
'signature': signature,
|
67 |
-
}, indent=2,
|
68 |
-
),
|
69 |
-
)
|
70 |
-
# 403 forbidden fix
|
71 |
-
stream_manifest[i]['url'] = url + '&sig=' + signature
|
72 |
-
|
73 |
-
|
74 |
-
def apply_descrambler(stream_data, key):
|
75 |
-
"""Apply various in-place transforms to YouTube's media stream data.
|
76 |
-
|
77 |
-
Creates a ``list`` of dictionaries by string splitting on commas, then
|
78 |
-
taking each list item, parsing it as a query string, converting it to a
|
79 |
-
``dict`` and unquoting the value.
|
80 |
-
|
81 |
-
:param dict dct:
|
82 |
-
Dictionary containing query string encoded values.
|
83 |
-
:param str key:
|
84 |
-
Name of the key in dictionary.
|
85 |
-
|
86 |
-
**Example**:
|
87 |
-
|
88 |
-
>>> d = {'foo': 'bar=1&var=test,em=5&t=url%20encoded'}
|
89 |
-
>>> apply_descrambler(d, 'foo')
|
90 |
-
>>> print(d)
|
91 |
-
{'foo': [{'bar': '1', 'var': 'test'}, {'em': '5', 't': 'url encoded'}]}
|
92 |
-
|
93 |
-
"""
|
94 |
-
stream_data[key] = [
|
95 |
-
{k: unquote(v) for k, v in parse_qsl(i)}
|
96 |
-
for i in stream_data[key].split(',')
|
97 |
-
]
|
98 |
-
logger.debug(
|
99 |
-
'applying descrambler\n%s',
|
100 |
-
pprint.pformat(stream_data[key], indent=2),
|
101 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
pytube/monostate.py
ADDED
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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__(self, stream: Any, chunk: bytes, bytes_remaining: int) -> None:
|
9 |
+
"""On download progress callback function.
|
10 |
+
|
11 |
+
:param stream:
|
12 |
+
An instance of :class:`Stream <Stream>` being downloaded.
|
13 |
+
:type stream:
|
14 |
+
:py:class:`pytube.Stream`
|
15 |
+
:param bytes chunk:
|
16 |
+
Segment of media file binary data, not yet written to disk.
|
17 |
+
:param int bytes_remaining:
|
18 |
+
How many bytes have been downloaded.
|
19 |
+
|
20 |
+
"""
|
21 |
+
...
|
22 |
+
|
23 |
+
|
24 |
+
class OnComplete(Protocol):
|
25 |
+
def __call__(self, stream: Any, file_path: Optional[str]) -> None:
|
26 |
+
"""On download complete handler function.
|
27 |
+
|
28 |
+
:param stream:
|
29 |
+
An instance of :class:`Stream <Stream>` being downloaded.
|
30 |
+
:type stream:
|
31 |
+
:py:class:`pytube.Stream`
|
32 |
+
:param file_path:
|
33 |
+
The file handle where the media is being written to.
|
34 |
+
:type file_path: str
|
35 |
+
|
36 |
+
:rtype: None
|
37 |
+
"""
|
38 |
+
...
|
39 |
+
|
40 |
+
|
41 |
+
class Monostate:
|
42 |
+
def __init__(
|
43 |
+
self,
|
44 |
+
on_progress: Optional[OnProgress],
|
45 |
+
on_complete: Optional[OnComplete],
|
46 |
+
title: Optional[str] = None,
|
47 |
+
duration: Optional[int] = None,
|
48 |
+
):
|
49 |
+
self.on_progress = on_progress
|
50 |
+
self.on_complete = on_complete
|
51 |
+
self.title = title
|
52 |
+
self.duration = duration
|
pytube/query.py
CHANGED
@@ -1,8 +1,14 @@
|
|
1 |
# -*- coding: utf-8 -*-
|
|
|
2 |
"""This module provides a query interface for media streams and captions."""
|
|
|
|
|
|
|
|
|
|
|
3 |
|
4 |
|
5 |
-
class StreamQuery:
|
6 |
"""Interface for querying the available media streams."""
|
7 |
|
8 |
def __init__(self, fmt_streams):
|
@@ -15,12 +21,24 @@ class StreamQuery:
|
|
15 |
self.itag_index = {int(s.itag): s for s in fmt_streams}
|
16 |
|
17 |
def filter(
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
24 |
):
|
25 |
"""Apply the given filtering criterion.
|
26 |
|
@@ -89,6 +107,9 @@ class StreamQuery:
|
|
89 |
Excludes progressive streams (audio and video are on separate
|
90 |
tracks).
|
91 |
|
|
|
|
|
|
|
92 |
:param bool only_audio:
|
93 |
Excludes streams with video tracks.
|
94 |
|
@@ -129,16 +150,12 @@ class StreamQuery:
|
|
129 |
|
130 |
if only_audio:
|
131 |
filters.append(
|
132 |
-
lambda s: (
|
133 |
-
s.includes_audio_track and not s.includes_video_track
|
134 |
-
),
|
135 |
)
|
136 |
|
137 |
if only_video:
|
138 |
filters.append(
|
139 |
-
lambda s: (
|
140 |
-
s.includes_video_track and not s.includes_audio_track
|
141 |
-
),
|
142 |
)
|
143 |
|
144 |
if progressive:
|
@@ -148,43 +165,49 @@ class StreamQuery:
|
|
148 |
filters.append(lambda s: s.is_adaptive)
|
149 |
|
150 |
if custom_filter_functions:
|
151 |
-
|
152 |
-
|
|
|
|
|
153 |
|
|
|
|
|
|
|
154 |
fmt_streams = self.fmt_streams
|
155 |
-
for
|
156 |
-
fmt_streams =
|
157 |
-
return StreamQuery(fmt_streams)
|
158 |
|
159 |
-
def order_by(self, attribute_name):
|
160 |
-
"""Apply a sort order
|
161 |
|
162 |
:param str attribute_name:
|
163 |
The name of the attribute to sort by.
|
164 |
"""
|
165 |
-
|
166 |
-
|
167 |
-
|
168 |
-
|
169 |
-
|
170 |
-
|
171 |
-
|
172 |
-
|
173 |
-
|
174 |
-
|
175 |
-
|
176 |
-
|
177 |
-
|
178 |
-
|
179 |
-
|
180 |
-
|
181 |
-
|
182 |
-
|
183 |
-
|
|
|
|
|
184 |
)
|
185 |
-
return StreamQuery(fmt_streams)
|
186 |
|
187 |
-
def desc(self):
|
188 |
"""Sort streams in descending order.
|
189 |
|
190 |
:rtype: :class:`StreamQuery <StreamQuery>`
|
@@ -192,7 +215,7 @@ class StreamQuery:
|
|
192 |
"""
|
193 |
return StreamQuery(self.fmt_streams[::-1])
|
194 |
|
195 |
-
def asc(self):
|
196 |
"""Sort streams in ascending order.
|
197 |
|
198 |
:rtype: :class:`StreamQuery <StreamQuery>`
|
@@ -200,10 +223,10 @@ class StreamQuery:
|
|
200 |
"""
|
201 |
return self
|
202 |
|
203 |
-
def get_by_itag(self, itag):
|
204 |
"""Get the corresponding :class:`Stream <Stream>` for a given itag.
|
205 |
|
206 |
-
:param
|
207 |
YouTube format identifier code.
|
208 |
:rtype: :class:`Stream <Stream>` or None
|
209 |
:returns:
|
@@ -211,12 +234,71 @@ class StreamQuery:
|
|
211 |
not found.
|
212 |
|
213 |
"""
|
214 |
-
|
215 |
-
|
216 |
-
|
217 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
218 |
|
219 |
-
def first(self):
|
220 |
"""Get the first :class:`Stream <Stream>` in the results.
|
221 |
|
222 |
:rtype: :class:`Stream <Stream>` or None
|
@@ -228,7 +310,7 @@ class StreamQuery:
|
|
228 |
try:
|
229 |
return self.fmt_streams[0]
|
230 |
except IndexError:
|
231 |
-
|
232 |
|
233 |
def last(self):
|
234 |
"""Get the last :class:`Stream <Stream>` in the results.
|
@@ -244,15 +326,19 @@ class StreamQuery:
|
|
244 |
except IndexError:
|
245 |
pass
|
246 |
|
247 |
-
|
248 |
-
|
|
|
249 |
|
250 |
:rtype: int
|
251 |
-
|
252 |
"""
|
253 |
-
|
|
|
|
|
|
|
254 |
|
255 |
-
|
|
|
256 |
"""Get all the results represented by this query as a list.
|
257 |
|
258 |
:rtype: list
|
@@ -260,21 +346,32 @@ class StreamQuery:
|
|
260 |
"""
|
261 |
return self.fmt_streams
|
262 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
263 |
|
264 |
-
|
|
|
265 |
"""Interface for querying the available captions."""
|
266 |
|
267 |
-
def __init__(self, captions):
|
268 |
"""Construct a :class:`Caption <Caption>`.
|
269 |
|
270 |
param list captions:
|
271 |
list of :class:`Caption <Caption>` instances.
|
272 |
|
273 |
"""
|
274 |
-
self.captions = captions
|
275 |
self.lang_code_index = {c.code: c for c in captions}
|
276 |
|
277 |
-
|
|
|
|
|
|
|
278 |
"""Get the :class:`Caption <Caption>` for a given ``lang_code``.
|
279 |
|
280 |
:param str lang_code:
|
@@ -286,10 +383,23 @@ class CaptionQuery:
|
|
286 |
"""
|
287 |
return self.lang_code_index.get(lang_code)
|
288 |
|
289 |
-
|
|
|
290 |
"""Get all the results represented by this query as a list.
|
291 |
|
292 |
:rtype: list
|
293 |
|
294 |
"""
|
295 |
-
return self.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
# -*- coding: utf-8 -*-
|
2 |
+
|
3 |
"""This module provides a query interface for media streams and captions."""
|
4 |
+
from typing import Callable, List, Optional, Union
|
5 |
+
from collections.abc import Mapping, Sequence
|
6 |
+
|
7 |
+
from pytube import Stream, Caption
|
8 |
+
from pytube.helpers import deprecated
|
9 |
|
10 |
|
11 |
+
class StreamQuery(Sequence):
|
12 |
"""Interface for querying the available media streams."""
|
13 |
|
14 |
def __init__(self, fmt_streams):
|
|
|
21 |
self.itag_index = {int(s.itag): s for s in fmt_streams}
|
22 |
|
23 |
def filter(
|
24 |
+
self,
|
25 |
+
fps=None,
|
26 |
+
res=None,
|
27 |
+
resolution=None,
|
28 |
+
mime_type=None,
|
29 |
+
type=None,
|
30 |
+
subtype=None,
|
31 |
+
file_extension=None,
|
32 |
+
abr=None,
|
33 |
+
bitrate=None,
|
34 |
+
video_codec=None,
|
35 |
+
audio_codec=None,
|
36 |
+
only_audio=None,
|
37 |
+
only_video=None,
|
38 |
+
progressive=None,
|
39 |
+
adaptive=None,
|
40 |
+
is_dash=None,
|
41 |
+
custom_filter_functions=None,
|
42 |
):
|
43 |
"""Apply the given filtering criterion.
|
44 |
|
|
|
107 |
Excludes progressive streams (audio and video are on separate
|
108 |
tracks).
|
109 |
|
110 |
+
:param bool is_dash:
|
111 |
+
Include/exclude dash streams.
|
112 |
+
|
113 |
:param bool only_audio:
|
114 |
Excludes streams with video tracks.
|
115 |
|
|
|
150 |
|
151 |
if only_audio:
|
152 |
filters.append(
|
153 |
+
lambda s: (s.includes_audio_track and not s.includes_video_track),
|
|
|
|
|
154 |
)
|
155 |
|
156 |
if only_video:
|
157 |
filters.append(
|
158 |
+
lambda s: (s.includes_video_track and not s.includes_audio_track),
|
|
|
|
|
159 |
)
|
160 |
|
161 |
if progressive:
|
|
|
165 |
filters.append(lambda s: s.is_adaptive)
|
166 |
|
167 |
if custom_filter_functions:
|
168 |
+
filters.extend(custom_filter_functions)
|
169 |
+
|
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":
|
182 |
+
"""Apply a sort order. Filters out stream the do not have the attribute.
|
183 |
|
184 |
:param str attribute_name:
|
185 |
The name of the attribute to sort by.
|
186 |
"""
|
187 |
+
has_attribute = [
|
188 |
+
s for s in self.fmt_streams if getattr(s, attribute_name) is not None
|
189 |
+
]
|
190 |
+
# Check that the attributes have string values.
|
191 |
+
if has_attribute and isinstance(getattr(has_attribute[0], attribute_name), str):
|
192 |
+
# Try to return a StreamQuery sorted by the integer representations
|
193 |
+
# of the values.
|
194 |
+
try:
|
195 |
+
return StreamQuery(
|
196 |
+
sorted(
|
197 |
+
has_attribute,
|
198 |
+
key=lambda s: int(
|
199 |
+
"".join(filter(str.isdigit, getattr(s, attribute_name)))
|
200 |
+
), # type: ignore # noqa: E501
|
201 |
+
)
|
202 |
+
)
|
203 |
+
except ValueError:
|
204 |
+
pass
|
205 |
+
|
206 |
+
return StreamQuery(
|
207 |
+
sorted(has_attribute, key=lambda s: getattr(s, attribute_name))
|
208 |
)
|
|
|
209 |
|
210 |
+
def desc(self) -> "StreamQuery":
|
211 |
"""Sort streams in descending order.
|
212 |
|
213 |
:rtype: :class:`StreamQuery <StreamQuery>`
|
|
|
215 |
"""
|
216 |
return StreamQuery(self.fmt_streams[::-1])
|
217 |
|
218 |
+
def asc(self) -> "StreamQuery":
|
219 |
"""Sort streams in ascending order.
|
220 |
|
221 |
:rtype: :class:`StreamQuery <StreamQuery>`
|
|
|
223 |
"""
|
224 |
return self
|
225 |
|
226 |
+
def get_by_itag(self, itag: int) -> Optional[Stream]:
|
227 |
"""Get the corresponding :class:`Stream <Stream>` for a given itag.
|
228 |
|
229 |
+
:param int itag:
|
230 |
YouTube format identifier code.
|
231 |
:rtype: :class:`Stream <Stream>` or None
|
232 |
:returns:
|
|
|
234 |
not found.
|
235 |
|
236 |
"""
|
237 |
+
return self.itag_index.get(int(itag))
|
238 |
+
|
239 |
+
def get_by_resolution(self, resolution: str) -> Optional[Stream]:
|
240 |
+
"""Get the corresponding :class:`Stream <Stream>` for a given resolution.
|
241 |
+
|
242 |
+
Stream must be a progressive mp4.
|
243 |
+
|
244 |
+
:param str resolution:
|
245 |
+
Video resolution i.e. "720p", "480p", "360p", "240p", "144p"
|
246 |
+
:rtype: :class:`Stream <Stream>` or None
|
247 |
+
:returns:
|
248 |
+
The :class:`Stream <Stream>` matching the given itag or None if
|
249 |
+
not found.
|
250 |
+
|
251 |
+
"""
|
252 |
+
return self.filter(
|
253 |
+
progressive=True, subtype="mp4", resolution=resolution
|
254 |
+
).first()
|
255 |
+
|
256 |
+
def get_lowest_resolution(self) -> Optional[Stream]:
|
257 |
+
"""Get lowest resolution stream that is a progressive mp4.
|
258 |
+
|
259 |
+
:rtype: :class:`Stream <Stream>` or None
|
260 |
+
:returns:
|
261 |
+
The :class:`Stream <Stream>` matching the given itag or None if
|
262 |
+
not found.
|
263 |
+
|
264 |
+
"""
|
265 |
+
return (
|
266 |
+
self.filter(progressive=True, subtype="mp4").order_by("resolution").first()
|
267 |
+
)
|
268 |
+
|
269 |
+
def get_highest_resolution(self) -> Optional[Stream]:
|
270 |
+
"""Get highest resolution stream that is a progressive video.
|
271 |
+
|
272 |
+
:rtype: :class:`Stream <Stream>` or None
|
273 |
+
:returns:
|
274 |
+
The :class:`Stream <Stream>` matching the given itag or None if
|
275 |
+
not found.
|
276 |
+
|
277 |
+
"""
|
278 |
+
return self.filter(progressive=True).order_by("resolution").last()
|
279 |
+
|
280 |
+
def get_audio_only(self, subtype: str = "mp4") -> Optional[Stream]:
|
281 |
+
"""Get highest bitrate audio stream for given codec (defaults to mp4)
|
282 |
+
|
283 |
+
:param str subtype:
|
284 |
+
Audio subtype, defaults to mp4
|
285 |
+
:rtype: :class:`Stream <Stream>` or None
|
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 |
|
304 |
:rtype: :class:`Stream <Stream>` or None
|
|
|
310 |
try:
|
311 |
return self.fmt_streams[0]
|
312 |
except IndexError:
|
313 |
+
return None
|
314 |
|
315 |
def last(self):
|
316 |
"""Get the last :class:`Stream <Stream>` in the results.
|
|
|
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]):
|
363 |
"""Construct a :class:`Caption <Caption>`.
|
364 |
|
365 |
param list captions:
|
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(
|
373 |
+
self, lang_code: str
|
374 |
+
) -> Optional[Caption]: # pragma: no cover
|
375 |
"""Get the :class:`Caption <Caption>` for a given ``lang_code``.
|
376 |
|
377 |
:param str lang_code:
|
|
|
383 |
"""
|
384 |
return self.lang_code_index.get(lang_code)
|
385 |
|
386 |
+
@deprecated("This object can be treated as a dictionary")
|
387 |
+
def all(self) -> List[Caption]: # pragma: no cover
|
388 |
"""Get all the results represented by this query as a list.
|
389 |
|
390 |
:rtype: list
|
391 |
|
392 |
"""
|
393 |
+
return list(self.lang_code_index.values())
|
394 |
+
|
395 |
+
def __getitem__(self, i: str):
|
396 |
+
return self.lang_code_index[i]
|
397 |
+
|
398 |
+
def __len__(self) -> int:
|
399 |
+
return len(self.lang_code_index)
|
400 |
+
|
401 |
+
def __iter__(self):
|
402 |
+
return iter(self.lang_code_index.values())
|
403 |
+
|
404 |
+
def __repr__(self) -> str:
|
405 |
+
return f"{self.lang_code_index}"
|
pytube/request.py
CHANGED
@@ -1,47 +1,89 @@
|
|
1 |
# -*- coding: utf-8 -*-
|
|
|
2 |
"""Implements a simple wrapper around urlopen."""
|
3 |
-
import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
4 |
|
5 |
-
from pytube.compat import urlopen
|
6 |
-
# 403 forbidden fix
|
7 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
8 |
|
9 |
-
|
10 |
-
|
11 |
-
streaming=False, chunk_size=8 * 1024,
|
12 |
-
):
|
13 |
"""Send an http GET request.
|
14 |
|
15 |
:param str url:
|
16 |
The URL to perform the GET request for.
|
17 |
-
:
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
23 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
24 |
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
|
42 |
-
"""Read the response in chunks."""
|
43 |
-
while True:
|
44 |
-
buf = response.read(chunk_size)
|
45 |
-
if not buf:
|
46 |
-
break
|
47 |
-
yield buf
|
|
|
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 Iterable, Dict, Optional
|
8 |
+
from urllib.request import Request
|
9 |
+
from urllib.request import urlopen
|
10 |
+
|
11 |
+
logger = logging.getLogger(__name__)
|
12 |
|
|
|
|
|
13 |
|
14 |
+
def _execute_request(
|
15 |
+
url: str, method: Optional[str] = None, headers: Optional[Dict[str, str]] = None
|
16 |
+
) -> HTTPResponse:
|
17 |
+
base_headers = {"User-Agent": "Mozilla/5.0"}
|
18 |
+
if headers:
|
19 |
+
base_headers.update(headers)
|
20 |
+
if url.lower().startswith("http"):
|
21 |
+
request = Request(url, headers=base_headers, method=method)
|
22 |
+
else:
|
23 |
+
raise ValueError("Invalid URL")
|
24 |
+
return urlopen(request) # nosec
|
25 |
|
26 |
+
|
27 |
+
def get(url) -> str:
|
|
|
|
|
28 |
"""Send an http GET request.
|
29 |
|
30 |
:param str url:
|
31 |
The URL to perform the GET request for.
|
32 |
+
:rtype: str
|
33 |
+
:returns:
|
34 |
+
UTF-8 encoded string of response
|
35 |
+
"""
|
36 |
+
return _execute_request(url).read().decode("utf-8")
|
37 |
+
|
38 |
+
|
39 |
+
def stream(
|
40 |
+
url: str, chunk_size: int = 4096, range_size: int = 9437184
|
41 |
+
) -> Iterable[bytes]:
|
42 |
+
"""Read the response in chunks.
|
43 |
+
:param str url: The URL to perform the GET request for.
|
44 |
+
:param int chunk_size: The size in bytes of each chunk. Defaults to 4KB
|
45 |
+
:param int range_size: The size in bytes of each range request. Defaults to 9MB
|
46 |
+
:rtype: Iterable[bytes]
|
47 |
"""
|
48 |
+
file_size: int = range_size # fake filesize to start
|
49 |
+
downloaded = 0
|
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(url, method="GET", headers={"Range": range_header})
|
54 |
+
if file_size == range_size:
|
55 |
+
try:
|
56 |
+
content_range = response.info()["Content-Range"]
|
57 |
+
file_size = int(content_range.split("/")[1])
|
58 |
+
except (KeyError, IndexError, ValueError) as e:
|
59 |
+
logger.error(e)
|
60 |
+
while True:
|
61 |
+
chunk = response.read(chunk_size)
|
62 |
+
if not chunk:
|
63 |
+
break
|
64 |
+
downloaded += len(chunk)
|
65 |
+
yield chunk
|
66 |
+
return # pylint: disable=R1711
|
67 |
+
|
68 |
+
|
69 |
+
@lru_cache(maxsize=None)
|
70 |
+
def filesize(url: str) -> int:
|
71 |
+
"""Fetch size in bytes of file at given URL
|
72 |
|
73 |
+
:param str url: The URL to get the size of
|
74 |
+
:returns: int: size in bytes of remote file
|
75 |
+
"""
|
76 |
+
return int(head(url)["content-length"])
|
77 |
+
|
78 |
+
|
79 |
+
def head(url: str) -> Dict:
|
80 |
+
"""Fetch headers returned http GET request.
|
81 |
+
|
82 |
+
:param str url:
|
83 |
+
The URL to perform the GET request for.
|
84 |
+
:rtype: dict
|
85 |
+
:returns:
|
86 |
+
dictionary of lowercase headers
|
87 |
+
"""
|
88 |
+
response_headers = _execute_request(url, method="HEAD").info()
|
89 |
+
return {k.lower(): v for k, v in response_headers.items()}
|
|
|
|
|
|
|
|
|
|
|
|
pytube/streams.py
CHANGED
@@ -1,4 +1,5 @@
|
|
1 |
# -*- coding: utf-8 -*-
|
|
|
2 |
"""
|
3 |
This module contains a container for stream manifest data.
|
4 |
|
@@ -7,26 +8,26 @@ combined). This was referred to as ``Video`` in the legacy pytube version, but
|
|
7 |
has been renamed to accommodate DASH (which serves the audio and video
|
8 |
separately).
|
9 |
"""
|
10 |
-
from __future__ import absolute_import
|
11 |
|
12 |
-
import
|
13 |
import logging
|
14 |
import os
|
15 |
-
import
|
|
|
16 |
|
17 |
from pytube import extract
|
18 |
from pytube import request
|
19 |
-
from pytube.helpers import safe_filename
|
20 |
from pytube.itags import get_format_profile
|
21 |
-
|
22 |
|
23 |
logger = logging.getLogger(__name__)
|
24 |
|
25 |
|
26 |
-
class Stream
|
27 |
"""Container for stream manifest data."""
|
28 |
|
29 |
-
def __init__(self, stream, player_config_args, monostate):
|
30 |
"""Construct a :class:`Stream <Stream>`.
|
31 |
|
32 |
:param dict stream:
|
@@ -42,67 +43,52 @@ class Stream(object):
|
|
42 |
# (Borg pattern).
|
43 |
self._monostate = monostate
|
44 |
|
45 |
-
self.
|
46 |
-
self.
|
47 |
-
self.itag = None # stream format id (youtube nomenclature)
|
48 |
-
self.res = None # resolution (e.g.: 480p, 720p, 1080p)
|
49 |
-
self.url = None # signed download url
|
50 |
-
|
51 |
-
self._filesize = None # filesize in bytes
|
52 |
-
self.mime_type = None # content identifier (e.g.: video/mp4)
|
53 |
-
self.type = None # the part of the mime before the slash
|
54 |
-
self.subtype = None # the part of the mime after the slash
|
55 |
-
|
56 |
-
self.codecs = [] # audio/video encoders (e.g.: vp8, mp4a)
|
57 |
-
self.audio_codec = None # audio codec of the stream (e.g.: vorbis)
|
58 |
-
self.video_codec = None # video codec of the stream (e.g.: vp8)
|
59 |
-
|
60 |
-
# Iterates over the key/values of stream and sets them as class
|
61 |
-
# attributes. This is an anti-pattern and should be removed.
|
62 |
-
self.set_attributes_from_dict(stream)
|
63 |
|
64 |
-
#
|
65 |
-
# frame rate, and whether the stream is live (HLS) or 3D.
|
66 |
-
self.fmt_profile = get_format_profile(self.itag)
|
67 |
-
|
68 |
-
# Same as above, except for the format profile attributes.
|
69 |
-
self.set_attributes_from_dict(self.fmt_profile)
|
70 |
-
|
71 |
-
# The player configuration which contains information like the video
|
72 |
-
# title.
|
73 |
-
# TODO(nficano): this should be moved to the monostate.
|
74 |
-
self.player_config_args = player_config_args
|
75 |
|
76 |
# 'video/webm; codecs="vp8, vorbis"' -> 'video/webm', ['vp8', 'vorbis']
|
77 |
-
self.mime_type, self.codecs = extract.mime_type_codec(
|
78 |
|
79 |
# 'video/webm' -> 'video', 'webm'
|
80 |
-
self.type, self.subtype = self.mime_type.split(
|
81 |
|
82 |
# ['vp8', 'vorbis'] -> video_codec: vp8, audio_codec: vorbis. DASH
|
83 |
# streams return NoneType for audio/video depending.
|
84 |
self.video_codec, self.audio_codec = self.parse_codecs()
|
85 |
|
86 |
-
|
87 |
-
|
88 |
|
89 |
-
:
|
90 |
-
|
91 |
-
|
92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
93 |
|
94 |
@property
|
95 |
-
def is_adaptive(self):
|
96 |
"""Whether the stream is DASH.
|
97 |
|
98 |
:rtype: bool
|
99 |
"""
|
100 |
# if codecs has two elements (e.g.: ['vp8', 'vorbis']): 2 % 2 = 0
|
101 |
# if codecs has one element (e.g.: ['vp8']) 1 % 2 = 1
|
102 |
-
return len(self.codecs) % 2
|
103 |
|
104 |
@property
|
105 |
-
def is_progressive(self):
|
106 |
"""Whether the stream is progressive.
|
107 |
|
108 |
:rtype: bool
|
@@ -110,26 +96,22 @@ class Stream(object):
|
|
110 |
return not self.is_adaptive
|
111 |
|
112 |
@property
|
113 |
-
def includes_audio_track(self):
|
114 |
"""Whether the stream only contains audio.
|
115 |
|
116 |
:rtype: bool
|
117 |
"""
|
118 |
-
|
119 |
-
return True
|
120 |
-
return self.type == 'audio'
|
121 |
|
122 |
@property
|
123 |
-
def includes_video_track(self):
|
124 |
"""Whether the stream only contains video.
|
125 |
|
126 |
:rtype: bool
|
127 |
"""
|
128 |
-
|
129 |
-
return True
|
130 |
-
return self.type == 'video'
|
131 |
|
132 |
-
def parse_codecs(self):
|
133 |
"""Get the video/audio codecs from list of codecs.
|
134 |
|
135 |
Parse a variable length sized list of codecs and returns a
|
@@ -153,7 +135,7 @@ class Stream(object):
|
|
153 |
return video, audio
|
154 |
|
155 |
@property
|
156 |
-
def filesize(self):
|
157 |
"""File size of the media stream in bytes.
|
158 |
|
159 |
:rtype: int
|
@@ -161,45 +143,57 @@ class Stream(object):
|
|
161 |
Filesize (in bytes) of the stream.
|
162 |
"""
|
163 |
if self._filesize is None:
|
164 |
-
|
165 |
-
self._filesize = int(headers['content-length'])
|
166 |
return self._filesize
|
167 |
|
168 |
@property
|
169 |
-
def title(self):
|
170 |
"""Get title of video
|
171 |
|
172 |
:rtype: str
|
173 |
:returns:
|
174 |
Youtube video title
|
175 |
"""
|
176 |
-
|
|
|
|
|
|
|
|
|
177 |
|
178 |
-
if
|
179 |
-
return player_config_args['title']
|
180 |
|
181 |
-
|
182 |
-
|
183 |
-
|
|
|
|
|
|
|
184 |
|
185 |
-
|
186 |
-
return details['title']
|
187 |
|
188 |
-
|
|
|
|
|
|
|
189 |
|
190 |
@property
|
191 |
-
def default_filename(self):
|
192 |
"""Generate filename based on the video title.
|
193 |
|
194 |
:rtype: str
|
195 |
:returns:
|
196 |
An os file system compatible filename.
|
197 |
"""
|
198 |
-
|
199 |
filename = safe_filename(self.title)
|
200 |
-
return
|
201 |
-
|
202 |
-
def download(
|
|
|
|
|
|
|
|
|
|
|
|
|
203 |
"""Write the media stream to disk.
|
204 |
|
205 |
:param output_path:
|
@@ -214,71 +208,82 @@ class Stream(object):
|
|
214 |
(optional) A string that will be prepended to the filename.
|
215 |
For example a number in a playlist or the name of a series.
|
216 |
If one is not specified, nothing will be prepended
|
217 |
-
This is
|
218 |
filename but still add a prefix.
|
219 |
:type filename_prefix: str or None
|
220 |
-
|
|
|
|
|
|
|
|
|
221 |
:rtype: str
|
222 |
|
223 |
"""
|
224 |
-
|
225 |
-
|
226 |
-
|
227 |
-
filename = '{filename}.{s.subtype}'.format(filename=safe, s=self)
|
228 |
-
filename = filename or self.default_filename
|
229 |
|
230 |
-
if
|
231 |
-
|
232 |
-
|
233 |
-
|
234 |
-
filename=filename,
|
235 |
-
)
|
236 |
|
237 |
-
# file path
|
238 |
-
fp = os.path.join(output_path, filename)
|
239 |
bytes_remaining = self.filesize
|
240 |
logger.debug(
|
241 |
-
|
242 |
-
self.filesize, fp,
|
243 |
)
|
244 |
|
245 |
-
with open(
|
246 |
-
for chunk in request.
|
247 |
# reduce the (bytes) remainder by the length of the chunk.
|
248 |
bytes_remaining -= len(chunk)
|
249 |
# send to the on_progress callback.
|
250 |
self.on_progress(chunk, fh, bytes_remaining)
|
251 |
-
self.on_complete(
|
252 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
253 |
|
254 |
-
def
|
|
|
|
|
|
|
255 |
"""Write the media stream to buffer
|
256 |
|
257 |
:rtype: io.BytesIO buffer
|
258 |
"""
|
259 |
-
buffer = io.BytesIO()
|
260 |
bytes_remaining = self.filesize
|
261 |
-
logger.
|
262 |
-
|
263 |
-
self.filesize,
|
264 |
)
|
265 |
|
266 |
-
for chunk in request.
|
267 |
# reduce the (bytes) remainder by the length of the chunk.
|
268 |
bytes_remaining -= len(chunk)
|
269 |
# send to the on_progress callback.
|
270 |
self.on_progress(chunk, buffer, bytes_remaining)
|
271 |
-
self.on_complete(
|
272 |
-
return buffer
|
273 |
|
274 |
-
def on_progress(self, chunk, file_handler, bytes_remaining):
|
275 |
"""On progress callback function.
|
276 |
|
277 |
This function writes the binary data to the file, then checks if an
|
278 |
additional callback is defined in the monostate. This is exposed to
|
279 |
allow things like displaying a progress bar.
|
280 |
|
281 |
-
:param
|
282 |
Segment of media file binary data, not yet written to disk.
|
283 |
:param file_handler:
|
284 |
The file handle where the media is being written to.
|
@@ -292,56 +297,43 @@ class Stream(object):
|
|
292 |
|
293 |
"""
|
294 |
file_handler.write(chunk)
|
295 |
-
logger.debug(
|
296 |
-
|
297 |
-
|
298 |
-
{
|
299 |
-
'chunk_size': len(chunk),
|
300 |
-
'bytes_remaining': bytes_remaining,
|
301 |
-
}, indent=2,
|
302 |
-
),
|
303 |
-
)
|
304 |
-
on_progress = self._monostate['on_progress']
|
305 |
-
if on_progress:
|
306 |
-
logger.debug('calling on_progress callback %s', on_progress)
|
307 |
-
on_progress(self, chunk, file_handler, bytes_remaining)
|
308 |
|
309 |
-
def on_complete(self,
|
310 |
"""On download complete handler function.
|
311 |
|
312 |
-
:param
|
313 |
The file handle where the media is being written to.
|
314 |
-
:type
|
315 |
-
:py:class:`io.BufferedWriter`
|
316 |
|
317 |
:rtype: None
|
318 |
|
319 |
"""
|
320 |
-
logger.debug(
|
321 |
-
on_complete = self._monostate
|
322 |
if on_complete:
|
323 |
-
logger.debug(
|
324 |
-
on_complete(self,
|
325 |
|
326 |
-
def __repr__(self):
|
327 |
"""Printable object representation.
|
328 |
|
329 |
:rtype: str
|
330 |
:returns:
|
331 |
A string representation of a :class:`Stream <Stream>` object.
|
332 |
"""
|
333 |
-
# TODO(nficano): this can probably be written better.
|
334 |
parts = ['itag="{s.itag}"', 'mime_type="{s.mime_type}"']
|
335 |
if self.includes_video_track:
|
336 |
parts.extend(['res="{s.resolution}"', 'fps="{s.fps}fps"'])
|
337 |
if not self.is_adaptive:
|
338 |
-
parts.extend(
|
339 |
-
'vcodec="{s.video_codec}"',
|
340 |
-
|
341 |
-
])
|
342 |
else:
|
343 |
parts.extend(['vcodec="{s.video_codec}"'])
|
344 |
else:
|
345 |
parts.extend(['abr="{s.abr}"', 'acodec="{s.audio_codec}"'])
|
346 |
-
parts
|
347 |
-
return
|
|
|
1 |
# -*- coding: utf-8 -*-
|
2 |
+
|
3 |
"""
|
4 |
This module contains a container for stream manifest data.
|
5 |
|
|
|
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 typing import Dict, Tuple, Optional, BinaryIO
|
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, target_directory
|
21 |
from pytube.itags import get_format_profile
|
22 |
+
from pytube.monostate import Monostate
|
23 |
|
24 |
logger = logging.getLogger(__name__)
|
25 |
|
26 |
|
27 |
+
class Stream:
|
28 |
"""Container for stream manifest data."""
|
29 |
|
30 |
+
def __init__(self, stream: Dict, player_config_args: Dict, monostate: Monostate):
|
31 |
"""Construct a :class:`Stream <Stream>`.
|
32 |
|
33 |
:param dict stream:
|
|
|
43 |
# (Borg pattern).
|
44 |
self._monostate = monostate
|
45 |
|
46 |
+
self.url = stream["url"] # signed download url
|
47 |
+
self.itag = int(stream["itag"]) # stream format id (youtube nomenclature)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
48 |
|
49 |
+
# set type and codec info
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
50 |
|
51 |
# 'video/webm; codecs="vp8, vorbis"' -> 'video/webm', ['vp8', 'vorbis']
|
52 |
+
self.mime_type, self.codecs = extract.mime_type_codec(stream["type"])
|
53 |
|
54 |
# 'video/webm' -> 'video', 'webm'
|
55 |
+
self.type, self.subtype = self.mime_type.split("/")
|
56 |
|
57 |
# ['vp8', 'vorbis'] -> video_codec: vp8, audio_codec: vorbis. DASH
|
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,
|
67 |
+
# frame rate, and whether the stream is live (HLS) or 3D.
|
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["fps"] # frames per second (video streams only)
|
72 |
+
self.resolution = itag_profile["resolution"] # resolution (e.g.: "480p")
|
73 |
+
self.is_3d = itag_profile["is_3d"]
|
74 |
+
self.is_hdr = itag_profile["is_hdr"]
|
75 |
+
self.is_live = itag_profile["is_live"]
|
76 |
+
|
77 |
+
# The player configuration, contains info like the video title.
|
78 |
+
self.player_config_args = player_config_args
|
79 |
|
80 |
@property
|
81 |
+
def is_adaptive(self) -> bool:
|
82 |
"""Whether the stream is DASH.
|
83 |
|
84 |
:rtype: bool
|
85 |
"""
|
86 |
# if codecs has two elements (e.g.: ['vp8', 'vorbis']): 2 % 2 = 0
|
87 |
# if codecs has one element (e.g.: ['vp8']) 1 % 2 = 1
|
88 |
+
return bool(len(self.codecs) % 2)
|
89 |
|
90 |
@property
|
91 |
+
def is_progressive(self) -> bool:
|
92 |
"""Whether the stream is progressive.
|
93 |
|
94 |
:rtype: bool
|
|
|
96 |
return not self.is_adaptive
|
97 |
|
98 |
@property
|
99 |
+
def includes_audio_track(self) -> bool:
|
100 |
"""Whether the stream only contains audio.
|
101 |
|
102 |
:rtype: bool
|
103 |
"""
|
104 |
+
return self.is_progressive or self.type == "audio"
|
|
|
|
|
105 |
|
106 |
@property
|
107 |
+
def includes_video_track(self) -> bool:
|
108 |
"""Whether the stream only contains video.
|
109 |
|
110 |
:rtype: bool
|
111 |
"""
|
112 |
+
return self.is_progressive or self.type == "video"
|
|
|
|
|
113 |
|
114 |
+
def parse_codecs(self) -> Tuple[Optional[str], Optional[str]]:
|
115 |
"""Get the video/audio codecs from list of codecs.
|
116 |
|
117 |
Parse a variable length sized list of codecs and returns a
|
|
|
135 |
return video, audio
|
136 |
|
137 |
@property
|
138 |
+
def filesize(self) -> int:
|
139 |
"""File size of the media stream in bytes.
|
140 |
|
141 |
:rtype: int
|
|
|
143 |
Filesize (in bytes) of the stream.
|
144 |
"""
|
145 |
if self._filesize is None:
|
146 |
+
self._filesize = request.filesize(self.url)
|
|
|
147 |
return self._filesize
|
148 |
|
149 |
@property
|
150 |
+
def title(self) -> str:
|
151 |
"""Get title of video
|
152 |
|
153 |
:rtype: str
|
154 |
:returns:
|
155 |
Youtube video title
|
156 |
"""
|
157 |
+
return self._monostate.title or "Unknown YouTube Video Title"
|
158 |
+
|
159 |
+
@property
|
160 |
+
def filesize_approx(self) -> int:
|
161 |
+
"""Get approximate filesize of the video
|
162 |
|
163 |
+
Falls back to HTTP call if there is not sufficient information to approximate
|
|
|
164 |
|
165 |
+
:rtype: int
|
166 |
+
:returns: size of video in bytes
|
167 |
+
"""
|
168 |
+
if self._monostate.duration and self.bitrate:
|
169 |
+
bits_in_byte = 8
|
170 |
+
return int((self._monostate.duration * self.bitrate) / bits_in_byte)
|
171 |
|
172 |
+
return self.filesize
|
|
|
173 |
|
174 |
+
@property
|
175 |
+
def expiration(self) -> datetime:
|
176 |
+
expire = parse_qs(self.url.split("?")[1])["expire"][0]
|
177 |
+
return datetime.utcfromtimestamp(int(expire))
|
178 |
|
179 |
@property
|
180 |
+
def default_filename(self) -> str:
|
181 |
"""Generate filename based on the video title.
|
182 |
|
183 |
:rtype: str
|
184 |
:returns:
|
185 |
An os file system compatible filename.
|
186 |
"""
|
|
|
187 |
filename = safe_filename(self.title)
|
188 |
+
return f"{filename}.{self.subtype}"
|
189 |
+
|
190 |
+
def download(
|
191 |
+
self,
|
192 |
+
output_path: Optional[str] = None,
|
193 |
+
filename: Optional[str] = None,
|
194 |
+
filename_prefix: Optional[str] = None,
|
195 |
+
skip_existing: bool = True,
|
196 |
+
) -> str:
|
197 |
"""Write the media stream to disk.
|
198 |
|
199 |
:param output_path:
|
|
|
208 |
(optional) A string that will be prepended to the filename.
|
209 |
For example a number in a playlist or the name of a series.
|
210 |
If one is not specified, nothing will be prepended
|
211 |
+
This is separate from filename so you can use the default
|
212 |
filename but still add a prefix.
|
213 |
:type filename_prefix: str or None
|
214 |
+
:param skip_existing:
|
215 |
+
(optional) skip existing files, defaults to True
|
216 |
+
:type skip_existing: bool
|
217 |
+
:returns:
|
218 |
+
Path to the saved video
|
219 |
:rtype: str
|
220 |
|
221 |
"""
|
222 |
+
file_path = self.get_file_path(
|
223 |
+
filename=filename, output_path=output_path, filename_prefix=filename_prefix
|
224 |
+
)
|
|
|
|
|
225 |
|
226 |
+
if skip_existing and self.exists_at_path(file_path):
|
227 |
+
logger.debug("file %s already exists, skipping", file_path)
|
228 |
+
self.on_complete(file_path)
|
229 |
+
return file_path
|
|
|
|
|
230 |
|
|
|
|
|
231 |
bytes_remaining = self.filesize
|
232 |
logger.debug(
|
233 |
+
"downloading (%s total bytes) file to %s", self.filesize, file_path,
|
|
|
234 |
)
|
235 |
|
236 |
+
with open(file_path, "wb") as fh:
|
237 |
+
for chunk in request.stream(self.url):
|
238 |
# reduce the (bytes) remainder by the length of the chunk.
|
239 |
bytes_remaining -= len(chunk)
|
240 |
# send to the on_progress callback.
|
241 |
self.on_progress(chunk, fh, bytes_remaining)
|
242 |
+
self.on_complete(file_path)
|
243 |
+
return file_path
|
244 |
+
|
245 |
+
def get_file_path(
|
246 |
+
self,
|
247 |
+
filename: Optional[str],
|
248 |
+
output_path: Optional[str],
|
249 |
+
filename_prefix: Optional[str] = None,
|
250 |
+
) -> str:
|
251 |
+
if filename:
|
252 |
+
filename = f"{safe_filename(filename)}.{self.subtype}"
|
253 |
+
else:
|
254 |
+
filename = self.default_filename
|
255 |
+
if filename_prefix:
|
256 |
+
filename = f"{safe_filename(filename_prefix)}{filename}"
|
257 |
+
return os.path.join(target_directory(output_path), filename)
|
258 |
|
259 |
+
def exists_at_path(self, file_path: str) -> bool:
|
260 |
+
return os.path.isfile(file_path) and os.path.getsize(file_path) == self.filesize
|
261 |
+
|
262 |
+
def stream_to_buffer(self, buffer: BinaryIO) -> None:
|
263 |
"""Write the media stream to buffer
|
264 |
|
265 |
:rtype: io.BytesIO buffer
|
266 |
"""
|
|
|
267 |
bytes_remaining = self.filesize
|
268 |
+
logger.info(
|
269 |
+
"downloading (%s total bytes) file to buffer", self.filesize,
|
|
|
270 |
)
|
271 |
|
272 |
+
for chunk in request.stream(self.url):
|
273 |
# reduce the (bytes) remainder by the length of the chunk.
|
274 |
bytes_remaining -= len(chunk)
|
275 |
# send to the on_progress callback.
|
276 |
self.on_progress(chunk, buffer, bytes_remaining)
|
277 |
+
self.on_complete(None)
|
|
|
278 |
|
279 |
+
def on_progress(self, chunk: bytes, file_handler: BinaryIO, bytes_remaining: int):
|
280 |
"""On progress callback function.
|
281 |
|
282 |
This function writes the binary data to the file, then checks if an
|
283 |
additional callback is defined in the monostate. This is exposed to
|
284 |
allow things like displaying a progress bar.
|
285 |
|
286 |
+
:param bytes chunk:
|
287 |
Segment of media file binary data, not yet written to disk.
|
288 |
:param file_handler:
|
289 |
The file handle where the media is being written to.
|
|
|
297 |
|
298 |
"""
|
299 |
file_handler.write(chunk)
|
300 |
+
logger.debug("download remaining: %s", bytes_remaining)
|
301 |
+
if self._monostate.on_progress:
|
302 |
+
self._monostate.on_progress(self, chunk, bytes_remaining)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
303 |
|
304 |
+
def on_complete(self, file_path: Optional[str]):
|
305 |
"""On download complete handler function.
|
306 |
|
307 |
+
:param file_path:
|
308 |
The file handle where the media is being written to.
|
309 |
+
:type file_path: str
|
|
|
310 |
|
311 |
:rtype: None
|
312 |
|
313 |
"""
|
314 |
+
logger.debug("download finished")
|
315 |
+
on_complete = self._monostate.on_complete
|
316 |
if on_complete:
|
317 |
+
logger.debug("calling on_complete callback %s", on_complete)
|
318 |
+
on_complete(self, file_path)
|
319 |
|
320 |
+
def __repr__(self) -> str:
|
321 |
"""Printable object representation.
|
322 |
|
323 |
:rtype: str
|
324 |
:returns:
|
325 |
A string representation of a :class:`Stream <Stream>` object.
|
326 |
"""
|
|
|
327 |
parts = ['itag="{s.itag}"', 'mime_type="{s.mime_type}"']
|
328 |
if self.includes_video_track:
|
329 |
parts.extend(['res="{s.resolution}"', 'fps="{s.fps}fps"'])
|
330 |
if not self.is_adaptive:
|
331 |
+
parts.extend(
|
332 |
+
['vcodec="{s.video_codec}"', 'acodec="{s.audio_codec}"',]
|
333 |
+
)
|
|
|
334 |
else:
|
335 |
parts.extend(['vcodec="{s.video_codec}"'])
|
336 |
else:
|
337 |
parts.extend(['abr="{s.abr}"', 'acodec="{s.audio_codec}"'])
|
338 |
+
parts.extend(['progressive="{s.is_progressive}"', 'type="{s.type}"'])
|
339 |
+
return f"<Stream: {' '.join(parts).format(s=self)}>"
|
pytube/version.py
ADDED
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# -*- coding: utf-8 -*-
|
2 |
+
|
3 |
+
__version__ = "9.6.4"
|
4 |
+
|
5 |
+
if __name__ == "__main__":
|
6 |
+
print(__version__)
|
setup.cfg
DELETED
@@ -1,23 +0,0 @@
|
|
1 |
-
[bumpversion]
|
2 |
-
commit = True
|
3 |
-
tag = True
|
4 |
-
current_version = 9.5.3
|
5 |
-
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(\-(?P<release>[a-z]+))?
|
6 |
-
serialize =
|
7 |
-
{major}.{minor}.{patch}
|
8 |
-
|
9 |
-
[metadata]
|
10 |
-
description-file = README.md
|
11 |
-
|
12 |
-
[bumpversion:file:setup.py]
|
13 |
-
|
14 |
-
[bumpversion:file:pytube/__init__.py]
|
15 |
-
|
16 |
-
[coverage:run]
|
17 |
-
source = pytube
|
18 |
-
omit =
|
19 |
-
pytube/compat.py
|
20 |
-
|
21 |
-
[flake8]
|
22 |
-
ignore = W605
|
23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
setup.py
CHANGED
@@ -1,92 +1,58 @@
|
|
1 |
#!/usr/bin/env python
|
2 |
# -*- coding: utf-8 -*-
|
3 |
-
"""This module contains setup instructions for
|
4 |
import codecs
|
5 |
import os
|
6 |
-
import sys
|
7 |
-
from shutil import rmtree
|
8 |
-
|
9 |
-
from setuptools import Command
|
10 |
from setuptools import setup
|
11 |
|
12 |
here = os.path.abspath(os.path.dirname(__file__))
|
13 |
|
14 |
-
with codecs.open(os.path.join(here,
|
15 |
-
long_description =
|
16 |
-
|
17 |
-
|
18 |
-
class UploadCommand(Command):
|
19 |
-
"""Support setup.py publish."""
|
20 |
-
|
21 |
-
description = 'Build and publish the package.'
|
22 |
-
user_options = []
|
23 |
-
|
24 |
-
@staticmethod
|
25 |
-
def status(s):
|
26 |
-
"""Prints things in bold."""
|
27 |
-
print('\033[1m{0}\033[0m'.format(s))
|
28 |
-
|
29 |
-
def initialize_options(self):
|
30 |
-
pass
|
31 |
-
|
32 |
-
def finalize_options(self):
|
33 |
-
pass
|
34 |
-
|
35 |
-
def run(self):
|
36 |
-
try:
|
37 |
-
self.status('Removing previous builds ...')
|
38 |
-
rmtree(os.path.join(here, 'dist'))
|
39 |
-
except Exception:
|
40 |
-
pass
|
41 |
-
self.status('Building Source distribution ...')
|
42 |
-
os.system('{0} setup.py sdist bdist_wheel'.format(sys.executable))
|
43 |
-
self.status('Uploading the package to PyPI via Twine ...')
|
44 |
-
os.system('twine upload dist/*')
|
45 |
-
sys.exit()
|
46 |
|
|
|
|
|
47 |
|
48 |
setup(
|
49 |
-
name=
|
50 |
-
version=
|
51 |
-
author=
|
52 |
-
author_email=
|
53 |
-
packages=[
|
54 |
-
package_data={
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
entry_points={
|
60 |
-
'console_scripts': [
|
61 |
-
'pytube = pytube.cli:main',
|
62 |
-
],
|
63 |
-
},
|
64 |
classifiers=[
|
65 |
-
|
66 |
-
|
67 |
-
|
68 |
-
|
69 |
-
|
70 |
-
|
71 |
-
|
72 |
-
|
73 |
-
|
74 |
-
|
75 |
-
|
76 |
-
|
77 |
-
|
78 |
-
|
79 |
-
|
80 |
-
|
81 |
-
|
82 |
-
|
83 |
-
'Topic :: Terminals',
|
84 |
-
'Topic :: Utilities',
|
85 |
],
|
86 |
-
description=(
|
87 |
include_package_data=True,
|
88 |
-
long_description_content_type=
|
89 |
long_description=long_description,
|
90 |
zip_safe=True,
|
91 |
-
|
|
|
|
|
|
|
|
|
|
|
92 |
)
|
|
|
1 |
#!/usr/bin/env python
|
2 |
# -*- coding: utf-8 -*-
|
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__))
|
9 |
|
10 |
+
with codecs.open(os.path.join(here, "README.md"), encoding="utf-8") as fh:
|
11 |
+
long_description = "\n" + fh.read()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
12 |
|
13 |
+
with open(os.path.join(here, "pytube", "version.py")) as fp:
|
14 |
+
exec(fp.read())
|
15 |
|
16 |
setup(
|
17 |
+
name="pytube3",
|
18 |
+
version=__version__, # noqa: F821
|
19 |
+
author="Nick Ficano, Harold Martin",
|
20 |
+
author_email="[email protected], [email protected]",
|
21 |
+
packages=["pytube", "pytube.contrib"],
|
22 |
+
package_data={"": ["LICENSE"],},
|
23 |
+
url="https://github.com/hbmartin/pytube3",
|
24 |
+
license="MIT",
|
25 |
+
entry_points={"console_scripts": ["pytube3 = pytube.cli:main",],},
|
26 |
+
install_requires=["typing_extensions"],
|
|
|
|
|
|
|
|
|
|
|
27 |
classifiers=[
|
28 |
+
"Development Status :: 5 - Production/Stable",
|
29 |
+
"Environment :: Console",
|
30 |
+
"Intended Audience :: Developers",
|
31 |
+
"License :: OSI Approved :: MIT License",
|
32 |
+
"Natural Language :: English",
|
33 |
+
"Operating System :: MacOS",
|
34 |
+
"Operating System :: Microsoft",
|
35 |
+
"Operating System :: POSIX",
|
36 |
+
"Operating System :: Unix",
|
37 |
+
"Programming Language :: Python :: 3.6",
|
38 |
+
"Programming Language :: Python :: 3.7",
|
39 |
+
"Programming Language :: Python :: 3.8",
|
40 |
+
"Programming Language :: Python",
|
41 |
+
"Topic :: Internet",
|
42 |
+
"Topic :: Multimedia :: Video",
|
43 |
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
44 |
+
"Topic :: Terminals",
|
45 |
+
"Topic :: Utilities",
|
|
|
|
|
46 |
],
|
47 |
+
description=("Python 3 library for downloading YouTube Videos."),
|
48 |
include_package_data=True,
|
49 |
+
long_description_content_type="text/markdown",
|
50 |
long_description=long_description,
|
51 |
zip_safe=True,
|
52 |
+
python_requires=">=3.6",
|
53 |
+
project_urls={
|
54 |
+
"Bug Reports": "https://github.com/hbmartin/pytube3/issues",
|
55 |
+
"Read the Docs": "https://pytube3.readthedocs.io/en/latest/?badge=latest",
|
56 |
+
},
|
57 |
+
keywords=["youtube", "download", "video", "stream",],
|
58 |
)
|
tests/conftest.py
CHANGED
@@ -1,6 +1,5 @@
|
|
1 |
# -*- coding: utf-8 -*-
|
2 |
"""Reusable dependency injected testing components."""
|
3 |
-
from __future__ import unicode_literals
|
4 |
|
5 |
import gzip
|
6 |
import json
|
@@ -15,39 +14,61 @@ def load_playback_file(filename):
|
|
15 |
"""Load a gzip json playback file."""
|
16 |
cur_fp = os.path.realpath(__file__)
|
17 |
cur_dir = os.path.dirname(cur_fp)
|
18 |
-
fp = os.path.join(cur_dir,
|
19 |
-
with gzip.open(fp,
|
20 |
-
content = fh.read().decode(
|
21 |
return json.loads(content)
|
22 |
|
23 |
|
24 |
def load_and_init_from_playback_file(filename):
|
25 |
"""Load a gzip json playback file and create YouTube instance."""
|
26 |
pb = load_playback_file(filename)
|
27 |
-
yt = YouTube(pb[
|
28 |
-
yt.watch_html = pb[
|
29 |
-
yt.js = pb[
|
30 |
-
yt.vid_info = pb[
|
31 |
-
yt.
|
32 |
return yt
|
33 |
|
34 |
|
35 |
@pytest.fixture
|
36 |
def cipher_signature():
|
37 |
"""Youtube instance initialized with video id 9bZkp7q19f0."""
|
38 |
-
filename =
|
39 |
return load_and_init_from_playback_file(filename)
|
40 |
|
41 |
|
42 |
@pytest.fixture
|
43 |
def presigned_video():
|
44 |
"""Youtube instance initialized with video id QRS8MkLhQmM."""
|
45 |
-
filename =
|
46 |
return load_and_init_from_playback_file(filename)
|
47 |
|
48 |
|
49 |
@pytest.fixture
|
50 |
def age_restricted():
|
51 |
"""Youtube instance initialized with video id zRbsm3e2ltw."""
|
52 |
-
filename =
|
53 |
return load_playback_file(filename)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
# -*- coding: utf-8 -*-
|
2 |
"""Reusable dependency injected testing components."""
|
|
|
3 |
|
4 |
import gzip
|
5 |
import json
|
|
|
14 |
"""Load a gzip json playback file."""
|
15 |
cur_fp = os.path.realpath(__file__)
|
16 |
cur_dir = os.path.dirname(cur_fp)
|
17 |
+
fp = os.path.join(cur_dir, "mocks", filename)
|
18 |
+
with gzip.open(fp, "rb") as fh:
|
19 |
+
content = fh.read().decode("utf-8")
|
20 |
return json.loads(content)
|
21 |
|
22 |
|
23 |
def load_and_init_from_playback_file(filename):
|
24 |
"""Load a gzip json playback file and create YouTube instance."""
|
25 |
pb = load_playback_file(filename)
|
26 |
+
yt = YouTube(pb["url"], defer_prefetch_init=True)
|
27 |
+
yt.watch_html = pb["watch_html"]
|
28 |
+
yt.js = pb["js"]
|
29 |
+
yt.vid_info = pb["video_info"]
|
30 |
+
yt.descramble()
|
31 |
return yt
|
32 |
|
33 |
|
34 |
@pytest.fixture
|
35 |
def cipher_signature():
|
36 |
"""Youtube instance initialized with video id 9bZkp7q19f0."""
|
37 |
+
filename = "yt-video-9bZkp7q19f0.json.gz"
|
38 |
return load_and_init_from_playback_file(filename)
|
39 |
|
40 |
|
41 |
@pytest.fixture
|
42 |
def presigned_video():
|
43 |
"""Youtube instance initialized with video id QRS8MkLhQmM."""
|
44 |
+
filename = "yt-video-QRS8MkLhQmM.json.gz"
|
45 |
return load_and_init_from_playback_file(filename)
|
46 |
|
47 |
|
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 |
+
|
55 |
+
@pytest.fixture
|
56 |
+
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__)), "mocks", "playlist.html.gz"
|
61 |
+
)
|
62 |
+
with gzip.open(file_path, "rb") as f:
|
63 |
+
return f.read().decode("utf-8")
|
64 |
+
|
65 |
+
|
66 |
+
@pytest.fixture
|
67 |
+
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__)), "mocks", "playlist_long.html.gz"
|
72 |
+
)
|
73 |
+
with gzip.open(file_path, "rb") as f:
|
74 |
+
return f.read().decode("utf-8")
|
tests/contrib/__pycache__/tmpgekc8jvs
DELETED
Binary file (1.36 kB)
|
|
tests/contrib/test_playlist.py
CHANGED
@@ -1,10 +1,220 @@
|
|
1 |
# -*- coding: utf-8 -*-
|
|
|
|
|
|
|
|
|
2 |
from pytube import Playlist
|
3 |
|
4 |
|
5 |
-
|
6 |
-
|
7 |
-
|
|
|
|
|
|
|
|
|
8 |
pl = Playlist(url)
|
9 |
pl_title = pl.title()
|
10 |
-
assert pl_title ==
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
# -*- coding: utf-8 -*-
|
2 |
+
import datetime
|
3 |
+
from unittest import mock
|
4 |
+
from unittest.mock import MagicMock
|
5 |
+
|
6 |
from pytube import Playlist
|
7 |
|
8 |
|
9 |
+
@mock.patch("pytube.contrib.playlist.request.get")
|
10 |
+
def test_title(request_get):
|
11 |
+
request_get.return_value = (
|
12 |
+
"<title>(149) Python Tutorial for Beginners "
|
13 |
+
"(For Absolute Beginners) - YouTube</title>"
|
14 |
+
)
|
15 |
+
url = "https://www.fakeurl.com/playlist?list=PLS1QulWo1RIaJECMeUT4LFwJ-ghgoSH6n"
|
16 |
pl = Playlist(url)
|
17 |
pl_title = pl.title()
|
18 |
+
assert pl_title == "(149) Python Tutorial for Beginners (For Absolute Beginners)"
|
19 |
+
|
20 |
+
|
21 |
+
@mock.patch("pytube.contrib.playlist.request.get")
|
22 |
+
def test_init_with_playlist_url(request_get):
|
23 |
+
request_get.return_value = ""
|
24 |
+
url = "https://www.youtube.com/playlist?list=PLS1QulWo1RIaJECMeUT4LFwJ-ghgoSH6n"
|
25 |
+
playlist = Playlist(url)
|
26 |
+
assert playlist.playlist_url == url
|
27 |
+
|
28 |
+
|
29 |
+
@mock.patch("pytube.contrib.playlist.request.get")
|
30 |
+
def test_init_with_watch_url(request_get):
|
31 |
+
request_get.return_value = ""
|
32 |
+
url = (
|
33 |
+
"https://www.youtube.com/watch?v=1KeYzjILqDo&"
|
34 |
+
"list=PLS1QulWo1RIaJECMeUT4LFwJ-ghgoSH6n&index=2&t=661s"
|
35 |
+
)
|
36 |
+
playlist = Playlist(url)
|
37 |
+
assert (
|
38 |
+
playlist.playlist_url
|
39 |
+
== "https://www.youtube.com/playlist?list=PLS1QulWo1RIaJECMeUT4LFwJ-ghgoSH6n"
|
40 |
+
)
|
41 |
+
|
42 |
+
|
43 |
+
@mock.patch("pytube.contrib.playlist.request.get")
|
44 |
+
def test_last_update(request_get, playlist_html):
|
45 |
+
expected = datetime.date(2019, 3, 7)
|
46 |
+
request_get.return_value = playlist_html
|
47 |
+
playlist = Playlist("url")
|
48 |
+
assert playlist.last_update == expected
|
49 |
+
|
50 |
+
|
51 |
+
@mock.patch("pytube.contrib.playlist.request.get")
|
52 |
+
def test_init_with_watch_id(request_get):
|
53 |
+
request_get.return_value = ""
|
54 |
+
playlist = Playlist("PLS1QulWo1RIaJECMeUT4LFwJ-ghgoSH6n")
|
55 |
+
assert (
|
56 |
+
playlist.playlist_url
|
57 |
+
== "https://www.youtube.com/playlist?list=PLS1QulWo1RIaJECMeUT4LFwJ-ghgoSH6n"
|
58 |
+
)
|
59 |
+
|
60 |
+
|
61 |
+
@mock.patch("pytube.contrib.playlist.request.get")
|
62 |
+
def test_video_urls(request_get, playlist_html):
|
63 |
+
url = "https://www.fakeurl.com/playlist?list=whatever"
|
64 |
+
request_get.return_value = playlist_html
|
65 |
+
playlist = Playlist(url)
|
66 |
+
playlist._find_load_more_url = MagicMock(return_value=None)
|
67 |
+
request_get.assert_called()
|
68 |
+
assert playlist.video_urls == [
|
69 |
+
"https://www.youtube.com/watch?v=ujTCoH21GlA",
|
70 |
+
"https://www.youtube.com/watch?v=45ryDIPHdGg",
|
71 |
+
"https://www.youtube.com/watch?v=1BYu65vLKdA",
|
72 |
+
"https://www.youtube.com/watch?v=3AQ_74xrch8",
|
73 |
+
"https://www.youtube.com/watch?v=ddqQUz9mZaM",
|
74 |
+
"https://www.youtube.com/watch?v=vwLT6bZrHEE",
|
75 |
+
"https://www.youtube.com/watch?v=TQKI0KE-JYY",
|
76 |
+
"https://www.youtube.com/watch?v=dNBvQ38MlT8",
|
77 |
+
"https://www.youtube.com/watch?v=JHxyrMgOUWI",
|
78 |
+
"https://www.youtube.com/watch?v=l2I8NycJMCY",
|
79 |
+
"https://www.youtube.com/watch?v=g1Zbuk1gAfk",
|
80 |
+
"https://www.youtube.com/watch?v=zixd-si9Q-o",
|
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):
|
120 |
+
url = "https://www.fakeurl.com/playlist?list=whatever"
|
121 |
+
request_get.return_value = playlist_html
|
122 |
+
playlist = Playlist(url)
|
123 |
+
playlist._find_load_more_url = MagicMock(return_value=None)
|
124 |
+
request_get.assert_called()
|
125 |
+
assert len(list(playlist.videos)) == 12
|
126 |
+
|
127 |
+
|
128 |
+
@mock.patch("pytube.contrib.playlist.request.get")
|
129 |
+
@mock.patch("pytube.cli.YouTube.__init__", return_value=None)
|
130 |
+
def test_load_more(youtube, request_get, playlist_html):
|
131 |
+
url = "https://www.fakeurl.com/playlist?list=whatever"
|
132 |
+
request_get.side_effect = [
|
133 |
+
playlist_html,
|
134 |
+
'{"content_html":"", "load_more_widget_html":""}',
|
135 |
+
]
|
136 |
+
playlist = Playlist(url)
|
137 |
+
playlist._find_load_more_url = MagicMock(side_effect=["dummy", None])
|
138 |
+
request_get.assert_called()
|
139 |
+
assert len(list(playlist.videos)) == 12
|
140 |
+
|
141 |
+
|
142 |
+
@mock.patch("pytube.contrib.playlist.request.get")
|
143 |
+
@mock.patch("pytube.contrib.playlist.install_proxy", return_value=None)
|
144 |
+
def test_proxy(install_proxy, request_get):
|
145 |
+
url = "https://www.fakeurl.com/playlist?list=whatever"
|
146 |
+
request_get.return_value = ""
|
147 |
+
Playlist(url, proxies={"http": "things"})
|
148 |
+
install_proxy.assert_called_with({"http": "things"})
|
149 |
+
|
150 |
+
|
151 |
+
@mock.patch("pytube.contrib.playlist.request.get")
|
152 |
+
def test_trimmed(request_get, playlist_html):
|
153 |
+
url = "https://www.fakeurl.com/playlist?list=whatever"
|
154 |
+
request_get.return_value = playlist_html
|
155 |
+
playlist = Playlist(url)
|
156 |
+
playlist._find_load_more_url = MagicMock(return_value=None)
|
157 |
+
assert request_get.call_count == 1
|
158 |
+
trimmed = list(playlist.trimmed("1BYu65vLKdA"))
|
159 |
+
assert trimmed == [
|
160 |
+
"https://www.youtube.com/watch?v=ujTCoH21GlA",
|
161 |
+
"https://www.youtube.com/watch?v=45ryDIPHdGg",
|
162 |
+
]
|
163 |
+
|
164 |
+
|
165 |
+
@mock.patch("pytube.contrib.playlist.request.get")
|
166 |
+
def test_playlist_failed_pagination(request_get, playlist_long_html):
|
167 |
+
url = "https://www.fakeurl.com/playlist?list=whatever"
|
168 |
+
request_get.side_effect = [
|
169 |
+
playlist_long_html,
|
170 |
+
"{}",
|
171 |
+
]
|
172 |
+
playlist = Playlist(url)
|
173 |
+
video_urls = playlist.video_urls
|
174 |
+
assert len(video_urls) == 100
|
175 |
+
assert request_get.call_count == 2
|
176 |
+
request_get.assert_called_with(
|
177 |
+
"https://www.youtube.com/browse_ajax?action_continuation=1&continuation"
|
178 |
+
"=4qmFsgIsEhpWTFVVYS12aW9HaGUyYnRCY1puZWFQb25LQRoOZWdaUVZEcERSMUUlM0Q%253D"
|
179 |
+
)
|
180 |
+
|
181 |
+
|
182 |
+
@mock.patch("pytube.contrib.playlist.request.get")
|
183 |
+
def test_playlist_pagination(request_get, playlist_html, playlist_long_html):
|
184 |
+
url = "https://www.fakeurl.com/playlist?list=whatever"
|
185 |
+
request_get.side_effect = [
|
186 |
+
playlist_long_html,
|
187 |
+
'{"content_html":"<a href=\\"/watch?v=BcWz41-4cDk&feature=plpp_video&ved'
|
188 |
+
'=CCYQxjQYACITCO33n5-pn-cCFUG3xAodLogN2yj6LA\\">}", "load_more_widget_html":""}',
|
189 |
+
"{}",
|
190 |
+
]
|
191 |
+
playlist = Playlist(url)
|
192 |
+
assert len(playlist.video_urls) == 101
|
193 |
+
assert request_get.call_count == 2
|
194 |
+
|
195 |
+
|
196 |
+
@mock.patch("pytube.contrib.playlist.request.get")
|
197 |
+
def test_trimmed_pagination(request_get, playlist_html, playlist_long_html):
|
198 |
+
url = "https://www.fakeurl.com/playlist?list=whatever"
|
199 |
+
request_get.side_effect = [
|
200 |
+
playlist_long_html,
|
201 |
+
'{"content_html":"<a href=\\"/watch?v=BcWz41-4cDk&feature=plpp_video&ved'
|
202 |
+
'=CCYQxjQYACITCO33n5-pn-cCFUG3xAodLogN2yj6LA\\">}", "load_more_widget_html":""}',
|
203 |
+
"{}",
|
204 |
+
]
|
205 |
+
playlist = Playlist(url)
|
206 |
+
assert len(list(playlist.trimmed("FN9vC8aR7Yk"))) == 3
|
207 |
+
assert request_get.call_count == 1
|
208 |
+
|
209 |
+
|
210 |
+
@mock.patch("pytube.contrib.playlist.request.get")
|
211 |
+
def test_trimmed_pagination_not_found(request_get, playlist_html, playlist_long_html):
|
212 |
+
url = "https://www.fakeurl.com/playlist?list=whatever"
|
213 |
+
request_get.side_effect = [
|
214 |
+
playlist_long_html,
|
215 |
+
'{"content_html":"<a href=\\"/watch?v=BcWz41-4cDk&feature=plpp_video&ved'
|
216 |
+
'=CCYQxjQYACITCO33n5-pn-cCFUG3xAodLogN2yj6LA\\">}", "load_more_widget_html":""}',
|
217 |
+
"{}",
|
218 |
+
]
|
219 |
+
playlist = Playlist(url)
|
220 |
+
assert len(list(playlist.trimmed("wont-be-found"))) == 101
|
tests/generate_fixture.py
ADDED
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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()
|
17 |
+
output = {
|
18 |
+
"url": sys.argv[1],
|
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")
|
26 |
+
print("Writing to: " + outpath)
|
27 |
+
with open(outpath, "w") as f:
|
28 |
+
json.dump(output, f)
|
tests/mocks/playlist.html.gz
ADDED
Binary file (33.2 kB). View file
|
|
tests/mocks/playlist_long.html.gz
ADDED
Binary file (47.7 kB). View file
|
|