migrate joyn and new template

This commit is contained in:
Nirvana
2026-10-11 08:13:47 +02:00
parent 3147ae85ad
commit e9769a4a2a
39 changed files with 5253 additions and 1744 deletions
@@ -0,0 +1,301 @@
# streaming_providers/base/testing/migration_pack.py
"""
Migration pack -- everything a migrator needs to know about a v1 provider
BEFORE touching it, collected automatically so nobody has to guess or ask:
python -m streaming_providers.base.testing.migration_pack <key> \
--repo <path to the repo root> [--extra-root <other repo> ...] \
[-o pack.txt] [--json before.json] [--compare before.json]
Sections of the report
1. REGISTRY how the class is registered and how the registry builds it:
__init__ signature, SUPPORTED_COUNTRIES, instances the
metadata mixin reports, and the SOURCE of the registry
methods that construct providers (create_instance, ...)
2. SURFACE every attribute the provider classes define (below the
base), with kind and signature. Snapshot it before the
migration (--json) and compare after (--compare): a
migration PRESERVES the public surface, it never adds to it.
3. LEGACY HELPERS source of every `self.<name>` the provider uses that is
defined in a legacy mixin / the base class (e.g.
_build_provider_headers, _setup_http_manager): you read
them here instead of asking for the files.
4. BACK-REFERENCES managers that reach into the provider (`self._provider.x`,
`provider.x`): each one needs an explicit collaborator in v2.
5. EXTERNAL USERS where the class name, the provider key and the commonly
moved attributes are referenced OUTSIDE the provider
directory (and in --extra-root repos: Kodi addon, PVR addon,
backend ...). Run it with ALL repos you have.
Read-only: it imports the package and reads files; it never instantiates the
provider and performs no network calls.
"""
import argparse
import ast
import inspect
import json
import os
import re
import sys
from typing import Dict, Iterable, List, Optional
MOVED_ATTRS = (
"_last_auth_error", "_profile", "bearer_token", "entitlement_tag",
"channel_manager", "_playout_cache", "_ensure_authenticated",
"set_user_credentials", "get_auth_details", "get_last_auth_error",
"authenticator", "provider_config", "http_manager", "get_profile",
)
TEXT_EXT = (".py", ".json", ".xml", ".yaml", ".yml", ".cpp", ".h", ".hpp",
".md", ".txt", ".po", ".ini", ".cfg", ".js", ".ts")
SKIP_DIRS = {".git", "__pycache__", "node_modules", "build", "dist", ".venv", "venv", ".idea"}
MAX_HITS = 150
# --------------------------------------------------------------------------
# 1. registry
# --------------------------------------------------------------------------
def registry_facts(cls, key: str) -> str:
out = [f"class {cls.__module__}.{cls.__qualname__}",
f"registry key {key!r}",
f"__init__ {_sig(cls.__init__)}"]
countries = getattr(cls, "SUPPORTED_COUNTRIES", None)
out.append(f"SUPPORTED_COUNTRIES = {countries!r}")
for name in ("supports_multiple_countries", "get_plugin_key", "get_all_possible_instances"):
fn = getattr(cls, name, None)
if callable(fn):
try:
out.append(f"{name}() -> {fn()!r}")
except Exception as exc: # noqa: BLE001
out.append(f"{name}() raised {type(exc).__name__}: {exc}")
try:
import importlib
reg = importlib.import_module(cls.__module__.split(".providers.")[0] + ".base.provider_registry")
except Exception as exc: # noqa: BLE001
out.append(f"\n(provider_registry not importable: {exc})")
return "\n".join(out)
for owner in ("ProviderMetadata", "ProviderRegistry"):
klass = getattr(reg, owner, None)
for meth in ("create_instance", "_extract_metadata", "discover_all_providers", "get_provider"):
fn = getattr(klass, meth, None) if klass else None
if fn is not None:
out.append(f"\n--- {owner}.{meth} ---\n{_source(fn)}")
return "\n".join(out)
# --------------------------------------------------------------------------
# 2. surface
# --------------------------------------------------------------------------
def _is_base(klass) -> bool:
mod = getattr(klass, "__module__", "") or ""
return klass is object or ".base." in mod + "." and (
".base.provider" in mod or ".base.managed_provider" in mod
or ".provider_mixins." in mod or ".base.models" in mod or ".base.managers" in mod)
def surface(cls) -> Dict[str, dict]:
snap: Dict[str, dict] = {}
for klass in cls.__mro__:
if _is_base(klass) or klass is object:
continue
for name, value in vars(klass).items():
if name.startswith("__") or name in snap:
continue
if isinstance(value, property):
kind, sig = "property", ""
elif isinstance(value, classmethod):
kind, sig = "classmethod", _sig(value.__func__)
elif isinstance(value, staticmethod):
kind, sig = "staticmethod", _sig(value.__func__)
elif callable(value):
kind, sig = "method", _sig(value)
else:
kind, sig = "attribute", ""
snap[name] = {"kind": kind, "sig": sig, "defined_in": klass.__qualname__}
return snap
def diff_surfaces(before: Dict[str, dict], after: Dict[str, dict]) -> str:
lines = []
for name in sorted(set(before) - set(after)):
lines.append(f"REMOVED {name} ({before[name]['kind']} {before[name]['sig']})")
for name in sorted(set(after) - set(before)):
lines.append(f"ADDED {name} ({after[name]['kind']} {after[name]['sig']})")
for name in sorted(set(before) & set(after)):
if before[name]["kind"] != after[name]["kind"] or before[name]["sig"] != after[name]["sig"]:
lines.append(f"CHANGED {name}: {before[name]['kind']} {before[name]['sig']}"
f" -> {after[name]['kind']} {after[name]['sig']}")
return "\n".join(lines) or "(no differences)"
# --------------------------------------------------------------------------
# 3 + 4. legacy helpers and back-references
# --------------------------------------------------------------------------
def _self_names(tree: ast.AST, owner: str = "self") -> List[str]:
names = []
for node in ast.walk(tree):
if isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name) and node.value.id == owner:
names.append(node.attr)
return names
def _package_files(cls) -> List[str]:
module = sys.modules.get(cls.__module__)
path = getattr(module, "__file__", None)
if not path:
return []
folder = os.path.dirname(path)
return sorted(os.path.join(folder, f) for f in os.listdir(folder) if f.endswith(".py"))
def legacy_helpers(cls) -> str:
own_file = inspect.getsourcefile(cls)
names = []
with open(own_file, encoding="utf-8") as fh:
names = _self_names(ast.parse(fh.read()))
seen, out = set(), []
for name in names:
if name in seen:
continue
seen.add(name)
for klass in cls.__mro__:
if name in vars(klass):
if klass is cls or not _is_base(klass) or klass.__module__.endswith("managed_provider"):
break
member = vars(klass)[name]
member = getattr(member, "fget", None) or getattr(member, "__func__", member)
if callable(member):
out.append(f"--- {klass.__qualname__}.{name} ({klass.__module__}) ---\n{_source(member)}")
break
return "\n\n".join(out) or "(the provider uses no legacy helper)"
def back_references(cls) -> str:
own = inspect.getsourcefile(cls)
hits = []
for path in _package_files(cls):
if os.path.abspath(path) == os.path.abspath(own):
continue
with open(path, encoding="utf-8") as fh:
tree = ast.parse(fh.read())
for owner in ("_provider", "provider"):
attrs = sorted(set(_self_names(tree, owner)))
# attribute access on `self._provider.<name>`
for node in ast.walk(tree):
if (isinstance(node, ast.Attribute) and isinstance(node.value, ast.Attribute)
and isinstance(node.value.value, ast.Name) and node.value.value.id == "self"
and node.value.attr in ("_provider", "provider")):
hits.append(f"{os.path.basename(path)}:{node.lineno} self.{node.value.attr}.{node.attr}")
return "\n".join(sorted(set(hits))) or "(no manager reaches into the provider)"
# --------------------------------------------------------------------------
# 5. external users
# --------------------------------------------------------------------------
def external_references(roots: Iterable[str], key: str, class_name: str, exclude_dir: Optional[str]) -> str:
patterns = {
f"class name {class_name}": re.compile(rf"\b{re.escape(class_name)}\b"),
f"provider key '{key}'": re.compile(rf"""["']{re.escape(key)}(?:_[a-z]{{2,3}})?["']"""),
"moved/compat attributes": re.compile(r"\.(" + "|".join(map(re.escape, MOVED_ATTRS)) + r")\b"),
}
results = {label: [] for label in patterns}
exclude = os.path.abspath(exclude_dir) if exclude_dir else None
for root in roots:
for folder, dirs, files in os.walk(root):
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
if exclude and os.path.abspath(folder).startswith(exclude):
continue
for name in files:
if not name.endswith(TEXT_EXT):
continue
path = os.path.join(folder, name)
try:
with open(path, encoding="utf-8", errors="ignore") as fh:
for number, line in enumerate(fh, 1):
for label, rx in patterns.items():
if rx.search(line) and len(results[label]) < MAX_HITS:
results[label].append(f"{path}:{number}: {line.strip()[:140]}")
except OSError:
continue
parts = []
for label, hits in results.items():
body = "\n".join(hits) if hits else "(none)"
parts.append(f"## {label}\n{body}")
return "\n\n".join(parts)
# --------------------------------------------------------------------------
# helpers / CLI
# --------------------------------------------------------------------------
def _sig(fn) -> str:
try:
return str(inspect.signature(fn))
except (TypeError, ValueError):
return "(?)"
def _source(fn) -> str:
try:
return inspect.getsource(fn)
except (OSError, TypeError):
return f"(source not available for {fn!r})"
def build_report(key: str, roots: List[str], extra: List[str]) -> str:
import streaming_providers
registry = getattr(streaming_providers, "AVAILABLE_PROVIDERS", {})
if key not in registry:
return f"unknown provider key {key!r}; registered: {sorted(registry)}"
cls = registry[key]
provider_dir = os.path.dirname(inspect.getsourcefile(cls))
sections = [
("1. REGISTRY", registry_facts(cls, key)),
("2. SURFACE (snapshot with --json before, --compare after)",
"\n".join(f"{n:34} {v['kind']:12} {v['sig']} [{v['defined_in']}]"
for n, v in sorted(surface(cls).items()))),
("3. LEGACY HELPERS used by the provider (source)", legacy_helpers(cls)),
("4. BACK-REFERENCES from managers to the provider", back_references(cls)),
("5. EXTERNAL USERS (outside the provider directory)",
external_references(roots + extra, key, cls.__name__, provider_dir)),
]
return "\n\n".join(f"{'=' * 78}\n{title}\n{'=' * 78}\n{body}" for title, body in sections)
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("key")
ap.add_argument("--repo", action="append", default=[], help="repo root to search (repeatable)")
ap.add_argument("--extra-root", action="append", default=[], help="additional repos (Kodi addon, PVR addon, ...)")
ap.add_argument("-o", "--output")
ap.add_argument("--json", help="write the surface snapshot to this file")
ap.add_argument("--compare", help="compare the current surface with a snapshot file")
args = ap.parse_args(argv)
import streaming_providers
cls = getattr(streaming_providers, "AVAILABLE_PROVIDERS", {}).get(args.key)
if args.json or args.compare:
if cls is None:
print(f"unknown provider key {args.key!r}")
return 2
current = surface(cls)
if args.json:
with open(args.json, "w", encoding="utf-8") as fh:
json.dump(current, fh, indent=2, sort_keys=True)
print(f"surface snapshot written to {args.json} ({len(current)} names)")
if args.compare:
with open(args.compare, encoding="utf-8") as fh:
print(diff_surfaces(json.load(fh), current))
return 0
report = build_report(args.key, args.repo, args.extra_root)
if args.output:
with open(args.output, "w", encoding="utf-8") as fh:
fh.write(report)
print(f"written to {args.output}")
else:
print(report)
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,158 @@
# streaming_providers/base/testing/provider_contract.py
"""
Provider contract -- checks every v2 (ManagedProvider) provider must pass.
from streaming_providers.base.testing.provider_contract import check_provider
problems, warnings = check_provider(ExampleProvider, "example")
assert problems == []
It instantiates the provider with a NETWORK-DISABLED http manager (every call
raises ConnectionError), so it also proves that the constructor performs no
network I/O. Nothing here talks to a real backend.
Errors (must be fixed) vs warnings (advisory):
errors
* class is a ManagedProvider; explicit HEADERS_FROM_MANAGERS in the class
body (the decision must be visible, not inherited)
* instantiation works offline with country = first SUPPORTED_COUNTRIES
(plus `ctor_kwargs` for providers whose constructor requires more)
* provider_name == registry key, get_plugin_key() == registry key
* capability flags agree with the managers that exist
* DRM declared: a manager overrides get_*_drm => DRM_IN_MANAGERS or
_build_drm(); DRM_IN_MANAGERS=True with no manager implementing it is
a mistake; both at once is refused by ManagedProvider itself
* header hooks overridden on a manager => HEADERS_FROM_MANAGERS must be
True (otherwise the hooks are dead code)
* EPG: no manager => epg_window == (0, 0); manager => implements_epg agrees
with epg_window != (0, 0)
* recordings: schedule_recording overridden => supports_scheduling True
* the provider package exports only the provider in __all__ and does not
expose ManagedProvider in its namespace (discovery picks the first
StreamingProvider subclass it finds)
warnings
* country not normalised to lowercase after construction
* empty SUPPORTED_COUNTRIES, empty PROVIDER_LABEL
"""
import sys
from contextlib import contextmanager
from typing import List, Optional, Tuple
from unittest import mock
from ..managed_provider import ManagedProvider
from ..managers import CatchupManager, ChannelManager, VodManager
class NullHttp:
"""HTTP manager that fails every call: proves 'no network in __init__'."""
def __getattr__(self, name):
def _blocked(*args, **kwargs):
raise ConnectionError(f"network disabled in contract test ({name})")
return _blocked
@contextmanager
def _offline(cls):
with mock.patch.object(cls, "_setup_http_manager", lambda self, **kw: NullHttp(), create=True):
yield
def _overrides(obj, abc, name) -> bool:
return obj is not None and getattr(type(obj), name) is not getattr(abc, name)
def check_provider(cls, key: str, ctor_kwargs: Optional[dict] = None) -> Tuple[List[str], List[str]]:
errors: List[str] = []
warnings: List[str] = []
if not (isinstance(cls, type) and issubclass(cls, ManagedProvider)):
return [f"{cls!r} is not a ManagedProvider subclass"], warnings
declared = [
k for k in cls.__mro__
if k is not ManagedProvider and issubclass(k, ManagedProvider)
and "HEADERS_FROM_MANAGERS" in vars(k)
]
if not declared:
errors.append("HEADERS_FROM_MANAGERS is not declared in the class body "
"(decide: True = manager header hooks, False = legacy {})")
countries = list(getattr(cls, "SUPPORTED_COUNTRIES", []) or [])
if not countries:
warnings.append("SUPPORTED_COUNTRIES is empty (single-country default)")
if not getattr(cls, "PROVIDER_LABEL", ""):
warnings.append("PROVIDER_LABEL is empty")
try:
with _offline(cls):
# ctor_kwargs: constructor arguments a provider REQUIRES beyond
# `country` (e.g. Joyn's config object); they override the default.
kwargs = {"country": countries[0] if countries else "DE", **(ctor_kwargs or {})}
inst = cls(**kwargs)
except Exception as exc: # noqa: BLE001 - report, don't crash the suite
return errors + [f"cannot instantiate offline: {type(exc).__name__}: {exc}"], warnings
if inst.provider_name != key:
errors.append(f"provider_name {inst.provider_name!r} != registry key {key!r}")
try:
if cls.get_plugin_key() != key:
errors.append(f"get_plugin_key() {cls.get_plugin_key()!r} != registry key {key!r}")
except Exception as exc: # noqa: BLE001
errors.append(f"get_plugin_key() failed: {exc}")
if inst.country != inst.country.lower():
warnings.append(f"country {inst.country!r} is not lower-case after construction")
# -- flags vs managers
caps = inst.capabilities
for name in ("channels", "vod", "recordings", "favorites", "bookmarks"):
if caps[name] != (getattr(inst, name) is not None):
errors.append(f"implements_{name} disagrees with the {name} manager")
# -- DRM declaration
folded = _overrides(inst.channels, ChannelManager, "get_channel_drm") or \
_overrides(inst.vod, VodManager, "get_vod_drm")
if folded and not (cls.DRM_IN_MANAGERS or inst.drm is not None):
errors.append("a manager implements get_*_drm but DRM_IN_MANAGERS is False "
"and no _build_drm(): the DRM is never served")
if cls.DRM_IN_MANAGERS and not folded:
errors.append("DRM_IN_MANAGERS = True but no manager overrides get_channel_drm/get_vod_drm")
# -- dead header hooks
hooks = (
_overrides(inst.channels, ChannelManager, "get_channel_manifest_headers"),
_overrides(inst.channels, ChannelManager, "get_segment_headers"),
_overrides(inst.vod, VodManager, "get_vod_manifest_headers"),
_overrides(inst.vod, VodManager, "get_segment_headers"),
_overrides(inst.catchup, CatchupManager, "get_catchup_manifest_headers"),
_overrides(inst.catchup, CatchupManager, "get_catchup_segment_headers"),
)
if any(hooks) and not cls.HEADERS_FROM_MANAGERS:
errors.append("a manager overrides header hooks but HEADERS_FROM_MANAGERS is False: "
"the hooks are dead code")
# -- EPG
if inst.epg is None:
if inst.epg_window != (0, 0) or inst.implements_epg:
errors.append("no EPG manager but epg_window != (0, 0) / implements_epg")
elif inst.implements_epg != (inst.epg_window != (0, 0)):
errors.append("implements_epg disagrees with epg_window != (0, 0)")
# -- recordings
if inst.recordings is not None:
from ..managers import RecordingsManager
if _overrides(inst.recordings, RecordingsManager, "schedule_recording") \
and not inst.recordings.supports_scheduling:
errors.append("schedule_recording is overridden but supports_scheduling is False")
# -- package namespace (discovery safety)
package = sys.modules.get(cls.__module__.rsplit(".", 1)[0])
if package is not None:
exported = getattr(package, "__all__", None)
if exported is None or cls.__name__ not in exported:
errors.append("package __init__ must define __all__ containing the provider class")
if hasattr(package, "ManagedProvider"):
errors.append("package namespace exposes ManagedProvider (discovery could pick it)")
return errors, warnings
@@ -0,0 +1,60 @@
# Migration brief — `<key>` (v1 → v2)
Fill this in **from the migration pack** before the migration starts, and hand it
to the migrator together with `pack.txt` and `before.json`. A migrator should not
have to ask anything that is answered here. Anything marked ❓ that you cannot
answer is a blocker — stop and get the answer; do not guess.
```bash
python -m streaming_providers.base.testing.migration_pack <key> --repo <repo> \
--extra-root <kodi addon> --extra-root <pvr addon> -o pack.txt
python -m streaming_providers.base.testing.migration_pack <key> --json before.json
```
## 1. Identity
| | |
|---|---|
| Directory / registry key / `provider_name` / credentials key | ❓ all equal? (§4) |
| Class name | |
| `SUPPORTED_COUNTRIES` (pack §1) | `[]` / one entry / several |
| How the registry calls the constructor (pack §1, source of `create_instance`) | ❓ `cls(country=…)`? `config`? extras? |
| Constructor signature the migrated class must keep | |
| Extra `ctor_kwargs` the contract test needs | |
| **Id grammar**: how are live ids and VOD ids told apart? (→ `handles_content_id` on both managers) | ❓ |
## 2. Capabilities (what v1 really has)
channels ☐ vod ☐ epg ☐ recordings ☐ favorites ☐ bookmarks ☐ catchup ☐ drm ☐ timers ☐(not delegated) events ☐(not delegated)
For each: does a v1 manager class already exist? Does it follow the 4-collaborator
constructor, or take the provider (`__init__(self, provider)`)? ❓
## 3. Decisions (README reference)
| Decision | Answer | Evidence (pack section) |
|---|---|---|
| Auth variant (§9): A native / B session adapter | | where does the login live? |
| `DRM_IN_MANAGERS` (§11): folded / dedicated / none | | do manifest and DRM share one response? |
| `HEADERS_FROM_MANAGERS` (§10) | | do v1 manager header hooks exist and are they dead code? what does the legacy helper (pack §3) do? |
| Shared state: entitlement / profile / … (§5.1) | session / dedicated collaborator / cache / helper | every back-reference in pack §4 mapped to a row |
| Credentials API (`set_user_credentials`, `get_auth_details`, …) | keep / drop | callers found (pack §5) **and** does the v1 surface (`before.json`) define them? Dropping a method v1 has is a REMOVED line |
| Token buffer(s) in the authenticator (grep `expires_in -`) | keep (reason: ___) / remove (device test: ___) | never remove by reflex (§9) |
| Provider-specific exceptions callers rely on | are they `ProviderError` subclasses? if not: make them so or `passthrough=` in `transport_errors` | `issubclass(X, ProviderError)` |
| `get_events` / `enrich_channel_data` / `get_program_details` | real implementation → keep on provider; inert stub → delete | §12.2 |
| Manager method renames (`get_manifest` → `get_channel_manifest`, …) | list | §12.2 |
| Failure behaviour (§8, §12.5) | v1 returns `[]`/`None` → v2 raises | OK for the UI? |
| VOD return shape | list / dict / `VodPage` | wrap with `normalize_vod_result` |
## 4. Legacy helpers the provider calls (pack §3)
| Helper | What it does (read the source) | v2 replacement (§12.6b) |
|---|---|---|
| | | |
## 5. External users (pack §5)
| Where | What it uses | Action (delegate / call-site fix / none) |
|---|---|---|
| | | |
## 6. Behaviour changes accepted (§12.5)
☐ typed errors instead of `[]`/`None` ☐ headers from manager hooks ☐ capabilities now reachable (list which) ☐ eager login removed ☐ token buffer changed (reason recorded) ☐ other: ______
## 7. Done when
Definition of done (README §18); `--compare before.json` reviewed — expected: REMOVED = deleted boilerplate/stubs/delegated flags, ADDED = only `_build_*`, `DRM_IN_MANAGERS`, `HEADERS_FROM_MANAGERS` (anything else is unexplained); device checklist (§12.7) signed off.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,11 @@
# streaming_providers/providers/example/__init__.py
"""
Example streaming provider.
Public API:
from streaming_providers.providers.example import ExampleProvider
"""
from .provider import ExampleProvider
__all__ = ["ExampleProvider"]
@@ -0,0 +1,111 @@
# streaming_providers/providers/example/auth.py
"""
Example authentication -- VARIANT A: the auth class itself satisfies
AuthProtocol (get_access_token / build_headers / invalidate), so managers
receive it directly as their `auth` collaborator.
Use Variant B (optional/session_adapter.py) instead when you already have
a stateful BaseAuthenticator with persisted sessions (see the Allente
provider): the adapter sits in front of it.
Rules (all of them learned the hard way):
* LAZY: no network call in __init__. The first caller that needs a token
triggers the login.
* One login for concurrent callers (lock + re-check after acquiring it).
* Typed errors, never None/[]: AuthError for credentials problems,
GeoBlockError for geo blocks, ServerError for everything unexpected.
* The token travels in a header HERE; if your API wants it in the query
string or body, add with_token()/auth_body() helpers and keep headers
token-free (simpli does this).
* invalidate() must also drop anything persisted, or a restart resurrects
the old session.
"""
import threading
import time
from typing import Dict, Optional
from ...base.errors import AuthError
from ...base.utils.logger import logger
from ...base.utils.transport import transport_errors
from .constants import ExampleConfig, ExampleDefaults
_PROVIDER = ExampleDefaults.PROVIDER_LABEL
class ExampleAuth:
def __init__(self, *, http_manager, config: ExampleConfig, credentials=None):
self._http = http_manager
self._config = config
self._credentials = credentials # object with .username / .password
self._token: Optional[str] = None
self._expires_at: float = 0.0
self._lock = threading.RLock()
# ------------------------------------------------------------------
# Credentials
# ------------------------------------------------------------------
@property
def has_credentials(self) -> bool:
c = self._credentials
return bool(c and getattr(c, "username", None) and getattr(c, "password", None))
def set_credentials(self, credentials) -> None:
"""Replace the credentials and drop the current session."""
self._credentials = credentials
self.invalidate()
# ------------------------------------------------------------------
# AuthProtocol
# ------------------------------------------------------------------
def get_access_token(self, force_refresh: bool = False) -> str:
if not force_refresh and self._token_valid():
return self._token
with self._lock:
if not force_refresh and self._token_valid(): # another thread logged in
return self._token
self._login()
return self._token
def build_headers(self) -> Dict[str, str]:
"""API headers incl. the bearer token (not for CDN requests)."""
return self._config.api_headers(self.get_access_token())
def invalidate(self) -> None:
self._token = None
self._expires_at = 0.0
# Variant with persistence: also clear the persisted session here.
# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------
def _token_valid(self) -> bool:
return bool(
self._token
and time.time() < self._expires_at - ExampleDefaults.TOKEN_REFRESH_BUFFER_SECONDS
)
def _login(self) -> None:
if not self.has_credentials:
raise AuthError(
f"{_PROVIDER}: no credentials configured. "
f"Set username and password in the addon settings."
)
with transport_errors("login", _PROVIDER):
# TODO: map your HTTP layer's 401/403 to AuthError here (and a
# geo-block answer to GeoBlockError); transport_errors lets
# typed errors through and wraps everything else in ServerError.
resp = self._http.post(
self._config.url(ExampleDefaults.PATH_LOGIN),
json={
"username": self._credentials.username,
"password": self._credentials.password,
},
headers=self._config.api_headers(),
operation="auth",
)
data = resp.json()
self._token = data["accessToken"]
lifetime = float(data.get("expiresIn") or ExampleDefaults.TOKEN_LIFETIME_SECONDS)
self._expires_at = time.time() + lifetime
logger.info(f"{_PROVIDER}: authenticated")
@@ -0,0 +1,124 @@
# streaming_providers/providers/example/channel_manager.py
"""
Example channel manager -- the one manager every live provider needs.
Contract (ChannelManager ABC; see README §Managers):
* get_channels() -> List[Channel] [abstract]
* get_channel_manifest(id) -> Optional[str] [abstract]
None = "not mine" (the router tries the next manager)
raise = a real failure (typed error from base.errors)
* get_channel_manifest_headers -> headers for the MPD fetch [override]
* get_segment_headers -> default = manifest headers
* get_channel_drm -> [] = no DRM [override]
* handles_content_id -> default True; override when ids of
several managers coexist (cheap, pure,
NO I/O)
Here manifest and DRM share ONE playout response, so DRM is folded into this
manager (provider: DRM_IN_MANAGERS = True) and a 5 s cache lets the two
separate backend calls (get_manifest, get_drm) share one request.
"""
import time
from typing import Any, Dict, List, Optional, Tuple
from ...base.managers import ChannelManager
from ...base.models import Channel, DRMConfig
from ...base.utils.logger import logger
from ...base.utils.transport import transport_errors
from .constants import ExampleDefaults
from .drm import create_widevine_config
from .models import ExampleChannel, ExamplePlayout
_PROVIDER = ExampleDefaults.PROVIDER_LABEL
class ExampleChannelManager(ChannelManager):
def __init__(
self,
*,
http_manager: Any,
auth: Any,
country: str,
config: Any,
playout_cache: Optional[Dict[str, Tuple[ExamplePlayout, float]]] = None,
):
# Extra collaborators are keyword-only, stored AFTER super().__init__,
# and super() gets ONLY the four required ones (no **kwargs anywhere:
# a typo at a call site must be an immediate TypeError).
super().__init__(
http_manager=http_manager, auth=auth, country=country, config=config
)
# Provider-owned dict, borrowed by reference. The provider clears it
# when credentials change.
self._playout_cache = playout_cache if playout_cache is not None else {}
# ------------------------------------------------------------------
# Channels
# ------------------------------------------------------------------
def get_channels(self, **kw: Any) -> List[Channel]:
with transport_errors("get_channels", _PROVIDER):
# No raise_for_status(): HTTPManager raises for 4xx/5xx itself.
resp = self.http_manager.get(
self.config.url(ExampleDefaults.PATH_CHANNELS),
headers=self.auth.build_headers(), # raises AuthError etc.
operation="api",
)
raw = resp.json().get("channels", [])
channels: List[ExampleChannel] = []
for entry in raw:
try:
channels.append(ExampleChannel.from_api_response(entry))
except (KeyError, TypeError, AttributeError) as exc:
logger.warning(f"{_PROVIDER}: skipping malformed channel entry: {exc!r}")
return [c.to_channel(ExampleDefaults.PROVIDER_NAME) for c in channels]
# ------------------------------------------------------------------
# Playout (shared by manifest and DRM)
# ------------------------------------------------------------------
def clear_playout_cache(self) -> None:
self._playout_cache.clear()
def _playout(self, content_id: str) -> ExamplePlayout:
now = time.time()
cached = self._playout_cache.get(content_id)
if cached and (now - cached[1]) < ExampleDefaults.PLAYOUT_CACHE_TTL:
return cached[0]
with transport_errors(f"playout for {content_id}", _PROVIDER):
resp = self.http_manager.get(
self.config.url(ExampleDefaults.PATH_PLAYOUT, channel_id=content_id),
headers=self.auth.build_headers(),
operation="api",
)
# ValueError on a malformed payload becomes a ServerError here.
info = ExamplePlayout.from_api_response(resp.json())
self._playout_cache[content_id] = (info, now)
return info
# ------------------------------------------------------------------
# Manifest / headers
# ------------------------------------------------------------------
def get_channel_manifest(self, content_id: str, **kw: Any) -> Optional[str]:
return self._playout(content_id).stream_url
def get_channel_manifest_headers(self, content_id: str, **kw: Any) -> Dict[str, str]:
# CDN headers, NOT the API headers the ABC default would return.
# This override only takes effect with HEADERS_FROM_MANAGERS = True.
return self.config.stream_headers()
# ------------------------------------------------------------------
# DRM (folded)
# ------------------------------------------------------------------
def get_channel_drm(self, content_id: str, **kw: Any) -> List[DRMConfig]:
# The backend calls get_drm(content_id=..., **kw) with kwargs such as
# drm_variant / preferred_quality / preferred_format (+ proxy extras)
# and never passes content_type: accept **kw, ignore what you don't use.
playout = self._playout(content_id)
if not playout.license_url:
return [] # clear stream
return [create_widevine_config(
cfg=self.config,
access_token=self.auth.get_access_token(),
license_url=playout.license_url,
)]
@@ -0,0 +1,95 @@
# streaming_providers/providers/example/constants.py
"""
Example constants -- the SINGLE SOURCE OF TRUTH for every URL, header value,
identifier and default of this provider. No magic strings anywhere else
(managers, drm.py and auth.py all read from here).
Identity rule (the one that bites): ONE id, used everywhere
directory name == AVAILABLE_PROVIDERS key == provider_name
== PROVIDER_NAME == credentials / session / enable-setting key
Never rename it after release (stored credentials and enable flags are
keyed by it).
"""
from typing import Optional
class ExampleDefaults:
PROVIDER_NAME = "example"
PROVIDER_LABEL = "Example" # shown in the UI, without country
PROVIDER_LOGO = "https://example.invalid/logo.png"
# SUPPORTED_COUNTRIES semantics (see README §Countries):
# ()/[] single-country provider, default country
# ("DE",) exactly one: the registry pins the construction country
# to this entry (UPPERCASE, verbatim)
# ("DE","AT") several: the registry fans out example_de, example_at
# (lowercase)
SUPPORTED_COUNTRIES = ("DE",)
# --- Hosts / paths -------------------------------------------------
BASE_URL = "https://api.example.invalid"
ORIGIN = "https://www.example.invalid"
PATH_LOGIN = "/v1/login"
PATH_CHANNELS = "/v1/channels"
PATH_PLAYOUT = "/v1/playout/{channel_id}"
# --- Headers -------------------------------------------------------
USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0"
TIMEOUT = 30
# --- Auth ----------------------------------------------------------
# Fallback only: prefer the expiry the login response carries.
TOKEN_LIFETIME_SECONDS = 3600
# Re-login this long before the token really expires.
TOKEN_REFRESH_BUFFER_SECONDS = 300
# --- Caches --------------------------------------------------------
PLAYOUT_CACHE_TTL = 5.0 # manifest + DRM share one playout call
class ExampleConfig:
"""
Per-instance configuration.
ONE instance is created by the provider and SHARED with auth, managers
and drm.py. Never rebuild a second config from a subset of values --
that is how header drift between layers happens.
"""
def __init__(self, config_dict: Optional[dict] = None):
config = config_dict or {}
self.base_url = config.get("base_url", ExampleDefaults.BASE_URL)
self.user_agent = config.get("user_agent", ExampleDefaults.USER_AGENT)
self.timeout = config.get("timeout", ExampleDefaults.TIMEOUT)
# ----- URL builders (no f-strings with hosts anywhere else) --------
def url(self, path: str, **fmt) -> str:
return f"{self.base_url}{path.format(**fmt)}"
# ----- Header builders ---------------------------------------------
def api_headers(self, access_token: Optional[str] = None) -> dict:
"""Headers for the provider's JSON API (NOT for the CDN)."""
headers = {
"User-Agent": self.user_agent,
"Accept": "application/json",
"Content-Type": "application/json",
"Origin": ExampleDefaults.ORIGIN,
}
if access_token:
headers["Authorization"] = f"Bearer {access_token}"
return headers
def stream_headers(self) -> dict:
"""
Headers for MPD / segment fetches by the player (CDN).
Keep this separate from api_headers(): the CDN usually wants only
User-Agent / Origin / Referer, and sending API headers (JSON
Content-Type, tenant ids, tokens) to it is noise at best and a
token leak at worst.
"""
return {
"User-Agent": self.user_agent,
"Origin": ExampleDefaults.ORIGIN,
}
@@ -0,0 +1,67 @@
# streaming_providers/providers/example/drm.py
"""
Widevine DRMConfig builder (provider-local, because the license endpoint is
provider-specific). The API usage below is copied from the verified Allente
builder -- adjust the three things that vary per provider:
1. license URL and request headers
2. req_data template (placeholders: {CHA-RAW} raw challenge,
{CHA-B64} base64 challenge)
3. wrapper / unwrapper (how the license request/response is wrapped)
Rules:
* NEVER duplicate URLs / header values here: read them from constants.py.
* Give every DRMConfig of one content an explicit, DISTINCT priority
(the factories default to 1; validate_drm_set rejects duplicates).
* Pre-encode req_headers yourself with quote_via=quote (spaces -> %20)
when a value contains spaces or ';' (User-Agent): the dict path is
encoded by LicenseConfig and the plain-header parser splits on ';'.
* Returning [] means "no DRM"; a broken configuration RAISES (a
LicenseConfigError is a ConfigurationError) -- do not swallow it.
* ISA runs outside Python's HTTPManager: a ProxyConfig does NOT apply to
license / manifest / segment requests made by inputstream.adaptive.
* Tokens embedded here live as long as ISA caches the config (the whole
playback session): document the token-expiry limitation.
* Verify on a device (dump ISA's outgoing license request and compare it
byte-for-byte with the browser capture) before shipping.
"""
import json
from urllib.parse import quote, urlencode
from ...base.models.drm import (
DRMConfig,
DRMSystem,
LicenseConfig,
LicenseUnwrapperParams,
)
from .constants import ExampleConfig
def create_widevine_config(
cfg: ExampleConfig,
access_token: str,
license_url: str,
priority: int = 1,
) -> DRMConfig:
headers = {
"content-type": "application/json",
"user-agent": cfg.user_agent,
"authorization": f"Bearer {access_token}",
}
req_headers = urlencode(headers, quote_via=quote)
license_config = LicenseConfig.create_with_req_data(
req_data_template=json.dumps({"playerPayload": "{CHA-B64}"}),
server_url=license_url,
req_headers=req_headers,
use_http_get_request=False,
wrapper="none",
unwrapper="json,base64",
unwrapper_params=LicenseUnwrapperParams(path_data="license"),
)
drm_config = DRMConfig(
system=DRMSystem.WIDEVINE, priority=priority, license=license_config
)
drm_config.validate() # raises LicenseConfigError on misuse
return drm_config
@@ -0,0 +1,56 @@
# streaming_providers/providers/example/models.py
"""
Provider-local data models: plain dataclasses + from_api_response parsers.
No business logic, no network calls.
Parsing is STRICT on purpose (KeyError on missing required fields); the
manager parses entry-by-entry and skips malformed entries, so one bad
channel cannot take the whole list down.
"""
from dataclasses import dataclass
from typing import Any, Dict, Optional
from ...base.models import Channel
@dataclass
class ExampleChannel:
id: str
name: str
logo_url: Optional[str] = None
@classmethod
def from_api_response(cls, data: Dict[str, Any]) -> "ExampleChannel":
return cls(id=data["id"], name=data["name"], logo_url=data.get("logoUrl"))
def to_channel(self, provider_name: str) -> Channel:
# NOTE the factory parameter names: create_live_channel and
# create_radio_channel take `channel_id`, create_vod_channel takes
# `content_id`.
channel = Channel.create_live_channel(
name=self.name, channel_id=self.id, provider=provider_name
)
channel.logo_url = self.logo_url or ""
channel.catchup_hours = 0 # set only when catchup is really offered
return channel
@dataclass
class ExamplePlayout:
"""Response of the playout endpoint (manifest + optional DRM)."""
stream_url: str
stream_id: str
license_url: Optional[str] = None # None = clear stream
@classmethod
def from_api_response(cls, data: Dict[str, Any]) -> "ExamplePlayout":
stream = data.get("stream") if isinstance(data, dict) else None
if not isinstance(stream, dict) or not stream.get("url") or not stream.get("id"):
raise ValueError("playout response is missing stream.url / stream.id")
return cls(
stream_url=stream["url"],
stream_id=stream["id"],
license_url=stream.get("licenseUrl"),
)
@@ -0,0 +1,47 @@
# streaming_providers/providers/example/bookmarks_manager.py
"""
Bookmarks (resume position) manager skeleton.
--- WIRING ---
from .bookmarks_manager import ExampleBookmarksManager
def _build_bookmarks(self):
return ExampleBookmarksManager(http_manager=self.http_manager, auth=self.auth,
country=self.country, config=self.provider_config)
--- END WIRING ---
Contract: update_bookmark is called on EVERY playback stop/pause, so a
repeated write of the same position must be a harmless no-op.
position_seconds: 0 = start, -1 = completed (>= 95 % counts as completed too;
providers just store what they get). Parameter order is
(content_id, position_seconds, content_type, ...) -- the ProviderManager
facade uses (content_type, position_seconds); callers must use KEYWORDS (the
backend does). delete_bookmark raises ItemNotFoundError (also a KeyError).
"""
from typing import Any, List, Optional
from ....base.errors import ItemNotFoundError
from ....base.managers import BookmarksManager
from ....base.models.bookmark import Bookmark, ContentType
class ExampleBookmarksManager(BookmarksManager):
def get_bookmarks(self, **kw: Any) -> List[Bookmark]:
return [] # TODO
def update_bookmark(
self,
content_id: str,
position_seconds: int,
content_type: ContentType,
duration_seconds: Optional[int] = None,
title: Optional[str] = None,
**kw: Any,
) -> Bookmark:
# TODO: persist, then return the confirmed Bookmark
return Bookmark.create(
"example", content_id, content_type, position_seconds, duration_seconds, title
)
def delete_bookmark(self, content_id: str, **kw: Any) -> None:
raise ItemNotFoundError(content_id) # TODO
@@ -0,0 +1,66 @@
# streaming_providers/providers/example/catchup_manager.py
"""
Catchup manager skeleton.
--- WIRING ---
from .catchup_manager import ExampleCatchupManager
def _build_catchup(self):
if self.channels is None: # built after channels; may borrow them
return None
return ExampleCatchupManager(http_manager=self.http_manager, auth=self.auth,
country=self.country, config=self.provider_config,
channels=self.channels)
--- END WIRING ---
Contract (CatchupManager ABC) and how the backend uses it
(CatchupOperations):
* catchup_window_hours: the provider-wide MAXIMUM in hours (> 0 turns the
capability on). The backend gates on it and validates request age with it
WITHOUT knowing the channel. Per-channel windows go in
catchup_window_for_channel(content_id); enforce them yourself (return
None for a start outside the channel's window).
* get_catchup_manifest(content_id, start_time, end_time=None, epg_id=None, **kw)
times are UNIX SECONDS; end_time is Optional (pass None, never a
sentinel); returns None when there is no catchup for that content --
NEVER fall back to the live URL (the DRM pipeline would extract the
live PSSH).
* get_catchup_drm: [] means "no catchup-specific DRM". ManagedProvider
turns [] into NotImplementedError, which the backend reads as "extract
PSSH from the catchup manifest". Reusing the live DRM must be explicit:
return it yourself, or set CATCHUP_DRM_FROM_LIVE = True on the provider.
* Header hooks: if your catchup streams need the CDN headers, OVERRIDE
get_catchup_manifest_headers / get_catchup_segment_headers (the ABC
default is auth.build_headers() = API headers).
* "Restart" (live manifest + player-side seek) cannot be a URL: expose it
as a separate provider method (see simpli get_restart()).
* catchup:<channel>@<ts>-style ids are provider grammar: parse them in
the provider's get_manifest / get_drm override and hand the manager
plain (content_id, start_time).
"""
from typing import Any, Optional
from ....base.managers import CatchupManager
class ExampleCatchupManager(CatchupManager):
def __init__(self, *, http_manager, auth, country, config, channels=None):
super().__init__(
http_manager=http_manager, auth=auth, country=country, config=config
)
self._channels = channels
@property
def catchup_window_hours(self) -> int:
return 24 # TODO: the MAXIMUM across channels
def get_catchup_manifest(
self,
content_id: str,
start_time: int,
end_time: Optional[int] = None,
epg_id: Optional[str] = None,
**kw: Any,
) -> Optional[str]:
# TODO: resolve the catchup manifest; None when not available.
return None
@@ -0,0 +1,33 @@
# streaming_providers/providers/example/drm_manager.py
"""
DEDICATED DRM manager skeleton -- only when DRM does NOT share state with
the manifest step (otherwise fold DRM into the channel/VOD manager and set
DRM_IN_MANAGERS = True).
--- WIRING ---
from .drm_manager import ExampleDrmManager
def _build_drm(self):
return ExampleDrmManager(http_manager=self.http_manager, auth=self.auth,
config=self.provider_config)
(and set DRM_IN_MANAGERS = False -- ManagedProvider refuses both at once)
--- END WIRING ---
Implements DrmManagerProtocol.get_drm_configs(content_id, content_type=None,
**kw) -> List[DRMConfig]. Decision tree (README §DRM):
folded in managers (default) -> DRM_IN_MANAGERS = True
dedicated object -> _build_drm(), DRM_IN_MANAGERS = False
catchup with different DRM -> CatchupManager.get_catchup_drm
Each config needs a distinct priority; [] = no DRM; broken config raises.
"""
from typing import Any, List, Optional
class ExampleDrmManager:
def __init__(self, *, http_manager, auth, config):
self.http_manager, self.auth, self.config = http_manager, auth, config
def get_drm_configs(
self, content_id: str, content_type: Optional[str] = None, **kw: Any
) -> List:
return [] # TODO
@@ -0,0 +1,52 @@
# streaming_providers/providers/example/epg_manager.py
"""
EPG manager skeleton.
--- WIRING ---
from .epg_manager import ExampleEpgManager
def _build_epg(self):
return ExampleEpgManager(http_manager=self.http_manager, auth=self.auth,
country=self.country, config=self.provider_config)
--- END WIRING ---
Contract (EpgManager ABC):
* epg_window -> (past_days, future_days); (0, 0) means "no EPG".
The default is (0, 0): a provider WITHOUT EPG simply has no EPG manager.
* implements_epg is derived from epg_window != (0, 0) and the backend uses
it to choose between the native path (this manager) and the generic
XMLTV path. If epg_window needs a network call (server-advertised
window), OVERRIDE implements_epg to return True: it is read on every
get_epg call and in the registry listing.
* get_epg(channel_id, start_time, end_time, **kw) -> List[EPGEntry]
- the backend hands timezone-aware UTC datetimes (already clamped to
epg_window) plus limit= in **kw; EPGEntry.start/end are unix
SECONDS (int). Never compare naive and aware datetimes.
- channel_id == the Channel.content_id of get_channels() (strip your
own prefixes if the id carries any).
* get_epg_grid(channel_ids, ...) default loops get_epg; override with the
native batch endpoint when there is one (one fetch serves all channels).
* get_program_details(program_id) -> Optional[EPGProgramDetails]
* handles_channel_id: override to skip channels without EPG.
"""
from datetime import datetime
from typing import Any, List, Optional, Tuple
from ....base.managers import EpgManager
class ExampleEpgManager(EpgManager):
@property
def epg_window(self) -> Tuple[int, int]:
return 7, 7 # TODO: past days, future days
def get_epg(
self,
channel_id: str,
start_time: Optional[datetime] = None,
end_time: Optional[datetime] = None,
**kw: Any,
) -> List:
# TODO: build EPGEntry objects (see base/models/epg_models.py) for
# the window [start_time, end_time]; [] when there is nothing.
return []
@@ -0,0 +1,42 @@
# streaming_providers/providers/example/favorites_manager.py
"""
Favorites manager skeleton.
--- WIRING ---
from .favorites_manager import ExampleFavoritesManager
def _build_favorites(self):
return ExampleFavoritesManager(http_manager=self.http_manager, auth=self.auth,
country=self.country, config=self.provider_config)
--- END WIRING ---
Contract: get_favorites -> [] when empty (not an error); add_favorite returns
the Favorite (FavoriteType: PROGRAM, CLIP, LIVE, EVENT -- there is no
CHANNEL) and raises OperationFailedError on refusal; remove_favorite raises
ItemNotFoundError (also a KeyError: the backend's FavoriteOperations catches
KeyError -> False) when it is not favorited. The backend calls add/remove by
KEYWORD (content_id=, favorite_type=, title=).
"""
from typing import Any, List, Optional
from ....base.errors import ItemNotFoundError
from ....base.managers import FavoritesManager
from ....base.models.favorite import Favorite, FavoriteType
class ExampleFavoritesManager(FavoritesManager):
def get_favorites(self, **kw: Any) -> List[Favorite]:
return [] # TODO
def add_favorite(
self,
content_id: str,
favorite_type: FavoriteType = FavoriteType.PROGRAM,
title: Optional[str] = None,
**kw: Any,
) -> Favorite:
# TODO: call the backend, then return the saved Favorite
return Favorite.create("example", content_id, favorite_type, title)
def remove_favorite(self, content_id: str, **kw: Any) -> None:
raise ItemNotFoundError(content_id) # TODO
@@ -0,0 +1,46 @@
# streaming_providers/providers/example/recordings_manager.py
"""
Recordings (cloud PVR) manager skeleton.
--- WIRING ---
from .recordings_manager import ExampleRecordingsManager
def _build_recordings(self):
return ExampleRecordingsManager(http_manager=self.http_manager, auth=self.auth,
country=self.country, config=self.provider_config)
--- END WIRING ---
Contract (RecordingsManager ABC) -- the part that bites:
* get_recordings(**kw) -> List[Recording] (models.recording.Recording or a
subclass). NOT Channel: RecordingOperations filters on
`Recording.is_deleted`. `include_deleted` arrives in **kw; honour it if
the backend can list deleted items, else ignore it.
* Two id namespaces: content_id ("rec:<x>", what get_manifest / get_drm
receive -- played through your CHANNEL manager, there is no manifest
method here) and your backend's own recording id. Recording.recording_id
is an ALIAS of content_id, so clients only hold content_id:
delete_recording MUST accept the content_id (resolve it through your
listing) and may also accept the backend id.
* delete_recording raises ItemNotFoundError (also a KeyError) when the
recording does not exist; never return silently on a failed delete.
* schedule_recording is optional. If you implement it you MUST also
override supports_scheduling -> True. NOTE: the legacy surface has no
entry point for it (timers use add_timer/Timer); wire it explicitly.
* Map your backend status onto RecordingStatus; unknown -> PENDING.
* Recording.to_dict()["ContentType"] stays "LIVE" (known quirk, TODO D-18).
"""
from typing import Any, List
from ....base.errors import ItemNotFoundError
from ....base.managers import RecordingsManager
from ....base.models.recording import Recording
class ExampleRecordingsManager(RecordingsManager):
def get_recordings(self, **kw: Any) -> List[Recording]:
# TODO: return Recording objects (content_id="rec:<x>", provider=<PROVIDER_NAME>)
return []
def delete_recording(self, recording_id: str, **kw: Any) -> None:
# TODO: accept "rec:<x>" (resolve via the listing) and the backend id.
raise ItemNotFoundError(f"no recording {recording_id!r}")
@@ -0,0 +1,135 @@
# streaming_providers/providers/example/session.py
"""
AUTH VARIANT B -- an AuthProtocol adapter in front of a stateful
authenticator (a BaseAuthenticator with persisted sessions, multi-step login,
profiles ...). Worked example: providers/allente/session.py.
managers --(AuthProtocol)--> Session --> YourAuthenticator
Use it when the login is a flow you do not want to rewrite. The session owns
the auth-derived state (token, entitlement, profile, last error) and gives the
managers three things: get_access_token(), build_headers(), invalidate().
Error contract
ensure() -> bool, NEVER raises (opportunistic pre-login, settings UI)
require() -> raises typed errors (AuthError / GeoBlockError / ServerError)
last_error keeps the ORIGINAL exception (the auth-status UI shows its type)
Retry policy (the part the Allente constants describe but the provider never
implemented -- every failing call retried a full login):
* PERMANENT failure (PERMANENT_ERRORS: wrong credentials, OTP, unsupported
account) -> no retry until the credentials change (reset()).
* TRANSIENT failure (network, 5xx, WAF) -> exponential backoff
base * 2^(n-1) seconds, capped. Hammering an SSO risks lockout / WAF bans.
Concurrency: one login for concurrent callers (RLock + re-check).
--- WIRING ---
(in provider.__init__, instead of ExampleAuth:)
self.authenticator = YourAuthenticator(config=self.provider_config,
http_manager=self.http_manager)
self.session = ExampleSession(authenticator=self.authenticator,
config=self.provider_config)
(and pass auth=self.session to the managers)
--- END WIRING ---
"""
import threading
import time
from typing import Callable, Dict, Optional, Tuple, Type
from ....base.errors import AuthError, ProviderError, ServerError
from ....base.utils.logger import logger
BACKOFF_BASE_SECONDS = 30
BACKOFF_MAX_SECONDS = 900
class ExampleSession:
PERMANENT_ERRORS: Tuple[Type[Exception], ...] = () # e.g. (YourAuthError,)
def __init__(self, *, authenticator, config, clock: Callable[[], float] = time.monotonic):
self.authenticator = authenticator
self.config = config
self._clock = clock
self.access_token: Optional[str] = None
self.last_error: Optional[Exception] = None
self._failure: Optional[Tuple[Type[ProviderError], str]] = None
self._permanent = False
self._failures = 0
self._retry_at = 0.0
self._lock = threading.RLock()
# ---- state ---------------------------------------------------------
def reset(self) -> None:
"""Credentials changed: forget everything, including the backoff."""
self.access_token = None
self.last_error = None
self._failure = None
self._permanent = False
self._failures = 0
self._retry_at = 0.0
def _ready(self) -> bool:
token = self.authenticator.current_token
return bool(self.access_token and token is not None and not token.is_expired)
# ---- gate ----------------------------------------------------------
def ensure(self) -> bool:
if self._ready():
return True
with self._lock:
if self._ready():
return True
if self._permanent:
return False # until reset()
if self._clock() < self._retry_at:
return False # still backing off
return self._login()
def require(self) -> None:
if self.ensure():
return
kind, message = self._failure or (AuthError, "not authenticated")
raise kind(message) from self.last_error
def _login(self) -> bool:
if not self.authenticator.has_user_credentials():
return self._fail(RuntimeError("no credentials configured"), AuthError,
permanent=True)
try:
token = self.authenticator.authenticate()
if token is None:
return self._fail(RuntimeError("authentication returned no token"), AuthError)
self.access_token = token.access_token
self.last_error, self._failure = None, None
self._failures, self._retry_at = 0, 0.0
return True
except self.PERMANENT_ERRORS as exc:
return self._fail(exc, AuthError, permanent=True)
except Exception as exc:
return self._fail(exc, ServerError)
def _fail(self, exc: Exception, kind: Type[ProviderError], *, permanent: bool = False) -> bool:
self.last_error = exc
self._failure = (kind, f"authentication failed — {exc}")
self._permanent = permanent
if not permanent:
self._failures += 1
delay = min(BACKOFF_BASE_SECONDS * 2 ** (self._failures - 1), BACKOFF_MAX_SECONDS)
self._retry_at = self._clock() + delay
logger.error(self._failure[1])
return False
# ---- AuthProtocol --------------------------------------------------
def get_access_token(self) -> str:
self.require()
return self.access_token # type: ignore[return-value]
def build_headers(self) -> Dict[str, str]:
self.require()
return self.config.api_headers(self.access_token)
def invalidate(self) -> None:
self.authenticator.invalidate_token() # memory AND persisted storage
self.reset()
@@ -0,0 +1,52 @@
# streaming_providers/providers/example/vod_manager.py
"""
VOD manager skeleton (browseable catalogue).
--- WIRING ---
from .vod_manager import ExampleVodManager
def _build_vod(self):
return ExampleVodManager(http_manager=self.http_manager, auth=self.auth,
country=self.country, config=self.provider_config)
--- END WIRING ---
Contract (VodManager ABC):
* content_id is an OPAQUE token: you pick the grammar and document it here
(e.g. "folder_<id>", "program_<id>", "clip_<id>"); empty string = root.
* get_vod_category(content_id="", cursor=None, page_size=24) -> VodPage
entries: mixed VodCategory / VodItem; next_cursor None = last page
(that, not `total`, is the authoritative end-of-list signal). An EMPTY
page is falsy (list semantics) -- test `page.has_more`, not `if page`.
* get_vod_manifest(content_id) -> Optional[str] None = "not mine"
* handles_content_id: override as soon as live and VOD ids can collide
(the router asks every manager; keep it cheap and I/O-free).
* search_vod / get_vod_manifest_headers / get_segment_headers / get_vod_drm:
concrete defaults; override what you need.
* VodItem pricing/mode mismatches RAISE (strict), Channel only warns.
* Slugs are derived per sibling set (see TODO D-1): do not persist them.
"""
from typing import Any, Optional
from ....base.managers import VodManager
from ....base.vod import VodPage
class ExampleVodManager(VodManager):
def get_vod_category(
self,
content_id: str = "",
cursor: Optional[str] = None,
page_size: int = 24,
**kw: Any,
) -> VodPage:
# TODO: fetch the children of `content_id` (root when empty) and
# return VodPage(entries=[...], next_cursor=<token or None>, total=<int|None>)
return VodPage()
def get_vod_manifest(self, content_id: str, **kw: Any) -> Optional[str]:
# TODO: return the manifest URL, or None if this id is not a VOD id.
return None
def handles_content_id(self, content_id: str) -> bool:
# TODO: cheap prefix test, e.g. content_id.startswith("vod:")
return True
@@ -0,0 +1,151 @@
# streaming_providers/providers/example/provider.py
"""
Example provider -- orchestrator only.
The provider owns the SHARED resources (config, http_manager, auth, caches)
and BUILDS the managers. Everything identical across providers -- capability
flags, content-id routing, manifest / DRM / header / EPG delegation, the
legacy recordings / favorites / bookmarks / catchup surface -- is inherited
from ManagedProvider. Do not re-implement it here.
What belongs here:
* identity ClassVars and provider_name
* __init__: config -> http_manager -> auth -> caches -> _init_managers()
* the _build_*() hooks of the managers you have (the rest return None)
* provider-specific id grammar (override get_manifest / get_drm, call
super() for everything else)
* credentials API for the settings UI
What does NOT belong here: flag properties (implements_*), _route(),
get_channels(), header/EPG/DRM delegation, per-capability business logic.
"""
from typing import ClassVar, Dict, List, Optional
from ...base.managed_provider import ManagedProvider
from ...base.models.proxy_models import ProxyConfig
from .auth import ExampleAuth
from .channel_manager import ExampleChannelManager
from .constants import ExampleConfig, ExampleDefaults
# Only the provider is public. Provider discovery (streaming_providers/
# __init__.py) takes the FIRST StreamingProvider subclass it finds in the
# package namespace -- ManagedProvider must never be that one.
__all__ = ["ExampleProvider"]
class ExampleProvider(ManagedProvider):
PROVIDER_LABEL: ClassVar[str] = ExampleDefaults.PROVIDER_LABEL
PROVIDER_LOGO: ClassVar[str] = ExampleDefaults.PROVIDER_LOGO
SUPPORTED_AUTH_TYPES: ClassVar[List[str]] = ["user_credentials"]
SUPPORTED_COUNTRIES: ClassVar[List[str]] = list(ExampleDefaults.SUPPORTED_COUNTRIES)
# --- The two decisions every provider MUST take explicitly -----------
# (the contract test fails if a manager overrides the hooks but the flag
# says otherwise)
#
# DRM: True = managers implement get_channel_drm / get_vod_drm
# (manifest and DRM share state -- the usual case)
# False = no DRM, OR a dedicated object via _build_drm()
DRM_IN_MANAGERS: ClassVar[bool] = True
#
# Headers: True = manifest / segment requests carry the managers' header
# hooks (default there: auth.build_headers())
# False = keep the legacy {} (migrating a provider whose hooks
# were dead code: decide and test on a device)
HEADERS_FROM_MANAGERS: ClassVar[bool] = True
@property
def provider_name(self) -> str:
"""Must equal the directory name / registry key."""
return ExampleDefaults.PROVIDER_NAME
def __init__(
self,
country: str = "DE",
config: Optional[Dict] = None,
proxy_config: Optional[ProxyConfig] = None,
):
# StreamingProvider.__init__ takes ONLY `country` (no **kwargs);
# the registry constructs providers with country only.
super().__init__(country=country)
# The registry passes UPPERCASE for single-country providers and
# lowercase for fanned-out ones, while CredentialManager /
# SessionManager / ProxyConfigManager key by lowercase: normalise.
self.country = self.country.lower()
# 1. ONE config object, shared by every layer.
self.provider_config = ExampleConfig(config or {})
# 2. HTTP manager (proxy: ctor arg -> ProxyConfigManager -> global).
self.http_manager = self._setup_http_manager(
provider_name=ExampleDefaults.PROVIDER_NAME,
proxy_config=proxy_config,
user_agent=self.provider_config.user_agent,
timeout=self.provider_config.timeout,
)
# 3. Auth: lazy, no network in __init__.
self.auth = ExampleAuth(
http_manager=self.http_manager, config=self.provider_config
)
# 4. Provider-owned caches, borrowed by managers by reference.
self._playout_cache: Dict = {}
# 5. Managers, in dependency order. NOTE: self.channels is now the
# ChannelManager (it shadows the legacy list) -- never assign a
# list to it.
self._init_managers()
# ------------------------------------------------------------------
# Manager factories -- return None (the default) for capabilities
# the provider does not have. The matching implements_* flag is derived.
# ------------------------------------------------------------------
def _build_channels(self):
return ExampleChannelManager(
http_manager=self.http_manager,
auth=self.auth,
country=self.country,
config=self.provider_config,
playout_cache=self._playout_cache,
)
# OPTIONAL managers: see optional/<x>_manager.py -- each file's docstring
# has the exact import + _build_* snippet to paste here, e.g.:
#
# def _build_epg(self):
# return ExampleEpgManager(http_manager=self.http_manager,
# auth=self.auth, country=self.country,
# config=self.provider_config)
#
# Build order is fixed by ManagedProvider._init_managers():
# channels, vod, epg, recordings, favorites, bookmarks, catchup, drm
# A manager that needs another one (catchup needs channels) reads
# self.channels inside its _build_* method.
# ------------------------------------------------------------------
# Provider-specific id grammar (example, keep only if you need it)
# ------------------------------------------------------------------
# def get_manifest(self, content_id, **kw):
# if content_id.startswith("special:"):
# ...handle...
# return super().get_manifest(content_id, **kw)
# ------------------------------------------------------------------
# OPTIONAL -- Credentials API for the settings UI.
# A migration PRESERVES the v1 surface and never adds to it: keep this only
# if the v1 provider has it or `grep -rn set_user_credentials` finds callers
# (README §9). Otherwise delete it together with its test.
# ------------------------------------------------------------------
def set_user_credentials(self, username: str, password: str) -> bool:
"""Store credentials and log in once; True on success."""
from types import SimpleNamespace
self.auth.set_credentials(SimpleNamespace(username=username, password=password))
self._playout_cache.clear() # ids/streams may be user-specific
try:
self.auth.get_access_token()
except Exception:
return False
return True
@@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""
Scaffold a new v2 provider from this template.
python scaffold.py <key> <ClassPrefix> [--label "Display Name"]
[--country DE] [--with vod,epg,recordings,catchup,
favorites,bookmarks,drm,session]
[--dest <providers dir>]
key directory name == registry key == provider_name == credentials
key. lower-case, [a-z][a-z0-9_]* (e.g. joyn, magenta2)
ClassPrefix CamelCase prefix of the classes (e.g. Joyn -> JoynProvider)
What it does
* copies the template into <dest>/<key>/ (default: the template's parent
directory, i.e. providers/), replacing example -> key, Example ->
ClassPrefix, EXAMPLE -> KEY in file names and contents
* moves the chosen optional/ managers next to provider.py (fixing their
relative imports) -- session = optional/session_adapter.py -> session.py
* copies tests/ (core provider tests)
* prints the _build_* wiring snippet of every chosen manager: PASTE THEM
into provider.py (the scaffold never edits your wiring)
It does not touch README.md of the template and does not register anything:
provider discovery picks the package up from the directory automatically.
"""
import argparse
import re
import shutil
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
OPTIONAL = {
"vod": ("vod_manager.py", "vod_manager.py"),
"epg": ("epg_manager.py", "epg_manager.py"),
"recordings": ("recordings_manager.py", "recordings_manager.py"),
"catchup": ("catchup_manager.py", "catchup_manager.py"),
"favorites": ("favorites_manager.py", "favorites_manager.py"),
"bookmarks": ("bookmarks_manager.py", "bookmarks_manager.py"),
"drm": ("drm_manager.py", "drm_manager.py"),
"session": ("session_adapter.py", "session.py"),
}
SKIP_TOP = {"scaffold.py", "README.md"}
def _convert(text: str, key: str, prefix: str) -> str:
return (text.replace("example", key)
.replace("Example", prefix)
.replace("EXAMPLE", key.upper()))
def scaffold(key, prefix, dest=None, label=None, country=None, with_=()):
if not re.fullmatch(r"[a-z][a-z0-9_]*", key):
raise SystemExit(f"invalid key {key!r}: use lower-case [a-z][a-z0-9_]*")
if not re.fullmatch(r"[A-Z][A-Za-z0-9]*", prefix):
raise SystemExit(f"invalid class prefix {prefix!r}: use CamelCase")
unknown = [w for w in with_ if w not in OPTIONAL]
if unknown:
raise SystemExit(f"unknown optional manager(s): {unknown}; choose from {sorted(OPTIONAL)}")
dest_root = Path(dest) if dest else HERE.parent
target = dest_root / key
if target.exists():
raise SystemExit(f"{target} already exists")
target.mkdir(parents=True)
for src in sorted(HERE.glob("*.py")):
if src.name in SKIP_TOP:
continue
(target / _convert(src.name, key, prefix)).write_text(
_convert(src.read_text(), key, prefix))
if label:
const = target / "constants.py"
const.write_text(re.sub(r'PROVIDER_LABEL = ".*?"', f'PROVIDER_LABEL = "{label}"',
const.read_text(), count=1))
if country:
for name, pattern, repl in (
("constants.py", r'SUPPORTED_COUNTRIES = \(".*?",\)', f'SUPPORTED_COUNTRIES = ("{country.upper()}",)'),
("provider.py", r'country: str = ".*?"', f'country: str = "{country.upper()}"'),
):
f = target / name
f.write_text(re.sub(pattern, repl, f.read_text(), count=1))
snippets = []
for name in with_:
src_name, dst_name = OPTIONAL[name]
text = _convert((HERE / "optional" / src_name).read_text(), key, prefix)
text = text.replace("from ....base", "from ...base")
(target / dst_name).write_text(text)
m = re.search(r"--- WIRING ---\n(.*?)--- END WIRING ---", text, re.S)
snippets.append((name, m.group(1).rstrip() if m else "(see the file docstring)"))
tests = HERE / "tests"
if tests.is_dir():
(target / "tests").mkdir()
for src in sorted(tests.glob("*.py")):
(target / "tests" / _convert(src.name, key, prefix)).write_text(
_convert(src.read_text(), key, prefix))
print(f"created {target}")
for name, snippet in snippets:
print(f"\n# --- paste into {prefix}Provider ({name}) ---\n{snippet}")
print("\nNext: README §Migrating / §Checklist; then run the provider tests "
"and the contract test.")
return target
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("key")
ap.add_argument("prefix")
ap.add_argument("--label")
ap.add_argument("--country")
ap.add_argument("--dest")
ap.add_argument("--with", dest="with_", default="", help="comma separated optional managers")
a = ap.parse_args(argv)
scaffold(a.key, a.prefix, a.dest, a.label, a.country,
[w for w in a.with_.split(",") if w])
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,174 @@
"""
Core tests of the Example provider -- the pattern to copy for every provider
(the scaffold copies this file, renamed). They use a fake HTTP manager and
no network; adjust the fake payloads to your API.
Run: pytest <this file>
"""
import threading
import time
from types import SimpleNamespace
import pytest
from streaming_providers.base.errors import AuthError, ServerError
from streaming_providers.base.testing.provider_contract import check_provider
from streaming_providers.providers.example.provider import ExampleProvider
class Resp:
def __init__(self, data):
self._d = data
def json(self):
return self._d
class FakeHttp:
"""Records calls; payloads mimic the (fictional) Example API."""
def __init__(self):
self.calls = []
self.channels = [
{"id": "1", "name": "One", "logoUrl": "https://x/1.png"},
{"id": "2", "name": "Two"},
{"name": "no id"}, # malformed -> skipped
]
self.license_url = None # None = clear stream
self.logins = 0
self.login_delay = 0.0
self.expires_in = 3600
def post(self, url, json=None, headers=None, operation=None):
self.calls.append(("POST", url, headers))
if url.endswith("/v1/login"):
self.logins += 1
time.sleep(self.login_delay)
return Resp({"accessToken": f"tok{self.logins}", "expiresIn": self.expires_in})
raise AssertionError(url)
def get(self, url, params=None, headers=None, operation=None):
self.calls.append(("GET", url, headers))
if url.endswith("/v1/channels"):
return Resp({"channels": self.channels})
cid = url.rsplit("/", 1)[1]
if cid == "broken":
return Resp({"stream": {}})
stream = {"url": f"https://cdn/{cid}.mpd", "id": f"s-{cid}"}
if self.license_url:
stream["licenseUrl"] = self.license_url
return Resp({"stream": stream})
def playouts(self):
return [c for c in self.calls if "/v1/playout/" in c[1]]
@pytest.fixture
def http(monkeypatch):
fake = FakeHttp()
monkeypatch.setattr(ExampleProvider, "_setup_http_manager", lambda self, **kw: fake)
return fake
def logged_in(http):
p = ExampleProvider("DE")
p.set_user_credentials("u", "p")
http.calls.clear()
return p
# ---- structure -------------------------------------------------------------
def test_contract(http):
errors, _warnings = check_provider(ExampleProvider, "example")
assert errors == []
def test_flags_identity_and_no_io_in_init(http):
p = ExampleProvider("DE")
assert http.calls == [] # lazy: nothing happened yet
assert p.provider_name == "example" and p.country == "de"
assert p.capabilities == {"channels": True, "vod": False, "epg": False, "recordings": False,
"favorites": False, "bookmarks": False, "catchup": False, "drm": True}
assert p.epg_window == (0, 0) and p.catchup_window == 0
assert p.channels is not None and p.channels is p.channels # the manager, not a list
# ---- auth ------------------------------------------------------------------
def test_without_credentials_every_call_raises_auth_error(http):
p = ExampleProvider("DE")
for call in (p.get_channels, lambda: p.get_manifest("1"), lambda: p.get_drm(content_id="1")):
with pytest.raises(AuthError):
call()
assert http.calls == [] # failed before any request
def test_login_is_lazy_shared_and_refreshed_on_expiry(http):
p = logged_in(http)
assert http.logins == 1
p.get_channels(); p.get_channels()
assert http.logins == 1 # token reused
p.auth._expires_at = 0 # expire it
p.get_channels()
assert http.logins == 2
def test_concurrent_callers_produce_one_login(http):
http.login_delay = 0.05
p = ExampleProvider("DE")
p.auth.set_credentials(SimpleNamespace(username="u", password="p"))
threads = [threading.Thread(target=p.get_channels) for _ in range(5)]
[t.start() for t in threads]; [t.join() for t in threads]
assert http.logins == 1
def test_set_user_credentials_result(http):
p = ExampleProvider("DE")
assert p.set_user_credentials("u", "p") is True
http.post = lambda *a, **k: (_ for _ in ()).throw(ConnectionError("down"))
assert ExampleProvider("DE").set_user_credentials("u", "p") is False
# ---- channels / manifest / headers / drm -----------------------------------
def test_channels_skip_malformed_and_build_model_objects(http):
chans = logged_in(http).get_channels()
assert [c.content_id for c in chans] == ["1", "2"]
assert chans[0].provider == "example" and chans[0].logo_url == "https://x/1.png"
def test_to_output_format_uses_the_manager(http):
out = logged_in(http).to_output_format()
assert out["Provider"] == "example" and len(out["Channels"]) == 2
def test_manifest_and_drm_share_one_playout_request(http):
http.license_url = "https://lic/widevine"
p = logged_in(http)
assert p.get_manifest("1") == "https://cdn/1.mpd"
configs = p.get_drm(content_id="1", drm_variant="auto", preferred_quality="hd") # as the backend calls it
assert len(configs) == 1 and configs[0].priority == 1
assert len(http.playouts()) == 1
def test_clear_stream_has_no_drm(http):
p = logged_in(http)
assert p.get_drm(content_id="1") == []
def test_cdn_headers_carry_no_token_but_api_calls_do(http):
p = logged_in(http)
h = p.get_manifest_headers("1")
assert "Authorization" not in h and "User-Agent" in h
assert p.get_segment_headers("1") == h
p.get_channels()
assert http.calls[-1][2]["Authorization"].startswith("Bearer ")
def test_bad_playout_is_a_server_error_and_cache_is_cleared_on_credential_change(http):
p = logged_in(http)
with pytest.raises(ServerError):
p.get_manifest("broken")
p.get_manifest("1")
assert p._playout_cache
p.set_user_credentials("u", "p")
assert p._playout_cache == {}
@@ -1,39 +1,7 @@
# streaming_providers/providers/joyn/__init__.py
"""
Joyn Provider - Complete package
Joyn streaming provider.
"""
from .provider import JoynProvider
from .auth import JoynAuthenticator, JoynAuthToken, JoynCredentials
from .channel_manager import JoynChannelManager
from .catchup_manager import JoynCatchupManager
from .constants import (
COUNTRY_TENANT_MAPPING,
DEFAULT_VIDEO_CONFIG,
JOYN_GRAPHQL_ENDPOINTS,
JOYN_STREAMING_ENDPOINTS,
)
from .epg_manager import JoynEpgManager
from .models import JoynChannel, PlaybackRestrictedException, JoynError
from .provider import JoynProvider, JoynConfig
from .vod_manager import JoynVodManager
__all__ = [
"JoynProvider",
"JoynConfig",
"JoynChannel",
"PlaybackRestrictedException",
"JoynError",
"JoynAuthenticator",
"JoynAuthToken",
"JoynCredentials",
"JoynChannelManager",
"JoynVodManager",
"JoynEpgManager",
"JoynCatchupManager",
"COUNTRY_TENANT_MAPPING",
"DEFAULT_VIDEO_CONFIG",
"JOYN_GRAPHQL_ENDPOINTS",
"JOYN_STREAMING_ENDPOINTS",
]
__version__ = "1.3.0"
__all__ = ["JoynProvider"]
+109 -205
View File
@@ -16,33 +16,27 @@ from ...base.auth.base_oauth2_auth import BaseOAuth2Authenticator, WafBlockedExc
from ...base.auth.credentials import ClientCredentials, UserPasswordCredentials
from ...base.models.proxy_models import ProxyConfig
from ...base.utils.logger import logger
from .config import JoynConfig
from .models import JoynAuthError, JoynMfaRequiredException
from .constants import (
COUNTRY_TENANT_MAPPING,
DEFAULT_COUNTRY,
DEFAULT_REQUEST_TIMEOUT,
DEFAULT_MAX_RETRIES,
DEFAULT_PLATFORM,
DEVICE_IDS,
JOYN_AUTH_ENDPOINTS,
JOYN_AUTH_HEADERS_BASE,
JOYN_CLIENT_VERSION,
JOYN_DOMAINS,
JOYN_OAUTH_SCOPE,
JOYN_SEC_CH_UA,
JOYN_SEC_CH_UA_PLATFORM,
JOYN_USER_AGENT,
SUPPORTED_COUNTRIES,
)
class JoynMfaRequiredException(Exception):
"""
Raised when the account has two-factor authentication enabled.
Joyn's login flow redirects to an MFA challenge page (signin.7pass.de/.../mfa)
instead of completing with an OAuth code. Since the challenge cannot be
satisfied without user interaction in this provider, the only viable fix is
for the user to disable MFA in their Joyn account settings.
"""
pass
# NOTE: JoynMfaRequiredException is imported from .models — it must NOT be
# redefined here. The migration brief moves it into the models hierarchy so
# it inherits AuthError, which the settings UI relies on. If you find
# yourself adding a `class JoynMfaRequiredException` below, stop: that would
# shadow the import and break the typed-error contract.
@dataclass
@@ -109,6 +103,12 @@ class JoynAuthToken(BaseAuthToken):
class JoynAuthenticator(BaseOAuth2Authenticator):
"""
Joyn authenticator based on actual network traffic logs.
Config: uses the shared JoynConfig from config.py. The provider creates it
once and passes it in via `config=`; a private one is built only when the
authenticator is used standalone. Do NOT define a local config class here
and do NOT rebuild a second JoynConfig next to the provider's one — that is
how the distribution_tenant drifted before.
"""
def __init__(
@@ -120,6 +120,7 @@ class JoynAuthenticator(BaseOAuth2Authenticator):
config_dir: Optional[str] = None,
http_manager=None,
proxy_config: Optional[ProxyConfig] = None,
config: Optional[JoynConfig] = None,
):
if country not in SUPPORTED_COUNTRIES:
raise ValueError(f"Unsupported country: {country}")
@@ -128,7 +129,12 @@ class JoynAuthenticator(BaseOAuth2Authenticator):
self.country = country
self.platform = platform
self.distribution_tenant = COUNTRY_TENANT_MAPPING.get(country, "JOYN")
# The shared config. Set BEFORE super().__init__: the base class may touch
# properties (oauth_redirect_uri, ...) that read it.
self._config = config if config is not None else JoynConfig(
country=country, platform=platform
)
self.distribution_tenant = self._config.distribution_tenant
# Cache for flow parameters
self._sso_endpoints_cache = None
@@ -140,7 +146,6 @@ class JoynAuthenticator(BaseOAuth2Authenticator):
self._web_login_url = None
self._extracted_client_id = None
# Initialize base class
super().__init__(
provider_name="joyn",
settings_manager=settings_manager,
@@ -156,29 +161,6 @@ class JoynAuthenticator(BaseOAuth2Authenticator):
self._device_id = self._load_or_generate_device_id()
self._use_pkce = True
# Create config object
class JoynConfig:
def __init__(self, country, platform):
self.country = country
self.platform = platform
self.timeout = DEFAULT_REQUEST_TIMEOUT
self.max_retries = DEFAULT_MAX_RETRIES
self.user_agent = JOYN_USER_AGENT
self.base_website = JOYN_DOMAINS.get(country, JOYN_DOMAINS["de"])
def get_base_headers(self):
return {
"User-Agent": self.user_agent,
"Accept": "application/json",
"Content-Type": "application/json",
"Origin": self.base_website,
"joyn-client-version": JOYN_CLIENT_VERSION,
"joyn-country": self.country.upper(),
"joyn-distribution-tenant": COUNTRY_TENANT_MAPPING.get(self.country, "JOYN"),
"joyn-platform": self.platform,
}
self._config = JoynConfig(country, platform)
self._enable_oidc_discovery = False
if settings_manager is not None:
@@ -188,7 +170,8 @@ class JoynAuthenticator(BaseOAuth2Authenticator):
available_countries=SUPPORTED_COUNTRIES,
)
# CRITICAL: Use the CORRECT web client ID
# The web client ID (still a fixed platform-constant; see the comment
# in the original about "the CORRECT web client ID").
self._client_id = DEVICE_IDS.get(self.platform, DEVICE_IDS[DEFAULT_PLATFORM])
logger.info(f"Using Joyn client_id: {self._client_id}")
@@ -196,6 +179,21 @@ class JoynAuthenticator(BaseOAuth2Authenticator):
logger.info(f"No credentials for joyn/{self.country}, using anonymous fallback")
self.credentials = self.get_fallback_credentials()
# ------------------------------------------------------------------
# Token accessor used by JoynSession
# ------------------------------------------------------------------
# The session needs the BaseAuthToken *object* (to check is_expired),
# while get_bearer_token returns a *string*. This property bridges the
# two without forcing the session to re-implement the token cache.
#
# Check whether BaseOAuth2Authenticator already provides this — if it
# does, delete this property and rely on the base. Grep:
# grep -n "def current_token\|self\._current_token" \
# base/auth/base_oauth2_auth.py base/auth/base_auth.py
@property
def current_token(self):
return self._current_token
def _load_or_generate_device_id(self) -> str:
"""Load existing device ID from settings or generate new one"""
if self.settings_manager and hasattr(self.settings_manager, 'get_setting'):
@@ -227,8 +225,7 @@ class JoynAuthenticator(BaseOAuth2Authenticator):
@property
def oauth_redirect_uri(self) -> str:
from .constants import get_oauth_redirect_uri
return get_oauth_redirect_uri(self.country)
return self._config.oauth_redirect_uri()
def _discover_sso_endpoints(self) -> Dict[str, str]:
"""Discover Joyn's SSO endpoints and keep the server-issued web-login URL and client_id as-is."""
@@ -237,8 +234,6 @@ class JoynAuthenticator(BaseOAuth2Authenticator):
return self._sso_endpoints_cache
try:
# Use the locally-generated per-install device id for discovery, matching the
# reference client's `client_ids['client_id']` (NOT the fixed DEVICE_IDS constant).
url = f"https://auth.joyn.de/sso/endpoints?client_id={self._device_id}&client_name={self.platform}"
headers = self._get_joyn_auth_headers()
@@ -246,15 +241,12 @@ class JoynAuthenticator(BaseOAuth2Authenticator):
url,
operation="sso_discovery",
headers=headers,
timeout=getattr(self.config, "timeout", 30)
timeout=self._config.timeout
)
response.raise_for_status()
endpoints = response.json()
# Keep the full, unmodified web-login URL. The server embeds its own client_id
# and tracking params (cmpUcId, cmpUcInstance) in this URL; the reference client
# GETs this URL verbatim rather than reconstructing an authorize URL by hand.
auth_endpoint_full = endpoints.get("web-login", "")
self._web_login_url = auth_endpoint_full
@@ -266,8 +258,6 @@ class JoynAuthenticator(BaseOAuth2Authenticator):
"", "", ""
))
# Extract the server-issued client_id and tracking params for reuse in
# consent-accept and redeem-token calls later in the flow.
params = parse_qs(parsed_auth.query)
self._extracted_client_id = params.get("client_id", [None])[0]
self._cmp_uc_id = params.get("cmpUcId", [None])[0]
@@ -308,9 +298,9 @@ class JoynAuthenticator(BaseOAuth2Authenticator):
def _get_joyn_auth_headers(self) -> Dict[str, str]:
headers = JOYN_AUTH_HEADERS_BASE.copy()
headers.update({
"Origin": JOYN_DOMAINS.get(self.country, JOYN_DOMAINS["de"]),
"Origin": self._config.website(),
"joyn-country": self.country.upper(),
"joyn-distribution-tenant": COUNTRY_TENANT_MAPPING.get(self.country, "JOYN"),
"joyn-distribution-tenant": self._config.distribution_tenant,
"joyn-platform": self.platform,
"joyn-request-id": str(uuid.uuid4()),
"Content-Type": "application/json",
@@ -344,12 +334,8 @@ class JoynAuthenticator(BaseOAuth2Authenticator):
return payload
def should_upgrade_token(self, token) -> bool:
# Joyn's login flow is rate-limited and locks the account on repeated
# failures. If the token hasn't been upgraded on the first attempt,
# don't keep retrying during this process lifetime.
if getattr(self, "_joyn_upgrade_attempted", False):
return False
# Only attempt upgrade if we have user credentials
from ...base.auth.credentials import UserPasswordCredentials
if not isinstance(self.credentials, UserPasswordCredentials):
return False
@@ -378,14 +364,10 @@ class JoynAuthenticator(BaseOAuth2Authenticator):
logger.debug("Anonymous token cannot be refreshed")
return None
# Joyn uses a dedicated refresh endpoint, distinct from the
# authorization-code redeem-token endpoint (oauth_token_endpoint).
# grant_type is the token_type (e.g. "Bearer"), matching the
# working reference client, not a standard "refresh_token" value.
payload = {
"refresh_token": self._current_token.refresh_token,
"grant_type": self._current_token.token_type,
"client_id": self._device_id, # Must match the client_id used for login/discovery
"client_id": self._device_id,
"client_name": self.platform,
}
@@ -394,7 +376,7 @@ class JoynAuthenticator(BaseOAuth2Authenticator):
operation="auth",
headers=self._get_joyn_auth_headers(),
json_data=payload,
timeout=getattr(self.config, "timeout", 30),
timeout=self._config.timeout,
)
self._check_oauth_error_response(response)
@@ -412,26 +394,20 @@ class JoynAuthenticator(BaseOAuth2Authenticator):
return ".".join(netloc.split(".")[-2:])
target = registrable(urlparse(url).netloc)
origin = registrable(urlparse(JOYN_DOMAINS.get(self.country, JOYN_DOMAINS["de"])).netloc)
origin = registrable(urlparse(self._config.website()).netloc)
return "same-site" if target == origin else "cross-site"
def _perform_oauth_authorization_code_flow(self, username: str, password: str) -> Dict[str, Any]:
"""Complete Joyn login flow matching the exact sequence observed from working traffic.
Joyn does not implement real PKCE (code_verifier is always sent empty in the
redeem-token call). The client_id used for consent-accept and redeem-token is
the one the server itself embeds in the web-login redirect URL, not a fixed
platform constant.
Accounts with two-factor authentication enabled will be redirected to an MFA
challenge page (signin.7pass.de/.../mfa) instead of receiving an OAuth code.
We cannot satisfy that challenge without user interaction, so we raise
JoynMfaRequiredException with an actionable message.
"""
Complete Joyn login flow matching the exact sequence observed from
working traffic. Unchanged from v1 (see the git history for the full
commentary on each step); the only change in the v2 migration is that
JoynMfaRequiredException now comes from models.py and inherits
AuthError.
"""
try:
logger.debug("Starting Joyn login flow")
# Discover endpoints - gives us the literal web-login URL and its embedded client_id
self._discover_sso_endpoints()
if not self._web_login_url:
@@ -441,17 +417,10 @@ class JoynAuthenticator(BaseOAuth2Authenticator):
cd1 = self._device_id
session = self._create_oauth_session()
# SessionAwareHTTPManager pre-loads Origin/Referer/User-Agent for the Joyn
# website and applies them AFTER our per-call headers, silently overriding
# them. We set explicit, per-step headers below (different Origin for
# auth.7pass.de vs auth.joyn.de), so clear the defaults here.
session.headers.clear()
def _request(method, url, **kwargs):
headers = kwargs.pop("headers", {}).copy()
# joyn-* headers don't belong on calls to the 7pass.de auth domain;
# they're only sent on calls to Joyn's own auth.joyn.de endpoints
# (SSO discovery, redeem-token), matching the reference client.
if "auth.7pass.de" in url:
clean_headers = {
k: v for k, v in headers.items()
@@ -461,15 +430,13 @@ class JoynAuthenticator(BaseOAuth2Authenticator):
clean_headers = dict(headers)
clean_headers.setdefault("User-Agent", JOYN_USER_AGENT)
clean_headers.setdefault("Accept", "*/*")
# Browser-realistic headers to prevent Cloudflare managed challenges.
# CF uses sec-fetch-* and ch-ua hints to distinguish real browsers from bots.
clean_headers.setdefault("Accept-Language", "de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7")
clean_headers.setdefault("Accept-Encoding", "gzip, deflate, br")
clean_headers.setdefault("Cache-Control", "no-cache")
clean_headers.setdefault("Pragma", "no-cache")
clean_headers.setdefault("sec-ch-ua-mobile", "?0")
clean_headers.setdefault("sec-ch-ua", '"Google Chrome";v="153", "Not_A Brand";v="8", "Chromium";v="153"')
clean_headers.setdefault("sec-ch-ua-platform", '"macOS"')
clean_headers.setdefault("sec-ch-ua", JOYN_SEC_CH_UA)
clean_headers.setdefault("sec-ch-ua-platform", JOYN_SEC_CH_UA_PLATFORM)
clean_headers.setdefault("Sec-Fetch-Site", self._sec_fetch_site_for(url))
clean_headers.setdefault("Sec-Fetch-Mode", "cors")
clean_headers.setdefault("Sec-Fetch-Dest", "empty")
@@ -479,7 +446,7 @@ class JoynAuthenticator(BaseOAuth2Authenticator):
clean_headers["Content-Type"] = content_type
allow_redirects = kwargs.pop("allow_redirects", True)
timeout = getattr(self.config, "timeout", 30)
timeout = self._config.timeout
if method.upper() == "GET":
return session.get(url, headers=clean_headers, timeout=timeout,
@@ -489,13 +456,10 @@ class JoynAuthenticator(BaseOAuth2Authenticator):
allow_redirects=allow_redirects, **kwargs)
def _check_cf(response):
"""Raise WafBlockedException if response is a Cloudflare challenge page."""
if "Just a moment" in response.text or "challenge-platform" in response.text:
raise WafBlockedException("Cloudflare managed challenge detected")
def _raise_if_cf_error(e):
"""http_manager raises HTTPError before we see the response, so 403/429
Cloudflare challenges must be detected from the exception's response body."""
resp = getattr(e, "response", None)
raw = getattr(resp, "text", str(e))
if "Just a moment" in raw or "challenge-platform" in raw or "captcha" in raw.lower():
@@ -503,7 +467,7 @@ class JoynAuthenticator(BaseOAuth2Authenticator):
if resp is not None and resp.status_code in (403, 429):
raise WafBlockedException(f"Joyn login blocked by WAF ({resp.status_code}): {e}")
# 1. GET the literal web-login URL as issued by the server (do not rebuild it)
# 1. GET the literal web-login URL
try:
response = _request("GET", self._web_login_url, allow_redirects=True)
except WafBlockedException:
@@ -534,46 +498,30 @@ class JoynAuthenticator(BaseOAuth2Authenticator):
logger.debug(f"Extracted request_id: {request_id}")
# 2. Language/registration-setup check (non-fatal probe)
# 2-4. Non-fatal probes
try:
_request(
"GET",
f"https://auth.7pass.de/registration-setup-srv/public/list?acceptlanguage=undefined&requestId={request_id}",
)
_request("GET", f"https://auth.7pass.de/registration-setup-srv/public/list?acceptlanguage=undefined&requestId={request_id}")
except Exception as e:
logger.debug(f"registration-setup failed (non-fatal): {e}")
# 3. Check whether the email exists (non-fatal probe)
try:
_request(
"POST",
f"https://auth.7pass.de/users-srv/user/checkexists/{request_id}",
json={"email": username, "requestId": request_id},
content_type="application/json",
)
_request("POST", f"https://auth.7pass.de/users-srv/user/checkexists/{request_id}",
json={"email": username, "requestId": request_id},
content_type="application/json")
except Exception as e:
logger.debug(f"checkexists failed (non-fatal): {e}")
# 4. Configured verification methods list (non-fatal probe)
try:
_request(
"POST",
"https://auth.7pass.de/verification-srv/v2/setup/public/configured/list",
json={"email": username, "request_id": request_id},
content_type="application/json",
)
_request("POST", "https://auth.7pass.de/verification-srv/v2/setup/public/configured/list",
json={"email": username, "request_id": request_id},
content_type="application/json")
except Exception as e:
logger.debug(f"verification-srv failed (non-fatal): {e}")
# 5. Submit username/password directly (form-encoded)
# 5. Login
login_response = _request(
"POST",
"https://auth.7pass.de/login-srv/login",
data=urlencode({
"username": username,
"password": password,
"requestId": request_id,
}).encode(),
"POST", "https://auth.7pass.de/login-srv/login",
data=urlencode({"username": username, "password": password, "requestId": request_id}).encode(),
content_type="application/x-www-form-urlencoded",
allow_redirects=True,
)
@@ -583,66 +531,51 @@ class JoynAuthenticator(BaseOAuth2Authenticator):
parsed = urlparse(final_url)
params = parse_qs(parsed.query)
# 5a. MFA detection. Accounts with 2FA enabled get redirected to
# signin.7pass.de/<tenant>/joyn/login/mfa instead of completing
# the OAuth flow with a `code`. We cannot satisfy the challenge
# without user interaction, so fail with an actionable message.
# 5a. MFA
if "signin.7pass.de" in final_url and "/mfa" in final_url:
logger.error(
"Joyn account has two-factor authentication enabled. "
"The provider cannot complete MFA challenges — please disable "
"MFA in your Joyn account settings to use this provider."
)
logger.error("Joyn account has two-factor authentication enabled.")
raise JoynMfaRequiredException(
"Two-factor authentication is enabled on this Joyn account. "
"Please disable MFA in your Joyn account settings "
"(https://www.joyn.de/account) to use this provider."
)
# 6. Handle consent if the server didn't return a code directly
# 6. Consent
if params.get("code") is None:
sub = params.get("sub", [None])[0]
track_id = params.get("track_id", [None])[0]
if sub and track_id:
logger.debug(f"Accepting consent for sub={sub}")
_request(
"POST",
"https://auth.7pass.de/consent-management-srv/consent/scope/accept",
json={
"sub": sub,
"client_id": client_id,
"scopes": [{"offline_access": "denied"}],
},
content_type="application/json",
)
_request("POST", "https://auth.7pass.de/consent-management-srv/consent/scope/accept",
json={"sub": sub, "client_id": client_id, "scopes": [{"offline_access": "denied"}]},
content_type="application/json")
try:
continue_response = _request(
"POST",
f"https://auth.7pass.de/login-srv/precheck/continue/{track_id}",
data=b"",
content_type="application/x-www-form-urlencoded",
allow_redirects=True,
)
"POST", f"https://auth.7pass.de/login-srv/precheck/continue/{track_id}",
data=b"", content_type="application/x-www-form-urlencoded",
allow_redirects=True)
except WafBlockedException:
raise
except Exception as e:
_raise_if_cf_error(e)
raise
final_url = continue_response.url
parsed = urlparse(final_url)
params = parse_qs(parsed.query)
auth_code = params.get("code", [None])[0]
if not auth_code:
raise Exception("No authorization code in response")
# The login POST did not end in an authorization code, there was
# no consent step to continue, no MFA redirect and no WAF page
# (all handled above): 7pass rejected the credentials (or the flow
# changed). Retrying the same input cannot help, and repeated
# failed logins lock the account — so this is a PERMANENT, typed
# AuthError; JoynSession will not retry until reset().
raise JoynAuthError(
"Joyn login did not return an authorization code "
"(credentials rejected, or the 7pass flow changed)"
)
logger.debug("Authorization code obtained")
# 7. Redeem the code directly. Joyn does not implement real PKCE — the working
# client always sends code_verifier as an empty string here.
# 7. Redeem
cd1_value = params.get("cd1", [None])[0] or cd1
redeem_data = {
"client_id": client_id,
@@ -655,13 +588,10 @@ class JoynAuthenticator(BaseOAuth2Authenticator):
try:
redeem_response = _request(
"POST",
self.oauth_token_endpoint,
json=redeem_data,
content_type="application/json",
"POST", self.oauth_token_endpoint,
json=redeem_data, content_type="application/json",
headers=self._get_joyn_auth_headers(),
allow_redirects=False,
)
allow_redirects=False)
except WafBlockedException:
raise
except Exception as e:
@@ -670,13 +600,12 @@ class JoynAuthenticator(BaseOAuth2Authenticator):
_check_cf(redeem_response)
token_data = redeem_response.json()
logger.info("Joyn login flow successful")
return token_data
except WafBlockedException:
raise
except JoynMfaRequiredException:
except (JoynMfaRequiredException, JoynAuthError):
raise
except Exception as e:
logger.error(f"Joyn login flow failed: {e}")
@@ -685,64 +614,43 @@ class JoynAuthenticator(BaseOAuth2Authenticator):
def authenticate_with_fallback(self, username: str, password: str) -> Dict[str, Any]:
try:
return self._perform_oauth_authorization_code_flow(username, password)
except JoynMfaRequiredException:
# MFA is a permanent, user-actionable condition — do not fall back
# to anonymous silently, or the user will think they're logged in.
raise
except (JoynMfaRequiredException, JoynAuthError):
raise # permanent and user-actionable: never fall back to anonymous
except WafBlockedException as e:
logger.warning(f"{self.provider_name}: WAF block detected ({e}), trying remote login")
try:
return self._perform_remote_login_flow()
except (WafBlockedException, ConnectionError, TimeoutError) as remote_err:
logger.warning(
f"{self.provider_name}: Remote login failed ({remote_err}), falling back to client credentials")
logger.warning(f"{self.provider_name}: Remote login failed ({remote_err}), falling back to client credentials")
return self._perform_oauth_client_credentials_flow()
except (ConnectionError, TimeoutError, requests.exceptions.HTTPError) as e:
# Only fall back to anonymous on actual network/API errors, not code bugs.
# Standard 'Exception' is intentionally omitted here so a TypeError/KeyError
# in the OAuth flow crashes loudly instead of silently downgrading a user
# who thinks they're logged in to an anonymous session.
logger.warning(f"{self.provider_name}: Network login failed ({e}), falling back to client credentials")
return self._perform_oauth_client_credentials_flow()
def _perform_oauth_client_credentials_flow(self) -> Dict[str, Any]:
try:
logger.info(f"Starting client credentials flow")
# Joyn's anonymous auth expects a client_id and anon_device_id.
# We use the persistent device_id for both to maintain consistency.
logger.info("Starting client credentials flow")
payload = {
"client_id": self._device_id,
"client_name": self.platform,
"anon_device_id": self._device_id
}
anonymous_token_url = "https://auth.joyn.de/auth/anonymous"
headers = {
"Content-Type": "application/json",
"User-Agent": JOYN_USER_AGENT,
"Accept": "application/json",
"Origin": JOYN_DOMAINS.get(self.country, JOYN_DOMAINS["de"]),
"Origin": self._config.website(),
}
logger.debug(f"Anonymous token request to {anonymous_token_url} with client_id: {payload['client_id']}")
response = self.http_manager.post(
anonymous_token_url,
operation="auth",
headers=headers,
json_data=payload,
timeout=getattr(self.config, "timeout", 30)
)
anonymous_token_url, operation="auth", headers=headers,
json_data=payload, timeout=self._config.timeout)
self._check_oauth_error_response(response)
response.raise_for_status()
token_data = response.json()
logger.info(f"Client credentials flow successful")
logger.info("Client credentials flow successful")
return token_data
except Exception as e:
logger.error(f"Client credentials flow failed: {e}")
raise
@@ -760,17 +668,19 @@ class JoynAuthenticator(BaseOAuth2Authenticator):
return self.credentials.to_auth_payload()
def _create_token_from_response(self, response_data: Dict[str, Any]) -> BaseAuthToken:
# Subtract 1800s (30 min) safety buffer so we refresh proactively before
# actual expiry, matching the legacy addon's behavior — prevents streams
# cutting off mid-playback while a refresh is still in flight.
raw_expires_in = response_data.get("expires_in", 86400)
safe_expires_in = max(60, raw_expires_in - 1800) # never go below 60s
# Rely on BaseAuthToken.is_expired (300 s buffer): one buffer, one place.
# v1 subtracted a second buffer here. Removed because no token is embedded
# in a DRMConfig or in CDN headers for Joyn (drm_license_headers() and
# cdn_headers() carry no bearer; the licence URL is self-signed), so there
# is no playback-session-long token lifetime to protect. RECORD this in
# MIGRATION_BRIEF.md §3 ("Token buffer") and confirm with a long
# playback across the expiry on a device (README §9).
expires_in = response_data.get("expires_in", 86400)
token = JoynAuthToken(
access_token=response_data["access_token"],
refresh_token=response_data.get("refresh_token", ""),
token_type=response_data.get("token_type", "Bearer"),
expires_in=safe_expires_in,
expires_in=expires_in,
issued_at=response_data.get("issued_at", time.time()),
)
token.auth_level = self._classify_token(token)
@@ -780,20 +690,16 @@ class JoynAuthenticator(BaseOAuth2Authenticator):
try:
if not token or not token.access_token:
return TokenAuthLevel.UNKNOWN
claims = token.get_jwt_claims() if hasattr(token, "get_jwt_claims") else None
if not claims:
return TokenAuthLevel.UNKNOWN
jidc = claims.get("jIdC", "")
if jidc.startswith("JNAA-"):
return TokenAuthLevel.CLIENT_CREDENTIALS
elif jidc.startswith("JNDE-"):
return TokenAuthLevel.USER_AUTHENTICATED
if "social_id" in claims:
return TokenAuthLevel.USER_AUTHENTICATED
return TokenAuthLevel.UNKNOWN
except Exception as e:
logger.error(f"Error classifying token: {e}")
@@ -806,7 +712,6 @@ class JoynAuthenticator(BaseOAuth2Authenticator):
)
else:
token_data = self._perform_oauth_client_credentials_flow()
return self._create_token_from_response(token_data)
def get_bearer_token(self, force_refresh: bool = False, force_upgrade: bool = False) -> str:
@@ -817,6 +722,7 @@ class JoynAuthenticator(BaseOAuth2Authenticator):
def invalidate_token(self) -> None:
self._current_token = None
self._joyn_upgrade_attempted = False
try:
self.settings_manager.clear_token(self.provider_name)
except (AttributeError, KeyError, IOError, OSError):
@@ -825,9 +731,7 @@ class JoynAuthenticator(BaseOAuth2Authenticator):
def debug_token_classification(self) -> Dict[str, Any]:
if not self._current_token:
return {"error": "No current token"}
claims = self._current_token.get_jwt_claims() if hasattr(self._current_token, "get_jwt_claims") else {}
return {
"token_type": type(self._current_token).__name__,
"auth_level": self._current_token.auth_level.value,
@@ -1,33 +0,0 @@
# streaming_providers/providers/joyn/catchup_manager.py
# -*- coding: utf-8 -*-
"""
Joyn Catchup Manager - Handles catchup/timeshift (to be implemented)
"""
from typing import List, Optional, Any
from ...base.models import DRMConfig
from ...base.utils.logger import logger
class JoynCatchupManager:
def __init__(self, provider: Any):
self.provider = provider
logger.info(f"[JoynCatchupManager] Initialised (catchup not yet implemented)")
@property
def catchup_window(self) -> int:
return 0
@property
def supports_catchup(self) -> bool:
return False
@staticmethod
def get_catchup_manifest(
content_id: str, start_time: int, end_time: int, epg_id: Optional[str] = None, **kwargs
) -> Optional[str]:
return None
@staticmethod
def get_catchup_drm(
content_id: str, start_time: int, end_time: int, epg_id: Optional[str] = None, **kwargs
) -> List[DRMConfig]:
return []
@@ -1,467 +1,189 @@
# streaming_providers/providers/joyn/channel_manager.py
# -*- coding: utf-8 -*-
"""
Joyn Channel Manager - Handles channels, EPG, and streaming data
Joyn Channel Manager.
Four required collaborators (`http_manager`, `auth`, `country`, `config`) plus
two declared extras:
* `entitlement` — the shared JoynEntitlement helper (README §5.1)
* `playout_cache` — provider-owned dict, borrowed by reference; makes
get_channel_manifest and get_channel_drm share ONE
playlist call per zap
Headers come from `self.config`; the bearer comes from
`self.auth.get_access_token()`. Never through the provider.
Routing: handles_content_id() is the cheap pure pre-filter (README §7). It is the
mirror image of JoynVodManager.handles_content_id — both call ids.is_vod_id() —
so a VOD id is never handed to this manager first.
"""
import hashlib
import json
import time
import urllib.parse
from base64 import b64decode
from typing import Dict, List, Optional, Tuple
from urllib.parse import urlparse
from typing import Any, Dict, List, Optional, Tuple
from ...base.models import DRMConfig, DRMSystem, LicenseConfig, StreamingChannel
from ...base.provider import AuthType
from ...base.errors import BadRequestError
from ...base.managers import ChannelManager
from ...base.models import DRMConfig
from ...base.utils.logger import logger
from ...base.utils.transport import transport_errors
from .constants import (
CONTENT_TYPE_LIVE,
CONTENT_TYPE_VOD,
DEFAULT_EPG_WINDOW_HOURS,
DEFAULT_LIVESTREAM_TYPES,
DEFAULT_MAX_RETRIES,
DEFAULT_REQUEST_TIMEOUT,
DEFAULT_VIDEO_CONFIG,
DRM_REQUEST_HEADERS,
DRM_SYSTEM_WIDEVINE,
ERROR_CODES,
GRAPHQL_LIVE_CHANNELS_FILTER,
GRAPHQL_MAX_RESULTS,
GRAPHQL_OFFSET,
GRAPHQL_PERSISTED_QUERY_VERSION,
GRAPHQL_QUERY_HASHES,
JOYN_API_BASE_HEADERS,
JOYN_CLIENT_VERSION,
JOYN_DOMAINS,
JOYN_GRAPHQL_BASE_HEADERS,
JOYN_GRAPHQL_ENDPOINTS,
JOYN_STREAMING_ENDPOINTS,
JOYN_USER_AGENT,
MODE_LIVE,
MODE_VOD,
SIGNATURE_SECRET_KEY,
PLAYOUT_CACHE_TTL,
PROVIDER_NAME,
)
from .models import (
JoynChannel,
JoynError,
JoynEntitlementError,
PlaybackRestrictedException,
SubscriptionRequiredException,
from .drm import build_widevine_config
from .ids import is_live_id, is_vod_id
from .models import JoynChannel, JoynPlayout
from .signing import ( # noqa: F401 (re-exported: external callers imported these from here)
build_signature,
create_video_payload,
video_config_fingerprint,
)
def create_video_payload(config: Optional[Dict] = None, compact: bool = True) -> str:
video_config = config or DEFAULT_VIDEO_CONFIG
payload = json.dumps(video_config)
return payload.replace(" ", "") if compact else payload
def build_signature(
entitlement_token: str,
video_payload: Optional[str] = None,
secret_key: Optional[str] = None,
) -> str:
if video_payload is None:
video_payload = create_video_payload()
if secret_key is None:
secret_key = b64decode(SIGNATURE_SECRET_KEY).decode("utf-8")
signature_input = f"{video_payload},{entitlement_token}{secret_key}"
return hashlib.sha1(signature_input.encode("utf-8")).hexdigest()
class JoynChannelManager:
"""
Owns channel/EPG streaming data AND the shared header-building + entitlement
logic that vod_manager also depends on. vod_manager calls back into this
manager (via self.provider.channel_manager) rather than keeping its own
copies of _get_graphql_headers / get_api_headers / get_entitlement_token.
"""
def __init__(self, provider):
self.provider = provider
self._channels_cache: Optional[List[StreamingChannel]] = None
self._cache_timestamp: float = 0.0
self._cache_ttl: int = 300 # 5 minutes
# channel_id -> resolved_id (usually "<id>-hd"). Populated lazily by
# get_channel_entitlement_token so we don't re-probe on every playback.
self._resolved_channel_variants: Dict[str, str] = {}
logger.info(f"[JoynChannelManager] Initialised for country={provider.country}")
@property
def http_manager(self):
return self.provider.http_manager
@property
def authenticator(self):
return self.provider.authenticator
@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 channels(self) -> List[StreamingChannel]:
"""Lazy-loaded channels list with TTL caching"""
if self._channels_cache is None or (time.time() - self._cache_timestamp) > self._cache_ttl:
self._channels_cache = self.get_channels()
self._cache_timestamp = time.time()
return self._channels_cache or []
# ========================================================================
# HEADERS
# ========================================================================
def _get_graphql_headers(self) -> Dict[str, str]:
return self.provider._build_provider_headers(
base_headers=JOYN_GRAPHQL_BASE_HEADERS,
auth_type=AuthType.NONE,
provider_headers={
"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",
},
class JoynChannelManager(ChannelManager):
def __init__(
self,
*,
http_manager: Any,
auth: Any,
country: str,
config: Any,
entitlement: Any,
playout_cache: Optional[Dict[str, Tuple[JoynPlayout, float]]] = None,
):
super().__init__(
http_manager=http_manager, auth=auth, country=country, config=config
)
# extras AFTER super(), never passed to it
self._entitlement = entitlement
self._playout_cache = playout_cache if playout_cache is not None else {}
logger.info(f"[JoynChannelManager] Initialised for country={self.country}")
def get_api_headers(self) -> Dict[str, str]:
# Public: vod_manager delegates to this instead of keeping its own
# copy (see JoynVodManager._get_api_headers).
return self.provider._build_provider_headers(
base_headers=JOYN_API_BASE_HEADERS,
auth_type=AuthType.BEARER,
provider_headers={
"joyn-client-version": JOYN_CLIENT_VERSION,
"joyn-country": self.country.upper(),
"joyn-distribution-tenant": self.distribution_tenant,
"joyn-platform": self.platform,
"joyn-b2b-context": "UNKNOWN",
"joyn-client-os": "UNKNOWN",
"origin": JOYN_DOMAINS.get(self.country, JOYN_DOMAINS["de"]),
},
)
# ------------------------------------------------------------------
# Routing
# ------------------------------------------------------------------
def _get_entitlement_headers(self) -> Dict[str, str]:
"""
Headers for the entitlement host.
def handles_content_id(self, content_id: str) -> bool:
"""Live ids are bare channel slugs. Pure, no I/O (README §7)."""
return is_live_id(content_id)
Entitlement lives on a *separate* host from the Joyn GraphQL/streaming
APIs and does not accept the joyn-* header set. Sending a minimal
header set (Authorization + Content-Type + UA) matches the working
reference client.
# ------------------------------------------------------------------
# Headers
# ------------------------------------------------------------------
The bearer is fetched via the authenticator (not read from
provider.bearer_token), so a long-running session picks up refreshes
automatically instead of sending a stale token.
"""
token = ""
if self.authenticator is not None:
try:
token = self.authenticator.get_bearer_token() or ""
except Exception as e:
logger.warning(f"Could not obtain bearer for entitlement call: {e}")
def _graphql_headers(self) -> Dict[str, str]:
# get_access_token() RAISES a typed error if no session can be had
# (README §8). An anonymous token is a normal, valid result when no
# credentials are stored; a permanent failure (MFA, rejected credentials)
# must surface here, not silently degrade to an anonymous channel list
# that then fails at playback.
token = self.auth.get_access_token()
return self.config.graphql_headers(token=token, authenticated=True)
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": "Mozilla/5.0",
}
if token:
headers["Authorization"] = f"Bearer {token}"
return headers
def get_channel_manifest_headers(self, content_id: str, **kw: Any) -> Dict[str, str]:
# CDN headers: no Authorization. The manifest URL carries its own
# signature; the bearer yields "400 InvalidArgument: Unsupported
# Authorization Type". get_segment_headers is NOT overridden: the ABC
# default returns these, which is what the CDN segments need.
return self.config.cdn_headers()
def get_manifest_headers(self, content_id: str, **kwargs) -> Dict[str, str]:
# The CDN-served manifest URL is self-authorizing; sending the Joyn
# provider bearer token to the CDN causes:
# 400 InvalidArgument: Unsupported Authorization Type
# so we deliberately omit Authorization here.
return self.provider._build_provider_headers(
base_headers={},
auth_type=AuthType.NONE,
provider_headers={
"User-Agent": JOYN_USER_AGENT,
"Origin": JOYN_DOMAINS.get(self.country, JOYN_DOMAINS["de"]),
},
)
# ------------------------------------------------------------------
# Playout (shared by manifest and DRM)
# ------------------------------------------------------------------
# ========================================================================
# ENTITLEMENT
# ========================================================================
def clear_playout_cache(self) -> None:
self._playout_cache.clear()
def get_entitlement_token(self, content_id: str, content_type: str = CONTENT_TYPE_LIVE) -> str:
headers = self._get_entitlement_headers()
payload = {"content_id": content_id, "content_type": content_type}
url = JOYN_STREAMING_ENDPOINTS["ENTITLEMENT"]
host = urlparse(url).netloc
try:
response = self.http_manager.post(
url,
operation="auth",
headers=headers,
json_data=payload,
timeout=DEFAULT_REQUEST_TIMEOUT,
def _playout(self, content_id: str, video_config: Optional[Dict] = None) -> JoynPlayout:
if is_vod_id(content_id):
# handles_content_id() should have kept this out; reaching it means a
# caller bypassed the router. A malformed *request*, not a server fault.
raise BadRequestError(
f"Joyn: {content_id!r} is a VOD id, not a live channel id"
)
if response.status_code == 400:
try:
error_data = response.json()
if isinstance(error_data, list) and len(error_data) > 0:
error = error_data[0]
code = error.get("code", "UNKNOWN")
msg = error.get("msg", "No error message provided")
if code == ERROR_CODES["PLAYBACK_RESTRICTED"]:
raise PlaybackRestrictedException(
f"Playback restricted for {content_id}: {msg}"
)
elif code == ERROR_CODES["BUSINESS_MODEL_NOT_SUITABLE"]:
raise SubscriptionRequiredException(
f"Subscription required for {content_id} ({code}): {msg}"
)
else:
raise JoynEntitlementError(
f"Entitlement error for {content_id} ({code}): {msg}"
)
except (json.JSONDecodeError, KeyError, IndexError) as e:
logger.warning(
f"Entitlement 400 for {content_id} from {host}: "
f"failed to parse error body: {e}"
)
raise JoynEntitlementError(
f"Bad response for {content_id} (400), failed to parse error: {e}"
)
now = time.time()
key = f"{content_id}:{video_config_fingerprint(video_config)}"
cached = self._playout_cache.get(key)
if cached and (now - cached[1]) < PLAYOUT_CACHE_TTL:
return cached[0]
if response.status_code >= 400:
logger.warning(
f"Entitlement failed for {content_id} (type={content_type}): "
f"HTTP {response.status_code} from {host}"
)
response.raise_for_status()
data = response.json()
# Working reference accepts either key; keep both for compatibility.
token = data.get("entitlement_token") or data.get("token")
if not token:
logger.warning(
f"Entitlement response for {content_id} from {host} "
f"had no entitlement_token: keys={list(data.keys())}"
)
raise JoynEntitlementError(f"No entitlement_token in response for {content_id}")
return token
except PlaybackRestrictedException:
raise
except SubscriptionRequiredException:
raise
except JoynEntitlementError:
raise
except Exception as e:
raise JoynEntitlementError(f"Error getting entitlement token for {content_id}: {e}")
def get_channel_entitlement_token(self, channel_id: str) -> Tuple[str, str]:
"""
Resolve entitlement for a live channel, trying the -hd variant first.
Joyn's live channels are indexed with an "-hd" suffix at the
entitlement service; asking for the bare slug returns no token for
HD-only streams. Mirrors the working reference's retry order.
Results are cached per original channel_id so the extra probe only
happens once per channel per process.
Returns:
(resolved_channel_id, entitlement_token)
Raises:
PlaybackRestrictedException / SubscriptionRequiredException for
account/rights errors (terminal — not retried on the other variant).
JoynEntitlementError if neither variant yields a token.
"""
# Fast path: we already know which variant works for this channel.
cached_resolved = self._resolved_channel_variants.get(channel_id)
if cached_resolved:
try:
token = self.get_entitlement_token(
content_id=cached_resolved, content_type=CONTENT_TYPE_LIVE
)
if token:
return cached_resolved, token
except (PlaybackRestrictedException, SubscriptionRequiredException):
# Rights changed since we cached — let it propagate.
raise
except JoynEntitlementError:
# Cached variant no longer works; drop it and re-probe below.
logger.debug(f"Cached variant {cached_resolved} no longer resolves; re-probing")
self._resolved_channel_variants.pop(channel_id, None)
candidates: List[str] = []
if channel_id.endswith("-sd"):
candidates.append(channel_id[:-3] + "-hd")
elif not channel_id.endswith("-hd"):
candidates.append(channel_id + "-hd")
candidates.append(channel_id)
last_error: Optional[Exception] = None
for cid in candidates:
try:
token = self.get_entitlement_token(
content_id=cid, content_type=CONTENT_TYPE_LIVE
)
if token:
self._resolved_channel_variants[channel_id] = cid
return cid, token
except (PlaybackRestrictedException, SubscriptionRequiredException):
# Rights errors are terminal — do not retry the next candidate.
raise
except JoynEntitlementError as e:
last_error = e
continue
raise last_error or JoynEntitlementError(
f"No entitlement token for {channel_id} (tried {candidates})"
)
# ========================================================================
# PLAYLIST / MANIFEST / DRM
# ========================================================================
def get_channel_playlist(
self,
channel_id: str,
entitlement_token: str,
video_config: Optional[Dict] = None,
) -> Dict:
video_payload = create_video_payload(video_config)
signature = build_signature(entitlement_token, video_payload)
url = JOYN_STREAMING_ENDPOINTS["PLAYLIST"].format(channel_id=channel_id)
url += f"?signature={signature}"
headers = JOYN_API_BASE_HEADERS.copy()
headers["Authorization"] = f"Bearer {entitlement_token}"
try:
with transport_errors(f"playout for {content_id}", "Joyn"):
resolved_id, entitlement_token = self._entitlement.get_channel_entitlement_token(
content_id
)
video_payload = create_video_payload(video_config)
signature = build_signature(entitlement_token, video_payload)
url = JOYN_STREAMING_ENDPOINTS["PLAYLIST"].format(channel_id=resolved_id)
url += f"?signature={signature}"
response = self.http_manager.post(
url,
operation="manifest",
headers=headers,
headers=self.config.api_headers(entitlement_token),
data=video_payload,
timeout=DEFAULT_REQUEST_TIMEOUT,
)
response.raise_for_status()
return response.json()
except Exception as e:
# Let our custom entitlement exceptions bubble up untouched so callers
# (and the UI layer) can distinguish "needs subscription" / "not allowed
# here" from a generic network failure instead of seeing everything as
# a flat JoynError.
if isinstance(
e,
(PlaybackRestrictedException, SubscriptionRequiredException, JoynEntitlementError),
):
raise
raise JoynError(f"Error getting playlist for {channel_id}: {e}")
response.raise_for_status() # no-op when the HTTP manager already raised
playout = JoynPlayout.from_playlist_response(
response.json(), entitlement_token
)
def get_manifest(
self,
content_id: str,
content_type: str = CONTENT_TYPE_LIVE,
video_config: Optional[Dict] = None,
**kwargs,
) -> Optional[str]:
try:
if content_type == CONTENT_TYPE_LIVE:
resolved_id, entitlement_token = self.get_channel_entitlement_token(content_id)
else:
entitlement_token = self.get_entitlement_token(
content_id=content_id, content_type=content_type
)
resolved_id = content_id
playlist_data = self.get_channel_playlist(resolved_id, entitlement_token, video_config)
return playlist_data.get("manifestUrl")
except Exception as e:
logger.error(f"Error getting manifest for channel {content_id}: {e}")
return None
# Evict expired entries on insert: the dict is keyed by id x video-config
# and would otherwise only ever grow.
for stale in [k for k, (_, ts) in self._playout_cache.items()
if (now - ts) >= PLAYOUT_CACHE_TTL]:
self._playout_cache.pop(stale, None)
self._playout_cache[key] = (playout, now)
return playout
def _build_drm_config(self, playlist_data: Dict) -> Optional[DRMConfig]:
"""Build a DRMConfig object from a playlist response.
# ------------------------------------------------------------------
# Manifest / DRM
# ------------------------------------------------------------------
The license endpoint authenticates via the token embedded in the URL's
signature query param — no Authorization header is sent. We include
Origin and User-Agent to satisfy Cloudflare WAF requirements, matching
captured browser traffic.
"""
license_url = playlist_data.get("licenseUrl")
if not license_url:
return None
def get_channel_manifest(self, content_id: str, **kw: Any) -> Optional[str]:
return self._playout(content_id, kw.get("video_config")).manifest_url
return DRMConfig(
system=DRMSystem.WIDEVINE,
priority=1,
license=LicenseConfig(
server_url=license_url,
server_certificate=playlist_data.get("certificateUrl"),
req_headers=json.dumps({
"User-Agent": JOYN_USER_AGENT,
"Origin": JOYN_DOMAINS.get(self.country, JOYN_DOMAINS["de"]),
"Content-Type": DRM_REQUEST_HEADERS["Content-Type"],
}),
req_data="{CHA-RAW}",
use_http_get_request=False,
),
)
def get_drm(
self,
content_id: str,
content_type: str = CONTENT_TYPE_LIVE,
video_config: Optional[Dict] = None,
**kwargs,
) -> List[DRMConfig]:
try:
if content_type == CONTENT_TYPE_LIVE:
resolved_id, entitlement_token = self.get_channel_entitlement_token(content_id)
else:
entitlement_token = self.get_entitlement_token(
content_id=content_id, content_type=content_type
)
resolved_id = content_id
playlist_data = self.get_channel_playlist(resolved_id, entitlement_token, video_config)
drm_config = self._build_drm_config(playlist_data)
return [drm_config] if drm_config else []
except Exception as e:
logger.error(f"Error getting DRM configs for channel {content_id}: {e}")
def get_channel_drm(self, content_id: str, **kw: Any) -> List[DRMConfig]:
playout = self._playout(content_id, kw.get("video_config"))
if not playout.license_url:
return []
return [build_widevine_config(self.config, playout.license_url, playout.certificate_url)]
# ========================================================================
# CHANNEL FETCHING
# ========================================================================
# ------------------------------------------------------------------
# Channels
# ------------------------------------------------------------------
def get_channels(
self,
time_window_hours: int = DEFAULT_EPG_WINDOW_HOURS,
fetch_manifests: bool = False,
populate_streaming_data: bool = True,
**kwargs,
) -> List[StreamingChannel]:
try:
headers = self._get_graphql_headers()
**kw: Any,
) -> List:
"""
Fetch and parse the GraphQL channel list.
Malformed entries are skipped with a warning — one bad upstream item must
not empty the whole list (README §12.5).
"""
with transport_errors("get_channels", "Joyn"):
headers = self._graphql_headers()
current_time = int(time.time())
end_time = current_time + (time_window_hours * 3600)
variables = {
"liveStreamGroupFilter": GRAPHQL_LIVE_CHANNELS_FILTER,
"first": GRAPHQL_MAX_RESULTS,
@@ -471,183 +193,55 @@ class JoynChannelManager:
"to": end_time,
}
variables_encoded = urllib.parse.quote(json.dumps(variables))
extensions = {
extensions_encoded = urllib.parse.quote(json.dumps({
"persistedQuery": {
"version": GRAPHQL_PERSISTED_QUERY_VERSION,
"sha256Hash": GRAPHQL_QUERY_HASHES["LIVE_CHANNELS"],
}
}
extensions_encoded = urllib.parse.quote(json.dumps(extensions))
url = f"{JOYN_GRAPHQL_ENDPOINTS['LIVE_CHANNELS']}&variables={variables_encoded}&extensions={extensions_encoded}"
response = self.http_manager.get(
url, operation="api", headers=headers, timeout=DEFAULT_REQUEST_TIMEOUT
}))
url = (
f"{JOYN_GRAPHQL_ENDPOINTS['LIVE_CHANNELS']}"
f"&variables={variables_encoded}&extensions={extensions_encoded}"
)
response.raise_for_status()
channel_data = response.json()
response = self.http_manager.get(
url, operation="api", headers=headers,
timeout=DEFAULT_REQUEST_TIMEOUT,
)
response.raise_for_status() # no-op when the HTTP manager already raised
data = response.json()
channels = self._process_graphql_response(channel_data)
if fetch_manifests and populate_streaming_data:
channels = self.populate_streaming_data(channels)
logger.info(f"Successfully fetched {len(channels)} channels for country {self.country}")
return channels
except Exception as e:
logger.error(f"Error fetching channels from GraphQL: {e}")
return []
def _process_graphql_response(self, response_data: Dict) -> List[StreamingChannel]:
if "data" not in response_data or "liveStreams" not in response_data["data"]:
raise JoynError("Invalid GraphQL response structure")
live_streams = response_data["data"]["liveStreams"]
channels = []
for stream_data in live_streams:
for stream_data in (data.get("data") or {}).get("liveStreams") or []:
try:
channel_id = stream_data.get("id", "")
title = stream_data.get("title", "Unknown Channel")
stream_type = stream_data.get("type", "LINEAR")
quality = stream_data.get("quality", "")
logo_url = None
if "logo" in stream_data and "url" in stream_data["logo"]:
logo_url = stream_data["logo"]["url"]
content_type = CONTENT_TYPE_LIVE if stream_type == "LINEAR" else CONTENT_TYPE_VOD
mode = MODE_LIVE if stream_type == "LINEAR" else MODE_VOD
joyn_channel = JoynChannel(
name=title,
channel_id=channel_id,
logo_url=logo_url,
mode=mode,
content_type=content_type,
country=self.country,
raw_data=stream_data,
)
if quality:
joyn_channel.name = f"{title} ({quality})"
if "brand" in stream_data:
brand_data = stream_data["brand"]
if "brand_id" in brand_data:
joyn_channel.raw_data["brand_id"] = brand_data["brand_id"]
if stream_data.get("eventStream", False):
joyn_channel.raw_data["is_event_stream"] = True
channels.append(
joyn_channel.to_streaming_channel(provider_name=self.provider.provider_name)
)
except Exception as e:
logger.warning(f"Error processing channel data: {e}")
channel = self._parse_channel_entry(stream_data)
except (KeyError, TypeError, AttributeError, ValueError) as exc:
logger.warning(f"Joyn: skipping malformed channel entry: {exc!r}")
continue
channels.append(channel.to_streaming_channel(provider_name=PROVIDER_NAME))
logger.info(f"Joyn: fetched {len(channels)} channels for country {self.country}")
return channels
# ========================================================================
# ENRICHMENT
# ========================================================================
def _parse_channel_entry(self, stream_data: Dict) -> JoynChannel:
channel_id = stream_data["id"]
title = stream_data.get("title", "Unknown Channel")
stream_type = stream_data.get("type", "LINEAR")
quality = stream_data.get("quality", "")
logo_url = (stream_data.get("logo") or {}).get("url")
def enrich_channel_data(
self,
channel: StreamingChannel,
video_config: Optional[Dict] = None,
**kwargs,
) -> Optional[StreamingChannel]:
try:
content_type = kwargs.get("content_type", channel.content_type)
if content_type == CONTENT_TYPE_LIVE:
resolved_id, entitlement_token = self.get_channel_entitlement_token(
channel.channel_id
)
else:
entitlement_token = self.get_entitlement_token(
content_id=channel.channel_id, content_type=content_type
)
resolved_id = channel.channel_id
playlist_data = self.get_channel_playlist(
resolved_id, entitlement_token, video_config
)
content_type = CONTENT_TYPE_LIVE if stream_type == "LINEAR" else CONTENT_TYPE_VOD
mode = MODE_LIVE if stream_type == "LINEAR" else MODE_VOD
manifest_url = playlist_data.get("manifestUrl")
if not manifest_url:
return None
channel.manifest = manifest_url
channel.streaming_format = playlist_data.get("streamingFormat", "dash")
drm_config = self._build_drm_config(playlist_data)
if drm_config:
channel.drm_config = drm_config
channel.cdm_type = DRM_SYSTEM_WIDEVINE
channel.cdm = f"pid={channel.channel_id}"
return channel
except Exception as e:
logger.error(f"Error enriching channel {channel.name}: {e}")
return None
def populate_streaming_data(
self,
channels: List[StreamingChannel],
video_config: Optional[Dict] = None,
max_retries: int = DEFAULT_MAX_RETRIES,
) -> List[StreamingChannel]:
successful_channels = []
for channel in channels:
retries = 0
success = False
is_restricted = False
while retries < max_retries and not success and not is_restricted:
try:
if channel.content_type == CONTENT_TYPE_LIVE:
resolved_id, entitlement_token = self.get_channel_entitlement_token(
channel.channel_id
)
else:
entitlement_token = self.get_entitlement_token(
content_id=channel.channel_id, content_type=channel.content_type
)
resolved_id = channel.channel_id
playlist_data = self.get_channel_playlist(
resolved_id, entitlement_token, video_config
)
manifest_url = playlist_data.get("manifestUrl")
if manifest_url:
channel.manifest = manifest_url
channel.cdm_type = DRM_SYSTEM_WIDEVINE
channel.cdm = f"pid={channel.channel_id}"
channel.license_url = playlist_data.get("licenseUrl")
channel.certificate_url = playlist_data.get("certificateUrl")
channel.streaming_format = playlist_data.get("streamingFormat", "dash")
successful_channels.append(channel)
success = True
else:
raise JoynError("No manifestUrl in response")
except (PlaybackRestrictedException, SubscriptionRequiredException) as e:
# Both are terminal, per-account/rights failures — do not retry.
logger.warning(
f"Playback restricted/subscription-required for {channel.name}: {e}"
)
is_restricted = True
except Exception as e:
retries += 1
if retries < max_retries:
time.sleep(1)
else:
logger.error(f"Failed to get streaming data for {channel.name}: {e}")
logger.info(
f"Streaming data population complete: "
f"{len(successful_channels)}/{len(channels)}"
channel = JoynChannel(
name=f"{title} ({quality})" if quality else title,
channel_id=channel_id,
logo_url=logo_url,
mode=mode,
content_type=content_type,
country=self.country,
raw_data=stream_data,
)
return successful_channels
if "brand" in stream_data and "brand_id" in stream_data["brand"]:
channel.raw_data["brand_id"] = stream_data["brand"]["brand_id"]
if stream_data.get("eventStream"):
channel.raw_data["is_event_stream"] = True
return channel
@@ -0,0 +1,186 @@
# streaming_providers/providers/joyn/config.py
# -*- coding: utf-8 -*-
"""
Joyn provider configuration — the single config object, shared by every layer.
One instance is created by JoynProvider and passed to auth, session, the
entitlement helper and every manager. Never rebuild a second config from a
subset of values: that is how header drift between layers happens, and Joyn
used to have exactly that bug (JoynAuthenticator defined its own local
JoynConfig class, with a slightly different distribution_tenant computation).
Header builders live here, not in the managers, so the difference between the
four Joyn header sets is visible in one place:
* graphql_headers() GraphQL API (joyn-* headers, optional bearer)
* api_headers(token) entitlement / playlist (bearer + joyn-*)
* entitlement_headers(token) entitlement host ONLY (bearer, NO joyn-*)
* cdn_headers() MPD/segment fetch by the player (NO bearer)
The CDN deliberately receives no Authorization header: the manifest URL is
self-authorizing, and sending the Joyn bearer to the CDN returns
"400 InvalidArgument: Unsupported Authorization Type".
"""
from typing import Dict, Optional
from .constants import (
COUNTRY_TENANT_MAPPING,
DEFAULT_COUNTRY,
DEFAULT_MAX_RETRIES,
DEFAULT_PLATFORM,
DEFAULT_REQUEST_TIMEOUT,
JOYN_API_BASE_HEADERS,
JOYN_CLIENT_VERSION,
JOYN_DOMAINS,
JOYN_GRAPHQL_BASE_HEADERS,
JOYN_USER_AGENT,
)
class JoynConfig:
"""
Per-instance configuration. One instance, shared by every collaborator.
country is normalised to lowercase here: the registry constructs
multi-country providers with the lowercase entry from SUPPORTED_COUNTRIES
("de"/"at"/"ch"), but the class-level default and ad-hoc callers may pass
"DE". Everything downstream (JOYN_DOMAINS, COUNTRY_TENANT_MAPPING,
JOYN_USER_AGENT's Origin) keys by lowercase.
"""
def __init__(self, country=DEFAULT_COUNTRY, platform=DEFAULT_PLATFORM,
timeout=DEFAULT_REQUEST_TIMEOUT, max_retries=DEFAULT_MAX_RETRIES):
self._country = country.lower()
self.platform = platform
self.timeout = timeout
self.max_retries = max_retries
self.user_agent = JOYN_USER_AGENT
self.base_website = JOYN_DOMAINS.get(self._country, JOYN_DOMAINS["de"])
self.distribution_tenant = COUNTRY_TENANT_MAPPING.get(self._country, "JOYN")
@property
def country(self) -> str:
return self._country
@country.setter
def country(self, value: str) -> None:
self._country = value.lower()
self.base_website = JOYN_DOMAINS.get(self._country, JOYN_DOMAINS["de"])
self.distribution_tenant = COUNTRY_TENANT_MAPPING.get(self._country, "JOYN")
# ------------------------------------------------------------------
# URL builders (no f-strings with hosts anywhere else)
# ------------------------------------------------------------------
def website(self) -> str:
return JOYN_DOMAINS.get(self.country, JOYN_DOMAINS["de"])
def oauth_redirect_uri(self) -> str:
return f"https://www.joyn.{self.country}/oauth"
# ------------------------------------------------------------------
# Header builders
# ------------------------------------------------------------------
def _joyn_common(self) -> Dict[str, str]:
return {
"joyn-client-version": JOYN_CLIENT_VERSION,
"joyn-country": self.country.upper(),
"joyn-distribution-tenant": self.distribution_tenant,
"joyn-platform": self.platform,
}
def graphql_headers(
self,
token: Optional[str] = None,
authenticated: bool = False,
) -> Dict[str, str]:
"""
GraphQL API headers.
joyn-user-state is R_A when a bearer is present, A_A otherwise — the
API uses this to decide whether to serve user-specific lanes. The
current channel_manager hardcodes R_A; that is only correct when the
caller is authenticated. Passing `authenticated=True` without a token
would be a contradiction and logs a warning upstream, not here.
"""
headers = JOYN_GRAPHQL_BASE_HEADERS.copy()
headers.update(self._joyn_common())
headers["joyn-user-state"] = "code=R_A" if authenticated else "code=A_A"
if token:
headers["Authorization"] = f"Bearer {token}"
return headers
def api_headers(self, token: str) -> Dict[str, str]:
"""
API headers for GraphQL that expect a bearer (VOD navigation,
user state, search). Distinct from entitlement_headers() because the
entitlement host rejects the joyn-* header set.
"""
headers = JOYN_API_BASE_HEADERS.copy()
headers.update(self._joyn_common())
headers.update({
"joyn-b2b-context": "UNKNOWN",
"joyn-client-os": "UNKNOWN",
"origin": self.website(),
"Authorization": f"Bearer {token}",
})
return headers
@staticmethod
def entitlement_headers(token: str) -> Dict[str, str]:
"""
Entitlement host: minimal headers only.
The entitlement service lives on a separate host that does not accept
the joyn-* set. Sending them causes 400s. Matching the working
reference client: bearer + JSON content type + a plain User-Agent.
The bare "Mozilla/5.0" (not JOYN_USER_AGENT) is deliberate — the
reference client does not send the full browser UA to this host.
"""
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": "Mozilla/5.0",
}
if token:
headers["Authorization"] = f"Bearer {token}"
return headers
def cdn_headers(self) -> Dict[str, str]:
"""
Headers for the CDN-served manifest and segments.
No Authorization on purpose: the manifest URL is self-authorizing
(the signature is in the query string), and the CDN rejects any
Authorization header with a 400.
"""
return {
"User-Agent": JOYN_USER_AGENT,
"Origin": self.website(),
}
def drm_license_headers(self) -> Dict[str, str]:
"""
License-request headers for Widevine.
No Authorization: the license URL carries its own signature. Origin
and User-Agent are required by the Cloudflare WAF in front of the
license endpoint — omitting them yields a challenge page, not a
license.
"""
return {
"User-Agent": JOYN_USER_AGENT,
"Origin": self.website(),
"Content-Type": "application/octet-stream",
}
# ------------------------------------------------------------------
# Convenience accessors (used by entitlement, playlist, VOD managers)
# ------------------------------------------------------------------
@property
def is_german(self) -> bool:
"""True for the DE tenant; a few endpoints are DE-only."""
return self.country == "de"
@@ -8,6 +8,9 @@ Joyn provider constants - Cleaned and organized
# Provider Metadata
# ============================================================================
# The one id: directory name == registry key == provider_name == credentials key.
PROVIDER_NAME = "joyn"
JOYN_LOGO = "https://upload.wikimedia.org/wikipedia/de/thumb/7/74/Joyn_%28Streaminganbieter%29_logo.svg/2560px-Joyn_%28Streaminganbieter%29_logo.svg.png"
# ============================================================================
@@ -50,7 +53,20 @@ DEVICE_IDS = {
# ============================================================================
# Chrome 154 — matches the current reference web client.
JOYN_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36"
# The User-Agent AND the client hints (sec-ch-ua*) are derived from the same
# values: the Cloudflare WAF in front of 7pass / the license host compares them,
# and a UA that says "Chrome 154 / Windows" next to hints that say "Chrome 153 /
# macOS" is a classic bot signature. Bump JOYN_BROWSER_MAJOR, nothing else.
JOYN_BROWSER_MAJOR = "154"
JOYN_USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
f"(KHTML, like Gecko) Chrome/{JOYN_BROWSER_MAJOR}.0.0.0 Safari/537.36"
)
JOYN_SEC_CH_UA = (
f'"Google Chrome";v="{JOYN_BROWSER_MAJOR}", "Not_A Brand";v="8", '
f'"Chromium";v="{JOYN_BROWSER_MAJOR}"'
)
JOYN_SEC_CH_UA_PLATFORM = '"Windows"'
JOYN_CLIENT_VERSION = "5.1592.3"
DEFAULT_PLATFORM = "web"
@@ -109,6 +125,7 @@ GRAPHQL_OFFSET = 0
JOYN_STREAMING_ENDPOINTS = {
"ENTITLEMENT": "https://entitlements-service-alb.prd.platform.s.joyn.de/api/user/entitlement-token",
"PLAYLIST": "https://api.vod-prd.s.joyn.de/v1/channel/{channel_id}/playlist",
"ASSET_PLAYLIST": "https://api.vod-prd.s.joyn.de/v1/asset/{asset_id}/playlist",
}
# Default video configuration for playlist requests.
@@ -158,6 +175,17 @@ DEFAULT_LIVESTREAM_TYPES = ["EVENT", "LINEAR", "ON_DEMAND"]
MODE_LIVE = "live"
MODE_VOD = "vod"
# ----------------------------------------------------------------------------
# Content-id grammar (parsed in ONE place: ids.py)
# live: bare channel slugs "sat1-de", "sat1-de-hd"
# VOD: a_<id> playable video asset (what the asset playlist wants)
# b_<id> movie, c_<id> season, d_<id> episode
# block-<n> lazy landing-page block
# anything containing "/" (browse path) or ":" (v1 block-id form)
# A live id never starts with these prefixes and never contains "/" or ":".
# ----------------------------------------------------------------------------
VOD_ID_PREFIXES = ("a_", "b_", "c_", "d_", "block-")
# ============================================================================
# Error Codes
# ============================================================================
@@ -223,6 +251,21 @@ DEFAULT_REQUEST_TIMEOUT = 30
DEFAULT_MAX_RETRIES = 3
DEFAULT_EPG_WINDOW_HOURS = 3
# ============================================================================
# Cache Configuration
# ============================================================================
# Manifest and DRM share one playlist call per zap. Five seconds is long
# enough for the two backend calls (get_manifest and get_drm arrive within
# milliseconds of each other in the DRM pipeline), short enough that a
# channel switch does not see a stale manifest URL.
PLAYOUT_CACHE_TTL = 5.0
# Entitlement tokens are short-lived; the token itself is not cached (the
# entitlement service has its own session concept), but the *resolution*
# of a channel id to its "-hd" variant is cached for the process lifetime
# in JoynEntitlement._resolved_channel_variants.
# ============================================================================
# Channel Configuration
# ============================================================================
@@ -0,0 +1,42 @@
# streaming_providers/providers/joyn/drm.py
# -*- coding: utf-8 -*-
"""
Widevine config builder — one function, used by the channel AND the VOD manager
(they used to carry two identical copies).
DRM is *folded* into the managers (DRM_IN_MANAGERS = True, README §11): manifest
and licence data come from the same /playlist response. This module only builds
the DRMConfig; it does no I/O.
DEVICE CHECK REQUIRED (README §11, §12.7 step 3)
-------------------------------------------------
`req_headers` is passed as a JSON string, exactly as v1 did. README §11 only
documents two encodings as safe: a dict (the framework url-encodes it) or a
pre-encoded `urlencode(..., quote_via=quote)` string. The User-Agent contains
";" ("Windows NT 10.0; Win64; x64"), which is what the plain-text header parser
splits on. If v1 licence requests worked on a device with the JSON string, this
is correct as is; if the licence request ever shows a truncated User-Agent,
switch to `config.drm_license_headers()` (dict) here — this is the only place.
Dump ISA's outgoing request and compare against the browser capture.
"""
import json
from typing import Any, Optional
from ...base.models import DRMConfig, DRMSystem, LicenseConfig
def build_widevine_config(
config: Any, license_url: str, certificate_url: Optional[str] = None
) -> DRMConfig:
return DRMConfig(
system=DRMSystem.WIDEVINE,
priority=1, # single system per content; explicit per README §11
license=LicenseConfig(
server_url=license_url,
server_certificate=certificate_url,
req_headers=json.dumps(config.drm_license_headers()),
req_data="{CHA-RAW}",
use_http_get_request=False,
),
)
@@ -0,0 +1,209 @@
# streaming_providers/providers/joyn/entitlement.py
# -*- coding: utf-8 -*-
"""
Joyn entitlement helper — a dedicated collaborator, provider-owned, injected
into both the channel manager and the VOD manager (README §5.1: two consumers).
Error contract
--------------
PlaybackRestrictedException / SubscriptionRequiredException (both
JoynEntitlementError -> EntitlementError) are terminal and propagate. Any other
entitlement failure (no token, unparsable 400 body) raises JoynEntitlementError.
Transport failures propagate and are wrapped by transport_errors at the call
site in the manager.
How a 400 is detected
---------------------
README §8 states that the HTTP manager raises for 4xx/5xx itself; v1 code
assumed it *returns* a 400 response. Neither is verified for every transport, so
BOTH are handled: a returned 400 response, and an exception that carries a
`.response` with status 400. Either way the Joyn error body is parsed, so the
`-hd` variant probe in get_channel_entitlement_token keeps working.
Cache lifecycle
---------------
`_resolved_channel_variants` maps a caller-facing channel id to the id the
entitlement service accepts ("sat1-de" -> "sat1-de-hd"). It is a property of the
channel, not of the session, so it is not time-based — but it IS cleared on
credential change: JoynProvider._clear_caches() is registered as the session's
`on_invalidate` callback and calls clear() below.
"""
from typing import Any, Dict, List, Optional, Tuple
from ...base.errors import ServerError
from ...base.utils.logger import logger
from .constants import (
CONTENT_TYPE_LIVE,
DEFAULT_REQUEST_TIMEOUT,
ERROR_CODES,
JOYN_STREAMING_ENDPOINTS,
)
from .models import (
JoynEntitlementError,
PlaybackRestrictedException,
SubscriptionRequiredException,
)
class JoynEntitlement:
def __init__(self, *, http_manager, auth, config, cache: Optional[Dict] = None):
self.http_manager = http_manager
self.auth = auth
self.config = config
self._cache = cache if cache is not None else {}
self._resolved_channel_variants: Dict[str, str] = self._cache.setdefault(
"resolved_channel_variants", {}
)
# ------------------------------------------------------------------
# Lifecycle
# ------------------------------------------------------------------
def clear(self) -> None:
"""Forget resolved channel variants (credentials changed).
Clears the inner dict in place: clearing the provider-owned outer dict
would orphan the reference held here.
"""
self._resolved_channel_variants.clear()
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def get_entitlement_token(
self, content_id: str, content_type: str = CONTENT_TYPE_LIVE
) -> str:
"""
Fetch an entitlement token for a single content id.
Raises:
PlaybackRestrictedException / SubscriptionRequiredException — rights
JoynEntitlementError — missing token, unparsable error body
ProviderError subclasses (AuthError, ...) propagate untouched
"""
token = self.auth.get_access_token()
headers = self.config.entitlement_headers(token)
payload = {"content_id": content_id, "content_type": content_type}
url = JOYN_STREAMING_ENDPOINTS["ENTITLEMENT"]
try:
response = self.http_manager.post(
url,
operation="auth", # the "auth" proxy scope matches the entitlement service
headers=headers,
json_data=payload,
timeout=DEFAULT_REQUEST_TIMEOUT,
)
except Exception as exc:
failed = getattr(exc, "response", None)
if failed is not None and getattr(failed, "status_code", None) == 400:
self._raise_from_400_body(content_id, failed) # always raises
raise
status = response.status_code
if status == 400:
self._raise_from_400_body(content_id, response) # always raises
if status >= 400:
logger.warning(
f"Entitlement failed for {content_id} (type={content_type}): HTTP {status}"
)
raise ServerError(f"Entitlement HTTP {status} for {content_id}")
data = response.json()
# The reference client accepts either key; keep both.
token = data.get("entitlement_token") or data.get("token")
if not token:
logger.warning(
f"Entitlement response for {content_id} had no token "
f"(keys={list(data.keys())})"
)
raise JoynEntitlementError(
f"No entitlement_token in response for {content_id}"
)
return token
def get_channel_entitlement_token(self, channel_id: str) -> Tuple[str, str]:
"""
Resolve entitlement for a live channel, trying the `-hd` variant.
Returns (resolved_channel_id, entitlement_token). The resolved id is what
the playlist endpoint must be called with.
Rights errors are terminal and propagate immediately — the same account
would get the same answer on the other variant.
"""
cached_resolved = self._resolved_channel_variants.get(channel_id)
if cached_resolved:
try:
token = self.get_entitlement_token(cached_resolved, CONTENT_TYPE_LIVE)
if token:
return cached_resolved, token
except (PlaybackRestrictedException, SubscriptionRequiredException):
raise
except JoynEntitlementError:
logger.debug(
f"Cached variant {cached_resolved} no longer resolves; re-probing"
)
self._resolved_channel_variants.pop(channel_id, None)
candidates: List[str] = []
if channel_id.endswith("-sd"):
candidates.append(channel_id[:-3] + "-hd")
elif not channel_id.endswith("-hd"):
candidates.append(channel_id + "-hd")
candidates.append(channel_id)
last_error: Optional[Exception] = None
for cid in candidates:
try:
token = self.get_entitlement_token(cid, CONTENT_TYPE_LIVE)
if token:
self._resolved_channel_variants[channel_id] = cid
return cid, token
except (PlaybackRestrictedException, SubscriptionRequiredException):
raise
except JoynEntitlementError as exc:
last_error = exc
continue
raise last_error or JoynEntitlementError(
f"No entitlement token for {channel_id} (tried {candidates})"
)
# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------
@staticmethod
def _raise_from_400_body(content_id: str, response: Any) -> None:
"""Always raises. Joyn answers 400 with a JSON *list*; element 0 has code/msg."""
try:
body = response.json()
except ValueError as exc:
logger.warning(
f"Entitlement 400 for {content_id}: failed to parse error body: {exc}"
)
raise JoynEntitlementError(
f"Bad entitlement response for {content_id} (400), "
f"failed to parse error: {exc}"
) from exc
error = body[0] if isinstance(body, list) and body else None
if not isinstance(error, dict):
raise JoynEntitlementError(
f"Bad entitlement response for {content_id} (400)"
)
code = error.get("code", "UNKNOWN")
msg = error.get("msg", "No error message provided")
if code == ERROR_CODES["PLAYBACK_RESTRICTED"]:
raise PlaybackRestrictedException(
f"Playback restricted for {content_id}: {msg}"
)
if code == ERROR_CODES["BUSINESS_MODEL_NOT_SUITABLE"]:
raise SubscriptionRequiredException(
f"Subscription required for {content_id} ({code}): {msg}"
)
raise JoynEntitlementError(f"Entitlement error for {content_id} ({code}): {msg}")
@@ -1,43 +0,0 @@
# streaming_providers/providers/joyn/epg_manager.py
# -*- coding: utf-8 -*-
"""
Joyn EPG Manager - Handles EPG data (to be implemented)
"""
from datetime import datetime
from typing import Dict, List, Optional, Tuple, Any
from ...base.utils.logger import logger
class JoynEpgManager:
def __init__(self, provider: Any):
self.provider = provider
logger.info(f"[JoynEpgManager] Initialised (EPG not yet implemented)")
@property
def implements_epg(self) -> bool:
return False
@property
def epg_window(self) -> Tuple[int, int]:
return 0, 0
@staticmethod
def get_events(
start_time: Optional[datetime] = None, end_time: Optional[datetime] = None, **kwargs
) -> List:
return []
@staticmethod
def get_epg(
channel_id: str, start_time: Optional[datetime] = None, end_time: Optional[datetime] = None, **kwargs
) -> List:
return []
@staticmethod
def get_epg_grid(
start_time: Optional[datetime] = None, end_time: Optional[datetime] = None, channel_ids: Optional[List[str]] = None, **kwargs
) -> Dict[str, List]:
return {}
@staticmethod
def get_program_details(program_id: str, **kwargs) -> Optional[Dict]:
return None
@@ -0,0 +1,30 @@
# streaming_providers/providers/joyn/ids.py
# -*- coding: utf-8 -*-
"""
Content-id grammar — the ONE place that decides "live or VOD".
Both managers' `handles_content_id()` call into this module, so the router
(README §7) and the managers can never disagree. Pure functions: no I/O, no
state, safe to call on every routing attempt.
Grammar (documented in constants.py next to VOD_ID_PREFIXES):
live — bare channel slugs ("sat1-de", "sat1-de-hd")
VOD — a_/b_/c_/d_ asset ids, block-<n>, any browse path containing "/",
any v1-style block id containing ":"
"""
from .constants import VOD_ID_PREFIXES
def is_vod_id(content_id: str) -> bool:
if not content_id:
return False
return (
content_id.startswith(VOD_ID_PREFIXES)
or "/" in content_id
or ":" in content_id
)
def is_live_id(content_id: str) -> bool:
return bool(content_id) and not is_vod_id(content_id)
@@ -1,85 +1,104 @@
# streaming_providers/providers/joyn/models.py
# -*- coding: utf-8 -*-
"""
Joyn provider-local models and exception hierarchy.
The exceptions now subclass the base `errors` types so the backend's typed-
error handling (§8 of the template README) recognises them:
JoynError (base for provider-local)
├── JoynAuthError AuthError
├── JoynEntitlementError EntitlementError
│ ├── PlaybackRestrictedException
│ └── SubscriptionRequiredException
└── JoynMfaRequiredException AuthError (permanent, user-actionable)
`JoynChannel` is unchanged in shape from v1. `from_api_data` is renamed to
`from_api_response` to match the template's naming; the only caller is the
channel manager.
`JoynPlayout` is new: it is what the playout cache stores, so the manifest and
DRM methods share one entitlement + playlist call per zap. Before this, the
two methods issued the entitlement call twice.
"""
import json
from dataclasses import dataclass, field
from typing import Dict, Optional
from typing import Any, Dict, Optional
from ...base.errors import AuthError, EntitlementError, ProviderError
from ...base.models import StreamingChannel
# ============================================================================
# Exception Hierarchy
# Exception hierarchy
# ============================================================================
class JoynError(Exception):
"""Base exception for all Joyn provider errors."""
pass
class JoynError(ProviderError):
"""Base for provider-local errors that don't fit a base category."""
class JoynAuthError(JoynError):
"""Authentication specific errors."""
pass
class JoynEntitlementError(JoynError):
"""Entitlement and rights management errors."""
pass
class JoynAuthError(JoynError, AuthError):
"""Authentication-specific errors that are not the raw flow's exceptions."""
class JoynEntitlementError(JoynError, EntitlementError):
"""Entitlement and rights management errors (untyped)."""
class PlaybackRestrictedException(JoynEntitlementError):
"""
Exception raised when content playback is restricted
"""
pass
"""The account is not permitted to play this content."""
class SubscriptionRequiredException(JoynEntitlementError):
"""Raised when content requires a subscription tier (e.g. PLUS) the account doesn't hold."""
pass
"""The content requires a subscription tier the account does not hold."""
class JoynMfaRequiredException(JoynError, AuthError):
"""
Raised when the account has two-factor authentication enabled.
Joyn's login flow redirects to an MFA challenge page
(signin.7pass.de/.../mfa) instead of completing with an OAuth code. The
challenge cannot be satisfied without user interaction, so the only
viable fix is for the user to disable MFA in their Joyn account settings.
"""
# ============================================================================
# Channel model (unchanged from v1)
# ============================================================================
@dataclass
class JoynChannel:
"""
Represents a Joyn channel with all necessary streaming data
"""
"""A Joyn live channel with all the streaming data the manager needs."""
# Core identification
name: str
channel_id: str
# Visual elements
logo_url: Optional[str] = None
# Streaming configuration
mode: str = "live" # "live" or "vod"
mode: str = "live"
session_manifest: bool = False
manifest: Optional[str] = None
manifest_script: Optional[str] = None
# CDM (Content Decryption Module) settings
cdm_type: Optional[str] = None
use_cdm: bool = True
cdm: Optional[str] = None # Usually "pid={pid}"
cdm: Optional[str] = None
cdm_mode: str = "external"
# Video settings
video: str = "best"
on_demand: bool = True
speed_up: bool = True
# Additional metadata
content_type: str = "LIVE" # 'LIVE' or 'VOD'
content_type: str = "LIVE"
description: Optional[str] = None
genre: Optional[str] = None
language: str = "de"
country: str = "DE"
# Streaming data
license_url: Optional[str] = None
certificate_url: Optional[str] = None
streaming_format: Optional[str] = None
# Internal tracking
raw_data: Dict = field(default_factory=dict)
@classmethod
def from_api_data(cls, api_data: Dict, **kwargs) -> "JoynChannel":
def from_api_response(cls, api_data: Dict, **kwargs) -> "JoynChannel":
channel = cls(
name=api_data.get("title", "Unknown Channel"),
channel_id=api_data.get("id", ""),
@@ -94,11 +113,11 @@ class JoynChannel:
def set_streaming_data(
self,
manifest: str,
cdm_type: str = None,
pid: str = None,
license_url: str = None,
certificate_url: str = None,
streaming_format: str = None,
cdm_type: Optional[str] = None,
pid: Optional[str] = None,
license_url: Optional[str] = None,
certificate_url: Optional[str] = None,
streaming_format: Optional[str] = None,
) -> None:
self.manifest = manifest
if cdm_type:
@@ -112,21 +131,6 @@ class JoynChannel:
if streaming_format:
self.streaming_format = streaming_format
def set_logo(self, logo_url: str) -> None:
self.logo_url = logo_url
def set_metadata(self, description: str = None, genre: str = None) -> None:
if description:
self.description = description
if genre:
self.genre = genre
def is_live(self) -> bool:
return self.content_type == "LIVE" and self.mode == "live"
def is_vod(self) -> bool:
return self.content_type == "VOD" or self.mode == "vod"
def to_streaming_channel(self, provider_name: str = "joyn") -> StreamingChannel:
return StreamingChannel(
name=self.name,
@@ -174,8 +178,36 @@ class JoynChannel:
def to_json(self, indent: int = 2) -> str:
return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False)
def __str__(self) -> str:
return f"JoynChannel(name='{self.name}', id='{self.channel_id}', type='{self.content_type}')"
def __repr__(self) -> str:
return self.__str__()
# ============================================================================
# Playout model (new — the cache entry shared by manifest and DRM)
# ============================================================================
@dataclass
class JoynPlayout:
"""
The result of one playlist call: everything both `get_channel_manifest`
and `get_channel_drm` need, so they share a single network round-trip
per zap (five seconds of cache, matching PLAYOUT_CACHE_TTL).
"""
manifest_url: str
entitlement_token: str
license_url: Optional[str] = None
certificate_url: Optional[str] = None
streaming_format: str = "dash"
@classmethod
def from_playlist_response(
cls, data: Dict[str, Any], entitlement_token: str
) -> "JoynPlayout":
manifest_url = data.get("manifestUrl")
if not manifest_url:
raise ValueError("playlist response is missing manifestUrl")
return cls(
manifest_url=manifest_url,
entitlement_token=entitlement_token,
license_url=data.get("licenseUrl"),
certificate_url=data.get("certificateUrl"),
streaming_format=data.get("streamingFormat", "dash"),
)
+160 -330
View File
@@ -1,369 +1,199 @@
# streaming_providers/providers/joyn/provider.py
# -*- coding: utf-8 -*-
"""
Joyn Provider - Pure Orchestrator
Wires together authentication, channel, and VOD managers
Joyn provider — orchestrator only.
The provider owns the SHARED resources and BUILDS the managers. Everything
identical across providers is inherited from ManagedProvider. What stays here:
* identity ClassVars
* __init__ wiring: config -> http_manager -> authenticator -> session ->
entitlement helper -> caches -> _init_managers()
* _build_channels / _build_vod
* _clear_caches (the session's on_invalidate hook)
Why BOTH `self.authenticator` and `self.auth` exist: `self.auth` (JoynSession) is
what the managers use. `self.authenticator` (the raw JoynAuthenticator) is kept
because ProviderAuthMixin._build_provider_headers reaches for
`self.authenticator.get_bearer_token()` directly — shared with v1 providers.
Managers never use it.
DRM_IN_MANAGERS = True: manifest and DRM come from the same /playlist call, so
both managers override get_*_drm (built by drm.build_widevine_config) and share a
playout cache with their manifest method.
HEADERS_FROM_MANAGERS = True: the CDN set differs from the API set (no bearer);
the manager header hooks return the right set per call.
Known limitations (README §18):
* A ProxyConfig only affects Python-side HTTPManager traffic. Licence, manifest
and segment requests are made by inputstream.adaptive inside Kodi.
* The 7pass login flow uses its own requests session inside the authenticator.
"""
from dataclasses import dataclass
from typing import ClassVar, Dict, List, Optional, Tuple, Union
from datetime import datetime
from typing import ClassVar, Dict, List, Optional
from ...base.models import DRMConfig, StreamingChannel, Event, ContentType
from ...base.managed_provider import ManagedProvider
from ...base.models.proxy_models import ProxyConfig
from ...base.provider import StreamingProvider
from ...base.utils.logger import logger
from .auth import JoynAuthenticator
from .channel_manager import JoynChannelManager
from .config import JoynConfig
from .constants import JOYN_LOGO, PROVIDER_NAME, SUPPORTED_COUNTRIES
from .entitlement import JoynEntitlement
from .session import JoynSession
from .vod_manager import JoynVodManager
from .epg_manager import JoynEpgManager
from .catchup_manager import JoynCatchupManager
from .constants import (
DEFAULT_PLATFORM,
DEFAULT_REQUEST_TIMEOUT,
DEFAULT_MAX_RETRIES,
JOYN_USER_AGENT,
JOYN_LOGO,
SUPPORTED_COUNTRIES,
COUNTRY_TENANT_MAPPING,
DEFAULT_EPG_WINDOW_HOURS,
)
from ...base.models.vod import VodCategory, VodItem
# Only the provider is public. Discovery takes the FIRST StreamingProvider
# subclass in the package namespace; ManagedProvider must never appear here.
__all__ = ["JoynProvider"]
@dataclass
class JoynConfig:
"""Configuration dataclass for Joyn Provider"""
country: str = "de"
platform: str = DEFAULT_PLATFORM
config_dir: Optional[str] = None
proxy_config: Optional[ProxyConfig] = None
proxy_url: Optional[str] = None
timeout: int = DEFAULT_REQUEST_TIMEOUT
max_retries: int = DEFAULT_MAX_RETRIES
class JoynProvider(StreamingProvider):
"""
Joyn streaming provider - Pure orchestrator
"""
class JoynProvider(ManagedProvider):
PROVIDER_LABEL: ClassVar[str] = "Joyn"
PROVIDER_LOGO: ClassVar[str] = JOYN_LOGO
SUPPORTED_AUTH_TYPES: ClassVar[List[str]] = [
"client_credentials",
"user_credentials",
]
PROVIDER_LOGO: ClassVar[str] = JOYN_LOGO
SUPPORTED_COUNTRIES: ClassVar[List[str]] = SUPPORTED_COUNTRIES
# Folded DRM (see module docstring). ManagedProvider refuses this flag
# together with a non-None _build_drm().
DRM_IN_MANAGERS: ClassVar[bool] = True
# Manager header hooks return the CDN set (no bearer); without this flag
# they would be dead code and the CDN would receive the API headers.
HEADERS_FROM_MANAGERS: ClassVar[bool] = True
def __init__(
self,
country: str = "DE",
config: Optional[JoynConfig] = None,
**kwargs,
proxy_config: Optional[ProxyConfig] = None,
proxy_url: Optional[str] = None,
config_dir: Optional[str] = None,
):
"""
Initialize Joyn provider
super().__init__(country=country)
self.country = self.country.lower()
if self.country not in SUPPORTED_COUNTRIES:
raise NotImplementedError(
f"Joyn does not support country {country!r} "
f"(supported: {', '.join(SUPPORTED_COUNTRIES)})"
)
Args:
config: JoynConfig dataclass. If None, will construct from kwargs.
"""
# Backward compatibility: construct config from kwargs if not provided
# 1. ONE config object, shared by every layer — including the
# authenticator. Re-derive every country-dependent field: the caller's
# config may have been built for another country.
if config is None:
config = JoynConfig(**kwargs)
config = JoynConfig(country=self.country)
else:
config.country = self.country # setter keeps everything in sync
self.provider_config = config
if not self.validate_country(config.country):
supported = ", ".join(self.SUPPORTED_COUNTRIES)
raise ValueError(f"Unsupported country: {config.country}. Joyn supports: {supported}")
super().__init__(country=config.country)
self.config = config
self.platform = config.platform
self.distribution_tenant = COUNTRY_TENANT_MAPPING.get(config.country, "JOYN")
# 1. SETUP HTTP MANAGER
# 2. HTTP manager.
self.http_manager = self._setup_http_manager(
provider_name="joyn",
proxy_config=config.proxy_config,
proxy_url=config.proxy_url,
config_dir=config.config_dir,
user_agent=JOYN_USER_AGENT,
timeout=config.timeout,
max_retries=config.max_retries,
provider_name=PROVIDER_NAME,
proxy_config=proxy_config,
proxy_url=proxy_url,
config_dir=config_dir,
user_agent=self.provider_config.user_agent,
timeout=self.provider_config.timeout,
max_retries=self.provider_config.max_retries,
)
# 2. SETUP AUTHENTICATION
# 3. Auth (two attributes on purpose — see module docstring).
self.authenticator = JoynAuthenticator(
country=config.country,
platform=config.platform,
config_dir=config.config_dir,
country=self.country,
platform=self.provider_config.platform,
config_dir=config_dir,
http_manager=self.http_manager,
proxy_config=self.http_manager.config.proxy_config,
proxy_config=proxy_config, # the raw ctor argument (may be None)
config=self.provider_config,
)
self.auth = JoynSession(
authenticator=self.authenticator,
config=self.provider_config,
on_invalidate=self._clear_caches,
)
# 3. SETUP MANAGERS
self.channel_manager = JoynChannelManager(provider=self)
self.vod_manager = JoynVodManager(provider=self)
self.epg_manager = JoynEpgManager(provider=self)
self.catchup_manager = JoynCatchupManager(provider=self)
# 4. Provider-owned caches, borrowed by managers by reference.
self._playout_cache: Dict = {}
self._entitlement_cache: Dict = {}
# Initial authentication
try:
self.bearer_token = self.authenticator.get_bearer_token()
except Exception as e:
logger.warning(f"Could not authenticate during initialization: {e}")
self.bearer_token = None
# 5. Entitlement helper, shared by both managers via keyword extra.
self._entitlement = JoynEntitlement(
http_manager=self.http_manager,
auth=self.auth,
config=self.provider_config,
cache=self._entitlement_cache,
)
# ============================================================================
# PROVIDER PROPERTIES
# ============================================================================
# 6. Managers, in dependency order. self.channels is the ChannelManager
# from here on — never assign a list to it. No eager login: the first
# caller that needs a token logs in through self.auth.
self._init_managers()
# ------------------------------------------------------------------
# Cache lifecycle (session.on_invalidate)
# ------------------------------------------------------------------
def _clear_caches(self) -> None:
"""Drop everything that is specific to the account / token.
Registered as JoynSession's on_invalidate callback, so it runs on every
credential change. Defensive about construction order: it can only be
invoked after __init__, but the managers are optional by design.
"""
self._playout_cache.clear()
entitlement = getattr(self, "_entitlement", None)
if entitlement is not None:
entitlement.clear()
vod = getattr(self, "vod", None)
if vod is not None and hasattr(vod, "clear_cache"):
vod.clear_cache()
# ------------------------------------------------------------------
# Manager factories
# ------------------------------------------------------------------
def _build_channels(self):
return JoynChannelManager(
http_manager=self.http_manager,
auth=self.auth,
country=self.country,
config=self.provider_config,
entitlement=self._entitlement,
playout_cache=self._playout_cache,
)
def _build_vod(self):
return JoynVodManager(
http_manager=self.http_manager,
auth=self.auth,
country=self.country,
config=self.provider_config,
entitlement=self._entitlement,
)
# No _build_epg, no _build_catchup: Joyn does not have them. A provider
# without a capability simply has no manager.
# ------------------------------------------------------------------
# Provider-specific id grammar
# ------------------------------------------------------------------
# None the router cannot express: live ids are bare slugs, VOD ids are
# a_/b_/c_/d_/block-<n>/paths — both managers decide through ids.is_vod_id().
@property
def provider_name(self) -> str:
return "joyn"
return PROVIDER_NAME
@property
def provider_label(self) -> str:
country_map = {
"de": "Joyn Germany",
"at": "Joyn Austria",
"ch": "Joyn Switzerland",
}
return country_map.get(self.config.country, f"Joyn ({self.config.country.upper()})")
@property
def provider_logo(self) -> str:
return self.PROVIDER_LOGO
@property
def supported_auth_types(self) -> List[str]:
return self.SUPPORTED_AUTH_TYPES
@property
def uses_dynamic_manifests(self) -> bool:
return False
@property
def implements_epg(self) -> bool:
return self.epg_manager.implements_epg
@property
def epg_window(self) -> Tuple[int, int]:
return self.epg_manager.epg_window
@property
def implements_vod(self) -> bool:
return self.vod_manager.implements_vod
@property
def catchup_window(self) -> int:
return self.catchup_manager.catchup_window
@property
def supports_catchup(self) -> bool:
return self.catchup_manager.supports_catchup
# ============================================================================
# DELEGATED METHODS
# ============================================================================
def authenticate(self, **kwargs) -> str:
self.bearer_token = self.authenticator.get_bearer_token(
force_refresh=kwargs.get("force_refresh", False)
)
return self.bearer_token
def refresh_authentication(self) -> str:
self.bearer_token = self.authenticator.get_bearer_token(force_refresh=True)
return self.bearer_token
def get_channels(
self,
time_window_hours: int = DEFAULT_EPG_WINDOW_HOURS,
fetch_manifests: bool = False,
populate_streaming_data: bool = True,
**kwargs,
) -> List[StreamingChannel]:
return self.channel_manager.get_channels(
time_window_hours=time_window_hours,
fetch_manifests=fetch_manifests,
populate_streaming_data=populate_streaming_data,
**kwargs,
)
def get_events(
self,
start_time: Optional[datetime] = None,
end_time: Optional[datetime] = None,
**kwargs,
) -> List[Event]:
return self.epg_manager.get_events(start_time, end_time, **kwargs)
def get_epg(
self,
channel_id: str,
start_time: Optional[datetime] = None,
end_time: Optional[datetime] = None,
**kwargs,
) -> List:
return self.epg_manager.get_epg(channel_id, start_time, end_time, **kwargs)
def get_epg_grid(
self,
start_time: Optional[datetime] = None,
end_time: Optional[datetime] = None,
channel_ids: Optional[List[str]] = None,
**kwargs,
) -> Dict[str, List]:
return self.epg_manager.get_epg_grid(start_time, end_time, channel_ids, **kwargs)
def get_program_details(self, program_id: str, **kwargs) -> Optional[Dict]:
return self.epg_manager.get_program_details(program_id, **kwargs)
# ============================================================================
# ROUTING HELPER
# ============================================================================
def _is_vod_content(self, content_id: str, content_type: str = ContentType.LIVE) -> bool:
"""
Route to the VOD manager if content_id matches VOD patterns, rather than
guessing off a bare "_" in content_id (which breaks the moment a live
channel slug picks up an underscore).
VOD IDs: a_/b_/c_/d_ prefixed asset ids, "block-" lazy-block ids, or
browsable paths (contain "/") and block ids (contain ":") as used by
JoynVodManager._is_block_id / get_vod_category.
Live channel IDs are plain slugs, e.g. "sat1-de".
"""
if content_type == ContentType.VOD:
return True
return (
content_id.startswith(("a_", "b_", "c_", "d_", "block-")) or
"/" in content_id or
":" in content_id
)
# ============================================================================
# MANIFEST/PLAYBACK METHODS
# ============================================================================
def get_manifest(
self,
content_id: str,
content_type: str = ContentType.LIVE,
video_config: Optional[Dict] = None,
**kwargs,
) -> Optional[str]:
"""
Get manifest URL - routes to VOD or Channel manager based on content_id.
"""
if self._is_vod_content(content_id, content_type):
return self.vod_manager.get_vod_manifest(content_id, video_config, **kwargs)
return self.channel_manager.get_manifest(
content_id=content_id,
content_type=content_type,
video_config=video_config,
**kwargs,
)
def get_manifest_headers(self, content_id: str, **kwargs) -> Dict[str, str]:
"""Get manifest headers"""
return self.channel_manager.get_manifest_headers(content_id, **kwargs)
def get_drm(
self,
content_id: str,
content_type: str = ContentType.LIVE,
video_config: Optional[Dict] = None,
**kwargs,
) -> List[DRMConfig]:
"""
Get DRM configurations - routes to VOD or Channel manager based on content_id.
"""
if self._is_vod_content(content_id, content_type):
return self.vod_manager.get_vod_drm(content_id, video_config, **kwargs)
return self.channel_manager.get_drm(
content_id=content_id,
content_type=content_type,
video_config=video_config,
**kwargs,
)
def enrich_channel_data(
self,
channel: StreamingChannel,
video_config: Optional[Dict] = None,
**kwargs,
) -> Optional[StreamingChannel]:
return self.channel_manager.enrich_channel_data(channel, video_config, **kwargs)
def populate_streaming_data(
self,
channels: List[StreamingChannel],
video_config: Optional[Dict] = None,
max_retries: int = DEFAULT_MAX_RETRIES,
) -> List[StreamingChannel]:
return self.channel_manager.populate_streaming_data(channels, video_config, max_retries)
def get_dynamic_manifest_params(self, channel: StreamingChannel, **kwargs) -> Optional[str]:
return None
def get_vod_category(self, content_id: str = "", **kwargs) -> List[Union[VodCategory, VodItem]]:
return self.vod_manager.get_vod_category(content_id, **kwargs)
def search_vod(
self,
query: str,
cursor: Optional[str] = None,
page_size: int = 24,
**kwargs,
) -> Dict:
return self.vod_manager.search(query, cursor, page_size, **kwargs)
def get_vod_item_details(self, content_id: str, **kwargs) -> Optional[Dict]:
# VodItem inherits from Content, which provides to_dict() — there is no
# to_vod_item() method, so the previous dataclasses.asdict(item.to_vod_item(...))
# call raised AttributeError on every invocation.
item = self.vod_manager.get_content_details(content_id, **kwargs)
if item:
return item.to_dict()
return None
def get_vod_manifest(
self,
content_id: str,
video_config: Optional[Dict] = None,
**kwargs,
) -> Optional[str]:
return self.vod_manager.get_vod_manifest(content_id, video_config, **kwargs)
def get_vod_drm(
self,
content_id: str,
video_config: Optional[Dict] = None,
**kwargs,
) -> List[DRMConfig]:
return self.vod_manager.get_vod_drm(content_id, video_config, **kwargs)
def get_vod_manifest_with_headers(
self,
content_id: str,
video_config: Optional[Dict] = None,
**kwargs,
) -> Tuple[Optional[str], Dict[str, str]]:
return self.vod_manager.get_vod_manifest_with_headers(content_id, video_config, **kwargs)
def to_output_format(self, channels: List[StreamingChannel] = None) -> Dict:
if channels is None:
channels = self.channel_manager.channels if self.channel_manager else []
return {
"Provider": self.provider_name,
"Country": self.config.country,
"Channels": [channel.to_dict() for channel in channels],
}
def to_json(self, channels: List[StreamingChannel] = None, indent: int = 2) -> str:
import json
return json.dumps(self.to_output_format(channels), indent=indent, ensure_ascii=False)
# ------------------------------------------------------------------
# Credentials API (settings UI) — deliberately NOT added
# ------------------------------------------------------------------
# The migration pack found no caller of set_user_credentials /
# get_auth_details / get_last_auth_error for Joyn. If the settings UI grows
# one: build credentials -> assign -> self.auth.invalidate() (clears memory +
# persisted token, resets backoff, runs _clear_caches) -> ONE login -> persist
# only on success.
@@ -0,0 +1,207 @@
# streaming_providers/providers/joyn/session.py
# -*- coding: utf-8 -*-
"""
Auth Variant B — a session adapter in front of the stateful JoynAuthenticator.
managers --(AuthProtocol)--> JoynSession --> JoynAuthenticator
The managers consume JoynSession through AuthProtocol (get_access_token /
build_headers / invalidate). The raw authenticator stays on the provider for one
reason: ProviderAuthMixin reaches for `self.authenticator.get_bearer_token()`
directly (shared with v1 providers). The managers never touch it.
Error contract
--------------
ensure() -> bool, never raises (settings UI, opportunistic pre-login)
require() -> raises typed errors
last_error keeps the ORIGINAL exception for the auth-status UI
* The TYPE of a ProviderError raised by the authenticator is preserved
(JoynMfaRequiredException -> stays an AuthError, a WAF block stays whatever
the authenticator made it). Only unexpected, untyped exceptions become
ServerError. README §9: "the session keeps its type".
* PERMANENT (MFA, rejected credentials): no retry until reset(). The 7pass
login is rate-limited and locks accounts on repeated failures.
* TRANSIENT (network, WAF, 5xx, rate limit): exponential backoff
BACKOFF_BASE_SECONDS * 2^(n-1), capped at BACKOFF_MAX_SECONDS.
Concurrency: one login for concurrent callers (RLock + re-check).
Invalidation: invalidate() clears the authenticator (memory + persisted), resets
the backoff state and then runs the registered `on_invalidate` callbacks — the
provider uses that to clear its playout / entitlement / VOD caches, which are
account-specific.
"""
import threading
import time
from typing import TYPE_CHECKING, Callable, Dict, List, Optional, Tuple, Type
from ...base.errors import AuthError, ProviderError, ServerError
from ...base.utils.logger import logger
from .models import JoynAuthError, JoynMfaRequiredException
if TYPE_CHECKING: # the session depends on the authenticator's *shape*, not its module
from .auth import JoynAuthenticator
BACKOFF_BASE_SECONDS = 30
BACKOFF_MAX_SECONDS = 900
class JoynSession:
# Failures that retrying with the same input cannot fix.
# JoynMfaRequiredException — the user must disable MFA.
# JoynAuthError — 7pass rejected the credentials / no auth code.
PERMANENT_ERRORS: Tuple[Type[Exception], ...] = (
JoynMfaRequiredException,
JoynAuthError,
)
def __init__(
self,
*,
authenticator: "JoynAuthenticator",
config,
clock: Callable[[], float] = time.monotonic,
on_invalidate: Optional[Callable[[], None]] = None,
):
self.authenticator = authenticator
self.config = config
self._clock = clock
self.last_error: Optional[Exception] = None
self._failures = 0
self._retry_at = 0.0
self._permanent = False
self._failure_kind: Optional[Tuple[Type[Exception], str]] = None
self._on_invalidate: List[Callable[[], None]] = []
if on_invalidate is not None:
self._on_invalidate.append(on_invalidate)
self._lock = threading.RLock()
# ------------------------------------------------------------------
# AuthProtocol
# ------------------------------------------------------------------
def get_access_token(self) -> str:
self.require()
token = self.authenticator.current_token
if token is None or token.is_expired:
# Expired between require() and here (clock edge): one more gate pass.
self.require()
token = self.authenticator.current_token
if token is None:
raise AuthError("not authenticated")
return token.access_token
def build_headers(self) -> Dict[str, str]:
"""ABC default header set (Joyn API headers + bearer). Managers needing
the CDN / entitlement / DRM sets call JoynConfig directly."""
return self.config.api_headers(self.get_access_token())
def invalidate(self) -> None:
"""Clear memory and persisted storage, reset backoff, drop account caches."""
self.authenticator.invalidate_token() # memory + persisted
self.reset()
for callback in list(self._on_invalidate):
try:
callback()
except Exception as exc: # a broken cache hook must not block a logout
logger.warning(f"Joyn: invalidate callback failed: {exc!r}")
# ------------------------------------------------------------------
# Gate
# ------------------------------------------------------------------
def ensure(self) -> bool:
"""Never raises. True if a usable token exists afterwards."""
if self._ready():
return True
with self._lock:
if self._ready():
return True
if self._permanent:
return False
if self._clock() < self._retry_at:
return False
return self._login()
def require(self) -> None:
"""Raises a typed error if no usable token can be produced."""
if self.ensure():
return
kind, message = self._failure_kind or (AuthError, "not authenticated")
try:
error = kind(message)
except TypeError: # exotic constructor on a provider-specific type
error = ServerError(message)
raise error from self.last_error
def reset(self) -> None:
"""Credentials changed: forget everything, including backoff."""
with self._lock:
self.last_error = None
self._failure_kind = None
self._permanent = False
self._failures = 0
self._retry_at = 0.0
# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------
def _ready(self) -> bool:
token = self.authenticator.current_token
return token is not None and not token.is_expired
def _login(self) -> bool:
# With no stored credentials the authenticator falls back to the anonymous
# flow: Joyn works without a login (v1 behaviour, preserved).
try:
self._fetch_token()
except Exception as exc:
permanent = isinstance(exc, self.PERMANENT_ERRORS)
kind: Type[Exception] = type(exc) if isinstance(exc, ProviderError) else ServerError
return self._fail(exc, kind, permanent=permanent)
if not self._ready():
return self._fail(
RuntimeError("authenticator returned no usable token"),
AuthError,
permanent=False,
)
self.last_error = None
self._failure_kind = None
self._permanent = False
self._failures = 0
self._retry_at = 0.0
return True
def _fetch_token(self) -> None:
"""
Let the authenticator produce a token.
First WITHOUT force_refresh: a valid persisted token (or a refresh-token
refresh) must not trigger a full 7pass login at every process start. Only
if that still leaves no usable token do we force a fresh login.
"""
self.authenticator.get_bearer_token()
if not self._ready():
self.authenticator.get_bearer_token(force_refresh=True)
def _fail(self, exc: Exception, kind: Type[Exception], *, permanent: bool) -> bool:
self.last_error = exc
self._failure_kind = (kind, f"authentication failed — {exc}")
self._permanent = permanent
if not permanent:
self._failures += 1
delay = min(
BACKOFF_BASE_SECONDS * 2 ** (self._failures - 1),
BACKOFF_MAX_SECONDS,
)
self._retry_at = self._clock() + delay
logger.error(self._failure_kind[1])
return False
@@ -0,0 +1,46 @@
# streaming_providers/providers/joyn/signing.py
# -*- coding: utf-8 -*-
"""
Playlist request signing and video-config helpers.
Pure functions shared by the channel and the VOD manager. They used to live in
channel_manager.py, which made the VOD manager import its sibling module; a
neutral module keeps "managers do not reach into each other" true.
IMPORTANT: the playlist request is signed over the exact JSON string built by
create_video_payload(). Changing DEFAULT_VIDEO_CONFIG changes the signature.
"""
import hashlib
import json
from base64 import b64decode
from typing import Dict, Optional
from .constants import DEFAULT_VIDEO_CONFIG, SIGNATURE_SECRET_KEY
def create_video_payload(config: Optional[Dict] = None, compact: bool = True) -> str:
video_config = config or DEFAULT_VIDEO_CONFIG
payload = json.dumps(video_config)
return payload.replace(" ", "") if compact else payload
def build_signature(
entitlement_token: str,
video_payload: Optional[str] = None,
secret_key: Optional[str] = None,
) -> str:
if video_payload is None:
video_payload = create_video_payload()
if secret_key is None:
secret_key = b64decode(SIGNATURE_SECRET_KEY).decode("utf-8")
signature_input = f"{video_payload},{entitlement_token}{secret_key}"
return hashlib.sha1(signature_input.encode("utf-8")).hexdigest()
def video_config_fingerprint(video_config: Optional[Dict]) -> str:
"""Cache-key component: different configs can yield different manifests/DRM."""
if not video_config:
return "default"
normalized = json.dumps(video_config, sort_keys=True, separators=(",", ":"))
return hashlib.sha1(normalized.encode("utf-8")).hexdigest()[:12]
@@ -0,0 +1,43 @@
def test_contract(http):
errors, warnings = check_provider(JoynProvider, "joyn")
assert errors == [], (errors, warnings)
def test_no_network_in_init(http):
JoynProvider("DE")
assert http.calls == [] # the old eager login is gone
def test_joyn_headers_do_not_leak_into_cdn(http):
p = JoynProvider("DE")
h = p.get_manifest_headers("sat1-de")
assert "Authorization" not in h
assert "joyn-client-version" not in h
def test_segment_headers_equal_manifest_headers(http):
p = JoynProvider("DE")
assert p.get_segment_headers("sat1-de") == p.get_manifest_headers("sat1-de")
def test_entitlement_400_playback_restricted_becomes_typed_error(http):
http.entitlement_response = 400, [{"code": "ENT_RVOD_Playback_Restricted", "msg": "nope"}]
p = JoynProvider("DE")
with pytest.raises(EntitlementError):
p.get_manifest("sat1-de")
def test_mfa_raises_auth_error_not_anonymous_fallback(http):
http.login_redirect_to_mfa = True
p = JoynProvider("DE")
p.set_user_credentials(...) if that exists # it doesn't, so:
with pytest.raises(AuthError):
p.channels.get_channels()
def test_get_vod_category_returns_vodpage(http):
from ...base.vod import VodPage
p = JoynProvider("DE")
page = p.vod.get_vod_category("")
assert isinstance(page, VodPage)
def test_handles_content_id_is_pure(http):
p = JoynProvider("DE")
# No HTTP call should be made for either of these
assert p.vod.handles_content_id("a_abc123") is True
assert p.vod.handles_content_id("sat1-de") is False
assert http.calls == []
File diff suppressed because it is too large Load Diff