From 761dc8e1c58872d646b2d75d85d059f5758a98e8 Mon Sep 17 00:00:00 2001 From: Gorgorot38 Date: Sun, 23 Feb 2025 10:26:37 +0100 Subject: [PATCH 1/2] Add segment skip ability --- .../resource.language.en_gb/strings.po | 38 ++++- .../language/resource.language.fr/strings.po | 36 +++++ resources/lib/dialogs.py | 64 +++++++- resources/lib/intro_skipper.py | 145 ++++++++++++++++++ resources/lib/play_utils.py | 44 +++++- resources/lib/utils.py | 8 +- resources/settings.xml | 13 ++ resources/skins/default/720p/SkipDialog.xml | 25 +++ service.py | 9 +- 9 files changed, 376 insertions(+), 6 deletions(-) create mode 100644 resources/lib/intro_skipper.py create mode 100644 resources/skins/default/720p/SkipDialog.xml diff --git a/resources/language/resource.language.en_gb/strings.po b/resources/language/resource.language.en_gb/strings.po index 8c9508ad..4cb13595 100644 --- a/resources/language/resource.language.en_gb/strings.po +++ b/resources/language/resource.language.en_gb/strings.po @@ -1155,4 +1155,40 @@ msgstr "Hide number of items to show on entry title" msgctxt "#30454" msgid " - Totally Unwatched" -msgstr " - Totally Unwatched" \ No newline at end of file +msgstr " - Totally Unwatched" + +msgctxt "#30666" +msgid "Segment Skipper" +msgstr "Segment Skipper" + +msgctxt "#30667" +msgid "Action to take" +msgstr "Action to take" + +msgctxt "#30668" +msgid "Start Offset (seconds)" +msgstr "Start Offset (seconds)" + +msgctxt "#30669" +msgid "End Offset (seconds)" +msgstr "End Offset (seconds)" + +msgctxt "#30670" +msgid "Intro Skipper" +msgstr "Intro Skipper" + +msgctxt "#30671" +msgid "Credit Skipper" +msgstr "Credit Skipper" + +msgctxt "#30672" +msgid "Skip" +msgstr "Skip" + +msgctxt "#30673" +msgid "Ask" +msgstr "Ask" + +msgctxt "#30674" +msgid "Do Nothing" +msgstr "Do Nothing" diff --git a/resources/language/resource.language.fr/strings.po b/resources/language/resource.language.fr/strings.po index 846bd983..a7d49253 100644 --- a/resources/language/resource.language.fr/strings.po +++ b/resources/language/resource.language.fr/strings.po @@ -1156,3 +1156,39 @@ msgstr "Revoir ensuite" msgctxt "#30453" msgid "Hide number of items to show on entry title" msgstr "Cacher ne nombre d'éléments à montrer dans les titres d'entrées" + +msgctxt "#30666" +msgid "Segment Skipper" +msgstr "Passer les segments" + +msgctxt "#30667" +msgid "Action to take" +msgstr "Action" + +msgctxt "#30668" +msgid "Start Offset (seconds)" +msgstr "Décalage début de segment (secondes)" + +msgctxt "#30669" +msgid "End Offset (seconds)" +msgstr "Décalage fin de segment (secondes)" + +msgctxt "#30670" +msgid "Intro Skipper" +msgstr "Introductions" + +msgctxt "#30671" +msgid "Credit Skipper" +msgstr "Crédits" + +msgctxt "#30672" +msgid "Skip" +msgstr "Passer" + +msgctxt "#30673" +msgid "Ask" +msgstr "Passer" + +msgctxt "#30674" +msgid "Do Nothing" +msgstr "Ne rien faire" diff --git a/resources/lib/dialogs.py b/resources/lib/dialogs.py index 292f290a..11716a37 100644 --- a/resources/lib/dialogs.py +++ b/resources/lib/dialogs.py @@ -3,9 +3,11 @@ ) import xbmcgui +import xbmc + from .lazylogger import LazyLogger -from .utils import translate_string, send_event_notification +from .utils import seconds_to_ticks, ticks_to_seconds, translate_string, send_event_notification log = LazyLogger(__name__) @@ -206,3 +208,63 @@ def set_episode_info(self, info): def get_play_called(self): return self.play_called + +class SkipDialog(xbmcgui.WindowXMLDialog): + + action_exitkeys_id = None + media_id = None + is_intro = False + intro_start = None + intro_end = None + credit_start = None + credit_end = None + + has_been_dissmissed = False + + def __init__(self, *args, **kwargs): + log.debug("SkipDialog: __init__") + xbmcgui.WindowXML.__init__(self, *args, **kwargs) + + def onInit(self): + log.debug("SkipDialog: onInit") + self.action_exitkeys_id = [10, 13] + + def onFocus(self, control_id): + pass + + def doAction(self, action_id): + pass + + def onMessage(self, message): + log.debug("SkipDialog: onMessage: {0}".format(message)) + + def onAction(self, action): + + if action.getId() == 10 or action.getId() == 92: # ACTION_PREVIOUS_MENU & ACTION_NAV_BACK + self.has_been_dissmissed = True + self.close() + else: + log.debug("SkipDialog: onAction: {0}".format(action.getId())) + + def onClick(self, control_id): + player = xbmc.Player() + current_ticks = seconds_to_ticks(player.getTime()) + if self.intro_start is not None and self.intro_end is not None and current_ticks >= self.intro_start and current_ticks <= self.intro_end: + # If click during intro, skip it + player.seekTime(ticks_to_seconds(self.intro_end)) + + elif self.credit_start is not None and self.credit_end is not None and current_ticks >= self.credit_start and current_ticks <= self.credit_end: + # If click during outro, skip it + player.seekTime(ticks_to_seconds(self.credit_end)) + + self.close() + + def get_play_called(self): + return self.play_called + + def is_button_shown(self): + try: + self.getFocus() + return True + except Exception: + return False diff --git a/resources/lib/intro_skipper.py b/resources/lib/intro_skipper.py new file mode 100644 index 00000000..6548bb3f --- /dev/null +++ b/resources/lib/intro_skipper.py @@ -0,0 +1,145 @@ +from __future__ import ( + division, absolute_import, print_function, unicode_literals +) + +import os +import threading + +import xbmc +import xbmcaddon +import xbmcgui + +from resources.lib.play_utils import set_correct_skip_info +from resources.lib.utils import seconds_to_ticks, ticks_to_seconds, translate_path + + +from .lazylogger import LazyLogger +from .dialogs import SkipDialog + + +log = LazyLogger(__name__) + + +class IntroSkipperService(threading.Thread): + + stop_thread = False + monitor = None + + def __init__(self, play_monitor): + super(IntroSkipperService, self).__init__() + self.monitor = play_monitor + + def run(self): + + from .play_utils import get_jellyfin_playing_item + settings = xbmcaddon.Addon() + plugin_path = settings.getAddonInfo('path') + plugin_path_real = translate_path(os.path.join(plugin_path)) + + skip_intro_dialog = None + skip_credit_dialog = None + + while not xbmc.Monitor().abortRequested() and not self.stop_thread: + player = xbmc.Player() + if player.isPlaying(): + item_id = get_jellyfin_playing_item() + if item_id is not None: + # Handle skip only on jellyfin items + current_ticks = seconds_to_ticks(player.getTime()) + + # Handle Intros + skip_intro_dialog = self.handle_intros(plugin_path_real, skip_intro_dialog, item_id, current_ticks, player) + + # Handle Credits + skip_credit_dialog = self.handle_credits(plugin_path_real, skip_credit_dialog, item_id, current_ticks, player) + + else: + if skip_intro_dialog is not None: + skip_intro_dialog.close() + skip_intro_dialog = None + + if skip_credit_dialog is not None: + skip_credit_dialog.close() + skip_credit_dialog = None + + if xbmc.Monitor().waitForAbort(1): + break + + xbmc.sleep(200) + + + def handle_intros(self, plugin_path_real: str, skip_intro_dialog: SkipDialog, item_id: str, current_ticks: float, player: xbmc.Player): + settings = xbmcaddon.Addon() + intro_skip_action = settings.getSetting("intro_skipper_action") + + # In case do nothing is selected return + if intro_skip_action == "2": + return + + if skip_intro_dialog is None: + skip_intro_dialog = SkipDialog("SkipDialog.xml", plugin_path_real, "default", "720p") + + set_correct_skip_info(item_id, skip_intro_dialog) + + is_intro = False + if skip_intro_dialog.intro_start is not None and skip_intro_dialog.intro_end is not None: + # Resets the dismiss var so that button can reappear in case of navigation in the timecodes + if current_ticks < skip_intro_dialog.intro_start or current_ticks > skip_intro_dialog.intro_end: + skip_intro_dialog.has_been_dissmissed = False + + # Checks if segment is playing + is_intro = current_ticks >= skip_intro_dialog.intro_start and current_ticks <= skip_intro_dialog.intro_end + + if intro_skip_action == "1" and is_intro: + # If auto skip is enabled, skips to semgent ends automatically + player.seekTime(ticks_to_seconds(skip_intro_dialog.intro_end)) + xbmcgui.Dialog().notification("JellyCon", "Intro Skipped") + elif intro_skip_action == "0": + # Otherwise show skip dialog + if is_intro and not skip_intro_dialog.has_been_dissmissed: + skip_intro_dialog.show() + else: + # Could not find doc on what happens when closing a closed dialog, but it seems fine + skip_intro_dialog.close() + + return skip_intro_dialog + + def handle_credits(self, plugin_path_real: str, skip_credit_dialog: SkipDialog, item_id: str, current_ticks: float, player: xbmc.Player): + settings = xbmcaddon.Addon() + credit_skip_action = settings.getSetting("credit_skipper_action") + + # In case do nothing is selected return + + if credit_skip_action == "2": + return + + if skip_credit_dialog is None: + skip_credit_dialog = SkipDialog("SkipDialog.xml", plugin_path_real, "default", "720p") + + set_correct_skip_info(item_id, skip_credit_dialog) + + is_credit = False + if skip_credit_dialog.credit_start is not None and skip_credit_dialog.credit_end is not None: + # Resets the dismiss var so that button can reappear in case of navigation in the timecodes + if current_ticks < skip_credit_dialog.credit_start or current_ticks > skip_credit_dialog.credit_end: + skip_credit_dialog.has_been_dissmissed = False + + # Checks if segment is playing + is_credit = current_ticks >= skip_credit_dialog.credit_start and current_ticks <= skip_credit_dialog.credit_end + + if credit_skip_action == "1" and is_credit: + # If auto skip is enabled, skips to semgent ends automatically + player.seekTime(ticks_to_seconds(skip_credit_dialog.credit_end)) + xbmcgui.Dialog().notification("JellyCon", "Credit Skipped") + elif credit_skip_action == "0": + # Otherwise show skip dialog + if is_credit and not skip_credit_dialog.has_been_dissmissed: + skip_credit_dialog.show() + else: + skip_credit_dialog.close() + + return skip_credit_dialog + + def stop_service(self): + log.debug("IntroSkipperService Stop Called") + self.stop_thread = True diff --git a/resources/lib/play_utils.py b/resources/lib/play_utils.py index 601bc17e..60a71be6 100644 --- a/resources/lib/play_utils.py +++ b/resources/lib/play_utils.py @@ -18,8 +18,8 @@ from .jellyfin import api from .lazylogger import LazyLogger -from .dialogs import ResumeDialog -from .utils import send_event_notification, convert_size, get_device_id, translate_string, load_user_details, translate_path, get_jellyfin_url, download_external_sub, get_bitrate +from .dialogs import ResumeDialog, SkipDialog +from .utils import seconds_to_ticks, send_event_notification, convert_size, get_device_id, translate_string, load_user_details, translate_path, get_jellyfin_url, download_external_sub, get_bitrate from .kodi_utils import HomeWindow from .datamanager import clear_old_cache_data from .item_functions import extract_item_info, add_gui_item, get_art @@ -1182,6 +1182,16 @@ def get_playing_data(): return {} +def get_jellyfin_playing_item(): + home_window = HomeWindow() + play_data_string = home_window.get_property('now_playing') + try: + play_data = json.loads(play_data_string) + except ValueError: + # This isn't a JellyCon item + return None + + return play_data.get("item_id") def get_play_url(media_source, play_session_id, channel_id=None): log.debug("get_play_url - media_source: {0}", media_source) @@ -1693,3 +1703,33 @@ def get_item_playback_info(item_id, force_transcode): log.debug("PlaybackInfo : {0}".format(play_info_result)) return play_info_result + +def get_media_segments(item_id): + url = "/MediaSegments/{}".format(item_id) + result = api.get(url) + if result is None or result["Items"] is None: + return None + return result["Items"] + +def set_correct_skip_info(item_id: str, skip_dialog: SkipDialog): + if (skip_dialog.media_id is None or skip_dialog.media_id != item_id) and item_id is not None: + # If playback item has changed (or is new), sets its id and fetch media segments (happens twice per media - intro and outro - but it is a light call) + skip_dialog.media_id = item_id + skip_dialog.has_been_dissmissed = False + segments = get_media_segments(item_id) + if segments is not None: + # Find the intro and outro timings + intro_start = next((segment["StartTicks"] for segment in segments if segment["Type"] == "Intro"), None) + intro_end = next((segment["EndTicks"] for segment in segments if segment["Type"] == "Intro"), None) + credit_start = next((segment["StartTicks"] for segment in segments if segment["Type"] == "Outro"), None) + credit_end = next((segment["EndTicks"] for segment in segments if segment["Type"] == "Outro"), None) + + # Sets timings with offsets if defined in settings + if intro_start is not None: + skip_dialog.intro_start = intro_start + seconds_to_ticks(settings.getSettingInt("intro_skipper_start_offset")) + if intro_end is not None: + skip_dialog.intro_end = intro_end - seconds_to_ticks(settings.getSettingInt("intro_skipper_end_offset")) + if credit_start is not None: + skip_dialog.credit_start = credit_start + seconds_to_ticks(settings.getSettingInt("credit_skipper_start_offset")) + if credit_end is not None: + skip_dialog.credit_end = credit_end - seconds_to_ticks(settings.getSettingInt("credit_skipper_end_offset")) diff --git a/resources/lib/utils.py b/resources/lib/utils.py index 159b44a5..ee151747 100644 --- a/resources/lib/utils.py +++ b/resources/lib/utils.py @@ -457,4 +457,10 @@ def get_filtered_items_count_text(): if settings.getSetting("hide_x_filtered_items_count") == 'true' : return "" else: - return " (" + settings.getSetting("show_x_filtered_items") + ")" \ No newline at end of file + return " (" + settings.getSetting("show_x_filtered_items") + ")" + +def seconds_to_ticks(seconds:float): + return seconds * 10000000 + +def ticks_to_seconds(ticks:int): + return round(ticks / 10000000, 1) diff --git a/resources/settings.xml b/resources/settings.xml index 7346cab4..626ff41e 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -113,6 +113,19 @@ + + + + + + + + + + + + + diff --git a/resources/skins/default/720p/SkipDialog.xml b/resources/skins/default/720p/SkipDialog.xml new file mode 100644 index 00000000..5aa51fc9 --- /dev/null +++ b/resources/skins/default/720p/SkipDialog.xml @@ -0,0 +1,25 @@ + + + 9000 + 2 + + 1 + 0 + 0 + + + + + 1020 + 550 + 150 + 65 + true + + center + white.png + font12 + 3014 + + + diff --git a/service.py b/service.py index 8e866098..d68b84b1 100644 --- a/service.py +++ b/service.py @@ -20,6 +20,7 @@ from resources.lib.tracking import set_timing_enabled from resources.lib.image_server import HttpImageServerThread from resources.lib.playnext import PlayNextService +from resources.lib.intro_skipper import IntroSkipperService settings = xbmcaddon.Addon() @@ -87,6 +88,10 @@ context_monitor = ContextMonitor() context_monitor.start() +# Start the skip service monitor +intro_skipper = IntroSkipperService(monitor) +intro_skipper.start() + background_interval = int(settings.getSetting('background_interval')) newcontent_interval = int(settings.getSetting('new_content_check_interval')) random_movie_list_interval = int(settings.getSetting('random_movie_refresh_interval')) @@ -104,7 +109,6 @@ home_window.set_property('exit', 'False') while home_window.get_property('exit') == 'False': - try: if xbmc.Player().isPlaying(): last_random_movie_update = time.time() - (random_movie_list_interval - 15) @@ -183,6 +187,9 @@ # call stop on the context menu monitor if context_monitor: context_monitor.stop_monitor() + +if intro_skipper: + intro_skipper.stop_service() # clear user and token when logging off home_window.clear_property("user_name") From af54d611b1af3e72c3a128f2bd7b5c79d57c29cd Mon Sep 17 00:00:00 2001 From: Gorgorot38 Date: Sun, 23 Feb 2025 14:51:38 +0100 Subject: [PATCH 2/2] fix return --- resources/lib/intro_skipper.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/lib/intro_skipper.py b/resources/lib/intro_skipper.py index 6548bb3f..565773f6 100644 --- a/resources/lib/intro_skipper.py +++ b/resources/lib/intro_skipper.py @@ -74,7 +74,7 @@ def handle_intros(self, plugin_path_real: str, skip_intro_dialog: SkipDialog, it # In case do nothing is selected return if intro_skip_action == "2": - return + return None if skip_intro_dialog is None: skip_intro_dialog = SkipDialog("SkipDialog.xml", plugin_path_real, "default", "720p") @@ -111,7 +111,7 @@ def handle_credits(self, plugin_path_real: str, skip_credit_dialog: SkipDialog, # In case do nothing is selected return if credit_skip_action == "2": - return + return None if skip_credit_dialog is None: skip_credit_dialog = SkipDialog("SkipDialog.xml", plugin_path_real, "default", "720p")