add simplitv

This commit is contained in:
Nirvana
2026-10-03 22:03:39 +02:00
parent e0ee6b9470
commit 307ebe6e0b
11 changed files with 2189 additions and 0 deletions
@@ -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", ""),
)