Files
script.service.ultimate/lib/streaming_providers/base/drm_operations.py
T
2026-10-18 14:51:46 +02:00

1135 lines
48 KiB
Python

# ============================================================================
# streaming_providers/base/drm_operations.py
"""
DRM resolution pipeline with caching, single-flight deduplication and
parse-once manifest analysis.
Two-phase plugin processing: GENERIC plugins first, then system-specific.
Sentinel semantics (unified for live and catchup content):
- [DRMConfig(system=NONE)] manifest verified as unencrypted → playable
without DRM. This result IS cached.
- [] no DRM solution could be found. NOT cached, so
transient provider/extractor failures self-heal
on retry.
Pipeline (one implementation, two thin adapters — see get_content_drm_configs
and get_catchup_content_drm_configs):
1. cache check (double-checked under a per-key lock → single-flight)
2. fetch manifest, parse ONCE → (is_encrypted, pssh_list); analysis failures
degrade gracefully rather than crashing the request
3. verified-clear short-circuit
4. generic plugin phase (with stub-PSSH upgrade via init segment, then
a media segment for providers that put the pssh in the moof)
5. provider DRM configs (generics become the base list if provider has none)
6. system-specific plugin loop with incremental ClearKey coverage checks
7. final composition: generic merge, ClearKey validation, reinstatement
8. cache (if non-empty) + return a copy
Cached DRM results are structurally invalidated when the plugin set changes
(`_plugin_epoch` is embedded in DRM-config cache keys). PSSH data is
manifest-derived and plugin-independent, so the PSSH cache deliberately does
NOT use the epoch.
Caching is centralised in `_resolve_content_drm`: `_compute_content_drm` is
pure with respect to the DRM-config cache and never writes to it directly.
Stream format (DASH / HLS):
The pipeline also reports the manifest's format. It is sniffed from the
manifest content that is fetched anyway (URL extension is only a fallback),
and the result travels with the DRM configs as a `ResolvedStream`, cached
under the same key. `get_content_drm_configs` / `get_catchup_content_drm_configs`
keep returning plain lists; `get_content_stream_info` /
`get_catchup_content_stream_info` return the full `ResolvedStream`.
HLS "verified clear" is deliberately strict, because a clear verdict is cached
and short-circuits the whole pipeline: the playlist must be a media playlist
with at least one segment and no key tag other than METHOD=NONE. For a master
playlist the first variant's media playlist is fetched and checked (only that
variant: audio renditions are not probed). METHOD=AES-128 is NOT DRM but also
not clear, so it is reported as unknown (None) and falls through. Encrypted
HLS (SAMPLE-AES, Widevine/FairPlay KEYFORMATs) is detected as encrypted but
the PSSH/segment-extraction fallbacks below are DASH-only.
"""
import re
import time
from collections import OrderedDict
from contextlib import contextmanager
from dataclasses import dataclass
from threading import Lock
from typing import Any, Callable, Dict, Iterator, List, NamedTuple, Optional, Tuple
from .drm import DRMPluginManager
from .models import DRMConfig, DRMSystem
from .network import HTTPManager
from .stream_format import (
StreamFormat,
detect_format,
first_hls_variant_uri,
hls_has_media_segments,
hls_key_attributes,
is_hls_master,
join_hls_uri,
)
from .utils import ManifestParser
from .utils.drm_extractor import DRMExtractor
from .utils.logger import logger
_CONTENT_PROTECTION_RE = re.compile(
r'<ContentProtection[^>]*schemeIdUri="urn:uuid:([^"]+)"[^>]*>', re.IGNORECASE
)
_DEFAULT_KID_RE = re.compile(r'(?:cenc:)?default_KID\s*=', re.IGNORECASE)
_PSSH_TAG_RE = re.compile(r'<(?:cenc:)?pssh[^>]*>', re.IGNORECASE)
# kwargs that produce distinct DRM results and therefore belong in cache keys
_VARIANT_KEYS = ("drm_variant", "preferred_quality", "preferred_format")
# The media-segment PSSH fallback only needs the moof at the start of the
# segment; request just this many bytes (MP4PSSHExtractor reads 100 KB at most).
_MEDIA_SEGMENT_PROBE_BYTES = 100 * 1024
class TTLCache:
"""Thread-safe TTL cache with LRU eviction and a hard size bound.
Replaces the previous PSSHCache/DRMConfigCache pair, which were identical
except for log wording and unbounded: expired entries were only evicted
when their specific key happened to be read again, so long-running
processes accumulated stale entries indefinitely.
"""
def __init__(self, ttl_seconds: int = 3600, max_size: int = 2048):
self._data: "OrderedDict[str, Tuple[Any, float]]" = OrderedDict()
self._ttl = ttl_seconds
self._max_size = max_size
self._lock = Lock()
def get(self, key: str) -> Optional[Any]:
with self._lock:
entry = self._data.get(key)
if entry is None:
return None
value, timestamp = entry
if time.time() - timestamp >= self._ttl:
del self._data[key]
logger.debug(f"TTLCache: EXPIRED '{key}'")
return None
self._data.move_to_end(key) # LRU touch
logger.debug(f"TTLCache: HIT '{key}'")
return value
def set(self, key: str, value: Any) -> None:
with self._lock:
self._data[key] = (value, time.time())
self._data.move_to_end(key)
while len(self._data) > self._max_size:
evicted, _ = self._data.popitem(last=False)
logger.debug(f"TTLCache: EVICTED (size bound) '{evicted}'")
logger.debug(f"TTLCache: SET '{key}'")
def clear(self) -> None:
with self._lock:
self._data.clear()
logger.debug("TTLCache: CLEARED")
def __len__(self) -> int:
with self._lock:
return len(self._data)
class _KeyedLocks:
"""Per-key locks giving single-flight semantics for cache misses.
Cleanup is best-effort: a waiter that already grabbed a Lock reference
keeps it, so at most one extra worker may be admitted right after the
holder finishes. The computation is idempotent, so this is harmless.
"""
def __init__(self):
self._guard = Lock()
self._locks: Dict[str, Lock] = {}
@contextmanager
def for_key(self, key: str) -> Iterator[None]:
with self._guard:
lock = self._locks.setdefault(key, Lock())
with lock:
yield
with self._guard:
if self._locks.get(key) is lock and not lock.locked():
self._locks.pop(key, None)
class _ManifestContext(NamedTuple):
"""Everything the PSSH/DRM phases need to know about one manifest."""
provider_name: str
channel_id: str
manifest_url: Optional[str]
manifest_headers: Optional[Dict[str, str]]
manifest_content: Optional[str]
parsed_pssh: Optional[List] # single manifest parse result; None = not analysed
analysis_failed: bool = False # parse raised; don't retry deterministically-failing work
# Post-redirect URL of the manifest when we fetched it ourselves.
effective_manifest_url: Optional[str] = None
@property
def url_for_segments(self) -> Optional[str]:
"""URL that segment construction must be based on: the FINAL
(post-redirect) manifest URL. Relative references resolve against it
(RFC 3986) and its query string carries the auth token that segment
requests must repeat."""
return self.effective_manifest_url or self.manifest_url
@dataclass(frozen=True)
class ResolvedStream:
"""Outcome of one resolution: the manifest's format plus its DRM configs.
Immutable (configs are a tuple) so the cached instance can be handed out
directly without defensive copies; the DRMConfig objects themselves are
still shared (shallow), as before.
stream_format is None when it could not be determined (manifest
unfetchable and no usable URL hint). An empty drm_configs means "no DRM
solution found" and such results are never cached (see
`_resolve_content_drm`).
"""
stream_format: Optional[StreamFormat]
drm_configs: Tuple[DRMConfig, ...] = ()
@dataclass
class _FormatProbe:
"""Out-parameter that lets the (many-exit) DRM pipeline report the format
it observed without changing every return statement."""
stream_format: Optional[StreamFormat] = None
class DRMOperations:
"""Resolves DRM configurations for live/VOD/event and catchup content.
Both public entry points delegate to one shared pipeline; only manifest
resolution and the provider-DRM fetch differ between them.
"""
def __init__(self, registry, cache_ttl: int = 3600, cache_max_size: int = 2048):
self.registry = registry
self.drm_plugin_manager = DRMPluginManager()
self.pssh_cache = TTLCache(ttl_seconds=cache_ttl, max_size=cache_max_size)
self.drm_config_cache = TTLCache(ttl_seconds=cache_ttl, max_size=cache_max_size)
self._keyed_locks = _KeyedLocks()
# Bumped whenever the plugin set changes; embedded in DRM-config
# cache keys so results computed under an old plugin set can never
# be read again (stale entries age out via LRU eviction).
self._plugin_epoch = 0
logger.debug("DRMOperations: Initialized with unified two-phase pipeline")
# ==========================================================================
# PUBLIC API — thin adapters
# ==========================================================================
def get_content_stream_info(
self, provider_name: str, channel_id: str, **kwargs
) -> ResolvedStream:
"""Resolve stream format AND DRM configs for live/VOD/event/recording
content in one pass (they share the manifest fetch and the cache).
kwargs are forwarded to the provider and DRM plugins; recognised
cache-key variants are drm_variant, preferred_quality, preferred_format.
"""
provider = self._get_provider(provider_name)
kwargs = self._inject_provider_proxy(provider, kwargs)
base_key = self._build_cache_key(provider_name, channel_id, **kwargs)
def resolve_manifest() -> Tuple[Optional[str], Optional[Dict[str, str]]]:
return provider.get_manifest_with_headers(channel_id, **kwargs)
def get_provider_configs() -> List[DRMConfig]:
return provider.get_drm(content_id=channel_id, **kwargs)
return self._resolve_content_drm(
provider, provider_name, channel_id, base_key,
resolve_manifest, get_provider_configs, kwargs,
)
def get_content_drm_configs(
self, provider_name: str, channel_id: str, **kwargs
) -> List[DRMConfig]:
"""Resolve DRM configs for live/VOD/event/recording content.
Thin wrapper over get_content_stream_info that keeps the historic
list return type (a fresh list on every call)."""
return list(
self.get_content_stream_info(provider_name, channel_id, **kwargs).drm_configs
)
def get_catchup_content_stream_info(
self,
provider_name: str,
channel_id: str,
catchup_manifest_url: str,
catchup_manifest_headers: Dict[str, str],
start_time: int,
end_time: int,
epg_id: Optional[str] = None,
**kwargs,
) -> ResolvedStream:
"""Resolve stream format and DRM configs for a catchup/timeshift window."""
provider = self._get_provider(provider_name)
kwargs = self._inject_provider_proxy(provider, kwargs)
base_key = self._build_catchup_cache_key(
provider_name, channel_id, start_time, end_time, epg_id, **kwargs
)
# Thread the time context through the whole pipeline so
# provider.get_segment_headers() receives start_time/end_time (needed
# by providers whose catchup init segments require catchup-scoped
# headers).
pipeline_kwargs = {**kwargs, "start_time": start_time, "end_time": end_time}
if epg_id:
pipeline_kwargs["epg_id"] = epg_id
def resolve_manifest() -> Tuple[Optional[str], Optional[Dict[str, str]]]:
return catchup_manifest_url, catchup_manifest_headers
def get_provider_configs() -> List[DRMConfig]:
try:
return provider.get_catchup_drm(
content_id=channel_id, start_time=start_time,
end_time=end_time, epg_id=epg_id, **kwargs,
)
except NotImplementedError:
return []
return self._resolve_content_drm(
provider, provider_name, channel_id, base_key,
resolve_manifest, get_provider_configs, pipeline_kwargs,
)
def get_catchup_content_drm_configs(
self,
provider_name: str,
channel_id: str,
catchup_manifest_url: str,
catchup_manifest_headers: Dict[str, str],
start_time: int,
end_time: int,
epg_id: Optional[str] = None,
**kwargs,
) -> List[DRMConfig]:
"""Resolve DRM configs for a catchup/timeshift window (list wrapper
over get_catchup_content_stream_info)."""
return list(
self.get_catchup_content_stream_info(
provider_name, channel_id, catchup_manifest_url,
catchup_manifest_headers, start_time, end_time, epg_id, **kwargs,
).drm_configs
)
# ==========================================================================
# THE PIPELINE
# ==========================================================================
def _resolve_content_drm(
self,
provider,
provider_name: str,
channel_id: str,
base_cache_key: str,
resolve_manifest: Callable[[], Tuple[Optional[str], Optional[Dict[str, str]]]],
get_provider_configs: Callable[[], List[DRMConfig]],
pipeline_kwargs: Dict[str, Any],
) -> ResolvedStream:
cache_key = self._config_cache_key(base_cache_key)
cached = self.drm_config_cache.get(cache_key)
if cached is not None:
return cached
# Single-flight: concurrent misses for the same key run the full
# pipeline exactly once; the others wait and hit the cache.
with self._keyed_locks.for_key(cache_key):
cached = self.drm_config_cache.get(cache_key) # double-check under lock
if cached is not None:
return cached
result = self._compute_content_drm(
provider, provider_name, channel_id, base_cache_key,
resolve_manifest, get_provider_configs, pipeline_kwargs,
)
# Caching policy — the ONLY place results are cached:
# a result with DRM configs is an answer (real configs, or
# verified-clear [NONE]) and gets cached; empty configs mean "no
# solution found" and deliberately do not, so transient
# provider/extractor failures self-heal on retry. (The format
# rides along in the same entry; a detected format alone is
# never worth caching.)
if result.drm_configs:
self.drm_config_cache.set(cache_key, result)
# ResolvedStream is immutable: no copy needed. (Shallow — see
# DRMConfig note.)
return result
def _compute_content_drm(
self,
provider,
provider_name: str,
channel_id: str,
base_cache_key: str,
resolve_manifest: Callable[[], Tuple[Optional[str], Optional[Dict[str, str]]]],
get_provider_configs: Callable[[], List[DRMConfig]],
pipeline_kwargs: Dict[str, Any],
) -> ResolvedStream:
"""Run the pipeline and pair its DRM result with the stream format
observed while the manifest was analysed.
Pure w.r.t. the DRM-config cache: caching is owned by
_resolve_content_drm.
"""
probe = _FormatProbe()
configs = self._compute_drm_configs(
provider, provider_name, channel_id, base_cache_key,
resolve_manifest, get_provider_configs, pipeline_kwargs, probe,
)
return ResolvedStream(probe.stream_format, tuple(configs))
def _compute_drm_configs(
self,
provider,
provider_name: str,
channel_id: str,
base_cache_key: str,
resolve_manifest: Callable[[], Tuple[Optional[str], Optional[Dict[str, str]]]],
get_provider_configs: Callable[[], List[DRMConfig]],
pipeline_kwargs: Dict[str, Any],
probe: _FormatProbe,
) -> List[DRMConfig]:
"""Run the full resolution pipeline and return the DRM configs.
The PSSH cache is still populated here because PSSH resolution is an
internal, multi-phase concern. `probe.stream_format` is set as soon as
it is known (URL hint first, then upgraded from the manifest content).
"""
# -- 1. Manifest: fetch once, parse once --------------------------------
manifest_url, manifest_headers = resolve_manifest()
manifest_content: Optional[str] = None
effective_manifest_url: Optional[str] = None
parsed_pssh: Optional[List] = None
analysis_failed = False
# Cheapest signal first (None for tokenised URLs without extension).
probe.stream_format = detect_format(url=manifest_url)
if manifest_url and manifest_url.startswith(("http://", "https://")):
http = getattr(provider, "http_manager", None) or HTTPManager()
manifest_content, effective_manifest_url = self._fetch_manifest_text(
http, manifest_url, manifest_headers
)
if effective_manifest_url and effective_manifest_url != manifest_url:
logger.debug(
f"DRMOperations: manifest redirected; segment URLs will be built "
f"from final URL: {ManifestParser.redact_url(effective_manifest_url)}"
)
if manifest_content is not None:
# Content beats the URL hint.
probe.stream_format = (
detect_format(manifest_content) or probe.stream_format
)
# Degrade, don't crash: extractor blowups on malformed content
# must leave the pipeline free to resolve DRM from provider
# configs and plugins (matches the previous behaviour, where
# this ran inside the fetch's try/except and was swallowed).
try:
is_encrypted, parsed_pssh = self._analyse_manifest(manifest_content)
if (
is_encrypted is None
and probe.stream_format is StreamFormat.HLS
and is_hls_master(manifest_content)
):
# A master playlist alone can't prove anything: the
# key tags live in the media playlists.
is_encrypted = self._resolve_hls_master_encryption(
provider, http, manifest_content,
effective_manifest_url or manifest_url, manifest_headers,
)
except Exception as e:
logger.warning(
f"DRMOperations: Manifest analysis failed for '{manifest_url}': {e}"
)
analysis_failed = True
else:
# `is False` (not `not is_encrypted`): an UNKNOWN state
# (None) must never short-circuit as "verified clear".
if is_encrypted is False:
return [DRMConfig(system=DRMSystem.NONE, priority=0)]
# Non-http, unfetchable or unanalysable manifest: fall through —
# provider configs and plugins may still resolve DRM. If nothing
# produces a config, the pipeline returns [] (never claims 'clear'
# unverified).
ctx = _ManifestContext(
provider_name=provider_name,
channel_id=channel_id,
manifest_url=manifest_url,
manifest_headers=manifest_headers,
manifest_content=manifest_content,
parsed_pssh=parsed_pssh,
analysis_failed=analysis_failed,
effective_manifest_url=effective_manifest_url,
)
# -- 2. Generic plugin phase ---------------------------------------------
generic_configs, pssh_list = self._run_generic_phase(ctx, base_cache_key, pipeline_kwargs)
if generic_configs and any(c.system != DRMSystem.NONE for c in generic_configs):
if pssh_list:
validated, has_full_coverage = self._check_clearkey_coverage(generic_configs, pssh_list)
else:
validated, has_full_coverage = generic_configs, False
if validated and has_full_coverage:
# Generic plugins alone already cover every key ID.
return self._select_configs_for_return(validated, True)
# Partial or no coverage: keep generic_configs for the final merge.
else:
generic_configs = None
# -- 3. Provider DRM configs ----------------------------------------------
provider_configs = get_provider_configs()
if not provider_configs:
if generic_configs:
# Provider has nothing, but generic plugins produced configs:
# they become the base list for the system-specific phase.
provider_configs = list(generic_configs)
generic_configs = None
else:
return [] # no solution found — uncached by policy, self-heals
# -- 4. PSSH resolution for the system-specific phase --------------------
if pssh_list is None and self.drm_plugin_manager.has_system_specific_plugins():
if self._needs_pssh_extraction(provider_configs):
pssh_list = self._resolve_pssh_data(ctx, base_cache_key, pipeline_kwargs)
# -- 5. System-specific plugin loop ---------------------------------------
provider_snapshot = list(provider_configs)
sorted_systems = sorted(
{c.system for c in provider_configs},
key=lambda s: min(c.priority for c in provider_configs if c.system == s),
)
remaining = list(provider_configs)
for drm_system in sorted_systems:
system_configs = [c for c in remaining if c.system == drm_system]
if not system_configs:
continue
batch_result = self.drm_plugin_manager.process_system_specific_plugins(
system_configs, pssh_list or [], **pipeline_kwargs
)
remaining = [c for c in remaining if c.system != drm_system] + batch_result
if pssh_list and any(c.system == DRMSystem.CLEARKEY for c in remaining):
remaining, has_full_coverage = self._check_clearkey_coverage(remaining, pssh_list)
if has_full_coverage:
break
# -- 6. Final composition ---------------------------------------------------
processed, has_full_coverage = self._compose_final_result(
remaining, generic_configs, provider_snapshot, pssh_list
)
if not processed:
return []
return self._select_configs_for_return(processed, has_full_coverage)
# ==========================================================================
# PIPELINE PHASES
# ==========================================================================
def _run_generic_phase(
self, ctx: _ManifestContext, base_cache_key: str,
pipeline_kwargs: Dict[str, Any],
) -> Tuple[Optional[List[DRMConfig]], Optional[List]]:
"""Run GENERIC plugins over PSSH data extracted from the manifest.
Returns (generic_configs, pssh_list). generic_configs is None when no
generic plugin is registered, no usable PSSH/key-ID data exists, or
the plugins produce nothing. pssh_list may be non-None even then —
the system-specific phase reuses it.
"""
if DRMSystem.GENERIC not in self.drm_plugin_manager.plugins:
return None, None
pssh_list = self._resolve_pssh_data(ctx, base_cache_key, pipeline_kwargs)
if not pssh_list:
return None, None
# Stub PSSH (no box / no key IDs): try to upgrade via the init segment.
if self._has_stub_pssh(pssh_list):
if not ctx.url_for_segments:
return None, pssh_list
upgraded = self._extract_pssh_from_manifest(
ctx.url_for_segments, ctx.manifest_headers, ctx.provider_name,
channel_id=ctx.channel_id,
manifest_content=ctx.manifest_content,
pssh_list=pssh_list,
**pipeline_kwargs,
)
if upgraded and not self._has_stub_pssh(upgraded):
pssh_list = upgraded
self.pssh_cache.set(base_cache_key, pssh_list)
else:
return None, pssh_list
total_kids = sum(len(p.key_ids) for p in pssh_list)
if total_kids == 0:
return None, pssh_list
dummy_configs = [DRMConfig(system=DRMSystem.NONE, priority=0)]
generic_configs = self.drm_plugin_manager.process_generic_plugins(
dummy_configs, pssh_list, **pipeline_kwargs
)
return (generic_configs or None), pssh_list
def _resolve_pssh_data(
self, ctx: _ManifestContext, base_cache_key: str,
pipeline_kwargs: Dict[str, Any],
) -> Optional[List]:
"""Resolve PSSH data, in priority order:
1. PSSH TTL cache (may hold a previous init-segment-upgraded result —
still valid even if THIS call's parse failed)
2. The list parsed during manifest analysis (populates the cache)
3. Full extraction: manifest (re-)fetch + init-segment fallback +
media-segment fallback
Note: the cache key includes catchup start/end times, so each timeshift
window gets its own entry even though PSSH is likely identical per
channel. The cache size bound caps this; keying by provider:channel is
a possible future optimisation if KIDs prove stable across windows.
"""
pssh_list = self.pssh_cache.get(base_cache_key)
if pssh_list is not None:
return pssh_list
if ctx.analysis_failed:
# Parsing this manifest already raised once and is deterministic —
# retrying would just re-raise (caught) and re-log in every phase.
return None
if ctx.parsed_pssh:
self.pssh_cache.set(base_cache_key, ctx.parsed_pssh)
return ctx.parsed_pssh
if ctx.url_for_segments:
pssh_list = self._extract_pssh_from_manifest(
ctx.url_for_segments, ctx.manifest_headers, ctx.provider_name,
channel_id=ctx.channel_id,
manifest_content=ctx.manifest_content,
# None → full parse inside; [] → skip the re-parse and go
# straight to init-segment extraction.
pssh_list=ctx.parsed_pssh,
**pipeline_kwargs,
)
if pssh_list:
self.pssh_cache.set(base_cache_key, pssh_list)
return pssh_list or None
return None
@staticmethod
def _compose_final_result(
processed: List[DRMConfig],
generic_configs: Optional[List[DRMConfig]],
provider_snapshot: List[DRMConfig],
pssh_list: Optional[List],
) -> Tuple[List[DRMConfig], bool]:
"""Merge generic extras, validate ClearKey coverage, reinstate drops.
Safety net: if ClearKey validation wipes the list (ClearKey present but
invalid), the provider's original configs for every missing system are
reinstated — the pipeline never returns fewer *systems* than the
provider offered.
"""
if generic_configs:
phase2_systems = {c.system for c in processed}
extra = [c for c in generic_configs if c.system not in phase2_systems]
if extra:
processed = processed + extra
if pssh_list and any(c.system == DRMSystem.CLEARKEY for c in processed):
processed, has_full_coverage = DRMOperations._check_clearkey_coverage(
processed, pssh_list
)
else:
has_full_coverage = False
if not has_full_coverage:
processed_systems = {c.system for c in processed}
reinstated = [c for c in provider_snapshot if c.system not in processed_systems]
if reinstated:
processed = processed + reinstated
return processed, has_full_coverage
# ==========================================================================
# MANIFEST ANALYSIS (parse once)
# ==========================================================================
@staticmethod
def _analyse_manifest(manifest_content: str) -> Tuple[Optional[bool], List]:
"""Parse manifest content once → (is_encrypted, pssh_list).
is_encrypted is False ONLY when the manifest is verifiably clear, and
None when it can't be decided (HLS master playlist, HLS AES-128,
empty HLS media playlist). DASH never yields None.
"""
if detect_format(manifest_content) is StreamFormat.HLS:
return DRMOperations._analyse_hls(manifest_content)
pssh_list = DRMExtractor._extract_from_manifest_content(manifest_content) or []
if any(p.drm_system and p.drm_system != DRMSystem.NONE for p in pssh_list):
return True, pssh_list
for match in _CONTENT_PROTECTION_RE.finditer(manifest_content):
drm_system = DRMSystem.from_uuid(match.group(1).lower())
if drm_system:
return True, pssh_list
if _DEFAULT_KID_RE.search(manifest_content):
return True, pssh_list
if _PSSH_TAG_RE.search(manifest_content):
return True, pssh_list
return False, pssh_list
@staticmethod
def _analyse_hls(content: str) -> Tuple[Optional[bool], List]:
"""HLS counterpart of the DASH analysis → (is_encrypted, pssh_list).
True any #EXT-X-KEY / #EXT-X-SESSION-KEY that is neither METHOD=NONE
nor identity AES-128 (SAMPLE-AES*, or a non-identity KEYFORMAT
such as Widevine/FairPlay/PlayReady). A tag without METHOD is
malformed and counted as encrypted (conservative).
None undecidable: METHOD=AES-128 (full-segment encryption — not DRM,
but not clear either; policy still open), a master playlist
(key tags live in the media playlists; caller resolves a
variant), or a media playlist without segments (nothing to
verify yet, e.g. a live playlist that hasn't started).
False media playlist with at least one segment and no key tag other
than METHOD=NONE.
pssh_list is always [] — PSSH extraction from HLS is not implemented.
"""
saw_aes128 = False
for attrs in hls_key_attributes(content):
method = attrs.get("METHOD", "").upper()
if method == "NONE":
continue
keyformat = attrs.get("KEYFORMAT", "identity").lower()
if method == "AES-128" and keyformat == "identity":
saw_aes128 = True
continue
return True, []
if saw_aes128:
logger.debug("DRMOperations: HLS playlist uses AES-128 — treated as unknown")
return None, []
if is_hls_master(content):
return None, []
if not hls_has_media_segments(content):
return None, []
return False, []
def _resolve_hls_master_encryption(
self,
provider,
http,
master_content: str,
master_url: str,
headers: Optional[Dict[str, str]],
) -> Optional[bool]:
"""Decide encryption for a master playlist by analysing the media
playlist of its FIRST variant. Returns None whenever that can't be
done reliably (no variant, fetch failure, nested master, unrecognised
content) so the caller never claims 'clear' without evidence.
Limitation: only one variant is probed; separately encrypted audio
renditions (#EXT-X-MEDIA) are not checked.
"""
variant_uri = first_hls_variant_uri(master_content)
if not variant_uri:
return None
inherit_query = bool(getattr(provider, "inherit_manifest_query_on_segments", True))
variant_url = self._join_hls_uri(master_url, variant_uri, inherit_query)
content, _ = self._fetch_manifest_text(http, variant_url, headers)
if content is None:
return None
if detect_format(content) is not StreamFormat.HLS or is_hls_master(content):
return None
is_encrypted, _ = self._analyse_hls(content)
return is_encrypted
@staticmethod
def _join_hls_uri(base_url: str, uri: str, inherit_query: bool) -> str:
"""Thin alias for stream_format.join_hls_uri (kept for callers/tests)."""
return join_hls_uri(base_url, uri, inherit_query)
@staticmethod
def _is_manifest_encrypted(manifest_content: str) -> Optional[bool]:
"""Back-compat wrapper around _analyse_manifest (None = undecidable)."""
return DRMOperations._analyse_manifest(manifest_content)[0]
@staticmethod
def _fetch_manifest_text(
http, manifest_url: str, manifest_headers: Optional[Dict[str, str]]
) -> Tuple[Optional[str], Optional[str]]:
"""Fetch manifest text; returns (content, final_url).
final_url is the post-redirect URL (response.url), falling back to the
requested URL when the response doesn't expose one. Relative segment
URLs must resolve against it rather than the requested URL, and its
query string (auth token) is inherited by constructed segment URLs.
Returns (None, None) with a debug log on failure.
"""
try:
response = http.get(manifest_url, headers=manifest_headers, timeout=10, operation="api")
response.raise_for_status()
return response.text, DRMOperations._final_url(response, manifest_url)
except Exception as e:
logger.debug(
f"DRMOperations: Manifest fetch failed for "
f"'{ManifestParser.redact_url(manifest_url)}': {e}"
)
return None, None
@staticmethod
def _final_url(response, requested_url: str) -> str:
"""Post-redirect URL of a response; the requested URL if unavailable.
Accepts str or URL-like objects (httpx.URL); anything that doesn't
stringify to an http(s) URL is ignored.
"""
final = getattr(response, "url", None)
if final is not None and not isinstance(final, str):
try:
final = str(final)
except Exception:
final = None
if isinstance(final, str) and final.startswith(("http://", "https://")):
return final
return requested_url
# ==========================================================================
# PSSH EXTRACTION
# ==========================================================================
def _extract_pssh_from_manifest(
self,
manifest_url: str,
manifest_headers: Optional[Dict[str, str]] = None,
provider_name: Optional[str] = None,
channel_id: Optional[str] = None,
manifest_content: Optional[str] = None,
pssh_list: Optional[List] = None,
**kwargs,
) -> List:
"""Extract PSSH data from a manifest, falling back to segments.
Levels, each tried only while the previous one left a PSSH unresolved:
1. manifest, 2. init segment (moov), 3. a media segment (moof).
manifest_url should be the FINAL (post-redirect) manifest URL when
known: constructed segment URLs resolve against it and repeat its
query string (auth tokens). A manifest fetched in here is tracked
through redirects the same way.
manifest_headers now defaults to None so the legacy facade call
(which passes only the URL) works. pssh_list lets callers that already
parsed the manifest skip the redundant re-parse and go straight to
segment extraction (passing [] implies the manifest was already
parsed and yielded nothing).
**kwargs (which contain start_time/end_time for catchup) are passed to
provider.get_segment_headers() so providers can return catchup-scoped
headers for init segment fetches.
"""
if not manifest_url or not manifest_url.startswith(("http://", "https://")):
return []
try:
http = None
segment_headers = manifest_headers # default to manifest headers
inherit_query = True
if provider_name:
provider = self.registry.get_provider(provider_name)
if provider:
http = getattr(provider, "http_manager", None)
# Escape hatch for CDNs that reject the manifest's query
# being repeated on segment requests.
inherit_query = bool(
getattr(provider, "inherit_manifest_query_on_segments", True)
)
if channel_id:
try:
# Full context (start_time, end_time, ...) for
# catchup-scoped segment headers.
segment_headers = provider.get_segment_headers(channel_id, **kwargs)
except Exception:
pass # fall back to manifest headers on error
if not http:
http = HTTPManager()
# Segment URLs must be built from the post-redirect URL.
effective_url = manifest_url
if pssh_list is None:
if not manifest_content:
manifest_content, fetched_url = self._fetch_manifest_text(
http, manifest_url, manifest_headers
)
if fetched_url:
effective_url = fetched_url
if manifest_content:
pssh_list = DRMExtractor._extract_from_manifest_content(manifest_content) or []
else:
return [] # manifest unobtainable
needs_segment_extraction = not pssh_list or any(
not p.pssh_box or not p.key_ids for p in pssh_list
)
if needs_segment_extraction:
if not manifest_content:
# Content is needed to locate the segment URLs.
manifest_content, fetched_url = self._fetch_manifest_text(
http, manifest_url, manifest_headers
)
if fetched_url:
effective_url = fetched_url
if not manifest_content:
return pssh_list
# Level 2: init segment (pssh in moov)
init_segment_url = ManifestParser.extract_single_init_segment_url(
manifest_content, effective_url, inherit_query=inherit_query
)
if init_segment_url:
segment_pssh = DRMExtractor._extract_from_single_segment(
init_segment_url,
[p.system_id for p in pssh_list] if pssh_list else [],
headers=segment_headers,
http_manager=http,
)
if segment_pssh:
pssh_list = DRMExtractor._merge_pssh_data(pssh_list, segment_pssh)
# Level 3: media segment (pssh in moof). Only while a
# system that should carry a PSSH is still unresolved, so
# providers whose init segment works never pay for this and
# ClearKey stubs (legitimately PSSH-less) don't trigger it.
if self._has_unresolved_pssh(pssh_list):
media_segment_url = ManifestParser.extract_media_segment_url(
manifest_content, effective_url, inherit_query=inherit_query
)
if media_segment_url:
# Only the moof at the start of the segment is needed.
probe_headers = {
**(segment_headers or {}),
"Range": f"bytes=0-{_MEDIA_SEGMENT_PROBE_BYTES - 1}",
}
media_pssh = DRMExtractor._extract_from_single_segment(
media_segment_url,
[p.system_id for p in pssh_list] if pssh_list else [],
headers=probe_headers,
http_manager=http,
)
if media_pssh:
pssh_list = DRMExtractor._merge_pssh_data(pssh_list, media_pssh)
if self._has_unresolved_pssh(pssh_list):
logger.warning(
f"DRMOperations: PSSH still unresolved after manifest, init and "
f"media segment extraction for {channel_id or manifest_url}; "
f"license acquisition may fail"
)
return pssh_list
except Exception as e:
logger.warning(f"DRMOperations: Failed to extract PSSH: {e}")
return []
# ==========================================================================
# VALIDATION / SELECTION HELPERS (unchanged semantics)
# ==========================================================================
@staticmethod
def _check_clearkey_coverage(
drm_configs: List[DRMConfig], pssh_data_list: List
) -> Tuple[List[DRMConfig], bool]:
"""Validate ClearKey configs against the key IDs required by the PSSH data.
Returns (validated_configs, has_full_coverage). If ClearKey configs are
present but none of their key IDs match, ALL configs are dropped
([], False) — the reinstatement step in _compose_final_result then
restores the provider's original systems. Coverage is per-config: two
ClearKey configs that together cover all key IDs do NOT count as full
coverage.
"""
if not pssh_data_list:
return drm_configs, False
required_key_ids = set()
for pssh_data in pssh_data_list:
if pssh_data.key_ids:
required_key_ids.update(kid.lower().replace("-", "") for kid in pssh_data.key_ids)
if not required_key_ids:
return drm_configs, False
validated_configs = []
has_valid_clearkey = False
has_full_coverage = False
for config in drm_configs:
if config.system != DRMSystem.CLEARKEY:
validated_configs.append(config)
continue
if not config.license or not config.license.keyids:
continue
provided_kids = {kid.lower().replace("-", "") for kid in config.license.keyids.keys()}
valid_keys = required_key_ids & provided_kids
if valid_keys:
has_valid_clearkey = True
validated_configs.append(config)
if valid_keys == required_key_ids:
has_full_coverage = True
if any(c.system == DRMSystem.CLEARKEY for c in drm_configs) and not has_valid_clearkey:
return [], False
return validated_configs, has_full_coverage
@staticmethod
def _select_configs_for_return(
configs: List[DRMConfig], has_full_clearkey_coverage: bool
) -> List[DRMConfig]:
"""When ClearKey fully covers the content, return only ClearKey configs.
(Renamed from _select_configs_for_cache_and_return — with caching
centralised in _resolve_content_drm, this no longer caches anything
and the old name would lie.)"""
if has_full_clearkey_coverage:
return [c for c in configs if c.system == DRMSystem.CLEARKEY]
return configs
@staticmethod
def _has_stub_pssh(pssh_data_list: List) -> bool:
if not pssh_data_list:
return True
for pssh in pssh_data_list:
if not pssh.pssh_box or not pssh.key_ids:
return True
return False
@staticmethod
def _has_unresolved_pssh(pssh_list: Optional[List]) -> bool:
"""True if a system that should carry a PSSH is still a stub.
Like _has_stub_pssh, but ClearKey stubs don't count: ClearKey content
legitimately has no PSSH box, so chasing one would only cost a
pointless segment download on every uncached resolution. An empty
list counts as unresolved (nothing found yet).
"""
if not pssh_list:
return True
return any(
(not p.pssh_box or not p.key_ids) and p.drm_system != DRMSystem.CLEARKEY
for p in pssh_list
)
def _needs_pssh_extraction(self, drm_configs) -> bool:
config_systems = {config.system for config in drm_configs}
plugin_systems = {
system for system in self.drm_plugin_manager.plugins.keys()
if system != DRMSystem.GENERIC
}
return bool(config_systems & plugin_systems)
# ==========================================================================
# CACHE KEYS & SMALL HELPERS
# ==========================================================================
def _config_cache_key(self, base_cache_key: str) -> str:
"""DRM-config cache keys embed the plugin epoch so that clearing or
reloading plugins structurally invalidates all derived results."""
return f"e{self._plugin_epoch}:{base_cache_key}"
@staticmethod
def _build_cache_key(provider_name: str, channel_id: str, **kwargs) -> str:
parts = [provider_name, channel_id]
for key in _VARIANT_KEYS:
value = kwargs.get(key)
if value is not None:
parts.append(f"{key}={str(value).lower()}")
return ":".join(parts)
@staticmethod
def _build_catchup_cache_key(
provider_name: str, channel_id: str, start_time: int, end_time: int,
epg_id: Optional[str], **kwargs
) -> str:
parts = [provider_name, channel_id, "catchup", str(start_time), str(end_time)]
if epg_id:
parts.append(f"epg={epg_id}")
for key in _VARIANT_KEYS:
value = kwargs.get(key)
if value is not None:
parts.append(f"{key}={str(value).lower()}")
return ":".join(parts)
def _get_provider(self, provider_name: str):
provider = self.registry.get_provider(provider_name)
if not provider:
raise ValueError(f"Provider '{provider_name}' not found or disabled")
return provider
@staticmethod
def _inject_provider_proxy(provider, kwargs: Dict[str, Any]) -> Dict[str, Any]:
"""Return kwargs with the provider's proxy injected unless the caller
supplied one explicitly. Returns a NEW dict — never mutates the
caller's mapping."""
if "proxy_config" in kwargs:
return kwargs
http_mgr = getattr(provider, "http_manager", None)
if http_mgr is None:
return kwargs
provider_proxy = getattr(getattr(http_mgr, "config", None), "proxy_config", None)
if provider_proxy is None:
return kwargs
return {**kwargs, "proxy_config": provider_proxy}
# ==========================================================================
# PLUGIN & CACHE MANAGEMENT
# ==========================================================================
def list_drm_plugins(self) -> Dict:
return self.drm_plugin_manager.list_plugins()
def clear_drm_plugins(self):
"""Clear all registered plugins and invalidate all cached DRM results.
The epoch bump means results computed under the old plugin set can
never be read again; stale entries age out via LRU eviction.
"""
self.drm_plugin_manager.clear_plugins()
self._plugin_epoch += 1
logger.debug(f"DRMOperations: Plugins cleared (epoch -> {self._plugin_epoch})")
def clear_pssh_cache(self):
self.pssh_cache.clear()
def clear_drm_config_cache(self):
self.drm_config_cache.clear()