nficano commited on
Commit
8c59837
·
unverified ·
2 Parent(s): 66b3380 aba400f

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 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
- *.py[oc]
 
 
 
 
 
 
2
 
3
  # Temp files
4
  *~
@@ -7,18 +13,52 @@
7
  \#*
8
  .#*
9
  *#
10
- dist
11
- .DS_Store
12
 
13
- # Build files
14
- build
15
- dist
16
- pkg
 
 
 
 
 
 
 
 
 
 
 
 
17
  *.egg
18
- *.egg-info
 
 
 
 
 
 
19
 
20
- # Test files
 
 
 
 
 
 
 
 
 
 
 
 
 
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.6-dev" # 3.6 development branch
12
- - "3.7-dev" # 3.7 development branch
13
  install: "make"
14
  script:
15
  - make ci
16
  before_install:
17
- - pip install pipenv flake8 --upgrade
18
  sudo: false
19
  after_success:
20
- coveralls
 
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.rst LICENSE NOTICE HISTORY.rst pytest.ini
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
- ci:
8
  pip install pipenv
9
  pipenv install --dev
10
- pipenv run flake8
11
- pipenv run pytest --cov-report term-missing --cov=pytube --ignore=W605
 
 
 
 
 
 
 
 
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
- "flake8" = "*"
10
- pytest = "*"
11
- mock = "*"
12
- pytest-mock = "*"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  pre-commit = "*"
14
- "enum34" = "*"
15
  pytest-cov = "*"
16
- "pathlib2" = "*"
17
- "scandir" = "*"
18
- bumpversion = "*"
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": "5a2d404725db87789c428cc6fb3f2945c4232b4838e18c4ad95d5f07d002315a"
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
- "aspy.yaml": {
21
  "hashes": [
22
- "sha256:ae249074803e8b957c83fdd82a99160d0d6d26dff9ba81ba608b42eebd7d8cd3",
23
- "sha256:c7390d79f58eb9157406966201abf26da0d56c07e0ff0deadc39c8f4dbc13482"
24
  ],
25
- "version": "==1.2.0"
 
 
 
 
 
 
 
26
  },
27
- "atomicwrites": {
28
  "hashes": [
29
- "sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4",
30
- "sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6"
31
  ],
32
  "version": "==1.3.0"
33
  },
34
  "attrs": {
35
  "hashes": [
36
- "sha256:69c0dbf2ed392de1cb5ec704444b08a5ef81680a61cb899dc08127123af36a79",
37
- "sha256:f0b870f674851ecbfbbbd364d6b5cbdff9dcedbc7f3f5e18a6891057f21fe399"
38
  ],
39
- "version": "==19.1.0"
40
  },
41
- "bleach": {
42
  "hashes": [
43
- "sha256:213336e49e102af26d9cde77dd2d0397afabc5a6bf2fed985dc35b5d1e285a16",
44
- "sha256:3fdf7f77adcf649c9911387df51254b813185e32b2c6619f690b593a617e19fa"
45
  ],
46
- "version": "==3.1.0"
47
  },
48
- "bumpversion": {
49
  "hashes": [
50
- "sha256:6744c873dd7aafc24453d8b6a1a0d6d109faf63cd0cd19cb78fd46e74932c77e",
51
- "sha256:6753d9ff3552013e2130f7bc03c1007e24473b4835952679653fb132367bdd57"
52
  ],
53
  "index": "pypi",
54
- "version": "==0.5.3"
55
  },
56
  "certifi": {
57
  "hashes": [
58
- "sha256:59b7658e26ca9c7339e00f8f4636cdfe59d34fa37b9b04f6f9e9926b3cece1a5",
59
- "sha256:b26104d6835d1f5e49452a26eb2ff87fe7090b89dfcaee5ea2212697e1e1d7ae"
60
  ],
61
- "version": "==2019.3.9"
62
  },
63
  "cfgv": {
64
  "hashes": [
65
- "sha256:6e9f2feea5e84bc71e56abd703140d7a2c250fc5ba38b8702fd6a68ed4e3b2ef",
66
- "sha256:e7f186d4a36c099a9e20b04ac3108bd8bb9b9257e692ce18c8c3764d5cb12172"
67
  ],
68
- "version": "==1.6.0"
69
  },
