Add movetv

This commit is contained in:
Nirvana
2026-03-29 21:46:57 +02:00
parent 1e190d0e27
commit 7199e65913
4 changed files with 231 additions and 227 deletions
+43 -8
View File
@@ -452,7 +452,8 @@ class DRMOperations:
if manifest_url:
logger.debug(f"Phase 2: PSSH cache miss for {cache_key}, extracting from manifest")
pssh_data_list = self._extract_pssh_from_manifest(
manifest_url, manifest_headers, provider_name
manifest_url, manifest_headers, provider_name,
channel_id=channel_id,
)
if pssh_data_list:
self.pssh_cache.set(cache_key, pssh_data_list)
@@ -582,7 +583,10 @@ class DRMOperations:
logger.debug(f"GENERIC plugin: Using pre-fetched manifest URL for '{channel_id}'")
if manifest_url:
pssh_data_list = self._extract_pssh_from_manifest(manifest_url, manifest_headers, provider_name)
pssh_data_list = self._extract_pssh_from_manifest(
manifest_url, manifest_headers, provider_name,
channel_id=channel_id,
)
if pssh_data_list:
self.pssh_cache.set(cache_key, pssh_data_list)
@@ -602,7 +606,10 @@ class DRMOperations:
logger.error(f"GENERIC plugin: Cannot get manifest URL for init segment extraction")
return None, pssh_data_list
real_pssh = self._extract_pssh_from_manifest(manifest_url, manifest_headers, provider_name)
real_pssh = self._extract_pssh_from_manifest(
manifest_url, manifest_headers, provider_name,
channel_id=channel_id,
)
if real_pssh and not self._has_stub_pssh(real_pssh):
logger.info(
@@ -666,8 +673,26 @@ class DRMOperations:
}
return bool(config_systems & plugin_systems)
def _extract_pssh_from_manifest(self, manifest_url: str, manifest_headers: dict[str, str], provider_name: Optional[str] = None) -> List:
"""Extract PSSH data from manifest using the provider's HTTPManager."""
def _extract_pssh_from_manifest(
self,
manifest_url: str,
manifest_headers: Dict[str, str],
provider_name: Optional[str] = None,
channel_id: Optional[str] = None,
) -> List:
"""
Extract PSSH data from manifest, falling back to the init segment if
the manifest itself carries only stub ContentProtection entries.
Args:
manifest_url: URL of the manifest to fetch.
manifest_headers: HTTP headers to use when fetching the manifest.
provider_name: Used to resolve the provider's HTTPManager and
segment headers. If None, a plain HTTPManager is used.
channel_id: Passed to provider.get_segment_headers() so providers
that require per-channel auth on segments supply the
correct headers. If None, segment headers default to {}.
"""
from .utils.drm_extractor import DRMExtractor
from .network import HTTPManager
@@ -677,19 +702,28 @@ class DRMOperations:
return []
try:
# 2. Resolve the correct HTTP manager
# 2. Resolve the correct HTTP manager and segment headers
http = None
segment_headers = {}
if provider_name:
provider = self.registry.get_provider(provider_name)
if provider:
http = provider.http_manager
logger.debug(f"Using configured HTTPManager for provider: {provider_name}")
if channel_id:
segment_headers = provider.get_segment_headers(channel_id)
logger.debug(
f"Resolved segment headers for '{provider_name}/{channel_id}': "
f"{list(segment_headers.keys())}"
)
if not http:
logger.debug("No provider manager found; using default HTTPManager")
http = HTTPManager()
# 3. Perform the request
# 3. Perform the manifest request
response = http.get(manifest_url, headers=manifest_headers, timeout=10, operation="api")
response.raise_for_status()
manifest_content = response.text
@@ -712,7 +746,8 @@ class DRMOperations:
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 []
[p.system_id for p in pssh_list] if pssh_list else [],
headers=segment_headers,
)
if segment_pssh:
@@ -6,7 +6,7 @@ Separated from general manifest parsing to maintain clear separation of concerns
import base64
import re
from typing import List
from typing import Dict, List, Optional
from ..models.drm import PSSHData
from .logger import logger
@@ -43,6 +43,8 @@ class DRMExtractor:
if fallback_to_segments and segment_urls:
incomplete_pssh = [p for p in pssh_list if not p.pssh_box or not p.key_ids]
if incomplete_pssh:
# No headers available in this deprecated path — callers that need
# auth on segment requests should use _extract_from_single_segment directly.
segment_pssh = DRMExtractor._extract_from_single_segment(
segment_urls[0], [p.system_id for p in incomplete_pssh]
)
@@ -92,13 +94,27 @@ class DRMExtractor:
@staticmethod
def _extract_from_single_segment(
segment_url: str,
expected_system_ids: List[str] = None
expected_system_ids: List[str] = None,
headers: Optional[Dict[str, str]] = None,
) -> List[PSSHData]:
"""Extract PSSH from a single segment URL."""
"""
Extract PSSH from a single segment URL.
Args:
segment_url: URL of the init segment to fetch
expected_system_ids: If provided, filter results to these DRM system IDs.
Falls back to returning all PSSH if no matches found.
headers: HTTP headers to use when fetching the segment (e.g. Authorization).
Providers that require auth on segment requests should supply these
via StreamingProvider.get_segment_headers().
"""
from .mp4_pssh_extractor import MP4PSSHExtractor
try:
pssh_from_segment = MP4PSSHExtractor.extract_from_url(segment_url)
pssh_from_segment = MP4PSSHExtractor.extract_from_url(
segment_url,
headers=headers or {},
)
if expected_system_ids:
# Normalize expected IDs using the model
@@ -41,6 +41,7 @@ class MPDRewriter:
channel: Optional[str] = None,
blocklist_path: str = "representation_blocklist.json",
clearkey_receiver_side: bool = False,
segment_headers: Optional[Dict[str, str]] = None,
):
self.media_proxy_url = media_proxy_url.rstrip("/")
self.provider_proxy_url = provider_proxy_url
@@ -61,6 +62,12 @@ class MPDRewriter:
self._static_params = {}
if self.provider_proxy_url:
self._static_params["proxy"] = self.provider_proxy_url
if segment_headers:
import json
encoded = base64.urlsafe_b64encode(
json.dumps(segment_headers).encode()
).decode().rstrip("=")
self._static_params["headers"] = encoded
@staticmethod
def encode_url(url: str) -> str:
+161 -215
View File
@@ -332,6 +332,58 @@ class UltimateService:
# Fallback to environment manager config
return self.env_manager.get_config(setting_id, default)
def _fetch_manifest_for_rewriter(
self,
provider: str,
channel_id: str,
manifest_url: str,
) -> tuple:
"""
Fetch a manifest and collect everything MPDRewriter needs.
Returns:
(manifest_text, ttl, provider_proxy_url, segment_headers)
Raises:
ValueError: if the provider HTTP manager is not available
requests.HTTPError: if the manifest fetch fails
"""
http_manager = self.manager.get_provider_http_manager(provider)
if not http_manager:
raise ValueError(f'Provider "{provider}" not configured properly (no HTTP manager)')
# Resolve manifest headers from the provider so auth tokens etc. are sent
provider_instance = self.manager.get_provider(provider)
manifest_headers = (
provider_instance.get_manifest_headers(channel_id)
if provider_instance else {}
)
segment_headers = (
provider_instance.get_segment_headers(channel_id)
if provider_instance else {}
)
manifest_response = http_manager.get(
manifest_url, headers=manifest_headers, operation="manifest"
)
manifest_response.raise_for_status()
# Cache TTL: prefer HTTP Cache-Control/Expires, fall back to MPD minimumUpdatePeriod
ttl = MPDRewriter.extract_cache_ttl(manifest_response.headers)
mpd_ttl = MPDRewriter.extract_mpd_update_period(manifest_response.text)
if mpd_ttl and mpd_ttl < ttl:
ttl = mpd_ttl
# Derive provider-side proxy URL for the media proxy to use when forwarding
provider_proxy_url = None
if http_manager.config.proxy_config:
proxy_cfg = http_manager.config.proxy_config
provider_proxy_url = (
f"{proxy_cfg.proxy_type.value.lower()}://{proxy_cfg.host}:{proxy_cfg.port}"
)
return manifest_response.text, ttl, provider_proxy_url, segment_headers
def get_decrypted_manifest(
self, provider: str, channel_id: str, keyids: dict,
highest_quality_only: bool = False, receiver_side: bool = False
@@ -345,6 +397,7 @@ class UltimateService:
channel_id: Channel ID
keyids: Dictionary of kid:key pairs
highest_quality_only: If True, keep only highest quality video representation
receiver_side: If True, inject ClearKey signaling for receiver-side decryption
Returns:
Rewritten MPD content as string
@@ -354,48 +407,24 @@ class UltimateService:
# Note: We don't cache decrypted manifests as they contain keys
logger.info(
f"Generating {'receiver-side clearkey' if receiver_side else 'decrypted'} manifest "
f"for {provider}/{channel_id} (highest_quality_only={highest_quality_only})")
f"for {provider}/{channel_id} (highest_quality_only={highest_quality_only})"
)
# Get original manifest URL
manifest_url = self.manager.get_channel_manifest(
provider_name=provider, channel_id=channel_id, country=country
)
if not manifest_url:
response.status = 404
response.content_type = "application/json"
return json.dumps(
{
"error": f'Manifest not available for channel "{channel_id}" from provider "{provider}"'
}
{"error": f'Manifest not available for channel "{channel_id}" from provider "{provider}"'}
)
# Get provider's HTTP manager
http_manager = self.manager.get_provider_http_manager(provider)
if not http_manager:
logger.error(f"No HTTP manager found for provider '{provider}'")
response.status = 502
response.content_type = "application/json"
return json.dumps(
{"error": f'Provider "{provider}" not configured properly'}
)
# Fetch manifest
try:
logger.debug(f"Fetching manifest for decryption: {manifest_url}")
manifest_response = http_manager.get(manifest_url, operation="manifest")
manifest_text, _ttl, provider_proxy_url, segment_headers = self._fetch_manifest_for_rewriter(
provider, channel_id, manifest_url
)
# Get provider proxy URL if configured
provider_proxy_url = None
if http_manager.config.proxy_config:
proxy_cfg = http_manager.config.proxy_config
provider_proxy_url = (
f"{proxy_cfg.proxy_type.value.lower()}://{proxy_cfg.host}:{proxy_cfg.port}"
)
logger.debug(f"Provider has proxy configured: {provider_proxy_url}")
# Rewrite MPD URLs to point to media proxy decrypt endpoint with keys
# CHANGED: Added provider and channel_id parameters for blocklist filtering
rewriter = MPDRewriter(
self.media_proxy_url,
provider_proxy_url,
@@ -404,127 +433,153 @@ class UltimateService:
provider=provider,
channel=channel_id,
clearkey_receiver_side=receiver_side,
segment_headers=segment_headers,
)
rewritten_mpd = rewriter.rewrite_mpd(manifest_response.text, manifest_url)
rewritten_mpd = rewriter.rewrite_mpd(manifest_text, manifest_url)
# Return rewritten MPD
response.content_type = "application/dash+xml; charset=utf-8"
return rewritten_mpd
except ValueError as e:
logger.error(str(e))
response.status = 502
response.content_type = "application/json"
return json.dumps({"error": str(e)})
except Exception as fetch_err:
logger.error(f"Failed to fetch manifest for decryption: {fetch_err}")
response.status = 502
response.content_type = "application/json"
return json.dumps({"error": f"Failed to fetch manifest: {str(fetch_err)}"})
def get_proxied_catchup_manifest(
self,
provider: str,
channel_id: str,
start_time: int,
end_time: int,
epg_id: str = None,
country: str = None,
) -> str:
"""
Get proxied and rewritten MPD manifest for catchup content using media proxy.
Similar to get_proxied_manifest but for catchup streams.
"""
cache_key = f"{channel_id}_catchup_{start_time}_{end_time}"
cached_mpd = self.mpd_cache.get(provider, cache_key)
if cached_mpd:
response.content_type = "application/dash+xml; charset=utf-8"
return cached_mpd
logger.info(f"Cache miss for catchup {provider}/{channel_id}, fetching manifest")
if not self.media_proxy_url:
response.status = 503
response.content_type = "application/json"
return json.dumps({"error": "Media proxy not configured (MEDIA_PROXY_URL not set)"})
manifest_url = self.manager.get_catchup_manifest(
provider_name=provider,
channel_id=channel_id,
start_time=start_time,
end_time=end_time,
epg_id=epg_id,
country=country,
)
if not manifest_url:
response.status = 404
response.content_type = "application/json"
return json.dumps({"error": "Catchup manifest not available"})
try:
manifest_text, ttl, provider_proxy_url, segment_headers = self._fetch_manifest_for_rewriter(
provider, channel_id, manifest_url
)
rewriter = MPDRewriter(
self.media_proxy_url,
provider_proxy_url,
None, # No keyids for catchup streams
False, # highest_quality_only — not needed for catchup
provider=provider,
channel=channel_id,
segment_headers=segment_headers,
)
rewritten_mpd = rewriter.rewrite_mpd(manifest_text, manifest_url)
self.mpd_cache.set(
provider=provider,
channel_id=cache_key,
mpd_content=rewritten_mpd,
ttl=ttl,
original_url=manifest_url,
)
response.content_type = "application/dash+xml; charset=utf-8"
return rewritten_mpd
except ValueError as e:
logger.error(str(e))
response.status = 502
response.content_type = "application/json"
return json.dumps({"error": str(e)})
except Exception as fetch_err:
logger.error(f"Failed to fetch catchup manifest: {fetch_err}")
response.status = 502
response.content_type = "application/json"
return json.dumps({"error": f"Failed to fetch manifest: {str(fetch_err)}"})
def get_proxied_manifest(self, provider: str, channel_id: str, highest_quality_only: bool = False) -> str:
"""
Get proxied and rewritten MPD manifest for a channel using media proxy.
Uses cache when available and valid.
Args:
provider: Provider name
channel_id: Channel ID
highest_quality_only: If True, keep only highest quality video representation
Returns:
Rewritten MPD content as string
"""
country = request.query.get("country")
# Try cache first (only if not using highest_quality_only, as that changes output)
if not highest_quality_only:
cached_mpd = self.mpd_cache.get(provider, channel_id)
if cached_mpd:
response.content_type = "application/dash+xml; charset=utf-8"
return cached_mpd
# Cache miss or highest_quality_only enabled - fetch and rewrite
logger.info(
f"Cache miss for {provider}/{channel_id}, fetching manifest (highest_quality_only={highest_quality_only})")
# Check if media proxy is configured
if not self.media_proxy_url:
response.status = 503
response.content_type = "application/json"
return json.dumps(
{"error": "Media proxy not configured (MEDIA_PROXY_URL not set)"}
)
return json.dumps({"error": "Media proxy not configured (MEDIA_PROXY_URL not set)"})
# Get original manifest URL
manifest_url = self.manager.get_channel_manifest(
provider_name=provider, channel_id=channel_id, country=country
)
if not manifest_url:
response.status = 404
response.content_type = "application/json"
return json.dumps(
{
"error": f'Manifest not available for channel "{channel_id}" from provider "{provider}"'
}
)
{"error": f'Manifest not available for channel "{channel_id}" from provider "{provider}"'})
# Get provider's HTTP manager to fetch manifest and check proxy config
http_manager = self.manager.get_provider_http_manager(provider)
if not http_manager:
logger.error(f"No HTTP manager found for provider '{provider}'")
response.status = 502
response.content_type = "application/json"
return json.dumps(
{"error": f'Provider "{provider}" not configured properly'}
)
# Fetch manifest via provider's HTTP manager
try:
logger.debug(f"Fetching manifest: {manifest_url}")
manifest_response = http_manager.get(manifest_url, operation="manifest")
manifest_text, ttl, provider_proxy_url, segment_headers = self._fetch_manifest_for_rewriter(
provider, channel_id, manifest_url
)
# Extract cache TTL from response headers
ttl = MPDRewriter.extract_cache_ttl(manifest_response.headers)
# Also check MPD's own update period as fallback
mpd_ttl = MPDRewriter.extract_mpd_update_period(manifest_response.text)
if mpd_ttl and mpd_ttl < ttl:
ttl = mpd_ttl
logger.debug(f"Using MPD minimumUpdatePeriod as TTL: {ttl}s")
# Get provider proxy URL if configured
provider_proxy_url = None
if http_manager.config.proxy_config:
# Build proxy URL from config
proxy_cfg = http_manager.config.proxy_config
provider_proxy_url = (
f"{proxy_cfg.proxy_type.value.lower()}://{proxy_cfg.host}:{proxy_cfg.port}"
)
logger.debug(f"Provider has proxy configured: {provider_proxy_url}")
# Rewrite MPD URLs to point to media proxy
# CHANGED: Added provider and channel_id parameters for blocklist filtering
rewriter = MPDRewriter(
self.media_proxy_url,
provider_proxy_url,
None, # No keyids for proxied (unencrypted) streams
None,
highest_quality_only,
provider=provider, # NEW: Enable blocklist filtering
channel=channel_id # NEW: Enable blocklist filtering
provider=provider,
channel=channel_id,
segment_headers=segment_headers,
)
rewritten_mpd = rewriter.rewrite_mpd(manifest_response.text, manifest_url)
rewritten_mpd = rewriter.rewrite_mpd(manifest_text, manifest_url)
# Cache the rewritten MPD (only if not using highest_quality_only)
if not highest_quality_only:
self.mpd_cache.set(
provider=provider,
channel_id=channel_id,
mpd_content=rewritten_mpd,
ttl=ttl,
original_url=manifest_url,
)
self.mpd_cache.set(provider=provider, channel_id=channel_id,
mpd_content=rewritten_mpd, ttl=ttl, original_url=manifest_url)
# Return rewritten MPD
response.content_type = "application/dash+xml; charset=utf-8"
return rewritten_mpd
except ValueError as e:
logger.error(str(e))
response.status = 502
response.content_type = "application/json"
return json.dumps({"error": str(e)})
except Exception as fetch_err:
logger.error(f"Failed to fetch manifest: {fetch_err}")
response.status = 502
@@ -1497,115 +1552,6 @@ class UltimateService:
return m3u_content
def get_proxied_catchup_manifest(
self,
provider: str,
channel_id: str,
start_time: int,
end_time: int,
epg_id: str = None,
country: str = None,
) -> str:
"""
Get proxied and rewritten MPD manifest for catchup content using media proxy.
Similar to get_proxied_manifest but for catchup streams.
"""
# Generate cache key that includes time parameters
cache_key = f"{channel_id}_catchup_{start_time}_{end_time}"
# Try cache first (with catchup-specific key)
cached_mpd = self.mpd_cache.get(provider, cache_key)
if cached_mpd:
response.content_type = "application/dash+xml; charset=utf-8"
return cached_mpd
logger.info(
f"Cache miss for catchup {provider}/{channel_id}, fetching manifest"
)
# Check if media proxy is configured
if not self.media_proxy_url:
response.status = 503
response.content_type = "application/json"
return json.dumps(
{"error": "Media proxy not configured (MEDIA_PROXY_URL not set)"}
)
# Get catchup manifest URL
manifest_url = self.manager.get_catchup_manifest(
provider_name=provider,
channel_id=channel_id,
start_time=start_time,
end_time=end_time,
epg_id=epg_id,
country=country,
)
if not manifest_url:
response.status = 404
response.content_type = "application/json"
return json.dumps({"error": f"Catchup manifest not available"})
# Get provider's HTTP manager
http_manager = self.manager.get_provider_http_manager(provider)
if not http_manager:
logger.error(f"No HTTP manager found for provider '{provider}'")
response.status = 502
response.content_type = "application/json"
return json.dumps(
{"error": f'Provider "{provider}" not configured properly'}
)
# Fetch manifest
try:
logger.debug(f"Fetching catchup manifest: {manifest_url}")
manifest_response = http_manager.get(manifest_url, operation="manifest")
# Extract cache TTL
ttl = MPDRewriter.extract_cache_ttl(manifest_response.headers)
mpd_ttl = MPDRewriter.extract_mpd_update_period(manifest_response.text)
if mpd_ttl and mpd_ttl < ttl:
ttl = mpd_ttl
# Get provider proxy URL if configured
provider_proxy_url = None
if http_manager.config.proxy_config:
proxy_cfg = http_manager.config.proxy_config
provider_proxy_url = (
f"{proxy_cfg.proxy_type.value.lower()}://{proxy_cfg.host}:{proxy_cfg.port}"
)
logger.debug(f"Provider has proxy configured: {provider_proxy_url}")
# Rewrite MPD URLs to point to media proxy
# CHANGED: Added provider and channel_id parameters for blocklist filtering
rewriter = MPDRewriter(
self.media_proxy_url,
provider_proxy_url,
None, # No keyids for catchup streams
False, # highest_quality_only - usually not needed for catchup
provider=provider, # NEW: Enable blocklist filtering
channel=channel_id # NEW: Enable blocklist filtering
)
rewritten_mpd = rewriter.rewrite_mpd(manifest_response.text, manifest_url)
# Cache the rewritten MPD with catchup-specific key
self.mpd_cache.set(
provider=provider,
channel_id=cache_key, # Use catchup-specific cache key
mpd_content=rewritten_mpd,
ttl=ttl,
original_url=manifest_url,
)
response.content_type = "application/dash+xml; charset=utf-8"
return rewritten_mpd
except Exception as fetch_err:
logger.error(f"Failed to fetch catchup manifest: {fetch_err}")
response.status = 502
response.content_type = "application/json"
return json.dumps({"error": f"Failed to fetch manifest: {str(fetch_err)}"})
@staticmethod
def get_settings_manager():
"""Simple helper to get SettingsManager"""