diff --git a/lib/streaming_providers/providers/joyn/vod_manager.py b/lib/streaming_providers/providers/joyn/vod_manager.py index a49bb77..b23290e 100644 --- a/lib/streaming_providers/providers/joyn/vod_manager.py +++ b/lib/streaming_providers/providers/joyn/vod_manager.py @@ -23,7 +23,6 @@ from .constants import ( ) from .models import PlaybackRestrictedException - # ============================================================================ # GraphQL Query Hashes for VOD # ============================================================================ @@ -36,6 +35,9 @@ VOD_GRAPHQL_HASHES = { "LIVE_LANE": "51659c62d4e4a6628d1e512190a3b0659486478b12be494875bef5a83dcb79ed", "HERO_RESUME": "d3b7e480f593ba4866598f8cfe95185b3e400fcff112c026dc4e1b5ad4b0d537", "COLLECTION_QUERY": "bdf4e08de65351750eefb2165a58af50c9e4b3526b78cd75e2066df2bc7ec8d8", + # --- NEW: captured from browser network tab --- + "PAGE_OVERVIEW_GENRE": "37ba6d0dde470df3f8999d49bcd24bc5c72b8e7192768026d82447c664c6ab7f", + "SEASON": "ee2396bb1b7c9f800e5cefd0b341271b7213fceb4ebe18d5a30dab41d703009f", # TODO: Capture these hashes from network tab to enable Search and Details "SEARCH": "", "CONTENT_DETAILS": "", @@ -49,6 +51,9 @@ GRAPHQL_OPERATIONS = { "LIVE_LANE": "LiveLane", "HERO_RESUME": "HeroLandingResumePositionsWithToken", "COLLECTION_QUERY": "PageOverviewCollectionQuery", + # --- NEW --- + "PAGE_OVERVIEW_GENRE": "PageOverviewGenre", + "SEASON": "Season", "SEARCH": "Search", "CONTENT_DETAILS": "PageDetail", } @@ -72,9 +77,9 @@ class JoynVodManager: logger.info(f"[JoynVodManager] Initialised for country={provider.country}") - # ============================================================================ - # PROPERTIES - Delegate to provider - # ============================================================================ + # ======================================================================== + # PROPERTIES + # ======================================================================== @property def http_manager(self): @@ -96,107 +101,98 @@ class JoynVodManager: def implements_vod(self) -> bool: return True - # ============================================================================ - # CACHING HELPER - # ============================================================================ + # ======================================================================== + # CACHING + # ======================================================================== def _get_cached_data(self, key: str, fetch_func: Callable, force_refresh: bool = False) -> Any: - """Generic TTL cache wrapper.""" 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: - """ - Stable short hash of video_config so the manifest/DRM cache can't - return one config's result for a different config's request. - """ if not video_config: return "default" normalized = json.dumps(video_config, sort_keys=True, separators=(",", ":")) return hashlib.sha1(normalized.encode("utf-8")).hexdigest()[:12] - # ============================================================================ - # HEADER BUILDING - Matched exactly to web logs - # ============================================================================ + # ======================================================================== + # HEADER / URL BUILDING + # ======================================================================== def _get_graphql_headers(self, authenticated: bool = False) -> Dict[str, str]: - """Get headers for GraphQL requests.""" - # Use the base headers from constants to avoid magic string duplication headers = JOYN_GRAPHQL_BASE_HEADERS.copy() headers.update({ "joyn-client-version": JOYN_CLIENT_VERSION, "joyn-country": self.country.upper(), "joyn-distribution-tenant": self.distribution_tenant, "joyn-platform": self.platform, - # R_A = Registered Account (logged in), A_A = Anonymous Account "joyn-user-state": "code=R_A" if authenticated else "code=A_A", }) - if authenticated and self.provider.bearer_token: headers["Authorization"] = f"Bearer {self.provider.bearer_token}" - return headers @staticmethod - def _build_graphql_url( - operation_name: str, - query_hash: str, - variables: Optional[Dict] = None, - ) -> str: - """Build GraphQL URL with persisted query""" + def _build_graphql_url(operation_name: str, query_hash: str, variables: Optional[Dict] = None) -> str: base_url = "https://api.joyn.de/graphql" - params = { "operationName": operation_name, "enable_user_location": "true", "watch_assistant_variant": "true", } - if variables: params["variables"] = json.dumps(variables, separators=(',', ':')) - - extensions = { - "persistedQuery": { - "version": 1, - "sha256Hash": query_hash, - } - } + extensions = {"persistedQuery": {"version": 1, "sha256Hash": query_hash}} params["extensions"] = json.dumps(extensions, separators=(',', ':')) - - query_string = "&".join( - f"{key}={urllib.parse.quote(value)}" - for key, value in params.items() - ) - + query_string = "&".join(f"{k}={urllib.parse.quote(v)}" for k, v in params.items()) return f"{base_url}?{query_string}" - # ============================================================================ - # NAVIGATION / DEEP TREE - # ============================================================================ + # ======================================================================== + # PATH / ID CLASSIFICATION HELPERS + # ======================================================================== + + @staticmethod + def _is_block_id(content_id: str) -> bool: + return ":" in content_id or content_id.startswith("block-") + + @staticmethod + def _is_season_id(content_id: str) -> bool: + """Season IDs start with c_ (e.g. c_p0f8glcsxkb).""" + return content_id.startswith("c_") + + @staticmethod + def _is_genre_path(path: str) -> bool: + return "/genre/" in path + + @staticmethod + def _is_series_path(path: str) -> bool: + return path.startswith("/serien/") and "/genre/" not in path + + @staticmethod + def _is_movie_path(path: str) -> bool: + return path.startswith("/filme/") and "/genre/" not in path + + # ======================================================================== + # NAVIGATION + # ======================================================================== def get_navigation(self) -> Dict[str, Any]: - """Get the main navigation structure""" try: url = self._build_graphql_url( operation_name=self._operations["NAVIGATION"], query_hash=self._query_hashes["NAVIGATION"], ) headers = self._get_graphql_headers(authenticated=False) - - response = self.http_manager.get( - url, operation="vod_navigation", headers=headers, timeout=DEFAULT_REQUEST_TIMEOUT - ) + response = self.http_manager.get(url, operation="vod_navigation", headers=headers, + timeout=DEFAULT_REQUEST_TIMEOUT) response.raise_for_status() - # FIX: Use `or {}` to handle {"data": null} safely return response.json().get("data") or {} - except Exception as e: logger.error(f"Error fetching navigation: {e}") return {} @@ -237,7 +233,6 @@ class JoynVodManager: fetch_url=url, details_url=url, )) - return categories def get_mediatheken_brands(self) -> List[VodCategory]: @@ -262,36 +257,145 @@ class JoynVodManager: logger.error(f"Error fetching mediatheken: {e}") return [] - # ============================================================================ - # COLLECTION QUERY - Paginated Browsing - # ============================================================================ + # ======================================================================== + # GENRE PAGE (NEW — uses PageOverviewGenre, not LandingPageClient) + # ======================================================================== - def get_collection( - self, block_id: str, first: int = 32, offset: int = 0, authenticated: bool = True + def get_genre_page( + self, path: str, first: int = 32, offset: int = 0, authenticated: bool = True ) -> Dict[str, Any]: + """Fetch a genre overview page using the PageOverviewGenre operation.""" + try: + if not path.startswith("/"): + path = f"/{path}" + + variables = {"first": first, "path": path, "offset": offset} + url = self._build_graphql_url( + operation_name=self._operations["PAGE_OVERVIEW_GENRE"], + query_hash=self._query_hashes["PAGE_OVERVIEW_GENRE"], + variables=variables, + ) + headers = self._get_graphql_headers(authenticated=authenticated) + response = self.http_manager.get(url, operation="vod_genre_page", headers=headers, + timeout=DEFAULT_REQUEST_TIMEOUT) + response.raise_for_status() + data = response.json() + + if "errors" in data: + logger.warning(f"GraphQL errors in genre page: {data['errors']}") + return {} + + return (data.get("data") or {}).get("page", {}) + except Exception as e: + logger.error(f"Error fetching genre page {path}: {e}") + return {} + + def _get_genre_items( + self, path: str, authenticated: bool = True, **kwargs + ) -> List[Union[VodCategory, VodItem]]: + page = self.get_genre_page( + path=path, + authenticated=authenticated, + first=kwargs.get("first", 32), + offset=kwargs.get("offset", 0), + ) + items: List[Union[VodCategory, VodItem]] = [] + for block in page.get("blocks", []): + for asset in block.get("assets", []): + item = self._parse_asset(asset) + if item: + items.append(item) + return items + + # ======================================================================== + # SEASON EPISODES (NEW — uses Season operation) + # ======================================================================== + + def get_season_episodes( + self, + season_id: str, + first: int = 20, + offset: int = 0, + license_filter: str = "FREE", + authenticated: bool = True, + ) -> Dict[str, Any]: + """Fetch episodes for a season using the Season operation.""" try: variables = { - "first": first, "offset": offset, "blockId": block_id, - "hasToken": authenticated and bool(self.provider.bearer_token), + "id": season_id, + "first": first, + "licenseFilter": license_filter, + "offset": offset, } + url = self._build_graphql_url( + operation_name=self._operations["SEASON"], + query_hash=self._query_hashes["SEASON"], + variables=variables, + ) + headers = self._get_graphql_headers(authenticated=authenticated) + response = self.http_manager.get(url, operation="vod_season", headers=headers, + timeout=DEFAULT_REQUEST_TIMEOUT) + response.raise_for_status() + data = response.json() + + if "errors" in data: + logger.warning(f"GraphQL errors in season query: {data['errors']}") + return {} + + return (data.get("data") or {}).get("season", {}) + except Exception as e: + logger.error(f"Error fetching season {season_id}: {e}") + return {} + + def _get_season_items( + self, season_id: str, authenticated: bool = True, **kwargs + ) -> List[Union[VodCategory, VodItem]]: + """Browse episodes of a season. Fetches both FREE and SVOD, deduplicates.""" + all_episodes: List[VodItem] = [] + seen_ids: set = set() + + for lic_filter in ["FREE", "SVOD"]: + season_data = self.get_season_episodes( + season_id=season_id, + license_filter=lic_filter, + authenticated=authenticated, + first=kwargs.get("first", 20), + offset=kwargs.get("offset", 0), + ) + for ep in season_data.get("episodes", []): + ep_id = ep.get("id") + if ep_id and ep_id not in seen_ids: + seen_ids.add(ep_id) + item = self._parse_episode_asset(ep) + if item: + all_episodes.append(item) + + # Sort by episode number + all_episodes.sort(key=lambda x: (x.episode_number or 9999)) + return all_episodes + + # ======================================================================== + # COLLECTION QUERY + # ======================================================================== + + def get_collection(self, block_id: str, first: int = 32, offset: int = 0, authenticated: bool = True) -> Dict[ + str, Any]: + try: + variables = {"first": first, "offset": offset, "blockId": block_id, + "hasToken": authenticated and bool(self.provider.bearer_token)} url = self._build_graphql_url( operation_name=self._operations["COLLECTION_QUERY"], query_hash=self._query_hashes["COLLECTION_QUERY"], variables=variables, ) headers = self._get_graphql_headers(authenticated=authenticated) - - response = self.http_manager.get( - url, operation="vod_collection", headers=headers, timeout=DEFAULT_REQUEST_TIMEOUT - ) + response = self.http_manager.get(url, operation="vod_collection", headers=headers, + timeout=DEFAULT_REQUEST_TIMEOUT) response.raise_for_status() data = response.json() - if "errors" in data: logger.warning(f"GraphQL errors in collection query: {data['errors']}") return {"assets": [], "total": 0} - - # FIX: Use `or {}` to handle {"data": null} safely block = (data.get("data") or {}).get("block", {}) return { "assets": block.get("assets", []), @@ -304,9 +408,8 @@ class JoynVodManager: logger.error(f"Error fetching collection {block_id}: {e}") return {"assets": [], "total": 0} - def get_collection_items( - self, block_id: str, first: int = 32, offset: int = 0, authenticated: bool = True - ) -> List[Union[VodCategory, VodItem]]: + def get_collection_items(self, block_id: str, first: int = 32, offset: int = 0, authenticated: bool = True) -> List[ + Union[VodCategory, VodItem]]: result = self.get_collection(block_id, first, offset, authenticated) items = [] for asset in result.get("assets", []): @@ -315,18 +418,15 @@ class JoynVodManager: items.append(item) return items - # ============================================================================ - # LANDING PAGE & GENRES - # ============================================================================ + # ======================================================================== + # LANDING PAGE + # ======================================================================== - def get_landing_page( - self, path: str = "/neu-beliebt", variation: str = "Default", authenticated: bool = True - ) -> Dict[str, Any]: + def get_landing_page(self, path: str = "/neu-beliebt", variation: str = "Default", authenticated: bool = True) -> \ + Dict[str, Any]: try: - # FIX: Ensure path starts with '/' if not path.startswith("/"): path = f"/{path}" - variables = {"path": path, "variation": variation} url = self._build_graphql_url( operation_name=self._operations["LANDING_PAGE"], @@ -334,18 +434,13 @@ class JoynVodManager: variables=variables, ) headers = self._get_graphql_headers(authenticated=authenticated) - - response = self.http_manager.get( - url, operation="vod_landing_page", headers=headers, timeout=DEFAULT_REQUEST_TIMEOUT - ) + response = self.http_manager.get(url, operation="vod_landing_page", headers=headers, + timeout=DEFAULT_REQUEST_TIMEOUT) response.raise_for_status() data = response.json() - if "errors" in data: logger.warning(f"GraphQL errors in landing page: {data['errors']}") return {} - - # FIX: Use `or {}` to handle {"data": null} safely return (data.get("data") or {}).get("page", {}) except Exception as e: logger.error(f"Error fetching landing page {path}: {e}") @@ -360,23 +455,19 @@ class JoynVodManager: variables=variables, ) headers = self._get_graphql_headers(authenticated=authenticated) - - response = self.http_manager.get( - url, operation="vod_blocks", headers=headers, timeout=DEFAULT_REQUEST_TIMEOUT - ) + response = self.http_manager.get(url, operation="vod_blocks", headers=headers, + timeout=DEFAULT_REQUEST_TIMEOUT) response.raise_for_status() return response.json().get("data", {}) except Exception as e: logger.error(f"Error fetching landing blocks: {e}") return {} - # ============================================================================ - # USER STATE / SUBSCRIPTION - # ============================================================================ + # ======================================================================== + # USER STATE + # ======================================================================== def get_user_state(self, force_refresh: bool = False) -> Dict[str, Any]: - """Get user state including subscription status, utilizing TTL cache.""" - def fetch_state(): try: url = self._build_graphql_url( @@ -385,19 +476,14 @@ class JoynVodManager: 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 = 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 {} - - # FIX: Use `or {}` to handle {"data": null} safely state = (data.get("data") or {}).get("me", {}) - subs = state.get("subscriptionsData", {}) config = subs.get("config", {}) self._has_plus = config.get("hasActivePlus", False) @@ -417,17 +503,19 @@ class JoynVodManager: state = self.get_user_state() return state.get("state", "code=R_A") - # ============================================================================ - # VOD CATEGORY - Main entry point for browsing - # ============================================================================ + # ======================================================================== + # VOD CATEGORY — MAIN ENTRY POINT (FIXED ROUTING) + # ======================================================================== def get_vod_category( - self, content_id: str = "", authenticated: bool = True, **kwargs + self, content_id: str = "", authenticated: bool = True, **kwargs ) -> List[Union[VodCategory, VodItem]]: try: + # --- Root: show navigation categories --- if not content_id or content_id == "/": return self.get_navigation_categories() + # --- Block ID (paginated collection) --- if self._is_block_id(content_id): return self.get_collection_items( block_id=content_id, @@ -436,25 +524,35 @@ class JoynVodManager: authenticated=authenticated, ) - return self._get_page_items(content_id, authenticated, **kwargs) + # --- Season ID (starts with c_) → fetch episodes --- + if self._is_season_id(content_id): + return self._get_season_items(content_id, authenticated, **kwargs) + + # --- Normalize path --- + path = content_id if content_id.startswith("/") else f"/{content_id}" + + # --- Genre page (uses PageOverviewGenre, NOT LandingPageClient) --- + if self._is_genre_path(path): + return self._get_genre_items(path, authenticated, **kwargs) + + # --- Series / Movie / Collection landing page --- + return self._get_page_items(path, authenticated, **kwargs) except Exception as e: logger.error(f"Error getting VOD category: {e}") return [] - @staticmethod - def _is_block_id(content_id: str) -> bool: - """Joyn block IDs are formatted as 'page_id:hash' (e.g., '411:abc123def...').""" - return ":" in content_id or content_id.startswith("block-") + # ======================================================================== + # PAGE ITEMS PARSER (FIXED — handles blocks without __typename) + # ======================================================================== def _get_page_items( self, path: str, authenticated: bool = True, **kwargs ) -> List[Union[VodCategory, VodItem]]: - # FIX: Normalize path to start with '/' as Joyn's GraphQL router requires it if not path.startswith("/"): path = f"/{path}" page = self.get_landing_page(path=path, authenticated=authenticated) - items = [] + items: List[Union[VodCategory, VodItem]] = [] for block in page.get("blocks", []): block_type = block.get("__typename") @@ -463,8 +561,10 @@ class JoynVodManager: if block_type == "StandardLane" and block_id: if kwargs.get("fetch_more", False): items.extend(self.get_collection_items( - block_id=block_id, first=kwargs.get("first", 32), - offset=kwargs.get("offset", 0), authenticated=authenticated, + block_id=block_id, + first=kwargs.get("first", 32), + offset=kwargs.get("offset", 0), + authenticated=authenticated, )) else: for asset in block.get("assets", []): @@ -489,11 +589,20 @@ class JoynVodManager: provider="joyn", fetch_url=asset.get("path"), )) + + # --- NEW: handle blocks WITHOUT __typename (genre overview pages, + # series detail pages, etc.) — just parse assets directly --- + elif not block_type and block.get("assets"): + for asset in block.get("assets", []): + item = self._parse_asset(asset) + if item: + items.append(item) + return items - # ============================================================================ + # ======================================================================== # ASSET PARSING - # ============================================================================ + # ======================================================================== def _parse_asset(self, asset: Dict[str, Any]) -> Optional[Union[VodCategory, VodItem]]: typename = asset.get("__typename") @@ -504,6 +613,16 @@ class JoynVodManager: return self._parse_content_asset(asset) elif typename == "Episode": return self._parse_episode_asset(asset) + elif typename == "Season": + # --- NEW: parse Season as a browseable category --- + return VodCategory( + content_id=asset_id, + name=f"Staffel {asset.get('number', '')}".strip(), + logo_url=asset.get("primaryImage", {}).get("url") or asset.get("iconicImage", {}).get("url"), + description=asset.get("title", title), + provider="joyn", + fetch_url=asset_id, + ) elif typename == "Brand": return VodCategory( content_id=asset_id, name=title, @@ -526,14 +645,15 @@ class JoynVodManager: return self._parse_asset(asset_data) return None + # ======================================================================== + # CONTENT ASSET PARSER (FIXED — series use path, movies use video ID) + # ======================================================================== + def _parse_content_asset(self, asset: Dict[str, Any]) -> Optional[VodItem]: - """Parse Series or Movie asset into VodItem, mapping fields explicitly.""" + typename = asset.get("__typename", "") asset_id = asset.get("id", "") title = asset.get("title", "Unknown") - - # NOTE: "path" (asset.get("path", "")) is intentionally not attached below — - # VodItem has no field to hold it (see accompanying note). Restore it here - # once a field exists on VodItem, rather than passing an invalid kwarg. + path = asset.get("path", "") primary_image = asset.get("primaryImage", {}) hero_portrait = asset.get("heroPortrait", {}) @@ -547,23 +667,32 @@ class JoynVodManager: is_free = "AVOD" in license_types or "FREE" in license_types is_premium = "SVOD" in license_types or "PLUS" in license_types + # --- FIX: For Series, use the path as content_id so the user can + # browse into it (get_vod_category receives the path). + # For Movies, use the video ID if available (for direct playback). + if typename == "Series": + content_id = path or asset_id + else: + # Movie — prefer video ID for direct playback + video_id = asset.get("video", {}).get("id") + content_id = video_id or asset_id + item = VodItem( name=title, - content_id=asset_id, + content_id=content_id, provider="joyn", logo_url=image_url, mode=StreamingMode.VOD, - content_type=ContentType.SERIES if asset.get("__typename") == "Series" else ContentType.MOVIE, + content_type=ContentType.SERIES if typename == "Series" else ContentType.MOVIE, description=asset.get("description", ""), country=self.country, duration_seconds=asset.get("duration") or asset.get("video", {}).get("duration"), genres=genres or None, genre=genres[0] if genres else None, - series_title=title if asset.get("__typename") == "Series" else None, + series_title=title if typename == "Series" else None, rating=f"FSK {min_age}" if min_age is not None else None, ) - # Utilize Content's pricing model instead of a metadata dict if is_free: item.set_free() elif is_premium: @@ -571,33 +700,49 @@ class JoynVodManager: return item + # ======================================================================== + # EPISODE ASSET PARSER (FIXED — correct field names, video ID as content_id) + # ======================================================================== + def _parse_episode_asset(self, asset: Dict[str, Any]) -> Optional[VodItem]: - """Parse Episode asset into VodItem, mapping fields explicitly.""" episode_id = asset.get("id", "") title = asset.get("title", "Unknown Episode") series_data = asset.get("series", {}) + season_data = asset.get("season", {}) + video_data = asset.get("video", {}) - # NOTE: "path" (asset.get("path", "")) is intentionally not attached below — - # same reason as in _parse_content_asset above. + # --- FIX: Use video ID (a_…) as content_id for playback. + # The entitlement API requires a video/asset ID, not an episode ID. + video_id = video_data.get("id", "") + content_id = video_id or episode_id genres = [g.get("name", "") for g in asset.get("genres", []) if g.get("name")] license_types = asset.get("licenseTypes", []) is_free = "AVOD" in license_types or "FREE" in license_types is_premium = "SVOD" in license_types or "PLUS" in license_types + # --- FIX: field is "number", not "episodeNumber" --- + ep_number = asset.get("number") + season_number = season_data.get("seasonNumber") + + description = "" + if series_data.get("title"): + description = f"Staffel {season_number}, Episode {ep_number} – {title}" + item = VodItem( name=title, - content_id=episode_id, + content_id=content_id, provider="joyn", mode=StreamingMode.VOD, content_type=ContentType.SERIES, - description=f"Episode {asset.get('episodeNumber')} of {series_data.get('title')}" if series_data.get("title") else "", + description=description, country=self.country, - duration_seconds=asset.get("video", {}).get("duration"), + logo_url=asset.get("primaryImage", {}).get("url"), + duration_seconds=video_data.get("duration"), genres=genres or None, genre=genres[0] if genres else None, - season_number=asset.get("season", {}).get("seasonNumber"), - episode_number=asset.get("episodeNumber"), + season_number=season_number, + episode_number=ep_number, series_id=series_data.get("id"), series_title=series_data.get("title"), ) @@ -609,31 +754,21 @@ class JoynVodManager: return item - # ============================================================================ - # SEARCH & DETAILS (Stubs - require unmapped GraphQL hashes) - # ============================================================================ + # ======================================================================== + # SEARCH & DETAILS (stubs) + # ======================================================================== def search(self, query: str, cursor: Optional[str] = None, page_size: int = 24, **kwargs) -> Dict[str, Any]: - """ - Search VOD catalogue. - TODO: The REST API is deprecated. We must capture the GraphQL Search hash - from the network tab to activate this. - """ logger.warning("Search GraphQL hash not configured. Search is currently disabled.") return {"items": [], "next_cursor": None, "total": 0} def get_content_details(self, content_id: str, authenticated: bool = True) -> Optional[Dict[str, Any]]: - """ - Get detailed metadata for a VOD item. - TODO: The REST API is deprecated. We must capture the GraphQL PageDetail hash - from the network tab to activate this. - """ logger.warning("Content Details GraphQL hash not configured. Details fetching is currently disabled.") return None - # ============================================================================ - # VOD PLAYBACK METHODS (With Caching) - # ============================================================================ + # ======================================================================== + # VOD PLAYBACK (FIXED — validates content_id is a video ID) + # ======================================================================== def get_vod_manifest(self, content_id: str, video_config: Optional[Dict] = None, **kwargs) -> Optional[str]: result = self._get_vod_manifest_and_drm( @@ -648,7 +783,7 @@ class JoynVodManager: return result.get("drm_configs", []) if result else [] def get_vod_manifest_with_headers( - self, content_id: str, video_config: Optional[Dict] = None, **kwargs + self, content_id: str, video_config: Optional[Dict] = None, **kwargs ) -> Tuple[Optional[str], Dict[str, str]]: result = self._get_vod_manifest_and_drm( content_id, video_config, max_retries=kwargs.get("max_retries", DEFAULT_MAX_RETRIES) @@ -662,18 +797,70 @@ class JoynVodManager: return result["manifest_url"], headers return None, {} + def _resolve_video_id(self, content_id: str) -> Optional[str]: + """ + Resolve any content ID to a video/asset ID (a_…) for playback. + + ID prefix convention (Joyn): + a_ = video / asset (what the entitlement API expects) + b_ = episode + c_ = season + d_ = series + """ + if content_id.startswith("a_"): + return content_id + + if content_id.startswith("b_"): + # Episode ID — would need an episode detail query to resolve. + # In practice, _parse_episode_asset already stores the video ID (a_) + # as content_id, so this path should not be hit. + logger.error( + f"Episode ID '{content_id}' passed to manifest. " + "The episode parser should have used the video ID instead." + ) + return None + + if content_id.startswith("d_"): + logger.error( + f"Series ID '{content_id}' passed to manifest. " + "Series are not directly playable — browse to an episode first." + ) + return None + + if content_id.startswith("c_"): + logger.error( + f"Season ID '{content_id}' passed to manifest. " + "Seasons are not directly playable — browse to an episode first." + ) + return None + + # Paths or unknown formats + logger.error(f"Cannot resolve '{content_id}' to a video ID.") + return None + def _get_vod_manifest_and_drm( - self, - content_id: str, - video_config: Optional[Dict] = None, - max_retries: int = DEFAULT_MAX_RETRIES, + self, + content_id: str, + video_config: Optional[Dict] = None, + max_retries: int = DEFAULT_MAX_RETRIES, ) -> Optional[Dict[str, Any]]: """ - Fetch manifest and DRM config. Cached for 5 minutes to prevent - duplicate round trips when manifest and DRM are requested sequentially - for the *same* content_id + video_config combination. + Fetch manifest and DRM config. + + CRITICAL: content_id must be a video/asset ID (starts with a_). + Series IDs (d_), season IDs (c_), and episode IDs (b_) are NOT accepted + by the entitlement API and will cause 'ENT_ASSET_NOT_AVAILABLE' errors. """ - cache_key = f"vod_playlist_{content_id}_{self._video_config_fingerprint(video_config)}" + # --- FIX: Resolve to video ID before anything else --- + video_id = self._resolve_video_id(content_id) + if not video_id: + logger.error( + f"VOD manifest aborted for '{content_id}': not a valid video ID. " + "Pass an episode's video ID (a_…) instead." + ) + return None + + cache_key = f"vod_playlist_{video_id}_{self._video_config_fingerprint(video_config)}" cached = self._cache.get(cache_key) if cached and (time.time() - cached["timestamp"] < self._cache_ttl): return cached["data"] @@ -685,12 +872,12 @@ class JoynVodManager: for attempt in range(max_retries): try: entitlement_token = self.provider.channel_manager.get_entitlement_token( - content_id=content_id, content_type="VOD" + content_id=video_id, content_type="VOD" ) video_payload = create_video_payload(video_config) signature = build_signature(entitlement_token, video_payload) - url = f"https://api.vod-prd.s.joyn.de/v1/vod/{content_id}/playlist?signature={signature}" + url = f"https://api.vod-prd.s.joyn.de/v1/vod/{video_id}/playlist?signature={signature}" headers = { "Authorization": f"Bearer {entitlement_token}", "Accept": "application/json", @@ -735,23 +922,21 @@ class JoynVodManager: else: result["drm_configs"] = [] - # Cache the successful result self._cache[cache_key] = {"timestamp": time.time(), "data": result} return result except PlaybackRestrictedException as e: - logger.warning(f"VOD playback restricted for {content_id}: {e}") + logger.warning(f"VOD playback restricted for {video_id}: {e}") return None except Exception as e: logger.warning(f"VOD attempt {attempt + 1}/{max_retries} failed: {e}") if attempt < max_retries - 1: time.sleep(1) - logger.error(f"VOD failed for {content_id} after {max_retries} attempts") + logger.error(f"VOD failed for {video_id} after {max_retries} attempts") return None def clear_cache(self): - """Clear all cached data""" self._cache.clear() self._user_state = None logger.debug("VOD cache cleared") \ No newline at end of file