mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-09-16 14:12:20 +02:00
joyn: vod - more improvements
This commit is contained in:
Generated
+1
-1
@@ -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>
|
||||
Generated
+3
-1
@@ -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">
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user