From 307ebe6e0b180a04ef4955ad34c33446b2bea3be Mon Sep 17 00:00:00 2001 From: Nirvana Date: Sat, 3 Oct 2026 22:03:39 +0200 Subject: [PATCH] add simplitv --- .../providers/simplitv/__init__.py | 11 + .../providers/simplitv/auth.py | 244 +++++++++++ .../providers/simplitv/catchup_manager.py | 182 ++++++++ .../providers/simplitv/channel_manager.py | 409 ++++++++++++++++++ .../providers/simplitv/constants.py | 227 ++++++++++ .../providers/simplitv/epg_manager.py | 322 ++++++++++++++ .../providers/simplitv/helpers.py | 51 +++ .../providers/simplitv/logos.py | 130 ++++++ .../providers/simplitv/models.py | 59 +++ .../providers/simplitv/provider.py | 349 +++++++++++++++ .../providers/simplitv/recordings_manager.py | 205 +++++++++ 11 files changed, 2189 insertions(+) create mode 100644 lib/streaming_providers/providers/simplitv/__init__.py create mode 100644 lib/streaming_providers/providers/simplitv/auth.py create mode 100644 lib/streaming_providers/providers/simplitv/catchup_manager.py create mode 100644 lib/streaming_providers/providers/simplitv/channel_manager.py create mode 100644 lib/streaming_providers/providers/simplitv/constants.py create mode 100644 lib/streaming_providers/providers/simplitv/epg_manager.py create mode 100644 lib/streaming_providers/providers/simplitv/helpers.py create mode 100644 lib/streaming_providers/providers/simplitv/logos.py create mode 100644 lib/streaming_providers/providers/simplitv/models.py create mode 100644 lib/streaming_providers/providers/simplitv/provider.py create mode 100644 lib/streaming_providers/providers/simplitv/recordings_manager.py diff --git a/lib/streaming_providers/providers/simplitv/__init__.py b/lib/streaming_providers/providers/simplitv/__init__.py new file mode 100644 index 0000000..0d01ef8 --- /dev/null +++ b/lib/streaming_providers/providers/simplitv/__init__.py @@ -0,0 +1,11 @@ +# streaming_providers/providers/simplitv/__init__.py +""" +simpliTV streaming provider. + +Registered via directory-based discovery. The module-level import here +is what makes the provider visible to the host. +""" + +from .provider import SimpliTVProvider + +__all__ = ["SimpliTVProvider"] diff --git a/lib/streaming_providers/providers/simplitv/auth.py b/lib/streaming_providers/providers/simplitv/auth.py new file mode 100644 index 0000000..fa15617 --- /dev/null +++ b/lib/streaming_providers/providers/simplitv/auth.py @@ -0,0 +1,244 @@ +# streaming_providers/providers/simplitv/auth.py +""" +simpliTV authentication. + +Two deliberate deviations from the AuthProtocol's *implied* shape (the +protocol only requires the three method names, not their semantics): + + 1. The simpliTV token is passed as a *query parameter* (GET) or *body + field* (POST), never as an Authorization header. build_headers() + therefore returns base headers only; callers attach the token via + with_token() / auth_body(). One endpoint (GetRecordings) names the + query parameter `tokenValue` instead of `token`. + + 2. A second credential, the *device key*, is required by + AcquireContent. It lives on the server: GetDevices returns the + account's registered device, and a device is only registered when + the list is empty. Nothing about it is persisted locally, so a + restart or invalidate() can never leak devices. + +Sessions are not persisted: the token is cheap to re-acquire, and the +device key is re-read from the server. +""" + +import secrets +import string +import threading +import time +from typing import Any, Dict, Optional +from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse + +from ...base.auth.base_auth import BaseAuthToken +from ...base.errors import AuthError, CredentialsError +from ...base.utils.logger import logger + +from .constants import SimpliTVConfig, SimpliTVDefaults + + +def _generate_device_key() -> str: + """32 lowercase-alnum chars (the addon's format), from a CSPRNG.""" + alphabet = string.ascii_lowercase + string.digits + return "".join(secrets.choice(alphabet) for _ in range(32)) + + +class SimpliTVAuth: + """ + Authenticator for simpliTV. + + Not a subclass of any base class -- matches AuthProtocol by shape for + the three shared methods, plus simpliTV-specific helpers + (with_token, auth_body) and the device-key accessor. + + Thread-safe: the backend serves requests from several threads, and + login / device registration must happen once, not once per thread. + """ + + def __init__( + self, + *, + http_manager, + country: str, + settings_manager=None, + credentials=None, + config: Optional[SimpliTVConfig] = None, + **provider_opts, + ): + self.http_manager = http_manager + self.country = country + self.settings_manager = settings_manager + self._credentials = credentials + self.config = config or SimpliTVConfig() + self._cached_token: Optional[BaseAuthToken] = None + self._device_key: Optional[str] = None + self._lock = threading.RLock() + + # ------------------------------------------------------------------ + # The three shared methods + # ------------------------------------------------------------------ + + def get_access_token(self, force_refresh: bool = False) -> str: + with self._lock: + if ( + not force_refresh + and self._cached_token + and not self._cached_token.is_expired + ): + return self._cached_token.access_token + self._cached_token = self._perform_authentication() + return self._cached_token.access_token + + def build_headers( + self, token: Optional[str] = None, **opts + ) -> Dict[str, str]: + """ + Return *base* headers for the simpliTV API. + + The token is deliberately NOT placed here -- see with_token() and + auth_body(). The `token` argument is accepted for protocol + compatibility and ignored. + """ + return self.config.get_api_headers() + + def invalidate(self) -> None: + """ + Drop the cached token; the next call logs in again. + + The device key is kept: it identifies the install, not the + session. + """ + with self._lock: + self._cached_token = None + + # ------------------------------------------------------------------ + # simpliTV-specific helpers + # ------------------------------------------------------------------ + + def with_token( + self, + url: str, + params: Optional[Dict[str, Any]] = None, + *, + param: str = SimpliTVDefaults.TOKEN_PARAM, + ) -> str: + """Append the token (as `param`) and any extra params to `url`.""" + token = self.get_access_token() + parsed = urlparse(url) + query = dict(parse_qsl(parsed.query)) + query[param] = token + if params: + query.update(params) + return urlunparse(parsed._replace(query=urlencode(query))) + + def auth_body(self, payload: Dict[str, Any]) -> Dict[str, Any]: + """Return `payload` with the token merged in as a body field.""" + merged = dict(payload) + merged["token"] = self.get_access_token() + return merged + + def get_device_key(self) -> str: + """ + Return the account's device key. + + Reads GetDevices first and uses the registered device; registers + a new one only if the account has none. + """ + with self._lock: + if self._device_key: + return self._device_key + + devices = self._list_devices() + if not devices: + self._register_device() + devices = self._list_devices() + if not devices: + raise AuthError( + "simpliTV: device registration returned no devices" + ) + self._device_key = devices[0]["key"] + logger.debug(f"simpliTV[{self.country}]: device key resolved") + return self._device_key + + # ------------------------------------------------------------------ + # Provider-specific implementation + # ------------------------------------------------------------------ + + def _perform_authentication(self) -> BaseAuthToken: + creds = self._resolve_credentials() + logger.debug(f"simpliTV[{self.country}]: logging in") + + payload = { + "Login": creds["username"], + "Password": creds["password"], + "LongExpiration": "true", + "platformCodename": self.config.platform_codename, + } + resp = self.http_manager.post( + self.config.authenticate_url(), + json=payload, + headers=self.build_headers(), + ) + token_value = resp.json().get("token") + if not token_value: + raise AuthError("simpliTV: no token in authenticate response") + + return BaseAuthToken( + access_token=token_value, + token_type="token", + expires_in=SimpliTVDefaults.TOKEN_LIFETIME_SECONDS, + issued_at=time.time(), + ) + + def _resolve_credentials(self) -> Dict[str, str]: + raw = self._credentials or self._load_stored_credentials() + if not raw: + raise CredentialsError("simpliTV: no credentials available") + + if isinstance(raw, dict): + username, password = raw.get("username"), raw.get("password") + else: + username = getattr(raw, "username", None) + password = getattr(raw, "password", None) + if not username or not password: + raise CredentialsError("simpliTV: username or password empty") + return {"username": username, "password": password} + + def _load_stored_credentials(self): + """ + Seam for credentials held by the host's settings_manager. + + TODO(host): the template expects a fallback to stored + credentials, but the settings_manager accessor name is not + known here and is deliberately not guessed. Implement against + the real base API; return None when nothing is stored. + """ + return None + + def _list_devices(self) -> list: + url = self.with_token( + self.config.get_devices_url(), + {"platformCodename": self.config.platform_codename}, + ) + resp = self.http_manager.get(url, headers=self.build_headers()) + return resp.json().get("devices") or [] + + def _register_device(self) -> None: + logger.info( + f"simpliTV[{self.country}]: no registered device on the " + f"account, registering one" + ) + payload = { + "deviceKey": _generate_device_key(), + "deviceName": SimpliTVDefaults.DEVICE_NAME, + "generalDeviceType": SimpliTVDefaults.DEVICE_GENERAL_TYPE, + "operatingSystem": SimpliTVDefaults.DEVICE_OS, + "platformCodename": self.config.platform_codename, + "pushToken": "", + "userAgent": self.config.user_agent, + "userToken": self.get_access_token(), + "versionOs": SimpliTVDefaults.DEVICE_OS_VERSION, + } + self.http_manager.post( + self.config.register_device_url(), + json=payload, + headers=self.build_headers(), + ) diff --git a/lib/streaming_providers/providers/simplitv/catchup_manager.py b/lib/streaming_providers/providers/simplitv/catchup_manager.py new file mode 100644 index 0000000..332c7d6 --- /dev/null +++ b/lib/streaming_providers/providers/simplitv/catchup_manager.py @@ -0,0 +1,182 @@ +# streaming_providers/providers/simplitv/catchup_manager.py +""" +simpliTV catchup manager. + +simpliTV has no catchup endpoint. The existing addon does two things, +and this manager mirrors both: + +1. Programme replay. An EPG programme (isCatchupEnabled) is played by + passing the *programme's own codename* to AcquireContent -- a plain + manifest, no time parameters. get_catchup_manifest does this when the + caller supplies epg_id (the programme codename, an EPG broadcast_id, + or a prog: id). + +2. Restart from the beginning. The addon plays the *live* manifest, which + carries a 3 hour DVR window, and seeks + (window - age - margin) seconds into it. A manifest URL cannot express + a seek, so this is exposed separately as get_restart_manifest(), + returning (url, seek_seconds). get_catchup_manifest never answers a + restart request with a bare live URL: the ABC can only hand back a + URL, and the caller would silently play live. + +Contract +-------- +The router parses the @ suffix of catchup:@ and +passes start_time. A malformed id raises BadRequestError (it is not +swallowed). end_time is ignored: the API has no end bound. +""" + +import time +from typing import List, Optional, Tuple + +from ...base.errors import BadRequestError, NotFoundError +from ...base.managers import CatchupManager +from ...base.models import DRMConfig + +from .channel_manager import ( + parse_catchup_id, + parse_live_id, + programme_codename_from_epg_id, +) +from .constants import SimpliTVDefaults + + +class SimpliTVCatchupManager(CatchupManager): + """Catchup for simpliTV.""" + + def __init__( + self, + *, + http_manager, + auth, + country, + config, + channels=None, # SimpliTVChannelManager: AcquireContent, DRM + ): + super().__init__( + http_manager=http_manager, + auth=auth, + country=country, + config=config, + ) + self._channels = channels + + # ------------------------------------------------------------------ + # Capability + # ------------------------------------------------------------------ + + @property + def catchup_window_hours(self) -> int: + return SimpliTVDefaults.CATCHUP_WINDOW_HOURS + + # ------------------------------------------------------------------ + # Abstract method + # ------------------------------------------------------------------ + + def get_catchup_manifest( + self, + content_id: str, + start_time: int, + end_time: Optional[int] = None, + epg_id: Optional[str] = None, + **kw, + ) -> Optional[str]: + """ + Manifest for replaying a programme (epg_id given), else None. + + Returns None when there is no epg_id (use get_restart_manifest + for restart-from-beginning) or when AcquireContent has no + manifest for the programme. Raises BadRequestError for a + malformed id. + """ + _channel_and_ts(content_id) # grammar check; raises on malformed + + if not epg_id or self._channels is None: + return None + + programme = programme_codename_from_epg_id(epg_id) + try: + return self._channels.get_channel_manifest( + f"{SimpliTVDefaults.PROGRAMME_PREFIX}{programme}" + ) + except NotFoundError: + return None + + # ------------------------------------------------------------------ + # Restart-from-beginning (live manifest + player-side seek) + # ------------------------------------------------------------------ + + def get_restart_manifest( + self, content_id: str, start_time: int = 0 + ) -> Optional[Tuple[str, int]]: + """ + Return (live manifest URL, seek_seconds) to restart a programme + that began at start_time, or None. + + seek_seconds is the offset from the *start* of the DVR window: + window - age - margin. The id's embedded @ is used when + start_time is not given. None when the start is in the future, + outside the window (less the safety margin), or the channel has + no manifest. + """ + codename, embedded = _channel_and_ts(content_id) + if start_time <= 0 and embedded: + start_time = embedded + if start_time <= 0 or self._channels is None: + return None + + window = SimpliTVDefaults.CATCHUP_WINDOW_HOURS * 3600 + margin = SimpliTVDefaults.CATCHUP_SEEK_MARGIN_SECONDS + age = int(time.time()) - start_time + if age < 0 or age >= window - margin: + return None + + try: + url = self._channels.get_channel_manifest( + f"{SimpliTVDefaults.LIVE_PREFIX}{codename}" + ) + except NotFoundError: + return None + if not url: + return None + return url, window - age - margin + + # ------------------------------------------------------------------ + # Concrete overrides + # ------------------------------------------------------------------ + + def get_catchup_drm( + self, + content_id: str, + start_time: int, + end_time: Optional[int] = None, + epg_id: Optional[str] = None, + **kw, + ) -> List[DRMConfig]: + """ + DRM for catchup: the programme's own (epg_id) when replaying, + otherwise the channel's live DRM (restart plays the live + stream). Scheme selection stays in the channel manager. + """ + codename, _ = _channel_and_ts(content_id) + if self._channels is None: + return [] + if epg_id: + target = ( + f"{SimpliTVDefaults.PROGRAMME_PREFIX}" + f"{programme_codename_from_epg_id(epg_id)}" + ) + else: + target = f"{SimpliTVDefaults.LIVE_PREFIX}{codename}" + return self._channels.get_channel_drm(target) + + +def _channel_and_ts(content_id: str) -> Tuple[str, Optional[int]]: + """(channel codename, embedded ts or None) for catchup: / live: ids.""" + if content_id.startswith(SimpliTVDefaults.CATCHUP_PREFIX): + return parse_catchup_id(content_id) + if content_id.startswith(SimpliTVDefaults.LIVE_PREFIX): + return parse_live_id(content_id), None + raise BadRequestError( + f"simpliTV: not a catchup/live id: {content_id!r}" + ) diff --git a/lib/streaming_providers/providers/simplitv/channel_manager.py b/lib/streaming_providers/providers/simplitv/channel_manager.py new file mode 100644 index 0000000..9139e4c --- /dev/null +++ b/lib/streaming_providers/providers/simplitv/channel_manager.py @@ -0,0 +1,409 @@ +# streaming_providers/providers/simplitv/channel_manager.py +""" +simpliTV channel manager. + +Content-id grammar (authoritative for everything played through +/Player/AcquireContent): + + live: live channel + rec: a recorded programme + prog: an EPG programme (replay) + +All three resolve to the same AcquireContent call -- the API shares one +codename namespace between channels and programmes. The distinction is +semantic (what the caller means by the id), not mechanical. + +Catchup ids (catchup:@) are handled by +SimpliTVCatchupManager and rejected here, so the router can dispatch +without a wasted AcquireContent call. + +Manifest and DRM are folded into this manager because both arrive in one +AcquireContent response (README: DRM shares state with the manifest step, +so it folds). The response is cached briefly so the manifest and DRM +steps of one playback share a single request. +""" + +import threading +import time +from typing import Any, Dict, List, Optional + +from ...base.errors import BadRequestError, NotFoundError +from ...base.managers import ChannelManager +from ...base.models import Channel +from ...base.models.drm_models import DRMConfig, DRMSystem, LicenseConfig +from ...base.utils.logger import logger + +from .constants import SimpliTVDefaults +from .helpers import prefer_dash, transport_errors +from .logos import channel_name_from_codename, logo_for_name +from .models import SimpliTVChannel + + +class SimpliTVChannelManager(ChannelManager): + """Fetches live channels, manifests and DRM for simpliTV.""" + + def __init__( + self, + *, + http_manager, + auth, + country, + config, + channels_cache: Optional[Dict] = None, + playback_cache: Optional[Dict] = None, + ): + super().__init__( + http_manager=http_manager, + auth=auth, + country=country, + config=config, + ) + self._channels_cache = ( + channels_cache if channels_cache is not None else {} + ) + # codename -> (expires_at, AcquireContent response) + self._playback_cache = ( + playback_cache if playback_cache is not None else {} + ) + self._cache_lock = threading.Lock() + + # ------------------------------------------------------------------ + # Routing + # ------------------------------------------------------------------ + + def handles_content_id(self, content_id: str) -> bool: + return content_id.startswith(( + SimpliTVDefaults.LIVE_PREFIX, + SimpliTVDefaults.RECORDING_PREFIX, + SimpliTVDefaults.PROGRAMME_PREFIX, + )) + + # ------------------------------------------------------------------ + # Channels + # ------------------------------------------------------------------ + + def get_channels(self, **kw) -> List[Channel]: + """ + Return the channel list. + + The tile endpoint yields only codenames (the existing addon reads + nothing else from it, and only from the first group), so names + and logos are derived locally -- see logos.py. EPG metadata is + not fetched here; that keeps this call independent of EPG + availability. + """ + # NOTE: token is in the body (auth_body), not a header. + body = self.auth.auth_body({ + "isParentalControlEnabled": "false", + "platformCodename": self.config.platform_codename, + }) + resp = self.http_manager.post( + self.config.channel_tiles_url(), + json=body, + headers=self.config.get_api_headers(), + ) + groups = resp.json().get("channels") or [] + tiles = groups[0].get("tiles", []) if groups else [] + + channels: List[Channel] = [] + seen = set() + for tile in tiles: + codename = tile.get("codename") + if not codename or codename in seen: + continue + seen.add(codename) + channels.append(self._channel_from_codename(codename)) + return channels + + @staticmethod + def _channel_from_codename(codename: str) -> SimpliTVChannel: + name = channel_name_from_codename(codename) + return SimpliTVChannel( + channel_id=f"{SimpliTVDefaults.LIVE_PREFIX}{codename}", + name=name, + codename=codename, + logo_url=logo_for_name(name), + ) + + # ------------------------------------------------------------------ + # Manifest + # ------------------------------------------------------------------ + + def get_channel_manifest( + self, content_id: str, **kw + ) -> Optional[str]: + """ + Return the manifest for a live:, rec: or prog: content id. + + DRM-protected streams are rewritten to DASH (what + inputstream.adaptive needs); unprotected streams keep the HLS URL + the API returned. + + The returned URL does NOT contain the API token (it was only used + for the AcquireContent request), and manifest/segment requests + need just a User-Agent -- see get_channel_manifest_headers(). + """ + codename = codename_from_playable_id(content_id) + + # A new playback: always a fresh response (licence data may be + # session bound); get_channel_drm then reads it from the cache. + acquire = self.acquire_content(codename, refresh=True) + + try: + manifest_url = acquire["MediaFiles"][0]["Formats"][0]["Url"] + except (KeyError, IndexError, TypeError): + raise NotFoundError( + f"simpliTV: no manifest in AcquireContent for {codename!r}" + ) + + if acquire.get("DrmInfo"): + manifest_url = prefer_dash(manifest_url) + return manifest_url + + # ------------------------------------------------------------------ + # Player request headers + # ------------------------------------------------------------------ + # The base layer's method names for these hooks were not available + # when this was written; the names below follow the review of the + # package. If the base calls different names, rename accordingly -- + # until then these are simply unused. + + def get_channel_manifest_headers(self, content_id: str, **kw) -> dict: + """User-Agent only -- see SimpliTVConfig.get_stream_headers().""" + return self.config.get_stream_headers() + + def get_segment_headers(self, content_id: str, **kw) -> dict: + """User-Agent only: the CDN does not authenticate segments.""" + return self.config.get_stream_headers() + + # ------------------------------------------------------------------ + # DRM (folded -- shares the AcquireContent response with the manifest) + # ------------------------------------------------------------------ + + def get_channel_drm( + self, content_id: str, **kw + ) -> List[DRMConfig]: + """ + Return DRM for a live:, rec: or prog: content id. + + Reads the response cached by get_channel_manifest when it is + still fresh, otherwise fetches it. + """ + codename = codename_from_playable_id(content_id) + acquire = self.acquire_content(codename) + + drm_info = acquire.get("DrmInfo") or [] + if not drm_info: + # Unprotected content is not a failure: [] per the + # None-vs-exception rule. + return [] + + wanted = ( + SimpliTVDefaults.DRM_INDEX_PLAYREADY + if self.config.prefer_playready + else SimpliTVDefaults.DRM_INDEX_WIDEVINE + ) + if wanted < len(drm_info): + index = wanted + else: + # Requested scheme isn't offered; some assets are + # single-scheme, so use what is there. + index = 0 + system = ( + DRMSystem.PLAYREADY + if index == SimpliTVDefaults.DRM_INDEX_PLAYREADY + else DRMSystem.WIDEVINE + ) + + entry = drm_info[index] + license_url = entry.get("LicenseServerUrl") + if not license_url: + return [] + challenge = entry.get("DrmChallengeCustomData") + + return [ + DRMConfig( + system=system, + priority=1, + license=LicenseConfig( + server_url=license_url, + req_headers=self.config.get_license_headers(challenge), + # Body = the raw CDM challenge (the addon's R{SSM}). + req_data="{CHA-RAW}", + use_http_get_request=False, + ), + ) + ] + + # ------------------------------------------------------------------ + # AcquireContent (shared with SimpliTVCatchupManager via this class) + # ------------------------------------------------------------------ + + def acquire_content( + self, codename: str, *, refresh: bool = False + ) -> Dict[str, Any]: + """ + GET /Player/AcquireContent for a codename. + + Responses are cached for PLAYBACK_CACHE_TTL seconds so the + manifest and DRM steps of one playback make one request; + refresh=True bypasses the cache (and repopulates it). + """ + if not refresh: + cached = self._cache_get(codename) + if cached is not None: + return cached + + logger.debug( + f"simpliTV: AcquireContent {codename!r} (refresh={refresh})" + ) + # NOTE: token is in the URL (via with_token), not a header. It + # authenticates THIS request only; the manifest URL returned in + # the response does not carry it. + url = self.auth.with_token( + self.config.acquire_content_url(), + { + "platformCodename": self.config.platform_codename, + "deviceKey": self.auth.get_device_key(), + "codename": codename, + }, + ) + with transport_errors(f"AcquireContent for {codename!r}"): + resp = self.http_manager.get( + url, headers=self.config.get_manifest_headers() + ) + data = resp.json() + + self._cache_put(codename, data) + return data + + # ------------------------------------------------------------------ + # Playback cache (TTL + size bound) + # ------------------------------------------------------------------ + + def _cache_get(self, codename: str) -> Optional[Dict[str, Any]]: + with self._cache_lock: + item = self._playback_cache.get(codename) + if item is None: + return None + expires_at, data = item + if expires_at <= time.monotonic(): + del self._playback_cache[codename] + return None + return data + + def _cache_put(self, codename: str, data: Dict[str, Any]) -> None: + now = time.monotonic() + with self._cache_lock: + if len(self._playback_cache) >= SimpliTVDefaults.PLAYBACK_CACHE_MAX: + for key in [ + k for k, (exp, _) in self._playback_cache.items() + if exp <= now + ]: + del self._playback_cache[key] + if len(self._playback_cache) >= SimpliTVDefaults.PLAYBACK_CACHE_MAX: + logger.debug( + "simpliTV: playback cache full of live entries, " + "clearing" + ) + self._playback_cache.clear() + self._playback_cache[codename] = ( + now + SimpliTVDefaults.PLAYBACK_CACHE_TTL, data, + ) + + +# ---------------------------------------------------------------------- +# Content-id grammar -- module scope so every manager can import the +# parsers without circular dependencies. All raise BadRequestError on +# malformed input; the router does not catch it. +# ---------------------------------------------------------------------- + +def _strip_prefix(content_id: str, prefix: str, kind: str) -> str: + if not content_id.startswith(prefix): + raise BadRequestError(f"simpliTV: not a {kind} id: {content_id!r}") + value = content_id[len(prefix):] + if not value: + raise BadRequestError( + f"simpliTV: {kind} id missing codename: {content_id!r}" + ) + return value + + +def parse_live_id(content_id: str) -> str: + """Codename from live:.""" + return _strip_prefix(content_id, SimpliTVDefaults.LIVE_PREFIX, "live") + + +def parse_recording_id(content_id: str) -> str: + """Programme codename from rec:.""" + return _strip_prefix( + content_id, SimpliTVDefaults.RECORDING_PREFIX, "recording" + ) + + +def parse_programme_id(content_id: str) -> str: + """Programme codename from prog:.""" + return _strip_prefix( + content_id, SimpliTVDefaults.PROGRAMME_PREFIX, "programme" + ) + + +def parse_catchup_id(content_id: str): + """ + Return (channel codename, start_ts) from catchup:@. + + The @ suffix is required -- a bare catchup: is a grammar + error, not a live id in disguise. + """ + rest = _strip_prefix( + content_id, SimpliTVDefaults.CATCHUP_PREFIX, "catchup" + ) + codename, sep, ts = rest.partition(SimpliTVDefaults.CATCHUP_TS_SEPARATOR) + if not sep or not codename: + raise BadRequestError( + f"simpliTV: catchup id must be " + f"catchup:{SimpliTVDefaults.CATCHUP_TS_SEPARATOR}: " + f"{content_id!r}" + ) + try: + return codename, int(ts) + except ValueError: + raise BadRequestError(f"simpliTV: catchup ts not an int: {ts!r}") + + +def codename_from_playable_id(content_id: str) -> str: + """ + Codename for a live:, rec: or prog: id (all share one namespace). + + Raises BadRequestError for any other prefix, so a malformed call + surfaces rather than silently mis-fetching. + """ + if content_id.startswith(SimpliTVDefaults.LIVE_PREFIX): + return parse_live_id(content_id) + if content_id.startswith(SimpliTVDefaults.RECORDING_PREFIX): + return parse_recording_id(content_id) + if content_id.startswith(SimpliTVDefaults.PROGRAMME_PREFIX): + return parse_programme_id(content_id) + raise BadRequestError( + f"simpliTV: not a live/rec/prog id: {content_id!r}" + ) + + +def programme_codename_from_epg_id(epg_id: str) -> str: + """ + Programme codename from whatever the host passes as epg_id: an EPG + broadcast_id (simplitv:::), a prog: id, or + the bare programme codename. + """ + if not epg_id: + raise BadRequestError("simpliTV: empty epg_id") + if epg_id.startswith(SimpliTVDefaults.BROADCAST_ID_PREFIX): + parts = epg_id.split(":", 3) + if len(parts) != 4 or not parts[3]: + raise BadRequestError( + f"simpliTV: malformed broadcast id: {epg_id!r}" + ) + return parts[3] + if epg_id.startswith(SimpliTVDefaults.PROGRAMME_PREFIX): + return parse_programme_id(epg_id) + return epg_id diff --git a/lib/streaming_providers/providers/simplitv/constants.py b/lib/streaming_providers/providers/simplitv/constants.py new file mode 100644 index 0000000..69b333e --- /dev/null +++ b/lib/streaming_providers/providers/simplitv/constants.py @@ -0,0 +1,227 @@ +# streaming_providers/providers/simplitv/constants.py +""" +simpliTV constants. + +All URLs, endpoint paths, static header values, and default parameters +live here so no other file contains magic strings. +""" + +from typing import Optional + + +class SimpliTVDefaults: + PROVIDER_NAME = "simplitv" + PROVIDER_LOGO = "https://files.app.simplitv.at/files/orf1-hd-bunt.png" + + BASE_URL = "https://api.app.simplitv.at" + WEBSITE = "https://streaming.simplitv.at" + + # --- Endpoint paths ------------------------------------------------ + PATH_AUTHENTICATE = "/v1/OrsUser/Authenticate" + PATH_GET_DEVICES = "/v1/Devices/GetDevices" + PATH_REGISTER_DEVICE = "/v1/Devices/RegisterDevice" + PATH_CHANNEL_TILES = "/v1/EpgTile/FilterChannelTiles" + PATH_PROGRAM_TILES = "/v1/EpgTile/FilterProgramTiles" + PATH_TILE_DETAILS = "/v2/Tile/GetTiles" + PATH_ACQUIRE_CONTENT = "/v1/Player/AcquireContent" + PATH_GET_RECORDINGS = "/v2/Pvr/GetRecordings" + PATH_SCHEDULE_RECORDING = "/v1/Pvr/ScheduleRecording" + PATH_DELETE_RECORDING = "/v1/Pvr/DeleteRecording" + + # --- Token transport ----------------------------------------------- + # Most endpoints take `token`; GetRecordings (v2) takes `tokenValue`. + TOKEN_PARAM = "token" + TOKEN_PARAM_RECORDINGS = "tokenValue" + + # The existing addon sends the headers a second time as a `$headers` + # query parameter on the two anonymous EPG endpoints. Mirrored here + # because it is known to work. + EPG_HEADERS_QUERY = ( + "$headers=%7B%22Content-Type%22:%22application%2Fjson%3B" + "charset%3Dutf-8%22,%22X-Api-Date-Format%22:%22iso%22," + "%22X-Api-Camel-Case%22:true%7D" + ) + + # --- Platform / device identity ------------------------------------ + PLATFORM_CODENAME = "www" + DEVICE_NAME = "Firefox" + DEVICE_GENERAL_TYPE = "2" + DEVICE_OS = "Windows" + DEVICE_OS_VERSION = "10" + + # --- Headers ------------------------------------------------------- + USER_AGENT = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) " + "Gecko/20100101 Firefox/110.0" + ) + HEADER_API_DATE_FORMAT = "iso" + HEADER_API_CAMEL_CASE = "true" + + TIMEOUT = 30 + + # --- DRM ----------------------------------------------------------- + # DrmInfo indices in the AcquireContent response: + # [0] = PlayReady, [1] = Widevine. + DRM_INDEX_PLAYREADY = 0 + DRM_INDEX_WIDEVINE = 1 + + # --- Auth ---------------------------------------------------------- + # simpliTV does not return expires_in; refresh well inside any + # plausible server-side expiry. + TOKEN_LIFETIME_SECONDS = 12 * 3600 + + # --- Caches -------------------------------------------------------- + PLAYBACK_CACHE_TTL = 60 # AcquireContent responses, seconds + PLAYBACK_CACHE_MAX = 256 + EPG_CACHE_TTL = 300 # EPG day-windows, seconds + EPG_CACHE_MAX = 64 + + # --- EPG ----------------------------------------------------------- + EPG_PAST_DAYS = 7 + EPG_FUTURE_DAYS = 7 + # The API is queried in day-sized windows, like the existing addon. + EPG_CHUNK_HOURS = 24 + + # --- Content-id grammar ------------------------------------------- + # live: -> SimpliTVChannelManager + # rec: -> SimpliTVChannelManager (same + # AcquireContent call as live) + # prog: -> SimpliTVChannelManager (EPG + # programme replay; also the id + # form scheduling a recording + # needs) + # catchup:@ -> SimpliTVCatchupManager + # (restart-from-beginning) + LIVE_PREFIX = "live:" + RECORDING_PREFIX = "rec:" + PROGRAMME_PREFIX = "prog:" + CATCHUP_PREFIX = "catchup:" + CATCHUP_TS_SEPARATOR = "@" + + # EPG broadcast ids: simplitv:::. The last segment is what catchup takes as epg_id. + BROADCAST_ID_PREFIX = "simplitv:" + + # --- Catchup ------------------------------------------------------- + # "Restart" plays the live manifest, which carries a 3 hour DVR + # window; the player seeks (window - age - margin) seconds into it. + CATCHUP_WINDOW_HOURS = 3 + CATCHUP_SEEK_MARGIN_SECONDS = 35 + + +class SimpliTVConfig: + """ + Per-instance configuration. + + Attributes relied on by provider.py / managers: + user_agent -- passed to _setup_http_manager. + timeout -- passed to _setup_http_manager. + prefer_playready -- selects PlayReady (True) or Widevine (False) + in get_channel_drm. Defaults to Widevine. + """ + + def __init__(self, config_dict: Optional[dict] = None): + config = config_dict or {} + self.base_url = config.get("base_url", SimpliTVDefaults.BASE_URL) + self.user_agent = config.get("user_agent", SimpliTVDefaults.USER_AGENT) + self.timeout = config.get("timeout", SimpliTVDefaults.TIMEOUT) + self.prefer_playready = bool(config.get("prefer_playready", False)) + self.platform_codename = config.get( + "platform_codename", SimpliTVDefaults.PLATFORM_CODENAME + ) + + # ----- Header builders -------------------------------------------- + + def get_api_headers(self) -> dict: + """ + Headers for the JSON API endpoints. + + These are *base* headers only -- the simpliTV token is not a + header (see auth.py). Callers put the token in the query string + (auth.with_token) or the body (auth.auth_body). + """ + return { + "User-Agent": self.user_agent, + "Content-type": "application/json;charset=utf-8", + "X-Api-Date-Format": SimpliTVDefaults.HEADER_API_DATE_FORMAT, + "X-Api-Camel-Case": SimpliTVDefaults.HEADER_API_CAMEL_CASE, + "Referer": f"{SimpliTVDefaults.WEBSITE}/", + } + + def get_manifest_headers(self) -> dict: + """Headers for /Player/AcquireContent (same UA as the API).""" + return { + "User-Agent": self.user_agent, + "Accept": "application/json", + } + + def get_stream_headers(self) -> dict: + """ + Headers for manifest and segment requests made by the player. + + The addon gives inputstream the User-Agent and nothing else: the + token is NOT part of the manifest URL (it only authenticates the + AcquireContent request) and the CDN does not authenticate + segments. Do not derive these from build_headers(), which carries + JSON / X-Api-* / Referer headers the CDN never saw from the addon. + """ + return {"User-Agent": self.user_agent} + + def get_license_headers(self, challenge: Optional[str] = None) -> dict: + """ + Headers for the DRM licence request. + + The challenge custom data travels as a *header*, URL-quoted; the + body is the raw CDM challenge (see channel_manager.get_channel_drm). + """ + from urllib.parse import quote + + headers = { + "User-Agent": self.user_agent, + "Referer": f"{SimpliTVDefaults.WEBSITE}/", + "Content-Type": "application/octet-stream", + } + if challenge: + headers["drmchallengecustomdata"] = quote(challenge) + return headers + + # ----- URL builders ----------------------------------------------- + + def _url(self, path: str) -> str: + return f"{self.base_url}{path}" + + def authenticate_url(self) -> str: + return self._url(SimpliTVDefaults.PATH_AUTHENTICATE) + + def get_devices_url(self) -> str: + return self._url(SimpliTVDefaults.PATH_GET_DEVICES) + + def register_device_url(self) -> str: + return self._url(SimpliTVDefaults.PATH_REGISTER_DEVICE) + + def channel_tiles_url(self) -> str: + return self._url(SimpliTVDefaults.PATH_CHANNEL_TILES) + + def program_tiles_url(self) -> str: + return ( + f"{self._url(SimpliTVDefaults.PATH_PROGRAM_TILES)}" + f"?{SimpliTVDefaults.EPG_HEADERS_QUERY}" + ) + + def tile_details_url(self) -> str: + return ( + f"{self._url(SimpliTVDefaults.PATH_TILE_DETAILS)}" + f"?{SimpliTVDefaults.EPG_HEADERS_QUERY}" + ) + + def acquire_content_url(self) -> str: + return self._url(SimpliTVDefaults.PATH_ACQUIRE_CONTENT) + + def get_recordings_url(self) -> str: + return self._url(SimpliTVDefaults.PATH_GET_RECORDINGS) + + def schedule_recording_url(self) -> str: + return self._url(SimpliTVDefaults.PATH_SCHEDULE_RECORDING) + + def delete_recording_url(self) -> str: + return self._url(SimpliTVDefaults.PATH_DELETE_RECORDING) diff --git a/lib/streaming_providers/providers/simplitv/epg_manager.py b/lib/streaming_providers/providers/simplitv/epg_manager.py new file mode 100644 index 0000000..5cd07bc --- /dev/null +++ b/lib/streaming_providers/providers/simplitv/epg_manager.py @@ -0,0 +1,322 @@ +# streaming_providers/providers/simplitv/epg_manager.py +""" +simpliTV EPG manager. + +Two endpoints, both anonymous (no token -- the existing addon sends +none): + + * /v1/EpgTile/FilterProgramTiles -- every channel's programmes in a + [from, to] window. The response's `programs` is a dict: + {channel codename: [programme, ...]}. + * /v2/Tile/GetTiles -- tile details for a programme id (cast, genres, + images). + +One request therefore serves the whole grid, so get_epg_grid is +overridden natively and get_epg is just a one-channel view of the same +data. Windows are fetched in day-sized chunks (as the addon does) and +cached briefly, so per-channel callers don't re-download the grid. + +Window: -7..+7 days around "now", matching the addon's TV-Guide. +""" + +import threading +import time +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, Iterator, List, Optional, Tuple + +from ...base.errors import ServerError +from ...base.managers import EpgManager +from ...base.models.epg_models import EPGEntry, EPGProgramDetails +from ...base.utils.logger import logger + +from .constants import SimpliTVDefaults +from .helpers import transport_errors + + +class SimpliTVEpgManager(EpgManager): + """EPG for simpliTV.""" + + def __init__(self, *, http_manager, auth, country, config): + super().__init__( + http_manager=http_manager, + auth=auth, + country=country, + config=config, + ) + # (from_iso, to_iso) -> (expires_at, {codename: [programme]}) + self._window_cache: Dict[ + Tuple[str, str], Tuple[float, Dict[str, List[dict]]] + ] = {} + self._cache_lock = threading.Lock() + + @property + def epg_window(self) -> Tuple[int, int]: + return ( + SimpliTVDefaults.EPG_PAST_DAYS, + SimpliTVDefaults.EPG_FUTURE_DAYS, + ) + + # ------------------------------------------------------------------ + # get_epg / get_epg_grid + # ------------------------------------------------------------------ + + def get_epg( + self, + channel_id: str, + start_time: Optional[datetime] = None, + end_time: Optional[datetime] = None, + **kw, + ) -> List[EPGEntry]: + """ + Programmes for `channel_id` in [start_time, end_time]. + + channel_id may carry a live: or catchup: prefix (and @ + suffix); EPG is keyed on the channel codename. + """ + codename = _strip_live_prefix(channel_id) + programs = self._fetch_programs(start_time, end_time) + return _entries_for(codename, programs.get(codename, [])) + + def get_epg_grid( + self, + channel_ids: List[str], + start_time: Optional[datetime] = None, + end_time: Optional[datetime] = None, + **kw, + ) -> Dict[str, List[EPGEntry]]: + """Native batch: one fetch serves every requested channel.""" + programs = self._fetch_programs(start_time, end_time) + grid: Dict[str, List[EPGEntry]] = {} + for channel_id in channel_ids: + codename = _strip_live_prefix(channel_id) + grid[channel_id] = _entries_for( + codename, programs.get(codename, []) + ) + return grid + + # ------------------------------------------------------------------ + # get_program_details + # ------------------------------------------------------------------ + + def get_program_details( + self, program_id: str, **kw + ) -> Optional[EPGProgramDetails]: + """ + Extended details for one tile id (EPGEntry.program_id). + + Returns None when the tile is not found. + """ + body = { + "platformCodename": self.config.platform_codename, + "requestedTiles": [{"id": program_id}], + } + with transport_errors(f"GetTiles for {program_id!r}"): + resp = self.http_manager.post( + self.config.tile_details_url(), + json=body, + headers=self.config.get_api_headers(), + ) + tiles = resp.json().get("tiles") or [] + if not tiles: + return None + return _tile_to_program_details(tiles[0]) + + # ------------------------------------------------------------------ + # Fetching + # ------------------------------------------------------------------ + + def _fetch_programs( + self, start_time: Optional[datetime], end_time: Optional[datetime] + ) -> Dict[str, List[dict]]: + """Merge day-sized windows into {codename: [programme]}.""" + start, end = _normalise_window(start_time, end_time) + merged: Dict[str, Dict[str, dict]] = {} + for w_start, w_end in _chunks(start, end): + chunk = self._fetch_window(_iso(w_start), _iso(w_end)) + for codename, items in chunk.items(): + bucket = merged.setdefault(codename, {}) + for programme in items: + # A programme straddling a boundary appears in both + # windows; the id dedupes it. + key = programme.get("id") or ( + f"{programme.get('codename')}:{programme.get('start')}" + ) + bucket[key] = programme + return {c: list(b.values()) for c, b in merged.items()} + + def _fetch_window( + self, from_iso: str, to_iso: str + ) -> Dict[str, List[dict]]: + key = (from_iso, to_iso) + now = time.monotonic() + with self._cache_lock: + hit = self._window_cache.get(key) + if hit and hit[0] > now: + return hit[1] + + body = { + "platformCodename": self.config.platform_codename, + "from": from_iso, + "to": to_iso, + } + with transport_errors(f"FilterProgramTiles {from_iso}..{to_iso}"): + resp = self.http_manager.post( + self.config.program_tiles_url(), + json=body, + headers=self.config.get_api_headers(), + ) + programs = resp.json().get("programs") + if not isinstance(programs, dict): + raise ServerError( + "simpliTV: FilterProgramTiles `programs` is not a " + "channel-keyed object" + ) + + with self._cache_lock: + if len(self._window_cache) >= SimpliTVDefaults.EPG_CACHE_MAX: + self._window_cache = { + k: v for k, v in self._window_cache.items() + if v[0] > now + } + if len(self._window_cache) >= SimpliTVDefaults.EPG_CACHE_MAX: + self._window_cache.clear() + self._window_cache[key] = ( + now + SimpliTVDefaults.EPG_CACHE_TTL, programs, + ) + return programs + + +# ---------------------------------------------------------------------- +# Module-private helpers +# ---------------------------------------------------------------------- + +def _strip_live_prefix(channel_id: str) -> str: + for prefix in ( + SimpliTVDefaults.LIVE_PREFIX, + SimpliTVDefaults.CATCHUP_PREFIX, + ): + if channel_id.startswith(prefix): + rest = channel_id[len(prefix):] + return rest.split(SimpliTVDefaults.CATCHUP_TS_SEPARATOR, 1)[0] + return channel_id + + +def _normalise_window( + start_time: Optional[datetime], end_time: Optional[datetime] +) -> Tuple[datetime, datetime]: + """UTC-aware [start, end]; missing bounds mean "now" / +1 minute.""" + def aware(dt: datetime) -> datetime: + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + + start = aware(start_time) if start_time else datetime.now(timezone.utc) + end = aware(end_time) if end_time else start + timedelta(minutes=1) + if end <= start: + end = start + timedelta(minutes=1) + return start, end + + +def _chunks( + start: datetime, end: datetime +) -> Iterator[Tuple[datetime, datetime]]: + step = timedelta(hours=SimpliTVDefaults.EPG_CHUNK_HOURS) + cursor = start + while cursor < end: + yield cursor, min(cursor + step, end) + cursor += step + + +def _iso(dt: datetime) -> str: + return dt.strftime("%Y-%m-%dT%H:%M:%S.000Z") + + +def _parse_iso_to_unix(s: Optional[str]) -> Optional[int]: + """ + ISO-8601 (Z or offset) -> Unix seconds, or None if missing/malformed. + + A timestamp without offset is read as UTC (as the addon does), never + as the server's local time. + """ + if not s: + return None + try: + dt = datetime.fromisoformat(s.replace("Z", "+00:00")) + except (ValueError, AttributeError, TypeError): + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return int(dt.timestamp()) + + +def _entries_for( + channel_codename: str, programmes: List[dict] +) -> List[EPGEntry]: + entries = [] + for programme in programmes: + entry = _programme_to_entry(channel_codename, programme) + if entry is not None: + entries.append(entry) + entries.sort(key=lambda e: e.start) + return entries + + +def _programme_to_entry( + channel_codename: str, programme: Dict[str, Any] +) -> Optional[EPGEntry]: + """ + Map a programme tile to EPGEntry; None if it has no usable times + (an entry without times would collide on its broadcast id). + + broadcast_id embeds the programme codename as its last segment -- + that is what the catchup manager takes as epg_id for replay. + program_id is the tile id GetTiles needs. + """ + start = _parse_iso_to_unix(programme.get("start")) + end = _parse_iso_to_unix(programme.get("stop")) + if start is None or end is None: + logger.debug( + f"simpliTV: skipping programme without times: " + f"{programme.get('id')!r}" + ) + return None + + images = programme.get("images") or [] + icon = images[0].get("url") if images else None + + return EPGEntry( + broadcast_id=( + f"{SimpliTVDefaults.BROADCAST_ID_PREFIX}{channel_codename}:" + f"{start}:{programme.get('codename', '')}" + ), + title=programme.get("title", ""), + description=programme.get("description", ""), + start=start, + end=end, + icon=icon, + program_id=programme.get("id"), + ) + + +def _tile_to_program_details(tile: Dict[str, Any]) -> EPGProgramDetails: + """ + Map a /v2/Tile/GetTiles tile to the shared EPGProgramDetails shape. + + Season/episode numbers, countries and genres are on the tile but not + in the shared shape; extend EPGProgramDetails first (template rule) + rather than inventing kwargs here. + """ + images = tile.get("images") or [] + icon = images[0].get("url") if images else None + + people = tile.get("people") or [] + cast = [p.get("fullName", "") for p in people if p.get("fullName")] + + return EPGProgramDetails( + program_id=tile.get("id", ""), + description=tile.get("description", ""), + episode_name=tile.get("subTitle", ""), + year=tile.get("date") or None, + icon=icon, + cast=cast, + ) diff --git a/lib/streaming_providers/providers/simplitv/helpers.py b/lib/streaming_providers/providers/simplitv/helpers.py new file mode 100644 index 0000000..8ee1d53 --- /dev/null +++ b/lib/streaming_providers/providers/simplitv/helpers.py @@ -0,0 +1,51 @@ +# streaming_providers/providers/simplitv/helpers.py +""" +Helpers shared by more than one simpliTV module. + +Public names on purpose: the template README requires that a helper +used by several modules is public (no leading underscore). +""" + +from contextlib import contextmanager + +from ...base.errors import ProviderError, ServerError + + +def prefer_dash(url: str) -> str: + """ + Rewrite an HLS manifest URL to its DASH equivalent. + + Use ONLY for DRM-protected content: inputstream.adaptive needs DASH + there. Unprotected streams stay on the HLS URL the API returned (the + existing addon plays those through ffmpegdirect). The replacement + list is exactly the addon's forward direction; the "nodrm" pair + exists only in the addon's reverse (DASH -> HLS) direction and must + not be applied here. + """ + for old, new in ( + (".m3u8", ".mpd"), + ("/hls/", "/dash/"), + ("hls_live", "dash_live"), + ("/hls4h/", "/dash4h/"), + ): + if old in url: + url = url.replace(old, new) + return url + + +@contextmanager +def transport_errors(what: str): + """ + Wrap *unexpected* failures (network, bad JSON) in ServerError while + letting typed provider errors through untouched. + + Callers rely on AuthError / RateLimitError / GeoBlockError etc. to + refresh tokens or back off, so these must never be flattened into a + generic error. + """ + try: + yield + except ProviderError: + raise + except Exception as e: + raise ServerError(f"simpliTV: {what} failed: {e}") from e diff --git a/lib/streaming_providers/providers/simplitv/logos.py b/lib/streaming_providers/providers/simplitv/logos.py new file mode 100644 index 0000000..c3ee809 --- /dev/null +++ b/lib/streaming_providers/providers/simplitv/logos.py @@ -0,0 +1,130 @@ +# streaming_providers/providers/simplitv/logos.py +""" +simpliTV channel names and logos. + +The channel-tile endpoint only yields a codename, so display names and +logos are derived locally. Ported from the existing addon's +channelname() / logomapper() so behaviour is identical. +""" + +from .constants import SimpliTVDefaults + +LOGO_MAP = { + "ORF1": "https://files.app.simplitv.at/files/orf1-hd-bunt.png", + "ORF2NO": "https://files.app.simplitv.at/files/orf-no-e-hd-bunt.png", + "ATV": "https://files.app.simplitv.at/files/atv-hd-bunt.png", + "PULS4AUSTRIA": "https://files.app.simplitv.at/files/puls4-hd-bunt.png", + "PU4": "https://files.app.simplitv.at/files/puls4-hd-bunt.png", + "SERVUSTV AUSTRIA": "https://files.app.simplitv.at/files/servustv-hd-bunt.png", + "ORF III": "https://files.app.simplitv.at/files/orf3-hd-bunt.png", + "ATV 2": "https://files.app.simplitv.at/files/atv2-bunt.png", + "OE24TV": "https://files.app.simplitv.at/files/oe24-tv-bunt.png", + "PULS24": "https://files.app.simplitv.at/files/puls24-bunt.png", + "PROSIEBENAUSTRIA": "https://files.app.simplitv.at/files/pro7-hd-bunt.png", + "SAT1AUSTRIA": "https://files.app.simplitv.at/files/sat1-hd-bunt.png", + "RTLAUSTRIA": "https://files.app.simplitv.at/files/rtl-austria-hd-bunt.png", + "VOXAUSTRIA": "https://files.app.simplitv.at/files/vox-hd-bunt.png", + "ZDF": "https://files.app.simplitv.at/files/zdf-hd-bunt.png", + "ARD": "https://files.app.simplitv.at/files/ard-hd-bunt.png", + "KABELEINSAUSTRIA": "https://files.app.simplitv.at/files/kabel1-hd-austria-bunt.png", + "SAT1GOLDAUSTRIA": "https://files.app.simplitv.at/files/sat1-gold-hd-bunt.png", + "3SAT": "https://files.app.simplitv.at/files/3sat-hd-bunt.png", + "SIXXAUSTRIA": "https://files.app.simplitv.at/files/sixx-austria-hd-bunt.png", + "PROSIEBENMAXXAUSTRIA": "https://files.app.simplitv.at/files/pro7-maxx-austria-bunt.png", + "N TV": "https://files.app.simplitv.at/files/ntv-hd-bunt.png", + "RTLZWEIAUSTRIA": "https://files.app.simplitv.at/files/rtl-zwei-hd-bunt.png", + "ZDFNEO": "https://files.app.simplitv.at/files/zdf-neo-hd-bunt.png", + "ZDFINFO": "https://files.app.simplitv.at/files/zdf-info-hd-bunt.png", + "RTLUP AUSTRIA": "https://files.app.simplitv.at/files/rtluphd-kopie.png", + "BFS": "https://files.app.simplitv.at/files/br-hd-bunt.png", + "NITRO AUSTRIA": "https://files.app.simplitv.at/files/nitro-logo-hd.png", + "TELE 5": "https://files.app.simplitv.at/files/tele-5-bunt.png", + "COMEDY CENTRAL": "https://files.app.simplitv.at/files/comedy-central-austria-bunt.png", + "ORF SPORT PLUS": "https://files.app.simplitv.at/files/orf-sport-plus-hd-bunt.png", + "EUROSPORT1AUSTRIA": "https://files.app.simplitv.at/files/eurosport-1-hd-bunt.png", + "SPORT1": "https://files.app.simplitv.at/files/sport1-hd-bunt.png", + "LAOLA1TV": "https://files.app.simplitv.at/files/laola1-logo-rgb.jpg", + "K19": "https://files.app.simplitv.at/files/element-1k19-logo.png", + "ARTE": "https://files.app.simplitv.at/files/arte-hd-bunt.png", + "TLCAUSTRIA": "https://files.app.simplitv.at/files/tlc-hd-bunt.png", + "KABELEINSDOKU": "https://files.app.simplitv.at/files/kabel1-doku-austria-bunt.png", + "DMAXAUSTRIA": "https://files.app.simplitv.at/files/dmax-hd-bunt.png", + "PHOENIX": "https://files.app.simplitv.at/files/phoenix-bunt.png", + "N24 DOKU": "https://files.app.simplitv.at/files/078-n24doku-1024x218.png", + "SCHAU TV": "https://files.app.simplitv.at/files/kurier-tv-logo-cmyk-b.png", + "KRONETV": "https://files.app.simplitv.at/files/kronetv-bunt.png", + "R9 OESTERREICH": "https://files.app.simplitv.at/files/logor9.png", + "WELT": "https://files.app.simplitv.at/files/welt-bunt.png", + "TAGESSCHAU 24": "https://files.app.simplitv.at/files/069-tagesschau24hd-1024x208.png", + "BILD TV": "https://files.app.simplitv.at/files/bild-tv-logo-august-2021.png", + "CNN": "https://files.app.simplitv.at/files/cnn-bunt.png", + "EURONEWS ENGLISH": "https://files.app.simplitv.at/files/logo-euronews-white-on-neon-rgb-kopie.png", + "AL JAZEERA ENGLISH": "https://files.app.simplitv.at/files/aje-logo-rgb.png", + "BLOOMBERG EUROPE": "https://files.app.simplitv.at/files/075-bloomberg-921x248.png", + "TVP WORLD": "https://files.app.simplitv.at/files/tvp-world-logo-2022.jpg", + "SUPERRTL AUSTRIA": "https://files.app.simplitv.at/files/super-rtl-hd-logo-orange.png", + "KIKA": "https://files.app.simplitv.at/files/kika-hd-bunt.png", + "NICKELODEON": "https://files.app.simplitv.at/files/nick-austria-bunt.png", + "RIC": "https://files.app.simplitv.at/files/ric-bunt.png", + "FIX UND FOXI": "https://files.app.simplitv.at/files/fix-foxi-bunt.png", + "ORF2": "https://files.app.simplitv.at/files/orf2-hd-bunt.png", + "ORF2OO": "https://files.app.simplitv.at/files/orf-oo-e-hd-bunt.png", + "ORF2ST": "https://files.app.simplitv.at/files/orf-steiermark-hd-bunt.png", + "ORF2T": "https://files.app.simplitv.at/files/orf-tirol-hd-bunt.png", + "ORF2B": "https://files.app.simplitv.at/files/orf-hd-burgenland-bunt.png", + "ORF2K": "https://files.app.simplitv.at/files/orf-hd-ka-ernten-bunt.png", + "ORF2S": "https://files.app.simplitv.at/files/orf-salzburg-hd-bunt.png", + "ORF2V": "https://files.app.simplitv.at/files/orf-vorarlberg-hd-bunt.png", + "KT1": "https://files.app.simplitv.at/files/kt1.png", + "WNTV": "https://files.app.simplitv.at/files/wntv-ihr-privatfernsehen-logo-1801150443.jpg", + "W24": "https://files.app.simplitv.at/files/100-w24.png", + "N1 NOE TV": "https://files.app.simplitv.at/files/senderlogo-oesterreichprogramm-n1.png", + "LT1": "https://files.app.simplitv.at/files/lt1-logo-neu.png", + "TV1 OOE": "https://files.app.simplitv.at/files/rz-logo-tv1-pos-rgb.jpg", + "TIROLTV": "https://files.app.simplitv.at/files/tiroltv.png", + "LAENDLETV": "https://files.app.simplitv.at/files/landletv-s-auf-w.png", + "DORFTV": "https://files.app.simplitv.at/files/dorftv-kopie-2.png", + "ONE": "https://files.app.simplitv.at/files/one-hd-bunt.png", + "SWR BW": "https://files.app.simplitv.at/files/062-swtbwhd-366x71.png", + "SR FERNSEHEN": "https://files.app.simplitv.at/files/067-srfernsehenhd-1024x627.png", + "NDR": "https://files.app.simplitv.at/files/ndr-hd-bunt.png", + "WDR KÖLN": "https://files.app.simplitv.at/files/063-wdrhd-1024x341.png", + "MDR SACHSEN": "https://files.app.simplitv.at/files/mdr-rgb.png", + "HR FERNSEHEN": "https://files.app.simplitv.at/files/065-hr-fernsehenhd-170x75.png", + "RBB BERLIN": "https://files.app.simplitv.at/files/rbb-rgb.png", + "ARD ALPHA": "https://files.app.simplitv.at/files/ardaplha-rgb.png", + "RADIO BREMEN": "https://files.app.simplitv.at/files/radio-bremen-rgb.jpg", + "OE3 VISUAL RADIO": "https://files.app.simplitv.at/files/oe3-logo.png", + "MTVAUSTRIA": "https://files.app.simplitv.at/files/mtv-hd-bunt.png", + "DELUXEMUSICAUSTRIA": "https://files.app.simplitv.at/files/deluxe-music-hd-bunt.png", + "MELODIETV": "https://files.app.simplitv.at/files/093-melodietv-573x538.png", + "SCHLAGER DELUXE": "https://files.app.simplitv.at/files/schlager-deluxe-logo.png", + "DEUTSCHES MUSIKFERNSEHEN": "https://files.app.simplitv.at/files/deutsches-musik-fernsehen-infarbe.jpg", + "VOLKSMUSIK TV": "https://files.app.simplitv.at/files/911px-volksmusik-tv-logosvg.png", + "MEI MUSI TV": "https://files.app.simplitv.at/files/mei-musi-tv.png", + "STARPARADISES TV": "https://files.app.simplitv.at/files/starparadies.png", + "LILO TV": "https://files.app.simplitv.at/files/lilo-color.png", + "HGTV": "https://files.app.simplitv.at/files/hgtv-bunt.png", + "ARCADIA TV": "https://files.app.simplitv.at/files/arcadiaworld.png", + "SONNENKLARTV": "https://files.app.simplitv.at/files/sk-tv-clm-rgb.png", + "BIBELTV": "https://files.app.simplitv.at/files/084-bibeltvhd-1024x198.png", + "FASHION TV": "https://files.app.simplitv.at/files/fashiontv-logo-blue-vertical-1.png", + "PLAYBOY TV": "https://files.app.simplitv.at/files/dorcel-2022-playboytveurope-logo-noir-transparent.png", + "HUSTLER TV": "https://files.app.simplitv.at/files/hustlertv-light.jpg", + "DORCEL TV": "https://files.app.simplitv.at/files/2022-dorceltv-black-rvb.jpg", +} + + +def channel_name_from_codename(codename: str) -> str: + """Display name for a codename (mirrors the addon's channelname()).""" + name = codename.replace("-", " ").upper() + if name.endswith(" HD"): + name = name[:-3] + if name.endswith("HD"): + name = name[:-2] + return name + + +def logo_for_name(name: str) -> str: + """Logo URL for a display name; provider logo when unknown.""" + return LOGO_MAP.get(name) or SimpliTVDefaults.PROVIDER_LOGO diff --git a/lib/streaming_providers/providers/simplitv/models.py b/lib/streaming_providers/providers/simplitv/models.py new file mode 100644 index 0000000..bd026a3 --- /dev/null +++ b/lib/streaming_providers/providers/simplitv/models.py @@ -0,0 +1,59 @@ +# streaming_providers/providers/simplitv/models.py +""" +simpliTV models. + +A custom Channel subclass carries extra provider metadata (codename, +logo, recording id/status). Base fields are preserved; extras are +appended. + +No custom AuthToken subclass -- the token is a plain opaque string and +the device key is read from the server (see auth.py). +""" + +from dataclasses import dataclass +from typing import Any, Dict + +from ...base.models import Channel + + +@dataclass +class SimpliTVChannel(Channel): + """ + A live channel or a recording. + + Live channels: channel_id is "live:", `codename` is the + channel codename, `recording_id` is empty. Names and logos are + derived from the codename (see logos.py). + + Recordings: channel_id is "rec:" (the content_id + used to play the recording), `codename` is that programme codename, + and `recording_id` is the provider's recording id (what + delete_recording takes). The two are different namespaces and must + not be conflated. `recording_status` is "Recorded", "Scheduled" or + "Failed"; only "Recorded" is playable. + """ + + codename: str = "" + logo_url: str = "" + current_programme: str = "" + current_start: str = "" + current_stop: str = "" + is_npvr_enabled: bool = False + is_series_recording_enabled: bool = False + is_catchup_enabled: bool = False + recording_id: str = "" + recording_status: str = "" + + def to_dict(self) -> Dict[str, Any]: + result = super().to_dict() + result["Codename"] = self.codename + result["LogoUrl"] = self.logo_url + result["CurrentProgramme"] = self.current_programme + result["CurrentStart"] = self.current_start + result["CurrentStop"] = self.current_stop + result["IsNpvrEnabled"] = self.is_npvr_enabled + result["IsSeriesRecordingEnabled"] = self.is_series_recording_enabled + result["IsCatchupEnabled"] = self.is_catchup_enabled + result["RecordingId"] = self.recording_id + result["RecordingStatus"] = self.recording_status + return result diff --git a/lib/streaming_providers/providers/simplitv/provider.py b/lib/streaming_providers/providers/simplitv/provider.py new file mode 100644 index 0000000..88f0a41 --- /dev/null +++ b/lib/streaming_providers/providers/simplitv/provider.py @@ -0,0 +1,349 @@ +# streaming_providers/providers/simplitv/provider.py +""" +simpliTV orchestrator. + +Owns shared resources (http_manager, caches, auth, managers) and +exposes the public StreamingProvider interface. + +Subclasses the existing StreamingProvider. Does NOT subclass any new +base class. Authentication is lazy -- no network I/O in __init__. + +Manager wiring +-------------- + + channels -> SimpliTVChannelManager (live:, rec:, prog: ids; + folded DRM) + vod -> None (no browseable catalogue) + epg -> SimpliTVEpgManager (anonymous; native grid) + recordings -> SimpliTVRecordingsManager (list, delete, schedule) + favorites -> None + bookmarks -> None + catchup -> SimpliTVCatchupManager (catchup: ids; borrows channels) + drm -> None (folded into channels) + +The catchup manager is built after the channel manager because it takes +the channel manager as a collaborator. No other manager has +cross-dependencies. + +Routing +------- +_route is used only by get_manifest and get_drm -- the methods whose +input is an opaque content_id with several possible owners. The channel +manager owns live:, rec: and prog:; the catchup manager owns catchup:. +For catchup: the router parses the @ suffix and hands the manager a +plain (content_id, start_time, end_time=None) triple. Malformed ids +raise BadRequestError and are not swallowed. + +Restart-from-beginning cannot be expressed as a manifest URL (it needs a +player-side seek), so it has its own method: get_restart(). +""" + +from typing import Any, Callable, ClassVar, Dict, List, Optional, Tuple + +from ...base.errors import NotFoundError +from ...base.managers import ChannelManager, VodManager +from ...base.models.proxy_models import ProxyConfig +from ...base.protocols import DrmManagerProtocol +from ...base.provider import StreamingProvider + +from .auth import SimpliTVAuth +from .catchup_manager import SimpliTVCatchupManager +from .channel_manager import SimpliTVChannelManager, parse_catchup_id +from .constants import SimpliTVConfig, SimpliTVDefaults +from .epg_manager import SimpliTVEpgManager +from .recordings_manager import SimpliTVRecordingsManager + + +class SimpliTVProvider(StreamingProvider): + """simpliTV streaming provider.""" + + PROVIDER_LABEL: ClassVar[str] = "simpliTV" + PROVIDER_LOGO: ClassVar[str] = SimpliTVDefaults.PROVIDER_LOGO + SUPPORTED_AUTH_TYPES: ClassVar[List[str]] = ["user_credentials"] + SUPPORTED_COUNTRIES: ClassVar[List[str]] = ["AT"] + + # Only "live" and "vod" narrow the folded DRM search; anything else + # (None, "event", "catchup", a typo) tries both domains. + _LIVE_ONLY_CONTENT_TYPES = frozenset({"live"}) + _VOD_ONLY_CONTENT_TYPES = frozenset({"vod"}) + + def __init__( + self, + country: str = "AT", + config: Optional[Dict] = None, + proxy_config: Optional[ProxyConfig] = None, + settings_manager=None, + credentials=None, + **kwargs, + ): + super().__init__(country) + + self.config = SimpliTVConfig(config or {}) + + # 1. HTTP manager. + self.http_manager = self._setup_http_manager( + provider_name=SimpliTVDefaults.PROVIDER_NAME, + proxy_config=proxy_config, + user_agent=self.config.user_agent, + timeout=self.config.timeout, + ) + + # 2. Auth (lazy -- no network call in __init__). + self._credentials = credentials + self.auth = self._build_auth(settings_manager) + + # 3. Provider-owned caches. Managers borrow these by reference. + self._channels_cache: Dict = {} + self._playback_cache: Dict = {} + self._recordings_cache: Dict = {} + + # 4. Managers, in dependency order. Catchup needs channels. + self.channels = self._build_channels() + self.vod = self._build_vod() + self.epg = self._build_epg() + self.recordings = self._build_recordings() + self.favorites = self._build_favorites() + self.bookmarks = self._build_bookmarks() + self.catchup = self._build_catchup() + self.drm = self._build_drm() + + # ------------------------------------------------------------------ + # Factory methods + # ------------------------------------------------------------------ + + def _build_auth(self, settings_manager): + return SimpliTVAuth( + http_manager=self.http_manager, + country=self.country, + settings_manager=settings_manager, + credentials=self._credentials, + config=self.config, + ) + + def _build_channels(self): + return SimpliTVChannelManager( + http_manager=self.http_manager, + auth=self.auth, + country=self.country, + config=self.config, + channels_cache=self._channels_cache, + playback_cache=self._playback_cache, + ) + + def _build_vod(self): + # No browseable VOD catalogue: content is live, catchup, or + # recordings. + return None + + def _build_epg(self): + return SimpliTVEpgManager( + http_manager=self.http_manager, + auth=self.auth, + country=self.country, + config=self.config, + ) + + def _build_recordings(self): + return SimpliTVRecordingsManager( + http_manager=self.http_manager, + auth=self.auth, + country=self.country, + config=self.config, + recordings_cache=self._recordings_cache, + ) + + def _build_favorites(self): + return None # no favorites endpoint + + def _build_bookmarks(self): + return None # no bookmarks / resume endpoint + + def _build_catchup(self): + if self.channels is None: + return None + return SimpliTVCatchupManager( + http_manager=self.http_manager, + auth=self.auth, + country=self.country, + config=self.config, + channels=self.channels, + ) + + def _build_drm(self) -> Optional[DrmManagerProtocol]: + # Folded into SimpliTVChannelManager -- the manifest and DRM + # share one /Player/AcquireContent response. + return None + + # ------------------------------------------------------------------ + # Capability flags + # ------------------------------------------------------------------ + + @property + def implements_channels(self) -> bool: + return self.channels is not None + + @property + def implements_vod(self) -> bool: + return self.vod is not None + + @property + def implements_epg(self) -> bool: + return self.epg is not None + + @property + def implements_recordings(self) -> bool: + return self.recordings is not None + + @property + def implements_favorites(self) -> bool: + return self.favorites is not None + + @property + def implements_bookmarks(self) -> bool: + return self.bookmarks is not None + + @property + def implements_catchup(self) -> bool: + return self.catchup is not None + + @property + def implements_drm(self) -> bool: + if self.drm is not None: + return True + folded_channels = ( + self.channels is not None + and type(self.channels).get_channel_drm + is not ChannelManager.get_channel_drm + ) + folded_vod = ( + self.vod is not None + and type(self.vod).get_vod_drm + is not VodManager.get_vod_drm + ) + return folded_channels or folded_vod + + # ------------------------------------------------------------------ + # Router + # ------------------------------------------------------------------ + + def _route(self, content_id: str, attempts: List[Tuple[Any, Callable]]): + """ + Try managers in order, skipping those whose handles_content_id() + rejects the id. Used only by get_manifest / get_drm. + + NotFoundError is remembered and re-raised if nobody else + resolves the id ("existed but is gone" vs "nobody handles it"). + BadRequestError is not caught: a malformed id surfaces. + """ + last_not_found: Optional[NotFoundError] = None + for manager, call in attempts: + if manager is None or not manager.handles_content_id(content_id): + continue + try: + result = call(manager) + except NotFoundError as e: + last_not_found = e + continue + if result: + return result + if last_not_found is not None: + raise last_not_found + return None + + # ------------------------------------------------------------------ + # Manifest routing + # ------------------------------------------------------------------ + + def get_manifest(self, content_id: str, **kw) -> Optional[str]: + """ + Route content_id to the right manager. + + live: -> channel manager + rec: -> channel manager + prog: -> channel manager + catchup:@ -> catchup manager, @ parsed + here into start_time. Yields a + manifest only for programme + replay (epg_id in kw); for + restart use get_restart(). + """ + if content_id.startswith(SimpliTVDefaults.CATCHUP_PREFIX): + if self.catchup is None: + return None + _, start_ts = parse_catchup_id(content_id) # raises if bad + return self.catchup.get_catchup_manifest( + content_id, start_ts, None, **kw + ) + + return self._route(content_id, [ + (self.channels, lambda m: m.get_channel_manifest( + content_id, **kw + )), + (self.vod, lambda m: m.get_vod_manifest(content_id, **kw)), + ]) + + def get_restart( + self, content_id: str, start_time: int = 0, **kw + ) -> Optional[Tuple[str, int]]: + """ + Restart a programme from its beginning. + + Returns (live manifest URL, seek_seconds from the start of the + 3h DVR window) or None when outside the window. The caller must + seek; the URL alone plays live. + """ + if self.catchup is None: + return None + return self.catchup.get_restart_manifest(content_id, start_time) + + # ------------------------------------------------------------------ + # DRM routing + # ------------------------------------------------------------------ + + def get_drm( + self, + content_id: str, + content_type: Optional[str] = None, + **kw, + ) -> List: + """ + DRM for the given content. + + content_type is an optional narrowing hint ("live"/"vod" only; + anything else tries both). catchup: ids go to the catchup + manager, which forwards to the channel manager's DRM. + """ + if self.drm is not None: + return self.drm.get_drm_configs( + content_id, content_type=content_type, **kw + ) + + if content_id.startswith(SimpliTVDefaults.CATCHUP_PREFIX): + if self.catchup is None: + return [] + _, start_ts = parse_catchup_id(content_id) # raises if bad + return self.catchup.get_catchup_drm( + content_id, start_ts, None, **kw + ) + + attempts: List[Tuple[Any, Callable]] = [] + if content_type not in self._VOD_ONLY_CONTENT_TYPES: + attempts.append( + (self.channels, lambda m: m.get_channel_drm( + content_id, **kw + )) + ) + if content_type not in self._LIVE_ONLY_CONTENT_TYPES: + attempts.append( + (self.vod, lambda m: m.get_vod_drm(content_id, **kw)) + ) + return self._route(content_id, attempts) or [] + + # ------------------------------------------------------------------ + # Channels + # ------------------------------------------------------------------ + + def get_channels(self, **kw): + if self.channels is None: + return [] + return self.channels.get_channels(**kw) diff --git a/lib/streaming_providers/providers/simplitv/recordings_manager.py b/lib/streaming_providers/providers/simplitv/recordings_manager.py new file mode 100644 index 0000000..ef089d5 --- /dev/null +++ b/lib/streaming_providers/providers/simplitv/recordings_manager.py @@ -0,0 +1,205 @@ +# streaming_providers/providers/simplitv/recordings_manager.py +""" +simpliTV recordings manager. + +Recording identity (kept strictly separate from content identity): + + recording_id -- the provider's recording id. This is what + delete_recording receives. It is NOT a content_id. + + content_id -- "rec:". This is what + get_manifest receives to play the recording. The + channel manager resolves it (same AcquireContent + call as live). + +This manager does not implement get_manifest -- do not add a parallel +manifest path here. + +get_recordings() returns SimpliTVChannel objects whose channel_id is the +content_id and whose recording_id carries the provider's id. Only +recordings whose recording_status is "Recorded" are playable; "Scheduled" +and "Failed" are listed but must not be played. + +Scheduling needs a *programme* codename (the EPG tile's own codename), +not a channel codename: pass prog:. +""" + +from typing import Dict, List, Optional + +from ...base.errors import BadRequestError +from ...base.managers import RecordingsManager +from ...base.models import Channel +from ...base.utils.logger import logger + +from .channel_manager import parse_programme_id +from .constants import SimpliTVDefaults +from .helpers import transport_errors +from .models import SimpliTVChannel + + +class SimpliTVRecordingsManager(RecordingsManager): + """NPvR recordings for simpliTV.""" + + def __init__( + self, + *, + http_manager, + auth, + country, + config, + recordings_cache: Optional[dict] = None, + ): + super().__init__( + http_manager=http_manager, + auth=auth, + country=country, + config=config, + ) + self._recordings_cache = ( + recordings_cache if recordings_cache is not None else {} + ) + + # ------------------------------------------------------------------ + # Abstract methods + # ------------------------------------------------------------------ + + def get_recordings(self, **kw) -> List[Channel]: + """ + Return all NPvR recordings across every page ([] if none). + + The page index base is unverified (the addon's own paging is + broken and limit=99999 normally returns everything in one page), + so the walk is bounded by the reported totalPages and + de-duplicated by recording id: a page that adds nothing new ends + the walk instead of looping or returning duplicates. + """ + found: Dict[str, SimpliTVChannel] = {} + page = 0 + while True: + # NOTE: GetRecordings names the token parameter `tokenValue`; + # the token is in the URL, not a header. + url = self.auth.with_token( + self.config.get_recordings_url(), + { + "platformCodename": self.config.platform_codename, + "page": page, + "limit": 99999, + "recordingType": "NPvr", + "recordingFlags": ( + "Programs,ProgramImages,ProgramCategories" + ), + }, + param=SimpliTVDefaults.TOKEN_PARAM_RECORDINGS, + ) + resp = self.http_manager.get( + url, headers=self.config.get_api_headers() + ) + data = resp.json() + + total_pages = (data.get("pagination") or {}).get("totalPages", 0) + if total_pages == 0: + break + + added = 0 + for rec in data.get("recordings", []): + channel = self._rec_to_channel(rec) + key = channel.recording_id or ( + f"{channel.codename}:{channel.current_start}" + ) + if key not in found: + found[key] = channel + added += 1 + + page += 1 + if page >= total_pages: + break + if added == 0: + logger.warning( + f"simpliTV: GetRecordings page {page} added no new " + f"recordings (totalPages={total_pages}); stopping" + ) + break + return list(found.values()) + + def delete_recording(self, recording_id: str, **kw) -> None: + """ + Delete a recording by recording_id. + + Raises: + KeyError: if the recording does not exist / was not deleted. + ProviderError subclasses: on transport / backend failure + (typed errors such as AuthError pass through). + """ + if not recording_id: + raise KeyError("simpliTV: empty recording_id") + + # NOTE: token is in the body (auth_body), not a header. + body = self.auth.auth_body({ + "platformCodename": self.config.platform_codename, + "recordingId": recording_id, + }) + with transport_errors("delete_recording"): + resp = self.http_manager.post( + self.config.delete_recording_url(), + json=body, + headers=self.config.get_api_headers(), + ) + data = resp.json() + + # The API answers 200 with success:false for "not found"; + # silently returning would hide real backend errors. + if not (data.get("result") or {}).get("success", False): + raise KeyError( + f"simpliTV: recording {recording_id!r} not deleted " + f"(response: {data!r})" + ) + + # ------------------------------------------------------------------ + # Optional override -- scheduling + # ------------------------------------------------------------------ + + def schedule_recording(self, content_id: str, **kw) -> bool: + """ + Schedule a recording of a programme. + + content_id must be prog:. Channel ids + (live:, catchup:) identify a channel, not a programme, and + cannot be scheduled; rec: ids are already recordings. + """ + if not content_id.startswith(SimpliTVDefaults.PROGRAMME_PREFIX): + raise BadRequestError( + f"simpliTV: scheduling needs a prog: " + f"id, got {content_id!r}" + ) + codename = parse_programme_id(content_id) + + body = self.auth.auth_body({ + "platformCodename": self.config.platform_codename, + "EpgId": {"Codename": codename}, + }) + with transport_errors("schedule_recording"): + resp = self.http_manager.post( + self.config.schedule_recording_url(), + json=body, + headers=self.config.get_api_headers(), + ) + data = resp.json() + return bool((data.get("result") or {}).get("success", False)) + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + def _rec_to_channel(self, rec: dict) -> SimpliTVChannel: + programme = rec.get("program") or {} + codename = programme.get("codename", "") + return SimpliTVChannel( + channel_id=f"{SimpliTVDefaults.RECORDING_PREFIX}{codename}", + name=programme.get("title", codename), + codename=codename, + current_programme=programme.get("title", ""), + current_start=programme.get("start", ""), + current_stop=programme.get("stop", ""), + recording_id=rec.get("recordingId", ""), + recording_status=rec.get("status", ""), + )