Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e9c09d5f1 | ||
|
|
2e25f9c7bd | ||
|
|
ddc66f0a2b | ||
|
|
c9f55c6e6b | ||
|
|
2648d1c669 | ||
|
|
bc2b5beef4 | ||
|
|
11284eddfb | ||
|
|
61097ce6de | ||
|
|
3a910bd03a | ||
|
|
e31ba61302 | ||
|
|
0e4275bd1e | ||
|
|
e0365ff2bb | ||
|
|
ae95aeec96 | ||
|
|
1b40c2b369 | ||
|
|
05b30b3a89 | ||
|
|
7a993206a1 | ||
|
|
2d2359f9a2 |
28
CHANGELOG.md
28
CHANGELOG.md
@@ -5,6 +5,33 @@ 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/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [1.5.3] - 2022-12-27
|
||||
|
||||
### Added
|
||||
|
||||
- Added a new utility `load_xml()` to parse XML data with lxml ignoring Namespaces.
|
||||
- PSSH class now has a `__str__` and `__repr__` representation to print the object in more Human-friendly and
|
||||
useful ways. `str(pssh)` is now identical to `pssh.dumps()` and `repr(pssh)` or just `pssh` in some cases will
|
||||
result in a nice overview of the PSSHs contents.
|
||||
- Added 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
|
||||
|
||||
- You must now explicitly specify the System ID to use when creating a new PSSH box.
|
||||
This allows you to now create PlayReady PSSH boxes.
|
||||
- The `playready_to_widevine()` method has been renamed to just `to_widevine()`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix the capitalization of the `key_IDs` field, and it's value when creating a new PSSH box.
|
||||
- Fix the ability to create v0 PSSH boxes by only setting the `key_IDs` field when the version is set to `1`.
|
||||
- Fix parsing of 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.
|
||||
- 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.
|
||||
- Fix support for loading PlayReadyObjects with more than one PlayReadyHeader (more than one record).
|
||||
|
||||
## [1.5.2] - 2022-10-11
|
||||
|
||||
### Fixed
|
||||
@@ -332,6 +359,7 @@ This release is primarily a maintenance release for `serve` functionality but so
|
||||
|
||||
Initial Release.
|
||||
|
||||
[1.5.3]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.5.3
|
||||
[1.5.2]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.5.2
|
||||
[1.5.1]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.5.1
|
||||
[1.5.0]: https://github.com/rlaphoenix/pywidevine/releases/tag/v1.5.0
|
||||
|
||||
22
poetry.lock
generated
22
poetry.lock
generated
@@ -62,11 +62,11 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||
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"]
|
||||
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"
|
||||
version = "2022.12.7"
|
||||
description = "Python package for providing Mozilla's CA Bundle."
|
||||
category = "main"
|
||||
optional = false
|
||||
@@ -81,7 +81,7 @@ optional = false
|
||||
python-versions = ">=3.6.0"
|
||||
|
||||
[package.extras]
|
||||
unicode_backport = ["unicodedata2"]
|
||||
unicode-backport = ["unicodedata2"]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
@@ -217,7 +217,7 @@ 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)"]
|
||||
use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
@@ -373,8 +373,8 @@ attrs = [
|
||||
{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"},
|
||||
{file = "certifi-2022.12.7-py3-none-any.whl", hash = "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18"},
|
||||
{file = "certifi-2022.12.7.tar.gz", hash = "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3"},
|
||||
]
|
||||
charset-normalizer = [
|
||||
{file = "charset-normalizer-2.1.0.tar.gz", hash = "sha256:575e708016ff3a5e3681541cb9d79312c416835686d054a23accb873b254f413"},
|
||||
@@ -616,6 +616,7 @@ pycryptodome = [
|
||||
{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-musllinux_1_1_aarch64.whl", hash = "sha256:2ae53125de5b0d2c95194d957db9bb2681da8c24d0fb0fe3b056de2bcaf5d837"},
|
||||
{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"},
|
||||
@@ -623,12 +624,14 @@ pycryptodome = [
|
||||
{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-cp27-cp27mu-musllinux_1_1_aarch64.whl", hash = "sha256:eb6fce570869e70cc8ebe68eaa1c26bed56d40ad0f93431ee61d400525433c54"},
|
||||
{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-musllinux_1_1_aarch64.whl", hash = "sha256:50ca7e587b8e541eb6c192acf92449d95377d1f88908c0a32ac5ac2703ebe28b"},
|
||||
{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"},
|
||||
@@ -652,6 +655,13 @@ pyyaml = [
|
||||
{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-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d4b0ba9512519522b118090257be113b9468d804b19d63c71dbcf4a48fa32358"},
|
||||
{file = "PyYAML-6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:81957921f441d50af23654aa6c5e5eaf9b06aba7f0a19c18a538dc7ef291c5a1"},
|
||||
{file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afa17f5bc4d1b10afd4466fd3a44dc0e245382deca5b3c353d8b757f9e3ecb8d"},
|
||||
{file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dbad0e9d368bb989f4515da330b88a057617d16b6a8245084f1b05400f24609f"},
|
||||
{file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:432557aa2c09802be39460360ddffd48156e30721f5e8d917f01d31694216782"},
|
||||
{file = "PyYAML-6.0-cp311-cp311-win32.whl", hash = "sha256:bfaef573a63ba8923503d27530362590ff4f576c626d86a9fed95822a8255fd7"},
|
||||
{file = "PyYAML-6.0-cp311-cp311-win_amd64.whl", hash = "sha256:01b45c0191e6d66c470b6cf1b9531a771a83c1c4208272ead47a3ae4f2f603bf"},
|
||||
{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"},
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.poetry]
|
||||
name = "pywidevine"
|
||||
version = "1.5.2"
|
||||
version = "1.5.3"
|
||||
description = "Widevine CDM (Content Decryption Module) implementation in Python."
|
||||
authors = ["rlaphoenix <rlaphoenix@pm.me>"]
|
||||
license = "GPL-3.0-only"
|
||||
|
||||
@@ -6,4 +6,4 @@ from .remotecdm import *
|
||||
from .session import *
|
||||
|
||||
|
||||
__version__ = "1.5.2"
|
||||
__version__ = "1.5.3"
|
||||
|
||||
@@ -3,20 +3,24 @@ from __future__ import annotations
|
||||
import base64
|
||||
import binascii
|
||||
import string
|
||||
from io import BytesIO
|
||||
from typing import Union, Optional
|
||||
from uuid import UUID
|
||||
|
||||
import construct
|
||||
from construct import Container
|
||||
from google.protobuf.message import DecodeError
|
||||
from lxml import etree
|
||||
from pymp4.parser import Box
|
||||
|
||||
from pywidevine.license_protocol_pb2 import WidevinePsshData
|
||||
from pywidevine.utils import load_xml
|
||||
|
||||
|
||||
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:
|
||||
Widevine = UUID(hex="edef8ba979d64acea3c827dcd51d21ed")
|
||||
@@ -24,13 +28,19 @@ class PSSH:
|
||||
|
||||
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)]
|
||||
|
||||
Supports the following forms of input data in either Base64 or Bytes form:
|
||||
- Full PSSH mp4 boxes (as defined by pymp4 Box).
|
||||
- Full Widevine Cenc Headers (as defined by WidevinePsshData proto).
|
||||
- Full PlayReady Objects and Headers (as defined by Microsoft Docs).
|
||||
|
||||
[Lenient mode (strict=False, default)]
|
||||
|
||||
@@ -77,21 +87,36 @@ class PSSH:
|
||||
cenc_header = cenc_header.SerializeToString()
|
||||
if cenc_header != data: # not actually a WidevinePsshData
|
||||
raise DecodeError()
|
||||
box = Box.parse(Box.build(dict(
|
||||
type=b"pssh",
|
||||
version=0,
|
||||
flags=0,
|
||||
system_ID=PSSH.SystemId.Widevine,
|
||||
init_data=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}.")
|
||||
# Data is not a Widevine Cenc Header, it's something custom.
|
||||
# The license server likely has something custom to parse it.
|
||||
# See doc-string about Lenient mode for more information.
|
||||
cenc_header = data
|
||||
|
||||
box = Box.parse(Box.build(dict(
|
||||
type=b"pssh",
|
||||
version=0,
|
||||
flags=0,
|
||||
system_ID=PSSH.SystemId.Widevine,
|
||||
init_data=cenc_header
|
||||
)))
|
||||
else:
|
||||
# Data is not a WidevineCencHeader nor a PlayReadyHeader.
|
||||
# The license server likely has something custom to parse it.
|
||||
# See doc-string about Lenient mode for more information.
|
||||
box = Box.parse(Box.build(dict(
|
||||
type=b"pssh",
|
||||
version=0,
|
||||
flags=0,
|
||||
system_ID=PSSH.SystemId.Widevine,
|
||||
init_data=data
|
||||
)))
|
||||
|
||||
self.version = box.version
|
||||
self.flags = box.flags
|
||||
@@ -99,15 +124,27 @@ class PSSH:
|
||||
self.__key_ids = box.key_IDs
|
||||
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
|
||||
def new(
|
||||
cls,
|
||||
system_id: UUID,
|
||||
key_ids: Optional[list[Union[UUID, str, bytes]]] = None,
|
||||
init_data: Optional[Union[WidevinePsshData, str, bytes]] = None,
|
||||
version: int = 0,
|
||||
flags: int = 0
|
||||
) -> PSSH:
|
||||
"""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):
|
||||
raise TypeError(f"Expected key_ids to be a list not {key_ids!r}")
|
||||
|
||||
@@ -134,22 +171,23 @@ class PSSH:
|
||||
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)]
|
||||
# ensure key_ids are UUID, supports hex, base64, and bytes
|
||||
if not all(isinstance(x, (UUID, bytes, str)) for x in key_ids):
|
||||
not_bytes = [x for x in key_ids if not isinstance(x, (UUID, bytes, str))]
|
||||
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}"
|
||||
)
|
||||
key_ids = [
|
||||
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
|
||||
]
|
||||
]
|
||||
|
||||
if init_data is not None:
|
||||
if isinstance(init_data, WidevinePsshData):
|
||||
@@ -164,19 +202,22 @@ class PSSH:
|
||||
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",
|
||||
version=version,
|
||||
flags=flags,
|
||||
system_ID=PSSH.SystemId.Widevine,
|
||||
key_ids=[key_ids, b""][key_ids is None],
|
||||
system_ID=system_id,
|
||||
init_data=[init_data, b""][init_data is None]
|
||||
)))
|
||||
# key_IDs should not be set yet
|
||||
))))
|
||||
|
||||
pssh = cls(box)
|
||||
|
||||
if key_ids and version == 0:
|
||||
pssh.set_key_ids([UUID(bytes=x) for x in key_ids])
|
||||
if key_ids:
|
||||
# We must reinforce the version because pymp4 forces v0 if key_IDs is not set.
|
||||
# The set_key_ids() func will set it efficiently in both init_data and the box where needed.
|
||||
# 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
|
||||
|
||||
@@ -186,9 +227,9 @@ class PSSH:
|
||||
Get all Key IDs from within the Box or Init Data, wherever possible.
|
||||
|
||||
Supports:
|
||||
- Version 1 Boxes
|
||||
- Widevine Headers
|
||||
- PlayReady Headers (4.0.0.0->4.3.0.0)
|
||||
- Version 1 PSSH Boxes
|
||||
- WidevineCencHeaders
|
||||
- PlayReadyHeaders (4.0.0.0->4.3.0.0)
|
||||
"""
|
||||
if self.version == 1 and self.__key_ids:
|
||||
return self.__key_ids
|
||||
@@ -208,23 +249,42 @@ class PSSH:
|
||||
]
|
||||
|
||||
if self.system_id == PSSH.SystemId.PlayReady:
|
||||
xml_string = self.init_data.decode("utf-16-le")
|
||||
# some of these init data has garbage(?) in front of it
|
||||
xml_string = xml_string[xml_string.index("<"):]
|
||||
xml = etree.fromstring(xml_string)
|
||||
header_version = xml.attrib["version"]
|
||||
if header_version == "4.0.0.0":
|
||||
key_ids = xml.xpath("DATA/KID/text()")
|
||||
elif header_version == "4.1.0.0":
|
||||
key_ids = xml.xpath("DATA/PROTECTINFO/KID/@VALUE")
|
||||
elif header_version in ("4.2.0.0", "4.3.0.0"):
|
||||
key_ids = xml.xpath("DATA/PROTECTINFO/KIDS/KID/@VALUE")
|
||||
else:
|
||||
raise ValueError(f"Unsupported PlayReady header version {header_version}")
|
||||
return [
|
||||
UUID(bytes=base64.b64decode(key_id))
|
||||
for key_id in key_ids
|
||||
]
|
||||
# Assuming init data is a PRO (PlayReadyObject)
|
||||
# https://learn.microsoft.com/en-us/playready/specifications/playready-header-specification
|
||||
pro_data = BytesIO(self.init_data)
|
||||
pro_length = int.from_bytes(pro_data.read(4), "little")
|
||||
if pro_length != len(self.init_data):
|
||||
raise ValueError("The PlayReadyObject seems to be corrupt (too big or small, or missing data).")
|
||||
pro_record_count = int.from_bytes(pro_data.read(2), "little")
|
||||
|
||||
for _ in range(pro_record_count):
|
||||
prr_type = int.from_bytes(pro_data.read(2), "little")
|
||||
prr_length = int.from_bytes(pro_data.read(2), "little")
|
||||
prr_value = pro_data.read(prr_length)
|
||||
if prr_type != 0x01:
|
||||
# No PlayReady Header, skip and hope for something else
|
||||
# TODO: Add support for Embedded License Stores (0x03)
|
||||
continue
|
||||
|
||||
prr_header = load_xml(prr_value.decode("utf-16-le"))
|
||||
prr_header_version = prr_header.attrib["version"]
|
||||
if prr_header_version == "4.0.0.0":
|
||||
key_ids = prr_header.xpath("DATA/KID/text()")
|
||||
elif prr_header_version == "4.1.0.0":
|
||||
key_ids = prr_header.xpath("DATA/PROTECTINFO/KID/@VALUE")
|
||||
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 = prr_header.xpath("DATA/PROTECTINFO/KIDS/KID/@VALUE")
|
||||
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()}")
|
||||
|
||||
@@ -243,7 +303,7 @@ class PSSH:
|
||||
"""Export the PSSH object as a full PSSH box in base64 form."""
|
||||
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.
|
||||
|
||||
@@ -251,8 +311,8 @@ class PSSH:
|
||||
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.
|
||||
"""
|
||||
if self.system_id != PSSH.SystemId.PlayReady:
|
||||
raise ValueError(f"This is not a PlayReady PSSH, {self.system_id}")
|
||||
if self.system_id == PSSH.SystemId.Widevine:
|
||||
raise ValueError("This is already a Widevine PSSH")
|
||||
|
||||
cenc_header = WidevinePsshData()
|
||||
cenc_header.algorithm = 1 # 0=Clear, 1=AES-CTR
|
||||
@@ -266,6 +326,72 @@ class PSSH:
|
||||
self.init_data = cenc_header.SerializeToString()
|
||||
self.system_id = PSSH.SystemId.Widevine
|
||||
|
||||
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>
|
||||
{f'<LA_URL>%s</LA_URL>' % la_url if la_url else ''}
|
||||
{f'<LUI_URL>%s</LUI_URL>' % lui_url if lui_url else ''}
|
||||
{f'<DS_ID>%s</DS_ID>' % base64.b64encode(ds_id).decode() if ds_id else ''}
|
||||
{f'<DECRYPTORSETUP>%s</DECRYPTORSETUP>' % decryptor_setup if decryptor_setup else ''}
|
||||
{f'<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[UUID]) -> None:
|
||||
"""Overwrite all Key IDs with the specified Key IDs."""
|
||||
if self.system_id != PSSH.SystemId.Widevine:
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Optional, Union
|
||||
|
||||
from lxml import etree
|
||||
from lxml.etree import ElementTree
|
||||
|
||||
|
||||
def get_binary_path(*names: str) -> Optional[Path]:
|
||||
@@ -10,3 +13,23 @@ def get_binary_path(*names: str) -> Optional[Path]:
|
||||
if path:
|
||||
return Path(path)
|
||||
return None
|
||||
|
||||
|
||||
def load_xml(xml: Union[str, bytes]) -> ElementTree:
|
||||
"""Parse XML data to an ElementTree, without namespaces anywhere."""
|
||||
if not isinstance(xml, bytes):
|
||||
xml = xml.encode("utf8")
|
||||
root = etree.fromstring(xml)
|
||||
for elem in root.getiterator():
|
||||
if not hasattr(elem.tag, "find"):
|
||||
# e.g. comment elements
|
||||
continue
|
||||
elem.tag = etree.QName(elem).localname
|
||||
for name, value in elem.attrib.items():
|
||||
local_name = etree.QName(name).localname
|
||||
if local_name == name:
|
||||
continue
|
||||
del elem.attrib[name]
|
||||
elem.attrib[local_name] = value
|
||||
etree.cleanup_namespaces(root)
|
||||
return root
|
||||
|
||||
Reference in New Issue
Block a user