mirror of
https://github.com/nirvana-7777/script.service.ultimate.git
synced 2026-10-05 23:42:57 +02:00
add simplitv
This commit is contained in:
@@ -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"]
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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 @<ts> suffix of catchup:<channel>@<unix_ts> 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 @<ts> 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}"
|
||||
)
|
||||
@@ -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:<channel codename> live channel
|
||||
rec:<programme codename> a recorded programme
|
||||
prog:<programme codename> 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:<channel>@<unix_ts>) 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:<channel codename>."""
|
||||
return _strip_prefix(content_id, SimpliTVDefaults.LIVE_PREFIX, "live")
|
||||
|
||||
|
||||
def parse_recording_id(content_id: str) -> str:
|
||||
"""Programme codename from rec:<programme codename>."""
|
||||
return _strip_prefix(
|
||||
content_id, SimpliTVDefaults.RECORDING_PREFIX, "recording"
|
||||
)
|
||||
|
||||
|
||||
def parse_programme_id(content_id: str) -> str:
|
||||
"""Programme codename from prog:<programme codename>."""
|
||||
return _strip_prefix(
|
||||
content_id, SimpliTVDefaults.PROGRAMME_PREFIX, "programme"
|
||||
)
|
||||
|
||||
|
||||
def parse_catchup_id(content_id: str):
|
||||
"""
|
||||
Return (channel codename, start_ts) from catchup:<channel>@<unix_ts>.
|
||||
|
||||
The @<ts> suffix is required -- a bare catchup:<channel> 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:<codename>{SimpliTVDefaults.CATCHUP_TS_SEPARATOR}<ts>: "
|
||||
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:<channel>:<start>:<programme>), 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
|
||||
@@ -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:<channel codename> -> SimpliTVChannelManager
|
||||
# rec:<programme codename> -> SimpliTVChannelManager (same
|
||||
# AcquireContent call as live)
|
||||
# prog:<programme codename> -> SimpliTVChannelManager (EPG
|
||||
# programme replay; also the id
|
||||
# form scheduling a recording
|
||||
# needs)
|
||||
# catchup:<channel>@<unix_ts> -> SimpliTVCatchupManager
|
||||
# (restart-from-beginning)
|
||||
LIVE_PREFIX = "live:"
|
||||
RECORDING_PREFIX = "rec:"
|
||||
PROGRAMME_PREFIX = "prog:"
|
||||
CATCHUP_PREFIX = "catchup:"
|
||||
CATCHUP_TS_SEPARATOR = "@"
|
||||
|
||||
# EPG broadcast ids: simplitv:<channel codename>:<start>:<programme
|
||||
# codename>. 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)
|
||||
@@ -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 @<ts>
|
||||
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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>", `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:<programme codename>" (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
|
||||
@@ -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 @<ts> 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:<codename> -> channel manager
|
||||
rec:<programme codename> -> channel manager
|
||||
prog:<programme codename> -> channel manager
|
||||
catchup:<channel>@<ts> -> catchup manager, @<ts> 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)
|
||||
@@ -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:<programme codename>". 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:<programme codename>.
|
||||
"""
|
||||
|
||||
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:<programme codename>. 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:<programme codename> "
|
||||
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", ""),
|
||||
)
|
||||
Reference in New Issue
Block a user