From 7f8939cee74d85e2817a5fab65f350338f0accf3 Mon Sep 17 00:00:00 2001 From: croneter Date: Sun, 7 Feb 2021 21:42:47 +0100 Subject: [PATCH] Add skip intro functionality --- .../resource.language.en_gb/strings.po | 4 ++ resources/lib/app/application.py | 2 + resources/lib/app/playstate.py | 3 +- resources/lib/kodimonitor.py | 7 ++ resources/lib/playlist_func.py | 27 ++++--- resources/lib/plex_api/base.py | 9 +++ resources/lib/plex_api/media.py | 10 +++ resources/lib/plex_functions.py | 8 ++- resources/lib/service_entry.py | 6 +- resources/lib/skip_plex_intro.py | 33 +++++++++ resources/lib/windows/skip_intro.py | 68 ++++++++++++++++++ resources/settings.xml | 1 + resources/skins/default/1080i/skip_intro.xml | 52 ++++++++++++++ .../default/media/skipintro-background.png | Bin 0 -> 2104 bytes .../skins/default/media/skipintro-button.png | Bin 0 -> 6210 bytes 15 files changed, 218 insertions(+), 12 deletions(-) create mode 100644 resources/lib/skip_plex_intro.py create mode 100644 resources/lib/windows/skip_intro.py create mode 100644 resources/skins/default/1080i/skip_intro.xml create mode 100644 resources/skins/default/media/skipintro-background.png create mode 100644 resources/skins/default/media/skipintro-button.png diff --git a/resources/language/resource.language.en_gb/strings.po b/resources/language/resource.language.en_gb/strings.po index 83cf68a6..76cf8365 100644 --- a/resources/language/resource.language.en_gb/strings.po +++ b/resources/language/resource.language.en_gb/strings.po @@ -571,6 +571,10 @@ msgctxt "#30524" msgid "Select Plex libraries to sync" msgstr "" +# PKC Settings - Playback +msgctxt "#30525" +msgid "Skip intro" +msgstr "" # PKC Settings - Playback msgctxt "#30527" diff --git a/resources/lib/app/application.py b/resources/lib/app/application.py index d3dcb06d..930810c5 100644 --- a/resources/lib/app/application.py +++ b/resources/lib/app/application.py @@ -50,6 +50,8 @@ class App(object): self.metadata_thread = None # Instance of ImageCachingThread() self.caching_thread = None + # Dialog to skip intro + self.skip_intro_dialog = None @property def is_playing(self): diff --git a/resources/lib/app/playstate.py b/resources/lib/app/playstate.py index aaf38e19..1f2c7ff4 100644 --- a/resources/lib/app/playstate.py +++ b/resources/lib/app/playstate.py @@ -33,7 +33,8 @@ class PlayState(object): 'muted': False, 'playmethod': None, 'playcount': None, - 'external_player': False # bool - xbmc.Player().isExternalPlayer() + 'external_player': False, # bool - xbmc.Player().isExternalPlayer() + 'intro_markers': [], } def __init__(self): diff --git a/resources/lib/kodimonitor.py b/resources/lib/kodimonitor.py index e317c312..93e0185b 100644 --- a/resources/lib/kodimonitor.py +++ b/resources/lib/kodimonitor.py @@ -334,6 +334,10 @@ class KodiMonitor(xbmc.Monitor): container_key = '/playQueues/%s' % playqueue.id else: container_key = '/library/metadata/%s' % plex_id + # Mechanik for Plex skip intro feature + if utils.settings('enableSkipIntro') == 'true': + api = API(item.xml) + status['intro_markers'] = api.intro_markers() # Remember the currently playing item app.PLAYSTATE.item = item # Remember that this player has been active @@ -366,6 +370,9 @@ def _playback_cleanup(ended=False): """ LOG.debug('playback_cleanup called. Active players: %s', app.PLAYSTATE.active_players) + if app.APP.skip_intro_dialog: + app.APP.skip_intro_dialog.close() + app.APP.skip_intro_dialog = None # We might have saved a transient token from a user flinging media via # Companion (if we could not use the playqueue to store the token) app.CONN.plex_transient_token = None diff --git a/resources/lib/playlist_func.py b/resources/lib/playlist_func.py index d6b8d92a..1e16b11b 100644 --- a/resources/lib/playlist_func.py +++ b/resources/lib/playlist_func.py @@ -470,7 +470,8 @@ def init_plex_playqueue(playlist, plex_id=None, kodi_item=None): params = { 'next': 0, 'type': playlist.type, - 'uri': item.uri + 'uri': item.uri, + 'includeMarkers': 1, # e.g. start + stop of intros } xml = DU().downloadUrl(url="{server}/%ss" % playlist.kind, action_type="POST", @@ -562,9 +563,15 @@ def add_item_to_plex_playqueue(playlist, pos, plex_id=None, kodi_item=None): item = playlist_item_from_plex(plex_id) else: item = playlist_item_from_kodi(kodi_item) - url = "{server}/%ss/%s?uri=%s" % (playlist.kind, playlist.id, item.uri) + url = "{server}/%ss/%s" % (playlist.kind, playlist.id) + parameters = { + 'uri': item.uri, + 'includeMarkers': 1, # e.g. start + stop of intros + } # Will always put the new item at the end of the Plex playlist - xml = DU().downloadUrl(url, action_type="PUT") + xml = DU().downloadUrl(url, + action_type="PUT", + parameters=parameters) try: xml[-1].attrib except (TypeError, AttributeError, KeyError, IndexError): @@ -663,10 +670,13 @@ def get_PMS_playlist(playlist, playlist_id=None): Raises PlaylistError if something went wrong """ playlist_id = playlist_id if playlist_id else playlist.id + parameters = {'includeMarkers': 1} if playlist.kind == 'playList': - xml = DU().downloadUrl("{server}/playlists/%s/items" % playlist_id) + xml = DU().downloadUrl("{server}/playlists/%s/items" % playlist_id, + parameters=parameters) else: - xml = DU().downloadUrl("{server}/playQueues/%s" % playlist_id) + xml = DU().downloadUrl("{server}/playQueues/%s" % playlist_id, + parameters=parameters) try: xml.attrib except AttributeError: @@ -765,9 +775,10 @@ def get_pms_playqueue(playqueue_id): """ Returns the Plex playqueue as an etree XML or None if unsuccessful """ - xml = DU().downloadUrl( - "{server}/playQueues/%s" % playqueue_id, - headerOptions={'Accept': 'application/xml'}) + parameters = {'includeMarkers': 1} + xml = DU().downloadUrl("{server}/playQueues/%s" % playqueue_id, + parameters=parameters, + headerOptions={'Accept': 'application/xml'}) try: xml.attrib except AttributeError: diff --git a/resources/lib/plex_api/base.py b/resources/lib/plex_api/base.py index 65f75887..ca1045de 100644 --- a/resources/lib/plex_api/base.py +++ b/resources/lib/plex_api/base.py @@ -42,6 +42,7 @@ class Base(object): self._writers = [] self._producers = [] self._locations = [] + self._intro_markers = [] self._guids = {} self._coll_match = None # Plex DB attributes @@ -469,6 +470,14 @@ class Base(object): guid = child.get('id') guid = guid.split('://', 1) self._guids[guid[0]] = guid[1] + elif child.tag == 'Marker' and child.get('type') == 'intro': + intro = (cast(float, child.get('startTimeOffset')), + cast(float, child.get('endTimeOffset'))) + if None in intro: + # Safety net if PMS xml is not as expected + continue + intro = (intro[0] / 1000.0, intro[1] / 1000.0) + self._intro_markers.append(intro) # Plex Movie agent (legacy) or "normal" Plex tv show agent if not self._guids: guid = self.xml.get('guid') diff --git a/resources/lib/plex_api/media.py b/resources/lib/plex_api/media.py index 195126b8..bfa47c08 100644 --- a/resources/lib/plex_api/media.py +++ b/resources/lib/plex_api/media.py @@ -42,6 +42,16 @@ class Media(object): value = self.xml[0][self.part].get(key) return value + def intro_markers(self): + """ + Returns a list of tuples with floats (startTimeOffset, endTimeOffset) + in Koditime or an empty list. + Each entry represents an (episode) intro that Plex detected and that + can be skipped + """ + self._scan_children() + return self._intro_markers + def video_codec(self): """ Returns the video codec and resolution for the child and part selected. diff --git a/resources/lib/plex_functions.py b/resources/lib/plex_functions.py index d7fa294c..c48a0aa9 100644 --- a/resources/lib/plex_functions.py +++ b/resources/lib/plex_functions.py @@ -472,6 +472,7 @@ def GetPlexMetadata(key, reraise=False): 'includeReviews': 1, 'includeRelated': 0, # Similar movies => Video -> Related 'skipRefresh': 1, + 'includeMarkers': 1, # e.g. start + stop of intros # 'includeRelatedCount': 0, # 'includeOnDeck': 1, # 'includeChapters': 1, @@ -511,7 +512,9 @@ def get_playback_xml(url, server_name, authenticate=True, token=None): """ Returns None if something went wrong """ - header_options = {'X-Plex-Token': token} if not authenticate else None + header_options = {'includeMarkers': 1} + if not authenticate: + header_options['X-Plex-Token'] = token try: xml = DU().downloadUrl(url, authenticate=authenticate, @@ -801,7 +804,8 @@ def init_plex_playqueue(plex_id, plex_type, section_uuid, trailers=False): (app.CONN.machine_identifier, plex_id)), 'includeChapters': '1', 'shuffle': '0', - 'repeat': '0' + 'repeat': '0', + 'includeMarkers': 1, # e.g. start + stop of intros } if trailers is True: args['extrasPrefixCount'] = utils.settings('trailerNumber') diff --git a/resources/lib/service_entry.py b/resources/lib/service_entry.py index 86356107..c3390b3b 100644 --- a/resources/lib/service_entry.py +++ b/resources/lib/service_entry.py @@ -18,6 +18,7 @@ from . import variables as v from . import app from . import loghandler from . import backgroundthread +from . import skip_plex_intro from .windows import userselect ############################################################################### @@ -545,7 +546,10 @@ class Service(object): self.playqueue.start() self.alexa.start() - xbmc.sleep(100) + elif app.APP.is_playing: + skip_plex_intro.check() + + xbmc.sleep(200) # EXITING PKC # Tell all threads to terminate (e.g. several lib sync threads) diff --git a/resources/lib/skip_plex_intro.py b/resources/lib/skip_plex_intro.py new file mode 100644 index 00000000..8051e686 --- /dev/null +++ b/resources/lib/skip_plex_intro.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from .windows.skip_intro import SkipIntroDialog +from . import app, variables as v + + +def skip_intro(intros): + progress = app.APP.player.getTime() + in_intro = False + for start, end in intros: + if start <= progress < end: + in_intro = True + if in_intro and app.APP.skip_intro_dialog is None: + app.APP.skip_intro_dialog = SkipIntroDialog('skip_intro.xml', + v.ADDON_PATH, + 'default', + '1080i', + intro_end=end) + app.APP.skip_intro_dialog.show() + elif not in_intro and app.APP.skip_intro_dialog is not None: + app.APP.skip_intro_dialog.close() + app.APP.skip_intro_dialog = None + + +def check(): + with app.APP.lock_playqueues: + if len(app.PLAYSTATE.active_players) != 1: + return + playerid = list(app.PLAYSTATE.active_players)[0] + intros = app.PLAYSTATE.player_states[playerid]['intro_markers'] + if not intros: + return + skip_intro(intros) diff --git a/resources/lib/windows/skip_intro.py b/resources/lib/windows/skip_intro.py new file mode 100644 index 00000000..fa66582b --- /dev/null +++ b/resources/lib/windows/skip_intro.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +from logging import getLogger + +from xbmcgui import WindowXMLDialog + +from .. import app + +logger = getLogger('PLEX.skipintro') + + +class SkipIntroDialog(WindowXMLDialog): + + def __init__(self, *args, **kwargs): + self.intro_end = kwargs.pop('intro_end', None) + + self._showing = False + self._on_hold = False + + logger.debug('SkipIntroDialog initialized, ends at %s', + self.intro_end) + WindowXMLDialog.__init__(self, *args, **kwargs) + + def show(self): + if not self.intro_end: + self.close() + return + + if not self.on_hold and not self.showing: + logger.debug('Showing dialog') + self.showing = True + WindowXMLDialog.show(self) + + def close(self): + if self.showing: + self.showing = False + logger.debug('Closing dialog') + WindowXMLDialog.close(self) + + def onClick(self, control_id): # pylint: disable=invalid-name + if self.intro_end and control_id == 3002: # 3002 = Skip Intro button + if app.APP.is_playing: + self.on_hold = True + logger.info('Skipping intro, seeking to %s', self.intro_end) + app.APP.player.seekTime(self.intro_end) + self.close() + + def onAction(self, action): # pylint: disable=invalid-name + close_actions = [10, 13, 92] + # 10 = previousmenu, 13 = stop, 92 = back + if action in close_actions: + self.on_hold = True + self.close() + + @property + def showing(self): + return self._showing + + @showing.setter + def showing(self, value): + self._showing = bool(value) + + @property + def on_hold(self): + return self._on_hold + + @on_hold.setter + def on_hold(self, value): + self._on_hold = bool(value) diff --git a/resources/settings.xml b/resources/settings.xml index 6b943a83..b046ce12 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -108,6 +108,7 @@ + diff --git a/resources/skins/default/1080i/skip_intro.xml b/resources/skins/default/1080i/skip_intro.xml new file mode 100644 index 00000000..6b0ca7c8 --- /dev/null +++ b/resources/skins/default/1080i/skip_intro.xml @@ -0,0 +1,52 @@ + + + 3002 + Dialog.Close(fullscreeninfo,true) + Dialog.Close(videoosd,true) + + + + + + + + + + 64 + + 100% + 64 + skipintro-background.png + + + 12 + 20 + 70% + + horizontal + 40 + 10 + right + + + 40 + auto + font20_title + 32 + ddffffff + eeffffff + ddffffff + 22000000 + center + center + skipintro-button.png + skipintro-button.png + skipintro-button.png + skipintro-button.png + + + + + + + diff --git a/resources/skins/default/media/skipintro-background.png b/resources/skins/default/media/skipintro-background.png new file mode 100644 index 0000000000000000000000000000000000000000..215944e6a72d57fc2c847225af1bc8fc982e2b43 GIT binary patch literal 2104 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 literal 0 HcmV?d00001