Add m3u playlists

This commit is contained in:
Nirvana
2026-02-13 16:50:09 +01:00
parent db2898a91c
commit db704d8e6c
17 changed files with 2318 additions and 759 deletions
@@ -1,5 +1,5 @@
# streaming_providers/base/models/__init__.py
from .drm_models import DRMConfig, DRMSystem, LicenseConfig, LicenseUnwrapperParams
from .drm import DRMConfig, DRMSystem, LicenseConfig, LicenseUnwrapperParams
from .streaming_channel import StreamingChannel
from .subscription import SubscriptionPackage, UserSubscription
@@ -0,0 +1,137 @@
"""
DRM Models Package
Comprehensive DRM (Digital Rights Management) support for streaming providers.
This package provides:
- DRM system identification and configuration
- PSSH (Protection System Specific Header) parsing
- tenc (Track Encryption) box parsing
- License server configuration
- Multi-DRM support with priority handling
Usage:
from streaming_providers.base.models.drm import (
DRMSystem,
DRMConfig,
PSSHData,
LicenseConfig,
)
# Create Widevine configuration
drm_config = DRMConfig.create_widevine(
server_url="https://license.example.com/widevine",
priority=1
)
# Parse PSSH from manifest
pssh_data = PSSHData(
system_id="edef8ba9-79d6-4ace-a3c8-27dcd51d21ed",
pssh_box="base64_encoded_pssh_box"
)
"""
# Version
__version__ = "2.0.0"
# Core enums
from .drm_systems import DRMSystem
# Data models
from .pssh_data import PSSHData
from .license_config import (
LicenseConfig,
LicenseUnwrapperParams,
WrapperType,
UnwrapperType,
)
from .drm_config import DRMConfig
# Parsers (for advanced usage)
from .pssh_parser import PSSHParser
from .tenc_parser import TencParser
# Exceptions
from .exceptions import (
DRMError,
InvalidPSSHError,
InvalidTencError,
InvalidUUIDError,
InvalidKeyIDError,
PSSHSizeError,
UnsupportedDRMSystemError,
LicenseConfigError,
Base64DecodingError,
)
# Utilities (for advanced usage)
from .utils import (
normalize_uuid,
format_uuid,
normalize_key_id,
bytes_to_uuid_hex,
bytes_to_uuid_formatted,
uuid_to_bytes,
safe_base64_decode,
safe_base64_encode,
deduplicate_key_ids,
)
# Constants (for advanced usage)
from .constants import (
PSSHOffsets,
TencOffsets,
MAX_PSSH_SIZE,
MAX_TENC_SIZE,
KID_SIZE_BYTES,
)
# Public API
__all__ = [
# Version
"__version__",
# Core classes
"DRMSystem",
"DRMConfig",
"PSSHData",
"LicenseConfig",
"LicenseUnwrapperParams",
# Enums
"WrapperType",
"UnwrapperType",
# Parsers
"PSSHParser",
"TencParser",
# Exceptions
"DRMError",
"InvalidPSSHError",
"InvalidTencError",
"InvalidUUIDError",
"InvalidKeyIDError",
"PSSHSizeError",
"UnsupportedDRMSystemError",
"LicenseConfigError",
"Base64DecodingError",
# Utilities
"normalize_uuid",
"format_uuid",
"normalize_key_id",
"bytes_to_uuid_hex",
"bytes_to_uuid_formatted",
"uuid_to_bytes",
"safe_base64_decode",
"safe_base64_encode",
"deduplicate_key_ids",
# Constants
"PSSHOffsets",
"TencOffsets",
"MAX_PSSH_SIZE",
"MAX_TENC_SIZE",
"KID_SIZE_BYTES",
]
@@ -0,0 +1,159 @@
"""
DRM Constants and Configuration
Contains all magic numbers, offsets, and constants used in DRM processing.
"""
from typing import FrozenSet
# Character sets for validation
HEX_CHARS: FrozenSet[str] = frozenset('0123456789abcdef')
BASE64_CHARS: FrozenSet[str] = frozenset(
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='
)
# Size limits for security
MAX_PSSH_SIZE: int = 64 * 1024 # 64KB
MAX_TENC_SIZE: int = 1024 # 1KB
MAX_KEY_ID_COUNT: int = 100 # Maximum KIDs in a single PSSH
# KID and UUID sizes
KID_SIZE_BYTES: int = 16 # 128 bits
UUID_HEX_LENGTH: int = 32 # 32 hex characters without hyphens
UUID_FORMATTED_LENGTH: int = 36 # 36 characters with hyphens
class PSSHOffsets:
"""
PSSH Box Binary Structure Offsets
PSSH Box Format (ISO/IEC 23001-7):
[0-3] Box size (uint32)
[4-7] Box type ('pssh')
[8] Version (uint8)
[9-11] Flags (uint24)
[12-27] System ID (16 bytes)
Version 0:
[28-31] Data size (uint32)
[32+] Data
Version 1+:
[28-31] KID count (uint32)
[32+] KIDs (16 bytes each)
[...] Data size (uint32)
[...] Data
"""
BOX_SIZE = 0
BOX_TYPE = 4
BOX_TYPE_END = 8
VERSION = 8
FLAGS = 9
FLAGS_END = 12
SYSTEM_ID_START = 12
SYSTEM_ID_END = 28
# Version 1+ specific
V1_KID_COUNT = 28
V1_KID_COUNT_END = 32
V1_KIDS_START = 32
# Version 0 specific
V0_DATA_SIZE = 28
V0_DATA_SIZE_END = 32
V0_DATA_START = 32
# Minimum sizes
MIN_PSSH_SIZE = 32 # Minimum valid PSSH box
MIN_V1_PSSH_SIZE = 36 # Minimum V1 PSSH with 0 KIDs
class TencOffsets:
"""
Track Encryption Box (tenc) Binary Structure Offsets
tenc Box Format (ISO/IEC 23001-7):
[0-3] Box size (uint32)
[4-7] Box type ('tenc')
[8] Version (uint8)
[9-11] Flags (uint24)
[12-15] Reserved (uint24) + default_crypt_byte_block (uint8)
Version 0:
[16] Reserved (uint8)
[17] default_is_protected (uint8)
[18] default_per_sample_IV_size (uint8)
[19-34] default_KID (16 bytes)
Version 1:
[16] default_constant_IV_size (uint8)
[17+] default_constant_IV (if size > 0)
[...] default_KID (16 bytes, after IV)
"""
BOX_SIZE = 0
BOX_TYPE = 4
BOX_TYPE_END = 8
VERSION = 8
# Version 0 specific
V0_IS_PROTECTED = 17
V0_IV_SIZE = 18
V0_KID_START = 19
V0_KID_END = 35
MIN_TENC_V0_SIZE = 35 # Minimum valid V0 tenc box
# DRM System UUID Mappings (normalized, no hyphens)
DRM_SYSTEM_UUIDS: dict[str, str] = {
"edef8ba979d64acea3c827dcd51d21ed": "WIDEVINE",
"9a04f07998404286ab92e65be0885f95": "PLAYREADY",
"e2719d58a985b3c9781ab030af78d30e": "CLEARKEY",
"3d5e6d359b9a41e8b843dd3c6e72c42c": "WISEPLAY",
"94ce86fb07ff4f43adb893d2fa968ca2": "FAIRPLAY",
}
# Reverse mapping for quick lookups
DRM_SYSTEM_NAMES: dict[str, str] = {
"WIDEVINE": "edef8ba979d64acea3c827dcd51d21ed",
"PLAYREADY": "9a04f07998404286ab92e65be0885f95",
"CLEARKEY": "e2719d58a985b3c9781ab030af78d30e",
"WISEPLAY": "3d5e6d359b9a41e8b843dd3c6e72c42c",
"FAIRPLAY": "94ce86fb07ff4f43adb893d2fa968ca2",
"GENERIC": "",
"NONE": "",
}
# DRM System Alias Mappings (all normalized - no dots, hyphens, lowercase)
DRM_ALIAS_MAPPING: dict[str, str] = {
# Widevine
"widevine": "WIDEVINE",
"comwidevinealpha": "WIDEVINE",
"edef8ba979d64acea3c827dcd51d21ed": "WIDEVINE",
# PlayReady
"playready": "PLAYREADY",
"commicrosoftplayready": "PLAYREADY",
"9a04f07998404286ab92e65be0885f95": "PLAYREADY",
# ClearKey
"clearkey": "CLEARKEY",
"orgw3clearkey": "CLEARKEY",
"e2719d58a985b3c9781ab030af78d30e": "CLEARKEY",
# FairPlay
"fairplay": "FAIRPLAY",
"comapplefps": "FAIRPLAY",
"skd": "FAIRPLAY",
"94ce86fb07ff4f43adb893d2fa968ca2": "FAIRPLAY",
# WisePlay
"wiseplay": "WISEPLAY",
"comhuaweiwiseplay": "WISEPLAY",
"3d5e6d359b9a41e8b843dd3c6e72c42c": "WISEPLAY",
# Generic/None
"generic": "GENERIC",
"none": "NONE",
"unencrypted": "NONE",
}
@@ -0,0 +1,169 @@
"""
DRM Configuration Model
Main configuration class that combines DRM system, PSSH data, and license config.
"""
from dataclasses import dataclass
from typing import Optional
from .drm_systems import DRMSystem
from .license_config import LicenseConfig
from .exceptions import LicenseConfigError
@dataclass
class DRMConfig:
"""
Complete DRM Configuration.
Combines DRM system identification with license configuration
for a single DRM system.
Attributes:
system: DRM system type
priority: Priority for multi-DRM scenarios (higher = preferred)
license: License server configuration (optional for unencrypted)
"""
system: DRMSystem
priority: int = 0
license: Optional[LicenseConfig] = None
def validate(self) -> None:
"""
Validate the DRM configuration.
Raises:
LicenseConfigError: If configuration is invalid
"""
if not isinstance(self.system, DRMSystem):
raise LicenseConfigError(
f"system must be a DRMSystem enum, got {type(self.system)}"
)
if self.license:
self.license.validate()
def to_dict(self) -> dict:
"""
Convert to dictionary format expected by players.
Returns:
Dictionary with DRM configuration in player-compatible format:
{
"com.widevine.alpha": {
"priority": 1,
"license": {...}
}
}
"""
result = {
self.system.value: {
"priority": self.priority
}
}
if self.license:
license_dict = self.license.to_dict()
if license_dict:
result[self.system.value]["license"] = license_dict
return result
@classmethod
def create_widevine(
cls,
server_url: str,
priority: int = 1,
**license_kwargs
) -> "DRMConfig":
"""
Helper to create Widevine DRM configuration.
Args:
server_url: Widevine license server URL
priority: Priority (default: 1)
**license_kwargs: Additional LicenseConfig parameters
Returns:
DRMConfig instance for Widevine
"""
license_config = LicenseConfig(server_url=server_url, **license_kwargs)
return cls(system=DRMSystem.WIDEVINE, priority=priority, license=license_config)
@classmethod
def create_playready(
cls,
server_url: str,
priority: int = 1,
**license_kwargs
) -> "DRMConfig":
"""
Helper to create PlayReady DRM configuration.
Args:
server_url: PlayReady license server URL
priority: Priority (default: 1)
**license_kwargs: Additional LicenseConfig parameters
Returns:
DRMConfig instance for PlayReady
"""
license_config = LicenseConfig(server_url=server_url, **license_kwargs)
return cls(system=DRMSystem.PLAYREADY, priority=priority, license=license_config)
@classmethod
def create_clearkey(
cls,
keyids: dict[str, str],
priority: int = 0,
**license_kwargs
) -> "DRMConfig":
"""
Helper to create ClearKey DRM configuration.
Args:
keyids: Mapping of Key IDs to Keys (hex strings)
priority: Priority (default: 0)
**license_kwargs: Additional LicenseConfig parameters
Returns:
DRMConfig instance for ClearKey
"""
license_config = LicenseConfig(keyids=keyids, **license_kwargs)
return cls(system=DRMSystem.CLEARKEY, priority=priority, license=license_config)
@classmethod
def create_fairplay(
cls,
server_url: str,
server_certificate: str,
priority: int = 1,
**license_kwargs
) -> "DRMConfig":
"""
Helper to create FairPlay DRM configuration.
Args:
server_url: FairPlay license server URL (skd://)
server_certificate: Base64-encoded FairPlay certificate
priority: Priority (default: 1)
**license_kwargs: Additional LicenseConfig parameters
Returns:
DRMConfig instance for FairPlay
"""
license_config = LicenseConfig(
server_url=server_url,
server_certificate=server_certificate,
**license_kwargs
)
return cls(system=DRMSystem.FAIRPLAY, priority=priority, license=license_config)
def __repr__(self) -> str:
"""Return detailed representation."""
has_license = "with license" if self.license else "no license"
return (
f"<DRMConfig({self.system.name}, priority={self.priority}, {has_license})>"
)
@@ -0,0 +1,144 @@
"""
DRM System Definitions
Enum for supported DRM systems with efficient UUID and alias lookups.
"""
from enum import Enum
from functools import cached_property
from typing import Optional
from .constants import DRM_SYSTEM_NAMES, DRM_SYSTEM_UUIDS, DRM_ALIAS_MAPPING
from .utils import normalize_uuid, normalize_alias
from .exceptions import InvalidUUIDError
class DRMSystem(str, Enum):
"""
Supported DRM Systems
Each DRM system has:
- A unique identifier (Android/EME format)
- A standard UUID (ISO/IEC 23001-7)
- Multiple aliases for flexible lookups
"""
WIDEVINE = "com.widevine.alpha"
PLAYREADY = "com.microsoft.playready"
WISEPLAY = "com.huawei.wiseplay"
CLEARKEY = "org.w3.clearkey"
FAIRPLAY = "com.apple.fps"
GENERIC = "generic"
NONE = "none"
@cached_property
def system_uuid(self) -> str:
"""
Get the standard UUID for this DRM system (normalized, no hyphens).
Returns:
32-character hex string (lowercase, no hyphens), or empty string for GENERIC/NONE
"""
# Get enum name (e.g., 'WIDEVINE')
enum_name = self.name
return DRM_SYSTEM_NAMES.get(enum_name, "")
@cached_property
def system_uuid_formatted(self) -> str:
"""
Get the formatted UUID with hyphens for this DRM system.
Returns:
UUID string with hyphens (8-4-4-4-12), or empty string for GENERIC/NONE
"""
uuid = self.system_uuid
if not uuid:
return ""
return (
f"{uuid[0:8]}-{uuid[8:12]}-{uuid[12:16]}-"
f"{uuid[16:20]}-{uuid[20:32]}"
)
@classmethod
def from_uuid(cls, uuid: str) -> Optional["DRMSystem"]:
"""
Get DRM system from UUID.
Accepts any format (with or without hyphens).
Args:
uuid: UUID string (e.g., "edef8ba9-79d6-4ace-a3c8-27dcd51d21ed")
Returns:
DRMSystem enum value or None if not recognized
Examples:
>>> DRMSystem.from_uuid("edef8ba9-79d6-4ace-a3c8-27dcd51d21ed")
<DRMSystem.WIDEVINE: 'com.widevine.alpha'>
>>> DRMSystem.from_uuid("edef8ba979d64acea3c827dcd51d21ed")
<DRMSystem.WIDEVINE: 'com.widevine.alpha'>
"""
if not uuid:
return None
try:
# Normalize UUID (removes hyphens, validates format)
normalized = normalize_uuid(uuid)
except InvalidUUIDError:
return None
# Lookup in mapping
enum_name = DRM_SYSTEM_UUIDS.get(normalized)
if not enum_name:
return None
return getattr(cls, enum_name, None)
@classmethod
def from_alias(cls, alias: str) -> Optional["DRMSystem"]:
"""
Resolve DRM system from human-friendly alias or UUID.
Handles:
- Short aliases: "clearkey", "widevine", "playready", "fairplay", "wiseplay"
- Full Android identifiers: "com.widevine.alpha", "org.w3.clearkey", etc.
- UUIDs (with or without hyphens): "edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"
Args:
alias: DRM system alias, identifier, or UUID
Returns:
DRMSystem enum value or None if unrecognized
Examples:
>>> DRMSystem.from_alias("widevine")
<DRMSystem.WIDEVINE: 'com.widevine.alpha'>
>>> DRMSystem.from_alias("com.widevine.alpha")
<DRMSystem.WIDEVINE: 'com.widevine.alpha'>
>>> DRMSystem.from_alias("clearkey")
<DRMSystem.CLEARKEY: 'org.w3.clearkey'>
"""
if not alias:
return None
# Normalize alias (remove dots, hyphens, lowercase)
normalized = normalize_alias(alias)
# Lookup in alias mapping
enum_name = DRM_ALIAS_MAPPING.get(normalized)
if not enum_name:
return None
return getattr(cls, enum_name, None)
def __str__(self) -> str:
"""Return the DRM system identifier"""
return self.value
def __repr__(self) -> str:
"""Return detailed representation"""
return f"<{self.__class__.__name__}.{self.name}: '{self.value}'>"
@@ -0,0 +1,50 @@
"""
DRM-specific Exception Classes
Custom exceptions for better error handling and debugging in DRM operations.
"""
class DRMError(Exception):
"""Base exception for all DRM-related errors"""
pass
class InvalidPSSHError(DRMError):
"""Raised when PSSH box data is invalid or malformed"""
pass
class InvalidTencError(DRMError):
"""Raised when tenc box data is invalid or malformed"""
pass
class InvalidUUIDError(DRMError):
"""Raised when UUID format is invalid"""
pass
class InvalidKeyIDError(DRMError):
"""Raised when Key ID format is invalid"""
pass
class PSSHSizeError(DRMError):
"""Raised when PSSH box exceeds size limits"""
pass
class UnsupportedDRMSystemError(DRMError):
"""Raised when DRM system is not supported"""
pass
class LicenseConfigError(DRMError):
"""Raised when license configuration is invalid"""
pass
class Base64DecodingError(DRMError):
"""Raised when base64 decoding fails"""
pass
@@ -0,0 +1,207 @@
"""
License Configuration Models
Data classes for DRM license configuration, including server URLs,
certificates, headers, and unwrapper parameters.
"""
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional
from .utils import safe_base64_decode, safe_base64_encode, normalize_key_id
from .exceptions import LicenseConfigError
class WrapperType(str, Enum):
"""License request wrapper types"""
BASE64 = "base64"
URLENC = "urlenc"
NONE = "none"
class UnwrapperType(str, Enum):
"""License response unwrapper types"""
AUTO = "auto"
BASE64 = "base64"
JSON = "json"
XML = "xml"
NONE = "none"
@dataclass
class LicenseUnwrapperParams:
"""
Parameters for license response unwrapping.
Used to extract license data and HDCP information from
non-standard license server responses.
Attributes:
path_data: JSON/XML path to license data (e.g., "license.data")
path_data_traverse: Whether to traverse nested structures for data
path_hdcp_res: JSON/XML path to HDCP resolution restriction
path_hdcp_res_traverse: Whether to traverse for HDCP resolution
path_hdcp_ver: JSON/XML path to HDCP version requirement
path_hdcp_ver_traverse: Whether to traverse for HDCP version
"""
path_data: Optional[str] = None
path_data_traverse: bool = False
path_hdcp_res: Optional[str] = None
path_hdcp_res_traverse: bool = False
path_hdcp_ver: Optional[str] = None
path_hdcp_ver_traverse: bool = False
def to_dict(self) -> dict:
"""Convert to dictionary, excluding None values."""
return {
k: v for k, v in {
"path_data": self.path_data,
"path_data_traverse": self.path_data_traverse,
"path_hdcp_res": self.path_hdcp_res,
"path_hdcp_res_traverse": self.path_hdcp_res_traverse,
"path_hdcp_ver": self.path_hdcp_ver,
"path_hdcp_ver_traverse": self.path_hdcp_ver_traverse,
}.items()
if v is not None and (not isinstance(v, bool) or v)
}
@dataclass
class LicenseConfig:
"""
DRM License Server Configuration.
Contains all information needed to acquire and process DRM licenses,
including server URLs, certificates, request customization, and
response processing.
Attributes:
server_url: License server URL
server_certificate: Base64-encoded server certificate (for FairPlay, etc.)
use_http_get_request: Use GET instead of POST for license requests
req_headers: Custom HTTP headers as JSON string
req_params: URL query parameters as JSON string
req_data: Base64-encoded custom request body data
wrapper: Request body wrapper type
unwrapper: Response unwrapper type
unwrapper_params: Parameters for response unwrapping
keyids: ClearKey key mappings (KID -> Key in hex)
"""
server_url: Optional[str] = None
server_certificate: Optional[str] = None
use_http_get_request: bool = False
req_headers: Optional[str] = None
req_params: Optional[str] = None
req_data: Optional[str] = None
wrapper: Optional[str] = None
unwrapper: Optional[str] = None
unwrapper_params: Optional[LicenseUnwrapperParams] = None
keyids: dict[str, str] = field(default_factory=dict)
def __post_init__(self):
"""Normalize key IDs in keyids mapping."""
if self.keyids:
# Normalize all KIDs and keys to lowercase hex
normalized_keyids = {}
for kid, key in self.keyids.items():
try:
norm_kid = normalize_key_id(kid)
norm_key = key.lower().replace("-", "")
normalized_keyids[norm_kid] = norm_key
except Exception:
# Skip invalid entries
pass
self.keyids = normalized_keyids
def validate(self) -> None:
"""
Validate the license configuration.
Raises:
LicenseConfigError: If configuration is invalid
"""
# Validate server_certificate if present
if self.server_certificate:
try:
safe_base64_decode(self.server_certificate)
except Exception as e:
raise LicenseConfigError(
f"server_certificate must be valid base64: {e}"
) from e
# Validate req_data if present
if self.req_data:
try:
safe_base64_decode(self.req_data)
except Exception as e:
raise LicenseConfigError(
f"req_data must be valid base64: {e}"
) from e
# Validate keyids format (for ClearKey)
if self.keyids:
for kid, key in self.keyids.items():
if len(kid) != 32:
raise LicenseConfigError(
f"Invalid KID length: {kid} (must be 32 hex chars)"
)
if len(key) != 32:
raise LicenseConfigError(
f"Invalid KEY length for KID {kid}: {key} (must be 32 hex chars)"
)
if not all(c in "0123456789abcdef" for c in kid):
raise LicenseConfigError(f"KID contains non-hex characters: {kid}")
if not all(c in "0123456789abcdef" for c in key):
raise LicenseConfigError(
f"KEY contains non-hex characters for KID {kid}: {key}"
)
@classmethod
def create_with_req_data(cls, req_data_template: str, **kwargs) -> "LicenseConfig":
"""
Helper to create LicenseConfig with base64-encoded req_data.
Args:
req_data_template: Plain text request data template
**kwargs: Other LicenseConfig parameters
Returns:
LicenseConfig instance with encoded req_data
"""
req_data_encoded = safe_base64_encode(req_data_template.encode("utf-8"))
return cls(req_data=req_data_encoded, **kwargs)
def to_dict(self) -> dict:
"""
Convert to dictionary, excluding None/empty values.
Returns:
Dictionary representation
"""
result = {}
if self.server_url:
result["server_url"] = self.server_url
if self.server_certificate:
result["server_certificate"] = self.server_certificate
if self.use_http_get_request:
result["use_http_get_request"] = self.use_http_get_request
if self.req_headers:
result["req_headers"] = self.req_headers
if self.req_params:
result["req_params"] = self.req_params
if self.req_data:
result["req_data"] = self.req_data
if self.wrapper:
result["wrapper"] = self.wrapper
if self.unwrapper:
result["unwrapper"] = self.unwrapper
if self.unwrapper_params:
result["unwrapper_params"] = self.unwrapper_params.to_dict()
if self.keyids:
result["keyids"] = self.keyids
return result
@@ -0,0 +1,196 @@
"""
PSSH Data Model
Data class for storing PSSH (Protection System Specific Header) information.
"""
from dataclasses import dataclass, field
from functools import cached_property
from typing import Optional
from .drm_systems import DRMSystem
from .pssh_parser import PSSHParser
from .utils import normalize_uuid, normalize_key_id, format_uuid, deduplicate_key_ids
from .exceptions import InvalidPSSHError, InvalidKeyIDError
@dataclass
class PSSHData:
"""
Container for PSSH (Protection System Specific Header) data.
Attributes:
system_id: DRM system UUID (normalized, 32 hex chars, no hyphens)
pssh_box: Base64-encoded PSSH box (optional if extracting from segments)
key_ids: List of Key IDs (normalized, 32 hex chars, no hyphens)
source: Source of PSSH data (e.g., "manifest", "segment", "init")
"""
system_id: str
pssh_box: str = ""
key_ids: list[str] = field(default_factory=list)
source: str = "manifest"
def __post_init__(self):
"""Normalize and validate all data at creation time."""
# Normalize system_id
if self.system_id:
try:
self.system_id = normalize_uuid(self.system_id)
except Exception as e:
raise InvalidPSSHError(f"Invalid system_id: {e}") from e
else:
raise InvalidPSSHError("system_id is required")
# Normalize key_ids
if self.key_ids:
normalized_kids = []
for kid in self.key_ids:
try:
normalized_kids.append(normalize_key_id(kid))
except InvalidKeyIDError:
# Skip invalid KIDs but don't fail
pass
# Remove duplicates while preserving order
self.key_ids = deduplicate_key_ids(normalized_kids)
# Auto-extract key_ids from pssh_box if provided but no key_ids
if self.pssh_box and not self.key_ids:
try:
metadata = PSSHParser.parse_pssh_box(self.pssh_box)
self.key_ids = metadata["key_ids"]
except Exception:
# If extraction fails, leave key_ids empty
# They might be available in tenc boxes or segments
pass
@cached_property
def drm_system(self) -> Optional[DRMSystem]:
"""
Get DRM system enum from system_id.
Returns:
DRMSystem enum value or None if not recognized
"""
return DRMSystem.from_uuid(self.system_id)
@cached_property
def system_id_formatted(self) -> str:
"""
Get system_id with hyphens for display.
Returns:
UUID string with hyphens (8-4-4-4-12)
"""
return format_uuid(self.system_id)
@cached_property
def pssh_version(self) -> int:
"""
Get PSSH version from the box.
Returns:
PSSH version number, or -1 if no PSSH box
"""
if not self.pssh_box:
return -1
try:
return PSSHParser.get_pssh_version(self.pssh_box)
except Exception:
return -1
def needs_tenc_fallback(self) -> bool:
"""
Check if this PSSH needs tenc box fallback for Key IDs.
Returns:
True if tenc fallback is needed (version 0 PSSH without KIDs)
"""
return PSSHParser.needs_tenc_fallback(self.pssh_box, self.drm_system)
@property
def needs_extraction(self) -> bool:
"""
Check if PSSH/key_ids need to be extracted from segments.
Returns:
True if PSSH box or Key IDs are missing
"""
return not self.pssh_box or not self.key_ids
def add_key_ids(self, new_kids: list[str]) -> None:
"""
Add Key IDs to the existing list (normalized and deduplicated).
Args:
new_kids: List of Key IDs to add
"""
normalized_kids = []
for kid in new_kids:
try:
normalized_kids.append(normalize_key_id(kid))
except InvalidKeyIDError:
# Skip invalid KIDs
pass
# Merge with existing and deduplicate
all_kids = self.key_ids + normalized_kids
self.key_ids = deduplicate_key_ids(all_kids)
def validate(self) -> None:
"""
Validate the PSSH data.
Raises:
InvalidPSSHError: If data is invalid
"""
if not self.system_id:
raise InvalidPSSHError("system_id is required")
# Validate system_id format
try:
normalize_uuid(self.system_id)
except Exception as e:
raise InvalidPSSHError(f"Invalid system_id format: {e}") from e
# Validate pssh_box if present
if self.pssh_box:
try:
PSSHParser.parse_pssh_box(self.pssh_box)
except Exception as e:
raise InvalidPSSHError(f"Invalid pssh_box: {e}") from e
# Validate key_ids format
for kid in self.key_ids:
try:
normalize_key_id(kid)
except Exception as e:
raise InvalidPSSHError(f"Invalid key_id format: {e}") from e
def to_dict(self) -> dict:
"""
Convert to dictionary representation.
Returns:
Dictionary with all PSSH data
"""
return {
"system_id": self.system_id,
"system_id_formatted": self.system_id_formatted,
"pssh_box": self.pssh_box,
"key_ids": self.key_ids,
"source": self.source,
"drm_system": self.drm_system.value if self.drm_system else None,
"version": self.pssh_version,
}
def __repr__(self) -> str:
"""Return detailed representation."""
drm_name = self.drm_system.name if self.drm_system else "UNKNOWN"
kid_count = len(self.key_ids)
return (
f"<PSSHData({drm_name}, v{self.pssh_version}, "
f"{kid_count} KIDs, source='{self.source}')>"
)
@@ -0,0 +1,284 @@
"""
PSSH Box Parser
Handles parsing of Protection System Specific Header (PSSH) boxes
according to ISO/IEC 23001-7 specification.
PSSH Box Structure:
┌────────────────────────────────────┐
│ Box Size (4 bytes) │
├────────────────────────────────────┤
│ Box Type 'pssh' (4 bytes) │
├────────────────────────────────────┤
│ Version (1 byte) | Flags (3 bytes) │
├────────────────────────────────────┤
│ System ID (16 bytes UUID) │
├────────────────────────────────────┤
│ [Version 1+] │
│ KID Count (4 bytes) │
│ KIDs (16 bytes each) │
├────────────────────────────────────┤
│ Data Size (4 bytes) │
├────────────────────────────────────┤
│ Data (variable length) │
└────────────────────────────────────┘
"""
import struct
from typing import Optional
from .constants import (
PSSHOffsets,
KID_SIZE_BYTES,
MAX_PSSH_SIZE,
MAX_KEY_ID_COUNT,
)
from .drm_systems import DRMSystem
from .exceptions import InvalidPSSHError, PSSHSizeError
from .utils import bytes_to_uuid_hex, safe_base64_decode
class PSSHParser:
"""
Parser for PSSH (Protection System Specific Header) boxes.
Handles both version 0 and version 1+ PSSH boxes, extracting:
- System ID (DRM system UUID)
- Key IDs (for version 1+)
- PSSH version
"""
@staticmethod
def parse_pssh_box(pssh_base64: str) -> dict:
"""
Parse a PSSH box and extract metadata.
Args:
pssh_base64: Base64-encoded PSSH box
Returns:
Dictionary containing:
- system_id: Normalized UUID (32 hex chars, no hyphens)
- version: PSSH version (0 or 1+)
- key_ids: List of Key IDs (normalized, no hyphens)
- drm_system: DRMSystem enum value (if recognized)
Raises:
InvalidPSSHError: If PSSH box is malformed
PSSHSizeError: If PSSH box exceeds size limits
"""
if not pssh_base64:
raise InvalidPSSHError("PSSH box cannot be empty")
# Decode base64
try:
pssh_bytes = safe_base64_decode(pssh_base64)
except Exception as e:
raise InvalidPSSHError(f"Failed to decode PSSH base64: {e}") from e
# Check size limits
if len(pssh_bytes) > MAX_PSSH_SIZE:
raise PSSHSizeError(
f"PSSH box too large: {len(pssh_bytes)} bytes (max: {MAX_PSSH_SIZE})"
)
# Validate minimum size
if len(pssh_bytes) < PSSHOffsets.MIN_PSSH_SIZE:
raise InvalidPSSHError(
f"PSSH box too small: {len(pssh_bytes)} bytes "
f"(minimum: {PSSHOffsets.MIN_PSSH_SIZE})"
)
# Parse box header
box_size = struct.unpack(">I", pssh_bytes[PSSHOffsets.BOX_SIZE:4])[0]
box_type = pssh_bytes[PSSHOffsets.BOX_TYPE:PSSHOffsets.BOX_TYPE_END]
if box_type != b"pssh":
raise InvalidPSSHError(
f"Not a PSSH box: type is '{box_type.decode('ascii', errors='ignore')}'"
)
# Parse version and system ID
version = pssh_bytes[PSSHOffsets.VERSION]
system_id_bytes = pssh_bytes[PSSHOffsets.SYSTEM_ID_START:PSSHOffsets.SYSTEM_ID_END]
system_id = bytes_to_uuid_hex(system_id_bytes)
# Try to identify DRM system
drm_system = DRMSystem.from_uuid(system_id)
# Extract Key IDs based on version
key_ids = []
if version > 0:
key_ids = PSSHParser._extract_kids_from_v1_pssh(pssh_bytes, drm_system)
return {
"system_id": system_id,
"version": version,
"key_ids": key_ids,
"drm_system": drm_system,
}
@staticmethod
def _extract_kids_from_v1_pssh(
pssh_bytes: bytes,
drm_system: Optional[DRMSystem]
) -> list[str]:
"""
Extract Key IDs from version 1+ PSSH box.
Args:
pssh_bytes: Raw PSSH box bytes
drm_system: Identified DRM system (if any)
Returns:
List of normalized Key IDs (32 hex chars, no hyphens)
Raises:
InvalidPSSHError: If PSSH structure is invalid
"""
if len(pssh_bytes) < PSSHOffsets.MIN_V1_PSSH_SIZE:
raise InvalidPSSHError(
f"Version 1+ PSSH too small: {len(pssh_bytes)} bytes "
f"(minimum: {PSSHOffsets.MIN_V1_PSSH_SIZE})"
)
# Read KID count
kid_count = struct.unpack(
">I",
pssh_bytes[PSSHOffsets.V1_KID_COUNT:PSSHOffsets.V1_KID_COUNT_END]
)[0]
# Validate KID count
if kid_count > MAX_KEY_ID_COUNT:
raise InvalidPSSHError(
f"Too many Key IDs in PSSH: {kid_count} (max: {MAX_KEY_ID_COUNT})"
)
# Calculate required size
required_size = PSSHOffsets.V1_KIDS_START + (kid_count * KID_SIZE_BYTES)
if len(pssh_bytes) < required_size:
raise InvalidPSSHError(
f"PSSH truncated: claims {kid_count} KIDs but only "
f"{len(pssh_bytes)} bytes available"
)
# Extract KIDs
key_ids = []
for i in range(kid_count):
start_offset = PSSHOffsets.V1_KIDS_START + (i * KID_SIZE_BYTES)
end_offset = start_offset + KID_SIZE_BYTES
kid_bytes = pssh_bytes[start_offset:end_offset]
# Convert to hex based on DRM system
# Widevine uses raw bytes, others may use UUID byte order
if drm_system == DRMSystem.WIDEVINE:
# Widevine: KIDs are raw bytes (big-endian)
kid_hex = bytes_to_uuid_hex(kid_bytes)
elif drm_system == DRMSystem.PLAYREADY:
# PlayReady: KIDs are in UUID byte order (mixed-endian)
kid_hex = PSSHParser._parse_uuid_bytes(kid_bytes)
else:
# Unknown system: try UUID format first, fallback to raw
try:
kid_hex = PSSHParser._parse_uuid_bytes(kid_bytes)
except Exception:
kid_hex = bytes_to_uuid_hex(kid_bytes)
key_ids.append(kid_hex)
return key_ids
@staticmethod
def _parse_uuid_bytes(data: bytes) -> str:
"""
Parse UUID bytes with mixed-endian format (Microsoft GUID format).
UUID byte order (RFC 4122):
- time_low (4 bytes): big-endian → little-endian
- time_mid (2 bytes): big-endian → little-endian
- time_hi_version (2 bytes): big-endian → little-endian
- clock_seq (2 bytes): big-endian (no change)
- node (6 bytes): big-endian (no change)
Args:
data: 16 bytes of UUID data
Returns:
32-character hex string (normalized, no hyphens)
"""
if len(data) != 16:
raise ValueError(f"UUID bytes must be 16 bytes (got {len(data)})")
# Parse with mixed-endian format
time_low = struct.unpack("<I", data[0:4])[0] # Little-endian
time_mid = struct.unpack("<H", data[4:6])[0] # Little-endian
time_hi = struct.unpack("<H", data[6:8])[0] # Little-endian
clock_seq = data[8:10].hex() # Big-endian (raw)
node = data[10:16].hex() # Big-endian (raw)
# Format as hex string
uuid_hex = (
f"{time_low:08x}{time_mid:04x}{time_hi:04x}{clock_seq}{node}"
)
return uuid_hex.lower()
@staticmethod
def get_pssh_version(pssh_base64: str) -> int:
"""
Get PSSH version without full parsing.
Args:
pssh_base64: Base64-encoded PSSH box
Returns:
PSSH version number (0, 1, etc.)
Raises:
InvalidPSSHError: If PSSH box is malformed
"""
if not pssh_base64:
raise InvalidPSSHError("PSSH box cannot be empty")
try:
pssh_bytes = safe_base64_decode(pssh_base64)
except Exception as e:
raise InvalidPSSHError(f"Failed to decode PSSH base64: {e}") from e
if len(pssh_bytes) < PSSHOffsets.VERSION + 1:
raise InvalidPSSHError(f"PSSH box too small to read version")
return pssh_bytes[PSSHOffsets.VERSION]
@staticmethod
def needs_tenc_fallback(pssh_base64: str, drm_system: Optional[DRMSystem]) -> bool:
"""
Check if PSSH needs tenc box fallback for Key IDs.
Version 0 PSSH boxes don't contain Key IDs, so we need to extract
them from tenc boxes in the MP4 structure.
Args:
pssh_base64: Base64-encoded PSSH box
drm_system: DRM system (if known)
Returns:
True if tenc fallback is needed
"""
if not pssh_base64:
return True
try:
metadata = PSSHParser.parse_pssh_box(pssh_base64)
# Need fallback if:
# 1. No Key IDs extracted
# 2. Version 0 PSSH (doesn't contain KIDs)
# 3. Widevine system (commonly uses tenc)
return (
not metadata["key_ids"] and
metadata["version"] == 0 and
metadata["drm_system"] == DRMSystem.WIDEVINE
)
except Exception:
return True
@@ -0,0 +1,217 @@
"""
tenc Box Parser
Handles parsing of Track Encryption (tenc) boxes for extracting Key IDs
when PSSH boxes don't contain them (version 0 PSSH).
"""
import struct
from .constants import TencOffsets, MAX_TENC_SIZE, KID_SIZE_BYTES
from .exceptions import InvalidTencError
from .utils import bytes_to_uuid_hex
class TencParser:
"""
Parser for tenc (Track Encryption) boxes.
Used as fallback when PSSH version 0 doesn't contain Key IDs.
According to ISO/IEC 23001-7, tenc boxes contain default encryption
information including the default Key ID.
"""
@staticmethod
def extract_kids_from_tenc(tenc_data: bytes) -> list[str]:
"""
Extract Key IDs from tenc box data.
This is typically used as a fallback when PSSH version 0 boxes
don't contain Key IDs directly.
Args:
tenc_data: Raw tenc box bytes
Returns:
List of normalized Key IDs (32 hex chars, no hyphens)
Raises:
InvalidTencError: If tenc box is malformed
"""
if not tenc_data:
raise InvalidTencError("tenc box data cannot be empty")
# Check size limits
if len(tenc_data) > MAX_TENC_SIZE:
raise InvalidTencError(
f"tenc box too large: {len(tenc_data)} bytes (max: {MAX_TENC_SIZE})"
)
# Validate minimum size for version 0
if len(tenc_data) < TencOffsets.MIN_TENC_V0_SIZE:
raise InvalidTencError(
f"tenc box too small: {len(tenc_data)} bytes "
f"(minimum: {TencOffsets.MIN_TENC_V0_SIZE})"
)
# Validate box type
box_type = tenc_data[TencOffsets.BOX_TYPE:TencOffsets.BOX_TYPE_END]
if box_type != b"tenc":
raise InvalidTencError(
f"Not a tenc box: type is '{box_type.decode('ascii', errors='ignore')}'"
)
# Parse version
version = tenc_data[TencOffsets.VERSION]
# Extract KID based on version
if version == 0:
return TencParser._extract_kid_from_v0_tenc(tenc_data)
elif version == 1:
return TencParser._extract_kid_from_v1_tenc(tenc_data)
else:
raise InvalidTencError(f"Unsupported tenc version: {version}")
@staticmethod
def _extract_kid_from_v0_tenc(tenc_data: bytes) -> list[str]:
"""
Extract Key ID from version 0 tenc box.
Version 0 structure:
[16] Reserved (uint8)
[17] default_is_protected (uint8)
[18] default_per_sample_IV_size (uint8)
[19-34] default_KID (16 bytes)
Args:
tenc_data: Raw tenc box bytes
Returns:
List containing single normalized Key ID
Raises:
InvalidTencError: If tenc structure is invalid
"""
if len(tenc_data) < TencOffsets.V0_KID_END:
raise InvalidTencError(
f"tenc v0 box truncated: {len(tenc_data)} bytes "
f"(need at least {TencOffsets.V0_KID_END})"
)
# Check if track is protected
is_protected = tenc_data[TencOffsets.V0_IS_PROTECTED]
if is_protected == 0:
# Unencrypted track, no KID
return []
# Extract KID bytes
kid_bytes = tenc_data[TencOffsets.V0_KID_START:TencOffsets.V0_KID_END]
# tenc KIDs are typically in UUID/GUID format (mixed-endian)
try:
kid_hex = TencParser._parse_guid_bytes(kid_bytes)
except Exception:
# Fallback to raw hex if GUID parsing fails
kid_hex = bytes_to_uuid_hex(kid_bytes)
return [kid_hex]
@staticmethod
def _extract_kid_from_v1_tenc(tenc_data: bytes) -> list[str]:
"""
Extract Key ID from version 1 tenc box.
Version 1 structure:
[16] default_constant_IV_size (uint8)
[17+] default_constant_IV (if IV_size > 0)
[...] default_KID (16 bytes, after IV)
Args:
tenc_data: Raw tenc box bytes
Returns:
List containing single normalized Key ID
Raises:
InvalidTencError: If tenc structure is invalid
"""
# Read IV size
if len(tenc_data) < 17:
raise InvalidTencError("tenc v1 box too small to read IV size")
iv_size = tenc_data[16]
# KID starts after IV
kid_start = 17 + iv_size
kid_end = kid_start + KID_SIZE_BYTES
if len(tenc_data) < kid_end:
raise InvalidTencError(
f"tenc v1 box truncated: {len(tenc_data)} bytes "
f"(need at least {kid_end} for KID)"
)
# Extract KID bytes
kid_bytes = tenc_data[kid_start:kid_end]
# tenc KIDs are typically in UUID/GUID format
try:
kid_hex = TencParser._parse_guid_bytes(kid_bytes)
except Exception:
kid_hex = bytes_to_uuid_hex(kid_bytes)
return [kid_hex]
@staticmethod
def _parse_guid_bytes(data: bytes) -> str:
"""
Parse GUID/UUID bytes with mixed-endian format (Microsoft GUID).
GUID byte order:
- Data1 (4 bytes): little-endian
- Data2 (2 bytes): little-endian
- Data3 (2 bytes): little-endian
- Data4 (8 bytes): big-endian
Args:
data: 16 bytes of GUID data
Returns:
32-character hex string (normalized, no hyphens)
"""
if len(data) != 16:
raise ValueError(f"GUID bytes must be 16 bytes (got {len(data)})")
# Parse with mixed-endian format (Microsoft GUID)
data1 = struct.unpack("<I", data[0:4])[0] # Little-endian uint32
data2 = struct.unpack("<H", data[4:6])[0] # Little-endian uint16
data3 = struct.unpack("<H", data[6:8])[0] # Little-endian uint16
data4 = data[8:16].hex() # Big-endian (raw bytes)
# Format as hex string
guid_hex = f"{data1:08x}{data2:04x}{data3:04x}{data4}"
return guid_hex.lower()
@staticmethod
def is_track_encrypted(tenc_data: bytes) -> bool:
"""
Check if track is encrypted based on tenc box.
Args:
tenc_data: Raw tenc box bytes
Returns:
True if track is encrypted, False otherwise
"""
if not tenc_data or len(tenc_data) < TencOffsets.V0_IS_PROTECTED + 1:
return False
version = tenc_data[TencOffsets.VERSION]
if version == 0:
is_protected = tenc_data[TencOffsets.V0_IS_PROTECTED]
return is_protected != 0
# Version 1+ tracks are assumed encrypted if tenc box exists
return True
@@ -0,0 +1,244 @@
"""
DRM Utility Functions
Common utility functions for UUID formatting, validation, and normalization.
"""
import base64
import binascii
from typing import Optional
from .constants import HEX_CHARS, UUID_HEX_LENGTH, UUID_FORMATTED_LENGTH
from .exceptions import InvalidUUIDError, InvalidKeyIDError, Base64DecodingError
def is_hex_string(s: str, length: Optional[int] = None) -> bool:
"""
Check if string contains only hexadecimal characters.
Args:
s: String to validate
length: Optional expected length
Returns:
True if string is valid hex (and matches length if provided)
"""
if not s:
return False
if length is not None and len(s) != length:
return False
return all(c in HEX_CHARS for c in s.lower())
def normalize_uuid(uuid: str) -> str:
"""
Normalize UUID to 32 lowercase hex characters without hyphens.
Args:
uuid: UUID string (with or without hyphens)
Returns:
Normalized UUID string (32 hex chars, no hyphens, lowercase)
Raises:
InvalidUUIDError: If UUID format is invalid
"""
if not uuid:
raise InvalidUUIDError("UUID cannot be empty")
# Remove hyphens and convert to lowercase
normalized = uuid.lower().replace("-", "")
# Validate format
if len(normalized) != UUID_HEX_LENGTH:
raise InvalidUUIDError(
f"UUID must be {UUID_HEX_LENGTH} hex characters (got {len(normalized)}): {uuid}"
)
if not is_hex_string(normalized):
raise InvalidUUIDError(f"UUID contains non-hex characters: {uuid}")
return normalized
def format_uuid(uuid: str) -> str:
"""
Format UUID with standard hyphens (8-4-4-4-12).
Args:
uuid: UUID string (32 hex chars, no hyphens)
Returns:
Formatted UUID string with hyphens
Raises:
InvalidUUIDError: If UUID format is invalid
"""
normalized = normalize_uuid(uuid)
return (
f"{normalized[0:8]}-{normalized[8:12]}-{normalized[12:16]}-"
f"{normalized[16:20]}-{normalized[20:32]}"
)
def normalize_key_id(kid: str) -> str:
"""
Normalize Key ID to 32 lowercase hex characters without hyphens.
Args:
kid: Key ID string (with or without hyphens)
Returns:
Normalized Key ID string (32 hex chars, no hyphens, lowercase)
Raises:
InvalidKeyIDError: If Key ID format is invalid
"""
if not kid:
raise InvalidKeyIDError("Key ID cannot be empty")
# Remove hyphens and convert to lowercase
normalized = kid.lower().replace("-", "")
# Validate format
if len(normalized) != UUID_HEX_LENGTH:
raise InvalidKeyIDError(
f"Key ID must be {UUID_HEX_LENGTH} hex characters (got {len(normalized)}): {kid}"
)
if not is_hex_string(normalized):
raise InvalidKeyIDError(f"Key ID contains non-hex characters: {kid}")
return normalized
def bytes_to_uuid_hex(data: bytes) -> str:
"""
Convert 16 bytes to UUID hex string (no hyphens).
This is more efficient than using the uuid module.
Args:
data: 16 bytes of UUID data
Returns:
32-character hex string (lowercase, no hyphens)
Raises:
InvalidUUIDError: If data is not 16 bytes
"""
if len(data) != 16:
raise InvalidUUIDError(f"UUID bytes must be 16 bytes long (got {len(data)})")
return data.hex().lower()
def bytes_to_uuid_formatted(data: bytes) -> str:
"""
Convert 16 bytes to formatted UUID string with hyphens.
Args:
data: 16 bytes of UUID data
Returns:
36-character UUID string with hyphens (8-4-4-4-12)
Raises:
InvalidUUIDError: If data is not 16 bytes
"""
hex_string = bytes_to_uuid_hex(data)
return format_uuid(hex_string)
def uuid_to_bytes(uuid: str) -> bytes:
"""
Convert UUID string to 16 bytes.
Args:
uuid: UUID string (with or without hyphens)
Returns:
16 bytes of UUID data
Raises:
InvalidUUIDError: If UUID format is invalid
"""
normalized = normalize_uuid(uuid)
return bytes.fromhex(normalized)
def safe_base64_decode(data: str) -> bytes:
"""
Safely decode base64 string with better error handling.
Args:
data: Base64-encoded string
Returns:
Decoded bytes
Raises:
Base64DecodingError: If decoding fails
"""
if not data:
raise Base64DecodingError("Cannot decode empty base64 string")
try:
return base64.b64decode(data)
except (binascii.Error, ValueError) as e:
raise Base64DecodingError(f"Failed to decode base64 data: {e}") from e
def safe_base64_encode(data: bytes) -> str:
"""
Safely encode bytes to base64 string.
Args:
data: Bytes to encode
Returns:
Base64-encoded string
"""
if not data:
return ""
return base64.b64encode(data).decode('utf-8')
def normalize_alias(alias: str) -> str:
"""
Normalize alias for consistent lookups.
Removes hyphens, dots, and converts to lowercase.
Args:
alias: Alias string
Returns:
Normalized alias
"""
return alias.lower().strip().replace("-", "").replace(".", "")
def deduplicate_key_ids(key_ids: list[str]) -> list[str]:
"""
Remove duplicate Key IDs while preserving order.
Args:
key_ids: List of Key IDs (normalized)
Returns:
List of unique Key IDs in original order
"""
seen = set()
result = []
for kid in key_ids:
if kid not in seen:
seen.add(kid)
result.append(kid)
return result
@@ -1,242 +0,0 @@
# streaming_providers/base/models/drm_models.py
import base64
from dataclasses import dataclass, field
from enum import Enum
from typing import Dict, List, Optional
class DRMSystem(str, Enum):
WIDEVINE = "com.widevine.alpha"
PLAYREADY = "com.microsoft.playready"
WISEPLAY = "com.huawei.wiseplay"
CLEARKEY = "org.w3.clearkey"
FAIRPLAY = "com.apple.fps"
GENERIC = "generic"
NONE = "none"
@property
def system_uuid(self) -> str:
"""Get the standard UUID for this DRM system"""
uuid_mapping = {
self.WIDEVINE: "edef8ba9-79d6-4ace-a3c8-27dcd51d21ed",
self.PLAYREADY: "9a04f079-9840-4286-ab92-e65be0885f95",
self.CLEARKEY: "e2719d58-a985-b3c9-781a-b030af78d30e",
self.WISEPLAY: "3d5e6d35-9b9a-41e8-b843-dd3c6e72c42c",
self.FAIRPLAY: "94ce86fb-07ff-4f43-adb8-93d2fa968ca2",
self.GENERIC: "", # No UUID for generic plugins
self.NONE: "", # No UUID for unencrypted
}
return uuid_mapping.get(self, "")
@classmethod
def from_uuid(cls, uuid: str) -> Optional["DRMSystem"]:
"""Get DRM system from UUID"""
uuid_lower = uuid.lower().replace("-", "")
uuid_mapping = {
"edef8ba979d64acea3c827dcd51d21ed": cls.WIDEVINE,
"9a04f07998404286ab92e65be0885f95": cls.PLAYREADY,
"e2719d58a985b3c9781ab030af78d30e": cls.CLEARKEY,
"3d5e6d359b9a41e8b843dd3c6e72c42c": cls.WISEPLAY,
}
return uuid_mapping.get(uuid_lower)
@classmethod
def from_alias(cls, alias: str) -> Optional["DRMSystem"]:
"""
Resolve DRM system from human-friendly alias or UUID
Handles:
- Short aliases: "clearkey", "widevine", "playready", "fairplay", "wiseplay"
- Full Android identifiers: "com.widevine.alpha", "org.w3.clearkey", etc.
- UUIDs (with or without hyphens): "edef8ba9-79d6-4ace-a3c8-27dcd51d21ed"
Returns:
DRMSystem enum value or None if unrecognized
"""
alias_lower = alias.lower().strip().replace("-", "")
alias_mapping = {
# Widevine
"widevine": cls.WIDEVINE,
"com.widevine.alpha": cls.WIDEVINE,
"edef8ba979d64acea3c827dcd51d21ed": cls.WIDEVINE,
# PlayReady
"playready": cls.PLAYREADY,
"com.microsoft.playready": cls.PLAYREADY,
"9a04f07998404286ab92e65be0885f95": cls.PLAYREADY,
# ClearKey
"clearkey": cls.CLEARKEY,
"org.w3.clearkey": cls.CLEARKEY,
"e2719d58a985b3c9781ab030af78d30e": cls.CLEARKEY,
# FairPlay
"fairplay": cls.FAIRPLAY,
"com.apple.fps": cls.FAIRPLAY,
"skd": cls.FAIRPLAY,
"94ce86fb07ff4f43adb893d2fa968ca2": cls.FAIRPLAY,
# WisePlay
"wiseplay": cls.WISEPLAY,
"com.huawei.wiseplay": cls.WISEPLAY,
"3d5e6d359b9a41e8b843dd3c6e72c42c": cls.WISEPLAY,
# Generic/None
"generic": cls.GENERIC,
"none": cls.NONE,
"unencrypted": cls.NONE,
}
return alias_mapping.get(alias_lower)
class WrapperType(str, Enum):
BASE64 = "base64"
URLENC = "urlenc"
NONE = "none"
@dataclass
class PSSHData:
system_id: str
pssh_box: str = "" # Base64 encoded PSSH box
key_ids: List[str] = field(default_factory=list)
source: str = "manifest" # "manifest", "mp4_segment", "unknown"
@property
def needs_extraction(self) -> bool:
"""Check if PSSH/key_ids need to be extracted from segments"""
return not self.pssh_box or not self.key_ids
@property
def drm_system(self) -> Optional[DRMSystem]:
"""Get the corresponding DRM system for this PSSH"""
return DRMSystem.from_uuid(self.system_id)
def validate(self):
"""Validate the PSSH data"""
if not self.system_id:
raise ValueError("system_id is required")
# FIX: pssh_box can be empty (PSSH in segments)
if self.pssh_box: # Only validate if not empty
try:
base64.b64decode(self.pssh_box)
except Exception:
raise ValueError("pssh_box must be valid base64")
# Validate key IDs if present
for kid in self.key_ids:
if not all(c in "0123456789abcdefABCDEF-" for c in kid):
raise ValueError(f"Invalid key ID format: {kid}")
class UnwrapperType(str, Enum):
AUTO = "auto"
BASE64 = "base64"
JSON = "json"
XML = "xml"
NONE = "none"
@dataclass
class LicenseUnwrapperParams:
path_data: Optional[str] = None
path_data_traverse: bool = False
path_hdcp_res: Optional[str] = None
path_hdcp_res_traverse: bool = False
path_hdcp_ver: Optional[str] = None
path_hdcp_ver_traverse: bool = False
@dataclass
class LicenseConfig:
server_url: Optional[str] = None
server_certificate: Optional[str] = None
use_http_get_request: bool = False
req_headers: Optional[str] = None
req_params: Optional[str] = None
req_data: Optional[str] = None
wrapper: Optional[str] = None
unwrapper: Optional[str] = None
unwrapper_params: Optional[LicenseUnwrapperParams] = None
keyids: Dict[str, str] = field(default_factory=dict) # For ClearKey
def validate(self):
"""Validate the license configuration"""
if self.server_certificate:
try:
base64.b64decode(self.server_certificate)
except Exception:
raise ValueError("server_certificate must be valid base64")
if self.req_data:
try:
base64.b64decode(self.req_data)
except Exception:
raise ValueError("req_data must be valid base64")
if self.keyids:
for kid, key in self.keyids.items():
if not all(c in "0123456789abcdefABCDEF" for c in kid):
raise ValueError(f"Invalid KID format: {kid}")
if not all(c in "0123456789abcdefABCDEF" for c in key):
raise ValueError(f"Invalid KEY format: {key}")
@classmethod
def create_with_base64_req_data(cls, req_data_template: str, **kwargs):
"""Helper to ensure req_data is base64 encoded"""
import base64
req_data_encoded = base64.b64encode(req_data_template.encode("utf-8")).decode(
"utf-8"
)
return cls(req_data=req_data_encoded, **kwargs)
@dataclass
class DRMConfig:
system: DRMSystem
priority: int = 0
license: Optional[LicenseConfig] = None
def validate(self):
"""Validate the DRM configuration"""
if self.license:
self.license.validate()
def to_dict(self) -> Dict:
"""Convert to dictionary format expected by players"""
result = {
str(self.system.value): { # Use .value to get the actual string
"priority": self.priority
}
}
if self.license:
license_dict = {}
if self.license.server_url:
license_dict["server_url"] = self.license.server_url
if self.license.server_certificate:
license_dict["server_certificate"] = self.license.server_certificate
if self.license.use_http_get_request:
license_dict["use_http_get_request"] = self.license.use_http_get_request
if self.license.req_headers:
license_dict["req_headers"] = self.license.req_headers
if self.license.req_params:
license_dict["req_params"] = self.license.req_params
if self.license.req_data:
license_dict["req_data"] = self.license.req_data
if self.license.wrapper:
license_dict["wrapper"] = self.license.wrapper
if self.license.unwrapper:
license_dict["unwrapper"] = self.license.unwrapper
if self.license.unwrapper_params:
license_dict["unwrapper_params"] = {
k: v
for k, v in vars(self.license.unwrapper_params).items()
if v is not None
}
if self.license.keyids:
license_dict["keyids"] = self.license.keyids
if license_dict:
result[str(self.system.value)]["license"] = license_dict
return result
@@ -5,6 +5,7 @@ from .manifest_parser import ManifestParser
from .mpd_cache import MPDCacheManager
from .mpd_rewriter import MPDRewriter
from .timestamp_converter import TimestampConverter
from .mp4_pssh_extractor import MP4PSSHExtractor
from .vfs import VFS
__all__ = [
@@ -14,5 +15,6 @@ __all__ = [
"VFS",
"MPDRewriter",
"MPDCacheManager",
"MP4PSSHExtractor",
"TimestampConverter",
]
@@ -3,7 +3,7 @@ import re
from typing import List, Optional
from urllib.parse import quote, urljoin, urlparse
from ..models.drm_models import DRMSystem, PSSHData
from ..models.drm import PSSHData
from .logger import logger
@@ -34,7 +34,7 @@ class ManifestParser:
@staticmethod
def _extract_from_manifest_content(manifest_content: str) -> List[PSSHData]:
"""Extract PSSH and DRM systems from manifest content"""
# Try regex extraction first (handles PSSH boxes with KIDs)
# Try regex extraction first
pssh_list = ManifestParser._extract_with_regex(manifest_content)
if pssh_list:
return pssh_list
@@ -43,7 +43,6 @@ class ManifestParser:
drm_systems_found = set()
result = []
# More efficient: compile regex once
cp_pattern = re.compile(
r'<ContentProtection[^>]*schemeIdUri="urn:uuid:([^"]+)"[^>]*>',
re.IGNORECASE,
@@ -52,49 +51,48 @@ class ManifestParser:
for match in cp_pattern.finditer(manifest_content):
system_id = match.group(1).lower()
# Skip mp4protection scheme
if (
"mp4protection"
in manifest_content[max(0, match.start() - 100) : match.start()]
):
# Skip mp4protection
if "mp4protection" in manifest_content[max(0, match.start() - 100):match.start()]:
continue
drm_system = DRMSystem.from_uuid(system_id)
if drm_system and system_id not in drm_systems_found:
drm_systems_found.add(system_id)
result.append(
PSSHData(
system_id=system_id,
pssh_box="", # Empty - PSSH in segments
key_ids=[],
source="manifest_scheme_only",
)
)
logger.debug(f"Found DRM system from schemeIdUri: {drm_system.value}")
# Let PSSHData handle normalization!
pssh_data = PSSHData(
system_id=system_id,
pssh_box="",
key_ids=[],
source="manifest_scheme_only",
)
if pssh_data.drm_system and system_id not in drm_systems_found:
drm_systems_found.add(pssh_data.system_id) # Use normalized ID
result.append(pssh_data)
logger.debug(f"Found DRM system: {pssh_data.drm_system.value}")
return result
@staticmethod
def _extract_from_single_segment(
segment_url: str, expected_system_ids: List[str] = None
segment_url: str, expected_system_ids: List[str] = None
) -> List[PSSHData]:
"""Extract PSSH from a single MP4 segment"""
from .mp4_parser import MP4PSSHExtractor
from .mp4_pssh_extractor import MP4PSSHExtractor
try:
pssh_from_segment = MP4PSSHExtractor.extract_from_url(segment_url)
# Filter for expected DRM systems if provided
if expected_system_ids:
# Normalize expected IDs using the model!
normalized_expected = []
for sys_id in expected_system_ids:
# Create temporary PSSHData to leverage its normalization
temp = PSSHData(system_id=sys_id, source="filter")
normalized_expected.append(temp.system_id)
filtered_pssh = [
p for p in pssh_from_segment if p.system_id in expected_system_ids
p for p in pssh_from_segment
if p.system_id in normalized_expected
]
if filtered_pssh:
logger.debug(f"Found {len(filtered_pssh)} PSSH boxes in segment")
return filtered_pssh
elif pssh_from_segment:
logger.debug(f"Found {len(pssh_from_segment)} PSSH boxes in segment")
return pssh_from_segment
except Exception as e:
logger.warning(f"Failed to extract PSSH from segment: {e}")
@@ -1,483 +0,0 @@
import base64
import struct
import uuid
from typing import List, Optional
from ..models.drm_models import PSSHData
from .logger import logger
class MP4PSSHExtractor:
"""Extract PSSH boxes and key IDs from MP4 segments"""
@staticmethod
def extract_from_url(segment_url: str, timeout: int = 10) -> List[PSSHData]:
"""Download MP4 segment and extract PSSH data"""
import requests
try:
response = requests.get(segment_url, timeout=timeout)
response.raise_for_status()
# Only download first ~100KB for efficiency
chunk_size = 1024 * 100
data = response.content[:chunk_size]
return MP4PSSHExtractor.extract_from_bytes(data)
except Exception as e:
logger.error(f"Failed to extract PSSH from {segment_url}: {e}")
return []
@staticmethod
def extract_from_bytes(data: bytes) -> List[PSSHData]:
"""Extract PSSH boxes and encryption info from MP4 binary data"""
pssh_data_list = []
offset = 0
# First, extract all tenc boxes to get default KIDs
tenc_kids = MP4PSSHExtractor._extract_all_tenc_kids(data)
while offset < len(data):
try:
# Read box size (4 bytes, big-endian)
if offset + 4 > len(data):
break
box_size = struct.unpack(">I", data[offset : offset + 4])[0]
if box_size == 0:
box_size = len(data) - offset # Box extends to end of file
elif box_size == 1:
# Extended size (skip for now - rare in practice)
break
if offset + box_size > len(data):
break
# Read box type (4 bytes)
box_type = data[offset + 4 : offset + 8].decode(
"ascii", errors="ignore"
)
if box_type == "moov":
# Look for PSSH in moov box
moov_data = data[offset : offset + box_size]
pssh_in_moov = MP4PSSHExtractor._extract_from_moov(moov_data)
# Enhance PSSH data with tenc KIDs if needed
for pssh in pssh_in_moov:
if not pssh.key_ids and tenc_kids:
pssh.key_ids = tenc_kids.copy()
pssh_data_list.extend(pssh_in_moov)
elif box_type == "pssh":
# Found standalone PSSH box
pssh_box = MP4PSSHExtractor._parse_pssh_box(
data[offset : offset + box_size]
)
if pssh_box:
# Add tenc KIDs if PSSH doesn't have its own
if not pssh_box.key_ids and tenc_kids:
pssh_box.key_ids = tenc_kids.copy()
pssh_data_list.append(pssh_box)
offset += box_size
except Exception as e:
logger.debug(f"Error parsing MP4 box at offset {offset}: {e}")
offset += 1 # Try to recover
return pssh_data_list
@staticmethod
def _extract_all_tenc_kids(data: bytes) -> List[str]:
"""Recursively extract all KIDs from tenc boxes in the MP4 data"""
kids = []
offset = 0
while offset < len(data):
try:
if offset + 8 > len(data):
break
box_size = struct.unpack(">I", data[offset : offset + 4])[0]
if box_size < 8 or offset + box_size > len(data):
offset += 1
continue
box_type = data[offset + 4 : offset + 8]
if box_type == b"tenc":
# Parse tenc box
logger.debug(
f"Found tenc box at offset {offset} (0x{offset:x}), size {box_size}"
)
tenc_kid = MP4PSSHExtractor._parse_tenc_box(
data[offset : offset + box_size]
)
if tenc_kid and tenc_kid not in kids:
kids.append(tenc_kid)
elif box_size > 8:
# Recursively search inside container boxes
container_boxes = {
b"moov",
b"trak",
b"mdia",
b"minf",
b"stbl",
b"stsd",
b"encv",
b"sinf",
b"schi",
b"udta",
}
if box_type in container_boxes:
# Recursively search inside the container
inner_data = data[offset + 8 : offset + box_size]
inner_kids = MP4PSSHExtractor._extract_all_tenc_kids(inner_data)
for kid in inner_kids:
if kid not in kids:
kids.append(kid)
offset += box_size
except Exception as e:
logger.debug(f"Error parsing box at offset {offset}: {e}")
offset += 1
return kids
@staticmethod
def _parse_tenc_box(tenc_bytes: bytes) -> Optional[str]:
"""Parse a tenc box and extract the default key ID"""
try:
# tenc box should be at least 32 bytes for version 0 with KID
if len(tenc_bytes) < 32:
logger.debug(
f"tenc box too small: {len(tenc_bytes)} bytes, need at least 32"
)
return None
# Parse box header
box_size = struct.unpack(">I", tenc_bytes[0:4])[0]
box_type = tenc_bytes[4:8]
if box_type != b"tenc":
logger.debug(f"Not a tenc box, type: {box_type.hex()}")
return None
version = tenc_bytes[8]
logger.debug(f"tenc box version: {version}")
if version == 0:
# For version 0: reserved(24) + is_encrypted(1) is a 32-bit field at bytes 12-15
# Actually, it's stored as a 32-bit big-endian integer
# Bytes 12-15: reserved (24 bits) + is_encrypted (1 bit)
# Byte 16: default_iv_size
# Bytes 17-32: default_KID (16 bytes)
# Read the 32-bit field at bytes 12-15
reserved_encrypted = struct.unpack(">I", tenc_bytes[12:16])[0]
# Extract is_encrypted from bit 24 (0-indexed from MSB)
# Actually, in big-endian, byte 14 is the 3rd byte of this 32-bit field
# Let me think: bytes[12], bytes[13], bytes[14], bytes[15]
# Byte 14 = tenc_bytes[14] = your "01"
# Byte 15 = tenc_bytes[15] = your "08"
# Actually simpler: byte 14 contains is_encrypted in its LSB
is_encrypted = (tenc_bytes[14] & 0x01) != 0
default_iv_size = tenc_bytes[15]
# KID starts at byte 16 (not 14!)
kid_offset = 16
logger.debug(
f"tenc v0: is_encrypted={is_encrypted}, default_iv_size={default_iv_size}, kid_offset={kid_offset}"
)
elif version == 1:
# Version 1 has different structure
kid_offset = 16 # Adjust as needed for v1
logger.debug(f"tenc v1: kid_offset={kid_offset}")
else:
logger.debug(f"Unknown tenc version: {version}")
return None
if kid_offset + 16 > len(tenc_bytes):
logger.debug(
f"tenc box too small for KID: {len(tenc_bytes)} bytes, need {kid_offset + 16}"
)
return None
# Extract KID bytes
kid_bytes = tenc_bytes[kid_offset : kid_offset + 16]
hex_kid = kid_bytes.hex()
logger.debug(f"KID bytes at offset {kid_offset}: {hex_kid}")
# Format as UUID
try:
kid_uuid = str(uuid.UUID(bytes=kid_bytes))
# Convert to lowercase without dashes (same format as PSSH KIDs)
clean_kid = kid_uuid.replace("-", "").lower()
logger.debug(f"Parsed KID: {clean_kid}")
return clean_kid
except Exception as e:
logger.debug(f"Failed to parse KID bytes as UUID: {e}, hex: {hex_kid}")
# Return hex string as fallback
return hex_kid
except Exception as e:
logger.debug(f"Failed to parse tenc box: {e}")
return None
# [Keep all the rest of the methods EXACTLY the same...]
@staticmethod
def _extract_from_moov(moov_data: bytes) -> List[PSSHData]:
"""Extract PSSH boxes from moov container"""
pssh_list = []
offset = 8 # Skip moov header
while offset < len(moov_data):
try:
box_size = struct.unpack(">I", moov_data[offset : offset + 4])[0]
box_type = moov_data[offset + 4 : offset + 8].decode(
"ascii", errors="ignore"
)
if box_type == "trak":
# Parse track for PSSH
trak_data = moov_data[offset : offset + box_size]
pssh_in_trak = MP4PSSHExtractor._extract_from_trak(trak_data)
pssh_list.extend(pssh_in_trak)
elif box_type == "pssh":
# PSSH directly in moov
pssh_box = MP4PSSHExtractor._parse_pssh_box(
moov_data[offset : offset + box_size]
)
if pssh_box:
pssh_list.append(pssh_box)
offset += box_size
except:
break
return pssh_list
@staticmethod
def _extract_from_trak(trak_data: bytes) -> List[PSSHData]:
"""Extract PSSH from trak box"""
pssh_list = []
offset = 8
while offset < len(trak_data):
try:
box_size = struct.unpack(">I", trak_data[offset : offset + 4])[0]
box_type = trak_data[offset + 4 : offset + 8].decode(
"ascii", errors="ignore"
)
if box_type == "mdia":
mdia_data = trak_data[offset : offset + box_size]
pssh_in_mdia = MP4PSSHExtractor._extract_from_mdia(mdia_data)
pssh_list.extend(pssh_in_mdia)
offset += box_size
except:
break
return pssh_list
@staticmethod
def _extract_from_mdia(mdia_data: bytes) -> List[PSSHData]:
"""Extract PSSH from mdia box"""
pssh_list = []
offset = 8
while offset < len(mdia_data):
try:
box_size = struct.unpack(">I", mdia_data[offset : offset + 4])[0]
box_type = mdia_data[offset + 4 : offset + 8].decode(
"ascii", errors="ignore"
)
if box_type == "minf":
minf_data = mdia_data[offset : offset + box_size]
pssh_in_minf = MP4PSSHExtractor._extract_from_minf(minf_data)
pssh_list.extend(pssh_in_minf)
offset += box_size
except:
break
return pssh_list
@staticmethod
def _extract_from_minf(minf_data: bytes) -> List[PSSHData]:
"""Extract PSSH from minf box"""
pssh_list = []
offset = 8
while offset < len(minf_data):
try:
box_size = struct.unpack(">I", minf_data[offset : offset + 4])[0]
box_type = minf_data[offset + 4 : offset + 8].decode(
"ascii", errors="ignore"
)
if box_type == "stbl":
stbl_data = minf_data[offset : offset + box_size]
pssh_in_stbl = MP4PSSHExtractor._extract_from_stbl(stbl_data)
pssh_list.extend(pssh_in_stbl)
offset += box_size
except:
break
return pssh_list
@staticmethod
def _extract_from_stbl(stbl_data: bytes) -> List[PSSHData]:
"""Extract PSSH from stbl box (where protection scheme info usually is)"""
pssh_list = []
offset = 8
while offset < len(stbl_data):
try:
box_size = struct.unpack(">I", stbl_data[offset : offset + 4])[0]
box_type = stbl_data[offset + 4 : offset + 8].decode(
"ascii", errors="ignore"
)
if box_type == "sinf":
sinf_data = stbl_data[offset : offset + box_size]
pssh_in_sinf = MP4PSSHExtractor._extract_from_sinf(sinf_data)
pssh_list.extend(pssh_in_sinf)
offset += box_size
except:
break
return pssh_list
@staticmethod
def _extract_from_sinf(sinf_data: bytes) -> List[PSSHData]:
"""Extract PSSH from sinf (protection scheme information) box"""
pssh_list = []
offset = 8
while offset < len(sinf_data):
try:
box_size = struct.unpack(">I", sinf_data[offset : offset + 4])[0]
box_type = sinf_data[offset + 4 : offset + 8].decode(
"ascii", errors="ignore"
)
if box_type == "schi":
schi_data = sinf_data[offset : offset + box_size]
pssh_in_schi = MP4PSSHExtractor._extract_from_schi(schi_data)
pssh_list.extend(pssh_in_schi)
offset += box_size
except:
break
return pssh_list
@staticmethod
def _extract_from_schi(schi_data: bytes) -> List[PSSHData]:
"""Extract PSSH from schi box (where PSSH boxes are typically stored)"""
pssh_list = []
offset = 8
while offset < len(schi_data):
try:
box_size = struct.unpack(">I", schi_data[offset : offset + 4])[0]
box_type = schi_data[offset + 4 : offset + 8].decode(
"ascii", errors="ignore"
)
if box_type == "pssh":
pssh_box = MP4PSSHExtractor._parse_pssh_box(
schi_data[offset : offset + box_size]
)
if pssh_box:
pssh_list.append(pssh_box)
offset += box_size
except:
break
return pssh_list
@staticmethod
def _parse_pssh_box(pssh_bytes: bytes) -> Optional[PSSHData]:
"""Parse a PSSH box and extract system_id, pssh_box, and key_ids"""
try:
if len(pssh_bytes) < 32: # Minimum size for PSSH box
return None
# Parse box header
box_size = struct.unpack(">I", pssh_bytes[0:4])[0]
box_type = pssh_bytes[4:8].decode("ascii")
if box_type != "pssh":
return None
version = pssh_bytes[8]
flags = struct.unpack(">I", b"\x00" + pssh_bytes[9:12])[0]
# Extract system ID (bytes 12-28)
system_id_bytes = pssh_bytes[12:28]
system_id = str(uuid.UUID(bytes=system_id_bytes))
# Extract key IDs (if version > 0)
key_ids = []
current_offset = 28
if version > 0:
# Read KID count
if current_offset + 4 > len(pssh_bytes):
return None
kid_count = struct.unpack(
">I", pssh_bytes[current_offset : current_offset + 4]
)[0]
current_offset += 4
# Read each KID
for _ in range(kid_count):
if current_offset + 16 > len(pssh_bytes):
break
kid_bytes = pssh_bytes[current_offset : current_offset + 16]
# kid_uuid = str(uuid.UUID(bytes=kid_bytes))
# key_ids.append(kid_uuid.replace("-", "").lower())
key_ids.append(kid_bytes.hex().lower())
current_offset += 16
else:
logger.debug(
f"Version 0 PSSH for system {system_id}, will look for KIDs in tenc box"
)
# Encode entire PSSH box as base64
pssh_b64 = base64.b64encode(pssh_bytes[:box_size]).decode("ascii")
return PSSHData(
system_id=system_id,
pssh_box=pssh_b64,
key_ids=key_ids,
source="mp4_segment",
)
except Exception as e:
logger.debug(f"Failed to parse PSSH box: {e}")
return None
@@ -0,0 +1,470 @@
"""
MP4 PSSH Extractor
Extracts PSSH boxes and Key IDs from MP4 segments using the refactored DRM models.
"""
import struct
from typing import Optional
from ..models.drm import PSSHData, TencParser, PSSHParser
from ..models.drm.exceptions import InvalidPSSHError, InvalidTencError
from .logger import logger
class MP4PSSHExtractor:
"""
Extract PSSH boxes and Key IDs from MP4 segments.
Uses the refactored DRM models for proper parsing and validation.
"""
@staticmethod
def extract_from_url(segment_url: str, timeout: int = 10) -> list[PSSHData]:
"""
Download MP4 segment and extract PSSH data.
Args:
segment_url: URL of the MP4 segment
timeout: Request timeout in seconds
Returns:
List of PSSHData objects with extracted information
"""
import requests
try:
response = requests.get(segment_url, timeout=timeout)
response.raise_for_status()
# Only download first ~100KB for efficiency
chunk_size = 1024 * 100
data = response.content[:chunk_size]
return MP4PSSHExtractor.extract_from_bytes(data)
except Exception as e:
logger.error(f"Failed to extract PSSH from {segment_url}: {e}")
return []
@staticmethod
def extract_from_bytes(data: bytes) -> list[PSSHData]:
"""
Extract PSSH boxes and encryption info from MP4 binary data.
Process:
1. Extract all tenc boxes to get default KIDs
2. Extract all PSSH boxes
3. Merge tenc KIDs with PSSH data where needed
Args:
data: Raw MP4 binary data
Returns:
List of PSSHData objects with complete information
"""
pssh_data_list = []
offset = 0
# First pass: Extract all tenc boxes to get default KIDs
tenc_kids = MP4PSSHExtractor._extract_all_tenc_kids(data)
if tenc_kids:
logger.debug(f"Extracted {len(tenc_kids)} KIDs from tenc boxes")
# Second pass: Extract PSSH boxes
while offset < len(data):
try:
# Read box size (4 bytes, big-endian)
if offset + 8 > len(data):
break
box_size = struct.unpack(">I", data[offset: offset + 4])[0]
# Handle special box sizes
if box_size == 0:
box_size = len(data) - offset # Box extends to end
elif box_size == 1:
# Extended size (64-bit) - skip for now
break
if offset + box_size > len(data):
break
# Read box type (4 bytes)
box_type = data[offset + 4: offset + 8]
if box_type == b"moov":
# Look for PSSH in moov container
moov_data = data[offset: offset + box_size]
pssh_in_moov = MP4PSSHExtractor._extract_from_moov(moov_data)
pssh_data_list.extend(pssh_in_moov)
elif box_type == b"pssh":
# Found standalone PSSH box
pssh_box = MP4PSSHExtractor._parse_pssh_box(
data[offset: offset + box_size]
)
if pssh_box:
pssh_data_list.append(pssh_box)
offset += box_size
except Exception as e:
logger.debug(f"Error parsing MP4 box at offset {offset}: {e}")
offset += 1 # Try to recover
# Third pass: Enhance PSSH data with tenc KIDs if needed
for pssh_data in pssh_data_list:
if pssh_data.needs_tenc_fallback() and tenc_kids:
logger.debug(
f"Adding {len(tenc_kids)} tenc KIDs to {pssh_data.drm_system} PSSH"
)
pssh_data.add_key_ids(tenc_kids)
return pssh_data_list
@staticmethod
def _extract_all_tenc_kids(data: bytes) -> list[str]:
"""
Extract all Key IDs from tenc boxes in the MP4 data.
Uses TencParser for proper tenc box parsing.
Args:
data: Raw MP4 binary data
Returns:
List of normalized Key IDs (32 hex chars, no hyphens)
"""
kids = []
offset = 0
while offset < len(data):
try:
if offset + 8 > len(data):
break
box_size = struct.unpack(">I", data[offset: offset + 4])[0]
box_type = data[offset + 4: offset + 8]
if box_size < 8 or offset + box_size > len(data):
offset += 1
continue
if box_type == b"tenc":
# Extract tenc box data
tenc_data = data[offset: offset + box_size]
# Use TencParser for proper parsing
try:
tenc_kids = TencParser.extract_kids_from_tenc(tenc_data)
for kid in tenc_kids:
if kid not in kids:
kids.append(kid)
logger.debug(f"Extracted KID from tenc: {kid[:8]}...")
except InvalidTencError as e:
logger.debug(f"Invalid tenc box at offset {offset}: {e}")
elif box_size > 8:
# Recursively search container boxes
container_boxes = {
b"moov", b"trak", b"mdia", b"minf",
b"stbl", b"stsd", b"encv", b"enca",
b"sinf", b"schi"
}
if box_type in container_boxes:
# Search inside container
inner_data = data[offset + 8: offset + box_size]
inner_kids = MP4PSSHExtractor._extract_all_tenc_kids(inner_data)
for kid in inner_kids:
if kid not in kids:
kids.append(kid)
offset += box_size
except Exception as e:
logger.debug(f"Error at offset {offset}: {e}")
offset += 1
return kids
@staticmethod
def _extract_from_moov(moov_data: bytes) -> list[PSSHData]:
"""
Extract PSSH boxes from moov container.
Args:
moov_data: Raw moov box data
Returns:
List of PSSHData objects found in moov
"""
pssh_list = []
offset = 8 # Skip moov header
while offset < len(moov_data):
try:
if offset + 8 > len(moov_data):
break
box_size = struct.unpack(">I", moov_data[offset: offset + 4])[0]
box_type = moov_data[offset + 4: offset + 8]
if box_size < 8 or offset + box_size > len(moov_data):
break
if box_type == b"trak":
# Parse track for PSSH
trak_data = moov_data[offset: offset + box_size]
pssh_in_trak = MP4PSSHExtractor._extract_from_trak(trak_data)
pssh_list.extend(pssh_in_trak)
elif box_type == b"pssh":
# PSSH directly in moov
pssh_box = MP4PSSHExtractor._parse_pssh_box(
moov_data[offset: offset + box_size]
)
if pssh_box:
pssh_list.append(pssh_box)
offset += box_size
except Exception as e:
logger.debug(f"Error in moov at offset {offset}: {e}")
break
return pssh_list
@staticmethod
def _extract_from_trak(trak_data: bytes) -> list[PSSHData]:
"""Extract PSSH from trak box"""
pssh_list = []
offset = 8
while offset < len(trak_data):
try:
if offset + 8 > len(trak_data):
break
box_size = struct.unpack(">I", trak_data[offset: offset + 4])[0]
box_type = trak_data[offset + 4: offset + 8]
if box_size < 8 or offset + box_size > len(trak_data):
break
if box_type == b"mdia":
mdia_data = trak_data[offset: offset + box_size]
pssh_in_mdia = MP4PSSHExtractor._extract_from_mdia(mdia_data)
pssh_list.extend(pssh_in_mdia)
offset += box_size
except Exception:
break
return pssh_list
@staticmethod
def _extract_from_mdia(mdia_data: bytes) -> list[PSSHData]:
"""Extract PSSH from mdia box"""
pssh_list = []
offset = 8
while offset < len(mdia_data):
try:
if offset + 8 > len(mdia_data):
break
box_size = struct.unpack(">I", mdia_data[offset: offset + 4])[0]
box_type = mdia_data[offset + 4: offset + 8]
if box_size < 8 or offset + box_size > len(mdia_data):
break
if box_type == b"minf":
minf_data = mdia_data[offset: offset + box_size]
pssh_in_minf = MP4PSSHExtractor._extract_from_minf(minf_data)
pssh_list.extend(pssh_in_minf)
offset += box_size
except Exception:
break
return pssh_list
@staticmethod
def _extract_from_minf(minf_data: bytes) -> list[PSSHData]:
"""Extract PSSH from minf box"""
pssh_list = []
offset = 8
while offset < len(minf_data):
try:
if offset + 8 > len(minf_data):
break
box_size = struct.unpack(">I", minf_data[offset: offset + 4])[0]
box_type = minf_data[offset + 4: offset + 8]
if box_size < 8 or offset + box_size > len(minf_data):
break
if box_type == b"stbl":
stbl_data = minf_data[offset: offset + box_size]
pssh_in_stbl = MP4PSSHExtractor._extract_from_stbl(stbl_data)
pssh_list.extend(pssh_in_stbl)
offset += box_size
except Exception:
break
return pssh_list
@staticmethod
def _extract_from_stbl(stbl_data: bytes) -> list[PSSHData]:
"""Extract PSSH from stbl box (where protection scheme info usually is)"""
pssh_list = []
offset = 8
while offset < len(stbl_data):
try:
if offset + 8 > len(stbl_data):
break
box_size = struct.unpack(">I", stbl_data[offset: offset + 4])[0]
box_type = stbl_data[offset + 4: offset + 8]
if box_size < 8 or offset + box_size > len(stbl_data):
break
if box_type == b"sinf":
sinf_data = stbl_data[offset: offset + box_size]
pssh_in_sinf = MP4PSSHExtractor._extract_from_sinf(sinf_data)
pssh_list.extend(pssh_in_sinf)
offset += box_size
except Exception:
break
return pssh_list
@staticmethod
def _extract_from_sinf(sinf_data: bytes) -> list[PSSHData]:
"""Extract PSSH from sinf (protection scheme information) box"""
pssh_list = []
offset = 8
while offset < len(sinf_data):
try:
if offset + 8 > len(sinf_data):
break
box_size = struct.unpack(">I", sinf_data[offset: offset + 4])[0]
box_type = sinf_data[offset + 4: offset + 8]
if box_size < 8 or offset + box_size > len(sinf_data):
break
if box_type == b"schi":
schi_data = sinf_data[offset: offset + box_size]
pssh_in_schi = MP4PSSHExtractor._extract_from_schi(schi_data)
pssh_list.extend(pssh_in_schi)
offset += box_size
except Exception:
break
return pssh_list
@staticmethod
def _extract_from_schi(schi_data: bytes) -> list[PSSHData]:
"""Extract PSSH from schi box (where PSSH boxes are typically stored)"""
pssh_list = []
offset = 8
while offset < len(schi_data):
try:
if offset + 8 > len(schi_data):
break
box_size = struct.unpack(">I", schi_data[offset: offset + 4])[0]
box_type = schi_data[offset + 4: offset + 8]
if box_size < 8 or offset + box_size > len(schi_data):
break
if box_type == b"pssh":
pssh_box = MP4PSSHExtractor._parse_pssh_box(
schi_data[offset: offset + box_size]
)
if pssh_box:
pssh_list.append(pssh_box)
offset += box_size
except Exception:
break
return pssh_list
@staticmethod
def _parse_pssh_box(pssh_bytes: bytes) -> Optional[PSSHData]:
"""
Parse PSSH box and create PSSHData object.
Uses PSSHParser for proper parsing and validation.
Args:
pssh_bytes: Raw PSSH box bytes
Returns:
PSSHData object or None if parsing fails
"""
try:
# Basic validation
if len(pssh_bytes) < 32:
logger.debug(f"PSSH box too small: {len(pssh_bytes)} bytes")
return None
box_type = pssh_bytes[4:8]
if box_type != b"pssh":
logger.debug(f"Not a PSSH box: {box_type}")
return None
# Encode entire PSSH box as base64
from ..models.drm.utils import safe_base64_encode
pssh_b64 = safe_base64_encode(pssh_bytes)
# Parse PSSH to get system_id and metadata
try:
metadata = PSSHParser.parse_pssh_box(pssh_b64)
except InvalidPSSHError as e:
logger.debug(f"Failed to parse PSSH: {e}")
return None
# Create PSSHData with parsed information
pssh_data = PSSHData(
system_id=metadata["system_id"],
pssh_box=pssh_b64,
key_ids=metadata["key_ids"],
source="mp4_segment",
)
# Log what we found
drm_name = pssh_data.drm_system.name if pssh_data.drm_system else "UNKNOWN"
kid_count = len(pssh_data.key_ids)
version = metadata["version"]
logger.debug(
f"Parsed PSSH: {drm_name} v{version} with {kid_count} KIDs"
)
return pssh_data
except Exception as e:
logger.debug(f"Failed to parse PSSH box: {e}")
return None
@@ -1,5 +1,6 @@
# streaming_providers/base/utils/mpd_rewriter.py
import base64
import struct
import xml.etree.ElementTree as ET
import re
from typing import Optional, Tuple, Set, Dict, List
@@ -417,8 +418,8 @@ class MPDRewriter:
return False
@staticmethod
def _parse_video_representation(
self,
representation: ET.Element,
adaptation_set: ET.Element,
period: ET.Element,
@@ -568,7 +569,8 @@ class MPDRewriter:
# Check default_KID attribute
kid_attr = cp.get("{urn:mpeg:cenc:2013}default_KID")
if kid_attr:
return kid_attr.replace("-", "").lower()
# Let the model normalize it later - just return raw
return kid_attr
# Check cenc:pssh
for pssh in cp.findall("cenc:pssh", self.CENC_NAMESPACE):
@@ -576,8 +578,13 @@ class MPDRewriter:
try:
pssh_data = base64.b64decode(pssh.text)
if len(pssh_data) >= 36:
kid_bytes = pssh_data[32:48]
return kid_bytes.hex()
version = pssh_data[8] if len(pssh_data) > 8 else 0
if version > 0:
kid_count = struct.unpack(">I", pssh_data[28:32])[0]
if kid_count > 0 and len(pssh_data) >= 48:
kid_bytes = pssh_data[32:48]
# Return raw hex - KeyConfiguration will normalize!
return kid_bytes.hex()
except Exception:
continue