mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-09-24 10:02:37 +02:00
Add timers to M2
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user