From ad8b6357d69f911fb9f52eb27f0f976424d6c935 Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Wed, 19 Aug 2026 19:56:03 +0200 Subject: [PATCH 01/16] Support HLS-DVR streaming protocol in play manager --- lib/play.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/play.py b/lib/play.py index 7cc09ff..9ade2f5 100644 --- a/lib/play.py +++ b/lib/play.py @@ -89,7 +89,7 @@ def play_video(self, media_id_or_urn): drm = True break - if utils.try_get(resource, "protocol") == "HLS": + if utils.try_get(resource, "protocol") in ("HLS", "HLS-DVR"): for key in ("SD", "HD"): if utils.try_get(resource, "quality") == key: stream_urls[key] = utils.try_get(resource, "url") From c24c607e241ff16f7187a28d316c8617ff8094c0 Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Thu, 20 Aug 2026 00:14:01 +0200 Subject: [PATCH 02/16] Fix DRM slideshow playback with a live-refreshing local manifest proxy DRM DASH streams defaulted to a low-framerate slideshow preview because inputstream.adaptive picked the first adaptation set, which is a trickplay track. Fixed by downloading the manifest, stripping all trickmode adaptation sets, injecting a BaseURL pointing back at the origin, and serving the filtered manifest through a local HTTP proxy (ManifestProxyServer). The proxy re-fetches and re-filters the manifest on every request so segments stay current for long-running 24/7 streams, and ties its lifetime to actual playback stop/end/error instead of a fixed shutdown timer, with a generous idle timeout as a safety net. Background refresh failures are logged quietly instead of surfacing a UI notification. --- lib/manifest_proxy.py | 149 ++++++++++++++++++++++++++++++++++++++++++ lib/play.py | 106 +++++++++++++++++++++++++++++- lib/srgssr.py | 20 ++++-- 3 files changed, 265 insertions(+), 10 deletions(-) create mode 100644 lib/manifest_proxy.py diff --git a/lib/manifest_proxy.py b/lib/manifest_proxy.py new file mode 100644 index 0000000..2736fec --- /dev/null +++ b/lib/manifest_proxy.py @@ -0,0 +1,149 @@ +# Copyright (C) 2018 Alexander Seiler +# +# +# This file is part of script.module.srgssr. +# +# script.module.srgssr is free software: you can redistribute it and/or +# modify it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# script.module.srgssr is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with script.module.srgssr. +# If not, see . + +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +# Safety net only, in case wait_until_playback_stops() never runs (e.g. crash). +IDLE_TIMEOUT_SECONDS = 3 * 60 * 60 +WATCHDOG_INTERVAL_SECONDS = 30 +STARTUP_TIMEOUT_SECONDS = 60 + + +class ManifestProxyServer: + """Serves a locally-filtered DASH manifest, re-fetching it on every request. + + This keeps the manifest live-updated for the whole playback session, and + ties its own lifetime to actual playback (stopped/ended/error) instead of a + fixed timer. + + Kodi stops delivering xbmc.Player callbacks once the invoking plugin + script exits, which normally happens right after resolving the URL. So + the caller must block in wait_until_playback_stops() to keep the script + (and this server) alive for as long as the stream plays. + """ + + def __init__(self, refresh, initial_xml, logger=None): + """ + refresh -- callable() -> bytes or None; None means "fetch failed", + keep serving the last good manifest. + initial_xml -- bytes, the manifest already fetched once by the caller. + logger -- optional callable(str) for diagnostic logging. + """ + self._refresh = refresh + self._log = logger or (lambda msg: None) + self._lock = threading.Lock() + self._xml = initial_xml + self._last_request = time.monotonic() + self._stopped = threading.Event() + + outer = self + + class Handler(BaseHTTPRequestHandler): + def log_message(self, format, *args): + pass + + def _send_headers(self, length): + self.send_response(200) + self.send_header('Content-type', 'application/dash+xml') + self.send_header('Content-Length', str(length)) + self.end_headers() + + def do_HEAD(self): + with outer._lock: + body = outer._xml + self._send_headers(len(body)) + + def do_GET(self): + with outer._lock: + outer._last_request = time.monotonic() + fresh = outer._safe_refresh() + with outer._lock: + if fresh is not None: + outer._xml = fresh + body = outer._xml + self._send_headers(len(body)) + self.wfile.write(body) + + self._server = ThreadingHTTPServer(('127.0.0.1', 0), Handler) + self.port = self._server.server_port + + threading.Thread(target=self._run_server, daemon=True).start() + threading.Thread(target=self._watchdog, daemon=True).start() + + @property + def url(self): + return f"http://127.0.0.1:{self.port}/manifest.mpd" + + def _safe_refresh(self): + try: + return self._refresh() + except Exception as e: + self._log(f"ManifestProxyServer: refresh failed: {e}") + return None + + def _run_server(self): + try: + self._server.serve_forever(poll_interval=0.5) + finally: + self._server.server_close() + + def _watchdog(self): + while not self._stopped.wait(WATCHDOG_INTERVAL_SECONDS): + with self._lock: + idle = time.monotonic() - self._last_request + if idle > IDLE_TIMEOUT_SECONDS: + self._log("ManifestProxyServer: idle timeout reached, shutting down") + self.stop() + break + + def stop(self): + if self._stopped.is_set(): + return + self._stopped.set() + self._server.shutdown() + + def wait_until_playback_stops(self, player, poll_interval=1.0): + """Blocks the calling (plugin script) thread until `player` is no + longer playing our manifest URL, then stops the server. + + Must be called from the plugin script's own thread -- that's the + only thread Kodi keeps delivering playback state to after + setResolvedUrl(). + """ + # Wait for playback to start, giving up if it never does. + start = time.monotonic() + while not self._stopped.is_set(): + if player.isPlayingVideo() and player.getPlayingFile() == self.url: + break + if time.monotonic() - start > STARTUP_TIMEOUT_SECONDS: + self._log("ManifestProxyServer: playback never started, shutting down") + self.stop() + return + if self._stopped.wait(poll_interval): + return + + while not self._stopped.is_set(): + if not (player.isPlayingVideo() and player.getPlayingFile() == self.url): + break + if self._stopped.wait(poll_interval): + return + + self.stop() diff --git a/lib/play.py b/lib/play.py index 9ade2f5..19962e0 100644 --- a/lib/play.py +++ b/lib/play.py @@ -19,14 +19,25 @@ from urllib.parse import parse_qsl, ParseResult from urllib.parse import urlparse as urlps +from urllib.parse import urlparse import json +import xml.etree.ElementTree as ET + +import requests +import xbmc import xbmcgui import xbmcplugin import inputstreamhelper import utils +from manifest_proxy import ManifestProxyServer + +# Force Python to use the standard DASH/CENC/PlayReady XML tags when serializing +ET.register_namespace('', 'urn:mpeg:dash:schema:mpd:2011') +ET.register_namespace('cenc', 'urn:mpeg:cenc:2013') +ET.register_namespace('mspr', 'urn:microsoft:playready') class Player: @@ -199,9 +210,22 @@ def play_drm(self, urn, title, resource_list): self.srgssr.log("play_drm: Unable to setup drm") return - play_item = xbmcgui.ListItem( - title, path=self.srgssr.get_auth_url(resource_data["url"]) - ) + auth_url = self.srgssr.get_auth_url(resource_data["url"]) + play_item_path = auth_url + manifest_update_url = auth_url + use_local_manifest = False + + try: + xml_data, removed_any = self._fetch_filtered_manifest(resource_data["url"]) + if xml_data is not None and removed_any: + proxy = self._start_manifest_proxy(resource_data["url"], xml_data) + play_item_path = proxy.url + manifest_update_url = proxy.url + use_local_manifest = True + except Exception as e: + self.srgssr.log(f"play_drm: Error modifying manifest: {e}") + + play_item = xbmcgui.ListItem(title, path=play_item_path) ia = "inputstream.adaptive" play_item.setProperty("inputstream", ia) lic_key = ( @@ -211,4 +235,80 @@ def play_drm(self, urn, title, resource_list): play_item.setProperty(f"{ia}.manifest_type", manifest_type) play_item.setProperty(f"{ia}.license_type", drm) play_item.setProperty(f"{ia}.license_key", lic_key) + if use_local_manifest: + play_item.setProperty(f"{ia}.manifest_update_url", manifest_update_url) xbmcplugin.setResolvedUrl(self.handle, True, play_item) + + if use_local_manifest: + # Blocks until playback ends, keeping the proxy alive that long. + proxy.wait_until_playback_stops(xbmc.Player()) + + def _start_manifest_proxy(self, stream_url, initial_xml): + """Starts a ManifestProxyServer that keeps re-fetching/filtering `stream_url`.""" + + def refresh(): + xml_data, _ = self._fetch_filtered_manifest(stream_url) + return xml_data + + return ManifestProxyServer(refresh, initial_xml, logger=self.srgssr.log) + + def _fetch_filtered_manifest(self, stream_url): + """Fetches the manifest for `stream_url`, rewrites its BaseURL to point + directly at the origin, and strips out trickmode tracks. + + Returns a (xml_bytes, removed_any) tuple. xml_bytes is None if the + manifest could not be fetched or parsed. + """ + auth_url = self.srgssr.get_auth_url(stream_url, notify_on_error=False) + manifest_content = self._quiet_fetch(auth_url) + if not manifest_content: + return None, False + + root = ET.fromstring(manifest_content.encode('utf-8')) + ns = {'mpd': 'urn:mpeg:dash:schema:mpd:2011'} + + # This points Kodi to the remote server for the actual video segments + base_elem = ET.Element('{urn:mpeg:dash:schema:mpd:2011}BaseURL') + parsed_url = urlparse(auth_url) + base_elem.text = f"{parsed_url.scheme}://{parsed_url.netloc}{parsed_url.path.rsplit('/', 1)[0]}/" + root.insert(0, base_elem) + + periods = root.findall('.//mpd:Period', ns) or root.findall('.//Period') + removed_any = False + + # Strip out the trickmode tracks completely + for period in periods: + adaptation_sets = period.findall('mpd:AdaptationSet', ns) or period.findall('AdaptationSet') + for aset in adaptation_sets: + is_trickmode = False + for prop in aset.findall('mpd:EssentialProperty', ns) or aset.findall('EssentialProperty'): + if 'trickmode' in prop.get('schemeIdUri', ''): + is_trickmode = True + for prop in aset.findall('mpd:SupplementalProperty', ns) or aset.findall('SupplementalProperty'): + if 'trickmode' in prop.get('schemeIdUri', ''): + is_trickmode = True + if is_trickmode: + period.remove(aset) + removed_any = True + + return ET.tostring(root, encoding='utf-8'), removed_any + + def _quiet_fetch(self, url): + """Like srgssr.open_url(use_cache=False), but without a UI notification + on failure -- used for background manifest refreshes during playback.""" + try: + headers = { + "User-Agent": ( + "Mozilla/5.0 (X11; Linux x86_64; rv:136.0) " + "Gecko/20100101 Firefox/136.0" + ) + } + response = requests.get(url, headers=headers, timeout=10) + if not response.ok: + self.srgssr.log(f"_quiet_fetch: {url} returned status {response.status_code}") + return "" + response.encoding = "UTF-8" + return response.text + except requests.RequestException as e: + self.srgssr.log(f"_quiet_fetch: failed to fetch {url}: {e}") + return "" diff --git a/lib/srgssr.py b/lib/srgssr.py index 1447c29..6f9ffe1 100644 --- a/lib/srgssr.py +++ b/lib/srgssr.py @@ -167,13 +167,15 @@ def build_url(mode=None, name=None, url=None, page_hash=None, page=None): added = True return purl - def open_url(self, url, use_cache=True): + def open_url(self, url, use_cache=True, notify_on_error=True): """Open and read the content given by a URL. Keyword arguments: - url -- the URL to open as a string - use_cache -- boolean to indicate if the cache provided by the - Kodi module SimpleCache should be used (default: True) + url -- the URL to open as a string + use_cache -- boolean to indicate if the cache provided by the + Kodi module SimpleCache should be used (default: True) + notify_on_error -- boolean to indicate if a UI notification should be + shown on failure (default: True) """ self.log("open_url, url = " + str(url)) cache_response = ( @@ -189,7 +191,8 @@ def open_url(self, url, use_cache=True): response = requests.get(url, headers=headers) if not response.ok: self.log(f"open_url: Failed to open url {url}") - xbmcgui.Dialog().notification(ADDON_NAME, LANGUAGE(30100), ICON, 4000) + if notify_on_error: + xbmcgui.Dialog().notification(ADDON_NAME, LANGUAGE(30100), ICON, 4000) return "" response.encoding = "UTF-8" self.cache.set( @@ -220,12 +223,14 @@ def read_all_available_shows(self): data = json.loads(self.open_url(self.apiv3_url + "shows")) return utils.try_get(data, "data", list, []) - def get_auth_url(self, url, segment_data=None): + def get_auth_url(self, url, segment_data=None, notify_on_error=True): """ Returns the authenticated URL from a given stream URL. Keyword arguments: - url -- a given stream URL + url -- a given stream URL + notify_on_error -- boolean to indicate if a UI notification should be + shown if the token request fails (default: True) """ self.log(f"get_auth_url, url = {url}") spl = urlps(url).path.split("/") @@ -234,6 +239,7 @@ def get_auth_url(self, url, segment_data=None): self.open_url( f"https://tp.srgssr.ch/akahd/token?acl=/{spl[1]}/{spl[2]}/*", use_cache=False, + notify_on_error=notify_on_error, ) ) or {} From d006c2deb9595d662e393d1ca85d57de45138b67 Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Fri, 21 Aug 2026 14:27:31 +0200 Subject: [PATCH 03/16] Add Rumantsch Grischun (rm_CH) date strings --- .../resource.language.rm_ch/strings.po | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 resources/language/resource.language.rm_ch/strings.po diff --git a/resources/language/resource.language.rm_ch/strings.po b/resources/language/resource.language.rm_ch/strings.po new file mode 100644 index 0000000..5b9dce3 --- /dev/null +++ b/resources/language/resource.language.rm_ch/strings.po @@ -0,0 +1,46 @@ +# Kodi Media Center language file +# Addon Name: script.module.srgssr +# Addon Provider: Alexander Seiler +msgid "" +msgstr "" +"Project-Id-Version: script.module.srgssr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: rm\n" + +msgctxt "#30058" +msgid "Today" +msgstr "Oz" + +msgctxt "#30059" +msgid "Yesterday" +msgstr "Ier" + +msgctxt "#30060" +msgid "Monday" +msgstr "Glindesdi" + +msgctxt "#30061" +msgid "Tuesday" +msgstr "Mardi" + +msgctxt "#30062" +msgid "Wednesday" +msgstr "Mesemna" + +msgctxt "#30063" +msgid "Thursday" +msgstr "Gievgia" + +msgctxt "#30064" +msgid "Friday" +msgstr "Venderdi" + +msgctxt "#30065" +msgid "Saturday" +msgstr "Sonda" + +msgctxt "#30066" +msgid "Sunday" +msgstr "Dumengia" From 0da92aa8a63adcfcfa338057f9cf09f4f0143468 Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Fri, 21 Aug 2026 14:23:22 +0200 Subject: [PATCH 04/16] Centralize Live TV sub-menu folder labels in shared library --- resources/language/resource.language.de_de/strings.po | 9 ++++++++- resources/language/resource.language.en_gb/strings.po | 9 ++++++++- resources/language/resource.language.fr_fr/strings.po | 9 ++++++++- resources/language/resource.language.it_it/strings.po | 9 ++++++++- resources/language/resource.language.rm_ch/strings.po | 8 ++++++++ 5 files changed, 40 insertions(+), 4 deletions(-) diff --git a/resources/language/resource.language.de_de/strings.po b/resources/language/resource.language.de_de/strings.po index e591952..5b65ccd 100644 --- a/resources/language/resource.language.de_de/strings.po +++ b/resources/language/resource.language.de_de/strings.po @@ -105,4 +105,11 @@ msgstr "Kürzlich gesuchte Audios" msgctxt "#30118" msgid "Recently searched shows" -msgstr "Kürzlich gesuchte Sendungen" \ No newline at end of file +msgstr "Kürzlich gesuchte Sendungen" +msgctxt "#30101" +msgid "Sports Live" +msgstr "Sport Live" + +msgctxt "#30102" +msgid "Other Live Streams" +msgstr "Andere Live-Streams" diff --git a/resources/language/resource.language.en_gb/strings.po b/resources/language/resource.language.en_gb/strings.po index e81a1cc..3fd66da 100644 --- a/resources/language/resource.language.en_gb/strings.po +++ b/resources/language/resource.language.en_gb/strings.po @@ -105,4 +105,11 @@ msgstr "" msgctxt "#30118" msgid "Recently searched shows" -msgstr "" \ No newline at end of file +msgstr "" +msgctxt "#30101" +msgid "Sports Live" +msgstr "Sports Live" + +msgctxt "#30102" +msgid "Other Live Streams" +msgstr "Other Live Streams" diff --git a/resources/language/resource.language.fr_fr/strings.po b/resources/language/resource.language.fr_fr/strings.po index 5130d7d..d54d7f8 100644 --- a/resources/language/resource.language.fr_fr/strings.po +++ b/resources/language/resource.language.fr_fr/strings.po @@ -105,4 +105,11 @@ msgstr "Audios récemment recherchées" msgctxt "#30118" msgid "Recently searched shows" -msgstr "Émissions récemment recherchées" \ No newline at end of file +msgstr "Émissions récemment recherchées" +msgctxt "#30101" +msgid "Sports Live" +msgstr "Sports en direct" + +msgctxt "#30102" +msgid "Other Live Streams" +msgstr "Les autres directs" diff --git a/resources/language/resource.language.it_it/strings.po b/resources/language/resource.language.it_it/strings.po index f1c8937..543d14b 100644 --- a/resources/language/resource.language.it_it/strings.po +++ b/resources/language/resource.language.it_it/strings.po @@ -105,4 +105,11 @@ msgstr "Audios cercato di recente" msgctxt "#30118" msgid "Recently searched shows" -msgstr "Show cercato di recente" \ No newline at end of file +msgstr "Show cercato di recente" +msgctxt "#30101" +msgid "Sports Live" +msgstr "Sport Live" + +msgctxt "#30102" +msgid "Other Live Streams" +msgstr "Altri flussi in diretta" diff --git a/resources/language/resource.language.rm_ch/strings.po b/resources/language/resource.language.rm_ch/strings.po index 5b9dce3..91453cf 100644 --- a/resources/language/resource.language.rm_ch/strings.po +++ b/resources/language/resource.language.rm_ch/strings.po @@ -44,3 +44,11 @@ msgstr "Sonda" msgctxt "#30066" msgid "Sunday" msgstr "Dumengia" + +msgctxt "#30101" +msgid "Sports Live" +msgstr "Sport Live" + +msgctxt "#30102" +msgid "Other Live Streams" +msgstr "Auters Live Streams" From 32b9804fd61a90c25e1942d0b17567b02a0bc19c Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Fri, 21 Aug 2026 18:48:44 +0200 Subject: [PATCH 05/16] Fix flake8 errors E501 and E303 --- lib/manifest_proxy.py | 16 +++-- lib/menus.py | 152 +++++++++++++++++++++++++++++------------- lib/play.py | 78 ++++++++++++++++------ lib/srgssr.py | 20 ++++-- lib/storage.py | 4 +- lib/utils.py | 16 +++-- lib/youtube.py | 24 +++++-- 7 files changed, 223 insertions(+), 87 deletions(-) diff --git a/lib/manifest_proxy.py b/lib/manifest_proxy.py index 2736fec..4e8c76d 100644 --- a/lib/manifest_proxy.py +++ b/lib/manifest_proxy.py @@ -28,7 +28,8 @@ class ManifestProxyServer: - """Serves a locally-filtered DASH manifest, re-fetching it on every request. + """Serves a locally-filtered DASH manifest, re-fetching it on every + request. This keeps the manifest live-updated for the whole playback session, and ties its own lifetime to actual playback (stopped/ended/error) instead of a @@ -110,7 +111,9 @@ def _watchdog(self): with self._lock: idle = time.monotonic() - self._last_request if idle > IDLE_TIMEOUT_SECONDS: - self._log("ManifestProxyServer: idle timeout reached, shutting down") + self._log( + "ManifestProxyServer: idle timeout reached, shutting down" + ) self.stop() break @@ -134,14 +137,19 @@ def wait_until_playback_stops(self, player, poll_interval=1.0): if player.isPlayingVideo() and player.getPlayingFile() == self.url: break if time.monotonic() - start > STARTUP_TIMEOUT_SECONDS: - self._log("ManifestProxyServer: playback never started, shutting down") + self._log( + "ManifestProxyServer: playback never started, " + "shutting down" + ) self.stop() return if self._stopped.wait(poll_interval): return while not self._stopped.is_set(): - if not (player.isPlayingVideo() and player.getPlayingFile() == self.url): + if not ( + player.isPlayingVideo() and player.getPlayingFile() == self.url + ): break if self._stopped.wait(poll_interval): return diff --git a/lib/menus.py b/lib/menus.py index 852a335..13c1094 100644 --- a/lib/menus.py +++ b/lib/menus.py @@ -46,7 +46,9 @@ def build_main_menu(self, identifiers=[]): self.srgssr.log("build_main_menu") def display_item(item): - return item in identifiers and self.srgssr.get_boolean_setting(item) + return item in identifiers and self.srgssr.get_boolean_setting( + item + ) main_menu_list = [ { @@ -126,11 +128,17 @@ def display_item(item): "identifier": f"{self.srgssr.bu.upper()}_YouTube", "name": self.srgssr.plugin_language(30074), "mode": 30, - "displayItem": display_item(f"{self.srgssr.bu.upper()}_YouTube"), + "displayItem": display_item( + f"{self.srgssr.bu.upper()}_YouTube" + ), "icon": self.srgssr.get_youtube_icon(), }, ] - folders = [item for item in main_menu_list if item["identifier"] in identifiers] + folders = [ + item + for item in main_menu_list + if item["identifier"] in identifiers + ] self.build_folder_menu(folders) def build_folder_menu(self, folders): @@ -143,13 +151,18 @@ def build_folder_menu(self, folders): if item.get("displayItem"): list_item = xbmcgui.ListItem(label=item["name"]) list_item.setProperty("IsPlayable", "false") - list_item.setArt({"thumb": item["icon"], "fanart": self.srgssr.fanart}) + list_item.setArt( + {"thumb": item["icon"], "fanart": self.srgssr.fanart} + ) purl_dict = item.get("purl", {}) mode = purl_dict.get("mode") or item.get("mode") uname = purl_dict.get("name") or item.get("identifier") purl = self.srgssr.build_url(mode=mode, name=uname) xbmcplugin.addDirectoryItem( - handle=self.handle, url=purl, listitem=list_item, isFolder=True + handle=self.handle, + url=purl, + listitem=list_item, + isFolder=True, ) def build_menu_apiv3( @@ -178,7 +191,9 @@ def build_menu_apiv3( # Build a combined and sorted list for several queries items = [] for query in queries: - data = json.loads(self.srgssr.open_url(self.srgssr.apiv3_url + query)) + data = json.loads( + self.srgssr.open_url(self.srgssr.apiv3_url + query) + ) if data: data = ( utils.try_get(data, ["data", "data"], list, []) @@ -206,8 +221,12 @@ def build_menu_apiv3( url = f"{self.srgssr.apiv3_url}{queries}{symb}next={cursor}" data = json.loads(self.srgssr.open_url(url)) else: - data = json.loads(self.srgssr.open_url(self.srgssr.apiv3_url + queries)) - cursor = utils.try_get(data, "next") or utils.try_get(data, ["data", "next"]) + data = json.loads( + self.srgssr.open_url(self.srgssr.apiv3_url + queries) + ) + cursor = utils.try_get(data, "next") or utils.try_get( + data, ["data", "next"] + ) try: data = data["data"] @@ -223,7 +242,9 @@ def build_menu_apiv3( ) for item in items: - self.build_entry_apiv3(item, is_show=is_show, whitelist_ids=whitelist_ids) + self.build_entry_apiv3( + item, is_show=is_show, whitelist_ids=whitelist_ids + ) if cursor: if page in (0, "0"): @@ -238,7 +259,10 @@ def build_menu_apiv3( if page: url = self.srgssr.build_url( - mode=mode, name=queries, page=int(page) + 1, page_hash=cursor + mode=mode, + name=queries, + page=int(page) + 1, + page_hash=cursor, ) else: url = self.srgssr.build_url( @@ -249,7 +273,9 @@ def build_menu_apiv3( label=">> " + self.srgssr.language(30073) ) # Next page next_item.setProperty("IsPlayable", "false") - xbmcplugin.addDirectoryItem(self.handle, url, next_item, isFolder=True) + xbmcplugin.addDirectoryItem( + self.handle, url, next_item, isFolder=True + ) def build_all_shows_menu(self, favids=None): """ @@ -333,7 +359,9 @@ def build_menu_from_page(self, url, path): return data = utils.try_get(js, path, list, []) if not data: - self.srgssr.log("build_menu_from_page: Could not find any data in json") + self.srgssr.log( + "build_menu_from_page: Could not find any data in json" + ) return for elem in data: try: @@ -391,7 +419,9 @@ def build_episode_menu( segment_option -- Which segment option to use. (default: False) """ - self.srgssr.log(f"build_episode_menu, video_id_or_urn = {video_id_or_urn}") + self.srgssr.log( + f"build_episode_menu, video_id_or_urn = {video_id_or_urn}" + ) if ":" in video_id_or_urn: json_url = ( "https://il.srgssr.ch/integrationlayer/2.0/" @@ -424,14 +454,14 @@ def build_episode_menu( segment_id = segment_urn.split(":")[-1] if segment_urn else None if not chapter_id: - self.srgssr.log( - f"build_episode_menu: No valid chapter URN \ - available for video_id {video_id}" - ) + self.srgssr.log(f"build_episode_menu: No valid chapter URN \ + available for video_id {video_id}") return show_image_url = utils.try_get(json_response, ["show", "imageUrl"]) - show_poster_image_url = utils.try_get(json_response, ["show", "posterImageUrl"]) + show_poster_image_url = utils.try_get( + json_response, ["show", "posterImageUrl"] + ) json_chapter_list = utils.try_get( json_response, "chapterList", data_type=list, default=[] @@ -442,10 +472,8 @@ def build_episode_menu( json_chapter = chapter break if not json_chapter: - self.srgssr.log( - f"build_episode_menu: No chapter ID found \ - for video_id {video_id}" - ) + self.srgssr.log(f"build_episode_menu: No chapter ID found \ + for video_id {video_id}") return # TODO: Simplify @@ -491,10 +519,8 @@ def build_episode_menu( json_segment = segment break if not json_segment: - self.srgssr.log( - f"build_episode_menu: No segment ID found \ - for video_id {video_id}" - ) + self.srgssr.log(f"build_episode_menu: No segment ID found \ + for video_id {video_id}") return # Generate a simple playable item for the video self.build_entry( @@ -555,11 +581,17 @@ def build_entry_apiv3(self, data, is_show=False, whitelist_ids=None): ) if is_show: poster = ( - show_poster_image_url or poster_image_url or show_image_url or image_url + show_poster_image_url + or poster_image_url + or show_image_url + or image_url ) else: poster = ( - image_url or poster_image_url or show_poster_image_url or show_image_url + image_url + or poster_image_url + or show_poster_image_url + or show_image_url ) list_item.setArt( { @@ -572,7 +604,9 @@ def build_entry_apiv3(self, data, is_show=False, whitelist_ids=None): url = self.srgssr.build_url(mode=100, name=urn) is_folder = True - xbmcplugin.addDirectoryItem(self.handle, url, list_item, isFolder=is_folder) + xbmcplugin.addDirectoryItem( + self.handle, url, list_item, isFolder=is_folder + ) def build_menu_by_urn(self, urn): """ @@ -641,11 +675,15 @@ def build_entry( # This needs to be removed from the URL: image_url = re.sub(r"/\d+x\d+", "", image_url) - duration = utils.try_get(json_entry, "duration", data_type=int, default=None) + duration = utils.try_get( + json_entry, "duration", data_type=int, default=None + ) if duration: duration = duration // 1000 else: - duration = utils.get_duration(utils.try_get(json_entry, "duration")) + duration = utils.get_duration( + utils.try_get(json_entry, "duration") + ) date_string = utils.try_get(json_entry, "date") dto = utils.parse_datetime(date_string) @@ -667,7 +705,10 @@ def build_entry( fanart = image_url poster = ( - image_url or poster_image_url or show_poster_image_url or show_image_url + image_url + or poster_image_url + or show_poster_image_url + or show_image_url ) list_item.setArt( { @@ -678,7 +719,9 @@ def build_entry( } ) - subs = utils.try_get(json_entry, "subtitleList", data_type=list, default=[]) + subs = utils.try_get( + json_entry, "subtitleList", data_type=list, default=[] + ) if subs: subtitle_list = [ utils.try_get(x, "url") @@ -688,7 +731,9 @@ def build_entry( if subtitle_list: list_item.setSubtitles(subtitle_list) else: - self.srgssr.log(f"No WEBVTT subtitles found for video id {vid}.") + self.srgssr.log( + f"No WEBVTT subtitles found for video id {vid}." + ) # TODO: # Prefer urn over vid as it contains already all data @@ -706,7 +751,9 @@ def build_entry( url = self.srgssr.build_url(mode=50, name=urn) else: url = self.srgssr.build_url(mode=50, name=name) - xbmcplugin.addDirectoryItem(self.handle, url, list_item, isFolder=is_folder) + xbmcplugin.addDirectoryItem( + self.handle, url, list_item, isFolder=is_folder + ) def build_dates_overview_menu(self): """ @@ -737,7 +784,10 @@ def folder_name(dato): elif dato == today + datetime.timedelta(-1): name = self.srgssr.language(30059) # Yesterday else: - name = "%s, %s" % (weekdays[dato.weekday()], dato.strftime("%d.%m.%Y")) + name = "%s, %s" % ( + weekdays[dato.weekday()], + dato.strftime("%d.%m.%Y"), + ) return name current_date = datetime.date.today() @@ -746,15 +796,21 @@ def folder_name(dato): for i in range(number_of_days): dato = current_date + datetime.timedelta(-i) list_item = xbmcgui.ListItem(label=folder_name(dato)) - list_item.setArt({"thumb": self.srgssr.icon, "fanart": self.srgssr.fanart}) + list_item.setArt( + {"thumb": self.srgssr.icon, "fanart": self.srgssr.fanart} + ) name = dato.strftime("%d-%m-%Y") purl = self.srgssr.build_url(mode=24, name=name) xbmcplugin.addDirectoryItem( handle=self.handle, url=purl, listitem=list_item, isFolder=True ) - choose_item = xbmcgui.ListItem(label=self.srgssr.language(30071)) # Choose date - choose_item.setArt({"thumb": self.srgssr.icon, "fanart": self.srgssr.fanart}) + choose_item = xbmcgui.ListItem( + label=self.srgssr.language(30071) + ) # Choose date + choose_item.setArt( + {"thumb": self.srgssr.icon, "fanart": self.srgssr.fanart} + ) purl = self.srgssr.build_url(mode=25) xbmcplugin.addDirectoryItem( handle=self.handle, url=purl, listitem=choose_item, isFolder=True @@ -810,7 +866,9 @@ def build_date_menu(self, date_string): for item in data: if not isinstance(item, dict): continue - channel = utils.try_get(item, "channel", data_type=dict, default={}) + channel = utils.try_get( + item, "channel", data_type=dict, default={} + ) name = utils.try_get(channel, "title") if not name: continue @@ -820,7 +878,9 @@ def build_date_menu(self, date_string): list_item.setArt({"thumb": image, "fanart": image}) channel_date_id = name.replace(" ", "-") + "_" + date_string cache_id = self.srgssr.addon_id + "." + channel_date_id - programs = utils.try_get(item, "programList", data_type=list, default=[]) + programs = utils.try_get( + item, "programList", data_type=list, default=[] + ) self.srgssr.cache.set(cache_id, programs) self.srgssr.log(f"build_date_menu: Cache set with id = {cache_id}") url = self.srgssr.build_url(mode=mode, name=cache_id) @@ -885,7 +945,9 @@ def build_search_menu(self): continue list_item = xbmcgui.ListItem(label=item["name"]) list_item.setProperty("IsPlayable", "false") - list_item.setArt({"thumb": item["icon"], "fanart": self.srgssr.fanart}) + list_item.setArt( + {"thumb": item["icon"], "fanart": self.srgssr.fanart} + ) url = self.srgssr.build_url(item["mode"]) xbmcplugin.addDirectoryItem( handle=self.handle, url=url, listitem=list_item, isFolder=True @@ -921,10 +983,8 @@ def build_search_media_menu(self, mode=28, name="", page=1, page_hash=""): page_hash -- the page hash when coming from a previous page (default: '') """ - self.srgssr.log( - f"build_search_media_menu, mode = {mode}, \ - name = {name}, page = {page}, page_hash = {page_hash}" - ) + self.srgssr.log(f"build_search_media_menu, mode = {mode}, \ + name = {name}, page = {page}, page_hash = {page_hash}") media_type = "video" if name: # `name` is provided by `next_page` folder or diff --git a/lib/play.py b/lib/play.py index 19962e0..f57799c 100644 --- a/lib/play.py +++ b/lib/play.py @@ -34,7 +34,8 @@ import utils from manifest_proxy import ManifestProxyServer -# Force Python to use the standard DASH/CENC/PlayReady XML tags when serializing +# Force Python to use the standard DASH/CENC/PlayReady XML tags when +# serializing ET.register_namespace('', 'urn:mpeg:dash:schema:mpd:2011') ET.register_namespace('cenc', 'urn:mpeg:cenc:2013') ET.register_namespace('mspr', 'urn:microsoft:playready') @@ -62,10 +63,13 @@ def play_video(self, media_id_or_urn): media_type = "video" urn = f"urn:{self.srgssr.bu}:{media_type}:{media_id_or_urn}" media_id = media_id_or_urn - self.srgssr.log("play_video, urn = " + urn + ", media_id = " + media_id) + self.srgssr.log( + "play_video, urn = " + urn + ", media_id = " + media_id + ) detail_url = ( - "https://il.srgssr.ch/integrationlayer/2.0/mediaComposition/byUrn/" + urn + "https://il.srgssr.ch/integrationlayer/2.0/mediaComposition/byUrn/" + + urn ) json_response = json.loads(self.srgssr.open_url(detail_url)) title = utils.try_get(json_response, ["episode", "title"], str, urn) @@ -74,10 +78,14 @@ def play_video(self, media_id_or_urn): json_response, "chapterList", data_type=list, default=[] ) if not chapter_list: - self.srgssr.log("play_video: no stream URL found (chapterList empty).") + self.srgssr.log( + "play_video: no stream URL found (chapterList empty)." + ) return - first_chapter = utils.try_get(chapter_list, 0, data_type=dict, default={}) + first_chapter = utils.try_get( + chapter_list, 0, data_type=dict, default={} + ) chapter = next( (e for e in chapter_list if e.get("id") == media_id), first_chapter ) @@ -85,7 +93,9 @@ def play_video(self, media_id_or_urn): chapter, "resourceList", data_type=list, default=[] ) if not resource_list: - self.srgssr.log("play_video: no stream URL found. (resourceList empty)") + self.srgssr.log( + "play_video: no stream URL found. (resourceList empty)" + ) return stream_urls = { @@ -115,7 +125,8 @@ def play_video(self, media_id_or_urn): stream_url = ( stream_urls["HD"] - if (stream_urls["HD"] and self.srgssr.prefer_hd) or not stream_urls["SD"] + if (stream_urls["HD"] and self.srgssr.prefer_hd) + or not stream_urls["SD"] else stream_urls["SD"] ) self.srgssr.log(f"play_video, stream_url = {stream_url}") @@ -190,7 +201,9 @@ def play_drm(self, urn, title, resource_list): quality = utils.try_get(resource, "quality") lic_url = "" if utils.try_get(resource, "protocol") == "DASH": - drmlist = utils.try_get(resource, "drmList", data_type=list, default=[]) + drmlist = utils.try_get( + resource, "drmList", data_type=list, default=[] + ) for item in drmlist: if utils.try_get(item, "type") == "WIDEVINE": lic_url = utils.try_get(item, "licenseUrl") @@ -216,9 +229,13 @@ def play_drm(self, urn, title, resource_list): use_local_manifest = False try: - xml_data, removed_any = self._fetch_filtered_manifest(resource_data["url"]) + xml_data, removed_any = self._fetch_filtered_manifest( + resource_data["url"] + ) if xml_data is not None and removed_any: - proxy = self._start_manifest_proxy(resource_data["url"], xml_data) + proxy = self._start_manifest_proxy( + resource_data["url"], xml_data + ) play_item_path = proxy.url manifest_update_url = proxy.url use_local_manifest = True @@ -236,7 +253,9 @@ def play_drm(self, urn, title, resource_list): play_item.setProperty(f"{ia}.license_type", drm) play_item.setProperty(f"{ia}.license_key", lic_key) if use_local_manifest: - play_item.setProperty(f"{ia}.manifest_update_url", manifest_update_url) + play_item.setProperty( + f"{ia}.manifest_update_url", manifest_update_url + ) xbmcplugin.setResolvedUrl(self.handle, True, play_item) if use_local_manifest: @@ -244,13 +263,17 @@ def play_drm(self, urn, title, resource_list): proxy.wait_until_playback_stops(xbmc.Player()) def _start_manifest_proxy(self, stream_url, initial_xml): - """Starts a ManifestProxyServer that keeps re-fetching/filtering `stream_url`.""" + """Starts a ManifestProxyServer that keeps re-fetching/filtering + `stream_url`. + """ def refresh(): xml_data, _ = self._fetch_filtered_manifest(stream_url) return xml_data - return ManifestProxyServer(refresh, initial_xml, logger=self.srgssr.log) + return ManifestProxyServer( + refresh, initial_xml, logger=self.srgssr.log + ) def _fetch_filtered_manifest(self, stream_url): """Fetches the manifest for `stream_url`, rewrites its BaseURL to point @@ -270,21 +293,32 @@ def _fetch_filtered_manifest(self, stream_url): # This points Kodi to the remote server for the actual video segments base_elem = ET.Element('{urn:mpeg:dash:schema:mpd:2011}BaseURL') parsed_url = urlparse(auth_url) - base_elem.text = f"{parsed_url.scheme}://{parsed_url.netloc}{parsed_url.path.rsplit('/', 1)[0]}/" + base_path = parsed_url.path.rsplit('/', 1)[0] + base_elem.text = ( + f"{parsed_url.scheme}://{parsed_url.netloc}{base_path}/" + ) root.insert(0, base_elem) - periods = root.findall('.//mpd:Period', ns) or root.findall('.//Period') + periods = root.findall('.//mpd:Period', ns) or root.findall( + './/Period' + ) removed_any = False # Strip out the trickmode tracks completely for period in periods: - adaptation_sets = period.findall('mpd:AdaptationSet', ns) or period.findall('AdaptationSet') + adaptation_sets = period.findall( + 'mpd:AdaptationSet', ns + ) or period.findall('AdaptationSet') for aset in adaptation_sets: is_trickmode = False - for prop in aset.findall('mpd:EssentialProperty', ns) or aset.findall('EssentialProperty'): + for prop in aset.findall( + 'mpd:EssentialProperty', ns + ) or aset.findall('EssentialProperty'): if 'trickmode' in prop.get('schemeIdUri', ''): is_trickmode = True - for prop in aset.findall('mpd:SupplementalProperty', ns) or aset.findall('SupplementalProperty'): + for prop in aset.findall( + 'mpd:SupplementalProperty', ns + ) or aset.findall('SupplementalProperty'): if 'trickmode' in prop.get('schemeIdUri', ''): is_trickmode = True if is_trickmode: @@ -295,7 +329,8 @@ def _fetch_filtered_manifest(self, stream_url): def _quiet_fetch(self, url): """Like srgssr.open_url(use_cache=False), but without a UI notification - on failure -- used for background manifest refreshes during playback.""" + on failure -- used for background manifest refreshes during playback. + """ try: headers = { "User-Agent": ( @@ -305,7 +340,10 @@ def _quiet_fetch(self, url): } response = requests.get(url, headers=headers, timeout=10) if not response.ok: - self.srgssr.log(f"_quiet_fetch: {url} returned status {response.status_code}") + self.srgssr.log( + "_quiet_fetch: " + f"{url} returned status {response.status_code}" + ) return "" response.encoding = "UTF-8" return response.text diff --git a/lib/srgssr.py b/lib/srgssr.py index 6f9ffe1..190dc47 100644 --- a/lib/srgssr.py +++ b/lib/srgssr.py @@ -88,7 +88,9 @@ def __init__(self, plugin_handle, bu="srf", addon_id=ADDON_ID): self.apiv3_url = f"{self.host_url}/play/v3/api/{bu}/production/" self.data_regex = r"window.__remixContext\s*=\s*(.+?);\s*" self.data_uri = f"special://home/addons/{self.addon_id}/resources/data" - self.media_uri = f"special://home/addons/{self.addon_id}/resources/media" + self.media_uri = ( + f"special://home/addons/{self.addon_id}/resources/media" + ) # Plugin options self.debug = self.get_boolean_setting("Enable_Debugging") @@ -172,14 +174,17 @@ def open_url(self, url, use_cache=True, notify_on_error=True): Keyword arguments: url -- the URL to open as a string - use_cache -- boolean to indicate if the cache provided by the - Kodi module SimpleCache should be used (default: True) + use_cache -- boolean to indicate if the cache provided by + the Kodi module SimpleCache should be used + (default: True) notify_on_error -- boolean to indicate if a UI notification should be shown on failure (default: True) """ self.log("open_url, url = " + str(url)) cache_response = ( - self.cache.get(f"{ADDON_NAME}.open_url, url = {url}") if use_cache else None + self.cache.get(f"{ADDON_NAME}.open_url, url = {url}") + if use_cache + else None ) if not cache_response: headers = { @@ -192,7 +197,9 @@ def open_url(self, url, use_cache=True, notify_on_error=True): if not response.ok: self.log(f"open_url: Failed to open url {url}") if notify_on_error: - xbmcgui.Dialog().notification(ADDON_NAME, LANGUAGE(30100), ICON, 4000) + xbmcgui.Dialog().notification( + ADDON_NAME, LANGUAGE(30100), ICON, 4000 + ) return "" response.encoding = "UTF-8" self.cache.set( @@ -237,7 +244,8 @@ def get_auth_url(self, url, segment_data=None, notify_on_error=True): token = ( json.loads( self.open_url( - f"https://tp.srgssr.ch/akahd/token?acl=/{spl[1]}/{spl[2]}/*", + "https://tp.srgssr.ch/akahd/token" + f"?acl=/{spl[1]}/{spl[2]}/*", use_cache=False, notify_on_error=notify_on_error, ) diff --git a/lib/storage.py b/lib/storage.py index 895e4e7..1df502f 100644 --- a/lib/storage.py +++ b/lib/storage.py @@ -63,7 +63,9 @@ def write_favourite_show_ids(self, show_ids): show_ids -- a list of show ids (as strings) """ show_ids_dict_list = [{"id": show_id} for show_id in show_ids] - file_path = os.path.join(self.profile_path, self.srgssr.fname_favourite_shows) + file_path = os.path.join( + self.profile_path, self.srgssr.fname_favourite_shows + ) if not os.path.exists(self.profile_path): os.makedirs(self.profile_path) with open(file_path, "w") as f: diff --git a/lib/utils.py b/lib/utils.py index 8d7ebfe..7b5f832 100644 --- a/lib/utils.py +++ b/lib/utils.py @@ -59,7 +59,9 @@ def assemble_query_string(query_list): """ if sys.version_info[0] >= 3: return "&".join(["{}={}".format(k, v) for (k, v) in query_list]) - return "&".join(["{}={}".decode("utf-8").format(k, v) for (k, v) in query_list]) + return "&".join( + ["{}={}".decode("utf-8").format(k, v) for (k, v) in query_list] + ) def str_or_none(inp, default=None): @@ -161,7 +163,9 @@ def _parse_date_time_tz(input_string): hour = int(dts[11:13]) minute = int(dts[14:16]) second = int(dts[17:19]) - date_time = datetime.datetime(year, month, day, hour, minute, second) + date_time = datetime.datetime( + year, month, day, hour, minute, second + ) return date_time except ValueError: return None @@ -268,7 +272,9 @@ def _parse_weekday_time(input_string): # wdl = [x for x in weekdays if input_string.startswith(x)] for key in list(identifiers.keys()): wdl = [ - x for x in identifiers[key] if re.match(x, input_string, re.IGNORECASE) + x + for x in identifiers[key] + if re.match(x, input_string, re.IGNORECASE) ] lang = key if wdl: @@ -340,7 +346,9 @@ def _parse_date_time(input_string): return None try: second = int(full_date_match.group("second")) - date_time = datetime.datetime(year, month, day, hour, minute, second) + date_time = datetime.datetime( + year, month, day, hour, minute, second + ) return date_time except (ValueError, TypeError): return date_time diff --git a/lib/youtube.py b/lib/youtube.py index 7416ad2..adc1519 100644 --- a/lib/youtube.py +++ b/lib/youtube.py @@ -40,7 +40,9 @@ def _read_youtube_channels(self, fname): Keyword arguments: fname -- the path to the file to be read """ - data_file = os.path.join(xbmcvfs.translatePath(self.srgssr.data_uri), fname) + data_file = os.path.join( + xbmcvfs.translatePath(self.srgssr.data_uri), fname + ) with open(data_file, "r", encoding="utf-8") as f: ch_content = json.load(f) cids = [elem["channel"] for elem in ch_content.get("channels", [])] @@ -88,7 +90,9 @@ def build_youtube_main_menu(self): } ) purl = self.srgssr.build_url(mode=item["mode"]) - xbmcplugin.addDirectoryItem(self.handle, purl, list_item, isFolder=True) + xbmcplugin.addDirectoryItem( + self.handle, purl, list_item, isFolder=True + ) def build_youtube_channel_overview_menu(self, mode): """ @@ -130,12 +134,16 @@ def build_youtube_channel_menu(self, cid, mode, page=1, page_token=""): self.handle, channel_ids, self.srgssr.addon_id, self.srgssr.debug ).build_channel_menu(cid, page_token=page_token) if next_page_token: - next_item = xbmcgui.ListItem(label=">> " + self.srgssr.language(30073)) + next_item = xbmcgui.ListItem( + label=">> " + self.srgssr.language(30073) + ) next_url = self.srgssr.build_url( mode=mode, name=cid, page_hash=next_page_token ) next_item.setProperty("IsPlayable", "false") - xbmcplugin.addDirectoryItem(self.handle, next_url, next_item, isFolder=True) + xbmcplugin.addDirectoryItem( + self.handle, next_url, next_item, isFolder=True + ) def build_youtube_newest_videos_menu(self, mode, page=1): """ @@ -157,7 +165,11 @@ def build_youtube_newest_videos_menu(self, mode, page=1): self.handle, channel_ids, self.srgssr.addon_id, self.srgssr.debug ).build_newest_videos(page=page) if next_page: - next_item = xbmcgui.ListItem(label=">> " + self.srgssr.language(30073)) + next_item = xbmcgui.ListItem( + label=">> " + self.srgssr.language(30073) + ) next_url = self.srgssr.build_url(mode=mode, page=next_page) next_item.setProperty("IsPlayable", "false") - xbmcplugin.addDirectoryItem(self.handle, next_url, next_item, isFolder=True) + xbmcplugin.addDirectoryItem( + self.handle, next_url, next_item, isFolder=True + ) From 961476338c0868c91e7cbe8ed288116acbcc2935 Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Fri, 21 Aug 2026 19:12:20 +0200 Subject: [PATCH 06/16] Resumaziuns e descripziuns en tut las linguas naziunalas --- addon.xml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/addon.xml b/addon.xml index e7b519c..3bad30c 100644 --- a/addon.xml +++ b/addon.xml @@ -11,8 +11,14 @@ Access the SRG SSR media libraries. Zugriff auf die Mediatheken von SRG SSR. + Accéder aux médiathèques de la SRG SSR. + Accedere alle mediateche della SRG SSR. + Access a las bibliotecas da medias da la SRG SSR. This addon allows a plugin to use the media libraries of SRG SSR. Dieses Addon erlaubt Plugins den Zugriff auf die Mediatheken von SRG SSR. + Cette extension permet à un plugin d'utiliser les médiathèques de la SRG SSR. + Questo componente aggiuntivo consente a un plugin di utilizzare le mediateche della SRG SSR. + Quai addon lubescha ad in plugin da far diever da las bibliotecas da medias da la SRG SSR. all GPL-3.0-or-later https://github.com/goggle/script.module.srgssr From 1744ba99fbf973fb5749a0423fc0c20755f79ae4 Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Sat, 22 Aug 2026 00:01:13 +0200 Subject: [PATCH 07/16] Fix scheduled livestream playback broken since IL 2.0 migration --- lib/play.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/lib/play.py b/lib/play.py index f57799c..8e3b9eb 100644 --- a/lib/play.py +++ b/lib/play.py @@ -55,6 +55,18 @@ def play_video(self, media_id_or_urn): Keyword arguments: media_id_or_urn -- the urn or id of the media to play """ + if ":scheduled_livestream:" in media_id_or_urn: + # The scheduledLivestreams IL 2.0 endpoint returns event + # pointer URNs (urn::scheduled_livestream:video:) + # that mediaComposition/byUrn cannot resolve. The actual + # playable stream uses the swisstxt URN scheme instead. + parts = media_id_or_urn.split(":") + bu = parts[1] + event_id = parts[-1] + media_id_or_urn = ( + f"urn:swisstxt:video:{bu}:{event_id.replace('-', '')}" + ) + if media_id_or_urn.startswith("urn:"): urn = media_id_or_urn media_id = media_id_or_urn.split(":")[-1] From f3ddfabd242c4b214514c1ef3411ff9637c4069f Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Sat, 22 Aug 2026 18:43:55 +0200 Subject: [PATCH 08/16] Allow passing title to the player (useful for live streams) --- lib/play.py | 18 ++++++++++++------ lib/srgssr.py | 10 +++++++--- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/lib/play.py b/lib/play.py index 8e3b9eb..4232c50 100644 --- a/lib/play.py +++ b/lib/play.py @@ -48,18 +48,18 @@ def __init__(self, srgssr_instance): self.srgssr = srgssr_instance self.handle = srgssr_instance.handle - def play_video(self, media_id_or_urn): + def play_video(self, media_id_or_urn, title=None): """ Gets the stream information starts to play it. Keyword arguments: media_id_or_urn -- the urn or id of the media to play + title -- fallback title, used when mediaComposition has + none (e.g. scheduled livestream events) """ if ":scheduled_livestream:" in media_id_or_urn: - # The scheduledLivestreams IL 2.0 endpoint returns event - # pointer URNs (urn::scheduled_livestream:video:) - # that mediaComposition/byUrn cannot resolve. The actual - # playable stream uses the swisstxt URN scheme instead. + # scheduledLivestreams event URNs aren't resolvable by + # mediaComposition/byUrn; convert to the playable swisstxt form. parts = media_id_or_urn.split(":") bu = parts[1] event_id = parts[-1] @@ -84,7 +84,13 @@ def play_video(self, media_id_or_urn): + urn ) json_response = json.loads(self.srgssr.open_url(detail_url)) - title = utils.try_get(json_response, ["episode", "title"], str, urn) + # try_get's default only kicks in on a missing/wrong-typed field, + # not an explicit "" -- some live events return episode.title=="". + title = ( + utils.try_get(json_response, ["episode", "title"], str, "") + or title + or urn + ) chapter_list = utils.try_get( json_response, "chapterList", data_type=list, default=[] diff --git a/lib/srgssr.py b/lib/srgssr.py index 190dc47..36c9784 100644 --- a/lib/srgssr.py +++ b/lib/srgssr.py @@ -138,7 +138,9 @@ def log(self, msg, level=xbmc.LOGDEBUG): xbmc.log(msg=message, level=level) @staticmethod - def build_url(mode=None, name=None, url=None, page_hash=None, page=None): + def build_url( + mode=None, name=None, url=None, page_hash=None, page=None, title=None + ): """Build a URL for the Kodi plugin. Keyword arguments: @@ -148,6 +150,8 @@ def build_url(mode=None, name=None, url=None, page_hash=None, page=None): page_hash -- a string (used to get additional videos through the API) page -- an integer used to indicate the current page in the list of items + title -- fallback title for the target mode (e.g. scheduled + livestream events) """ try: mode = str(mode) @@ -158,8 +162,8 @@ def build_url(mode=None, name=None, url=None, page_hash=None, page=None): except Exception: pass added = False - queries = (url, mode, name, page_hash, page) - query_names = ("url", "mode", "name", "page_hash", "page") + queries = (url, mode, name, page_hash, page, title) + query_names = ("url", "mode", "name", "page_hash", "page", "title") purl = sys.argv[0] for query, qname in zip(queries, query_names): if query: From c0cb6631b8a29afca1aa2f97800c6b80fb4f77f1 Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Sun, 23 Aug 2026 20:05:49 +0200 Subject: [PATCH 09/16] Allow resume position to be passed to the script --- lib/menus.py | 1 + lib/play.py | 24 +++++++++++++++++++++--- lib/srgssr.py | 20 +++++++++++++++++--- 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/lib/menus.py b/lib/menus.py index 13c1094..1861296 100644 --- a/lib/menus.py +++ b/lib/menus.py @@ -751,6 +751,7 @@ def build_entry( url = self.srgssr.build_url(mode=50, name=urn) else: url = self.srgssr.build_url(mode=50, name=name) + xbmcplugin.addDirectoryItem( self.handle, url, list_item, isFolder=is_folder ) diff --git a/lib/play.py b/lib/play.py index 4232c50..9f357ed 100644 --- a/lib/play.py +++ b/lib/play.py @@ -48,7 +48,7 @@ def __init__(self, srgssr_instance): self.srgssr = srgssr_instance self.handle = srgssr_instance.handle - def play_video(self, media_id_or_urn, title=None): + def play_video(self, media_id_or_urn, title=None, resume=None): """ Gets the stream information starts to play it. @@ -56,6 +56,9 @@ def play_video(self, media_id_or_urn, title=None): media_id_or_urn -- the urn or id of the media to play title -- fallback title, used when mediaComposition has none (e.g. scheduled livestream events) + resume -- resume position in seconds; must be set on the + played ListItem itself, not just the listing's, + or Kodi won't seek there """ if ":scheduled_livestream:" in media_id_or_urn: # scheduledLivestreams event URNs aren't resolvable by @@ -116,6 +119,12 @@ def play_video(self, media_id_or_urn, title=None): ) return + duration_ms = utils.try_get(chapter, "duration", data_type=int) + duration_sec = duration_ms // 1000 if duration_ms else None + if resume and not duration_sec: + # Kodi needs both ResumeTime and TotalTime to seek on start. + resume = None + stream_urls = { "SD": "", "HD": "", @@ -134,7 +143,9 @@ def play_video(self, media_id_or_urn, title=None): stream_urls[key] = utils.try_get(resource, "url") if drm: - self.play_drm(urn, title, resource_list) + self.play_drm( + urn, title, resource_list, resume=resume, duration=duration_sec + ) return if not stream_urls["SD"] and not stream_urls["HD"]: @@ -203,9 +214,13 @@ def play_video(self, media_id_or_urn, title=None): play_item.setProperty("inputstream.adaptive.manifest_type", mf_type) play_item.setProperty("IsPlayable", "true") + if resume and duration_sec: + play_item.setProperty("ResumeTime", str(resume)) + play_item.setProperty("TotalTime", str(duration_sec)) + xbmcplugin.setResolvedUrl(self.handle, True, play_item) - def play_drm(self, urn, title, resource_list): + def play_drm(self, urn, title, resource_list, resume=None, duration=None): self.srgssr.log(f"play_drm: urn = {urn}") preferred_quality = "HD" if self.srgssr.prefer_hd else "SD" resource_data = { @@ -274,6 +289,9 @@ def play_drm(self, urn, title, resource_list): play_item.setProperty( f"{ia}.manifest_update_url", manifest_update_url ) + if resume and duration: + play_item.setProperty("ResumeTime", str(resume)) + play_item.setProperty("TotalTime", str(duration)) xbmcplugin.setResolvedUrl(self.handle, True, play_item) if use_local_manifest: diff --git a/lib/srgssr.py b/lib/srgssr.py index 36c9784..2b7a72e 100644 --- a/lib/srgssr.py +++ b/lib/srgssr.py @@ -139,7 +139,13 @@ def log(self, msg, level=xbmc.LOGDEBUG): @staticmethod def build_url( - mode=None, name=None, url=None, page_hash=None, page=None, title=None + mode=None, + name=None, + url=None, + page_hash=None, + page=None, + title=None, + resume=None, ): """Build a URL for the Kodi plugin. @@ -152,6 +158,8 @@ def build_url( the list of items title -- fallback title for the target mode (e.g. scheduled livestream events) + resume -- a resume position in seconds, for mode=50 (play_video) + to seek to on start instead of playing from 0 """ try: mode = str(mode) @@ -161,9 +169,15 @@ def build_url( page = str(page) except Exception: pass + try: + resume = str(resume) if resume else None + except Exception: + resume = None added = False - queries = (url, mode, name, page_hash, page, title) - query_names = ("url", "mode", "name", "page_hash", "page", "title") + queries = (url, mode, name, page_hash, page, title, resume) + query_names = ( + "url", "mode", "name", "page_hash", "page", "title", "resume", + ) purl = sys.argv[0] for query, qname in zip(queries, query_names): if query: From 96df66b582f02d7ff2f5a6aa553aac126bf34a38 Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Mon, 31 Aug 2026 07:27:25 +0200 Subject: [PATCH 10/16] Actually populate the homepage --- lib/menus.py | 129 +++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 104 insertions(+), 25 deletions(-) diff --git a/lib/menus.py b/lib/menus.py index 1861296..b5d8efa 100644 --- a/lib/menus.py +++ b/lib/menus.py @@ -324,18 +324,92 @@ def build_homepage_menu(self): """ Builds the homepage menu. """ - self.build_menu_from_page( - self.srgssr.playtv_url, - ( - "state", - "loaderData", - "play-now", - "initialData", - "pacPageConfigs", - "landingPage", - "sections", - ), - ) + success = self.build_menu_from_api("landing-page") + if not success: + self.srgssr.log("build_homepage_menu: API failed, falling back to page parsing") + self.build_menu_from_page( + self.srgssr.playtv_url, + ( + "state", + "loaderData", + "play-now", + "initialData", + "pacPageConfigs", + "landingPage", + "sections", + ), + ) + + def build_menu_from_api(self, query, path=("data", "sections")): + """ + Builds a menu by calling a v3 API endpoint. + + Keyword arguments: + query -- the API endpoint query (e.g. 'landing-page') + path -- the path to the relevant data in the json + """ + url = self.srgssr.apiv3_url + query + self.srgssr.log(f"build_menu_from_api: fetching {url}") + try: + response_text = self.srgssr.open_url(url) + if not response_text: + self.srgssr.log(f"build_menu_from_api: Empty response from {url}") + return False + js = json.loads(response_text) + except Exception as e: + self.srgssr.log(f"build_menu_from_api: Failed to fetch/parse {url}: {e}") + return False + + data = utils.try_get(js, path, list, []) + if not data: + self.srgssr.log( + f"build_menu_from_api: Could not find any data at path {path} in json" + ) + return False + + has_items = False + for elem in data: + try: + id = elem["id"] + section_type = elem["sectionType"] + title = utils.try_get(elem, ("representation", "title")) + if title: + title = "".join(c for c in title if ord(c) <= 0xFFFF).strip() + if section_type in ( + "MediaSection", + "ShowSection", + "MediaSectionWithShow", + ): + if ( + section_type == "MediaSection" + and not title + and utils.try_get(elem, ("representation", "name")) + == "HeroStage" + ): + title = self.srgssr.language(30053) + if not title: + continue + list_item = xbmcgui.ListItem(label=title) + list_item.setArt( + { + "thumb": self.srgssr.icon, + "fanart": self.srgssr.fanart, + } + ) + if section_type == "MediaSection": + name = f"media-section?sectionId={id}" + elif section_type == "ShowSection": + name = f"show-section?sectionId={id}" + elif section_type == "MediaSectionWithShow": + name = f"media-section-with-show?sectionId={id}" + url = self.srgssr.build_url(mode=1000, name=name, page=1) + xbmcplugin.addDirectoryItem( + self.handle, url, list_item, isFolder=True + ) + has_items = True + except Exception: + pass + return has_items def build_menu_from_page(self, url, path): """ @@ -368,6 +442,8 @@ def build_menu_from_page(self, url, path): id = elem["id"] section_type = elem["sectionType"] title = utils.try_get(elem, ("representation", "title")) + if title: + title = "".join(c for c in title if ord(c) <= 0xFFFF).strip() if section_type in ( "MediaSection", "ShowSection", @@ -625,19 +701,22 @@ def build_menu_by_urn(self, urn): elif "video" in urn: self.build_episode_menu(id) elif "topic" in urn: - self.build_menu_from_page( - self.srgssr.playtv_url, - ( - "state", - "loaderData", - "play-now", - "initialData", - "pacPageConfigs", - "topicPages", - urn, - "sections", - ), - ) + success = self.build_menu_from_api(f"topic-page?topicUrn={urn}") + if not success: + self.srgssr.log(f"build_menu_by_urn: topic-page API failed, falling back to page parsing for {urn}") + self.build_menu_from_page( + self.srgssr.playtv_url, + ( + "state", + "loaderData", + "play-now", + "initialData", + "pacPageConfigs", + "topicPages", + urn, + "sections", + ), + ) def build_entry( self, From f691f668a4d8c79aa2526bb57ec450662ea5c987 Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Mon, 31 Aug 2026 07:33:57 +0200 Subject: [PATCH 11/16] Don't create '>> Next page' if there is none --- lib/menus.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/lib/menus.py b/lib/menus.py index b5d8efa..3f622e5 100644 --- a/lib/menus.py +++ b/lib/menus.py @@ -234,12 +234,17 @@ def build_menu_apiv3( self.srgssr.log("No media found.") return - items = ( - utils.try_get(data, "data", list, []) - or utils.try_get(data, "medias", list, []) - or utils.try_get(data, "results", list, []) - or data - ) + items = utils.try_get(data, "data", list, None) + if items is None: + items = utils.try_get(data, "medias", list, None) + if items is None: + items = utils.try_get(data, "results", list, None) + if items is None: + items = data if isinstance(data, list) else [] + + if not items: + self.srgssr.log("No items found.") + return for item in items: self.build_entry_apiv3( From ade74f1fd8d5170fdb7ea54f556b5cb921bf433e Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Mon, 31 Aug 2026 07:37:56 +0200 Subject: [PATCH 12/16] Integrate expiry dates into videos --- lib/menus.py | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/lib/menus.py b/lib/menus.py index 3f622e5..614bda1 100644 --- a/lib/menus.py +++ b/lib/menus.py @@ -623,6 +623,49 @@ def build_entry_apiv3(self, data, is_show=False, whitelist_ids=None): self.srgssr.log(f"build_entry_apiv3: urn = {urn}") title = utils.try_get(data, "title") + # Add remaining time or expiration info for limited-time content: + valid_to_str = utils.try_get(data, "validTo") + if valid_to_str: + valid_to_dt = utils.parse_datetime(valid_to_str) + if valid_to_dt: + now_dt = datetime.datetime.now() + delta = valid_to_dt - now_dt + if delta.total_seconds() > 0 and delta.days <= 30: + days = delta.days + hours = delta.seconds // 3600 + color = "gold" + if days > 0: + if self.srgssr.bu == "rts": + time_left_str = f"Encore {days}j" + elif self.srgssr.bu == "srf": + time_left_str = f"Noch {days}T" + elif self.srgssr.bu == "rsi": + time_left_str = f"Ancora {days}g" + else: + time_left_str = f"{days}d left" + else: + color = "red" + if hours > 0: + if self.srgssr.bu == "rts": + time_left_str = f"Encore {hours}h" + elif self.srgssr.bu == "srf": + time_left_str = f"Noch {hours}Std" + elif self.srgssr.bu == "rsi": + time_left_str = f"Ancora {hours}o" + else: + time_left_str = f"{hours}h left" + else: + if self.srgssr.bu == "rts": + time_left_str = "Derniers instants" + elif self.srgssr.bu == "srf": + time_left_str = "Letzte Momente" + elif self.srgssr.bu == "rsi": + time_left_str = "Ultimi momenti" + else: + time_left_str = "Ending soon" + + title = f"[COLOR {color}][{time_left_str}][/COLOR] {title}" + # Add the date & time to the title for upcoming livestreams: if utils.try_get(data, "type") == "SCHEDULED_LIVESTREAM": dt = utils.try_get(data, "date") @@ -637,6 +680,27 @@ def build_entry_apiv3(self, data, is_show=False, whitelist_ids=None): return description = utils.try_get(data, "description") lead = utils.try_get(data, "lead") + if valid_to_str: + valid_to_dt = utils.parse_datetime(valid_to_str) + if valid_to_dt: + if self.srgssr.bu == "rts": + formatted_expiry = valid_to_dt.strftime("%d.%m.%Y à %H:%M") + expiry_desc = f"[Disponible jusqu'au {formatted_expiry}]" + elif self.srgssr.bu == "srf": + formatted_expiry = valid_to_dt.strftime("%d.%m.%Y um %H:%M") + expiry_desc = f"[Verfügbar bis {formatted_expiry}]" + elif self.srgssr.bu == "rsi": + formatted_expiry = valid_to_dt.strftime("%d.%m.%Y alle %H:%M") + expiry_desc = f"[Disponibile fino al {formatted_expiry}]" + else: + formatted_expiry = valid_to_dt.strftime("%d.%m.%Y at %H:%M") + expiry_desc = f"[Available until {formatted_expiry}]" + + plot_content = description or lead or "" + if plot_content: + description = f"{expiry_desc}\n{plot_content}" + else: + description = expiry_desc image_url = utils.try_get(data, "imageUrl") poster_image_url = utils.try_get(data, "posterImageUrl") show_image_url = utils.try_get(data, ["show", "imageUrl"]) From 3652e2129b279853653ea7e8f4c4c5ef7a3e78a3 Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Mon, 31 Aug 2026 07:46:33 +0200 Subject: [PATCH 13/16] Fix next page query --- lib/menus.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/menus.py b/lib/menus.py index 614bda1..148eb2d 100644 --- a/lib/menus.py +++ b/lib/menus.py @@ -218,7 +218,7 @@ def build_menu_apiv3( if cursor: symb = "&" if "?" in queries else "?" - url = f"{self.srgssr.apiv3_url}{queries}{symb}next={cursor}" + url = f"{self.srgssr.apiv3_url}{queries}{symb}next={quote_plus(cursor)}" data = json.loads(self.srgssr.open_url(url)) else: data = json.loads( From a548c5c36c6934c90258568a2bb02d995e55f8c6 Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Mon, 31 Aug 2026 08:05:45 +0200 Subject: [PATCH 14/16] Misleading information about future streams --- lib/menus.py | 48 +++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/lib/menus.py b/lib/menus.py index 148eb2d..ce869a5 100644 --- a/lib/menus.py +++ b/lib/menus.py @@ -623,14 +623,56 @@ def build_entry_apiv3(self, data, is_show=False, whitelist_ids=None): self.srgssr.log(f"build_entry_apiv3: urn = {urn}") title = utils.try_get(data, "title") - # Add remaining time or expiration info for limited-time content: + # Add remaining time, start countdown, or expiration info: valid_to_str = utils.try_get(data, "validTo") + valid_from_str = utils.try_get(data, "validFrom") or utils.try_get(data, "date") + valid_from_dt = utils.parse_datetime(valid_from_str) if valid_from_str else None + if valid_to_str: valid_to_dt = utils.parse_datetime(valid_to_str) if valid_to_dt: now_dt = datetime.datetime.now() - delta = valid_to_dt - now_dt - if delta.total_seconds() > 0 and delta.days <= 30: + + # Case 1: The stream/video is scheduled for the future (has not started yet) + if valid_from_dt and valid_from_dt > now_dt: + delta_start = valid_from_dt - now_dt + if delta_start.days <= 30: + days_start = delta_start.days + hours_start = delta_start.seconds // 3600 + color = "cyan" + if days_start > 0: + if self.srgssr.bu == "rts": + time_left_str = f"Dans {days_start}j" + elif self.srgssr.bu == "srf": + time_left_str = f"In {days_start}T" + elif self.srgssr.bu == "rsi": + time_left_str = f"Tra {days_start}g" + else: + time_left_str = f"In {days_start}d" + elif hours_start > 0: + if self.srgssr.bu == "rts": + time_left_str = f"Dans {hours_start}h" + elif self.srgssr.bu == "srf": + time_left_str = f"In {hours_start}Std" + elif self.srgssr.bu == "rsi": + time_left_str = f"Tra {hours_start}o" + else: + time_left_str = f"In {hours_start}h" + else: + if self.srgssr.bu == "rts": + time_left_str = "Commence bientôt" + elif self.srgssr.bu == "srf": + time_left_str = "Beginnt bald" + elif self.srgssr.bu == "rsi": + time_left_str = "Inizia presto" + else: + time_left_str = "Starting soon" + + title = f"[COLOR {color}][{time_left_str}][/COLOR] {title}" + + # Case 2: Already started / available, and expiring soon (within 30 days) + elif (valid_to_dt - now_dt).total_seconds() > 0 and (valid_to_dt - now_dt).days <= 30: + delta = valid_to_dt - now_dt days = delta.days hours = delta.seconds // 3600 color = "gold" From 38e0df3ba98dc5965b40a8008ce6b0593046b8a3 Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Mon, 31 Aug 2026 08:10:36 +0200 Subject: [PATCH 15/16] Add Romanch --- lib/menus.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/lib/menus.py b/lib/menus.py index ce869a5..3a48857 100644 --- a/lib/menus.py +++ b/lib/menus.py @@ -647,6 +647,8 @@ def build_entry_apiv3(self, data, is_show=False, whitelist_ids=None): time_left_str = f"In {days_start}T" elif self.srgssr.bu == "rsi": time_left_str = f"Tra {days_start}g" + elif self.srgssr.bu == "rtr": + time_left_str = f"In {days_start}d" else: time_left_str = f"In {days_start}d" elif hours_start > 0: @@ -656,6 +658,8 @@ def build_entry_apiv3(self, data, is_show=False, whitelist_ids=None): time_left_str = f"In {hours_start}Std" elif self.srgssr.bu == "rsi": time_left_str = f"Tra {hours_start}o" + elif self.srgssr.bu == "rtr": + time_left_str = f"In {hours_start}u" else: time_left_str = f"In {hours_start}h" else: @@ -665,6 +669,8 @@ def build_entry_apiv3(self, data, is_show=False, whitelist_ids=None): time_left_str = "Beginnt bald" elif self.srgssr.bu == "rsi": time_left_str = "Inizia presto" + elif self.srgssr.bu == "rtr": + time_left_str = "Cumenza baud" else: time_left_str = "Starting soon" @@ -683,6 +689,8 @@ def build_entry_apiv3(self, data, is_show=False, whitelist_ids=None): time_left_str = f"Noch {days}T" elif self.srgssr.bu == "rsi": time_left_str = f"Ancora {days}g" + elif self.srgssr.bu == "rtr": + time_left_str = f"Ancor {days}d" else: time_left_str = f"{days}d left" else: @@ -694,6 +702,8 @@ def build_entry_apiv3(self, data, is_show=False, whitelist_ids=None): time_left_str = f"Noch {hours}Std" elif self.srgssr.bu == "rsi": time_left_str = f"Ancora {hours}o" + elif self.srgssr.bu == "rtr": + time_left_str = f"Ancor {hours}u" else: time_left_str = f"{hours}h left" else: @@ -703,6 +713,8 @@ def build_entry_apiv3(self, data, is_show=False, whitelist_ids=None): time_left_str = "Letzte Momente" elif self.srgssr.bu == "rsi": time_left_str = "Ultimi momenti" + elif self.srgssr.bu == "rtr": + time_left_str = "Ultims muments" else: time_left_str = "Ending soon" @@ -734,6 +746,9 @@ def build_entry_apiv3(self, data, is_show=False, whitelist_ids=None): elif self.srgssr.bu == "rsi": formatted_expiry = valid_to_dt.strftime("%d.%m.%Y alle %H:%M") expiry_desc = f"[Disponibile fino al {formatted_expiry}]" + elif self.srgssr.bu == "rtr": + formatted_expiry = valid_to_dt.strftime("%d.%m.%Y a las %H:%M") + expiry_desc = f"[Disponibel fin ils {formatted_expiry}]" else: formatted_expiry = valid_to_dt.strftime("%d.%m.%Y at %H:%M") expiry_desc = f"[Available until {formatted_expiry}]" From 4f0f1742bca3f9dfe369dd8b1cd7ca4752dc315c Mon Sep 17 00:00:00 2001 From: Fridrich Strba Date: Mon, 31 Aug 2026 08:17:53 +0200 Subject: [PATCH 16/16] Avoid language mix on home page --- lib/srgssr.py | 123 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 121 insertions(+), 2 deletions(-) diff --git a/lib/srgssr.py b/lib/srgssr.py index 2b7a72e..716c3e9 100644 --- a/lib/srgssr.py +++ b/lib/srgssr.py @@ -64,6 +64,78 @@ def get_params(): return dict(parse_qsl(sys.argv[2][1:])) +STATIC_TRANSLATIONS = { + "fr": { + 30050: "Toutes les émissions", + 30051: "Émissions favorites", + 30052: "Nouvelles émissions favorites", + 30053: "Recommandations", + 30057: "Émissions par date", + 30058: "Thèmes", + 30060: "Page d'accueil", + 30073: "Page suivante", + 30074: "YouTube", + 30085: "Recherche", + 30110: "Chaînes YouTube", + 30111: "Dernières vidéos YouTube", + 30112: "Rechercher des vidéos", + 30115: "Rechercher", + 30116: "Dernières recherches de vidéos", + }, + "de": { + 30050: "Alle Sendungen", + 30051: "Bevorzugte Sendungen", + 30052: "Neuste bevorzugte Sendungen", + 30053: "Empfehlungen", + 30057: "Sendungen nach Datum", + 30058: "Themen", + 30060: "Startseite", + 30073: "Nächste Seite", + 30074: "YouTube", + 30085: "Suche", + 30110: "YouTube Kanäle", + 30111: "Neuste YouTube Videos", + 30112: "Videos suchen", + 30115: "Suche", + 30116: "Neuste Videosuchen", + }, + "it": { + 30050: "Tutti i programmi", + 30051: "Programmi preferiti", + 30052: "Nuovi show preferiti", + 30053: "Consigli", + 30057: "Programmi per data", + 30058: "Temi", + 30060: "Pagina iniziale", + 30073: "Pagina successiva", + 30074: "YouTube", + 30085: "Cerca", + 30110: "Canali YouTube", + 30111: "Ultimi video YouTube", + 30112: "Cerca video", + 30115: "Cerca", + 30116: "Ricerche video recenti", + }, + "rm": { + 30050: "Tuts ils programs", + 30051: "Programs favurits", + 30052: "Novas missiuns favuritas", + 30053: "Recumandaziuns", + 30057: "Programs tenor data", + 30058: "Temas", + 30060: "Pagina principala", + 30073: "Proxima pagina", + 30074: "YouTube", + 30085: "Tschertgar", + 30110: "Canals da YouTube", + 30111: "Ultims videos da YouTube", + 30112: "Tschertgar videos", + 30115: "Tschertgar", + 30116: "Ultimas tschertgas da videos", + } +} + + class SRGSSR: """ Base class for all SRG SSR related plugins. @@ -79,8 +151,8 @@ def __init__(self, plugin_handle, bu="srf", addon_id=ADDON_ID): self.addon_id = addon_id self.icon = self.real_settings.getAddonInfo("icon") self.fanart = self.real_settings.getAddonInfo("fanart") - self.language = LANGUAGE - self.plugin_language = self.real_settings.getLocalizedString + self.language = self.get_localized_string + self.plugin_language = self.get_localized_string self.host_url = f"https://www.{bu}.ch" if bu == "swi": self.host_url = "https://play.swissinfo.ch" @@ -114,6 +186,53 @@ def __init__(self, plugin_handle, bu="srf", addon_id=ADDON_ID): if filename.startswith("urn") and filename.endswith(".vtt"): xbmcvfs.delete(clean_dir + "/" + filename) + def get_localized_string(self, id_or_str): + """ + Returns a localized string according to Kodi's locale, + falling back to the business unit's (BU's) native Swiss language + if Kodi is running in a non-Swiss language (to prevent language mixing). + """ + try: + val_id = int(id_or_str) + except (ValueError, TypeError): + return str(id_or_str) + + # 1. Determine target BU language + bu_lang = "en" + if self.bu == "rts": + bu_lang = "fr" + elif self.bu == "srf": + bu_lang = "de" + elif self.bu == "rsi": + bu_lang = "it" + elif self.bu == "rtr": + bu_lang = "rm" + + # 2. Determine active Kodi language + try: + kodi_lang = xbmc.getLanguage(xbmc.ISO_639_1, True) + except Exception: + kodi_lang = "en" + + # Check if Kodi is running in a Swiss language + is_swiss_lang = False + kodi_lang_lower = kodi_lang.lower() + for sl in ("de", "fr", "it", "rm"): + if sl in kodi_lang_lower: + is_swiss_lang = True + break + + # 3. If Kodi is not in a Swiss language, force the BU's native language! + if not is_swiss_lang and bu_lang in STATIC_TRANSLATIONS: + if val_id in STATIC_TRANSLATIONS[bu_lang]: + return STATIC_TRANSLATIONS[bu_lang][val_id] + + # 4. Otherwise, fallback to Kodi's native translation + try: + return self.real_settings.getLocalizedString(val_id) + except Exception: + return "" + def get_boolean_setting(self, setting): """ Returns the boolean value of a specified setting.