Files
script.service.ultimate/lib/streaming_providers/providers/joyn/vod_manager.py
T
2026-09-10 21:28:56 +02:00

1173 lines
50 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# streaming_providers/providers/joyn/vod_manager.py
# -*- coding: utf-8 -*-
"""
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, Dict, List, Optional, Tuple, Union
from ...base.models import DRMConfig, DRMSystem, LicenseConfig
from ...base.models.content import ContentType, StreamingMode
from ...base.models.vod import VodCategory, VodItem
from ...base.utils.logger import logger
from .channel_manager import build_signature, create_video_payload
from .constants import (
DEFAULT_MAX_RETRIES,
DEFAULT_REQUEST_TIMEOUT,
DRM_REQUEST_HEADERS,
JOYN_CLIENT_VERSION,
JOYN_GRAPHQL_BASE_HEADERS,
JOYN_GRAPHQL_BASE_URL,
JOYN_USER_AGENT,
)
from .models import PlaybackRestrictedException, SubscriptionRequiredException
# ============================================================================
# GraphQL Query Hashes for VOD
# ============================================================================
VOD_GRAPHQL_HASHES = {
"NAVIGATION": "818622ff5afe143664241034ba0c537650ec9a2eeae4d568830b47d8de605b7e",
"LANDING_PAGE": "5838ac6b328cda91a70da264d5be09d4735cf95de8d9ee00cec60e77fcc3df08",
"LANDING_BLOCKS": "1655591f83b0dc1508ad4d52c5f37f72d410f48ad08c3e5f2de8622f86a21c68",
"GET_ME_STATE": "55ebb3812b45628017ee6c7f36f0b88a94e9778b9f11ad8a6fc05849182c07ec",
"LIVE_LANE": "51659c62d4e4a6628d1e512190a3b0659486478b12be494875bef5a83dcb79ed",
"HERO_RESUME": "d3b7e480f593ba4866598f8cfe95185b3e400fcff112c026dc4e1b5ad4b0d537",
"COLLECTION_QUERY": "bdf4e08de65351750eefb2165a58af50c9e4b3526b78cd75e2066df2bc7ec8d8",
"PAGE_OVERVIEW_GENRE": "37ba6d0dde470df3f8999d49bcd24bc5c72b8e7192768026d82447c664c6ab7f",
"SEASON": "ee2396bb1b7c9f800e5cefd0b341271b7213fceb4ebe18d5a30dab41d703009f",
"MOVIE_DETAIL": "9ae6bcd8c45a5e350438d1cc415a022fe053e938c93438509f60ae3abb425fa7",
"PLAYABLE_ASSET": "e2db6e6f9090f14848d3989920a1342f6813099c65ee7faef1e334f23e390970",
# 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 = {
"NAVIGATION": "Navigation",
"LANDING_PAGE": "LandingPageClient",
"LANDING_BLOCKS": "LandingBlocks",
"GET_ME_STATE": "GetMeState",
"LIVE_LANE": "LiveLane",
"HERO_RESUME": "HeroLandingResumePositionsWithToken",
"COLLECTION_QUERY": "PageOverviewCollectionQuery",
"PAGE_OVERVIEW_GENRE": "PageOverviewGenre",
"SEASON": "Season",
"MOVIE_DETAIL": "PageMovieDetailStatic",
"PLAYABLE_ASSET": "PlayableAssetWithToken",
# 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
"""
def __init__(self, provider):
self.provider = provider
self._cache: Dict[str, Dict[str, Any]] = {}
self._cache_ttl: int = 300
self._query_hashes = VOD_GRAPHQL_HASHES
self._operations = GRAPHQL_OPERATIONS
self._user_state = None
self._has_plus = False
logger.info(f"[JoynVodManager] Initialised for country={provider.country}")
# ========================================================================
# PROPERTIES
# ========================================================================
@property
def http_manager(self):
return self.provider.http_manager
@property
def country(self) -> str:
return self.provider.country
@property
def platform(self) -> str:
return self.provider.platform
@property
def distribution_tenant(self) -> str:
return self.provider.distribution_tenant
@property
def implements_vod(self) -> bool:
return True
# ========================================================================
# CACHING
# ========================================================================
@staticmethod
def _video_config_fingerprint(video_config: Optional[Dict]) -> str:
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 / URL BUILDING
# ========================================================================
def _get_graphql_headers(self, authenticated: bool = False) -> Dict[str, str]:
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,
"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:
base_url = JOYN_GRAPHQL_BASE_URL
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}}
params["extensions"] = json.dumps(extensions, separators=(',', ':'))
query_string = "&".join(f"{k}={urllib.parse.quote(v)}" for k, v in params.items())
return f"{base_url}?{query_string}"
# ========================================================================
# 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:
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]:
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.raise_for_status()
return response.json().get("data") or {}
except Exception as e:
logger.error(f"Error fetching navigation: {e}")
return {}
def get_navigation_tree(self) -> List[Dict[str, Any]]:
return self.get_navigation().get("navigation", [])
# 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",
))
return categories
def get_genres_from_navigation(self, media_type: str = None) -> List[VodCategory]:
"""Parse seriesGenre and movieGenre from the Navigation API response."""
nav_data = self.get_navigation()
categories = []
types_to_fetch = [media_type] if media_type else ["seriesGenre", "movieGenre"]
for mt in types_to_fetch:
genre_data = nav_data.get(mt, {})
for block in genre_data.get("blocks", []):
for asset in block.get("assets", []):
if asset.get("__typename") == "GenreItem":
categories.append(VodCategory(
content_id=asset.get("path", asset.get("id")),
name=asset.get("title", ""),
logo_url=asset.get("genreImage", {}).get("url"),
description=f"Genre: {asset.get('title')}",
provider="joyn",
fetch_url=asset.get("path"),
details_url=asset.get("path"),
))
return categories
# ========================================================================
# GENRE PAGE
# ========================================================================
def get_genre_page(self, path: str, first: int = 32, offset: int = 0, authenticated: bool = True) -> Dict[str, Any]:
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
# ========================================================================
# SERIES / MOVIE DETAIL PAGE (HTML FALLBACK)
# ========================================================================
def _extract_page_json(self, html: str) -> Optional[Dict[str, Any]]:
"""Extract the Next.js RSC page payload (initialData) embedded in series/movie detail HTML."""
match = re.search(
r'self\.__next_f\.push\(\[1,\s*"a:(\[.*?\])\\n"\]\)', html, re.DOTALL
)
if not match:
return None
try:
raw = match.group(1).encode().decode("unicode_escape")
data = json.loads(raw)
# data[3] is the props dict; ["initialData"]["page"] holds the series/movie object
return data[3]["initialData"]["page"]
except (json.JSONDecodeError, UnicodeDecodeError, IndexError, KeyError, TypeError) as e:
logger.warning(f"Failed to parse embedded page JSON: {e}")
return None
def _get_series_items_fallback(self, path: str, authenticated: bool = True, **kwargs) -> List[
Union[VodCategory, VodItem]]:
"""Series detail pages: parse the season/episode data Next.js already embeds server-side."""
url = f"https://www.joyn.de{path}"
headers = {
"User-Agent": JOYN_USER_AGENT,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
}
try:
response = self.http_manager.get(
url, operation="vod_series_html", headers=headers, timeout=DEFAULT_REQUEST_TIMEOUT
)
response.raise_for_status()
html = response.text
page = self._extract_page_json(html)
if not page:
logger.warning(f"No embedded page JSON found for {path} (html_len={len(html)})")
return []
series = page.get("series", {})
all_seasons = series.get("allSeasons") or series.get("freeSeasons") or []
if not all_seasons:
logger.warning(f"No seasons in embedded data for {path}")
return []
all_seasons = sorted(all_seasons, key=lambda s: s.get("number") or 9999)
if len(all_seasons) == 1:
items = []
for ep in all_seasons[0].get("episodes", []):
item = self._parse_episode_asset(ep)
if item:
items.append(item)
items.sort(key=lambda x: (x.episode_number or 9999))
return items
items = []
for s in all_seasons:
items.append(VodCategory(
content_id=s["id"],
name=f"Staffel {s.get('number', '')}".strip(),
provider="joyn",
fetch_url=s["id"],
))
return items
except Exception as e:
logger.error(f"Error fetching series HTML for {path}: {e}")
return []
def get_movie_detail(self, path: str, authenticated: bool = True) -> Dict[str, Any]:
try:
if not path.startswith("/"):
path = f"/{path}"
variables = {"path": path}
url = self._build_graphql_url(
operation_name=self._operations["MOVIE_DETAIL"],
query_hash=self._query_hashes["MOVIE_DETAIL"],
variables=variables,
)
headers = self._get_graphql_headers(authenticated=authenticated)
response = self.http_manager.get(url, operation="vod_movie_detail", headers=headers,
timeout=DEFAULT_REQUEST_TIMEOUT)
response.raise_for_status()
data = response.json()
if "errors" in data:
logger.warning(f"GraphQL errors in movie detail: {data['errors']}")
return {}
return (data.get("data") or {}).get("page", {}).get("movie", {})
except Exception as e:
logger.error(f"Error fetching movie detail {path}: {e}")
return {}
def _get_movie_items_fallback(self, path: str, authenticated: bool = True, **kwargs) -> List[VodItem]:
"""Movie detail pages, via GraphQL — no HTML scraping needed."""
movie = self.get_movie_detail(path, authenticated=authenticated)
if not movie:
logger.warning(f"No movie data found for {path}")
return []
item = self._parse_content_asset(movie)
return [item] if item else []
# ========================================================================
# SEASON EPISODES
# ========================================================================
def get_season_episodes(self, season_id: str, first: int = 20, offset: int = 0, license_filter: str = "FREE",
authenticated: bool = True) -> Dict[str, Any]:
try:
variables = {"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]]:
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)
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.raise_for_status()
data = response.json()
if "errors" in data:
logger.warning(f"GraphQL errors in collection query: {data['errors']}")
return {"assets": [], "total": 0}
block = (data.get("data") or {}).get("block", {})
return {
"assets": block.get("assets", []),
"headline": block.get("headline", ""),
"id": block.get("id"),
"typename": block.get("__typename"),
"total": len(block.get("assets", [])),
}
except Exception as e:
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]]:
result = self.get_collection(block_id, first, offset, authenticated)
items = []
for asset in result.get("assets", []):
item = self._parse_asset(asset)
if item:
items.append(item)
return items
# ========================================================================
# LANDING PAGE
# ========================================================================
def get_landing_page(self, path: str = "/neu-beliebt", variation: str = "Default", authenticated: bool = True) -> \
Dict[str, Any]:
try:
if not path.startswith("/"):
path = f"/{path}"
variables = {"path": path, "variation": variation}
url = self._build_graphql_url(
operation_name=self._operations["LANDING_PAGE"],
query_hash=self._query_hashes["LANDING_PAGE"],
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.raise_for_status()
data = response.json()
if "errors" in data:
logger.warning(f"GraphQL errors in landing page: {data['errors']}")
return {}
return (data.get("data") or {}).get("page", {})
except Exception as e:
logger.error(f"Error fetching landing page {path}: {e}")
return {}
def _get_page_items(self, path: str, authenticated: bool = True, **kwargs) -> List[Union[VodCategory, VodItem]]:
page = self.get_landing_page(path=path, authenticated=authenticated)
items: List[Union[VodCategory, VodItem]] = []
def process_block(block: Dict[str, Any]):
block_type = block.get("__typename")
block_id = block.get("id")
if block_type == "StandardLane" and block_id:
# If assets are present inline, parse them
if block.get("assets"):
for asset in block.get("assets", []):
item = self._parse_asset(asset)
if item:
items.append(item)
# If no assets, it's a lazyBlock and we must fetch the collection
else:
items.extend(self.get_collection_items(
block_id=block_id,
first=kwargs.get("first", 32),
offset=kwargs.get("offset", 0),
authenticated=authenticated,
))
# Parse other lane types that contain inline assets
elif block_type in ["HeroLane", "FeaturedLane", "GenreLane", "LiveLane", "ChannelLane", "BigTeaserLane",
"RecoForYouLane"] or not block_type:
for asset in block.get("assets", []):
item = self._parse_asset(asset)
if item:
items.append(item)
# Process initial blocks
for block in page.get("blocks", []):
process_block(block)
# Process lazy blocks (these require fetching via process_block -> get_collection_items)
for block in page.get("lazyBlocks", []):
process_block(block)
return items
# ========================================================================
# USER STATE
# ========================================================================
@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 {}
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()
return self._has_plus
# ========================================================================
# VOD CATEGORY — MAIN ENTRY POINT
# ========================================================================
def get_vod_category(self, content_id: str = "", authenticated: bool = True, **kwargs) -> List[
Union[VodCategory, VodItem]]:
try:
if not content_id or content_id == "/":
return self.get_navigation_categories()
# 1. Handle Genre directories explicitly
if content_id in ["/serien/genre", "/filme/genre"]:
media_type = "seriesGenre" if "serien" in content_id else "movieGenre"
return self.get_genres_from_navigation(media_type=media_type)
# 2. Handle Block IDs (lazyBlocks)
if self._is_block_id(content_id):
return self.get_collection_items(block_id=content_id, first=kwargs.get("first", 32),
offset=kwargs.get("offset", 0), authenticated=authenticated)
# 3. Handle Season IDs
if self._is_season_id(content_id):
return self._get_season_items(content_id, authenticated, **kwargs)
path = content_id if content_id.startswith("/") else f"/{content_id}"
# 4. Handle Specific Genre Paths (e.g. /serien/genre/comedy)
if self._is_genre_path(path):
return self._get_genre_items(path, authenticated, **kwargs)
# 5. Handle Specific Series Paths (e.g. /serien/tv-total)
# Note: _is_series_path checks for startswith("/serien/") so it won't catch just "/serien"
if self._is_series_path(path):
return self._get_series_items_fallback(path, authenticated, **kwargs)
# 6. Handle Specific Movie Paths (e.g. /filme/the-dark-knight)
if self._is_movie_path(path):
return self._get_movie_items_fallback(path, authenticated, **kwargs)
# 7. Fallback to standard landing page parsing
# This correctly handles /serien, /filme, /sport, /mediatheken, /neu-beliebt, etc.
return self._get_page_items(path, authenticated, **kwargs)
except Exception as e:
logger.error(f"Error getting VOD category: {e}")
return []
# ========================================================================
# SEARCH
# ========================================================================
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.
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:
# 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:
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"],
query_hash=query_hash,
variables=variables,
)
headers = self._get_graphql_headers(authenticated=authenticated)
response = self.http_manager.get(url, operation="vod_search", headers=headers,
timeout=DEFAULT_REQUEST_TIMEOUT)
response.raise_for_status()
data = response.json()
if "errors" in data:
logger.warning(f"GraphQL errors in search for query='{query}': {data['errors']}")
return {"items": [], "cursor": None, "total": 0}
result = (data.get("data") or {}).get("search", {}) or {}
assets = result.get("assets") or result.get("results") or []
items: List[Union[VodCategory, VodItem]] = []
for asset in assets:
item = self._parse_asset(asset)
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": 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}
# ========================================================================
# ASSET PARSING
# ========================================================================
def _parse_asset(self, asset: Dict[str, Any]) -> Optional[Union[VodCategory, VodItem]]:
typename = asset.get("__typename")
asset_id = asset.get("id", "")
title = asset.get("title", "Unknown")
path = asset.get("path", "")
# SERIES = Category (not playable)
if typename == "Series":
return VodCategory(
content_id=path or asset_id, # Use path for navigation
name=title,
logo_url=asset.get("primaryImage", {}).get("url") or asset.get("iconicImage", {}).get("url"),
description=asset.get("description", ""),
provider="joyn",
fetch_url=path or asset_id, # This will be used to fetch seasons
child_count=None,
details_url=path,
)
# MOVIE = Playable item (VodItem)
elif typename == "Movie":
video_id = asset.get("video", {}).get("id")
content_id = video_id or asset_id
item = self._parse_content_asset(asset)
if item:
item.content_id = content_id # Ensure we use the video ID for manifest
return item
# EPISODE = Playable item (VodItem)
elif typename == "Episode":
return self._parse_episode_asset(asset)
# SEASON = Category (not playable)
elif typename == "Season":
season_num = asset.get("number", "")
name = f"Staffel {season_num}".strip() if season_num else title
return VodCategory(
content_id=asset_id, # Season ID (e.g., c_piuczpyg0ld)
name=name,
logo_url=asset.get("primaryImage", {}).get("url") or asset.get("iconicImage", {}).get("url"),
description=title,
provider="joyn",
fetch_url=asset_id, # This will be used to fetch episodes
child_count=None,
details_url=None,
)
# Other types...
elif typename == "Brand":
return VodCategory(
content_id=asset_id,
name=title,
logo_url=asset.get("logo", {}).get("url"),
description=f"{title} Mediathek",
provider="joyn",
fetch_url=asset.get("path"),
)
elif typename == "GenreItem":
return VodCategory(
content_id=asset.get("path", asset_id),
name=title,
logo_url=asset.get("genreImage", {}).get("url"),
description=f"Genre: {title}",
provider="joyn",
fetch_url=asset.get("path"),
)
return None
def _parse_content_asset(self, asset: Dict[str, Any]) -> Optional[VodItem]:
"""Parse movie/playable content into VodItem"""
typename = asset.get("__typename", "")
# Only handle Movies here, Series should be handled by _parse_asset as VodCategory
if typename != "Movie":
return None
asset_id = asset.get("id", "")
title = asset.get("title", "Unknown")
path = asset.get("path", "")
image_url = asset.get("primaryImage", {}).get("url") or asset.get("heroPortrait", {}).get("url") or asset.get(
"iconicImage", {}).get("url")
genres = [g.get("name", "") for g in asset.get("genres", []) if g.get("name")]
min_age = asset.get("ageRating", {}).get("minAge")
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
video_id = asset.get("video", {}).get("id")
content_id = video_id or asset_id
item = VodItem(
name=title,
content_id=content_id,
provider="joyn",
logo_url=image_url,
mode=StreamingMode.VOD,
content_type=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,
rating=f"FSK {min_age}" if min_age is not None else None,
)
if is_free:
item.set_free()
elif is_premium:
item.set_subscription(tiers=["PLUS"])
return item
def _parse_episode_asset(self, asset: Dict[str, Any]) -> Optional[VodItem]:
asset_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", {})
# Use the video ID (a_xxxxx) as content_id for manifest requests
video_id = video_data.get("id", "")
content_id = video_id or asset_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
ep_number = asset.get("number")
season_number = season_data.get("seasonNumber")
description = f"Staffel {season_number}, Episode {ep_number} – {title}" if series_data.get("title") else ""
item = VodItem(
name=title,
content_id=content_id,
provider="joyn",
mode=StreamingMode.VOD,
content_type=ContentType.SERIES,
description=description,
country=self.country,
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=season_number,
episode_number=ep_number,
series_id=series_data.get("id"),
series_title=series_data.get("title"),
)
if is_free:
item.set_free()
elif is_premium:
item.set_subscription(tiers=["PLUS"])
return item
# ========================================================================
# VOD PLAYBACK
# ========================================================================
def get_vod_manifest(self, content_id: str, video_config: Optional[Dict] = None, **kwargs) -> Optional[str]:
result = self._get_vod_manifest_and_drm(content_id, video_config,
max_retries=kwargs.get("max_retries", DEFAULT_MAX_RETRIES))
return result.get("manifest_url") if result else None
def get_vod_drm(self, content_id: str, video_config: Optional[Dict] = None, **kwargs) -> List[Any]:
result = self._get_vod_manifest_and_drm(content_id, video_config,
max_retries=kwargs.get("max_retries", DEFAULT_MAX_RETRIES))
return result.get("drm_configs", []) if result else []
def get_vod_manifest_with_headers(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))
if result and result.get("manifest_url"):
headers = {
"Authorization": f"Bearer {result.get('entitlement_token', '')}",
"Accept": "application/json",
"User-Agent": JOYN_USER_AGENT,
}
return result["manifest_url"], headers
return None, {}
def get_playable_asset(self, asset_id: str, authenticated: bool = True) -> Dict[str, Any]:
"""
Fetch a single playable asset (Movie/Episode) by its b_/c_/d_ id.
Returns the `asset` object including its `video.id` (a_…) which is
required for the vod-prd playlist endpoint.
"""
try:
url = self._build_graphql_url(
operation_name=self._operations["PLAYABLE_ASSET"],
query_hash=self._query_hashes["PLAYABLE_ASSET"],
variables={"id": asset_id},
)
headers = self._get_graphql_headers(authenticated=authenticated)
response = self.http_manager.get(
url, operation="vod_playable_asset", headers=headers,
timeout=DEFAULT_REQUEST_TIMEOUT,
)
response.raise_for_status()
data = response.json()
if "errors" in data:
logger.warning(f"GraphQL errors in PlayableAssetWithToken for {asset_id}: {data['errors']}")
return {}
return (data.get("data") or {}).get("asset", {}) or {}
except Exception as e:
logger.error(f"Error fetching playable asset {asset_id}: {e}")
return {}
def get_content_details(self, content_id: str, authenticated: bool = True, **kwargs) -> Optional[VodItem]:
"""
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}")
return None
typename = asset.get("__typename")
if typename == "Episode":
return self._parse_episode_asset(asset)
return self._parse_content_asset(asset)
def _resolve_video_id(self, content_id: str) -> Optional[str]:
# Already a video id
if content_id.startswith("a_"):
logger.debug("fast-path a_ id")
return content_id
# Catalog asset id (movie/episode) -> resolve via PlayableAssetWithToken
if content_id.startswith(("b_", "c_", "d_")):
logger.debug("resolving via PlayableAssetWithToken")
cache_key = f"video_id:{content_id}"
cached = self._cache.get(cache_key)
if cached and (time.time() - cached["timestamp"] < self._cache_ttl):
return cached["data"]
asset = self.get_playable_asset(content_id, authenticated=True)
video_id = (asset.get("video") or {}).get("id")
if not video_id:
logger.error(
f"Cannot resolve '{content_id}' to a video ID "
f"(PlayableAssetWithToken returned no video.id)."
)
return None
self._cache[cache_key] = {"timestamp": time.time(), "data": video_id}
logger.debug(f"Resolved {content_id} -> {video_id}")
return video_id
logger.error(f"Cannot resolve '{content_id}' to a video ID (unknown prefix).")
return None
def _get_vod_manifest_and_drm(self, content_id: str, video_config: Optional[Dict] = None,
max_retries: int = DEFAULT_MAX_RETRIES) -> Optional[Dict[str, Any]]:
video_id = self._resolve_video_id(content_id)
if not video_id:
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"]
for attempt in range(max_retries):
try:
entitlement_token = self.provider.channel_manager.get_entitlement_token(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/asset/{video_id}/playlist?signature={signature}"
headers = {
"Authorization": f"Bearer {entitlement_token}",
"Accept": "application/json",
"Content-Type": "application/json",
"User-Agent": JOYN_USER_AGENT,
}
response = self.http_manager.post(url, operation="vod_playlist", headers=headers, data=video_payload,
timeout=DEFAULT_REQUEST_TIMEOUT)
response.raise_for_status()
playlist_data = response.json()
logger.debug(
f"VOD playlist response for {video_id}: {json.dumps(playlist_data, separators=(',', ':'))}"
)
manifest_url = playlist_data.get("manifestUrl")
if not manifest_url:
logger.warning(
f"VOD attempt {attempt + 1}/{max_retries} for {video_id}: no manifestUrl in response"
)
if attempt < max_retries - 1:
time.sleep(1)
continue
result = {
"manifest_url": manifest_url,
"entitlement_token": entitlement_token,
"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,
),
)
result["drm_configs"] = [drm_config]
else:
result["drm_configs"] = []
self._cache[cache_key] = {"timestamp": time.time(), "data": result}
return result
except PlaybackRestrictedException as e:
logger.warning(f"VOD playback restricted for {video_id}: {e}")
return None
except SubscriptionRequiredException as e:
has_plus = self.has_plus_subscription()
logger.warning(
f"VOD {video_id} requires a subscription this account doesn't hold "
f"(hasActivePlus={has_plus}), not retrying: {e}"
)
return None # permanent, per-account failure — retrying gains nothing
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 {video_id} after {max_retries} attempts")
return None
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")