diff --git a/resources/language/resource.language.en_gb/strings.po b/resources/language/resource.language.en_gb/strings.po index 8c9508a..f7a08a2 100644 --- a/resources/language/resource.language.en_gb/strings.po +++ b/resources/language/resource.language.en_gb/strings.po @@ -1155,4 +1155,52 @@ 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" + +msgctxt "#30675" +msgid "Commercial Skipper" +msgstr "Commercial Skipper" + +msgctxt "#30676" +msgid "Preview Skipper" +msgstr "Preview Skipper" + +msgctxt "#30677" +msgid "Recap Skipper" +msgstr "Recap Skipper" diff --git a/resources/lib/dialogs.py b/resources/lib/dialogs.py index 292f290..bf35a94 100644 --- a/resources/lib/dialogs.py +++ b/resources/lib/dialogs.py @@ -3,9 +3,11 @@ from __future__ import ( ) 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,57 @@ class PlayNextDialog(xbmcgui.WindowXMLDialog): def get_play_called(self): return self.play_called + +class SkipDialog(xbmcgui.WindowXMLDialog): + + action_exitkeys_id = None + media_id = None + start = None + 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): + log.debug("SkipDialog: onAction: {0}".format(action.getId())) + if action.getId() == 10 or action.getId() == 92: # ACTION_PREVIOUS_MENU & ACTION_NAV_BACK + log.debug("SkipDialog: dismissing dialog so it does not open again") + self.has_been_dissmissed = True + self.close() + + def onClick(self, control_id): + log.debug("SkipDialog: onClick: {0}".format(control_id)) + player = xbmc.Player() + current_ticks = seconds_to_ticks(player.getTime()) + if self.start is not None and self.end is not None and current_ticks >= self.start and current_ticks <= self.end: + log.debug("SkipDialog: skipping segment because current ticks ({0}) is in range".format(current_ticks)) + # If click during segment, skip it + player.seekTime(ticks_to_seconds(self.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 0000000..86be68b --- /dev/null +++ b/resources/lib/intro_skipper.py @@ -0,0 +1,158 @@ +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 get_media_segments +from resources.lib.utils import seconds_to_ticks, ticks_to_seconds, translate_path +from resources.lib.intro_skipper_utils import get_setting_skip_action, set_correct_skip_info + + +from .lazylogger import LazyLogger +from .dialogs import SkipDialog + +from typing import Literal + +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 + skip_commercial_dialog = None + skip_preview_dialog = None + skip_recap_dialog = None + + segments = None + playing_item_id = None + + log.debug("SkipService: starting service") + + 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: + log.debug("SkipService: playing item is from jellyfin : {0}".format(item_id)) + + # If item id has changed or is new, retrieve segments + if playing_item_id is None or playing_item_id != item_id : + log.debug("SkipService: item is new, retrieving media segments : {0}".format(item_id)) + segments = get_media_segments(item_id) + + # Setting global playing item to current playing item + playing_item_id = item_id + + # Handle skip only on jellyfin items + current_ticks = seconds_to_ticks(player.getTime()) + + # Handle Intros + skip_intro_dialog = self.handle_dialog(plugin_path_real, skip_intro_dialog, item_id, current_ticks, player, segments, "Intro") + # Handle Credits + skip_credit_dialog = self.handle_dialog(plugin_path_real, skip_credit_dialog, item_id, current_ticks, player, segments, "Outro") + # Handle commercial + skip_commercial_dialog = self.handle_dialog(plugin_path_real, skip_commercial_dialog, item_id, current_ticks, player, segments, "Commercial") + # Handle preview + skip_preview_dialog = self.handle_dialog(plugin_path_real, skip_preview_dialog, item_id, current_ticks, player, segments, "Preview") + # Handle recap + skip_recap_dialog = self.handle_dialog(plugin_path_real, skip_recap_dialog, item_id, current_ticks, player, segments, "Recap") + + else: + playing_item_id = None + if skip_intro_dialog is not None: + log.debug("SkipService: Playback stopped, killing Intro dialog") + skip_intro_dialog.close() + skip_intro_dialog = None + + if skip_credit_dialog is not None: + log.debug("SkipService: Playback stopped, killing Outro dialog") + skip_credit_dialog.close() + skip_credit_dialog = None + + if skip_commercial_dialog is not None: + log.debug("SkipService: Playback stopped, killing Commercial dialog") + skip_commercial_dialog.close() + skip_commercial_dialog = None + + if skip_preview_dialog is not None: + log.debug("SkipService: Playback stopped, killing Preview dialog") + skip_preview_dialog.close() + skip_preview_dialog = None + + if skip_recap_dialog is not None: + log.debug("SkipService: Playback stopped, killing Recap dialog") + skip_recap_dialog.close() + skip_recap_dialog = None + + if xbmc.Monitor().waitForAbort(1): + break + + xbmc.sleep(200) + + + def handle_dialog(self, plugin_path_real: str, dialog: SkipDialog, item_id: str, current_ticks: float, player: xbmc.Player, segments, type: Literal["Commercial", "Preview", "Recap", "Outro", "Intro"]): + skip_action = get_setting_skip_action(type) + + # In case do nothing is selected return + if skip_action == "2": + log.debug("SkipService: ignore {0} is selected".format(type)) + return None + + if dialog is None: + log.debug("SkipService: init dialog") + dialog = SkipDialog("SkipDialog.xml", plugin_path_real, "default", "720p") + + set_correct_skip_info(item_id, dialog, segments, type) + + is_segment = False + if dialog.start is not None and dialog.end is not None: + # Resets the dismiss var so that button can reappear in case of navigation in the timecodes + if (current_ticks < dialog.start or current_ticks > dialog.end) and dialog.has_been_dissmissed is True: + log.debug("SkipService: {0} skip was dismissed. It is reset beacause timecode is outside of segment") + dialog.has_been_dissmissed = False + + # Checks if segment is playing + is_segment = current_ticks >= dialog.start and current_ticks <= dialog.end + + if skip_action == "1" and is_segment: + log.debug("SkipService: {0} is set to automatic skip, skipping segment".format(type)) + # If auto skip is enabled, skips to semgent ends automatically + player.seekTime(ticks_to_seconds(dialog.end)) + xbmcgui.Dialog().notification("JellyCon", "{0} Skipped".format(type)) + elif skip_action == "0": + # Otherwise show skip dialog + if is_segment and not dialog.has_been_dissmissed: + log.debug("SkipService: {0} is playing, showing dialog".format(type)) + dialog.show() + else: + # Could not find doc on what happens when closing a closed dialog, but it seems fine + log.debug("SkipService: {0} is not playing, closing dialog".format(type)) + dialog.close() + + return dialog + + + def stop_service(self): + log.debug("IntroSkipperService Stop Called") + self.stop_thread = True diff --git a/resources/lib/intro_skipper_utils.py b/resources/lib/intro_skipper_utils.py new file mode 100644 index 0000000..1a9e46f --- /dev/null +++ b/resources/lib/intro_skipper_utils.py @@ -0,0 +1,72 @@ +from typing import Literal + +import xbmcaddon + +from .lazylogger import LazyLogger +from .dialogs import SkipDialog + +from .utils import seconds_to_ticks + +log = LazyLogger(__name__) + +def get_setting_skip_action(type: Literal["Commercial", "Preview", "Recap", "Outro", "Intro"]): + settings = xbmcaddon.Addon() + if (type == "Commercial"): + return settings.getSetting("commercial_skipper_action") + elif (type == "Preview"): + return settings.getSetting("preview_skipper_action") + elif (type == "Recap"): + return settings.getSetting("recap_skipper_action") + elif (type == "Outro"): + return settings.getSetting("credit_skipper_action") + elif (type == "Intro"): + return settings.getSetting("intro_skipper_action") + return "" + +def get_setting_skip_start_offset(type: Literal["Commercial", "Preview", "Recap", "Outro", "Intro"]): + settings = xbmcaddon.Addon() + if (type == "Commercial"): + return settings.getSettingInt("commercial_skipper_start_offset") + elif (type == "Preview"): + return settings.getSettingInt("preview_skipper_start_offset") + elif (type == "Recap"): + return settings.getSettingInt("recap_skipper_start_offset") + elif (type == "Outro"): + return settings.getSettingInt("credit_skipper_start_offset") + elif (type == "Intro"): + return settings.getSettingInt("intro_skipper_start_offset") + return 0 + +def get_setting_skip_end_offset(type: Literal["Commercial", "Preview", "Recap", "Outro", "Intro"]): + settings = xbmcaddon.Addon() + if (type == "Commercial"): + return settings.getSettingInt("commercial_skipper_end_offset") + elif (type == "Preview"): + return settings.getSettingInt("preview_skipper_end_offset") + elif (type == "Recap"): + return settings.getSettingInt("recap_skipper_end_offset") + elif (type == "Outro"): + return settings.getSettingInt("credit_skipper_end_offset") + elif (type == "Intro"): + return settings.getSettingInt("intro_skipper_end_offset") + return 0 + +def set_correct_skip_info(item_id: str, skip_dialog: SkipDialog, segments, type: Literal["Commercial", "Preview", "Recap", "Outro", "Intro"]): + 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 set media segments info + log.debug("SkipDialogInfo : Media Id has changed to {0}, setting segments".format(item_id)) + skip_dialog.media_id = item_id + skip_dialog.has_been_dissmissed = False + if segments is not None: + # Find the intro and outro timings + start = next((segment["StartTicks"] for segment in segments if segment["Type"] == type), None) + end = next((segment["EndTicks"] for segment in segments if segment["Type"] == type), None) + + # Sets timings with offsets if defined in settings + if start is not None: + skip_dialog.start = start + seconds_to_ticks(get_setting_skip_start_offset(type)) + log.debug("SkipDialogInfo : Setting {0} start to {1}".format(type, skip_dialog.start)) + if end is not None: + skip_dialog.end = end - seconds_to_ticks(get_setting_skip_end_offset(type)) + log.debug("SkipDialogInfo : Setting {0} end to {1}".format(type, skip_dialog.end)) + \ No newline at end of file diff --git a/resources/lib/play_utils.py b/resources/lib/play_utils.py index b760f83..54ca143 100644 --- a/resources/lib/play_utils.py +++ b/resources/lib/play_utils.py @@ -1183,6 +1183,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) @@ -1694,3 +1704,11 @@ 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: + log.debug("GetMediaSegments : Media segments cloud not be retrieved") + return None + return result["Items"] diff --git a/resources/lib/utils.py b/resources/lib/utils.py index 7e39fa9..7a58669 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 7346cab..31c5bb8 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -113,6 +113,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/skins/default/720p/SkipDialog.xml b/resources/skins/default/720p/SkipDialog.xml new file mode 100644 index 0000000..5aa51fc --- /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 8e86609..d68b84b 100644 --- a/service.py +++ b/service.py @@ -20,6 +20,7 @@ from resources.lib.datamanager import clear_old_cache_data 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 @@ if context_menu: 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 @@ first_run = True 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 @@ if play_next_service: # 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")