Files
script.service.ultimate/lib/streaming_providers/providers/joyn/channel_manager.py
T
2026-10-12 09:55:01 +02:00

258 lines
11 KiB
Python

# streaming_providers/providers/joyn/channel_manager.py
# -*- coding: utf-8 -*-
"""
Joyn Channel Manager.
Four required collaborators (`http_manager`, `auth`, `country`, `config`) plus
two declared extras:
* `entitlement` — the shared JoynEntitlement helper (README §5.1)
* `playout_cache` — provider-owned dict, borrowed by reference; makes
get_channel_manifest and get_channel_drm share ONE
playlist call per zap
Headers come from `self.config`. The channel manager's GraphQL header builder
tolerates an absent session (see `_graphql_headers`): the LiveChannelsAndEpg
operation is served anonymously (joyn-user-state=A_A) as well as authenticated
(R_A), and there is no reason a broken login should hide the channel list. The
VOD manager uses a stricter builder for user-scoped queries; the two
deliberately differ.
Routing: handles_content_id() is the cheap pure pre-filter (README §7). It is the
mirror image of JoynVodManager.handles_content_id — both call ids.is_vod_id() —
so a VOD id is never handed to this manager first.
"""
import time
from typing import Any, Dict, List, Optional, Tuple
from ...base.errors import BadRequestError
from ...base.managers import ChannelManager
from ...base.models import DRMConfig
from ...base.utils.logger import logger
from ...base.utils.transport import transport_errors
from .constants import (
CONTENT_TYPE_LIVE,
CONTENT_TYPE_VOD,
DEFAULT_EPG_WINDOW_HOURS,
DEFAULT_LIVESTREAM_TYPES,
DEFAULT_REQUEST_TIMEOUT,
GRAPHQL_LIVE_CHANNELS_FILTER,
GRAPHQL_MAX_RESULTS,
GRAPHQL_OFFSET,
JOYN_STREAMING_ENDPOINTS,
MODE_LIVE,
MODE_VOD,
PLAYOUT_CACHE_TTL,
PROVIDER_NAME,
)
from .drm import build_widevine_config
from .ids import is_live_id, is_vod_id
from .models import JoynChannel, JoynPlayout
from .graphql import build_url
from .signing import ( # noqa: F401 (re-exported: external callers imported these from here)
build_signature,
create_video_payload,
video_config_fingerprint,
)
class JoynChannelManager(ChannelManager):
def __init__(
self,
*,
http_manager: Any,
auth: Any,
country: str,
config: Any,
entitlement: Any,
playout_cache: Optional[Dict[str, Tuple[JoynPlayout, float]]] = None,
):
super().__init__(
http_manager=http_manager, auth=auth, country=country, config=config
)
# extras AFTER super(), never passed to it
self._entitlement = entitlement
self._playout_cache = playout_cache if playout_cache is not None else {}
logger.info(f"[JoynChannelManager] Initialised for country={self.country}")
# ------------------------------------------------------------------
# Routing
# ------------------------------------------------------------------
def handles_content_id(self, content_id: str) -> bool:
"""Live ids are bare channel slugs. Pure, no I/O (README §7)."""
return is_live_id(content_id)
# ------------------------------------------------------------------
# Headers
# ------------------------------------------------------------------
def _graphql_headers(self) -> Dict[str, str]:
"""
GraphQL headers for the channel listing.
Deliberately tolerant of an absent session:
* LiveChannelsAndEpg is served to anonymous callers
(joyn-user-state=A_A). It is a public catalogue, not a
user-scoped resource.
* A permanent login failure (MFA, rejected credentials) must NOT
stop the channel list: there is no reason a user who cannot
log in should be unable to see what is on. The failure
surfaces on the first authenticated call (VOD, entitlement,
playback), where the user can act on it.
* A *transient* login failure (network, WAF) also does not block
the listing: `ensure()` returns False and the request goes out
anonymously, and the session's backoff keeps the next attempt
from hammering the login endpoint.
The VOD manager's `_get_graphql_headers(authenticated=True)` uses
the opposite policy — a user-scoped query is meaningless without a
session and raises typed errors. The two policies are intentional.
"""
token = None
if self.auth.ensure():
token = self.auth.get_access_token()
return self.config.graphql_headers(
token=token, authenticated=token is not None
)
def get_channel_manifest_headers(self, content_id: str, **kw: Any) -> Dict[str, str]:
# CDN headers: no Authorization. The manifest URL carries its own
# signature; the bearer yields "400 InvalidArgument: Unsupported
# Authorization Type". get_segment_headers is NOT overridden: the ABC
# default returns these, which is what the CDN segments need.
return self.config.cdn_headers()
# ------------------------------------------------------------------
# Playout (shared by manifest and DRM)
# ------------------------------------------------------------------
def clear_playout_cache(self) -> None:
self._playout_cache.clear()
def _playout(self, content_id: str, video_config: Optional[Dict] = None) -> JoynPlayout:
if is_vod_id(content_id):
# handles_content_id() should have kept this out; reaching it means a
# caller bypassed the router. A malformed *request*, not a server fault.
raise BadRequestError(
f"Joyn: {content_id!r} is a VOD id, not a live channel id"
)
now = time.time()
key = f"{content_id}:{video_config_fingerprint(video_config)}"
cached = self._playout_cache.get(key)
if cached and (now - cached[1]) < PLAYOUT_CACHE_TTL:
return cached[0]
with transport_errors(f"playout for {content_id}", "Joyn"):
resolved_id, entitlement_token = self._entitlement.get_channel_entitlement_token(
content_id
)
video_payload = create_video_payload(video_config)
signature = build_signature(entitlement_token, video_payload)
url = JOYN_STREAMING_ENDPOINTS["PLAYLIST"].format(channel_id=resolved_id)
url += f"?signature={signature}"
response = self.http_manager.post(
url,
operation="manifest",
headers=self.config.api_headers(entitlement_token),
data=video_payload,
timeout=DEFAULT_REQUEST_TIMEOUT,
)
response.raise_for_status() # no-op when the HTTP manager already raised
playout = JoynPlayout.from_playlist_response(
response.json(), entitlement_token
)
# Evict expired entries on insert: the dict is keyed by id x video-config
# and would otherwise only ever grow.
for stale in [k for k, (_, ts) in self._playout_cache.items()
if (now - ts) >= PLAYOUT_CACHE_TTL]:
self._playout_cache.pop(stale, None)
self._playout_cache[key] = (playout, now)
return playout
# ------------------------------------------------------------------
# Manifest / DRM
# ------------------------------------------------------------------
def get_channel_manifest(self, content_id: str, **kw: Any) -> Optional[str]:
return self._playout(content_id, kw.get("video_config")).manifest_url
def get_channel_drm(self, content_id: str, **kw: Any) -> List[DRMConfig]:
playout = self._playout(content_id, kw.get("video_config"))
if not playout.license_url:
return []
return [build_widevine_config(self.config, playout.license_url, playout.certificate_url)]
# ------------------------------------------------------------------
# Channels
# ------------------------------------------------------------------
def get_channels(
self,
time_window_hours: int = DEFAULT_EPG_WINDOW_HOURS,
**kw: Any,
) -> List:
"""
Fetch and parse the GraphQL channel list.
Malformed entries are skipped with a warning — one bad upstream item must
not empty the whole list (README §12.5).
"""
with transport_errors("get_channels", "Joyn"):
headers = self._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,
}
url = build_url("LIVE_CHANNELS", variables)
response = self.http_manager.get(
url, operation="api", headers=headers,
timeout=DEFAULT_REQUEST_TIMEOUT,
)
response.raise_for_status() # no-op when the HTTP manager already raised
data = response.json()
channels = []
for stream_data in (data.get("data") or {}).get("liveStreams") or []:
try:
channel = self._parse_channel_entry(stream_data)
except (KeyError, TypeError, AttributeError, ValueError) as exc:
logger.warning(f"Joyn: skipping malformed channel entry: {exc!r}")
continue
channels.append(channel.to_streaming_channel(provider_name=PROVIDER_NAME))
logger.info(f"Joyn: fetched {len(channels)} channels for country {self.country}")
return channels
def _parse_channel_entry(self, stream_data: Dict) -> JoynChannel:
channel_id = stream_data["id"]
title = stream_data.get("title", "Unknown Channel")
stream_type = stream_data.get("type", "LINEAR")
quality = stream_data.get("quality", "")
logo_url = (stream_data.get("logo") or {}).get("url")
content_type = CONTENT_TYPE_LIVE if stream_type == "LINEAR" else CONTENT_TYPE_VOD
mode = MODE_LIVE if stream_type == "LINEAR" else MODE_VOD
channel = JoynChannel(
name=f"{title} ({quality})" if quality else title,
channel_id=channel_id,
logo_url=logo_url,
mode=mode,
content_type=content_type,
country=self.country,
raw_data=stream_data,
)
if "brand" in stream_data and "brand_id" in stream_data["brand"]:
channel.raw_data["brand_id"] = stream_data["brand"]["brand_id"]
if stream_data.get("eventStream"):
channel.raw_data["is_event_stream"] = True
return channel