update template

This commit is contained in:
Nirvana
2026-10-03 20:39:40 +02:00
parent f946539652
commit e0ee6b9470
10 changed files with 1355 additions and 42 deletions
@@ -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"]
__all__ = [
"ChannelManager",
"VodManager",
"EpgManager",
"RecordingsManager",
"FavoritesManager",
"BookmarksManager",
"CatchupManager",
]
@@ -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
@@ -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 []
@@ -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)
@@ -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"
)
@@ -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:<codename>@<ts>` — but
the router does NOT route catchup through `_route`, because it needs
to parse the `@<ts>` 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:<codename>` 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.
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.
@@ -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")
@@ -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 []
@@ -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.
# """
# ...
@@ -1,4 +1,4 @@
# streaming_providers/providers/magenta2/recordings_manager.py
# streaming_providers/providers/magenta2/recordings.py
"""
Magenta2 Recordings Manager