Simpli - corrections

This commit is contained in:
Nirvana
2026-10-04 08:49:40 +02:00
parent 1d3288b0af
commit 55fbb2cfd0
9 changed files with 1143 additions and 297 deletions
@@ -2,8 +2,7 @@
"""
simpliTV authentication.
Two deliberate deviations from the AuthProtocol's *implied* shape (the
protocol only requires the three method names, not their semantics):
Two deliberate deviations from the AuthProtocol's *implied* shape:
1. The simpliTV token is passed as a *query parameter* (GET) or *body
field* (POST), never as an Authorization header. build_headers()
@@ -12,19 +11,35 @@ protocol only requires the three method names, not their semantics):
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.
AcquireContent. It is discovered from the server (GetDevices) and
registered once if the account has none. Sessions are not
persisted: the token is cheap to re-acquire.
Sessions are not persisted: the token is cheap to re-acquire, and the
device key is re-read from the server.
Device key lifecycle
--------------------
On first use:
1. GetDevices. If the account already has devices, reuse the first
one's key. This is the common case on a re-install.
2. If GetDevices is empty, generate a fresh key and RegisterDevice.
3. GetDevices again and use the first entry. If the server does not
hand a device back, raise -- do not proceed with an unverified
key. The read-back, not the RegisterDevice response, is the
authoritative check (the RegisterDevice response shape is not
verified).
The key is cached in-process only. The settings_manager accessor names
are host-specific and are not guessed. A process restart therefore hits
GetDevices again, which returns the already-registered device.
Error handling: network / JSON failures in the device calls are wrapped
in ServerError by transport_errors(); typed provider errors (AuthError
etc.) pass through unchanged.
"""
import secrets
import string
import threading
import time
from datetime import datetime, timezone
from typing import Any, Dict, Optional
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
@@ -33,20 +48,73 @@ from ...base.errors import AuthError, CredentialsError
from ...base.utils.logger import logger
from .constants import SimpliTVConfig, SimpliTVDefaults
from .helpers import parse_iso, transport_errors
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))
"""
Return a 10-digit numeric device key.
The browser log shows the format the server accepts:
"1421859737". Earlier versions of this provider used a 32-char
lowercase-alnum string (ported from the addon); that shape is not
what the API advertises.
"""
return f"{secrets.randbelow(10**10):010d}"
def _mask(key: Optional[str]) -> str:
"""Short tag for logs; never the full key."""
if not key or len(key) < 6:
return "***"
return f"{key[:2]}...{key[-2:]}"
def _parse_expiry(iso: Optional[str]) -> Optional[int]:
"""
Parse `tokenExpirationTime` to seconds from now. Returns None on
missing / malformed input or a time already in the past.
"""
dt = parse_iso(iso)
if dt is None:
return None
delta = (dt - datetime.now(timezone.utc)).total_seconds()
if delta <= 0:
return None
return int(delta)
def _extract_device_key(entry: Any) -> str:
"""
Return the device key from one GetDevices entry.
The response shape is unverified (the browser capture never calls
GetDevices). "key" is the addon's field name; "deviceKey" is the
field the RegisterDevice payload uses. Either is accepted; if
neither is present (or the entry is not an object), raise rather
than silently return empty.
"""
if not isinstance(entry, dict):
raise AuthError(
f"simpliTV: GetDevices entry is not an object: "
f"{type(entry).__name__}"
)
for field in ("key", "deviceKey"):
value = entry.get(field)
if value:
return str(value)
raise AuthError(
f"simpliTV: GetDevices entry has no device key field "
f"(keys: {sorted(entry.keys())!r})"
)
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
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
@@ -93,8 +161,8 @@ class SimpliTVAuth:
"""
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
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()
@@ -120,7 +188,15 @@ class SimpliTVAuth:
*,
param: str = SimpliTVDefaults.TOKEN_PARAM,
) -> str:
"""Append the token (as `param`) and any extra params to `url`."""
"""
Append the token (as `param`) and any extra params to `url`.
NOTE: this parses and re-encodes the existing query string, so
the pre-encoded `$headers` blob is re-encoded. The key/value
pairs are identical, but the bytes differ from the browser
capture. If the server ever proves sensitive to that, build the
query string by hand instead.
"""
token = self.get_access_token()
parsed = urlparse(url)
query = dict(parse_qsl(parsed.query))
@@ -139,23 +215,37 @@ class SimpliTVAuth:
"""
Return the account's device key.
Reads GetDevices first and uses the registered device; registers
a new one only if the account has none.
Order of preference:
1. In-process cache.
2. GetDevices -- reuse the first registered device.
3. RegisterDevice with a fresh key, then GetDevices again.
"""
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 devices:
self._device_key = _extract_device_key(devices[0])
logger.debug(
f"simpliTV[{self.country}]: reusing device "
f"{_mask(self._device_key)}"
)
return self._device_key
self._register_device(_generate_device_key())
devices = self._list_devices()
if not devices:
raise AuthError(
"simpliTV: device registration returned no devices"
"simpliTV: device registration did not result in a "
"registered device (GetDevices is still empty)"
)
self._device_key = devices[0]["key"]
logger.debug(f"simpliTV[{self.country}]: device key resolved")
self._device_key = _extract_device_key(devices[0])
logger.debug(
f"simpliTV[{self.country}]: registered device "
f"{_mask(self._device_key)}"
)
return self._device_key
# ------------------------------------------------------------------
@@ -166,25 +256,32 @@ class SimpliTVAuth:
creds = self._resolve_credentials()
logger.debug(f"simpliTV[{self.country}]: logging in")
# The browser sends lowercase `login`/`password` and does NOT
# send LongExpiration.
payload = {
"Login": creds["username"],
"Password": creds["password"],
"LongExpiration": "true",
"platformCodename": self.config.platform_codename,
"login": creds["username"],
"password": creds["password"],
}
resp = self.http_manager.post(
self.config.authenticate_url(),
json=payload,
headers=self.build_headers(),
)
token_value = resp.json().get("token")
data = resp.json()
token_value = data.get("token")
if not token_value:
raise AuthError("simpliTV: no token in authenticate response")
expires_in = (
_parse_expiry(data.get("tokenExpirationTime"))
or SimpliTVDefaults.TOKEN_LIFETIME_SECONDS
)
return BaseAuthToken(
access_token=token_value,
token_type="token",
expires_in=SimpliTVDefaults.TOKEN_LIFETIME_SECONDS,
expires_in=expires_in,
issued_at=time.time(),
)
@@ -206,39 +303,72 @@ class SimpliTVAuth:
"""
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.
TODO(host): implement against the real base API; return None
when nothing is stored.
"""
return None
# ------------------------------------------------------------------
# Device list / registration
# ------------------------------------------------------------------
def _list_devices(self) -> list:
"""
GET /v1/Devices/GetDevices. Returns the raw device list (may be
empty; that is not an error).
with_token() runs outside transport_errors so a login failure
keeps its own type.
"""
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 []
with transport_errors("GetDevices"):
resp = self.http_manager.get(
url, headers=self.config.get_api_headers()
)
devices = resp.json().get("devices")
return devices if isinstance(devices, list) else []
def _register_device(self) -> None:
logger.info(
f"simpliTV[{self.country}]: no registered device on the "
f"account, registering one"
def _register_device(self, device_key: str) -> None:
"""
Register a device key with the server.
Payload matches the browser capture for RegisterDevice. The
response shape is not verified, so only an explicit
`result.success == false` is treated as a refusal; anything
else is accepted and the caller's GetDevices read-back decides
whether registration actually happened.
"""
logger.debug(
f"simpliTV[{self.country}]: registering device "
f"{_mask(device_key)}"
)
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,
"deviceKey": device_key,
"userToken": self.get_access_token(),
"pushToken": "",
"generalDeviceType": SimpliTVDefaults.DEVICE_GENERAL_TYPE,
"deviceName": SimpliTVDefaults.DEVICE_NAME,
"browserVersion": SimpliTVDefaults.DEVICE_BROWSER_VERSION,
"userAgent": self.config.user_agent,
"operatingSystem": SimpliTVDefaults.DEVICE_OS,
"versionOs": SimpliTVDefaults.DEVICE_OS_VERSION,
}
self.http_manager.post(
self.config.register_device_url(),
json=payload,
headers=self.build_headers(),
)
with transport_errors("RegisterDevice"):
resp = self.http_manager.post(
self.config.register_device_url(),
json=payload,
headers=self.build_headers(),
)
try:
data = resp.json()
except ValueError:
return # empty / non-JSON body: rely on the read-back
result = data.get("result") if isinstance(data, dict) else None
if isinstance(result, dict) and result.get("success") is False:
raise AuthError(
f"simpliTV: RegisterDevice refused (response: {data!r})"
)
@@ -11,13 +11,28 @@ and this manager mirrors both:
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.
2. Restart from the beginning. The addon plays the *live* manifest,
which carries a 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.
DVR window
----------
The window is per-channel and advertised in the AcquireContent
response as AdditionalInfo.Epg_TimeshiftSeconds (the browser log shows
7200, 10800 and 14400 across channels). get_restart_manifest reads
that value from the channel manager's AcquireContent response. If the
field is missing, the smallest observed window (2h) is used, so a seek
can never land outside the real window.
The ABC exposes catchup_window_hours as a single integer, which cannot
express a per-channel value. It returns the same smallest observed
window as the conservative answer: a host that trusts it will only
offer replays the shortest channel allows.
Contract
--------
@@ -31,7 +46,7 @@ from typing import List, Optional, Tuple
from ...base.errors import BadRequestError, NotFoundError
from ...base.managers import CatchupManager
from ...base.models import DRMConfig
from ...base.models.drm_config import DRMConfig
from .channel_manager import (
parse_catchup_id,
@@ -41,6 +56,11 @@ from .channel_manager import (
from .constants import SimpliTVDefaults
# Smallest timeshift window observed across channels (7200s = 2h). Used
# as catchup_window_hours and as the per-channel fallback.
_MIN_TIMESHIFT_HOURS = 2
class SimpliTVCatchupManager(CatchupManager):
"""Catchup for simpliTV."""
@@ -67,7 +87,15 @@ class SimpliTVCatchupManager(CatchupManager):
@property
def catchup_window_hours(self) -> int:
return SimpliTVDefaults.CATCHUP_WINDOW_HOURS
"""
Conservative global window, in hours.
The API advertises per-channel windows (2h, 3h, 4h); the ABC
only exposes a single integer, so the smallest observed value
is returned. get_restart_manifest uses the real per-channel
value.
"""
return _MIN_TIMESHIFT_HOURS
# ------------------------------------------------------------------
# Abstract method
@@ -118,6 +146,10 @@ class SimpliTVCatchupManager(CatchupManager):
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.
The window is read from AcquireContent's
AdditionalInfo.Epg_TimeshiftSeconds for the channel, with the
2h minimum as fallback.
"""
codename, embedded = _channel_and_ts(content_id)
if start_time <= 0 and embedded:
@@ -125,7 +157,12 @@ class SimpliTVCatchupManager(CatchupManager):
if start_time <= 0 or self._channels is None:
return None
window = SimpliTVDefaults.CATCHUP_WINDOW_HOURS * 3600
try:
acquire = self._channels.acquire_content(codename)
except NotFoundError:
return None
window = _timeshift_seconds(acquire)
margin = SimpliTVDefaults.CATCHUP_SEEK_MARGIN_SECONDS
age = int(time.time()) - start_time
if age < 0 or age >= window - margin:
@@ -156,7 +193,7 @@ class SimpliTVCatchupManager(CatchupManager):
"""
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.
stream).
"""
codename, _ = _channel_and_ts(content_id)
if self._channels is None:
@@ -180,3 +217,19 @@ def _channel_and_ts(content_id: str) -> Tuple[str, Optional[int]]:
raise BadRequestError(
f"simpliTV: not a catchup/live id: {content_id!r}"
)
def _timeshift_seconds(acquire: dict) -> int:
"""
Read AdditionalInfo.Epg_TimeshiftSeconds from an AcquireContent
response. Accepts an int or a numeric string; a missing or
non-positive value falls back to the 2h minimum.
"""
info = acquire.get("AdditionalInfo") or {}
try:
seconds = int(info.get("Epg_TimeshiftSeconds"))
except (TypeError, ValueError):
seconds = 0
if seconds > 0:
return seconds
return _MIN_TIMESHIFT_HOURS * 3600
@@ -21,16 +21,26 @@ 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.
Format selection
----------------
The AcquireContent response carries DASH (Type 9) and/or HLS (Type 2)
entries. Protected content prefers DASH (inputstream.adaptive needs it
for Widevine/PlayReady; PSSH extraction is more reliable). Unprotected
content prefers HLS, which is what the CDN has always served the addon.
prefer_dash() runs only when a protected asset offers no DASH entry.
"""
import threading
import time
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Tuple
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.models.drm_config import DRMConfig
from ...base.models.drm_systems import DRMSystem
from ...base.models.license_config import LicenseConfig
from ...base.utils.logger import logger
from .constants import SimpliTVDefaults
@@ -86,13 +96,13 @@ class SimpliTVChannelManager(ChannelManager):
"""
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.
The tile endpoint yields only codenames, 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. The
`$headers` query string is baked into channel_tiles_url().
"""
# NOTE: token is in the body (auth_body), not a header.
body = self.auth.auth_body({
"isParentalControlEnabled": "false",
"platformCodename": self.config.platform_codename,
@@ -135,13 +145,14 @@ class SimpliTVChannelManager(ChannelManager):
"""
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.
Protected content prefers DASH, unprotected content prefers
HLS. If a protected asset has no DASH entry, prefer_dash() is
applied to the URL that was selected.
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().
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
SimpliTVConfig.get_stream_headers().
"""
codename = codename_from_playable_id(content_id)
@@ -150,30 +161,37 @@ class SimpliTVChannelManager(ChannelManager):
acquire = self.acquire_content(codename, refresh=True)
try:
manifest_url = acquire["MediaFiles"][0]["Formats"][0]["Url"]
formats = acquire["MediaFiles"][0]["Formats"]
except (KeyError, IndexError, TypeError):
raise NotFoundError(
f"simpliTV: no manifest in AcquireContent for {codename!r}"
f"simpliTV: no media files in AcquireContent for "
f"{codename!r}"
)
if acquire.get("DrmInfo"):
protected = bool(acquire.get("DrmInfo"))
manifest_url, fmt_type = _select_format(formats, protected=protected)
if not manifest_url:
raise NotFoundError(
f"simpliTV: no manifest URL in AcquireContent for "
f"{codename!r}"
)
# Protected asset without a DASH entry: rewrite to the DASH
# equivalent. Never applied to unprotected content.
if protected and fmt_type != SimpliTVDefaults.FORMAT_TYPE_DASH:
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()."""
"""User-Agent + Origin -- 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."""
"""User-Agent + Origin: the CDN does not authenticate segments."""
return self.config.get_stream_headers()
# ------------------------------------------------------------------
@@ -184,56 +202,90 @@ class SimpliTVChannelManager(ChannelManager):
self, content_id: str, **kw
) -> List[DRMConfig]:
"""
Return DRM for a live:, rec: or prog: content id.
Return DRM configs for a live:, rec: or prog: content id.
Both Widevine (priority 1) and PlayReady (priority 2) are
returned when the AcquireContent response advertises them.
inputstream.adaptive selects whichever CDM the platform
actually supports, so the priority ordering is safe on a
PlayReady-only device. FairPlay is ignored: Kodi cannot play
it.
Reads the response cached by get_channel_manifest when it is
still fresh, otherwise fetches it.
still fresh, otherwise fetches it. Unprotected content returns
[] (per the None-vs-exception rule).
"""
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
)
# Index by DrmSystem string, not position: the server may add
# or reorder systems.
by_system: Dict[str, dict] = {}
for entry in drm_info:
name = entry.get("DrmSystem")
if name and name not in by_system:
by_system[name] = entry
entry = drm_info[index]
license_url = entry.get("LicenseServerUrl")
if not license_url:
return []
configs: List[DRMConfig] = []
widevine = by_system.get(SimpliTVDefaults.DRM_SYSTEM_WIDEVINE)
if widevine and widevine.get("LicenseServerUrl"):
configs.append(self._build_drm_config(
system=DRMSystem.WIDEVINE,
priority=SimpliTVDefaults.DRM_PRIORITY_WIDEVINE,
entry=widevine,
))
playready = by_system.get(SimpliTVDefaults.DRM_SYSTEM_PLAYREADY)
if playready and playready.get("LicenseServerUrl"):
configs.append(self._build_drm_config(
system=DRMSystem.PLAYREADY,
priority=SimpliTVDefaults.DRM_PRIORITY_PLAYREADY,
entry=playready,
))
return configs
def _build_drm_config(
self,
*,
system: DRMSystem,
priority: int,
entry: dict,
) -> DRMConfig:
"""
Build one DRMConfig from a single DrmInfo entry.
The challenge custom data is passed *raw* (base64) in the
`drmchallengecustomdata` header, matching the browser capture.
It is deliberately not URL-quoted here.
UNVERIFIED: whether the host's LicenseConfig / inputstream
.adaptive serialisation of req_headers preserves `+`, `/` and
`=` unchanged cannot be determined from this package. Check on
a real device (a mangled value shows up as a licence-server
4xx); if it is altered, the quoting belongs in LicenseConfig,
not here.
The licence body is the raw CDM challenge, injected by ISA via
the {CHA-RAW} placeholder (see SimpliTVDefaults.REQ_DATA_CHA_RAW).
"""
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,
),
)
]
return DRMConfig(
system=system,
priority=priority,
license=LicenseConfig(
server_url=entry["LicenseServerUrl"],
req_headers=self.config.get_license_headers(challenge),
req_data=SimpliTVDefaults.REQ_DATA_CHA_RAW,
use_http_get_request=False,
),
)
# ------------------------------------------------------------------
# AcquireContent (shared with SimpliTVCatchupManager via this class)
@@ -245,6 +297,11 @@ class SimpliTVChannelManager(ChannelManager):
"""
GET /Player/AcquireContent for a codename.
The request carries the token, the device key, the platform
codename, and a millisecond cache-buster `t=` -- all in the
query string. There is NO `$headers` parameter on this
endpoint.
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).
@@ -257,15 +314,13 @@ class SimpliTVChannelManager(ChannelManager):
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,
"t": int(time.time() * 1000),
},
)
with transport_errors(f"AcquireContent for {codename!r}"):
@@ -312,6 +367,41 @@ class SimpliTVChannelManager(ChannelManager):
)
# ----------------------------------------------------------------------
# AcquireContent format selection
# ----------------------------------------------------------------------
def _select_format(
formats: List[dict], *, protected: bool
) -> Tuple[Optional[str], Optional[int]]:
"""
Return (manifest URL, format Type) from a Formats array.
Protected content prefers DASH (Type 9), then HLS (Type 2).
Unprotected content prefers HLS, then DASH. The first entry with a
URL is a final fallback (its Type is returned as-is, possibly
None). Array order is never assumed.
"""
if protected:
order = (
SimpliTVDefaults.FORMAT_TYPE_DASH,
SimpliTVDefaults.FORMAT_TYPE_HLS,
)
else:
order = (
SimpliTVDefaults.FORMAT_TYPE_HLS,
SimpliTVDefaults.FORMAT_TYPE_DASH,
)
for wanted in order:
for fmt in formats:
if fmt.get("Type") == wanted and fmt.get("Url"):
return fmt["Url"], wanted
for fmt in formats:
if fmt.get("Url"):
return fmt["Url"], fmt.get("Type")
return None, None
# ----------------------------------------------------------------------
# Content-id grammar -- module scope so every manager can import the
# parsers without circular dependencies. All raise BadRequestError on
@@ -352,8 +442,8 @@ 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.
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"
@@ -392,8 +482,8 @@ def codename_from_playable_id(content_id: str) -> str:
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.
broadcast_id (simplitv:<channel>:<start>:<programme>), a prog: id,
or the bare programme codename.
"""
if not epg_id:
raise BadRequestError("simpliTV: empty epg_id")
@@ -406,4 +496,4 @@ def programme_codename_from_epg_id(epg_id: str) -> str:
return parts[3]
if epg_id.startswith(SimpliTVDefaults.PROGRAMME_PREFIX):
return parse_programme_id(epg_id)
return epg_id
return epg_id
@@ -13,8 +13,13 @@ 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"
# --- Endpoints / hosts ---------------------------------------------
# Confirmed against the browser capture: the API host is
# api.app.austrostream.at (multi-tenant; simpliTV is the
# X-Tenant-Codename: simplitv tenant). The web app itself lives on
# streaming.simpli.at, NOT streaming.simplitv.at.
BASE_URL = "https://api.app.austrostream.at"
WEBSITE = "https://streaming.simpli.at"
# --- Endpoint paths ------------------------------------------------
PATH_AUTHENTICATE = "/v1/OrsUser/Authenticate"
@@ -22,29 +27,48 @@ class SimpliTVDefaults:
PATH_REGISTER_DEVICE = "/v1/Devices/RegisterDevice"
PATH_CHANNEL_TILES = "/v1/EpgTile/FilterChannelTiles"
PATH_PROGRAM_TILES = "/v1/EpgTile/FilterProgramTiles"
PATH_EPG_AVAILABLE_DAYS = "/v1/EpgTile/GetAvailableDays"
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"
PATH_GET_USER_PRODUCTS = "/v1/IpottTransaction/GetUserProducts"
PATH_GET_ALL_REMINDERS = "/v1/Reminder/GetAllReminders"
# --- 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 = (
# The API expects a `$headers` query parameter carrying a JSON blob
# of the request headers. Two shapes appear in the browser capture:
#
# * WITH Content-Type — Authenticate, FilterChannelTiles,
# GetAvailableDays, RegisterDevice
# * WITHOUT Content-Type — GetUserProducts, GetRecordings,
# GetAllReminders, FilterProgramTiles
#
# Pre-encoded (percent-escaped) so they can be appended verbatim.
EPG_HEADERS_QUERY_WITH_CT = (
"$headers=%7B%22Content-Type%22:%22application%2Fjson%3B"
"charset%3Dutf-8%22,%22X-Api-Date-Format%22:%22iso%22,"
"%22X-Api-Resource-Language-Context%22:%22de%22,"
"%22X-Api-Camel-Case%22:true%7D"
)
EPG_HEADERS_QUERY_NO_CT = (
"$headers=%7B%22X-Api-Date-Format%22:%22iso%22,"
"%22X-Api-Resource-Language-Context%22:%22de%22,"
"%22X-Api-Camel-Case%22:true%7D"
)
# --- Platform / device identity ------------------------------------
PLATFORM_CODENAME = "www"
TENANT_CODENAME = "simplitv"
RESOURCE_LANGUAGE_CONTEXT = "de"
DEVICE_NAME = "Firefox"
DEVICE_BROWSER_VERSION = "110.0"
DEVICE_GENERAL_TYPE = "2"
DEVICE_OS = "Windows"
DEVICE_OS_VERSION = "10"
@@ -60,14 +84,34 @@ class SimpliTVDefaults:
TIMEOUT = 30
# --- DRM -----------------------------------------------------------
# DrmInfo indices in the AcquireContent response:
# [0] = PlayReady, [1] = Widevine.
DRM_INDEX_PLAYREADY = 0
DRM_INDEX_WIDEVINE = 1
# DrmInfo is returned as a list of objects with a `DrmSystem` string
# (PlayReady / Widevine / FairPlay). Kodi cannot play FairPlay, and
# Widevine is more broadly available than PlayReady, so we emit:
#
# Widevine priority 1
# PlayReady priority 2
#
# inputstream.adaptive picks the lowest-numbered system whose CDM is
# actually present on the platform, so this ordering is safe even on
# a PlayReady-only device.
DRM_SYSTEM_WIDEVINE = "Widevine"
DRM_SYSTEM_PLAYREADY = "PlayReady"
DRM_PRIORITY_WIDEVINE = 1
DRM_PRIORITY_PLAYREADY = 2
# ISA placeholder for the raw CDM challenge in the license body.
# LicenseConfig auto-base64-encodes plain strings, so "{CHA-RAW}"
# is stored (and sent to ISA) as its base64 form; ISA decodes it
# back to the literal placeholder and substitutes the challenge.
REQ_DATA_CHA_RAW = "{CHA-RAW}"
# MediaFiles[].Formats[].Type values in AcquireContent responses.
FORMAT_TYPE_HLS = 2
FORMAT_TYPE_DASH = 9
# --- Auth ----------------------------------------------------------
# simpliTV does not return expires_in; refresh well inside any
# plausible server-side expiry.
# Fallback only: the response now carries `tokenExpirationTime`,
# which is preferred when present.
TOKEN_LIFETIME_SECONDS = 12 * 3600
# --- Caches --------------------------------------------------------
@@ -77,35 +121,27 @@ class SimpliTVDefaults:
EPG_CACHE_MAX = 64
# --- EPG -----------------------------------------------------------
# The server advertises its actual window via GetAvailableDays;
# these are fallbacks used only when that call fails.
EPG_PAST_DAYS = 7
EPG_FUTURE_DAYS = 7
# The API is queried in day-sized windows, like the existing addon.
EPG_FUTURE_DAYS = 14
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 / timeshift ------------------------------------------
# The DVR window is per-channel (AdditionalInfo.Epg_TimeshiftSeconds
# in AcquireContent; 7200, 10800 and 14400 in the browser capture).
# SimpliTVCatchupManager reads the per-channel value and falls back
# to its own _MIN_TIMESHIFT_HOURS when the field is missing -- there
# is deliberately no global "catchup window" constant, because the
# API has none.
CATCHUP_SEEK_MARGIN_SECONDS = 35
@@ -114,10 +150,8 @@ 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.
user_agent -- passed to _setup_http_manager.
timeout -- passed to _setup_http_manager.
"""
def __init__(self, config_dict: Optional[dict] = None):
@@ -125,7 +159,6 @@ class SimpliTVConfig:
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
)
@@ -136,53 +169,70 @@ class SimpliTVConfig:
"""
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).
The simpliTV token is NOT a header (see auth.py): it is passed
as a URL query parameter or body field. These are the base
headers the browser sends alongside every API call.
"""
return {
"User-Agent": self.user_agent,
"Content-type": "application/json;charset=utf-8",
"Accept": "application/json, text/plain, */*",
"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,
"X-Api-Resource-Language-Context":
SimpliTVDefaults.RESOURCE_LANGUAGE_CONTEXT,
"X-Tenant-Codename": SimpliTVDefaults.TENANT_CODENAME,
"Origin": SimpliTVDefaults.WEBSITE,
"Referer": f"{SimpliTVDefaults.WEBSITE}/",
}
def get_manifest_headers(self) -> dict:
"""Headers for /Player/AcquireContent (same UA as the API)."""
"""Headers for /Player/AcquireContent (UA + JSON accept)."""
return {
"User-Agent": self.user_agent,
"Accept": "application/json",
"X-Tenant-Codename": SimpliTVDefaults.TENANT_CODENAME,
"Origin": SimpliTVDefaults.WEBSITE,
}
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.
The CDN does not authenticate: the addon gives inputstream only
the User-Agent. The token in the AcquireContent URL
authenticates the API call, not the CDN fetch. Origin is added
to match the browser capture; the CDN sends
access-control-allow-origin: * so it is not required.
"""
return {"User-Agent": self.user_agent}
return {
"User-Agent": self.user_agent,
"Origin": SimpliTVDefaults.WEBSITE,
}
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
The challenge custom data travels as a *header*, sent raw: the
value is already base64 and must not be URL-quoted. The request
body is the raw CDM challenge, injected by ISA via the
{CHA-RAW} placeholder.
The dict is passed to LicenseConfig, which urlencodes it for
the inputstream.adaptive DRM JSON. ISA decodes the
percent-encoding before sending the header, so the DRM server
sees the raw base64 -- matching the browser. If a real device
ever rejects the licence, the first thing to check is whether
ISA truly decodes req_headers values.
"""
headers = {
"User-Agent": self.user_agent,
"Referer": f"{SimpliTVDefaults.WEBSITE}/",
"Origin": SimpliTVDefaults.WEBSITE,
"Content-Type": "application/octet-stream",
}
if challenge:
headers["drmchallengecustomdata"] = quote(challenge)
headers["drmchallengecustomdata"] = challenge
return headers
# ----- URL builders -----------------------------------------------
@@ -190,38 +240,77 @@ class SimpliTVConfig:
def _url(self, path: str) -> str:
return f"{self.base_url}{path}"
def _url_with_headers(self, path: str, *, include_ct: bool = True) -> str:
"""Append the pre-encoded `$headers=` query string to `path`."""
q = (
SimpliTVDefaults.EPG_HEADERS_QUERY_WITH_CT
if include_ct
else SimpliTVDefaults.EPG_HEADERS_QUERY_NO_CT
)
return f"{self._url(path)}?{q}"
def authenticate_url(self) -> str:
return self._url(SimpliTVDefaults.PATH_AUTHENTICATE)
return self._url_with_headers(
SimpliTVDefaults.PATH_AUTHENTICATE, include_ct=True
)
def get_devices_url(self) -> str:
return self._url(SimpliTVDefaults.PATH_GET_DEVICES)
return self._url_with_headers(
SimpliTVDefaults.PATH_GET_DEVICES, include_ct=False
)
def register_device_url(self) -> str:
return self._url(SimpliTVDefaults.PATH_REGISTER_DEVICE)
return self._url_with_headers(
SimpliTVDefaults.PATH_REGISTER_DEVICE, include_ct=True
)
def channel_tiles_url(self) -> str:
return self._url(SimpliTVDefaults.PATH_CHANNEL_TILES)
return self._url_with_headers(
SimpliTVDefaults.PATH_CHANNEL_TILES, include_ct=True
)
def program_tiles_url(self) -> str:
return (
f"{self._url(SimpliTVDefaults.PATH_PROGRAM_TILES)}"
f"?{SimpliTVDefaults.EPG_HEADERS_QUERY}"
# FilterProgramTiles: no Content-Type in its $headers blob.
return self._url_with_headers(
SimpliTVDefaults.PATH_PROGRAM_TILES, include_ct=False
)
def epg_available_days_url(self) -> str:
return self._url_with_headers(
SimpliTVDefaults.PATH_EPG_AVAILABLE_DAYS, include_ct=True
)
def tile_details_url(self) -> str:
return (
f"{self._url(SimpliTVDefaults.PATH_TILE_DETAILS)}"
f"?{SimpliTVDefaults.EPG_HEADERS_QUERY}"
return self._url_with_headers(
SimpliTVDefaults.PATH_TILE_DETAILS, include_ct=False
)
def acquire_content_url(self) -> str:
# AcquireContent does NOT use $headers; it carries the token
# and deviceKey directly in the query string.
return self._url(SimpliTVDefaults.PATH_ACQUIRE_CONTENT)
def get_recordings_url(self) -> str:
return self._url(SimpliTVDefaults.PATH_GET_RECORDINGS)
return self._url_with_headers(
SimpliTVDefaults.PATH_GET_RECORDINGS, include_ct=False
)
def schedule_recording_url(self) -> str:
return self._url(SimpliTVDefaults.PATH_SCHEDULE_RECORDING)
return self._url_with_headers(
SimpliTVDefaults.PATH_SCHEDULE_RECORDING, include_ct=True
)
def delete_recording_url(self) -> str:
return self._url(SimpliTVDefaults.PATH_DELETE_RECORDING)
return self._url_with_headers(
SimpliTVDefaults.PATH_DELETE_RECORDING, include_ct=True
)
def user_products_url(self) -> str:
return self._url_with_headers(
SimpliTVDefaults.PATH_GET_USER_PRODUCTS, include_ct=False
)
def reminders_url(self) -> str:
return self._url_with_headers(
SimpliTVDefaults.PATH_GET_ALL_REMINDERS, include_ct=False
)
@@ -2,27 +2,38 @@
"""
simpliTV EPG manager.
Two endpoints, both anonymous (no token -- the existing addon sends
none):
Endpoints:
* /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).
* GET /v1/EpgTile/FilterProgramTiles -- every channel's programmes in
a [from, to] window. The response's `programs` is a dict:
{channel codename: [programme, ...]}. Programme tiles carry
`from` / `to` and have NO title, description or 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.
* POST /v2/Tile/GetTiles -- tile details by id. Called in batches to
enrich the programmes FilterProgramTiles returned. Only a compact
summary (title, description, icon) is cached per id, and the cache
is size-bounded. Ids the server did not return are cached as empty
so they are not re-requested on every call. The summaries used for
one request are collected locally, so cache eviction can never
blank titles in the response being built.
Window: -7..+7 days around "now", matching the addon's TV-Guide.
* POST /v1/EpgTile/GetAvailableDays -- the server's EPG window. Used
only to expose epg_window; the fetch windows themselves follow the
caller's [start, end].
If GetTiles fails, the grid is still returned: titles fall back to the
programme codename (and a warning is logged) instead of failing the
whole EPG request.
Windows are fetched in day-sized chunks (as the addon does) and cached
briefly, so per-channel callers don't re-download the grid.
"""
import threading
import time
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Iterator, List, Optional, Tuple
from urllib.parse import quote
from ...base.errors import ServerError
from ...base.managers import EpgManager
@@ -30,7 +41,13 @@ from ...base.models.epg_models import EPGEntry, EPGProgramDetails
from ...base.utils.logger import logger
from .constants import SimpliTVDefaults
from .helpers import transport_errors
from .helpers import parse_iso, transport_errors
# GetTiles ids per request.
_TILE_BATCH = 500
# Upper bound on cached tile summaries; the oldest half is dropped when
# exceeded.
_TILE_CACHE_MAX = 50000
class SimpliTVEpgManager(EpgManager):
@@ -47,14 +64,21 @@ class SimpliTVEpgManager(EpgManager):
self._window_cache: Dict[
Tuple[str, str], Tuple[float, Dict[str, List[dict]]]
] = {}
# programme id -> {"title", "description", "icon"} ({} = unknown)
self._tile_cache: Dict[str, dict] = {}
self._available_window: Optional[Tuple[int, int]] = None
self._available_window_expires: float = 0.0
self._cache_lock = threading.Lock()
@property
def epg_window(self) -> Tuple[int, int]:
return (
SimpliTVDefaults.EPG_PAST_DAYS,
SimpliTVDefaults.EPG_FUTURE_DAYS,
)
"""
Server-advertised EPG window as (past_days, future_days).
Informational: it does NOT bound the fetch windows, which
follow the caller's [start, end].
"""
return self._fetch_available_window()
# ------------------------------------------------------------------
# get_epg / get_epg_grid
@@ -74,8 +98,8 @@ class SimpliTVEpgManager(EpgManager):
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, []))
programs, tiles = self._fetch_programs(start_time, end_time)
return _entries_for(codename, programs.get(codename, []), tiles)
def get_epg_grid(
self,
@@ -85,12 +109,12 @@ class SimpliTVEpgManager(EpgManager):
**kw,
) -> Dict[str, List[EPGEntry]]:
"""Native batch: one fetch serves every requested channel."""
programs = self._fetch_programs(start_time, end_time)
programs, tiles = 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, [])
codename, programs.get(codename, []), tiles
)
return grid
@@ -104,22 +128,68 @@ class SimpliTVEpgManager(EpgManager):
"""
Extended details for one tile id (EPGEntry.program_id).
Always fetches the full tile (cast, subtitle, year are not in
the cached summary), so every call makes one small request.
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}"):
tile = self._fetch_tiles([program_id]).get(program_id)
if not tile:
return None
return _tile_to_program_details(tile)
# ------------------------------------------------------------------
# Available-days window
# ------------------------------------------------------------------
def _fetch_available_window(self) -> Tuple[int, int]:
"""
Return (past_days, future_days) advertised by the server.
Cached for EPG_CACHE_TTL seconds; falls back to the constants
on any error or unusable answer, logged at info because being
permanently on the fallback is worth noticing.
"""
now = time.monotonic()
with self._cache_lock:
if (
self._available_window is not None
and self._available_window_expires > now
):
return self._available_window
try:
resp = self.http_manager.post(
self.config.tile_details_url(),
json=body,
self.config.epg_available_days_url(),
json={},
headers=self.config.get_api_headers(),
)
tiles = resp.json().get("tiles") or []
if not tiles:
return None
return _tile_to_program_details(tiles[0])
data = resp.json()
window = _days_from_window(data.get("from"), data.get("to"))
if window is not None:
with self._cache_lock:
self._available_window = window
self._available_window_expires = (
now + SimpliTVDefaults.EPG_CACHE_TTL
)
return window
logger.info(
"simpliTV: GetAvailableDays returned no usable from/to; "
"using defaults for epg_window"
)
except Exception as e:
logger.info(
f"simpliTV: GetAvailableDays failed ({e}); using "
f"defaults for epg_window"
)
fallback = (
SimpliTVDefaults.EPG_PAST_DAYS,
SimpliTVDefaults.EPG_FUTURE_DAYS,
)
with self._cache_lock:
self._available_window = fallback
self._available_window_expires = now + 60
return fallback
# ------------------------------------------------------------------
# Fetching
@@ -127,8 +197,17 @@ class SimpliTVEpgManager(EpgManager):
def _fetch_programs(
self, start_time: Optional[datetime], end_time: Optional[datetime]
) -> Dict[str, List[dict]]:
"""Merge day-sized windows into {codename: [programme]}."""
) -> Tuple[Dict[str, List[dict]], Dict[str, dict]]:
"""
Merge day-sized windows into {codename: [programme]} and
return it together with {programme id: tile summary} for every
programme in it.
The summaries are gathered into a local dict (from the cache
for known ids, from GetTiles for the rest) rather than re-read
from the shared cache afterwards, so eviction during a large
fetch cannot drop titles from this response.
"""
start, end = _normalise_window(start_time, end_time)
merged: Dict[str, Dict[str, dict]] = {}
for w_start, w_end in _chunks(start, end):
@@ -139,10 +218,43 @@ class SimpliTVEpgManager(EpgManager):
# 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')}"
f"{programme.get('codename')}:"
f"{programme.get('from')}"
)
bucket[key] = programme
return {c: list(b.values()) for c, b in merged.items()}
all_ids = list(dict.fromkeys(
programme["id"]
for bucket in merged.values()
for programme in bucket.values()
if programme.get("id")
))
tiles: Dict[str, dict] = {}
missing: List[str] = []
with self._cache_lock:
for pid in all_ids:
if pid in self._tile_cache:
tiles[pid] = self._tile_cache[pid]
else:
missing.append(pid)
for i in range(0, len(missing), _TILE_BATCH):
batch = missing[i:i + _TILE_BATCH]
try:
fresh = self._fetch_tiles(batch)
except Exception as e:
# Degrade to codename titles rather than fail the EPG.
logger.warning(
f"simpliTV: GetTiles failed ({e}); programme titles "
f"fall back to codenames"
)
break
for pid in batch:
tile = fresh.get(pid)
tiles[pid] = _summarise_tile(tile) if tile else {}
return {c: list(b.values()) for c, b in merged.items()}, tiles
def _fetch_window(
self, from_iso: str, to_iso: str
@@ -154,18 +266,20 @@ class SimpliTVEpgManager(EpgManager):
if hit and hit[0] > now:
return hit[1]
body = {
"platformCodename": self.config.platform_codename,
"from": from_iso,
"to": to_iso,
}
# FilterProgramTiles is a GET with query-string parameters.
# `from` and `to` MUST be percent-encoded (colons, dots).
url = (
f"{self.config.program_tiles_url()}"
f"&platformCodename={quote(self.config.platform_codename)}"
f"&from={quote(from_iso)}"
f"&to={quote(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(),
resp = self.http_manager.get(
url, headers=self.config.get_api_headers()
)
programs = resp.json().get("programs")
if not isinstance(programs, dict):
raise ServerError(
"simpliTV: FilterProgramTiles `programs` is not a "
@@ -185,6 +299,55 @@ class SimpliTVEpgManager(EpgManager):
)
return programs
# ------------------------------------------------------------------
# Tile details (batched, cached as compact summaries)
# ------------------------------------------------------------------
def _fetch_tiles(self, program_ids: List[str]) -> Dict[str, dict]:
"""
POST /v2/Tile/GetTiles for the given ids.
Returns {id: full tile} for the ids the server returned, and
records a compact summary for every requested id in the cache
(an empty summary for ids the server did not return). Raises
ServerError on transport failure; callers decide how to
degrade.
"""
if not program_ids:
return {}
body = {
"platformCodename": self.config.platform_codename,
"requestedTiles": [{"id": pid} for pid in program_ids],
}
with transport_errors(f"GetTiles for {len(program_ids)} tiles"):
resp = self.http_manager.post(
self.config.tile_details_url(),
json=body,
headers=self.config.get_api_headers(),
)
tiles = resp.json().get("tiles") or []
fresh: Dict[str, dict] = {}
for tile in tiles:
if not isinstance(tile, dict):
continue
tid = tile.get("id")
if tid:
fresh[tid] = tile
with self._cache_lock:
for pid in program_ids:
tile = fresh.get(pid)
self._tile_cache[pid] = (
_summarise_tile(tile) if tile else {}
)
if len(self._tile_cache) > _TILE_CACHE_MAX:
# dicts keep insertion order: drop the oldest half.
for old in list(self._tile_cache)[: len(self._tile_cache) // 2]:
del self._tile_cache[old]
return fresh
# ----------------------------------------------------------------------
# Module-private helpers
@@ -232,29 +395,65 @@ def _iso(dt: datetime) -> str:
def _parse_iso_to_unix(s: Optional[str]) -> Optional[int]:
"""
ISO-8601 (Z or offset) -> Unix seconds, or None if missing/malformed.
"""ISO-8601 -> Unix seconds, or None if missing / malformed."""
dt = parse_iso(s)
return int(dt.timestamp()) if dt is not None else None
A timestamp without offset is read as UTC (as the addon does), never
as the server's local time.
def _days_from_window(
from_iso: Optional[str], to_iso: Optional[str]
) -> Optional[Tuple[int, int]]:
"""
if not s:
Convert GetAvailableDays's from/to to (past_days, future_days)
relative to now. None if either bound is missing or unparseable.
"""
frm = parse_iso(from_iso)
to = parse_iso(to_iso)
if frm is None or to is None:
return None
try:
dt = datetime.fromisoformat(s.replace("Z", "+00:00"))
except (ValueError, AttributeError, TypeError):
now = datetime.now(timezone.utc)
past = max(0, int((now - frm).total_seconds() // 86400))
future = max(0, int((to - now).total_seconds() // 86400))
return past, future
def _first_image_url(images: Any) -> Optional[str]:
"""
URL of the first image, tolerating an unverified response shape:
a list of {"url": ...} objects, a list of plain strings, or
anything else (-> None).
"""
if not isinstance(images, list) or not images:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return int(dt.timestamp())
first = images[0]
if isinstance(first, dict):
return first.get("url") or None
if isinstance(first, str):
return first or None
return None
def _summarise_tile(tile: Dict[str, Any]) -> Dict[str, Any]:
"""Compact per-programme summary kept in the tile cache."""
return {
"title": tile.get("title") or "",
"description": tile.get("description") or "",
"icon": _first_image_url(tile.get("images")),
}
def _entries_for(
channel_codename: str, programmes: List[dict]
channel_codename: str,
programmes: List[dict],
tiles: Dict[str, dict],
) -> List[EPGEntry]:
entries = []
for programme in programmes:
entry = _programme_to_entry(channel_codename, programme)
entry = _programme_to_entry(
channel_codename,
programme,
tiles.get(programme.get("id") or "") or {},
)
if entry is not None:
entries.append(entry)
entries.sort(key=lambda e: e.start)
@@ -262,18 +461,25 @@ def _entries_for(
def _programme_to_entry(
channel_codename: str, programme: Dict[str, Any]
channel_codename: str,
programme: Dict[str, Any],
tile: 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).
Map a FilterProgramTiles programme (plus its tile summary, if any)
to EPGEntry.
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.
FilterProgramTiles emits `from` / `to` (not `start` / `stop`) and
carries no title, description or images; those come from `tile`.
When no title is available the programme codename is used, so the
grid is never blank.
"""
start = _parse_iso_to_unix(programme.get("start"))
end = _parse_iso_to_unix(programme.get("stop"))
start = _parse_iso_to_unix(
programme.get("from") or programme.get("start")
)
end = _parse_iso_to_unix(
programme.get("to") or programme.get("stop")
)
if start is None or end is None:
logger.debug(
f"simpliTV: skipping programme without times: "
@@ -281,19 +487,23 @@ def _programme_to_entry(
)
return None
images = programme.get("images") or []
icon = images[0].get("url") if images else None
title = (
tile.get("title")
or programme.get("title")
or programme.get("codename")
or "(no title)"
)
return EPGEntry(
broadcast_id=(
f"{SimpliTVDefaults.BROADCAST_ID_PREFIX}{channel_codename}:"
f"{start}:{programme.get('codename', '')}"
),
title=programme.get("title", ""),
description=programme.get("description", ""),
title=title,
description=tile.get("description") or "",
start=start,
end=end,
icon=icon,
icon=tile.get("icon"),
program_id=programme.get("id"),
)
@@ -302,21 +512,22 @@ 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.
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")]
people = tile.get("people")
cast = [
p["fullName"]
for p in (people if isinstance(people, list) else [])
if isinstance(p, dict) and 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,
icon=_first_image_url(tile.get("images")),
cast=cast,
)
)
@@ -6,7 +6,10 @@ Public names on purpose: the template README requires that a helper
used by several modules is public (no leading underscore).
"""
import re
from contextlib import contextmanager
from datetime import datetime, timezone
from typing import Optional
from ...base.errors import ProviderError, ServerError
@@ -15,12 +18,21 @@ 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.
Fallback only, and only for DRM-protected content. The
AcquireContent response normally carries both DASH (Type 9) and HLS
(Type 2) entries, and the channel manager picks the DASH one
directly when the content is protected; this function is called
only when the protected asset is HLS-only.
Never applied to unprotected content: the CDN has been serving the
HLS URL to the addon for those since day one, and swapping it out
has not been validated. Restart relies on a DVR seek offset from
the window start, and DASH time-shift semantics can differ from
HLS.
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"),
@@ -49,3 +61,37 @@ def transport_errors(what: str):
raise
except Exception as e:
raise ServerError(f"simpliTV: {what} failed: {e}") from e
# Fractional seconds followed by end-of-string, Z, or a UTC offset.
_FRACTION = re.compile(r"\.(\d+)(?=$|[Zz]|[+-]\d{2}(?::?\d{2})?$)")
_OFFSET_NO_COLON = re.compile(r"([+-]\d{2})(\d{2})$")
def parse_iso(value: Optional[str]) -> Optional[datetime]:
"""
Parse an ISO-8601 timestamp to a timezone-aware datetime, or None
if missing / malformed.
datetime.fromisoformat on Python < 3.11 accepts only 3 or 6
fractional digits and rejects "Z" and "+HHMM". .NET-style APIs
emit 7 digits, so the string is normalised first: fraction cut or
padded to 6 digits, "Z" -> "+00:00", "+HHMM" -> "+HH:MM". A
timestamp without an offset is read as UTC, never as local time.
"""
if not value or not isinstance(value, str):
return None
s = value.strip()
if not s:
return None
s = _FRACTION.sub(lambda m: "." + m.group(1)[:6].ljust(6, "0"), s)
if s[-1] in "Zz":
s = s[:-1] + "+00:00"
s = _OFFSET_NO_COLON.sub(r"\1:\2", s)
try:
dt = datetime.fromisoformat(s)
except (ValueError, TypeError):
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
@@ -4,12 +4,151 @@ 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.
channelname() / logomapper() so behaviour is identical, then extended
for the codenames observed in the browser capture.
Lookup order:
1. _CODENAME_ALIASES -- explicit codename -> display-name table for
the codenames whose derivation does not match the spelling used
as a LOGO_MAP key.
2. channel_name_from_codename -- general derivation (dashes to
spaces, uppercase, strip trailing HD/FHD).
3. LOGO_MAP -- display name -> logo URL.
4. PROVIDER_LOGO fallback.
"""
from .constants import SimpliTVDefaults
# Codenames whose local derivation does not match the display name
# used as a LOGO_MAP key. Anything not in this table falls back to
# channel_name_from_codename().
_CODENAME_ALIASES = {
"atvhd": "ATV",
"atv-ii-fhd": "ATV II",
"atv-2": "ATV 2",
"n-tvhd": "N TV",
"tele-5-hd": "TELE 5",
"puls4austriahd": "PULS4",
"puls24hd": "PULS24",
"puls24": "PULS24",
"pu4": "PU4",
"prosiebenaustriahd": "PROSIEBEN AUSTRIA",
"sat1austriahd": "SAT.1 AUSTRIA",
"sat1goldaustriahd": "SAT.1 GOLD AUSTRIA",
"rtlaustriahd": "RTL AUSTRIA",
"rtlzweiaustriahd": "RTL ZWEI AUSTRIA",
"rtlup-austria-hd": "RTLUP AUSTRIA",
"rtl-crime-hd": "RTL CRIME",
"rtl-living-hd": "RTL LIVING",
"rtl-passion-hd": "RTL PASSION",
"voxaustriahd": "VOX AUSTRIA",
"kabeleinsaustriahd": "KABEL EINS AUSTRIA",
"kabeleinsdokuhd": "KABELEINS DOKU",
"prosiebenmaxxaustriahd": "PROSIEBEN MAXX AUSTRIA",
"sixxaustriahd": "SIXX AUSTRIA",
"nitro-austria-hd": "NITRO AUSTRIA",
"dmaxaustriahd": "DMAX AUSTRIA",
"tlcaustriahd": "TLC AUSTRIA",
"geo-television-hd": "GEO TELEVISION",
"hgtv-hd": "HGTV",
"servustv-austria": "SERVUS TV AUSTRIA",
"orf-sport-plus": "ORF SPORT PLUS",
"orf-iii": "ORF III",
"orf2-europe": "ORF 2 EUROPE",
"orf1": "ORF 1",
"orf2": "ORF 2",
"orf2no": "ORF 2 NO",
"orf2oo": "ORF 2 OOE",
"orf2st": "ORF 2 STEIERMARK",
"orf2t": "ORF 2 TIROL",
"orf2b": "ORF 2 BURGENLAND",
"orf2k": "ORF 2 KAERNTEN",
"orf2s": "ORF 2 SALZBURG",
"orf2v": "ORF 2 VORARLBERG",
"orf-kids": "ORF KIDS",
"zdf-hd": "ZDF",
"zdfneo": "ZDF NEO",
"zdfinfo-hd": "ZDF INFO",
"ard-hd": "ARD",
"ard-alpha-hd": "ARD ALPHA",
"arte": "ARTE",
"3sat": "3SAT",
"kika-hd": "KIKA",
"phoenix": "PHOENIX",
"one": "ONE",
"tagesschau-24-hd": "TAGESSCHAU 24",
"hr-fernsehen-hd": "HR FERNSEHEN",
"mdr-sachsen-hd": "MDR SACHSEN",
"ndr": "NDR",
"rbb-berlin-hd": "RBB BERLIN",
"radio-bremen-hd": "RADIO BREMEN",
"sr-fernsehen-hd": "SR FERNSEHEN",
"swr-bw-hd": "SWR BW",
"wdr-koeln-hd": "WDR KOELN",
"br-hd": "BFS",
"cnn": "CNN",
"welt": "WELT",
"euronews-german-sd": "EURONEWS ENGLISH",
"al-jazeera-english-hd": "AL JAZEERA ENGLISH",
"bloomberg-europe": "BLOOMBERG EUROPE",
"tvp-world": "TVP WORLD",
"nickelodeon": "NICKELODEON",
"ric": "RIC",
"ric-today": "RIC TODAY",
"fix-und-foxi": "FIX UND FOXI",
"superrtl-austria-hd": "SUPERRTL AUSTRIA",
"k-tv": "K-T-V",
"kronehit-tv": "KRONEHIT TV",
"schau-tv": "SCHAU TV",
"r9-oesterreich-hd": "R9 OESTERREICH",
"sonnenklartv-hd": "SONNENKLARTV",
"lt1-hd": "LT1",
"tv1-ooe": "TV1 OOE",
"laendletv": "LAENDLETV",
"tiroltv": "TIROLTV",
"dorftv": "DORFTV",
"n1-noe-tv": "N1 NOE TV",
"w24": "W24",
"kt1": "KT1",
"myzentv": "MYZENTV",
"museum-tv": "MUSEUM TV",
"kaminfeuer": "KAMINFEUER",
"shoplc": "SHOP LC",
"pegasus-hls": "PEGASUS HLS",
"motorvision": "MOTORVISION",
"more-than-sports": "MORE THAN SPORTS",
"wedotv": "WEDOTV",
"wedo-sports": "WEDO SPORTS",
"wedo-big-stories": "WEDO BIG STORIES",
"wedo-true-stories": "WEDO TRUE STORIES",
"bibeltv-hd": "BIBELTV",
"hopetvhd": "HOPETV",
"kronehit-tv": "KRONEHIT TV",
"schlager-deluxe": "SCHLAGER DELUXE",
"melodietv": "MELODIETV",
"deutsches-musikfernsehen": "DEUTSCHES MUSIKFERNSEHEN",
"mtvaustriahd": "MTV AUSTRIA",
"deluxemusicaustriahd": "DELUXEMUSIC AUSTRIA",
"starbaradies-tv": "STARPARADISES TV",
"lilo-tv": "LILO TV",
"playboy-tv": "PLAYBOY TV",
"hustler-tv": "HUSTLER TV",
"dorcel-tv": "DORCEL TV",
"radio-bremen-hd": "RADIO BREMEN",
"ard-alpha-hd": "ARD ALPHA",
"bfs": "BFS",
"mtvaustriahd": "MTV AUSTRIA",
"k-tv": "K-TV",
"k-tv": "K-T-V",
}
LOGO_MAP = {
# ------------------------------------------------------------------
# Original addon entries, preserved verbatim.
# ------------------------------------------------------------------
"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",
@@ -88,7 +227,7 @@ LOGO_MAP = {
"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",
"WDR KOELN": "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",
@@ -112,19 +251,96 @@ LOGO_MAP = {
"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",
# ------------------------------------------------------------------
# Extensions from the browser capture.
# Names here are the alias-table targets; do not rename without
# updating _CODENAME_ALIASES.
# ------------------------------------------------------------------
"ATV II": "https://files.app.simplitv.at/files/atv2-bunt.png",
"PULS4": "https://files.app.simplitv.at/files/puls4-hd-bunt.png",
"PROSIEBEN AUSTRIA": "https://files.app.simplitv.at/files/pro7-hd-bunt.png",
"SAT.1 AUSTRIA": "https://files.app.simplitv.at/files/sat1-hd-bunt.png",
"SAT.1 GOLD AUSTRIA": "https://files.app.simplitv.at/files/sat1-gold-hd-bunt.png",
"RTL AUSTRIA": "https://files.app.simplitv.at/files/rtl-austria-hd-bunt.png",
"RTL ZWEI AUSTRIA": "https://files.app.simplitv.at/files/rtl-zwei-hd-bunt.png",
"RTLUP AUSTRIA": "https://files.app.simplitv.at/files/rtluphd-kopie.png",
"RTL CRIME": "https://files.app.simplitv.at/files/rtl-crime-hd-bunt.png",
"RTL LIVING": "https://files.app.simplitv.at/files/rtl-living-hd-bunt.png",
"RTL PASSION": "https://files.app.simplitv.at/files/rtl-passion-hd-bunt.png",
"VOX AUSTRIA": "https://files.app.simplitv.at/files/vox-hd-bunt.png",
"KABEL EINS AUSTRIA": "https://files.app.simplitv.at/files/kabel1-hd-austria-bunt.png",
"KABELEINS DOKU": "https://files.app.simplitv.at/files/kabel1-doku-austria-bunt.png",
"PROSIEBEN MAXX AUSTRIA": "https://files.app.simplitv.at/files/pro7-maxx-austria-bunt.png",
"SIXX AUSTRIA": "https://files.app.simplitv.at/files/sixx-austria-hd-bunt.png",
"DMAX AUSTRIA": "https://files.app.simplitv.at/files/dmax-hd-bunt.png",
"TLC AUSTRIA": "https://files.app.simplitv.at/files/tlc-hd-bunt.png",
"GEO TELEVISION": "https://files.app.simplitv.at/files/geo-television-hd-bunt.png",
"ZDF NEO": "https://files.app.simplitv.at/files/zdf-neo-hd-bunt.png",
"ZDF INFO": "https://files.app.simplitv.at/files/zdf-info-hd-bunt.png",
"ORF 1": "https://files.app.simplitv.at/files/orf1-hd-bunt.png",
"ORF 2": "https://files.app.simplitv.at/files/orf2-hd-bunt.png",
"ORF 2 NO": "https://files.app.simplitv.at/files/orf-no-e-hd-bunt.png",
"ORF 2 OOE": "https://files.app.simplitv.at/files/orf-oo-e-hd-bunt.png",
"ORF 2 STEIERMARK": "https://files.app.simplitv.at/files/orf-steiermark-hd-bunt.png",
"ORF 2 TIROL": "https://files.app.simplitv.at/files/orf-tirol-hd-bunt.png",
"ORF 2 BURGENLAND": "https://files.app.simplitv.at/files/orf-hd-burgenland-bunt.png",
"ORF 2 KAERNTEN": "https://files.app.simplitv.at/files/orf-hd-ka-ernten-bunt.png",
"ORF 2 SALZBURG": "https://files.app.simplitv.at/files/orf-salzburg-hd-bunt.png",
"ORF 2 VORARLBERG": "https://files.app.simplitv.at/files/orf-vorarlberg-hd-bunt.png",
"ORF 2 EUROPE": "https://files.app.simplitv.at/files/orf2-europe-hd-bunt.png",
"ORF KIDS": "https://files.app.simplitv.at/files/orf-kids-bunt.png",
"RIC TODAY": "https://files.app.simplitv.at/files/ric-today-bunt.png",
"MUSEUM TV": "https://files.app.simplitv.at/files/museum-tv-logo.png",
"KAMINFEUER": "https://files.app.simplitv.at/files/kaminfeuer-bunt.png",
"SHOP LC": "https://files.app.simplitv.at/files/shoplc-bunt.png",
"PEGASUS HLS": "https://files.app.simplitv.at/files/pegasus-hls.png",
"MOTORVISION": "https://files.app.simplitv.at/files/motorvision-hd-bunt.png",
"MORE THAN SPORTS": "https://files.app.simplitv.at/files/more-than-sports-bunt.png",
"WEDOTV": "https://files.app.simplitv.at/files/wedotv-bunt.png",
"WEDO SPORTS": "https://files.app.simplitv.at/files/wedo-sports-bunt.png",
"WEDO BIG STORIES": "https://files.app.simplitv.at/files/wedo-big-stories-bunt.png",
"WEDO TRUE STORIES": "https://files.app.simplitv.at/files/wedo-true-stories-bunt.png",
"HOPETV": "https://files.app.simplitv.at/files/hopetv-bunt.png",
"KRONEHIT TV": "https://files.app.simplitv.at/files/kronehit-tv-logo.png",
"MYZENTV": "https://files.app.simplitv.at/files/museum-tv-logo.png",
"K-T-V": "https://files.app.simplitv.at/files/k-tv-bunt.png",
"K-TV": "https://files.app.simplitv.at/files/k-tv-bunt.png",
"DELUXEMUSIC AUSTRIA": "https://files.app.simplitv.at/files/deluxe-music-hd-bunt.png",
"STARPARADISES TV": "https://files.app.simplitv.at/files/starparadies.png",
}
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]
"""
Display name for a codename.
Prefers the explicit alias table; otherwise normalises dashes and
underscores to spaces, uppercases, and strips a trailing HD / FHD
quality suffix.
"""
if not codename:
return ""
lower = codename.lower()
if lower in _CODENAME_ALIASES:
return _CODENAME_ALIASES[lower]
name = codename.replace("-", " ").replace("_", " ").upper()
# Strip a trailing quality marker. FHD must be checked before HD.
for suffix in (" FHD", "FHD", " HD", "HD"):
if name.endswith(suffix) and len(name) > len(suffix):
name = name[: -len(suffix)].rstrip()
break
# Collapse any double spaces left behind.
while " " in name:
name = name.replace(" ", " ")
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
return LOGO_MAP.get(name) or SimpliTVDefaults.PROVIDER_LOGO
@@ -6,8 +6,9 @@ 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).
No custom AuthToken subclass: the token is a plain opaque string, and
the device key is discovered via GetDevices (registered only if the
account has none) -- see auth.py for the two-step flow.
"""
from dataclasses import dataclass
@@ -56,4 +57,4 @@ class SimpliTVChannel(Channel):
result["IsCatchupEnabled"] = self.is_catchup_enabled
result["RecordingId"] = self.recording_id
result["RecordingStatus"] = self.recording_status
return result
return result
@@ -36,6 +36,15 @@ 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().
Catchup windows
---------------
The DVR window is per-channel (AdditionalInfo.Epg_TimeshiftSeconds in
the AcquireContent response: 2h, 3h or 4h in the browser capture).
SimpliTVCatchupManager reads the per-channel value inside
get_restart_manifest. Its catchup_window_hours property returns the
conservative minimum (2h) because the ABC can only express a single
integer.
"""
from typing import Any, Callable, ClassVar, Dict, List, Optional, Tuple
@@ -289,8 +298,9 @@ class SimpliTVProvider(StreamingProvider):
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.
channel's DVR window) or None when the programme began outside
that window. The window is per-channel (see "Catchup windows"
above). The caller must seek; the URL alone plays live.
"""
if self.catchup is None:
return None
@@ -346,4 +356,4 @@ class SimpliTVProvider(StreamingProvider):
def get_channels(self, **kw):
if self.channels is None:
return []
return self.channels.get_channels(**kw)
return self.channels.get_channels(**kw)