mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-10-17 13:22:29 +02:00
work towards provider v2
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user