Add timers to M2

This commit is contained in:
Nirvana
2026-04-03 20:20:05 +02:00
parent 282d9f527f
commit a63545da7c
4 changed files with 903 additions and 263 deletions
@@ -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"
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"]
@@ -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<days>\d+)D)?"
r"(?:T"
r"(?:(?P<hours>\d+)H)?"
r"(?:(?P<minutes>\d+)M)?"
r"(?:(?P<seconds>\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
@@ -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<days>\d+)D)?"
r"(?:T"
r"(?:(?P<hours>\d+)H)?"
r"(?:(?P<minutes>\d+)M)?"
r"(?:(?P<seconds>\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
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)
@@ -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": "<listing_guid>", "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