mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-10-06 16:02:47 +02:00
update template
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user