# streaming_providers/providers/simpli/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 = "simpli" PROVIDER_LOGO = "https://files.app.austrostream.at/files/tenants/simpli/images/logo/v2/simpli-horizontal-center-2.png" # --- Endpoints / hosts --------------------------------------------- # The API host is api.app.austrostream.at (multi-tenant). The # browser capture sends X-Tenant-Codename: simplitv -- note the "tv", # which differs from PROVIDER_NAME. The web app lives on # streaming.simpli.at. BASE_URL = "https://api.app.austrostream.at" WEBSITE = "https://streaming.simpli.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_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 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" # NOTE: the tenant codename is "simplitv" (with the "tv"), matching # X-Tenant-Codename in the browser capture. It is NOT the same as # PROVIDER_NAME ("simpli"), which is the internal registry key and # the credentials.json key. 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" # --- 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 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 ---------------------------------------------------------- # Fallback only: the response now carries `tokenExpirationTime`, # which is preferred when present. 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 ----------------------------------------------------------- # The server advertises its actual window via GetAvailableDays; # these are fallbacks used only when that call fails. EPG_PAST_DAYS = 7 EPG_FUTURE_DAYS = 14 EPG_CHUNK_HOURS = 24 # --- Content-id grammar ------------------------------------------- LIVE_PREFIX = "live:" RECORDING_PREFIX = "rec:" PROGRAMME_PREFIX = "prog:" CATCHUP_PREFIX = "catchup:" CATCHUP_TS_SEPARATOR = "@" BROADCAST_ID_PREFIX = "simpli:" # --- 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 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. """ 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.platform_codename = config.get( "platform_codename", SimpliTVDefaults.PLATFORM_CODENAME ) # ----- Header builders -------------------------------------------- def get_api_headers(self) -> dict: """ Headers for the JSON API endpoints. 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. NOTE: Content-Type here is application/json;charset=utf-8, which is correct for most endpoints. Authenticate is the exception: it needs Content-Type: text/plain even though the body is JSON. That override lives in auth.py. """ return { "User-Agent": self.user_agent, "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 (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 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, "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*, 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, "Origin": SimpliTVDefaults.WEBSITE, "Content-Type": "application/octet-stream", } if challenge: headers["drmchallengecustomdata"] = challenge return headers # ----- URL builders ----------------------------------------------- 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_with_headers( SimpliTVDefaults.PATH_AUTHENTICATE, include_ct=True ) def get_devices_url(self) -> str: return self._url_with_headers( SimpliTVDefaults.PATH_GET_DEVICES, include_ct=False ) def register_device_url(self) -> str: return self._url_with_headers( SimpliTVDefaults.PATH_REGISTER_DEVICE, include_ct=True ) def channel_tiles_url(self) -> str: return self._url_with_headers( SimpliTVDefaults.PATH_CHANNEL_TILES, include_ct=True ) def program_tiles_url(self) -> str: # 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 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_with_headers( SimpliTVDefaults.PATH_GET_RECORDINGS, include_ct=False ) def schedule_recording_url(self) -> str: return self._url_with_headers( SimpliTVDefaults.PATH_SCHEDULE_RECORDING, include_ct=True ) def delete_recording_url(self) -> str: 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 )