From e0ee6b9470e1ef70e3b02980e93a3f324a2c6a66 Mon Sep 17 00:00:00 2001 From: Nirvana Date: Sat, 3 Oct 2026 20:39:40 +0200 Subject: [PATCH] update template --- .../base/managers/__init__.py | 46 +- .../base/managers/bookmarks.py | 110 +++++ .../base/managers/catchup.py | 185 ++++++++ .../base/managers/favorites.py | 127 +++++ .../base/managers/recordings.py | 170 +++++++ .../providers/_template/README.md | 438 ++++++++++++++++-- .../providers/_template/bookmarks_manager.py | 89 ++++ .../providers/_template/catchup_manager.py | 115 +++++ .../providers/_template/recordings_manager.py | 115 +++++ .../providers/magenta2/recordings_manager.py | 2 +- 10 files changed, 1355 insertions(+), 42 deletions(-) create mode 100644 lib/streaming_providers/base/managers/bookmarks.py create mode 100644 lib/streaming_providers/base/managers/catchup.py create mode 100644 lib/streaming_providers/base/managers/favorites.py create mode 100644 lib/streaming_providers/base/managers/recordings.py create mode 100644 lib/streaming_providers/providers/_template/bookmarks_manager.py create mode 100644 lib/streaming_providers/providers/_template/catchup_manager.py create mode 100644 lib/streaming_providers/providers/_template/recordings_manager.py diff --git a/lib/streaming_providers/base/managers/__init__.py b/lib/streaming_providers/base/managers/__init__.py index 6d9277c..7531c79 100644 --- a/lib/streaming_providers/base/managers/__init__.py +++ b/lib/streaming_providers/base/managers/__init__.py @@ -2,9 +2,9 @@ """ Manager ABCs for streaming providers. -Each manager wraps one capability area (channels, VOD, EPG) with a fixed -public interface. Providers subclass and implement the abstract methods; -the concrete methods (headers, DRM defaults, search no-ops) come for free. +Each manager wraps one capability area with a fixed public interface. +Providers subclass and implement the abstract methods; the concrete +methods (headers, DRM defaults, search no-ops) come for free. Design rules ------------ @@ -17,21 +17,49 @@ Design rules On the None-vs-exception rule ----------------------------- -Manager top-level methods (get_*_manifest, get_*_drm) signal "this manager -doesn't handle that content_id" by returning None / [] -- NOT by raising -NotFoundError. See providers/_template/README.md for the full rule. +Manager top-level methods signal "this manager doesn't handle that +content_id" by returning None / [] -- NOT by raising NotFoundError. +See providers/_template/README.md for the full rule. Rigidity note ------------- The ABCs enforce the *method names and signatures* through the abstract method mechanism. They do NOT enforce that providers raise the right error classes, or that they return the right content shapes. Those are -conventions documented in the template. Treat the ABCs as "the interface -is fixed" not as "everything about a manager is enforced." +conventions documented in the template. + +Manager list +------------ +Three required capabilities: + ChannelManager + VodManager (providers without a browseable catalogue return None) + EpgManager (providers without EPG return None) + +Four optional capabilities -- providers implement the ones they support: + RecordingsManager (cloud / network PVR) + FavoritesManager (user bookmarks on programs / channels) + BookmarksManager (resume position) + CatchupManager (timeshift / restart) + +Providers signal a capability's presence by whether _build_*() returns +a manager or None. Capability flags (implements_vod, implements_epg, +implements_recordings, ...) are derived from that. """ from .channel import ChannelManager from .vod import VodManager from .epg import EpgManager +from .recordings import RecordingsManager +from .favorites import FavoritesManager +from .bookmarks import BookmarksManager +from .catchup import CatchupManager -__all__ = ["ChannelManager", "VodManager", "EpgManager"] \ No newline at end of file +__all__ = [ + "ChannelManager", + "VodManager", + "EpgManager", + "RecordingsManager", + "FavoritesManager", + "BookmarksManager", + "CatchupManager", +] \ No newline at end of file diff --git a/lib/streaming_providers/base/managers/bookmarks.py b/lib/streaming_providers/base/managers/bookmarks.py new file mode 100644 index 0000000..3417392 --- /dev/null +++ b/lib/streaming_providers/base/managers/bookmarks.py @@ -0,0 +1,110 @@ +# streaming_providers/base/managers/bookmarks.py +""" +BookmarksManager ABC. + +Public interface +---------------- + get_bookmarks(**kw) -> List[Bookmark] [abstract] + update_bookmark(content_id, position_seconds, ...) -> Bookmark [abstract] + delete_bookmark(content_id, **kw) -> None [abstract] + +Constructor contract +-------------------- +Four required keyword-only collaborators. + +Return-value conventions +------------------------ +get_bookmarks returns [] when the user has no bookmarks. Not an error. + +update_bookmark raises RuntimeError if the provider rejects the write +(e.g. content inaccessible, backend error). It is called on every +playback stop / pause, so providers should tolerate a write that +overwrites the same position with a no-op rather than failing. + +delete_bookmark raises KeyError if no bookmark exists for content_id, +so callers can distinguish "already gone" from "successfully deleted". +The base ProviderBookmarksMixin documents the same rule. + +Caller guidance +--------------- +`position_seconds = -1` marks the content as completed. A position +that reaches the model's COMPLETION_THRESHOLD (>= 95% by default) is +also treated as completed by the caller -- providers just store what +they are given. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any, List, Optional + +from ..models.bookmark import Bookmark, ContentType +from ..protocols import AuthProtocol +from ..utils.logger import logger + + +class BookmarksManager(ABC): + """Abstract base for provider bookmarks managers.""" + + def __init__( + self, + *, + http_manager: Any, + auth: AuthProtocol, + country: str, + config: Any, + ) -> None: + if not isinstance(auth, AuthProtocol): + logger.warning( + f"{self.__class__.__name__}: auth does not match AuthProtocol " + f"(missing one of get_access_token / build_headers / " + f"invalidate). Got {type(auth).__name__}." + ) + self.http_manager = http_manager + self.auth = auth + self.country = country + self.config = config + + # ------------------------------------------------------------------ + # Abstract + # ------------------------------------------------------------------ + + @abstractmethod + def get_bookmarks(self, **kw: Any) -> List[Bookmark]: + """ + Return all bookmarks for the authenticated user. + + Return [] when the user has no bookmarks. + """ + raise NotImplementedError + + @abstractmethod + 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: + """ + Save or update a bookmark. + + Called on playback stop / pause, so should be tolerant of + repeated writes to the same position (a no-op write is fine). + + Raises RuntimeError if the provider rejects the write. + """ + raise NotImplementedError + + @abstractmethod + def delete_bookmark(self, content_id: str, **kw: Any) -> None: + """ + Delete a bookmark. + + Raises: + KeyError: if no bookmark exists for content_id. + RuntimeError: on backend failure. + """ + raise NotImplementedError \ No newline at end of file diff --git a/lib/streaming_providers/base/managers/catchup.py b/lib/streaming_providers/base/managers/catchup.py new file mode 100644 index 0000000..ac58635 --- /dev/null +++ b/lib/streaming_providers/base/managers/catchup.py @@ -0,0 +1,185 @@ +# streaming_providers/base/managers/catchup.py +""" +CatchupManager ABC. + +Public interface +---------------- + catchup_window_hours -> int [concrete] + supports_catchup -> bool [concrete] + get_catchup_manifest(content_id, start_time, end_time=None, ...) + -> Optional[str] [abstract] + get_catchup_manifest_headers(...) -> Dict[str,str] [concrete] + get_catchup_drm(...) -> List[DRMConfig] [concrete] + +Constructor contract +-------------------- +Four required keyword-only collaborators. Catchup usually needs +additional collaborators at construction -- the channel manager (for +live manifest lookup) and/or the EPG manager (for EPG-based manifest +resolution, as MoveTV does). Those are explicit keyword-only extras +in the subclass. + +Return-value conventions +------------------------ +get_catchup_manifest returns None when the provider cannot resolve a +catchup manifest for the given content and window. It does NOT raise +NotFoundError -- "no catchup for this content" is a valid result, and +callers fall back to the live manifest. + +get_catchup_drm returns [] when catchup shares DRM with live (the +common case), or a provider-specific list when catchup uses different +DRM. + +Return the manifest of the live stream +-------------------------------------- +The catchup manifest is a *modified* live manifest URL (with time +parameters) for providers like Magenta and MoveTV, or a distinct URL +for providers whose catchup is served from a different origin. This +ABC does not constrain the shape; it just names the entry point. + +Do NOT silently fall back to the live manifest URL from within +get_catchup_manifest. Callers that want the live manifest on failure +should call provider.get_manifest() themselves. Silently returning a +live URL as a "catchup" URL would cause the DRM pipeline to extract +PSSH from the live stream, which may differ from the catchup stream's +encryption context. + +end_time is optional +-------------------- +The ABC accepts end_time as Optional[int] because some providers' +catchup APIs only take a start timestamp (simpliTV, for example -- +it appends a single "start" parameter to the manifest URL and does +not consume an end bound). Providers whose API does use both bounds +should still declare and use end_time; providers whose API does not +should accept it for signature compatibility and document that it is +ignored. + +Do NOT pass a sentinel value (0, or start_time, or start_time + 1800) +when the ABC accepts None. Pass None. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any, Dict, List, Optional + +from ..models import DRMConfig +from ..protocols import AuthProtocol +from ..utils.logger import logger + + +class CatchupManager(ABC): + """Abstract base for provider catchup managers.""" + + def __init__( + self, + *, + http_manager: Any, + auth: AuthProtocol, + country: str, + config: Any, + ) -> None: + if not isinstance(auth, AuthProtocol): + logger.warning( + f"{self.__class__.__name__}: auth does not match AuthProtocol " + f"(missing one of get_access_token / build_headers / " + f"invalidate). Got {type(auth).__name__}." + ) + self.http_manager = http_manager + self.auth = auth + self.country = country + self.config = config + + # ------------------------------------------------------------------ + # Capability + # ------------------------------------------------------------------ + + @property + def catchup_window_hours(self) -> int: + """ + Return the catchup window in hours. + + Default 0 means no catchup. Providers override. + """ + return 0 + + @property + def supports_catchup(self) -> bool: + """True when catchup_window_hours > 0.""" + return self.catchup_window_hours > 0 + + # ------------------------------------------------------------------ + # Abstract + # ------------------------------------------------------------------ + + @abstractmethod + 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]: + """ + Return the catchup manifest URL for the given content and window. + + Args: + content_id: Channel identifier. + start_time: Window start as Unix timestamp (seconds). + end_time: Window end as Unix timestamp (seconds), or None + when the provider's API does not use an end bound + (or when the caller does not know it). Providers + that need both bounds should require the caller + to pass end_time and raise BadRequestError on + None; providers that don't should accept None + and ignore it. + epg_id: Optional EPG event id, for providers that need it. + + Return None when the provider cannot resolve catchup for the + content or window. Do not raise NotFoundError -- "no catchup" is + a valid result. + + Do NOT fall back to the live manifest URL here. See the module + docstring. + """ + raise NotImplementedError + + # ------------------------------------------------------------------ + # Concrete + # ------------------------------------------------------------------ + + def get_catchup_manifest_headers( + self, + content_id: str, + start_time: int, + end_time: Optional[int] = None, + epg_id: Optional[str] = None, + **kw: Any, + ) -> Dict[str, str]: + """ + Headers for the catchup manifest request. + + Default: the auth headers, which is correct for most providers. + Override when catchup requires additional or different headers. + """ + return self.auth.build_headers() + + def get_catchup_drm( + self, + content_id: str, + start_time: int, + end_time: Optional[int] = None, + epg_id: Optional[str] = None, + **kw: Any, + ) -> List[DRMConfig]: + """ + DRM for catchup content. + + Default: []. Most providers' catchup shares DRM with live (the + caller falls back to the channel manager's DRM), or has no DRM. + + Providers whose catchup uses a distinct DRM configuration + (different license URL, different PSSH) override this. + """ + return [] \ No newline at end of file diff --git a/lib/streaming_providers/base/managers/favorites.py b/lib/streaming_providers/base/managers/favorites.py new file mode 100644 index 0000000..9cf5e66 --- /dev/null +++ b/lib/streaming_providers/base/managers/favorites.py @@ -0,0 +1,127 @@ +# streaming_providers/base/managers/favorites.py +""" +FavoritesManager ABC. + +Public interface +---------------- + get_favorites(**kw) -> List[Favorite] [abstract] + add_favorite(content_id, **kw) -> Favorite [abstract] + remove_favorite(content_id, **kw) -> None [abstract] + is_favorite(content_id, favorites=None) -> bool [concrete] + +Constructor contract +-------------------- +Four required keyword-only collaborators. + +Return-value conventions +------------------------ +get_favorites returns [] when the user has no favorites or the provider +does not support favorites. Not an error. + +add_favorite returns the created Favorite. Raises RuntimeError on +rejection (e.g. provider's backend refuses the operation). + +remove_favorite raises KeyError if the content_id is not currently +favorited, and RuntimeError on backend failure. Deleting a +non-existent favorite is a KeyError -- consistent with +FavoritesManager's counterpart in ProviderFavoritesMixin. + +Provider guidance +----------------- +Favorites can be content of any type: a channel, a programme, a clip, +a series. The content_id is whatever the provider uses to identify the +favorited item. FavoriteType on the returned Favorite distinguishes +them (PROGRAM, CHANNEL, CLIP, LIVE, EVENT). +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any, List, Optional + +from ..models.favorite import Favorite, FavoriteType +from ..protocols import AuthProtocol +from ..utils.logger import logger + + +class FavoritesManager(ABC): + """Abstract base for provider favorites managers.""" + + def __init__( + self, + *, + http_manager: Any, + auth: AuthProtocol, + country: str, + config: Any, + ) -> None: + if not isinstance(auth, AuthProtocol): + logger.warning( + f"{self.__class__.__name__}: auth does not match AuthProtocol " + f"(missing one of get_access_token / build_headers / " + f"invalidate). Got {type(auth).__name__}." + ) + self.http_manager = http_manager + self.auth = auth + self.country = country + self.config = config + + # ------------------------------------------------------------------ + # Abstract + # ------------------------------------------------------------------ + + @abstractmethod + def get_favorites(self, **kw: Any) -> List[Favorite]: + """ + Return all favorites for the authenticated user. + + Return [] when the user has no favorites. Do NOT raise for + "empty" -- that is a valid state. + """ + raise NotImplementedError + + @abstractmethod + def add_favorite( + self, + content_id: str, + favorite_type: FavoriteType = FavoriteType.PROGRAM, + title: Optional[str] = None, + **kw: Any, + ) -> Favorite: + """ + Add a favorite. + + Raises RuntimeError if the provider refuses the operation. + """ + raise NotImplementedError + + @abstractmethod + def remove_favorite(self, content_id: str, **kw: Any) -> None: + """ + Remove a favorite. + + Raises: + KeyError: if content_id is not currently favorited. + RuntimeError: on backend failure. + """ + raise NotImplementedError + + # ------------------------------------------------------------------ + # Concrete + # ------------------------------------------------------------------ + + def is_favorite( + self, + content_id: str, + favorites: Optional[List[Favorite]] = None, + **kw: Any, + ) -> bool: + """ + Return True if content_id is currently favorited. + + Pass favorites=... to avoid a redundant get_favorites() call + when checking multiple ids. + """ + if favorites is None: + favorites = self.get_favorites(**kw) + return any(f.content_id == content_id for f in favorites) \ No newline at end of file diff --git a/lib/streaming_providers/base/managers/recordings.py b/lib/streaming_providers/base/managers/recordings.py new file mode 100644 index 0000000..7597c4b --- /dev/null +++ b/lib/streaming_providers/base/managers/recordings.py @@ -0,0 +1,170 @@ +# streaming_providers/base/managers/recordings.py +""" +RecordingsManager ABC. + +Public interface +---------------- + handles_recording_id(recording_id) -> bool [concrete] + get_recordings(**kw) -> List[Channel] [abstract] + delete_recording(recording_id, **kw) -> None [abstract] + schedule_recording(content_id, **kw) -> Channel|bool [concrete, optional] + +Constructor contract +-------------------- +Four required keyword-only collaborators. No **kwargs. Subclasses accept +extra keyword-only args explicitly and call super().__init__ with only +the four required. + +Return-value conventions +------------------------ +get_recordings returns [] when the provider has no recordings. Callers +should treat an empty list as "no recordings", not as an error. + +delete_recording raises KeyError when the recording does not exist, and +ProviderError subclasses (usually ServerError / EntitlementError) on +transport or permission failures. Returning silently on a failed delete +would hide real errors. + +schedule_recording is optional -- the ABC provides a default that raises +NotImplementedYetError. Providers that support scheduling override it. + +Recording identity +------------------ +A recording is identified by its provider-side recording_id, which is a +distinct namespace from content_id. Some providers use the same value +for both; some do not. The ABC treats them as separate parameters so +that distinction is preserved. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any, List + +from ..errors import NotImplementedYetError +from ..models import Channel +from ..protocols import AuthProtocol +from ..utils.logger import logger + + +class RecordingsManager(ABC): + """ + Abstract base for provider recordings managers. + + Recording identity + ------------------ + A recording is identified by its provider-side recording_id, which + is a distinct namespace from content_id. Some providers use the same + value for both; some do not. The ABC treats them as separate + parameters so that distinction is preserved. + + No manifest method + ------------------ + This ABC deliberately has no get_manifest method. Recordings are + played via the same content_id that a channel or VOD manager + resolves -- the provider's router decides which manager owns the + manifest fetch. Some providers resolve a recording's manifest + through their ChannelManager (the API shares the codename + namespace between a channel and its recordings, as simpliTV does); + others may route through VodManager or a dedicated playback path. + The recordings manager's job is the *list* and the *delete*, not + the playback URL. + + Providers that need a recording's manifest should implement the + routing in their provider's get_manifest, not by adding a + get_manifest method here. See providers/simplitv/provider.py for + the pattern. + """ + + def __init__( + self, + *, + http_manager: Any, + auth: AuthProtocol, + country: str, + config: Any, + ) -> None: + if not isinstance(auth, AuthProtocol): + logger.warning( + f"{self.__class__.__name__}: auth does not match AuthProtocol " + f"(missing one of get_access_token / build_headers / " + f"invalidate). Got {type(auth).__name__}." + ) + self.http_manager = http_manager + self.auth = auth + self.country = country + self.config = config + + # ------------------------------------------------------------------ + # Routing + # ------------------------------------------------------------------ + + def handles_recording_id(self, recording_id: str) -> bool: + """ + True if this manager can handle the given recording_id. + + Default: True. Override in providers whose recording IDs have a + distinguishable prefix or format, so a router can dispatch + without a wasted request. Not used by the base provider; a + provider that surfaces recordings through multiple managers can + consult this to route delete/update calls. + """ + return True + + # ------------------------------------------------------------------ + # Abstract + # ------------------------------------------------------------------ + + @abstractmethod + def get_recordings(self, **kw: Any) -> List[Channel]: + """ + Return recordings for the authenticated user. + + Return [] when the provider has no recordings. Do NOT raise + NotFoundError for "no recordings" -- that is a valid empty + result, not a missing resource. + + Recording objects are returned as Channel instances (or a + Channel subclass carrying extra fields such as recording_id, + start/stop times, and the underlying content_id). The base + Channel shape is preserved because downstream callers expect a + content_id and a name. + + Providers whose recordings are conceptually distinct from their + channels (e.g. cloud-PVR recordings that store their own + programme metadata) should subclass Channel with the extra + fields they need, and document them in the subclass's docstring. + """ + raise NotImplementedError + + @abstractmethod + def delete_recording(self, recording_id: str, **kw: Any) -> None: + """ + Delete a recording. + + Raises: + KeyError: if the recording does not exist. + ProviderError: on transport / permission / server failure. + """ + raise NotImplementedError + + # ------------------------------------------------------------------ + # Concrete -- optional + # ------------------------------------------------------------------ + + def schedule_recording(self, content_id: str, **kw: Any) -> Any: + """ + Schedule a recording of content_id. + + Optional. Return value is provider-specific: some providers + return the created recording (a Channel), others return a bool + indicating success. The ABC does not constrain the shape + because there is no shared one across providers. + + Default raises NotImplementedYetError. Providers that support + scheduling override this. + """ + raise NotImplementedYetError( + f"{self.__class__.__name__}.schedule_recording is not " + f"implemented" + ) \ No newline at end of file diff --git a/lib/streaming_providers/providers/_template/README.md b/lib/streaming_providers/providers/_template/README.md index 5f1b0b0..0c9890e 100644 --- a/lib/streaming_providers/providers/_template/README.md +++ b/lib/streaming_providers/providers/_template/README.md @@ -8,18 +8,18 @@ and fill in the stubs. Read this file first — it explains the contract. - HTTP manager setup, proxying, retries. - Credential storage / Kodi sync via the base `settings_manager`. - Token caching and session persistence (in your Auth class). -- Capability flags (`implements_vod`, `implements_epg`) — derived from - whether you wire up the corresponding manager. +- Capability flags (`implements_vod`, `implements_epg`, `implements_recordings`, + ...) — derived from whether you wire up the corresponding manager. - Shared error types, shared `VodPage` shape, shared `Channel` base. ## What you implement 1. **Auth** — `auth.py`. Writes the three shared methods and any optional extensions the provider needs. -2. **Managers** — `channel_manager.py`, `vod_manager.py`, `epg_manager.py`. - Each subclasses the corresponding ABC from `base/managers/` and - implements the abstract methods. -3. **Provider wiring** — `provider.py`. Fills in `_build_*` factory +2. **Managers** — one file per capability the provider supports. Each + subclasses the corresponding ABC from `base/managers/` and implements + the abstract methods. See "The manager ABCs" below for the full list. +3. **Provider wiring** — `provider.py`. Fills in the `_build_*` factory methods; returns `None` for capabilities the provider doesn't have. 4. **Constants** — `constants.py`. URLs, endpoints, static headers. 5. **Models** (optional) — `models.py`. Only if you need a custom Channel @@ -27,10 +27,34 @@ and fill in the stubs. Read this file first — it explains the contract. ## The manager ABCs -Subclass `base.managers.ChannelManager`, `VodManager`, `EpgManager`. Each -has the same constructor contract and a small public interface. +There are **seven** manager ABCs. Three are required capabilities, four +are optional. Providers implement the ones their service offers and +return `None` from the corresponding `_build_*()` for the rest. -**Constructor contract:** +### Required capabilities + + ChannelManager -- live channels, channel manifest, channel DRM + VodManager -- browseable VOD catalogue (None for live-only) + EpgManager -- EPG (None for providers without EPG) + +### Optional capabilities + + RecordingsManager -- cloud / network PVR + FavoritesManager -- user favorites on programs / channels + BookmarksManager -- resume positions + CatchupManager -- timeshift / restart + +**The rule: if a capability area has a public interface in the base +layer, it gets a manager ABC.** The base layer's +`ProviderRecordingsMixin`, `ProviderFavoritesMixin`, +`ProviderBookmarksMixin`, and `ProviderCatchupMixin` correspond +one-to-one to the four optional managers. Do not fold a capability into +another manager because the data happens to come from the same +endpoint — the public interface is the contract, not the URL. + +### Constructor contract + +All seven managers share the same constructor contract: def __init__( self, @@ -39,7 +63,7 @@ has the same constructor contract and a small public interface. auth, country, config, - your_extra_cache=None, # any extra keyword-only args you need + your_extra_collaborator=None, # optional, subclass-specific ): super().__init__( http_manager=http_manager, @@ -47,7 +71,7 @@ has the same constructor contract and a small public interface. country=country, config=config, ) - self._your_extra_cache = your_extra_cache or {} + self._your_extra_collaborator = your_extra_collaborator The base constructor accepts ONLY the four required collaborators. There is no `**provider_opts` passthrough. This is deliberate: a typo at a call @@ -61,6 +85,85 @@ Subclasses declare their extra keyword-only args explicitly, call Managers never hold a reference to the provider. If you need something the provider owns, inject it at construction. +### Cross-manager collaborators + +Some managers need collaborators from other managers. Common cases: + +* **CatchupManager** needs `channels` (to resolve a live manifest URL or + a stream uid) and/or `epg` (to resolve an epg_id from a start_time). +* **RecordingsManager** *may* need `channels` if the provider resolves + a recording's manifest through the channel manager (simpliTV does + this by default; the recordings manager itself never implements + `get_manifest`, so the collaborator is only needed if the recordings + manager fetches supplementary channel metadata). +* **DrmManager** (dedicated architecture) may need `channels` or `vod` + to look up an asset's DRM config. +* **FavoritesManager** and **BookmarksManager** rarely need other + managers, but if the content_id needs resolving to a program id, they + may need a lookup helper. + +Pass these as explicit keyword-only constructor arguments. The provider +builds them in dependency order: + + self.channels = self._build_channels() + self.epg = self._build_epg() + self.recordings = self._build_recordings() + self.catchup = self._build_catchup() # sees channels + epg + ... + +Don't introduce cycles between managers. If two managers need each +other, one of them should own the shared state and the other should +borrow it via a cache argument, not via a mutual reference. + +If you find that a manager needs a provider-owned cache that isn't +otherwise exposed, pass the cache dict itself as a keyword-only +collaborator (as `playback_cache` is passed to simpliTV's channel and +catchup managers). Do not reach back to the provider. + +Managers may also need each other's **content-id parsers**. Put the +parsers at module scope in the primary content-id namespace file +(usually `channel_manager.py`), and import them where needed. Parsers +are pure functions — no cross-manager state is involved. + +### Capability flags + +The provider exposes a derived boolean per capability: + + @property + def implements_channels(self) -> bool: + return self.channels is not None + + @property + def implements_vod(self) -> bool: + return self.vod is not None + + @property + def implements_epg(self) -> bool: + return self.epg is not None + + @property + def implements_recordings(self) -> bool: + return self.recordings is not None + + @property + def implements_favorites(self) -> bool: + return self.favorites is not None + + @property + def implements_bookmarks(self) -> bool: + return self.bookmarks is not None + + @property + def implements_catchup(self) -> bool: + return self.catchup is not None + + @property + def implements_drm(self) -> bool: + # See the DRM section for the folded-vs-dedicated derivation. + +The pattern is uniform: a capability is present iff its manager object is +present. There is no separate boolean to keep in sync. + ## Return-value rule: None vs exception This is the single most important convention. Every manager follows it. @@ -70,7 +173,8 @@ This is the single most important convention. Every manager follows it. `get_channel_manifest("clip_123")` from a channel manager that only handles live channels returns `None`. Not an error — the router uses it to fall through to the VOD manager. Same for `get_vod_manifest`, -`get_channel_drm`, `get_vod_drm`, `get_epg`. +`get_channel_drm`, `get_vod_drm`, `get_epg`, `get_recordings`, +`get_favorites`, `get_bookmarks`. **Raise for genuine failures.** @@ -101,9 +205,35 @@ failure" is an exception.** ## Routing with handles_content_id() -The orchestrator's `get_manifest` / `get_drm` try managers in order. To -avoid a wasted request, override `handles_content_id()` on managers whose -content_ids have a distinguishable shape: +The router pattern in the provider's `_route()` helper is used for +methods whose input is an **opaque content_id** with multiple possible +owners. In practice that means: + + get_manifest(content_id) -> channel | vod | catchup (whichever owns it) + get_drm(content_id, ...) -> channel | vod (whichever owns it) + +Not every capability needs routing: + +* **Capabilities with their own ID namespace** (recordings) don't route. + `get_recordings()` and `delete_recording(recording_id)` are named + methods on the recordings manager. The caller names the capability + explicitly; there is no ambiguity to resolve. +* **Capabilities that operate on all content** (EPG, favorites, + bookmarks) don't route. `get_epg(channel_id, ...)`, + `add_favorite(content_id)`, `update_bookmark(content_id)` are named + methods that go straight to their manager. +* **Capabilities that need cross-manager collaborators** (catchup) route + through the provider if their content_id namespace overlaps with + another manager's, or are called directly on their manager otherwise. + +The rule: **route when a content_id could belong to more than one +manager. Otherwise, name the capability explicitly.** + +### handles_content_id + +For managers that DO participate in routing, override +`handles_content_id()` when the manager's content_id grammar has a +distinguishable shape: # In your VodManager: def handles_content_id(self, content_id: str) -> bool: @@ -111,16 +241,142 @@ content_ids have a distinguishable shape: # In your ChannelManager: def handles_content_id(self, content_id: str) -> bool: - return content_id.isdigit() + return content_id.startswith(("live:", "vod:", "series:")) If your provider has no content_id grammar, leave it returning `True` and the router falls back to try-and-catch. +Which managers typically override `handles_content_id`: + +* **ChannelManager**, **VodManager**, **CatchupManager** — yes. + Content-id grammar usually has a prefix or shape that discriminates. +* **EpgManager** — has `handles_channel_id()`, used internally by + `get_epg_grid()`. Does not participate in `_route`. +* **RecordingsManager** — has `handles_recording_id()` for the case + where multiple recordings managers exist (cloud PVR + local PVR, + say). Does not participate in `_route` for content_id, because + recordings have their own ID namespace. +* **FavoritesManager**, **BookmarksManager** — usually no. They operate + on whatever content_id the caller provides; there is no grammar to + discriminate on. + `BadRequestError` is intentionally not caught by the router. Providers that use a 400 response as an endpoint-dispatch signal (like Magenta's page-vs-component guess) should override `handles_content_id()` instead, so the router never has to guess. +### Parsers vs. dispatch + +Two separate concerns that look similar: + +* **Grammar**: what content_id shapes a manager will accept. Exposed + as `handles_content_id()`. A manager whose grammar covers several + prefixes returns True for all of them. + +* **Dispatch**: which manager the router tries for a given id. This is + the router's own decision, expressed by what it passes to `_route()` + (or by explicit prefix branches above `_route`). + +They can differ. In simpliTV: + +* The channel manager's grammar covers `live:` and `rec:` — it accepts + both. +* The router routes both through `_route` uniformly, because they + resolve to the same endpoint. +* The catchup manager's grammar covers `catchup:@` — but + the router does NOT route catchup through `_route`, because it needs + to parse the `@` suffix and pass it as an argument. + +The rule: **route through `_route` when the manager needs only the +content_id. Add an explicit prefix branch above `_route` when the +manager needs parsed arguments (a timestamp, an episode index, a +query-like suffix).** One parser, in the router. + +### Content-id grammar parsers + +Put content-id grammar parsers at module scope in the file that owns +the provider's primary content-id namespace (usually +`channel_manager.py`): + + def parse_live_id(content_id): ... + def parse_recording_id(content_id): ... + def parse_catchup_id(content_id): ... + +Other managers and the provider import them from there. Do not +duplicate the parsers per manager — one grammar, one parser, imported +everywhere it's needed. + +Parsers should raise `BadRequestError` on malformed input. The router +does not catch `BadRequestError`, so a malformed id surfaces to the +caller rather than silently falling through to the wrong manager. + +Parsers must live at module scope, not as class methods, so multiple +managers can import them without a circular dependency. + +### Multiple managers with the same prefix + +Two managers may claim the same content-id prefix if their +responsibilities are disjoint. simpliTV: + +* `SimpliTVChannelManager` accepts `rec:` for manifest and + DRM — the streaming side. +* `SimpliTVRecordingsManager` owns `get_recordings` and + `delete_recording` — the list side. + +Only one of them participates in `_route` for the prefix, and the +choice is which manager owns the manifest fetch. The provider's +`get_manifest` is the authoritative declaration of that choice. + +Do not give the same prefix two router branches. Two branches for the +same prefix means two code paths for the same content, and they will +drift. + +### Sentinels for unused ABC parameters + +If the ABC's method signature requires a parameter your provider's API +does not use, prefer `None` for optional arguments. The +`CatchupManager.get_catchup_manifest` signature declares `end_time` as +`Optional[int]` for exactly this reason — providers whose catchup API +only takes a start bound can pass `None` and document that they ignore +it. + +Never pass `None` where the ABC expects `int`. Never pass `0` without a +comment — `0` is ambiguous (a valid timestamp? a "no value" marker?). +Prefer `None` when the ABC permits it; if it doesn't, add a comment +explaining the sentinel. + +### Routing for recordings manifests + +Some providers (simpliTV) route a recording's manifest through the +channel manager, because the API shares the codename namespace between +a channel and its recordings. Others may route through the VOD manager +or a dedicated playback path. + +The routing lives in the provider's `get_manifest`, not in the +recordings manager. The recordings manager owns the *list* and the +*delete*; manifest resolution is the router's job. See +`providers/simplitv/provider.py` for a concrete example — it routes the +`rec:` prefix to the channel manager, whose `handles_content_id` +accepts it alongside `live:`. + +Do not add a `get_manifest` method to `RecordingsManager`. + +### Private helpers used by multiple managers + +If a helper function is used by more than one manager, it is public by +default. Name it without the underscore. + + # Bad: catchup_manager imports _prefer_dash from channel_manager + from .channel_manager import _prefer_dash + + # Good: the helper is public + from .channel_manager import prefer_dash + +The underscore-prefixed convention is for helpers private to one file. +An underscore-prefixed name imported across modules is a signal the +helper should either be renamed public or moved to a shared module +(`constants.py`, or a new `helpers.py`). + ## The Auth protocol Auth is a documented protocol (see `base/protocols.py`), not an ABC. @@ -154,23 +410,20 @@ Optional extensions — implement only if needed: There is no fixed interface for `authorize_playback`. The name is a convention; the shape is provider-specific. +**Deviation: token not in a header.** Some providers pass the token as a +URL query parameter or body field rather than an `Authorization` header. +simpliTV does this — `build_headers()` returns base headers only, and +callers attach the token via `auth.with_token(url)` / `auth.auth_body()`. +If your provider deviates this way, document it prominently in your +`auth.py` module docstring **and** add a one-line comment at every +manager call site that uses `build_headers()`, so a future reader doesn't +assume a bearer token is being sent. + The manager ABCs verify `auth` against `AuthProtocol` at construction time via `isinstance` (this works because the protocol is `@runtime_checkable`). The check confirms method *presence*, not signatures — a mismatched signature will not be caught here. -## Conventions - -- **Custom Channel / AuthToken subclasses are fine.** Call - `super().to_dict()` in your override. MoveTV, Discovery, and HRTi all - do this. -- **Provider owns caches; managers borrow them.** Create caches in the - provider's `__init__`; pass them into manager constructors. -- **Content ID grammar is provider-specific.** Pick one and document it - in the manager's docstring. -- **Playback authorization is provider-specific.** Don't force it into a - shared interface. See the five existing providers for five shapes. - ## DRM DRM is optional. Providers with no DRM leave `_build_drm()` returning @@ -197,20 +450,20 @@ response, an account/licence structure, and so on. ### Source pattern: how the licence URL is produced Four source patterns appear across the existing providers. The source -pattern is orthogonal to the architecture -- any source can be wired +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: A. Per-content upfront token (RTL+) B. Constructed from token claims + /user/account (Magenta) - C. Arrives with the playbackInfo response (Discovery) + C. Arrives with the playbackInfo response (Discovery, simpliTV) D. Session id becomes a base64 auth blob (HRTi) 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 +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 @@ -256,6 +509,109 @@ On the dedicated-manager path, the hint is passed through to type from `content_id` grammar when `content_type is None`. Managers are encouraged to widen when in doubt, for the same reason as above. +## Catchup + +Catchup is a distinct capability with its own manager. It is optional — +providers without catchup leave `_build_catchup()` returning `None` and +`implements_catchup` is `False`. + +The catchup step is a *modified* live-manifest URL (with time +parameters) for some providers, a distinct URL from a different origin +for others, and an EPG-based resolution for others. The ABC names the +entry point; the shape is provider-specific. + +**Do not fall back to the live manifest.** If `get_catchup_manifest` +cannot resolve catchup for the requested window, return `None`. Do not +return the live manifest URL — the DRM pipeline would extract PSSH from +the live stream, which may differ from the catchup stream's encryption +context. Callers that want the live manifest on failure should call +`provider.get_manifest()` themselves. + +`end_time` is **Optional[int]** on the ABC. Providers whose catchup API +only takes a start bound (simpliTV) pass `None` and document that they +ignore the argument. Providers whose API uses both bounds require the +caller to pass it and raise `BadRequestError` on `None`. Do not pass a +sentinel value (`0`, `start_time + 1800`) when the ABC accepts `None`. + +Catchup usually needs collaborators from other managers: + +* **channels** — to resolve a live manifest URL (Magenta, simpliTV) or + a stream uid (MoveTV). +* **epg** — to resolve an `epg_id` from a `start_time` (MoveTV). + +Pass these as explicit keyword-only constructor arguments. See "Cross- +manager collaborators" above. + +## Recordings + +Recordings are a distinct capability with their own manager. Optional. + +Recording identity is separate from content identity: + +* `recording_id` is what you pass to `delete_recording`. +* `content_id` is what you pass to `get_manifest` to play the recording. + +These are usually different namespaces. Keep them separate. + +`get_recordings()` returns `Channel` objects (or a subclass carrying +`recording_id` and programme metadata). The base Channel shape is +preserved because downstream callers expect a `content_id` and a `name`. + +`delete_recording` raises `KeyError` if the recording doesn't exist and +a `ProviderError` subclass on backend failure. It does **not** silently +return on failure — deleting a recording the user asked to delete and +having the deletion fail must surface. + +**No manifest method.** The recordings manager deliberately has no +`get_manifest`. Recordings are played via a content_id that the +provider's router resolves — usually through the channel manager +(simpliTV) or the VOD manager, depending on how the provider's API +exposes the manifest. See "Routing for recordings manifests" above. + +**Two managers may touch the `rec:` prefix.** The channel manager +accepts `rec:` for manifest and DRM fetch (the recording shares an +endpoint with its live channel). The recordings manager owns the list +and delete. This is not a conflict — they touch disjoint concerns. +Only one of them participates in the routing; the provider's +`get_manifest` is authoritative about which. See "Multiple managers +with the same prefix" above. + +## Favorites and bookmarks + +Both are optional, both are user-scoped, both follow the standard +manager contract. + +Favorites: `FavoriteType` on each returned `Favorite` distinguishes +program / channel / clip / live / event. Providers that only support +one type validate the incoming type in `add_favorite` and reject the +others. Removing a non-existent favorite raises `KeyError`. + +Bookmarks: `update_bookmark` is called on every playback stop / pause, +often consecutively for the same position. Providers should tolerate +repeated no-op writes to the same position without erroring or firing +spurious events. `position_seconds = -1` marks the content as +completed. Deleting a non-existent bookmark raises `KeyError`. + +## Conventions + +- **Custom Channel / AuthToken subclasses are fine.** Call + `super().to_dict()` in your override. MoveTV, Discovery, HRTi, and + simpliTV all do this. +- **Provider owns caches; managers borrow them.** Create caches in the + provider's `__init__`; pass them into manager constructors by + reference. +- **Content ID grammar is provider-specific.** Pick one and document it + in the manager's docstring, or in a module-scope parser (see + "Content-id grammar parsers" above). +- **Playback authorization is provider-specific.** Don't force it into a + shared interface. See the existing providers for examples. +- **Extra capabilities that don't fit an ABC.** If the provider has a + capability area that doesn't correspond to a base-layer mixin and + doesn't warrant a new ABC, put the methods on the concrete manager + that owns the domain, and pass them through on the provider. Document + the capability in the manager's class docstring. Don't invent an + ad-hoc manager class. + ## Errors Raise from `base.errors`: @@ -303,4 +659,22 @@ object for inspection without hitting the network. If your provider has a strong reason to authenticate eagerly (e.g. you need to fail fast on bad credentials), do it in the provider's `__init__` -inside a try/except and log a warning — do not raise. \ No newline at end of file +inside a try/except and log a warning — do not raise. + +## Reference providers + +The six existing providers, in order of implementation complexity: + + simpliTV -- built from this template. Recordings + catchup + folded + DRM. Content-id grammar with three prefixes, module-scope + parsers, and a router that parses the catchup timestamp. + Good first read. + MoveTV -- dynamic manifests, play-auth headers, EPG-based catchup. + Magenta EU -- EPG-heavy, VOD with typed errors, multi-country. + HRTi -- session-authorize playback, custom credentials shape. + Discovery -- dynamic endpoint discovery, Arkose challenge, playbackInfo. + RTL+ -- layout-driven, three tokens, largest surface. + +Read the closest one before writing a new provider. Each demonstrates a +different source pattern for DRM, a different content-id grammar, and a +different shape for playback authorization. \ No newline at end of file diff --git a/lib/streaming_providers/providers/_template/bookmarks_manager.py b/lib/streaming_providers/providers/_template/bookmarks_manager.py new file mode 100644 index 0000000..8f36368 --- /dev/null +++ b/lib/streaming_providers/providers/_template/bookmarks_manager.py @@ -0,0 +1,89 @@ +# streaming_providers/providers/_template/bookmarks_manager.py +""" +{TODO: Provider name} bookmarks manager (optional). + +Include this file only if the provider supports resume positions. +Providers without bookmarks don't create a bookmarks manager -- the +provider's implements_bookmarks is False and calls to get_bookmarks +return []. + +See ../_template/README.md for the contract. + +Reference implementation +------------------------ +No existing provider implements bookmarks today. The base mixin +ProviderBookmarksMixin (base/provider_mixins/bookmarks.py) documents +the shape callers expect. Read it before writing yours -- the +semantics of position_seconds = -1, the COMPLETION_THRESHOLD, and the +KeyError on delete-missing are all there. + +Call frequency +-------------- +update_bookmark is called on every playback stop / pause, often +consecutively for the same position. Providers should tolerate +repeated no-op writes to the same position without erroring or +firing spurious events. +""" + +from typing import Any, List, Optional + +from ...base.managers import BookmarksManager +from ...base.models.bookmark import Bookmark, ContentType +from ...base.utils.logger import logger + + +class YourBookmarksManager(BookmarksManager): + """Bookmarks for {TODO: provider name}.""" + + def __init__( + self, + *, + http_manager, + auth, + country, + config, + bookmarks_cache=None, + ): + super().__init__( + http_manager=http_manager, + auth=auth, + country=country, + config=config, + ) + self._bookmarks_cache = ( + bookmarks_cache if bookmarks_cache is not None else {} + ) + + # ----- Abstract methods ----- + + def get_bookmarks(self, **kw) -> List[Bookmark]: + """Return all bookmarks for the user. [] when there are none.""" + raise NotImplementedError("YourBookmarksManager.get_bookmarks") + + def update_bookmark( + self, + content_id: str, + position_seconds: int, + content_type: ContentType, + duration_seconds: Optional[int] = None, + title: Optional[str] = None, + **kw, + ) -> Bookmark: + """ + Save or update a bookmark. Called on playback stop / pause. + + position_seconds = -1 marks the content as completed. + + Raises RuntimeError if the provider rejects. + """ + raise NotImplementedError("YourBookmarksManager.update_bookmark") + + def delete_bookmark(self, content_id: str, **kw) -> None: + """ + Delete a bookmark. + + Raises: + KeyError: if no bookmark exists for content_id. + RuntimeError: on backend failure. + """ + raise NotImplementedError("YourBookmarksManager.delete_bookmark") \ No newline at end of file diff --git a/lib/streaming_providers/providers/_template/catchup_manager.py b/lib/streaming_providers/providers/_template/catchup_manager.py new file mode 100644 index 0000000..fddf9b9 --- /dev/null +++ b/lib/streaming_providers/providers/_template/catchup_manager.py @@ -0,0 +1,115 @@ +# streaming_providers/providers/_template/catchup_manager.py +""" +{TODO: Provider name} catchup manager (optional). + +Include this file only if the provider supports timeshift / restart. +Providers without catchup don't create a catchup manager -- the +provider's implements_catchup is False and calls to +get_catchup_manifest return None. + +See ../_template/README.md for the contract. + +Reference implementations +------------------------- +MoveTV's provider (providers/movetv/provider.py) implements catchup +by resolving an EPG entry, then POSTing to a catchup-source endpoint +that returns a URL + a play-auth header. The URL and header are +coupled, so the manager needs a reference to the EPG manager (to +resolve epg_id) and to the channel manager (for the channel's stream +uid). + +Magenta EU's provider (providers/magentaeu/provider.py) implements +catchup by appending start/end query parameters to the live manifest +URL via build_catchup_url(). + +HRTi has no catchup -- its VOD and EPG are separate domains, and +authorize_session's session id is not reused for timeshift. + +State sharing +------------- +The catchup step often shares state with the channel manager (the live +manifest URL) or the EPG manager (the epg_id for the requested window). +Pass those collaborators as explicit keyword-only arguments rather than +reaching back to the provider. + +Do NOT fall back to the live manifest +------------------------------------- +If get_catchup_manifest cannot resolve catchup for the given window, +return None. Do not return the live manifest URL as a "catchup" +manifest -- the DRM pipeline would extract PSSH from the live stream, +which may differ from the catchup stream's encryption context. +""" + +from typing import Any, Dict, List, Optional + +from ...base.managers import CatchupManager +from ...base.models import DRMConfig +from ...base.utils.logger import logger + + +class YourCatchupManager(CatchupManager): + """Catchup for {TODO: provider name}.""" + + def __init__( + self, + *, + http_manager, + auth, + country, + config, + channels=None, + epg=None, + ): + super().__init__( + http_manager=http_manager, + auth=auth, + country=country, + config=config, + ) + # Common collaborators. Catchup often needs one or both. + # - channels: for resolving a channel's live manifest URL + # (Magenta) or its stream uid (MoveTV). + # - epg: for resolving an epg_id from a start_time + # (MoveTV). + self._channels = channels + self._epg = epg + + # ----- Capability ----- + + @property + def catchup_window_hours(self) -> int: + """Return the catchup window in hours. 0 means no catchup.""" + return 0 # TODO: e.g. 168 for 7 days + + # ----- Abstract method ----- + + def get_catchup_manifest( + self, + content_id: str, + start_time: int, + end_time: int, + epg_id: Optional[str] = None, + **kw, + ) -> Optional[str]: + """ + Return the catchup manifest URL, or None if not resolvable. + + Do NOT fall back to the live manifest URL here. + """ + raise NotImplementedError("YourCatchupManager.get_catchup_manifest") + + # ----- Concrete methods (override when needed) ----- + + # def get_catchup_drm( + # self, + # content_id: str, + # start_time: int, + # end_time: int, + # epg_id: Optional[str] = None, + # **kw, + # ) -> List[DRMConfig]: + # """ + # Override only if catchup uses a different DRM config from live. + # The default returns [] -- the caller falls back to live DRM. + # """ + # return [] \ No newline at end of file diff --git a/lib/streaming_providers/providers/_template/recordings_manager.py b/lib/streaming_providers/providers/_template/recordings_manager.py new file mode 100644 index 0000000..b820d17 --- /dev/null +++ b/lib/streaming_providers/providers/_template/recordings_manager.py @@ -0,0 +1,115 @@ +# streaming_providers/providers/_template/recordings_manager.py +""" +{TODO: Provider name} recordings manager (optional). + +Include this file only if the provider has cloud / network PVR +recordings. Providers without recordings don't create a recordings +manager -- the provider's implements_recordings is False and calls to +get_recordings return []. + +See ../_template/README.md for the contract. + +Reference implementation +------------------------ +simpliTV's SimpliTVRecordingsManager +(providers/simplitv/recordings_manager.py) is the first example: it +returns recordings as SimpliTVChannel objects, carries recording_id +on the subclass, and paginates through /v2/Pvr/GetRecordings. + +Recording identity +------------------ +A recording has its own id (recording_id) distinct from the content_id +of the underlying programme. The two namespaces are usually different: +recording_id is what you pass to delete_recording, content_id is what +you pass to get_manifest to play the recording. Keep them separate. +""" + +from typing import Any, List + +from ...base.managers import RecordingsManager +from ...base.models import Channel +from ...base.utils.logger import logger + + +class YourRecordingsManager(RecordingsManager): + """ + Recordings for {TODO: provider name}. + + What this manager owns + ---------------------- + get_recordings -- the recording list + delete_recording -- remove one recording by recording_id + schedule_recording -- (optional) create a new recording + + What this manager does NOT own + ------------------------------ + Manifest fetching. A recording is played via a content_id that + the provider's router resolves. Depending on the provider, that + fetch might route through the ChannelManager (simpliTV shares + the codename namespace between a channel and its recordings), + through the VodManager, or through a dedicated playback path. + + Do not add a get_manifest method here. Route it in the + provider's get_manifest instead. See + providers/simplitv/provider.py for the pattern. + + Recording identity + ------------------ + recording_id -- what delete_recording receives. Not a content_id. + content_id -- what get_manifest receives to play the + recording. Usually a prefixed form of the + underlying programme's identifier. + + See ../_template/README.md for the full contract. + """ + + def __init__( + self, + *, + http_manager, + auth, + country, + config, + recordings_cache=None, + ): + super().__init__( + http_manager=http_manager, + auth=auth, + country=country, + config=config, + ) + self._recordings_cache = ( + recordings_cache if recordings_cache is not None else {} + ) + + # ----- Abstract methods ----- + + def get_recordings(self, **kw) -> List[Channel]: + """ + Return the user's recordings. + + Return [] when there are none. Do NOT raise for "empty". + """ + raise NotImplementedError("YourRecordingsManager.get_recordings") + + def delete_recording(self, recording_id: str, **kw) -> None: + """ + Delete a recording. + + Raises: + KeyError: if the recording doesn't exist. + ProviderError: on backend failure. + """ + raise NotImplementedError("YourRecordingsManager.delete_recording") + + # ----- Concrete methods (optional overrides) ----- + + # def schedule_recording(self, content_id: str, **kw): + # """ + # Schedule a recording of content_id. + # + # Override only if the provider supports scheduling. + # Return value is provider-specific: some providers return the + # created recording (a Channel), others return a bool. + # """ + # ... \ No newline at end of file diff --git a/lib/streaming_providers/providers/magenta2/recordings_manager.py b/lib/streaming_providers/providers/magenta2/recordings_manager.py index 64bf032..3f5b0f9 100644 --- a/lib/streaming_providers/providers/magenta2/recordings_manager.py +++ b/lib/streaming_providers/providers/magenta2/recordings_manager.py @@ -1,4 +1,4 @@ -# streaming_providers/providers/magenta2/recordings_manager.py +# streaming_providers/providers/magenta2/recordings.py """ Magenta2 Recordings Manager