diff --git a/lib/streaming_providers/providers/_template/README.md b/lib/streaming_providers/providers/_template/README.md index a508c56..5f1b0b0 100644 --- a/lib/streaming_providers/providers/_template/README.md +++ b/lib/streaming_providers/providers/_template/README.md @@ -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 diff --git a/lib/streaming_providers/providers/_template/drm_manager.py b/lib/streaming_providers/providers/_template/drm_manager.py index 186a318..10de1b7 100644 --- a/lib/streaming_providers/providers/_template/drm_manager.py +++ b/lib/streaming_providers/providers/_template/drm_manager.py @@ -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 diff --git a/lib/streaming_providers/providers/_template/provider.py b/lib/streaming_providers/providers/_template/provider.py index 8a8d789..eb38474 100644 --- a/lib/streaming_providers/providers/_template/provider.py +++ b/lib/streaming_providers/providers/_template/provider.py @@ -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 [] \ No newline at end of file + + # 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 [] \ No newline at end of file