joyn: vod - more improvements

This commit is contained in:
Nirvana
2026-09-10 21:28:56 +02:00
parent 5777f88c8b
commit c327a55844
6 changed files with 270 additions and 140 deletions
+1 -1
View File
@@ -3,5 +3,5 @@
<component name="Black">
<option name="sdkName" value="Python 3.12" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.12 (.venv)" project-jdk-type="Python SDK" />
<component name="ProjectRootManager" version="2" />
</project>
+3 -1
View File
@@ -5,8 +5,10 @@
<sourceFolder url="file://$MODULE_DIR$/lib" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/lib/streaming_providers/base" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/lib/streaming_providers" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/.venvmac/lib/python3.9/site-packages/pip/_vendor" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.12 (.venv)" jdkType="Python SDK" />
<orderEntry type="jdk" jdkName="~/venvs/script.service.ultimate" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="PyDocumentationSettings">
+16 -4
View File
@@ -5,6 +5,8 @@ import json
import re
import time
import uuid
import requests
from dataclasses import dataclass, field
from typing import Any, Dict, Optional
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
@@ -648,12 +650,16 @@ class JoynAuthenticator(BaseOAuth2Authenticator):
logger.warning(f"{self.provider_name}: WAF block detected ({e}), trying remote login")
try:
return self._perform_remote_login_flow()
except Exception as remote_err:
except (WafBlockedException, ConnectionError, TimeoutError) as remote_err:
logger.warning(
f"{self.provider_name}: Remote login failed ({remote_err}), falling back to client credentials")
return self._perform_oauth_client_credentials_flow()
except Exception as e:
logger.warning(f"{self.provider_name}: Login failed ({e}), falling back to client credentials")
except (ConnectionError, TimeoutError, requests.exceptions.HTTPError) as e:
# Only fall back to anonymous on actual network/API errors, not code bugs.
# Standard 'Exception' is intentionally omitted here so a TypeError/KeyError
# in the OAuth flow crashes loudly instead of silently downgrading a user
# who thinks they're logged in to an anonymous session.
logger.warning(f"{self.provider_name}: Network login failed ({e}), falling back to client credentials")
return self._perform_oauth_client_credentials_flow()
def _perform_oauth_client_credentials_flow(self) -> Dict[str, Any]:
@@ -711,11 +717,17 @@ class JoynAuthenticator(BaseOAuth2Authenticator):
return self.credentials.to_auth_payload()
def _create_token_from_response(self, response_data: Dict[str, Any]) -> BaseAuthToken:
# Subtract 1800s (30 min) safety buffer so we refresh proactively before
# actual expiry, matching the legacy addon's behavior — prevents streams
# cutting off mid-playback while a refresh is still in flight.
raw_expires_in = response_data.get("expires_in", 86400)
safe_expires_in = max(60, raw_expires_in - 1800) # never go below 60s
token = JoynAuthToken(
access_token=response_data["access_token"],
refresh_token=response_data.get("refresh_token", ""),
token_type=response_data.get("token_type", "Bearer"),
expires_in=response_data.get("expires_in", 86400),
expires_in=safe_expires_in,
issued_at=response_data.get("issued_at", time.time()),
)
token.auth_level = self._classify_token(token)
@@ -304,6 +304,12 @@ class JoynChannelManager:
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(
@@ -324,6 +330,34 @@ class JoynChannelManager:
def get_manifest_headers(self, content_id: str, **kwargs) -> Dict[str, str]:
return self.get_api_headers()
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,
@@ -335,26 +369,8 @@ class JoynChannelManager:
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)
license_url = playlist_data.get("licenseUrl")
if not license_url:
return []
drm_config = DRMConfig(
system=DRMSystem.WIDEVINE,
priority=1,
license=LicenseConfig.create_with_req_data(
req_data_template="{CHA-RAW}",
server_url=license_url,
server_certificate=playlist_data.get("certificateUrl"),
req_headers=json.dumps({
"Authorization": f"Bearer {self.provider.bearer_token}",
"Content-Type": DRM_REQUEST_HEADERS["Content-Type"],
"User-Agent": JOYN_USER_AGENT,
}),
use_http_get_request=False,
),
)
return [drm_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 []
@@ -377,22 +393,8 @@ class JoynChannelManager:
channel.manifest = manifest_url
channel.streaming_format = playlist_data.get("streamingFormat", "dash")
license_url = playlist_data.get("licenseUrl")
if license_url:
drm_config = 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,
"Content-Type": DRM_REQUEST_HEADERS["Content-Type"],
}),
req_data="{CHA-RAW}",
use_http_get_request=False,
),
)
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}"
@@ -8,7 +8,6 @@ Wires together authentication, channel, and VOD managers
from dataclasses import dataclass
from typing import ClassVar, Dict, List, Optional, Tuple, Union
from datetime import datetime
import dataclasses
from ...base.models import DRMConfig, StreamingChannel, Event, ContentType
from ...base.models.proxy_models import ProxyConfig
@@ -221,6 +220,29 @@ class JoynProvider(StreamingProvider):
def get_program_details(self, program_id: str, **kwargs) -> Optional[Dict]:
return self.epg_manager.get_program_details(program_id, **kwargs)
# ============================================================================
# ROUTING HELPER
# ============================================================================
def _is_vod_content(self, content_id: str, content_type: str = ContentType.LIVE) -> bool:
"""
Route to the VOD manager if content_id matches VOD patterns, rather than
guessing off a bare "_" in content_id (which breaks the moment a live
channel slug picks up an underscore).
VOD IDs: a_/b_/c_/d_ prefixed asset ids, "block-" lazy-block ids, or
browsable paths (contain "/") and block ids (contain ":") as used by
JoynVodManager._is_block_id / get_vod_category.
Live channel IDs are plain slugs, e.g. "sat1-de".
"""
if content_type == ContentType.VOD:
return True
return (
content_id.startswith(("a_", "b_", "c_", "d_", "block-")) or
"/" in content_id or
":" in content_id
)
# ============================================================================
# MANIFEST/PLAYBACK METHODS
# ============================================================================
@@ -234,9 +256,8 @@ class JoynProvider(StreamingProvider):
) -> Optional[str]:
"""
Get manifest URL - routes to VOD or Channel manager based on content_id.
Joyn VOD IDs contain an underscore (e.g., d_p203osk1gxp), Live IDs do not (e.g., sat1-de).
"""
if "_" in content_id or content_type == ContentType.VOD:
if self._is_vod_content(content_id, content_type):
return self.vod_manager.get_vod_manifest(content_id, video_config, **kwargs)
return self.channel_manager.get_manifest(
@@ -260,7 +281,7 @@ class JoynProvider(StreamingProvider):
"""
Get DRM configurations - routes to VOD or Channel manager based on content_id.
"""
if "_" in content_id or content_type == ContentType.VOD:
if self._is_vod_content(content_id, content_type):
return self.vod_manager.get_vod_drm(content_id, video_config, **kwargs)
return self.channel_manager.get_drm(
@@ -302,9 +323,12 @@ class JoynProvider(StreamingProvider):
return self.vod_manager.search(query, cursor, page_size, **kwargs)
def get_vod_item_details(self, content_id: str, **kwargs) -> Optional[Dict]:
# VodItem inherits from Content, which provides to_dict() — there is no
# to_vod_item() method, so the previous dataclasses.asdict(item.to_vod_item(...))
# call raised AttributeError on every invocation.
item = self.vod_manager.get_content_details(content_id, **kwargs)
if item:
return dataclasses.asdict(item.to_vod_item(self.provider_name, self.config.country))
return item.to_dict()
return None
def get_vod_manifest(
@@ -5,12 +5,13 @@ Joyn VOD Manager - Handles VOD catalogue operations via GraphQL
Supports deep navigation and authenticated requests
"""
import functools
import hashlib
import json
import re
import time
import urllib.parse
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
from typing import Any, Dict, List, Optional, Tuple, Union
from ...base.models import DRMConfig, DRMSystem, LicenseConfig
from ...base.models.content import ContentType, StreamingMode
@@ -44,7 +45,10 @@ VOD_GRAPHQL_HASHES = {
"SEASON": "ee2396bb1b7c9f800e5cefd0b341271b7213fceb4ebe18d5a30dab41d703009f",
"MOVIE_DETAIL": "9ae6bcd8c45a5e350438d1cc415a022fe053e938c93438509f60ae3abb425fa7",
"PLAYABLE_ASSET": "e2db6e6f9090f14848d3989920a1342f6813099c65ee7faef1e334f23e390970",
"SEARCH": "",
# Recovered from the legacy addon's const.py (SEARCH.HASH) — not captured
# independently from live traffic like the hashes above, so keep an eye on
# this if Joyn ever rotates persisted-query hashes.
"SEARCH": "bb2bab6cbe17321d7eddd5006e7f40765faedd79790b193a59d83f4640694856",
}
GRAPHQL_OPERATIONS = {
@@ -59,10 +63,48 @@ GRAPHQL_OPERATIONS = {
"SEASON": "Season",
"MOVIE_DETAIL": "PageMovieDetailStatic",
"PLAYABLE_ASSET": "PlayableAssetWithToken",
"SEARCH": "Search",
# Legacy const.py names this operation "SearchQ", not "Search".
"SEARCH": "SearchQ",
}
def ttl_cache(ttl_seconds: int = 300):
"""
Simple TTL cache decorator for methods.
Cache storage lives PER INSTANCE (in self.__dict__), not in the decorator's
closure. A closure-level cache dict would be shared across every
JoynVodManager instance (one per country/account), which would leak one
account's cached state into another's, and would also keep every instance
alive forever since the dict holds a strong reference to `self` as part of
the cache key.
A `force_refresh=True` kwarg bypasses the cached value for that call and
repopulates the cache, mirroring what the old `_get_cached_data` helper did.
"""
def decorator(func):
cache_attr = f"_ttl_cache_{func.__name__}"
@functools.wraps(func)
def wrapper(self, *args, **kwargs):
force_refresh = kwargs.pop("force_refresh", False)
cache: Dict[Any, Dict[str, Any]] = self.__dict__.setdefault(cache_attr, {})
key = (args, frozenset(kwargs.items()))
if not force_refresh:
cached = cache.get(key)
if cached and (time.time() - cached["timestamp"] < ttl_seconds):
return cached["data"]
data = func(self, *args, **kwargs)
cache[key] = {"timestamp": time.time(), "data": data}
return data
wrapper.cache_attr = cache_attr
return wrapper
return decorator
class JoynVodManager:
"""
Joyn VOD Manager using GraphQL API
@@ -109,15 +151,6 @@ class JoynVodManager:
# CACHING
# ========================================================================
def _get_cached_data(self, key: str, fetch_func: Callable, force_refresh: bool = False) -> Any:
if not force_refresh:
cached = self._cache.get(key)
if cached and (time.time() - cached["timestamp"] < self._cache_ttl):
return cached["data"]
data = fetch_func()
self._cache[key] = {"timestamp": time.time(), "data": data}
return data
@staticmethod
def _video_config_fingerprint(video_config: Optional[Dict]) -> str:
if not video_config:
@@ -203,32 +236,82 @@ class JoynVodManager:
def get_navigation_tree(self) -> List[Dict[str, Any]]:
return self.get_navigation().get("navigation", [])
def get_navigation_categories(self, parent_title: Optional[str] = None) -> List[VodCategory]:
# We hardcode a clean, user-friendly VOD root menu.
# This excludes Live TV (handled by channel_manager), removes duplicates,
# and groups Genres into their own clickable directories.
standard_pages = [
{"url": "/neu-beliebt", "title": "Neu & Beliebt"},
{"url": "/serien", "title": "Serien"},
{"url": "/filme", "title": "Filme"},
{"url": "/sport", "title": "Sport"},
{"url": "/news", "title": "News & Doku"},
{"url": "/mediatheken", "title": "Mediatheken"},
{"url": "/collections/sendung-im-tv-verpasst", "title": "Sendung im TV verpasst?"},
{"url": "/serien/genre", "title": "Serien Genres"},
{"url": "/filme/genre", "title": "Filme Genres"},
]
# Whitelist of root-menu paths we're willing to surface from the live
# Navigation API. Keeps Live TV (handled by channel_manager) and any
# unrelated blocks out of the VOD root menu, while picking up anything
# Joyn adds under these paths without a code change.
ALLOWED_NAV_PATHS = {
"/neu-beliebt", "/serien", "/filme", "/sport", "/news",
"/mediatheken", "/collections/sendung-im-tv-verpasst",
}
# Fallback used if the live Navigation response is empty or doesn't match
# the shape we expect (path/title on each entry). The exact shape of the
# top-level "navigation" list — unlike NAVIGATION's seriesGenre/movieGenre
# blocks, which get_genres_from_navigation() already parses from real
# traffic — hasn't been captured/verified here, so we fail open to this
# known-good static menu rather than risk an empty VOD root menu in
# production.
_STATIC_NAV_FALLBACK = [
{"url": "/neu-beliebt", "title": "Neu & Beliebt"},
{"url": "/serien", "title": "Serien"},
{"url": "/filme", "title": "Filme"},
{"url": "/sport", "title": "Sport"},
{"url": "/news", "title": "News & Doku"},
{"url": "/mediatheken", "title": "Mediatheken"},
{"url": "/collections/sendung-im-tv-verpasst", "title": "Sendung im TV verpasst?"},
]
def get_navigation_categories(self, parent_title: Optional[str] = None) -> List[VodCategory]:
categories: List[VodCategory] = []
nav_data = self.get_navigation()
nav_entries = nav_data.get("navigation", []) if isinstance(nav_data, dict) else []
for block in nav_entries:
if not isinstance(block, dict):
continue
path = block.get("path")
title = block.get("title", "")
if path in self.ALLOWED_NAV_PATHS:
categories.append(VodCategory(
content_id=path,
name=title or path,
description=title or path,
provider="joyn",
fetch_url=path,
details_url=path,
))
if not categories:
# Live response didn't match the expected shape (or the call
# failed upstream and returned {}) — fail open to the static menu
# instead of shipping an empty VOD root.
logger.warning(
"Joyn navigation response didn't yield any whitelisted categories; "
"falling back to the static VOD root menu"
)
for page in self._STATIC_NAV_FALLBACK:
categories.append(VodCategory(
content_id=page["url"],
name=page["title"],
description=page["title"],
provider="joyn",
fetch_url=page["url"],
details_url=page["url"],
))
# Genre directories are synthetic — Joyn's Navigation API doesn't list
# them as their own entries — so they're always appended.
categories.append(VodCategory(
content_id="/serien/genre", name="Serien Genres", description="Serien Genres",
provider="joyn", fetch_url="/serien/genre", details_url="/serien/genre",
))
categories.append(VodCategory(
content_id="/filme/genre", name="Filme Genres", description="Filme Genres",
provider="joyn", fetch_url="/filme/genre", details_url="/filme/genre",
))
categories = []
for page in standard_pages:
categories.append(VodCategory(
content_id=page["url"],
name=page["title"],
description=page["title"],
provider="joyn",
fetch_url=page["url"],
details_url=page["url"],
))
return categories
def get_genres_from_navigation(self, media_type: str = None) -> List[VodCategory]:
@@ -569,33 +652,33 @@ class JoynVodManager:
# USER STATE
# ========================================================================
def get_user_state(self, force_refresh: bool = False) -> Dict[str, Any]:
def fetch_state():
try:
url = self._build_graphql_url(
operation_name=self._operations["GET_ME_STATE"],
query_hash=self._query_hashes["GET_ME_STATE"],
variables={},
)
headers = self._get_graphql_headers(authenticated=True)
response = self.http_manager.get(url, operation="vod_user_state", headers=headers,
timeout=DEFAULT_REQUEST_TIMEOUT)
response.raise_for_status()
data = response.json()
if "errors" in data:
logger.warning(f"GraphQL errors in user state: {data['errors']}")
return {}
state = (data.get("data") or {}).get("me", {})
subs = state.get("subscriptionsData", {})
config = subs.get("config", {})
self._has_plus = config.get("hasActivePlus", False)
return state
except Exception as e:
logger.error(f"Error fetching user state: {e}")
@ttl_cache(ttl_seconds=300)
def get_user_state(self) -> Dict[str, Any]:
# Pass force_refresh=True to bypass the cache for one call; the decorator
# strips that kwarg before it reaches this function.
try:
url = self._build_graphql_url(
operation_name=self._operations["GET_ME_STATE"],
query_hash=self._query_hashes["GET_ME_STATE"],
variables={},
)
headers = self._get_graphql_headers(authenticated=True)
response = self.http_manager.get(url, operation="vod_user_state", headers=headers,
timeout=DEFAULT_REQUEST_TIMEOUT)
response.raise_for_status()
data = response.json()
if "errors" in data:
logger.warning(f"GraphQL errors in user state: {data['errors']}")
return {}
self._user_state = self._get_cached_data("user_state", fetch_state, force_refresh)
return self._user_state
state = (data.get("data") or {}).get("me", {})
subs = state.get("subscriptionsData", {})
config = subs.get("config", {})
self._has_plus = config.get("hasActivePlus", False)
self._user_state = state
return state
except Exception as e:
logger.error(f"Error fetching user state: {e}")
return {}
def has_plus_subscription(self) -> bool:
self.get_user_state()
@@ -655,29 +738,29 @@ class JoynVodManager:
def search(self, query: str, cursor: Optional[str] = None, page_size: int = 24, authenticated: bool = True,
**kwargs) -> Dict[str, Any]:
"""
Search the VOD catalogue. This is what provider.search_vod() calls — it
was missing entirely before, which meant that provider method raised
AttributeError on every call.
Search the VOD catalogue. This is what provider.search_vod() calls.
NOTE: VOD_GRAPHQL_HASHES["SEARCH"] is an empty placeholder — unlike every
other hash in this file, it was never captured from real Joyn traffic. The
variable shape (`query`/`first`/cursor field) and response shape
(`data.search.assets` vs `.results`, cursor field name) below are a
best-effort guess from the sibling queries, not verified against a real
request. Capture an actual `Search` request from the Joyn web client
(same way the other persisted-query hashes in this file were captured)
before relying on this in production. Until then this fails loudly
instead of silently sending a request that can't work.
Query hash, operation name ("SearchQ"), and variable names (`text` /
`first` / `offset`, offset as an int) come from the legacy addon's
const.py, not from a captured live request against this GraphQL
endpoint — the original modern implementation guessed `query` instead
of `text`, which is why search failed outright. If Joyn changes this
endpoint's shape, this is the first place to check.
"""
query_hash = self._query_hashes.get("SEARCH", "")
if not query_hash:
logger.error("Joyn SEARCH persisted query hash is not configured; refusing to send a broken request.")
return {"items": [], "cursor": None, "total": 0}
# Should not happen with the hash above in place; kept as a safe
# fallback so a future accidental removal fails loudly instead of
# sending a request that can't work.
raise NotImplementedError("Joyn SEARCH persisted query hash is not configured.")
try:
variables: Dict[str, Any] = {"query": query, "first": page_size}
if cursor:
variables["offset"] = cursor
offset = int(cursor) if cursor else 0
variables: Dict[str, Any] = {
"text": query,
"first": page_size,
"offset": offset,
}
url = self._build_graphql_url(
operation_name=self._operations["SEARCH"],
@@ -703,11 +786,18 @@ class JoynVodManager:
if item:
items.append(item)
# Joyn's search endpoint appears to page by numeric offset rather
# than returning its own cursor token; if we got a full page back,
# assume there may be more and advance by what we consumed.
next_cursor = str(offset + len(assets)) if len(assets) == page_size else None
return {
"items": items,
"cursor": result.get("nextCursor") or result.get("cursor"),
"cursor": next_cursor,
"total": result.get("total", len(items)),
}
except NotImplementedError:
raise
except Exception as e:
logger.error(f"Error searching VOD for query='{query}': {e}")
return {"items": [], "cursor": None, "total": 0}
@@ -933,19 +1023,18 @@ class JoynVodManager:
def get_content_details(self, content_id: str, authenticated: bool = True, **kwargs) -> Optional[VodItem]:
"""
Fetch full details for a single playable item (movie or episode) by its
b_/c_/d_/a_ id. This is what provider.get_vod_item_details() calls — it
was missing entirely before, which meant that provider method raised
AttributeError on every call.
NOTE: provider.get_vod_item_details() calls `.to_vod_item(provider_name,
country)` on whatever this returns. I don't have base/models/vod.py in
front of me, so I can't confirm VodItem exposes that method — this builds
a VodItem the same way _parse_content_asset/_parse_episode_asset already
do elsewhere in this file. If VodItem doesn't implement `.to_vod_item()`,
that call in provider.py will still raise — worth checking the base model
before shipping.
Fetch full details for a single playable item by its b_/c_/d_ id.
This is what provider.get_vod_item_details() calls — that provider
method uses VodItem.to_dict() (inherited from Content) on whatever
this returns, since VodItem has no separate to_vod_item() method.
"""
# PlayableAssetWithToken expects a catalog ID (b_, c_, d_). It returns
# an empty asset for a video ID (a_), which would otherwise surface as
# a misleading "No asset data found" warning below.
if content_id.startswith("a_"):
logger.debug(f"Skipping PlayableAssetWithToken for video ID {content_id}")
return None
asset = self.get_playable_asset(content_id, authenticated=authenticated)
if not asset:
logger.warning(f"No asset data found for content_id={content_id}")
@@ -1079,5 +1168,6 @@ class JoynVodManager:
def clear_cache(self):
self._cache.clear()
self.__dict__.pop(type(self).get_user_state.cache_attr, None)
self._user_state = None
logger.debug("VOD cache cleared")