From c327a55844731d8ce8a27de53f3e8fcf8367c052 Mon Sep 17 00:00:00 2001 From: Nirvana Date: Thu, 10 Sep 2026 21:28:56 +0200 Subject: [PATCH] joyn: vod - more improvements --- .idea/misc.xml | 2 +- .idea/script.service.ultimate.iml | 4 +- .../providers/joyn/auth.py | 20 +- .../providers/joyn/channel_manager.py | 74 ++--- .../providers/joyn/provider.py | 34 ++- .../providers/joyn/vod_manager.py | 276 ++++++++++++------ 6 files changed, 270 insertions(+), 140 deletions(-) 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