diff --git a/lib/streaming_providers/base/testing/__init__.py b/lib/streaming_providers/base/testing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lib/streaming_providers/base/testing/migration_pack.py b/lib/streaming_providers/base/testing/migration_pack.py new file mode 100644 index 0000000..45deccf --- /dev/null +++ b/lib/streaming_providers/base/testing/migration_pack.py @@ -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 \ + --repo [--extra-root ...] \ + [-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.` 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.` + 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()) \ No newline at end of file diff --git a/lib/streaming_providers/base/testing/provider_contract.py b/lib/streaming_providers/base/testing/provider_contract.py new file mode 100644 index 0000000..f9f25d7 --- /dev/null +++ b/lib/streaming_providers/base/testing/provider_contract.py @@ -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 \ No newline at end of file diff --git a/lib/streaming_providers/providers/_template_v2/MIGRATION_BRIEF.md b/lib/streaming_providers/providers/_template_v2/MIGRATION_BRIEF.md new file mode 100644 index 0000000..034238f --- /dev/null +++ b/lib/streaming_providers/providers/_template_v2/MIGRATION_BRIEF.md @@ -0,0 +1,60 @@ +# Migration brief — `` (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 --repo \ + --extra-root --extra-root -o pack.txt +python -m streaming_providers.base.testing.migration_pack --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. diff --git a/lib/streaming_providers/providers/_template_v2/README.md b/lib/streaming_providers/providers/_template_v2/README.md new file mode 100644 index 0000000..99bf695 --- /dev/null +++ b/lib/streaming_providers/providers/_template_v2/README.md @@ -0,0 +1,1306 @@ +# Provider template v2 (`_template_v2`) + +The v2 way to write a streaming provider: a thin **provider class** on top of +`ManagedProvider`, one **manager** per capability, a shared **error contract**, +and a **contract test** that tells you immediately when something is wired +wrong. + +`_template` (v1) stays next to it and keeps working. **New providers use v2; +existing ones are migrated over time** (§12). The two coexist because +`ManagedProvider` *is a* `StreamingProvider` — registry, backend operations and +the legacy mixins do not know the difference. + +Directories starting with `_` are skipped by provider discovery, so neither +template is ever registered. + +> **How reliable is this document?** Everything here was derived from the code +> of the base, the `*_operations.py` layer and two real migrations (simpli, +> Allente), and is exercised by the test suite that ships with the template +> (86 tests, in a sandbox with stubs for the modules that were never uploaded). +> Statements about things *not* seen are marked **(unverified)**. The first +> provider you migrate against this README is the real validation — please +> note every place where the README was wrong or silent, and report it. + +--- + +## Contents + +1. [What you get, in 60 seconds](#1-what-you-get-in-60-seconds) +2. [Quickstart: a new provider in 10 steps](#2-quickstart-a-new-provider-in-10-steps) +3. [The mental model](#3-the-mental-model) +4. [Identity, naming, countries](#4-identity-naming-countries) +5. [The provider class](#5-the-provider-class) +6. [Managers (one section per capability)](#6-managers) +7. [Routing and content ids](#7-routing-and-content-ids) +8. [Error contract](#8-error-contract) +9. [Authentication (two variants)](#9-authentication) +10. [HTTP, headers, proxies](#10-http-headers-proxies) +11. [DRM](#11-drm) +12. [Migrating an existing (v1) provider](#12-migrating-an-existing-v1-provider) +13. [Models, time, data rules](#13-models-time-data-rules) +14. [Caches and concurrency](#14-caches-and-concurrency) +15. [Testing](#15-testing) +16. [Traps (the list of things that already bit us)](#16-traps) +17. [Not covered / open decisions](#17-not-covered--open-decisions) +18. [Definition of done](#18-definition-of-done) +19. [Appendix](#19-appendix) + +--- + +## 1. What you get, in 60 seconds + +| You write | You inherit (do **not** re-implement) | +|---|---| +| `provider.py`: identity ClassVars, `__init__` wiring, `_build_*()` factories | the 8 `implements_*` flags (derived from the managers), `capabilities` | +| a `ChannelManager` (+ optional VOD / EPG / recordings / favorites / bookmarks / catchup managers) | `get_manifest`, `get_drm`, header delegation, content-id routing (`_route`) | +| `auth.py` (or a `session.py` adapter) | `get_channels`, `to_output_format`, EPG delegation | +| `constants.py`, `models.py`, `drm.py` | the legacy surface the backend calls: `get_recordings`, `delete_recording`, `get_favorites`…, `get_bookmarks`…, `get_vod_category`, `search_vod`, `catchup_window`, `get_catchup_*` | +| provider-specific id grammar (override + `super()`) | wiring checks at construction (wrong manager type, `DRM_IN_MANAGERS` + `_build_drm()`) | + +What v2 buys compared to v1: + +* **No silent gaps.** In v1 every provider copy-pasted ~130 lines of flags and + routing, and the copies drifted. The first migration (simpli) showed that its + EPG, recordings and catchup had *never* been reachable through the backend — + nothing complained, because legacy mixin defaults answered. In v2 a manager + that exists is exposed; one that doesn't is `None`. +* **One error contract** (§8) instead of "log and return `[]`". +* **A contract test** (§15) that catches the classic mistakes in one call. + +--- + +## 2. Quickstart: a new provider in 10 steps + +```bash +cd streaming_providers/providers +python _template_v2/scaffold.py joyn Joyn --label "Joyn" --country DE \ + --with epg,recordings,catchup +``` + +1. **Pick the id** (`joyn`): lower-case, `[a-z][a-z0-9_]*`. It is the directory + name, the registry key, `provider_name` and the credentials key — **forever** + (§4). +2. The scaffold copies the template to `providers/joyn/`, replaces + `example → joyn`, `Example → Joyn`, moves the chosen optional managers next to + `provider.py` and **prints the `_build_*()` snippets**. Paste them into + `provider.py`. (Without the scaffold: copy the directory, replace the tokens + by hand, move files out of `optional/`, delete `optional/` and `scaffold.py`. + Optional files use `from ....base` one level deeper; after moving it must be + `from ...base`.) +3. **`constants.py`**: fill in hosts, paths, headers, `SUPPORTED_COUNTRIES`. + Nothing else may contain URLs or header values. +4. **`models.py`**: write the `from_api_response()` parsers (strict) and the + conversion to `Channel`. +5. **Auth**: keep `auth.py` (Variant A) *or* replace it with your stateful + authenticator + `session.py` (Variant B) — §9. +6. **`channel_manager.py`**: implement `get_channels`, `get_channel_manifest` + (+ headers, + folded DRM if manifest and DRM share state). +7. **Decide the two flags** in `provider.py` — `DRM_IN_MANAGERS` and + `HEADERS_FROM_MANAGERS` (§11, §10). They must be present in the class body. +8. **Optional managers**: implement what the provider really has; delete the + rest. A capability the provider does not have is simply not built. +9. **Tests**: adapt `tests/test__provider.py` (fake HTTP, no network) and run + the contract test (§15). +10. **Verify on a device** with the checklist in §12.7. Then done (§18). + +--- + +## 3. The mental model + +``` +Kodi addon / PVR / API client + │ + ▼ +Backend routes ──► ProviderManager (facade) ──► *Operations ──► ProviderRegistry.get_provider(key) + │ + ▼ + YourProvider(ManagedProvider) + ┌───────────────┼─────────────────────────┐ + ▼ ▼ ▼ + ChannelManager EpgManager … (one per capability) auth (AuthProtocol) + │ │ │ + └── http_manager (HTTPManager, proxy-aware) ┘ +``` + +* The **operations layer** (`channel_operations`, `epg_operations`, + `drm_operations`, `catchup_operations`, `vod_operations`, `recording_operations`, + `favorite_operations`, `bookmark_operations`, `timer_operations`) is the *only* + caller of a provider. What it calls is the **legacy surface** (below). You never + write that surface — `ManagedProvider` maps it onto your managers. +* A **manager** owns one capability. Its constructor takes four keyword-only + collaborators — `http_manager`, `auth`, `country`, `config` — and nothing else + unless you add explicit keyword-only extras (§6.0). +* The **provider** owns shared resources (config, HTTP manager, auth, caches) and + builds the managers. Managers *borrow* caches by reference. +* **Auth** is a collaborator that satisfies `AuthProtocol`: + `get_access_token() / build_headers() / invalidate()`. + +### The legacy surface and where it goes + +This is what the backend actually calls (verified in the operations code), and +which manager answers it: + +| Backend call | → manager method | +|---|---| +| `get_channels(**kw)` | `ChannelManager.get_channels` | +| `get_manifest(content_id=…, **kw)` / `get_manifest_with_headers` | routed: `ChannelManager.get_channel_manifest`, then `VodManager.get_vod_manifest` | +| `get_manifest_headers`, `get_segment_headers(id, **kw)` | the manager that resolved the id (route cache); with `start_time=` in `kw` → `CatchupManager.get_catchup_segment_headers` | +| `get_drm(content_id=…, drm_variant=…, preferred_quality=…, preferred_format=…, )` | folded: `get_channel_drm` / `get_vod_drm`; or `_build_drm().get_drm_configs` | +| `implements_epg`, `epg_window`, `get_epg(channel_id, start_time=, end_time=, limit=, country=)`, `get_epg_grid(start_time=, end_time=, channel_ids=, country=)`, `get_program_details` | `EpgManager` | +| `get_vod_category(content_id=, cursor=, page_size=)`, `search_vod(query=, cursor=, page_size=)` | `VodManager` (`VodPage` → dict) | +| `get_recordings(include_deleted=)`, `delete_recording(recording_id)` | `RecordingsManager` | +| `get_favorites()`, `add_favorite(content_id=, favorite_type=, title=)`, `remove_favorite(content_id=)` | `FavoritesManager` | +| `get_bookmarks`, `update_bookmark(content_id=, position_seconds=, …)`, `delete_bookmark(content_id=)` | `BookmarksManager` | +| `catchup_window`, `supports_catchup`, `validate_catchup_request`, `get_catchup_window_for_channel`, `get_catchup_manifest(content_id=, start_time=, end_time=, epg_id=, country=, drm_variant=)`, `get_catchup_manifest_with_headers`, `get_catchup_drm` | `CatchupManager` | +| `implements_timers`, `get_timer_types`, `get_timers`, `add_timer`, `update_timer`, `delete_timer` | **not delegated** — legacy mixin defaults (§17) | +| `get_events`, subscriptions, `enrich_channel_data` | **not delegated** (§17) | + +The backend calls by **keyword** wherever it matters. That is why every manager +method takes `**kw`: the backend adds arguments (`country`, `drm_variant`, +`limit`, proxy extras) you may not care about — accept and ignore them. + +--- + +## 4. Identity, naming, countries + +### One id, everywhere + +``` +directory name == AVAILABLE_PROVIDERS key == provider_name == PROVIDER_NAME + == credentials / session key == enable-flag key +``` + +* Define it once: `ExampleDefaults.PROVIDER_NAME` in `constants.py`; + `provider_name` returns it. +* `AVAILABLE_PROVIDERS` is filled by discovery with the **directory name** + (`streaming_providers/__init__.py`). `ProviderManager.get_provider_class()` + looks providers up by that key (after stripping a `_xx` country suffix). +* The class name is **not** the id. `get_plugin_key()` (on every provider class) + returns the key the class is registered under; the old class-name derivation + (`SimpliTVProvider → "simplitv"`) survives only as a fallback for unregistered + classes. Name your class `Provider` anyway. +* **Never rename after release**: stored credentials, sessions and enable flags + are keyed by it, and `provider_name` also appears as `"Provider"` in the JSON + clients receive (`to_output_format`). +* Renamed a provider once (simplitv → simpli)? Then the *only* thing to check is + every place that derived a name from the class (`grep -rn 'replace("provider"' + base providers`). + +### Countries — three different cases + +`SUPPORTED_COUNTRIES` (ClassVar, list of ISO codes): + +| Value | Meaning | What the registry does | +|---|---|---| +| `[]` | single-country provider, default country | instance with the default country | +| exactly **one** entry | single-country, pinned | constructs with that entry **verbatim, UPPERCASE** (`"SE"`) | +| **several** entries | multi-country | fans out `joyn_de`, `joyn_at`, … and constructs with **lowercase** | + +* `supports_multiple_countries()` is `len(SUPPORTED_COUNTRIES) > 1`. A wildcard + `["*"]` is **not** multi-country here. +* The framework's storage managers (CredentialManager, SessionManager, + ProxyConfigManager) key by **lowercase** country. The registry may hand you + uppercase. **Normalise in `__init__`: `self.country = self.country.lower()`** + (the contract test warns if you don't). +* Validate unsupported countries in `__init__` (raise `NotImplementedError`), + as Allente does. +* `get_all_possible_instances()` hard-codes `"DE"` for single-country providers + (known issue, §17) — do not rely on it for AT/SE/… providers. + +### How the registry constructs providers — check, don't assume + +What is **verified** (read in the code): the metadata side — a single-entry +`SUPPORTED_COUNTRIES` pins the construction country to that entry verbatim +(uppercase); several entries produce one instance per country +(`get_all_possible_instances()` reports `requires_country_suffix`), with registry +keys `_`; `ProviderManager.get_provider_class()` strips the `_xx` suffix. + +What is **not verified here** (it was documented by the Allente provider, whose +author read it, but the registry's `create_instance` source is not part of this +README): *exactly how* the instance is called — `cls(country=…)`, with a +`config` object, with extras. A provider whose constructor looks different +(Joyn: `__init__(config, **kwargs)`) is therefore **not** "wrong"; it is simply +what the registry has been feeding it. + +**Procedure — do this before you touch the constructor** + +```bash +python -m streaming_providers.base.testing.migration_pack --repo +``` + +Section 1 prints the class's `__init__` signature, `SUPPORTED_COUNTRIES`, what the +metadata mixin reports, and the **source of `create_instance` / `_extract_metadata` / +`discover_all_providers`**. Rules derived from it: + +* Your migrated `__init__` must accept **exactly what the registry passes today** + (and what the settings/credentials flow passes, if any). Keep a `config` + argument and/or `**kwargs` if `create_instance` uses them; the template's + `(country, config, proxy_config)` is a starting point, not a requirement. +* `country` must reach `super().__init__(country=…)` and then be lower-cased. +* The contract test builds the provider with `country=SUPPORTED_COUNTRIES[0]`; if + your constructor **requires** more, pass it: + `check_provider(cls, key, ctor_kwargs={"config": …})`. +* Multi-country: the *class* is registered once under ``; the registry creates + the per-country instances. `provider_name` must still equal `` (not + `_`). + +**Is `config`/`**kwargs` used by something other than the registry?** The registry +source does not show the settings/credentials flow. Check before dropping either: + +```bash +grep -rn --include='*.py' '(' # direct constructions +grep -rn --include='*.py' 'get_provider_class' # data-driven construction +``` + +A call site with `config=`/extras ⇒ keep the parameter. Nothing found ⇒ it may still +be data-driven; the safe default for a **migration** is to **keep the v1 constructor +signature unchanged** (the migration pack prints it) and change only the body. + +--- + +## 5. The provider class + +`provider.py` in this template is a complete, runnable example. Anatomy: + +```python +class ExampleProvider(ManagedProvider): + PROVIDER_LABEL, PROVIDER_LOGO, SUPPORTED_AUTH_TYPES, SUPPORTED_COUNTRIES # static metadata + DRM_IN_MANAGERS: ClassVar[bool] = True # decision 1 — §11 + HEADERS_FROM_MANAGERS: ClassVar[bool] = True # decision 2 — §10 + + @property + def provider_name(self) -> str: ... # abstract in the base, must equal the key + + def __init__(self, country="DE", config=None, proxy_config=None): + super().__init__(country=country) # ONLY country — no **kwargs + self.country = self.country.lower() + self.provider_config = ExampleConfig(config or {}) # 1. ONE config + self.http_manager = self._setup_http_manager(...) # 2. HTTP + self.auth = ExampleAuth(http_manager=…, config=…) # 3. auth, lazy + self._playout_cache = {} # 4. caches + self._init_managers() # 5. managers + + def _build_channels(self): ... # one factory per manager you have +``` + +Rules: + +1. **Required members**: `provider_name` (abstract), a concrete `get_manifest` + (inherited from `ManagedProvider`). `StreamingProvider.__init__` takes only + `country` (no `**kwargs`) — that is the **base** call. Your own `__init__` + must accept what the **registry passes** (see §4 "How the registry constructs + providers"; check it with the migration pack). For a **new** provider the + template shape `(country, config, proxy_config)` has no `**kwargs` so that a + typo fails loudly; a **migrated** provider keeps `config`/`**kwargs` if + `create_instance` or the settings flow uses them (simpli and Joyn do). +2. **Order in `__init__`**: `super().__init__` → config → http_manager → auth → + caches → `_init_managers()`. `_init_managers()` must come last; it builds the + managers in the fixed order `channels, vod, epg, recordings, favorites, + bookmarks, catchup, drm` and runs the wiring checks. +3. **`_build_*()` factories** return a manager or `None` (the default). A manager + that needs another (catchup needs channels) reads `self.channels` in its + factory — that is why the order is fixed. +4. **Flags are derived. Never override `implements_*`.** `implements_catchup` is + `catchup.supports_catchup`; `implements_epg` is `epg.implements_epg`; + `implements_drm` is `drm is not None or DRM_IN_MANAGERS`. +5. **`self.channels` is the `ChannelManager`** (it shadows the legacy list + `StreamingProvider.__init__` creates). Never assign a list to it, never do + `self.channels = fetched_channels`. `to_output_format()` is already overridden + for this. +6. **No network I/O in `__init__`.** One accepted exception: an *opportunistic* + login when credentials are already stored (Allente). The contract test runs + your constructor with a network-disabled HTTP manager and fails on any call. +7. **`__all__ = ["YourProvider"]`** in `provider.py` **and** in the package + `__init__.py`, and never import `ManagedProvider` into the package namespace. + Discovery takes the *first* `StreamingProvider` subclass it finds in + `dir(package)` (alphabetical!): an exported `ManagedProvider` sorts before + most provider names and would be registered instead. +8. **Provider-specific id grammar** (e.g. `catchup:@`) is the one + thing that stays in the provider: override `get_manifest` / `get_drm`, handle + your prefix, `return super().…` for everything else. +9. **Credentials API for the settings UI** lives on the provider + (`set_user_credentials`, `get_last_auth_error`, `get_auth_details(context)`); + see §9. + +### 5.1 Where shared state lives (the rule that prevents `provider` back-references) + +A manager must never hold or call the provider (`self._provider.bearer_token`, +`self._provider.get_profile()`): that is the v1 Allente shape and it makes the +manager untestable and the state ownership unclear. State a manager needs comes in +through a **constructor collaborator**. Decide per piece of state: + +| The state is … | It lives in … | Managers get it via | +|---|---|---| +| derived from the login (token, entitlement tag, profile, user id) | the **auth / session** object | `self.auth.` (Allente: `auth.entitlement_tag`, `auth.profile`) | +| fetched separately, with its own lifecycle/expiry (e.g. an entitlement or package list loaded after login) | a **small dedicated collaborator** (`JoynEntitlements`), owned by the provider | keyword-only extra `entitlements=` on every manager that needs it | +| a cache | a plain **dict owned by the provider** | keyword-only extra, borrowed by reference | +| a pure helper (id parsing, header building, URL building) | a **module-level function** or the `Config` | import it | +| needed by the **settings UI only** | the provider (thin delegate to the session) | — (managers never read it) | +| read by a **legacy mixin through `self`** (e.g. `ProviderAuthMixin` reads `self.authenticator.get_bearer_token()`; shared with every v1 provider) | **stays on the provider — do not delete it** | managers do **not** use it and do not call the mixin; they use `self.auth` and build headers from `Config` | + +Threshold: **two consumers is enough** for a dedicated collaborator (live manager + +VOD manager already are two). A "provider-owned helper method that managers call" +is the back-reference in disguise — don't, not even "for the first pass": the +migration then has to be done twice. If you cannot decide between "part of the +session" and "separate type": derived from the token ⇒ session; has its own +fetch/refresh ⇒ separate type. + +`python -m streaming_providers.base.testing.migration_pack --repo `, +section 4, lists every back-reference the v1 managers make into the provider — +each line needs one of the rows above. + +--- + +## 6. Managers + +### 6.0 Constructor contract (all managers) + +```python +class YourManager(ChannelManager): # or Vod/Epg/Recordings/Favorites/Bookmarks/Catchup + def __init__(self, *, http_manager, auth, country, config, my_cache=None): + super().__init__(http_manager=http_manager, auth=auth, country=country, config=config) + self._my_cache = my_cache if my_cache is not None else {} +``` + +* Four required keyword-only collaborators, **no `**kwargs`** (a typo at a call + site is an immediate `TypeError`). +* Extras are keyword-only, declared explicitly, stored **after** `super().__init__`, + and `super()` receives **only the four**. +* `auth` is checked against `AuthProtocol` (method presence only); a mismatch is + a logged warning, not an error. +* Managers expose `self.http_manager`, `self.auth`, `self.country`, `self.config`. +* Managers never talk to each other through the provider; a manager that needs + another gets it as an explicit extra (`channels=`). +* **Return-value convention for all managers:** + *"I don't handle this id"* is a **return value** (`None` / `[]`); + *real failures* **raise** typed errors from `base.errors` (§8). + +### 6.1 `ChannelManager` (every live provider) + +| Method | Kind | Contract | +|---|---|---| +| `get_channels(**kw) -> List[Channel]` | abstract | `[]` if none. Parse entry-by-entry, skip malformed entries with a warning. | +| `get_channel_manifest(content_id, **kw) -> Optional[str]` | abstract | `None` = not mine (router tries the next manager); raise for real failures | +| `get_channel_manifest_headers(content_id, **kw)` | override | default = `auth.build_headers()` = **API headers**; override with the CDN headers (§10) | +| `get_segment_headers(content_id, **kw)` | concrete | default = manifest headers | +| `get_channel_drm(content_id, **kw) -> List[DRMConfig]` | concrete | default `[]` = no DRM (§11) | +| `handles_content_id(content_id)` | concrete | default `True`; override when ids of several managers coexist. **Cheap, pure, no I/O.** | + +### 6.2 `VodManager` + +> **Return `VodPage`, not a list.** `ManagedProvider` tolerates a list/dict/`None` at +> runtime (`normalize_vod_result`), so a v1-shaped return **does not break anything** +> — but it violates the ABC, and nothing (not even the contract test) will tell you. +> When migrating a `get_vod_category`/`search_vod` that returns a list or dict, wrap it: +> `VodPage(entries=…, next_cursor=…, total=…)`, or `normalize_vod_result(result)`. + +* `get_vod_category(content_id="", cursor=None, page_size=24, **kw) -> VodPage` + (abstract); empty `content_id` = root. `content_id` is an **opaque token** — you + define and document the grammar (`folder_`, `program_`, …). +* `get_vod_manifest(content_id, **kw) -> Optional[str]` (abstract; `None` = not mine). +* `search_vod`, `get_vod_manifest_headers`, `get_segment_headers`, `get_vod_drm`: + concrete defaults (`VodPage()`, auth headers, manifest headers, `[]`). +* `VodPage(entries, next_cursor, total)`: **`next_cursor is None` is the + authoritative end-of-list**; `total` may be unknown. An *empty* page is falsy + (list semantics) — test `page.has_more`, never `if page:`. +* `ManagedProvider` converts the result to the dict `VodOperations` reads + (`normalize_vod_result` handles `VodPage`, legacy dict, plain list and `None` — + verified by a test). So at **runtime** a manager that still returns a list works. + The **contract**, though, is `VodPage`: new and migrated managers return + `VodPage(entries=…, next_cursor=…, total=…)`. If the v1 code returns a list, + the migration is one line at the manager's return: + `return normalize_vod_result(entries)` (from `base.vod`), or build the `VodPage`. + The provider-level `get_vod_category` of v1 is **deleted** (inherited). +* **Strictness**: `VodItem` with a pricing/mode mismatch **raises**; + `Channel` only warns. A single bad upstream item can fail a page — guard your + parsing. +* Slugs are derived per sibling set, so they are **not stable across pages** — + never persist them (§17). + +### 6.3 `EpgManager` + +* `epg_window -> (past_days, future_days)`; **`(0, 0)` = no EPG and is the + default**. A provider without EPG simply has **no EPG manager**; the provider's + `epg_window` is then `(0, 0)` and `implements_epg` is `False` (contract-tested). +* `implements_epg` is `epg_window != (0, 0)`. The backend uses it to choose + between the **native path (your 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 (simpli does this). +* `get_epg(channel_id, start_time, end_time, **kw) -> List[EPGEntry]` (abstract): + the backend hands **timezone-aware UTC `datetime`s**, already clamped to + `epg_window`, plus `limit=` in `**kw`. `EPGEntry.start/end` are **unix seconds + (int)**. `channel_id` is the `Channel.content_id` from `get_channels()` (strip + your own prefixes if you add some). +* `get_epg_grid(channel_ids, …)`: the default loops `get_epg`; override with a + native batch endpoint. `ManagedProvider` passes arguments **by keyword** (the + legacy order is `(start, end, channel_ids)`, the ABC's `(channel_ids, start, + end)`) and, when the backend passes no ids, builds them from `get_channels()`. +* `get_program_details(program_id) -> Optional[EPGProgramDetails]`. +* `handles_channel_id(channel_id)`: override to skip channels without EPG. + +### 6.4 `RecordingsManager` + +* **Return `models.recording.Recording` (or a subclass), not `Channel`.** + `RecordingOperations` filters on `Recording.is_deleted`; a `Channel` has none. +* `get_recordings(**kw)`: `include_deleted` arrives in `**kw` (always). Honour it + if the backend lists deleted items, otherwise ignore it. +* **Two id namespaces.** `content_id` (`rec:`) is what `get_manifest` / + `get_drm` receive — recordings play through your **channel** manager, there is no + manifest method on this ABC. Your backend's own recording id is a second + namespace. **`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` when it does not exist (it is also + a `KeyError`, which the backend catches). Never return silently on a failed + delete. +* Map the backend status onto `RecordingStatus`; unknown → `PENDING`. +* `schedule_recording` is optional; if you override it you **must** also override + `supports_scheduling → True`. The legacy surface has **no entry point** for it + (timers use `add_timer`/`Timer`), so it is unreachable until you wire it (§17). +* Known quirk: `Recording.to_dict()["ContentType"]` is `"LIVE"` (never set; §17). + +### 6.5 `FavoritesManager` / `BookmarksManager` + +* Empty is `[]`, not an error. +* `add_favorite(content_id, favorite_type=PROGRAM, title=None, **kw)`; + `FavoriteType` is `PROGRAM, CLIP, LIVE, EVENT` (there is **no** `CHANNEL`). +* `update_bookmark(content_id, position_seconds, content_type, duration_seconds=None, + title=None, **kw)` is called on **every** playback stop/pause — repeated writes + of the same position must be harmless no-ops. `position_seconds`: `0` start, + `-1` completed (≥95 % counts as completed on the caller's side). +* Remove/delete of something that is not there raises `ItemNotFoundError` (a + `KeyError`: `FavoriteOperations`/`BookmarkOperations` catch `KeyError` → `False`). + Backend refusals raise `OperationFailedError` (a `RuntimeError`). +* Argument order is `(content_id, position_seconds, content_type, …)` here but + `(content_type, position_seconds)` in the `ProviderManager` facade — the backend + calls by keyword; so must you if you call it. + +### 6.6 `CatchupManager` + +* `catchup_window_hours` = the **provider-wide maximum** (`> 0` turns the + capability on). The backend gates on it and validates request age with it + *without knowing the channel*. Per-channel windows: override + `catchup_window_for_channel(content_id)` **and enforce them yourself** (return + `None` for a start outside the channel's window). If your global value is the + minimum, valid requests on longer channels are rejected. +* `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 + (0, `start_time`, `start+1800`). 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 the PSSH + from the catchup manifest"* (also correct for clear streams). Reusing the live + DRM must be **explicit**: return it from the manager, or set + `CATCHUP_DRM_FROM_LIVE = True` on the provider. +* Header hooks: the default is `auth.build_headers()` (**API headers**). If the + catchup streams are fetched from the CDN, override `get_catchup_manifest_headers` + and `get_catchup_segment_headers`. +* **Restart-from-beginning** (live manifest + player-side seek) cannot be + expressed as a manifest URL. Give it its own provider method (simpli: + `get_restart(content_id, start_time=0)` → `(url, seek_seconds)`; `start_time=0` + means "use the `@` embedded in the id"). +* `catchup:@` style ids are provider grammar: parse them in the + provider's `get_manifest`/`get_drm` and hand the manager plain + `(content_id, start_time)`; a malformed id raises `BadRequestError`. + +### 6.7 `DRM` + +See §11 — folded into `ChannelManager`/`VodManager`, or a dedicated object. + +--- + +## 7. Routing and content ids + +`ManagedProvider._route` resolves a content id for `get_manifest` and `get_drm`: + +1. `handles_content_id()` is a **cheap pre-filter**; `False` skips the manager. +2. A manager that passes may still return `None`/`[]` → the **next** is tried. +3. `NotFoundError` is remembered and **re-raised only if nobody resolves** the id + ("existed but is gone" vs "nobody handles it"). +4. `BadRequestError` is **never** caught — a malformed id surfaces. +5. The manager that resolved an id is remembered (bounded, locked), so headers go + to the manager that produced the manifest. +6. `content_type` narrows only for `"live"` and `"vod"`; anything else widens. (The + backend never passes it, so rules 1–5 decide in practice.) + +Order: channels, then VOD. + +**Consequence for providers with live *and* VOD managers:** only `NotFoundError` makes +the router try the next manager. Any other exception from the first manager +**propagates and VOD is never asked**. v1 code that returned `None` on every error +used to fall through silently; in v2 the two managers must be told apart by +`handles_content_id()` (implement it on **both**: pure prefix/format test, no I/O), +otherwise a VOD id is handed to the channel manager first and fails there. + +Id grammar guidance: + +* Ids are opaque to the base. Use short **prefixes** when several kinds coexist + (`live:`, `rec:`, `prog:`, `catchup:`), document the grammar in `constants.py` + (`LIVE_PREFIX = "live:"`), and parse in **one** place (module-level + `parse_*` helpers, unit-tested). +* `handles_content_id` must be pure; never call the network to decide ownership. +* Recording playback ids are channel-manager ids (`rec:…`); the **provider's + router** decides — not the recordings manager. + +--- + +## 8. Error contract + +### The rule + +> **"Not mine" is a return value. A failure is an exception.** + +| Situation | Do | +|---|---| +| id is not handled by this manager | return `None` / `[]` | +| nothing there (empty list, no recordings) | return `[]` — not an error | +| no DRM / clear stream | return `[]` | +| missing/invalid credentials, OTP, wrong password | raise `AuthError` | +| geo block | raise `GeoBlockError` | +| rate limit | raise `RateLimitError` | +| WAF / CAPTCHA challenge (e.g. a Cloudflare interstitial, a 403 with a challenge page) | raise a `RateLimitError` subclass (provider-specific, e.g. `WafBlockedException`) — **transient**: back off, never classify as permanent | +| entitlement missing | raise `EntitlementError` | +| network down, 5xx, WAF page, malformed payload | raise `ServerError` (via `transport_errors`) | +| id syntactically wrong | raise `BadRequestError` | +| resource that existed is gone | raise `NotFoundError` / `ItemNotFoundError` | +| broken provider configuration (DRM config invalid, wiring wrong) | raise `ConfigurationError` (a `DRMError` is one) | +| backend refused a write | raise `OperationFailedError` | +| capability is not supported by design | `UnsupportedOperationError` (a `NotImplementedYetError`) | + +Names marked above beyond the ones in the shipped base were taken from +docstrings and imports — **(unverified)**: check `base/errors.py` for the exact +constructors (the templates assume `Error("message")`). + +### Compatibility on purpose + +`ItemNotFoundError` is a `NotFoundError` **and** a `KeyError`; +`OperationFailedError` is a `ProviderError` **and** a `RuntimeError`; +`UnsupportedOperationError` is a `NotImplementedYetError`. Existing `except +KeyError` / `except RuntimeError` in the operations layer keep working. + +### Helpers + +* `base.utils.transport.transport_errors(what, provider="")` wraps *unexpected* + exceptions in `ServerError` and lets typed `ProviderError`s through untouched + (callers rely on `AuthError`/`RateLimitError` to refresh tokens or back off — + never flatten them). +* The HTTP manager raises for 4xx/5xx itself — don't call `raise_for_status()`. + How it types a 401/403 is **(unverified)**: map them to `AuthError` at the one + place you call login. + +### Backend behaviours you must know + +* `get_all_channels` / `get_all_*` catch exceptions **per provider** and return + `[]` for that provider; single-provider calls **propagate** your exception. +* `get_segment_headers` is called inside a `try` in the DRM pipeline whose + exceptions are **swallowed** (falls back to manifest headers) — a bug there is + silent. Test it. +* `NotImplementedError` from `get_catchup_drm` is **not** an error: it means + "extract the PSSH from the catchup manifest". +* `NotImplementedError` from `get_catchup_manifest` → `None` + log. +* v1 providers typically logged and returned `[]`/`None` on auth failure. **v2 + providers raise** — a deliberate behaviour change (§12.5). + +--- + +## 9. Authentication + +Two supported shapes. Both give managers an `auth` that satisfies `AuthProtocol`. + +### Rules for both + +* **Lazy**: no network in `__init__`. The first caller that needs a token logs in. +* **One login for concurrent callers**: lock + re-check after acquiring it. +* **Typed errors**, never `None`/`[]`. +* `invalidate()` clears memory **and persisted storage** (otherwise a restart + resurrects the old session; this bit `set_user_credentials`). +* The token's own expiry (`is_expired`, fixed 300 s buffer in `BaseAuthToken`) is + the single truth — **a new provider must not add a second buffer constant.** + **Migrating a provider that already has one** (look for `expires_in - 1800`-style + subtractions in `_create_token_from_response`, grep `expires_in -`): do **not** + remove it by reflex. It may be deliberate — a token embedded in a DRM config or + header lives for the whole playback session (§11), so an early refresh can be the + workaround that keeps playback alive. Find the reason (comment, history), keep it + in **one** documented place (token creation) unless a device test proves it + redundant, and record the decision in `MIGRATION_BRIEF.md`. +* Never log credentials or the dict from `to_auth_payload()`; verify the HTTP + layer does not log request bodies at DEBUG. +* Token transport is provider-specific: header (Allente, template), query string + or body (simpli: `with_token()`/`auth_body()` helpers, token-free headers). + Keep `build_headers()` token-free if the token does not belong in headers. + +### Variant A — the auth class *is* the `AuthProtocol` (`auth.py`) + +Use for simple token flows. `ExampleAuth` shows: credentials injected, lazy +`get_access_token()` with `RLock`, expiry check, `build_headers()` with the bearer, +`invalidate()`, `transport_errors` around the login. It does **not** persist +sessions. + +### Variant B — a session adapter in front of a stateful authenticator (`optional/session_adapter.py`) + +Use when you already have a `BaseAuthenticator` (persisted sessions, refresh +tokens, multi-step SSO, profile selection): **don't rewrite the flow**, wrap it. + +``` +managers ──AuthProtocol──► Session ──► YourAuthenticator (BaseAuthenticator) +``` + +**Keep both objects on the provider.** `self.authenticator` (the raw authenticator) +and `self.auth`/`self.session` (the adapter). The adapter *wraps* the authenticator, it +does not replace it: legacy mixins shared with every v1 provider +(`ProviderAuthMixin`, seen reading `self.authenticator.get_bearer_token()`) still reach +for `self.authenticator`. Deleting it as "v1 leftover" breaks them silently. Managers +must never call the raw authenticator — they use the adapter. + +* `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 + name (`AllenteOTPRequiredError`). +* The session also holds auth-derived state (entitlement tag, profile, …) that the + managers read; the provider keeps thin delegates for the settings UI. +* **Retry policy** (implemented in the template adapter; the Allente constants + describe it but its provider never did): *permanent* failure (wrong credentials, + OTP, unsupported account) ⇒ no retry until `reset()` (credentials changed); + *transient* failure (network, 5xx, rate limit, WAF/CAPTCHA, and any `ProviderError` the + authenticator raises itself — the session keeps its type) ⇒ exponential backoff + `base · 2^(n−1)` s, capped. Without it, every failing call repeats a full SSO + login — lockout/WAF risk. Tested with an injectable clock. + +### Credentials API on the provider (OPTIONAL — only if v1 has it) + +**A migration preserves the v1 public surface; it never adds to it.** Of the two +migrated providers only Allente has `set_user_credentials`; the template contains +it as an *example*. Whether the settings UI (or anything else) calls it is **not +verified here**. Decision rule: `grep -rn set_user_credentials --include='*.py'` +(the migration pack does it for you). Callers found, or v1 has the method ⇒ keep +it. No callers and v1 lacks it ⇒ **do not add it** (and delete it from the +template copy). The same applies to `get_last_auth_error` and `get_auth_details` +(the latter is a hook of the legacy auth mixin, which this README has not seen +(**unverified**) — keep what v1 has, add nothing). + +```python +def set_user_credentials(self, username, password) -> bool # store, log in once, True on success +def get_last_auth_error(self) -> Optional[Exception] +def get_auth_details(self, context) -> Dict # hook used by the auth-status UI (free-form dict) +``` + +`set_user_credentials` sequence (Allente): build credentials → assign → **`invalidate` +(memory + persisted)** → reset state and playout cache → exactly **one** login → +persist only on success. + +--- + +## 10. HTTP, headers, proxies + +* `self.http_manager = self._setup_http_manager(provider_name=, proxy_config=, + user_agent=, timeout=)`. Proxy resolution: ctor argument → ProxyConfigManager + (`provider`, `country` lowercase) → global. +* Call it as `http_manager.get/post(url, params=, json=, headers=, operation=)`. + `operation` is one of `"api"`, `"auth"`, `"manifest"`, `"license"` — it selects + whether the proxy applies (`ProxyScope`). +* **A `ProxyConfig` only affects Python-side `HTTPManager` traffic.** The license + request, the manifest fetch and all segment fetches are made by + inputstream.adaptive **inside Kodi** and do **not** use it. Geo-unblocking users + also need Kodi's global proxy. State this limitation in the provider docstring. +* `RequestConfig` offers `inject_origin` (opt-in Origin header), + `use_tls_impersonation` (curl_cffi, ignored under Kodi), accept headers. Use + them instead of ad-hoc header hacks. + +### API headers vs stream (CDN) headers + +Keep two builders in `constants.py`: `api_headers(token=None)` and +`stream_headers()`. The CDN usually wants only User-Agent / Origin / Referer (and +Akamai-style edges *reject* requests without a whitelisted origin/referer — Allente). +Sending API headers (JSON `Content-Type`, tenant ids, tokens) to the CDN is noise at +best and a token leak at worst. + +### `HEADERS_FROM_MANAGERS` — decide explicitly + +| Value | Meaning | +|---|---| +| `True` | manifest/segment requests carry the managers' header hooks (`get_channel_manifest_headers`, `get_segment_headers`, VOD and catchup equivalents). The manager default is `auth.build_headers()` = **API headers**, so **override the hooks** with `stream_headers()`. | +| `False` | keep the legacy `{}` — for a provider being migrated whose manager hooks were dead code and whose real behaviour you want to preserve first. | + +The contract test fails when a manager overrides header hooks while the flag is +`False` ("dead code"), and when the flag is not declared in the class body. + +Segment/manifest headers for **catchup**: `get_segment_headers(id, start_time=…)` +is routed to the catchup manager (the DRM pipeline passes `start_time`). + +--- + +## 11. DRM + +### Decision tree + +``` +Do manifest and DRM share state (one playout/AcquireContent response)? +├─ yes ─► fold DRM into the manager: DRM_IN_MANAGERS = True, +│ implement get_channel_drm / get_vod_drm (Allente, simpli — the usual case) +└─ no ─► dedicated object: DRM_IN_MANAGERS = False, + _build_drm() returns an object with + get_drm_configs(content_id, content_type=None, **kw) (optional/drm_manager.py) +Catchup with DIFFERENT DRM? ─► CatchupManager.get_catchup_drm (§6.6) +No DRM at all? ─► DRM_IN_MANAGERS = False, no _build_drm() +``` + +`DRM_IN_MANAGERS = True` together with `_build_drm()` is refused at construction. +The old method-identity sniffing is gone: the choice is **declared**, and the +contract test checks the declaration against the managers. + +### How the backend calls it + +`provider.get_drm(content_id=, **kw)` — **keyword** `content_id`; `kw` holds +`drm_variant` (`"auto"`, `"software"`, …), `preferred_quality`, `preferred_format` +and injected proxy extras. **`content_type` is never passed.** Accept `**kw` and +ignore what you don't use. `get_drm(content_id, drm_variant=None, content_type=None, +**kw)` is the signature `ManagedProvider` exposes. + +### Building configs + +* Factories (`create_widevine`, …) default to `priority=1`. **Give every config of + one content an explicit, distinct priority** (lowest wins in ISA); duplicates fail + `validate_drm_set` and surface as `ConfigurationError`. Several systems: simpli + emits Widevine 1 / PlayReady 2 (ISA picks the lowest number whose CDM is present). +* `[]` = no DRM / unsupported system (log a warning). A **broken configuration + raises** (`LicenseConfigError` is a `DRMError`, which is a `ConfigurationError`) — + do not swallow it into `[]`. +* `LicenseConfig` `req_data` placeholders: `{CHA-RAW}` (raw challenge), + `{CHA-B64}` (base64 challenge). `create_with_req_data()` base64-encodes the + template for you; `wrapper="none"` + `unwrapper="json,base64"` + + `LicenseUnwrapperParams(path_data="license")` is the JSON-in/JSON-out pattern. +* `req_headers` encoding: pass a dict (the framework url-encodes it) **or** + pre-encode yourself with `urlencode(headers, quote_via=quote)` (spaces → `%20`). + The plain-text header parser splits on `;`/`,` followed by `Name:` — a + `User-Agent` such as `Mozilla/5.0 (X11; Linux …)` is the classic victim. + `+` vs `%20` for spaces is **not verified on a device**; `%20` is unambiguous. +* `req_data` auto-detection (`_is_base64`) cannot tell plain text like `abcd` from + base64; prefer `create_with_req_data()`. +* **Verify on a device**: dump ISA's outgoing license request (URL, headers, body) + and compare byte-for-byte with the browser capture. +* **Known v1 limitation:** a bearer token embedded in the `DRMConfig` lives as + long as ISA caches it (the whole playback session) — playback across token expiry + is not supported; a fresh zap re-invokes `get_drm`. +* **Catchup DRM semantics**: see §6.6 (`[]` ⇒ `NotImplementedError` ⇒ PSSH + extraction; live DRM reuse is explicit). + +--- + +## 12. Migrating an existing (v1) provider + +> **Goal:** the migrated provider behaves like the old one except for the +> deliberate changes in §12.5, and exposes the capabilities it always had but never +> wired. Plan one provider at a time; keep v1 providers untouched. + +### 12.0 Preconditions + +* The base patches are in (ManagedProvider, manager ABCs incl. `_base.py`, + `base/utils/transport.py`, `base/testing/provider_contract.py`, registry + `get_plugin_key`), and the repo's own tests are green. +* The provider has a test baseline you can run before/after (even if it is just + "list channels, play one on a device"). + +### 12.0b The migration input pack (do this FIRST; hand the output to the migrator) + +A migrator who only sees the provider directory cannot answer: *how does the +registry construct it?*, *what do the legacy mixin helpers it calls do?*, +*who else uses it?* Those answers live outside the directory. Generate them once +(by someone with the **whole** repo — and the Kodi/PVR/backend repos if they are +separate): + +```bash +python -m streaming_providers.base.testing.migration_pack \ + --repo --extra-root --extra-root \ + -o pack.txt +python -m streaming_providers.base.testing.migration_pack --json before.json # surface BEFORE +``` + +**Performance.** Sections 1–4 take under a second. Section 5 walks the trees you pass +in: point `--repo` at the directory **containing `streaming_providers`**, not at a whole +Kodi addon tree, and pass only the sibling repos you need as `--extra-root`. The scan +is bounded (source-like extensions only, files > 1 MB and asset directories skipped, +`--max-seconds 120` by default), prints `scanning …` to stderr and writes each +section as soon as it is finished — a Ctrl-C keeps everything before section 5. +`--skip-external` skips it; the manual greps in §12.6 are the fallback. + +`pack.txt` contains: (1) registry facts and the constructor source, (2) the +provider's current surface, (3) **the source of every legacy mixin/base helper the +provider calls** (`_build_provider_headers`, `_setup_http_manager`, …), (4) every +place a manager reaches into the provider, (5) every external reference to the class, +the key and the commonly moved attributes. Fill `MIGRATION_BRIEF.md` (this +directory) from it: it lists the decisions the migrator needs and nothing else +should be asked mid-migration. After the migration: + +```bash +python -m streaming_providers.base.testing.migration_pack --compare before.json +``` + +Every `REMOVED`/`CHANGED` line must be explained (deleted boilerplate is fine; a +removed public method that something calls is not), every `ADDED` line justified. + +### 12.1 Inventory (10 minutes, saves hours) + +```bash +cd streaming_providers/providers/ +grep -n "def \|class \|@property" provider.py # what the provider defines +grep -rn --include='*.py' -E 'implements_|_route|self\.channels *=|def get_(manifest|drm|segment|epg|recordings|catchup)' . +grep -rn --include='*.py' '' ../../base ../../providers | grep -v "providers//" # outside users +``` + +Fill this table for your provider: + +| Question | Answer decides | +|---|---| +| Which capabilities exist (channels, VOD, EPG, recordings, favorites, bookmarks, catchup, DRM)? | which managers you build | +| Are managers already ABC subclasses with the 4-collaborator constructor? | simpli-style (easy) vs Allente-style (constructor `(provider)` → rewrite) | +| Where does auth live? Native token class or a stateful `BaseAuthenticator`? | Variant A or B (§9) | +| Do manifest and DRM share one backend response? | `DRM_IN_MANAGERS` (§11) | +| Does the v1 provider override `get_manifest_headers`/`get_segment_headers`? Do its managers define header hooks? | `HEADERS_FROM_MANAGERS` (§10) | +| Which id prefixes/formats exist? How does v1 tell a **live id from a VOD id**? Any `catchup:`-like grammar? | `handles_content_id()` on **both** managers (§7) and provider-level overrides (§5.8) | +| What does the provider return when auth fails (`[]`/`None`/raise)? | behaviour change list (§12.5) | +| `provider_name`, directory, class name, credentials key — all equal? | §4 | +| Does anything outside the provider read its attributes (`bearer_token`, `_profile`, `channel_manager`, …)? | compatibility delegates / call-site fixes | + +### 12.2 v1 → v2 mapping (what to delete, what to keep) + +| In the v1 provider | In v2 | +|---|---| +| eight `implements_*` properties | **delete** (derived) | +| `implements_drm` with method-identity sniffing | **delete**; set `DRM_IN_MANAGERS = True/False` | +| `_route(...)` | **delete** (inherited) | +| `get_channels()` | **delete** (inherited) | +| the manager-assignment block (`self.channels = …; self.vod = …`) | **replace** by `self._init_managers()` (last line of `__init__`) | +| `_build_vod/_favorites/_bookmarks/_drm` returning `None` | **delete** (inherited `None`) | +| `get_manifest` with routing | **delete**, except the provider-specific prefix branch → `super()` | +| `get_drm(content_id, content_type=None, **kw)` with `_LIVE_ONLY/_VOD_ONLY` sets | **delete**, except a prefix branch (`catchup:`) | +| provider-level `get_manifest_headers` / `get_segment_headers` | **move** into manager hooks (`stream_headers()`), set `HEADERS_FROM_MANAGERS` | +| `self.channels = fetched_list` | **remove** (it clobbers the manager) | +| `get_epg/get_recordings/get_catchup_*` missing or inert | **nothing to write** — now delegated (they become reachable: §12.5) | +| v1 manager methods named after the provider API (`get_manifest`, `get_drm`, `get_manifest_headers`) | **rename** to the ABC names (`get_channel_manifest`, `get_channel_drm`, `get_channel_manifest_headers`; VOD: `get_vod_*`) — the ABC cannot be instantiated otherwise | +| `get_events` / `enrich_channel_data` overridden to delegate to an inert stub manager | **delete**; the base defaults answer `[]` / `None`. If the provider **really** implements events, keep the method on the provider with its logic (there is no events manager; §17) and do not leave it calling a manager that no longer exists | +| `get_program_details` | delegated by `ManagedProvider` to the EPG manager when one exists, `None` otherwise — nothing to write | +| `_build_provider_headers(...)` (legacy mixin) called by managers through the provider | stop calling it: build headers from `Config` (`api_headers(token)`, `stream_headers()`) with the token from `self.auth`; the mixin itself stays — the base still uses it | +| manager `__init__(self, provider)` | **rewrite** to the 4-collaborator constructor; borrow state through `auth` / explicit extras | +| provider-held auth state (`bearer_token`, `_profile`, `_last_auth_error`) | move into the session (Variant B), keep delegates | +| `try: … except Exception: log; return []` in managers | `transport_errors` + typed errors (§8) | +| recordings returned as `Channel` subclass | return `Recording` (§6.4) | +| `__init__(…, **kwargs)` | drop unless the registry needs it | +| `catchup_window_hours` = minimum of per-channel windows | **maximum**, plus `catchup_window_for_channel` (§6.6) | +| ad-hoc `get_restart()` etc. | **keep** (provider-level, outside the ABC) | + +### 12.3 Step by step + +1. Create a branch. Run the provider's baseline (channels list, one manifest, one DRM + playback) and note the outputs. +2. Decide the table in §12.1. +3. Convert managers (constructor first, then `None`-vs-raise, then `transport_errors`). +4. Convert the provider: delete the boilerplate, add `DRM_IN_MANAGERS`, + `HEADERS_FROM_MANAGERS`, `_init_managers()`, `__all__`, country normalisation. +5. Move auth behind `AuthProtocol` (Variant A or B). +6. Keep compatibility delegates for anything §12.1 found outside the provider. +7. Write/adapt the tests (§15) and run the **contract test**. +8. Run the external-reference grep (§12.6), then the device checklist (§12.7). +9. Remove the delegates once nothing uses them (separate commit). + +### 12.4 What becomes reachable that never was + +If the v1 provider did not define `get_epg*`, `get_recordings`/`delete_recording`, +`get_catchup_*`, `catchup_window`, manager-driven headers — those were answered by +the legacy mixin defaults (`[]`, `{}`, `NotImplementedError`, window `0`). After +migration they hit **your managers**. That is the point, and also the biggest +behaviour change: test each on a device, and expect to find bugs in managers that +were never exercised through the backend (simpli: the recording ids, the +catchup window, the EPG flag network call). + +### 12.5 Deliberate behaviour changes (review each; write down yours) + +| Change | Why | Revert | +|---|---|---| +| Auth/transport failures **raise** typed errors instead of `[]`/`None` | the UI can show the real reason; contract §8 | override the call with `try/except ProviderError: return []` | +| Broken DRM config raises instead of `[]` | a misconfiguration must not look like "no DRM" | — | +| Manifest/segment headers from the manager hooks (`{}` → UA+Origin) | the hooks were dead code | `HEADERS_FROM_MANAGERS = False` | +| `catchup_window` = max, per-channel hook | the backend validates with the global value | one constant | +| Recordings are `Recording` objects; delete accepts the content id | `r.is_deleted`; clients hold content ids | — | +| One malformed channel entry is skipped (before: the whole list failed) | resilience | — | +| `implements_catchup` requires `supports_catchup` | window must be > 0 | — | +| Manifest and DRM share one backend round-trip per zap (playout cache) | they used to call the entitlement/playlist endpoint twice | `PLAYOUT_CACHE_TTL = 0` | +| Managers read a **fresh token per request** from `self.auth` | a `provider.bearer_token` snapshot is stale by the first request | — | +| Eager login removed from `__init__` | network at registry-construction time; first call is now slower | restore only as the documented opportunistic login (stored credentials) | +| A second token buffer removed / kept | **needs a recorded reason either way** (§9) | — | + +### 12.6 External references (needs the whole repo — the migration pack section 5 does this) + +A migrator working only inside the provider directory **cannot** know this; do not +let them guess. Run the migration pack with every repo that consumes the backend +(the Kodi addon and the PVR addon refer to providers by the `"Provider"` string / +key, not by Python imports, so a Python-only grep misses them: `--extra-root` +searches XML/JSON/C++/YAML too). Manual equivalent: + +```bash +grep -rn --include='*.py' -E '_last_auth_error|\._profile|Manager\(|\.channel_manager|_ensure_authenticated|_playout_cache|bearer_token|entitlement_tag' base providers | grep -v "providers//" +grep -rn --include='*.py' 'replace("provider"' base providers # class-name derived keys +``` + +Anything outside the provider hitting a moved attribute needs a delegate or a +call-site fix. Check what reads `plugin_name`/`plugin` for your provider (it +changes for providers whose directory ≠ lower-cased class name minus "provider"). + +### 12.6b Legacy helpers (`provider_mixins/*`, `base/provider.py`) + +v1 providers call helpers defined in the legacy mixins, e.g. +`_build_provider_headers(auth_type=AuthType.BEARER, …)`, `_setup_http_manager(…)`. +This README does **not** describe them (their source was not available to its +author — **unverified**). The migration pack prints the source of each one the +provider uses (section 3). Then decide, per helper: + +| What the helper does | In v2 | +|---|---| +| builds request headers and **reads a token from the provider** (`self.bearer_token`, `self.authenticator.…`) | the manager builds the headers itself from `self.auth.build_headers()` / `self.auth.get_access_token()`; **tokens come from `auth`, never from a provider attribute** | +| builds request headers from static config only | move into `Config.api_headers()` / `stream_headers()` (constants.py) | +| sets up HTTP / proxy (`_setup_http_manager`) | keep calling it in `__init__` exactly as v1 does | +| anything else | keep the call if it still works; if a manager needs it, move the logic into a module function or the `Config` — a manager calling a provider method is a back-reference (§5.1) | + +If a helper is used by managers through the provider, it shows up in section 4 as +well; handle it once, here. + +### 12.7 Verification on a device + +1. Provider shows up in the registry listing with the right `capabilities` (and + the listing is fast — no network in flag checks). +2. Channels list loads; a malformed upstream entry does not empty it. +3. Play a clear channel and a DRM channel (license request dump compared to the + browser capture). +4. Manifest + segment requests carry the intended headers (CDN accepts them). +5. EPG: window honoured, grid and details load; `limit` respected. +6. Catchup / restart: inside the channel window works, outside returns nothing + (and not the live stream), DRM path (PSSH extraction when `[]`). +7. Recordings: list, play, **delete using the id the client holds**, scheduling. +8. Favorites/bookmarks: add, repeat the same bookmark write, delete missing item → + handled as "not found". +9. Wrong password / OTP / geoblock: the UI shows the typed reason; **no login + loop** (check the SSO traffic). +10. Credentials change: old token not resurrected after restart. + +### 12.8 Rollback + +Each step is a separate commit; `git revert` restores the v1 provider. v1 and v2 +providers can be mixed freely. + +--- + +## 13. Models, time, data rules + +* **`Content` → `Channel` / `VodItem` / `Recording`.** `Content.pricing` of `None` + means *unknown*, **not free**. `Channel` only *warns* about pricing/mode + mismatches; `VodItem` (via `Content.__post_init__`) *raises*. `Recording` skips + `Content.__post_init__` (it forces `mode="vod"`). +* Factories: `Channel.create_live_channel(name, channel_id, provider)`, + `create_radio_channel(name, channel_id, provider)`, + **`create_vod_channel(name, content_id, provider)`** (different parameter name). + Construct dataclasses with `content_id=` and `provider=` (the `channel_id` + property does not work in `__init__`). +* `Channel.catchup_hours` is the per-channel window; leave `0` unless catchup is + really offered (do not conflate with "start-over"). +* `provider` on models is the **registry key** (`PROVIDER_NAME`). +* **Time:** the backend hands **timezone-aware UTC** `datetime`s to EPG; EPG and + catchup use **unix seconds (int)** at the manager boundary. Never mix naive and + aware datetimes (that was a real `TypeError` in `PricePoint.is_active`). Parse + upstream timestamps with a helper that normalises fractions/`Z`/offsets and reads + a missing offset as UTC (see simpli `helpers.parse_iso`). +* `Quality` and similar are `(str, Enum)`: JSON-safe, compare equal to their + string, but on Python ≥ 3.11 `f"{Quality.HD}"` gives `Quality.HD` — use `.value` + when you format or when a non-JSON serialiser is involved. +* **Python ≥ 3.9** (PEP 585 generics are used in the base). +* `safe_base64_decode` is plain `base64.b64decode` (non-strict: silently drops + invalid characters) — validate base64 yourself where it matters. +* Pagination: `next_cursor is None` ends a list; `total` is a hint. +* Don't persist VOD slugs; they depend on the sibling set (§17). + +--- + +## 14. Caches and concurrency + +* The host is multi-threaded. Provider-owned caches are **plain dicts created in + the provider and borrowed by managers by reference** + (`playout_cache=self._playout_cache`). Check-then-set races are benign for cheap + reads (two playout calls), **not** for logins (hence the lock in auth). +* Cache keys include everything that changes the answer (id, and e.g. the DRM + level if you ever map `drm_variant` to a level). +* **TTL cache** pattern used by simpli/Allente: `(value, timestamp)` tuples, TTL in + `constants.py` (`PLAYOUT_CACHE_TTL = 5.0`; manifest + DRM share one request). +* **Clear caches when credentials change** (`set_user_credentials` → + `_playout_cache.clear()`): ids/stream ids may be user- or token-specific. +* The route cache in `ManagedProvider` is bounded (`ROUTE_CACHE_SIZE = 512`) and locked. +* A shared thread-safe TTL helper does not exist yet (§17) — keep caches small and + per-provider. + +--- + +## 15. Testing + +### What ships + +| File | Purpose | +|---|---| +| `tests/test_example_provider.py` (copied by the scaffold) | the pattern: fake HTTP, no network; auth, flags, manifest/DRM sharing, headers, caches, credentials | +| `base/testing/provider_contract.py` → `check_provider(cls, key, ctor_kwargs=None)` | the **contract**: returns `(errors, warnings)` | +| `base/testing/migration_pack.py` | **before** a migration: registry facts, surface snapshot, legacy helper sources, back-references, external users (§12.0b); **after**: `--compare` | +| repo-level test (add once) | parametrise `check_provider` over every `ManagedProvider` in `AVAILABLE_PROVIDERS` | + +```python +# tests/test_provider_contract.py (repo level, once) +import pytest, streaming_providers +from streaming_providers.base.managed_provider import ManagedProvider +from streaming_providers.base.testing.provider_contract import check_provider + +V2 = {k: c for k, c in streaming_providers.AVAILABLE_PROVIDERS.items() if issubclass(c, ManagedProvider)} + +@pytest.mark.parametrize("key", sorted(V2)) +def test_contract(key): + errors, warnings = check_provider(V2[key], key) + assert errors == [], (errors, warnings) +``` + +### The contract checks (all offline — the HTTP manager raises on every call) + +* subclass of `ManagedProvider`; `HEADERS_FROM_MANAGERS` declared in the class body +* instantiable with `country = SUPPORTED_COUNTRIES[0]` **without any network call** — + the offline HTTP manager raises *and records*; an attempt swallowed by an + `except Exception` in `__init__` is still reported (`allow_init_network=True` downgrades + it to a warning for the documented opportunistic login) +* `provider_name == key`, `get_plugin_key() == key` +* each `implements_x` agrees with the manager; a manager overriding `get_*_drm` + ⇒ `DRM_IN_MANAGERS` or `_build_drm()`; `DRM_IN_MANAGERS` with no manager + implementing DRM is an error; overridden header hooks ⇒ `HEADERS_FROM_MANAGERS` + True; EPG flag/window consistency (`(0, 0)` without a manager); overridden + `schedule_recording` ⇒ `supports_scheduling`; package `__all__` contains the + provider and no `ManagedProvider` in the namespace +* warnings: country not lower-case, empty `SUPPORTED_COUNTRIES`/`PROVIDER_LABEL` + +### Pattern for provider tests + +* `monkeypatch.setattr(YourProvider, "_setup_http_manager", lambda self, **kw: fake)` + — works in the repo and in the sandbox. +* A `FakeHttp` records `(method, url, headers)` and returns objects with `.json()`. + Fake payloads mimic your API; one malformed entry belongs in every list. +* Test per manager: happy path, **malformed entry skipped**, **typed error on + failure** (no `[]`), "not mine" returns `None`/`[]`, cache hit/expiry, header + hooks (no token in CDN headers), ids (grammar parsers as pure functions). +* Auth: no network in `__init__`; one login for N concurrent callers; expiry → + re-login; permanent failure not retried; credentials change clears state. +* Call the provider **the way the backend does**: `get_drm(content_id=…, + drm_variant=…, preferred_quality=…)`, `get_manifest(content_id=…)`, + `get_catchup_manifest_with_headers(content_id=, start_time=, end_time=None, + epg_id=, country=, drm_variant=)`. +* Run the tests of the **real** package, not only a sandbox, before trusting them. +* Providers whose constructor requires more than `country` (a config object): call + `check_provider(cls, key, ctor_kwargs={...})`. + +### How the shipped suite was built (context) + +72 tests ran in a sandbox: real files for the models/ABCs/ManagedProvider, **stubs** +for modules that were never available (legacy mixins other than the ones seen, +`HTTPManager`, `errors.py`, the DRM package internals). A stub that is wrong would +make a test pass wrongly — the first run against the real package +(`V2_USE_REAL_PACKAGE=1 pytest tests`) is part of the validation. + +--- + +## 16. Traps + +Things that already cost time. Each is covered by a test or the contract unless noted. + +1. `self.channels` is the manager. Assigning a list clobbers it; `to_output_format` + would crash on the manager. +2. `provider_name` is **abstract**; a subclass without it cannot be instantiated. +3. `StreamingProvider.__init__(country)` takes **no `**kwargs`**. +4. `__all__` in `provider.py` **and** package `__init__`; never export + `ManagedProvider` (discovery picks the first subclass alphabetically). +5. `implements_epg` is read constantly; a network call in `epg_window` makes every + capability check an HTTP request → override `implements_epg → True`. +6. Dead header hooks: manager hooks are ignored unless `HEADERS_FROM_MANAGERS = True`. +7. The ABC header default is the **API** headers — don't send them to the CDN. +8. `catchup_window_hours` is the **max**; the global gate is used without the channel. +9. Catchup DRM `[]` ⇒ `NotImplementedError` ⇒ PSSH extraction; it is **not** "same as + live". +10. `get_segment_headers` is swallowed in the DRM pipeline — a bug is silent. +11. Recordings are `Recording`, not `Channel`; clients hold the **content id**; + `delete_recording` must accept it; `Recording.recording_id` is its alias. +12. `schedule_recording` needs `supports_scheduling = True` and has no legacy entry + point. +13. Bookmark argument order differs between mixin and facade → keywords. +14. `ItemNotFoundError` (a `KeyError`) for "not there", never a bare `KeyError`. +15. `Channel.create_vod_channel` takes `content_id`, the others `channel_id`. +16. `VodItem` raises on pricing/mode mismatch; `Channel` warns; `None` pricing ≠ free. +17. Naive vs aware datetimes; EPG gets aware UTC; EPGEntry/catchup use unix seconds. +18. Country case: registry gives `"SE"` (single) or lowercase (fan-out); storage keys + are lowercase → normalise; `["*"]` is not multi-country. +19. Class-name-derived plugin keys (`replace("provider", "")`) disagree with renamed + providers. +20. `FavoriteType` has no `CHANNEL`. +21. LicenseConfig header encoding: `;`-splitting corrupts a UA; `+` vs `%20` + unverified on device; ISA ignores `ProxyConfig`. +22. DRM priorities default to 1 — duplicates fail validation. +23. `safe_base64_decode` is non-strict. +24. A token embedded in a DRMConfig lives for the whole playback session. +25. Python ≥ 3.9; `(str, Enum)` formatting differs on ≥ 3.11 (use `.value`). +26. A documented policy is not an implemented policy (Allente's backoff existed only + in comments) — implement or delete it. +27. Network I/O in `__init__` (other than an opportunistic login with stored + credentials) — fails the contract test, **including** a call whose error is + swallowed by `except Exception` (the offline HTTP manager records attempts). +28. `Recording.to_dict()["ContentType"]` is `"LIVE"`; `Channel.detect_and_set_radio()` + leaves a video quality on radio channels (base issues, not yours — don't "fix" + them in the provider). +29. A migrator who only sees the provider directory cannot know how the registry + constructs it, what the legacy mixin helpers do, or who else calls it — run the + migration pack (§12.0b) first; never guess. +30. Adding API a v1 provider never had (`set_user_credentials`, `get_profile`, …) + "because the template has it" — a migration preserves the surface; compare + with `--compare before.json`. +31. A manager calling the provider (`self._provider.x`), even "temporarily" — + shared state belongs in `auth`/session or an explicit collaborator (§5.1). +32. Assuming `**kwargs`/`config` in a constructor are mistakes: read what the + registry passes (§4) before changing the signature. +33. Returning a list from `get_vod_category` — works at runtime, violates the ABC; + return `VodPage`. +34. Deleting `self.authenticator` after moving to Variant B — legacy mixins still read + it; keep raw authenticator **and** adapter (§9). +35. Live and VOD managers without `handles_content_id()` — the first manager's + exception stops the routing (§7); v1's "return `None` on error" hid this. +36. Removing a token buffer / constant "because the README says single truth" — the + rule forbids *adding* one; an existing one needs a recorded reason (§9). +37. Wrapping a provider-specific exception type in `transport_errors` without + checking that it is a `ProviderError` — it is flattened into `ServerError`; make it + a subclass or pass it via `passthrough=(…,)`. +38. Treating the migration pack's `ADDED` lines as errors: `_build_*`, + `DRM_IN_MANAGERS`, `HEADERS_FROM_MANAGERS` are expected additions (§12.0b). + +--- + +## 17. Not covered / open decisions + +Known gaps in the base, so you don't hunt for them: + +* **Timers** (`Timer`, `TimerType`, `add_timer` …), **events** (`get_events`), + **subscriptions**, `enrich_channel_data`, `get_epg_xmltv` have **no manager**; the + legacy mixin defaults answer. Wire timers on the provider if you need them (a thin + adapter onto `schedule_recording` is the recommended first step); a `TimersManager` + only once a second provider needs it. +* **Time types** are mixed (aware `datetime` in EPG, unix `int` in catchup/EPGEntry); + there is no shared converter module yet. +* **No shared TTL cache** with per-key locking; **no `close()`** lifecycle for HTTP + sessions/threads. +* **VOD**: slugs are not stable across pages; `VodCategory.fetch_url` is lost + between `get_vod_category` calls (only the opaque `content_id` arrives). +* `get_all_possible_instances()` hard-codes `"DE"` for single-country providers; + `_is_provider_enabled` in the registry is fail-open. +* `base/provider.py` imports `providers.auth` (base depends on providers). +* `README` v1 (`_template`) describes the old shape; it is not rewritten. +* Provider-specific features outside the ABCs (restart, `get_restart`) are free-form; + there is no `RestartInfo` type yet. + +--- + +## 18. Definition of done + +- [ ] Directory = registry key = `provider_name` = `PROVIDER_NAME` = credentials key; class `Provider` +- [ ] `__all__` in `provider.py` and `__init__.py`; `ManagedProvider` not exported +- [ ] `SUPPORTED_COUNTRIES` chosen deliberately; `self.country` lower-cased; unsupported country rejected +- [ ] `DRM_IN_MANAGERS` and `HEADERS_FROM_MANAGERS` declared in the class body, with a comment why +- [ ] No `implements_*` overrides, no `_route`, no `self.channels = ` +- [ ] All URLs/headers/ids in `constants.py`; API headers and stream headers separate +- [ ] Managers: 4-collaborator constructors, extras keyword-only, `**kw` everywhere +- [ ] Failures raise typed errors; "not mine" returns `None`/`[]`; `transport_errors` around network calls +- [ ] Malformed upstream entries are skipped, not fatal +- [ ] Auth lazy, locked, typed errors, `invalidate()` clears persisted state, no login loop +- [ ] `set_user_credentials` / `get_last_auth_error` / `get_auth_details` behave (if the UI uses them) +- [ ] EPG: `(0, 0)` without a manager; `implements_epg` free of network calls; ids match channel ids +- [ ] Catchup: global window = max, per-channel enforced, no live fallback, DRM semantics understood +- [ ] Recordings: `Recording`, content-id delete, `supports_scheduling` consistent +- [ ] Caches borrowed from the provider, cleared on credential change +- [ ] Migration pack generated before, `--compare before.json` reviewed after (no unexplained REMOVED/ADDED) +- [ ] Provider tests green; **contract test green**; repo tests green +- [ ] Device checklist §12.7 done; behaviour changes §12.5 written down in the commit message +- [ ] Provider README/docstring lists known limitations (token expiry in DRM, proxy not applied to ISA, …) + +--- + +## 19. Appendix + +### 19.1 Files in this template + +``` +_template_v2/ + README.md this document + __init__.py exports the provider only + provider.py runnable orchestrator (channels + folded DRM) + constants.py the single source of truth (ids, URLs, headers, TTLs) + Config + models.py parsers + conversion to Channel + auth.py Variant A (native AuthProtocol) + drm.py Widevine config builder (pattern from Allente) + channel_manager.py ChannelManager with playout cache + folded DRM + scaffold.py creates a provider from this template + MIGRATION_BRIEF.md the decisions a migrator needs, filled from the migration pack (§12.0b) + optional/ vod, epg, recordings, catchup, favorites, bookmarks, drm managers, + session_adapter (Variant B incl. backoff) — each with a WIRING block + tests/test_example_provider.py +base files this template relies on (shipped alongside): + base/managed_provider.py, base/managers/*.py (+ _base.py), base/utils/transport.py, + base/testing/provider_contract.py, base/testing/migration_pack.py, + provider_mixins/metadata.py (get_plugin_key) +``` + +**Files the template does not ship but real providers write** (the scaffold does not +create them; nothing registers them): + +* `config.py` — when header/URL building is richer than `api_headers` / `stream_headers` + (Joyn has four header sets). `constants.py` stays the single source of the *values*. +* `session.py` — Variant B (§9); the skeleton is `optional/session_adapter.py`. +* `entitlement.py` (or similar) — a dedicated collaborator for state shared by two + managers that is **not** derived from the token (§5.1): per-content entitlement + tokens, a selected profile with its own lifecycle. Provider-owned, passed to the + managers as a keyword-only extra. + +### 19.2 `ManagedProvider` quick reference + +| Member | Notes | +|---|---| +| `DRM_IN_MANAGERS` | declared; refused together with `_build_drm()` | +| `HEADERS_FROM_MANAGERS` | declared; default in the base is `True`, legacy was `{}` | +| `CATCHUP_DRM_FROM_LIVE` | default `False` | +| `ROUTE_CACHE_SIZE` | 512 | +| `_init_managers()` | builds `channels, vod, epg, recordings, favorites, bookmarks, catchup, drm`, then wiring checks | +| `_build_()` | return a manager or `None` | +| `capabilities` | dict of the eight derived flags | +| `get_manifest / get_manifest_headers / get_segment_headers / get_drm` | routed (see §3, §7) | +| `epg_window, get_epg, get_epg_grid, get_program_details` | EPG delegation | +| `get_vod_category, search_vod` | VOD delegation (dict result) | +| `get_recordings, delete_recording` | recordings delegation | +| `get_favorites, add_favorite, remove_favorite` / `get_bookmarks, update_bookmark, delete_bookmark` | delegation | +| `catchup_window, get_catchup_window_for_channel, get_catchup_manifest(+headers), get_catchup_drm` | catchup delegation | +| `to_output_format` | uses `get_channels()` (not the shadowed list) | + +### 19.3 Scaffold usage + +``` +python scaffold.py [--label "Display Name"] [--country DE] + [--with vod,epg,recordings,catchup,favorites,bookmarks,drm,session] + [--dest ] +``` + +Refuses to overwrite an existing directory, validates `key`/`prefix`, prints the +wiring snippets. It never edits your wiring and never registers anything. + +### 19.4 Glossary + +* **Folded DRM** — DRM served by `get_channel_drm`/`get_vod_drm`, sharing state + with the manifest step. **Dedicated DRM** — a separate object from `_build_drm()`. +* **Route cache** — `content_id → "channels" | "vod"`, filled when a manifest is + resolved; headers use it. +* **None-vs-exception rule** — §8. +* **ISA** — inputstream.adaptive (Kodi). +* **Legacy surface** — what the operations layer calls (§3). +* **Variant A / B** — native `AuthProtocol` class / session adapter (§9). diff --git a/lib/streaming_providers/providers/_template_v2/__init__.py b/lib/streaming_providers/providers/_template_v2/__init__.py new file mode 100644 index 0000000..edf3889 --- /dev/null +++ b/lib/streaming_providers/providers/_template_v2/__init__.py @@ -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"] diff --git a/lib/streaming_providers/providers/_template_v2/auth.py b/lib/streaming_providers/providers/_template_v2/auth.py new file mode 100644 index 0000000..8d09de4 --- /dev/null +++ b/lib/streaming_providers/providers/_template_v2/auth.py @@ -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") diff --git a/lib/streaming_providers/providers/_template_v2/channel_manager.py b/lib/streaming_providers/providers/_template_v2/channel_manager.py new file mode 100644 index 0000000..1133ff7 --- /dev/null +++ b/lib/streaming_providers/providers/_template_v2/channel_manager.py @@ -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, + )] diff --git a/lib/streaming_providers/providers/_template_v2/constants.py b/lib/streaming_providers/providers/_template_v2/constants.py new file mode 100644 index 0000000..86c8cf8 --- /dev/null +++ b/lib/streaming_providers/providers/_template_v2/constants.py @@ -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, + } diff --git a/lib/streaming_providers/providers/_template_v2/drm.py b/lib/streaming_providers/providers/_template_v2/drm.py new file mode 100644 index 0000000..986cce9 --- /dev/null +++ b/lib/streaming_providers/providers/_template_v2/drm.py @@ -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 diff --git a/lib/streaming_providers/providers/_template_v2/models.py b/lib/streaming_providers/providers/_template_v2/models.py new file mode 100644 index 0000000..53dc5c2 --- /dev/null +++ b/lib/streaming_providers/providers/_template_v2/models.py @@ -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"), + ) diff --git a/lib/streaming_providers/providers/_template_v2/optional/__init__.py b/lib/streaming_providers/providers/_template_v2/optional/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lib/streaming_providers/providers/_template_v2/optional/bookmarks_manager.py b/lib/streaming_providers/providers/_template_v2/optional/bookmarks_manager.py new file mode 100644 index 0000000..24b4912 --- /dev/null +++ b/lib/streaming_providers/providers/_template_v2/optional/bookmarks_manager.py @@ -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 diff --git a/lib/streaming_providers/providers/_template_v2/optional/catchup_manager.py b/lib/streaming_providers/providers/_template_v2/optional/catchup_manager.py new file mode 100644 index 0000000..e800e3d --- /dev/null +++ b/lib/streaming_providers/providers/_template_v2/optional/catchup_manager.py @@ -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:@-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 diff --git a/lib/streaming_providers/providers/_template_v2/optional/drm_manager.py b/lib/streaming_providers/providers/_template_v2/optional/drm_manager.py new file mode 100644 index 0000000..35c6da8 --- /dev/null +++ b/lib/streaming_providers/providers/_template_v2/optional/drm_manager.py @@ -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 diff --git a/lib/streaming_providers/providers/_template_v2/optional/epg_manager.py b/lib/streaming_providers/providers/_template_v2/optional/epg_manager.py new file mode 100644 index 0000000..b655cd9 --- /dev/null +++ b/lib/streaming_providers/providers/_template_v2/optional/epg_manager.py @@ -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 [] diff --git a/lib/streaming_providers/providers/_template_v2/optional/favorites_manager.py b/lib/streaming_providers/providers/_template_v2/optional/favorites_manager.py new file mode 100644 index 0000000..3ebd809 --- /dev/null +++ b/lib/streaming_providers/providers/_template_v2/optional/favorites_manager.py @@ -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 diff --git a/lib/streaming_providers/providers/_template_v2/optional/recordings_manager.py b/lib/streaming_providers/providers/_template_v2/optional/recordings_manager.py new file mode 100644 index 0000000..821bd5d --- /dev/null +++ b/lib/streaming_providers/providers/_template_v2/optional/recordings_manager.py @@ -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:", 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:", provider=) + return [] + + def delete_recording(self, recording_id: str, **kw: Any) -> None: + # TODO: accept "rec:" (resolve via the listing) and the backend id. + raise ItemNotFoundError(f"no recording {recording_id!r}") diff --git a/lib/streaming_providers/providers/_template_v2/optional/session_adapter.py b/lib/streaming_providers/providers/_template_v2/optional/session_adapter.py new file mode 100644 index 0000000..265cc41 --- /dev/null +++ b/lib/streaming_providers/providers/_template_v2/optional/session_adapter.py @@ -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() diff --git a/lib/streaming_providers/providers/_template_v2/optional/vod_manager.py b/lib/streaming_providers/providers/_template_v2/optional/vod_manager.py new file mode 100644 index 0000000..edf90a0 --- /dev/null +++ b/lib/streaming_providers/providers/_template_v2/optional/vod_manager.py @@ -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_", "program_", "clip_"); 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=, total=) + 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 diff --git a/lib/streaming_providers/providers/_template_v2/provider.py b/lib/streaming_providers/providers/_template_v2/provider.py new file mode 100644 index 0000000..90a53a3 --- /dev/null +++ b/lib/streaming_providers/providers/_template_v2/provider.py @@ -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/_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 diff --git a/lib/streaming_providers/providers/_template_v2/scaffold.py b/lib/streaming_providers/providers/_template_v2/scaffold.py new file mode 100644 index 0000000..7a2d55b --- /dev/null +++ b/lib/streaming_providers/providers/_template_v2/scaffold.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +""" +Scaffold a new v2 provider from this template. + + python scaffold.py [--label "Display Name"] + [--country DE] [--with vod,epg,recordings,catchup, + favorites,bookmarks,drm,session] + [--dest ] + + 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 // (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()) diff --git a/lib/streaming_providers/providers/_template_v2/tests/test_example_provider.py b/lib/streaming_providers/providers/_template_v2/tests/test_example_provider.py new file mode 100644 index 0000000..22ff0ee --- /dev/null +++ b/lib/streaming_providers/providers/_template_v2/tests/test_example_provider.py @@ -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 +""" + +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 == {} diff --git a/lib/streaming_providers/providers/joyn/__init__.py b/lib/streaming_providers/providers/joyn/__init__.py index 707d9b0..c2ce671 100644 --- a/lib/streaming_providers/providers/joyn/__init__.py +++ b/lib/streaming_providers/providers/joyn/__init__.py @@ -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" \ No newline at end of file +__all__ = ["JoynProvider"] \ No newline at end of file diff --git a/lib/streaming_providers/providers/joyn/auth.py b/lib/streaming_providers/providers/joyn/auth.py index 63a362e..5dcda7c 100644 --- a/lib/streaming_providers/providers/joyn/auth.py +++ b/lib/streaming_providers/providers/joyn/auth.py @@ -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//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, diff --git a/lib/streaming_providers/providers/joyn/catchup_manager.py b/lib/streaming_providers/providers/joyn/catchup_manager.py deleted file mode 100644 index 09f3f75..0000000 --- a/lib/streaming_providers/providers/joyn/catchup_manager.py +++ /dev/null @@ -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 [] \ No newline at end of file diff --git a/lib/streaming_providers/providers/joyn/channel_manager.py b/lib/streaming_providers/providers/joyn/channel_manager.py index 67d4a37..816e05c 100644 --- a/lib/streaming_providers/providers/joyn/channel_manager.py +++ b/lib/streaming_providers/providers/joyn/channel_manager.py @@ -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 "-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 \ No newline at end of file + 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 \ No newline at end of file diff --git a/lib/streaming_providers/providers/joyn/config.py b/lib/streaming_providers/providers/joyn/config.py new file mode 100644 index 0000000..4a5a9d8 --- /dev/null +++ b/lib/streaming_providers/providers/joyn/config.py @@ -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" \ No newline at end of file diff --git a/lib/streaming_providers/providers/joyn/constants.py b/lib/streaming_providers/providers/joyn/constants.py index 5cc2a56..4dc7d8d 100644 --- a/lib/streaming_providers/providers/joyn/constants.py +++ b/lib/streaming_providers/providers/joyn/constants.py @@ -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_ playable video asset (what the asset playlist wants) +# b_ movie, c_ season, d_ episode +# block- 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 # ============================================================================ diff --git a/lib/streaming_providers/providers/joyn/drm.py b/lib/streaming_providers/providers/joyn/drm.py new file mode 100644 index 0000000..074a19a --- /dev/null +++ b/lib/streaming_providers/providers/joyn/drm.py @@ -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, + ), + ) \ No newline at end of file diff --git a/lib/streaming_providers/providers/joyn/entitlement.py b/lib/streaming_providers/providers/joyn/entitlement.py new file mode 100644 index 0000000..6bd1ab9 --- /dev/null +++ b/lib/streaming_providers/providers/joyn/entitlement.py @@ -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}") \ No newline at end of file diff --git a/lib/streaming_providers/providers/joyn/epg_manager.py b/lib/streaming_providers/providers/joyn/epg_manager.py deleted file mode 100644 index e54c5fc..0000000 --- a/lib/streaming_providers/providers/joyn/epg_manager.py +++ /dev/null @@ -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 \ No newline at end of file diff --git a/lib/streaming_providers/providers/joyn/ids.py b/lib/streaming_providers/providers/joyn/ids.py new file mode 100644 index 0000000..a66dd3c --- /dev/null +++ b/lib/streaming_providers/providers/joyn/ids.py @@ -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-, 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) \ No newline at end of file diff --git a/lib/streaming_providers/providers/joyn/models.py b/lib/streaming_providers/providers/joyn/models.py index cbd8a49..e9076de 100644 --- a/lib/streaming_providers/providers/joyn/models.py +++ b/lib/streaming_providers/providers/joyn/models.py @@ -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__() \ No newline at end of file +# ============================================================================ +# 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"), + ) \ No newline at end of file diff --git a/lib/streaming_providers/providers/joyn/provider.py b/lib/streaming_providers/providers/joyn/provider.py index a1c92f1..36d6c91 100644 --- a/lib/streaming_providers/providers/joyn/provider.py +++ b/lib/streaming_providers/providers/joyn/provider.py @@ -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-/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) \ No newline at end of file + # ------------------------------------------------------------------ + # 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. \ No newline at end of file diff --git a/lib/streaming_providers/providers/joyn/session.py b/lib/streaming_providers/providers/joyn/session.py new file mode 100644 index 0000000..5e0580f --- /dev/null +++ b/lib/streaming_providers/providers/joyn/session.py @@ -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 \ No newline at end of file diff --git a/lib/streaming_providers/providers/joyn/signing.py b/lib/streaming_providers/providers/joyn/signing.py new file mode 100644 index 0000000..3a19afb --- /dev/null +++ b/lib/streaming_providers/providers/joyn/signing.py @@ -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] \ No newline at end of file diff --git a/lib/streaming_providers/providers/joyn/tests/test_joyn_provider.py b/lib/streaming_providers/providers/joyn/tests/test_joyn_provider.py new file mode 100644 index 0000000..aa55b3c --- /dev/null +++ b/lib/streaming_providers/providers/joyn/tests/test_joyn_provider.py @@ -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 == [] \ No newline at end of file diff --git a/lib/streaming_providers/providers/joyn/vod_manager.py b/lib/streaming_providers/providers/joyn/vod_manager.py index 431677a..077c423 100644 --- a/lib/streaming_providers/providers/joyn/vod_manager.py +++ b/lib/streaming_providers/providers/joyn/vod_manager.py @@ -1,36 +1,76 @@ # streaming_providers/providers/joyn/vod_manager.py # -*- coding: utf-8 -*- """ -Joyn VOD Manager - Handles VOD catalogue operations via GraphQL -Supports deep navigation and authenticated requests +Joyn VOD Manager — browseable catalogue (GraphQL) and playback. + +Collaborators (constructor contract, README §6.0): + + required (keyword-only): + http_manager, auth, country, config + declared extras (keyword-only): + entitlement — the shared JoynEntitlement helper; the VOD manager is + the second consumer (the first is JoynChannelManager), + which is why it lives in its own collaborator and not + on a sibling manager (README §5.1). v1 reached it via + provider.channel_manager.get_entitlement_token. + +Return types: `get_vod_category` and `search_vod` return `VodPage`. V1 returned +a bare list and a dict respectively — both of which the base tolerates at +runtime (`normalize_vod_result`), but the ABC says `VodPage`, and the migration +brief commits to it (README §6.2, trap 33). + +Content-id grammar (opaque to the base): + a_… Joyn "video" asset id — the id the playlist endpoint wants + b_/c_/d_ catalog ids: movies (b_), seasons (c_), episodes (d_) + block-… lazyBlocks from a landing page; also anything with ":" in it + /path browsable HTML/GraphQL paths — /serien, /filme, /serien/, … + +`handles_content_id` implements the prefilter that v1 had as +`JoynProvider._is_vod_content`. It is pure (no I/O): a prefix / character +check, called on every router attempt. + +Errors follow the v2 contract: "not mine" is decided by `handles_content_id` +(the router never asks this manager about a live id) — once a VOD id is here, +only a result or a typed failure is valid. Typed `ProviderError`s (AuthError, +RateLimitError, GeoBlockError, EntitlementError, NotFoundError, ...) ALWAYS +propagate unchanged; only untyped exceptions are wrapped in `ServerError`. +Only `ServerError`s are retried. """ import functools -import hashlib import json import re import time import urllib.parse -from typing import Any, Dict, List, Optional, Tuple, Union +from typing import Any, Dict, List, Optional, Union -from ...base.models import DRMConfig, DRMSystem, LicenseConfig +from ...base.errors import NotFoundError, ProviderError, ServerError +from ...base.managers import VodManager +from ...base.models import DRMConfig from ...base.models.content import ContentType, StreamingMode from ...base.models.vod import VodCategory, VodItem from ...base.utils.logger import logger -from .channel_manager import build_signature, create_video_payload +from ...base.utils.transport import transport_errors +from ...base.vod import VodPage +from .drm import build_widevine_config +from .ids import is_vod_id +from .signing import build_signature, create_video_payload, video_config_fingerprint from .constants import ( + CONTENT_TYPE_VOD, DEFAULT_MAX_RETRIES, DEFAULT_REQUEST_TIMEOUT, - DRM_REQUEST_HEADERS, - JOYN_CLIENT_VERSION, - JOYN_GRAPHQL_BASE_HEADERS, JOYN_GRAPHQL_BASE_URL, + JOYN_STREAMING_ENDPOINTS, JOYN_USER_AGENT, ) -from .models import PlaybackRestrictedException, SubscriptionRequiredException +from .models import ( + JoynEntitlementError, + PlaybackRestrictedException, + SubscriptionRequiredException, +) # ============================================================================ -# GraphQL Query Hashes for VOD +# GraphQL query hashes (VOD-specific; live hashes live in constants.py) # ============================================================================ VOD_GRAPHQL_HASHES = { @@ -80,7 +120,7 @@ def ttl_cache(ttl_seconds: int = 300): the cache key. A `force_refresh=True` kwarg bypasses the cached value for that call and - repopulates the cache, mirroring what the old `_get_cached_data` helper did. + repopulates the cache. """ def decorator(func): cache_attr = f"_ttl_cache_{func.__name__}" @@ -105,78 +145,122 @@ def ttl_cache(ttl_seconds: int = 300): return decorator -class JoynVodManager: +class JoynVodManager(VodManager): """ - Joyn VOD Manager using GraphQL API + Joyn VOD manager using the GraphQL API (browse) and the vod-prd playlist + endpoint (playback). No `self.provider` accesses — every collaborator is + either one of the four required ones or an explicit keyword-only extra. """ - def __init__(self, provider): - self.provider = provider + # Root-menu paths we surface from the live Navigation API; anything else + # is filtered out so Live TV (handled by the channel manager) and unrelated + # blocks do not end up in the VOD root. Add new paths here as Joyn adds + # them under these sections — the whitelist is deliberate, not incidental. + ALLOWED_NAV_PATHS = { + "/neu-beliebt", "/serien", "/filme", "/sport", "/news", + "/mediatheken", "/collections/sendung-im-tv-verpasst", + } + + # Fallback used if the live Navigation response is empty or does not match + # the expected shape. Fail open to a known-good static menu rather than + # ship an empty VOD root. + _STATIC_NAV_FALLBACK = [ + {"url": "/neu-beliebt", "title": "Neu & Beliebt"}, + {"url": "/serien", "title": "Serien"}, + {"url": "/filme", "title": "Filme"}, + {"url": "/sport", "title": "Sport"}, + {"url": "/news", "title": "News & Doku"}, + {"url": "/mediatheken", "title": "Mediatheken"}, + {"url": "/collections/sendung-im-tv-verpasst", "title": "Sendung im TV verpasst?"}, + ] + + def __init__( + self, + *, + http_manager: Any, + auth: Any, + country: str, + config: Any, + entitlement: Any, + ): + super().__init__( + http_manager=http_manager, auth=auth, country=country, config=config + ) + self._entitlement = entitlement # extra AFTER super(), never passed to it + + # Per-instance caches. Provider-owned dicts are used only where the + # cache must survive credentials changes and be cleared by the + # provider; the VOD cache is self-contained and cleared by + # clear_cache(), which the provider calls from set_user_credentials + # (if/when that exists). self._cache: Dict[str, Dict[str, Any]] = {} self._cache_ttl: int = 300 self._query_hashes = VOD_GRAPHQL_HASHES self._operations = GRAPHQL_OPERATIONS - self._user_state = None + self._user_state: Optional[Dict[str, Any]] = None self._has_plus = False - logger.info(f"[JoynVodManager] Initialised for country={provider.country}") + logger.info(f"[JoynVodManager] Initialised for country={self.country}") # ======================================================================== - # PROPERTIES + # CAPABILITY / ROUTING # ======================================================================== - @property - def http_manager(self): - return self.provider.http_manager + def handles_content_id(self, content_id: str) -> bool: + """ + Cheap, pure prefilter — the router asks this on every attempt, so it + must not do I/O. - @property - def country(self) -> str: - return self.provider.country - - @property - def platform(self) -> str: - return self.provider.platform - - @property - def distribution_tenant(self) -> str: - return self.provider.distribution_tenant - - @property - def implements_vod(self) -> bool: - return True + Mirrors v1's `JoynProvider._is_vod_content`: + * a_/b_/c_/d_ prefixed asset ids + * block- lazy-block ids + * any path containing "/" (browsable routes) + * any id containing ":" (v1 block-id grammar) + Live channel ids are bare slugs like "sat1-de" and do not match. + """ + return is_vod_id(content_id) # the same function the channel manager negates # ======================================================================== # CACHING # ======================================================================== - @staticmethod - def _video_config_fingerprint(video_config: Optional[Dict]) -> str: - if not video_config: - return "default" - normalized = json.dumps(video_config, sort_keys=True, separators=(",", ":")) - return hashlib.sha1(normalized.encode("utf-8")).hexdigest()[:12] + def clear_cache(self) -> None: + """Called by the provider when credentials change.""" + self._cache.clear() + self.__dict__.pop(type(self).get_user_state.cache_attr, None) + self._user_state = None + logger.debug("VOD cache cleared") # ======================================================================== # HEADER / URL BUILDING # ======================================================================== def _get_graphql_headers(self, authenticated: bool = False) -> Dict[str, str]: - headers = JOYN_GRAPHQL_BASE_HEADERS.copy() - headers.update({ - "joyn-client-version": JOYN_CLIENT_VERSION, - "joyn-country": self.country.upper(), - "joyn-distribution-tenant": self.distribution_tenant, - "joyn-platform": self.platform, - "joyn-user-state": "code=R_A" if authenticated else "code=A_A", - }) - if authenticated and self.provider.bearer_token: - headers["Authorization"] = f"Bearer {self.provider.bearer_token}" - return headers + """ + GraphQL headers via the shared config. + + `authenticated=True` means "this is a user-scoped query (user state, + search, season under an account)" — we then ask the session for a + token and set joyn-user-state=code=R_A. When False, the query is + anonymous and the reference client sends A_A. + """ + token: Optional[str] = None + if authenticated: + # RAISES a typed error if no session can be had (README §8) instead of + # silently degrading to an anonymous request. + token = self.auth.get_access_token() + return self.config.graphql_headers( + token=token, authenticated=token is not None + ) @staticmethod - def _build_graphql_url(operation_name: str, query_hash: str, variables: Optional[Dict] = None) -> str: + def _build_graphql_url( + operation_name: str, + query_hash: str, + variables: Optional[Dict] = None, + ) -> str: base_url = JOYN_GRAPHQL_BASE_URL params = { "operationName": operation_name, @@ -184,10 +268,12 @@ class JoynVodManager: "watch_assistant_variant": "true", } if variables: - params["variables"] = json.dumps(variables, separators=(',', ':')) + params["variables"] = json.dumps(variables, separators=(",", ":")) extensions = {"persistedQuery": {"version": 1, "sha256Hash": query_hash}} - params["extensions"] = json.dumps(extensions, separators=(',', ':')) - query_string = "&".join(f"{k}={urllib.parse.quote(v)}" for k, v in params.items()) + params["extensions"] = json.dumps(extensions, separators=(",", ":")) + query_string = "&".join( + f"{k}={urllib.parse.quote(v)}" for k, v in params.items() + ) return f"{base_url}?{query_string}" # ======================================================================== @@ -219,50 +305,39 @@ class JoynVodManager: # ======================================================================== def get_navigation(self) -> Dict[str, Any]: - try: + with transport_errors("vod_navigation", "Joyn"): url = self._build_graphql_url( operation_name=self._operations["NAVIGATION"], query_hash=self._query_hashes["NAVIGATION"], ) headers = self._get_graphql_headers(authenticated=False) - response = self.http_manager.get(url, operation="vod_navigation", headers=headers, - timeout=DEFAULT_REQUEST_TIMEOUT) + response = self.http_manager.get( + url, + operation="vod_navigation", + headers=headers, + timeout=DEFAULT_REQUEST_TIMEOUT, + ) response.raise_for_status() return response.json().get("data") or {} - except Exception as e: - logger.error(f"Error fetching navigation: {e}") - return {} def get_navigation_tree(self) -> List[Dict[str, Any]]: return self.get_navigation().get("navigation", []) - # Whitelist of root-menu paths we're willing to surface from the live - # Navigation API. Keeps Live TV (handled by channel_manager) and any - # unrelated blocks out of the VOD root menu, while picking up anything - # Joyn adds under these paths without a code change. - ALLOWED_NAV_PATHS = { - "/neu-beliebt", "/serien", "/filme", "/sport", "/news", - "/mediatheken", "/collections/sendung-im-tv-verpasst", - } + def get_navigation_categories( + self, parent_title: Optional[str] = None + ) -> List[VodCategory]: + """ + Root menu. - # Fallback used if the live Navigation response is empty or doesn't match - # the shape we expect (path/title on each entry). The exact shape of the - # top-level "navigation" list — unlike NAVIGATION's seriesGenre/movieGenre - # blocks, which get_genres_from_navigation() already parses from real - # traffic — hasn't been captured/verified here, so we fail open to this - # known-good static menu rather than risk an empty VOD root menu in - # production. - _STATIC_NAV_FALLBACK = [ - {"url": "/neu-beliebt", "title": "Neu & Beliebt"}, - {"url": "/serien", "title": "Serien"}, - {"url": "/filme", "title": "Filme"}, - {"url": "/sport", "title": "Sport"}, - {"url": "/news", "title": "News & Doku"}, - {"url": "/mediatheken", "title": "Mediatheken"}, - {"url": "/collections/sendung-im-tv-verpasst", "title": "Sendung im TV verpasst?"}, - ] + Two sources of entries: + * whitelisted paths from the live Navigation API (kept in + ALLOWED_NAV_PATHS so Live TV and unrelated blocks stay out) + * two synthetic genre directories that Joyn does not list itself - def get_navigation_categories(self, parent_title: Optional[str] = None) -> List[VodCategory]: + If the live response does not yield a whitelisted entry at all + (upstream empty or shape changed), fall back to a static menu rather + than ship an empty root. + """ categories: List[VodCategory] = [] nav_data = self.get_navigation() @@ -284,9 +359,6 @@ class JoynVodManager: )) if not categories: - # Live response didn't match the expected shape (or the call - # failed upstream and returned {}) — fail open to the static menu - # instead of shipping an empty VOD root. logger.warning( "Joyn navigation response didn't yield any whitelisted categories; " "falling back to the static VOD root menu" @@ -301,8 +373,7 @@ class JoynVodManager: details_url=page["url"], )) - # Genre directories are synthetic — Joyn's Navigation API doesn't list - # them as their own entries — so they're always appended. + # Genre directories are synthetic — always appended. categories.append(VodCategory( content_id="/serien/genre", name="Serien Genres", description="Serien Genres", provider="joyn", fetch_url="/serien/genre", details_url="/serien/genre", @@ -314,10 +385,10 @@ class JoynVodManager: return categories - def get_genres_from_navigation(self, media_type: str = None) -> List[VodCategory]: + def get_genres_from_navigation(self, media_type: Optional[str] = None) -> List[VodCategory]: """Parse seriesGenre and movieGenre from the Navigation API response.""" nav_data = self.get_navigation() - categories = [] + categories: List[VodCategory] = [] types_to_fetch = [media_type] if media_type else ["seriesGenre", "movieGenre"] @@ -341,11 +412,16 @@ class JoynVodManager: # GENRE PAGE # ======================================================================== - def get_genre_page(self, path: str, first: int = 32, offset: int = 0, authenticated: bool = True) -> Dict[str, Any]: - try: + def get_genre_page( + self, + path: str, + first: int = 32, + offset: int = 0, + authenticated: bool = True, + ) -> Dict[str, Any]: + with transport_errors(f"vod_genre_page {path}", "Joyn"): if not path.startswith("/"): path = f"/{path}" - variables = {"first": first, "path": path, "offset": offset} url = self._build_graphql_url( operation_name=self._operations["PAGE_OVERVIEW_GENRE"], @@ -353,23 +429,30 @@ class JoynVodManager: variables=variables, ) headers = self._get_graphql_headers(authenticated=authenticated) - response = self.http_manager.get(url, operation="vod_genre_page", headers=headers, - timeout=DEFAULT_REQUEST_TIMEOUT) + response = self.http_manager.get( + url, operation="vod_genre_page", headers=headers, + timeout=DEFAULT_REQUEST_TIMEOUT, + ) response.raise_for_status() data = response.json() - if "errors" in data: - logger.warning(f"GraphQL errors in genre page: {data['errors']}") - return {} - - return (data.get("data") or {}).get("page", {}) - except Exception as e: - logger.error(f"Error fetching genre page {path}: {e}") + if "errors" in data: + logger.warning(f"GraphQL errors in genre page: {data['errors']}") return {} + return (data.get("data") or {}).get("page", {}) - def _get_genre_items(self, path: str, authenticated: bool = True, **kwargs) -> List[Union[VodCategory, VodItem]]: - page = self.get_genre_page(path=path, authenticated=authenticated, first=kwargs.get("first", 32), - offset=kwargs.get("offset", 0)) + def _get_genre_items( + self, + path: str, + authenticated: bool = True, + **kwargs, + ) -> List[Union[VodCategory, VodItem]]: + page = self.get_genre_page( + path=path, + authenticated=authenticated, + first=kwargs.get("first", 32), + offset=kwargs.get("offset", 0), + ) items: List[Union[VodCategory, VodItem]] = [] for block in page.get("blocks", []): for asset in block.get("assets", []): @@ -379,11 +462,11 @@ class JoynVodManager: return items # ======================================================================== - # SERIES / MOVIE DETAIL PAGE (HTML FALLBACK) + # SERIES / MOVIE DETAIL # ======================================================================== def _extract_page_json(self, html: str) -> Optional[Dict[str, Any]]: - """Extract the Next.js RSC page payload (initialData) embedded in series/movie detail HTML.""" + """Extract the Next.js RSC page payload (initialData) embedded in detail HTML.""" match = re.search( r'self\.__next_f\.push\(\[1,\s*"a:(\[.*?\])\\n"\]\)', html, re.DOTALL ) @@ -392,66 +475,66 @@ class JoynVodManager: try: raw = match.group(1).encode().decode("unicode_escape") data = json.loads(raw) - # data[3] is the props dict; ["initialData"]["page"] holds the series/movie object return data[3]["initialData"]["page"] except (json.JSONDecodeError, UnicodeDecodeError, IndexError, KeyError, TypeError) as e: logger.warning(f"Failed to parse embedded page JSON: {e}") return None - def _get_series_items_fallback(self, path: str, authenticated: bool = True, **kwargs) -> List[ - Union[VodCategory, VodItem]]: + def _get_series_items_fallback( + self, + path: str, + authenticated: bool = True, + **kwargs, + ) -> List[Union[VodCategory, VodItem]]: """Series detail pages: parse the season/episode data Next.js already embeds server-side.""" url = f"https://www.joyn.de{path}" headers = { "User-Agent": JOYN_USER_AGENT, "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", } - try: + with transport_errors(f"vod_series_html {path}", "Joyn"): response = self.http_manager.get( - url, operation="vod_series_html", headers=headers, timeout=DEFAULT_REQUEST_TIMEOUT + url, operation="vod_series_html", headers=headers, + timeout=DEFAULT_REQUEST_TIMEOUT, ) response.raise_for_status() html = response.text - page = self._extract_page_json(html) - if not page: - logger.warning(f"No embedded page JSON found for {path} (html_len={len(html)})") - return [] - - series = page.get("series", {}) - all_seasons = series.get("allSeasons") or series.get("freeSeasons") or [] - - if not all_seasons: - logger.warning(f"No seasons in embedded data for {path}") - return [] - - all_seasons = sorted(all_seasons, key=lambda s: s.get("number") or 9999) - - if len(all_seasons) == 1: - items = [] - for ep in all_seasons[0].get("episodes", []): - item = self._parse_episode_asset(ep) - if item: - items.append(item) - items.sort(key=lambda x: (x.episode_number or 9999)) - return items - - items = [] - for s in all_seasons: - items.append(VodCategory( - content_id=s["id"], - name=f"Staffel {s.get('number', '')}".strip(), - provider="joyn", - fetch_url=s["id"], - )) - return items - - except Exception as e: - logger.error(f"Error fetching series HTML for {path}: {e}") + page = self._extract_page_json(html) + if not page: + logger.warning(f"No embedded page JSON found for {path} (html_len={len(html)})") return [] + series = page.get("series", {}) + all_seasons = series.get("allSeasons") or series.get("freeSeasons") or [] + + if not all_seasons: + logger.warning(f"No seasons in embedded data for {path}") + return [] + + all_seasons = sorted(all_seasons, key=lambda s: s.get("number") or 9999) + + if len(all_seasons) == 1: + items = [] + for ep in all_seasons[0].get("episodes", []): + item = self._parse_episode_asset(ep) + if item: + items.append(item) + items.sort(key=lambda x: (x.episode_number or 9999)) + return items + + items: List[Union[VodCategory, VodItem]] = [] + for s in all_seasons: + items.append(VodCategory( + content_id=s["id"], + name=f"Staffel {s.get('number', '')}".strip(), + provider="joyn", + fetch_url=s["id"], + )) + return items + def get_movie_detail(self, path: str, authenticated: bool = True) -> Dict[str, Any]: - try: + with transport_errors(f"vod_movie_detail {path}", "Joyn"): if not path.startswith("/"): path = f"/{path}" variables = {"path": path} @@ -461,20 +544,24 @@ class JoynVodManager: variables=variables, ) headers = self._get_graphql_headers(authenticated=authenticated) - response = self.http_manager.get(url, operation="vod_movie_detail", headers=headers, - timeout=DEFAULT_REQUEST_TIMEOUT) + response = self.http_manager.get( + url, operation="vod_movie_detail", headers=headers, + timeout=DEFAULT_REQUEST_TIMEOUT, + ) response.raise_for_status() data = response.json() - if "errors" in data: - logger.warning(f"GraphQL errors in movie detail: {data['errors']}") - return {} - return (data.get("data") or {}).get("page", {}).get("movie", {}) - except Exception as e: - logger.error(f"Error fetching movie detail {path}: {e}") - return {} - def _get_movie_items_fallback(self, path: str, authenticated: bool = True, **kwargs) -> List[VodItem]: - """Movie detail pages, via GraphQL — no HTML scraping needed.""" + if "errors" in data: + logger.warning(f"GraphQL errors in movie detail: {data['errors']}") + return {} + return (data.get("data") or {}).get("page", {}).get("movie", {}) + + def _get_movie_items_fallback( + self, + path: str, + authenticated: bool = True, + **kwargs, + ) -> List[VodItem]: movie = self.get_movie_detail(path, authenticated=authenticated) if not movie: logger.warning(f"No movie data found for {path}") @@ -486,32 +573,43 @@ class JoynVodManager: # SEASON EPISODES # ======================================================================== - def get_season_episodes(self, season_id: str, first: int = 20, offset: int = 0, license_filter: str = "FREE", - authenticated: bool = True) -> Dict[str, Any]: - try: - variables = {"id": season_id, "first": first, "licenseFilter": license_filter, "offset": offset} + def get_season_episodes( + self, + season_id: str, + first: int = 20, + offset: int = 0, + license_filter: str = "FREE", + authenticated: bool = True, + ) -> Dict[str, Any]: + with transport_errors(f"vod_season {season_id}", "Joyn"): + variables = { + "id": season_id, "first": first, + "licenseFilter": license_filter, "offset": offset, + } url = self._build_graphql_url( operation_name=self._operations["SEASON"], query_hash=self._query_hashes["SEASON"], variables=variables, ) headers = self._get_graphql_headers(authenticated=authenticated) - response = self.http_manager.get(url, operation="vod_season", headers=headers, - timeout=DEFAULT_REQUEST_TIMEOUT) + response = self.http_manager.get( + url, operation="vod_season", headers=headers, + timeout=DEFAULT_REQUEST_TIMEOUT, + ) response.raise_for_status() data = response.json() - if "errors" in data: - logger.warning(f"GraphQL errors in season query: {data['errors']}") - return {} - - return (data.get("data") or {}).get("season", {}) - except Exception as e: - logger.error(f"Error fetching season {season_id}: {e}") + if "errors" in data: + logger.warning(f"GraphQL errors in season query: {data['errors']}") return {} + return (data.get("data") or {}).get("season", {}) - def _get_season_items(self, season_id: str, authenticated: bool = True, **kwargs) -> List[ - Union[VodCategory, VodItem]]: + def _get_season_items( + self, + season_id: str, + authenticated: bool = True, + **kwargs, + ) -> List[Union[VodCategory, VodItem]]: all_episodes: List[VodItem] = [] seen_ids: set = set() @@ -538,40 +636,53 @@ class JoynVodManager: # COLLECTION QUERY # ======================================================================== - def get_collection(self, block_id: str, first: int = 32, offset: int = 0, authenticated: bool = True) -> Dict[ - str, Any]: - try: - variables = {"first": first, "offset": offset, "blockId": block_id, - "hasToken": authenticated and bool(self.provider.bearer_token)} + def get_collection( + self, + block_id: str, + first: int = 32, + offset: int = 0, + authenticated: bool = True, + ) -> Dict[str, Any]: + with transport_errors(f"vod_collection {block_id}", "Joyn"): + has_token = authenticated and self.auth.ensure() + variables = { + "first": first, "offset": offset, "blockId": block_id, + "hasToken": has_token, + } url = self._build_graphql_url( operation_name=self._operations["COLLECTION_QUERY"], query_hash=self._query_hashes["COLLECTION_QUERY"], variables=variables, ) headers = self._get_graphql_headers(authenticated=authenticated) - response = self.http_manager.get(url, operation="vod_collection", headers=headers, - timeout=DEFAULT_REQUEST_TIMEOUT) + response = self.http_manager.get( + url, operation="vod_collection", headers=headers, + timeout=DEFAULT_REQUEST_TIMEOUT, + ) response.raise_for_status() data = response.json() - if "errors" in data: - logger.warning(f"GraphQL errors in collection query: {data['errors']}") - return {"assets": [], "total": 0} - block = (data.get("data") or {}).get("block", {}) - return { - "assets": block.get("assets", []), - "headline": block.get("headline", ""), - "id": block.get("id"), - "typename": block.get("__typename"), - "total": len(block.get("assets", [])), - } - except Exception as e: - logger.error(f"Error fetching collection {block_id}: {e}") - return {"assets": [], "total": 0} - def get_collection_items(self, block_id: str, first: int = 32, offset: int = 0, authenticated: bool = True) -> List[ - Union[VodCategory, VodItem]]: + if "errors" in data: + logger.warning(f"GraphQL errors in collection query: {data['errors']}") + return {"assets": [], "total": 0} + block = (data.get("data") or {}).get("block", {}) + return { + "assets": block.get("assets", []), + "headline": block.get("headline", ""), + "id": block.get("id"), + "typename": block.get("__typename"), + "total": len(block.get("assets", [])), + } + + def get_collection_items( + self, + block_id: str, + first: int = 32, + offset: int = 0, + authenticated: bool = True, + ) -> List[Union[VodCategory, VodItem]]: result = self.get_collection(block_id, first, offset, authenticated) - items = [] + items: List[Union[VodCategory, VodItem]] = [] for asset in result.get("assets", []): item = self._parse_asset(asset) if item: @@ -582,9 +693,13 @@ class JoynVodManager: # LANDING PAGE # ======================================================================== - def get_landing_page(self, path: str = "/neu-beliebt", variation: str = "Default", authenticated: bool = True) -> \ - Dict[str, Any]: - try: + def get_landing_page( + self, + path: str = "/neu-beliebt", + variation: str = "Default", + authenticated: bool = True, + ) -> Dict[str, Any]: + with transport_errors(f"vod_landing_page {path}", "Joyn"): if not path.startswith("/"): path = f"/{path}" variables = {"path": path, "variation": variation} @@ -594,19 +709,24 @@ class JoynVodManager: variables=variables, ) headers = self._get_graphql_headers(authenticated=authenticated) - response = self.http_manager.get(url, operation="vod_landing_page", headers=headers, - timeout=DEFAULT_REQUEST_TIMEOUT) + response = self.http_manager.get( + url, operation="vod_landing_page", headers=headers, + timeout=DEFAULT_REQUEST_TIMEOUT, + ) response.raise_for_status() data = response.json() - if "errors" in data: - logger.warning(f"GraphQL errors in landing page: {data['errors']}") - return {} - return (data.get("data") or {}).get("page", {}) - except Exception as e: - logger.error(f"Error fetching landing page {path}: {e}") - return {} - def _get_page_items(self, path: str, authenticated: bool = True, **kwargs) -> List[Union[VodCategory, VodItem]]: + if "errors" in data: + logger.warning(f"GraphQL errors in landing page: {data['errors']}") + return {} + return (data.get("data") or {}).get("page", {}) + + def _get_page_items( + self, + path: str, + authenticated: bool = True, + **kwargs, + ) -> List[Union[VodCategory, VodItem]]: page = self.get_landing_page(path=path, authenticated=authenticated) items: List[Union[VodCategory, VodItem]] = [] @@ -615,13 +735,11 @@ class JoynVodManager: block_id = block.get("id") if block_type == "StandardLane" and block_id: - # If assets are present inline, parse them if block.get("assets"): for asset in block.get("assets", []): item = self._parse_asset(asset) if item: items.append(item) - # If no assets, it's a lazyBlock and we must fetch the collection else: items.extend(self.get_collection_items( block_id=block_id, @@ -629,20 +747,17 @@ class JoynVodManager: offset=kwargs.get("offset", 0), authenticated=authenticated, )) - - # Parse other lane types that contain inline assets - elif block_type in ["HeroLane", "FeaturedLane", "GenreLane", "LiveLane", "ChannelLane", "BigTeaserLane", - "RecoForYouLane"] or not block_type: + elif block_type in [ + "HeroLane", "FeaturedLane", "GenreLane", "LiveLane", + "ChannelLane", "BigTeaserLane", "RecoForYouLane", + ] or not block_type: for asset in block.get("assets", []): item = self._parse_asset(asset) if item: items.append(item) - # Process initial blocks for block in page.get("blocks", []): process_block(block) - - # Process lazy blocks (these require fetching via process_block -> get_collection_items) for block in page.get("lazyBlocks", []): process_block(block) @@ -654,153 +769,253 @@ class JoynVodManager: @ttl_cache(ttl_seconds=300) def get_user_state(self) -> Dict[str, Any]: - # Pass force_refresh=True to bypass the cache for one call; the decorator - # strips that kwarg before it reaches this function. - try: + # force_refresh=True bypasses the cache for one call; the decorator + # strips it before the call reaches this function. + with transport_errors("vod_user_state", "Joyn"): url = self._build_graphql_url( operation_name=self._operations["GET_ME_STATE"], query_hash=self._query_hashes["GET_ME_STATE"], variables={}, ) headers = self._get_graphql_headers(authenticated=True) - response = self.http_manager.get(url, operation="vod_user_state", headers=headers, - timeout=DEFAULT_REQUEST_TIMEOUT) + response = self.http_manager.get( + url, operation="vod_user_state", headers=headers, + timeout=DEFAULT_REQUEST_TIMEOUT, + ) response.raise_for_status() data = response.json() - if "errors" in data: - logger.warning(f"GraphQL errors in user state: {data['errors']}") - return {} - state = (data.get("data") or {}).get("me", {}) - subs = state.get("subscriptionsData", {}) - config = subs.get("config", {}) - self._has_plus = config.get("hasActivePlus", False) - self._user_state = state - return state - except Exception as e: - logger.error(f"Error fetching user state: {e}") + + if "errors" in data: + logger.warning(f"GraphQL errors in user state: {data['errors']}") return {} + state = (data.get("data") or {}).get("me", {}) + subs = state.get("subscriptionsData", {}) + config = subs.get("config", {}) + self._has_plus = config.get("hasActivePlus", False) + self._user_state = state + return state def has_plus_subscription(self) -> bool: self.get_user_state() return self._has_plus # ======================================================================== - # VOD CATEGORY — MAIN ENTRY POINT + # VOD CATEGORY — MAIN ENTRY POINT (returns VodPage) # ======================================================================== - def get_vod_category(self, content_id: str = "", authenticated: bool = True, **kwargs) -> List[ - Union[VodCategory, VodItem]]: - try: - if not content_id or content_id == "/": - return self.get_navigation_categories() - - # 1. Handle Genre directories explicitly - if content_id in ["/serien/genre", "/filme/genre"]: - media_type = "seriesGenre" if "serien" in content_id else "movieGenre" - return self.get_genres_from_navigation(media_type=media_type) - - # 2. Handle Block IDs (lazyBlocks) - if self._is_block_id(content_id): - return self.get_collection_items(block_id=content_id, first=kwargs.get("first", 32), - offset=kwargs.get("offset", 0), authenticated=authenticated) - - # 3. Handle Season IDs - if self._is_season_id(content_id): - return self._get_season_items(content_id, authenticated, **kwargs) - - path = content_id if content_id.startswith("/") else f"/{content_id}" - - # 4. Handle Specific Genre Paths (e.g. /serien/genre/comedy) - if self._is_genre_path(path): - return self._get_genre_items(path, authenticated, **kwargs) - - # 5. Handle Specific Series Paths (e.g. /serien/tv-total) - # Note: _is_series_path checks for startswith("/serien/") so it won't catch just "/serien" - if self._is_series_path(path): - return self._get_series_items_fallback(path, authenticated, **kwargs) - - # 6. Handle Specific Movie Paths (e.g. /filme/the-dark-knight) - if self._is_movie_path(path): - return self._get_movie_items_fallback(path, authenticated, **kwargs) - - # 7. Fallback to standard landing page parsing - # This correctly handles /serien, /filme, /sport, /mediatheken, /neu-beliebt, etc. - return self._get_page_items(path, authenticated, **kwargs) - - except Exception as e: - logger.error(f"Error getting VOD category: {e}") - return [] - - # ======================================================================== - # SEARCH - # ======================================================================== - - def search(self, query: str, cursor: Optional[str] = None, page_size: int = 24, authenticated: bool = True, - **kwargs) -> Dict[str, Any]: + def get_vod_category( + self, + content_id: str = "", + cursor: Optional[str] = None, + page_size: int = 24, + **kwargs, + ) -> VodPage: """ - Search the VOD catalogue. This is what provider.search_vod() calls. + Return one page of the VOD catalogue. - Query hash, operation name ("SearchQ"), and variable names (`text` / - `first` / `offset`, offset as an int) come from the legacy addon's - const.py, not from a captured live request against this GraphQL - endpoint — the original modern implementation guessed `query` instead - of `text`, which is why search failed outright. If Joyn changes this - endpoint's shape, this is the first place to check. + Pagination: Joyn pages by numeric offset, exposed as the opaque `cursor` + string. Only the endpoints that really honour `offset` are pageable — + block ids (collections) and genre paths. A full page there yields + `next_cursor = offset + len(entries)`. Everything else (navigation, + genre directories, season, series, movie, landing pages) returns its + complete list with `next_cursor=None`, using the helpers' own default + sizes (as v1 did): season episodes are fetched per licence filter, and + landing pages apply the offset per block, so a single offset cannot page + them without duplicating items. + + Typed ProviderErrors propagate unchanged; only untyped exceptions become + ServerError. + """ + offset = self._parse_offset(cursor) + first = kwargs.get("first") + try: + entries = self._get_vod_category_entries( + content_id=content_id, + cursor=cursor, + page_size=page_size, + authenticated=True, + **kwargs, + ) + except ProviderError: + raise + except Exception as exc: + logger.error(f"Joyn VOD category failed for {content_id!r}: {exc}") + raise ServerError(f"Joyn VOD category failed for {content_id!r}") from exc + + entries = list(entries) + next_cursor = None + if self._is_pageable(content_id): + effective_first = first if first is not None else page_size + if len(entries) >= effective_first: + next_cursor = str(offset + len(entries)) + return VodPage(entries=entries, next_cursor=next_cursor, total=None) + + @staticmethod + def _parse_offset(cursor: Optional[str]) -> int: + if cursor is None: + return 0 + try: + return int(cursor) + except (TypeError, ValueError): + logger.warning(f"Joyn: ignoring non-numeric cursor {cursor!r}") + return 0 + + def _is_pageable(self, content_id: str) -> bool: + """True only for endpoints that honour `offset` (see get_vod_category).""" + if not content_id or content_id == "/": + return False + if content_id in ("/serien/genre", "/filme/genre"): + return False + if self._is_block_id(content_id): + return True + if self._is_season_id(content_id): + return False + path = content_id if content_id.startswith("/") else f"/{content_id}" + return self._is_genre_path(path) + + def _get_vod_category_entries( + self, + content_id: str, + cursor: Optional[str], + page_size: int, + authenticated: bool, + **kwargs, + ) -> List[Union[VodCategory, VodItem]]: + """ + The v1 body of `get_vod_category`, unchanged in dispatch order; only + the `cursor` / `page_size` plumbing is new (was `first` / `offset` in + kwargs before; kept here for compatibility with callers that still + pass `first`/`offset`, which we honour over `page_size` if present). + """ + explicit_first = kwargs.pop("first", None) + offset = kwargs.pop("offset", 0) + if cursor is not None: + offset = self._parse_offset(cursor) + + # Pageable endpoints get page_size; the others keep the helpers' own + # defaults (32 / 20) so a smaller page_size cannot truncate them silently. + first = explicit_first + if first is None and self._is_pageable(content_id): + first = page_size + paging: Dict[str, Any] = {"offset": offset} + if first is not None: + paging["first"] = first + + if not content_id or content_id == "/": + return self.get_navigation_categories() + + # 1. Genre directories + if content_id in ("/serien/genre", "/filme/genre"): + media_type = "seriesGenre" if "serien" in content_id else "movieGenre" + return self.get_genres_from_navigation(media_type=media_type) + + # 2. Block ids + if self._is_block_id(content_id): + return self.get_collection_items( + block_id=content_id, authenticated=authenticated, **paging, + ) + + # 3. Season ids + if self._is_season_id(content_id): + return self._get_season_items(content_id, authenticated, **paging) + + path = content_id if content_id.startswith("/") else f"/{content_id}" + + # 4. Specific genre paths + if self._is_genre_path(path): + return self._get_genre_items(path, authenticated, **paging) + + # 5. Specific series paths + if self._is_series_path(path): + return self._get_series_items_fallback(path, authenticated, **paging) + + # 6. Specific movie paths + if self._is_movie_path(path): + return self._get_movie_items_fallback(path, authenticated, **paging) + + # 7. Landing page fallback + return self._get_page_items(path, authenticated, **paging) + + # ======================================================================== + # SEARCH (returns VodPage) + # ======================================================================== + + def search_vod( + self, + query: str, + cursor: Optional[str] = None, + page_size: int = 24, + **kwargs, + ) -> VodPage: + """ + Search the VOD catalogue. + + Query hash, operation name ("SearchQ") and variable names + (`text` / `first` / `offset`) come from the legacy addon's const.py, + not from a captured live request against this GraphQL endpoint — the + original modern implementation guessed `query` instead of `text`, + which is why search failed outright. If Joyn changes this endpoint's + shape, this is the first place to check. + + Pagination: numeric offset, advanced by page_size on a full page. + Returned as the opaque `next_cursor` string; callers pass it back as + `cursor`. """ query_hash = self._query_hashes.get("SEARCH", "") if not query_hash: - # Should not happen with the hash above in place; kept as a safe - # fallback so a future accidental removal fails loudly instead of - # sending a request that can't work. - raise NotImplementedError("Joyn SEARCH persisted query hash is not configured.") + raise ServerError("Joyn SEARCH persisted query hash is not configured.") + + offset = 0 + if cursor: + try: + offset = int(cursor) + except (TypeError, ValueError): + logger.warning(f"Joyn: ignoring non-numeric search cursor {cursor!r}") + offset = 0 try: - offset = int(cursor) if cursor else 0 variables: Dict[str, Any] = { "text": query, "first": page_size, "offset": offset, } - url = self._build_graphql_url( operation_name=self._operations["SEARCH"], query_hash=query_hash, variables=variables, ) - headers = self._get_graphql_headers(authenticated=authenticated) - response = self.http_manager.get(url, operation="vod_search", headers=headers, - timeout=DEFAULT_REQUEST_TIMEOUT) + headers = self._get_graphql_headers(authenticated=True) + response = self.http_manager.get( + url, operation="vod_search", headers=headers, + timeout=DEFAULT_REQUEST_TIMEOUT, + ) response.raise_for_status() data = response.json() - - if "errors" in data: - logger.warning(f"GraphQL errors in search for query='{query}': {data['errors']}") - return {"items": [], "cursor": None, "total": 0} - - result = (data.get("data") or {}).get("search", {}) or {} - assets = result.get("assets") or result.get("results") or [] - - items: List[Union[VodCategory, VodItem]] = [] - for asset in assets: - item = self._parse_asset(asset) - if item: - items.append(item) - - # Joyn's search endpoint appears to page by numeric offset rather - # than returning its own cursor token; if we got a full page back, - # assume there may be more and advance by what we consumed. - next_cursor = str(offset + len(assets)) if len(assets) == page_size else None - - return { - "items": items, - "cursor": next_cursor, - "total": result.get("total", len(items)), - } - except NotImplementedError: + except ProviderError: raise - except Exception as e: - logger.error(f"Error searching VOD for query='{query}': {e}") - return {"items": [], "cursor": None, "total": 0} + except Exception as exc: + logger.error(f"Joyn VOD search failed for query={query!r}: {exc}") + raise ServerError(f"Joyn VOD search failed for query={query!r}") from exc + + if "errors" in data: + logger.warning(f"GraphQL errors in search for query={query!r}: {data['errors']}") + return VodPage(entries=[], next_cursor=None, total=0) + + result = (data.get("data") or {}).get("search", {}) or {} + assets = result.get("assets") or result.get("results") or [] + + items: List[Union[VodCategory, VodItem]] = [] + for asset in assets: + item = self._parse_asset(asset) + if item: + items.append(item) + + next_cursor = str(offset + len(assets)) if len(assets) == page_size else None + return VodPage( + entries=items, + next_cursor=next_cursor, + total=result.get("total", len(items)), + ) # ======================================================================== # ASSET PARSING @@ -812,49 +1027,46 @@ class JoynVodManager: title = asset.get("title", "Unknown") path = asset.get("path", "") - # SERIES = Category (not playable) if typename == "Series": return VodCategory( - content_id=path or asset_id, # Use path for navigation + content_id=path or asset_id, name=title, - logo_url=asset.get("primaryImage", {}).get("url") or asset.get("iconicImage", {}).get("url"), + logo_url=asset.get("primaryImage", {}).get("url") + or asset.get("iconicImage", {}).get("url"), description=asset.get("description", ""), provider="joyn", - fetch_url=path or asset_id, # This will be used to fetch seasons + fetch_url=path or asset_id, child_count=None, details_url=path, ) - # MOVIE = Playable item (VodItem) - elif typename == "Movie": + if typename == "Movie": video_id = asset.get("video", {}).get("id") content_id = video_id or asset_id item = self._parse_content_asset(asset) if item: - item.content_id = content_id # Ensure we use the video ID for manifest + item.content_id = content_id return item - # EPISODE = Playable item (VodItem) - elif typename == "Episode": + if typename == "Episode": return self._parse_episode_asset(asset) - # SEASON = Category (not playable) - elif typename == "Season": + if typename == "Season": season_num = asset.get("number", "") name = f"Staffel {season_num}".strip() if season_num else title return VodCategory( - content_id=asset_id, # Season ID (e.g., c_piuczpyg0ld) + content_id=asset_id, name=name, - logo_url=asset.get("primaryImage", {}).get("url") or asset.get("iconicImage", {}).get("url"), + logo_url=asset.get("primaryImage", {}).get("url") + or asset.get("iconicImage", {}).get("url"), description=title, provider="joyn", - fetch_url=asset_id, # This will be used to fetch episodes + fetch_url=asset_id, child_count=None, details_url=None, ) - # Other types... - elif typename == "Brand": + if typename == "Brand": return VodCategory( content_id=asset_id, name=title, @@ -864,7 +1076,7 @@ class JoynVodManager: fetch_url=asset.get("path"), ) - elif typename == "GenreItem": + if typename == "GenreItem": return VodCategory( content_id=asset.get("path", asset_id), name=title, @@ -877,19 +1089,19 @@ class JoynVodManager: return None def _parse_content_asset(self, asset: Dict[str, Any]) -> Optional[VodItem]: - """Parse movie/playable content into VodItem""" + """Parse movie/playable content into VodItem.""" typename = asset.get("__typename", "") - - # Only handle Movies here, Series should be handled by _parse_asset as VodCategory if typename != "Movie": return None asset_id = asset.get("id", "") title = asset.get("title", "Unknown") - path = asset.get("path", "") - image_url = asset.get("primaryImage", {}).get("url") or asset.get("heroPortrait", {}).get("url") or asset.get( - "iconicImage", {}).get("url") + image_url = ( + asset.get("primaryImage", {}).get("url") + or asset.get("heroPortrait", {}).get("url") + or asset.get("iconicImage", {}).get("url") + ) genres = [g.get("name", "") for g in asset.get("genres", []) if g.get("name")] min_age = asset.get("ageRating", {}).get("minAge") license_types = asset.get("licenseTypes", []) @@ -913,12 +1125,10 @@ class JoynVodManager: genre=genres[0] if genres else None, rating=f"FSK {min_age}" if min_age is not None else None, ) - if is_free: item.set_free() elif is_premium: item.set_subscription(tiers=["PLUS"]) - return item def _parse_episode_asset(self, asset: Dict[str, Any]) -> Optional[VodItem]: @@ -928,7 +1138,6 @@ class JoynVodManager: season_data = asset.get("season", {}) video_data = asset.get("video", {}) - # Use the video ID (a_xxxxx) as content_id for manifest requests video_id = video_data.get("id", "") content_id = video_id or asset_id @@ -940,7 +1149,10 @@ class JoynVodManager: ep_number = asset.get("number") season_number = season_data.get("seasonNumber") - description = f"Staffel {season_number}, Episode {ep_number} – {title}" if series_data.get("title") else "" + description = ( + f"Staffel {season_number}, Episode {ep_number} – {title}" + if series_data.get("title") else "" + ) item = VodItem( name=title, @@ -959,48 +1171,55 @@ class JoynVodManager: series_id=series_data.get("id"), series_title=series_data.get("title"), ) - if is_free: item.set_free() elif is_premium: item.set_subscription(tiers=["PLUS"]) - return item # ======================================================================== # VOD PLAYBACK # ======================================================================== - def get_vod_manifest(self, content_id: str, video_config: Optional[Dict] = None, **kwargs) -> Optional[str]: - result = self._get_vod_manifest_and_drm(content_id, video_config, - max_retries=kwargs.get("max_retries", DEFAULT_MAX_RETRIES)) - return result.get("manifest_url") if result else None + def get_vod_manifest( + self, + content_id: str, + video_config: Optional[Dict] = None, + **kwargs, + ) -> Optional[str]: + """ + Return the manifest URL for a VOD content id, or raise. - def get_vod_drm(self, content_id: str, video_config: Optional[Dict] = None, **kwargs) -> List[Any]: - result = self._get_vod_manifest_and_drm(content_id, video_config, - max_retries=kwargs.get("max_retries", DEFAULT_MAX_RETRIES)) - return result.get("drm_configs", []) if result else [] + Reached only after the router confirmed the id is VOD + (`handles_content_id`), so `None` ("not mine") is not a valid outcome: + * an id that cannot be resolved to a playable video -> NotFoundError + * a playlist without manifestUrl after all retries -> ServerError + * rights / auth / transport problems -> their typed error + """ + result = self._get_vod_manifest_and_drm( + content_id, video_config, + max_retries=kwargs.get("max_retries", DEFAULT_MAX_RETRIES), + ) + return result["manifest_url"] - def get_vod_manifest_with_headers(self, content_id: str, video_config: Optional[Dict] = None, **kwargs) -> Tuple[ - Optional[str], Dict[str, str]]: - result = self._get_vod_manifest_and_drm(content_id, video_config, - max_retries=kwargs.get("max_retries", DEFAULT_MAX_RETRIES)) - if result and result.get("manifest_url"): - headers = { - "Authorization": f"Bearer {result.get('entitlement_token', '')}", - "Accept": "application/json", - "User-Agent": JOYN_USER_AGENT, - } - return result["manifest_url"], headers - return None, {} + def get_vod_drm( + self, + content_id: str, + video_config: Optional[Dict] = None, + **kwargs, + ) -> List[DRMConfig]: + result = self._get_vod_manifest_and_drm( + content_id, video_config, + max_retries=kwargs.get("max_retries", DEFAULT_MAX_RETRIES), + ) + return result["drm_configs"] def get_playable_asset(self, asset_id: str, authenticated: bool = True) -> Dict[str, Any]: """ Fetch a single playable asset (Movie/Episode) by its b_/c_/d_ id. - Returns the `asset` object including its `video.id` (a_…) which is - required for the vod-prd playlist endpoint. + Returns the `asset` object including its `video.id` (a_…). """ - try: + with transport_errors(f"vod_playable_asset {asset_id}", "Joyn"): url = self._build_graphql_url( operation_name=self._operations["PLAYABLE_ASSET"], query_hash=self._query_hashes["PLAYABLE_ASSET"], @@ -1013,24 +1232,25 @@ class JoynVodManager: ) response.raise_for_status() data = response.json() - if "errors" in data: - logger.warning(f"GraphQL errors in PlayableAssetWithToken for {asset_id}: {data['errors']}") - return {} - return (data.get("data") or {}).get("asset", {}) or {} - except Exception as e: - logger.error(f"Error fetching playable asset {asset_id}: {e}") - return {} - def get_content_details(self, content_id: str, authenticated: bool = True, **kwargs) -> Optional[VodItem]: + if "errors" in data: + logger.warning( + f"GraphQL errors in PlayableAssetWithToken for {asset_id}: {data['errors']}" + ) + return {} + return (data.get("data") or {}).get("asset", {}) or {} + + def get_content_details( + self, + content_id: str, + authenticated: bool = True, + **kwargs, + ) -> Optional[VodItem]: """ - Fetch full details for a single playable item by its b_/c_/d_ id. - This is what provider.get_vod_item_details() calls — that provider - method uses VodItem.to_dict() (inherited from Content) on whatever - this returns, since VodItem has no separate to_vod_item() method. + Full details for a single playable item by its b_/c_/d_ id. + Called by `provider.get_vod_item_details()`, which serialises the + result with VodItem.to_dict() (inherited from Content). """ - # PlayableAssetWithToken expects a catalog ID (b_, c_, d_). It returns - # an empty asset for a video ID (a_), which would otherwise surface as - # a misleading "No asset data found" warning below. if content_id.startswith("a_"): logger.debug(f"Skipping PlayableAssetWithToken for video ID {content_id}") return None @@ -1046,14 +1266,10 @@ class JoynVodManager: return self._parse_content_asset(asset) def _resolve_video_id(self, content_id: str) -> Optional[str]: - # Already a video id if content_id.startswith("a_"): - logger.debug("fast-path a_ id") return content_id - # Catalog asset id (movie/episode) -> resolve via PlayableAssetWithToken if content_id.startswith(("b_", "c_", "d_")): - logger.debug("resolving via PlayableAssetWithToken") cache_key = f"video_id:{content_id}" cached = self._cache.get(cache_key) if cached and (time.time() - cached["timestamp"] < self._cache_ttl): @@ -1063,111 +1279,135 @@ class JoynVodManager: video_id = (asset.get("video") or {}).get("id") if not video_id: logger.error( - f"Cannot resolve '{content_id}' to a video ID " + f"Cannot resolve {content_id!r} to a video ID " f"(PlayableAssetWithToken returned no video.id)." ) return None self._cache[cache_key] = {"timestamp": time.time(), "data": video_id} - logger.debug(f"Resolved {content_id} -> {video_id}") return video_id - logger.error(f"Cannot resolve '{content_id}' to a video ID (unknown prefix).") + logger.error(f"Cannot resolve {content_id!r} to a video ID (unknown prefix).") return None - def _get_vod_manifest_and_drm(self, content_id: str, video_config: Optional[Dict] = None, - max_retries: int = DEFAULT_MAX_RETRIES) -> Optional[Dict[str, Any]]: + def _get_vod_manifest_and_drm( + self, + content_id: str, + video_config: Optional[Dict] = None, + max_retries: int = DEFAULT_MAX_RETRIES, + ) -> Optional[Dict[str, Any]]: + """ + Shared workhorse for get_vod_manifest and get_vod_drm. + + Retries ONLY transient failures (ServerError / untyped network errors) + with a one-second sleep. Every other typed ProviderError — rights + (PlaybackRestricted, SubscriptionRequired), AuthError, RateLimitError, + GeoBlockError, NotFoundError — is permanent for this call: it propagates + immediately and unchanged (retrying gains nothing, risks rate limits, and + flattening it into ServerError would hide the reason from callers). + + Entitlement comes from `self._entitlement` (the shared helper); + headers come from `self.config.*`; the session's token comes from + `self.auth.get_access_token()` — none of these touch the provider. + """ video_id = self._resolve_video_id(content_id) if not video_id: - return None + raise NotFoundError(f"Joyn: cannot resolve {content_id!r} to a playable video") - cache_key = f"vod_playlist:{video_id}:{self._video_config_fingerprint(video_config)}" + cache_key = f"vod_playlist:{video_id}:{video_config_fingerprint(video_config)}" cached = self._cache.get(cache_key) if cached and (time.time() - cached["timestamp"] < self._cache_ttl): return cached["data"] + last_exc: Optional[Exception] = None + for attempt in range(max_retries): try: - entitlement_token = self.provider.channel_manager.get_entitlement_token(content_id=video_id, - content_type="VOD") + entitlement_token = self._entitlement.get_entitlement_token( + content_id=video_id, content_type=CONTENT_TYPE_VOD + ) video_payload = create_video_payload(video_config) signature = build_signature(entitlement_token, video_payload) - url = f"https://api.vod-prd.s.joyn.de/v1/asset/{video_id}/playlist?signature={signature}" - headers = { - "Authorization": f"Bearer {entitlement_token}", - "Accept": "application/json", - "Content-Type": "application/json", - "User-Agent": JOYN_USER_AGENT, - } + url = ( + JOYN_STREAMING_ENDPOINTS["ASSET_PLAYLIST"].format(asset_id=video_id) + + f"?signature={signature}" + ) + headers = self.config.api_headers(entitlement_token) - response = self.http_manager.post(url, operation="vod_playlist", headers=headers, data=video_payload, - timeout=DEFAULT_REQUEST_TIMEOUT) + response = self.http_manager.post( + url, + operation="vod_playlist", + headers=headers, + data=video_payload, + timeout=DEFAULT_REQUEST_TIMEOUT, + ) response.raise_for_status() playlist_data = response.json() logger.debug( - f"VOD playlist response for {video_id}: {json.dumps(playlist_data, separators=(',', ':'))}" + f"VOD playlist response for {video_id}: " + f"{json.dumps(playlist_data, separators=(',', ':'))}" ) manifest_url = playlist_data.get("manifestUrl") if not manifest_url: + last_exc = ServerError( + f"VOD playlist for {video_id} had no manifestUrl" + ) logger.warning( - f"VOD attempt {attempt + 1}/{max_retries} for {video_id}: no manifestUrl in response" + f"VOD attempt {attempt + 1}/{max_retries} for {video_id}: " + f"no manifestUrl in response" ) if attempt < max_retries - 1: time.sleep(1) continue - result = { + result: Dict[str, Any] = { "manifest_url": manifest_url, "entitlement_token": entitlement_token, "streaming_format": playlist_data.get("streamingFormat", "dash"), + "drm_configs": [], } license_url = playlist_data.get("licenseUrl") if license_url: - drm_config = DRMConfig( - system=DRMSystem.WIDEVINE, - priority=1, - license=LicenseConfig( - server_url=license_url, - server_certificate=playlist_data.get("certificateUrl"), - req_headers=json.dumps({ - "User-Agent": JOYN_USER_AGENT, - "Content-Type": DRM_REQUEST_HEADERS["Content-Type"], - }), - req_data="{CHA-RAW}", - use_http_get_request=False, - ), - ) - result["drm_configs"] = [drm_config] - else: - result["drm_configs"] = [] + result["drm_configs"] = [build_widevine_config( + self.config, license_url, playlist_data.get("certificateUrl") + )] self._cache[cache_key] = {"timestamp": time.time(), "data": result} return result - except PlaybackRestrictedException as e: - logger.warning(f"VOD playback restricted for {video_id}: {e}") - return None - except SubscriptionRequiredException as e: - has_plus = self.has_plus_subscription() + except PlaybackRestrictedException: + # Permanent, account-level restriction — propagate. + raise + except SubscriptionRequiredException as exc: + # Permanent, account-level; log the diagnostic (does the + # account hold Plus?) and propagate. + try: + has_plus = self.has_plus_subscription() + except Exception: # diagnostics must never mask the real SubscriptionRequired + has_plus = None logger.warning( - f"VOD {video_id} requires a subscription this account doesn't hold " - f"(hasActivePlus={has_plus}), not retrying: {e}" + f"VOD {video_id} requires a subscription this account " + f"doesn't hold (hasActivePlus={has_plus}): {exc}" ) - return None # permanent, per-account failure — retrying gains nothing - except Exception as e: - logger.warning(f"VOD attempt {attempt + 1}/{max_retries} failed: {e}") + raise + except JoynEntitlementError: + # Any other entitlement-shaped failure is also permanent for + # this content; do not waste retries on it. + raise + except Exception as exc: + if isinstance(exc, ProviderError) and not isinstance(exc, ServerError): + raise # typed and not transient: never retry, never flatten + last_exc = exc + logger.warning(f"VOD attempt {attempt + 1}/{max_retries} failed: {exc}") if attempt < max_retries - 1: time.sleep(1) logger.error(f"VOD failed for {video_id} after {max_retries} attempts") - return None - - def clear_cache(self): - self._cache.clear() - self.__dict__.pop(type(self).get_user_state.cache_attr, None) - self._user_state = None - logger.debug("VOD cache cleared") \ No newline at end of file + if last_exc is not None: + raise ServerError(f"VOD failed for {video_id}: {last_exc}") from last_exc + raise ServerError(f"VOD failed for {video_id}: no attempt was made (max_retries={max_retries})") + return None \ No newline at end of file