mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-10-30 10:42:45 +01:00
464 lines
18 KiB
Python
464 lines
18 KiB
Python
# streaming_providers/providers/joyn/channel_manager.py
|
|
# -*- coding: utf-8 -*-
|
|
"""
|
|
Joyn Channel Manager - Handles channels, EPG, and streaming data
|
|
"""
|
|
|
|
import hashlib
|
|
import json
|
|
import time
|
|
import urllib.parse
|
|
from base64 import b64decode
|
|
from typing import Dict, List, Optional
|
|
|
|
from ...base.models import DRMConfig, DRMSystem, LicenseConfig, StreamingChannel
|
|
from ...base.provider import AuthType
|
|
from ...base.utils.logger import logger
|
|
from .constants import (
|
|
CONTENT_TYPE_LIVE,
|
|
CONTENT_TYPE_VOD,
|
|
DEFAULT_EPG_WINDOW_HOURS,
|
|
DEFAULT_LIVESTREAM_TYPES,
|
|
DEFAULT_MAX_RETRIES,
|
|
DEFAULT_REQUEST_TIMEOUT,
|
|
DEFAULT_VIDEO_CONFIG,
|
|
DRM_REQUEST_HEADERS,
|
|
DRM_SYSTEM_WIDEVINE,
|
|
ERROR_CODES,
|
|
GRAPHQL_LIVE_CHANNELS_FILTER,
|
|
GRAPHQL_MAX_RESULTS,
|
|
GRAPHQL_OFFSET,
|
|
GRAPHQL_PERSISTED_QUERY_VERSION,
|
|
GRAPHQL_QUERY_HASHES,
|
|
JOYN_API_BASE_HEADERS,
|
|
JOYN_CLIENT_VERSION,
|
|
JOYN_DOMAINS,
|
|
JOYN_GRAPHQL_BASE_HEADERS,
|
|
JOYN_GRAPHQL_ENDPOINTS,
|
|
JOYN_STREAMING_ENDPOINTS,
|
|
JOYN_USER_AGENT,
|
|
MODE_LIVE,
|
|
MODE_VOD,
|
|
SIGNATURE_SECRET_KEY,
|
|
)
|
|
from .models import JoynChannel, JoynError, JoynEntitlementError, PlaybackRestrictedException, SubscriptionRequiredException
|
|
|
|
|
|
def create_video_payload(config: Optional[Dict] = None, compact: bool = True) -> str:
|
|
video_config = config or DEFAULT_VIDEO_CONFIG
|
|
payload = json.dumps(video_config)
|
|
return payload.replace(" ", "") if compact else payload
|
|
|
|
|
|
def build_signature(
|
|
entitlement_token: str,
|
|
video_payload: Optional[str] = None,
|
|
secret_key: Optional[str] = None,
|
|
) -> str:
|
|
if video_payload is None:
|
|
video_payload = create_video_payload()
|
|
if secret_key is None:
|
|
secret_key = b64decode(SIGNATURE_SECRET_KEY).decode("utf-8")
|
|
signature_input = f"{video_payload},{entitlement_token}{secret_key}"
|
|
return hashlib.sha1(signature_input.encode("utf-8")).hexdigest()
|
|
|
|
|
|
class JoynChannelManager:
|
|
"""
|
|
Owns channel/EPG streaming data AND the shared header-building + entitlement
|
|
logic that vod_manager also depends on. vod_manager calls back into this
|
|
manager (via self.provider.channel_manager) rather than keeping its own
|
|
copies of _get_graphql_headers / get_api_headers / get_entitlement_token.
|
|
"""
|
|
|
|
def __init__(self, provider):
|
|
self.provider = provider
|
|
|
|
self._channels_cache: Optional[List[StreamingChannel]] = None
|
|
self._cache_timestamp: float = 0.0
|
|
self._cache_ttl: int = 300 # 5 minutes
|
|
|
|
logger.info(f"[JoynChannelManager] Initialised for country={provider.country}")
|
|
|
|
@property
|
|
def http_manager(self):
|
|
return self.provider.http_manager
|
|
|
|
@property
|
|
def authenticator(self):
|
|
return self.provider.authenticator
|
|
|
|
@property
|
|
def country(self) -> str:
|
|
return self.provider.country
|
|
|
|
@property
|
|
def platform(self) -> str:
|
|
return self.provider.platform
|
|
|
|
@property
|
|
def distribution_tenant(self) -> str:
|
|
return self.provider.distribution_tenant
|
|
|
|
@property
|
|
def channels(self) -> List[StreamingChannel]:
|
|
"""Lazy-loaded channels list with TTL caching"""
|
|
if self._channels_cache is None or (time.time() - self._cache_timestamp) > self._cache_ttl:
|
|
self._channels_cache = self.get_channels()
|
|
self._cache_timestamp = time.time()
|
|
return self._channels_cache or []
|
|
|
|
def _get_graphql_headers(self) -> Dict[str, str]:
|
|
return self.provider._build_provider_headers(
|
|
base_headers=JOYN_GRAPHQL_BASE_HEADERS,
|
|
auth_type=AuthType.NONE,
|
|
provider_headers={
|
|
"joyn-client-version": JOYN_CLIENT_VERSION,
|
|
"joyn-country": self.country.upper(),
|
|
"joyn-distribution-tenant": self.distribution_tenant,
|
|
"joyn-platform": self.platform,
|
|
"joyn-user-state": "code=R_A",
|
|
},
|
|
)
|
|
|
|
def get_api_headers(self) -> Dict[str, str]:
|
|
# Public: vod_manager delegates to this instead of keeping its own
|
|
# copy (see JoynVodManager._get_api_headers).
|
|
return self.provider._build_provider_headers(
|
|
base_headers=JOYN_API_BASE_HEADERS,
|
|
auth_type=AuthType.BEARER,
|
|
provider_headers={
|
|
"joyn-client-version": JOYN_CLIENT_VERSION,
|
|
"joyn-country": self.country.upper(),
|
|
"joyn-distribution-tenant": self.distribution_tenant,
|
|
"joyn-platform": self.platform,
|
|
"joyn-b2b-context": "UNKNOWN",
|
|
"joyn-client-os": "UNKNOWN", # Restored missing header
|
|
"origin": JOYN_DOMAINS.get(self.country, JOYN_DOMAINS["de"]),
|
|
},
|
|
)
|
|
|
|
def get_channels(
|
|
self,
|
|
time_window_hours: int = DEFAULT_EPG_WINDOW_HOURS,
|
|
fetch_manifests: bool = False,
|
|
populate_streaming_data: bool = True,
|
|
**kwargs,
|
|
) -> List[StreamingChannel]:
|
|
try:
|
|
headers = self._get_graphql_headers()
|
|
current_time = int(time.time())
|
|
end_time = current_time + (time_window_hours * 3600)
|
|
|
|
variables = {
|
|
"liveStreamGroupFilter": GRAPHQL_LIVE_CHANNELS_FILTER,
|
|
"first": GRAPHQL_MAX_RESULTS,
|
|
"offset": GRAPHQL_OFFSET,
|
|
"livestreamTypes": DEFAULT_LIVESTREAM_TYPES,
|
|
"from": current_time,
|
|
"to": end_time,
|
|
}
|
|
variables_encoded = urllib.parse.quote(json.dumps(variables))
|
|
extensions = {
|
|
"persistedQuery": {
|
|
"version": GRAPHQL_PERSISTED_QUERY_VERSION,
|
|
"sha256Hash": GRAPHQL_QUERY_HASHES["LIVE_CHANNELS"],
|
|
}
|
|
}
|
|
extensions_encoded = urllib.parse.quote(json.dumps(extensions))
|
|
|
|
url = f"{JOYN_GRAPHQL_ENDPOINTS['LIVE_CHANNELS']}&variables={variables_encoded}&extensions={extensions_encoded}"
|
|
|
|
response = self.http_manager.get(
|
|
url, operation="api", headers=headers, timeout=DEFAULT_REQUEST_TIMEOUT
|
|
)
|
|
response.raise_for_status()
|
|
channel_data = response.json()
|
|
|
|
channels = self._process_graphql_response(channel_data)
|
|
|
|
if fetch_manifests and populate_streaming_data:
|
|
channels = self.populate_streaming_data(channels)
|
|
|
|
logger.info(f"Successfully fetched {len(channels)} channels for country {self.country}")
|
|
return channels
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error fetching channels from GraphQL: {e}")
|
|
return []
|
|
|
|
def _process_graphql_response(self, response_data: Dict) -> List[StreamingChannel]:
|
|
if "data" not in response_data or "liveStreams" not in response_data["data"]:
|
|
raise JoynError("Invalid GraphQL response structure")
|
|
|
|
live_streams = response_data["data"]["liveStreams"]
|
|
channels = []
|
|
|
|
for stream_data in live_streams:
|
|
try:
|
|
channel_id = stream_data.get("id", "")
|
|
title = stream_data.get("title", "Unknown Channel")
|
|
stream_type = stream_data.get("type", "LINEAR")
|
|
quality = stream_data.get("quality", "")
|
|
|
|
logo_url = None
|
|
if "logo" in stream_data and "url" in stream_data["logo"]:
|
|
logo_url = stream_data["logo"]["url"]
|
|
|
|
content_type = CONTENT_TYPE_LIVE if stream_type == "LINEAR" else CONTENT_TYPE_VOD
|
|
mode = MODE_LIVE if stream_type == "LINEAR" else MODE_VOD
|
|
|
|
joyn_channel = JoynChannel(
|
|
name=title,
|
|
channel_id=channel_id,
|
|
logo_url=logo_url,
|
|
mode=mode,
|
|
content_type=content_type,
|
|
country=self.country,
|
|
raw_data=stream_data,
|
|
)
|
|
|
|
if quality:
|
|
joyn_channel.name = f"{title} ({quality})"
|
|
|
|
if "brand" in stream_data:
|
|
brand_data = stream_data["brand"]
|
|
if "brand_id" in brand_data:
|
|
joyn_channel.raw_data["brand_id"] = brand_data["brand_id"]
|
|
|
|
if stream_data.get("eventStream", False):
|
|
joyn_channel.raw_data["is_event_stream"] = True
|
|
|
|
channels.append(joyn_channel.to_streaming_channel(provider_name=self.provider.provider_name))
|
|
except Exception as e:
|
|
logger.warning(f"Error processing channel data: {e}")
|
|
|
|
return channels
|
|
|
|
def get_entitlement_token(self, content_id: str, content_type: str = CONTENT_TYPE_LIVE) -> str:
|
|
headers = self.get_api_headers()
|
|
payload = {"content_id": content_id, "content_type": content_type}
|
|
|
|
try:
|
|
response = self.http_manager.post(
|
|
JOYN_STREAMING_ENDPOINTS["ENTITLEMENT"],
|
|
operation="auth",
|
|
headers=headers,
|
|
json_data=payload,
|
|
timeout=DEFAULT_REQUEST_TIMEOUT,
|
|
)
|
|
|
|
if response.status_code == 400:
|
|
try:
|
|
error_data = response.json()
|
|
if isinstance(error_data, list) and len(error_data) > 0:
|
|
error = error_data[0]
|
|
code = error.get("code", "UNKNOWN")
|
|
msg = error.get("msg", "No error message provided")
|
|
if code == ERROR_CODES["PLAYBACK_RESTRICTED"]:
|
|
raise PlaybackRestrictedException(f"Playback restricted for {content_id}: {msg}")
|
|
elif code == ERROR_CODES["BUSINESS_MODEL_NOT_SUITABLE"]:
|
|
raise SubscriptionRequiredException(
|
|
f"Subscription required for {content_id} ({code}): {msg}")
|
|
else:
|
|
raise JoynEntitlementError(f"Entitlement error for {content_id} ({code}): {msg}")
|
|
except (json.JSONDecodeError, KeyError, IndexError) as e:
|
|
raise JoynEntitlementError(f"Bad response for {content_id} (400), failed to parse error: {e}")
|
|
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
return data["entitlement_token"]
|
|
|
|
except PlaybackRestrictedException:
|
|
raise
|
|
except JoynEntitlementError:
|
|
raise
|
|
except KeyError:
|
|
raise JoynEntitlementError(f"No entitlement_token in response for {content_id}")
|
|
except Exception as e:
|
|
raise JoynEntitlementError(f"Error getting entitlement token for {content_id}: {e}")
|
|
|
|
def get_channel_playlist(
|
|
self,
|
|
channel_id: str,
|
|
entitlement_token: str,
|
|
video_config: Optional[Dict] = None,
|
|
) -> Dict:
|
|
video_payload = create_video_payload(video_config)
|
|
signature = build_signature(entitlement_token, video_payload)
|
|
|
|
url = JOYN_STREAMING_ENDPOINTS["PLAYLIST"].format(channel_id=channel_id)
|
|
url += f"?signature={signature}"
|
|
|
|
headers = JOYN_API_BASE_HEADERS.copy()
|
|
headers["Authorization"] = f"Bearer {entitlement_token}"
|
|
|
|
try:
|
|
response = self.http_manager.post(
|
|
url,
|
|
operation="manifest",
|
|
headers=headers,
|
|
data=video_payload,
|
|
timeout=DEFAULT_REQUEST_TIMEOUT,
|
|
)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
except Exception as e:
|
|
# Let our custom entitlement exceptions bubble up untouched so callers
|
|
# (and the UI layer) can distinguish "needs subscription" / "not allowed
|
|
# here" from a generic network failure instead of seeing everything as
|
|
# a flat JoynError.
|
|
if isinstance(e, (PlaybackRestrictedException, SubscriptionRequiredException, JoynEntitlementError)):
|
|
raise
|
|
raise JoynError(f"Error getting playlist for {channel_id}: {e}")
|
|
|
|
def get_manifest(
|
|
self,
|
|
content_id: str,
|
|
content_type: str = CONTENT_TYPE_LIVE,
|
|
video_config: Optional[Dict] = None,
|
|
**kwargs,
|
|
) -> Optional[str]:
|
|
try:
|
|
entitlement_token = self.get_entitlement_token(content_id=content_id, content_type=content_type)
|
|
playlist_data = self.get_channel_playlist(content_id, entitlement_token, video_config)
|
|
return playlist_data.get("manifestUrl")
|
|
except Exception as e:
|
|
logger.error(f"Error getting manifest for channel {content_id}: {e}")
|
|
return None
|
|
|
|
def get_manifest_headers(self, content_id: str, **kwargs) -> Dict[str, str]:
|
|
# The CDN-served manifest URL is self-authorizing; sending the Joyn
|
|
# provider bearer token to the CDN causes:
|
|
# 400 InvalidArgument: Unsupported Authorization Type
|
|
# so we deliberately omit Authorization here.
|
|
return self.provider._build_provider_headers(
|
|
base_headers={},
|
|
auth_type=AuthType.NONE,
|
|
provider_headers={
|
|
"User-Agent": JOYN_USER_AGENT,
|
|
"Origin": JOYN_DOMAINS.get(self.country, JOYN_DOMAINS["de"]),
|
|
},
|
|
)
|
|
|
|
def _build_drm_config(self, playlist_data: Dict) -> Optional[DRMConfig]:
|
|
"""Build a DRMConfig object from a playlist response.
|
|
|
|
The license endpoint authenticates via the token embedded in the URL's
|
|
signature query param — no Authorization header is sent. We include
|
|
Origin and User-Agent to satisfy Cloudflare WAF requirements, matching
|
|
captured browser traffic.
|
|
"""
|
|
license_url = playlist_data.get("licenseUrl")
|
|
if not license_url:
|
|
return None
|
|
|
|
return DRMConfig(
|
|
system=DRMSystem.WIDEVINE,
|
|
priority=1,
|
|
license=LicenseConfig(
|
|
server_url=license_url,
|
|
server_certificate=playlist_data.get("certificateUrl"),
|
|
req_headers=json.dumps({
|
|
"User-Agent": JOYN_USER_AGENT,
|
|
"Origin": JOYN_DOMAINS.get(self.country, JOYN_DOMAINS["de"]),
|
|
"Content-Type": DRM_REQUEST_HEADERS["Content-Type"],
|
|
}),
|
|
req_data="{CHA-RAW}",
|
|
use_http_get_request=False,
|
|
),
|
|
)
|
|
|
|
def get_drm(
|
|
self,
|
|
content_id: str,
|
|
content_type: str = CONTENT_TYPE_LIVE,
|
|
video_config: Optional[Dict] = None,
|
|
**kwargs,
|
|
) -> List[DRMConfig]:
|
|
try:
|
|
entitlement_token = self.get_entitlement_token(content_id=content_id, content_type=content_type)
|
|
playlist_data = self.get_channel_playlist(content_id, entitlement_token, video_config)
|
|
|
|
drm_config = self._build_drm_config(playlist_data)
|
|
return [drm_config] if drm_config else []
|
|
except Exception as e:
|
|
logger.error(f"Error getting DRM configs for channel {content_id}: {e}")
|
|
return []
|
|
|
|
def enrich_channel_data(
|
|
self,
|
|
channel: StreamingChannel,
|
|
video_config: Optional[Dict] = None,
|
|
**kwargs,
|
|
) -> Optional[StreamingChannel]:
|
|
try:
|
|
content_type = kwargs.get("content_type", channel.content_type)
|
|
entitlement_token = self.get_entitlement_token(content_id=channel.channel_id, content_type=content_type)
|
|
playlist_data = self.get_channel_playlist(channel.channel_id, entitlement_token, video_config)
|
|
|
|
manifest_url = playlist_data.get("manifestUrl")
|
|
if not manifest_url:
|
|
return None
|
|
|
|
channel.manifest = manifest_url
|
|
channel.streaming_format = playlist_data.get("streamingFormat", "dash")
|
|
|
|
drm_config = self._build_drm_config(playlist_data)
|
|
if drm_config:
|
|
channel.drm_config = drm_config
|
|
channel.cdm_type = DRM_SYSTEM_WIDEVINE
|
|
channel.cdm = f"pid={channel.channel_id}"
|
|
|
|
return channel
|
|
except Exception as e:
|
|
logger.error(f"Error enriching channel {channel.name}: {e}")
|
|
return None
|
|
|
|
def populate_streaming_data(
|
|
self,
|
|
channels: List[StreamingChannel],
|
|
video_config: Optional[Dict] = None,
|
|
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
) -> List[StreamingChannel]:
|
|
successful_channels = []
|
|
|
|
for channel in channels:
|
|
retries = 0
|
|
success = False
|
|
is_restricted = False
|
|
|
|
while retries < max_retries and not success and not is_restricted:
|
|
try:
|
|
entitlement_token = self.get_entitlement_token(
|
|
content_id=channel.channel_id, content_type=channel.content_type
|
|
)
|
|
playlist_data = self.get_channel_playlist(
|
|
channel.channel_id, entitlement_token, video_config
|
|
)
|
|
|
|
manifest_url = playlist_data.get("manifestUrl")
|
|
if manifest_url:
|
|
channel.manifest = manifest_url
|
|
channel.cdm_type = DRM_SYSTEM_WIDEVINE
|
|
channel.cdm = f"pid={channel.channel_id}"
|
|
channel.license_url = playlist_data.get("licenseUrl")
|
|
channel.certificate_url = playlist_data.get("certificateUrl")
|
|
channel.streaming_format = playlist_data.get("streamingFormat", "dash")
|
|
|
|
successful_channels.append(channel)
|
|
success = True
|
|
else:
|
|
raise JoynError("No manifestUrl in response")
|
|
except PlaybackRestrictedException as e:
|
|
logger.warning(f"Playback restricted for {channel.name}: {e}")
|
|
is_restricted = True
|
|
except Exception as e:
|
|
retries += 1
|
|
if retries < max_retries:
|
|
time.sleep(1)
|
|
else:
|
|
logger.error(f"Failed to get streaming data for {channel.name}: {e}")
|
|
|
|
logger.info(f"Streaming data population complete: {len(successful_channels)}/{len(channels)}")
|
|
return successful_channels |