mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-10-09 09:22:25 +02:00
round2 of new abstract managers
This commit is contained in:
@@ -1,18 +1,18 @@
|
||||
# streaming_providers/base/managed_provider.py
|
||||
"""
|
||||
ManagedProvider -- StreamingProvider plus manager composition. DRAFT.
|
||||
ManagedProvider -- StreamingProvider plus manager composition. DRAFT (round 2).
|
||||
|
||||
Supersedes provider_v2.py. It SUBCLASSES StreamingProvider, so registry
|
||||
metadata, isinstance checks in the backend and the legacy mixins keep
|
||||
working. Only new/migrated providers use it; legacy providers are
|
||||
untouched.
|
||||
It SUBCLASSES StreamingProvider, so registry metadata, isinstance checks in
|
||||
the backend and the legacy mixins keep working. Only new/migrated providers
|
||||
use it; legacy providers are untouched.
|
||||
|
||||
It moves out of every provider (see simplitv/provider.py) what is
|
||||
identical in all of them:
|
||||
It moves out of every provider what is identical in all of them:
|
||||
* the _build_*() hooks (default None = capability absent)
|
||||
* the implements_* flags
|
||||
* _route() and the default get_manifest() / get_drm() routing
|
||||
* get_channels(), EPG + header delegation, DRM validation
|
||||
* delegation of the legacy mixin surface (VOD, recordings, favorites,
|
||||
bookmarks, catchup) to the manager ABCs -- round 2
|
||||
|
||||
A provider still owns: http/auth/cache setup, the _build_*() bodies, and
|
||||
anything with provider-specific grammar (simplitv: the catchup: branch
|
||||
@@ -38,6 +38,25 @@ Routing rules
|
||||
5. The manager that resolved an id is remembered (bounded, locked), so
|
||||
headers go to the same manager that produced the manifest.
|
||||
6. content_type narrows only for "live" and "vod"; anything else widens.
|
||||
(The backend never passes content_type -- drm_operations calls
|
||||
get_drm(content_id=..., **kw) -- so in practice rules 1-5 decide.)
|
||||
|
||||
Legacy surface -> manager (round 2)
|
||||
-----------------------------------
|
||||
Backend call (via *Operations) Manager method
|
||||
----------------------------------------- ------------------------------------
|
||||
get_vod_category / search_vod VodManager (VodPage -> paged dict)
|
||||
get_recordings(include_deleted=) RecordingsManager.get_recordings
|
||||
delete_recording(recording_id) RecordingsManager.delete_recording
|
||||
get_favorites / add_favorite / remove_... FavoritesManager
|
||||
get_bookmarks / update_bookmark / delete_.. BookmarksManager
|
||||
catchup_window / get_catchup_window_for_.. CatchupManager.catchup_window_hours /
|
||||
catchup_window_for_channel
|
||||
get_catchup_manifest(+_headers) CatchupManager
|
||||
get_catchup_drm CatchupManager; [] -> NotImplementedError
|
||||
(see CATCHUP_DRM_FROM_LIVE)
|
||||
get_segment_headers(id, start_time=...) CatchupManager.get_catchup_segment_headers
|
||||
Not delegated (no manager ABC): timers, events, subscriptions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -58,11 +77,15 @@ from .managers import (
|
||||
VodManager,
|
||||
)
|
||||
from .models import StreamingChannel
|
||||
from .models.bookmark import Bookmark, ContentType
|
||||
from .models.favorite import Favorite, FavoriteType
|
||||
from .models.recording import Recording
|
||||
# Module paths below assume models/drm/{exceptions,drm_config}.py (see TODO R-1).
|
||||
from .models.drm.drm_config import validate_drm_set
|
||||
from .models.drm.exceptions import DRMError
|
||||
from .protocols import DrmManagerProtocol
|
||||
from .provider import StreamingProvider
|
||||
from .vod import normalize_vod_result
|
||||
|
||||
_ROUTED = ("channels", "vod")
|
||||
|
||||
@@ -76,6 +99,12 @@ class ManagedProvider(StreamingProvider):
|
||||
# auth.build_headers(), NOT the legacy {}). Set False to keep legacy {}.
|
||||
HEADERS_FROM_MANAGERS: ClassVar[bool] = True
|
||||
|
||||
# False: an empty CatchupManager.get_catchup_drm() result raises
|
||||
# NotImplementedError, which the backend maps to "extract PSSH from the
|
||||
# catchup manifest" (the legacy mixin contract). True: reuse the live DRM
|
||||
# -- only for providers whose catchup is encrypted exactly like live.
|
||||
CATCHUP_DRM_FROM_LIVE: ClassVar[bool] = False
|
||||
|
||||
ROUTE_CACHE_SIZE: ClassVar[int] = 512
|
||||
|
||||
# NOTE: `self.channels` holds the ChannelManager here (template
|
||||
@@ -163,6 +192,9 @@ class ManagedProvider(StreamingProvider):
|
||||
@property
|
||||
def implements_epg(self) -> bool:
|
||||
# Manager present AND it reports a window (epg_window != (0, 0)).
|
||||
# NOTE: EPGOperations uses this flag to choose between the native
|
||||
# path and the generic XMLTV path, so a manager that forgets to set
|
||||
# epg_window silently switches the provider to XMLTV.
|
||||
return self.epg is not None and self.epg.implements_epg
|
||||
|
||||
@property
|
||||
@@ -258,6 +290,9 @@ class ManagedProvider(StreamingProvider):
|
||||
# The legacy ProviderEpgMixin never looks at self.epg: epg_window is
|
||||
# (0, 0), get_epg() returns [] and get_epg_grid() returns {} unless the
|
||||
# provider overrides them. Delegate once, here.
|
||||
# EPGOperations passes limit= and country= to get_epg; `limit` travels
|
||||
# in **kw to the manager, `country` is dropped (the manager has its own).
|
||||
# It hands over timezone-aware UTC datetimes.
|
||||
|
||||
@property
|
||||
def epg_window(self) -> Tuple[int, int]:
|
||||
@@ -307,6 +342,8 @@ class ManagedProvider(StreamingProvider):
|
||||
channels = self.get_channels()
|
||||
return super().to_output_format(channels)
|
||||
|
||||
# --- Manifest / headers -------------------------------------------
|
||||
|
||||
def get_manifest(self, content_id: str, **kw: Any) -> Optional[str]:
|
||||
return self._route(content_id, [
|
||||
("channels", lambda m: m.get_channel_manifest(content_id, **kw)),
|
||||
@@ -325,11 +362,27 @@ class ManagedProvider(StreamingProvider):
|
||||
|
||||
def get_segment_headers(self, content_id: str, **kw: Any) -> Dict[str, str]:
|
||||
if self.HEADERS_FROM_MANAGERS:
|
||||
# The backend's catchup DRM/segment pipeline calls
|
||||
# get_segment_headers(id, start_time=..., end_time=..., epg_id=...)
|
||||
# (drm_operations) and swallows exceptions; live calls carry no
|
||||
# start_time. start_time therefore marks a catchup request.
|
||||
if self.catchup is not None and "start_time" in kw:
|
||||
rest = dict(kw)
|
||||
start = rest.pop("start_time")
|
||||
return self.catchup.get_catchup_segment_headers(
|
||||
content_id,
|
||||
start,
|
||||
end_time=rest.pop("end_time", None),
|
||||
epg_id=rest.pop("epg_id", None),
|
||||
**rest,
|
||||
)
|
||||
name, mgr = self._routed_manager(content_id)
|
||||
if name in _ROUTED: # both ChannelManager and VodManager have the hook
|
||||
return mgr.get_segment_headers(content_id, **kw)
|
||||
return self.get_manifest_headers(content_id, **kw)
|
||||
|
||||
# --- DRM -----------------------------------------------------------
|
||||
|
||||
def get_drm(
|
||||
self,
|
||||
content_id: str,
|
||||
@@ -337,6 +390,9 @@ class ManagedProvider(StreamingProvider):
|
||||
content_type: Optional[str] = None, # "live" | "vod" narrow; else widen
|
||||
**kw: Any,
|
||||
) -> List:
|
||||
# drm_operations calls get_drm(content_id=..., **kw) with keywords
|
||||
# only (drm_variant, preferred_quality, preferred_format, proxy
|
||||
# extras) and never passes content_type.
|
||||
if drm_variant is not None:
|
||||
kw["drm_variant"] = drm_variant
|
||||
|
||||
@@ -373,4 +429,215 @@ class ManagedProvider(StreamingProvider):
|
||||
f"{type(self).__name__}: invalid DRM configuration for "
|
||||
f"{content_id}: {exc}"
|
||||
) from exc
|
||||
return configs
|
||||
return configs
|
||||
|
||||
# --- Catchup -------------------------------------------------------
|
||||
# Legacy ProviderCatchupMixin contract (see its docstrings):
|
||||
# * catchup_window (hours) drives supports_catchup and
|
||||
# validate_catchup_request; it must be the provider-wide MAXIMUM.
|
||||
# * get_catchup_window_for_channel() is the per-channel hook.
|
||||
# * get_catchup_manifest never falls back to the live manifest.
|
||||
# * get_catchup_drm raises NotImplementedError => the pipeline extracts
|
||||
# PSSH from the catchup manifest.
|
||||
# Parameter names (content_id, start_time, end_time, epg_id) must stay
|
||||
# identical: the legacy get_catchup_manifest_with_headers() calls these
|
||||
# methods by keyword.
|
||||
|
||||
@property
|
||||
def catchup_window(self) -> int:
|
||||
return self.catchup.catchup_window_hours if self.catchup is not None else 0
|
||||
|
||||
def get_catchup_window_for_channel(self, content_id: str) -> int:
|
||||
if self.catchup is None:
|
||||
return 0
|
||||
return self.catchup.catchup_window_for_channel(content_id)
|
||||
|
||||
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]:
|
||||
if self.catchup is None:
|
||||
raise NotImplementedError(
|
||||
f"{type(self).__name__} has no catchup manager"
|
||||
)
|
||||
return self.catchup.get_catchup_manifest(
|
||||
content_id, start_time, end_time=end_time, epg_id=epg_id, **kw
|
||||
)
|
||||
|
||||
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]:
|
||||
if not self.HEADERS_FROM_MANAGERS or self.catchup is None:
|
||||
return super().get_catchup_manifest_headers(
|
||||
content_id, start_time, end_time, epg_id, **kw
|
||||
)
|
||||
return self.catchup.get_catchup_manifest_headers(
|
||||
content_id, start_time, end_time=end_time, epg_id=epg_id, **kw
|
||||
)
|
||||
|
||||
def get_catchup_drm(
|
||||
self,
|
||||
content_id: str,
|
||||
start_time: int,
|
||||
end_time: Optional[int] = None,
|
||||
epg_id: Optional[str] = None,
|
||||
drm_variant: Optional[str] = None,
|
||||
**kw: Any,
|
||||
) -> List:
|
||||
if self.catchup is None:
|
||||
raise NotImplementedError(
|
||||
f"{type(self).__name__} has no catchup manager"
|
||||
)
|
||||
if drm_variant is not None:
|
||||
kw["drm_variant"] = drm_variant
|
||||
configs = self.catchup.get_catchup_drm(
|
||||
content_id, start_time, end_time=end_time, epg_id=epg_id, **kw
|
||||
)
|
||||
if configs:
|
||||
return self._validate_drm(content_id, configs)
|
||||
if self.CATCHUP_DRM_FROM_LIVE:
|
||||
return self.get_drm(content_id, content_type="live", **kw)
|
||||
raise NotImplementedError(
|
||||
f"{type(self).__name__}: no catchup-specific DRM; the DRM "
|
||||
f"pipeline extracts PSSH from the catchup manifest"
|
||||
)
|
||||
|
||||
# --- VOD -----------------------------------------------------------
|
||||
# VodOperations reads {"entries", "next_cursor", "total"} (or a list).
|
||||
|
||||
@staticmethod
|
||||
def _vod_result(result: Any) -> Dict[str, Any]:
|
||||
"""
|
||||
Manager result -> the dict VodOperations reads.
|
||||
|
||||
normalize_vod_result (base/vod.py) is the single bridge for VodPage,
|
||||
legacy dict, bare list and None. Entries stay model objects (as
|
||||
legacy providers returned them); VodPage.to_dict() would serialise
|
||||
them, which is the route layer's job.
|
||||
"""
|
||||
page = normalize_vod_result(result)
|
||||
return {
|
||||
"entries": list(page.entries),
|
||||
"next_cursor": page.next_cursor,
|
||||
"total": page.total,
|
||||
}
|
||||
|
||||
def get_vod_category(
|
||||
self,
|
||||
content_id: str = "",
|
||||
cursor: Optional[str] = None,
|
||||
page_size: int = 24,
|
||||
**kw: Any,
|
||||
):
|
||||
if self.vod is None:
|
||||
return super().get_vod_category(
|
||||
content_id, cursor=cursor, page_size=page_size, **kw
|
||||
)
|
||||
return self._vod_result(
|
||||
self.vod.get_vod_category(
|
||||
content_id, cursor=cursor, page_size=page_size, **kw
|
||||
)
|
||||
)
|
||||
|
||||
def search_vod(
|
||||
self,
|
||||
query: str,
|
||||
cursor: Optional[str] = None,
|
||||
page_size: int = 24,
|
||||
**kw: Any,
|
||||
):
|
||||
if self.vod is None:
|
||||
return super().search_vod(
|
||||
query, cursor=cursor, page_size=page_size, **kw
|
||||
)
|
||||
return self._vod_result(
|
||||
self.vod.search_vod(query, cursor=cursor, page_size=page_size, **kw)
|
||||
)
|
||||
|
||||
# --- Recordings ----------------------------------------------------
|
||||
|
||||
def get_recordings(
|
||||
self, include_deleted: bool = False, **kw: Any
|
||||
) -> List[Recording]:
|
||||
if self.recordings is None:
|
||||
return super().get_recordings(include_deleted=include_deleted, **kw)
|
||||
return self.recordings.get_recordings(include_deleted=include_deleted, **kw)
|
||||
|
||||
def delete_recording(self, recording_id: str, **kw: Any) -> None:
|
||||
if self.recordings is None:
|
||||
return super().delete_recording(recording_id, **kw)
|
||||
return self.recordings.delete_recording(recording_id, **kw)
|
||||
|
||||
# --- Favorites -----------------------------------------------------
|
||||
|
||||
def get_favorites(self, **kw: Any) -> List[Favorite]:
|
||||
if self.favorites is None:
|
||||
return super().get_favorites(**kw)
|
||||
return self.favorites.get_favorites(**kw)
|
||||
|
||||
def add_favorite(
|
||||
self,
|
||||
content_id: str,
|
||||
favorite_type: FavoriteType,
|
||||
title: Optional[str] = None,
|
||||
**kw: Any,
|
||||
) -> Favorite:
|
||||
if self.favorites is None:
|
||||
return super().add_favorite(content_id, favorite_type, title, **kw)
|
||||
return self.favorites.add_favorite(
|
||||
content_id, favorite_type=favorite_type, title=title, **kw
|
||||
)
|
||||
|
||||
def remove_favorite(self, content_id: str, **kw: Any) -> None:
|
||||
if self.favorites is None:
|
||||
return super().remove_favorite(content_id, **kw)
|
||||
return self.favorites.remove_favorite(content_id, **kw)
|
||||
|
||||
# --- Bookmarks -----------------------------------------------------
|
||||
# batch_update_bookmarks() stays the legacy implementation: it calls
|
||||
# self.update_bookmark() per item, i.e. this delegation.
|
||||
# Argument order follows the legacy mixin (position_seconds before
|
||||
# content_type); ProviderManager's facade uses the opposite order, so
|
||||
# callers must use keywords (verified: bookmark_operations calls by keyword).
|
||||
|
||||
def get_bookmarks(self, **kw: Any) -> List[Bookmark]:
|
||||
if self.bookmarks is None:
|
||||
return super().get_bookmarks(**kw)
|
||||
return self.bookmarks.get_bookmarks(**kw)
|
||||
|
||||
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:
|
||||
if self.bookmarks is None:
|
||||
return super().update_bookmark(
|
||||
content_id, position_seconds, content_type,
|
||||
duration_seconds, title, **kw,
|
||||
)
|
||||
return self.bookmarks.update_bookmark(
|
||||
content_id,
|
||||
position_seconds=position_seconds,
|
||||
content_type=content_type,
|
||||
duration_seconds=duration_seconds,
|
||||
title=title,
|
||||
**kw,
|
||||
)
|
||||
|
||||
def delete_bookmark(self, content_id: str, **kw: Any) -> None:
|
||||
if self.bookmarks is None:
|
||||
return super().delete_bookmark(content_id, **kw)
|
||||
return self.bookmarks.delete_bookmark(content_id, **kw)
|
||||
|
||||
@@ -49,4 +49,4 @@ class ManagerBase(ABC):
|
||||
self.http_manager = http_manager
|
||||
self.auth = auth
|
||||
self.country = country
|
||||
self.config = config
|
||||
self.config = config
|
||||
|
||||
@@ -5,6 +5,7 @@ CatchupManager ABC.
|
||||
Public interface
|
||||
----------------
|
||||
catchup_window_hours -> int [concrete]
|
||||
catchup_window_for_channel(content_id) -> int [concrete]
|
||||
supports_catchup -> bool [concrete]
|
||||
get_catchup_manifest(content_id, start_time, end_time=None, ...)
|
||||
-> Optional[str] [abstract]
|
||||
@@ -27,9 +28,13 @@ 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.
|
||||
get_catchup_drm returns [] when the provider has no catchup-specific DRM
|
||||
configuration, or a provider-specific list when catchup uses its own DRM.
|
||||
[] does NOT mean "same as live": ManagedProvider turns [] into
|
||||
NotImplementedError, which the DRM pipeline
|
||||
(drm_operations.get_catchup_content_drm_configs) reads as "extract the PSSH
|
||||
from the catchup manifest itself". Providers whose catchup is encrypted
|
||||
exactly like live set ManagedProvider.CATCHUP_DRM_FROM_LIVE = True.
|
||||
|
||||
What a catchup manifest is (and is not)
|
||||
----------------------------------------
|
||||
@@ -78,12 +83,22 @@ class CatchupManager(ManagerBase):
|
||||
@property
|
||||
def catchup_window_hours(self) -> int:
|
||||
"""
|
||||
Return the catchup window in hours.
|
||||
Provider-wide catchup window in hours (the MAXIMUM over all
|
||||
channels). Default 0 means no catchup. Providers override.
|
||||
|
||||
Default 0 means no catchup. Providers override.
|
||||
This feeds the legacy provider.catchup_window, which the backend
|
||||
uses to gate catchup and to validate request age without knowing
|
||||
the channel. Per-channel windows go in catchup_window_for_channel().
|
||||
"""
|
||||
return 0
|
||||
|
||||
def catchup_window_for_channel(self, content_id: str) -> int:
|
||||
"""
|
||||
Catchup window in hours for one channel. Default: the provider-wide
|
||||
window. Override when windows differ per channel (simpliTV: 2/3/4 h).
|
||||
"""
|
||||
return self.catchup_window_hours
|
||||
|
||||
@property
|
||||
def supports_catchup(self) -> bool:
|
||||
"""True when catchup_window_hours > 0."""
|
||||
@@ -176,8 +191,11 @@ class CatchupManager(ManagerBase):
|
||||
"""
|
||||
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.
|
||||
Default: [] = no catchup-specific DRM configuration. ManagedProvider
|
||||
raises NotImplementedError for [], so the DRM pipeline extracts the
|
||||
PSSH from the catchup manifest (also the right outcome for clear
|
||||
streams). It does NOT fall back to live DRM unless the provider sets
|
||||
CATCHUP_DRM_FROM_LIVE = True.
|
||||
|
||||
Providers whose catchup uses a distinct DRM configuration
|
||||
(different license URL, different PSSH) override this.
|
||||
|
||||
@@ -13,10 +13,10 @@ Public interface
|
||||
|
||||
Constructor contract
|
||||
--------------------
|
||||
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.
|
||||
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.
|
||||
|
||||
Return-value conventions
|
||||
------------------------
|
||||
@@ -48,8 +48,7 @@ class ChannelManager(ManagerBase):
|
||||
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. It is a cheap, I/O-free
|
||||
PRE-FILTER: False means the manager is never asked.
|
||||
a wasted request to the wrong manager.
|
||||
"""
|
||||
return True
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ 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).
|
||||
them (PROGRAM, CLIP, LIVE, EVENT).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -5,9 +5,9 @@ RecordingsManager ABC.
|
||||
Public interface
|
||||
----------------
|
||||
handles_recording_id(recording_id) -> bool [concrete]
|
||||
get_recordings(**kw) -> List[Channel] [abstract]
|
||||
get_recordings(**kw) -> List[Recording] [abstract]
|
||||
delete_recording(recording_id, **kw) -> None [abstract]
|
||||
schedule_recording(content_id, **kw) -> Channel|bool [concrete, optional]
|
||||
schedule_recording(content_id, **kw) -> Recording|bool [concrete, optional]
|
||||
|
||||
Constructor contract
|
||||
--------------------
|
||||
@@ -43,7 +43,7 @@ from abc import abstractmethod
|
||||
from typing import Any, List
|
||||
|
||||
from ..errors import UnsupportedOperationError
|
||||
from ..models import Channel
|
||||
from ..models.recording import Recording
|
||||
from ._base import ManagerBase
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ class RecordingsManager(ManagerBase):
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@abstractmethod
|
||||
def get_recordings(self, **kw: Any) -> List[Channel]:
|
||||
def get_recordings(self, **kw: Any) -> List[Recording]:
|
||||
"""
|
||||
Return recordings for the authenticated user.
|
||||
|
||||
@@ -105,16 +105,17 @@ class RecordingsManager(ManagerBase):
|
||||
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.
|
||||
Items MUST be models.recording.Recording (or a subclass).
|
||||
RecordingOperations filters on ``Recording.is_deleted`` and the
|
||||
legacy mixin is typed List[Recording]; a bare Channel has no
|
||||
``is_deleted`` and breaks that filter.
|
||||
|
||||
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.
|
||||
``include_deleted`` arrives in **kw (RecordingOperations always
|
||||
passes it). Honour it when the backend can list deleted items;
|
||||
otherwise ignore it -- the caller filters again.
|
||||
|
||||
Providers whose recordings carry extra fields subclass Recording and
|
||||
document them in the subclass's docstring.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -150,7 +151,7 @@ class RecordingsManager(ManagerBase):
|
||||
Schedule a recording of content_id.
|
||||
|
||||
Optional. Return value is provider-specific: some providers
|
||||
return the created recording (a Channel), others return a bool
|
||||
return the created recording (a Recording), others return a bool
|
||||
indicating success. The ABC does not constrain the shape
|
||||
because there is no shared one across providers.
|
||||
|
||||
|
||||
@@ -25,9 +25,9 @@ documents it here. The base class does not parse content_id. Examples:
|
||||
|
||||
Constructor contract
|
||||
--------------------
|
||||
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.
|
||||
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.
|
||||
|
||||
Return-value conventions
|
||||
------------------------
|
||||
@@ -59,7 +59,6 @@ class VodManager(ManagerBase):
|
||||
|
||||
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
|
||||
|
||||
@@ -108,13 +107,7 @@ class VodManager(ManagerBase):
|
||||
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.
|
||||
"""
|
||||
"""Headers for segment requests. Default: VOD manifest headers."""
|
||||
return self.get_vod_manifest_headers(content_id, **kw)
|
||||
|
||||
def get_vod_drm(
|
||||
|
||||
@@ -77,6 +77,26 @@ class ProviderMetadataMixin:
|
||||
"""
|
||||
return cls.SUPPORTED_COUNTRIES.copy()
|
||||
|
||||
@classmethod
|
||||
def get_plugin_key(cls) -> str:
|
||||
"""
|
||||
The key this class is registered under in AVAILABLE_PROVIDERS (the
|
||||
provider package's directory name, e.g. "simpli").
|
||||
|
||||
Falls back to the class-name derivation ("SimpliTVProvider" ->
|
||||
"simplitv") only when the class is not registered (e.g. unit tests).
|
||||
The derivation used to be the only source and disagreed with the
|
||||
registry key for renamed providers.
|
||||
"""
|
||||
try:
|
||||
from ... import AVAILABLE_PROVIDERS # lazy: package init imports us
|
||||
except ImportError:
|
||||
AVAILABLE_PROVIDERS = {}
|
||||
for key, klass in AVAILABLE_PROVIDERS.items():
|
||||
if klass is cls:
|
||||
return key
|
||||
return cls.__name__.lower().replace("provider", "")
|
||||
|
||||
@classmethod
|
||||
def get_all_possible_instances(cls) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
@@ -91,7 +111,7 @@ class ProviderMetadataMixin:
|
||||
for country in cls.SUPPORTED_COUNTRIES:
|
||||
instances.append(
|
||||
{
|
||||
"plugin": cls.__name__.lower().replace("provider", ""),
|
||||
"plugin": cls.get_plugin_key(),
|
||||
"country": country.upper(),
|
||||
"label": cls.get_static_label(country),
|
||||
"requires_country_suffix": True,
|
||||
@@ -101,7 +121,7 @@ class ProviderMetadataMixin:
|
||||
# Single-country provider
|
||||
instances.append(
|
||||
{
|
||||
"plugin": cls.__name__.lower().replace("provider", ""),
|
||||
"plugin": cls.get_plugin_key(),
|
||||
"country": "DE", # Default country for single-country providers
|
||||
"label": cls.get_static_label(),
|
||||
"requires_country_suffix": False,
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
# streaming_providers/providers/simpli/__init__.py
|
||||
"""
|
||||
simpliTV streaming provider.
|
||||
|
||||
Registered via directory-based discovery. The module-level import here
|
||||
is what makes the provider visible to the host.
|
||||
"""
|
||||
|
||||
from .provider import SimpliTVProvider
|
||||
|
||||
__all__ = ["SimpliTVProvider"]
|
||||
|
||||
@@ -29,10 +29,12 @@ that value from the channel manager's AcquireContent response. If the
|
||||
field is missing, the smallest observed window (2h) is used, so a seek
|
||||
can never land outside the real window.
|
||||
|
||||
The ABC exposes catchup_window_hours as a single integer, which cannot
|
||||
express a per-channel value. It returns the same smallest observed
|
||||
window as the conservative answer: a host that trusts it will only
|
||||
offer replays the shortest channel allows.
|
||||
The ABC exposes catchup_window_hours as the provider-wide MAXIMUM (the
|
||||
backend's validate_catchup_request uses it without knowing the channel),
|
||||
and catchup_window_for_channel() as the per-channel value. The per-channel
|
||||
limit is enforced here: get_restart_manifest() returns None for a start
|
||||
outside the channel's own window, so a 4h global value never lets a 2h
|
||||
channel seek outside its DVR window.
|
||||
|
||||
Contract
|
||||
--------
|
||||
@@ -42,7 +44,7 @@ swallowed). end_time is ignored: the API has no end bound.
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import List, Optional, Tuple
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from ...base.errors import BadRequestError, NotFoundError
|
||||
from ...base.managers import CatchupManager
|
||||
@@ -56,9 +58,12 @@ from .channel_manager import (
|
||||
from .constants import SimpliTVDefaults
|
||||
|
||||
|
||||
# Smallest timeshift window observed across channels (7200s = 2h). Used
|
||||
# as catchup_window_hours and as the per-channel fallback.
|
||||
# Timeshift windows observed across channels: 7200 s (2h), 10800 s (3h),
|
||||
# 14400 s (4h). The minimum is the per-channel fallback; the maximum is the
|
||||
# provider-wide catchup_window_hours. Set _MAX_TIMESHIFT_HOURS back to
|
||||
# _MIN_TIMESHIFT_HOURS to restore the previous conservative global value.
|
||||
_MIN_TIMESHIFT_HOURS = 2
|
||||
_MAX_TIMESHIFT_HOURS = 4
|
||||
|
||||
|
||||
class SimpliTVCatchupManager(CatchupManager):
|
||||
@@ -88,14 +93,27 @@ class SimpliTVCatchupManager(CatchupManager):
|
||||
@property
|
||||
def catchup_window_hours(self) -> int:
|
||||
"""
|
||||
Conservative global window, in hours.
|
||||
|
||||
The API advertises per-channel windows (2h, 3h, 4h); the ABC
|
||||
only exposes a single integer, so the smallest observed value
|
||||
is returned. get_restart_manifest uses the real per-channel
|
||||
value.
|
||||
Provider-wide window in hours: the largest observed per-channel
|
||||
window (4h). Per-channel limits are enforced in
|
||||
get_restart_manifest(); see catchup_window_for_channel().
|
||||
"""
|
||||
return _MIN_TIMESHIFT_HOURS
|
||||
return _MAX_TIMESHIFT_HOURS
|
||||
|
||||
def catchup_window_for_channel(self, content_id: str) -> int:
|
||||
"""
|
||||
This channel's DVR window in hours (AcquireContent
|
||||
AdditionalInfo.Epg_TimeshiftSeconds, floored), or the 2h minimum
|
||||
when it cannot be determined. Costs at most one cached
|
||||
AcquireContent call (PLAYBACK_CACHE_TTL).
|
||||
"""
|
||||
if self._channels is None:
|
||||
return _MIN_TIMESHIFT_HOURS
|
||||
try:
|
||||
codename, _ = _channel_and_ts(content_id)
|
||||
acquire = self._channels.acquire_content(codename)
|
||||
except (BadRequestError, NotFoundError):
|
||||
return _MIN_TIMESHIFT_HOURS
|
||||
return max(1, _timeshift_seconds(acquire) // 3600)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Abstract method
|
||||
@@ -182,6 +200,32 @@ class SimpliTVCatchupManager(CatchupManager):
|
||||
# Concrete overrides
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_catchup_manifest_headers(
|
||||
self,
|
||||
content_id: str,
|
||||
start_time: int,
|
||||
end_time: Optional[int] = None,
|
||||
epg_id: Optional[str] = None,
|
||||
**kw,
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
Player headers: User-Agent + Origin, as for live (see
|
||||
SimpliTVConfig.get_stream_headers()). The ABC default would send
|
||||
the API headers (JSON Content-Type, tenant, Referer) to the CDN.
|
||||
"""
|
||||
return self.config.get_stream_headers()
|
||||
|
||||
def get_catchup_segment_headers(
|
||||
self,
|
||||
content_id: str,
|
||||
start_time: int,
|
||||
end_time: Optional[int] = None,
|
||||
epg_id: Optional[str] = None,
|
||||
**kw,
|
||||
) -> Dict[str, str]:
|
||||
"""Segment headers: same as the manifest headers (CDN, no token)."""
|
||||
return self.config.get_stream_headers()
|
||||
|
||||
def get_catchup_drm(
|
||||
self,
|
||||
content_id: str,
|
||||
|
||||
@@ -119,6 +119,17 @@ class SimpliTVEpgManager(EpgManager):
|
||||
"""
|
||||
return self._fetch_available_window()
|
||||
|
||||
@property
|
||||
def implements_epg(self) -> bool:
|
||||
"""
|
||||
Always True. The ABC derives this from epg_window != (0, 0), but
|
||||
epg_window here is a (cached) GetAvailableDays request, and the
|
||||
flag is read on every get_epg call and in the registry listing.
|
||||
The window is informational and never (0, 0) (it falls back to
|
||||
the EPG_PAST_DAYS / EPG_FUTURE_DAYS constants).
|
||||
"""
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# get_epg / get_epg_grid
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -32,6 +32,7 @@ from typing import Any, Dict
|
||||
|
||||
from ...base.auth.base_auth import BaseAuthToken
|
||||
from ...base.models import Channel
|
||||
from ...base.models.recording import Recording
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -101,4 +102,46 @@ class SimpliTVChannel(Channel):
|
||||
result["IsCatchupEnabled"] = self.is_catchup_enabled
|
||||
result["RecordingId"] = self.recording_id
|
||||
result["RecordingStatus"] = self.recording_status
|
||||
return result
|
||||
return result
|
||||
|
||||
@dataclass
|
||||
class SimpliTVRecording(Recording):
|
||||
"""
|
||||
One NPvR recording.
|
||||
|
||||
Inherits Recording (not Channel): RecordingOperations filters on
|
||||
`is_deleted`, and the legacy mixin is typed List[Recording].
|
||||
|
||||
Ids (two namespaces):
|
||||
content_id "rec:<programme codename>" -- plays the recording
|
||||
(get_manifest / get_drm) AND is what clients hold, since
|
||||
Recording.recording_id is an alias of content_id.
|
||||
remote_id the provider's recordingId -- what DeleteRecording takes.
|
||||
SimpliTVRecordingsManager.delete_recording accepts either.
|
||||
|
||||
remote_status is the raw API status ("Recorded", "Scheduled",
|
||||
"Failed"); `status` is the mapped RecordingStatus. Only "Recorded" is
|
||||
playable. to_dict() keeps the keys the former SimpliTVChannel-based
|
||||
recordings emitted (Codename, RecordingId, RecordingStatus,
|
||||
CurrentStart, CurrentStop).
|
||||
"""
|
||||
|
||||
codename: str = ""
|
||||
remote_id: str = ""
|
||||
remote_status: str = ""
|
||||
current_start: str = ""
|
||||
current_stop: str = ""
|
||||
|
||||
@property
|
||||
def is_playable(self) -> bool:
|
||||
return self.remote_status == "Recorded"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
result = super().to_dict()
|
||||
result["Codename"] = self.codename
|
||||
result["CurrentProgramme"] = self.name
|
||||
result["CurrentStart"] = self.current_start
|
||||
result["CurrentStop"] = self.current_stop
|
||||
result["RecordingId"] = self.remote_id
|
||||
result["RecordingStatus"] = self.remote_status
|
||||
return result
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
"""
|
||||
simpliTV orchestrator.
|
||||
|
||||
Owns shared resources (http_manager, caches, auth, managers) and
|
||||
exposes the public StreamingProvider interface.
|
||||
Owns shared resources (http_manager, caches, auth) and builds the
|
||||
managers; everything that is identical across manager-based providers
|
||||
(capability flags, content-id routing, manifest/DRM/header/EPG delegation,
|
||||
the legacy recordings/catchup surface) lives in ManagedProvider.
|
||||
|
||||
Subclasses the existing StreamingProvider. Does NOT subclass any new
|
||||
base class. Authentication is lazy -- no network I/O in __init__.
|
||||
Subclasses ManagedProvider (itself a StreamingProvider). Authentication
|
||||
is lazy -- no network I/O in __init__.
|
||||
|
||||
Manager wiring
|
||||
--------------
|
||||
@@ -19,20 +21,19 @@ Manager wiring
|
||||
favorites -> None
|
||||
bookmarks -> None
|
||||
catchup -> SimpliTVCatchupManager (catchup: ids; borrows channels)
|
||||
drm -> None (folded into channels)
|
||||
drm -> None (DRM_IN_MANAGERS = True)
|
||||
|
||||
The catchup manager is built after the channel manager because it takes
|
||||
the channel manager as a collaborator. No other manager has
|
||||
cross-dependencies.
|
||||
the channel manager as a collaborator (_init_managers builds in
|
||||
dependency order). No other manager has cross-dependencies.
|
||||
|
||||
Routing
|
||||
-------
|
||||
_route is used only by get_manifest and get_drm -- the methods whose
|
||||
input is an opaque content_id with several possible owners. The channel
|
||||
manager owns live:, rec: and prog:; the catchup manager owns catchup:.
|
||||
For catchup: the router parses the @<ts> suffix and hands the manager a
|
||||
plain (content_id, start_time, end_time=None) triple. Malformed ids
|
||||
raise BadRequestError and are not swallowed.
|
||||
What stays here
|
||||
---------------
|
||||
Only grammar that belongs to simpliTV: the catchup:<channel>@<ts> id.
|
||||
get_manifest and get_drm hand catchup: ids to the catchup manager (the
|
||||
@<ts> suffix is parsed here into start_time); every other id goes to
|
||||
ManagedProvider's router. Malformed ids raise BadRequestError and are not
|
||||
swallowed.
|
||||
|
||||
Restart-from-beginning cannot be expressed as a manifest URL (it needs a
|
||||
player-side seek), so it has its own method: get_restart().
|
||||
@@ -41,19 +42,24 @@ Catchup windows
|
||||
---------------
|
||||
The DVR window is per-channel (AdditionalInfo.Epg_TimeshiftSeconds in
|
||||
the AcquireContent response: 2h, 3h or 4h in the browser capture).
|
||||
SimpliTVCatchupManager reads the per-channel value inside
|
||||
get_restart_manifest. Its catchup_window_hours property returns the
|
||||
conservative minimum (2h) because the ABC can only express a single
|
||||
integer.
|
||||
SimpliTVCatchupManager.catchup_window_hours is the provider-wide maximum
|
||||
(what the backend's validate_catchup_request sees), and
|
||||
catchup_window_for_channel() / get_restart_manifest() apply the real
|
||||
per-channel value.
|
||||
|
||||
Headers
|
||||
-------
|
||||
HEADERS_FROM_MANAGERS = True: manifest and segment requests carry the
|
||||
managers' headers (User-Agent + Origin, SimpliTVConfig.get_stream_headers).
|
||||
The previous provider returned {} because it never overrode
|
||||
get_manifest_headers, which made those manager hooks dead code. Set the
|
||||
flag to False to restore the old {}.
|
||||
"""
|
||||
|
||||
from typing import Any, Callable, ClassVar, Dict, List, Optional, Tuple
|
||||
from typing import ClassVar, Dict, List, Optional, Tuple
|
||||
|
||||
from ...base.errors import NotFoundError
|
||||
from ...base.managers import ChannelManager, VodManager
|
||||
from ...base.managed_provider import ManagedProvider
|
||||
from ...base.models.proxy_models import ProxyConfig
|
||||
from ...base.protocols import DrmManagerProtocol
|
||||
from ...base.provider import StreamingProvider
|
||||
|
||||
from .auth import SimpliTVAuth
|
||||
from .catchup_manager import SimpliTVCatchupManager
|
||||
@@ -62,8 +68,13 @@ from .constants import SimpliTVConfig, SimpliTVDefaults
|
||||
from .epg_manager import SimpliTVEpgManager
|
||||
from .recordings_manager import SimpliTVRecordingsManager
|
||||
|
||||
# Only the provider is public: provider discovery (streaming_providers/
|
||||
# __init__.py) takes the first StreamingProvider subclass it finds in the
|
||||
# package namespace, and ManagedProvider must never be that one.
|
||||
__all__ = ["SimpliTVProvider"]
|
||||
|
||||
class SimpliTVProvider(StreamingProvider):
|
||||
|
||||
class SimpliTVProvider(ManagedProvider):
|
||||
"""simpli streaming provider."""
|
||||
|
||||
PROVIDER_LABEL: ClassVar[str] = "simpli"
|
||||
@@ -71,16 +82,16 @@ class SimpliTVProvider(StreamingProvider):
|
||||
SUPPORTED_AUTH_TYPES: ClassVar[List[str]] = ["user_credentials"]
|
||||
SUPPORTED_COUNTRIES: ClassVar[List[str]] = ["AT"]
|
||||
|
||||
# Manifest and DRM share one /Player/AcquireContent response, so DRM is
|
||||
# served by SimpliTVChannelManager.get_channel_drm (no _build_drm()).
|
||||
DRM_IN_MANAGERS: ClassVar[bool] = True
|
||||
HEADERS_FROM_MANAGERS: ClassVar[bool] = True
|
||||
|
||||
@property
|
||||
def provider_name(self) -> str:
|
||||
"""Return the provider name (matches the directory / registry key)."""
|
||||
return SimpliTVDefaults.PROVIDER_NAME # "simpli"
|
||||
|
||||
# Only "live" and "vod" narrow the folded DRM search; anything else
|
||||
# (None, "event", "catchup", a typo) tries both domains.
|
||||
_LIVE_ONLY_CONTENT_TYPES = frozenset({"live"})
|
||||
_VOD_ONLY_CONTENT_TYPES = frozenset({"vod"})
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
country: str = "AT",
|
||||
@@ -111,15 +122,8 @@ class SimpliTVProvider(StreamingProvider):
|
||||
self._playback_cache: Dict = {}
|
||||
self._recordings_cache: Dict = {}
|
||||
|
||||
# 4. Managers, in dependency order. Catchup needs channels.
|
||||
self.channels = self._build_channels()
|
||||
self.vod = self._build_vod()
|
||||
self.epg = self._build_epg()
|
||||
self.recordings = self._build_recordings()
|
||||
self.favorites = self._build_favorites()
|
||||
self.bookmarks = self._build_bookmarks()
|
||||
self.catchup = self._build_catchup()
|
||||
self.drm = self._build_drm()
|
||||
# 4. Managers, in dependency order (catchup needs channels).
|
||||
self._init_managers()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Factory methods
|
||||
@@ -144,11 +148,6 @@ class SimpliTVProvider(StreamingProvider):
|
||||
playback_cache=self._playback_cache,
|
||||
)
|
||||
|
||||
def _build_vod(self):
|
||||
# No browseable VOD catalogue: content is live, catchup, or
|
||||
# recordings.
|
||||
return None
|
||||
|
||||
def _build_epg(self):
|
||||
return SimpliTVEpgManager(
|
||||
http_manager=self.http_manager,
|
||||
@@ -166,12 +165,6 @@ class SimpliTVProvider(StreamingProvider):
|
||||
recordings_cache=self._recordings_cache,
|
||||
)
|
||||
|
||||
def _build_favorites(self):
|
||||
return None # no favorites endpoint
|
||||
|
||||
def _build_bookmarks(self):
|
||||
return None # no bookmarks / resume endpoint
|
||||
|
||||
def _build_catchup(self):
|
||||
if self.channels is None:
|
||||
return None
|
||||
@@ -183,89 +176,11 @@ class SimpliTVProvider(StreamingProvider):
|
||||
channels=self.channels,
|
||||
)
|
||||
|
||||
def _build_drm(self) -> Optional[DrmManagerProtocol]:
|
||||
# Folded into SimpliTVChannelManager -- the manifest and DRM
|
||||
# share one /Player/AcquireContent response.
|
||||
return None
|
||||
# _build_vod / _build_favorites / _build_bookmarks / _build_drm:
|
||||
# inherited (None): no catalogue, favorites, bookmarks; DRM is folded.
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Capability flags
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@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:
|
||||
if self.drm is not None:
|
||||
return True
|
||||
folded_channels = (
|
||||
self.channels is not None
|
||||
and type(self.channels).get_channel_drm
|
||||
is not ChannelManager.get_channel_drm
|
||||
)
|
||||
folded_vod = (
|
||||
self.vod is not None
|
||||
and type(self.vod).get_vod_drm
|
||||
is not VodManager.get_vod_drm
|
||||
)
|
||||
return folded_channels or folded_vod
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Router
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _route(self, content_id: str, attempts: List[Tuple[Any, Callable]]):
|
||||
"""
|
||||
Try managers in order, skipping those whose handles_content_id()
|
||||
rejects the id. Used only by get_manifest / get_drm.
|
||||
|
||||
NotFoundError is remembered and re-raised if nobody else
|
||||
resolves the id ("existed but is gone" vs "nobody handles it").
|
||||
BadRequestError is not caught: a malformed id surfaces.
|
||||
"""
|
||||
last_not_found: Optional[NotFoundError] = None
|
||||
for manager, call in attempts:
|
||||
if manager is None or not manager.handles_content_id(content_id):
|
||||
continue
|
||||
try:
|
||||
result = call(manager)
|
||||
except NotFoundError as e:
|
||||
last_not_found = e
|
||||
continue
|
||||
if result:
|
||||
return result
|
||||
if last_not_found is not None:
|
||||
raise last_not_found
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Manifest routing
|
||||
# catchup: ids (simpliTV-specific grammar)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_manifest(self, content_id: str, **kw) -> Optional[str]:
|
||||
@@ -288,13 +203,34 @@ class SimpliTVProvider(StreamingProvider):
|
||||
return self.catchup.get_catchup_manifest(
|
||||
content_id, start_ts, None, **kw
|
||||
)
|
||||
return super().get_manifest(content_id, **kw)
|
||||
|
||||
return self._route(content_id, [
|
||||
(self.channels, lambda m: m.get_channel_manifest(
|
||||
content_id, **kw
|
||||
)),
|
||||
(self.vod, lambda m: m.get_vod_manifest(content_id, **kw)),
|
||||
])
|
||||
def get_drm(
|
||||
self,
|
||||
content_id: str,
|
||||
drm_variant: Optional[str] = None,
|
||||
content_type: Optional[str] = None,
|
||||
**kw,
|
||||
) -> List:
|
||||
"""
|
||||
DRM for the given content.
|
||||
|
||||
catchup: ids go to the catchup manager, which forwards to the
|
||||
channel manager's DRM (the programme's own when replaying, the
|
||||
channel's live DRM when restarting). Everything else is
|
||||
ManagedProvider's folded-DRM routing.
|
||||
"""
|
||||
if content_id.startswith(SimpliTVDefaults.CATCHUP_PREFIX):
|
||||
if self.catchup is None:
|
||||
return []
|
||||
_, start_ts = parse_catchup_id(content_id) # raises if bad
|
||||
if drm_variant is not None:
|
||||
kw["drm_variant"] = drm_variant
|
||||
configs = self.catchup.get_catchup_drm(
|
||||
content_id, start_ts, None, **kw
|
||||
)
|
||||
return self._validate_drm(content_id, configs)
|
||||
return super().get_drm(content_id, drm_variant, content_type, **kw)
|
||||
|
||||
def get_restart(
|
||||
self, content_id: str, start_time: int = 0, **kw
|
||||
@@ -306,59 +242,8 @@ class SimpliTVProvider(StreamingProvider):
|
||||
channel's DVR window) or None when the programme began outside
|
||||
that window. The window is per-channel (see "Catchup windows"
|
||||
above). The caller must seek; the URL alone plays live.
|
||||
start_time=0 means "use the @<ts> embedded in a catchup: id".
|
||||
"""
|
||||
if self.catchup is None:
|
||||
return None
|
||||
return self.catchup.get_restart_manifest(content_id, start_time)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# DRM routing
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_drm(
|
||||
self,
|
||||
content_id: str,
|
||||
content_type: Optional[str] = None,
|
||||
**kw,
|
||||
) -> List:
|
||||
"""
|
||||
DRM for the given content.
|
||||
|
||||
content_type is an optional narrowing hint ("live"/"vod" only;
|
||||
anything else tries both). catchup: ids go to the catchup
|
||||
manager, which forwards to the channel manager's DRM.
|
||||
"""
|
||||
if self.drm is not None:
|
||||
return self.drm.get_drm_configs(
|
||||
content_id, content_type=content_type, **kw
|
||||
)
|
||||
|
||||
if content_id.startswith(SimpliTVDefaults.CATCHUP_PREFIX):
|
||||
if self.catchup is None:
|
||||
return []
|
||||
_, start_ts = parse_catchup_id(content_id) # raises if bad
|
||||
return self.catchup.get_catchup_drm(
|
||||
content_id, start_ts, None, **kw
|
||||
)
|
||||
|
||||
attempts: List[Tuple[Any, Callable]] = []
|
||||
if content_type not in self._VOD_ONLY_CONTENT_TYPES:
|
||||
attempts.append(
|
||||
(self.channels, lambda m: m.get_channel_drm(
|
||||
content_id, **kw
|
||||
))
|
||||
)
|
||||
if content_type not in self._LIVE_ONLY_CONTENT_TYPES:
|
||||
attempts.append(
|
||||
(self.vod, lambda m: m.get_vod_drm(content_id, **kw))
|
||||
)
|
||||
return self._route(content_id, attempts) or []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Channels
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_channels(self, **kw):
|
||||
if self.channels is None:
|
||||
return []
|
||||
return self.channels.get_channels(**kw)
|
||||
@@ -15,16 +15,15 @@ Recording identity (kept strictly separate from content identity):
|
||||
This manager does not implement get_manifest -- do not add a parallel
|
||||
manifest path here.
|
||||
|
||||
get_recordings() returns SimpliTVChannel objects whose content_id is the
|
||||
"rec:..." id and whose recording_id carries the provider's id. Only
|
||||
recordings whose recording_status is "Recorded" are playable;
|
||||
"Scheduled" and "Failed" are listed but must not be played.
|
||||
get_recordings() returns SimpliTVRecording objects (a Recording
|
||||
subclass, so RecordingOperations can read `is_deleted`) whose content_id
|
||||
is the "rec:..." id and whose remote_id carries the provider's id. Only
|
||||
recordings whose remote_status is "Recorded" are playable; "Scheduled"
|
||||
and "Failed" are listed but must not be played.
|
||||
|
||||
Note on construction: the base Content dataclass declares `content_id`
|
||||
and a required `provider` field. `Channel.channel_id` is a property
|
||||
that proxies to `content_id`, but dataclass __init__ bypasses
|
||||
properties, so SimpliTVChannel is built with `content_id=` and
|
||||
`provider=`.
|
||||
Clients only hold the content_id (Recording.recording_id aliases it), so
|
||||
delete_recording accepts the "rec:<codename>" id as well as the provider's
|
||||
recordingId and resolves the former through GetRecordings.
|
||||
|
||||
Scheduling needs a *programme* codename (the EPG tile's own codename),
|
||||
not a channel codename: pass prog:<programme codename>.
|
||||
@@ -32,15 +31,15 @@ not a channel codename: pass prog:<programme codename>.
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from ...base.errors import BadRequestError
|
||||
from ...base.errors import BadRequestError, ItemNotFoundError
|
||||
from ...base.managers import RecordingsManager
|
||||
from ...base.models import Channel
|
||||
from ...base.models.recording import Recording, RecordingStatus
|
||||
from ...base.utils.logger import logger
|
||||
|
||||
from .channel_manager import parse_programme_id
|
||||
from .constants import SimpliTVDefaults
|
||||
from .helpers import transport_errors
|
||||
from .models import SimpliTVChannel
|
||||
from .helpers import parse_iso, transport_errors
|
||||
from .models import SimpliTVRecording
|
||||
|
||||
|
||||
class SimpliTVRecordingsManager(RecordingsManager):
|
||||
@@ -69,9 +68,18 @@ class SimpliTVRecordingsManager(RecordingsManager):
|
||||
# Abstract methods
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_recordings(self, **kw) -> List[Channel]:
|
||||
def get_recordings(self, **kw) -> List[Recording]:
|
||||
"""
|
||||
Return all NPvR recordings across every page ([] if none).
|
||||
Return all NPvR recordings ([] if none).
|
||||
|
||||
`include_deleted` (passed by RecordingOperations in **kw) is
|
||||
ignored: the API has no deleted state.
|
||||
"""
|
||||
return list(self._fetch_recordings())
|
||||
|
||||
def _fetch_recordings(self) -> List[SimpliTVRecording]:
|
||||
"""
|
||||
All NPvR recordings across every page.
|
||||
|
||||
The page index base is unverified (the addon's own paging is
|
||||
broken and limit=99999 normally returns everything in one page),
|
||||
@@ -79,7 +87,7 @@ class SimpliTVRecordingsManager(RecordingsManager):
|
||||
de-duplicated by recording id: a page that adds nothing new ends
|
||||
the walk instead of looping or returning duplicates.
|
||||
"""
|
||||
found: Dict[str, SimpliTVChannel] = {}
|
||||
found: Dict[str, SimpliTVRecording] = {}
|
||||
page = 0
|
||||
while True:
|
||||
# NOTE: GetRecordings names the token parameter `tokenValue`;
|
||||
@@ -108,12 +116,12 @@ class SimpliTVRecordingsManager(RecordingsManager):
|
||||
|
||||
added = 0
|
||||
for rec in data.get("recordings", []):
|
||||
channel = self._rec_to_channel(rec)
|
||||
key = channel.recording_id or (
|
||||
f"{channel.codename}:{channel.current_start}"
|
||||
recording = self._rec_to_recording(rec)
|
||||
key = recording.remote_id or (
|
||||
f"{recording.codename}:{recording.current_start}"
|
||||
)
|
||||
if key not in found:
|
||||
found[key] = channel
|
||||
found[key] = recording
|
||||
added += 1
|
||||
|
||||
page += 1
|
||||
@@ -129,20 +137,29 @@ class SimpliTVRecordingsManager(RecordingsManager):
|
||||
|
||||
def delete_recording(self, recording_id: str, **kw) -> None:
|
||||
"""
|
||||
Delete a recording by recording_id.
|
||||
Delete a recording.
|
||||
|
||||
recording_id is either the provider's recordingId or the
|
||||
"rec:<programme codename>" content id (what clients hold); the
|
||||
latter is resolved through GetRecordings.
|
||||
|
||||
Raises:
|
||||
KeyError: if the recording does not exist / was not deleted.
|
||||
ItemNotFoundError: (also a KeyError) if the recording does not
|
||||
exist / was not deleted.
|
||||
ProviderError subclasses: on transport / backend failure
|
||||
(typed errors such as AuthError pass through).
|
||||
"""
|
||||
if not recording_id:
|
||||
raise KeyError("simpliTV: empty recording_id")
|
||||
raise ItemNotFoundError("simpliTV: empty recording_id")
|
||||
|
||||
remote_id = recording_id
|
||||
if recording_id.startswith(SimpliTVDefaults.RECORDING_PREFIX):
|
||||
remote_id = self._remote_id_for(recording_id)
|
||||
|
||||
# NOTE: token is in the body (auth_body), not a header.
|
||||
body = self.auth.auth_body({
|
||||
"platformCodename": self.config.platform_codename,
|
||||
"recordingId": recording_id,
|
||||
"recordingId": remote_id,
|
||||
})
|
||||
with transport_errors("delete_recording"):
|
||||
resp = self.http_manager.post(
|
||||
@@ -155,15 +172,31 @@ class SimpliTVRecordingsManager(RecordingsManager):
|
||||
# The API answers 200 with success:false for "not found";
|
||||
# silently returning would hide real backend errors.
|
||||
if not (data.get("result") or {}).get("success", False):
|
||||
raise KeyError(
|
||||
raise ItemNotFoundError(
|
||||
f"simpliTV: recording {recording_id!r} not deleted "
|
||||
f"(response: {data!r})"
|
||||
)
|
||||
|
||||
def _remote_id_for(self, content_id: str) -> str:
|
||||
"""Provider recordingId for a rec:<codename> content id."""
|
||||
with transport_errors("resolve recording id"):
|
||||
recordings = self._fetch_recordings()
|
||||
for recording in recordings:
|
||||
if recording.content_id == content_id and recording.remote_id:
|
||||
return recording.remote_id
|
||||
raise ItemNotFoundError(
|
||||
f"simpliTV: no recording with id {content_id!r}"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Optional override -- scheduling
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def supports_scheduling(self) -> bool:
|
||||
"""schedule_recording() is implemented (prog:<codename> ids)."""
|
||||
return True
|
||||
|
||||
def schedule_recording(self, content_id: str, **kw) -> bool:
|
||||
"""
|
||||
Schedule a recording of a programme.
|
||||
@@ -196,24 +229,44 @@ class SimpliTVRecordingsManager(RecordingsManager):
|
||||
# Internal
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _rec_to_channel(self, rec: dict) -> SimpliTVChannel:
|
||||
def _rec_to_recording(self, rec: dict) -> SimpliTVRecording:
|
||||
"""
|
||||
Map one GetRecordings entry to SimpliTVChannel.
|
||||
Map one GetRecordings entry to SimpliTVRecording.
|
||||
|
||||
content_id is "rec:<programme codename>" (the id used to play
|
||||
the recording); recording_id is the provider's id (the id used
|
||||
the recording); remote_id is the provider's id (the id used
|
||||
to delete it). They are different namespaces.
|
||||
"""
|
||||
programme = rec.get("program") or {}
|
||||
codename = programme.get("codename", "")
|
||||
return SimpliTVChannel(
|
||||
start_raw = programme.get("start", "")
|
||||
stop_raw = programme.get("stop", "")
|
||||
start, stop = parse_iso(start_raw), parse_iso(stop_raw)
|
||||
duration = (
|
||||
int((stop - start).total_seconds())
|
||||
if start and stop and stop > start
|
||||
else None
|
||||
)
|
||||
raw_status = rec.get("status", "")
|
||||
return SimpliTVRecording(
|
||||
name=programme.get("title", codename),
|
||||
content_id=f"{SimpliTVDefaults.RECORDING_PREFIX}{codename}",
|
||||
provider=SimpliTVDefaults.PROVIDER_NAME,
|
||||
status=_STATUS_MAP.get(raw_status, RecordingStatus.PENDING),
|
||||
recording_time=start,
|
||||
duration_seconds=duration,
|
||||
codename=codename,
|
||||
current_programme=programme.get("title", ""),
|
||||
current_start=programme.get("start", ""),
|
||||
current_stop=programme.get("stop", ""),
|
||||
recording_id=rec.get("recordingId", ""),
|
||||
recording_status=rec.get("status", ""),
|
||||
)
|
||||
remote_id=rec.get("recordingId", ""),
|
||||
remote_status=raw_status,
|
||||
current_start=start_raw,
|
||||
current_stop=stop_raw,
|
||||
)
|
||||
|
||||
|
||||
# API status -> RecordingStatus. Unknown values stay PENDING (not playable,
|
||||
# not claimed to have failed).
|
||||
_STATUS_MAP = {
|
||||
"Recorded": RecordingStatus.COMPLETED,
|
||||
"Scheduled": RecordingStatus.PENDING,
|
||||
"Failed": RecordingStatus.FAILED,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user