add template provider

This commit is contained in:
Nirvana
2026-10-03 15:17:03 +02:00
parent 7d08dbec71
commit ecf4cd7e39
3 changed files with 162 additions and 86 deletions
@@ -177,41 +177,54 @@ DRM is optional. Providers with no DRM leave `_build_drm()` returning
`None` and don't override `get_channel_drm` / `get_vod_drm`. The
provider's `get_drm()` returns `[]` and `implements_drm` is `False`.
Providers with DRM pick ONE of two architectures:
Providers with DRM make two independent choices.
**Architecture 1 — dedicated DRM manager (preferred for new providers)**
### Architecture: dedicated manager vs. folded into managers
Create `drm_manager.py` with a class matching
`base.protocols.DrmManagerProtocol`. Wire it in the provider's
`_build_drm()` factory. The provider's `get_drm()` delegates to it.
Rule: Does the DRM step share state with the manifest step?
yes -> fold into the channel/vod managers
no -> use a dedicated DRM manager
When to pick this: DRM is a distinct step with its own data sources
(upfront tokens, licence URL construction, session authorization) that
does not share significant state with the manifest fetch.
Tie-breaker: when both work, prefer the dedicated manager, because it
keeps DRM logic in one place. Folded exists for cases where the
alternative would be plumbing session state, an upfront token, or a
playbackInfo response between two managers that both need it.
See `drm_manager.py` in this directory for four concrete reference
patterns (RTL+ upfront token, Magenta constructed URL, Discovery
playbackInfo, HRTi session id). Pick the closest and adapt.
"State" means any value the DRM step would otherwise have to receive
from the manifest step: a session id, an upfront token, a playbackInfo
response, an account/licence structure, and so on.
**Architecture 2 — folded into channel/vod managers**
### Source pattern: how the licence URL is produced
Override `get_channel_drm()` on your `ChannelManager` and/or
`get_vod_drm()` on your `VodManager`. Leave `_build_drm()` returning
`None`. The provider's `get_drm()` routes through the manager list, and
`implements_drm` is derived from whether either override is present.
Four source patterns appear across the existing providers. The source
pattern is orthogonal to the architecture -- any source can be wired
into either architecture. See `drm_manager.py` in this directory for
file-level references and mechanics:
When to pick this: the DRM call shares state with the manifest fetch
(session ids, playbackInfo responses) and a separate manager would have
to be handed that state anyway. HRTi and Magenta use this shape.
A. Per-content upfront token (RTL+)
B. Constructed from token claims + /user/account (Magenta)
C. Arrives with the playbackInfo response (Discovery)
D. Session id becomes a base64 auth blob (HRTi)
**The three method names**
New-provider guidance: pick the architecture first (state-sharing rule
above), then pick the source pattern that most closely matches how the
provider's licence URL is produced, and adapt the mechanics.
The existing providers currently sit in a mix of shapes -- some have DRM
logic directly on the provider class, some in a playback manager, some
in a VOD manager that predates this template. "Source pattern" describes
what their code does, not what class it lives in. Migrating any provider
onto the dedicated or folded architecture is optional; when migrated,
each will map to whichever architecture the state-sharing rule selects.
### The three method names
Three names appear in the DRM path. They are not interchangeable:
get_drm_configs — the dedicated DrmManager's only method
(matches DrmManagerProtocol)
get_channel_drm — the folded architecture's live-channel entry point
get_vod_drm — the folded architecture's VOD entry point
get_drm_configs -- the dedicated DrmManager's only method
(matches DrmManagerProtocol)
get_channel_drm -- the folded architecture's live-channel entry point
get_vod_drm -- the folded architecture's VOD entry point
New providers using the dedicated-manager architecture implement
`get_drm_configs` and leave the other two alone. Providers using the
@@ -220,20 +233,28 @@ leave `get_drm_configs` alone.
`StreamingProvider.get_drm(content_id, content_type=None)` is the public
method callers use; it dispatches to whichever architecture the provider
chose. `content_type` is a hint — pass it when you already know the
content type (e.g. the backend streaming route has already resolved the
item). Leave it `None` and the DRM source infers the type from its own
`content_id` grammar, which it knows better than the caller.
chose.
**Which architecture to pick — a rule of thumb**
### content_type hint semantics
Does the DRM call share state with the manifest fetch?
yes -> folded (Architecture 2)
no -> dedicated (Architecture 1)
`content_type` is an optional hint. Pass it when you already know the
content type (e.g. the backend streaming route, which has already
resolved the item). It is a *narrowing* hint, not a required argument.
Dedicated is preferred when both work, because it keeps the DRM logic in
one place. Folded is the right call when the alternative would be passing
session or playback state between two managers anyway.
On the folded path, only two values narrow the search:
"live" -> channel manager only
"vod" -> VOD manager only
Any other value -- including None, "event", "catchup", or a typo --
tries both. This is deliberate: widening on unknown input is always
safe, but a wrong narrowing produces a silent `[]` for protected
content, which is the hardest kind of bug to trace.
On the dedicated-manager path, the hint is passed through to
`get_drm_configs`. The manager may honour it, ignore it, or infer the
type from `content_id` grammar when `content_type is None`. Managers are
encouraged to widen when in doubt, for the same reason as above.
## Errors
@@ -12,6 +12,29 @@ DrmManagerProtocol). The shape is shared; the implementations vary enough
that a shared ABC would need more escape hatches than it saves. This file
is a scaffold and a document -- not an abstract class.
Source patterns vs. architecture
--------------------------------
"Source pattern" describes HOW a provider obtains DRM material (an
upfront token, a constructed URL, a playbackInfo response, a session id).
"Architecture" describes WHERE the code lives in the target model (a
dedicated DrmManager or folded into the channel/vod managers). The two
are orthogonal: any source pattern can be wired into either architecture.
A third shape is common in the existing providers: DRM logic in the
provider itself (methods on the provider class, not on any manager).
This is neither of the target architectures. It works, and it is not
required to migrate, but new providers should prefer one of the two
target architectures for testability.
The four source patterns described below are references for the
*mechanics* of obtaining DRM material, not for the architecture. See the
file-level pointers in each pattern for a working example.
Use the README's "DRM" section to pick the architecture first (the rule
is: fold if DRM shares state with the manifest step). Then pick the
source pattern below that most closely matches how your provider's
license URL is produced, and adapt the mechanics.
How to structure a DRM manager for a new provider
-------------------------------------------------
@@ -55,16 +78,12 @@ How to structure a DRM manager for a new provider
The provider's get_drm() delegates to this manager when present.
3. Reference implementations
The four existing patterns differ enough that picking the closest
match and adapting it is faster than designing from scratch. Read
the referenced files before writing yours; the description below
is a summary, the code is the source of truth.
3. Source patterns -- read the referenced files before writing yours
Pattern A -- per-content upfront token
Files: providers/rtlplus/provider.py (DRM flow)
providers/rtlplus/auth.py (upfront token)
providers/lib_drmtoday.py (the shared library)
Files: providers/rtlplus/provider.py
providers/rtlplus/auth.py
providers/lib_drmtoday.py
Summary: fetch the layout for the content_id, extract the DRM
asset config, call auth.get_scoped_token("upfront", content_id,
@@ -74,11 +93,11 @@ How to structure a DRM manager for a new provider
Requires an authenticated user (profile selected). The upfront
token is per-content, not per-session.
Pattern B -- construct licence URL from token claims
Files: providers/magentaeu/vod_manager.py (DRM flow)
providers/magentaeu/provider.py (live DRM flow)
providers/magentaeu/auth.py (token claims)
providers/lib_theplatform.py (URL + config builders)
Pattern B -- constructed licence URL from token claims
Files: providers/magentaeu/vod_manager.py
providers/magentaeu/provider.py
providers/magentaeu/auth.py
providers/lib_theplatform.py
Summary: resolve the media item to get release_pid (via the
/media endpoint), read persona_jwt from the access token claims,
@@ -91,10 +110,8 @@ How to structure a DRM manager for a new provider
sources: /media response, token claims, /user/account.
Pattern C -- DRM arrives with the playback response
Files: providers/discovery/playback_manager.py (playbackInfo +
DRM extraction in one place)
providers/discovery/constants.py (platform_os -> DRM
system mapping)
Files: providers/discovery/playback_manager.py
providers/discovery/constants.py
Summary: during get_manifest, POST playbackInfo and cache the
response. get_drm() is a cache lookup; no separate DRM call.
@@ -106,10 +123,9 @@ How to structure a DRM manager for a new provider
drm.expirationDate governs cache invalidation.
Pattern D -- session id becomes a base64 auth blob
Files: providers/hrti/provider.py (both live and VOD DRM flows)
providers/hrti/auth.py (session authorize + licence
data generation)
providers/lib_drmtoday.py (the shared library)
Files: providers/hrti/provider.py
providers/hrti/auth.py
providers/lib_drmtoday.py
Summary: auth.authorize_session(...) returns a session dict
carrying "DrmId". auth.get_license_data(DrmId) returns a
@@ -124,8 +140,7 @@ How to structure a DRM manager for a new provider
4. What to cache and where
Every existing provider caches DRM material somewhere:
* RTL+ no cache; the upfront token is refetched per playback.
* Magenta the account_info lives on the auth token, not the
DRM manager.
* Magenta the account_info lives on the auth token.
* Discovery the whole playbackInfo response lives in the
playback_cache, keyed by edit_id, TTL from
drm.expirationDate.
@@ -133,8 +148,8 @@ How to structure a DRM manager for a new provider
during the manifest step.
Pick whichever fits. If DRM shares state with manifest, put the cache
on the provider and pass it into both managers, matching the pattern
the other managers already use.
on the provider and pass it into both managers -- that is exactly the
signal that the folded architecture is the right choice.
5. Testing
DRM managers take http_manager and auth by injection, so they are
@@ -34,6 +34,19 @@ class YourProvider(StreamingProvider):
SUPPORTED_AUTH_TYPES: ClassVar[List[str]] = ["user_credentials"]
SUPPORTED_COUNTRIES: ClassVar[List[str]] = ["TODO", "country", "codes"]
# Module-private constants used by get_drm's folded path.
#
# Only these two values narrow the search when content_type is
# provided. Anything else (None, "event", "catchup", or a typo)
# tries both domains. This is deliberate: a wrong narrowing produces
# a silent [] for protected content, which is the hardest kind of
# bug to trace. Widening on unknown input is always safe.
#
# Adding more narrowing values here is a deliberate act and should
# be justified by a caller that reliably knows the content type.
_LIVE_ONLY_CONTENT_TYPES = frozenset({"live"})
_VOD_ONLY_CONTENT_TYPES = frozenset({"vod"})
def __init__(
self,
country: str = "TODO",
@@ -47,7 +60,7 @@ class YourProvider(StreamingProvider):
config = config or {}
self.config = YourConfig(config)
# 1. HTTP manager.
# 1. HTTP manager (existing StreamingProvider helper).
self.http_manager = self._setup_http_manager(
provider_name="TODO: provider_name",
proxy_config=proxy_config,
@@ -68,7 +81,9 @@ class YourProvider(StreamingProvider):
self.epg = self._build_epg()
self.drm = self._build_drm()
# ----- Factory methods -----
# ------------------------------------------------------------------
# Factory methods
# ------------------------------------------------------------------
def _build_auth(self, settings_manager):
return YourProviderAuth(
@@ -103,7 +118,8 @@ class YourProvider(StreamingProvider):
"""
Return a dedicated DRM manager, or None.
Two supported architectures:
Two supported architectures (see the README's "DRM" section for
the state-sharing rule that picks between them):
* Dedicated manager: return a class matching DrmManagerProtocol
here; the provider's get_drm() delegates to it.
@@ -113,13 +129,10 @@ class YourProvider(StreamingProvider):
get_vod_drm() on your VodManager. The provider's get_drm()
falls back to routing to those.
New providers should prefer the dedicated manager (see the README
section "DRM"). The folded style exists for providers whose DRM
call shares significant state with the manifest step.
See providers/_template/drm_manager.py for the four existing
patterns (RTL+ upfront token, Magenta constructed URL, Discovery
playbackInfo, HRTi session id).
New providers should prefer the dedicated manager unless the DRM
step shares state with the manifest step. See
providers/_template/drm_manager.py for the four existing source
patterns.
"""
# TODO: return YourDrmManager(
# http_manager=self.http_manager,
@@ -130,7 +143,9 @@ class YourProvider(StreamingProvider):
# )
return None
# ----- Capability flags (derived from manager presence) -----
# ------------------------------------------------------------------
# Capability flags (derived from manager presence)
# ------------------------------------------------------------------
@property
def implements_channels(self) -> bool:
@@ -173,7 +188,9 @@ class YourProvider(StreamingProvider):
)
return folded_channels or folded_vod
# ----- Router -----
# ------------------------------------------------------------------
# Router
# ------------------------------------------------------------------
def _route(self, content_id: str, attempts: List[Tuple[Any, Callable]]):
"""
@@ -188,6 +205,11 @@ class YourProvider(StreamingProvider):
If nobody resolved and a NotFoundError was seen, re-raise it --
that's "the content existed in some manager's domain but is gone",
distinct from "nobody handles this id at all" (which returns None).
BadRequestError and other errors are NOT caught here. Providers
that need endpoint-fallback behavior (Magenta's page-vs-component
dispatch) should override handles_content_id() instead, so the
router never has to guess.
"""
last_not_found: Optional[NotFoundError] = None
for manager, call in attempts:
@@ -204,7 +226,9 @@ class YourProvider(StreamingProvider):
raise last_not_found
return None
# ----- Public delegations -----
# ------------------------------------------------------------------
# Public delegations
# ------------------------------------------------------------------
def get_channels(self, **kw):
if self.channels is None:
@@ -212,6 +236,13 @@ class YourProvider(StreamingProvider):
return self.channels.get_channels(**kw)
def get_manifest(self, content_id: str, **kw) -> Optional[str]:
"""
Return the manifest URL for the given content, routing by manager.
Providers with events, catchup, or other content types extend
this method to add their branches. Each branch is a
(manager, call) tuple in the attempts list.
"""
return self._route(content_id, [
(self.channels, lambda m: m.get_channel_manifest(content_id, **kw)),
(self.vod, lambda m: m.get_vod_manifest(content_id, **kw)),
@@ -226,16 +257,16 @@ class YourProvider(StreamingProvider):
"""
Return DRM configuration(s) for the given content.
content_type is an optional hint. When None (the default), the
DRM source infers the type from its own content_id grammar --
which is the preferred mode, since the source knows its own
grammar better than the caller does. Callers that already know
the type (e.g. the backend's streaming route) should pass it
explicitly.
content_type is an optional hint. Only "live" and "vod" narrow
the search on the folded path; any other value (including None,
"event", "catchup", or an unrecognized string) tries both
domains. This is deliberate: a wrong narrowing produces a silent
[] for protected content, which is the hardest kind of bug to
trace. Widening on unknown input is always safe.
If a dedicated DRM manager is configured, delegate to it. Otherwise
fall back to per-manager DRM (channel manager's get_channel_drm,
VOD manager's get_vod_drm) via the router.
If a dedicated DRM manager is configured, the hint is passed
through unchanged and the manager decides what to do with it.
Otherwise the folded path narrows the search as described above.
"""
if self.drm is not None:
return self.drm.get_drm_configs(
@@ -243,7 +274,16 @@ class YourProvider(StreamingProvider):
content_type=content_type,
**kw,
)
return self._route(content_id, [
(self.channels, lambda m: m.get_channel_drm(content_id, **kw)),
(self.vod, lambda m: m.get_vod_drm(content_id, **kw)),
]) or []
# Folded path: invert the check so only recognized values narrow.
# Unknown values (including typos) fall through to "try both".
attempts: List[Tuple[Any, Callable]] = []
if content_type not in self._VOD_ONLY_CONTENT_TYPES:
attempts.append(
(self.channels, lambda m: m.get_channel_drm(content_id, **kw))
)
if content_type not in self._LIVE_ONLY_CONTENT_TYPES:
attempts.append(
(self.vod, lambda m: m.get_vod_drm(content_id, **kw))
)
return self._route(content_id, attempts) or []