From a63545da7c0de55ee62ce2242251bdc4afdd5ce6 Mon Sep 17 00:00:00 2001 From: Nirvana Date: Fri, 3 Apr 2026 20:20:05 +0200 Subject: [PATCH] Add timers to M2 --- .../providers/magenta2/constants.py | 10 +- .../providers/magenta2/pvr_helpers.py | 373 ++++++++++++++ .../providers/magenta2/recordings_manager.py | 300 ++--------- .../providers/magenta2/timers_manager.py | 483 ++++++++++++++++++ 4 files changed, 903 insertions(+), 263 deletions(-) create mode 100644 lib/streaming_providers/providers/magenta2/pvr_helpers.py create mode 100644 lib/streaming_providers/providers/magenta2/timers_manager.py diff --git a/lib/streaming_providers/providers/magenta2/constants.py b/lib/streaming_providers/providers/magenta2/constants.py index dab0a7b..17fd52c 100644 --- a/lib/streaming_providers/providers/magenta2/constants.py +++ b/lib/streaming_providers/providers/magenta2/constants.py @@ -373,4 +373,12 @@ PVR_RECORDING_STATUSES_ALL = [ ] # Accept header required by the nPVR API (differs from standard JSON endpoints) -PVR_ACCEPT_HEADER = "application/json; v=2; charset=utf-8" \ No newline at end of file +PVR_ACCEPT_HEADER = "application/json; v=2; charset=utf-8" + +# Timer type IDs — used by TimersManager and get_timer_types() +PVR_TIMER_TYPE_EPG_ONE_SHOT = 1 + +# Recording statuses that represent pending timers (not yet captured). +# Used as the byRecordingStatus filter in TimersManager.get_timers(). +# Kept as a list (like the STATUSES_ACTIVE/ALL siblings) for easy pipe-joining. +PVR_RECORDING_STATUSES_TIMERS = ["SCHEDULED"] \ No newline at end of file diff --git a/lib/streaming_providers/providers/magenta2/pvr_helpers.py b/lib/streaming_providers/providers/magenta2/pvr_helpers.py new file mode 100644 index 0000000..10d2384 --- /dev/null +++ b/lib/streaming_providers/providers/magenta2/pvr_helpers.py @@ -0,0 +1,373 @@ +# streaming_providers/providers/magenta2/pvr_helpers.py +""" +Shared nPVR utility helpers for Magenta2. + +Both RecordingsManager and TimersManager need the same low-level parsing and +mapping logic. Keeping it here avoids duplication while keeping each manager +class focused on its own domain. + +Public surface +-------------- + PvrHelpers — static-method namespace (no state) + PvrHttpMixin — HTTP + auth plumbing shared by both managers +""" + +import re +from datetime import datetime, timezone +from typing import Callable, Dict, List, Optional + +from .constants import PVR_ACCEPT_HEADER +from ...base.utils.logger import logger +from ...base.network import HTTPManager +from .config_models import ProviderConfig + + +# --------------------------------------------------------------------------- +# Pure parsing / mapping helpers +# --------------------------------------------------------------------------- + +class PvrHelpers: + """Static helpers for parsing nPVR API responses.""" + + # ------------------------------------------------------------------ + # Duration + # ------------------------------------------------------------------ + + @staticmethod + def parse_iso8601_duration(duration_str: str) -> Optional[int]: + """ + Parse an ISO 8601 duration string and return total seconds. + + Supported: PT2H44M5S, PT26M19S, PT45S, P1DT2H + + Returns: + Total duration in whole seconds, or None on parse failure. + """ + pattern = re.compile( + r"P(?:(?P\d+)D)?" + r"(?:T" + r"(?:(?P\d+)H)?" + r"(?:(?P\d+)M)?" + r"(?:(?P\d+(?:\.\d+)?)S)?" + r")?", + re.IGNORECASE, + ) + match = pattern.fullmatch(duration_str.strip()) + if not match: + return None + days = int(match.group("days") or 0) + hours = int(match.group("hours") or 0) + minutes = int(match.group("minutes") or 0) + seconds = float(match.group("seconds") or 0) + total = days * 86400 + hours * 3600 + minutes * 60 + int(seconds) + return total if total > 0 else None + + # ------------------------------------------------------------------ + # Datetime + # ------------------------------------------------------------------ + + @staticmethod + def parse_datetime(dt_str: Optional[str]) -> Optional[datetime]: + """Parse an ISO 8601 datetime string to a timezone-aware datetime.""" + if not dt_str: + return None + try: + return datetime.fromisoformat(dt_str.replace("Z", "+00:00")) + except (ValueError, AttributeError): + return None + + # ------------------------------------------------------------------ + # Lifetime + # ------------------------------------------------------------------ + + @staticmethod + def compute_lifetime_days(expiration_str: Optional[str]) -> Optional[int]: + """ + Compute whole days remaining until expiry. + + Returns: + Days remaining (minimum 0), or None when not available. + """ + if not expiration_str: + return None + try: + expiry = datetime.fromisoformat(expiration_str.replace("Z", "+00:00")) + now = datetime.now(tz=timezone.utc) + delta = expiry - now + return max(0, delta.days) + except (ValueError, AttributeError): + return None + + # ------------------------------------------------------------------ + # Genre + # ------------------------------------------------------------------ + + @staticmethod + def extract_primary_genre(tags: List[Dict]) -> Optional[str]: + """ + Extract the primary genre title from a program tags list. + + Prefers ``genre-primary``; falls back to first ``genre-secondary``. + """ + primary: Optional[str] = None + secondary: Optional[str] = None + for tag in tags: + scheme = tag.get("scheme", "") + title = tag.get("title", "").strip() + if not title: + continue + if scheme == "genre-primary" and primary is None: + primary = title + elif scheme == "genre-secondary" and secondary is None: + secondary = title + return primary or secondary + + # ------------------------------------------------------------------ + # Thumbnails + # ------------------------------------------------------------------ + + @staticmethod + def pick_thumbnail( + thumbnails: Dict, + preferred_key: str, + fallback_key: Optional[str] = None, + ) -> Optional[str]: + """ + Pick a thumbnail URL from a program thumbnails dict. + + Matches keys by prefix (e.g. ``"mainWide"`` matches ``"mainWide-0x0"``). + Falls back to ``fallback_key`` then to the first available URL. + """ + for key, value in thumbnails.items(): + if key.startswith(preferred_key): + return (value or {}).get("url") + if fallback_key: + for key, value in thumbnails.items(): + if key.startswith(fallback_key): + return (value or {}).get("url") + for value in thumbnails.values(): + url = (value or {}).get("url") + if url: + return url + return None + + # ------------------------------------------------------------------ + # URI helpers + # ------------------------------------------------------------------ + + @staticmethod + def extract_numeric_tail(uri: Optional[str]) -> Optional[int]: + """ + Extract the numeric ID from the tail of a theplatform URI. + + e.g. ``".../Station/265809448374"`` → ``265809448374`` + """ + if not uri: + return None + tail = uri.rstrip("/").rsplit("/", 1)[-1] + try: + return int(tail) + except (ValueError, TypeError): + return None + + @staticmethod + def extract_mpx_guid(playback_url: Optional[str]) -> Optional[str]: + """ + Extract the MPX media GUID from a theplatform selector URL. + + e.g. ``"http://link.theplatform.eu/s/mdeprod/media/W3tyz38x4RtWzxcuggbnBw"`` + → ``"W3tyz38x4RtWzxcuggbnBw"`` + + Returns None when the URL is absent or does not contain ``/media/``. + """ + if not playback_url: + return None + tail = playback_url.rstrip("/").rsplit("/media/", 1) + if len(tail) == 2: + return tail[1].split("?")[0] or None + return None + + +# --------------------------------------------------------------------------- +# HTTP + auth plumbing shared by both managers +# --------------------------------------------------------------------------- + +class PvrHttpMixin: + """ + Mixin providing HTTP and auth plumbing for nPVR manager classes. + + Concrete subclasses must assign these attributes in their ``__init__``: + _http — HTTPManager + _provider — str (provider name for log messages) + _provider_config — ProviderConfig or None + _auth_headers_callback — Callable[[], Dict[str, str]] or None + """ + + # Typed stubs — assigned by the concrete subclass __init__. + # Declared here so that static analysers (PyCharm, mypy) resolve + # attribute references inside the mixin methods without warnings. + _http: HTTPManager + _provider: str + _provider_config: Optional[ProviderConfig] + _auth_headers_callback: Optional[Callable[[], Dict[str, str]]] + + # ------------------------------------------------------------------ + # Base-URL resolution + # ------------------------------------------------------------------ + + def _get_pvr_base_url(self) -> str: + """ + Resolve the nPVR base URL from the discovered manifest config. + + Resolution order: + 1. ``provider_config.manifest.mpx.pvr_base_url`` (dynamic, preferred) + 2. Raises RuntimeError — no hardcoded fallback. + """ + if self._provider_config is not None: + try: + pvr_url = self._provider_config.manifest.mpx.pvr_base_url + if pvr_url: + return pvr_url.rstrip("/") + except AttributeError: + pass + + raise RuntimeError( + f"{self._provider}: PVR base URL not available — " + "provider configuration must be fully discovered before " + "using this manager." + ) + + # ------------------------------------------------------------------ + # Auth headers + # ------------------------------------------------------------------ + + def _build_auth_headers(self) -> Dict[str, str]: + """ + Build authentication headers for nPVR requests. + + The nPVR API uses ``Authorization: Basic {persona_token}``. + + Raises: + RuntimeError: When ``auth_headers_callback`` is not configured. + """ + if self._auth_headers_callback is None: + raise RuntimeError( + f"{self._provider}: auth_headers_callback not configured — " + "cannot make authenticated nPVR requests." + ) + try: + return self._auth_headers_callback() + except Exception as exc: + raise RuntimeError( + f"{self._provider}: auth_headers_callback raised an exception: {exc}" + ) from exc + + # ------------------------------------------------------------------ + # HTTP verbs + # ------------------------------------------------------------------ + + def _get(self, url: str, params: Dict) -> Optional[Dict]: + """ + Authenticated GET against the nPVR API. + + Sets the required ``Accept`` header via ``PVR_ACCEPT_HEADER``. + + Returns: + Parsed JSON body, or None on error. + """ + headers = self._build_auth_headers() + headers["Accept"] = PVR_ACCEPT_HEADER + + try: + response = self._http.get(url, params=params, headers=headers) + if response and response.status_code == 200: + return response.json() + logger.warning( + f"{self._provider}: nPVR GET failed " + f"[{response.status_code if response else 'no response'}] {url}" + ) + except Exception as exc: + logger.error(f"{self._provider}: nPVR GET exception for {url}: {exc}") + return None + + def _post(self, url: str, payload: Dict) -> Optional[Dict]: + """ + Authenticated POST against the nPVR API. + + Returns: + Parsed JSON body on 200/201, or None on error. + """ + headers = self._build_auth_headers() + headers["Content-Type"] = PVR_ACCEPT_HEADER + headers["Accept"] = PVR_ACCEPT_HEADER + + try: + response = self._http.post(url, json=payload, headers=headers) + if response and response.status_code in (200, 201): + return response.json() + logger.warning( + f"{self._provider}: nPVR POST failed " + f"[{response.status_code if response else 'no response'}] {url}" + ) + except Exception as exc: + logger.error(f"{self._provider}: nPVR POST exception for {url}: {exc}") + return None + + def _put(self, url: str, payload: Dict) -> Optional[Dict]: + """ + Authenticated PUT against the nPVR API. + + Returns: + Parsed JSON body on 200, or None on error. + """ + headers = self._build_auth_headers() + headers["Content-Type"] = PVR_ACCEPT_HEADER + headers["Accept"] = PVR_ACCEPT_HEADER + + try: + response = self._http.put(url, json=payload, headers=headers) + if response and response.status_code == 200: + return response.json() + logger.warning( + f"{self._provider}: nPVR PUT failed " + f"[{response.status_code if response else 'no response'}] {url}" + ) + except Exception as exc: + logger.error(f"{self._provider}: nPVR PUT exception for {url}: {exc}") + return None + + def _delete(self, url: str) -> int: + """ + Authenticated DELETE against the nPVR API. + + Returns: + HTTP status code on success. + + Raises: + KeyError: On 404. + RuntimeError: On any other non-success status or transport error. + """ + headers = self._build_auth_headers() + + try: + response = self._http.delete(url, headers=headers) + except Exception as exc: + raise RuntimeError( + f"{self._provider}: DELETE request failed for {url}: {exc}" + ) from exc + + if response is None: + raise RuntimeError( + f"{self._provider}: No response received for DELETE {url}" + ) + + if response.status_code == 404: + raise KeyError(f"{self._provider}: Resource not found: {url}") + + if response.status_code not in (200, 204): + raise RuntimeError( + f"{self._provider}: DELETE failed [{response.status_code}] {url}: " + f"{response.text[:200]}" + ) + + return response.status_code \ No newline at end of file diff --git a/lib/streaming_providers/providers/magenta2/recordings_manager.py b/lib/streaming_providers/providers/magenta2/recordings_manager.py index d487662..64bf032 100644 --- a/lib/streaming_providers/providers/magenta2/recordings_manager.py +++ b/lib/streaming_providers/providers/magenta2/recordings_manager.py @@ -36,7 +36,7 @@ Public interface recordings_manager.get_recording_manifest(recording_id: str) -> Optional[str] """ -from datetime import datetime, timezone +from datetime import datetime from typing import Dict, List, Optional from ...base.models.recording import Recording, RecordingStatus @@ -44,13 +44,16 @@ from ...base.utils.logger import logger from .constants import ( PVR_DEFAULT_PAGE_LIMIT, + PVR_GET_RECORDINGS_PATH, PVR_MAX_PAGE_LIMIT, + PVR_RECORDINGS_PATH, PVR_RECORDING_STATUSES_ACTIVE, PVR_RECORDING_STATUSES_ALL, ) +from .pvr_helpers import PvrHelpers, PvrHttpMixin -class RecordingsManager: +class RecordingsManager(PvrHttpMixin): """ Manages Magenta2 nPVR recording catalogue retrieval and deletion. @@ -111,7 +114,7 @@ class RecordingsManager: the API returns a non-200 status. """ pvr_base_url = self._get_pvr_base_url() - url = f"{pvr_base_url}/get-recordings" + url = f"{pvr_base_url}{PVR_GET_RECORDINGS_PATH}" statuses = PVR_RECORDING_STATUSES_ALL if include_deleted else PVR_RECORDING_STATUSES_ACTIVE params = { @@ -150,37 +153,12 @@ class RecordingsManager: KeyError: When the recording does not exist (404). """ pvr_base_url = self._get_pvr_base_url() - url = f"{pvr_base_url}/recordings/{recording_id}" - - headers = self._build_auth_headers() - try: - response = self._http.delete(url, headers=headers) - except Exception as exc: - raise RuntimeError( - f"{self._provider}: DELETE request failed for recording " - f"'{recording_id}': {exc}" - ) from exc - - if response is None: - raise RuntimeError( - f"{self._provider}: No response received when deleting " - f"recording '{recording_id}'" - ) - - if response.status_code == 404: - raise KeyError( - f"{self._provider}: Recording '{recording_id}' not found on provider" - ) - - if response.status_code not in (200, 204): - raise RuntimeError( - f"{self._provider}: Failed to delete recording '{recording_id}' " - f"[HTTP {response.status_code}]: {response.text[:200]}" - ) + url = f"{pvr_base_url}{PVR_RECORDINGS_PATH}/{recording_id}" + status_code = self._delete(url) logger.info( f"{self._provider}: Deleted recording '{recording_id}' " - f"[HTTP {response.status_code}]" + f"[HTTP {status_code}]" ) def get_recording_manifest(self, recording_id: str) -> Optional[str]: @@ -205,7 +183,7 @@ class RecordingsManager: available (e.g. recording is PENDING or FAILED). """ pvr_base_url = self._get_pvr_base_url() - url = f"{pvr_base_url}/recordings/{recording_id}" + url = f"{pvr_base_url}{PVR_RECORDINGS_PATH}/{recording_id}" data = self._get(url, {}) if not data: @@ -219,88 +197,6 @@ class RecordingsManager: ) return playback_url - # ========================================================================= - # Private helpers – HTTP layer - # ========================================================================= - - def _get_pvr_base_url(self) -> str: - """ - Resolve the nPVR base URL from the discovered manifest config. - - Resolution order: - 1. ``provider_config.manifest.mpx.pvr_base_url`` (dynamic, preferred) - 2. Raises ``RuntimeError`` (no hardcoded fallback) - - The URL is intentionally not hardcoded. It is always present in the - manifest response under ``mpx.pvrBaseUrl`` and must be discovered - before the RecordingsManager is used. - """ - if self._provider_config is not None: - try: - pvr_url = self._provider_config.manifest.mpx.pvr_base_url - if pvr_url: - return pvr_url.rstrip("/") - except AttributeError: - pass - - raise RuntimeError( - f"{self._provider}: PVR base URL not available — " - "provider configuration must be fully discovered before " - "using RecordingsManager." - ) - - def _build_auth_headers(self) -> Dict[str, str]: - """ - Build authentication headers for nPVR requests. - - The nPVR API uses ``Authorization: Basic {persona_token}``, which is - distinct from the Bearer tokens used by VOD endpoints. The - auth_headers_callback is expected to return headers that already - include a Basic Authorization value. - - Raises: - RuntimeError: When auth_headers_callback is not configured. - """ - if self._auth_headers_callback is None: - raise RuntimeError( - f"{self._provider}: auth_headers_callback not configured — " - "cannot make authenticated nPVR requests." - ) - try: - return self._auth_headers_callback() - except Exception as exc: - raise RuntimeError( - f"{self._provider}: auth_headers_callback raised an exception: {exc}" - ) from exc - - def _get(self, url: str, params: Dict) -> Optional[Dict]: - """ - Perform an authenticated GET request against the nPVR API. - - Accept header is set to the nPVR-required value: - application/json; v=2; charset=utf-8 - - Returns: - Parsed JSON body, or ``None`` on error. - """ - headers = self._build_auth_headers() - # nPVR API requires a versioned Accept header — different from standard JSON. - headers["Accept"] = "application/json; v=2; charset=utf-8" - - try: - response = self._http.get(url, params=params, headers=headers) - if response and response.status_code == 200: - return response.json() - logger.warning( - f"{self._provider}: nPVR request failed " - f"[{response.status_code if response else 'no response'}] {url}" - ) - except Exception as exc: - logger.error( - f"{self._provider}: nPVR request exception for {url}: {exc}" - ) - return None - # ========================================================================= # Private helpers – response mapping # ========================================================================= @@ -330,7 +226,7 @@ class RecordingsManager: """ recording_id: str = raw.get("id", "") status_str: str = raw.get("recordingStatus", "GENERATED") - status = self._map_status(status_str) + status = _map_status(status_str) program: Dict = raw.get("program") or {} listing: Dict = raw.get("listing") or {} @@ -355,7 +251,7 @@ class RecordingsManager: duration_seconds: Optional[int] = None recording_duration_str: Optional[str] = raw.get("recordingDuration") if recording_duration_str: - duration_seconds = self._parse_iso8601_duration(recording_duration_str) + duration_seconds = PvrHelpers.parse_iso8601_duration(recording_duration_str) if duration_seconds is None: runtime_raw = program.get("runtime") if runtime_raw is not None: @@ -365,10 +261,14 @@ class RecordingsManager: pass # ── Timing ──────────────────────────────────────────────────────── - recording_time: Optional[datetime] = self._parse_datetime(raw.get("startDateTime")) + recording_time: Optional[datetime] = PvrHelpers.parse_datetime( + raw.get("startDateTime") + ) # ── Lifetime (days until expiry) ────────────────────────────────── - lifetime: Optional[int] = self._compute_lifetime_days(raw.get("expirationDateTime")) + lifetime: Optional[int] = PvrHelpers.compute_lifetime_days( + raw.get("expirationDateTime") + ) # ── Release year ────────────────────────────────────────────────── release_year: Optional[int] = None @@ -380,21 +280,21 @@ class RecordingsManager: pass # ── Genre ───────────────────────────────────────────────────────── - genre_description: Optional[str] = self._extract_primary_genre( + genre_description: Optional[str] = PvrHelpers.extract_primary_genre( program.get("tags") or [] ) # ── Thumbnails ──────────────────────────────────────────────────── thumbnails: Dict = program.get("thumbnails") or {} - thumbnail_url: Optional[str] = self._pick_thumbnail(thumbnails, "mainWide") - fanart_url: Optional[str] = self._pick_thumbnail( + thumbnail_url: Optional[str] = PvrHelpers.pick_thumbnail(thumbnails, "mainWide") + fanart_url: Optional[str] = PvrHelpers.pick_thumbnail( thumbnails, "posterWideNoTitle", fallback_key="HighResLandscapeProductionStill" ) # ── Channel info ────────────────────────────────────────────────── # stationId is a theplatform URI; extract the numeric tail as channel_uid. station_id_uri: Optional[str] = listing.get("stationId") - channel_uid: Optional[int] = self._extract_numeric_tail(station_id_uri) + channel_uid: Optional[int] = PvrHelpers.extract_numeric_tail(station_id_uri) # ── Playback / manifest ─────────────────────────────────────────── # playbackUrl is a theplatform selector URL in the same format used @@ -407,11 +307,7 @@ class RecordingsManager: # time — unlike VOD which must walk VodDetails → productInformation → # VodPlayer to discover the same value. playback_url: Optional[str] = raw.get("playbackUrl") - mpx_guid: Optional[str] = None - if playback_url: - tail = playback_url.rstrip("/").rsplit("/media/", 1) - if len(tail) == 2: - mpx_guid = tail[1].split("?")[0] or None + mpx_guid: Optional[str] = PvrHelpers.extract_mpx_guid(playback_url) # Use the MPX GUID as content_id when available so downstream manifest # and DRM calls resolve correctly. Fall back to the nPVR recording ID @@ -448,140 +344,20 @@ class RecordingsManager: status=status, ) - @staticmethod - def _map_status(status_str: str) -> RecordingStatus: - """Map nPVR API recordingStatus string to RecordingStatus enum.""" - _map = { - "SCHEDULED": RecordingStatus.PENDING, - "RECORDING": RecordingStatus.RECORDING, - "RECORDED": RecordingStatus.COMPLETED, - "GENERATED": RecordingStatus.COMPLETED, - "FAILED": RecordingStatus.FAILED, - "TO_DELETE": RecordingStatus.DELETED, - "DELETED": RecordingStatus.DELETED, - } - return _map.get(status_str.upper(), RecordingStatus.COMPLETED) - @staticmethod - def _parse_iso8601_duration(duration_str: str) -> Optional[int]: - """ - Parse an ISO 8601 duration string and return total seconds. +# --------------------------------------------------------------------------- +# Module-level helpers (pure functions; no access to manager state) +# --------------------------------------------------------------------------- - Supported formats: PT2H44M5S, PT26M19S, PT45S, P1DT2H - - Returns: - Total duration in whole seconds, or None on parse failure. - """ - import re - pattern = re.compile( - r"P(?:(?P\d+)D)?" - r"(?:T" - r"(?:(?P\d+)H)?" - r"(?:(?P\d+)M)?" - r"(?:(?P\d+(?:\.\d+)?)S)?" - r")?", - re.IGNORECASE, - ) - match = pattern.fullmatch(duration_str.strip()) - if not match: - return None - days = int(match.group("days") or 0) - hours = int(match.group("hours") or 0) - minutes = int(match.group("minutes") or 0) - seconds = float(match.group("seconds") or 0) - total = days * 86400 + hours * 3600 + minutes * 60 + int(seconds) - return total if total > 0 else None - - @staticmethod - def _parse_datetime(dt_str: Optional[str]) -> Optional[datetime]: - """Parse an ISO 8601 datetime string to a timezone-aware datetime.""" - if not dt_str: - return None - try: - # Python 3.7+ handles the Z suffix via fromisoformat after replacement - return datetime.fromisoformat(dt_str.replace("Z", "+00:00")) - except (ValueError, AttributeError): - return None - - @staticmethod - def _compute_lifetime_days(expiration_str: Optional[str]) -> Optional[int]: - """ - Compute days remaining until expiry from an ISO 8601 datetime string. - - Returns: - Whole days remaining (minimum 0), or None when not available. - """ - if not expiration_str: - return None - try: - expiry = datetime.fromisoformat(expiration_str.replace("Z", "+00:00")) - now = datetime.now(tz=timezone.utc) - delta = expiry - now - return max(0, delta.days) - except (ValueError, AttributeError): - return None - - @staticmethod - def _extract_primary_genre(tags: List[Dict]) -> Optional[str]: - """ - Extract the primary genre title from a program tags list. - - Looks for the first tag with scheme == ``"genre-primary"``. - Falls back to the first tag with scheme == ``"genre-secondary"`` if - no primary genre is present. - """ - primary: Optional[str] = None - secondary: Optional[str] = None - for tag in tags: - scheme = tag.get("scheme", "") - title = tag.get("title", "").strip() - if not title: - continue - if scheme == "genre-primary" and primary is None: - primary = title - elif scheme == "genre-secondary" and secondary is None: - secondary = title - return primary or secondary - - @staticmethod - def _pick_thumbnail( - thumbnails: Dict, - preferred_key: str, - fallback_key: Optional[str] = None, - ) -> Optional[str]: - """ - Pick a thumbnail URL from the program thumbnails dict. - - Looks for a key that starts with ``preferred_key`` (e.g. ``"mainWide"`` - matches ``"mainWide-0x0"``). Falls back to ``fallback_key`` and then - to the first available URL. - """ - for key, value in thumbnails.items(): - if key.startswith(preferred_key): - return (value or {}).get("url") - if fallback_key: - for key, value in thumbnails.items(): - if key.startswith(fallback_key): - return (value or {}).get("url") - # Last resort: first available thumbnail - for value in thumbnails.values(): - url = (value or {}).get("url") - if url: - return url - return None - - @staticmethod - def _extract_numeric_tail(uri: Optional[str]) -> Optional[int]: - """ - Extract the numeric ID from the tail of a theplatform URI. - - e.g. ``"http://data.entertainment.tv.theplatform.eu/.../Station/265809448374"`` - → ``265809448374`` - """ - if not uri: - return None - tail = uri.rstrip("/").rsplit("/", 1)[-1] - try: - return int(tail) - except (ValueError, TypeError): - return None \ No newline at end of file +def _map_status(status_str: str) -> RecordingStatus: + """Map nPVR API recordingStatus string to RecordingStatus enum.""" + _map = { + "SCHEDULED": RecordingStatus.PENDING, + "RECORDING": RecordingStatus.RECORDING, + "RECORDED": RecordingStatus.COMPLETED, + "GENERATED": RecordingStatus.COMPLETED, + "FAILED": RecordingStatus.FAILED, + "TO_DELETE": RecordingStatus.DELETED, + "DELETED": RecordingStatus.DELETED, + } + return _map.get(status_str.upper(), RecordingStatus.COMPLETED) \ No newline at end of file diff --git a/lib/streaming_providers/providers/magenta2/timers_manager.py b/lib/streaming_providers/providers/magenta2/timers_manager.py new file mode 100644 index 0000000..881da83 --- /dev/null +++ b/lib/streaming_providers/providers/magenta2/timers_manager.py @@ -0,0 +1,483 @@ +# streaming_providers/providers/magenta2/timers_manager.py +""" +Magenta2 Timers Manager + +Handles nPVR timer (scheduled recording) CRUD for the Magenta2 provider +using the Audience nPVR API. + +API overview +------------ +Base URL: + Same as RecordingsManager — discovered from manifest["mpx"]["pvrBaseUrl"]: + https://audience.npvr.eu.theplatform.com/npvr-audience/2709353023 + +List timers (SCHEDULED recordings): + GET {pvr_base_url}/get-recordings + Query params: limit, offset, byRecordingStatus=SCHEDULED + Authorization: Basic {persona_token} + Accept: application/json; v=2; charset=utf-8 + +Schedule a new timer: + POST {pvr_base_url}/recordings + Body: {"listingId": "", "startOffsetSeconds": N, + "endOffsetSeconds": N} + Authorization: Basic {persona_token} + Content-Type: application/json; v=2; charset=utf-8 + + NOTE: The exact request body shape must be confirmed against the live API. + The listing GUID is derived from Timer.epg_event_id (the listing.guid field + in the get-recordings response, e.g. "3sat_hd_02fe69d5"). + +Update an existing timer: + PUT {pvr_base_url}/recordings/{recording_id} + Body: same shape as POST, partial updates accepted + Authorization: Basic {persona_token} + +Delete a timer (cancel before it fires): + DELETE {pvr_base_url}/recordings/{recording_id} + Authorization: Basic {persona_token} + — identical to recording deletion; the backend differentiates by status + +Public interface +---------------- + timers_manager.get_timers(**kwargs) -> List[Timer] + + timers_manager.add_timer(timer: Timer) -> Timer + + timers_manager.update_timer(timer: Timer) -> Timer + + timers_manager.delete_timer(client_index: int, + force_delete: bool = False) -> None + + timers_manager.get_timer_types() -> List[TimerType] + +Relationship to RecordingsManager +---------------------------------- +Both managers talk to the same nPVR base URL and use the same auth scheme. +Shared HTTP/auth plumbing lives in PvrHttpMixin (pvr_helpers.py). +Shared parsing utilities live in PvrHelpers (pvr_helpers.py). + +Timers are SCHEDULED recordings that have not yet been captured. After the +broadcast window passes the backend transitions them to RECORDING → GENERATED, +at which point RecordingsManager takes ownership of the object. +""" + +from datetime import datetime, timezone +from typing import Dict, List, Optional + +from ...base.models.timer import Timer, TimerState +from ...base.models.timer_type import TimerType, TimerTypeAttribute +from ...base.utils.logger import logger + +from .constants import ( + PVR_DEFAULT_PAGE_LIMIT, + PVR_MAX_PAGE_LIMIT, + PVR_GET_RECORDINGS_PATH, + PVR_RECORDINGS_PATH, + PVR_TIMER_TYPE_EPG_ONE_SHOT, + PVR_RECORDING_STATUSES_TIMERS, +) +from .pvr_helpers import PvrHelpers, PvrHttpMixin + + + +class TimersManager(PvrHttpMixin): + """ + Manages Magenta2 nPVR timer CRUD. + + Constructor mirrors RecordingsManager for symmetry — both are created by + the parent provider and share the same http_manager + auth callback. + + Args: + http_manager: HTTPManager instance from the parent provider. + provider_name: Provider identifier string (e.g. ``"magenta2"``). + provider_config: ProviderConfig; supplies ``pvr_base_url``. + auth_headers_callback: ``() -> Dict[str, str]`` returning per-request + headers with ``Authorization: Basic {persona}``. + """ + + def __init__( + self, + http_manager, + provider_name: str, + provider_config=None, + auth_headers_callback=None, + ): + self._http = http_manager + self._provider = provider_name + self._provider_config = provider_config + self._auth_headers_callback = auth_headers_callback + + # ========================================================================= + # Timer types + # ========================================================================= + + @staticmethod + def get_timer_types() -> List[TimerType]: + """ + Return the timer types supported by Magenta2. + + Currently Magenta2 supports EPG-based one-shot timers only. The + listing GUID is required at creation time (REQUIRES_EPG_TAG_ON_CREATE) + since the nPVR POST endpoint expects a listingId. + + Extend this list when the API confirms support for manual or + recurring timers. + """ + return [ + TimerType( + type_id=PVR_TIMER_TYPE_EPG_ONE_SHOT, + description="EPG-based one-time recording", + attributes=( + TimerTypeAttribute.IS_EPG_BASED + | TimerTypeAttribute.SUPPORTS_CHANNELS + | TimerTypeAttribute.SUPPORTS_START_TIME + | TimerTypeAttribute.SUPPORTS_END_TIME + | TimerTypeAttribute.SUPPORTS_PADDING + | TimerTypeAttribute.REQUIRES_EPG_TAG_ON_CREATE + ), + ) + ] + + # ========================================================================= + # Public API + # ========================================================================= + + def get_timers( + self, + *, + limit: int = PVR_DEFAULT_PAGE_LIMIT, + offset: int = 1, + ) -> List[Timer]: + """ + Fetch all SCHEDULED timers from the nPVR API. + + The API is paginated (same as get-recordings). Call again with an + incremented ``offset`` to page through all results. + + Args: + limit: Maximum timers per request. Capped at PVR_MAX_PAGE_LIMIT. + offset: 1-based page offset. + + Returns: + List of :class:`Timer` objects ordered as returned by the API. + + Raises: + RuntimeError: When auth_headers_callback is missing or the API + returns a non-200 status. + """ + pvr_base_url = self._get_pvr_base_url() + url = f"{pvr_base_url}{PVR_GET_RECORDINGS_PATH}" + + params = { + "limit": min(limit, PVR_MAX_PAGE_LIMIT), + "offset": offset, + "byRecordingStatus": "|".join(PVR_RECORDING_STATUSES_TIMERS), + } + + data = self._get(url, params) + if not data: + return [] + + raw_recordings = data.get("recordings", []) + timers = [ + self._map_timer(r) + for r in raw_recordings + if r + ] + + logger.info( + f"{self._provider}: Retrieved {len(timers)} timers " + f"(offset={offset})" + ) + return timers + + def add_timer(self, timer: Timer) -> Timer: + """ + Schedule a new timer on the nPVR backend. + + The timer must be EPG-based: ``timer.epg_event_id`` must be set to the + listing GUID (e.g. ``"3sat_hd_02fe69d5"``). This is the ``listing.guid`` + field present on every recording/timer returned by the get-recordings + endpoint. + + Args: + timer: Timer to create. ``client_index`` is ignored — the backend + assigns and returns it on the created object. + + Returns: + The saved Timer with ``client_index`` populated from the API + response. + + Raises: + ValueError: If ``timer.epg_event_id`` is not set. + RuntimeError: If the API rejects the request (e.g. scheduling + conflict, listing not found). + """ + if not timer.epg_event_id: + raise ValueError( + f"{self._provider}: add_timer() requires timer.epg_event_id " + "(listing GUID, e.g. '3sat_hd_02fe69d5')" + ) + + pvr_base_url = self._get_pvr_base_url() + url = f"{pvr_base_url}{PVR_RECORDINGS_PATH}" + + payload = self._build_create_payload(timer) + data = self._post(url, payload) + + if not data: + raise RuntimeError( + f"{self._provider}: add_timer() — no response from nPVR API " + f"for listing '{timer.epg_event_id}'" + ) + + created = self._map_timer(data) + logger.info( + f"{self._provider}: Timer created — " + f"client_index={created.client_index} title='{created.title}'" + ) + return created + + def update_timer(self, timer: Timer) -> Timer: + """ + Update an existing timer on the nPVR backend. + + ``timer.client_index`` must be set to the ``id`` returned when the + timer was first fetched or created (the short hex nPVR recording ID). + + Args: + timer: Timer with updated fields. + + Returns: + The updated Timer as confirmed by the API. + + Raises: + KeyError: If no timer with that client_index exists (404). + ValueError: If ``timer.client_index`` is 0 (not yet saved). + RuntimeError: If the API rejects the update. + """ + if not timer.client_index: + raise ValueError( + f"{self._provider}: update_timer() requires a non-zero " + "timer.client_index" + ) + + pvr_base_url = self._get_pvr_base_url() + url = f"{pvr_base_url}{PVR_RECORDINGS_PATH}/{timer.client_index}" + + payload = self._build_create_payload(timer) + data = self._put(url, payload) + + if not data: + raise RuntimeError( + f"{self._provider}: update_timer() — no response from nPVR API " + f"for timer '{timer.client_index}'" + ) + + updated = self._map_timer(data) + logger.info( + f"{self._provider}: Timer updated — " + f"client_index={updated.client_index} title='{updated.title}'" + ) + return updated + + def delete_timer( + self, + client_index: int, + force_delete: bool = False, + **kwargs, + ) -> None: + """ + Cancel a timer on the nPVR backend. + + Uses the same DELETE endpoint as RecordingsManager.delete_recording() + because the backend distinguishes timers from recordings only by their + current status. + + Args: + client_index: The nPVR recording ID (== Timer.client_index for + saved timers). + force_delete: Unused for SCHEDULED timers; included for interface + parity with the base provider contract. When a timer + is already in RECORDING state this flag would abort + the active capture — implement if/when needed. + + Raises: + KeyError: If no timer with that ID exists (404). + RuntimeError: If the API refuses deletion. + """ + pvr_base_url = self._get_pvr_base_url() + url = f"{pvr_base_url}{PVR_RECORDINGS_PATH}/{client_index}" + + status_code = self._delete(url) + logger.info( + f"{self._provider}: Timer '{client_index}' cancelled " + f"[HTTP {status_code}]" + ) + + # ========================================================================= + # Private helpers – request building + # ========================================================================= + + @staticmethod + def _build_create_payload(timer: Timer) -> Dict: + """ + Build the POST/PUT request body for the nPVR recordings endpoint. + + The Magenta2 nPVR API expects the listing GUID (timer.epg_event_id) + as ``listingId``, plus optional pre/post padding offsets in seconds. + + NOTE: Confirm the exact field names against the live API spec. + ``startOffsetSeconds`` / ``endOffsetSeconds`` are inferred from + the sample JSON data (recordings carry these fields). + """ + payload: Dict = { + "listingId": timer.epg_event_id, + } + + # Pre/post padding — convert from minutes (Timer model) to seconds (API) + if timer.margin_start: + payload["startOffsetSeconds"] = timer.margin_start * 60 + if timer.margin_end: + payload["endOffsetSeconds"] = timer.margin_end * 60 + + return payload + + # ========================================================================= + # Private helpers – response mapping + # ========================================================================= + + def _map_timer(self, raw: Dict) -> Timer: + """ + Map a raw nPVR API recording dict (status=SCHEDULED) to a :class:`Timer`. + + Field mapping: + id → client_index (cast to int via hash) + recordingStatus → state + listing.guid → epg_event_id + listing.stationId → client_channel_uid (numeric tail) + program.title / series.title → title + program.description → description + startDateTime → start_time + endDateTime → end_time + startOffsetSeconds → margin_start (seconds → minutes) + endOffsetSeconds → margin_end (seconds → minutes) + program.tags[genre-primary] → (description field only) + program.thumbnails → (not carried on Timer — no Content) + """ + recording_id: str = raw.get("id", "") + + # client_index must be an int per the PVR contract. + # The nPVR IDs are hex strings — use a stable numeric hash. + # We also store the original string in epg_event_id / series_link + # so that update/delete calls can reconstruct the URL path. + client_index: int = _hex_id_to_int(recording_id) + + # ── State ──────────────────────────────────────────────────────── + state = _map_timer_state(raw.get("recordingStatus", "SCHEDULED")) + + # ── Title ──────────────────────────────────────────────────────── + program: Dict = raw.get("program") or {} + series: Dict = raw.get("series") or {} + title: str = ( + program.get("title") + or series.get("title") + or raw.get("title") + or f"Timer {recording_id[:8]}" + ).strip() + + # ── Description ────────────────────────────────────────────────── + description: Optional[str] = ( + program.get("shortDescription") + or program.get("description") + or None + ) + + # ── Channel ────────────────────────────────────────────────────── + listing: Dict = raw.get("listing") or {} + station_id_uri = listing.get("stationId") + client_channel_uid = PvrHelpers.extract_numeric_tail(station_id_uri) or -1 + + # ── EPG linkage ─────────────────────────────────────────────────── + # listing.guid is the stable identifier used to re-create / update + # the timer (passed back as listingId in the POST body). + listing_guid: Optional[str] = listing.get("guid") # e.g. "3sat_hd_02fe69d5" + + # The nPVR recording ID is used for DELETE/PUT URL construction; + # store it in series_link so it survives a round-trip through Timer. + # (client_index is an int and cannot hold the hex string directly.) + series_link: Optional[str] = recording_id or None + + # ── Timing ─────────────────────────────────────────────────────── + start_time = PvrHelpers.parse_datetime(raw.get("startDateTime")) + end_time = PvrHelpers.parse_datetime(raw.get("endDateTime")) + + # ── Padding (API stores seconds; Timer model uses minutes) ──────── + margin_start = _seconds_to_minutes(raw.get("startOffsetSeconds", 0)) + margin_end = _seconds_to_minutes(raw.get("endOffsetSeconds", 0)) + + return Timer( + client_index=client_index, + state=state, + timer_type_id=PVR_TIMER_TYPE_EPG_ONE_SHOT, + title=title, + provider=self._provider, + # channel + client_channel_uid=client_channel_uid, + # timing + start_time=start_time, + end_time=end_time, + # padding + margin_start=margin_start, + margin_end=margin_end, + # EPG linkage + epg_event_id=listing_guid, + series_link=series_link, + # metadata + description=description, + last_updated=datetime.now(tz=timezone.utc), + ) + + +# --------------------------------------------------------------------------- +# Module-level helpers (pure functions; no access to manager state) +# --------------------------------------------------------------------------- + +def _map_timer_state(status_str: str) -> TimerState: + """Map nPVR recordingStatus to TimerState.""" + _map = { + "SCHEDULED": TimerState.SCHEDULED, + "RECORDING": TimerState.RECORDING, + "RECORDED": TimerState.COMPLETED, + "GENERATED": TimerState.COMPLETED, + "FAILED": TimerState.ERROR, + "TO_DELETE": TimerState.CANCELLED, + "DELETED": TimerState.CANCELLED, + } + return _map.get(status_str.upper(), TimerState.SCHEDULED) + + +def _hex_id_to_int(hex_str: str) -> int: + """ + Convert an nPVR hex recording ID to a stable positive integer. + + The PVR contract requires client_index to be an unsigned int. We use the + lower 31 bits of the hex value to stay safely within signed-int range on + all platforms. Collisions are astronomically unlikely for the number of + timers a single user would have. + """ + if not hex_str: + return 0 + try: + return int(hex_str, 16) & 0x7FFF_FFFF + except ValueError: + return hash(hex_str) & 0x7FFF_FFFF + + +def _seconds_to_minutes(seconds) -> int: + """Convert seconds (int or None) to whole minutes, minimum 0.""" + try: + return max(0, int(seconds) // 60) + except (TypeError, ValueError): + return 0 \ No newline at end of file