migrate joyn and new template

This commit is contained in:
Nirvana
2026-10-11 08:13:47 +02:00
parent 3147ae85ad
commit e9769a4a2a
39 changed files with 5253 additions and 1744 deletions
@@ -0,0 +1,301 @@
# streaming_providers/base/testing/migration_pack.py
"""
Migration pack -- everything a migrator needs to know about a v1 provider
BEFORE touching it, collected automatically so nobody has to guess or ask:
python -m streaming_providers.base.testing.migration_pack <key> \
--repo <path to the repo root> [--extra-root <other repo> ...] \
[-o pack.txt] [--json before.json] [--compare before.json]
Sections of the report
1. REGISTRY how the class is registered and how the registry builds it:
__init__ signature, SUPPORTED_COUNTRIES, instances the
metadata mixin reports, and the SOURCE of the registry
methods that construct providers (create_instance, ...)
2. SURFACE every attribute the provider classes define (below the
base), with kind and signature. Snapshot it before the
migration (--json) and compare after (--compare): a
migration PRESERVES the public surface, it never adds to it.
3. LEGACY HELPERS source of every `self.<name>` the provider uses that is
defined in a legacy mixin / the base class (e.g.
_build_provider_headers, _setup_http_manager): you read
them here instead of asking for the files.
4. BACK-REFERENCES managers that reach into the provider (`self._provider.x`,
`provider.x`): each one needs an explicit collaborator in v2.
5. EXTERNAL USERS where the class name, the provider key and the commonly
moved attributes are referenced OUTSIDE the provider
directory (and in --extra-root repos: Kodi addon, PVR addon,
backend ...). Run it with ALL repos you have.
Read-only: it imports the package and reads files; it never instantiates the
provider and performs no network calls.
"""
import argparse
import ast
import inspect
import json
import os
import re
import sys
from typing import Dict, Iterable, List, Optional
MOVED_ATTRS = (
"_last_auth_error", "_profile", "bearer_token", "entitlement_tag",
"channel_manager", "_playout_cache", "_ensure_authenticated",
"set_user_credentials", "get_auth_details", "get_last_auth_error",
"authenticator", "provider_config", "http_manager", "get_profile",
)
TEXT_EXT = (".py", ".json", ".xml", ".yaml", ".yml", ".cpp", ".h", ".hpp",
".md", ".txt", ".po", ".ini", ".cfg", ".js", ".ts")
SKIP_DIRS = {".git", "__pycache__", "node_modules", "build", "dist", ".venv", "venv", ".idea"}
MAX_HITS = 150
# --------------------------------------------------------------------------
# 1. registry
# --------------------------------------------------------------------------
def registry_facts(cls, key: str) -> str:
out = [f"class {cls.__module__}.{cls.__qualname__}",
f"registry key {key!r}",
f"__init__ {_sig(cls.__init__)}"]
countries = getattr(cls, "SUPPORTED_COUNTRIES", None)
out.append(f"SUPPORTED_COUNTRIES = {countries!r}")
for name in ("supports_multiple_countries", "get_plugin_key", "get_all_possible_instances"):
fn = getattr(cls, name, None)
if callable(fn):
try:
out.append(f"{name}() -> {fn()!r}")
except Exception as exc: # noqa: BLE001
out.append(f"{name}() raised {type(exc).__name__}: {exc}")
try:
import importlib
reg = importlib.import_module(cls.__module__.split(".providers.")[0] + ".base.provider_registry")
except Exception as exc: # noqa: BLE001
out.append(f"\n(provider_registry not importable: {exc})")
return "\n".join(out)
for owner in ("ProviderMetadata", "ProviderRegistry"):
klass = getattr(reg, owner, None)
for meth in ("create_instance", "_extract_metadata", "discover_all_providers", "get_provider"):
fn = getattr(klass, meth, None) if klass else None
if fn is not None:
out.append(f"\n--- {owner}.{meth} ---\n{_source(fn)}")
return "\n".join(out)
# --------------------------------------------------------------------------
# 2. surface
# --------------------------------------------------------------------------
def _is_base(klass) -> bool:
mod = getattr(klass, "__module__", "") or ""
return klass is object or ".base." in mod + "." and (
".base.provider" in mod or ".base.managed_provider" in mod
or ".provider_mixins." in mod or ".base.models" in mod or ".base.managers" in mod)
def surface(cls) -> Dict[str, dict]:
snap: Dict[str, dict] = {}
for klass in cls.__mro__:
if _is_base(klass) or klass is object:
continue
for name, value in vars(klass).items():
if name.startswith("__") or name in snap:
continue
if isinstance(value, property):
kind, sig = "property", ""
elif isinstance(value, classmethod):
kind, sig = "classmethod", _sig(value.__func__)
elif isinstance(value, staticmethod):
kind, sig = "staticmethod", _sig(value.__func__)
elif callable(value):
kind, sig = "method", _sig(value)
else:
kind, sig = "attribute", ""
snap[name] = {"kind": kind, "sig": sig, "defined_in": klass.__qualname__}
return snap
def diff_surfaces(before: Dict[str, dict], after: Dict[str, dict]) -> str:
lines = []
for name in sorted(set(before) - set(after)):
lines.append(f"REMOVED {name} ({before[name]['kind']} {before[name]['sig']})")
for name in sorted(set(after) - set(before)):
lines.append(f"ADDED {name} ({after[name]['kind']} {after[name]['sig']})")
for name in sorted(set(before) & set(after)):
if before[name]["kind"] != after[name]["kind"] or before[name]["sig"] != after[name]["sig"]:
lines.append(f"CHANGED {name}: {before[name]['kind']} {before[name]['sig']}"
f" -> {after[name]['kind']} {after[name]['sig']}")
return "\n".join(lines) or "(no differences)"
# --------------------------------------------------------------------------
# 3 + 4. legacy helpers and back-references
# --------------------------------------------------------------------------
def _self_names(tree: ast.AST, owner: str = "self") -> List[str]:
names = []
for node in ast.walk(tree):
if isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name) and node.value.id == owner:
names.append(node.attr)
return names
def _package_files(cls) -> List[str]:
module = sys.modules.get(cls.__module__)
path = getattr(module, "__file__", None)
if not path:
return []
folder = os.path.dirname(path)
return sorted(os.path.join(folder, f) for f in os.listdir(folder) if f.endswith(".py"))
def legacy_helpers(cls) -> str:
own_file = inspect.getsourcefile(cls)
names = []
with open(own_file, encoding="utf-8") as fh:
names = _self_names(ast.parse(fh.read()))
seen, out = set(), []
for name in names:
if name in seen:
continue
seen.add(name)
for klass in cls.__mro__:
if name in vars(klass):
if klass is cls or not _is_base(klass) or klass.__module__.endswith("managed_provider"):
break
member = vars(klass)[name]
member = getattr(member, "fget", None) or getattr(member, "__func__", member)
if callable(member):
out.append(f"--- {klass.__qualname__}.{name} ({klass.__module__}) ---\n{_source(member)}")
break
return "\n\n".join(out) or "(the provider uses no legacy helper)"
def back_references(cls) -> str:
own = inspect.getsourcefile(cls)
hits = []
for path in _package_files(cls):
if os.path.abspath(path) == os.path.abspath(own):
continue
with open(path, encoding="utf-8") as fh:
tree = ast.parse(fh.read())
for owner in ("_provider", "provider"):
attrs = sorted(set(_self_names(tree, owner)))
# attribute access on `self._provider.<name>`
for node in ast.walk(tree):
if (isinstance(node, ast.Attribute) and isinstance(node.value, ast.Attribute)
and isinstance(node.value.value, ast.Name) and node.value.value.id == "self"
and node.value.attr in ("_provider", "provider")):
hits.append(f"{os.path.basename(path)}:{node.lineno} self.{node.value.attr}.{node.attr}")
return "\n".join(sorted(set(hits))) or "(no manager reaches into the provider)"
# --------------------------------------------------------------------------
# 5. external users
# --------------------------------------------------------------------------
def external_references(roots: Iterable[str], key: str, class_name: str, exclude_dir: Optional[str]) -> str:
patterns = {
f"class name {class_name}": re.compile(rf"\b{re.escape(class_name)}\b"),
f"provider key '{key}'": re.compile(rf"""["']{re.escape(key)}(?:_[a-z]{{2,3}})?["']"""),
"moved/compat attributes": re.compile(r"\.(" + "|".join(map(re.escape, MOVED_ATTRS)) + r")\b"),
}
results = {label: [] for label in patterns}
exclude = os.path.abspath(exclude_dir) if exclude_dir else None
for root in roots:
for folder, dirs, files in os.walk(root):
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
if exclude and os.path.abspath(folder).startswith(exclude):
continue
for name in files:
if not name.endswith(TEXT_EXT):
continue
path = os.path.join(folder, name)
try:
with open(path, encoding="utf-8", errors="ignore") as fh:
for number, line in enumerate(fh, 1):
for label, rx in patterns.items():
if rx.search(line) and len(results[label]) < MAX_HITS:
results[label].append(f"{path}:{number}: {line.strip()[:140]}")
except OSError:
continue
parts = []
for label, hits in results.items():
body = "\n".join(hits) if hits else "(none)"
parts.append(f"## {label}\n{body}")
return "\n\n".join(parts)
# --------------------------------------------------------------------------
# helpers / CLI
# --------------------------------------------------------------------------
def _sig(fn) -> str:
try:
return str(inspect.signature(fn))
except (TypeError, ValueError):
return "(?)"
def _source(fn) -> str:
try:
return inspect.getsource(fn)
except (OSError, TypeError):
return f"(source not available for {fn!r})"
def build_report(key: str, roots: List[str], extra: List[str]) -> str:
import streaming_providers
registry = getattr(streaming_providers, "AVAILABLE_PROVIDERS", {})
if key not in registry:
return f"unknown provider key {key!r}; registered: {sorted(registry)}"
cls = registry[key]
provider_dir = os.path.dirname(inspect.getsourcefile(cls))
sections = [
("1. REGISTRY", registry_facts(cls, key)),
("2. SURFACE (snapshot with --json before, --compare after)",
"\n".join(f"{n:34} {v['kind']:12} {v['sig']} [{v['defined_in']}]"
for n, v in sorted(surface(cls).items()))),
("3. LEGACY HELPERS used by the provider (source)", legacy_helpers(cls)),
("4. BACK-REFERENCES from managers to the provider", back_references(cls)),
("5. EXTERNAL USERS (outside the provider directory)",
external_references(roots + extra, key, cls.__name__, provider_dir)),
]
return "\n\n".join(f"{'=' * 78}\n{title}\n{'=' * 78}\n{body}" for title, body in sections)
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("key")
ap.add_argument("--repo", action="append", default=[], help="repo root to search (repeatable)")
ap.add_argument("--extra-root", action="append", default=[], help="additional repos (Kodi addon, PVR addon, ...)")
ap.add_argument("-o", "--output")
ap.add_argument("--json", help="write the surface snapshot to this file")
ap.add_argument("--compare", help="compare the current surface with a snapshot file")
args = ap.parse_args(argv)
import streaming_providers
cls = getattr(streaming_providers, "AVAILABLE_PROVIDERS", {}).get(args.key)
if args.json or args.compare:
if cls is None:
print(f"unknown provider key {args.key!r}")
return 2
current = surface(cls)
if args.json:
with open(args.json, "w", encoding="utf-8") as fh:
json.dump(current, fh, indent=2, sort_keys=True)
print(f"surface snapshot written to {args.json} ({len(current)} names)")
if args.compare:
with open(args.compare, encoding="utf-8") as fh:
print(diff_surfaces(json.load(fh), current))
return 0
report = build_report(args.key, args.repo, args.extra_root)
if args.output:
with open(args.output, "w", encoding="utf-8") as fh:
fh.write(report)
print(f"written to {args.output}")
else:
print(report)
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,158 @@
# streaming_providers/base/testing/provider_contract.py
"""
Provider contract -- checks every v2 (ManagedProvider) provider must pass.
from streaming_providers.base.testing.provider_contract import check_provider
problems, warnings = check_provider(ExampleProvider, "example")
assert problems == []
It instantiates the provider with a NETWORK-DISABLED http manager (every call
raises ConnectionError), so it also proves that the constructor performs no
network I/O. Nothing here talks to a real backend.
Errors (must be fixed) vs warnings (advisory):
errors
* class is a ManagedProvider; explicit HEADERS_FROM_MANAGERS in the class
body (the decision must be visible, not inherited)
* instantiation works offline with country = first SUPPORTED_COUNTRIES
(plus `ctor_kwargs` for providers whose constructor requires more)
* provider_name == registry key, get_plugin_key() == registry key
* capability flags agree with the managers that exist
* DRM declared: a manager overrides get_*_drm => DRM_IN_MANAGERS or
_build_drm(); DRM_IN_MANAGERS=True with no manager implementing it is
a mistake; both at once is refused by ManagedProvider itself
* header hooks overridden on a manager => HEADERS_FROM_MANAGERS must be
True (otherwise the hooks are dead code)
* EPG: no manager => epg_window == (0, 0); manager => implements_epg agrees
with epg_window != (0, 0)
* recordings: schedule_recording overridden => supports_scheduling True
* the provider package exports only the provider in __all__ and does not
expose ManagedProvider in its namespace (discovery picks the first
StreamingProvider subclass it finds)
warnings
* country not normalised to lowercase after construction
* empty SUPPORTED_COUNTRIES, empty PROVIDER_LABEL
"""
import sys
from contextlib import contextmanager
from typing import List, Optional, Tuple
from unittest import mock
from ..managed_provider import ManagedProvider
from ..managers import CatchupManager, ChannelManager, VodManager
class NullHttp:
"""HTTP manager that fails every call: proves 'no network in __init__'."""
def __getattr__(self, name):
def _blocked(*args, **kwargs):
raise ConnectionError(f"network disabled in contract test ({name})")
return _blocked
@contextmanager
def _offline(cls):
with mock.patch.object(cls, "_setup_http_manager", lambda self, **kw: NullHttp(), create=True):
yield
def _overrides(obj, abc, name) -> bool:
return obj is not None and getattr(type(obj), name) is not getattr(abc, name)
def check_provider(cls, key: str, ctor_kwargs: Optional[dict] = None) -> Tuple[List[str], List[str]]:
errors: List[str] = []
warnings: List[str] = []
if not (isinstance(cls, type) and issubclass(cls, ManagedProvider)):
return [f"{cls!r} is not a ManagedProvider subclass"], warnings
declared = [
k for k in cls.__mro__
if k is not ManagedProvider and issubclass(k, ManagedProvider)
and "HEADERS_FROM_MANAGERS" in vars(k)
]
if not declared:
errors.append("HEADERS_FROM_MANAGERS is not declared in the class body "
"(decide: True = manager header hooks, False = legacy {})")
countries = list(getattr(cls, "SUPPORTED_COUNTRIES", []) or [])
if not countries:
warnings.append("SUPPORTED_COUNTRIES is empty (single-country default)")
if not getattr(cls, "PROVIDER_LABEL", ""):
warnings.append("PROVIDER_LABEL is empty")
try:
with _offline(cls):
# ctor_kwargs: constructor arguments a provider REQUIRES beyond
# `country` (e.g. Joyn's config object); they override the default.
kwargs = {"country": countries[0] if countries else "DE", **(ctor_kwargs or {})}
inst = cls(**kwargs)
except Exception as exc: # noqa: BLE001 - report, don't crash the suite
return errors + [f"cannot instantiate offline: {type(exc).__name__}: {exc}"], warnings
if inst.provider_name != key:
errors.append(f"provider_name {inst.provider_name!r} != registry key {key!r}")
try:
if cls.get_plugin_key() != key:
errors.append(f"get_plugin_key() {cls.get_plugin_key()!r} != registry key {key!r}")
except Exception as exc: # noqa: BLE001
errors.append(f"get_plugin_key() failed: {exc}")
if inst.country != inst.country.lower():
warnings.append(f"country {inst.country!r} is not lower-case after construction")
# -- flags vs managers
caps = inst.capabilities
for name in ("channels", "vod", "recordings", "favorites", "bookmarks"):
if caps[name] != (getattr(inst, name) is not None):
errors.append(f"implements_{name} disagrees with the {name} manager")
# -- DRM declaration
folded = _overrides(inst.channels, ChannelManager, "get_channel_drm") or \
_overrides(inst.vod, VodManager, "get_vod_drm")
if folded and not (cls.DRM_IN_MANAGERS or inst.drm is not None):
errors.append("a manager implements get_*_drm but DRM_IN_MANAGERS is False "
"and no _build_drm(): the DRM is never served")
if cls.DRM_IN_MANAGERS and not folded:
errors.append("DRM_IN_MANAGERS = True but no manager overrides get_channel_drm/get_vod_drm")
# -- dead header hooks
hooks = (
_overrides(inst.channels, ChannelManager, "get_channel_manifest_headers"),
_overrides(inst.channels, ChannelManager, "get_segment_headers"),
_overrides(inst.vod, VodManager, "get_vod_manifest_headers"),
_overrides(inst.vod, VodManager, "get_segment_headers"),
_overrides(inst.catchup, CatchupManager, "get_catchup_manifest_headers"),
_overrides(inst.catchup, CatchupManager, "get_catchup_segment_headers"),
)
if any(hooks) and not cls.HEADERS_FROM_MANAGERS:
errors.append("a manager overrides header hooks but HEADERS_FROM_MANAGERS is False: "
"the hooks are dead code")
# -- EPG
if inst.epg is None:
if inst.epg_window != (0, 0) or inst.implements_epg:
errors.append("no EPG manager but epg_window != (0, 0) / implements_epg")
elif inst.implements_epg != (inst.epg_window != (0, 0)):
errors.append("implements_epg disagrees with epg_window != (0, 0)")
# -- recordings
if inst.recordings is not None:
from ..managers import RecordingsManager
if _overrides(inst.recordings, RecordingsManager, "schedule_recording") \
and not inst.recordings.supports_scheduling:
errors.append("schedule_recording is overridden but supports_scheduling is False")
# -- package namespace (discovery safety)
package = sys.modules.get(cls.__module__.rsplit(".", 1)[0])
if package is not None:
exported = getattr(package, "__all__", None)
if exported is None or cls.__name__ not in exported:
errors.append("package __init__ must define __all__ containing the provider class")
if hasattr(package, "ManagedProvider"):
errors.append("package namespace exposes ManagedProvider (discovery could pick it)")
return errors, warnings