diff --git a/lib/streaming_providers/base/managed_provider.py b/lib/streaming_providers/base/managed_provider.py index 752c052..a28697a 100644 --- a/lib/streaming_providers/base/managed_provider.py +++ b/lib/streaming_providers/base/managed_provider.py @@ -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 \ No newline at end of file + 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) diff --git a/lib/streaming_providers/base/managers/_base.py b/lib/streaming_providers/base/managers/_base.py index d76c6a8..3abdc5e 100644 --- a/lib/streaming_providers/base/managers/_base.py +++ b/lib/streaming_providers/base/managers/_base.py @@ -49,4 +49,4 @@ class ManagerBase(ABC): self.http_manager = http_manager self.auth = auth self.country = country - self.config = config \ No newline at end of file + self.config = config diff --git a/lib/streaming_providers/base/managers/catchup.py b/lib/streaming_providers/base/managers/catchup.py index 6a292b9..f77fe64 100644 --- a/lib/streaming_providers/base/managers/catchup.py +++ b/lib/streaming_providers/base/managers/catchup.py @@ -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. diff --git a/lib/streaming_providers/base/managers/channel.py b/lib/streaming_providers/base/managers/channel.py index 0e0ee7b..8a30c3c 100644 --- a/lib/streaming_providers/base/managers/channel.py +++ b/lib/streaming_providers/base/managers/channel.py @@ -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 diff --git a/lib/streaming_providers/base/managers/favorites.py b/lib/streaming_providers/base/managers/favorites.py index 97b6380..24eba0f 100644 --- a/lib/streaming_providers/base/managers/favorites.py +++ b/lib/streaming_providers/base/managers/favorites.py @@ -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 diff --git a/lib/streaming_providers/base/managers/recordings.py b/lib/streaming_providers/base/managers/recordings.py index 2115e9d..a2e99b7 100644 --- a/lib/streaming_providers/base/managers/recordings.py +++ b/lib/streaming_providers/base/managers/recordings.py @@ -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. diff --git a/lib/streaming_providers/base/managers/vod.py b/lib/streaming_providers/base/managers/vod.py index 0231b19..f0393de 100644 --- a/lib/streaming_providers/base/managers/vod.py +++ b/lib/streaming_providers/base/managers/vod.py @@ -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( diff --git a/lib/streaming_providers/base/provider_mixins/metadata.py b/lib/streaming_providers/base/provider_mixins/metadata.py index 67910ac..3598187 100644 --- a/lib/streaming_providers/base/provider_mixins/metadata.py +++ b/lib/streaming_providers/base/provider_mixins/metadata.py @@ -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, diff --git a/lib/streaming_providers/providers/simpli/__init__.py b/lib/streaming_providers/providers/simpli/__init__.py index cceee3b..e69de29 100644 --- a/lib/streaming_providers/providers/simpli/__init__.py +++ b/lib/streaming_providers/providers/simpli/__init__.py @@ -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"] diff --git a/lib/streaming_providers/providers/simpli/catchup_manager.py b/lib/streaming_providers/providers/simpli/catchup_manager.py index 0c613e7..9d10616 100644 --- a/lib/streaming_providers/providers/simpli/catchup_manager.py +++ b/lib/streaming_providers/providers/simpli/catchup_manager.py @@ -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, diff --git a/lib/streaming_providers/providers/simpli/epg_manager.py b/lib/streaming_providers/providers/simpli/epg_manager.py index f0540b5..36a262e 100644 --- a/lib/streaming_providers/providers/simpli/epg_manager.py +++ b/lib/streaming_providers/providers/simpli/epg_manager.py @@ -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 # ------------------------------------------------------------------ diff --git a/lib/streaming_providers/providers/simpli/models.py b/lib/streaming_providers/providers/simpli/models.py index 129c274..12ee6f7 100644 --- a/lib/streaming_providers/providers/simpli/models.py +++ b/lib/streaming_providers/providers/simpli/models.py @@ -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 \ No newline at end of file + 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:" -- 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 diff --git a/lib/streaming_providers/providers/simpli/provider.py b/lib/streaming_providers/providers/simpli/provider.py index 6724906..513af12 100644 --- a/lib/streaming_providers/providers/simpli/provider.py +++ b/lib/streaming_providers/providers/simpli/provider.py @@ -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 @ 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:@ id. +get_manifest and get_drm hand catchup: ids to the catchup manager (the +@ 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 @ 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) \ No newline at end of file diff --git a/lib/streaming_providers/providers/simpli/recordings_manager.py b/lib/streaming_providers/providers/simpli/recordings_manager.py index c00a8d6..1c009bf 100644 --- a/lib/streaming_providers/providers/simpli/recordings_manager.py +++ b/lib/streaming_providers/providers/simpli/recordings_manager.py @@ -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:" 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:. @@ -32,15 +31,15 @@ not a channel codename: pass prog:. 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:" 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: 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: 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:" (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", ""), - ) \ No newline at end of file + 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, +}