work towards provider v2

This commit is contained in:
Nirvana
2026-10-07 12:12:10 +02:00
parent 777101f89d
commit 04ededea69
24 changed files with 1428 additions and 864 deletions
@@ -8,6 +8,8 @@ methods (headers, DRM defaults, search no-ops) come for free.
Design rules
------------
* All managers share one constructor contract, implemented once in the
private ManagerBase (managers/_base.py).
* Constructors take four required collaborators -- http_manager, auth,
country, config -- plus keyword-only extras (caches, collaborators).
* Managers never hold a reference to the provider. Shared state is passed
@@ -30,12 +32,17 @@ 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)
All seven managers are OPTIONAL (see providers/_template/README.md): a
provider wires the ones its service offers and returns None from the other
_build_*() factories. A VOD-only provider has no ChannelManager; a
metadata-only provider may have just an EpgManager.
Four optional capabilities -- providers implement the ones they support:
Core capabilities:
ChannelManager (live channels, channel manifest / DRM)
VodManager (browseable catalogue)
EpgManager (guide data)
Four further capabilities -- providers implement the ones they support:
RecordingsManager (cloud / network PVR)
FavoritesManager (user bookmarks on programs / channels)
BookmarksManager (resume position)
@@ -16,14 +16,19 @@ 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
update_bookmark raises OperationFailedError 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.
delete_bookmark raises ItemNotFoundError if no bookmark exists for
content_id, so callers can distinguish "already gone" from "successfully
deleted". The base ProviderBookmarksMixin documents the same rule (as
KeyError; ItemNotFoundError is a KeyError subclass).
ItemNotFoundError is both a NotFoundError (ProviderError) and a KeyError, and
OperationFailedError is both a ProviderError and a RuntimeError, so handlers
written against the old KeyError / RuntimeError convention keep working.
Caller guidance
---------------
@@ -35,36 +40,16 @@ they are given.
from __future__ import annotations
from abc import ABC, abstractmethod
from abc import abstractmethod
from typing import Any, List, Optional
from ..models.bookmark import Bookmark, ContentType
from ..protocols import AuthProtocol
from ..utils.logger import logger
from ._base import ManagerBase
class BookmarksManager(ABC):
class BookmarksManager(ManagerBase):
"""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
# ------------------------------------------------------------------
@@ -94,7 +79,8 @@ class BookmarksManager(ABC):
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.
Raises OperationFailedError (also a RuntimeError) if the provider
rejects the write.
"""
raise NotImplementedError
@@ -104,7 +90,8 @@ class BookmarksManager(ABC):
Delete a bookmark.
Raises:
KeyError: if no bookmark exists for content_id.
RuntimeError: on backend failure.
ItemNotFoundError: if no bookmark exists for content_id
(also a KeyError).
OperationFailedError: on backend failure (also a RuntimeError).
"""
raise NotImplementedError
@@ -9,6 +9,7 @@ Public interface
get_catchup_manifest(content_id, start_time, end_time=None, ...)
-> Optional[str] [abstract]
get_catchup_manifest_headers(...) -> Dict[str,str] [concrete]
get_catchup_segment_headers(...) -> Dict[str,str] [concrete]
get_catchup_drm(...) -> List[DRMConfig] [concrete]
Constructor contract
@@ -30,8 +31,8 @@ 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
--------------------------------------
What a catchup manifest is (and is not)
----------------------------------------
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
@@ -60,36 +61,16 @@ when the ABC accepts None. Pass None.
from __future__ import annotations
from abc import ABC, abstractmethod
from abc import abstractmethod
from typing import Any, Dict, List, Optional
from ..models import DRMConfig
from ..protocols import AuthProtocol
from ..utils.logger import logger
from ._base import ManagerBase
class CatchupManager(ABC):
class CatchupManager(ManagerBase):
"""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
# ------------------------------------------------------------------
@@ -165,6 +146,25 @@ class CatchupManager(ABC):
"""
return self.auth.build_headers()
def get_catchup_segment_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 catchup segment requests.
Default: the catchup manifest headers. Mirrors
ChannelManager.get_segment_headers; override for providers whose
catchup segments need different or token-bound headers.
"""
return self.get_catchup_manifest_headers(
content_id, start_time, end_time, epg_id, **kw
)
def get_catchup_drm(
self,
content_id: str,
@@ -13,10 +13,10 @@ Public interface
Constructor contract
--------------------
Four required keyword-only collaborators. Subclasses that need extra state
declare additional keyword-only args and store them on self AFTER calling
super().__init__. The base does NOT accept **kwargs -- a typo at a call
site becomes an immediate TypeError, which is what you want.
Four required keyword-only collaborators (see ManagerBase). Subclasses that
need extra state declare additional keyword-only args and store them on
self AFTER calling super().__init__. The base does NOT accept **kwargs -- a
typo at a call site becomes an immediate TypeError, which is what you want.
Return-value conventions
------------------------
@@ -27,39 +27,16 @@ failures propagate as exceptions from base.errors.
from __future__ import annotations
from abc import ABC, abstractmethod
from abc import abstractmethod
from typing import Any, Dict, List, Optional
from ..models import Channel, DRMConfig
from ..protocols import AuthProtocol
from ..utils.logger import logger
from ._base import ManagerBase
class ChannelManager(ABC):
class ChannelManager(ManagerBase):
"""Abstract base for provider channel managers."""
def __init__(
self,
*,
http_manager: Any,
auth: AuthProtocol,
country: str,
config: Any,
) -> None:
# Sanity check the Auth collaborator against the runtime_checkable
# protocol. isinstance on a runtime_checkable Protocol only verifies
# method presence, not signatures -- that's the intended check here.
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
# ------------------------------------------------------------------
@@ -71,7 +48,8 @@ class ChannelManager(ABC):
Default: True. Override in providers whose channel content_ids have
a distinguishable grammar (e.g. numeric-only for live channels).
The orchestrator uses this to route get_manifest / get_drm without
a wasted request to the wrong manager.
a wasted request to the wrong manager. It is a cheap, I/O-free
PRE-FILTER: False means the manager is never asked.
"""
return True
+3 -23
View File
@@ -22,37 +22,17 @@ epg_window returns (past_days, future_days). (0, 0) means no EPG support.
from __future__ import annotations
from abc import ABC, abstractmethod
from abc import abstractmethod
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
from ..models.epg_models import EPGEntry, EPGProgramDetails
from ..protocols import AuthProtocol
from ..utils.logger import logger
from ._base import ManagerBase
class EpgManager(ABC):
class EpgManager(ManagerBase):
"""Abstract base for provider EPG 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
# ------------------------------------------------------------------
@@ -18,13 +18,17 @@ 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
add_favorite returns the created Favorite. Raises OperationFailedError 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.
remove_favorite raises ItemNotFoundError if the content_id is not currently
favorited, and OperationFailedError on backend failure. Deleting a
non-existent favorite is an ItemNotFoundError -- which is also a KeyError,
consistent with ProviderFavoritesMixin.
ItemNotFoundError is both a NotFoundError (ProviderError) and a KeyError, and
OperationFailedError is both a ProviderError and a RuntimeError, so handlers
written against the old KeyError / RuntimeError convention keep working.
Provider guidance
-----------------
@@ -36,36 +40,16 @@ them (PROGRAM, CHANNEL, CLIP, LIVE, EVENT).
from __future__ import annotations
from abc import ABC, abstractmethod
from abc import abstractmethod
from typing import Any, List, Optional
from ..models.favorite import Favorite, FavoriteType
from ..protocols import AuthProtocol
from ..utils.logger import logger
from ._base import ManagerBase
class FavoritesManager(ABC):
class FavoritesManager(ManagerBase):
"""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
# ------------------------------------------------------------------
@@ -91,7 +75,8 @@ class FavoritesManager(ABC):
"""
Add a favorite.
Raises RuntimeError if the provider refuses the operation.
Raises OperationFailedError (also a RuntimeError) if the provider
refuses the operation.
"""
raise NotImplementedError
@@ -101,8 +86,9 @@ class FavoritesManager(ABC):
Remove a favorite.
Raises:
KeyError: if content_id is not currently favorited.
RuntimeError: on backend failure.
ItemNotFoundError: if content_id is not currently favorited
(also a KeyError).
OperationFailedError: on backend failure (also a RuntimeError).
"""
raise NotImplementedError
@@ -20,13 +20,14 @@ 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.
delete_recording raises ItemNotFoundError (also a KeyError) when the
recording does not exist, and ProviderError subclasses (usually ServerError
/ EntitlementError / OperationFailedError) 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.
UnsupportedOperationError, and supports_scheduling is False. Providers that
support scheduling override both.
Recording identity
------------------
@@ -38,16 +39,15 @@ that distinction is preserved.
from __future__ import annotations
from abc import ABC, abstractmethod
from abc import abstractmethod
from typing import Any, List
from ..errors import NotImplementedYetError
from ..errors import UnsupportedOperationError
from ..models import Channel
from ..protocols import AuthProtocol
from ..utils.logger import logger
from ._base import ManagerBase
class RecordingsManager(ABC):
class RecordingsManager(ManagerBase):
"""
Abstract base for provider recordings managers.
@@ -72,29 +72,10 @@ class RecordingsManager(ABC):
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/simpli/provider.py for
the pattern.
get_manifest method here. See the simpliTV provider's 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
# ------------------------------------------------------------------
@@ -143,8 +124,9 @@ class RecordingsManager(ABC):
Delete a recording.
Raises:
KeyError: if the recording does not exist.
ProviderError: on transport / permission / server failure.
ItemNotFoundError: if the recording does not exist (also a
KeyError).
ProviderError: on transport / permission / server failure.
"""
raise NotImplementedError
@@ -152,6 +134,17 @@ class RecordingsManager(ABC):
# Concrete -- optional
# ------------------------------------------------------------------
@property
def supports_scheduling(self) -> bool:
"""
True when schedule_recording() is implemented. Default False.
Providers that override schedule_recording MUST also override this
to return True, so callers can gate the UI instead of catching
UnsupportedOperationError.
"""
return False
def schedule_recording(self, content_id: str, **kw: Any) -> Any:
"""
Schedule a recording of content_id.
@@ -161,10 +154,11 @@ class RecordingsManager(ABC):
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.
Default raises UnsupportedOperationError (a NotImplementedYetError
subclass, so old handlers still match). "Not supported by this
provider" is not the same as "planned, not done yet". Providers
that support scheduling override this AND supports_scheduling.
"""
raise NotImplementedYetError(
f"{self.__class__.__name__}.schedule_recording is not "
f"implemented"
raise UnsupportedOperationError(
f"{self.__class__.__name__} does not support schedule_recording"
)
+20 -26
View File
@@ -10,6 +10,7 @@ Public interface
get_vod_manifest(content_id, **kw) -> Optional[str] [abstract]
search_vod(query, cursor=None, page_size=24, **kw) -> VodPage [concrete]
get_vod_manifest_headers(content_id, **kw) -> Dict[str, str] [concrete]
get_segment_headers(content_id, **kw) -> Dict[str, str] [concrete]
get_vod_drm(content_id, **kw) -> List[DRMConfig] [concrete]
content_id grammar
@@ -24,9 +25,9 @@ documents it here. The base class does not parse content_id. Examples:
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. A typo at a call site becomes an immediate TypeError.
Four required keyword-only collaborators (see ManagerBase). No **kwargs.
Subclasses accept extra keyword-only args explicitly and call
super().__init__ with only the four required.
Return-value conventions
------------------------
@@ -37,37 +38,17 @@ propagate as exceptions from base.errors.
from __future__ import annotations
from abc import ABC, abstractmethod
from abc import abstractmethod
from typing import Any, Dict, List, Optional
from ..models import DRMConfig
from ..protocols import AuthProtocol
from ..utils.logger import logger
from ..vod import VodPage
from ._base import ManagerBase
class VodManager(ABC):
class VodManager(ManagerBase):
"""Abstract base for provider VOD 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
# ------------------------------------------------------------------
# Routing
# ------------------------------------------------------------------
@@ -78,6 +59,7 @@ class VodManager(ABC):
Default: True. Override in providers whose VOD content_ids have a
distinguishable grammar (e.g. start with "details_" or "clip_").
Cheap, I/O-free PRE-FILTER: False means the manager is never asked.
"""
return True
@@ -123,6 +105,18 @@ class VodManager(ABC):
"""Headers for the manifest request. Default: auth headers."""
return self.auth.build_headers()
def get_segment_headers(
self, content_id: str, **kw: Any
) -> Dict[str, str]:
"""
Headers for segment requests. Default: manifest headers.
Mirrors ChannelManager.get_segment_headers so the orchestrator can
ask any routed manager for segment headers. Override for providers
with token-bound segment URLs.
"""
return self.get_vod_manifest_headers(content_id, **kw)
def get_vod_drm(
self, content_id: str, **kw: Any
) -> List[DRMConfig]: