mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-10-12 02:42:39 +02:00
migrate joyn and new template
This commit is contained in:
@@ -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"]
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -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
Reference in New Issue
Block a user