work towards provider v2

This commit is contained in:
Nirvana
2026-10-07 12:12:10 +02:00
parent 777101f89d
commit 04ededea69
24 changed files with 1428 additions and 864 deletions
@@ -34,6 +34,12 @@ class Channel(Content):
f"Channel {self.name} ({self.content_id}): "
f"session_manifest=True but manifest URL is set - manifest will be ignored"
)
# Content.__post_init__ is deliberately NOT called: it raises on
# pricing/mode mismatches, and Channel's documented contract is
# "advisory warnings, the code runs" (one bad upstream channel must
# not kill the whole channel list). VodItem stays strict.
for problem in self._pricing_mode_problems():
logger.warning(f"Channel {self.name} ({self.content_id}): {problem}")
# Backward compatibility alias
@property
+36 -24
View File
@@ -1,6 +1,6 @@
from dataclasses import dataclass
from decimal import Decimal
from typing import Dict, List, Optional, Set
from typing import Dict, List, Optional
from .drm import DRMConfig
from .pricing import AccessType, PricePoint, Pricing
@@ -68,34 +68,44 @@ class Content:
# Pricing (None = UNKNOWN, NOT FREE)
pricing: Optional[Pricing] = None
def __post_init__(self):
"""Validate pricing and mode consistency."""
def _pricing_mode_problems(self) -> List[str]:
"""
Pricing-vs-mode inconsistencies, as human-readable strings.
Only combinations that are unambiguous are constrained:
PPV_LIVE -> mode must be "live"
TVOD_RENTAL / TVOD_PURCHASE -> mode must be "vod"
AVOD, PPV_REPLAY and SVOD_PPV used to be forced to "live". That made
every ad-supported on-demand item and every PPV replay (the VodItem
docstring's own "past sports event") fail at construction, because
VodItem normalises mode to "vod" before validating. Those access
types describe how content is MONETISED, not how it is delivered, so
they no longer constrain the mode.
"""
if not self.pricing:
return
# Define mode mappings
live_types: Set[AccessType] = {
AccessType.PPV_LIVE,
AccessType.PPV_REPLAY,
AccessType.SVOD_PPV,
AccessType.AVOD
}
vod_types: Set[AccessType] = {
AccessType.TVOD_RENTAL,
AccessType.TVOD_PURCHASE
}
# Check mode consistency
if self.pricing.access_type in live_types and self.mode != StreamingMode.LIVE:
raise ValueError(
f"Content '{self.name}' has {self.pricing.access_type.value} pricing "
return []
access = self.pricing.access_type
problems: List[str] = []
if access == AccessType.PPV_LIVE and self.mode != StreamingMode.LIVE:
problems.append(
f"Content '{self.name}' has {access.value} pricing "
f"but mode is '{self.mode}' (expected '{StreamingMode.LIVE}')"
)
elif self.pricing.access_type in vod_types and self.mode != StreamingMode.VOD:
raise ValueError(
f"Content '{self.name}' has {self.pricing.access_type.value} pricing "
elif (
access in (AccessType.TVOD_RENTAL, AccessType.TVOD_PURCHASE)
and self.mode != StreamingMode.VOD
):
problems.append(
f"Content '{self.name}' has {access.value} pricing "
f"but mode is '{self.mode}' (expected '{StreamingMode.VOD}')"
)
return problems
def __post_init__(self):
"""Validate pricing and mode consistency (strict: raises)."""
problems = self._pricing_mode_problems()
if problems:
raise ValueError(problems[0])
# --- Pricing Properties ---
@@ -226,6 +236,8 @@ class Content:
"ContentType": self.content_type,
"Country": self.country,
"Language": self.language,
"Description": self.description,
"Genre": self.genre,
"StreamingFormat": self.streaming_format,
"LicenseUrl": self.license_url,
"CertificateUrl": self.certificate_url,
@@ -4,8 +4,8 @@ DRM Configuration Model
Main configuration class that combines DRM system, PSSH data, and license config.
"""
from dataclasses import dataclass
from typing import Optional
from dataclasses import dataclass, replace
from typing import Dict, Optional, Sequence
from .drm_systems import DRMSystem
from .license_config import LicenseConfig
@@ -213,4 +213,66 @@ class DRMConfig:
has_license = "with license" if self.license else "no license"
return (
f"<DRMConfig({self.system.name}, priority={self.priority}, {has_license})>"
)
)
# ---------------------------------------------------------------------------
# Multi-DRM helpers
# ---------------------------------------------------------------------------
def validate_drm_set(configs: Sequence[DRMConfig]) -> None:
"""
Validate a set of DRMConfig objects that will be sent to ISA together.
Checks each config (DRMConfig.validate) and that no two configs share a
priority. The factories (create_widevine, create_playready, ...) all
default to priority=1, so a naive [widevine, playready] list is INVALID
until priorities are assigned -- see merge_drm_configs(auto_priority=True).
Raises:
LicenseConfigError: on any invalid config or duplicate priority.
"""
seen: Dict[int, DRMConfig] = {}
for cfg in configs:
cfg.validate()
if cfg.priority in seen:
raise LicenseConfigError(
f"{cfg.system.name} and {seen[cfg.priority].system.name} "
f"share priority {cfg.priority}; each DRM needs a unique "
f"priority (lower number = higher priority)."
)
seen[cfg.priority] = cfg
def merge_drm_configs(
configs: Sequence[DRMConfig], *, auto_priority: bool = False
) -> dict:
"""
Merge several DRMConfig objects into ONE inputstream.adaptive.drm dict.
Args:
configs: DRM configs in preference order (first = most preferred).
auto_priority: Renumber priorities 1..n in the given order (on
copies; the inputs are not mutated). Without it the configs
must already carry unique priorities.
Raises:
LicenseConfigError: invalid config, duplicate priority, or the same
DRM system listed twice. (A config with pre_init_data must keep
priority=1; auto_priority will trip that rule if it is not first.)
"""
items = list(configs)
if auto_priority:
items = [replace(c, priority=i) for i, c in enumerate(items, start=1)]
validate_drm_set(items)
merged: dict = {}
for cfg in items:
entry = cfg.to_dict()
clash = set(entry) & set(merged)
if clash:
raise LicenseConfigError(
f"DRM system listed twice: {sorted(clash)}"
)
merged.update(entry)
return merged
@@ -2,12 +2,26 @@
DRM-specific Exception Classes
Custom exceptions for better error handling and debugging in DRM operations.
All of them descend from DRMError, which is a ConfigurationError and
therefore a ProviderError (base/errors.py). A caller that handles
``ProviderError`` now also catches DRM configuration problems; before this
change DRMError derived from plain Exception and slipped past every
``except ProviderError`` handler.
"""
from ...errors import ConfigurationError
class DRMError(Exception):
"""Base exception for all DRM-related errors"""
pass
class DRMError(ConfigurationError):
"""Base exception for all DRM-related errors.
``message`` is optional so call sites that raise a bare
``InvalidPSSHError()`` keep working (ProviderError itself requires one).
"""
def __init__(self, message: str = "", **kw) -> None:
super().__init__(message, **kw)
class InvalidPSSHError(DRMError):
@@ -5,12 +5,13 @@ Data classes for DRM license configuration, including server URLs,
certificates, headers, and unwrapper parameters.
"""
import base64
import json
import re
from dataclasses import dataclass, field
from enum import Enum
from typing import Dict, Optional, Union
from urllib.parse import urlencode, unquote
from urllib.parse import quote, urlencode
from .utils import safe_base64_decode, safe_base64_encode, normalize_key_id
from .exceptions import LicenseConfigError
@@ -149,15 +150,17 @@ class LicenseConfig:
norm_kid = normalize_key_id(kid)
norm_key = key.lower().replace("-", "")
normalized_keyids[norm_kid] = norm_key
except Exception:
pass
except Exception as e:
# Previously swallowed: a bad pair vanished silently,
# keyids ended up empty and validate() still passed.
raise LicenseConfigError(
f"Invalid ClearKey entry (kid={kid!r}): {e}"
) from e
self.keyids = normalized_keyids
@staticmethod
def _is_base64(s: str) -> bool:
"""Check if string appears to be base64 encoded."""
import base64
import re
# Base64 pattern: only valid chars, length multiple of 4
if not re.match(r'^[A-Za-z0-9+/]*={0,2}$', s):
return False
@@ -167,6 +170,21 @@ class LicenseConfig:
except Exception:
return False
@staticmethod
def _split_flags(value: Optional[str]) -> list:
"""Split a comma-separated flag string ("base64,urlenc") into tokens."""
return [t.strip().lower() for t in (value or "").split(",") if t.strip()]
@classmethod
def _validate_flag_list(cls, name: str, value: Optional[str], enum_cls) -> None:
allowed = {m.value for m in enum_cls}
for token in cls._split_flags(value):
if token not in allowed:
raise LicenseConfigError(
f"Invalid {name} flag '{token}'. "
f"Allowed: {', '.join(sorted(allowed))}"
)
def validate(self) -> None:
"""
Validate the license configuration.
@@ -201,6 +219,17 @@ class LicenseConfig:
"Construct via the normal dataclass constructor to ensure normalization."
)
self._validate_flag_list("wrapper", self.wrapper, WrapperType)
self._validate_flag_list("unwrapper", self.unwrapper, UnwrapperType)
if (
set(self._split_flags(self.unwrapper)) & {"json", "xml"}
and self.unwrapper_params is None
):
raise LicenseConfigError(
"unwrapper_params is required when unwrapper includes "
"'json' or 'xml'."
)
if self.keyids:
for kid, key in self.keyids.items():
if len(kid) != 32:
@@ -272,7 +301,7 @@ class LicenseConfig:
# Case 1: Already a dict
if isinstance(headers, dict):
return urlencode(headers)
return self._encode_headers(headers)
# Case 2: String input
if isinstance(headers, str):
@@ -285,21 +314,23 @@ class LicenseConfig:
try:
header_dict = json.loads(headers)
if isinstance(header_dict, dict):
return urlencode(header_dict)
return self._encode_headers(header_dict)
except json.JSONDecodeError as e:
raise LicenseConfigError(
f"Invalid JSON in req_headers: {e}"
) from e
# Already URL-encoded: contains '=' and at least one '&' or ';'
if "=" in headers and ("&" in headers or ";" in headers):
# "Key: Value" plain text: a ':' appears before any '='.
if self._looks_plain(headers):
return self._parse_plain_headers(headers)
# URL-encoded "k=v[&k=v...]". A SINGLE pair is valid too (the
# class docstring's own example is one pair, which the old
# '&'/';' requirement rejected).
if "=" in headers:
self._validate_urlencoded_headers(headers)
return headers
# "Key: Value" plain-text format (no '=' present)
if ":" in headers and "=" not in headers:
return self._parse_plain_headers(headers)
raise LicenseConfigError(
f"Invalid req_headers format: '{headers}'. "
f"Must be a dict, JSON string, URL-encoded string, "
@@ -310,6 +341,25 @@ class LicenseConfig:
f"req_headers must be dict, string, or None, got {type(headers).__name__}"
)
@staticmethod
def _looks_plain(headers: str) -> bool:
"""True when ``headers`` is "Key: Value" text rather than "k=v"."""
colon = headers.find(":")
equals = headers.find("=")
return colon != -1 and (equals == -1 or colon < equals)
@staticmethod
def _encode_headers(header_dict: Dict[str, str]) -> str:
"""
URL-encode a header dict.
quote_via=quote with safe="" gives "%20" for spaces and "%2F" for
"/" (as documented), where the default quote_plus wrote "+" for
spaces. TODO(device-verify): confirm ISA decodes "+" and "%20" alike;
"%20" is the unambiguous choice either way.
"""
return urlencode(header_dict, quote_via=quote, safe="")
@staticmethod
def _validate_urlencoded_headers(headers: str) -> None:
"""
@@ -334,20 +384,22 @@ class LicenseConfig:
key, value = pair.split("=", 1)
if not key.strip():
raise LicenseConfigError(f"Empty header key in pair: '{pair}'")
try:
unquote(value) # Raises if the percent-encoding is broken
except Exception as e:
# unquote() never raises, so the old try/except validated nothing.
if re.search(r"%(?![0-9A-Fa-f]{2})", pair):
raise LicenseConfigError(
f"Header value is not properly URL-encoded: '{value}'"
) from e
f"Header value has a malformed percent-escape: '{pair}'"
)
@staticmethod
def _parse_plain_headers(headers: str) -> str:
"""
Convert ``"Key: Value"`` lines to a URL-encoded string.
Lines may be separated by newlines, carriage-returns, semicolons,
or commas. Blank lines are silently skipped.
Headers are separated by newlines / carriage-returns, or by a ';' or
',' that is followed by another "Name:" token. A comma or semicolon
inside a value ("Mozilla/5.0 (KHTML, like Gecko)", "text/html;q=0.9")
does NOT split. Blank lines are skipped; a header with an empty value
is kept (it used to be dropped silently).
Example::
@@ -364,7 +416,7 @@ class LicenseConfig:
LicenseConfigError: If any non-blank line lacks a ``':'``.
"""
header_dict: Dict[str, str] = {}
for line in re.split(r"[\n\r;,]+", headers):
for line in re.split(r"[\n\r]+|[;,]\s*(?=[A-Za-z0-9_\-]+\s*:)", headers):
line = line.strip()
if not line:
continue
@@ -375,9 +427,10 @@ class LicenseConfig:
key, value = line.split(":", 1)
key = key.strip()
value = value.strip()
if key and value:
header_dict[key] = value
return urlencode(header_dict)
if not key:
raise LicenseConfigError(f"Empty header name in line: '{line}'")
header_dict[key] = value
return LicenseConfig._encode_headers(header_dict)
def to_dict(self) -> dict:
"""
+18 -2
View File
@@ -19,6 +19,22 @@ class AccessType(Enum):
SVOD_PPV = "svod_ppv" # Subscription + extra PPV surcharge
def _as_comparable(now: datetime, bound: datetime) -> datetime:
"""
Make ``now`` comparable with ``bound``.
Python refuses to compare naive and timezone-aware datetimes. Upstream
APIs usually deliver aware valid_from/valid_until values, while the
default "now" was naive, so is_active() raised TypeError. A naive value
is interpreted as local wall-clock time.
"""
if (now.tzinfo is None) == (bound.tzinfo is None):
return now
if bound.tzinfo is not None:
return now.astimezone() # naive now -> aware (local)
return now.astimezone().replace(tzinfo=None) # aware now -> naive local
@dataclass
class PricePoint:
"""A specific price for a region/quality/time period."""
@@ -42,9 +58,9 @@ class PricePoint:
def is_active(self, at: Optional[datetime] = None) -> bool:
"""Check if this price point is currently valid."""
now = at or datetime.now()
if self.valid_from and now < self.valid_from:
if self.valid_from and _as_comparable(now, self.valid_from) < self.valid_from:
return False
if self.valid_until and now > self.valid_until:
if self.valid_until and _as_comparable(now, self.valid_until) > self.valid_until:
return False
return True
+6 -4
View File
@@ -124,8 +124,10 @@ class VodCategory:
# of reconstructing it from content_id, which loses query params.
fetch_url: Optional[str] = None
# Cached slug (computed lazily if not set)
_slug: Optional[str] = field(default=None, repr=False)
# Cached slug (computed lazily). init=False / compare=False: it is a
# cache, not state -- two equal categories must compare equal whether or
# not .slug was accessed, and it must not be an __init__ parameter.
_slug: Optional[str] = field(default=None, init=False, repr=False, compare=False)
def __post_init__(self):
"""Validate and clean up required fields."""
@@ -218,8 +220,8 @@ class VodItem(Content):
# False → full broadcast recording (videoType == "STANDALONE_EVENT" etc.)
is_highlight: bool = False
# Cached slug
_slug: Optional[str] = field(default=None, repr=False)
# Cached slug (see VodCategory._slug)
_slug: Optional[str] = field(default=None, init=False, repr=False, compare=False)
def __post_init__(self):
"""Validate and clean up required fields."""