70
  "chardet": {
71
  "hashes": [
@@ -74,49 +89,70 @@
74
  ],
75
  "version": "==3.0.4"
76
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  "coverage": {
78
  "hashes": [
79
- "sha256:3684fabf6b87a369017756b551cef29e505cb155ddb892a7a29277b978da88b9",
80
- "sha256:39e088da9b284f1bd17c750ac672103779f7954ce6125fd4382134ac8d152d74",
81
- "sha256:3c205bc11cc4fcc57b761c2da73b9b72a59f8d5ca89979afb0c1c6f9e53c7390",
82
- "sha256:465ce53a8c0f3a7950dfb836438442f833cf6663d407f37d8c52fe7b6e56d7e8",
83
- "sha256:48020e343fc40f72a442c8a1334284620f81295256a6b6ca6d8aa1350c763bbe",
84
- "sha256:5296fc86ab612ec12394565c500b412a43b328b3907c0d14358950d06fd83baf",
85
- "sha256:5f61bed2f7d9b6a9ab935150a6b23d7f84b8055524e7be7715b6513f3328138e",
86
- "sha256:68a43a9f9f83693ce0414d17e019daee7ab3f7113a70c79a3dd4c2f704e4d741",
87
- "sha256:6b8033d47fe22506856fe450470ccb1d8ba1ffb8463494a15cfc96392a288c09",
88
- "sha256:7ad7536066b28863e5835e8cfeaa794b7fe352d99a8cded9f43d1161be8e9fbd",
89
- "sha256:7bacb89ccf4bedb30b277e96e4cc68cd1369ca6841bde7b005191b54d3dd1034",
90
- "sha256:839dc7c36501254e14331bcb98b27002aa415e4af7ea039d9009409b9d2d5420",
91
- "sha256:8f9a95b66969cdea53ec992ecea5406c5bd99c9221f539bca1e8406b200ae98c",
92
- "sha256:932c03d2d565f75961ba1d3cec41ddde00e162c5b46d03f7423edcb807734eab",
93
- "sha256:988529edadc49039d205e0aa6ce049c5ccda4acb2d6c3c5c550c17e8c02c05ba",
94
- "sha256:998d7e73548fe395eeb294495a04d38942edb66d1fa61eb70418871bc621227e",
95
- "sha256:9de60893fb447d1e797f6bf08fdf0dbcda0c1e34c1b06c92bd3a363c0ea8c609",
96
- "sha256:9e80d45d0c7fcee54e22771db7f1b0b126fb4a6c0a2e5afa72f66827207ff2f2",
97
- "sha256:a545a3dfe5082dc8e8c3eb7f8a2cf4f2870902ff1860bd99b6198cfd1f9d1f49",
98
- "sha256:a5d8f29e5ec661143621a8f4de51adfb300d7a476224156a39a392254f70687b",
99
- "sha256:aca06bfba4759bbdb09bf52ebb15ae20268ee1f6747417837926fae990ebc41d",
100
- "sha256:bb23b7a6fd666e551a3094ab896a57809e010059540ad20acbeec03a154224ce",
101
- "sha256:bfd1d0ae7e292105f29d7deaa9d8f2916ed8553ab9d5f39ec65bcf5deadff3f9",
102
- "sha256:c62ca0a38958f541a73cf86acdab020c2091631c137bd359c4f5bddde7b75fd4",
103
- "sha256:c709d8bda72cf4cd348ccec2a4881f2c5848fd72903c185f363d361b2737f773",
104
- "sha256:c968a6aa7e0b56ecbd28531ddf439c2ec103610d3e2bf3b75b813304f8cb7723",
105
- "sha256:df785d8cb80539d0b55fd47183264b7002077859028dfe3070cf6359bf8b2d9c",
106
- "sha256:f406628ca51e0ae90ae76ea8398677a921b36f0bd71aab2099dfed08abd0322f",
107
- "sha256:f46087bbd95ebae244a0eda01a618aff11ec7a069b15a3ef8f6b520db523dcf1",
108
- "sha256:f8019c5279eb32360ca03e9fac40a12667715546eed5c5eb59eb381f2f501260",
109
- "sha256:fc5f4d209733750afd2714e9109816a29500718b32dd9a5db01c0cb3a019b96a"
110
- ],
111
- "version": "==4.5.3"
112
  },
113
  "coveralls": {
114
  "hashes": [
115
- "sha256:baa26648430d5c2225ab12d7e2067f75597a4b967034bba7e3d5ab7501d207a1",
116
- "sha256:ff9b7823b15070f26f654837bb02a201d006baaf2083e0514ffd3b34a3ffed81"
117
  ],
118
  "index": "pypi",
119
- "version": "==1.7.0"
 
 
 
 
 
 
120
  },
121
  "docopt": {
122
  "hashes": [
@@ -126,11 +162,10 @@
126
  },
127
  "docutils": {
128
  "hashes": [
129
- "sha256:02aec4bd92ab067f6ff27a38a38a41173bf01bed8f89157768c1573f53e474a6",
130
- "sha256:51e64ef2ebfb29cae1faa133b3710143496eca21c530f3f71424d77687764274",
131
- "sha256:7a4bd47eaf6596e1295ecb11361139febe29b084a87bf005bf899f9a42edc3c6"
132
  ],
133
- "version": "==0.14"
134
  },
135
  "entrypoints": {
136
  "hashes": [
@@ -139,31 +174,209 @@
139
  ],
140
  "version": "==0.3"
141
  },
142
- "enum34": {
143
  "hashes": [
144
- "sha256:2d81cbbe0e73112bdfe6ef8576f2238f2ba27dd0d55752a776c41d38b7da2850",
145
- "sha256:644837f692e5f550741432dd3f223bbb9852018674981b1664e5dc339387588a",
146
- "sha256:6bd0f6ad48ec2aa117d3d141940d484deccda84d4fcd884f5c3d93c23ecd8c79",
147
- "sha256:8ad8c4783bf61ded74527bffb48ed9b54166685e4230386a9ed9b1279e2df5b1"
148
  ],
149
- "index": "pypi",
150
- "version": "==1.1.6"
 
 
 
 
 
 
 
 
 
 
 
 
151
  },
152
  "flake8": {
153
  "hashes": [
154
- "sha256:859996073f341f2670741b51ec1e67a01da142831aa1fdc6242dbf88dffbe661",
155
- "sha256:a796a115208f5c03b18f332f7c11729812c8c3ded6c46319c59b53efd3819da8"
156
  ],
157
  "index": "pypi",
158
- "version": "==3.7.7"
159
  },
160
- "identify": {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  "hashes": [
162
- "sha256:443f419ca6160773cbaf22dbb302b1e436a386f23129dbb5482b68a147c2eca9",
163
- "sha256:bd7f15fe07112b713fb68fbdde3a34dd774d9062128f2c398104889f783f989d"
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
- "importlib-metadata": {
175
  "hashes": [
176
- "sha256:46fc60c34b6ed7547e2a723fc8de6dc2e3a1173f8423246b3ce497f064e9c3de",
177
- "sha256:bc136180e961875af88b1ab85b4009f4f1278f8396a60526c0009f503a1a96ca"
178
  ],
179
- "version": "==0.9"
180
  },
181
- "importlib-resources": {
182
  "hashes": [
183
- "sha256:6e2783b2538bd5a14678284a3962b0660c715e5a0f10243fd5e00a4b5974f50b",
184
- "sha256:d3279fd0f6f847cced9f7acc19bd3e5df54d34f93a2e7bb5f238f81545787078"
185
- ],
186
- "markers": "python_version < '3.7'",
187
- "version": "==1.0.2"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  },
189
  "mccabe": {
190
  "hashes": [
@@ -193,72 +461,89 @@
193
  ],
194
  "version": "==0.6.1"
195
  },
196
- "mock": {
197
  "hashes": [
198
- "sha256:5ce3c71c5545b472da17b72268978914d0252980348636840bd34a00b5cc96c1",
199
- "sha256:b158b6df76edd239b8208d481dc46b6afd45a846b7812ff0ce58971cf5bc8bba"
200
  ],
201
- "index": "pypi",
202
- "version": "==2.0.0"
203
  },
204
- "more-itertools": {
205
  "hashes": [
206
- "sha256:38a936c0a6d98a38bcc2d03fdaaedaba9f412879461dd2ceff8d37564d6522e4",
207
- "sha256:c0a5785b1109a6bd7fac76d6837fd1feca158e54e521ccd2ae8bfe393cc9d4fc",
208
- "sha256:fe7a7cae1ccb57d33952113ff4fa1bc5f879963600ed74918f1236e212ee50b9"
 
 
 
 
 
 
 
 
 
 
 
209
  ],
210
  "index": "pypi",
211
- "version": "==5.0.0"
 
 
 
 
 
 
 
212
  },
213
  "nodeenv": {
214
  "hashes": [
215
- "sha256:ad8259494cf1c9034539f6cced78a1da4840a4b157e23640bc4a0c0546b0cb7a"
216
  ],
217
- "version": "==1.3.3"
218
  },
219
- "pathlib2": {
220
  "hashes": [
221
- "sha256:25199318e8cc3c25dcb45cbe084cc061051336d5a9ea2a12448d3d8cb748f742",
222
- "sha256:5887121d7f7df3603bca2f710e7219f3eca0eb69e0b7cc6e0a022e155ac931a7"
223
  ],
224
- "index": "pypi",
225
- "version": "==2.3.3"
226
  },
227
- "pbr": {
228
  "hashes": [
229
- "sha256:6901995b9b686cb90cceba67a0f6d4d14ae003cd59bc12beb61549bdfbe3bc89",
230
- "sha256:d950c64aeea5456bbd147468382a5bb77fe692c13c9f00f0219814ce5b642755"
231
  ],
232
- "version": "==5.2.0"
233
  },
234
- "pkginfo": {
235
  "hashes": [
236
- "sha256:7424f2c8511c186cd5424bbf31045b77435b37a8d604990b79d4e70d741148bb",
237
- "sha256:a6d9e40ca61ad3ebd0b72fbadd4fba16e4c0e4df0428c041e01e06eb6ee71f32"
238
  ],
239
- "version": "==1.5.0.1"
 
240
  },
241
  "pluggy": {
242
  "hashes": [
243
- "sha256:19ecf9ce9db2fce065a7a0586e07cfb4ac8614fe96edf628a264b1c70116cf8f",
244
- "sha256:84d306a647cc805219916e62aab89caa97a33a1dd8c342e87a37f91073cd4746"
245
  ],
246
- "version": "==0.9.0"
247
  },
248
  "pre-commit": {
249
  "hashes": [
250
- "sha256:2576a2776098f3902ef9540a84696e8e06bf18a337ce43a6a889e7fa5d26c4c5",
251
- "sha256:82f2f2d657d7f9280de9f927ae56886d60b9ef7f3714eae92d12713cd9cb9e11"
252
  ],
253
  "index": "pypi",
254
- "version": "==1.15.2"
255
  },
256
  "py": {
257
  "hashes": [
258
- "sha256:64f65755aee5b381cea27766a3a147c3f15b9b6b9ac88676de66ba2ae36793fa",
259
- "sha256:dc639b046a6e2cff5bbe40194ad65936d6ba360b52b3c3fe1d08a82dd50b5e53"
260
  ],
261
- "version": "==1.8.0"
262
  },
263
  "pycodestyle": {
264
  "hashes": [
@@ -276,145 +561,253 @@
276
  },
277
  "pygments": {
278
  "hashes": [
279
- "sha256:5ffada19f6203563680669ee7f53b64dabbeb100eb51b61996085e99c03b284a",
280
- "sha256:e8218dd399a61674745138520d0d4cf2621d7e032439341bc3f647bff125818d"
 
 
 
 
 
 
 
281
  ],
282
- "version": "==2.3.1"
283
  },
284
  "pytest": {
285
  "hashes": [
286
- "sha256:3773f4c235918987d51daf1db66d51c99fac654c81d6f2f709a046ab446d5e5d",
287
- "sha256:b7802283b70ca24d7119b32915efa7c409982f59913c1a6c0640aacf118b95f5"
288
  ],
289
  "index": "pypi",
290
- "version": "==4.4.1"
291
  },
292
  "pytest-cov": {
293
  "hashes": [
294
- "sha256:0ab664b25c6aa9716cbf203b17ddb301932383046082c081b9848a0edf5add33",
295
- "sha256:230ef817450ab0699c6cc3c9c8f7a829c34674456f2ed8df1fe1d39780f7c87f"
296
  ],
297
  "index": "pypi",
298
- "version": "==2.6.1"
299
  },
300
  "pytest-mock": {
301
  "hashes": [
302
- "sha256:43ce4e9dd5074993e7c021bb1c22cbb5363e612a2b5a76bc6d956775b10758b7",
303
- "sha256:5bf5771b1db93beac965a7347dc81c675ec4090cb841e49d9d34637a25c30568"
304
  ],
305
  "index": "pypi",
306
- "version": "==1.10.4"
307
  },
308
- "pyyaml": {
309
  "hashes": [
310
- "sha256:1adecc22f88d38052fb787d959f003811ca858b799590a5eaa70e63dca50308c",
311
- "sha256:436bc774ecf7c103814098159fbb84c2715d25980175292c648f2da143909f95",
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
- "version": "==5.1"
 
323
  },
324
- "readme-renderer": {
325
  "hashes": [
326
- "sha256:bb16f55b259f27f75f640acf5e00cf897845a8b3e4731b5c1a436e4b8529202f",
327
- "sha256:c8532b79afc0375a85f10433eca157d6b50f7d6990f337fa498c96cd4bfc203d"
328
  ],
329
- "version": "==24.0"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
  },
331
  "requests": {
332
  "hashes": [
333
- "sha256:502a824f31acdacb3a35b6690b5fbf0bc41d63a24a45c4004352b0242707598e",
334
- "sha256:7bf2a778576d825600030a110f3c0e3e8edc51dfaafe1c146e39a2027784957b"
335
  ],
336
- "version": "==2.21.0"
337
  },
338
- "requests-toolbelt": {
339
  "hashes": [
340
- "sha256:380606e1d10dc85c3bd47bf5a6095f815ec007be7a8b69c878507068df059e6f",
341
- "sha256:968089d4584ad4ad7c171454f0a5c6dac23971e9472521ea3b6d49d610aa6fc0"
342
  ],
343
- "version": "==0.9.1"
 
 
 
 
 
 
 
344
  },
345
- "scandir": {
346
  "hashes": [
347
- "sha256:2586c94e907d99617887daed6c1d102b5ca28f1085f90446554abf1faf73123e",
348
- "sha256:2ae41f43797ca0c11591c0c35f2f5875fa99f8797cb1a1fd440497ec0ae4b022",
349
- "sha256:2b8e3888b11abb2217a32af0766bc06b65cc4a928d8727828ee68af5a967fa6f",
350
- "sha256:2c712840c2e2ee8dfaf36034080108d30060d759c7b73a01a52251cc8989f11f",
351
- "sha256:4d4631f6062e658e9007ab3149a9b914f3548cb38bfb021c64f39a025ce578ae",
352
- "sha256:67f15b6f83e6507fdc6fca22fedf6ef8b334b399ca27c6b568cbfaa82a364173",
353
- "sha256:7d2d7a06a252764061a020407b997dd036f7bd6a175a5ba2b345f0a357f0b3f4",
354
- "sha256:8c5922863e44ffc00c5c693190648daa6d15e7c1207ed02d6f46a8dcc2869d32",
355
- "sha256:92c85ac42f41ffdc35b6da57ed991575bdbe69db895507af88b9f499b701c188",
356
- "sha256:b24086f2375c4a094a6b51e78b4cf7ca16c721dcee2eddd7aa6494b42d6d519d",
357
- "sha256:cb925555f43060a1745d0a321cca94bcea927c50114b623d73179189a4e100ac"
358
  ],
359
  "index": "pypi",
360
- "version": "==1.10.0"
361
  },
362
- "six": {
363
  "hashes": [
364
- "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c",
365
- "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73"
366
  ],
367
- "version": "==1.12.0"
368
  },
369
- "toml": {
370
  "hashes": [
371
- "sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c",
372
- "sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e"
373
  ],
374
- "version": "==0.10.0"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
375
  },
376
- "tqdm": {
377
  "hashes": [
378
- "sha256:d385c95361699e5cf7622485d9b9eae2d4864b21cd5a2374a9c381ffed701021",
379
- "sha256:e22977e3ebe961f72362f6ddfb9197cc531c9737aaf5f607ef09740c849ecd05"
380
  ],
381
- "version": "==4.31.1"
382
  },
383
- "twine": {
384
  "hashes": [
385
- "sha256:0fb0bfa3df4f62076cab5def36b1a71a2e4acb4d1fa5c97475b048117b1a6446",
386
- "sha256:d6c29c933ecfc74e9b1d9fa13aa1f87c5d5770e119f5a4ce032092f0ff5b14dc"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
387
  ],
388
  "index": "pypi",
389
- "version": "==1.13.0"
390
  },
391
  "urllib3": {
392
  "hashes": [
393
- "sha256:4c291ca23bbb55c76518905869ef34bdd5f0e46af7afe6861e8375643ffee1a0",
394
- "sha256:9a247273df709c4fedb38c711e44292304f73f39ab01beda9f6b9fc375669ac3"
395
  ],
396
- "version": "==1.24.2"
397
  },
398
  "virtualenv": {
399
  "hashes": [
400
- "sha256:15ee248d13e4001a691d9583948ad3947bcb8a289775102e4c4aa98a8b7a6d73",
401
- "sha256:bfc98bb9b42a3029ee41b96dc00a34c2f254cbf7716bec824477b2c82741a5c4"
402
  ],
403
- "version": "==16.5.0"
404
  },
405
- "webencodings": {
406
  "hashes": [
407
- "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78",
408
- "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"
409
  ],
410
- "version": "==0.5.1"
411
  },
412
  "zipp": {
413
  "hashes": [
414
- "sha256:139391b239594fd8b91d856bc530fbd2df0892b17dd8d98a91f018715954185f",
415
- "sha256:8047e4575ce8d700370a3301bbfc972896a5845eb62dd535da395b86be95dfad"
416
  ],
417
- "version": "==0.4.0"
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/pytube.svg" alt="pypi">
8
- <a href="https://travis-ci.org/nficano/pytube"><img src="https://travis-ci.org/nficano/pytube.svg?branch=master" /></a>
9
- <a href="http://python-pytube.readthedocs.io/en/latest/?badge=latest"><img src="https://readthedocs.org/projects/python-pytube/badge/?version=latest" /></a>
10
- <a href="https://coveralls.io/github/nficano/pytube?branch=master"><img src="https://coveralls.io/repos/github/nficano/pytube/badge.svg?branch=master#23e6f7ac56dd3bde" /></a>
11
- <a href="https://pypi.org/project/pytube/"><img src="https://img.shields.io/pypi/dm/pytube.svg" alt="pypi"></a>
12
- <a href="https://pypi.python.org/pypi/pytube/"><img src="https://img.shields.io/pypi/pyversions/pytube.svg" /></a>
 
13
  </p>
14
  </div>
15
 
16
- # pytube
17
- *pytube* is a very serious, lightweight, dependency-free Python library (and command-line utility) for downloading YouTube Videos.
18
 
19
- ## Description
20
- YouTube is the most popular video-sharing platform in the world and as a hacker you may encounter a situation where you want to script something to download videos. For this I present to you *pytube*.
 
 
 
 
 
 
21
 
22
- *pytube* is a lightweight library written in Python. It has no third party dependencies and aims to be highly reliable.
23
 
24
- *pytube* also makes pipelining easy, allowing you to specify callback functions for different download events, such as ``on progress`` or ``on complete``.
25
 
26
- Finally *pytube* also includes a command-line utility, allowing you to quickly download videos right from terminal.
 
 
 
27
 
28
- ### Behold, a perfect balance of simplicity versus flexibility:
29
 
 
30
  ```python
31
- >>> YouTube('https://youtu.be/9bZkp7q19f0').streams.first().download()
 
 
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
- - Support for Both Progressive & DASH Streams
43
- - Support for downloading complete playlist
44
- - Easily Register ``on_download_progress`` & ``on_download_complete`` callbacks
45
- - Command-line Interfaced Included
46
- - Caption Track Support
47
- - Outputs Caption Tracks to .srt format (SubRip Subtitle)
48
- - Ability to Capture Thumbnail URL.
49
- - Extensively Documented Source Code
50
- - No Third-Party Dependencies
51
-
52
- ## Installation
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.first().download()
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.all()
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).all()
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).all()
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
- >>> pl = Playlist("https://www.youtube.com/watch?v=Edpy1szoG80&list=PL153hDY-y1E00uQtCVCVC8xJ25TYX8yPU")
143
- >>> pl.download_all()
144
- >>> # or if you want to download in a specific directory
145
- >>> pl.download_all('/path/to/directory/')
146
  ```
147
- This will download the highest progressive stream available (generally 720p) from the given playlist. Later more options would be given for user's flexibility
148
- to choose video resolution.
 
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).all()
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').all()
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).all()
182
  >>> # this can also be expressed as:
183
- >>> yt.streams.filter(subtype='mp4').filter(progressive=True).all()
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().all()
198
  ```
199
- Note that ``order_by`` cannot be used if your attribute is undefined in any of the Stream instances, so be sure to apply a filter to remove those before calling it.
 
 
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, file_handle):
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, file_handle, bytes_remaining):
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
- pytube also ships with a tiny cli interface for downloading and probing videos.
222
 
223
  Let's start with downloading:
224
 
225
  ```bash
226
- $ pytube http://youtube.com/watch?v=9bZkp7q19f0 --itag=22
227
  ```
228
  To view available streams:
229
 
230
  ```bash
231
- $ pytube http://youtube.com/watch?v=9bZkp7q19f0 --list
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
  ```
233
 
234
- Finally, if you're filing a bug report, the cli contains a switch called ``--build-playback-report``, which bundles up the state, allowing others to easily replay your issue.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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&amp;utm_medium=referral&amp;utm_content=hbmartin/pytube3&amp;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 = 'pytube'
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 = 'pytubedoc'
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, 'pytube.tex', 'pytube Documentation',
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, 'pytube', 'pytube Documentation',
136
  [author], 1,
137
  ),
138
  ]
@@ -145,8 +145,8 @@ man_pages = [
145
  # dir menu entry, description, category)
146
  texinfo_documents = [
147
  (
148
- master_doc, 'pytube', 'pytube Documentation',
149
- author, 'pytube', 'One line description of project.',
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
- .. pytube documentation master file, created by
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
- pytube
7
  ======
8
  Release v\ |version|. (:ref:`Installation <install>`)
9
 
10
- .. image:: https://img.shields.io/pypi/v/pytube.svg
11
  :alt: Pypi
12
- :target: https://pypi.python.org/pypi/pytube/
13
 
14
- .. image:: https://travis-ci.org/nficano/pytube.svg?branch=master
15
  :alt: Build status
16
- :target: https://travis-ci.org/nficano/pytube
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/pytube.svg
27
  :alt: Python Versions
28
- :target: https://pypi.python.org/pypi/pytube/
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 pytube
11
 
12
  Get the Source Code
13
  -------------------
14
 
15
- pytube is actively developed on GitHub, where the source is `available <https://github.com/nficano/pytube>`_.
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/nficano/pytube/tarball/master>`_::
22
 
23
- $ curl -OL https://github.com/nficano/pytube/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::
 
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__ = 'pytube'
8
- __version__ = '9.5.3'
9
- __author__ = 'Nick Ficano'
10
- __license__ = 'MIT License'
11
- __copyright__ = 'Copyright 2019 Nick Ficano'
12
 
13
- from pytube.logging import create_logger
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.contrib.playlist import Playlist
 
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.compat import install_proxy
23
- from pytube.compat import parse_qsl
24
  from pytube.exceptions import VideoUnavailable
25
- from pytube.helpers import apply_mixin
26
 
27
  logger = logging.getLogger(__name__)
28
 
29
 
30
- class YouTube(object):
31
  """Core developer interface for pytube."""
32
 
33
  def __init__(
34
- self, url=None, defer_prefetch_init=False, on_progress_callback=None,
35
- on_complete_callback=None, proxies=None,
 
 
 
 
36
  ):
37
  """Construct a :class:`YouTube <YouTube>`.
38
 
39
  :param str url:
40
  A valid YouTube watch URL.
41
- :param bool defer_init:
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 # js fetched by js_url
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
- self.vid_info = None # content fetched by vid_info_url
58
- self.vid_info_url = None # the url to vid info, parsed from watch html
 
 
59
 
60
- self.watch_html = None # the html of /watch?v=<video_id>
61
- self.embed_html = None
62
- self.player_config_args = None # inline js in the html containing
 
63
  # streams
64
- self.age_restricted = None
65
 
66
- self.fmt_streams = [] # list of :class:`Stream <Stream>` instances
67
- self.caption_tracks = []
68
 
69
  # video_id part of /watch?v=<video_id>
70
  self.video_id = extract.video_id(url)
71
 
72
- # https://www.youtube.com/watch?v=<video_id>
73
- self.watch_url = extract.watch_url(self.video_id)
74
 
75
- self.embed_url = extract.embed_url(self.video_id)
76
- # A dictionary shared between all instances of :class:`Stream <Stream>`
77
- # (Borg pattern).
78
- self.stream_monostate = {
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.prefetch_init()
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('init started')
111
 
112
- self.vid_info = {k: v for k, v in parse_qsl(self.vid_info)}
113
  if self.age_restricted:
114
  self.player_config_args = self.vid_info
115
  else:
116
- self.player_config_args = extract.get_ytplayer_config(
117
- self.watch_html,
118
- )['args']
119
 
120
  # Fix for KeyError: 'title' issue #434
121
- if 'title' not in self.player_config_args:
122
- i_start = (
123
- self.watch_html
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(' - youtube')
130
  title = title[:index] if index > 0 else title
131
- self.player_config_args['title'] = title
132
 
133
- self.vid_descr = extract.get_vid_descr(self.watch_html)
134
  # https://github.com/nficano/pytube/issues/165
135
- stream_maps = ['url_encoded_fmt_stream_map']
136
- if 'adaptive_fmts' in self.player_config_args:
137
- stream_maps.append('adaptive_fmts')
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
- mixins.apply_descrambler(self.vid_info, fmt)
143
- mixins.apply_descrambler(self.player_config_args, fmt)
144
-
145
- try:
146
- mixins.apply_signature(self.player_config_args, fmt, self.js)
147
- except TypeError:
148
- self.js_url = extract.js_url(
149
- self.embed_html, self.age_restricted,
150
- )
151
  self.js = request.get(self.js_url)
152
- mixins.apply_signature(self.player_config_args, fmt, self.js)
 
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
- apply_mixin(self.player_config_args, 'player_response', json.loads)
 
 
 
159
 
160
- self.initialize_caption_objects()
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 '<img class="icon meh" src="/yts/img' not in self.watch_html:
175
- raise VideoUnavailable('This video is unavailable.')
176
- self.embed_html = request.get(url=self.embed_url)
177
  self.age_restricted = extract.is_age_restricted(self.watch_html)
178
- self.vid_info_url = extract.video_info_url(
179
- video_id=self.video_id,
180
- watch_url=self.watch_url,
181
- watch_html=self.watch_html,
182
- embed_html=self.embed_html,
183
- age_restricted=self.age_restricted,
184
- )
185
- self.vid_info = request.get(self.vid_info_url)
 
 
 
 
 
 
 
 
186
  if not self.age_restricted:
187
- self.js_url = extract.js_url(self.watch_html, self.age_restricted)
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
- def initialize_caption_objects(self):
214
- """Populate instances of :class:`Caption <Caption>`.
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
- if 'captions' not in self.player_config_args['player_response']:
223
- return
224
- # https://github.com/nficano/pytube/issues/167
225
- caption_tracks = (
226
- self.player_config_args
227
- .get('player_response', {})
228
- .get('captions', {})
229
- .get('playerCaptionsTracklistRenderer', {})
230
- .get('captionTracks', [])
231
  )
232
- for caption_track in caption_tracks:
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([c for c in self.caption_tracks])
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([s for s in self.fmt_streams])
250
 
251
  @property
252
- def thumbnail_url(self):
253
  """Get the thumbnail url image.
254
 
255
  :rtype: str
256
 
257
  """
258
- return self.player_config_args['thumbnail_url']
 
 
 
 
 
 
 
 
 
259
 
260
  @property
261
- def title(self):
262
  """Get the video title.
263
 
264
  :rtype: str
265
 
266
  """
267
- return self.player_config_args['title']
 
 
268
 
269
  @property
270
- def description(self):
271
  """Get the video description.
272
 
273
  :rtype: str
274
 
275
  """
276
- return self.vid_descr
 
 
277
 
278
  @property
279
- def rating(self):
280
  """Get the video average rating.
281
 
282
- :rtype: str
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 self.player_config_args['length_seconds']
 
 
 
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
- self.player_config_args
310
- .get('player_response', {})
311
- .get('videoDetails', {})
312
- .get('viewCount')
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
- ``file_handle``, ``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):
328
  """Register a download complete callback function post initialization.
329
 
330
  :param callable func:
331
- A callback function that takes ``stream`` and ``file_handle``.
332
 
333
  :rtype: None
334
 
335
  """
336
- self.stream_monostate['on_complete'] = func
 
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 pytube.compat import unescape
 
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('baseUrl')
21
- self.name = caption_track['name']['simpleText']
22
- self.code = caption_track['languageCode']
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
- def float_to_srt_time_format(self, d):
 
38
  """Convert decimal durations into proper srt format.
39
 
40
  :rtype: str
41
  :returns:
42
  SubRip Subtitle (str) formatted time duration.
43
 
44
- >>> float_to_srt_time_format(3.89)
45
- '00:00:03,890'
46
  """
47
- frac, whole = math.modf(d)
48
- time_fmt = time.strftime('%H:%M:%S,', time.gmtime(whole))
49
- ms = '{:.3f}'.format(frac).replace('0.', '')
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.getchildren()):
61
- text = child.text or ''
62
- caption = unescape(
63
- text
64
- .replace('\n', ' ')
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
- '{seq}\n{start} --> {end}\n{text}\n'.format(
73
- seq=sequence_number,
74
- start=self.float_to_srt_time_format(start),
75
- end=self.float_to_srt_time_format(end),
76
- text=caption,
77
- )
78
  )
79
  segments.append(line)
80
- return '\n'.join(segments).strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 countains all logic necessary to decipher the signature.
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
- def get_initial_function_name(js):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- pattern = [
40
- r'\b[cs]\s*&&\s*[adf]\.set\([^,]+\s*,\s*encodeURIComponent\s*\(\s*(?P<sig>[a-zA-Z0-9$]+)\(', # noqa: E501
41
- 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
 
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'\.sig\|\|(?P<sig>[a-zA-Z0-9$]+)\(',
45
- r'yt\.akamaized\.net/\)\s*\|\|\s*.*?\s*[cs]\s*&&\s*[adf]\.set\([^,]+\s*,\s*(?:encodeURIComponent\s*\()?\s*(?P<si$', # noqa: E501
46
- r'\b[cs]\s*&&\s*[adf]\.set\([^,]+\s*,\s*(?P<sig>[a-zA-Z0-9$]+)\(', # noqa: E501
47
- r'\b[a-zA-Z0-9]+\s*&&\s*[a-zA-Z0-9]+\.set\([^,]+\s*,\s*(?P<sig>[a-zA-Z0-9$]+)\(', # noqa: E501
48
- r'\bc\s*&&\s*a\.set\([^,]+\s*,\s*\([^)]*\)\s*\(\s*(?P<sig>[a-zA-Z0-9$]+)\(', # noqa: E501
49
- r'\bc\s*&&\s*[a-zA-Z0-9]+\.set\([^,]+\s*,\s*\([^)]*\)\s*\(\s*(?P<sig>[a-zA-Z0-9$]+)\(', # noqa: E501
50
- r'\bc\s*&&\s*[a-zA-Z0-9]+\.set\([^,]+\s*,\s*\([^)]*\)\s*\(\s*(?P<sig>[a-zA-Z0-9$]+)\(', # noqa: E501
51
  ]
 
 
 
 
 
 
 
52
 
53
- logger.debug('finding initial function name')
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'%s=function\(\w\){[a-z=\.\(\"\)]*;(.*);(?:.+)}' % name
80
- logger.debug('getting transform plan')
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'var %s={(.*?)};' % re.escape(var)
107
- logger.debug('getting transform object')
108
- return (
109
- regex_search(pattern, js, group=1, flags=re.DOTALL)
110
- .replace('\n', ' ')
111
- .split(', ')
112
- )
113
 
 
114
 
115
- def get_transform_map(js, var):
 
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(':', 1)
133
  fn = map_functions(function)
134
  mapper[name] = fn
135
  return mapper
136
 
137
 
138
- def reverse(arr, b):
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
- ('{\w\.reverse\(\)}', reverse),
203
  # function(a,b){a.splice(0,b)}
204
- ('{\w\.splice\(0,\w\)}', splice),
205
  # function(a,b){var c=a[0];a[0]=a[b%a.length];a[b]=c}
206
- ('{var\s\w=\w\[0\];\w\[0\]=\w\[\w\%\w.length\];\w\[\w\]=\w}', swap),
207
  # function(a,b){var c=a[0];a[0]=a[b%a.length];a[b%a.length]=c}
208
  (
209
- '{var\s\w=\w\[0\];\w\[0\]=\w\[\w\%\w.length\];'
210
- '\w\[\w\%\w.length\]=\w}', swap,
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
- parser.add_argument('url', help='The YouTube /watch url', nargs='?')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  parser.add_argument(
26
- '--version', action='version',
27
- version='%(prog)s ' + __version__,
28
  )
29
  parser.add_argument(
30
- '--itag', type=int, help=(
31
- 'The itag for the desired stream'
 
 
 
 
32
  ),
33
  )
34
  parser.add_argument(
35
- '-l', '--list', action='store_true', help=(
36
- 'The list option causes pytube cli to return a list of streams '
37
- 'available to download'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  ),
39
  )
40
  parser.add_argument(
41
- '-v', '--verbose', action='count', default=0, dest='verbosity',
42
- help='Verbosity level',
 
 
 
 
43
  )
44
  parser.add_argument(
45
- '--build-playback-report', action='store_true', help=(
46
- 'Save the html and js to disk'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  ),
48
  )
49
 
50
- args = parser.parse_args()
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(url):
68
  """Serialize the request data to json for offline debugging.
69
 
70
- :param str url:
71
- A valid YouTube watch URL.
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 = yt.js
81
- watch_html = yt.watch_html
82
- vid_info = yt.vid_info
83
 
84
- with gzip.open(fp, 'wb') as fh:
85
  fh.write(
86
- json.dumps({
87
- 'url': url,
88
- 'js': js,
89
- 'watch_html': watch_html,
90
- 'video_info': vid_info,
91
- })
92
- .encode('utf8'),
 
93
  )
94
 
95
 
96
- def get_terminal_size():
97
- """Return the terminal size in rows and columns."""
98
- rows, columns = os.popen('stty size', 'r').read().split()
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 str:
116
  Character to use for presenting progress segment.
117
  :param float scale:
118
- Scale multipler to reduce progress bar size.
119
 
120
  """
121
- _, columns = get_terminal_size()
122
  max_width = int(columns * scale)
123
 
124
  filled = int(round(max_width * bytes_received / float(filesize)))
125
  remaining = max_width - filled
126
- bar = ch * filled + ' ' * remaining
127
  percent = round(100.0 * bytes_received / float(filesize), 1)
128
- text = ' ↳ |{bar}| {percent}%\r'.format(bar=bar, percent=percent)
129
  sys.stdout.write(text)
130
  sys.stdout.flush()
131
 
132
 
133
- def on_progress(stream, chunk, file_handle, bytes_remaining):
134
- """On download progress callback function.
135
-
136
- :param object stream:
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 download(url, itag):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  """Start downloading a YouTube video.
153
 
154
- :param str url:
155
- A valid YouTube watch URL.
156
- :param str itag:
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
- yt = YouTube(url, on_progress_callback=on_progress)
163
- stream = yt.streams.get_by_itag(itag)
164
- print('\n{fn} | {fs} bytes'.format(
165
- fn=stream.default_filename,
166
- fs=stream.filesize,
167
- ))
 
 
 
168
  try:
169
- stream.download()
170
- sys.stdout.write('\n')
171
  except KeyboardInterrupt:
172
  sys.exit()
173
 
174
 
175
- def display_streams(url):
176
  """Probe YouTube video and lists its available formats.
177
 
178
- :param str url:
179
  A valid YouTube watch URL.
180
 
181
  """
182
- yt = YouTube(url)
183
- for stream in yt.streams.all():
184
  print(stream)
185
 
186
 
187
- if __name__ == '__main__':
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 collections import OrderedDict
 
 
 
9
 
10
- from pytube import request
11
- from pytube.__main__ import YouTube
12
 
13
  logger = logging.getLogger(__name__)
14
 
15
 
16
- class Playlist(object):
17
- """Handles all the task of manipulating and downloading a whole YouTube
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 construct_playlist_url(self):
27
- """There are two kinds of playlist urls in YouTube. One that contains
28
- watch?v= in URL, another one contains the "playlist?list=" portion. It
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
- load_more_url = 'https://www.youtube.com' + re.search(
48
- r'data-uix-load-more-href=\"(/browse_ajax\?'
49
- 'action_continuation=.*?)\"', req,
50
- ).group(1)
51
- except AttributeError:
52
- load_more_url = ''
53
- return load_more_url
54
-
55
- def parse_links(self):
56
- """Parse the video links from the page source, extracts and
57
- returns the /watch?v= part from video link href
58
- It's an alternative for BeautifulSoup
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  """
 
60
 
61
- url = self.construct_playlist_url()
62
- req = request.get(url)
63
-
64
- # split the page source by line and process each line
65
- content = [x for x in req.split('\n') if 'pl-video-title-link' in x]
66
- link_list = [x.split('href="', 1)[1].split('&', 1)[0] for x in content]
 
 
 
 
 
 
 
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._load_more_url(req)
71
- while len(load_more_url): # there is an url found
72
- logger.debug('load more url: %s' % load_more_url)
 
73
  req = request.get(load_more_url)
74
  load_more = json.loads(req)
75
- videos = re.findall(
76
- r'href=\"(/watch\?v=[\w-]*)',
77
- load_more['content_html'],
78
- )
79
- # remove duplicates
80
- link_list.extend(list(OrderedDict.fromkeys(videos)))
81
- load_more_url = self._load_more_url(
82
- load_more['load_more_widget_html'],
 
 
 
 
 
 
 
 
 
83
  )
84
 
85
- return link_list
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
 
87
- def populate_video_urls(self):
88
- """Construct complete links of all the videos in playlist and
89
- populate video_urls list
90
 
91
- :return: urls -> string
92
  """
 
 
 
 
 
 
 
93
 
94
- base_url = 'https://www.youtube.com'
95
- link_list = self.parse_links()
96
 
97
- for video_id in link_list:
98
- complete_url = base_url + video_id
99
- self.video_urls.append(complete_url)
 
 
100
 
101
- def _path_num_prefix_generator(self, reverse=False):
 
102
  """
103
- This generator function generates number prefixes, for the items
104
- in the playlist.
 
 
 
 
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
- """Download all the videos in the the playlist. Initially, download
126
- resolution is 720p (or highest available), later more option
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 -> oldests.
143
  :type reverse_numbering: bool
 
 
 
144
  """
145
-
146
- self.populate_video_urls()
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
- try:
154
- yt = YouTube(link)
155
- except Exception as e:
156
- logger.debug(e)
157
- if not self.suppress_exception:
158
- raise e
159
- else:
160
- logger.debug('Exception suppressed')
 
 
 
 
161
  else:
162
- # TODO: this should not be hardcoded to a single user's
163
- # preference
164
- dl_stream = yt.streams.filter(
165
- progressive=True, subtype='mp4',
166
- ).order_by('resolution').desc().first()
167
-
168
- logger.debug('download path: %s', download_path)
169
- if prefix_number:
170
- prefix = next(prefix_gen)
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
- try:
181
- url = self.construct_playlist_url()
182
- req = request.get(url)
183
- open_tag = '<title>'
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 sys
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
- :param str msg:
22
- User defined error message.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  :param str video_id:
24
  A YouTube video identifier.
25
  """
26
- if video_id is not None:
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 RegexMatchError(ExtractError):
36
- """Regex pattern did not return any matches."""
37
 
 
 
 
 
 
 
38
 
39
- class LiveStreamError(ExtractError):
40
- """Video is a live stream."""
41
 
42
 
43
- class VideoUnavailable(PytubeError):
44
- """Video is unavailable."""
 
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.compat import HTMLParser
7
- from pytube.compat import quote
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 == 'p':
20
  for attr in attrs:
21
- if attr[0] == 'id' and attr[1] == 'eow-description':
22
  self.in_vid_descr = True
23
 
24
  def handle_endtag(self, tag):
25
- if self.in_vid_descr and tag == 'p':
26
  self.in_vid_descr = False
27
 
28
  def handle_startendtag(self, tag, attrs):
29
- if self.in_vid_descr and tag == 'br':
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 += '\n{}'.format(data)
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'og:restrictions:age', watch_html, group=0)
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'(?:v=|\/)([0-9A-Za-z_-]{11}).*', url, group=1)
72
 
73
 
74
- def watch_url(video_id):
75
- """Construct a sanitized YouTube watch url, given a video id.
76
 
77
  :param str video_id:
78
  A YouTube video identifier.
 
 
79
  :rtype: str
80
  :returns:
81
- Sanitized YouTube watch url.
 
82
  """
83
- return 'https://youtube.com/watch?v=' + video_id
84
-
85
-
86
- def embed_url(video_id):
87
- return 'https://www.youtube.com/embed/{}'.format(video_id)
88
-
89
-
90
- def eurl(video_id):
91
- return 'https://youtube.googleapis.com/v/{}'.format(video_id)
92
-
93
-
94
- def video_info_url(
95
- video_id, watch_url, watch_html, embed_html,
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
- if age_restricted:
116
  sts = regex_search(r'"sts"\s*:\s*(\d+)', embed_html, group=1)
117
- # Here we use ``OrderedDict`` so that the output is consistent between
118
- # Python 2.7+.
119
- params = OrderedDict([
120
- ('video_id', video_id),
121
- ('eurl', eurl(video_id)),
122
- ('sts', sts),
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 js_url(html, age_restricted=False):
 
 
 
 
136
  """Get the base JavaScript url.
137
 
138
  Construct the base JavaScript url, which contains the decipher
139
  "transforms".
140
 
141
- :param str watch_html:
142
  The html contents of the watch page.
143
- :param bool age_restricted:
144
- Is video age restricted.
145
-
146
  """
147
- ytplayer_config = get_ytplayer_config(html, age_restricted)
148
- base_js = ytplayer_config['assets']['js']
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
- >>> mime_type_codec('audio/webm; codecs="opus"')
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'(\w+\/\w+)\;\scodecs=\"([a-zA-Z-0-9.,\s]*)\"'
172
- mime_type, codecs = regex_search(pattern, mime_type_codec, groups=True)
173
- return mime_type, [c.strip() for c in codecs.split(',')]
 
 
 
 
174
 
175
 
176
- def get_ytplayer_config(html, age_restricted=False):
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 watch_html:
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
- if age_restricted:
192
- pattern = r";yt\.setConfig\(\{'PLAYER_CONFIG':\s*({.*})(,'EXPERIMENT_FLAGS'|;)" # noqa: E501
193
- else:
194
- pattern = r';ytplayer\.config\s*=\s*({.*?});'
195
- yt_player_config = regex_search(pattern, html, group=1)
196
- return json.loads(yt_player_config)
197
-
198
-
199
- def get_vid_descr(html):
 
 
 
 
 
 
 
 
 
 
200
  html_parser = PytubeHTMLParser()
201
- html_parser.feed(html)
 
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 pprint
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, groups=False, group=None, flags=0):
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
- if type(pattern) == list:
35
- for p in pattern:
36
- regex = re.compile(p, flags)
37
- results = regex.search(string)
38
- if not results:
39
- raise RegexMatchError(
40
- 'regex pattern ({pattern}) had zero matches'
41
- .format(pattern=p),
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
- ntfs_chrs = [chr(i) for i in range(0, 31)]
120
- chrs = [
121
- '\"', '\#', '\$', '\%', '\'', '\*', '\,', '\.', '\/', '\:', '"',
122
- '\;', '\<', '\>', '\?', '\\', '\^', '\|', '\~', '\\\\',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  ]
124
- pattern = '|'.join(ntfs_chrs + chrs)
125
  regex = re.compile(pattern, re.UNICODE)
126
- filename = regex.sub('', s)
127
- return unicode(filename[:max_length].rsplit(' ', 0)[0])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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: ('240p', '64kbps'),
6
- 6: ('270p', '64kbps'),
7
- 13: ('144p', None),
8
- 17: ('144p', '24kbps'),
9
- 18: ('360p', '96kbps'),
10
- 22: ('720p', '192kbps'),
11
- 34: ('360p', '128kbps'),
12
- 35: ('480p', '128kbps'),
13
- 36: ('240p', None),
14
- 37: ('1080p', '192kbps'),
15
- 38: ('3072p', '192kbps'),
16
- 43: ('360p', '128kbps'),
17
- 44: ('480p', '128kbps'),
18
- 45: ('720p', '192kbps'),
19
- 46: ('1080p', '192kbps'),
20
- 59: ('480p', '128kbps'),
21
- 78: ('480p', '128kbps'),
22
- 82: ('360p', '128kbps'),
23
- 83: ('480p', '128kbps'),
24
- 84: ('720p', '192kbps'),
25
- 85: ('1080p', '192kbps'),
26
- 91: ('144p', '48kbps'),
27
- 92: ('240p', '48kbps'),
28
- 93: ('360p', '128kbps'),
29
- 94: ('480p', '128kbps'),
30
- 95: ('720p', '256kbps'),
31
- 96: ('1080p', '256kbps'),
32
- 100: ('360p', '128kbps'),
33
- 101: ('480p', '192kbps'),
34
- 102: ('720p', '192kbps'),
35
- 132: ('240p', '48kbps'),
36
- 151: ('720p', '24kbps'),
37
-
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
-
81
  # DASH Audio
82
- 139: (None, '48kbps'),
83
- 140: (None, '128kbps'),
84
- 141: (None, '256kbps'),
85
- 171: (None, '128kbps'),
86
- 172: (None, '256kbps'),
87
- 249: (None, '50kbps'),
88
- 250: (None, '70kbps'),
89
- 251: (None, '160kbps'),
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 dditional format information for a given itag.
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
- 'resolution': res,
115
- 'abr': bitrate,
116
- 'is_live': itag in LIVE,
117
- 'is_3d': itag in _3D,
118
- 'is_hdr': itag in HDR,
119
- 'fps': 60 if itag in _60FPS else 30,
 
 
 
 
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
- self, fps=None, res=None, resolution=None, mime_type=None,
19
- type=None, subtype=None, file_extension=None, abr=None,
20
- bitrate=None, video_codec=None, audio_codec=None,
21
- only_audio=None, only_video=None,
22
- progressive=None, adaptive=None,
23
- custom_filter_functions=None,
 
 
 
 
 
 
 
 
 
 
 
 
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
- for fn in custom_filter_functions:
152
- filters.append(fn)
 
 
153
 
 
 
 
154
  fmt_streams = self.fmt_streams
155
- for fn in filters:
156
- fmt_streams = list(filter(fn, fmt_streams))
157
- return StreamQuery(fmt_streams)
158
 
159
- def order_by(self, attribute_name):
160
- """Apply a sort order to a resultset.
161
 
162
  :param str attribute_name:
163
  The name of the attribute to sort by.
164
  """
165
- integer_attr_repr = {}
166
- for stream in self.fmt_streams:
167
- attr = getattr(stream, attribute_name)
168
- if attr is None:
169
- break
170
- num = ''.join(x for x in attr if x.isdigit())
171
- integer_attr_repr[attr] = int(''.join(num)) if num else None
172
-
173
- # if every attribute has an integer representation
174
- if integer_attr_repr and all(integer_attr_repr.values()):
175
- def key(s):
176
- return integer_attr_repr[getattr(s, attribute_name)]
177
- else:
178
- def key(s):
179
- return getattr(s, attribute_name)
180
-
181
- fmt_streams = sorted(
182
- self.fmt_streams,
183
- key=key,
 
 
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 str int itag:
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
- try:
215
- return self.itag_index[int(itag)]
216
- except KeyError:
217
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- pass
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
- def count(self):
248
- """Get the count the query would return.
 
249
 
250
  :rtype: int
251
-
252
  """
253
- return len(self.fmt_streams)
 
 
 
254
 
255
- def all(self):
 
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
- class CaptionQuery:
 
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
- def get_by_language_code(self, lang_code):
 
 
 
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
- def all(self):
 
290
  """Get all the results represented by this query as a list.
291
 
292
  :rtype: list
293
 
294
  """
295
- return self.captions
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  # -*- coding: utf-8 -*-
2
+
3
  """This module provides a query interface for media streams and captions."""
4
+ from typing import Callable, List, Optional, Union
5
+ from collections.abc import Mapping, Sequence
6
+
7
+ from pytube import Stream, Caption
8
+ from pytube.helpers import deprecated
9
 
10
 
11
+ class StreamQuery(Sequence):
12
  """Interface for querying the available media streams."""
13
 
14
  def __init__(self, fmt_streams):
 
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 urllib.request
 
 
 
 
 
 
 
4
 
5
- from pytube.compat import urlopen
6
- # 403 forbidden fix
7
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
- def get(
10
- url=None, headers=False,
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
- :param bool headers:
18
- Only return the http headers.
19
- :param bool streaming:
20
- Returns the response body in chunks via a generator.
21
- :param int chunk_size:
22
- The size in bytes of each chunk.
 
 
 
 
 
 
 
 
 
23
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
- # https://github.com/nficano/pytube/pull/465
26
- req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
27
- response = urlopen(req)
28
-
29
- if streaming:
30
- return stream_response(response, chunk_size)
31
- elif headers:
32
- # https://github.com/nficano/pytube/issues/160
33
- return {k.lower(): v for k, v in response.info().items()}
34
- return (
35
- response
36
- .read()
37
- .decode('utf-8')
38
- )
39
-
40
-
41
- def stream_response(response, chunk_size=8 * 1024):
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 io
13
  import logging
14
  import os
15
- import pprint
 
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(object):
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.abr = None # average bitrate (audio streams only)
46
- self.fps = None # frames per second (video streams only)
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
- # Additional information about the stream format, such as resolution,
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(self.type)
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
- def set_attributes_from_dict(self, dct):
87
- """Set class attributes from dictionary items.
88
 
89
- :rtype: None
90
- """
91
- for key, val in dct.items():
92
- setattr(self, key, val)
 
 
 
 
 
 
 
 
 
 
 
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
- if self.is_progressive:
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
- if self.is_progressive:
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
- headers = request.get(self.url, headers=True)
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
- player_config_args = self.player_config_args or {}
 
 
 
 
177
 
178
- if 'title' in player_config_args:
179
- return player_config_args['title']
180
 
181
- details = self.player_config_args.get(
182
- 'player_response', {},
183
- ).get('videoDetails', {})
 
 
 
184
 
185
- if 'title' in details:
186
- return details['title']
187
 
188
- return 'Unknown YouTube Video Title'
 
 
 
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 '{filename}.{s.subtype}'.format(filename=filename, s=self)
201
-
202
- def download(self, output_path=None, filename=None, filename_prefix=None):
 
 
 
 
 
 
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 seperate from filename so you can use the default
218
  filename but still add a prefix.
219
  :type filename_prefix: str or None
220
-
 
 
 
 
221
  :rtype: str
222
 
223
  """
224
- output_path = output_path or os.getcwd()
225
- if filename:
226
- safe = safe_filename(filename)
227
- filename = '{filename}.{s.subtype}'.format(filename=safe, s=self)
228
- filename = filename or self.default_filename
229
 
230
- if filename_prefix:
231
- filename = '{prefix}{filename}'\
232
- .format(
233
- prefix=safe_filename(filename_prefix),
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
- 'downloading (%s total bytes) file to %s',
242
- self.filesize, fp,
243
  )
244
 
245
- with open(fp, 'wb') as fh:
246
- for chunk in request.get(self.url, streaming=True):
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(fh)
252
- return fp
 
 
 
 
 
 
 
 
 
 
 
 
 
 
253
 
254
- def stream_to_buffer(self):
 
 
 
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.debug(
262
- 'downloading (%s total bytes) file to BytesIO buffer',
263
- self.filesize,
264
  )
265
 
266
- for chunk in request.get(self.url, streaming=True):
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(buffer)
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 str chunk:
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
- 'download progress\n%s',
297
- pprint.pformat(
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, file_handle):
310
  """On download complete handler function.
311
 
312
- :param file_handle:
313
  The file handle where the media is being written to.
314
- :type file_handle:
315
- :py:class:`io.BufferedWriter`
316
 
317
  :rtype: None
318
 
319
  """
320
- logger.debug('download finished')
321
- on_complete = self._monostate['on_complete']
322
  if on_complete:
323
- logger.debug('calling on_complete callback %s', on_complete)
324
- on_complete(self, file_handle)
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
- 'acodec="{s.audio_codec}"',
341
- ])
342
  else:
343
  parts.extend(['vcodec="{s.video_codec}"'])
344
  else:
345
  parts.extend(['abr="{s.abr}"', 'acodec="{s.audio_codec}"'])
346
- parts = ' '.join(parts).format(s=self)
347
- return '<Stream: {parts}>'.format(parts=parts)
 
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 pytube."""
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, 'README.md'), encoding='utf-8') as fh:
15
- long_description = '\n' + fh.read()
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='pytube',
50
- version='9.5.3',
51
- author='Nick Ficano',
52
- author_email='[email protected]',
53
- packages=['pytube', 'pytube.contrib'],
54
- package_data={
55
- '': ['LICENSE'],
56
- },
57
- url='https://github.com/nficano/pytube',
58
- license='MIT',
59
- entry_points={
60
- 'console_scripts': [
61
- 'pytube = pytube.cli:main',
62
- ],
63
- },
64
  classifiers=[
65
- 'Development Status :: 5 - Production/Stable',
66
- 'Environment :: Console',
67
- 'Intended Audience :: Developers',
68
- 'License :: OSI Approved :: MIT License',
69
- 'Natural Language :: English',
70
- 'Operating System :: MacOS',
71
- 'Operating System :: Microsoft',
72
- 'Operating System :: POSIX',
73
- 'Operating System :: Unix',
74
- 'Programming Language :: Python :: 2.7',
75
- 'Programming Language :: Python :: 3.4',
76
- 'Programming Language :: Python :: 3.5',
77
- 'Programming Language :: Python :: 3.6',
78
- 'Programming Language :: Python :: 3.7',
79
- 'Programming Language :: Python',
80
- 'Topic :: Internet',
81
- 'Topic :: Multimedia :: Video',
82
- 'Topic :: Software Development :: Libraries :: Python Modules',
83
- 'Topic :: Terminals',
84
- 'Topic :: Utilities',
85
  ],
86
- description=('A pythonic library for downloading YouTube Videos.'),
87
  include_package_data=True,
88
- long_description_content_type='text/markdown',
89
  long_description=long_description,
90
  zip_safe=True,
91
- cmdclass={'upload': UploadCommand},
 
 
 
 
 
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
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, 'mocks', filename)
19
- with gzip.open(fp, 'rb') as fh:
20
- content = fh.read().decode('utf-8')
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['url'], defer_prefetch_init=True)
28
- yt.watch_html = pb['watch_html']
29
- yt.js = pb['js']
30
- yt.vid_info = pb['video_info']
31
- yt.init()
32
  return yt
33
 
34
 
35
  @pytest.fixture
36
  def cipher_signature():
37
  """Youtube instance initialized with video id 9bZkp7q19f0."""
38
- filename = 'yt-video-9bZkp7q19f0-1507588332.json.tar.gz'
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 = 'yt-video-QRS8MkLhQmM-1507588031.json.tar.gz'
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 = 'yt-video-zRbsm3e2ltw-1507777044.json.tar.gz'
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
- def test_title():
6
- list_key = 'PLsyeobzWxl7poL9JTVyndKe62ieoN-MZ3'
7
- url = 'https://www.youtube.com/playlist?list=' + list_key
 
 
 
 
8
  pl = Playlist(url)
9
  pl_title = pl.title()
10
- assert pl_title == 'Python Tutorial for Beginners'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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&amp;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&amp;feature=plpp_video&amp;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&amp;feature=plpp_video&amp;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&amp;feature=plpp_video&amp;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