diff --git a/.idea/misc.xml b/.idea/misc.xml
index ab32451..8fd82f6 100644
--- a/.idea/misc.xml
+++ b/.idea/misc.xml
@@ -3,5 +3,5 @@
-
+
\ No newline at end of file
diff --git a/.idea/script.service.ultimate.iml b/.idea/script.service.ultimate.iml
index f741041..979d44d 100644
--- a/.idea/script.service.ultimate.iml
+++ b/.idea/script.service.ultimate.iml
@@ -5,8 +5,10 @@
+
+
-
+
diff --git a/lib/streaming_providers/providers/joyn/auth.py b/lib/streaming_providers/providers/joyn/auth.py
index 2f9b178..c593aca 100644
--- a/lib/streaming_providers/providers/joyn/auth.py
+++ b/lib/streaming_providers/providers/joyn/auth.py
@@ -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)
diff --git a/lib/streaming_providers/providers/joyn/channel_manager.py b/lib/streaming_providers/providers/joyn/channel_manager.py
index d0e67c6..0f0cc87 100644
--- a/lib/streaming_providers/providers/joyn/channel_manager.py
+++ b/lib/streaming_providers/providers/joyn/channel_manager.py
@@ -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}"
diff --git a/lib/streaming_providers/providers/joyn/provider.py b/lib/streaming_providers/providers/joyn/provider.py
index fe995c4..a1c92f1 100644
--- a/lib/streaming_providers/providers/joyn/provider.py
+++ b/lib/streaming_providers/providers/joyn/provider.py
@@ -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(
diff --git a/lib/streaming_providers/providers/joyn/vod_manager.py b/lib/streaming_providers/providers/joyn/vod_manager.py
index 2aa5f3e..431677a 100644
--- a/lib/streaming_providers/providers/joyn/vod_manager.py
+++ b/lib/streaming_providers/providers/joyn/vod_manager.py
@@ -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")
\ No newline at end of file