mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-10-21 23:32:48 +02:00
1318 lines
51 KiB
Python
1318 lines
51 KiB
Python
# streaming_providers/providers/joyn/vod_manager.py
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
Joyn VOD Manager — browseable catalogue (GraphQL) and playback.
|
||
|
||
Collaborators (constructor contract, README §6.0):
|
||
|
||
required (keyword-only):
|
||
http_manager, auth, country, config
|
||
declared extras (keyword-only):
|
||
entitlement — the shared JoynEntitlement helper; the VOD manager is
|
||
the second consumer (the first is JoynChannelManager),
|
||
which is why it lives in its own collaborator and not
|
||
on a sibling manager (README §5.1). v1 reached it via
|
||
provider.channel_manager.get_entitlement_token.
|
||
|
||
Return types: `get_vod_category` and `search_vod` return `VodPage`. V1 returned
|
||
a bare list and a dict respectively — both of which the base tolerates at
|
||
runtime (`normalize_vod_result`), but the ABC says `VodPage`, and the migration
|
||
brief commits to it (README §6.2, trap 33).
|
||
|
||
Content-id grammar (opaque to the base):
|
||
a_… Joyn "video" asset id — the id the playlist endpoint wants
|
||
b_/c_/d_ catalog ids: movies (b_), seasons (c_), episodes (d_)
|
||
block-… lazyBlocks from a landing page; also anything with ":" in it
|
||
/path browsable HTML/GraphQL paths — /serien, /filme, /serien/<slug>, …
|
||
|
||
`handles_content_id` implements the prefilter that v1 had as
|
||
`JoynProvider._is_vod_content`. It is pure (no I/O): a prefix / character
|
||
check, called on every router attempt.
|
||
|
||
Errors follow the v2 contract: "not mine" is decided by `handles_content_id`
|
||
(the router never asks this manager about a live id) — once a VOD id is here,
|
||
only a result or a typed failure is valid. Typed `ProviderError`s (AuthError,
|
||
RateLimitError, GeoBlockError, EntitlementError, NotFoundError, ...) ALWAYS
|
||
propagate unchanged; only untyped exceptions are wrapped in `ServerError`.
|
||
Only `ServerError`s are retried.
|
||
|
||
GraphQL URLs: every operation is built through `graphql.build_url(key, ...)`.
|
||
The hashes and operation names live in `constants.GRAPHQL_QUERY_HASHES` /
|
||
`constants.GRAPHQL_OPERATIONS`; there is no local copy in this file.
|
||
"""
|
||
|
||
import functools
|
||
import json
|
||
import re
|
||
import time
|
||
from typing import Any, Dict, List, Optional, Union
|
||
|
||
from ...base.errors import NotFoundError, ProviderError, ServerError
|
||
from ...base.managers import VodManager
|
||
from ...base.models import DRMConfig
|
||
from ...base.models.content import ContentType, StreamingMode
|
||
from ...base.models.vod import VodCategory, VodItem
|
||
from ...base.utils.logger import logger
|
||
from ...base.utils.transport import transport_errors
|
||
from ...base.vod import VodPage
|
||
from .drm import build_widevine_config
|
||
from .ids import is_vod_id
|
||
from .signing import build_signature, create_video_payload, video_config_fingerprint
|
||
from .graphql import build_url
|
||
from .constants import (
|
||
CONTENT_TYPE_VOD,
|
||
DEFAULT_MAX_RETRIES,
|
||
DEFAULT_REQUEST_TIMEOUT,
|
||
JOYN_STREAMING_ENDPOINTS,
|
||
JOYN_USER_AGENT,
|
||
)
|
||
from .models import (
|
||
JoynEntitlementError,
|
||
PlaybackRestrictedException,
|
||
SubscriptionRequiredException,
|
||
)
|
||
|
||
|
||
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.
|
||
"""
|
||
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(VodManager):
|
||
"""
|
||
Joyn VOD manager using the GraphQL API (browse) and the vod-prd playlist
|
||
endpoint (playback). No `self.provider` accesses — every collaborator is
|
||
either one of the four required ones or an explicit keyword-only extra.
|
||
"""
|
||
|
||
# Root-menu paths we surface from the live Navigation API; anything else
|
||
# is filtered out so Live TV (handled by the channel manager) and unrelated
|
||
# blocks do not end up in the VOD root. Add new paths here as Joyn adds
|
||
# them under these sections — the whitelist is deliberate, not incidental.
|
||
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 does not match
|
||
# the expected shape. Fail open to a known-good static menu rather than
|
||
# ship an empty VOD root.
|
||
_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 __init__(
|
||
self,
|
||
*,
|
||
http_manager: Any,
|
||
auth: Any,
|
||
country: str,
|
||
config: Any,
|
||
entitlement: Any,
|
||
):
|
||
super().__init__(
|
||
http_manager=http_manager, auth=auth, country=country, config=config
|
||
)
|
||
self._entitlement = entitlement # extra AFTER super(), never passed to it
|
||
|
||
# Per-instance caches. Provider-owned dicts are used only where the
|
||
# cache must survive credentials changes and be cleared by the
|
||
# provider; the VOD cache is self-contained and cleared by
|
||
# clear_cache(), which the provider calls from set_user_credentials
|
||
# (if/when that exists).
|
||
self._cache: Dict[str, Dict[str, Any]] = {}
|
||
self._cache_ttl: int = 300
|
||
|
||
self._user_state: Optional[Dict[str, Any]] = None
|
||
self._has_plus = False
|
||
|
||
logger.info(f"[JoynVodManager] Initialised for country={self.country}")
|
||
|
||
# ========================================================================
|
||
# CAPABILITY / ROUTING
|
||
# ========================================================================
|
||
|
||
def handles_content_id(self, content_id: str) -> bool:
|
||
"""
|
||
Cheap, pure prefilter — the router asks this on every attempt, so it
|
||
must not do I/O.
|
||
|
||
Mirrors v1's `JoynProvider._is_vod_content`:
|
||
* a_/b_/c_/d_ prefixed asset ids
|
||
* block-<n> lazy-block ids
|
||
* any path containing "/" (browsable routes)
|
||
* any id containing ":" (v1 block-id grammar)
|
||
Live channel ids are bare slugs like "sat1-de" and do not match.
|
||
"""
|
||
return is_vod_id(content_id) # the same function the channel manager negates
|
||
|
||
# ========================================================================
|
||
# CACHING
|
||
# ========================================================================
|
||
|
||
def clear_cache(self) -> None:
|
||
"""Called by the provider when credentials change."""
|
||
self._cache.clear()
|
||
self.__dict__.pop(type(self).get_user_state.cache_attr, None)
|
||
self._user_state = None
|
||
logger.debug("VOD cache cleared")
|
||
|
||
# ========================================================================
|
||
# HEADER / URL BUILDING
|
||
# ========================================================================
|
||
|
||
def _get_graphql_headers(self, authenticated: bool = False) -> Dict[str, str]:
|
||
"""
|
||
GraphQL headers via the shared config.
|
||
|
||
`authenticated=True` means "this is a user-scoped query (user state,
|
||
search, season under an account)" — we then ask the session for a
|
||
token and set joyn-user-state=code=R_A. When False, the query is
|
||
anonymous and the reference client sends A_A.
|
||
|
||
Unlike the channel/EPG managers' header builder (which uses ensure()
|
||
and degrades to anonymous), this one RAISES on a permanent login
|
||
failure: a user-scoped query is meaningless without a session, and
|
||
the failure must surface so the user can act on it (README §8).
|
||
"""
|
||
token: Optional[str] = None
|
||
if authenticated:
|
||
token = self.auth.get_access_token()
|
||
return self.config.graphql_headers(
|
||
token=token, authenticated=token is not None
|
||
)
|
||
|
||
# ========================================================================
|
||
# 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]:
|
||
with transport_errors("vod_navigation", "Joyn"):
|
||
url = build_url("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 {}
|
||
|
||
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]:
|
||
"""
|
||
Root menu.
|
||
|
||
Two sources of entries:
|
||
* whitelisted paths from the live Navigation API (kept in
|
||
ALLOWED_NAV_PATHS so Live TV and unrelated blocks stay out)
|
||
* two synthetic genre directories that Joyn does not list itself
|
||
|
||
If the live response does not yield a whitelisted entry at all
|
||
(upstream empty or shape changed), fall back to a static menu rather
|
||
than ship an empty root.
|
||
"""
|
||
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:
|
||
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 — 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: Optional[str] = None) -> List[VodCategory]:
|
||
"""Parse seriesGenre and movieGenre from the Navigation API response."""
|
||
nav_data = self.get_navigation()
|
||
categories: List[VodCategory] = []
|
||
|
||
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]:
|
||
with transport_errors(f"vod_genre_page {path}", "Joyn"):
|
||
if not path.startswith("/"):
|
||
path = f"/{path}"
|
||
variables = {"first": first, "path": path, "offset": offset}
|
||
url = build_url("PAGE_OVERVIEW_GENRE", 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", {})
|
||
|
||
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
|
||
# ========================================================================
|
||
|
||
def _extract_page_json(self, html: str) -> Optional[Dict[str, Any]]:
|
||
"""Extract the Next.js RSC page payload (initialData) embedded in 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)
|
||
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",
|
||
}
|
||
with transport_errors(f"vod_series_html {path}", "Joyn"):
|
||
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: List[Union[VodCategory, VodItem]] = []
|
||
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
|
||
|
||
def get_movie_detail(self, path: str, authenticated: bool = True) -> Dict[str, Any]:
|
||
with transport_errors(f"vod_movie_detail {path}", "Joyn"):
|
||
if not path.startswith("/"):
|
||
path = f"/{path}"
|
||
variables = {"path": path}
|
||
url = build_url("MOVIE_DETAIL", 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", {})
|
||
|
||
def _get_movie_items_fallback(
|
||
self,
|
||
path: str,
|
||
authenticated: bool = True,
|
||
**kwargs,
|
||
) -> List[VodItem]:
|
||
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]:
|
||
with transport_errors(f"vod_season {season_id}", "Joyn"):
|
||
variables = {
|
||
"id": season_id, "first": first,
|
||
"licenseFilter": license_filter, "offset": offset,
|
||
}
|
||
url = build_url("SEASON", 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", {})
|
||
|
||
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]:
|
||
with transport_errors(f"vod_collection {block_id}", "Joyn"):
|
||
has_token = authenticated and self.auth.ensure()
|
||
variables = {
|
||
"first": first, "offset": offset, "blockId": block_id,
|
||
"hasToken": has_token,
|
||
}
|
||
url = build_url("COLLECTION_QUERY", 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", [])),
|
||
}
|
||
|
||
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: List[Union[VodCategory, VodItem]] = []
|
||
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]:
|
||
with transport_errors(f"vod_landing_page {path}", "Joyn"):
|
||
if not path.startswith("/"):
|
||
path = f"/{path}"
|
||
variables = {"path": path, "variation": variation}
|
||
url = build_url("LANDING_PAGE", 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", {})
|
||
|
||
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 block.get("assets"):
|
||
for asset in block.get("assets", []):
|
||
item = self._parse_asset(asset)
|
||
if item:
|
||
items.append(item)
|
||
else:
|
||
items.extend(self.get_collection_items(
|
||
block_id=block_id,
|
||
first=kwargs.get("first", 32),
|
||
offset=kwargs.get("offset", 0),
|
||
authenticated=authenticated,
|
||
))
|
||
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)
|
||
|
||
for block in page.get("blocks", []):
|
||
process_block(block)
|
||
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]:
|
||
# force_refresh=True bypasses the cache for one call; the decorator
|
||
# strips it before the call reaches this function.
|
||
with transport_errors("vod_user_state", "Joyn"):
|
||
url = build_url("GET_ME_STATE")
|
||
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
|
||
|
||
def has_plus_subscription(self) -> bool:
|
||
self.get_user_state()
|
||
return self._has_plus
|
||
|
||
# ========================================================================
|
||
# VOD CATEGORY — MAIN ENTRY POINT (returns VodPage)
|
||
# ========================================================================
|
||
|
||
def get_vod_category(
|
||
self,
|
||
content_id: str = "",
|
||
cursor: Optional[str] = None,
|
||
page_size: int = 24,
|
||
**kwargs,
|
||
) -> VodPage:
|
||
"""
|
||
Return one page of the VOD catalogue.
|
||
|
||
Pagination: Joyn pages by numeric offset, exposed as the opaque `cursor`
|
||
string. Only the endpoints that really honour `offset` are pageable —
|
||
block ids (collections) and genre paths. A full page there yields
|
||
`next_cursor = offset + len(entries)`. Everything else (navigation,
|
||
genre directories, season, series, movie, landing pages) returns its
|
||
complete list with `next_cursor=None`, using the helpers' own default
|
||
sizes (as v1 did): season episodes are fetched per licence filter, and
|
||
landing pages apply the offset per block, so a single offset cannot page
|
||
them without duplicating items.
|
||
|
||
Typed ProviderErrors propagate unchanged; only untyped exceptions become
|
||
ServerError.
|
||
"""
|
||
offset = self._parse_offset(cursor)
|
||
first = kwargs.get("first")
|
||
try:
|
||
entries = self._get_vod_category_entries(
|
||
content_id=content_id,
|
||
cursor=cursor,
|
||
page_size=page_size,
|
||
authenticated=True,
|
||
**kwargs,
|
||
)
|
||
except ProviderError:
|
||
raise
|
||
except Exception as exc:
|
||
logger.error(f"Joyn VOD category failed for {content_id!r}: {exc}")
|
||
raise ServerError(f"Joyn VOD category failed for {content_id!r}") from exc
|
||
|
||
entries = list(entries)
|
||
next_cursor = None
|
||
if self._is_pageable(content_id):
|
||
effective_first = first if first is not None else page_size
|
||
if len(entries) >= effective_first:
|
||
next_cursor = str(offset + len(entries))
|
||
return VodPage(entries=entries, next_cursor=next_cursor, total=None)
|
||
|
||
@staticmethod
|
||
def _parse_offset(cursor: Optional[str]) -> int:
|
||
if cursor is None:
|
||
return 0
|
||
try:
|
||
return int(cursor)
|
||
except (TypeError, ValueError):
|
||
logger.warning(f"Joyn: ignoring non-numeric cursor {cursor!r}")
|
||
return 0
|
||
|
||
def _is_pageable(self, content_id: str) -> bool:
|
||
"""True only for endpoints that honour `offset` (see get_vod_category)."""
|
||
if not content_id or content_id == "/":
|
||
return False
|
||
if content_id in ("/serien/genre", "/filme/genre"):
|
||
return False
|
||
if self._is_block_id(content_id):
|
||
return True
|
||
if self._is_season_id(content_id):
|
||
return False
|
||
path = content_id if content_id.startswith("/") else f"/{content_id}"
|
||
return self._is_genre_path(path)
|
||
|
||
def _get_vod_category_entries(
|
||
self,
|
||
content_id: str,
|
||
cursor: Optional[str],
|
||
page_size: int,
|
||
authenticated: bool,
|
||
**kwargs,
|
||
) -> List[Union[VodCategory, VodItem]]:
|
||
"""
|
||
The v1 body of `get_vod_category`, unchanged in dispatch order; only
|
||
the `cursor` / `page_size` plumbing is new (was `first` / `offset` in
|
||
kwargs before; kept here for compatibility with callers that still
|
||
pass `first`/`offset`, which we honour over `page_size` if present).
|
||
"""
|
||
explicit_first = kwargs.pop("first", None)
|
||
offset = kwargs.pop("offset", 0)
|
||
if cursor is not None:
|
||
offset = self._parse_offset(cursor)
|
||
|
||
# Pageable endpoints get page_size; the others keep the helpers' own
|
||
# defaults (32 / 20) so a smaller page_size cannot truncate them silently.
|
||
first = explicit_first
|
||
if first is None and self._is_pageable(content_id):
|
||
first = page_size
|
||
paging: Dict[str, Any] = {"offset": offset}
|
||
if first is not None:
|
||
paging["first"] = first
|
||
|
||
if not content_id or content_id == "/":
|
||
return self.get_navigation_categories()
|
||
|
||
# 1. Genre directories
|
||
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. Block ids
|
||
if self._is_block_id(content_id):
|
||
return self.get_collection_items(
|
||
block_id=content_id, authenticated=authenticated, **paging,
|
||
)
|
||
|
||
# 3. Season ids
|
||
if self._is_season_id(content_id):
|
||
return self._get_season_items(content_id, authenticated, **paging)
|
||
|
||
path = content_id if content_id.startswith("/") else f"/{content_id}"
|
||
|
||
# 4. Specific genre paths
|
||
if self._is_genre_path(path):
|
||
return self._get_genre_items(path, authenticated, **paging)
|
||
|
||
# 5. Specific series paths
|
||
if self._is_series_path(path):
|
||
return self._get_series_items_fallback(path, authenticated, **paging)
|
||
|
||
# 6. Specific movie paths
|
||
if self._is_movie_path(path):
|
||
return self._get_movie_items_fallback(path, authenticated, **paging)
|
||
|
||
# 7. Landing page fallback
|
||
return self._get_page_items(path, authenticated, **paging)
|
||
|
||
# ========================================================================
|
||
# SEARCH (returns VodPage)
|
||
# ========================================================================
|
||
|
||
def search_vod(
|
||
self,
|
||
query: str,
|
||
cursor: Optional[str] = None,
|
||
page_size: int = 24,
|
||
**kwargs,
|
||
) -> VodPage:
|
||
"""
|
||
Search the VOD catalogue.
|
||
|
||
Query hash, operation name ("SearchQ") and variable names
|
||
(`text` / `first` / `offset`) 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.
|
||
|
||
Pagination: numeric offset, advanced by page_size on a full page.
|
||
Returned as the opaque `next_cursor` string; callers pass it back as
|
||
`cursor`.
|
||
"""
|
||
offset = 0
|
||
if cursor:
|
||
try:
|
||
offset = int(cursor)
|
||
except (TypeError, ValueError):
|
||
logger.warning(f"Joyn: ignoring non-numeric search cursor {cursor!r}")
|
||
offset = 0
|
||
|
||
try:
|
||
variables: Dict[str, Any] = {
|
||
"text": query,
|
||
"first": page_size,
|
||
"offset": offset,
|
||
}
|
||
url = build_url("SEARCH", variables)
|
||
headers = self._get_graphql_headers(authenticated=True)
|
||
response = self.http_manager.get(
|
||
url, operation="vod_search", headers=headers,
|
||
timeout=DEFAULT_REQUEST_TIMEOUT,
|
||
)
|
||
response.raise_for_status()
|
||
data = response.json()
|
||
except ProviderError:
|
||
raise
|
||
except Exception as exc:
|
||
logger.error(f"Joyn VOD search failed for query={query!r}: {exc}")
|
||
raise ServerError(f"Joyn VOD search failed for query={query!r}") from exc
|
||
|
||
if "errors" in data:
|
||
logger.warning(f"GraphQL errors in search for query={query!r}: {data['errors']}")
|
||
return VodPage(entries=[], next_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)
|
||
|
||
next_cursor = str(offset + len(assets)) if len(assets) == page_size else None
|
||
return VodPage(
|
||
entries=items,
|
||
next_cursor=next_cursor,
|
||
total=result.get("total", len(items)),
|
||
)
|
||
|
||
# ========================================================================
|
||
# 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", "")
|
||
|
||
if typename == "Series":
|
||
return VodCategory(
|
||
content_id=path or asset_id,
|
||
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,
|
||
child_count=None,
|
||
details_url=path,
|
||
)
|
||
|
||
if 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
|
||
return item
|
||
|
||
if typename == "Episode":
|
||
return self._parse_episode_asset(asset)
|
||
|
||
if typename == "Season":
|
||
season_num = asset.get("number", "")
|
||
name = f"Staffel {season_num}".strip() if season_num else title
|
||
return VodCategory(
|
||
content_id=asset_id,
|
||
name=name,
|
||
logo_url=asset.get("primaryImage", {}).get("url")
|
||
or asset.get("iconicImage", {}).get("url"),
|
||
description=title,
|
||
provider="joyn",
|
||
fetch_url=asset_id,
|
||
child_count=None,
|
||
details_url=None,
|
||
)
|
||
|
||
if 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"),
|
||
)
|
||
|
||
if 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", "")
|
||
if typename != "Movie":
|
||
return None
|
||
|
||
asset_id = asset.get("id", "")
|
||
title = asset.get("title", "Unknown")
|
||
|
||
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", {})
|
||
|
||
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]:
|
||
"""
|
||
Return the manifest URL for a VOD content id, or raise.
|
||
|
||
Reached only after the router confirmed the id is VOD
|
||
(`handles_content_id`), so `None` ("not mine") is not a valid outcome:
|
||
* an id that cannot be resolved to a playable video -> NotFoundError
|
||
* a playlist without manifestUrl after all retries -> ServerError
|
||
* rights / auth / transport problems -> their typed error
|
||
"""
|
||
result = self._get_vod_manifest_and_drm(
|
||
content_id, video_config,
|
||
max_retries=kwargs.get("max_retries", DEFAULT_MAX_RETRIES),
|
||
)
|
||
return result["manifest_url"]
|
||
|
||
def get_vod_drm(
|
||
self,
|
||
content_id: str,
|
||
video_config: Optional[Dict] = None,
|
||
**kwargs,
|
||
) -> List[DRMConfig]:
|
||
result = self._get_vod_manifest_and_drm(
|
||
content_id, video_config,
|
||
max_retries=kwargs.get("max_retries", DEFAULT_MAX_RETRIES),
|
||
)
|
||
return result["drm_configs"]
|
||
|
||
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_…).
|
||
"""
|
||
with transport_errors(f"vod_playable_asset {asset_id}", "Joyn"):
|
||
url = build_url("PLAYABLE_ASSET", {"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 {}
|
||
|
||
def get_content_details(
|
||
self,
|
||
content_id: str,
|
||
authenticated: bool = True,
|
||
**kwargs,
|
||
) -> Optional[VodItem]:
|
||
"""
|
||
Full details for a single playable item by its b_/c_/d_ id.
|
||
Called by `provider.get_vod_item_details()`, which serialises the
|
||
result with VodItem.to_dict() (inherited from Content).
|
||
"""
|
||
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]:
|
||
if content_id.startswith("a_"):
|
||
return content_id
|
||
|
||
if content_id.startswith(("b_", "c_", "d_")):
|
||
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!r} to a video ID "
|
||
f"(PlayableAssetWithToken returned no video.id)."
|
||
)
|
||
return None
|
||
|
||
self._cache[cache_key] = {"timestamp": time.time(), "data": video_id}
|
||
return video_id
|
||
|
||
logger.error(f"Cannot resolve {content_id!r} 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]]:
|
||
"""
|
||
Shared workhorse for get_vod_manifest and get_vod_drm.
|
||
|
||
Retries ONLY transient failures (ServerError / untyped network errors)
|
||
with a one-second sleep. Every other typed ProviderError — rights
|
||
(PlaybackRestricted, SubscriptionRequired), AuthError, RateLimitError,
|
||
GeoBlockError, NotFoundError — is permanent for this call: it propagates
|
||
immediately and unchanged (retrying gains nothing, risks rate limits, and
|
||
flattening it into ServerError would hide the reason from callers).
|
||
|
||
Entitlement comes from `self._entitlement` (the shared helper);
|
||
headers come from `self.config.*`; the session's token comes from
|
||
`self.auth.get_access_token()` — none of these touch the provider.
|
||
"""
|
||
video_id = self._resolve_video_id(content_id)
|
||
if not video_id:
|
||
raise NotFoundError(f"Joyn: cannot resolve {content_id!r} to a playable video")
|
||
|
||
cache_key = f"vod_playlist:{video_id}:{video_config_fingerprint(video_config)}"
|
||
cached = self._cache.get(cache_key)
|
||
if cached and (time.time() - cached["timestamp"] < self._cache_ttl):
|
||
return cached["data"]
|
||
|
||
last_exc: Optional[Exception] = None
|
||
|
||
for attempt in range(max_retries):
|
||
try:
|
||
entitlement_token = self._entitlement.get_entitlement_token(
|
||
content_id=video_id, content_type=CONTENT_TYPE_VOD
|
||
)
|
||
video_payload = create_video_payload(video_config)
|
||
signature = build_signature(entitlement_token, video_payload)
|
||
|
||
url = (
|
||
JOYN_STREAMING_ENDPOINTS["ASSET_PLAYLIST"].format(asset_id=video_id)
|
||
+ f"?signature={signature}"
|
||
)
|
||
headers = self.config.api_headers(entitlement_token)
|
||
|
||
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}: "
|
||
f"{json.dumps(playlist_data, separators=(',', ':'))}"
|
||
)
|
||
|
||
manifest_url = playlist_data.get("manifestUrl")
|
||
if not manifest_url:
|
||
last_exc = ServerError(
|
||
f"VOD playlist for {video_id} had no manifestUrl"
|
||
)
|
||
logger.warning(
|
||
f"VOD attempt {attempt + 1}/{max_retries} for {video_id}: "
|
||
f"no manifestUrl in response"
|
||
)
|
||
if attempt < max_retries - 1:
|
||
time.sleep(1)
|
||
continue
|
||
|
||
result: Dict[str, Any] = {
|
||
"manifest_url": manifest_url,
|
||
"entitlement_token": entitlement_token,
|
||
"streaming_format": playlist_data.get("streamingFormat", "dash"),
|
||
"drm_configs": [],
|
||
}
|
||
|
||
license_url = playlist_data.get("licenseUrl")
|
||
if license_url:
|
||
result["drm_configs"] = [build_widevine_config(
|
||
self.config, license_url, playlist_data.get("certificateUrl")
|
||
)]
|
||
|
||
self._cache[cache_key] = {"timestamp": time.time(), "data": result}
|
||
return result
|
||
|
||
except PlaybackRestrictedException:
|
||
# Permanent, account-level restriction — propagate.
|
||
raise
|
||
except SubscriptionRequiredException as exc:
|
||
# Permanent, account-level; log the diagnostic (does the
|
||
# account hold Plus?) and propagate.
|
||
try:
|
||
has_plus = self.has_plus_subscription()
|
||
except Exception: # diagnostics must never mask the real SubscriptionRequired
|
||
has_plus = None
|
||
logger.warning(
|
||
f"VOD {video_id} requires a subscription this account "
|
||
f"doesn't hold (hasActivePlus={has_plus}): {exc}"
|
||
)
|
||
raise
|
||
except JoynEntitlementError:
|
||
# Any other entitlement-shaped failure is also permanent for
|
||
# this content; do not waste retries on it.
|
||
raise
|
||
except Exception as exc:
|
||
if isinstance(exc, ProviderError) and not isinstance(exc, ServerError):
|
||
raise # typed and not transient: never retry, never flatten
|
||
last_exc = exc
|
||
logger.warning(f"VOD attempt {attempt + 1}/{max_retries} failed: {exc}")
|
||
if attempt < max_retries - 1:
|
||
time.sleep(1)
|
||
|
||
logger.error(f"VOD failed for {video_id} after {max_retries} attempts")
|
||
if last_exc is not None:
|
||
raise ServerError(f"VOD failed for {video_id}: {last_exc}") from last_exc
|
||
raise ServerError(f"VOD failed for {video_id}: no attempt was made (max_retries={max_retries})")
|
||
return None |