mirror of
https://github.com/devine-dl/pywidevine.git
synced 2025-12-13 01:52:16 +00:00
Compare commits
79 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4298db7546 | ||
|
|
0f2f34c83f | ||
|
|
b30171302f | ||
|
|
8c310ad5e3 | ||
|
|
d58db89baf | ||
|
|
8cd623a217 | ||
|
|
72f84249bd | ||
|
|
b624355764 | ||
|
|
c32dd6a757 | ||
|
|
e75bde1de2 | ||
|
|
1bb0b246f4 | ||
|
|
9ec88ef1a8 | ||
|
|
7c2caa2e7e | ||
|
|
fe033c9647 | ||
|
|
7ea2a72a8c | ||
|
|
84d30a69a9 | ||
|
|
c39dd6df5d | ||
|
|
94f8eba960 | ||
|
|
25e03529f6 | ||
|
|
a04e751aa1 | ||
|
|
17cefbf1d8 | ||
|
|
bcb2185f75 | ||
|
|
532e68aba9 | ||
|
|
e348fc5df2 | ||
|
|
4fc8216c4a | ||
|
|
81fd2649a4 | ||
|
|
00532979b6 | ||
|
|
9479c069b5 | ||
|
|
ba83e29147 | ||
|
|
49315eceb8 | ||
|
|
5087da31a0 | ||
|
|
79cdbc007c | ||
|
|
c362192c11 | ||
|
|
0e6aa1d5e8 | ||
|
|
97ec2e1c60 | ||
|
|
0c31f88d23 | ||
|
|
2d8163f76d | ||
|
|
797799a5aa | ||
|
|
dfdba71caf | ||
|
|
65d8135e2a | ||
|
|
2fb3b21e4a | ||
|
|
cd990e0f4e | ||
|
|
52fd5e74ba | ||
|
|
2656a795c3 | ||
|
|
bbbaeafbb6 | ||
|
|
c71f867a72 | ||
|
|
dad32e728b | ||
|
|
db7bf977a1 | ||
|
|
bfaae20e81 | ||
|
|
728a3e7575 | ||
|
|
29693bedf6 | ||
|
|
db6eaef450 | ||
|
|
6a7f8b9a39 | ||
|
|
e4a8316227 | ||
|
|
9568d7fdb9 | ||
|
|
ece0914920 | ||
|
|
2ab659eab6 | ||
|
|
99aef63354 | ||
|
|
fd3df13e9c | ||
|
|
2e9c09d5f1 | ||
|
|
2e25f9c7bd | ||
|
|
ddc66f0a2b | ||
|
|
c9f55c6e6b | ||
|
|
2648d1c669 | ||
|
|
bc2b5beef4 | ||
|
|
11284eddfb | ||
|
|
61097ce6de | ||
|
|
3a910bd03a | ||
|
|
e31ba61302 | ||
|
|
0e4275bd1e | ||
|
|
e0365ff2bb | ||
|
|
ae95aeec96 | ||
|
|
1b40c2b369 | ||
|
|
05b30b3a89 | ||
|
|
7a993206a1 | ||
|
|
2d2359f9a2 | ||
|
|
8146e055e6 | ||
|
|
58208ab68f | ||
|
|
7996a3d91c |
@ -1,17 +0,0 @@
|
|||||||
version = 1
|
|
||||||
|
|
||||||
exclude_patterns = [
|
|
||||||
"pywidevine/license_protocol_pb2.py"
|
|
||||||
]
|
|
||||||
|
|
||||||
[[analyzers]]
|
|
||||||
name = "python"
|
|
||||||
enabled = true
|
|
||||||
|
|
||||||
[analyzers.meta]
|
|
||||||
runtime_version = "3.x.x"
|
|
||||||
max_line_length = 120
|
|
||||||
|
|
||||||
[[analyzers]]
|
|
||||||
name = "secrets"
|
|
||||||
enabled = false
|
|
||||||
15
.editorconfig
Normal file
15
.editorconfig
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
end_of_line = lf
|
||||||
|
charset = utf-8
|
||||||
|
insert_final_newline = true
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.{feature,json,md,yaml,yml,toml}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
46
.github/workflows/cd.yml
vendored
46
.github/workflows/cd.yml
vendored
@ -9,37 +9,25 @@ jobs:
|
|||||||
tagged-release:
|
tagged-release:
|
||||||
name: Tagged Release
|
name: Tagged Release
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
environment:
|
||||||
|
name: pypi
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v5
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v2
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: '3.10.x'
|
python-version: "3.14"
|
||||||
- name: Install Poetry
|
- name: Install uv
|
||||||
uses: abatilo/actions-poetry@v2.1.0
|
uses: astral-sh/setup-uv@v6
|
||||||
with:
|
with:
|
||||||
poetry-version: '1.1.11'
|
version: "0.9.5"
|
||||||
- name: Configure poetry
|
enable-cache: true
|
||||||
run: poetry config virtualenvs.in-project true
|
- name: Install the project
|
||||||
- name: Install dependencies
|
run: uv sync --locked
|
||||||
run: |
|
- name: Build project
|
||||||
python -m pip install --upgrade pip wheel
|
run: uv build
|
||||||
poetry install
|
|
||||||
- name: Build a wheel
|
|
||||||
run: poetry build
|
|
||||||
- name: Upload wheel
|
|
||||||
uses: actions/upload-artifact@v2.2.4
|
|
||||||
with:
|
|
||||||
name: Python Wheel
|
|
||||||
path: "dist/*.whl"
|
|
||||||
- name: Deploy release
|
|
||||||
uses: marvinpinto/action-automatic-releases@latest
|
|
||||||
with:
|
|
||||||
prerelease: false
|
|
||||||
repo_token: "${{ secrets.GITHUB_TOKEN }}"
|
|
||||||
files: |
|
|
||||||
dist/*.whl
|
|
||||||
- name: Publish to PyPI
|
- name: Publish to PyPI
|
||||||
env:
|
run: uv publish
|
||||||
POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_TOKEN }}
|
|
||||||
run: poetry publish
|
|
||||||
|
|||||||
53
.github/workflows/ci.yml
vendored
53
.github/workflows/ci.yml
vendored
@ -7,39 +7,40 @@ on:
|
|||||||
branches: [ master ]
|
branches: [ master ]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: "3.14"
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v6
|
||||||
|
with:
|
||||||
|
version: "0.9.5"
|
||||||
|
enable-cache: true
|
||||||
|
- name: Install the project
|
||||||
|
run: uv sync --locked --all-extras --dev
|
||||||
|
- name: Run pre-commit which does various checks
|
||||||
|
run: uv run pre-commit run --all-files --show-diff-on-failure
|
||||||
build:
|
build:
|
||||||
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ['3.7', '3.8', '3.9', '3.10']
|
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||||
poetry-version: [1.1.11]
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v5
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v2
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
- name: Install poetry
|
- name: Install uv
|
||||||
uses: abatilo/actions-poetry@v2.1.0
|
uses: astral-sh/setup-uv@v6
|
||||||
with:
|
with:
|
||||||
poetry-version: ${{ matrix.poetry-version }}
|
version: "0.9.5"
|
||||||
- name: Install project
|
enable-cache: true
|
||||||
run: |
|
- name: Install the project
|
||||||
poetry install --no-dev
|
run: uv sync --locked --all-extras
|
||||||
python -m pip install flake8 pytest
|
|
||||||
|
|
||||||
- name: Lint with flake8
|
|
||||||
run: |
|
|
||||||
# stop the build if there are Python syntax errors or undefined names
|
|
||||||
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
|
|
||||||
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
|
|
||||||
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
|
|
||||||
# - name: Test with pytest
|
|
||||||
# run: |
|
|
||||||
# pytest
|
|
||||||
|
|
||||||
- name: Build project
|
- name: Build project
|
||||||
run: poetry build
|
run: uv build
|
||||||
|
|||||||
40
.gitignore
vendored
40
.gitignore
vendored
@ -23,7 +23,6 @@ parts/
|
|||||||
sdist/
|
sdist/
|
||||||
var/
|
var/
|
||||||
wheels/
|
wheels/
|
||||||
pip-wheel-metadata/
|
|
||||||
share/python-wheels/
|
share/python-wheels/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
.installed.cfg
|
.installed.cfg
|
||||||
@ -53,6 +52,7 @@ coverage.xml
|
|||||||
*.py,cover
|
*.py,cover
|
||||||
.hypothesis/
|
.hypothesis/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
cover/
|
||||||
|
|
||||||
# Translations
|
# Translations
|
||||||
*.mo
|
*.mo
|
||||||
@ -75,6 +75,7 @@ instance/
|
|||||||
docs/_build/
|
docs/_build/
|
||||||
|
|
||||||
# PyBuilder
|
# PyBuilder
|
||||||
|
.pybuilder/
|
||||||
target/
|
target/
|
||||||
|
|
||||||
# Jupyter Notebook
|
# Jupyter Notebook
|
||||||
@ -85,7 +86,9 @@ profile_default/
|
|||||||
ipython_config.py
|
ipython_config.py
|
||||||
|
|
||||||
# pyenv
|
# pyenv
|
||||||
.python-version
|
# For a library or package, you might want to ignore these files since the code is
|
||||||
|
# intended to run in multiple environments; otherwise, check them in:
|
||||||
|
# .python-version
|
||||||
|
|
||||||
# pipenv
|
# pipenv
|
||||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
@ -94,7 +97,22 @@ ipython_config.py
|
|||||||
# install all needed dependencies.
|
# install all needed dependencies.
|
||||||
#Pipfile.lock
|
#Pipfile.lock
|
||||||
|
|
||||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
|
# poetry
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||||
|
#poetry.lock
|
||||||
|
|
||||||
|
# pdm
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||||
|
#pdm.lock
|
||||||
|
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||||
|
# in version control.
|
||||||
|
# https://pdm.fming.dev/#use-with-ide
|
||||||
|
.pdm.toml
|
||||||
|
|
||||||
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||||
__pypackages__/
|
__pypackages__/
|
||||||
|
|
||||||
# Celery stuff
|
# Celery stuff
|
||||||
@ -120,9 +138,6 @@ venv.bak/
|
|||||||
# Rope project settings
|
# Rope project settings
|
||||||
.ropeproject
|
.ropeproject
|
||||||
|
|
||||||
# Jetbrains project settings
|
|
||||||
.idea
|
|
||||||
|
|
||||||
# mkdocs documentation
|
# mkdocs documentation
|
||||||
/site
|
/site
|
||||||
|
|
||||||
@ -133,3 +148,16 @@ dmypy.json
|
|||||||
|
|
||||||
# Pyre type checker
|
# Pyre type checker
|
||||||
.pyre/
|
.pyre/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# Cython debug symbols
|
||||||
|
cython_debug/
|
||||||
|
|
||||||
|
# PyCharm
|
||||||
|
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||||
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
|
.idea/
|
||||||
|
|||||||
30
.pre-commit-config.yaml
Normal file
30
.pre-commit-config.yaml
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# See https://pre-commit.com for more information
|
||||||
|
# See https://pre-commit.com/hooks.html for more hooks
|
||||||
|
|
||||||
|
exclude: '_pb2.pyi?$'
|
||||||
|
repos:
|
||||||
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
|
rev: v0.14.2
|
||||||
|
hooks:
|
||||||
|
- id: ruff-check
|
||||||
|
language: system
|
||||||
|
types: [ python ]
|
||||||
|
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||||
|
rev: v1.18.2
|
||||||
|
hooks:
|
||||||
|
- id: mypy
|
||||||
|
language: system
|
||||||
|
types: [ python ]
|
||||||
|
args: [] # override defaults to empty
|
||||||
|
- repo: https://github.com/pycqa/isort
|
||||||
|
rev: 6.1.0
|
||||||
|
hooks:
|
||||||
|
- id: isort
|
||||||
|
language: system
|
||||||
|
types: [ python ]
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: v6.0.0
|
||||||
|
hooks:
|
||||||
|
- id: end-of-file-fixer
|
||||||
|
- id: trailing-whitespace
|
||||||
|
args: [--markdown-linebreak-ext=md]
|
||||||
12
.vscode/extensions.json
vendored
Normal file
12
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"EditorConfig.EditorConfig",
|
||||||
|
"streetsidesoftware.code-spell-checker",
|
||||||
|
"ms-python.python",
|
||||||
|
"ms-python.vscode-pylance",
|
||||||
|
"charliermarsh.ruff",
|
||||||
|
"ms-python.isort",
|
||||||
|
"ms-python.mypy-type-checker",
|
||||||
|
"redhat.vscode-yaml"
|
||||||
|
]
|
||||||
|
}
|
||||||
600
CHANGELOG.md
600
CHANGELOG.md
@ -5,338 +5,530 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [1.5.1] - 2022-10-23
|
## [1.9.0] - 2025-12-22
|
||||||
|
|
||||||
|
Just a small update to update imports, support the latest python version, and fix configs.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added import path shortcuts in the `__init__.py` package constructor to all the user classes. Now you can do e.g.,
|
- Support for Python 3.14 (and retroactively 3.13).
|
||||||
`from pywidevine import PSSH` instead of `from pywidevine.pssh import PSSH`. You can still do it both ways.
|
|
||||||
- Improved error handling and sanitization checks when parsing some Service Certificates in `set_service_certificate()`.
|
|
||||||
|
|
||||||
### Changed
|
### Removed
|
||||||
|
|
||||||
- Maximum concurrent Cdm sessions are now set to 16 as it seems tto be a more common limit on more up-to-date CDMs,
|
- Dropped support for Python 3.8.
|
||||||
including Android's OEMCrypto Library. This also helps encourage people to close their sessions when they are no
|
- DeepSource config and badge.
|
||||||
longer required.
|
|
||||||
- Service Certificates are now stored in the session as a `SignedDrmCertificate`. This is to keep the signature with
|
|
||||||
the stored Certificate for use by the user if necessary. It also reduces code repetition relating to the usage of the
|
|
||||||
signature.
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Improved reliability of computing License Signatures. Some license messages when parsed would be slightly different
|
- Fixed pre-commit config, now using all official repos while still utilizing poetry dependencies.
|
||||||
when re-serialized with `SerializeToString()`, therefore the computed signature would have always mismatched.
|
- Fixed the GitHub workflows for CI/CD, now using the latest action versions.
|
||||||
- Added support for Key IDs that are integer values. Effectively all values are now considered to be a UUID as 16 bytes
|
|
||||||
(in hex or bytes) or an integer value with support for up to 16 bytes. All integer values are converted to a UUID and
|
|
||||||
are loaded big-endian.
|
|
||||||
- Fixed acquisition of the Certificate's provider_id within `set_service_certificate()` in some edge cases, but also
|
|
||||||
when you try to remove the certificate by setting it to `None`.
|
|
||||||
- PSSH now dumps in the same version the PSSH was loaded or created in. Previously it would always dump as a v1 PSSH
|
|
||||||
box due to a cascading check in pymp4. It now also honors the currently set version in the case it gets overridden.
|
|
||||||
|
|
||||||
## [1.5.0] - 2022-09-24
|
## [1.8.0] - 2023-12-22
|
||||||
|
|
||||||
With just one change this brings along a reduced dependency tree, smoother experience across different platforms, and
|
### Added
|
||||||
speed improvements (especially on larger input messages).
|
|
||||||
|
- Added `py.typed` file to support PEP561 and silence Mypy.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Updated protobuf dependency to v4.x branch with recompiled proto-buffers. They now also have python stub files.
|
- Dropped support for Python 3.7.
|
||||||
|
- Recompiled protobuffers for version 4.25.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Missing `yaml` dependency as it was only installed alongside the `serve` extras group.
|
||||||
|
- Duplicate Concatenated SignedMessages no longer throw a verification failure in `Cdm.set_service_certificate()`.
|
||||||
|
To ensure security of the messages, verification will still fail if any of the SignedMessages do not match each other.
|
||||||
|
|
||||||
|
### New Contributors
|
||||||
|
|
||||||
|
- [sr0lle](https://github.com/sr0lle)
|
||||||
|
|
||||||
|
## [1.7.0] - 2023-11-21
|
||||||
|
|
||||||
|
- Supported Serve API: `v1.4.3` or newer
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Ability to specify output filename by specifying a full path or a relative file name in CLI command `create-device`.
|
||||||
|
- Add the staging privacy certificate (`staging.google.com`) to `Cdm.staging_privacy_cert`.
|
||||||
|
- Similar to `common_privacy_cert` which would be used on Google's production license server,
|
||||||
|
- Though this one is used on Google's staging license server (a production-ready testing server).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Raise an error if a file already exists at the output path in CLI command `create-device`.
|
||||||
|
- Use std-lib xml instead of lxml to reduce dependencies and support ARM (#35).
|
||||||
|
- Lessen restriction on Python version to any Python version `>=3.7`, but `<4.0`.
|
||||||
|
- I was hoping to do `^3.7`, but some dependencies also require `<4.0` therefore I cannot, for now.
|
||||||
|
- Move Key ID parsing to static `PSSH.parse_key_ids()` method.
|
||||||
|
- The `shaka-packager` subprocess call's return code is now returned from `Cdm.decrypt()`.
|
||||||
|
- The flags variable of a `Device` now defaults to a dict, even if not set.
|
||||||
|
- Heavily improve initializing of protobuf objects, improving readability, typing, and linting quite a bit.
|
||||||
|
- Renamed Device's `_Types` enum class to `DeviceTypes`.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- Removed `Device.Types` class variable alias to `_Types` enum class as a static linter cannot recognize a class
|
||||||
|
variable as a type. Instead, the actual `_Types` (now named `DeviceTypes`) enum should be imported and used instead.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Ensure output directory exists before creating new `.wvd` files in CLI command `create-device`.
|
||||||
|
- Ignore empty Key ID values in v4.0.0.0 PlayReadyHeaders.
|
||||||
|
- Remove `Cdm.system_id` class variable as it conflicted with the `cdm.system_id` class instance variable of the same
|
||||||
|
name. It's also generally not needed. The same data can be gotten via `Cdm.uuid.bytes`.
|
||||||
|
- Casting of `type_` when passed a non-int value in `Cdm.get_license_challenge()`.
|
||||||
|
- Pass a PSSH object in `test` CLI command instead of a string.
|
||||||
|
- Lower-case and setup `__all__` correctly, add missing `__all__` in some of the modules.
|
||||||
|
- For the longest time I thought it was `__ALL__` and an iterable of objects/variables.
|
||||||
|
- However, its actually `__all__` and explicitly a list of Strings...
|
||||||
|
|
||||||
|
### New Contributors
|
||||||
|
|
||||||
|
- [mediaminister](https://github.com/mediaminister)
|
||||||
|
|
||||||
|
## [1.6.0] - 2023-02-03
|
||||||
|
|
||||||
|
- Supported Serve API: `v1.4.3` or newer
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Support Python 3.11.
|
||||||
|
- New CLI command `export-device` to export WVD files back as files. I.e., a private key and client ID blob file.
|
||||||
|
|
||||||
|
## [1.5.3] - 2022-12-27
|
||||||
|
|
||||||
|
- Supported Serve API: `v1.4.3` or newer
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- New utility `load_xml()` to parse XML data with lxml ignoring Namespaces.
|
||||||
|
- PSSH class now have `__str__` and `__repr__` methods to print the object in more Human-friendly ways.
|
||||||
|
- `str(pssh)` is now identical to `pssh.dumps()`.
|
||||||
|
- `repr(pssh)` or just `pssh` in some cases will result in a nice overview of the PSSHs contents.
|
||||||
|
- New `to_playready()` method to convert Widevine PSSH Data to PlayReady PSSH Data. Please note that the
|
||||||
|
Checksums for AES-CTR and COCKTAIL KIDs cannot be calculated as the Content Encryption Key would be needed.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The System ID must now be explicitly specified when creating a new PSSH box in `PSSH.new()`.
|
||||||
|
- This allows you to now create PlayReady PSSH boxes.
|
||||||
|
- The `playready_to_widevine()` method has been renamed to just `to_widevine()`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Correct capitalization of the `key_IDs` field when making the new box in `PSSH.new()`.
|
||||||
|
- Correct the value type of `key_IDs` value when creating a new box in `PSSH.new()`.
|
||||||
|
- Ensure Key IDs are list of UUIDs instead of bytes in `PSSH.new()`.
|
||||||
|
- Create v0 PSSH boxes by only setting the `key_IDs` field when the version is set to `1` in `PSSH.new()`.
|
||||||
|
- Fix loading of PlayReadyHeaders (and PlayReadyObjects) as PSSH boxes. It would previously load it under the
|
||||||
|
Widevine SystemID breaking all PlayReady-specific code after construction.
|
||||||
|
- Parse Key IDs within PlayReadyHeaders by using the new `load_xml()` utility to ignore namespaces so that `xpath` can
|
||||||
|
correctly locate any and all KID tags.
|
||||||
|
- Support parsing PlayReadyObjects with more than one PlayReadyHeader (more than one record).
|
||||||
|
## [1.5.2] - 2022-10-11
|
||||||
|
|
||||||
|
- Supported Serve API: `v1.4.3` or newer
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed license signature calculation for newer Widevine Server licenses on OEM Crypto v16.0.0 or newer.
|
||||||
|
The `oemcrypto_core_message` data needed to be part of the HMAC ingest if available.
|
||||||
|
|
||||||
|
## [1.5.1] - 2022-10-23
|
||||||
|
|
||||||
|
- Supported Serve API: `v1.4.3` or newer
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Support for big-int Key IDs in `PSSH`. All integer values are converted to a UUID and are loaded big-endian.
|
||||||
|
- Import path shortcuts in the `__init__.py` package constructor to all the user classes.
|
||||||
|
- Now you can do e.g., `from pywidevine import PSSH` instead of `from pywidevine.pssh import PSSH`.
|
||||||
|
- You can still do it the full direct way if you want.
|
||||||
|
- Parsing check to the raw DrmCertificate in `Cdm.set_service_certificate()`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Service Certificates are now stored in the session as a `SignedDrmCertificate`.
|
||||||
|
- This is to keep the signature with the Certificate, without wrapping it in a SignedMessage unnecessarily.
|
||||||
|
- Reduced the maximum concurrent Cdm sessions from 50 to 16 as it seems to be a more common limit on more up-to-date
|
||||||
|
devices and versions of OEMCrypto. This also helps encourage people to close their sessions when they are no longer
|
||||||
|
required.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Acquisition of the Certificate's provider_id in `Cdm.set_service_certificate()` in some edge cases, but also when you
|
||||||
|
try to remove the certificate by setting it to `None`.
|
||||||
|
- When exporting a PSSH object it will now do so in the same version it was initially loaded or created in. Previously
|
||||||
|
it would always dump as a v1 PSSH box due to a cascading check in pymp4. It now also honors the currently set version
|
||||||
|
in the case it gets overridden.
|
||||||
|
- Improved reliability of computing License Signatures by verifying the signature against the original raw License
|
||||||
|
message instead of the re-serialized version of the message.
|
||||||
|
- Some license messages when parsed would be slightly different when re-serialized against my protobuf, therefore the
|
||||||
|
computed signature would have always mismatched.
|
||||||
|
|
||||||
|
## [1.5.0] - 2022-09-24
|
||||||
|
|
||||||
|
- Supported Serve API: `v1.4.3` or newer
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Updated `protobuf` dependency to `v4.x` branch with recompiled proto-buffers, specifically `v4.21.6`.
|
||||||
|
|
||||||
## [1.4.4] - 2022-09-24
|
## [1.4.4] - 2022-09-24
|
||||||
|
|
||||||
|
- Supported Serve API: `v1.4.3` or newer
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
- Updated `protobuf` dependency to v3.19.5 due to the Security Advisory [GHSA-8gq9-2x98-w8hf].
|
- Updated `protobuf` dependency to `3.19.5` due to the Security Advisory [GHSA-8gq9-2x98-w8hf].
|
||||||
|
|
||||||
[GHSA-8gq9-2x98-w8hf]: <https://github.com/protocolbuffers/protobuf/security/advisories/GHSA-8gq9-2x98-w8hf>
|
[GHSA-8gq9-2x98-w8hf]: <https://github.com/protocolbuffers/protobuf/security/advisories/GHSA-8gq9-2x98-w8hf>
|
||||||
|
|
||||||
## [1.4.3] - 2022-09-10
|
## [1.4.3] - 2022-09-10
|
||||||
|
|
||||||
RemoteCdm minimum supported Serve API version is now v1.4.3.
|
- Supported Serve API: `v1.4.3` or newer
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Cdm now has a `get_service_certificate()` endpoint to get the currently set service certificate of a Session.
|
- Serve's `/get_license_challenge` endpoint can now disable privacy mode per-request, even if a service certificate is
|
||||||
RemoteCdm and Serve also has support for these endpoints.
|
set, as long as privacy mode is not enforced in the Serve API config.
|
||||||
|
- New Cdm method `get_service_certificate()` to get the currently set service certificate of a Session.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Added installation instructions, troubleshooting steps, a minimal example, and a list of features to the README.
|
- All f-string formatting in log statements have been replaced with logging formatting to save performance when that
|
||||||
- The minimum version for lxml has been upped to >=4.9.1. This is due to some vulnerabilities present in all older
|
log wouldn't have been printed.
|
||||||
versions.
|
- The Serve APIs `/open` endpoint's function has been renamed from `open()` to `open_()` to prevent shadowing the
|
||||||
- All f-string formatting in log statements have been replaced with logging formatting to improve performance when
|
built-in `open`.
|
||||||
logging is disabled.
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Updated `lxml` dependency to `>=4.9.1` due to the Security Advisory [GHSA-wrxv-2j5q-m38w].
|
||||||
|
|
||||||
|
[GHSA-wrxv-2j5q-m38w]: <https://github.com/advisories/GHSA-wrxv-2j5q-m38w>
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
|
|
||||||
- The Protocol image has been removed from the README as it is too broad to Browser scenarios and some stuff on it
|
- The Protocol image has been removed from the README as it is too broad to Browser scenarios and some stuff on it
|
||||||
is too broad. If the viewer is really interested they can Google it to get a much better view into the Protocol.
|
is too broad. If the viewer is really interested they can Google it to get a much better view into the Protocol.
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Serve's get_license_challenge can now disable privacy mode even if a service certificate is set, as long as privacy
|
|
||||||
mode is not enforced in settings.
|
|
||||||
|
|
||||||
## [1.4.2] - 2022-09-05
|
## [1.4.2] - 2022-09-05
|
||||||
|
|
||||||
|
- Supported Serve API: `v1.4.0` to `v1.4.2`
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Device's constructor no longer throws `ValueError` exceptions if it fails to parse the provided Client ID or it's
|
- Sessions in `Cdm.open()` are now initialized with a unique session number.
|
||||||
VMP data if any. It will now raise a `DecodeError`.
|
- Android Cdm Devices now use a Request ID formula similar to OEMCrypto library when generating a Challenge.
|
||||||
|
This formula has yet to be fully confirmed and ironed out, but it is closer than the Chrome Cdm formula.
|
||||||
|
- `Device` no longer throws `ValueError` exceptions on `DecodeErrors` if it fails to parse the provided Client ID, or
|
||||||
|
it's VMP data if any. It will now re-raise `DecodeError`.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Android Cdm Devices now use a Request ID formula similar to OEMCrypto library when generating a Challenge.
|
- Parsed Proto Messages now go through an elaborate yet efficient verification, it must parse and serialize back to it's
|
||||||
This formula has yet to be fully confirmed and ironed out, but it is better than the Chrome Cdm formula.
|
received form, byte-for-byte, or it will be rejected.
|
||||||
- Various Proto Message Parsing now has full verification and expects the parsed response to be the same length
|
- This prevents protobuf from parsing a message that could be a different message depending on the starting bytes.
|
||||||
as the serialized input, or it will throw an error. For example, this prevents vague errors to happen when you
|
- It was possible to bypass some minor checks by providing specially crafted messages that parsed as other messages.
|
||||||
provide a bad License to `Cdm.parse_license`. It also prevents possibilities of it going past various other checks
|
However, I haven't noticed any way where this would lead to a vulnerability or anything bad. It mostly just lead to
|
||||||
depending on the first few bytes provided.
|
Serve API crashes or just rejected messages down the chain as they wouldn't have the right data within them.
|
||||||
|
|
||||||
## [1.4.1] - 2022-08-17
|
## [1.4.1] - 2022-08-17
|
||||||
|
|
||||||
Small patch release for some fixes to the PSSH classes recent face-lift.
|
- Supported Serve API: `v1.4.0` to `v1.4.2`
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- `PSSH.overwrite_key_ids` static method is now an instance method named `set_key_ids` and works on the current
|
- Rework `PSSH.overwrite_key_ids()` as an instance method now named `PSSH.set_key_ids()`.
|
||||||
instance instead of making and returning a new one.
|
- Rework `PSSH.get_key_ids()` as a property method named `PSSH.key_ids`. This allows swift access to all the Key IDs of
|
||||||
- `PSSH.get_key_ids` static method is now a property method named `key_ids`. This allows swift access to all the
|
the current PSSH object data.
|
||||||
Key IDs of the current access.
|
- Rework `PSSH.from_playready_pssh()` as an instance method now named `PSSH.playready_to_widevine()` that now converts
|
||||||
- `PSSH.from_playready_pssh` class method is now an instance method named `playready_to_widevine` and now converts
|
the current instances values directly. This allows you to more easily instance as any PSSH, then convert after wards
|
||||||
the current instances values directly. This allows you to more easily instance as any PSSH, then convert afterwards.
|
and only if wanted and when needed.
|
||||||
|
|
||||||
## [1.4.0] - 2022-08-06
|
## [1.4.0] - 2022-08-06
|
||||||
|
|
||||||
This release is a face-lift for the PSSH class with a moderate amount of Cdm and Serve interface changes.
|
- Supported Serve API: `v1.4.0` to `v1.4.2`
|
||||||
You will likely need to make a moderate amount of changes in your client code, please study the changelog.
|
|
||||||
|
|
||||||
Please note that while it was always privatized as `_sessions`, accessing the Session directly for any purpose was
|
|
||||||
never recommended or supported. With v1.4.0, there will be drastic problems if you continue to do so. One of the
|
|
||||||
few reasons to do that was to get the license keys which is no longer required with CDMs new `get_keys()` method.
|
|
||||||
|
|
||||||
RemoteCdm minimum supported Serve API version is now v1.4.0.
|
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- The PSSH class now has a `new()` method to craft a new PSSH box. The box can be crafted from arbitrary init_data
|
- New PSSH boxes can now be manually crafted with `PSSH.new()`.
|
||||||
and/or key_ids. If only key_ids is supplied a new Widevine Cenc Header will be created and the key IDs will be put
|
- The box can be crafted from arbitrary init_data and/or key_ids.
|
||||||
into it. This allows you to make compliant v0 or v1 boxes with as little data as just a Key ID.
|
- If only key_ids is supplied a new Widevine CENC Header will be created and the key IDs will be put into it.
|
||||||
- The PSSH class now has `dump()` and `dumps()` methods to serialize the data as binary or base64 respectively. It will
|
- This allows you to make compliant v0 or v1 boxes with as little data as just a Key ID.
|
||||||
be serialized as a pymp4 PSSH box, ready to be used in an MP4 file.
|
- PSSH boxes can now be exported as MP4 Box objects using pymp4 with `PSSH.dump()`.
|
||||||
- Cdm now has a method `get_keys()` to get the keys of the loaded license. This is the alternative to manually
|
- PSSH boxes can now also be exported as Base64 strings with `PSSH.dumps()`.
|
||||||
accessing the keys by navigating the `_sessions` class instance variable.
|
- License Keys can now be obtained from a Cdm session with a parsed license using `Cdm.get_keys()`.
|
||||||
- Serve API now also has a `/get_keys` endpoint to call the `get_keys()` method of the underlying Cdm session.
|
- This is the alternative to manually accessing the keys from the `Cdm._sessions` object.
|
||||||
|
- It is also available on the Serve API through the new `/get_keys` endpoint.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Cdm and RemoteCdm now expect a PSSH object as the `init_data` param for `get_license_challenge`. You can no longer
|
- `PSSH.get_as_box()` has been merged into the PSSH constructor, simplifying usage of the PSSH class.
|
||||||
provide it anything else, that includes base64 or bytes form. It must be a PSSH object.
|
- `PSSH.from_playready_pssh()` is now a class method and returns as a PSSH object.
|
||||||
- Serve no longer returns license keys in the response of the `/keys` endpoint.
|
- Only PSSH objects are now accepted by `Cdm.get_license_challenge()`.
|
||||||
- Serve has changed the endpoint `/challenge` to `/get_license_challenge` and `/keys` to `/parse_license`. This is to
|
- You can no longer provide it anything else, that includes base64 or bytes form.
|
||||||
be consistent with the method names of the underlying Cdm class.
|
- You should first parse or make a new PSSH with the PSSH class, and then pass that object.
|
||||||
- The PSSH class has been reworked from being a static helper class to a proper PSSH class.
|
- This is to simplify typing and repetition across the codebase.
|
||||||
- PSSH.from_playready_pssh is now a class method and returns as a PSSH object.
|
- Serve's `/challenge` endpoint has been changed to `/get_license_challenge`, and `/keys` to `/parse_license`.
|
||||||
|
- This is to be consistent with the method names of the underlying Cdm class.
|
||||||
|
- Serve now passes the license type value as-is (as a string) instead of parsing it to an integer.
|
||||||
|
- Serve now passes the key type value as-is (as a string) instead of parsing it to an integer.
|
||||||
|
- Serve no longer returns license keys in the response of the `/parse_license` endpoint.
|
||||||
|
- Once parsed, the `/get_keys` endpoint should be used to retrieve keys.
|
||||||
|
- Privatized the `Cdm._sessions` class instance variable even more to `Cdm.__sessions`.
|
||||||
|
- If you still need something from it, while not advised, you can call it via `cdm._Cdm__sessions`.
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
|
|
||||||
- PSSH.get_as_box has been removed and merged into the PSSH constructor.
|
- `PSSH.from_key_ids()` has been removed entirely, you should now use `PSSH.new(key_ids=...)` instead.
|
||||||
- PSSH.from_key_ids has been removed entirely, you should now use `PSSH.new(key_ids=...)` instead.
|
- Unnecessary parsing of the license message received by RemoteCdm is now skipped. Parsing should be done by the Serve
|
||||||
- All uses of a local Session() object has been removed from RemoteCdm. The session is now fully controlled by the
|
API as it will be able to actually decrypt and verify the message.
|
||||||
|
- All uses of a local `Session` object has been removed from `RemoteCdm`. The session is now fully controlled by the
|
||||||
remote API and de-synchronization by external alteration or unexpected exceptions is no longer a possibility.
|
remote API and de-synchronization by external alteration or unexpected exceptions is no longer a possibility.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Various uses of the `key_ids` field of WidevinePsshData proto has been fixed in the PSSH class.
|
- Correct the WidevinePsshData proto field name from `key_id` to `key_ids` in the PSSH class.
|
||||||
- Fixed a few Serve API crashes in edge cases with improved error handling on Cdm method calls.
|
- Handle `DecodeError` and `SignatureMismatch` exceptions in the Serve `/set_service_certificate` endpoint.
|
||||||
|
- Handle `InvalidInitData` and `InvalidLicenseType` exceptions in the Serve `/get_license_challenge` endpoint.
|
||||||
|
- Handle various exceptions in the Serve `/parse_license` endpoint.
|
||||||
|
- Handle various client-side runtime errors in `RemoteCdm` with improved error handling.
|
||||||
|
|
||||||
## [1.3.1] - 2022-08-04
|
## [1.3.1] - 2022-08-04
|
||||||
|
|
||||||
|
- Supported Serve API: `v1.3.0` to `v1.3.1`
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Cdm and RemoteCdm can now be supplied a string value for `device_type` for scenarios where providing it as a string
|
- String value support to the `device_type` parameter in `Cdm`s constructor.
|
||||||
is more convenient (e.g., from Config files).
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Serve no longer requires `force_privacy_mode` to be defined in the config file. It now assumes a default of false.
|
||||||
|
- Serve now uses `pywidevine serve ...` instead of the full project url in the Server header.
|
||||||
|
- `RemoteCdm`s Server version check is now case-insensitive.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- The `force_privacy_mode` key no longer needs to be defined at all in the configuration file. This was previously
|
- `RemoteCdm`s Server version check now ignores other Server/Proxy names prepended or appended to the Server header.
|
||||||
crashing serve APIs if it wasn't set before starting.
|
- For example, if reverse-proxied through Caddy it may have prepended "Caddy" to the Server header.
|
||||||
- RemoteCdm's Server version check will no longer fail under certain serving conditions e.g., Caddy prepending `Caddy`
|
|
||||||
to the Server header value. It also fixes case sensitivity and removed the full url from the header.
|
|
||||||
|
|
||||||
## [1.3.0] - 2022-08-04
|
## [1.3.0] - 2022-08-04
|
||||||
|
|
||||||
|
- Supported Serve API: `v1.3.0` to `v1.3.1`
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- New RemoteCdm class to be used as Client code for the `serve` Remote CDM API server. The RemoteCdm should be used
|
- New Client for using the Serve API; `RemoteCdm` class. It has an identical interface as the original `Cdm` class.
|
||||||
entirely separately from the normal Cdm class. All serve APIs must update to v1.3.0 to be compatible. The RemoteCdm
|
- However, the constructor is different. Instead of passing a Widevine device object, you need to pass information
|
||||||
verifies the server version to ensure compatibility. Changes to the serve API schema will be immediately reflected in
|
about the API like its host (including port if not on a reverse-proxy), and info about the device like its name and
|
||||||
the RemoteCdm code in the future.
|
security level.
|
||||||
- Implemented `/set_service_certificate` endpoint in serve schema as an improved way of setting the service certificate
|
- Other than that, once the RemoteCdm object is created, you use it exactly the same. Magic!
|
||||||
than passing it to `/challenge`.
|
- Any time there's a change or fix to `Cdm` in this update or any in the future, will also be done to RemoteCdm.
|
||||||
- You can now unset the service certificate by providing an empty service certificate value (or None or null). This
|
- New Serve endpoint `/set_service_certificate` as an improved way of setting (or unsetting) the service certificate.
|
||||||
includes support for doing so even in serve API and the new RemoteCdm.
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- The Construction of the Cdm object has changed. You can now initialize it with more direct values if you don't want
|
- `Cdm`s constructor now uses more direct values, so you don't have to use the Device class or `.wvd` files.
|
||||||
to use the Device class or don't want to use `.wvd` files. To use Device classes, you must now use the
|
- To continue using `.wvd` files you must now use `Cdm.from_device()` instead.
|
||||||
`Cdm.from_device()` class method.
|
- You can now unset the Service certificate by providing `None` to `Cdm.set_service_certificate().
|
||||||
- The ability to pass the certificate to `/challenge` has been removed. Please use the new `/set_service_certificate`
|
|
||||||
endpoint before calling `/challenge`. You do not need to set it every time. Once per session is enough unless you
|
### Removed
|
||||||
now want to use a different certificate.
|
|
||||||
|
- Serve's `/challenge` endpoint no longer accepts a `service_certificate` item in the JSON payload.
|
||||||
|
- Instead, use the new `/set_service_certificate` endpoint before calling `/challenge`.
|
||||||
|
- You do not need to set it every time. Once per session is enough unless you now want to use a different certificate.
|
||||||
|
|
||||||
## [1.2.1] - 2022-08-02
|
## [1.2.1] - 2022-08-02
|
||||||
|
|
||||||
This release is primarily a maintenance release for `serve` functionality but some Cdm fixes are also present.
|
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- You can now return all License Keys from Serve's `/keys` endpoint by supplying `ALL` as the key type.
|
- Support `SignedDrmCertificate` and `SignedMessages` messages in `Cdm.encrypt_client_id()`. This is mainly as a
|
||||||
This adds support for Exchange Systems like Netflix's WidevineExchange MSL scheme. I recommend using `ALL` unless
|
convenience for any scripts wanting to encrypt their Client ID with a service certificate manually.
|
||||||
you only want `CONTENT` keys and will not be using any other type of keys including `SIGNING` and `OPERATOR_SESSION`.
|
- All License Keys from Serve's `/keys` endpoint can now be received by providing `ALL` as the key type.
|
||||||
- Serve now has a `/close` endpoint to close a session. The Cdm has a limit of 50 sessions per user.
|
- This adds support for systems needing more than two types of keys from the license, e.g., Netflix MSL.
|
||||||
- Serve now responds with a `Server` header denoting that pywidevine serve is being used, also specifying the version.
|
- For faster response times it is best to still ask for only `CONTENT` keys if that's all you need.
|
||||||
This allows Clients to selectively support APIs based on version, and also verify the API as being supported at all.
|
- Serve now has a `/close` endpoint to close a session. All clients should close the session once they are finished
|
||||||
- Serve now verifies that all Devices in config actually exist before letting you start serving.
|
with it or the user will eventually hit a limit of 50 sessions per user and the server will hog memory til it
|
||||||
|
restarts.
|
||||||
|
- Serve now verifies that all Devices in config actually exist before starting the server.
|
||||||
|
- Serve now responds with a `Server` header denoting that pywidevine serve is being used, and it's version.
|
||||||
|
- This allows Clients to selectively support APIs based on version; verify the API as being supported.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Downgraded lxml to >=4.8.0 to support projects using pycaption, which is likely considering the project's topic.
|
- Lessened version pin on `lxml` from `^4.9.1` to `>=4.8.0` to support projects using pycaption.
|
||||||
|
- Service Certificate is now saved in the session as a `SignedMessage` with a `SignedDrmCertificate` instead of the raw
|
||||||
|
`DrmCertificate`. The `SignedMessage` is unsigned as the `SignedDrmCertificate` within it, is signed. This is so
|
||||||
|
anything inheriting or using the Cdm (e.g., `serve`) can verify the certificate down the chain and keep it signed.
|
||||||
|
- Serve now constructs one Cdm object for each user+device combination so one user cannot fill or overuse the CDM
|
||||||
|
session limit.
|
||||||
- All of Serve's endpoints now have a `/{device}` prefix. E.g., instead of `/challenge/STREAMING`, it's now
|
- All of Serve's endpoints now have a `/{device}` prefix. E.g., instead of `/challenge/STREAMING`, it's now
|
||||||
`/device_name/challenge/STREAMING`. This is to support a multi-device per-user Cdm setup, see Fixed below regarding
|
`/device_name/challenge/STREAMING`. This is to support the previous change.
|
||||||
Serve's Cdm objects.
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Fixed support for Raw PSSH values, e.g., Netflix's WidevineExchange MSL Scheme arbitrary init_data value.
|
- Handle server crash when the session limit is reached in Serve's `/open` endpoint by returning a 400 error.
|
||||||
- The Service Certificate is now saved to the Session in full SignedMessage form instead of just the underlying
|
- Serve now correctly updates (or rather now makes a new Cdm object) if a user switches from one Device to another.
|
||||||
DrmCertificate. This is so any class inheriting the Cdm (e.g., for Remote capabilities) can sufficiently use
|
- Previously it would reuse an existing Cdm object, but would forget to switch device if they changed.
|
||||||
and supply the service certificate while being signed.
|
- Note: It does still leave the previous Cdm with the older Device in memory.
|
||||||
- Serve's /open endpoint will now return a 400 error if there's too many sessions opened.
|
- Handle IOError when parsing bytes as MP4 Box to allow arbitrary data to be made as new boxes in `PSSH.get_as_box()`.
|
||||||
- Serve's Cdm objects with Device initialized are now stored per-user and device name. This fixes the issue where the
|
|
||||||
entire user base has only 50 sessions available to be used. Effectively rate limiting to only 50 users at a time.
|
|
||||||
Since /close endpoint was not implemented yet, there was no way to even close effectively meaning only 50 uses could
|
|
||||||
be done.
|
|
||||||
|
|
||||||
## [1.2.0] - 2022-07-30
|
## [1.2.0] - 2022-07-30
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- New CLI command `serve` to serve local WVD devices and CDM sessions remotely as a JSON API.
|
- New CLI command `serve` that hosts a CDM API that can be externally accessed with authentication. This can be used to
|
||||||
- The CLI command `migrate` can now accept a folder path to batch migrate WVD files.
|
access and/or share your CDM without exposing your Widevine device private key, or even it's identity by enforcing
|
||||||
- The Cdm now uses custom exceptions where the use case is justified. All custom exceptions are under a parent custom
|
Privacy Mode.
|
||||||
exception to allow catching of any Pywidevine exception.
|
- Requires installing with the `serve` extras, i.e., `pip install pywidevine[serve]`.
|
||||||
|
- The default host of `127.0.0.1` blocks access outside your network, even if port-forwarded. Use
|
||||||
|
`-h 0.0.0.0` to allow remote access.
|
||||||
|
- Setup requires the use of a config file for configuring the CDM and authentication. An example config file named
|
||||||
|
`serve.example.yml` in the project root folder has verbose documentation on available options.
|
||||||
|
- Batch migration of WVD files by passing a folder as the path to the CLI command `migrate`.
|
||||||
|
- Strict mode to `PSSH.get_as_box()` to raise an Exception if passed data is not already a box, as it has been improved
|
||||||
|
to create a new box if not detected as a box already.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- The Cdm has been reworked as a session-based Cdm. You now initialize the Cdm with just the device you wish to use,
|
- Elevated the Development Status Classifier from 4 (Beta) to 5 (Production/Stable).
|
||||||
and now you open sessions with `Cdm.open()` to get a session ID. For usage example see `license` CLI command in
|
- License messages passed to `Cdm.parse_license()` are now rejected if they are not of `LICENSE` type.
|
||||||
`main.py`.
|
- Service Certificates passed to `Cdm.set_service_certificate()` are now verified. This patches a trivial "exploit"
|
||||||
- The Cdm no longer requires you to specify `raw` bool parameter. It now supports arbitrary and valid Widevine Cenc
|
that allows an attacker to recover the plaintext Client ID from a license under Privacy Mode. See
|
||||||
Header Data without needing to explicitly specify which it is.
|
<https://gist.github.com/rlaphoenix/74acabdd7269a21845e18b621c5860ef>.
|
||||||
- The Cdm `pssh` param has been renamed as `init_data`. Doc-strings have been changed to prioritize explanation of it
|
- Data passed to `PSSH.get_as_box()` now supports arbitrary and box data automatically as it tries to detect if it is a
|
||||||
referring to Widevine Cenc Header rather than PSSH Boxes. This is to show that the Cdm more-so wants Init Data than
|
valid box, otherwise makes a new box.
|
||||||
a PSSH box. The full PSSH is never kept nor ever used, only it's init data is. It still supports PSSH box data.
|
- Renamed the `Cdm` constructor's parameter `pssh` to `init_data`, as that's what the Cdm actually wants and uses,
|
||||||
- Cdm `set_service_certificate()` now returns the provider ID string rather than the underlying (and now verified)
|
whereas a `PSSH` is an `mp4` atom (aka box) containing `init_data` (a Widevine CENC Header). The full PSSH is never
|
||||||
DrmCertificate. This is because the DrmCertificate is not likely useful and would still be possible to obtain in full
|
kept nor ever used. It still accepts PSSH box data.
|
||||||
but quick access to the Provider ID may be more useful.
|
- Service Certificate's Provider ID is now returned by `Cdm.set_service_certificate()` instead of the passed
|
||||||
- License responses can now be only be parsed once by `Cdm.parse_license()`. Any further attempts will raise an
|
certificate, of which they would already have.
|
||||||
InvalidContext exception. This is because context data is now cleared for it's respective License Request once it's
|
- The Cdm class now works more closely to the official CDM model. Instead of using one Cdm object per-request having to
|
||||||
parsed to reduce data lingering in memory.
|
provide device information each time,
|
||||||
- Trove Classifier for Development Status is now 5 (Production/Stable).
|
- You now initialize the Cdm with the Widevine device you wish to use and then open sessions with `Cdm.open()`.
|
||||||
|
- You will receive a session ID that are then passed to other methods of the same Cdm object.
|
||||||
|
- The PSSH/init_data that used to be passed to the constructor is now passed to `Cdm.get_license_challenge()`.
|
||||||
|
- This allows initializing one Cdm object with up to 50 sessions open at the same time.
|
||||||
|
Session limits seem to fluctuate between libraries and devices. 50 seems like a permissive value.
|
||||||
|
- Once you are finished with DRM operations, discard all session (and key) data by calling `Cdm.close(session_id)`.
|
||||||
|
- License Keys are no longer returned by `Cdm.parse_license()` and now must be obtained directly from `cdm._sessions`.
|
||||||
|
- For example, `for key in cdm._sessions[session_id].keys: print(f"[{key.type}] {key.kid.hex}:{key.key.hex()}")`.
|
||||||
|
- This is to detach the action of parsing a license as just for getting keys, as it isn't. It can be and should be
|
||||||
|
used for a lot more data like security requirements like HDCP, expiration, and more.
|
||||||
|
- It is also to detour users from directly using the keys over the `Cdm.decrypt()` method.
|
||||||
|
- Various std-lib exceptions have been replaced with custom exceptions under `pywidevine.exceptions`.
|
||||||
|
- License responses can now only be parsed once by `Cdm.parse_license()`. Any further attempts will raise an
|
||||||
|
`InvalidContext` exception.
|
||||||
|
- This is as license context data is cleared once used to reduce data lingering in memory, otherwise the more license
|
||||||
|
requests you make without closing the session, the more and more memory is taken up.
|
||||||
|
- Open multiple sessions in the same Cdm object if you need to request and parse multiple licenses on the same device.
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
|
|
||||||
- You can no longer provide a direct `DrmCertificate` to `Cdm.set_service_certificate()` for security reasons.
|
- Direct `DrmCertificate`s are no longer supported by `Cdm.set_service_certificate()` as they have no signature.
|
||||||
You must provide either a `SignedDrmCertificate` or a `SignedMessage` containing a `SignedDrmCertificate`.
|
See the 3rd Change above. Provide either a `SignedDrmCertificate` or a `SignedMessage` containing a
|
||||||
- PSSH `from_init_data()` has been removed. It was unused and is unnecessary with improvements to `get_as_box()`.
|
`SignedDrmCertificate`. A `SignedMessage` containing a `DrmCertificate` will also be rejected.
|
||||||
|
- `PSSH.from_init_data()`, use `PSSH.get_as_box()`.
|
||||||
|
- `raw` parameter of `Cdm` constructor, as well as CLI commands as it is now handled upstream by the `PSSH` creation.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Cdm `set_service_certificate()` now verifies the signature of the provided Certificate. This patches a trivial
|
- Detection of Widevine CENC Header data encoded as bytes in `PSSH.get_as_box()`.
|
||||||
exploit/workaround that allows an attacker to recover the plaintext Client ID from an encrypted Client ID.
|
- Custom ValueError on missing contexts instead of the generic KeyError in `Cdm.parse_license()`.
|
||||||
- Cdm `parse_license()` now verifies the input message type as a `LICENSE` message.
|
- Typing of `type_` parameter in `Cdm.get_license_challenge()`.
|
||||||
- Cdm `parse_license()` now clears context for the License Request once it's License Response message has been parsed.
|
- Value of `type_` parameter if is a string in `Cdm.get_license_challenge()`.
|
||||||
This reduces data lingering in the `context` dictionary when it may only be needed once.
|
|
||||||
- The Context Availability error handler in Cdm `parse_license()` has been fixed.
|
|
||||||
- Typing of `type_` param of `Cdm.get_license_challenge()` has been fixed.
|
|
||||||
|
|
||||||
## [1.1.1] - 2022-07-22
|
## [1.1.1] - 2022-07-22
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- The --vmp argument of the create-device command is now optional.
|
- The `-v/--vmp` parameter of the `test` CLI command is now optional.
|
||||||
|
|
||||||
## [1.1.0] - 2022-07-21
|
## [1.1.0] - 2022-07-21
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added support for setting a Service Certificate in SignedDrmCertificate form as well as raw DrmCertificate form.
|
- WVD (Widevine Device file) Version 2 bringing reduced file sizes by up to 30%~.
|
||||||
However, It's unlikely for the service to provide the certificate in raw DrmCertificate form without a signature.
|
- New CLI command `create-device` to create `.wvd` files (Widevine Device files) from RSA PEM/DER Private Keys and
|
||||||
- Added a CLI command `create-device` to create Widevine Device (`.wvd`) files from RSA PEM/DER Private Keys and
|
|
||||||
Client ID blobs. You can also provide VMP (FileHashes) data which will be merged into the Client ID blob.
|
Client ID blobs. You can also provide VMP (FileHashes) data which will be merged into the Client ID blob.
|
||||||
- Added a CLI command `migrate` that uses `Device.migrate()` and `dump()` to migrate older v1 Widevine Device files
|
- New CLI command `migrate` that uses `Device.migrate()` and `dump()` to migrate older v1 Widevine Device files to v2.
|
||||||
to v2.
|
- New `Device` method `migrate()` to load an older Widevine Device file format. It is recommended to then use the
|
||||||
- Added the v1 Structure of Widevine Devices for migration use.
|
`dumps()` method to save it as a new v2 Widevine Device file, which can then be loaded normally.
|
||||||
- Added `Device.migrate()` class method that effectively loads older format WVD data. You can then use `dumps()` to
|
- Support `SignedDrmCertificate` and `DrmCertificate` messages in `Cdm.set_service_certificate()`. Services can provide
|
||||||
get back the WVD data in the latest supported format.
|
the certificate as a `SignedMessage`, `SignedDrmCertificate`, or a `DrmCertificate`. Only `SignedMessage` and
|
||||||
- Added ability to use Privacy mode on the test command.
|
`SignedDrmCertificate` are signed.
|
||||||
|
- Privacy Mode can now be used in the `test` CLI command with the `-p/--privacy` flag.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Set Service Certificates are now stored as the raw underlying DrmCertificate as the signature data is unused by
|
- Moved all `.wvd` Widevine Device file structures from `Device` to a `_Structures` class in `device.py`. The
|
||||||
the CDM.
|
`_Structures` class can be imported and used directly, or via `Device.structures`.
|
||||||
- Moved all Widevine Device structures under a Structures class.
|
- Moved the majority of Widevine Device file migration code from the CLI command `migrate` to `Device.migrate()`. The
|
||||||
- I removed the `send_key_control_nonce` flag from all Structures even though it was technically used.
|
CLI command `migrate` now internally uses `Device.migrate()`.
|
||||||
This is because the flag was never used as of this project, and I do not want to take up the flag slot.
|
- Set Service Certificates are now stored as `DrmCertificate`s instead of a `SignedMessage` as the signature and other
|
||||||
|
data in the message is unused and unneeded.
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Devices `dump()` function now uses the correct `type_` parameter when building the struct.
|
|
||||||
- Fixed release date year of v1.0.0 and v1.0.1 in the changelog.
|
|
||||||
|
|
||||||
## [1.0.1] - 2022-07-21
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- More information to the PyPI meta information, e.g., classifiers, readme, some URLs.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Moved the License Type parameter from the Cdm constructor to `get_license_challenge()`.
|
|
||||||
- The Session ID is no longer used as the Request ID which could help with blocks or replay checks due
|
|
||||||
to it being the same Session ID for each request. It's now a random 16 byte value each time.
|
|
||||||
- Only the Context Data of each license request is now stored instead of the full message.
|
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
|
|
||||||
- Removed unnecessary and unused `raw` Cdm class instance variable.
|
- Unused Widevine Device file flag `send_key_control_nonce` from v1 and v2 Structures as it was only used before initial
|
||||||
|
release, and isn't a necessary nor useful flag.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- CDMs `set_service_certificate()` now correctly raises a DecodeError on Decode Error instead of a ValueError.
|
- Correct the type argument name from `type` to `type_` in `Device.dump()`.
|
||||||
- Context Data will now always match to their corresponding License Responses. This fixes an issue where creating
|
|
||||||
a second challenge would overwrite the context data of the first challenge. Parsing the first challenge after
|
### Security
|
||||||
would result in either a key decrypt error, or garbage key data.
|
|
||||||
|
- Even though support for more kinds of Service Certificate Signatures were added, they are still unverified as the
|
||||||
|
signing public key is Unknown.
|
||||||
|
|
||||||
|
## [1.0.1] - 2022-07-21
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Moved the License Type parameter from the `Cdm` constructor to it's `get_license_challenge()` method.
|
||||||
|
- Every License request now uses a unique random value instead of the CDM Session ID.
|
||||||
|
- Only the Context Data of License requests are now stored in the Session instead of the full message.
|
||||||
|
- Session ID formula now uses a random 16-byte value for both Chrome and Android provisions.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- Unused and unnecessary `Cdm.raw` class instance variable.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Re-raise DecodeErrors instead of a new ValueError on DecodeErrors in `Cdm.set_service_certificate()`.
|
||||||
|
- Creating a new License request no longer overwrites the context data of the previous challenge.
|
||||||
|
|
||||||
## [1.0.0] - 2022-07-20
|
## [1.0.0] - 2022-07-20
|
||||||
|
|
||||||
Initial Release.
|
Initial Release.
|
||||||
|
|
||||||
[1.5.1]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.5.1
|
### Security
|
||||||
[1.5.0]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.5.0
|
|
||||||
[1.4.4]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.4.4
|
- Service Certificate Signatures are unverified as the signing public key is Unknown.
|
||||||
[1.4.3]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.4.3
|
|
||||||
[1.4.2]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.4.2
|
[1.8.0]: https://github.com/devine-dl/pywidevine/releases/tag/v1.8.0
|
||||||
[1.4.1]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.4.1
|
[1.7.0]: https://github.com/devine-dl/pywidevine/releases/tag/v1.7.0
|
||||||
[1.4.0]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.4.0
|
[1.6.0]: https://github.com/devine-dl/pywidevine/releases/tag/v1.6.0
|
||||||
[1.3.1]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.3.1
|
[1.5.3]: https://github.com/devine-dl/pywidevine/releases/tag/v1.5.3
|
||||||
[1.3.0]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.3.0
|
[1.5.2]: https://github.com/devine-dl/pywidevine/releases/tag/v1.5.2
|
||||||
[1.2.1]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.2.1
|
[1.5.1]: https://github.com/devine-dl/pywidevine/releases/tag/v1.5.1
|
||||||
[1.2.0]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.2.0
|
[1.5.0]: https://github.com/devine-dl/pywidevine/releases/tag/v1.5.0
|
||||||
[1.1.1]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.1.1
|
[1.4.4]: https://github.com/devine-dl/pywidevine/releases/tag/v1.4.4
|
||||||
[1.1.0]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.1.0
|
[1.4.3]: https://github.com/devine-dl/pywidevine/releases/tag/v1.4.3
|
||||||
[1.0.1]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.0.1
|
[1.4.2]: https://github.com/devine-dl/pywidevine/releases/tag/v1.4.2
|
||||||
[1.0.0]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.0.0
|
[1.4.1]: https://github.com/devine-dl/pywidevine/releases/tag/v1.4.1
|
||||||
|
[1.4.0]: https://github.com/devine-dl/pywidevine/releases/tag/v1.4.0
|
||||||
|
[1.3.1]: https://github.com/devine-dl/pywidevine/releases/tag/v1.3.1
|
||||||
|
[1.3.0]: https://github.com/devine-dl/pywidevine/releases/tag/v1.3.0
|
||||||
|
[1.2.1]: https://github.com/devine-dl/pywidevine/releases/tag/v1.2.1
|
||||||
|
[1.2.0]: https://github.com/devine-dl/pywidevine/releases/tag/v1.2.0
|
||||||
|
[1.1.1]: https://github.com/devine-dl/pywidevine/releases/tag/v1.1.1
|
||||||
|
[1.1.0]: https://github.com/devine-dl/pywidevine/releases/tag/v1.1.0
|
||||||
|
[1.0.1]: https://github.com/devine-dl/pywidevine/releases/tag/v1.0.1
|
||||||
|
[1.0.0]: https://github.com/devine-dl/pywidevine/releases/tag/v1.0.0
|
||||||
|
|||||||
49
CONTRIBUTING.md
Normal file
49
CONTRIBUTING.md
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
# Development
|
||||||
|
|
||||||
|
This project is managed using [Poetry](https://python-poetry.org), a fantastic Python packaging and dependency manager.
|
||||||
|
Install the latest version of Poetry before continuing. Development currently requires Python 3.8+.
|
||||||
|
|
||||||
|
## Set up
|
||||||
|
|
||||||
|
Starting from Zero? Not sure where to begin? Here's steps on setting up this Python project using Poetry. Note that
|
||||||
|
Poetry installation instructions should be followed from the Poetry Docs: https://python-poetry.org/docs/#installation
|
||||||
|
|
||||||
|
1. While optional, It's recommended to configure Poetry to install Virtual environments within project folders:
|
||||||
|
```shell
|
||||||
|
poetry config virtualenvs.in-project true
|
||||||
|
```
|
||||||
|
This makes it easier for Visual Studio Code to detect the Virtual Environment, as well as other IDEs and systems.
|
||||||
|
I've also had issues with Poetry creating duplicate Virtual environments in the default folder for an unknown
|
||||||
|
reason which quickly filled up my System storage.
|
||||||
|
2. Clone the Repository:
|
||||||
|
```shell
|
||||||
|
git clone https://github.com/devine-dl/pywidevine
|
||||||
|
cd pywidevine
|
||||||
|
```
|
||||||
|
3. Install the Project with Poetry:
|
||||||
|
```shell
|
||||||
|
poetry install
|
||||||
|
```
|
||||||
|
This creates a Virtual environment and then installs all project dependencies and executables into the Virtual
|
||||||
|
environment. Your System Python environment is not affected at all.
|
||||||
|
4. Now activate the Virtual environment:
|
||||||
|
```shell
|
||||||
|
poetry shell
|
||||||
|
```
|
||||||
|
Note:
|
||||||
|
- You can alternatively just prefix `poetry run` to any command you wish to run under the Virtual environment.
|
||||||
|
- I recommend entering the Virtual environment and all further instructions will have assumed you did.
|
||||||
|
- JetBrains PyCharm has integrated support for Poetry and automatically enters Poetry Virtual environments, assuming
|
||||||
|
the Python Interpreter on the bottom right is set up correctly.
|
||||||
|
- For more information, see: https://python-poetry.org/docs/basic-usage/#using-your-virtual-environment
|
||||||
|
5. Install Pre-commit tooling to ensure safe and quality commits:
|
||||||
|
```shell
|
||||||
|
pre-commit install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building Source and Wheel distributions
|
||||||
|
|
||||||
|
poetry build
|
||||||
|
|
||||||
|
You can optionally specify `-f` to build `sdist` or `wheel` only.
|
||||||
|
Built files can be found in the `/dist` directory.
|
||||||
173
README.md
173
README.md
@ -1,74 +1,65 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="docs/images/widevine_icon_24.png"> <a href="https://github.com/rlaphoenix/pywidevine">pywidevine</a>
|
<img src="docs/images/widevine_icon_24.png"> <a href="https://github.com/devine-dl/pywidevine">pywidevine</a>
|
||||||
<br/>
|
<br/>
|
||||||
<sup><em>Python Widevine CDM implementation.</em></sup>
|
<sup><em>Python Widevine CDM implementation</em></sup>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/rlaphoenix/pywidevine/actions/workflows/ci.yml">
|
<a href="https://github.com/devine-dl/pywidevine/blob/master/LICENSE">
|
||||||
<img src="https://github.com/rlaphoenix/pywidevine/actions/workflows/ci.yml/badge.svg" alt="Build status">
|
<img src="https://img.shields.io/:license-GPL%203.0-blue.svg" alt="License">
|
||||||
</a>
|
</a>
|
||||||
<a href="https://pypi.org/project/pywidevine">
|
<a href="https://pypi.org/project/pywidevine">
|
||||||
<img src="https://img.shields.io/badge/python-3.7%2B-informational" alt="Python version">
|
<img src="https://img.shields.io/badge/python-3.9%2B-informational" alt="Python version">
|
||||||
</a>
|
</a>
|
||||||
<a href="https://deepsource.io/gh/rlaphoenix/pywidevine">
|
<a href="https://github.com/astral-sh/uv">
|
||||||
<img src="https://deepsource.io/gh/rlaphoenix/pywidevine.svg/?label=active+issues" alt="DeepSource">
|
<img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/Onyx-Nostalgia/uv/refs/heads/fix/logo-badge/assets/badge/v0.json" alt="Manager: uv">
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/astral-sh/ruff">
|
||||||
|
<img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json" alt="Linter: Ruff">
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/devine-dl/pywidevine/actions/workflows/ci.yml">
|
||||||
|
<img src="https://github.com/devine-dl/pywidevine/actions/workflows/ci.yml/badge.svg" alt="Build status">
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- 🛡️ Security-first approach; All user input has Signatures verified
|
- 🚀 Seamless Installation via [pip](#installation)
|
||||||
- 👥 Remotely accessible Server/Client CDM code
|
- 🛡️ Robust Security with message signature verification
|
||||||
- 📦 Supports parsing and serialization of WVD (v2) provisions
|
- 🙈 Privacy Mode with Service Certificates
|
||||||
- 🛠️ Class for creation, parsing, and conversion of PSSH data
|
- 🌐 Servable CDM API Server and Client with Authentication
|
||||||
- 🧩 Plug-and-play installation via PIP/PyPI
|
- 📦 Custom provision serialization format (WVD v2)
|
||||||
- 🗃️ YAML configuration files
|
- 🧰 Create, parse, or convert PSSH headers with ease
|
||||||
|
- 🗃️ User-friendly YAML configuration
|
||||||
- ❤️ Forever FOSS!
|
- ❤️ Forever FOSS!
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
*Note: Requires [Python] 3.7.0 or newer with PIP installed.*
|
### With pip
|
||||||
|
|
||||||
```shell
|
> Since *pip* is pre-installed with Python, it is the most straight forward way to install pywidevine.
|
||||||
$ pip install pywidevine
|
|
||||||
```
|
|
||||||
|
|
||||||
You now have the `pywidevine` package installed and a `pywidevine` executable is now available.
|
Simply run `pip install pywidevine` and it will be ready to use from the CLI or within scripts in a minute.
|
||||||
Check it out with `pywidevine --help` - Voilà 🎉!
|
|
||||||
|
|
||||||
### From Source Code
|
### With uv
|
||||||
|
|
||||||
The following steps are instructions on download, preparing, and running the code under a Poetry environment.
|
> This is recommended for those who wish to install from the source code, are working on changes in the source code, or
|
||||||
You can skip steps 3-5 with a simple `pip install .` call instead, but you miss out on a wide array of benefits.
|
just simply prefer it's many handy features.
|
||||||
|
|
||||||
1. `git clone https://github.com/rlaphoenix/pywidevine`
|
Go to to the official website and [get uv installed](https://docs.astral.sh/uv/getting-started/installation/). Download
|
||||||
2. `cd pywidevine`
|
or clone this repository, go inside it, and run `uv run pywidevine --version`. To run scripts, like a `license.py` that
|
||||||
3. (optional) `poetry config virtualenvs.in-project true`
|
is importing pywidevine, do `uv run license.py`. Effectively, put `uv run` before calling whatever is using pywidevine.
|
||||||
4. `poetry install`
|
For other ways to run pywidevine with uv, see [Running commands](https://docs.astral.sh/uv/guides/projects/#running-commands).
|
||||||
5. `poetry run pywidevine --help`
|
|
||||||
|
|
||||||
As seen in Step 5, running the `pywidevine` executable is somewhat different to a normal PIP installation.
|
|
||||||
See [Poetry's Docs] on various ways of making calls under the virtual-environment.
|
|
||||||
|
|
||||||
[Python]: <https://python.org>
|
|
||||||
[Poetry]: <https://python-poetry.org>
|
|
||||||
[Poetry's Docs]: <https://python-poetry.org/docs/basic-usage/#using-your-virtual-environment>
|
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
The following is a minimal example of using pywidevine in a script. It gets a License for Bitmovin's
|
There are two ways to use pywidevine, through scripts, or the CLI (command-line interface).
|
||||||
Art of Motion Demo. There's various stuff not shown in this specific example like:
|
Most people would be using it through scripts due to complexities working with license server APIs.
|
||||||
|
|
||||||
- Privacy Mode
|
### Scripts
|
||||||
- Setting Service Certificates
|
|
||||||
- Remote CDMs and Serving
|
|
||||||
- Choosing a License Type to request
|
|
||||||
- Creating WVD files
|
|
||||||
- and much more!
|
|
||||||
|
|
||||||
Just take a look around the Cdm code to see what stuff does. Everything is documented quite well.
|
The following is a minimal example of using pywidevine in a script to get a License for Bitmovin's Art of Motion Demo.
|
||||||
There's also various functions in `main.py` that showcases a lot of features.
|
This demo can be found on [Bitmovin's DRM Stream Test demo page](https://bitmovin.com/demos/drm/).
|
||||||
|
|
||||||
```py
|
```py
|
||||||
from pywidevine.cdm import Cdm
|
from pywidevine.cdm import Cdm
|
||||||
@ -77,46 +68,93 @@ from pywidevine.pssh import PSSH
|
|||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
# prepare pssh
|
# prepare pssh (usually inside the MPD/M3U8, an API response, the player page, or inside the pssh mp4 box)
|
||||||
pssh = PSSH("AAAAW3Bzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAADsIARIQ62dqu8s0Xpa"
|
pssh = PSSH("AAAAW3Bzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAADsIARIQ62dqu8s0Xpa"
|
||||||
"7z2FmMPGj2hoNd2lkZXZpbmVfdGVzdCIQZmtqM2xqYVNkZmFsa3IzaioCSEQyAA==")
|
"7z2FmMPGj2hoNd2lkZXZpbmVfdGVzdCIQZmtqM2xqYVNkZmFsa3IzaioCSEQyAA==")
|
||||||
|
|
||||||
# load device
|
# load device from a WVD file (your provision)
|
||||||
device = Device.load("C:/Path/To/A/Provision.wvd")
|
device = Device.load("C:/Path/To/A/Provision.wvd")
|
||||||
|
|
||||||
# load cdm
|
# load cdm (creating a CDM instance using that device)
|
||||||
cdm = Cdm.from_device(device)
|
cdm = Cdm.from_device(device)
|
||||||
|
|
||||||
# open cdm session
|
# open cdm session (note that any one device should have a practical limit to amount of sessions open at any one time)
|
||||||
session_id = cdm.open()
|
session_id = cdm.open()
|
||||||
|
|
||||||
# get license challenge
|
# get license challenge (generate a license request message, signed using the device with the pssh)
|
||||||
challenge = cdm.get_license_challenge(session_id, pssh)
|
challenge = cdm.get_license_challenge(session_id, pssh)
|
||||||
|
|
||||||
# send license challenge (assuming a generic license server SDK with no API front)
|
# send license challenge to bitmovin's license server (which has no auth and asks simply for the license challenge as-is)
|
||||||
licence = requests.post("https://...", data=challenge)
|
# another license server may require authentication and ask for it as JSON or form data instead
|
||||||
|
# you may also be required to use privacy mode, where you use their service certificate when creating the challenge
|
||||||
|
licence = requests.post("https://cwip-shaka-proxy.appspot.com/no_auth", data=challenge)
|
||||||
licence.raise_for_status()
|
licence.raise_for_status()
|
||||||
|
|
||||||
# parse license challenge
|
# parse the license response message received from the license server API
|
||||||
cdm.parse_license(session_id, licence.content)
|
cdm.parse_license(session_id, licence.content)
|
||||||
|
|
||||||
# print keys
|
# print keys
|
||||||
for key in cdm.get_keys(session_id):
|
for key in cdm.get_keys(session_id):
|
||||||
print(f"[{key.type}] {key.kid.hex}:{key.key.hex()}")
|
print(f"[{key.type}] {key.kid.hex}:{key.key.hex()}")
|
||||||
|
|
||||||
# close session, disposes of session data
|
# finished, close the session, disposing of all keys and other related data
|
||||||
cdm.close(session_id)
|
cdm.close(session_id)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Troubleshooting
|
There are other features not shown in this small example like:
|
||||||
|
|
||||||
### Executable `pywidevine` was not found
|
- Privacy Mode
|
||||||
|
- Setting Service Certificates
|
||||||
|
- Remote CDMs and Serving
|
||||||
|
- Choosing a License Type
|
||||||
|
- Creating WVD files
|
||||||
|
- and much more!
|
||||||
|
|
||||||
Make sure the Python installation's Scripts directory is added to your Path Environment Variable.
|
> [!TIP]
|
||||||
|
> For examples, take a look at the methods available in the [Cdm class](/pywidevine/cdm.py) and read their doc-strings
|
||||||
|
> for further information.
|
||||||
|
|
||||||
If this happened under a Poetry environment, make sure you use the appropriate Poetry-specific way of calling
|
### Command-line Interface
|
||||||
the executable. You may make this executable available globally by adding the .venv's Scripts folder to your
|
|
||||||
Path Environment Variable.
|
The CLI can be useful to do simple license calls, migrate WVD files, and test provisions.
|
||||||
|
Take a look at `pywidevine --help` to see a list of commands available.
|
||||||
|
|
||||||
|
```plain
|
||||||
|
Usage: pywidevine [OPTIONS] COMMAND [ARGS]...
|
||||||
|
|
||||||
|
pywidevine—Python Widevine CDM implementation.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-v, --version Print version information.
|
||||||
|
-d, --debug Enable DEBUG level logs.
|
||||||
|
--help Show this message and exit.
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
create-device Create a Widevine Device (.wvd) file from an RSA Private...
|
||||||
|
export-device Export a Widevine Device (.wvd) file to an RSA Private...
|
||||||
|
license Make a License Request for PSSH to SERVER using DEVICE.
|
||||||
|
migrate Upgrade from earlier versions of the Widevine Device...
|
||||||
|
serve Serve your local CDM and Widevine Devices Remotely.
|
||||||
|
test Test the CDM code by getting Content Keys for Bitmovin's...
|
||||||
|
```
|
||||||
|
|
||||||
|
Every command has further help information, simply type `pywidevine <command> --help`.
|
||||||
|
For example, `pywidevine test --help`:
|
||||||
|
|
||||||
|
```plain
|
||||||
|
Usage: pywidevine test [OPTIONS] DEVICE
|
||||||
|
|
||||||
|
Test the CDM code by getting Content Keys for Bitmovin's Art of Motion
|
||||||
|
example. https://bitmovin.com/demos/drm
|
||||||
|
https://bitmovin-a.akamaihd.net/content/art-of-motion_drm/mpds/11331.mpd
|
||||||
|
|
||||||
|
The device argument is a Path to a Widevine Device (.wvd) file which
|
||||||
|
contains the device private key among other required information.
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-p, --privacy Use Privacy Mode, off by default.
|
||||||
|
--help Show this message and exit.
|
||||||
|
```
|
||||||
|
|
||||||
## Disclaimer
|
## Disclaimer
|
||||||
|
|
||||||
@ -159,11 +197,20 @@ been improving its security using math and obscurity for years. It's getting har
|
|||||||
versions only being beaten by Brute-force style methods. However, they have a huge team of very skilled workers, and
|
versions only being beaten by Brute-force style methods. However, they have a huge team of very skilled workers, and
|
||||||
making a CDM in C++ has immediate security benefits and a lot of methods to obscure and obfuscate the code.
|
making a CDM in C++ has immediate security benefits and a lot of methods to obscure and obfuscate the code.
|
||||||
|
|
||||||
## Credit
|
## Contributors
|
||||||
|
|
||||||
|
<a href="https://github.com/rlaphoenix"><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/17136956?v=4&h=25&w=25&fit=cover&mask=circle&maxage=7d" alt=""/></a>
|
||||||
|
<a href="https://github.com/mediaminister"><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/45148099?v=4&h=25&w=25&fit=cover&mask=circle&maxage=7d" alt=""/></a>
|
||||||
|
<a href="https://github.com/sr0lle"><img src="https://images.weserv.nl/?url=avatars.githubusercontent.com/u/111277375?v=4&h=25&w=25&fit=cover&mask=circle&maxage=7d" alt=""/></a>
|
||||||
|
|
||||||
|
## Licensing
|
||||||
|
|
||||||
|
This software is licensed under the terms of [GNU General Public License, Version 3.0](LICENSE).
|
||||||
|
You can find a copy of the license in the LICENSE file in the root folder.
|
||||||
|
|
||||||
- Widevine Icon © Google.
|
- Widevine Icon © Google.
|
||||||
- The awesome community for their shared research and insight into the Widevine Protocol and Key Derivation.
|
- Props to the awesome community for their shared research and insight into the Widevine Protocol and Key Derivation.
|
||||||
|
|
||||||
## License
|
* * *
|
||||||
|
|
||||||
[GNU General Public License, Version 3.0](LICENSE)
|
© rlaphoenix 2022-2025
|
||||||
|
|||||||
775
poetry.lock
generated
775
poetry.lock
generated
@ -1,775 +0,0 @@
|
|||||||
[[package]]
|
|
||||||
name = "aiohttp"
|
|
||||||
version = "3.8.1"
|
|
||||||
description = "Async http client/server framework (asyncio)"
|
|
||||||
category = "main"
|
|
||||||
optional = true
|
|
||||||
python-versions = ">=3.6"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
aiosignal = ">=1.1.2"
|
|
||||||
async-timeout = ">=4.0.0a3,<5.0"
|
|
||||||
asynctest = {version = "0.13.0", markers = "python_version < \"3.8\""}
|
|
||||||
attrs = ">=17.3.0"
|
|
||||||
charset-normalizer = ">=2.0,<3.0"
|
|
||||||
frozenlist = ">=1.1.1"
|
|
||||||
multidict = ">=4.5,<7.0"
|
|
||||||
typing-extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""}
|
|
||||||
yarl = ">=1.0,<2.0"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
speedups = ["Brotli", "aiodns", "cchardet"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "aiosignal"
|
|
||||||
version = "1.2.0"
|
|
||||||
description = "aiosignal: a list of registered asynchronous callbacks"
|
|
||||||
category = "main"
|
|
||||||
optional = true
|
|
||||||
python-versions = ">=3.6"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
frozenlist = ">=1.1.0"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "async-timeout"
|
|
||||||
version = "4.0.2"
|
|
||||||
description = "Timeout context manager for asyncio programs"
|
|
||||||
category = "main"
|
|
||||||
optional = true
|
|
||||||
python-versions = ">=3.6"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
typing-extensions = {version = ">=3.6.5", markers = "python_version < \"3.8\""}
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "asynctest"
|
|
||||||
version = "0.13.0"
|
|
||||||
description = "Enhance the standard unittest package with features for testing asyncio libraries"
|
|
||||||
category = "main"
|
|
||||||
optional = true
|
|
||||||
python-versions = ">=3.5"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "attrs"
|
|
||||||
version = "21.4.0"
|
|
||||||
description = "Classes Without Boilerplate"
|
|
||||||
category = "main"
|
|
||||||
optional = true
|
|
||||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
dev = ["cloudpickle", "coverage[toml] (>=5.0.2)", "furo", "hypothesis", "mypy", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "six", "sphinx", "sphinx-notfound-page", "zope.interface"]
|
|
||||||
docs = ["furo", "sphinx", "sphinx-notfound-page", "zope.interface"]
|
|
||||||
tests = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "six", "zope.interface"]
|
|
||||||
tests_no_zope = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "six"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "certifi"
|
|
||||||
version = "2022.6.15"
|
|
||||||
description = "Python package for providing Mozilla's CA Bundle."
|
|
||||||
category = "main"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.6"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "charset-normalizer"
|
|
||||||
version = "2.1.0"
|
|
||||||
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
|
|
||||||
category = "main"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.6.0"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
unicode_backport = ["unicodedata2"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "click"
|
|
||||||
version = "8.1.3"
|
|
||||||
description = "Composable command line interface toolkit"
|
|
||||||
category = "main"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.7"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
colorama = {version = "*", markers = "platform_system == \"Windows\""}
|
|
||||||
importlib-metadata = {version = "*", markers = "python_version < \"3.8\""}
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "colorama"
|
|
||||||
version = "0.4.5"
|
|
||||||
description = "Cross-platform colored terminal text."
|
|
||||||
category = "main"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "construct"
|
|
||||||
version = "2.8.8"
|
|
||||||
description = "A powerful declarative parser/builder for binary data"
|
|
||||||
category = "main"
|
|
||||||
optional = false
|
|
||||||
python-versions = "*"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "frozenlist"
|
|
||||||
version = "1.3.0"
|
|
||||||
description = "A list-like structure which implements collections.abc.MutableSequence"
|
|
||||||
category = "main"
|
|
||||||
optional = true
|
|
||||||
python-versions = ">=3.7"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "idna"
|
|
||||||
version = "3.3"
|
|
||||||
description = "Internationalized Domain Names in Applications (IDNA)"
|
|
||||||
category = "main"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.5"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "importlib-metadata"
|
|
||||||
version = "4.12.0"
|
|
||||||
description = "Read metadata from Python packages"
|
|
||||||
category = "main"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.7"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""}
|
|
||||||
zipp = ">=0.5"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
docs = ["jaraco.packaging (>=9)", "rst.linker (>=1.9)", "sphinx"]
|
|
||||||
perf = ["ipython"]
|
|
||||||
testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "lxml"
|
|
||||||
version = "4.9.1"
|
|
||||||
description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API."
|
|
||||||
category = "main"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, != 3.4.*"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
cssselect = ["cssselect (>=0.7)"]
|
|
||||||
html5 = ["html5lib"]
|
|
||||||
htmlsoup = ["BeautifulSoup4"]
|
|
||||||
source = ["Cython (>=0.29.7)"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "multidict"
|
|
||||||
version = "6.0.2"
|
|
||||||
description = "multidict implementation"
|
|
||||||
category = "main"
|
|
||||||
optional = true
|
|
||||||
python-versions = ">=3.7"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "protobuf"
|
|
||||||
version = "4.21.6"
|
|
||||||
description = ""
|
|
||||||
category = "main"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.7"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pycryptodome"
|
|
||||||
version = "3.15.0"
|
|
||||||
description = "Cryptographic library for Python"
|
|
||||||
category = "main"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pymp4"
|
|
||||||
version = "1.2.0"
|
|
||||||
description = "A Python parser for MP4 boxes"
|
|
||||||
category = "main"
|
|
||||||
optional = false
|
|
||||||
python-versions = "*"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
construct = "2.8.8"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pyyaml"
|
|
||||||
version = "6.0"
|
|
||||||
description = "YAML parser and emitter for Python"
|
|
||||||
category = "main"
|
|
||||||
optional = true
|
|
||||||
python-versions = ">=3.6"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "requests"
|
|
||||||
version = "2.28.1"
|
|
||||||
description = "Python HTTP for Humans."
|
|
||||||
category = "main"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.7, <4"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
certifi = ">=2017.4.17"
|
|
||||||
charset-normalizer = ">=2,<3"
|
|
||||||
idna = ">=2.5,<4"
|
|
||||||
urllib3 = ">=1.21.1,<1.27"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
|
|
||||||
use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "typing-extensions"
|
|
||||||
version = "4.3.0"
|
|
||||||
description = "Backported and Experimental Type Hints for Python 3.7+"
|
|
||||||
category = "main"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.7"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "unidecode"
|
|
||||||
version = "1.3.4"
|
|
||||||
description = "ASCII transliterations of Unicode text"
|
|
||||||
category = "main"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.5"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "urllib3"
|
|
||||||
version = "1.26.10"
|
|
||||||
description = "HTTP library with thread-safe connection pooling, file post, and more."
|
|
||||||
category = "main"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, <4"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"]
|
|
||||||
secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)"]
|
|
||||||
socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "yarl"
|
|
||||||
version = "1.7.2"
|
|
||||||
description = "Yet another URL library"
|
|
||||||
category = "main"
|
|
||||||
optional = true
|
|
||||||
python-versions = ">=3.6"
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
idna = ">=2.0"
|
|
||||||
multidict = ">=4.0"
|
|
||||||
typing-extensions = {version = ">=3.7.4", markers = "python_version < \"3.8\""}
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zipp"
|
|
||||||
version = "3.8.1"
|
|
||||||
description = "Backport of pathlib-compatible object wrapper for zip files"
|
|
||||||
category = "main"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.7"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
docs = ["jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx"]
|
|
||||||
testing = ["func-timeout", "jaraco.itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"]
|
|
||||||
|
|
||||||
[extras]
|
|
||||||
serve = ["aiohttp", "PyYAML"]
|
|
||||||
|
|
||||||
[metadata]
|
|
||||||
lock-version = "1.1"
|
|
||||||
python-versions = ">=3.7,<3.11"
|
|
||||||
content-hash = "d0658d6c8f08997e9ebd6a12e2613f76275c21b7c133e2e976d16f8b63c9437f"
|
|
||||||
|
|
||||||
[metadata.files]
|
|
||||||
aiohttp = [
|
|
||||||
{file = "aiohttp-3.8.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1ed0b6477896559f17b9eaeb6d38e07f7f9ffe40b9f0f9627ae8b9926ae260a8"},
|
|
||||||
{file = "aiohttp-3.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7dadf3c307b31e0e61689cbf9e06be7a867c563d5a63ce9dca578f956609abf8"},
|
|
||||||
{file = "aiohttp-3.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a79004bb58748f31ae1cbe9fa891054baaa46fb106c2dc7af9f8e3304dc30316"},
|
|
||||||
{file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:12de6add4038df8f72fac606dff775791a60f113a725c960f2bab01d8b8e6b15"},
|
|
||||||
{file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f0d5f33feb5f69ddd57a4a4bd3d56c719a141080b445cbf18f238973c5c9923"},
|
|
||||||
{file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eaba923151d9deea315be1f3e2b31cc39a6d1d2f682f942905951f4e40200922"},
|
|
||||||
{file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:099ebd2c37ac74cce10a3527d2b49af80243e2a4fa39e7bce41617fbc35fa3c1"},
|
|
||||||
{file = "aiohttp-3.8.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:2e5d962cf7e1d426aa0e528a7e198658cdc8aa4fe87f781d039ad75dcd52c516"},
|
|
||||||
{file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fa0ffcace9b3aa34d205d8130f7873fcfefcb6a4dd3dd705b0dab69af6712642"},
|
|
||||||
{file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:61bfc23df345d8c9716d03717c2ed5e27374e0fe6f659ea64edcd27b4b044cf7"},
|
|
||||||
{file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:31560d268ff62143e92423ef183680b9829b1b482c011713ae941997921eebc8"},
|
|
||||||
{file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:01d7bdb774a9acc838e6b8f1d114f45303841b89b95984cbb7d80ea41172a9e3"},
|
|
||||||
{file = "aiohttp-3.8.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:97ef77eb6b044134c0b3a96e16abcb05ecce892965a2124c566af0fd60f717e2"},
|
|
||||||
{file = "aiohttp-3.8.1-cp310-cp310-win32.whl", hash = "sha256:c2aef4703f1f2ddc6df17519885dbfa3514929149d3ff900b73f45998f2532fa"},
|
|
||||||
{file = "aiohttp-3.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:713ac174a629d39b7c6a3aa757b337599798da4c1157114a314e4e391cd28e32"},
|
|
||||||
{file = "aiohttp-3.8.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:473d93d4450880fe278696549f2e7aed8cd23708c3c1997981464475f32137db"},
|
|
||||||
{file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99b5eeae8e019e7aad8af8bb314fb908dd2e028b3cdaad87ec05095394cce632"},
|
|
||||||
{file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3af642b43ce56c24d063325dd2cf20ee012d2b9ba4c3c008755a301aaea720ad"},
|
|
||||||
{file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3630c3ef435c0a7c549ba170a0633a56e92629aeed0e707fec832dee313fb7a"},
|
|
||||||
{file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4a4a4e30bf1edcad13fb0804300557aedd07a92cabc74382fdd0ba6ca2661091"},
|
|
||||||
{file = "aiohttp-3.8.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6f8b01295e26c68b3a1b90efb7a89029110d3a4139270b24fda961893216c440"},
|
|
||||||
{file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:a25fa703a527158aaf10dafd956f7d42ac6d30ec80e9a70846253dd13e2f067b"},
|
|
||||||
{file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:5bfde62d1d2641a1f5173b8c8c2d96ceb4854f54a44c23102e2ccc7e02f003ec"},
|
|
||||||
{file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:51467000f3647d519272392f484126aa716f747859794ac9924a7aafa86cd411"},
|
|
||||||
{file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:03a6d5349c9ee8f79ab3ff3694d6ce1cfc3ced1c9d36200cb8f08ba06bd3b782"},
|
|
||||||
{file = "aiohttp-3.8.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:102e487eeb82afac440581e5d7f8f44560b36cf0bdd11abc51a46c1cd88914d4"},
|
|
||||||
{file = "aiohttp-3.8.1-cp36-cp36m-win32.whl", hash = "sha256:4aed991a28ea3ce320dc8ce655875e1e00a11bdd29fe9444dd4f88c30d558602"},
|
|
||||||
{file = "aiohttp-3.8.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b0e20cddbd676ab8a64c774fefa0ad787cc506afd844de95da56060348021e96"},
|
|
||||||
{file = "aiohttp-3.8.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:37951ad2f4a6df6506750a23f7cbabad24c73c65f23f72e95897bb2cecbae676"},
|
|
||||||
{file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c23b1ad869653bc818e972b7a3a79852d0e494e9ab7e1a701a3decc49c20d51"},
|
|
||||||
{file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:15b09b06dae900777833fe7fc4b4aa426556ce95847a3e8d7548e2d19e34edb8"},
|
|
||||||
{file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:477c3ea0ba410b2b56b7efb072c36fa91b1e6fc331761798fa3f28bb224830dd"},
|
|
||||||
{file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2f2f69dca064926e79997f45b2f34e202b320fd3782f17a91941f7eb85502ee2"},
|
|
||||||
{file = "aiohttp-3.8.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ef9612483cb35171d51d9173647eed5d0069eaa2ee812793a75373447d487aa4"},
|
|
||||||
{file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:6d69f36d445c45cda7b3b26afef2fc34ef5ac0cdc75584a87ef307ee3c8c6d00"},
|
|
||||||
{file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:55c3d1072704d27401c92339144d199d9de7b52627f724a949fc7d5fc56d8b93"},
|
|
||||||
{file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:b9d00268fcb9f66fbcc7cd9fe423741d90c75ee029a1d15c09b22d23253c0a44"},
|
|
||||||
{file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:07b05cd3305e8a73112103c834e91cd27ce5b4bd07850c4b4dbd1877d3f45be7"},
|
|
||||||
{file = "aiohttp-3.8.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c34dc4958b232ef6188c4318cb7b2c2d80521c9a56c52449f8f93ab7bc2a8a1c"},
|
|
||||||
{file = "aiohttp-3.8.1-cp37-cp37m-win32.whl", hash = "sha256:d2f9b69293c33aaa53d923032fe227feac867f81682f002ce33ffae978f0a9a9"},
|
|
||||||
{file = "aiohttp-3.8.1-cp37-cp37m-win_amd64.whl", hash = "sha256:6ae828d3a003f03ae31915c31fa684b9890ea44c9c989056fea96e3d12a9fa17"},
|
|
||||||
{file = "aiohttp-3.8.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0c7ebbbde809ff4e970824b2b6cb7e4222be6b95a296e46c03cf050878fc1785"},
|
|
||||||
{file = "aiohttp-3.8.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8b7ef7cbd4fec9a1e811a5de813311ed4f7ac7d93e0fda233c9b3e1428f7dd7b"},
|
|
||||||
{file = "aiohttp-3.8.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c3d6a4d0619e09dcd61021debf7059955c2004fa29f48788a3dfaf9c9901a7cd"},
|
|
||||||
{file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:718626a174e7e467f0558954f94af117b7d4695d48eb980146016afa4b580b2e"},
|
|
||||||
{file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:589c72667a5febd36f1315aa6e5f56dd4aa4862df295cb51c769d16142ddd7cd"},
|
|
||||||
{file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2ed076098b171573161eb146afcb9129b5ff63308960aeca4b676d9d3c35e700"},
|
|
||||||
{file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:086f92daf51a032d062ec5f58af5ca6a44d082c35299c96376a41cbb33034675"},
|
|
||||||
{file = "aiohttp-3.8.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:11691cf4dc5b94236ccc609b70fec991234e7ef8d4c02dd0c9668d1e486f5abf"},
|
|
||||||
{file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:31d1e1c0dbf19ebccbfd62eff461518dcb1e307b195e93bba60c965a4dcf1ba0"},
|
|
||||||
{file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:11a67c0d562e07067c4e86bffc1553f2cf5b664d6111c894671b2b8712f3aba5"},
|
|
||||||
{file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:bb01ba6b0d3f6c68b89fce7305080145d4877ad3acaed424bae4d4ee75faa950"},
|
|
||||||
{file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:44db35a9e15d6fe5c40d74952e803b1d96e964f683b5a78c3cc64eb177878155"},
|
|
||||||
{file = "aiohttp-3.8.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:844a9b460871ee0a0b0b68a64890dae9c415e513db0f4a7e3cab41a0f2fedf33"},
|
|
||||||
{file = "aiohttp-3.8.1-cp38-cp38-win32.whl", hash = "sha256:7d08744e9bae2ca9c382581f7dce1273fe3c9bae94ff572c3626e8da5b193c6a"},
|
|
||||||
{file = "aiohttp-3.8.1-cp38-cp38-win_amd64.whl", hash = "sha256:04d48b8ce6ab3cf2097b1855e1505181bdd05586ca275f2505514a6e274e8e75"},
|
|
||||||
{file = "aiohttp-3.8.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:f5315a2eb0239185af1bddb1abf472d877fede3cc8d143c6cddad37678293237"},
|
|
||||||
{file = "aiohttp-3.8.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a996d01ca39b8dfe77440f3cd600825d05841088fd6bc0144cc6c2ec14cc5f74"},
|
|
||||||
{file = "aiohttp-3.8.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:13487abd2f761d4be7c8ff9080de2671e53fff69711d46de703c310c4c9317ca"},
|
|
||||||
{file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ea302f34477fda3f85560a06d9ebdc7fa41e82420e892fc50b577e35fc6a50b2"},
|
|
||||||
{file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2f635ce61a89c5732537a7896b6319a8fcfa23ba09bec36e1b1ac0ab31270d2"},
|
|
||||||
{file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e999f2d0e12eea01caeecb17b653f3713d758f6dcc770417cf29ef08d3931421"},
|
|
||||||
{file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0770e2806a30e744b4e21c9d73b7bee18a1cfa3c47991ee2e5a65b887c49d5cf"},
|
|
||||||
{file = "aiohttp-3.8.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:d15367ce87c8e9e09b0f989bfd72dc641bcd04ba091c68cd305312d00962addd"},
|
|
||||||
{file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:6c7cefb4b0640703eb1069835c02486669312bf2f12b48a748e0a7756d0de33d"},
|
|
||||||
{file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:71927042ed6365a09a98a6377501af5c9f0a4d38083652bcd2281a06a5976724"},
|
|
||||||
{file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:28d490af82bc6b7ce53ff31337a18a10498303fe66f701ab65ef27e143c3b0ef"},
|
|
||||||
{file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:b6613280ccedf24354406caf785db748bebbddcf31408b20c0b48cb86af76866"},
|
|
||||||
{file = "aiohttp-3.8.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:81e3d8c34c623ca4e36c46524a3530e99c0bc95ed068fd6e9b55cb721d408fb2"},
|
|
||||||
{file = "aiohttp-3.8.1-cp39-cp39-win32.whl", hash = "sha256:7187a76598bdb895af0adbd2fb7474d7f6025d170bc0a1130242da817ce9e7d1"},
|
|
||||||
{file = "aiohttp-3.8.1-cp39-cp39-win_amd64.whl", hash = "sha256:1c182cb873bc91b411e184dab7a2b664d4fea2743df0e4d57402f7f3fa644bac"},
|
|
||||||
{file = "aiohttp-3.8.1.tar.gz", hash = "sha256:fc5471e1a54de15ef71c1bc6ebe80d4dc681ea600e68bfd1cbce40427f0b7578"},
|
|
||||||
]
|
|
||||||
aiosignal = [
|
|
||||||
{file = "aiosignal-1.2.0-py3-none-any.whl", hash = "sha256:26e62109036cd181df6e6ad646f91f0dcfd05fe16d0cb924138ff2ab75d64e3a"},
|
|
||||||
{file = "aiosignal-1.2.0.tar.gz", hash = "sha256:78ed67db6c7b7ced4f98e495e572106d5c432a93e1ddd1bf475e1dc05f5b7df2"},
|
|
||||||
]
|
|
||||||
async-timeout = [
|
|
||||||
{file = "async-timeout-4.0.2.tar.gz", hash = "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15"},
|
|
||||||
{file = "async_timeout-4.0.2-py3-none-any.whl", hash = "sha256:8ca1e4fcf50d07413d66d1a5e416e42cfdf5851c981d679a09851a6853383b3c"},
|
|
||||||
]
|
|
||||||
asynctest = [
|
|
||||||
{file = "asynctest-0.13.0-py3-none-any.whl", hash = "sha256:5da6118a7e6d6b54d83a8f7197769d046922a44d2a99c21382f0a6e4fadae676"},
|
|
||||||
{file = "asynctest-0.13.0.tar.gz", hash = "sha256:c27862842d15d83e6a34eb0b2866c323880eb3a75e4485b079ea11748fd77fac"},
|
|
||||||
]
|
|
||||||
attrs = [
|
|
||||||
{file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"},
|
|
||||||
{file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"},
|
|
||||||
]
|
|
||||||
certifi = [
|
|
||||||
{file = "certifi-2022.6.15-py3-none-any.whl", hash = "sha256:fe86415d55e84719d75f8b69414f6438ac3547d2078ab91b67e779ef69378412"},
|
|
||||||
{file = "certifi-2022.6.15.tar.gz", hash = "sha256:84c85a9078b11105f04f3036a9482ae10e4621616db313fe045dd24743a0820d"},
|
|
||||||
]
|
|
||||||
charset-normalizer = [
|
|
||||||
{file = "charset-normalizer-2.1.0.tar.gz", hash = "sha256:575e708016ff3a5e3681541cb9d79312c416835686d054a23accb873b254f413"},
|
|
||||||
{file = "charset_normalizer-2.1.0-py3-none-any.whl", hash = "sha256:5189b6f22b01957427f35b6a08d9a0bc45b46d3788ef5a92e978433c7a35f8a5"},
|
|
||||||
]
|
|
||||||
click = [
|
|
||||||
{file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"},
|
|
||||||
{file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"},
|
|
||||||
]
|
|
||||||
colorama = [
|
|
||||||
{file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"},
|
|
||||||
{file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"},
|
|
||||||
]
|
|
||||||
construct = [
|
|
||||||
{file = "construct-2.8.8.tar.gz", hash = "sha256:1b84b8147f6fd15bcf64b737c3e8ac5100811ad80c830cb4b2545140511c4157"},
|
|
||||||
]
|
|
||||||
frozenlist = [
|
|
||||||
{file = "frozenlist-1.3.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d2257aaba9660f78c7b1d8fea963b68f3feffb1a9d5d05a18401ca9eb3e8d0a3"},
|
|
||||||
{file = "frozenlist-1.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4a44ebbf601d7bac77976d429e9bdb5a4614f9f4027777f9e54fd765196e9d3b"},
|
|
||||||
{file = "frozenlist-1.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:45334234ec30fc4ea677f43171b18a27505bfb2dba9aca4398a62692c0ea8868"},
|
|
||||||
{file = "frozenlist-1.3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47be22dc27ed933d55ee55845d34a3e4e9f6fee93039e7f8ebadb0c2f60d403f"},
|
|
||||||
{file = "frozenlist-1.3.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03a7dd1bfce30216a3f51a84e6dd0e4a573d23ca50f0346634916ff105ba6e6b"},
|
|
||||||
{file = "frozenlist-1.3.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:691ddf6dc50480ce49f68441f1d16a4c3325887453837036e0fb94736eae1e58"},
|
|
||||||
{file = "frozenlist-1.3.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bde99812f237f79eaf3f04ebffd74f6718bbd216101b35ac7955c2d47c17da02"},
|
|
||||||
{file = "frozenlist-1.3.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6a202458d1298ced3768f5a7d44301e7c86defac162ace0ab7434c2e961166e8"},
|
|
||||||
{file = "frozenlist-1.3.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b9e3e9e365991f8cc5f5edc1fd65b58b41d0514a6a7ad95ef5c7f34eb49b3d3e"},
|
|
||||||
{file = "frozenlist-1.3.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:04cb491c4b1c051734d41ea2552fde292f5f3a9c911363f74f39c23659c4af78"},
|
|
||||||
{file = "frozenlist-1.3.0-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:436496321dad302b8b27ca955364a439ed1f0999311c393dccb243e451ff66aa"},
|
|
||||||
{file = "frozenlist-1.3.0-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:754728d65f1acc61e0f4df784456106e35afb7bf39cfe37227ab00436fb38676"},
|
|
||||||
{file = "frozenlist-1.3.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6eb275c6385dd72594758cbe96c07cdb9bd6becf84235f4a594bdf21e3596c9d"},
|
|
||||||
{file = "frozenlist-1.3.0-cp310-cp310-win32.whl", hash = "sha256:e30b2f9683812eb30cf3f0a8e9f79f8d590a7999f731cf39f9105a7c4a39489d"},
|
|
||||||
{file = "frozenlist-1.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:f7353ba3367473d1d616ee727945f439e027f0bb16ac1a750219a8344d1d5d3c"},
|
|
||||||
{file = "frozenlist-1.3.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:88aafd445a233dbbf8a65a62bc3249a0acd0d81ab18f6feb461cc5a938610d24"},
|
|
||||||
{file = "frozenlist-1.3.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4406cfabef8f07b3b3af0f50f70938ec06d9f0fc26cbdeaab431cbc3ca3caeaa"},
|
|
||||||
{file = "frozenlist-1.3.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8cf829bd2e2956066dd4de43fd8ec881d87842a06708c035b37ef632930505a2"},
|
|
||||||
{file = "frozenlist-1.3.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:603b9091bd70fae7be28bdb8aa5c9990f4241aa33abb673390a7f7329296695f"},
|
|
||||||
{file = "frozenlist-1.3.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:25af28b560e0c76fa41f550eacb389905633e7ac02d6eb3c09017fa1c8cdfde1"},
|
|
||||||
{file = "frozenlist-1.3.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94c7a8a9fc9383b52c410a2ec952521906d355d18fccc927fca52ab575ee8b93"},
|
|
||||||
{file = "frozenlist-1.3.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:65bc6e2fece04e2145ab6e3c47428d1bbc05aede61ae365b2c1bddd94906e478"},
|
|
||||||
{file = "frozenlist-1.3.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:3f7c935c7b58b0d78c0beea0c7358e165f95f1fd8a7e98baa40d22a05b4a8141"},
|
|
||||||
{file = "frozenlist-1.3.0-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd89acd1b8bb4f31b47072615d72e7f53a948d302b7c1d1455e42622de180eae"},
|
|
||||||
{file = "frozenlist-1.3.0-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:6983a31698490825171be44ffbafeaa930ddf590d3f051e397143a5045513b01"},
|
|
||||||
{file = "frozenlist-1.3.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:adac9700675cf99e3615eb6a0eb5e9f5a4143c7d42c05cea2e7f71c27a3d0846"},
|
|
||||||
{file = "frozenlist-1.3.0-cp37-cp37m-win32.whl", hash = "sha256:0c36e78b9509e97042ef869c0e1e6ef6429e55817c12d78245eb915e1cca7468"},
|
|
||||||
{file = "frozenlist-1.3.0-cp37-cp37m-win_amd64.whl", hash = "sha256:57f4d3f03a18facacb2a6bcd21bccd011e3b75d463dc49f838fd699d074fabd1"},
|
|
||||||
{file = "frozenlist-1.3.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:8c905a5186d77111f02144fab5b849ab524f1e876a1e75205cd1386a9be4b00a"},
|
|
||||||
{file = "frozenlist-1.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b5009062d78a8c6890d50b4e53b0ddda31841b3935c1937e2ed8c1bda1c7fb9d"},
|
|
||||||
{file = "frozenlist-1.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2fdc3cd845e5a1f71a0c3518528bfdbfe2efaf9886d6f49eacc5ee4fd9a10953"},
|
|
||||||
{file = "frozenlist-1.3.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:92e650bd09b5dda929523b9f8e7f99b24deac61240ecc1a32aeba487afcd970f"},
|
|
||||||
{file = "frozenlist-1.3.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:40dff8962b8eba91fd3848d857203f0bd704b5f1fa2b3fc9af64901a190bba08"},
|
|
||||||
{file = "frozenlist-1.3.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:768efd082074bb203c934e83a61654ed4931ef02412c2fbdecea0cff7ecd0274"},
|
|
||||||
{file = "frozenlist-1.3.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:006d3595e7d4108a12025ddf415ae0f6c9e736e726a5db0183326fd191b14c5e"},
|
|
||||||
{file = "frozenlist-1.3.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:871d42623ae15eb0b0e9df65baeee6976b2e161d0ba93155411d58ff27483ad8"},
|
|
||||||
{file = "frozenlist-1.3.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:aff388be97ef2677ae185e72dc500d19ecaf31b698986800d3fc4f399a5e30a5"},
|
|
||||||
{file = "frozenlist-1.3.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:9f892d6a94ec5c7b785e548e42722e6f3a52f5f32a8461e82ac3e67a3bd073f1"},
|
|
||||||
{file = "frozenlist-1.3.0-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:e982878792c971cbd60ee510c4ee5bf089a8246226dea1f2138aa0bb67aff148"},
|
|
||||||
{file = "frozenlist-1.3.0-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:c6c321dd013e8fc20735b92cb4892c115f5cdb82c817b1e5b07f6b95d952b2f0"},
|
|
||||||
{file = "frozenlist-1.3.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:30530930410855c451bea83f7b272fb1c495ed9d5cc72895ac29e91279401db3"},
|
|
||||||
{file = "frozenlist-1.3.0-cp38-cp38-win32.whl", hash = "sha256:40ec383bc194accba825fbb7d0ef3dda5736ceab2375462f1d8672d9f6b68d07"},
|
|
||||||
{file = "frozenlist-1.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:f20baa05eaa2bcd5404c445ec51aed1c268d62600362dc6cfe04fae34a424bd9"},
|
|
||||||
{file = "frozenlist-1.3.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:0437fe763fb5d4adad1756050cbf855bbb2bf0d9385c7bb13d7a10b0dd550486"},
|
|
||||||
{file = "frozenlist-1.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b684c68077b84522b5c7eafc1dc735bfa5b341fb011d5552ebe0968e22ed641c"},
|
|
||||||
{file = "frozenlist-1.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:93641a51f89473837333b2f8100f3f89795295b858cd4c7d4a1f18e299dc0a4f"},
|
|
||||||
{file = "frozenlist-1.3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6d32ff213aef0fd0bcf803bffe15cfa2d4fde237d1d4838e62aec242a8362fa"},
|
|
||||||
{file = "frozenlist-1.3.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31977f84828b5bb856ca1eb07bf7e3a34f33a5cddce981d880240ba06639b94d"},
|
|
||||||
{file = "frozenlist-1.3.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3c62964192a1c0c30b49f403495911298810bada64e4f03249ca35a33ca0417a"},
|
|
||||||
{file = "frozenlist-1.3.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4eda49bea3602812518765810af732229b4291d2695ed24a0a20e098c45a707b"},
|
|
||||||
{file = "frozenlist-1.3.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acb267b09a509c1df5a4ca04140da96016f40d2ed183cdc356d237286c971b51"},
|
|
||||||
{file = "frozenlist-1.3.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:e1e26ac0a253a2907d654a37e390904426d5ae5483150ce3adedb35c8c06614a"},
|
|
||||||
{file = "frozenlist-1.3.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f96293d6f982c58ebebb428c50163d010c2f05de0cde99fd681bfdc18d4b2dc2"},
|
|
||||||
{file = "frozenlist-1.3.0-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:e84cb61b0ac40a0c3e0e8b79c575161c5300d1d89e13c0e02f76193982f066ed"},
|
|
||||||
{file = "frozenlist-1.3.0-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:ff9310f05b9d9c5c4dd472983dc956901ee6cb2c3ec1ab116ecdde25f3ce4951"},
|
|
||||||
{file = "frozenlist-1.3.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d26b650b71fdc88065b7a21f8ace70175bcf3b5bdba5ea22df4bfd893e795a3b"},
|
|
||||||
{file = "frozenlist-1.3.0-cp39-cp39-win32.whl", hash = "sha256:01a73627448b1f2145bddb6e6c2259988bb8aee0fb361776ff8604b99616cd08"},
|
|
||||||
{file = "frozenlist-1.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:772965f773757a6026dea111a15e6e2678fbd6216180f82a48a40b27de1ee2ab"},
|
|
||||||
{file = "frozenlist-1.3.0.tar.gz", hash = "sha256:ce6f2ba0edb7b0c1d8976565298ad2deba6f8064d2bebb6ffce2ca896eb35b0b"},
|
|
||||||
]
|
|
||||||
idna = [
|
|
||||||
{file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"},
|
|
||||||
{file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"},
|
|
||||||
]
|
|
||||||
importlib-metadata = [
|
|
||||||
{file = "importlib_metadata-4.12.0-py3-none-any.whl", hash = "sha256:7401a975809ea1fdc658c3aa4f78cc2195a0e019c5cbc4c06122884e9ae80c23"},
|
|
||||||
{file = "importlib_metadata-4.12.0.tar.gz", hash = "sha256:637245b8bab2b6502fcbc752cc4b7a6f6243bb02b31c5c26156ad103d3d45670"},
|
|
||||||
]
|
|
||||||
lxml = [
|
|
||||||
{file = "lxml-4.9.1-cp27-cp27m-macosx_10_15_x86_64.whl", hash = "sha256:98cafc618614d72b02185ac583c6f7796202062c41d2eeecdf07820bad3295ed"},
|
|
||||||
{file = "lxml-4.9.1-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c62e8dd9754b7debda0c5ba59d34509c4688f853588d75b53c3791983faa96fc"},
|
|
||||||
{file = "lxml-4.9.1-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:21fb3d24ab430fc538a96e9fbb9b150029914805d551deeac7d7822f64631dfc"},
|
|
||||||
{file = "lxml-4.9.1-cp27-cp27m-win32.whl", hash = "sha256:86e92728ef3fc842c50a5cb1d5ba2bc66db7da08a7af53fb3da79e202d1b2cd3"},
|
|
||||||
{file = "lxml-4.9.1-cp27-cp27m-win_amd64.whl", hash = "sha256:4cfbe42c686f33944e12f45a27d25a492cc0e43e1dc1da5d6a87cbcaf2e95627"},
|
|
||||||
{file = "lxml-4.9.1-cp27-cp27mu-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dad7b164905d3e534883281c050180afcf1e230c3d4a54e8038aa5cfcf312b84"},
|
|
||||||
{file = "lxml-4.9.1-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:a614e4afed58c14254e67862456d212c4dcceebab2eaa44d627c2ca04bf86837"},
|
|
||||||
{file = "lxml-4.9.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:f9ced82717c7ec65a67667bb05865ffe38af0e835cdd78728f1209c8fffe0cad"},
|
|
||||||
{file = "lxml-4.9.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:d9fc0bf3ff86c17348dfc5d322f627d78273eba545db865c3cd14b3f19e57fa5"},
|
|
||||||
{file = "lxml-4.9.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:e5f66bdf0976ec667fc4594d2812a00b07ed14d1b44259d19a41ae3fff99f2b8"},
|
|
||||||
{file = "lxml-4.9.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fe17d10b97fdf58155f858606bddb4e037b805a60ae023c009f760d8361a4eb8"},
|
|
||||||
{file = "lxml-4.9.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8caf4d16b31961e964c62194ea3e26a0e9561cdf72eecb1781458b67ec83423d"},
|
|
||||||
{file = "lxml-4.9.1-cp310-cp310-win32.whl", hash = "sha256:4780677767dd52b99f0af1f123bc2c22873d30b474aa0e2fc3fe5e02217687c7"},
|
|
||||||
{file = "lxml-4.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:b122a188cd292c4d2fcd78d04f863b789ef43aa129b233d7c9004de08693728b"},
|
|
||||||
{file = "lxml-4.9.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:be9eb06489bc975c38706902cbc6888f39e946b81383abc2838d186f0e8b6a9d"},
|
|
||||||
{file = "lxml-4.9.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:f1be258c4d3dc609e654a1dc59d37b17d7fef05df912c01fc2e15eb43a9735f3"},
|
|
||||||
{file = "lxml-4.9.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:927a9dd016d6033bc12e0bf5dee1dde140235fc8d0d51099353c76081c03dc29"},
|
|
||||||
{file = "lxml-4.9.1-cp35-cp35m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9232b09f5efee6a495a99ae6824881940d6447debe272ea400c02e3b68aad85d"},
|
|
||||||
{file = "lxml-4.9.1-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:04da965dfebb5dac2619cb90fcf93efdb35b3c6994fea58a157a834f2f94b318"},
|
|
||||||
{file = "lxml-4.9.1-cp35-cp35m-win32.whl", hash = "sha256:4d5bae0a37af799207140652a700f21a85946f107a199bcb06720b13a4f1f0b7"},
|
|
||||||
{file = "lxml-4.9.1-cp35-cp35m-win_amd64.whl", hash = "sha256:4878e667ebabe9b65e785ac8da4d48886fe81193a84bbe49f12acff8f7a383a4"},
|
|
||||||
{file = "lxml-4.9.1-cp36-cp36m-macosx_10_15_x86_64.whl", hash = "sha256:1355755b62c28950f9ce123c7a41460ed9743c699905cbe664a5bcc5c9c7c7fb"},
|
|
||||||
{file = "lxml-4.9.1-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:bcaa1c495ce623966d9fc8a187da80082334236a2a1c7e141763ffaf7a405067"},
|
|
||||||
{file = "lxml-4.9.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6eafc048ea3f1b3c136c71a86db393be36b5b3d9c87b1c25204e7d397cee9536"},
|
|
||||||
{file = "lxml-4.9.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:13c90064b224e10c14dcdf8086688d3f0e612db53766e7478d7754703295c7c8"},
|
|
||||||
{file = "lxml-4.9.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:206a51077773c6c5d2ce1991327cda719063a47adc02bd703c56a662cdb6c58b"},
|
|
||||||
{file = "lxml-4.9.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:e8f0c9d65da595cfe91713bc1222af9ecabd37971762cb830dea2fc3b3bb2acf"},
|
|
||||||
{file = "lxml-4.9.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:8f0a4d179c9a941eb80c3a63cdb495e539e064f8054230844dcf2fcb812b71d3"},
|
|
||||||
{file = "lxml-4.9.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:830c88747dce8a3e7525defa68afd742b4580df6aa2fdd6f0855481e3994d391"},
|
|
||||||
{file = "lxml-4.9.1-cp36-cp36m-win32.whl", hash = "sha256:1e1cf47774373777936c5aabad489fef7b1c087dcd1f426b621fda9dcc12994e"},
|
|
||||||
{file = "lxml-4.9.1-cp36-cp36m-win_amd64.whl", hash = "sha256:5974895115737a74a00b321e339b9c3f45c20275d226398ae79ac008d908bff7"},
|
|
||||||
{file = "lxml-4.9.1-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:1423631e3d51008871299525b541413c9b6c6423593e89f9c4cfbe8460afc0a2"},
|
|
||||||
{file = "lxml-4.9.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:2aaf6a0a6465d39b5ca69688fce82d20088c1838534982996ec46633dc7ad6cc"},
|
|
||||||
{file = "lxml-4.9.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:9f36de4cd0c262dd9927886cc2305aa3f2210db437aa4fed3fb4940b8bf4592c"},
|
|
||||||
{file = "lxml-4.9.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:ae06c1e4bc60ee076292e582a7512f304abdf6c70db59b56745cca1684f875a4"},
|
|
||||||
{file = "lxml-4.9.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:57e4d637258703d14171b54203fd6822fda218c6c2658a7d30816b10995f29f3"},
|
|
||||||
{file = "lxml-4.9.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6d279033bf614953c3fc4a0aa9ac33a21e8044ca72d4fa8b9273fe75359d5cca"},
|
|
||||||
{file = "lxml-4.9.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:a60f90bba4c37962cbf210f0188ecca87daafdf60271f4c6948606e4dabf8785"},
|
|
||||||
{file = "lxml-4.9.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:6ca2264f341dd81e41f3fffecec6e446aa2121e0b8d026fb5130e02de1402785"},
|
|
||||||
{file = "lxml-4.9.1-cp37-cp37m-win32.whl", hash = "sha256:27e590352c76156f50f538dbcebd1925317a0f70540f7dc8c97d2931c595783a"},
|
|
||||||
{file = "lxml-4.9.1-cp37-cp37m-win_amd64.whl", hash = "sha256:eea5d6443b093e1545ad0210e6cf27f920482bfcf5c77cdc8596aec73523bb7e"},
|
|
||||||
{file = "lxml-4.9.1-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:f05251bbc2145349b8d0b77c0d4e5f3b228418807b1ee27cefb11f69ed3d233b"},
|
|
||||||
{file = "lxml-4.9.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:487c8e61d7acc50b8be82bda8c8d21d20e133c3cbf41bd8ad7eb1aaeb3f07c97"},
|
|
||||||
{file = "lxml-4.9.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:8d1a92d8e90b286d491e5626af53afef2ba04da33e82e30744795c71880eaa21"},
|
|
||||||
{file = "lxml-4.9.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:b570da8cd0012f4af9fa76a5635cd31f707473e65a5a335b186069d5c7121ff2"},
|
|
||||||
{file = "lxml-4.9.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5ef87fca280fb15342726bd5f980f6faf8b84a5287fcc2d4962ea8af88b35130"},
|
|
||||||
{file = "lxml-4.9.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:93e414e3206779ef41e5ff2448067213febf260ba747fc65389a3ddaa3fb8715"},
|
|
||||||
{file = "lxml-4.9.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6653071f4f9bac46fbc30f3c7838b0e9063ee335908c5d61fb7a4a86c8fd2036"},
|
|
||||||
{file = "lxml-4.9.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:32a73c53783becdb7eaf75a2a1525ea8e49379fb7248c3eeefb9412123536387"},
|
|
||||||
{file = "lxml-4.9.1-cp38-cp38-win32.whl", hash = "sha256:1a7c59c6ffd6ef5db362b798f350e24ab2cfa5700d53ac6681918f314a4d3b94"},
|
|
||||||
{file = "lxml-4.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:1436cf0063bba7888e43f1ba8d58824f085410ea2025befe81150aceb123e345"},
|
|
||||||
{file = "lxml-4.9.1-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:4beea0f31491bc086991b97517b9683e5cfb369205dac0148ef685ac12a20a67"},
|
|
||||||
{file = "lxml-4.9.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:41fb58868b816c202e8881fd0f179a4644ce6e7cbbb248ef0283a34b73ec73bb"},
|
|
||||||
{file = "lxml-4.9.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:bd34f6d1810d9354dc7e35158aa6cc33456be7706df4420819af6ed966e85448"},
|
|
||||||
{file = "lxml-4.9.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:edffbe3c510d8f4bf8640e02ca019e48a9b72357318383ca60e3330c23aaffc7"},
|
|
||||||
{file = "lxml-4.9.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6d949f53ad4fc7cf02c44d6678e7ff05ec5f5552b235b9e136bd52e9bf730b91"},
|
|
||||||
{file = "lxml-4.9.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:079b68f197c796e42aa80b1f739f058dcee796dc725cc9a1be0cdb08fc45b000"},
|
|
||||||
{file = "lxml-4.9.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9c3a88d20e4fe4a2a4a84bf439a5ac9c9aba400b85244c63a1ab7088f85d9d25"},
|
|
||||||
{file = "lxml-4.9.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:4e285b5f2bf321fc0857b491b5028c5f276ec0c873b985d58d7748ece1d770dd"},
|
|
||||||
{file = "lxml-4.9.1-cp39-cp39-win32.whl", hash = "sha256:ef72013e20dd5ba86a8ae1aed7f56f31d3374189aa8b433e7b12ad182c0d2dfb"},
|
|
||||||
{file = "lxml-4.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:10d2017f9150248563bb579cd0d07c61c58da85c922b780060dcc9a3aa9f432d"},
|
|
||||||
{file = "lxml-4.9.1-pp37-pypy37_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538747a9d7827ce3e16a8fdd201a99e661c7dee3c96c885d8ecba3c35d1032c"},
|
|
||||||
{file = "lxml-4.9.1-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:0645e934e940107e2fdbe7c5b6fb8ec6232444260752598bc4d09511bd056c0b"},
|
|
||||||
{file = "lxml-4.9.1-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:6daa662aba22ef3258934105be2dd9afa5bb45748f4f702a3b39a5bf53a1f4dc"},
|
|
||||||
{file = "lxml-4.9.1-pp38-pypy38_pp73-macosx_10_15_x86_64.whl", hash = "sha256:603a464c2e67d8a546ddaa206d98e3246e5db05594b97db844c2f0a1af37cf5b"},
|
|
||||||
{file = "lxml-4.9.1-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:c4b2e0559b68455c085fb0f6178e9752c4be3bba104d6e881eb5573b399d1eb2"},
|
|
||||||
{file = "lxml-4.9.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0f3f0059891d3254c7b5fb935330d6db38d6519ecd238ca4fce93c234b4a0f73"},
|
|
||||||
{file = "lxml-4.9.1-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_24_i686.whl", hash = "sha256:c852b1530083a620cb0de5f3cd6826f19862bafeaf77586f1aef326e49d95f0c"},
|
|
||||||
{file = "lxml-4.9.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:287605bede6bd36e930577c5925fcea17cb30453d96a7b4c63c14a257118dbb9"},
|
|
||||||
{file = "lxml-4.9.1.tar.gz", hash = "sha256:fe749b052bb7233fe5d072fcb549221a8cb1a16725c47c37e42b0b9cb3ff2c3f"},
|
|
||||||
]
|
|
||||||
multidict = [
|
|
||||||
{file = "multidict-6.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0b9e95a740109c6047602f4db4da9949e6c5945cefbad34a1299775ddc9a62e2"},
|
|
||||||
{file = "multidict-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac0e27844758d7177989ce406acc6a83c16ed4524ebc363c1f748cba184d89d3"},
|
|
||||||
{file = "multidict-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:041b81a5f6b38244b34dc18c7b6aba91f9cdaf854d9a39e5ff0b58e2b5773b9c"},
|
|
||||||
{file = "multidict-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5fdda29a3c7e76a064f2477c9aab1ba96fd94e02e386f1e665bca1807fc5386f"},
|
|
||||||
{file = "multidict-6.0.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3368bf2398b0e0fcbf46d85795adc4c259299fec50c1416d0f77c0a843a3eed9"},
|
|
||||||
{file = "multidict-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4f052ee022928d34fe1f4d2bc743f32609fb79ed9c49a1710a5ad6b2198db20"},
|
|
||||||
{file = "multidict-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:225383a6603c086e6cef0f2f05564acb4f4d5f019a4e3e983f572b8530f70c88"},
|
|
||||||
{file = "multidict-6.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50bd442726e288e884f7be9071016c15a8742eb689a593a0cac49ea093eef0a7"},
|
|
||||||
{file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:47e6a7e923e9cada7c139531feac59448f1f47727a79076c0b1ee80274cd8eee"},
|
|
||||||
{file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:0556a1d4ea2d949efe5fd76a09b4a82e3a4a30700553a6725535098d8d9fb672"},
|
|
||||||
{file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:626fe10ac87851f4cffecee161fc6f8f9853f0f6f1035b59337a51d29ff3b4f9"},
|
|
||||||
{file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:8064b7c6f0af936a741ea1efd18690bacfbae4078c0c385d7c3f611d11f0cf87"},
|
|
||||||
{file = "multidict-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:2d36e929d7f6a16d4eb11b250719c39560dd70545356365b494249e2186bc389"},
|
|
||||||
{file = "multidict-6.0.2-cp310-cp310-win32.whl", hash = "sha256:fcb91630817aa8b9bc4a74023e4198480587269c272c58b3279875ed7235c293"},
|
|
||||||
{file = "multidict-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:8cbf0132f3de7cc6c6ce00147cc78e6439ea736cee6bca4f068bcf892b0fd658"},
|
|
||||||
{file = "multidict-6.0.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:05f6949d6169878a03e607a21e3b862eaf8e356590e8bdae4227eedadacf6e51"},
|
|
||||||
{file = "multidict-6.0.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2c2e459f7050aeb7c1b1276763364884595d47000c1cddb51764c0d8976e608"},
|
|
||||||
{file = "multidict-6.0.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d0509e469d48940147e1235d994cd849a8f8195e0bca65f8f5439c56e17872a3"},
|
|
||||||
{file = "multidict-6.0.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:514fe2b8d750d6cdb4712346a2c5084a80220821a3e91f3f71eec11cf8d28fd4"},
|
|
||||||
{file = "multidict-6.0.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:19adcfc2a7197cdc3987044e3f415168fc5dc1f720c932eb1ef4f71a2067e08b"},
|
|
||||||
{file = "multidict-6.0.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9d153e7f1f9ba0b23ad1568b3b9e17301e23b042c23870f9ee0522dc5cc79e8"},
|
|
||||||
{file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:aef9cc3d9c7d63d924adac329c33835e0243b5052a6dfcbf7732a921c6e918ba"},
|
|
||||||
{file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:4571f1beddff25f3e925eea34268422622963cd8dc395bb8778eb28418248e43"},
|
|
||||||
{file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:d48b8ee1d4068561ce8033d2c344cf5232cb29ee1a0206a7b828c79cbc5982b8"},
|
|
||||||
{file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:45183c96ddf61bf96d2684d9fbaf6f3564d86b34cb125761f9a0ef9e36c1d55b"},
|
|
||||||
{file = "multidict-6.0.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:75bdf08716edde767b09e76829db8c1e5ca9d8bb0a8d4bd94ae1eafe3dac5e15"},
|
|
||||||
{file = "multidict-6.0.2-cp37-cp37m-win32.whl", hash = "sha256:a45e1135cb07086833ce969555df39149680e5471c04dfd6a915abd2fc3f6dbc"},
|
|
||||||
{file = "multidict-6.0.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6f3cdef8a247d1eafa649085812f8a310e728bdf3900ff6c434eafb2d443b23a"},
|
|
||||||
{file = "multidict-6.0.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0327292e745a880459ef71be14e709aaea2f783f3537588fb4ed09b6c01bca60"},
|
|
||||||
{file = "multidict-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e875b6086e325bab7e680e4316d667fc0e5e174bb5611eb16b3ea121c8951b86"},
|
|
||||||
{file = "multidict-6.0.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:feea820722e69451743a3d56ad74948b68bf456984d63c1a92e8347b7b88452d"},
|
|
||||||
{file = "multidict-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc57c68cb9139c7cd6fc39f211b02198e69fb90ce4bc4a094cf5fe0d20fd8b0"},
|
|
||||||
{file = "multidict-6.0.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:497988d6b6ec6ed6f87030ec03280b696ca47dbf0648045e4e1d28b80346560d"},
|
|
||||||
{file = "multidict-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:89171b2c769e03a953d5969b2f272efa931426355b6c0cb508022976a17fd376"},
|
|
||||||
{file = "multidict-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:684133b1e1fe91eda8fa7447f137c9490a064c6b7f392aa857bba83a28cfb693"},
|
|
||||||
{file = "multidict-6.0.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fd9fc9c4849a07f3635ccffa895d57abce554b467d611a5009ba4f39b78a8849"},
|
|
||||||
{file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:e07c8e79d6e6fd37b42f3250dba122053fddb319e84b55dd3a8d6446e1a7ee49"},
|
|
||||||
{file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:4070613ea2227da2bfb2c35a6041e4371b0af6b0be57f424fe2318b42a748516"},
|
|
||||||
{file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:47fbeedbf94bed6547d3aa632075d804867a352d86688c04e606971595460227"},
|
|
||||||
{file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:5774d9218d77befa7b70d836004a768fb9aa4fdb53c97498f4d8d3f67bb9cfa9"},
|
|
||||||
{file = "multidict-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2957489cba47c2539a8eb7ab32ff49101439ccf78eab724c828c1a54ff3ff98d"},
|
|
||||||
{file = "multidict-6.0.2-cp38-cp38-win32.whl", hash = "sha256:e5b20e9599ba74391ca0cfbd7b328fcc20976823ba19bc573983a25b32e92b57"},
|
|
||||||
{file = "multidict-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:8004dca28e15b86d1b1372515f32eb6f814bdf6f00952699bdeb541691091f96"},
|
|
||||||
{file = "multidict-6.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2e4a0785b84fb59e43c18a015ffc575ba93f7d1dbd272b4cdad9f5134b8a006c"},
|
|
||||||
{file = "multidict-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6701bf8a5d03a43375909ac91b6980aea74b0f5402fbe9428fc3f6edf5d9677e"},
|
|
||||||
{file = "multidict-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a007b1638e148c3cfb6bf0bdc4f82776cef0ac487191d093cdc316905e504071"},
|
|
||||||
{file = "multidict-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:07a017cfa00c9890011628eab2503bee5872f27144936a52eaab449be5eaf032"},
|
|
||||||
{file = "multidict-6.0.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c207fff63adcdf5a485969131dc70e4b194327666b7e8a87a97fbc4fd80a53b2"},
|
|
||||||
{file = "multidict-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:373ba9d1d061c76462d74e7de1c0c8e267e9791ee8cfefcf6b0b2495762c370c"},
|
|
||||||
{file = "multidict-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfba7c6d5d7c9099ba21f84662b037a0ffd4a5e6b26ac07d19e423e6fdf965a9"},
|
|
||||||
{file = "multidict-6.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:19d9bad105dfb34eb539c97b132057a4e709919ec4dd883ece5838bcbf262b80"},
|
|
||||||
{file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:de989b195c3d636ba000ee4281cd03bb1234635b124bf4cd89eeee9ca8fcb09d"},
|
|
||||||
{file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7c40b7bbece294ae3a87c1bc2abff0ff9beef41d14188cda94ada7bcea99b0fb"},
|
|
||||||
{file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:d16cce709ebfadc91278a1c005e3c17dd5f71f5098bfae1035149785ea6e9c68"},
|
|
||||||
{file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:a2c34a93e1d2aa35fbf1485e5010337c72c6791407d03aa5f4eed920343dd360"},
|
|
||||||
{file = "multidict-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:feba80698173761cddd814fa22e88b0661e98cb810f9f986c54aa34d281e4937"},
|
|
||||||
{file = "multidict-6.0.2-cp39-cp39-win32.whl", hash = "sha256:23b616fdc3c74c9fe01d76ce0d1ce872d2d396d8fa8e4899398ad64fb5aa214a"},
|
|
||||||
{file = "multidict-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:4bae31803d708f6f15fd98be6a6ac0b6958fcf68fda3c77a048a4f9073704aae"},
|
|
||||||
{file = "multidict-6.0.2.tar.gz", hash = "sha256:5ff3bd75f38e4c43f1f470f2df7a4d430b821c4ce22be384e1459cb57d6bb013"},
|
|
||||||
]
|
|
||||||
protobuf = [
|
|
||||||
{file = "protobuf-4.21.6-cp310-abi3-win32.whl", hash = "sha256:49f88d56a9180dbb7f6199c920f5bb5c1dd0172f672983bb281298d57c2ac8eb"},
|
|
||||||
{file = "protobuf-4.21.6-cp310-abi3-win_amd64.whl", hash = "sha256:7a6cc8842257265bdfd6b74d088b829e44bcac3cca234c5fdd6052730017b9ea"},
|
|
||||||
{file = "protobuf-4.21.6-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:ba596b9ffb85c909fcfe1b1a23136224ed678af3faf9912d3fa483d5f9813c4e"},
|
|
||||||
{file = "protobuf-4.21.6-cp37-abi3-manylinux2014_aarch64.whl", hash = "sha256:4143513c766db85b9d7c18dbf8339673c8a290131b2a0fe73855ab20770f72b0"},
|
|
||||||
{file = "protobuf-4.21.6-cp37-abi3-manylinux2014_x86_64.whl", hash = "sha256:b6cea204865595a92a7b240e4b65bcaaca3ad5d2ce25d9db3756eba06041138e"},
|
|
||||||
{file = "protobuf-4.21.6-cp37-cp37m-win32.whl", hash = "sha256:9666da97129138585b26afcb63ad4887f602e169cafe754a8258541c553b8b5d"},
|
|
||||||
{file = "protobuf-4.21.6-cp37-cp37m-win_amd64.whl", hash = "sha256:308173d3e5a3528787bb8c93abea81d5a950bdce62840d9760effc84127fb39c"},
|
|
||||||
{file = "protobuf-4.21.6-cp38-cp38-win32.whl", hash = "sha256:aa29113ec901281f29d9d27b01193407a98aa9658b8a777b0325e6d97149f5ce"},
|
|
||||||
{file = "protobuf-4.21.6-cp38-cp38-win_amd64.whl", hash = "sha256:8f9e60f7d44592c66e7b332b6a7b4b6e8d8b889393c79dbc3a91f815118f8eac"},
|
|
||||||
{file = "protobuf-4.21.6-cp39-cp39-win32.whl", hash = "sha256:80e6540381080715fddac12690ee42d087d0d17395f8d0078dfd6f1181e7be4c"},
|
|
||||||
{file = "protobuf-4.21.6-cp39-cp39-win_amd64.whl", hash = "sha256:77b355c8604fe285536155286b28b0c4cbc57cf81b08d8357bf34829ea982860"},
|
|
||||||
{file = "protobuf-4.21.6-py2.py3-none-any.whl", hash = "sha256:07a0bb9cc6114f16a39c866dc28b6e3d96fa4ffb9cc1033057412547e6e75cb9"},
|
|
||||||
{file = "protobuf-4.21.6-py3-none-any.whl", hash = "sha256:c7c864148a237f058c739ae7a05a2b403c0dfa4ce7d1f3e5213f352ad52d57c6"},
|
|
||||||
{file = "protobuf-4.21.6.tar.gz", hash = "sha256:6b1040a5661cd5f6e610cbca9cfaa2a17d60e2bb545309bc1b278bb05be44bdd"},
|
|
||||||
]
|
|
||||||
pycryptodome = [
|
|
||||||
{file = "pycryptodome-3.15.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:ff7ae90e36c1715a54446e7872b76102baa5c63aa980917f4aa45e8c78d1a3ec"},
|
|
||||||
{file = "pycryptodome-3.15.0-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:2ffd8b31561455453ca9f62cb4c24e6b8d119d6d531087af5f14b64bee2c23e6"},
|
|
||||||
{file = "pycryptodome-3.15.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:2ea63d46157386c5053cfebcdd9bd8e0c8b7b0ac4a0507a027f5174929403884"},
|
|
||||||
{file = "pycryptodome-3.15.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:7c9ed8aa31c146bef65d89a1b655f5f4eab5e1120f55fc297713c89c9e56ff0b"},
|
|
||||||
{file = "pycryptodome-3.15.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:5099c9ca345b2f252f0c28e96904643153bae9258647585e5e6f649bb7a1844a"},
|
|
||||||
{file = "pycryptodome-3.15.0-cp27-cp27m-manylinux2014_aarch64.whl", hash = "sha256:2ec709b0a58b539a4f9d33fb8508264c3678d7edb33a68b8906ba914f71e8c13"},
|
|
||||||
{file = "pycryptodome-3.15.0-cp27-cp27m-win32.whl", hash = "sha256:fd2184aae6ee2a944aaa49113e6f5787cdc5e4db1eb8edb1aea914bd75f33a0c"},
|
|
||||||
{file = "pycryptodome-3.15.0-cp27-cp27m-win_amd64.whl", hash = "sha256:7e3a8f6ee405b3bd1c4da371b93c31f7027944b2bcce0697022801db93120d83"},
|
|
||||||
{file = "pycryptodome-3.15.0-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:b9c5b1a1977491533dfd31e01550ee36ae0249d78aae7f632590db833a5012b8"},
|
|
||||||
{file = "pycryptodome-3.15.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:0926f7cc3735033061ef3cf27ed16faad6544b14666410727b31fea85a5b16eb"},
|
|
||||||
{file = "pycryptodome-3.15.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:2aa55aae81f935a08d5a3c2042eb81741a43e044bd8a81ea7239448ad751f763"},
|
|
||||||
{file = "pycryptodome-3.15.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:c3640deff4197fa064295aaac10ab49a0d55ef3d6a54ae1499c40d646655c89f"},
|
|
||||||
{file = "pycryptodome-3.15.0-cp27-cp27mu-manylinux2014_aarch64.whl", hash = "sha256:045d75527241d17e6ef13636d845a12e54660aa82e823b3b3341bcf5af03fa79"},
|
|
||||||
{file = "pycryptodome-3.15.0-cp35-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9ee40e2168f1348ae476676a2e938ca80a2f57b14a249d8fe0d3cdf803e5a676"},
|
|
||||||
{file = "pycryptodome-3.15.0-cp35-abi3-manylinux1_i686.whl", hash = "sha256:4c3ccad74eeb7b001f3538643c4225eac398c77d617ebb3e57571a897943c667"},
|
|
||||||
{file = "pycryptodome-3.15.0-cp35-abi3-manylinux1_x86_64.whl", hash = "sha256:1b22bcd9ec55e9c74927f6b1f69843cb256fb5a465088ce62837f793d9ffea88"},
|
|
||||||
{file = "pycryptodome-3.15.0-cp35-abi3-manylinux2010_i686.whl", hash = "sha256:57f565acd2f0cf6fb3e1ba553d0cb1f33405ec1f9c5ded9b9a0a5320f2c0bd3d"},
|
|
||||||
{file = "pycryptodome-3.15.0-cp35-abi3-manylinux2010_x86_64.whl", hash = "sha256:4b52cb18b0ad46087caeb37a15e08040f3b4c2d444d58371b6f5d786d95534c2"},
|
|
||||||
{file = "pycryptodome-3.15.0-cp35-abi3-manylinux2014_aarch64.whl", hash = "sha256:092a26e78b73f2530b8bd6b3898e7453ab2f36e42fd85097d705d6aba2ec3e5e"},
|
|
||||||
{file = "pycryptodome-3.15.0-cp35-abi3-win32.whl", hash = "sha256:e244ab85c422260de91cda6379e8e986405b4f13dc97d2876497178707f87fc1"},
|
|
||||||
{file = "pycryptodome-3.15.0-cp35-abi3-win_amd64.whl", hash = "sha256:c77126899c4b9c9827ddf50565e93955cb3996813c18900c16b2ea0474e130e9"},
|
|
||||||
{file = "pycryptodome-3.15.0-pp27-pypy_73-macosx_10_9_x86_64.whl", hash = "sha256:9eaadc058106344a566dc51d3d3a758ab07f8edde013712bc8d22032a86b264f"},
|
|
||||||
{file = "pycryptodome-3.15.0-pp27-pypy_73-manylinux1_x86_64.whl", hash = "sha256:ff287bcba9fbeb4f1cccc1f2e90a08d691480735a611ee83c80a7d74ad72b9d9"},
|
|
||||||
{file = "pycryptodome-3.15.0-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:60b4faae330c3624cc5a546ba9cfd7b8273995a15de94ee4538130d74953ec2e"},
|
|
||||||
{file = "pycryptodome-3.15.0-pp27-pypy_73-win32.whl", hash = "sha256:a8f06611e691c2ce45ca09bbf983e2ff2f8f4f87313609d80c125aff9fad6e7f"},
|
|
||||||
{file = "pycryptodome-3.15.0-pp36-pypy36_pp73-macosx_10_9_x86_64.whl", hash = "sha256:b9cc96e274b253e47ad33ae1fccc36ea386f5251a823ccb50593a935db47fdd2"},
|
|
||||||
{file = "pycryptodome-3.15.0-pp36-pypy36_pp73-manylinux1_x86_64.whl", hash = "sha256:ecaaef2d21b365d9c5ca8427ffc10cebed9d9102749fd502218c23cb9a05feb5"},
|
|
||||||
{file = "pycryptodome-3.15.0-pp36-pypy36_pp73-manylinux2010_x86_64.whl", hash = "sha256:d2a39a66057ab191e5c27211a7daf8f0737f23acbf6b3562b25a62df65ffcb7b"},
|
|
||||||
{file = "pycryptodome-3.15.0-pp36-pypy36_pp73-win32.whl", hash = "sha256:9c772c485b27967514d0df1458b56875f4b6d025566bf27399d0c239ff1b369f"},
|
|
||||||
{file = "pycryptodome-3.15.0.tar.gz", hash = "sha256:9135dddad504592bcc18b0d2d95ce86c3a5ea87ec6447ef25cfedea12d6018b8"},
|
|
||||||
]
|
|
||||||
pymp4 = [
|
|
||||||
{file = "pymp4-1.2.0.tar.gz", hash = "sha256:4a3d2e0838cfe28cd3dc64f45379e16d91b0212192f87a3e28f3804372727456"},
|
|
||||||
]
|
|
||||||
pyyaml = [
|
|
||||||
{file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"},
|
|
||||||
{file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"},
|
|
||||||
{file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"},
|
|
||||||
{file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"},
|
|
||||||
{file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"},
|
|
||||||
{file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"},
|
|
||||||
{file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"},
|
|
||||||
{file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"},
|
|
||||||
{file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"},
|
|
||||||
{file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"},
|
|
||||||
{file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"},
|
|
||||||
{file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"},
|
|
||||||
{file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"},
|
|
||||||
{file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"},
|
|
||||||
{file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"},
|
|
||||||
{file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"},
|
|
||||||
{file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"},
|
|
||||||
{file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"},
|
|
||||||
{file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"},
|
|
||||||
{file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"},
|
|
||||||
{file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"},
|
|
||||||
{file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"},
|
|
||||||
{file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"},
|
|
||||||
{file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"},
|
|
||||||
{file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"},
|
|
||||||
{file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"},
|
|
||||||
{file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"},
|
|
||||||
{file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"},
|
|
||||||
{file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"},
|
|
||||||
{file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"},
|
|
||||||
{file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"},
|
|
||||||
{file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"},
|
|
||||||
{file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"},
|
|
||||||
]
|
|
||||||
requests = [
|
|
||||||
{file = "requests-2.28.1-py3-none-any.whl", hash = "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349"},
|
|
||||||
{file = "requests-2.28.1.tar.gz", hash = "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983"},
|
|
||||||
]
|
|
||||||
typing-extensions = [
|
|
||||||
{file = "typing_extensions-4.3.0-py3-none-any.whl", hash = "sha256:25642c956049920a5aa49edcdd6ab1e06d7e5d467fc00e0506c44ac86fbfca02"},
|
|
||||||
{file = "typing_extensions-4.3.0.tar.gz", hash = "sha256:e6d2677a32f47fc7eb2795db1dd15c1f34eff616bcaf2cfb5e997f854fa1c4a6"},
|
|
||||||
]
|
|
||||||
unidecode = [
|
|
||||||
{file = "Unidecode-1.3.4-py3-none-any.whl", hash = "sha256:afa04efcdd818a93237574791be9b2817d7077c25a068b00f8cff7baa4e59257"},
|
|
||||||
{file = "Unidecode-1.3.4.tar.gz", hash = "sha256:8e4352fb93d5a735c788110d2e7ac8e8031eb06ccbfe8d324ab71735015f9342"},
|
|
||||||
]
|
|
||||||
urllib3 = [
|
|
||||||
{file = "urllib3-1.26.10-py2.py3-none-any.whl", hash = "sha256:8298d6d56d39be0e3bc13c1c97d133f9b45d797169a0e11cdd0e0489d786f7ec"},
|
|
||||||
{file = "urllib3-1.26.10.tar.gz", hash = "sha256:879ba4d1e89654d9769ce13121e0f94310ea32e8d2f8cf587b77c08bbcdb30d6"},
|
|
||||||
]
|
|
||||||
yarl = [
|
|
||||||
{file = "yarl-1.7.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f2a8508f7350512434e41065684076f640ecce176d262a7d54f0da41d99c5a95"},
|
|
||||||
{file = "yarl-1.7.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:da6df107b9ccfe52d3a48165e48d72db0eca3e3029b5b8cb4fe6ee3cb870ba8b"},
|
|
||||||
{file = "yarl-1.7.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a1d0894f238763717bdcfea74558c94e3bc34aeacd3351d769460c1a586a8b05"},
|
|
||||||
{file = "yarl-1.7.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dfe4b95b7e00c6635a72e2d00b478e8a28bfb122dc76349a06e20792eb53a523"},
|
|
||||||
{file = "yarl-1.7.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c145ab54702334c42237a6c6c4cc08703b6aa9b94e2f227ceb3d477d20c36c63"},
|
|
||||||
{file = "yarl-1.7.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1ca56f002eaf7998b5fcf73b2421790da9d2586331805f38acd9997743114e98"},
|
|
||||||
{file = "yarl-1.7.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1d3d5ad8ea96bd6d643d80c7b8d5977b4e2fb1bab6c9da7322616fd26203d125"},
|
|
||||||
{file = "yarl-1.7.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:167ab7f64e409e9bdd99333fe8c67b5574a1f0495dcfd905bc7454e766729b9e"},
|
|
||||||
{file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:95a1873b6c0dd1c437fb3bb4a4aaa699a48c218ac7ca1e74b0bee0ab16c7d60d"},
|
|
||||||
{file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6152224d0a1eb254f97df3997d79dadd8bb2c1a02ef283dbb34b97d4f8492d23"},
|
|
||||||
{file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:5bb7d54b8f61ba6eee541fba4b83d22b8a046b4ef4d8eb7f15a7e35db2e1e245"},
|
|
||||||
{file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:9c1f083e7e71b2dd01f7cd7434a5f88c15213194df38bc29b388ccdf1492b739"},
|
|
||||||
{file = "yarl-1.7.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f44477ae29025d8ea87ec308539f95963ffdc31a82f42ca9deecf2d505242e72"},
|
|
||||||
{file = "yarl-1.7.2-cp310-cp310-win32.whl", hash = "sha256:cff3ba513db55cc6a35076f32c4cdc27032bd075c9faef31fec749e64b45d26c"},
|
|
||||||
{file = "yarl-1.7.2-cp310-cp310-win_amd64.whl", hash = "sha256:c9c6d927e098c2d360695f2e9d38870b2e92e0919be07dbe339aefa32a090265"},
|
|
||||||
{file = "yarl-1.7.2-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:9b4c77d92d56a4c5027572752aa35082e40c561eec776048330d2907aead891d"},
|
|
||||||
{file = "yarl-1.7.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c01a89a44bb672c38f42b49cdb0ad667b116d731b3f4c896f72302ff77d71656"},
|
|
||||||
{file = "yarl-1.7.2-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c19324a1c5399b602f3b6e7db9478e5b1adf5cf58901996fc973fe4fccd73eed"},
|
|
||||||
{file = "yarl-1.7.2-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3abddf0b8e41445426d29f955b24aeecc83fa1072be1be4e0d194134a7d9baee"},
|
|
||||||
{file = "yarl-1.7.2-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:6a1a9fe17621af43e9b9fcea8bd088ba682c8192d744b386ee3c47b56eaabb2c"},
|
|
||||||
{file = "yarl-1.7.2-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8b0915ee85150963a9504c10de4e4729ae700af11df0dc5550e6587ed7891e92"},
|
|
||||||
{file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:29e0656d5497733dcddc21797da5a2ab990c0cb9719f1f969e58a4abac66234d"},
|
|
||||||
{file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:bf19725fec28452474d9887a128e98dd67eee7b7d52e932e6949c532d820dc3b"},
|
|
||||||
{file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:d6f3d62e16c10e88d2168ba2d065aa374e3c538998ed04996cd373ff2036d64c"},
|
|
||||||
{file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:ac10bbac36cd89eac19f4e51c032ba6b412b3892b685076f4acd2de18ca990aa"},
|
|
||||||
{file = "yarl-1.7.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:aa32aaa97d8b2ed4e54dc65d241a0da1c627454950f7d7b1f95b13985afd6c5d"},
|
|
||||||
{file = "yarl-1.7.2-cp36-cp36m-win32.whl", hash = "sha256:87f6e082bce21464857ba58b569370e7b547d239ca22248be68ea5d6b51464a1"},
|
|
||||||
{file = "yarl-1.7.2-cp36-cp36m-win_amd64.whl", hash = "sha256:ac35ccde589ab6a1870a484ed136d49a26bcd06b6a1c6397b1967ca13ceb3913"},
|
|
||||||
{file = "yarl-1.7.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a467a431a0817a292121c13cbe637348b546e6ef47ca14a790aa2fa8cc93df63"},
|
|
||||||
{file = "yarl-1.7.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ab0c3274d0a846840bf6c27d2c60ba771a12e4d7586bf550eefc2df0b56b3b4"},
|
|
||||||
{file = "yarl-1.7.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d260d4dc495c05d6600264a197d9d6f7fc9347f21d2594926202fd08cf89a8ba"},
|
|
||||||
{file = "yarl-1.7.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fc4dd8b01a8112809e6b636b00f487846956402834a7fd59d46d4f4267181c41"},
|
|
||||||
{file = "yarl-1.7.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c1164a2eac148d85bbdd23e07dfcc930f2e633220f3eb3c3e2a25f6148c2819e"},
|
|
||||||
{file = "yarl-1.7.2-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:67e94028817defe5e705079b10a8438b8cb56e7115fa01640e9c0bb3edf67332"},
|
|
||||||
{file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:89ccbf58e6a0ab89d487c92a490cb5660d06c3a47ca08872859672f9c511fc52"},
|
|
||||||
{file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:8cce6f9fa3df25f55521fbb5c7e4a736683148bcc0c75b21863789e5185f9185"},
|
|
||||||
{file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:211fcd65c58bf250fb994b53bc45a442ddc9f441f6fec53e65de8cba48ded986"},
|
|
||||||
{file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c10ea1e80a697cf7d80d1ed414b5cb8f1eec07d618f54637067ae3c0334133c4"},
|
|
||||||
{file = "yarl-1.7.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:52690eb521d690ab041c3919666bea13ab9fbff80d615ec16fa81a297131276b"},
|
|
||||||
{file = "yarl-1.7.2-cp37-cp37m-win32.whl", hash = "sha256:695ba021a9e04418507fa930d5f0704edbce47076bdcfeeaba1c83683e5649d1"},
|
|
||||||
{file = "yarl-1.7.2-cp37-cp37m-win_amd64.whl", hash = "sha256:c17965ff3706beedafd458c452bf15bac693ecd146a60a06a214614dc097a271"},
|
|
||||||
{file = "yarl-1.7.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:fce78593346c014d0d986b7ebc80d782b7f5e19843ca798ed62f8e3ba8728576"},
|
|
||||||
{file = "yarl-1.7.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c2a1ac41a6aa980db03d098a5531f13985edcb451bcd9d00670b03129922cd0d"},
|
|
||||||
{file = "yarl-1.7.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:39d5493c5ecd75c8093fa7700a2fb5c94fe28c839c8e40144b7ab7ccba6938c8"},
|
|
||||||
{file = "yarl-1.7.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1eb6480ef366d75b54c68164094a6a560c247370a68c02dddb11f20c4c6d3c9d"},
|
|
||||||
{file = "yarl-1.7.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ba63585a89c9885f18331a55d25fe81dc2d82b71311ff8bd378fc8004202ff6"},
|
|
||||||
{file = "yarl-1.7.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e39378894ee6ae9f555ae2de332d513a5763276a9265f8e7cbaeb1b1ee74623a"},
|
|
||||||
{file = "yarl-1.7.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c0910c6b6c31359d2f6184828888c983d54d09d581a4a23547a35f1d0b9484b1"},
|
|
||||||
{file = "yarl-1.7.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:6feca8b6bfb9eef6ee057628e71e1734caf520a907b6ec0d62839e8293e945c0"},
|
|
||||||
{file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8300401dc88cad23f5b4e4c1226f44a5aa696436a4026e456fe0e5d2f7f486e6"},
|
|
||||||
{file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:788713c2896f426a4e166b11f4ec538b5736294ebf7d5f654ae445fd44270832"},
|
|
||||||
{file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:fd547ec596d90c8676e369dd8a581a21227fe9b4ad37d0dc7feb4ccf544c2d59"},
|
|
||||||
{file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:737e401cd0c493f7e3dd4db72aca11cfe069531c9761b8ea474926936b3c57c8"},
|
|
||||||
{file = "yarl-1.7.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:baf81561f2972fb895e7844882898bda1eef4b07b5b385bcd308d2098f1a767b"},
|
|
||||||
{file = "yarl-1.7.2-cp38-cp38-win32.whl", hash = "sha256:ede3b46cdb719c794427dcce9d8beb4abe8b9aa1e97526cc20de9bd6583ad1ef"},
|
|
||||||
{file = "yarl-1.7.2-cp38-cp38-win_amd64.whl", hash = "sha256:cc8b7a7254c0fc3187d43d6cb54b5032d2365efd1df0cd1749c0c4df5f0ad45f"},
|
|
||||||
{file = "yarl-1.7.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:580c1f15500e137a8c37053e4cbf6058944d4c114701fa59944607505c2fe3a0"},
|
|
||||||
{file = "yarl-1.7.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3ec1d9a0d7780416e657f1e405ba35ec1ba453a4f1511eb8b9fbab81cb8b3ce1"},
|
|
||||||
{file = "yarl-1.7.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3bf8cfe8856708ede6a73907bf0501f2dc4e104085e070a41f5d88e7faf237f3"},
|
|
||||||
{file = "yarl-1.7.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1be4bbb3d27a4e9aa5f3df2ab61e3701ce8fcbd3e9846dbce7c033a7e8136746"},
|
|
||||||
{file = "yarl-1.7.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:534b047277a9a19d858cde163aba93f3e1677d5acd92f7d10ace419d478540de"},
|
|
||||||
{file = "yarl-1.7.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6ddcd80d79c96eb19c354d9dca95291589c5954099836b7c8d29278a7ec0bda"},
|
|
||||||
{file = "yarl-1.7.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:9bfcd43c65fbb339dc7086b5315750efa42a34eefad0256ba114cd8ad3896f4b"},
|
|
||||||
{file = "yarl-1.7.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f64394bd7ceef1237cc604b5a89bf748c95982a84bcd3c4bbeb40f685c810794"},
|
|
||||||
{file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:044daf3012e43d4b3538562da94a88fb12a6490652dbc29fb19adfa02cf72eac"},
|
|
||||||
{file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:368bcf400247318382cc150aaa632582d0780b28ee6053cd80268c7e72796dec"},
|
|
||||||
{file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:bab827163113177aee910adb1f48ff7af31ee0289f434f7e22d10baf624a6dfe"},
|
|
||||||
{file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0cba38120db72123db7c58322fa69e3c0efa933040ffb586c3a87c063ec7cae8"},
|
|
||||||
{file = "yarl-1.7.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:59218fef177296451b23214c91ea3aba7858b4ae3306dde120224cfe0f7a6ee8"},
|
|
||||||
{file = "yarl-1.7.2-cp39-cp39-win32.whl", hash = "sha256:1edc172dcca3f11b38a9d5c7505c83c1913c0addc99cd28e993efeaafdfaa18d"},
|
|
||||||
{file = "yarl-1.7.2-cp39-cp39-win_amd64.whl", hash = "sha256:797c2c412b04403d2da075fb93c123df35239cd7b4cc4e0cd9e5839b73f52c58"},
|
|
||||||
{file = "yarl-1.7.2.tar.gz", hash = "sha256:45399b46d60c253327a460e99856752009fcee5f5d3c80b2f7c0cae1c38d56dd"},
|
|
||||||
]
|
|
||||||
zipp = [
|
|
||||||
{file = "zipp-3.8.1-py3-none-any.whl", hash = "sha256:47c40d7fe183a6f21403a199b3e4192cca5774656965b0a4988ad2f8feb5f009"},
|
|
||||||
{file = "zipp-3.8.1.tar.gz", hash = "sha256:05b45f1ee8f807d0cc928485ca40a07cb491cf092ff587c0df9cb1fd154848d2"},
|
|
||||||
]
|
|
||||||
142
pyproject.toml
142
pyproject.toml
@ -1,45 +1,97 @@
|
|||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core>=1.0.0"]
|
requires = ["hatchling"]
|
||||||
build-backend = "poetry.core.masonry.api"
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
[tool.poetry]
|
[project]
|
||||||
name = "pywidevine"
|
name = "pywidevine"
|
||||||
version = "1.5.1"
|
version = "1.9.0"
|
||||||
description = "Widevine CDM (Content Decryption Module) implementation in Python."
|
description = "Widevine CDM (Content Decryption Module) implementation in Python."
|
||||||
authors = ["rlaphoenix <rlaphoenix@pm.me>"]
|
authors = [{ name = "rlaphoenix", email = "rlaphoenix@pm.me" }]
|
||||||
license = "GPL-3.0-only"
|
requires-python = ">=3.9"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
repository = "https://github.com/rlaphoenix/pywidevine"
|
license = "GPL-3.0-only"
|
||||||
keywords = ["widevine", "drm", "google"]
|
keywords = [
|
||||||
classifiers = [
|
"python",
|
||||||
"Development Status :: 5 - Production/Stable",
|
"drm",
|
||||||
"Intended Audience :: Developers",
|
"widevine",
|
||||||
"Intended Audience :: End Users/Desktop",
|
"google",
|
||||||
"Natural Language :: English",
|
]
|
||||||
"Operating System :: OS Independent",
|
classifiers = [
|
||||||
"Topic :: Multimedia :: Video",
|
"Development Status :: 5 - Production/Stable",
|
||||||
"Topic :: Security :: Cryptography"
|
"Intended Audience :: Developers",
|
||||||
]
|
"Intended Audience :: End Users/Desktop",
|
||||||
|
"Natural Language :: English",
|
||||||
[tool.poetry.urls]
|
"Operating System :: OS Independent",
|
||||||
"Bug Tracker" = "https://github.com/rlaphoenix/pywidevine/issues"
|
"Topic :: Multimedia :: Video",
|
||||||
"Forums" = "https://github.com/rlaphoenix/pywidevine/discussions"
|
"Topic :: Security :: Cryptography",
|
||||||
"Changelog" = "https://github.com/rlaphoenix/pywidevine/blob/master/CHANGELOG.md"
|
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||||
|
]
|
||||||
[tool.poetry.dependencies]
|
dependencies = [
|
||||||
python = ">=3.7,<3.11"
|
"protobuf~=6.33.0",
|
||||||
protobuf = "4.21.6"
|
"pymp4~=1.4.0",
|
||||||
pymp4 = "^1.2.0"
|
"pycryptodome~=3.23.0",
|
||||||
pycryptodome = "^3.15.0"
|
"click~=8.1.7",
|
||||||
click = "^8.1.3"
|
"requests~=2.32.5",
|
||||||
requests = "^2.28.1"
|
"Unidecode~=1.3.7",
|
||||||
lxml = ">=4.9.1"
|
"PyYAML~=6.0.3",
|
||||||
Unidecode = "^1.3.4"
|
]
|
||||||
aiohttp = {version = "^3.8.1", optional = true}
|
|
||||||
PyYAML = {version = "^6.0", optional = true}
|
[project.optional-dependencies]
|
||||||
|
serve = ["aiohttp~=3.13.1"]
|
||||||
[tool.poetry.extras]
|
|
||||||
serve = ["aiohttp", "PyYAML"]
|
[project.urls]
|
||||||
|
Repository = "https://github.com/devine-dl/pywidevine"
|
||||||
[tool.poetry.scripts]
|
Issues = "https://github.com/devine-dl/pywidevine/issues"
|
||||||
pywidevine = "pywidevine.main:main"
|
Discussions = "https://github.com/devine-dl/pywidevine/discussions"
|
||||||
|
Changelog = "https://github.com/devine-dl/pywidevine/blob/master/CHANGELOG.md"
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
pywidevine = "pywidevine.main:main"
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"pre-commit~=4.3.0",
|
||||||
|
"mypy~=1.18.2",
|
||||||
|
"mypy-protobuf~=3.6.0",
|
||||||
|
"types-protobuf~=6.32.1.20250918",
|
||||||
|
"types-requests~=2.32.4.20250913",
|
||||||
|
"types-PyYAML~=6.0.12.20250915",
|
||||||
|
"isort~=6.1.0",
|
||||||
|
"ruff~=0.14.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.sdist]
|
||||||
|
include = [
|
||||||
|
"pywidevine",
|
||||||
|
"CHANGELOG.md",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["pywidevine"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
extend-exclude = [
|
||||||
|
"*_pb2.py",
|
||||||
|
"*.pyi",
|
||||||
|
]
|
||||||
|
force-exclude = true
|
||||||
|
line-length = 120
|
||||||
|
select = ["E4", "E7", "E9", "F", "W"]
|
||||||
|
|
||||||
|
[tool.ruff.extend-per-file-ignores]
|
||||||
|
"pywidevine/__init__.py" = ["F403"]
|
||||||
|
|
||||||
|
[tool.isort]
|
||||||
|
line_length = 118
|
||||||
|
extend_skip_glob = ["*_pb2.py", "*.pyi"]
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
check_untyped_defs = true
|
||||||
|
disallow_incomplete_defs = true
|
||||||
|
disallow_untyped_defs = true
|
||||||
|
exclude = [
|
||||||
|
'_pb2.pyi?$' # generated protobuffer files
|
||||||
|
]
|
||||||
|
follow_imports = "silent"
|
||||||
|
ignore_missing_imports = true
|
||||||
|
no_implicit_optional = true
|
||||||
|
|||||||
@ -5,5 +5,4 @@ from .pssh import *
|
|||||||
from .remotecdm import *
|
from .remotecdm import *
|
||||||
from .session import *
|
from .session import *
|
||||||
|
|
||||||
|
__version__ = "1.9.0"
|
||||||
__version__ = "1.5.1"
|
|
||||||
|
|||||||
@ -7,45 +7,58 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Union, Optional
|
from typing import Optional, Union
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from Crypto.Cipher import AES, PKCS1_OAEP
|
from Crypto.Cipher import AES, PKCS1_OAEP
|
||||||
from Crypto.Hash import SHA1, HMAC, SHA256, CMAC
|
from Crypto.Hash import CMAC, HMAC, SHA1, SHA256
|
||||||
from Crypto.PublicKey import RSA
|
from Crypto.PublicKey import RSA
|
||||||
from Crypto.Random import get_random_bytes
|
from Crypto.Random import get_random_bytes
|
||||||
from Crypto.Signature import pss
|
from Crypto.Signature import pss
|
||||||
from Crypto.Util import Padding
|
from Crypto.Util import Padding
|
||||||
from google.protobuf.message import DecodeError
|
from google.protobuf.message import DecodeError
|
||||||
|
|
||||||
from pywidevine.device import Device
|
from pywidevine.device import Device, DeviceTypes
|
||||||
from pywidevine.exceptions import TooManySessions, InvalidSession, InvalidLicenseType, SignatureMismatch, \
|
from pywidevine.exceptions import (InvalidContext, InvalidInitData, InvalidLicenseMessage, InvalidLicenseType,
|
||||||
InvalidInitData, InvalidLicenseMessage, NoKeysLoaded, InvalidContext
|
InvalidSession, NoKeysLoaded, SignatureMismatch, TooManySessions)
|
||||||
from pywidevine.key import Key
|
from pywidevine.key import Key
|
||||||
from pywidevine.license_protocol_pb2 import DrmCertificate, SignedMessage, SignedDrmCertificate, LicenseType, \
|
from pywidevine.license_protocol_pb2 import (ClientIdentification, DrmCertificate, EncryptedClientIdentification,
|
||||||
LicenseRequest, ProtocolVersion, ClientIdentification, EncryptedClientIdentification, License
|
License, LicenseRequest, LicenseType, SignedDrmCertificate,
|
||||||
|
SignedMessage)
|
||||||
from pywidevine.pssh import PSSH
|
from pywidevine.pssh import PSSH
|
||||||
from pywidevine.session import Session
|
from pywidevine.session import Session
|
||||||
from pywidevine.utils import get_binary_path
|
from pywidevine.utils import get_binary_path
|
||||||
|
|
||||||
|
|
||||||
class Cdm:
|
class Cdm:
|
||||||
system_id = b"\xed\xef\x8b\xa9\x79\xd6\x4a\xce\xa3\xc8\x27\xdc\xd5\x1d\x21\xed"
|
uuid = UUID(bytes=b"\xed\xef\x8b\xa9\x79\xd6\x4a\xce\xa3\xc8\x27\xdc\xd5\x1d\x21\xed")
|
||||||
uuid = UUID(bytes=system_id)
|
|
||||||
urn = f"urn:uuid:{uuid}"
|
urn = f"urn:uuid:{uuid}"
|
||||||
key_format = urn
|
key_format = urn
|
||||||
service_certificate_challenge = b"\x08\x04"
|
service_certificate_challenge = b"\x08\x04"
|
||||||
common_privacy_cert = ("CAUSxwUKwQIIAxIQFwW5F8wSBIaLBjM6L3cqjBiCtIKSBSKOAjCCAQoCggEBAJntWzsyfateJO/DtiqVtZhSCtW8y"
|
common_privacy_cert = (
|
||||||
"zdQPgZFuBTYdrjfQFEEQa2M462xG7iMTnJaXkqeB5UpHVhYQCOn4a8OOKkSeTkwCGELbxWMh4x+Ib/7/up34QGeHl"
|
# Used by Google's production license server (license.google.com)
|
||||||
"eB6KRfRiY9FOYOgFioYHrc4E+shFexN6jWfM3rM3BdmDoh+07svUoQykdJDKR+ql1DghjduvHK3jOS8T1v+2RC/TH"
|
# Not publicly accessible directly, but a lot of services have their own gateways to it
|
||||||
"hv0CwxgTRxLpMlSCkv5fuvWCSmvzu9Vu69WTi0Ods18Vcc6CCuZYSC4NZ7c4kcHCCaA1vZ8bYLErF8xNEkKdO7Dev"
|
"CAUSxwUKwQIIAxIQFwW5F8wSBIaLBjM6L3cqjBiCtIKSBSKOAjCCAQoCggEBAJntWzsyfateJO/DtiqVtZhSCtW8yzdQPgZFuBTYdrjfQFEE"
|
||||||
"Sy8BDFnoKEPiWC8La59dsPxebt9k+9MItHEbzxJQAZyfWgkCAwEAAToUbGljZW5zZS53aWRldmluZS5jb20SgAOuN"
|
"Qa2M462xG7iMTnJaXkqeB5UpHVhYQCOn4a8OOKkSeTkwCGELbxWMh4x+Ib/7/up34QGeHleB6KRfRiY9FOYOgFioYHrc4E+shFexN6jWfM3r"
|
||||||
"HMUtag1KX8nE4j7e7jLUnfSSYI83dHaMLkzOVEes8y96gS5RLknwSE0bv296snUE5F+bsF2oQQ4RgpQO8GVK5uk5M"
|
"M3BdmDoh+07svUoQykdJDKR+ql1DghjduvHK3jOS8T1v+2RC/THhv0CwxgTRxLpMlSCkv5fuvWCSmvzu9Vu69WTi0Ods18Vcc6CCuZYSC4NZ"
|
||||||
"4PxL/CCpgIqq9L/NGcHc/N9XTMrCjRtBBBbPneiAQwHL2zNMr80NQJeEI6ZC5UYT3wr8+WykqSSdhV5Cs6cD7xdn9"
|
"7c4kcHCCaA1vZ8bYLErF8xNEkKdO7DevSy8BDFnoKEPiWC8La59dsPxebt9k+9MItHEbzxJQAZyfWgkCAwEAAToUbGljZW5zZS53aWRldmlu"
|
||||||
"qm9Nta/gr52u/DLpP3lnSq8x2/rZCR7hcQx+8pSJmthn8NpeVQ/ypy727+voOGlXnVaPHvOZV+WRvWCq5z3CqCLl5"
|
"ZS5jb20SgAOuNHMUtag1KX8nE4j7e7jLUnfSSYI83dHaMLkzOVEes8y96gS5RLknwSE0bv296snUE5F+bsF2oQQ4RgpQO8GVK5uk5M4PxL/C"
|
||||||
"+Gf2Ogsrf9s2LFvE7NVV2FvKqcWTw4PIV9Sdqrd+QLeFHd/SSZiAjjWyWOddeOrAyhb3BHMEwg2T7eTo/xxvF+YkP"
|
"CpgIqq9L/NGcHc/N9XTMrCjRtBBBbPneiAQwHL2zNMr80NQJeEI6ZC5UYT3wr8+WykqSSdhV5Cs6cD7xdn9qm9Nta/gr52u/DLpP3lnSq8x2"
|
||||||
"j89qPwXCYcOxF+6gjomPwzvofcJOxkJkoMmMzcFBDopvab5tDQsyN9UPLGhGC98X/8z8QSQ+spbJTYLdgFenFoGq4"
|
"/rZCR7hcQx+8pSJmthn8NpeVQ/ypy727+voOGlXnVaPHvOZV+WRvWCq5z3CqCLl5+Gf2Ogsrf9s2LFvE7NVV2FvKqcWTw4PIV9Sdqrd+QLeF"
|
||||||
"7gLwDS6NWYYQSqzE3Udf2W7pzk4ybyG4PHBYV3s4cyzdq8amvtE/sNSdOKReuHpfQ=")
|
"Hd/SSZiAjjWyWOddeOrAyhb3BHMEwg2T7eTo/xxvF+YkPj89qPwXCYcOxF+6gjomPwzvofcJOxkJkoMmMzcFBDopvab5tDQsyN9UPLGhGC98"
|
||||||
|
"X/8z8QSQ+spbJTYLdgFenFoGq47gLwDS6NWYYQSqzE3Udf2W7pzk4ybyG4PHBYV3s4cyzdq8amvtE/sNSdOKReuHpfQ=")
|
||||||
|
staging_privacy_cert = (
|
||||||
|
# Used by Google's staging license server (staging.google.com)
|
||||||
|
# This can be publicly accessed without authentication using https://cwip-shaka-proxy.appspot.com/no_auth
|
||||||
|
"CAUSxQUKvwIIAxIQKHA0VMAI9jYYredEPbbEyBiL5/mQBSKOAjCCAQoCggEBALUhErjQXQI/zF2V4sJRwcZJtBd82NK+7zVbsGdD3mYePSq8"
|
||||||
|
"MYK3mUbVX9wI3+lUB4FemmJ0syKix/XgZ7tfCsB6idRa6pSyUW8HW2bvgR0NJuG5priU8rmFeWKqFxxPZmMNPkxgJxiJf14e+baq9a1Nuip+"
|
||||||
|
"FBdt8TSh0xhbWiGKwFpMQfCB7/+Ao6BAxQsJu8dA7tzY8U1nWpGYD5LKfdxkagatrVEB90oOSYzAHwBTK6wheFC9kF6QkjZWt9/v70JIZ2fz"
|
||||||
|
"PvYoPU9CVKtyWJOQvuVYCPHWaAgNRdiTwryi901goMDQoJk87wFgRwMzTDY4E5SGvJ2vJP1noH+a2UMCAwEAAToSc3RhZ2luZy5nb29nbGUu"
|
||||||
|
"Y29tEoADmD4wNSZ19AunFfwkm9rl1KxySaJmZSHkNlVzlSlyH/iA4KrvxeJ7yYDa6tq/P8OG0ISgLIJTeEjMdT/0l7ARp9qXeIoA4qprhM19"
|
||||||
|
"ccB6SOv2FgLMpaPzIDCnKVww2pFbkdwYubyVk7jei7UPDe3BKTi46eA5zd4Y+oLoG7AyYw/pVdhaVmzhVDAL9tTBvRJpZjVrKH1lexjOY9Dv"
|
||||||
|
"1F/FJp6X6rEctWPlVkOyb/SfEJwhAa/K81uDLyiPDZ1Flg4lnoX7XSTb0s+Cdkxd2b9yfvvpyGH4aTIfat4YkF9Nkvmm2mU224R1hx0WjocL"
|
||||||
|
"sjA89wxul4TJPS3oRa2CYr5+DU4uSgdZzvgtEJ0lksckKfjAF0K64rPeytvDPD5fS69eFuy3Tq26/LfGcF96njtvOUA4P5xRFtICogySKe6W"
|
||||||
|
"nCUZcYMDtQ0BMMM1LgawFNg4VA+KDCJ8ABHg9bOOTimO0sswHrRWSWX1XF15dXolCk65yEqz5lOfa2/fVomeopkU")
|
||||||
root_signed_cert = SignedDrmCertificate()
|
root_signed_cert = SignedDrmCertificate()
|
||||||
root_signed_cert.ParseFromString(base64.b64decode(
|
root_signed_cert.ParseFromString(base64.b64decode(
|
||||||
"CpwDCAASAQAY3ZSIiwUijgMwggGKAoIBgQC0/jnDZZAD2zwRlwnoaM3yw16b8udNI7EQ24dl39z7nzWgVwNTTPZtNX2meNuzNtI/nECplSZy"
|
"CpwDCAASAQAY3ZSIiwUijgMwggGKAoIBgQC0/jnDZZAD2zwRlwnoaM3yw16b8udNI7EQ24dl39z7nzWgVwNTTPZtNX2meNuzNtI/nECplSZy"
|
||||||
@ -66,7 +79,7 @@ class Cdm:
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
device_type: Union[Device.Types, str],
|
device_type: Union[DeviceTypes, str],
|
||||||
system_id: int,
|
system_id: int,
|
||||||
security_level: int,
|
security_level: int,
|
||||||
client_id: ClientIdentification,
|
client_id: ClientIdentification,
|
||||||
@ -76,9 +89,9 @@ class Cdm:
|
|||||||
if not device_type:
|
if not device_type:
|
||||||
raise ValueError("Device Type must be provided")
|
raise ValueError("Device Type must be provided")
|
||||||
if isinstance(device_type, str):
|
if isinstance(device_type, str):
|
||||||
device_type = Device.Types[device_type]
|
device_type = DeviceTypes[device_type]
|
||||||
if not isinstance(device_type, Device.Types):
|
if not isinstance(device_type, DeviceTypes):
|
||||||
raise TypeError(f"Expected device_type to be a {Device.Types!r} not {device_type!r}")
|
raise TypeError(f"Expected device_type to be a {DeviceTypes!r} not {device_type!r}")
|
||||||
|
|
||||||
if not system_id:
|
if not system_id:
|
||||||
raise ValueError("System ID must be provided")
|
raise ValueError("System ID must be provided")
|
||||||
@ -151,7 +164,7 @@ class Cdm:
|
|||||||
raise InvalidSession(f"Session identifier {session_id!r} is invalid.")
|
raise InvalidSession(f"Session identifier {session_id!r} is invalid.")
|
||||||
del self.__sessions[session_id]
|
del self.__sessions[session_id]
|
||||||
|
|
||||||
def set_service_certificate(self, session_id: bytes, certificate: Optional[Union[bytes, str]]) -> str:
|
def set_service_certificate(self, session_id: bytes, certificate: Optional[Union[bytes, str]]) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Set a Service Privacy Certificate for Privacy Mode. (optional but recommended)
|
Set a Service Privacy Certificate for Privacy Mode. (optional but recommended)
|
||||||
|
|
||||||
@ -178,7 +191,8 @@ class Cdm:
|
|||||||
match the underlying DrmCertificate.
|
match the underlying DrmCertificate.
|
||||||
|
|
||||||
Returns the Service Provider ID of the verified DrmCertificate if successful.
|
Returns the Service Provider ID of the verified DrmCertificate if successful.
|
||||||
If certificate is None, it will return the now unset certificate's Provider ID.
|
If certificate is None, it will return the now-unset certificate's Provider ID,
|
||||||
|
or None if no certificate was set yet.
|
||||||
"""
|
"""
|
||||||
session = self.__sessions.get(session_id)
|
session = self.__sessions.get(session_id)
|
||||||
if not session:
|
if not session:
|
||||||
@ -208,7 +222,11 @@ class Cdm:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
signed_message.ParseFromString(certificate)
|
signed_message.ParseFromString(certificate)
|
||||||
if signed_message.SerializeToString() == certificate:
|
if all(
|
||||||
|
# See https://github.com/devine-dl/pywidevine/issues/41
|
||||||
|
bytes(chunk) == signed_message.SerializeToString()
|
||||||
|
for chunk in zip(*[iter(certificate)] * len(signed_message.SerializeToString()))
|
||||||
|
):
|
||||||
signed_drm_certificate.ParseFromString(signed_message.msg)
|
signed_drm_certificate.ParseFromString(signed_message.msg)
|
||||||
else:
|
else:
|
||||||
signed_drm_certificate.ParseFromString(certificate)
|
signed_drm_certificate.ParseFromString(certificate)
|
||||||
@ -262,7 +280,7 @@ class Cdm:
|
|||||||
self,
|
self,
|
||||||
session_id: bytes,
|
session_id: bytes,
|
||||||
pssh: PSSH,
|
pssh: PSSH,
|
||||||
type_: Union[int, str] = LicenseType.STREAMING,
|
license_type: str = "STREAMING",
|
||||||
privacy_mode: bool = True
|
privacy_mode: bool = True
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
"""
|
"""
|
||||||
@ -271,8 +289,10 @@ class Cdm:
|
|||||||
Parameters:
|
Parameters:
|
||||||
session_id: Session identifier.
|
session_id: Session identifier.
|
||||||
pssh: PSSH Object to get the init data from.
|
pssh: PSSH Object to get the init data from.
|
||||||
type_: Type of License you wish to exchange, often `STREAMING`. The `OFFLINE`
|
license_type: Type of License you wish to exchange, often `STREAMING`.
|
||||||
Licenses are for Offline licensing of Downloaded content.
|
- "STREAMING": Normal one-time-use license.
|
||||||
|
- "OFFLINE": Offline-use licence, usually for Downloaded content.
|
||||||
|
- "AUTOMATIC": License type decision is left to provider.
|
||||||
privacy_mode: Encrypt the Client ID using the Privacy Certificate. If the
|
privacy_mode: Encrypt the Client ID using the Privacy Certificate. If the
|
||||||
privacy certificate is not set yet, this does nothing.
|
privacy certificate is not set yet, this does nothing.
|
||||||
|
|
||||||
@ -295,17 +315,15 @@ class Cdm:
|
|||||||
if not isinstance(pssh, PSSH):
|
if not isinstance(pssh, PSSH):
|
||||||
raise InvalidInitData(f"Expected pssh to be a {PSSH}, not {pssh!r}")
|
raise InvalidInitData(f"Expected pssh to be a {PSSH}, not {pssh!r}")
|
||||||
|
|
||||||
try:
|
if not isinstance(license_type, str):
|
||||||
if isinstance(type_, int):
|
raise InvalidLicenseType(f"Expected license_type to be a {str}, not {license_type!r}")
|
||||||
LicenseType.Name(int(type_))
|
if license_type not in LicenseType.keys():
|
||||||
elif isinstance(type_, str):
|
raise InvalidLicenseType(
|
||||||
type_ = LicenseType.Value(type_)
|
f"Invalid license_type value of '{license_type}'. "
|
||||||
elif not isinstance(type_, LicenseType):
|
f"Available values: {LicenseType.keys()}"
|
||||||
raise InvalidLicenseType()
|
)
|
||||||
except ValueError:
|
|
||||||
raise InvalidLicenseType(f"License Type {type_!r} is invalid")
|
|
||||||
|
|
||||||
if self.device_type == Device.Types.ANDROID:
|
if self.device_type == DeviceTypes.ANDROID:
|
||||||
# OEMCrypto's request_id seems to be in AES CTR Counter block form with no suffix
|
# OEMCrypto's request_id seems to be in AES CTR Counter block form with no suffix
|
||||||
# Bytes 5-8 does not seem random, in real tests they have been consecutive \x00 or \xFF
|
# Bytes 5-8 does not seem random, in real tests they have been consecutive \x00 or \xFF
|
||||||
# Real example: A0DCE548000000000500000000000000
|
# Real example: A0DCE548000000000500000000000000
|
||||||
@ -317,35 +335,36 @@ class Cdm:
|
|||||||
else:
|
else:
|
||||||
request_id = get_random_bytes(16)
|
request_id = get_random_bytes(16)
|
||||||
|
|
||||||
license_request = LicenseRequest()
|
license_request = LicenseRequest(
|
||||||
license_request.type = LicenseRequest.RequestType.Value("NEW")
|
client_id=(
|
||||||
license_request.request_time = int(time.time())
|
self.__client_id
|
||||||
license_request.protocol_version = ProtocolVersion.Value("VERSION_2_1")
|
) if not (session.service_certificate and privacy_mode) else None,
|
||||||
license_request.key_control_nonce = random.randrange(1, 2 ** 31)
|
encrypted_client_id=self.encrypt_client_id(
|
||||||
|
|
||||||
# pssh_data may be either a WidevineCencHeader or custom data
|
|
||||||
# we have to assume the pssh.init_data value is valid, we cannot test
|
|
||||||
license_request.content_id.widevine_pssh_data.pssh_data.append(pssh.init_data)
|
|
||||||
license_request.content_id.widevine_pssh_data.license_type = type_
|
|
||||||
license_request.content_id.widevine_pssh_data.request_id = request_id
|
|
||||||
|
|
||||||
if session.service_certificate and privacy_mode:
|
|
||||||
# encrypt the client id for privacy mode
|
|
||||||
license_request.encrypted_client_id.CopyFrom(self.encrypt_client_id(
|
|
||||||
client_id=self.__client_id,
|
client_id=self.__client_id,
|
||||||
service_certificate=session.service_certificate
|
service_certificate=session.service_certificate
|
||||||
))
|
) if session.service_certificate and privacy_mode else None,
|
||||||
else:
|
content_id=LicenseRequest.ContentIdentification(
|
||||||
license_request.client_id.CopyFrom(self.__client_id)
|
widevine_pssh_data=LicenseRequest.ContentIdentification.WidevinePsshData(
|
||||||
|
pssh_data=[pssh.init_data], # either a WidevineCencHeader or custom data
|
||||||
|
license_type=license_type,
|
||||||
|
request_id=request_id
|
||||||
|
)
|
||||||
|
),
|
||||||
|
type="NEW",
|
||||||
|
request_time=int(time.time()),
|
||||||
|
protocol_version="VERSION_2_1",
|
||||||
|
key_control_nonce=random.randrange(1, 2 ** 31),
|
||||||
|
).SerializeToString()
|
||||||
|
|
||||||
license_message = SignedMessage()
|
signed_license_request = SignedMessage(
|
||||||
license_message.type = SignedMessage.MessageType.LICENSE_REQUEST
|
type="LICENSE_REQUEST",
|
||||||
license_message.msg = license_request.SerializeToString()
|
msg=license_request,
|
||||||
license_message.signature = self.__signer.sign(SHA1.new(license_message.msg))
|
signature=self.__signer.sign(SHA1.new(license_request))
|
||||||
|
).SerializeToString()
|
||||||
|
|
||||||
session.context[request_id] = self.derive_context(license_message.msg)
|
session.context[request_id] = self.derive_context(license_request)
|
||||||
|
|
||||||
return license_message.SerializeToString()
|
return signed_license_request
|
||||||
|
|
||||||
def parse_license(self, session_id: bytes, license_message: Union[SignedMessage, bytes, str]) -> None:
|
def parse_license(self, session_id: bytes, license_message: Union[SignedMessage, bytes, str]) -> None:
|
||||||
"""
|
"""
|
||||||
@ -395,7 +414,7 @@ class Cdm:
|
|||||||
if not isinstance(license_message, SignedMessage):
|
if not isinstance(license_message, SignedMessage):
|
||||||
raise InvalidLicenseMessage(f"Expecting license_response to be a SignedMessage, got {license_message!r}")
|
raise InvalidLicenseMessage(f"Expecting license_response to be a SignedMessage, got {license_message!r}")
|
||||||
|
|
||||||
if license_message.type != SignedMessage.MessageType.LICENSE:
|
if license_message.type != SignedMessage.MessageType.Value("LICENSE"):
|
||||||
raise InvalidLicenseMessage(
|
raise InvalidLicenseMessage(
|
||||||
f"Expecting a LICENSE message, not a "
|
f"Expecting a LICENSE message, not a "
|
||||||
f"'{SignedMessage.MessageType.Name(license_message.type)}' message."
|
f"'{SignedMessage.MessageType.Name(license_message.type)}' message."
|
||||||
@ -413,11 +432,14 @@ class Cdm:
|
|||||||
key=self.__decrypter.decrypt(license_message.session_key)
|
key=self.__decrypter.decrypt(license_message.session_key)
|
||||||
)
|
)
|
||||||
|
|
||||||
# explicitly use the original `license_message.msg` instead of a re-serializing from `licence`
|
# 1. Explicitly use the original `license_message.msg` instead of a re-serializing from `licence`
|
||||||
# as some differences may end up in the output due to differences in the proto
|
# as some differences may end up in the output due to differences in the proto schema
|
||||||
|
# 2. The oemcrypto_core_message (unknown purpose) is part of the signature algorithm starting with
|
||||||
|
# OEM Crypto API v16 and if available, must be prefixed when HMAC'ing a signature.
|
||||||
|
|
||||||
computed_signature = HMAC. \
|
computed_signature = HMAC. \
|
||||||
new(mac_key_server, digestmod=SHA256). \
|
new(mac_key_server, digestmod=SHA256). \
|
||||||
|
update(license_message.oemcrypto_core_message or b""). \
|
||||||
update(license_message.msg). \
|
update(license_message.msg). \
|
||||||
digest()
|
digest()
|
||||||
|
|
||||||
@ -471,7 +493,7 @@ class Cdm:
|
|||||||
output_file: Union[Path, str],
|
output_file: Union[Path, str],
|
||||||
temp_dir: Optional[Union[Path, str]] = None,
|
temp_dir: Optional[Union[Path, str]] = None,
|
||||||
exists_ok: bool = False
|
exists_ok: bool = False
|
||||||
):
|
) -> int:
|
||||||
"""
|
"""
|
||||||
Decrypt a Widevine-encrypted file using Shaka-packager.
|
Decrypt a Widevine-encrypted file using Shaka-packager.
|
||||||
Shaka-packager is much more stable than mp4decrypt.
|
Shaka-packager is much more stable than mp4decrypt.
|
||||||
@ -508,8 +530,7 @@ class Cdm:
|
|||||||
|
|
||||||
input_file = Path(input_file)
|
input_file = Path(input_file)
|
||||||
output_file = Path(output_file)
|
output_file = Path(output_file)
|
||||||
if temp_dir:
|
temp_dir_ = Path(temp_dir) if temp_dir else None
|
||||||
temp_dir = Path(temp_dir)
|
|
||||||
|
|
||||||
if not input_file.is_file():
|
if not input_file.is_file():
|
||||||
raise FileNotFoundError(f"Input file does not exist, {input_file}")
|
raise FileNotFoundError(f"Input file does not exist, {input_file}")
|
||||||
@ -543,18 +564,18 @@ class Cdm:
|
|||||||
])
|
])
|
||||||
]
|
]
|
||||||
|
|
||||||
if temp_dir:
|
if temp_dir_:
|
||||||
temp_dir.mkdir(parents=True, exist_ok=True)
|
temp_dir_.mkdir(parents=True, exist_ok=True)
|
||||||
args.extend(["--temp_dir", temp_dir])
|
args.extend(["--temp_dir", str(temp_dir_)])
|
||||||
|
|
||||||
subprocess.check_call([executable, *args])
|
return subprocess.check_call([executable, *args])
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def encrypt_client_id(
|
def encrypt_client_id(
|
||||||
client_id: ClientIdentification,
|
client_id: ClientIdentification,
|
||||||
service_certificate: Union[SignedDrmCertificate, DrmCertificate],
|
service_certificate: Union[SignedDrmCertificate, DrmCertificate],
|
||||||
key: bytes = None,
|
key: Optional[bytes] = None,
|
||||||
iv: bytes = None
|
iv: Optional[bytes] = None
|
||||||
) -> EncryptedClientIdentification:
|
) -> EncryptedClientIdentification:
|
||||||
"""Encrypt the Client ID with the Service's Privacy Certificate."""
|
"""Encrypt the Client ID with the Service's Privacy Certificate."""
|
||||||
privacy_key = key or get_random_bytes(16)
|
privacy_key = key or get_random_bytes(16)
|
||||||
@ -567,20 +588,19 @@ class Cdm:
|
|||||||
if not isinstance(service_certificate, DrmCertificate):
|
if not isinstance(service_certificate, DrmCertificate):
|
||||||
raise ValueError(f"Expecting Service Certificate to be a DrmCertificate, not {service_certificate!r}")
|
raise ValueError(f"Expecting Service Certificate to be a DrmCertificate, not {service_certificate!r}")
|
||||||
|
|
||||||
enc_client_id = EncryptedClientIdentification()
|
encrypted_client_id = EncryptedClientIdentification(
|
||||||
enc_client_id.provider_id = service_certificate.provider_id
|
provider_id=service_certificate.provider_id,
|
||||||
enc_client_id.service_certificate_serial_number = service_certificate.serial_number
|
service_certificate_serial_number=service_certificate.serial_number,
|
||||||
|
encrypted_client_id=AES.
|
||||||
enc_client_id.encrypted_client_id = AES. \
|
new(privacy_key, AES.MODE_CBC, privacy_iv).
|
||||||
new(privacy_key, AES.MODE_CBC, privacy_iv). \
|
encrypt(Padding.pad(client_id.SerializeToString(), 16)),
|
||||||
encrypt(Padding.pad(client_id.SerializeToString(), 16))
|
encrypted_client_id_iv=privacy_iv,
|
||||||
|
encrypted_privacy_key=PKCS1_OAEP.
|
||||||
enc_client_id.encrypted_privacy_key = PKCS1_OAEP. \
|
new(RSA.importKey(service_certificate.public_key)).
|
||||||
new(RSA.importKey(service_certificate.public_key)). \
|
|
||||||
encrypt(privacy_key)
|
encrypt(privacy_key)
|
||||||
enc_client_id.encrypted_client_id_iv = privacy_iv
|
)
|
||||||
|
|
||||||
return enc_client_id
|
return encrypted_client_id
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def derive_context(message: bytes) -> tuple[bytes, bytes]:
|
def derive_context(message: bytes) -> tuple[bytes, bytes]:
|
||||||
@ -635,4 +655,4 @@ class Cdm:
|
|||||||
return enc_key, mac_key_server, mac_key_client
|
return enc_key, mac_key_server, mac_key_client
|
||||||
|
|
||||||
|
|
||||||
__ALL__ = (Cdm,)
|
__all__ = ("Cdm",)
|
||||||
|
|||||||
@ -14,10 +14,10 @@ from construct import Padded, Padding, Struct, this
|
|||||||
from Crypto.PublicKey import RSA
|
from Crypto.PublicKey import RSA
|
||||||
from google.protobuf.message import DecodeError
|
from google.protobuf.message import DecodeError
|
||||||
|
|
||||||
from pywidevine.license_protocol_pb2 import ClientIdentification, FileHashes, SignedDrmCertificate, DrmCertificate
|
from pywidevine.license_protocol_pb2 import ClientIdentification, DrmCertificate, FileHashes, SignedDrmCertificate
|
||||||
|
|
||||||
|
|
||||||
class _Types(Enum):
|
class DeviceTypes(Enum):
|
||||||
CHROME = 1
|
CHROME = 1
|
||||||
ANDROID = 2
|
ANDROID = 2
|
||||||
|
|
||||||
@ -36,7 +36,7 @@ class _Structures:
|
|||||||
"version" / Const(Int8ub, 2),
|
"version" / Const(Int8ub, 2),
|
||||||
"type_" / CEnum(
|
"type_" / CEnum(
|
||||||
Int8ub,
|
Int8ub,
|
||||||
**{t.name: t.value for t in _Types}
|
**{t.name: t.value for t in DeviceTypes}
|
||||||
),
|
),
|
||||||
"security_level" / Int8ub,
|
"security_level" / Int8ub,
|
||||||
"flags" / Padded(1, COptional(BitStruct(
|
"flags" / Padded(1, COptional(BitStruct(
|
||||||
@ -55,7 +55,7 @@ class _Structures:
|
|||||||
"version" / Const(Int8ub, 1),
|
"version" / Const(Int8ub, 1),
|
||||||
"type_" / CEnum(
|
"type_" / CEnum(
|
||||||
Int8ub,
|
Int8ub,
|
||||||
**{t.name: t.value for t in _Types}
|
**{t.name: t.value for t in DeviceTypes}
|
||||||
),
|
),
|
||||||
"security_level" / Int8ub,
|
"security_level" / Int8ub,
|
||||||
"flags" / Padded(1, COptional(BitStruct(
|
"flags" / Padded(1, COptional(BitStruct(
|
||||||
@ -72,14 +72,13 @@ class _Structures:
|
|||||||
|
|
||||||
|
|
||||||
class Device:
|
class Device:
|
||||||
Types = _Types
|
|
||||||
Structures = _Structures
|
Structures = _Structures
|
||||||
supported_structure = Structures.v2
|
supported_structure = Structures.v2
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
*_: Any,
|
*_: Any,
|
||||||
type_: Types,
|
type_: DeviceTypes,
|
||||||
security_level: int,
|
security_level: int,
|
||||||
flags: Optional[dict],
|
flags: Optional[dict],
|
||||||
private_key: Optional[bytes],
|
private_key: Optional[bytes],
|
||||||
@ -103,9 +102,9 @@ class Device:
|
|||||||
if not private_key:
|
if not private_key:
|
||||||
raise ValueError("Private Key is required, the WVD does not contain one or is malformed.")
|
raise ValueError("Private Key is required, the WVD does not contain one or is malformed.")
|
||||||
|
|
||||||
self.type = self.Types[type_] if isinstance(type_, str) else type_
|
self.type = DeviceTypes[type_] if isinstance(type_, str) else type_
|
||||||
self.security_level = security_level
|
self.security_level = security_level
|
||||||
self.flags = flags
|
self.flags = flags or {}
|
||||||
self.private_key = RSA.importKey(private_key)
|
self.private_key = RSA.importKey(private_key)
|
||||||
self.client_id = ClientIdentification()
|
self.client_id = ClientIdentification()
|
||||||
try:
|
try:
|
||||||
@ -199,36 +198,36 @@ class Device:
|
|||||||
raise ValueError("Device Data does not seem to be a WVD file (v0).")
|
raise ValueError("Device Data does not seem to be a WVD file (v0).")
|
||||||
|
|
||||||
if header.version == 1: # v1 to v2
|
if header.version == 1: # v1 to v2
|
||||||
data = _Structures.v1.parse(data)
|
v1_struct = _Structures.v1.parse(data)
|
||||||
data.version = 2 # update version to 2 to allow loading
|
v1_struct.version = 2 # update version to 2 to allow loading
|
||||||
data.flags = Container() # blank flags that may have been used in v1
|
v1_struct.flags = Container() # blank flags that may have been used in v1
|
||||||
|
|
||||||
vmp = FileHashes()
|
vmp = FileHashes()
|
||||||
if data.vmp:
|
if v1_struct.vmp:
|
||||||
try:
|
try:
|
||||||
vmp.ParseFromString(data.vmp)
|
vmp.ParseFromString(v1_struct.vmp)
|
||||||
if vmp.SerializeToString() != data.vmp:
|
if vmp.SerializeToString() != v1_struct.vmp:
|
||||||
raise DecodeError("partial parse")
|
raise DecodeError("partial parse")
|
||||||
except DecodeError as e:
|
except DecodeError as e:
|
||||||
raise DecodeError(f"Failed to parse VMP data as FileHashes, {e}")
|
raise DecodeError(f"Failed to parse VMP data as FileHashes, {e}")
|
||||||
data.vmp = vmp
|
v1_struct.vmp = vmp
|
||||||
|
|
||||||
client_id = ClientIdentification()
|
client_id = ClientIdentification()
|
||||||
try:
|
try:
|
||||||
client_id.ParseFromString(data.client_id)
|
client_id.ParseFromString(v1_struct.client_id)
|
||||||
if client_id.SerializeToString() != data.client_id:
|
if client_id.SerializeToString() != v1_struct.client_id:
|
||||||
raise DecodeError("partial parse")
|
raise DecodeError("partial parse")
|
||||||
except DecodeError as e:
|
except DecodeError as e:
|
||||||
raise DecodeError(f"Failed to parse VMP data as FileHashes, {e}")
|
raise DecodeError(f"Failed to parse VMP data as FileHashes, {e}")
|
||||||
|
|
||||||
new_vmp_data = data.vmp.SerializeToString()
|
new_vmp_data = v1_struct.vmp.SerializeToString()
|
||||||
if client_id.vmp_data and client_id.vmp_data != new_vmp_data:
|
if client_id.vmp_data and client_id.vmp_data != new_vmp_data:
|
||||||
logging.getLogger("migrate").warning("Client ID already has Verified Media Path data")
|
logging.getLogger("migrate").warning("Client ID already has Verified Media Path data")
|
||||||
client_id.vmp_data = new_vmp_data
|
client_id.vmp_data = new_vmp_data
|
||||||
data.client_id = client_id.SerializeToString()
|
v1_struct.client_id = client_id.SerializeToString()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = _Structures.v2.build(data)
|
data = _Structures.v2.build(v1_struct)
|
||||||
except ConstructError as e:
|
except ConstructError as e:
|
||||||
raise ValueError(f"Migration failed, {e}")
|
raise ValueError(f"Migration failed, {e}")
|
||||||
|
|
||||||
@ -238,4 +237,4 @@ class Device:
|
|||||||
raise ValueError(f"Device Data seems to be corrupt or invalid, or migration failed, {e}")
|
raise ValueError(f"Device Data seems to be corrupt or invalid, or migration failed, {e}")
|
||||||
|
|
||||||
|
|
||||||
__ALL__ = (Device,)
|
__all__ = ("Device", "DeviceTypes")
|
||||||
|
|||||||
@ -27,7 +27,7 @@ class Key:
|
|||||||
def from_key_container(cls, key: License.KeyContainer, enc_key: bytes) -> Key:
|
def from_key_container(cls, key: License.KeyContainer, enc_key: bytes) -> Key:
|
||||||
"""Load Key from a KeyContainer object."""
|
"""Load Key from a KeyContainer object."""
|
||||||
permissions = []
|
permissions = []
|
||||||
if key.type == License.KeyContainer.KeyType.OPERATOR_SESSION:
|
if key.type == License.KeyContainer.KeyType.Value("OPERATOR_SESSION"):
|
||||||
for descriptor, value in key.operator_session_key_permissions.ListFields():
|
for descriptor, value in key.operator_session_key_permissions.ListFields():
|
||||||
if value == 1:
|
if value == 1:
|
||||||
permissions.append(descriptor.name)
|
permissions.append(descriptor.name)
|
||||||
@ -61,3 +61,6 @@ class Key:
|
|||||||
kid += b"\x00" * (16 - len(kid))
|
kid += b"\x00" * (16 - len(kid))
|
||||||
|
|
||||||
return UUID(bytes=kid)
|
return UUID(bytes=kid)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ("Key",)
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@ -1,3 +1,5 @@
|
|||||||
|
# mypy: ignore-errors
|
||||||
|
|
||||||
from google.protobuf.internal import containers as _containers
|
from google.protobuf.internal import containers as _containers
|
||||||
from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper
|
from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper
|
||||||
from google.protobuf import descriptor as _descriptor
|
from google.protobuf import descriptor as _descriptor
|
||||||
|
|||||||
@ -6,13 +6,15 @@ from zlib import crc32
|
|||||||
|
|
||||||
import click
|
import click
|
||||||
import requests
|
import requests
|
||||||
|
import yaml
|
||||||
from construct import ConstructError
|
from construct import ConstructError
|
||||||
from unidecode import unidecode, UnidecodeError
|
from google.protobuf.json_format import MessageToDict
|
||||||
|
from unidecode import UnidecodeError, unidecode
|
||||||
|
|
||||||
from pywidevine import __version__
|
from pywidevine import __version__
|
||||||
from pywidevine.cdm import Cdm
|
from pywidevine.cdm import Cdm
|
||||||
from pywidevine.device import Device
|
from pywidevine.device import Device, DeviceTypes
|
||||||
from pywidevine.license_protocol_pb2 import LicenseType, FileHashes
|
from pywidevine.license_protocol_pb2 import FileHashes, LicenseType
|
||||||
from pywidevine.pssh import PSSH
|
from pywidevine.pssh import PSSH
|
||||||
|
|
||||||
|
|
||||||
@ -24,27 +26,25 @@ def main(version: bool, debug: bool) -> None:
|
|||||||
logging.basicConfig(level=logging.DEBUG if debug else logging.INFO)
|
logging.basicConfig(level=logging.DEBUG if debug else logging.INFO)
|
||||||
log = logging.getLogger()
|
log = logging.getLogger()
|
||||||
|
|
||||||
copyright_years = 2022
|
|
||||||
current_year = datetime.now().year
|
current_year = datetime.now().year
|
||||||
if copyright_years != current_year:
|
copyright_years = f"2022-{current_year}"
|
||||||
copyright_years = f"{copyright_years}-{current_year}"
|
|
||||||
|
|
||||||
log.info("pywidevine version %s Copyright (c) %s rlaphoenix", __version__, copyright_years)
|
log.info("pywidevine version %s Copyright (c) %s rlaphoenix", __version__, copyright_years)
|
||||||
log.info("https://github.com/rlaphoenix/pywidevine")
|
log.info("https://github.com/devine-dl/pywidevine")
|
||||||
if version:
|
if version:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
@main.command(name="license")
|
@main.command(name="license")
|
||||||
@click.argument("device", type=Path)
|
@click.argument("device_path", type=Path)
|
||||||
@click.argument("pssh", type=str)
|
@click.argument("pssh", type=PSSH)
|
||||||
@click.argument("server", type=str)
|
@click.argument("server", type=str)
|
||||||
@click.option("-t", "--type", "type_", type=click.Choice(LicenseType.keys(), case_sensitive=False),
|
@click.option("-t", "--type", "license_type", type=click.Choice(LicenseType.keys(), case_sensitive=False),
|
||||||
default="STREAMING",
|
default="STREAMING",
|
||||||
help="License Type to Request.")
|
help="License Type to Request.")
|
||||||
@click.option("-p", "--privacy", is_flag=True, default=False,
|
@click.option("-p", "--privacy", is_flag=True, default=False,
|
||||||
help="Use Privacy Mode, off by default.")
|
help="Use Privacy Mode, off by default.")
|
||||||
def license_(device: Path, pssh: str, server: str, type_: str, privacy: bool):
|
def license_(device_path: Path, pssh: PSSH, server: str, license_type: str, privacy: bool) -> None:
|
||||||
"""
|
"""
|
||||||
Make a License Request for PSSH to SERVER using DEVICE.
|
Make a License Request for PSSH to SERVER using DEVICE.
|
||||||
It will return a list of all keys within the returned license.
|
It will return a list of all keys within the returned license.
|
||||||
@ -63,11 +63,8 @@ def license_(device: Path, pssh: str, server: str, type_: str, privacy: bool):
|
|||||||
"""
|
"""
|
||||||
log = logging.getLogger("license")
|
log = logging.getLogger("license")
|
||||||
|
|
||||||
# prepare pssh
|
|
||||||
pssh = PSSH(pssh)
|
|
||||||
|
|
||||||
# load device
|
# load device
|
||||||
device = Device.load(device)
|
device = Device.load(device_path)
|
||||||
log.info("[+] Loaded Device (%s L%s)", device.system_id, device.security_level)
|
log.info("[+] Loaded Device (%s L%s)", device.system_id, device.security_level)
|
||||||
log.debug(device)
|
log.debug(device)
|
||||||
|
|
||||||
@ -82,37 +79,36 @@ def license_(device: Path, pssh: str, server: str, type_: str, privacy: bool):
|
|||||||
|
|
||||||
if privacy:
|
if privacy:
|
||||||
# get service cert for license server via cert challenge
|
# get service cert for license server via cert challenge
|
||||||
service_cert = requests.post(
|
service_cert_res = requests.post(
|
||||||
url=server,
|
url=server,
|
||||||
data=cdm.service_certificate_challenge
|
data=cdm.service_certificate_challenge
|
||||||
)
|
)
|
||||||
if service_cert.status_code != 200:
|
if service_cert_res.status_code != 200:
|
||||||
log.error(
|
log.error(
|
||||||
"[-] Failed to get Service Privacy Certificate: [%s] %s",
|
"[-] Failed to get Service Privacy Certificate: [%s] %s",
|
||||||
service_cert.status_code,
|
service_cert_res.status_code,
|
||||||
service_cert.text
|
service_cert_res.text
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
service_cert = service_cert.content
|
service_cert = service_cert_res.content
|
||||||
provider_id = cdm.set_service_certificate(session_id, service_cert)
|
provider_id = cdm.set_service_certificate(session_id, service_cert)
|
||||||
log.info("[+] Set Service Privacy Certificate: %s", provider_id)
|
log.info("[+] Set Service Privacy Certificate: %s", provider_id)
|
||||||
log.debug(service_cert)
|
log.debug(service_cert)
|
||||||
|
|
||||||
# get license challenge
|
# get license challenge
|
||||||
license_type = LicenseType.Value(type_)
|
|
||||||
challenge = cdm.get_license_challenge(session_id, pssh, license_type, privacy_mode=True)
|
challenge = cdm.get_license_challenge(session_id, pssh, license_type, privacy_mode=True)
|
||||||
log.info("[+] Created License Request Message (Challenge)")
|
log.info("[+] Created License Request Message (Challenge)")
|
||||||
log.debug(challenge)
|
log.debug(challenge)
|
||||||
|
|
||||||
# send license challenge
|
# send license challenge
|
||||||
licence = requests.post(
|
license_res = requests.post(
|
||||||
url=server,
|
url=server,
|
||||||
data=challenge
|
data=challenge
|
||||||
)
|
)
|
||||||
if licence.status_code != 200:
|
if license_res.status_code != 200:
|
||||||
log.error("[-] Failed to send challenge: [%s] %s", licence.status_code, licence.text)
|
log.error("[-] Failed to send challenge: [%s] %s", license_res.status_code, license_res.text)
|
||||||
return
|
return
|
||||||
licence = licence.content
|
licence = license_res.content
|
||||||
log.info("[+] Got License Message")
|
log.info("[+] Got License Message")
|
||||||
log.debug(licence)
|
log.debug(licence)
|
||||||
|
|
||||||
@ -133,7 +129,7 @@ def license_(device: Path, pssh: str, server: str, type_: str, privacy: bool):
|
|||||||
@click.option("-p", "--privacy", is_flag=True, default=False,
|
@click.option("-p", "--privacy", is_flag=True, default=False,
|
||||||
help="Use Privacy Mode, off by default.")
|
help="Use Privacy Mode, off by default.")
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def test(ctx: click.Context, device: Path, privacy: bool):
|
def test(ctx: click.Context, device: Path, privacy: bool) -> None:
|
||||||
"""
|
"""
|
||||||
Test the CDM code by getting Content Keys for Bitmovin's Art of Motion example.
|
Test the CDM code by getting Content Keys for Bitmovin's Art of Motion example.
|
||||||
https://bitmovin.com/demos/drm
|
https://bitmovin.com/demos/drm
|
||||||
@ -144,8 +140,8 @@ def test(ctx: click.Context, device: Path, privacy: bool):
|
|||||||
"""
|
"""
|
||||||
# The PSSH is the same for all tracks both video and audio.
|
# The PSSH is the same for all tracks both video and audio.
|
||||||
# However, this might not be the case for all services/manifests.
|
# However, this might not be the case for all services/manifests.
|
||||||
pssh = "AAAAW3Bzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAADsIARIQ62dqu8s0Xpa" \
|
pssh = PSSH("AAAAW3Bzc2gAAAAA7e+LqXnWSs6jyCfc1R0h7QAAADsIARIQ62dqu8s0Xpa"
|
||||||
"7z2FmMPGj2hoNd2lkZXZpbmVfdGVzdCIQZmtqM2xqYVNkZmFsa3IzaioCSEQyAA=="
|
"7z2FmMPGj2hoNd2lkZXZpbmVfdGVzdCIQZmtqM2xqYVNkZmFsa3IzaioCSEQyAA==")
|
||||||
|
|
||||||
# This License Server requires no authorization at all, no cookies, no credentials
|
# This License Server requires no authorization at all, no cookies, no credentials
|
||||||
# nothing. This is often not the case for real services.
|
# nothing. This is often not the case for real services.
|
||||||
@ -153,28 +149,28 @@ def test(ctx: click.Context, device: Path, privacy: bool):
|
|||||||
|
|
||||||
# Specify OFFLINE if it's a PSSH for a download/offline mode title, e.g., the
|
# Specify OFFLINE if it's a PSSH for a download/offline mode title, e.g., the
|
||||||
# Download feature on Netflix Apps. Otherwise, use STREAMING or AUTOMATIC.
|
# Download feature on Netflix Apps. Otherwise, use STREAMING or AUTOMATIC.
|
||||||
license_type = LicenseType.STREAMING
|
license_type = "STREAMING"
|
||||||
|
|
||||||
# this runs the `cdm license` CLI-command code with the data we set above
|
# this runs the `cdm license` CLI-command code with the data we set above
|
||||||
# it will print information as it goes to the terminal
|
# it will print information as it goes to the terminal
|
||||||
ctx.invoke(
|
ctx.invoke(
|
||||||
license_,
|
license_,
|
||||||
device=device,
|
device_path=device,
|
||||||
pssh=pssh,
|
pssh=pssh,
|
||||||
server=license_server,
|
server=license_server,
|
||||||
type_=LicenseType.Name(license_type),
|
license_type=license_type,
|
||||||
privacy=privacy
|
privacy=privacy
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@main.command()
|
@main.command()
|
||||||
@click.option("-t", "--type", "type_", type=click.Choice([x.name for x in Device.Types], case_sensitive=False),
|
@click.option("-t", "--type", "type_", type=click.Choice([x.name for x in DeviceTypes], case_sensitive=False),
|
||||||
required=True, help="Device Type")
|
required=True, help="Device Type")
|
||||||
@click.option("-l", "--level", type=click.IntRange(1, 3), required=True, help="Device Security Level")
|
@click.option("-l", "--level", type=click.IntRange(1, 3), required=True, help="Device Security Level")
|
||||||
@click.option("-k", "--key", type=Path, required=True, help="Device RSA Private Key in PEM or DER format")
|
@click.option("-k", "--key", type=Path, required=True, help="Device RSA Private Key in PEM or DER format")
|
||||||
@click.option("-c", "--client_id", type=Path, required=True, help="Widevine ClientIdentification Blob file")
|
@click.option("-c", "--client_id", type=Path, required=True, help="Widevine ClientIdentification Blob file")
|
||||||
@click.option("-v", "--vmp", type=Path, default=None, help="Widevine FileHashes Blob file")
|
@click.option("-v", "--vmp", type=Path, default=None, help="Widevine FileHashes Blob file")
|
||||||
@click.option("-o", "--output", type=Path, default=None, help="Output Directory")
|
@click.option("-o", "--output", type=Path, default=None, help="Output Path or Directory")
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def create_device(
|
def create_device(
|
||||||
ctx: click.Context,
|
ctx: click.Context,
|
||||||
@ -199,7 +195,7 @@ def create_device(
|
|||||||
log = logging.getLogger("create-device")
|
log = logging.getLogger("create-device")
|
||||||
|
|
||||||
device = Device(
|
device = Device(
|
||||||
type_=Device.Types[type_.upper()],
|
type_=DeviceTypes[type_.upper()],
|
||||||
security_level=level,
|
security_level=level,
|
||||||
flags=None,
|
flags=None,
|
||||||
private_key=key.read_bytes(),
|
private_key=key.read_bytes(),
|
||||||
@ -228,7 +224,19 @@ def create_device(
|
|||||||
except UnidecodeError as e:
|
except UnidecodeError as e:
|
||||||
raise click.ClickException(f"Failed to sanitize name, {e}")
|
raise click.ClickException(f"Failed to sanitize name, {e}")
|
||||||
|
|
||||||
out_path = (output or Path.cwd()) / f"{name}_{device.system_id}_l{device.security_level}.wvd"
|
if output and output.suffix:
|
||||||
|
if output.suffix.lower() != ".wvd":
|
||||||
|
log.warning(f"Saving WVD with the file extension '{output.suffix}' but '.wvd' is recommended.")
|
||||||
|
out_path = output
|
||||||
|
else:
|
||||||
|
out_dir = output or Path.cwd()
|
||||||
|
out_path = out_dir / f"{name}_{device.system_id}_l{device.security_level}.wvd"
|
||||||
|
|
||||||
|
if out_path.exists():
|
||||||
|
log.error(f"A file already exists at the path '{out_path}', cannot overwrite.")
|
||||||
|
return
|
||||||
|
|
||||||
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
out_path.write_bytes(wvd_bin)
|
out_path.write_bytes(wvd_bin)
|
||||||
|
|
||||||
log.info("Created Widevine Device (.wvd) file, %s", out_path.name)
|
log.info("Created Widevine Device (.wvd) file, %s", out_path.name)
|
||||||
@ -247,6 +255,85 @@ def create_device(
|
|||||||
log.info(" + Saved to: %s", out_path.absolute())
|
log.info(" + Saved to: %s", out_path.absolute())
|
||||||
|
|
||||||
|
|
||||||
|
@main.command()
|
||||||
|
@click.argument("wvd_path", type=Path)
|
||||||
|
@click.option("-o", "--out_dir", type=Path, default=None, help="Output Directory")
|
||||||
|
@click.pass_context
|
||||||
|
def export_device(ctx: click.Context, wvd_path: Path, out_dir: Optional[Path] = None) -> None:
|
||||||
|
"""
|
||||||
|
Export a Widevine Device (.wvd) file to an RSA Private Key (PEM and DER) and Client ID Blob.
|
||||||
|
Optionally also a VMP (Verified Media Path) Blob, which will be stored in the Client ID.
|
||||||
|
|
||||||
|
If an output directory is not specified, it will be stored in the current working directory.
|
||||||
|
"""
|
||||||
|
if not wvd_path.is_file():
|
||||||
|
raise click.UsageError("wvd_path: Not a path to a file, or it doesn't exist.", ctx)
|
||||||
|
|
||||||
|
log = logging.getLogger("export-device")
|
||||||
|
log.info("Exporting Widevine Device (.wvd) file, %s", wvd_path.stem)
|
||||||
|
|
||||||
|
if not out_dir:
|
||||||
|
out_dir = Path.cwd()
|
||||||
|
|
||||||
|
out_path = out_dir / wvd_path.stem
|
||||||
|
if out_path.exists():
|
||||||
|
if any(out_path.iterdir()):
|
||||||
|
log.error("Output directory is not empty, cannot overwrite.")
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
log.warning("Output directory already exists, but is empty.")
|
||||||
|
else:
|
||||||
|
out_path.mkdir(parents=True)
|
||||||
|
|
||||||
|
device = Device.load(wvd_path)
|
||||||
|
|
||||||
|
log.info(f"L{device.security_level} {device.system_id} {device.type.name}")
|
||||||
|
log.info(f"Saving to: {out_path}")
|
||||||
|
|
||||||
|
device_meta = {
|
||||||
|
"wvd": {
|
||||||
|
"device_type": device.type.name,
|
||||||
|
"security_level": device.security_level,
|
||||||
|
**device.flags
|
||||||
|
},
|
||||||
|
"client_info": {},
|
||||||
|
"capabilities": MessageToDict(device.client_id, preserving_proto_field_name=True)["client_capabilities"]
|
||||||
|
}
|
||||||
|
for client_info in device.client_id.client_info:
|
||||||
|
device_meta["client_info"][client_info.name] = client_info.value
|
||||||
|
|
||||||
|
device_meta_path = out_path / "metadata.yml"
|
||||||
|
device_meta_path.write_text(yaml.dump(device_meta), encoding="utf8")
|
||||||
|
log.info("Exported Device Metadata as metadata.yml")
|
||||||
|
|
||||||
|
if device.private_key:
|
||||||
|
private_key_path = out_path / "private_key.pem"
|
||||||
|
private_key_path.write_text(
|
||||||
|
data=device.private_key.export_key().decode(),
|
||||||
|
encoding="utf8"
|
||||||
|
)
|
||||||
|
private_key_path.with_suffix(".der").write_bytes(
|
||||||
|
device.private_key.export_key(format="DER")
|
||||||
|
)
|
||||||
|
log.info("Exported Private Key as private_key.der and private_key.pem")
|
||||||
|
else:
|
||||||
|
log.warning("No Private Key available")
|
||||||
|
|
||||||
|
if device.client_id:
|
||||||
|
client_id_path = out_path / "client_id.bin"
|
||||||
|
client_id_path.write_bytes(device.client_id.SerializeToString())
|
||||||
|
log.info("Exported Client ID as client_id.bin")
|
||||||
|
else:
|
||||||
|
log.warning("No Client ID available")
|
||||||
|
|
||||||
|
if device.client_id.vmp_data:
|
||||||
|
vmp_path = out_path / "vmp.bin"
|
||||||
|
vmp_path.write_bytes(device.client_id.vmp_data)
|
||||||
|
log.info("Exported VMP (File Hashes) as vmp.bin")
|
||||||
|
else:
|
||||||
|
log.info("No VMP (File Hashes) available")
|
||||||
|
|
||||||
|
|
||||||
@main.command()
|
@main.command()
|
||||||
@click.argument("path", type=Path)
|
@click.argument("path", type=Path)
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
@ -289,10 +376,10 @@ def migrate(ctx: click.Context, path: Path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@main.command("serve", short_help="Serve your local CDM and Widevine Devices Remotely.")
|
@main.command("serve", short_help="Serve your local CDM and Widevine Devices Remotely.")
|
||||||
@click.argument("config", type=Path)
|
@click.argument("config_path", type=Path)
|
||||||
@click.option("-h", "--host", type=str, default="127.0.0.1", help="Host to serve from.")
|
@click.option("-h", "--host", type=str, default="127.0.0.1", help="Host to serve from.")
|
||||||
@click.option("-p", "--port", type=int, default=8786, help="Port to serve from.")
|
@click.option("-p", "--port", type=int, default=8786, help="Port to serve from.")
|
||||||
def serve_(config: Path, host: str, port: int):
|
def serve_(config_path: Path, host: str, port: int) -> None:
|
||||||
"""
|
"""
|
||||||
Serve your local CDM and Widevine Devices Remotely.
|
Serve your local CDM and Widevine Devices Remotely.
|
||||||
|
|
||||||
@ -304,8 +391,8 @@ def serve_(config: Path, host: str, port: int):
|
|||||||
Host as 127.0.0.1 may block remote access even if port-forwarded.
|
Host as 127.0.0.1 may block remote access even if port-forwarded.
|
||||||
Instead, use 0.0.0.0 and ensure the TCP port you choose is forwarded.
|
Instead, use 0.0.0.0 and ensure the TCP port you choose is forwarded.
|
||||||
"""
|
"""
|
||||||
from pywidevine import serve
|
from pywidevine import serve # isort:skip
|
||||||
import yaml
|
import yaml # isort:skip
|
||||||
|
|
||||||
config = yaml.safe_load(config.read_text(encoding="utf8"))
|
config = yaml.safe_load(config_path.read_text(encoding="utf8"))
|
||||||
serve.run(config, host, port)
|
serve.run(config, host, port)
|
||||||
|
|||||||
@ -3,20 +3,24 @@ from __future__ import annotations
|
|||||||
import base64
|
import base64
|
||||||
import binascii
|
import binascii
|
||||||
import string
|
import string
|
||||||
from typing import Union, Optional
|
from io import BytesIO
|
||||||
|
from typing import Optional, Union
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
from xml.etree.ElementTree import XML
|
||||||
|
|
||||||
import construct
|
import construct
|
||||||
from construct import Container
|
from construct import Container
|
||||||
from google.protobuf.message import DecodeError
|
from google.protobuf.message import DecodeError
|
||||||
from lxml import etree
|
|
||||||
from pymp4.parser import Box
|
from pymp4.parser import Box
|
||||||
|
|
||||||
from pywidevine.license_protocol_pb2 import WidevinePsshData
|
from pywidevine.license_protocol_pb2 import WidevinePsshData
|
||||||
|
|
||||||
|
|
||||||
class PSSH:
|
class PSSH:
|
||||||
"""PSSH-related utilities. Somewhat Widevine-biased."""
|
"""
|
||||||
|
MP4 PSSH Box-related utilities.
|
||||||
|
Allows you to load, create, and modify various kinds of DRM system headers.
|
||||||
|
"""
|
||||||
|
|
||||||
class SystemId:
|
class SystemId:
|
||||||
Widevine = UUID(hex="edef8ba979d64acea3c827dcd51d21ed")
|
Widevine = UUID(hex="edef8ba979d64acea3c827dcd51d21ed")
|
||||||
@ -24,13 +28,19 @@ class PSSH:
|
|||||||
|
|
||||||
def __init__(self, data: Union[Container, str, bytes], strict: bool = False):
|
def __init__(self, data: Union[Container, str, bytes], strict: bool = False):
|
||||||
"""
|
"""
|
||||||
Load a PSSH box or Widevine Cenc Header data as a new v0 PSSH box.
|
Load a PSSH box, WidevineCencHeader, or PlayReadyHeader.
|
||||||
|
|
||||||
|
When loading a WidevineCencHeader or PlayReadyHeader, a new v0 PSSH box will be
|
||||||
|
created and the header will be parsed and stored in the init_data field. However,
|
||||||
|
PlayReadyHeaders (and PlayReadyObjects) are not yet currently parsed and are
|
||||||
|
stored as bytes.
|
||||||
|
|
||||||
[Strict mode (strict=True)]
|
[Strict mode (strict=True)]
|
||||||
|
|
||||||
Supports the following forms of input data in either Base64 or Bytes form:
|
Supports the following forms of input data in either Base64 or Bytes form:
|
||||||
- Full PSSH mp4 boxes (as defined by pymp4 Box).
|
- Full PSSH mp4 boxes (as defined by pymp4 Box).
|
||||||
- Full Widevine Cenc Headers (as defined by WidevinePsshData proto).
|
- Full Widevine Cenc Headers (as defined by WidevinePsshData proto).
|
||||||
|
- Full PlayReady Objects and Headers (as defined by Microsoft Docs).
|
||||||
|
|
||||||
[Lenient mode (strict=False, default)]
|
[Lenient mode (strict=False, default)]
|
||||||
|
|
||||||
@ -72,26 +82,41 @@ class PSSH:
|
|||||||
box = Box.parse(data)
|
box = Box.parse(data)
|
||||||
except (IOError, construct.ConstructError): # not a box
|
except (IOError, construct.ConstructError): # not a box
|
||||||
try:
|
try:
|
||||||
cenc_header = WidevinePsshData()
|
widevine_pssh_data = WidevinePsshData()
|
||||||
cenc_header.ParseFromString(data)
|
widevine_pssh_data.ParseFromString(data)
|
||||||
cenc_header = cenc_header.SerializeToString()
|
data_serialized = widevine_pssh_data.SerializeToString()
|
||||||
if cenc_header != data: # not actually a WidevinePsshData
|
if data_serialized != data: # not actually a WidevinePsshData
|
||||||
raise DecodeError()
|
raise DecodeError()
|
||||||
|
box = Box.parse(Box.build(dict(
|
||||||
|
type=b"pssh",
|
||||||
|
version=0,
|
||||||
|
flags=0,
|
||||||
|
system_ID=PSSH.SystemId.Widevine,
|
||||||
|
init_data=data_serialized
|
||||||
|
)))
|
||||||
except DecodeError: # not a widevine cenc header
|
except DecodeError: # not a widevine cenc header
|
||||||
if strict:
|
if "</WRMHEADER>".encode("utf-16-le") in data:
|
||||||
|
# TODO: Actually parse `data` as a PlayReadyHeader object and store that instead
|
||||||
|
box = Box.parse(Box.build(dict(
|
||||||
|
type=b"pssh",
|
||||||
|
version=0,
|
||||||
|
flags=0,
|
||||||
|
system_ID=PSSH.SystemId.PlayReady,
|
||||||
|
init_data=data
|
||||||
|
)))
|
||||||
|
elif strict:
|
||||||
raise DecodeError(f"Could not parse data as a {Container} nor a {WidevinePsshData}.")
|
raise DecodeError(f"Could not parse data as a {Container} nor a {WidevinePsshData}.")
|
||||||
# Data is not a Widevine Cenc Header, it's something custom.
|
else:
|
||||||
# The license server likely has something custom to parse it.
|
# Data is not a WidevineCencHeader nor a PlayReadyHeader.
|
||||||
# See doc-string about Lenient mode for more information.
|
# The license server likely has something custom to parse it.
|
||||||
cenc_header = data
|
# See doc-string about Lenient mode for more information.
|
||||||
|
box = Box.parse(Box.build(dict(
|
||||||
box = Box.parse(Box.build(dict(
|
type=b"pssh",
|
||||||
type=b"pssh",
|
version=0,
|
||||||
version=0,
|
flags=0,
|
||||||
flags=0,
|
system_ID=PSSH.SystemId.Widevine,
|
||||||
system_ID=PSSH.SystemId.Widevine,
|
init_data=data
|
||||||
init_data=cenc_header
|
)))
|
||||||
)))
|
|
||||||
|
|
||||||
self.version = box.version
|
self.version = box.version
|
||||||
self.flags = box.flags
|
self.flags = box.flags
|
||||||
@ -99,15 +124,27 @@ class PSSH:
|
|||||||
self.__key_ids = box.key_IDs
|
self.__key_ids = box.key_IDs
|
||||||
self.init_data = box.init_data
|
self.init_data = box.init_data
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"PSSH<{self.system_id}>(v{self.version}; {self.flags}, {self.key_ids}, {self.init_data})"
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.dumps()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def new(
|
def new(
|
||||||
cls,
|
cls,
|
||||||
|
system_id: UUID,
|
||||||
key_ids: Optional[list[Union[UUID, str, bytes]]] = None,
|
key_ids: Optional[list[Union[UUID, str, bytes]]] = None,
|
||||||
init_data: Optional[Union[WidevinePsshData, str, bytes]] = None,
|
init_data: Optional[Union[WidevinePsshData, str, bytes]] = None,
|
||||||
version: int = 0,
|
version: int = 0,
|
||||||
flags: int = 0
|
flags: int = 0
|
||||||
) -> PSSH:
|
) -> PSSH:
|
||||||
"""Craft a new version 0 or 1 PSSH Box."""
|
"""Craft a new version 0 or 1 PSSH Box."""
|
||||||
|
if not system_id:
|
||||||
|
raise ValueError("A System ID must be specified.")
|
||||||
|
if not isinstance(system_id, UUID):
|
||||||
|
raise TypeError(f"Expected system_id to be a UUID, not {system_id!r}")
|
||||||
|
|
||||||
if key_ids is not None and not isinstance(key_ids, list):
|
if key_ids is not None and not isinstance(key_ids, list):
|
||||||
raise TypeError(f"Expected key_ids to be a list not {key_ids!r}")
|
raise TypeError(f"Expected key_ids to be a list not {key_ids!r}")
|
||||||
|
|
||||||
@ -133,24 +170,6 @@ class PSSH:
|
|||||||
if init_data is None and key_ids is None:
|
if init_data is None and key_ids is None:
|
||||||
raise ValueError("Version 1 PSSH boxes must use either init_data or key_ids but neither were provided")
|
raise ValueError("Version 1 PSSH boxes must use either init_data or key_ids but neither were provided")
|
||||||
|
|
||||||
if key_ids is not None:
|
|
||||||
# ensure key_ids are bytes, supports hex, base64, and bytes
|
|
||||||
key_ids = [
|
|
||||||
(
|
|
||||||
x.bytes if isinstance(x, UUID) else
|
|
||||||
bytes.fromhex(x) if all(c in string.hexdigits for c in x) else
|
|
||||||
base64.b64decode(x) if isinstance(x, str) else
|
|
||||||
x
|
|
||||||
)
|
|
||||||
for x in key_ids
|
|
||||||
]
|
|
||||||
if not all(isinstance(x, bytes) for x in key_ids):
|
|
||||||
not_bytes = [x for x in key_ids if not isinstance(x, bytes)]
|
|
||||||
raise TypeError(
|
|
||||||
"Expected all of key_ids to be a UUID, hex, base64, or bytes, but one or more are not, "
|
|
||||||
f"{not_bytes!r}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if init_data is not None:
|
if init_data is not None:
|
||||||
if isinstance(init_data, WidevinePsshData):
|
if isinstance(init_data, WidevinePsshData):
|
||||||
init_data = init_data.SerializeToString()
|
init_data = init_data.SerializeToString()
|
||||||
@ -164,19 +183,22 @@ class PSSH:
|
|||||||
f"Expecting init_data to be {WidevinePsshData}, hex, base64, or bytes, not {init_data!r}"
|
f"Expecting init_data to be {WidevinePsshData}, hex, base64, or bytes, not {init_data!r}"
|
||||||
)
|
)
|
||||||
|
|
||||||
box = Box.parse(Box.build(dict(
|
pssh = cls(Box.parse(Box.build(dict(
|
||||||
type=b"pssh",
|
type=b"pssh",
|
||||||
version=version,
|
version=version,
|
||||||
flags=flags,
|
flags=flags,
|
||||||
system_ID=PSSH.SystemId.Widevine,
|
system_ID=system_id,
|
||||||
key_ids=[key_ids, b""][key_ids is None],
|
|
||||||
init_data=[init_data, b""][init_data is None]
|
init_data=[init_data, b""][init_data is None]
|
||||||
)))
|
# key_IDs should not be set yet
|
||||||
|
))))
|
||||||
|
|
||||||
pssh = cls(box)
|
if key_ids:
|
||||||
|
# We must reinforce the version because pymp4 forces v0 if key_IDs is not set.
|
||||||
if key_ids and version == 0:
|
# The set_key_ids() func will set it efficiently in both init_data and the box where needed.
|
||||||
pssh.set_key_ids([UUID(bytes=x) for x in key_ids])
|
# The version must be reinforced ONLY if we have key_id data or there's a possibility of making
|
||||||
|
# a v1 PSSH box, that did not have key_IDs set in the PSSH box.
|
||||||
|
pssh.version = version
|
||||||
|
pssh.set_key_ids(key_ids)
|
||||||
|
|
||||||
return pssh
|
return pssh
|
||||||
|
|
||||||
@ -186,9 +208,9 @@ class PSSH:
|
|||||||
Get all Key IDs from within the Box or Init Data, wherever possible.
|
Get all Key IDs from within the Box or Init Data, wherever possible.
|
||||||
|
|
||||||
Supports:
|
Supports:
|
||||||
- Version 1 Boxes
|
- Version 1 PSSH Boxes
|
||||||
- Widevine Headers
|
- WidevineCencHeaders
|
||||||
- PlayReady Headers (4.0.0.0->4.3.0.0)
|
- PlayReadyHeaders (4.0.0.0->4.3.0.0)
|
||||||
"""
|
"""
|
||||||
if self.version == 1 and self.__key_ids:
|
if self.version == 1 and self.__key_ids:
|
||||||
return self.__key_ids
|
return self.__key_ids
|
||||||
@ -208,23 +230,54 @@ class PSSH:
|
|||||||
]
|
]
|
||||||
|
|
||||||
if self.system_id == PSSH.SystemId.PlayReady:
|
if self.system_id == PSSH.SystemId.PlayReady:
|
||||||
xml_string = self.init_data.decode("utf-16-le")
|
# Assuming init data is a PRO (PlayReadyObject)
|
||||||
# some of these init data has garbage(?) in front of it
|
# https://learn.microsoft.com/en-us/playready/specifications/playready-header-specification
|
||||||
xml_string = xml_string[xml_string.index("<"):]
|
pro_data = BytesIO(self.init_data)
|
||||||
xml = etree.fromstring(xml_string)
|
pro_length = int.from_bytes(pro_data.read(4), "little")
|
||||||
header_version = xml.attrib["version"]
|
if pro_length != len(self.init_data):
|
||||||
if header_version == "4.0.0.0":
|
raise ValueError("The PlayReadyObject seems to be corrupt (too big or small, or missing data).")
|
||||||
key_ids = xml.xpath("DATA/KID/text()")
|
pro_record_count = int.from_bytes(pro_data.read(2), "little")
|
||||||
elif header_version == "4.1.0.0":
|
|
||||||
key_ids = xml.xpath("DATA/PROTECTINFO/KID/@VALUE")
|
for _ in range(pro_record_count):
|
||||||
elif header_version in ("4.2.0.0", "4.3.0.0"):
|
prr_type = int.from_bytes(pro_data.read(2), "little")
|
||||||
key_ids = xml.xpath("DATA/PROTECTINFO/KIDS/KID/@VALUE")
|
prr_length = int.from_bytes(pro_data.read(2), "little")
|
||||||
else:
|
prr_value = pro_data.read(prr_length)
|
||||||
raise ValueError(f"Unsupported PlayReady header version {header_version}")
|
if prr_type != 0x01:
|
||||||
return [
|
# No PlayReady Header, skip and hope for something else
|
||||||
UUID(bytes=base64.b64decode(key_id))
|
# TODO: Add support for Embedded License Stores (0x03)
|
||||||
for key_id in key_ids
|
continue
|
||||||
]
|
|
||||||
|
wrm_ns = {"wrm": "http://schemas.microsoft.com/DRM/2007/03/PlayReadyHeader"}
|
||||||
|
prr_header = XML(prr_value.decode("utf-16-le"))
|
||||||
|
prr_header_version = prr_header.get("version")
|
||||||
|
if prr_header_version == "4.0.0.0":
|
||||||
|
key_ids = [
|
||||||
|
x.text
|
||||||
|
for x in prr_header.findall("./wrm:DATA/wrm:KID", wrm_ns)
|
||||||
|
if x.text
|
||||||
|
]
|
||||||
|
elif prr_header_version == "4.1.0.0":
|
||||||
|
key_ids = [
|
||||||
|
x.attrib["VALUE"]
|
||||||
|
for x in prr_header.findall("./wrm:DATA/wrm:PROTECTINFO/wrm:KID", wrm_ns)
|
||||||
|
]
|
||||||
|
elif prr_header_version in ("4.2.0.0", "4.3.0.0"):
|
||||||
|
# TODO: Retain the Encryption Scheme information in v4.3.0.0
|
||||||
|
# This is because some Key IDs can be AES-CTR while some are AES-CBC.
|
||||||
|
# Conversion to WidevineCencHeader could use this information.
|
||||||
|
key_ids = [
|
||||||
|
x.attrib["VALUE"]
|
||||||
|
for x in prr_header.findall("./wrm:DATA/wrm:PROTECTINFO/wrm:KIDS/wrm:KID", wrm_ns)
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unsupported PlayReadyHeader version {prr_header_version}")
|
||||||
|
|
||||||
|
return [
|
||||||
|
UUID(bytes=base64.b64decode(key_id))
|
||||||
|
for key_id in key_ids
|
||||||
|
]
|
||||||
|
|
||||||
|
raise ValueError("Unsupported PlayReadyObject, no PlayReadyHeader within the object.")
|
||||||
|
|
||||||
raise ValueError(f"This PSSH is not supported by key_ids() property, {self.dumps()}")
|
raise ValueError(f"This PSSH is not supported by key_ids() property, {self.dumps()}")
|
||||||
|
|
||||||
@ -243,7 +296,7 @@ class PSSH:
|
|||||||
"""Export the PSSH object as a full PSSH box in base64 form."""
|
"""Export the PSSH object as a full PSSH box in base64 form."""
|
||||||
return base64.b64encode(self.dump()).decode()
|
return base64.b64encode(self.dump()).decode()
|
||||||
|
|
||||||
def playready_to_widevine(self) -> None:
|
def to_widevine(self) -> None:
|
||||||
"""
|
"""
|
||||||
Convert PlayReady PSSH data to Widevine PSSH data.
|
Convert PlayReady PSSH data to Widevine PSSH data.
|
||||||
|
|
||||||
@ -251,45 +304,139 @@ class PSSH:
|
|||||||
can be used in a Widevine PSSH Header. The converted data may or may not result
|
can be used in a Widevine PSSH Header. The converted data may or may not result
|
||||||
in an accepted PSSH. It depends on what the License Server is expecting.
|
in an accepted PSSH. It depends on what the License Server is expecting.
|
||||||
"""
|
"""
|
||||||
if self.system_id != PSSH.SystemId.PlayReady:
|
if self.system_id == PSSH.SystemId.Widevine:
|
||||||
raise ValueError(f"This is not a PlayReady PSSH, {self.system_id}")
|
raise ValueError("This is already a Widevine PSSH")
|
||||||
|
|
||||||
cenc_header = WidevinePsshData()
|
widevine_pssh_data = WidevinePsshData(
|
||||||
cenc_header.algorithm = 1 # 0=Clear, 1=AES-CTR
|
key_ids=[x.bytes for x in self.key_ids],
|
||||||
cenc_header.key_ids[:] = [x.bytes for x in self.key_ids]
|
algorithm="AESCTR"
|
||||||
|
)
|
||||||
|
|
||||||
if self.version == 1:
|
if self.version == 1:
|
||||||
# ensure both cenc header and box has same Key IDs
|
# ensure both cenc header and box has same Key IDs
|
||||||
# v1 uses both this and within init data for basically no reason
|
# v1 uses both this and within init data for basically no reason
|
||||||
self.__key_ids = self.key_ids
|
self.__key_ids = self.key_ids
|
||||||
|
|
||||||
self.init_data = cenc_header.SerializeToString()
|
self.init_data = widevine_pssh_data.SerializeToString()
|
||||||
self.system_id = PSSH.SystemId.Widevine
|
self.system_id = PSSH.SystemId.Widevine
|
||||||
|
|
||||||
def set_key_ids(self, key_ids: list[UUID]) -> None:
|
def to_playready(
|
||||||
|
self,
|
||||||
|
la_url: Optional[str] = None,
|
||||||
|
lui_url: Optional[str] = None,
|
||||||
|
ds_id: Optional[bytes] = None,
|
||||||
|
decryptor_setup: Optional[str] = None,
|
||||||
|
custom_data: Optional[str] = None
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Convert Widevine PSSH data to PlayReady v4.3.0.0 PSSH data.
|
||||||
|
|
||||||
|
Note that it is impossible to create the CHECKSUM values for AES-CTR Key IDs
|
||||||
|
as you must encrypt the Key ID with the Content Encryption Key using AES-ECB.
|
||||||
|
This may cause software incompatibilities.
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
la_url: Contains the URL for the license acquisition Web service.
|
||||||
|
Only absolute URLs are allowed.
|
||||||
|
lui_url: Contains the URL for the license acquisition Web service.
|
||||||
|
Only absolute URLs are allowed.
|
||||||
|
ds_id: Service ID for the domain service.
|
||||||
|
decryptor_setup: This tag may only contain the value "ONDEMAND". It
|
||||||
|
indicates to an application that it should not expect the full
|
||||||
|
license chain for the content to be available for acquisition, or
|
||||||
|
already present on the client machine, prior to setting up the
|
||||||
|
media graph. If this tag is not set then it indicates that an
|
||||||
|
application can enforce the license to be acquired, or already
|
||||||
|
present on the client machine, prior to setting up the media graph.
|
||||||
|
custom_data: The content author can add custom XML inside this
|
||||||
|
element. Microsoft code does not act on any data contained inside
|
||||||
|
this element. The Syntax of this params XML is not validated.
|
||||||
|
"""
|
||||||
|
if self.system_id == PSSH.SystemId.PlayReady:
|
||||||
|
raise ValueError("This is already a PlayReady PSSH")
|
||||||
|
|
||||||
|
key_ids_xml = ""
|
||||||
|
for key_id in self.key_ids:
|
||||||
|
# Note that it's impossible to create the CHECKSUM value without the Key for the KID
|
||||||
|
key_ids_xml += f"""
|
||||||
|
<KID ALGID="AESCTR" VALUE="{base64.b64encode(key_id.bytes).decode()}"></KID>
|
||||||
|
"""
|
||||||
|
|
||||||
|
prr_value = f"""
|
||||||
|
<WRMHEADER xmlns="http://schemas.microsoft.com/DRM/2007/03/PlayReadyHeader" version="4.3.0.0">
|
||||||
|
<DATA>
|
||||||
|
<PROTECTINFO>
|
||||||
|
<KIDS>{key_ids_xml}</KIDS>
|
||||||
|
</PROTECTINFO>
|
||||||
|
{'<LA_URL>%s</LA_URL>' % la_url if la_url else ''}
|
||||||
|
{'<LUI_URL>%s</LUI_URL>' % lui_url if lui_url else ''}
|
||||||
|
{'<DS_ID>%s</DS_ID>' % base64.b64encode(ds_id).decode() if ds_id else ''}
|
||||||
|
{'<DECRYPTORSETUP>%s</DECRYPTORSETUP>' % decryptor_setup if decryptor_setup else ''}
|
||||||
|
{'<CUSTOMATTRIBUTES xmlns="">%s</CUSTOMATTRIBUTES>' % custom_data if custom_data else ''}
|
||||||
|
</DATA>
|
||||||
|
</WRMHEADER>
|
||||||
|
""".encode("utf-16-le")
|
||||||
|
|
||||||
|
prr_length = len(prr_value).to_bytes(2, "little")
|
||||||
|
prr_type = (1).to_bytes(2, "little") # Has PlayReadyHeader
|
||||||
|
pro_record_count = (1).to_bytes(2, "little")
|
||||||
|
pro = pro_record_count + prr_type + prr_length + prr_value
|
||||||
|
pro = (len(pro) + 4).to_bytes(4, "little") + pro
|
||||||
|
|
||||||
|
self.init_data = pro
|
||||||
|
self.system_id = PSSH.SystemId.PlayReady
|
||||||
|
|
||||||
|
def set_key_ids(self, key_ids: list[Union[UUID, str, bytes]]) -> None:
|
||||||
"""Overwrite all Key IDs with the specified Key IDs."""
|
"""Overwrite all Key IDs with the specified Key IDs."""
|
||||||
if self.system_id != PSSH.SystemId.Widevine:
|
if self.system_id != PSSH.SystemId.Widevine:
|
||||||
# TODO: Add support for setting the Key IDs in a PlayReady Header
|
# TODO: Add support for setting the Key IDs in a PlayReady Header
|
||||||
raise ValueError(f"Only Widevine PSSH Boxes are supported, not {self.system_id}.")
|
raise ValueError(f"Only Widevine PSSH Boxes are supported, not {self.system_id}.")
|
||||||
|
|
||||||
if not isinstance(key_ids, list):
|
key_id_uuids = self.parse_key_ids(key_ids)
|
||||||
raise TypeError(f"Expecting key_ids to be a list, not {key_ids!r}")
|
|
||||||
|
|
||||||
if not all(isinstance(x, UUID) for x in key_ids):
|
|
||||||
not_uuid = [x for x in key_ids if not isinstance(x, UUID)]
|
|
||||||
raise TypeError(f"All Key IDs in key_ids must be a {UUID}, not {not_uuid}")
|
|
||||||
|
|
||||||
if self.version == 1 or self.__key_ids:
|
if self.version == 1 or self.__key_ids:
|
||||||
# only use v1 box key_ids if version is 1, or it's already being used
|
# only use v1 box key_ids if version is 1, or it's already being used
|
||||||
# this is in case the service stupidly expects it for version 0
|
# this is in case the service stupidly expects it for version 0
|
||||||
self.__key_ids = key_ids
|
self.__key_ids = key_id_uuids
|
||||||
|
|
||||||
cenc_header = WidevinePsshData()
|
cenc_header = WidevinePsshData()
|
||||||
cenc_header.ParseFromString(self.init_data)
|
cenc_header.ParseFromString(self.init_data)
|
||||||
|
|
||||||
cenc_header.key_ids[:] = [
|
cenc_header.key_ids[:] = [
|
||||||
key_id.bytes
|
key_id.bytes
|
||||||
for key_id in key_ids
|
for key_id in key_id_uuids
|
||||||
]
|
]
|
||||||
|
|
||||||
self.init_data = cenc_header.SerializeToString()
|
self.init_data = cenc_header.SerializeToString()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_key_ids(key_ids: list[Union[UUID, str, bytes]]) -> list[UUID]:
|
||||||
|
"""
|
||||||
|
Parse a list of Key IDs in hex, base64, or bytes to UUIDs.
|
||||||
|
|
||||||
|
Raises TypeError if `key_ids` is not a list, or the list contains one
|
||||||
|
or more items that are not a UUID, str, or bytes object.
|
||||||
|
"""
|
||||||
|
if not isinstance(key_ids, list):
|
||||||
|
raise TypeError(f"Expected key_ids to be a list, not {key_ids!r}")
|
||||||
|
|
||||||
|
if not all(isinstance(x, (UUID, str, bytes)) for x in key_ids):
|
||||||
|
raise TypeError("Some items of key_ids are not a UUID, str, or bytes. Unsure how to continue...")
|
||||||
|
|
||||||
|
uuids = [
|
||||||
|
UUID(bytes=key_id_b)
|
||||||
|
for key_id in key_ids
|
||||||
|
for key_id_b in [
|
||||||
|
key_id.bytes if isinstance(key_id, UUID) else
|
||||||
|
(
|
||||||
|
bytes.fromhex(key_id) if all(c in string.hexdigits for c in key_id) else
|
||||||
|
base64.b64decode(key_id)
|
||||||
|
) if isinstance(key_id, str) else
|
||||||
|
key_id
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
return uuids
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ("PSSH",)
|
||||||
|
|||||||
0
pywidevine/py.typed
Normal file
0
pywidevine/py.typed
Normal file
@ -3,21 +3,21 @@ from __future__ import annotations
|
|||||||
import base64
|
import base64
|
||||||
import binascii
|
import binascii
|
||||||
import re
|
import re
|
||||||
from typing import Union, Optional
|
from typing import Optional, Union
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from Crypto.Hash import SHA1
|
from Crypto.Hash import SHA1
|
||||||
from Crypto.PublicKey import RSA
|
from Crypto.PublicKey import RSA
|
||||||
from Crypto.Signature import pss
|
from Crypto.Signature import pss
|
||||||
from google.protobuf.message import DecodeError
|
from google.protobuf.message import DecodeError
|
||||||
from pywidevine.cdm import Cdm
|
|
||||||
from pywidevine.device import Device
|
|
||||||
from pywidevine.exceptions import InvalidInitData, InvalidLicenseType, InvalidLicenseMessage, DeviceMismatch, \
|
|
||||||
SignatureMismatch
|
|
||||||
from pywidevine.key import Key
|
|
||||||
|
|
||||||
from pywidevine.license_protocol_pb2 import LicenseType, SignedMessage, License, ClientIdentification, \
|
from pywidevine.cdm import Cdm
|
||||||
SignedDrmCertificate
|
from pywidevine.device import Device, DeviceTypes
|
||||||
|
from pywidevine.exceptions import (DeviceMismatch, InvalidInitData, InvalidLicenseMessage, InvalidLicenseType,
|
||||||
|
SignatureMismatch)
|
||||||
|
from pywidevine.key import Key
|
||||||
|
from pywidevine.license_protocol_pb2 import (ClientIdentification, License, LicenseType, SignedDrmCertificate,
|
||||||
|
SignedMessage)
|
||||||
from pywidevine.pssh import PSSH
|
from pywidevine.pssh import PSSH
|
||||||
|
|
||||||
|
|
||||||
@ -26,7 +26,7 @@ class RemoteCdm(Cdm):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
device_type: Union[Device.Types, str],
|
device_type: Union[DeviceTypes, str],
|
||||||
system_id: int,
|
system_id: int,
|
||||||
security_level: int,
|
security_level: int,
|
||||||
host: str,
|
host: str,
|
||||||
@ -37,9 +37,9 @@ class RemoteCdm(Cdm):
|
|||||||
if not device_type:
|
if not device_type:
|
||||||
raise ValueError("Device Type must be provided")
|
raise ValueError("Device Type must be provided")
|
||||||
if isinstance(device_type, str):
|
if isinstance(device_type, str):
|
||||||
device_type = Device.Types[device_type]
|
device_type = DeviceTypes[device_type]
|
||||||
if not isinstance(device_type, Device.Types):
|
if not isinstance(device_type, DeviceTypes):
|
||||||
raise TypeError(f"Expected device_type to be a {Device.Types!r} not {device_type!r}")
|
raise TypeError(f"Expected device_type to be a {DeviceTypes!r} not {device_type!r}")
|
||||||
|
|
||||||
if not system_id:
|
if not system_id:
|
||||||
raise ValueError("System ID must be provided")
|
raise ValueError("System ID must be provided")
|
||||||
@ -86,10 +86,10 @@ class RemoteCdm(Cdm):
|
|||||||
server = r.headers.get("Server")
|
server = r.headers.get("Server")
|
||||||
if not server or "pywidevine serve" not in server.lower():
|
if not server or "pywidevine serve" not in server.lower():
|
||||||
raise ValueError(f"This Remote CDM API does not seem to be a pywidevine serve API ({server}).")
|
raise ValueError(f"This Remote CDM API does not seem to be a pywidevine serve API ({server}).")
|
||||||
server_version = re.search(r"pywidevine serve v([\d.]+)", server, re.IGNORECASE)
|
server_version_re = re.search(r"pywidevine serve v([\d.]+)", server, re.IGNORECASE)
|
||||||
if not server_version:
|
if not server_version_re:
|
||||||
raise ValueError("The pywidevine server API is not stating the version correctly, cannot continue.")
|
raise ValueError("The pywidevine server API is not stating the version correctly, cannot continue.")
|
||||||
server_version = server_version.group(1)
|
server_version = server_version_re.group(1)
|
||||||
if server_version < "1.4.3":
|
if server_version < "1.4.3":
|
||||||
raise ValueError(f"This pywidevine serve API version ({server_version}) is not supported.")
|
raise ValueError(f"This pywidevine serve API version ({server_version}) is not supported.")
|
||||||
|
|
||||||
@ -185,7 +185,7 @@ class RemoteCdm(Cdm):
|
|||||||
self,
|
self,
|
||||||
session_id: bytes,
|
session_id: bytes,
|
||||||
pssh: PSSH,
|
pssh: PSSH,
|
||||||
type_: Union[int, str] = LicenseType.STREAMING,
|
license_type: str = "STREAMING",
|
||||||
privacy_mode: bool = True
|
privacy_mode: bool = True
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
if not pssh:
|
if not pssh:
|
||||||
@ -193,20 +193,16 @@ class RemoteCdm(Cdm):
|
|||||||
if not isinstance(pssh, PSSH):
|
if not isinstance(pssh, PSSH):
|
||||||
raise InvalidInitData(f"Expected pssh to be a {PSSH}, not {pssh!r}")
|
raise InvalidInitData(f"Expected pssh to be a {PSSH}, not {pssh!r}")
|
||||||
|
|
||||||
try:
|
if not isinstance(license_type, str):
|
||||||
if isinstance(type_, int):
|
raise InvalidLicenseType(f"Expected license_type to be a {str}, not {license_type!r}")
|
||||||
type_ = LicenseType.Name(int(type_))
|
if license_type not in LicenseType.keys():
|
||||||
elif isinstance(type_, str):
|
raise InvalidLicenseType(
|
||||||
type_ = LicenseType.Name(LicenseType.Value(type_))
|
f"Invalid license_type value of '{license_type}'. "
|
||||||
elif isinstance(type_, LicenseType):
|
f"Available values: {LicenseType.keys()}"
|
||||||
type_ = LicenseType.Name(type_)
|
)
|
||||||
else:
|
|
||||||
raise InvalidLicenseType()
|
|
||||||
except ValueError:
|
|
||||||
raise InvalidLicenseType(f"License Type {type_!r} is invalid")
|
|
||||||
|
|
||||||
r = self.__session.post(
|
r = self.__session.post(
|
||||||
url=f"{self.host}/{self.device_name}/get_license_challenge/{type_}",
|
url=f"{self.host}/{self.device_name}/get_license_challenge/{license_type}",
|
||||||
json={
|
json={
|
||||||
"session_id": session_id.hex(),
|
"session_id": session_id.hex(),
|
||||||
"init_data": pssh.dumps(),
|
"init_data": pssh.dumps(),
|
||||||
@ -251,7 +247,7 @@ class RemoteCdm(Cdm):
|
|||||||
if not isinstance(license_message, SignedMessage):
|
if not isinstance(license_message, SignedMessage):
|
||||||
raise InvalidLicenseMessage(f"Expecting license_response to be a SignedMessage, got {license_message!r}")
|
raise InvalidLicenseMessage(f"Expecting license_response to be a SignedMessage, got {license_message!r}")
|
||||||
|
|
||||||
if license_message.type != SignedMessage.MessageType.LICENSE:
|
if license_message.type != SignedMessage.MessageType.Value("LICENSE"):
|
||||||
raise InvalidLicenseMessage(
|
raise InvalidLicenseMessage(
|
||||||
f"Expecting a LICENSE message, not a "
|
f"Expecting a LICENSE message, not a "
|
||||||
f"'{SignedMessage.MessageType.Name(license_message.type)}' message."
|
f"'{SignedMessage.MessageType.Name(license_message.type)}' message."
|
||||||
@ -301,4 +297,4 @@ class RemoteCdm(Cdm):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
__ALL__ = (RemoteCdm,)
|
__all__ = ("RemoteCdm",)
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import base64
|
import base64
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Union
|
from typing import Any, Optional, Union
|
||||||
|
|
||||||
|
from aiohttp.typedefs import Handler
|
||||||
from google.protobuf.message import DecodeError
|
from google.protobuf.message import DecodeError
|
||||||
|
|
||||||
from pywidevine.pssh import PSSH
|
from pywidevine.pssh import PSSH
|
||||||
@ -20,14 +21,14 @@ except ImportError:
|
|||||||
from pywidevine import __version__
|
from pywidevine import __version__
|
||||||
from pywidevine.cdm import Cdm
|
from pywidevine.cdm import Cdm
|
||||||
from pywidevine.device import Device
|
from pywidevine.device import Device
|
||||||
from pywidevine.exceptions import TooManySessions, InvalidSession, SignatureMismatch, InvalidInitData, \
|
from pywidevine.exceptions import (InvalidContext, InvalidInitData, InvalidLicenseMessage, InvalidLicenseType,
|
||||||
InvalidLicenseType, InvalidLicenseMessage, InvalidContext
|
InvalidSession, SignatureMismatch, TooManySessions)
|
||||||
|
|
||||||
routes = web.RouteTableDef()
|
routes = web.RouteTableDef()
|
||||||
|
|
||||||
|
|
||||||
async def _startup(app: web.Application):
|
async def _startup(app: web.Application) -> None:
|
||||||
app["cdms"]: dict[tuple[str, str], Cdm] = {}
|
app["cdms"] = {}
|
||||||
app["config"]["devices"] = {
|
app["config"]["devices"] = {
|
||||||
path.stem: path
|
path.stem: path
|
||||||
for x in app["config"]["devices"]
|
for x in app["config"]["devices"]
|
||||||
@ -38,7 +39,7 @@ async def _startup(app: web.Application):
|
|||||||
raise FileNotFoundError(f"Device file does not exist: {device}")
|
raise FileNotFoundError(f"Device file does not exist: {device}")
|
||||||
|
|
||||||
|
|
||||||
async def _cleanup(app: web.Application):
|
async def _cleanup(app: web.Application) -> None:
|
||||||
app["cdms"].clear()
|
app["cdms"].clear()
|
||||||
del app["cdms"]
|
del app["cdms"]
|
||||||
app["config"].clear()
|
app["config"].clear()
|
||||||
@ -46,7 +47,7 @@ async def _cleanup(app: web.Application):
|
|||||||
|
|
||||||
|
|
||||||
@routes.get("/")
|
@routes.get("/")
|
||||||
async def ping(_) -> web.Response:
|
async def ping(_: Any) -> web.Response:
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
"status": 200,
|
"status": 200,
|
||||||
"message": "Pong!"
|
"message": "Pong!"
|
||||||
@ -211,13 +212,15 @@ async def get_service_certificate(request: web.Request) -> web.Response:
|
|||||||
}, status=400)
|
}, status=400)
|
||||||
|
|
||||||
if service_certificate:
|
if service_certificate:
|
||||||
service_certificate = base64.b64encode(service_certificate.SerializeToString()).decode()
|
service_certificate_b64 = base64.b64encode(service_certificate.SerializeToString()).decode()
|
||||||
|
else:
|
||||||
|
service_certificate_b64 = None
|
||||||
|
|
||||||
return web.json_response({
|
return web.json_response({
|
||||||
"status": 200,
|
"status": 200,
|
||||||
"message": "Successfully got the Service Certificate.",
|
"message": "Successfully got the Service Certificate.",
|
||||||
"data": {
|
"data": {
|
||||||
"service_certificate": service_certificate
|
"service_certificate": service_certificate_b64
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -267,7 +270,7 @@ async def get_license_challenge(request: web.Request) -> web.Response:
|
|||||||
license_request = cdm.get_license_challenge(
|
license_request = cdm.get_license_challenge(
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
pssh=init_data,
|
pssh=init_data,
|
||||||
type_=license_type,
|
license_type=license_type,
|
||||||
privacy_mode=privacy_mode
|
privacy_mode=privacy_mode
|
||||||
)
|
)
|
||||||
except InvalidSession:
|
except InvalidSession:
|
||||||
@ -366,7 +369,7 @@ async def get_keys(request: web.Request) -> web.Response:
|
|||||||
session_id = bytes.fromhex(body["session_id"])
|
session_id = bytes.fromhex(body["session_id"])
|
||||||
|
|
||||||
# get key type
|
# get key type
|
||||||
key_type = request.match_info["key_type"]
|
key_type: Optional[str] = request.match_info["key_type"]
|
||||||
if key_type == "ALL":
|
if key_type == "ALL":
|
||||||
key_type = None
|
key_type = None
|
||||||
|
|
||||||
@ -414,26 +417,24 @@ async def get_keys(request: web.Request) -> web.Response:
|
|||||||
|
|
||||||
|
|
||||||
@web.middleware
|
@web.middleware
|
||||||
async def authentication(request: web.Request, handler) -> web.Response:
|
async def authentication(request: web.Request, handler: Handler) -> web.Response:
|
||||||
response = None
|
secret_key = request.headers.get("X-Secret-Key")
|
||||||
if request.path != "/":
|
|
||||||
secret_key = request.headers.get("X-Secret-Key")
|
|
||||||
if not secret_key:
|
|
||||||
request.app.logger.debug(f"{request.remote} did not provide authorization.")
|
|
||||||
response = web.json_response({
|
|
||||||
"status": "401",
|
|
||||||
"message": "Secret Key is Empty."
|
|
||||||
}, status=401)
|
|
||||||
elif secret_key not in request.app["config"]["users"]:
|
|
||||||
request.app.logger.debug(f"{request.remote} failed authentication with '{secret_key}'.")
|
|
||||||
response = web.json_response({
|
|
||||||
"status": "401",
|
|
||||||
"message": "Secret Key is Invalid, the Key is case-sensitive."
|
|
||||||
}, status=401)
|
|
||||||
|
|
||||||
if response is None:
|
if request.path != "/" and not secret_key:
|
||||||
|
request.app.logger.debug(f"{request.remote} did not provide authorization.")
|
||||||
|
response = web.json_response({
|
||||||
|
"status": "401",
|
||||||
|
"message": "Secret Key is Empty."
|
||||||
|
}, status=401)
|
||||||
|
elif request.path != "/" and secret_key not in request.app["config"]["users"]:
|
||||||
|
request.app.logger.debug(f"{request.remote} failed authentication with '{secret_key}'.")
|
||||||
|
response = web.json_response({
|
||||||
|
"status": "401",
|
||||||
|
"message": "Secret Key is Invalid, the Key is case-sensitive."
|
||||||
|
}, status=401)
|
||||||
|
else:
|
||||||
try:
|
try:
|
||||||
response = await handler(request)
|
response = await handler(request) # type: ignore[assignment]
|
||||||
except web.HTTPException as e:
|
except web.HTTPException as e:
|
||||||
request.app.logger.error(f"An unexpected error has occurred, {e}")
|
request.app.logger.error(f"An unexpected error has occurred, {e}")
|
||||||
response = web.json_response({
|
response = web.json_response({
|
||||||
@ -442,13 +443,13 @@ async def authentication(request: web.Request, handler) -> web.Response:
|
|||||||
}, status=500)
|
}, status=500)
|
||||||
|
|
||||||
response.headers.update({
|
response.headers.update({
|
||||||
"Server": f"https://github.com/rlaphoenix/pywidevine serve v{__version__}"
|
"Server": f"https://github.com/devine-dl/pywidevine serve v{__version__}"
|
||||||
})
|
})
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
def run(config: dict, host: Optional[Union[str, web.HostSequence]] = None, port: Optional[int] = None):
|
def run(config: dict, host: Optional[Union[str, web.HostSequence]] = None, port: Optional[int] = None) -> None:
|
||||||
app = web.Application(middlewares=[authentication])
|
app = web.Application(middlewares=[authentication])
|
||||||
app.on_startup.append(_startup)
|
app.on_startup.append(_startup)
|
||||||
app.on_cleanup.append(_cleanup)
|
app.on_cleanup.append(_cleanup)
|
||||||
|
|||||||
@ -13,3 +13,6 @@ class Session:
|
|||||||
self.service_certificate: Optional[SignedDrmCertificate] = None
|
self.service_certificate: Optional[SignedDrmCertificate] = None
|
||||||
self.context: dict[bytes, tuple[bytes, bytes]] = {}
|
self.context: dict[bytes, tuple[bytes, bytes]] = {}
|
||||||
self.keys: list[Key] = []
|
self.keys: list[Key] = []
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ("Session",)
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
# List of Widevine Device (.wvd) file paths to use with serve.
|
# List of Widevine Device (.wvd) file paths to use with serve.
|
||||||
# Note: Each individual user needs explicit permission to use a device listed.
|
# Note: Each individual user needs explicit permission to use a device listed.
|
||||||
devices:
|
devices:
|
||||||
- 'C:\Users\rlaphoenix\Documents\WVDs\test_device_001.wvd'
|
- 'C:\Users\devine-dl\Documents\WVDs\test_device_001.wvd'
|
||||||
|
|
||||||
# List of User's by Secret Key. The Secret Key must be supplied by the User to use the API.
|
# List of User's by Secret Key. The Secret Key must be supplied by the User to use the API.
|
||||||
users:
|
users:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user