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 diff --git a/lib/manifest_proxy.py b/lib/manifest_proxy.py new file mode 100644 index 0000000..4e8c76d --- /dev/null +++ b/lib/manifest_proxy.py @@ -0,0 +1,157 @@ +# 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/menus.py b/lib/menus.py index 852a335..1861296 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,10 @@ 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 +785,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 +797,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 +867,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 +879,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 +946,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 +984,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 7cc09ff..9f357ed 100644 --- a/lib/play.py +++ b/lib/play.py @@ -19,14 +19,26 @@ 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: @@ -36,13 +48,28 @@ 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, resume=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) + 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 + # mediaComposition/byUrn; convert to the playable swisstxt form. + 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] @@ -51,22 +78,35 @@ 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) + # 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=[] ) 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 ) @@ -74,9 +114,17 @@ 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 + 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": "", @@ -89,13 +137,15 @@ 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") 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"]: @@ -104,7 +154,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}") @@ -163,9 +214,13 @@ def play_video(self, media_id_or_urn): 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 = { @@ -179,7 +234,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") @@ -199,9 +256,26 @@ 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 +285,104 @@ 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 + ) + 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: + # 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_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' + ) + 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( + "_quiet_fetch: " + f"{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..2b7a72e 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") @@ -136,7 +138,15 @@ 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, + resume=None, + ): """Build a URL for the Kodi plugin. Keyword arguments: @@ -146,6 +156,10 @@ 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) + 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) @@ -155,9 +169,15 @@ def build_url(mode=None, name=None, url=None, page_hash=None, page=None): 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) - query_names = ("url", "mode", "name", "page_hash", "page") + 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: @@ -167,17 +187,22 @@ 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 = ( - 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 = { @@ -189,7 +214,10 @@ 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,20 +248,24 @@ 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("/") 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, ) ) or {} 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 + ) 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 new file mode 100644 index 0000000..91453cf --- /dev/null +++ b/resources/language/resource.language.rm_ch/strings.po @@ -0,0 +1,54 @@ +# 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" + +msgctxt "#30101" +msgid "Sports Live" +msgstr "Sport Live" + +msgctxt "#30102" +msgid "Other Live Streams" +msgstr "Auters Live Streams"