diff --git a/default.py b/default.py index 131c923a5..ad06a5786 100644 --- a/default.py +++ b/default.py @@ -39,7 +39,21 @@ def __init__(self): mode = params.get('mode', '') itemid = params.get('id', '') - if mode == 'play': + if mode == 'playstrm': + while not utils.window('plex.playlist.play'): + xbmc.sleep(25) + if utils.window('plex.playlist.aborted'): + LOG.info("playback aborted") + break + else: + LOG.info("Playback started") + xbmcplugin.setResolvedUrl(int(argv[1]), + False, + xbmcgui.ListItem()) + utils.window('plex.playlist.play', clear=True) + utils.window('plex.playlist.aborted', clear=True) + + elif mode == 'play': self.play() elif mode == 'plex_node': diff --git a/resources/lib/app/playstate.py b/resources/lib/app/playstate.py index c99aeafa5..052b43327 100644 --- a/resources/lib/app/playstate.py +++ b/resources/lib/app/playstate.py @@ -53,12 +53,25 @@ def __init__(self): } self.played_info = {} - # Set by SpecialMonitor - did user choose to resume playback or start from the - # beginning? - self.resume_playback = False + # Set by SpecialMonitor - did user choose to resume playback or start + # from the beginning? + # Do set to None if NO resume dialog is displayed! True/False otherwise + self.resume_playback = None + # Don't ask user whether to resume but immediatly resume + self.autoplay = False # Was the playback initiated by the user using the Kodi context menu? self.context_menu_play = False - # Set by context menu - shall we force-transcode the next playing item? - self.force_transcode = False - # Which Kodi player is/has been active? (either int 1, 2 or 3) + # Which Kodi player is/has been active? (either int 0, 1, 2) self.active_players = set() + # Have we initiated playback via Plex Companion or Alexa - so from the + # Plex side of things? + self.initiated_by_plex = False + # PKC adds/replaces items in the playqueue. We need to use + # xbmcplugin.setResolvedUrl() AFTER an item has successfully been added + # This flag is set by Kodimonitor/xbmc.Monitor() and the Playlist.OnAdd + # signal only when the currently playing item that called the + # webservice has successfully been processed + self.playlist_ready = False + # Flag for Kodimonitor to check when the correct item has been + # processed and the Playlist.OnAdd signal has been received + self.playlist_start_pos = None diff --git a/resources/lib/context_entry.py b/resources/lib/context_entry.py index 211ca7098..9c6a0236d 100644 --- a/resources/lib/context_entry.py +++ b/resources/lib/context_entry.py @@ -7,7 +7,7 @@ from .plex_api import API from .plex_db import PlexDB -from . import context, plex_functions as PF, playqueue as PQ +from . import context, plex_functions as PF from . import utils, variables as v, app ############################################################################### @@ -112,8 +112,7 @@ def _action_menu(self): """ selected = self._selected_option if selected == OPTIONS['Transcode']: - app.PLAYSTATE.force_transcode = True - self._PMS_play() + self._PMS_play(transcode=True) elif selected == OPTIONS['PMS_Play']: self._PMS_play() elif selected == OPTIONS['Extras']: @@ -139,17 +138,21 @@ def _delete_item(self): if PF.delete_item_from_pms(self.plex_id) is False: utils.dialog("ok", heading="{plex}", line1=utils.lang(30414)) - def _PMS_play(self): + def _PMS_play(self, transcode=False): """ For using direct paths: Initiates playback using the PMS """ - playqueue = PQ.get_playqueue_from_type( - v.KODI_PLAYLIST_TYPE_FROM_KODI_TYPE[self.kodi_type]) - playqueue.clear() - app.PLAYSTATE.context_menu_play = True - handle = self.api.path(force_first_media=False, force_addon=True) - handle = 'RunPlugin(%s)' % handle - xbmc.executebuiltin(handle.encode('utf-8')) + path = ('http://127.0.0.1:%s/plex/play/file.strm?plex_id=%s' + % (v.WEBSERVICE_PORT, self.plex_id)) + if self.plex_type: + path += '&plex_type=%s' % self.plex_type + if self.kodi_id: + path += '&kodi_id=%s' % self.kodi_id + if self.kodi_type: + path += '&kodi_type=%s' % self.kodi_type + if transcode: + path += '&transcode=true' + xbmc.executebuiltin(('PlayMedia(%s)' % path).encode('utf-8')) def _extras(self): """ diff --git a/resources/lib/entrypoint.py b/resources/lib/entrypoint.py index 0c41266be..5f14b7e22 100644 --- a/resources/lib/entrypoint.py +++ b/resources/lib/entrypoint.py @@ -217,7 +217,8 @@ def show_listing(xml, plex_type=None, section_id=None, synched=True, key=None, # Need to chain keys for navigation widgets.KEY = key # Process all items to show - widgets.attach_kodi_ids(xml) + if synched: + widgets.attach_kodi_ids(xml) all_items = widgets.process_method_on_list(widgets.generate_item, xml) all_items = widgets.process_method_on_list(widgets.prepare_listitem, all_items) diff --git a/resources/lib/itemtypes/movies.py b/resources/lib/itemtypes/movies.py index 409534d0a..6a83a2d46 100644 --- a/resources/lib/itemtypes/movies.py +++ b/resources/lib/itemtypes/movies.py @@ -72,10 +72,12 @@ def add_update(self, xml, section, children=None): scraper='metadata.local') if do_indirect: # Set plugin path and media flags using real filename - filename = api.file_name(force_first_media=True) - path = 'plugin://%s.movies/' % v.ADDON_ID - filename = ('%s?plex_id=%s&plex_type=%s&mode=play&filename=%s' - % (path, plex_id, v.PLEX_TYPE_MOVIE, filename)) + path = 'http://127.0.0.1:%s/plex/kodi/movies/' % v.WEBSERVICE_PORT + filename = '{0}/file.strm?kodi_id={1}&kodi_type={2}&plex_id={0}&plex_type={3}' + filename = filename.format(plex_id, + kodi_id, + v.KODI_TYPE_MOVIE, + v.PLEX_TYPE_MOVIE) playurl = filename kodi_pathid = self.kodidb.get_path(path) diff --git a/resources/lib/itemtypes/tvshows.py b/resources/lib/itemtypes/tvshows.py index fc616cdcb..6e713d34e 100644 --- a/resources/lib/itemtypes/tvshows.py +++ b/resources/lib/itemtypes/tvshows.py @@ -180,7 +180,8 @@ def add_update(self, xml, section, children=None): scraper='metadata.local') else: # Set plugin path - toplevelpath = "plugin://%s.tvshows/" % v.ADDON_ID + toplevelpath = ('http://127.0.0.1:%s/plex/kodi/shows/' + % v.WEBSERVICE_PORT) path = "%s%s/" % (toplevelpath, plex_id) # Do NOT set a parent id because addon-path cannot be "stacked" toppathid = None @@ -448,22 +449,25 @@ def add_update(self, xml, section, children=None): if do_indirect: # Set plugin path - do NOT use "intermediate" paths for the show # as with direct paths! - filename = api.file_name(force_first_media=True) - path = 'plugin://%s.tvshows/%s/' % (v.ADDON_ID, show_id) - filename = ('%s?plex_id=%s&plex_type=%s&mode=play&filename=%s' - % (path, plex_id, v.PLEX_TYPE_EPISODE, filename)) + # Set plugin path and media flags using real filename + path = ('http://127.0.0.1:%s/plex/kodi/shows/%s/' + % (v.WEBSERVICE_PORT, show_id)) + filename = '{0}/file.strm?kodi_id={1}&kodi_type={2}&plex_id={0}&plex_type={3}' + filename = filename.format(plex_id, + kodi_id, + v.KODI_TYPE_EPISODE, + v.PLEX_TYPE_EPISODE) playurl = filename # Root path tvshows/ already saved in Kodi DB - kodi_pathid = self.kodidb.add_path(path) - if not app.SYNC.direct_paths: - # need to set a 2nd file entry for a path without plex show id - # This fixes e.g. context menu and widgets working as they - # should - # A dirty hack, really - path_2 = 'plugin://%s.tvshows/' % v.ADDON_ID - # filename_2 is exactly the same as filename - # so WITH plex show id! - kodi_pathid_2 = self.kodidb.add_path(path_2) + kodi_pathid = self.kodidb.get_path(path) + # HACK + # need to set a 2nd file entry for a path without plex show id + # This fixes e.g. context menu and widgets working as they + # should + path_2 = 'http://127.0.0.1:%s/plex/kodi/shows/' % v.WEBSERVICE_PORT + # filename_2 is exactly the same as filename + # so WITH plex show id! + kodi_pathid_2 = self.kodidb.add_path(path_2) # UPDATE THE EPISODE ##### if update_item: diff --git a/resources/lib/kodi_db/video.py b/resources/lib/kodi_db/video.py index 98fd92fb5..2a52c34b2 100644 --- a/resources/lib/kodi_db/video.py +++ b/resources/lib/kodi_db/video.py @@ -5,12 +5,12 @@ from sqlite3 import IntegrityError from . import common -from .. import path_ops, timing, variables as v, app +from .. import path_ops, timing, variables as v LOG = getLogger('PLEX.kodi_db.video') -MOVIE_PATH = 'plugin://%s.movies/' % v.ADDON_ID -SHOW_PATH = 'plugin://%s.tvshows/' % v.ADDON_ID +MOVIE_PATH = 'http://127.0.0.1:%s/plex/kodi/movies/' % v.WEBSERVICE_PORT +SHOW_PATH = 'http://127.0.0.1:%s/plex/kodi/shows/' % v.WEBSERVICE_PORT class KodiVideoDB(common.KodiDBBase): @@ -174,15 +174,17 @@ def modify_file(self, filename, path_id, date_added): def obsolete_file_ids(self): """ Returns a generator for idFile of all Kodi file ids that do not have a - dateAdded set (dateAdded NULL) and the filename start with - 'plugin://plugin.video.plexkodiconnect' - These entries should be deleted as they're created falsely by Kodi. - """ - return (x[0] for x in self.cursor.execute(''' - SELECT idFile FROM files - WHERE dateAdded IS NULL - AND strFilename LIKE \'plugin://plugin.video.plexkodiconnect%\' - ''')) + dateAdded set (dateAdded NULL) and the associated path entry has + a field noUpdate of NULL as well as dateAdded of NULL + """ + return (x[0] for x in self.cursor.execute(""" + SELECT files.idFile + FROM files + LEFT JOIN path ON path.idPath = files.idPath + WHERE files.dateAdded IS NULL + AND path.noUpdate IS NULL + AND path.dateAdded IS NULL + """)) def show_id_from_path(self, path): """ diff --git a/resources/lib/kodimonitor.py b/resources/lib/kodimonitor.py index f5ea2288b..75727da76 100644 --- a/resources/lib/kodimonitor.py +++ b/resources/lib/kodimonitor.py @@ -14,15 +14,21 @@ from .plex_db import PlexDB from . import kodi_db from .downloadutils import DownloadUtils as DU -from . import utils, timing, plex_functions as PF, playback -from . import json_rpc as js, playqueue as PQ, playlist_func as PL -from . import backgroundthread, app, variables as v +from . import utils, timing, plex_functions as PF, json_rpc as js +from . import playqueue as PQ, backgroundthread, app, variables as v LOG = getLogger('PLEX.kodimonitor') # "Start from beginning", "Play from beginning" -STRINGS = (utils.try_encode(utils.lang(12021)), - utils.try_encode(utils.lang(12023))) +STRINGS = (utils.lang(12021).encode('utf-8'), + utils.lang(12023).encode('utf-8')) + + +class MonitorError(Exception): + """ + Exception we raise for all errors associated with xbmc.Monitor + """ + pass class KodiMonitor(xbmc.Monitor): @@ -31,11 +37,14 @@ class KodiMonitor(xbmc.Monitor): """ def __init__(self): self._already_slept = False - self.hack_replay = None - xbmc.Monitor.__init__(self) + # Info to the currently playing item + self.playerid = None + self.playlistid = None + self.playqueue = None for playerid in app.PLAYSTATE.player_states: app.PLAYSTATE.player_states[playerid] = copy.deepcopy(app.PLAYSTATE.template) app.PLAYSTATE.old_player_states[playerid] = copy.deepcopy(app.PLAYSTATE.template) + xbmc.Monitor.__init__(self) LOG.info("Kodi monitor started.") def onScanStarted(self, library): @@ -67,42 +76,22 @@ def onNotification(self, sender, method, data): data = loads(data, 'utf-8') LOG.debug("Method: %s Data: %s", method, data) - # Hack - if not method == 'Player.OnStop': - self.hack_replay = None - if method == "Player.OnPlay": with app.APP.lock_playqueues: - self.PlayBackStart(data) + self.on_play(data) elif method == "Player.OnStop": - # Should refresh our video nodes, e.g. on deck - # xbmc.executebuiltin('ReloadSkin()') - if (self.hack_replay and not data.get('end') and - self.hack_replay == data['item']): - # Hack for add-on paths - self.hack_replay = None - with app.APP.lock_playqueues: - self._hack_addon_paths_replay_video() - elif data.get('end'): + if data.get('end'): with app.APP.lock_playqueues: _playback_cleanup(ended=True) else: with app.APP.lock_playqueues: _playback_cleanup() elif method == 'Playlist.OnAdd': - if 'item' in data and data['item'].get('type') == v.KODI_TYPE_SHOW: - # Hitting the "browse" button on tv show info dialog - # Hence show the tv show directly - xbmc.executebuiltin("Dialog.Close(all, true)") - js.activate_window('videos', - 'videodb://tvshows/titles/%s/' % data['item']['id']) - with app.APP.lock_playqueues: - self._playlist_onadd(data) + self._playlist_onadd(data) elif method == 'Playlist.OnRemove': self._playlist_onremove(data) elif method == 'Playlist.OnClear': - with app.APP.lock_playqueues: - self._playlist_onclear(data) + self._playlist_onclear(data) elif method == "VideoLibrary.OnUpdate": # Manually marking as watched/unwatched playcount = data.get('playcount') @@ -144,60 +133,24 @@ def onNotification(self, sender, method, data): LOG.info('Kodi OnQuit detected - shutting down') app.APP.stop_pkc = True - @staticmethod - def _hack_addon_paths_replay_video(): - """ - Hack we need for RESUMABLE items because Kodi lost the path of the - last played item that is now being replayed (see playback.py's - Player().play()) Also see playqueue.py _compare_playqueues() - - Needed if user re-starts the same video from the library using addon - paths. (Video is only added to playqueue, then immediately stoppen. - There is no playback initialized by Kodi.) Log excerpts: - Method: Playlist.OnAdd Data: - {u'item': {u'type': u'movie', u'id': 4}, - u'playlistid': 1, - u'position': 0} - Now we would hack! - Method: Player.OnStop Data: - {u'item': {u'type': u'movie', u'id': 4}, - u'end': False} - (within the same micro-second!) - """ - LOG.info('Detected re-start of playback of last item') - old = app.PLAYSTATE.old_player_states[1] - kwargs = { - 'plex_id': old['plex_id'], - 'plex_type': old['plex_type'], - 'path': old['file'], - 'resolve': False - } - task = backgroundthread.FunctionAsTask(playback.playback_triage, - None, - **kwargs) - backgroundthread.BGThreader.addTasksToFront([task]) - def _playlist_onadd(self, data): - """ - Called if an item is added to a Kodi playlist. Example data dict: - { - u'item': { - u'type': u'movie', - u'id': 2}, - u'playlistid': 1, - u'position': 0 - } - Will NOT be called if playback initiated by Kodi widgets - """ - if 'id' not in data['item']: + ''' + Called when a new item is added to a Kodi playqueue + ''' + if 'item' in data and data['item'].get('type') == v.KODI_TYPE_SHOW: + # Hitting the "browse" button on tv show info dialog + # Hence show the tv show directly + xbmc.executebuiltin("Dialog.Close(all, true)") + js.activate_window('videos', + 'videodb://tvshows/titles/%s/' % data['item']['id']) return - old = app.PLAYSTATE.old_player_states[data['playlistid']] - if (not app.SYNC.direct_paths and - data['position'] == 0 and data['playlistid'] == 1 and - not PQ.PLAYQUEUES[data['playlistid']].items and - data['item']['type'] == old['kodi_type'] and - data['item']['id'] == old['kodi_id']): - self.hack_replay = data['item'] + + if data['position'] == 0: + self.playlistid = data['playlistid'] + if app.PLAYSTATE.playlist_start_pos == data['position']: + LOG.debug('Playlist ready') + app.PLAYSTATE.playlist_ready = True + app.PLAYSTATE.playlist_start_pos = None def _playlist_onremove(self, data): """ @@ -209,20 +162,25 @@ def _playlist_onremove(self, data): """ pass - @staticmethod - def _playlist_onclear(data): + def _playlist_onclear(self, data): """ Called if a Kodi playlist is cleared. Example data dict: { u'playlistid': 1, } + Let's NOT use this as Kodi's responses when e.g. playing an entire + folder are NOT threadsafe: Playlist.OnAdd might be added first, then + Playlist.OnClear might be received LATER """ - playqueue = PQ.PLAYQUEUES[data['playlistid']] - if not playqueue.is_pkc_clear(): - playqueue.pkc_edit = True - playqueue.clear(kodi=False) - else: - LOG.debug('Detected PKC clear - ignoring') + if self.playlistid == data['playlistid']: + LOG.debug('Resetting autoplay') + app.PLAYSTATE.autoplay = False + # playqueue = PQ.PLAYQUEUES[data['playlistid']] + # if not playqueue.is_pkc_clear(): + # playqueue.pkc_edit = True + # playqueue.clear(kodi=False) + # else: + # LOG.debug('Detected PKC clear - ignoring') @staticmethod def _get_ids(kodi_id, kodi_type, path): @@ -243,24 +201,6 @@ def _get_ids(kodi_id, kodi_type, path): plex_type = db_item['plex_type'] return plex_id, plex_type - @staticmethod - def _add_remaining_items_to_playlist(playqueue): - """ - Adds all but the very first item of the Kodi playlist to the Plex - playqueue - """ - items = js.playlist_get_items(playqueue.playlistid) - if not items: - LOG.error('Could not retrieve Kodi playlist items') - return - # Remove first item - items.pop(0) - try: - for i, item in enumerate(items): - PL.add_item_to_plex_playqueue(playqueue, i + 1, kodi_item=item) - except PL.PlaylistError: - LOG.info('Could not build Plex playlist for: %s', items) - def _json_item(self, playerid): """ Uses JSON RPC to get the playing item's info and returns the tuple @@ -283,7 +223,73 @@ def _json_item(self, playerid): json_item.get('type'), json_item.get('file')) - def PlayBackStart(self, data): + def _get_playerid(self, data): + """ + Sets self.playerid with an int 0, 1 [or 2] or raises MonitorError + 0: usually video + 1: usually audio + """ + try: + self.playerid = data['player']['playerid'] + except (TypeError, KeyError): + LOG.info('Aborting playback report - data invalid for updates: %s', + data) + raise MonitorError() + if self.playerid == -1: + # Kodi might return -1 for "last player" + try: + self.playerid = js.get_player_ids()[0] + except IndexError: + LOG.error('Coud not get playerid for data: %s', data) + raise MonitorError() + + def _check_playing_item(self, data): + """ + Returns a PF.PlaylistItem() for the currently playing item + Raises MonitorError or IndexError if we need to init the PKC playqueue + """ + info = js.get_player_props(self.playerid) + LOG.debug('Current info for player %s: %s', self.playerid, info) + position = info['position'] if info['position'] != -1 else 0 + kodi_playlist = js.playlist_get_items(self.playerid) + LOG.debug('Current Kodi playlist: %s', kodi_playlist) + playlistitem = PQ.PlaylistItem(kodi_item=kodi_playlist[position]) + if isinstance(self.playqueue.items[0], PQ.PlaylistItemDummy): + # This dummy item will be deleted by webservice soon - it won't + # play + LOG.debug('Dummy item detected') + position = 1 + elif playlistitem != self.playqueue.items[position]: + LOG.debug('Different playqueue items: %s vs. %s ', + playlistitem, self.playqueue.items[position]) + raise MonitorError() + # Return the PKC playqueue item - contains more info + return self.playqueue.items[position] + + def _load_playerstate(self, item): + """ + Pass in a PF.PlaylistItem(). Will then set the currently playing + state with app.PLAYSTATE.player_states[self.playerid] + """ + if self.playqueue.id: + container_key = '/playQueues/%s' % self.playqueue.id + else: + container_key = '/library/metadata/%s' % item.plex_id + status = app.PLAYSTATE.player_states[self.playerid] + # Remember that this player has been active + app.PLAYSTATE.active_players.add(self.playerid) + status.update(js.get_player_props(self.playerid)) + status['container_key'] = container_key + status['file'] = item.file + status['kodi_id'] = item.kodi_id + status['kodi_type'] = item.kodi_type + status['plex_id'] = item.plex_id + status['plex_type'] = item.plex_type + status['playmethod'] = item.playmethod + status['playcount'] = item.playcount + LOG.debug('Set player state for player %s: %s', self.playerid, status) + + def on_play(self, data): """ Called whenever playback is started. Example data: { @@ -292,87 +298,25 @@ def PlayBackStart(self, data): } Unfortunately when using Widgets, Kodi doesn't tell us shit """ + # Some init self._already_slept = False + self.playerid = None # Get the type of media we're playing try: - playerid = data['player']['playerid'] - except (TypeError, KeyError): - LOG.info('Aborting playback report - item invalid for updates %s', - data) + self._get_playerid(data) + except MonitorError: return - kodi_id = data['item'].get('id') if 'item' in data else None - kodi_type = data['item'].get('type') if 'item' in data else None - path = data['item'].get('file') if 'item' in data else None - if playerid == -1: - # Kodi might return -1 for "last player" - # Getting the playerid is really a PITA - try: - playerid = js.get_player_ids()[0] - except IndexError: - # E.g. Kodi 18 doesn't tell us anything useful - if kodi_type in v.KODI_VIDEOTYPES: - playlist_type = v.KODI_TYPE_VIDEO_PLAYLIST - elif kodi_type in v.KODI_AUDIOTYPES: - playlist_type = v.KODI_TYPE_AUDIO_PLAYLIST - else: - LOG.error('Unexpected type %s, data %s', kodi_type, data) - return - playerid = js.get_playlist_id(playlist_type) - if not playerid: - LOG.error('Coud not get playerid for data %s', data) - return - playqueue = PQ.PLAYQUEUES[playerid] - info = js.get_player_props(playerid) - if playqueue.kodi_playlist_playback: - # Kodi will tell us the wrong position - of the playlist, not the - # playqueue, when user starts playing from a playlist :-( - pos = 0 - LOG.debug('Detected playback from a Kodi playlist') - else: - pos = info['position'] if info['position'] != -1 else 0 - LOG.debug('Detected position %s for %s', pos, playqueue) - status = app.PLAYSTATE.player_states[playerid] + self.playqueue = PQ.PLAYQUEUES[self.playerid] + LOG.debug('Current PKC playqueue: %s', self.playqueue) + item = None try: - item = playqueue.items[pos] - LOG.debug('PKC playqueue item is: %s', item) - except IndexError: - # PKC playqueue not yet initialized - LOG.debug('Position %s not in PKC playqueue yet', pos) - initialize = True - else: - if not kodi_id: - kodi_id, kodi_type, path = self._json_item(playerid) - if kodi_id and item.kodi_id: - if item.kodi_id != kodi_id or item.kodi_type != kodi_type: - LOG.debug('Detected different Kodi id') - initialize = True - else: - initialize = False - else: - # E.g. clips set-up previously with no Kodi DB entry - if not path: - kodi_id, kodi_type, path = self._json_item(playerid) - if path == '': - LOG.debug('Detected empty path: aborting playback report') - return - if item.file != path: - # Clips will get a new path - LOG.debug('Detected different path') - try: - tmp_plex_id = int(utils.REGEX_PLEX_ID.findall(path)[0]) - except IndexError: - LOG.debug('No Plex id in path, need to init playqueue') - initialize = True - else: - if tmp_plex_id == item.plex_id: - LOG.debug('Detected different path for the same id') - initialize = False - else: - LOG.debug('Different Plex id, need to init playqueue') - initialize = True - else: - initialize = False - if initialize: + item = self._check_playing_item(data) + except (MonitorError, IndexError): + LOG.debug('Detected that we need to initialize the PKC playqueue') + + if not item: + # Initialize the PKC playqueue + # Yet TODO LOG.debug('Need to initialize Plex and PKC playqueue') if not kodi_id or not kodi_type: kodi_id, kodi_type, path = self._json_item(playerid) @@ -381,8 +325,10 @@ def PlayBackStart(self, data): LOG.debug('No Plex id obtained - aborting playback report') app.PLAYSTATE.player_states[playerid] = copy.deepcopy(app.PLAYSTATE.template) return - item = PL.init_plex_playqueue(playqueue, plex_id=plex_id) - item.file = path + playlistitem = PQ.PlaylistItem(plex_id=plex_id, + grab_xml=True) + playlistitem.file = path + self.playqueue.init(playlistitem) # Set the Plex container key (e.g. using the Plex playqueue) container_key = None if info['playlistid'] != -1: @@ -392,29 +338,7 @@ def PlayBackStart(self, data): container_key = '/playQueues/%s' % container_key elif plex_id is not None: container_key = '/library/metadata/%s' % plex_id - else: - LOG.debug('No need to initialize playqueues') - kodi_id = item.kodi_id - kodi_type = item.kodi_type - plex_id = item.plex_id - plex_type = item.plex_type - if playqueue.id: - container_key = '/playQueues/%s' % playqueue.id - else: - container_key = '/library/metadata/%s' % plex_id - # Remember that this player has been active - app.PLAYSTATE.active_players.add(playerid) - status.update(info) - LOG.debug('Set the Plex container_key to: %s', container_key) - status['container_key'] = container_key - status['file'] = path - status['kodi_id'] = kodi_id - status['kodi_type'] = kodi_type - status['plex_id'] = plex_id - status['plex_type'] = plex_type - status['playmethod'] = item.playmethod - status['playcount'] = item.playcount - LOG.debug('Set the player state: %s', status) + self._load_playerstate(item) def _playback_cleanup(ended=False): @@ -428,6 +352,7 @@ def _playback_cleanup(ended=False): # 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 + LOG.debug('Playstate is: %s', app.PLAYSTATE.player_states) for playerid in app.PLAYSTATE.active_players: status = app.PLAYSTATE.player_states[playerid] # Remember the last played item later @@ -438,8 +363,8 @@ def _playback_cleanup(ended=False): DU().downloadUrl( '{server}/video/:/transcode/universal/stop', parameters={'session': v.PKC_MACHINE_IDENTIFIER}) - if playerid == 1: - # Bookmarks might not be pickup up correctly, so let's do them + if status['plex_type'] in v.PLEX_VIDEOTYPES: + # Bookmarks are not be pickup up correctly, so let's do them # manually. Applies to addon paths, but direct paths might have # started playback via PMS _record_playstate(status, ended) @@ -507,13 +432,23 @@ def _record_playstate(status, ended): totaltime, playcount, last_played) + # We might need to reconsider cleaning the file/path table in the future + # _clean_file_table() + # Update the current view to show e.g. an up-to-date progress bar and use + # the latest resume point info + if xbmc.getCondVisibility('Container.Content(musicvideos)'): + # Prevent cursor from moving + xbmc.executebuiltin('Container.Refresh') + else: + # Update widgets + xbmc.executebuiltin('UpdateLibrary(video)') + if xbmc.getCondVisibility('Window.IsMedia'): + xbmc.executebuiltin('Container.Refresh') # Hack to force "in progress" widget to appear if it wasn't visible before if (app.APP.force_reload_skin and xbmc.getCondVisibility('Window.IsVisible(Home.xml)')): LOG.debug('Refreshing skin to update widgets') xbmc.executebuiltin('ReloadSkin()') - task = backgroundthread.FunctionAsTask(_clean_file_table, None) - backgroundthread.BGThreader.addTasksToFront([task]) def _clean_file_table(): @@ -524,13 +459,13 @@ def _clean_file_table(): This function tries for at most 5 seconds to clean the file table. """ LOG.debug('Start cleaning Kodi files table') - app.APP.monitor.waitForAbort(2) + # app.APP.monitor.waitForAbort(1) try: - with kodi_db.KodiVideoDB() as kodidb_1: - with kodi_db.KodiVideoDB(lock=False) as kodidb_2: - for file_id in kodidb_1.obsolete_file_ids(): - LOG.debug('Removing obsolete Kodi file_id %s', file_id) - kodidb_2.remove_file(file_id, remove_orphans=False) + with kodi_db.KodiVideoDB() as kodidb: + file_ids = list(kodidb.obsolete_file_ids()) + LOG.debug('Obsolete kodi file_ids: %s', file_ids) + for file_id in file_ids: + kodidb.remove_file(file_id) except utils.OperationalError: LOG.debug('Database was locked, unable to clean file table') else: @@ -566,5 +501,5 @@ def _run(self): app.PLAYSTATE.resume_playback = True if control == 1001 else False else: # Different context menu is displayed - app.PLAYSTATE.resume_playback = False + app.PLAYSTATE.resume_playback = None xbmc.sleep(100) diff --git a/resources/lib/playback.py b/resources/lib/playback.py deleted file mode 100644 index 0873b9c6f..000000000 --- a/resources/lib/playback.py +++ /dev/null @@ -1,551 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -""" -Used to kick off Kodi playback -""" -from __future__ import absolute_import, division, unicode_literals -from logging import getLogger -from threading import Thread - -from .plex_api import API -from .plex_db import PlexDB -from . import plex_functions as PF -from . import utils -from .kodi_db import KodiVideoDB -from . import playlist_func as PL -from . import playqueue as PQ -from . import json_rpc as js -from . import transfer -from .playutils import PlayUtils -from . import variables as v -from . import app - -############################################################################### -LOG = getLogger('PLEX.playback') -# Do we need to return ultimately with a setResolvedUrl? -RESOLVE = True -############################################################################### - - -def playback_triage(plex_id=None, plex_type=None, path=None, resolve=True): - """ - Hit this function for addon path playback, Plex trailers, etc. - Will setup playback first, then on second call complete playback. - - Will set Playback_Successful() with potentially a PKCListItem() attached - (to be consumed by setResolvedURL in default.py) - - If trailers or additional (movie-)parts are added, default.py is released - and a completely new player instance is called with a new playlist. This - circumvents most issues with Kodi & playqueues - - Set resolve to False if you do not want setResolvedUrl to be called on - the first pass - e.g. if you're calling this function from the original - service.py Python instance - """ - plex_id = utils.cast(int, plex_id) - LOG.info('playback_triage called with plex_id %s, plex_type %s, path %s, ' - 'resolve %s', plex_id, plex_type, path, resolve) - global RESOLVE - # If started via Kodi context menu, we never resolve - RESOLVE = resolve if not app.PLAYSTATE.context_menu_play else False - if not app.CONN.online or not app.ACCOUNT.authenticated: - if not app.CONN.online: - LOG.error('PMS not online for playback') - # "{0} offline" - utils.dialog('notification', - utils.lang(29999), - utils.lang(39213).format(app.CONN.server_name), - icon='{plex}') - else: - LOG.error('Not yet authenticated for PMS, abort starting playback') - # "Unauthorized for PMS" - utils.dialog('notification', utils.lang(29999), utils.lang(30017)) - _ensure_resolve(abort=True) - return - with app.APP.lock_playqueues: - playqueue = PQ.get_playqueue_from_type( - v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[plex_type]) - try: - pos = js.get_position(playqueue.playlistid) - except KeyError: - # Kodi bug - Playlist plays (not Playqueue) will ALWAYS be audio for - # add-on paths - LOG.info('No position returned from player! Assuming playlist') - playqueue = PQ.get_playqueue_from_type(v.KODI_PLAYLIST_TYPE_AUDIO) - try: - pos = js.get_position(playqueue.playlistid) - except KeyError: - LOG.info('Assuming video instead of audio playlist playback') - playqueue = PQ.get_playqueue_from_type(v.KODI_PLAYLIST_TYPE_VIDEO) - try: - pos = js.get_position(playqueue.playlistid) - except KeyError: - LOG.error('Still no position - abort') - # "Play error" - utils.dialog('notification', - utils.lang(29999), - utils.lang(30128), - icon='{error}') - _ensure_resolve(abort=True) - return - # HACK to detect playback of playlists for add-on paths - items = js.playlist_get_items(playqueue.playlistid) - try: - item = items[pos] - except IndexError: - LOG.info('Could not apply playlist hack! Probably Widget playback') - else: - if ('id' not in item and - item.get('type') == 'unknown' and item.get('title') == ''): - LOG.info('Kodi playlist play detected') - _playlist_playback(plex_id, plex_type) - return - - # Can return -1 (as in "no playlist") - pos = pos if pos != -1 else 0 - LOG.debug('playQueue position %s for %s', pos, playqueue) - # Have we already initiated playback? - try: - item = playqueue.items[pos] - except IndexError: - LOG.debug('PKC playqueue yet empty, need to initialize playback') - initiate = True - else: - if item.plex_id != plex_id: - LOG.debug('Received new plex_id %s, expected %s', - plex_id, item.plex_id) - initiate = True - else: - initiate = False - if initiate: - _playback_init(plex_id, plex_type, playqueue, pos) - else: - # kick off playback on second pass - _conclude_playback(playqueue, pos) - - -def _playlist_playback(plex_id, plex_type): - """ - Really annoying Kodi behavior: Kodi will throw the ENTIRE playlist some- - where, causing Playlist.onAdd to fire for each item like this: - Playlist.OnAdd Data: {u'item': {u'type': u'episode', u'id': 164}, - u'playlistid': 0, - u'position': 2} - This does NOT work for Addon paths, type and id will be unknown: - {u'item': {u'type': u'unknown'}, - u'playlistid': 0, - u'position': 7} - At the end, only the element being played actually shows up in the Kodi - playqueue. - Hence: if we fail the first addon paths call, Kodi will start playback - for the next item in line :-) - (by the way: trying to get active Kodi player id will return []) - """ - xml = PF.GetPlexMetadata(plex_id, reraise=True) - if xml in (None, 401): - _ensure_resolve(abort=True) - return - # Kodi bug: playqueue will ALWAYS be audio playqueue UNTIL playback - # has actually started. Need to tell Kodimonitor - playqueue = PQ.get_playqueue_from_type(v.KODI_PLAYLIST_TYPE_AUDIO) - playqueue.clear(kodi=False) - # Set the flag for the potentially WRONG audio playlist so Kodimonitor - # can pick up on it - playqueue.kodi_playlist_playback = True - playlist_item = PL.playlist_item_from_xml(xml[0]) - playqueue.items.append(playlist_item) - _conclude_playback(playqueue, pos=0) - - -def _playback_init(plex_id, plex_type, playqueue, pos): - """ - Playback setup if Kodi starts playing an item for the first time. - """ - LOG.info('Initializing PKC playback') - xml = PF.GetPlexMetadata(plex_id, reraise=True) - if xml in (None, 401): - LOG.error('Could not get a PMS xml for plex id %s', plex_id) - _ensure_resolve(abort=True) - return - if playqueue.kodi_pl.size() > 1: - # Special case - we already got a filled Kodi playqueue - try: - _init_existing_kodi_playlist(playqueue, pos) - except PL.PlaylistError: - LOG.error('Playback_init for existing Kodi playlist failed') - # "Play error" - utils.dialog('notification', - utils.lang(29999), - utils.lang(30128), - icon='{error}') - _ensure_resolve(abort=True) - return - # Now we need to use setResolvedUrl for the item at position ZERO - # playqueue.py will pick up the missing items - _conclude_playback(playqueue, 0) - return - # "Usual" case - consider trailers and parts and build both Kodi and Plex - # playqueues - # Pass dummy PKC video with 0 length so Kodi immediately stops playback - # and we can build our own playqueue. - _ensure_resolve() - api = API(xml[0]) - trailers = False - if (plex_type == v.PLEX_TYPE_MOVIE and not api.resume_point() and - utils.settings('enableCinema') == "true"): - if utils.settings('askCinema') == "true": - # "Play trailers?" - trailers = utils.yesno_dialog(utils.lang(29999), utils.lang(33016)) - else: - trailers = True - LOG.debug('Playing trailers: %s', trailers) - playqueue.clear() - if plex_type != v.PLEX_TYPE_CLIP: - # Post to the PMS to create a playqueue - in any case due to Companion - xml = PF.init_plex_playqueue(plex_id, - xml.attrib.get('librarySectionUUID'), - mediatype=plex_type, - trailers=trailers) - if xml is None: - LOG.error('Could not get a playqueue xml for plex id %s, UUID %s', - plex_id, xml.attrib.get('librarySectionUUID')) - # "Play error" - utils.dialog('notification', - utils.lang(29999), - utils.lang(30128), - icon='{error}') - # Do NOT use _ensure_resolve() because we resolved above already - app.PLAYSTATE.context_menu_play = False - app.PLAYSTATE.force_transcode = False - app.PLAYSTATE.resume_playback = False - return - PL.get_playlist_details_from_xml(playqueue, xml) - stack = _prep_playlist_stack(xml) - _process_stack(playqueue, stack) - # Always resume if playback initiated via PMS and there IS a resume - # point - offset = api.resume_point() * 1000 if app.PLAYSTATE.context_menu_play else None - # Reset some playback variables - app.PLAYSTATE.context_menu_play = False - app.PLAYSTATE.force_transcode = False - # New thread to release this one sooner (e.g. harddisk spinning up) - thread = Thread(target=threaded_playback, - args=(playqueue.kodi_pl, pos, offset)) - thread.setDaemon(True) - LOG.info('Done initializing playback, starting Kodi player at pos %s and ' - 'resume point %s', pos, offset) - # By design, PKC will start Kodi playback using Player().play(). Kodi - # caches paths like our plugin://pkc. If we use Player().play() between - # 2 consecutive startups of exactly the same Kodi library item, Kodi's - # cache will have been flushed for some reason. Hence the 2nd call for - # plugin://pkc will be lost; Kodi will try to startup playback for an empty - # path: log entry is "CGUIWindowVideoBase::OnPlayMedia " - thread.start() - # Ensure that PKC playqueue monitor ignores the changes we just made - playqueue.pkc_edit = True - - -def _ensure_resolve(abort=False): - """ - Will check whether RESOLVE=True and if so, fail Kodi playback startup - with the path 'PKC_Dummy_Path_Which_Fails' using setResolvedUrl (and some - pickling) - - This way we're making sure that other Python instances (calling default.py) - will be destroyed. - """ - if RESOLVE: - # Releases the other Python thread without a ListItem - transfer.send(True) - # Shows PKC error message - # transfer.send(None) - if abort: - # Reset some playback variables - app.PLAYSTATE.context_menu_play = False - app.PLAYSTATE.force_transcode = False - app.PLAYSTATE.resume_playback = False - - -def _init_existing_kodi_playlist(playqueue, pos): - """ - Will take the playqueue's kodi_pl with MORE than 1 element and initiate - playback (without adding trailers) - """ - LOG.debug('Kodi playlist size: %s', playqueue.kodi_pl.size()) - kodi_items = js.playlist_get_items(playqueue.playlistid) - if not kodi_items: - LOG.error('No Kodi items returned') - raise PL.PlaylistError('No Kodi items returned') - item = PL.init_plex_playqueue(playqueue, kodi_item=kodi_items[pos]) - item.force_transcode = app.PLAYSTATE.force_transcode - # playqueue.py will add the rest - this will likely put the PMS under - # a LOT of strain if the following Kodi setting is enabled: - # Settings -> Player -> Videos -> Play next video automatically - LOG.debug('Done init_existing_kodi_playlist') - - -def _prep_playlist_stack(xml): - stack = [] - for item in xml: - api = API(item) - if (app.PLAYSTATE.context_menu_play is False and - api.plex_type() not in (v.PLEX_TYPE_CLIP, v.PLEX_TYPE_EPISODE)): - # If user chose to play via PMS or force transcode, do not - # use the item path stored in the Kodi DB - with PlexDB(lock=False) as plexdb: - db_item = plexdb.item_by_id(api.plex_id(), api.plex_type()) - kodi_id = db_item['kodi_id'] if db_item else None - kodi_type = db_item['kodi_type'] if db_item else None - else: - # We will never store clips (trailers) in the Kodi DB. - # Also set kodi_id to None for playback via PMS, so that we're - # using add-on paths. - # Also do NOT associate episodes with library items for addon paths - # as artwork lookup is broken (episode path does not link back to - # season and show) - kodi_id = None - kodi_type = None - for part, _ in enumerate(item[0]): - api.set_part_number(part) - if kodi_id is None: - # Need to redirect again to PKC to conclude playback - path = api.path() - listitem = api.create_listitem() - listitem.setPath(utils.try_encode(path)) - else: - # Will add directly via the Kodi DB - path = None - listitem = None - stack.append({ - 'kodi_id': kodi_id, - 'kodi_type': kodi_type, - 'file': path, - 'xml_video_element': item, - 'listitem': listitem, - 'part': part, - 'playcount': api.viewcount(), - 'offset': api.resume_point(), - 'id': api.item_id() - }) - return stack - - -def _process_stack(playqueue, stack): - """ - Takes our stack and adds the items to the PKC and Kodi playqueues. - """ - # getposition() can return -1 - pos = max(playqueue.kodi_pl.getposition(), 0) + 1 - for item in stack: - if item['kodi_id'] is None: - playlist_item = PL.add_listitem_to_Kodi_playlist( - playqueue, - pos, - item['listitem'], - file=item['file'], - xml_video_element=item['xml_video_element']) - else: - # Directly add element so we have full metadata - playlist_item = PL.add_item_to_kodi_playlist( - playqueue, - pos, - kodi_id=item['kodi_id'], - kodi_type=item['kodi_type'], - xml_video_element=item['xml_video_element']) - playlist_item.playcount = item['playcount'] - playlist_item.offset = item['offset'] - playlist_item.part = item['part'] - playlist_item.id = item['id'] - playlist_item.force_transcode = app.PLAYSTATE.force_transcode - pos += 1 - - -def _conclude_playback(playqueue, pos): - """ - ONLY if actually being played (e.g. at 5th position of a playqueue). - - Decide on direct play, direct stream, transcoding - path to - direct paths: file itself - PMS URL - Web URL - audiostream (e.g. let user choose) - subtitle stream (e.g. let user choose) - Init Kodi Playback (depending on situation): - start playback - return PKC listitem attached to result - """ - LOG.info('Concluding playback for playqueue position %s', pos) - listitem = transfer.PKCListItem() - item = playqueue.items[pos] - if item.xml is not None: - # Got a Plex element - api = API(item.xml) - api.set_part_number(item.part) - api.create_listitem(listitem) - playutils = PlayUtils(api, item) - playurl = playutils.getPlayUrl() - else: - api = None - playurl = item.file - if not playurl: - LOG.info('Did not get a playurl, aborting playback silently') - app.PLAYSTATE.resume_playback = False - transfer.send(True) - return - listitem.setPath(utils.try_encode(playurl)) - if item.playmethod == 'DirectStream': - listitem.setSubtitles(api.cache_external_subs()) - elif item.playmethod == 'Transcode': - playutils.audio_subtitle_prefs(listitem) - - if app.PLAYSTATE.resume_playback is True: - app.PLAYSTATE.resume_playback = False - if item.plex_type not in (v.PLEX_TYPE_SONG, v.PLEX_TYPE_CLIP): - # Do NOT use item.offset directly but get it from the DB - # (user might have initiated same video twice) - with PlexDB(lock=False) as plexdb: - db_item = plexdb.item_by_id(item.plex_id, item.plex_type) - file_id = db_item['kodi_fileid'] if db_item else None - with KodiVideoDB(lock=False) as kodidb: - item.offset = kodidb.get_resume(file_id) - LOG.info('Resuming playback at %s', item.offset) - if v.KODIVERSION >= 18 and api: - # Kodi 18 Alpha 3 broke StartOffset - try: - percent = (item.offset or api.resume_point()) / api.runtime() * 100.0 - except ZeroDivisionError: - percent = 0.0 - LOG.debug('Resuming at %s percent', percent) - listitem.setProperty('StartPercent', str(percent)) - else: - listitem.setProperty('StartOffset', str(item.offset)) - listitem.setProperty('resumetime', str(item.offset)) - elif v.KODIVERSION >= 18: - listitem.setProperty('StartPercent', '0') - # Reset the resumable flag - transfer.send(listitem) - LOG.info('Done concluding playback') - - -def process_indirect(key, offset, resolve=True): - """ - Called e.g. for Plex "Play later" - Plex items where we need to fetch an - additional xml for the actual playurl. In the PMS metadata, indirect="1" is - set. - - Will release default.py with setResolvedUrl - - Set resolve to False if playback should be kicked off directly, not via - setResolvedUrl - """ - LOG.info('process_indirect called with key: %s, offset: %s, resolve: %s', - key, offset, resolve) - global RESOLVE - RESOLVE = resolve - offset = int(v.PLEX_TO_KODI_TIMEFACTOR * float(offset)) if offset != '0' else None - if key.startswith('http') or key.startswith('{server}'): - xml = PF.get_playback_xml(key, app.CONN.server_name) - elif key.startswith('/system/services'): - xml = PF.get_playback_xml('http://node.plexapp.com:32400%s' % key, - 'plexapp.com', - authenticate=False, - token=app.ACCOUNT.plex_token) - else: - xml = PF.get_playback_xml('{server}%s' % key, app.CONN.server_name) - if xml is None: - _ensure_resolve(abort=True) - return - - api = API(xml[0]) - listitem = transfer.PKCListItem() - api.create_listitem(listitem) - playqueue = PQ.get_playqueue_from_type( - v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[api.plex_type()]) - playqueue.clear() - item = PL.Playlist_Item() - item.xml = xml[0] - item.offset = offset - item.plex_type = v.PLEX_TYPE_CLIP - item.playmethod = 'DirectStream' - - # Need to get yet another xml to get the final playback url - try: - xml = PF.get_playback_xml('http://node.plexapp.com:32400%s' - % xml[0][0][0].attrib['key'], - 'plexapp.com', - authenticate=False, - token=app.ACCOUNT.plex_token) - except (TypeError, IndexError, AttributeError): - LOG.error('XML malformed: %s', xml.attrib) - xml = None - if xml is None: - _ensure_resolve(abort=True) - return - - try: - playurl = xml[0].attrib['key'] - except (TypeError, IndexError, AttributeError): - LOG.error('Last xml malformed: %s', xml.attrib) - _ensure_resolve(abort=True) - return - - item.file = playurl - listitem.setPath(utils.try_encode(playurl)) - playqueue.items.append(item) - if resolve is True: - transfer.send(listitem) - else: - thread = Thread(target=app.APP.player.play, - args={'item': utils.try_encode(playurl), - 'listitem': listitem}) - thread.setDaemon(True) - LOG.info('Done initializing PKC playback, starting Kodi player') - thread.start() - - -def play_xml(playqueue, xml, offset=None, start_plex_id=None): - """ - Play all items contained in the xml passed in. Called by Plex Companion. - - Either supply the ratingKey of the starting Plex element. Or set - playqueue.selectedItemID - """ - LOG.info("play_xml called with offset %s, start_plex_id %s", - offset, start_plex_id) - stack = _prep_playlist_stack(xml) - _process_stack(playqueue, stack) - LOG.debug('Playqueue after play_xml update: %s', playqueue) - if start_plex_id is not None: - for startpos, item in enumerate(playqueue.items): - if item.plex_id == start_plex_id: - break - else: - startpos = 0 - else: - for startpos, item in enumerate(playqueue.items): - if item.id == playqueue.selectedItemID: - break - else: - startpos = 0 - thread = Thread(target=threaded_playback, - args=(playqueue.kodi_pl, startpos, offset)) - LOG.info('Done play_xml, starting Kodi player at position %s', startpos) - thread.start() - - -def threaded_playback(kodi_playlist, startpos, offset): - """ - Seek immediately after kicking off playback is not reliable. - """ - app.APP.player.play(kodi_playlist, None, False, startpos) - if offset and offset != '0': - i = 0 - while not app.APP.is_playing: - app.APP.monitor.waitForAbort(0.1) - i += 1 - if i > 100: - LOG.error('Could not seek to %s', offset) - return - js.seek_to(int(offset)) diff --git a/resources/lib/playback_starter.py b/resources/lib/playback_starter.py index 5800f0eb9..974c2ba74 100644 --- a/resources/lib/playback_starter.py +++ b/resources/lib/playback_starter.py @@ -3,7 +3,9 @@ from __future__ import absolute_import, division, unicode_literals from logging import getLogger -from . import utils, playback, context_entry, transfer, backgroundthread +from .plex_api import API +from . import utils, context_entry, transfer, backgroundthread, variables as v +from . import app, plex_functions as PF, playqueue as PQ ############################################################################### @@ -34,16 +36,102 @@ def run(self): mode = params.get('mode') resolve = False if params.get('handle') == '-1' else True LOG.debug('Received mode: %s, params: %s', mode, params) - if mode == 'play': - playback.playback_triage(plex_id=params.get('plex_id'), - plex_type=params.get('plex_type'), - path=params.get('path'), - resolve=resolve) - elif mode == 'plex_node': - playback.process_indirect(params['key'], - params['offset'], - resolve=resolve) + if mode == 'plex_node': + process_indirect(params['key'], + params['offset'], + resolve=resolve) elif mode == 'context_menu': context_entry.ContextMenu(kodi_id=params.get('kodi_id'), kodi_type=params.get('kodi_type')) LOG.debug('Finished PlaybackTask') + + +def process_indirect(key, offset, resolve=True): + """ + Called e.g. for Plex "Play later" - Plex items where we need to fetch an + additional xml for the actual playurl. In the PMS metadata, indirect="1" is + set. + + Will release default.py with setResolvedUrl + + Set resolve to False if playback should be kicked off directly, not via + setResolvedUrl + """ + LOG.info('process_indirect called with key: %s, offset: %s, resolve: %s', + key, offset, resolve) + global RESOLVE + RESOLVE = resolve + offset = int(v.PLEX_TO_KODI_TIMEFACTOR * float(offset)) if offset != '0' else None + if key.startswith('http') or key.startswith('{server}'): + xml = PF.get_playback_xml(key, app.CONN.server_name) + elif key.startswith('/system/services'): + xml = PF.get_playback_xml('http://node.plexapp.com:32400%s' % key, + 'plexapp.com', + authenticate=False, + token=app.ACCOUNT.plex_token) + else: + xml = PF.get_playback_xml('{server}%s' % key, app.CONN.server_name) + if xml is None: + _ensure_resolve(abort=True) + return + + api = API(xml[0]) + listitem = transfer.PKCListItem() + api.create_listitem(listitem) + playqueue = PQ.get_playqueue_from_type( + v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[api.plex_type()]) + playqueue.clear() + item = PQ.PlaylistItem(xml_video_element=xml[0]) + item.offset = offset + item.playmethod = 'DirectStream' + + # Need to get yet another xml to get the final playback url + try: + xml = PF.get_playback_xml('http://node.plexapp.com:32400%s' + % xml[0][0][0].attrib['key'], + 'plexapp.com', + authenticate=False, + token=app.ACCOUNT.plex_token) + except (TypeError, IndexError, AttributeError): + LOG.error('XML malformed: %s', xml.attrib) + xml = None + if xml is None: + _ensure_resolve(abort=True) + return + try: + playurl = xml[0].attrib['key'] + except (TypeError, IndexError, AttributeError): + LOG.error('Last xml malformed: %s\n%s', xml.tag, xml.attrib) + _ensure_resolve(abort=True) + return + + item.file = playurl + listitem.setPath(playurl.encode('utf-8')) + playqueue.items.append(item) + if resolve is True: + transfer.send(listitem) + else: + LOG.info('Done initializing PKC playback, starting Kodi player') + app.APP.player.play(item=playurl.encode('utf-8'), + listitem=listitem) + + +def _ensure_resolve(abort=False): + """ + Will check whether RESOLVE=True and if so, fail Kodi playback startup + with the path 'PKC_Dummy_Path_Which_Fails' using setResolvedUrl (and some + pickling) + + This way we're making sure that other Python instances (calling default.py) + will be destroyed. + """ + if RESOLVE: + # Releases the other Python thread without a ListItem + transfer.send(True) + # Shows PKC error message + # transfer.send(None) + if abort: + # Reset some playback variables + app.PLAYSTATE.context_menu_play = False + app.PLAYSTATE.force_transcode = False + app.PLAYSTATE.resume_playback = False diff --git a/resources/lib/playlist_func.py b/resources/lib/playlist_func.py deleted file mode 100644 index a0ba8645f..000000000 --- a/resources/lib/playlist_func.py +++ /dev/null @@ -1,839 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -""" -Collection of functions associated with Kodi and Plex playlists and playqueues -""" -from __future__ import absolute_import, division, unicode_literals -from logging import getLogger - -from .plex_api import API -from .plex_db import PlexDB -from . import plex_functions as PF -from .kodi_db import kodiid_from_filename -from .downloadutils import DownloadUtils as DU -from . import utils -from . import json_rpc as js -from . import variables as v -from . import app - -############################################################################### - -LOG = getLogger('PLEX.playlist_func') - -############################################################################### - - -class PlaylistError(Exception): - """ - Exception for our playlist constructs - """ - pass - - -class Playqueue_Object(object): - """ - PKC object to represent PMS playQueues and Kodi playlist for queueing - - playlistid = None [int] Kodi playlist id (0, 1, 2) - type = None [str] Kodi type: 'audio', 'video', 'picture' - kodi_pl = None Kodi xbmc.PlayList object - items = [] [list] of Playlist_Items - id = None [str] Plex playQueueID, unique Plex identifier - version = None [int] Plex version of the playQueue - selectedItemID = None - [str] Plex selectedItemID, playing element in queue - selectedItemOffset = None - [str] Offset of the playing element in queue - shuffled = 0 [int] 0: not shuffled, 1: ??? 2: ??? - repeat = 0 [int] 0: not repeated, 1: ??? 2: ??? - - If Companion playback is initiated by another user: - plex_transient_token = None - """ - kind = 'playQueue' - - def __init__(self): - self.id = None - self.type = None - self.playlistid = None - self.kodi_pl = None - self.items = [] - self.version = None - self.selectedItemID = None - self.selectedItemOffset = None - self.shuffled = 0 - self.repeat = 0 - self.plex_transient_token = None - # Need a hack for detecting swaps of elements - self.old_kodi_pl = [] - # Did PKC itself just change the playqueue so the PKC playqueue monitor - # should not pick up any changes? - self.pkc_edit = False - # Workaround to avoid endless loops of detecting PL clears - self._clear_list = [] - # To keep track if Kodi playback was initiated from a Kodi playlist - # There are a couple of pitfalls, unfortunately... - self.kodi_playlist_playback = False - - def __repr__(self): - answ = ("{{" - "'playlistid': {self.playlistid}, " - "'id': {self.id}, " - "'version': {self.version}, " - "'type': '{self.type}', " - "'selectedItemID': {self.selectedItemID}, " - "'selectedItemOffset': {self.selectedItemOffset}, " - "'shuffled': {self.shuffled}, " - "'repeat': {self.repeat}, " - "'kodi_playlist_playback': {self.kodi_playlist_playback}, " - "'pkc_edit': {self.pkc_edit}, ".format(self=self)) - answ = answ.encode('utf-8') - # Since list.__repr__ will return string, not unicode - return answ + b"'items': {self.items}}}".format(self=self) - - def __str__(self): - return self.__repr__() - - def is_pkc_clear(self): - """ - Returns True if PKC has cleared the Kodi playqueue just recently. - Then this clear will be ignored from now on - """ - try: - self._clear_list.pop() - except IndexError: - return False - else: - return True - - def clear(self, kodi=True): - """ - Resets the playlist object to an empty playlist. - - Pass kodi=False in order to NOT clear the Kodi playqueue - """ - # kodi monitor's on_clear method will only be called if there were some - # items to begin with - if kodi and self.kodi_pl.size() != 0: - self._clear_list.append(None) - self.kodi_pl.clear() # Clear Kodi playlist object - self.items = [] - self.id = None - self.version = None - self.selectedItemID = None - self.selectedItemOffset = None - self.shuffled = 0 - self.repeat = 0 - self.plex_transient_token = None - self.old_kodi_pl = [] - self.kodi_playlist_playback = False - LOG.debug('Playlist cleared: %s', self) - - -class Playlist_Item(object): - """ - Object to fill our playqueues and playlists with. - - id = None [int] Plex playlist/playqueue id, e.g. playQueueItemID - plex_id = None [int] Plex unique item id, "ratingKey" - plex_type = None [str] Plex type, e.g. 'movie', 'clip' - plex_uuid = None [str] Plex librarySectionUUID - kodi_id = None [int] Kodi unique kodi id (unique only within type!) - kodi_type = None [str] Kodi type: 'movie' - file = None [str] Path to the item's file. STRING!! - uri = None [str] Weird Plex uri path involving plex_uuid. STRING! - guid = None [str] Weird Plex guid - xml = None [etree] XML from PMS, 1 lvl below - playmethod = None [str] either 'DirectPlay', 'DirectStream', 'Transcode' - playcount = None [int] how many times the item has already been played - offset = None [int] the item's view offset UPON START in Plex time - part = 0 [int] part number if Plex video consists of mult. parts - force_transcode [bool] defaults to False - """ - def __init__(self): - self._id = None - self._plex_id = None - self.plex_type = None - self.plex_uuid = None - self._kodi_id = None - self.kodi_type = None - self.file = None - self.uri = None - self.guid = None - self.xml = None - self.playmethod = None - self._playcount = None - self._offset = None - # If Plex video consists of several parts; part number - self._part = 0 - self.force_transcode = False - - @property - def plex_id(self): - return self._plex_id - - @plex_id.setter - def plex_id(self, value): - if not isinstance(value, int) and value is not None: - raise TypeError('Passed %s instead of int!' % type(value)) - self._plex_id = value - - @property - def id(self): - return self._id - - @id.setter - def id(self, value): - if not isinstance(value, int) and value is not None: - raise TypeError('Passed %s instead of int!' % type(value)) - self._id = value - - @property - def kodi_id(self): - return self._kodi_id - - @kodi_id.setter - def kodi_id(self, value): - if not isinstance(value, int) and value is not None: - raise TypeError('Passed %s instead of int!' % type(value)) - self._kodi_id = value - - @property - def playcount(self): - return self._playcount - - @playcount.setter - def playcount(self, value): - if not isinstance(value, int) and value is not None: - raise TypeError('Passed %s instead of int!' % type(value)) - self._playcount = value - - @property - def offset(self): - return self._offset - - @offset.setter - def offset(self, value): - if not isinstance(value, (int, float)) and value is not None: - raise TypeError('Passed %s instead of int!' % type(value)) - self._offset = value - - @property - def part(self): - return self._part - - @part.setter - def part(self, value): - if not isinstance(value, int) and value is not None: - raise TypeError('Passed %s instead of int!' % type(value)) - self._part = value - - def __repr__(self): - answ = ("{{" - "'id': {self.id}, " - "'plex_id': {self.plex_id}, " - "'plex_type': '{self.plex_type}', " - "'plex_uuid': '{self.plex_uuid}', " - "'kodi_id': {self.kodi_id}, " - "'kodi_type': '{self.kodi_type}', " - "'file': '{self.file}', " - "'uri': '{self.uri}', " - "'guid': '{self.guid}', " - "'playmethod': '{self.playmethod}', " - "'playcount': {self.playcount}, " - "'offset': {self.offset}, " - "'force_transcode': {self.force_transcode}, " - "'part': {self.part}, ".format(self=self)) - answ = answ.encode('utf-8') - # etree xml.__repr__() could return string, not unicode - return answ + b"'xml': \"{self.xml}\"}}".format(self=self) - - def __str__(self): - return self.__repr__() - - def plex_stream_index(self, kodi_stream_index, stream_type): - """ - Pass in the kodi_stream_index [int] in order to receive the Plex stream - index. - - stream_type: 'video', 'audio', 'subtitle' - - Returns None if unsuccessful - """ - stream_type = v.PLEX_STREAM_TYPE_FROM_STREAM_TYPE[stream_type] - count = 0 - if kodi_stream_index == -1: - # Kodi telling us "it's the last one" - iterator = list(reversed(self.xml[0][self.part])) - kodi_stream_index = 0 - else: - iterator = self.xml[0][self.part] - # Kodi indexes differently than Plex - for stream in iterator: - if (stream.attrib['streamType'] == stream_type and - 'key' in stream.attrib): - if count == kodi_stream_index: - return stream.attrib['id'] - count += 1 - for stream in iterator: - if (stream.attrib['streamType'] == stream_type and - 'key' not in stream.attrib): - if count == kodi_stream_index: - return stream.attrib['id'] - count += 1 - - def kodi_stream_index(self, plex_stream_index, stream_type): - """ - Pass in the kodi_stream_index [int] in order to receive the Plex stream - index. - - stream_type: 'video', 'audio', 'subtitle' - - Returns None if unsuccessful - """ - stream_type = v.PLEX_STREAM_TYPE_FROM_STREAM_TYPE[stream_type] - count = 0 - for stream in self.xml[0][self.part]: - if (stream.attrib['streamType'] == stream_type and - 'key' in stream.attrib): - if stream.attrib['id'] == plex_stream_index: - return count - count += 1 - for stream in self.xml[0][self.part]: - if (stream.attrib['streamType'] == stream_type and - 'key' not in stream.attrib): - if stream.attrib['id'] == plex_stream_index: - return count - count += 1 - - -def playlist_item_from_kodi(kodi_item): - """ - Turns the JSON answer from Kodi into a playlist element - - Supply with data['item'] as returned from Kodi JSON-RPC interface. - kodi_item dict contains keys 'id', 'type', 'file' (if applicable) - """ - item = Playlist_Item() - item.kodi_id = kodi_item.get('id') - item.kodi_type = kodi_item.get('type') - if item.kodi_id: - with PlexDB(lock=False) as plexdb: - db_item = plexdb.item_by_kodi_id(kodi_item['id'], kodi_item['type']) - if db_item: - item.plex_id = db_item['plex_id'] - item.plex_type = db_item['plex_type'] - item.plex_uuid = db_item['plex_id'] # we dont need the uuid yet :-) - item.file = kodi_item.get('file') - if item.plex_id is None and item.file is not None: - try: - query = item.file.split('?', 1)[1] - except IndexError: - query = '' - query = dict(utils.parse_qsl(query)) - item.plex_id = utils.cast(int, query.get('plex_id')) - item.plex_type = query.get('itemType') - if item.plex_id is None and item.file is not None: - item.uri = ('library://whatever/item/%s' - % utils.quote(item.file, safe='')) - else: - # TO BE VERIFIED - PLEX DOESN'T LIKE PLAYLIST ADDS IN THIS MANNER - item.uri = ('library://%s/item/library%%2Fmetadata%%2F%s' % - (item.plex_uuid, item.plex_id)) - LOG.debug('Made playlist item from Kodi: %s', item) - return item - - -def verify_kodi_item(plex_id, kodi_item): - """ - Tries to lookup kodi_id and kodi_type for kodi_item (with kodi_item['file'] - supplied) - if and only if plex_id is None. - - Returns the kodi_item with kodi_item['id'] and kodi_item['type'] possibly - set to None if unsuccessful. - - Will raise a PlaylistError if plex_id is None and kodi_item['file'] starts - with either 'plugin' or 'http' - """ - if plex_id is not None or kodi_item.get('id') is not None: - # Got all the info we need - return kodi_item - # Special case playlist startup - got type but no id - if (not app.SYNC.direct_paths and app.SYNC.enable_music and - kodi_item.get('type') == v.KODI_TYPE_SONG and - kodi_item['file'].startswith('http')): - kodi_item['id'], _ = kodiid_from_filename(kodi_item['file'], - v.KODI_TYPE_SONG) - LOG.debug('Detected song. Research results: %s', kodi_item) - return kodi_item - # Need more info since we don't have kodi_id nor type. Use file path. - if ((kodi_item['file'].startswith('plugin') and - not kodi_item['file'].startswith('plugin://%s' % v.ADDON_ID)) or - kodi_item['file'].startswith('http')): - LOG.info('kodi_item %s cannot be used for Plex playback', kodi_item) - raise PlaylistError - LOG.debug('Starting research for Kodi id since we didnt get one: %s', - kodi_item) - # Try the VIDEO DB first - will find both movies and episodes - kodi_id, kodi_type = kodiid_from_filename(kodi_item['file'], - db_type='video') - if not kodi_id: - # No movie or episode found - try MUSIC DB now for songs - kodi_id, kodi_type = kodiid_from_filename(kodi_item['file'], - db_type='music') - kodi_item['id'] = kodi_id - kodi_item['type'] = None if kodi_id is None else kodi_type - LOG.debug('Research results for kodi_item: %s', kodi_item) - return kodi_item - - -def playlist_item_from_plex(plex_id): - """ - Returns a playlist element providing the plex_id ("ratingKey") - - Returns a Playlist_Item - """ - item = Playlist_Item() - item.plex_id = plex_id - with PlexDB(lock=False) as plexdb: - db_item = plexdb.item_by_id(plex_id) - if db_item: - item.plex_type = db_item['plex_type'] - item.kodi_id = db_item['kodi_id'] - item.kodi_type = db_item['kodi_type'] - else: - raise KeyError('Could not find plex_id %s in database' % plex_id) - item.plex_uuid = plex_id - item.uri = ('library://%s/item/library%%2Fmetadata%%2F%s' % - (item.plex_uuid, plex_id)) - LOG.debug('Made playlist item from plex: %s', item) - return item - - -def playlist_item_from_xml(xml_video_element, kodi_id=None, kodi_type=None): - """ - Returns a playlist element for the playqueue using the Plex xml - - xml_video_element: etree xml piece 1 level underneath - """ - item = Playlist_Item() - api = API(xml_video_element) - item.plex_id = api.plex_id() - item.plex_type = api.plex_type() - # item.id will only be set if you passed in an xml_video_element from e.g. - # a playQueue - item.id = api.item_id() - if kodi_id is not None: - item.kodi_id = kodi_id - item.kodi_type = kodi_type - elif item.plex_id is not None and item.plex_type != v.PLEX_TYPE_CLIP: - with PlexDB(lock=False) as plexdb: - db_element = plexdb.item_by_id(item.plex_id) - if db_element: - item.kodi_id = db_element['kodi_id'] - item.kodi_type = db_element['kodi_type'] - item.guid = api.guid_html_escaped() - item.playcount = api.viewcount() - item.offset = api.resume_point() - item.xml = xml_video_element - LOG.debug('Created new playlist item from xml: %s', item) - return item - - -def _get_playListVersion_from_xml(playlist, xml): - """ - Takes a PMS xml as input to overwrite the playlist version (e.g. Plex - playQueueVersion). - - Raises PlaylistError if unsuccessful - """ - playlist.version = utils.cast(int, - xml.get('%sVersion' % playlist.kind)) - if playlist.version is None: - raise PlaylistError('Could not get new playlist Version for playlist ' - '%s' % playlist) - - -def get_playlist_details_from_xml(playlist, xml): - """ - Takes a PMS xml as input and overwrites all the playlist's details, e.g. - playlist.id with the XML's playQueueID - - Raises PlaylistError if something went wrong. - """ - playlist.id = utils.cast(int, - xml.get('%sID' % playlist.kind)) - playlist.version = utils.cast(int, - xml.get('%sVersion' % playlist.kind)) - playlist.shuffled = utils.cast(int, - xml.get('%sShuffled' % playlist.kind)) - playlist.selectedItemID = utils.cast(int, - xml.get('%sSelectedItemID' - % playlist.kind)) - playlist.selectedItemOffset = utils.cast(int, - xml.get('%sSelectedItemOffset' - % playlist.kind)) - LOG.debug('Updated playlist from xml: %s', playlist) - - -def update_playlist_from_PMS(playlist, playlist_id=None, xml=None): - """ - Updates Kodi playlist using a new PMS playlist. Pass in playlist_id if we - need to fetch a new playqueue - - If an xml is passed in, the playlist will be overwritten with its info - """ - if xml is None: - xml = get_PMS_playlist(playlist, playlist_id) - # Clear our existing playlist and the associated Kodi playlist - playlist.clear() - # Set new values - get_playlist_details_from_xml(playlist, xml) - for plex_item in xml: - playlist_item = add_to_Kodi_playlist(playlist, plex_item) - if playlist_item is not None: - playlist.items.append(playlist_item) - - -def init_plex_playqueue(playlist, plex_id=None, kodi_item=None): - """ - Initializes the Plex side without changing the Kodi playlists - WILL ALSO UPDATE OUR PLAYLISTS. - - Returns the first PKC playlist item or raises PlaylistError - """ - LOG.debug('Initializing the playqueue on the Plex side: %s', playlist) - playlist.clear(kodi=False) - verify_kodi_item(plex_id, kodi_item) - try: - if plex_id: - item = playlist_item_from_plex(plex_id) - else: - item = playlist_item_from_kodi(kodi_item) - params = { - 'next': 0, - 'type': playlist.type, - 'uri': item.uri - } - xml = DU().downloadUrl(url="{server}/%ss" % playlist.kind, - action_type="POST", - parameters=params) - get_playlist_details_from_xml(playlist, xml) - # Need to get the details for the playlist item - item = playlist_item_from_xml(xml[0]) - except (KeyError, IndexError, TypeError): - LOG.error('Could not init Plex playlist: plex_id %s, kodi_item %s', - plex_id, kodi_item) - raise PlaylistError - playlist.items.append(item) - LOG.debug('Initialized the playqueue on the Plex side: %s', playlist) - return item - - -def add_listitem_to_playlist(playlist, pos, listitem, kodi_id=None, - kodi_type=None, plex_id=None, file=None): - """ - Adds a listitem to both the Kodi and Plex playlist at position pos [int]. - - If file is not None, file will overrule kodi_id! - - file: str!! - """ - LOG.debug('add_listitem_to_playlist at position %s. Playlist before add: ' - '%s', pos, playlist) - kodi_item = {'id': kodi_id, 'type': kodi_type, 'file': file} - if playlist.id is None: - init_plex_playqueue(playlist, plex_id, kodi_item) - else: - add_item_to_plex_playqueue(playlist, pos, plex_id, kodi_item) - if kodi_id is None and playlist.items[pos].kodi_id: - kodi_id = playlist.items[pos].kodi_id - kodi_type = playlist.items[pos].kodi_type - if file is None: - file = playlist.items[pos].file - # Otherwise we double the item! - del playlist.items[pos] - kodi_item = {'id': kodi_id, 'type': kodi_type, 'file': file} - add_listitem_to_Kodi_playlist(playlist, - pos, - listitem, - file, - kodi_item=kodi_item) - - -def add_item_to_playlist(playlist, pos, kodi_id=None, kodi_type=None, - plex_id=None, file=None): - """ - Adds an item to BOTH the Kodi and Plex playlist at position pos [int] - file: str! - - Raises PlaylistError if something went wrong - """ - LOG.debug('add_item_to_playlist. Playlist before adding: %s', playlist) - kodi_item = {'id': kodi_id, 'type': kodi_type, 'file': file} - if playlist.id is None: - item = init_plex_playqueue(playlist, plex_id, kodi_item) - else: - item = add_item_to_plex_playqueue(playlist, pos, plex_id, kodi_item) - params = { - 'playlistid': playlist.playlistid, - 'position': pos - } - if item.kodi_id is not None: - params['item'] = {'%sid' % item.kodi_type: int(item.kodi_id)} - else: - params['item'] = {'file': item.file} - reply = js.playlist_insert(params) - if reply.get('error') is not None: - raise PlaylistError('Could not add item to playlist. Kodi reply. %s' - % reply) - return item - - -def add_item_to_plex_playqueue(playlist, pos, plex_id=None, kodi_item=None): - """ - Adds a new item to the playlist at position pos [int] only on the Plex - side of things (e.g. because the user changed the Kodi side) - WILL ALSO UPDATE OUR PLAYLISTS - - Returns the PKC PlayList item or raises PlaylistError - """ - verify_kodi_item(plex_id, kodi_item) - if plex_id: - 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) - # Will always put the new item at the end of the Plex playlist - xml = DU().downloadUrl(url, action_type="PUT") - try: - xml[-1].attrib - except (TypeError, AttributeError, KeyError, IndexError): - raise PlaylistError('Could not add item %s to playlist %s' - % (kodi_item, playlist)) - api = API(xml[-1]) - item.xml = xml[-1] - item.id = api.item_id() - item.guid = api.guid_html_escaped() - item.offset = api.resume_point() - item.playcount = api.viewcount() - playlist.items.append(item) - if pos == len(playlist.items) - 1: - # Item was added at the end - _get_playListVersion_from_xml(playlist, xml) - else: - # Move the new item to the correct position - move_playlist_item(playlist, - len(playlist.items) - 1, - pos) - LOG.debug('Successfully added item on the Plex side: %s', playlist) - return item - - -def add_item_to_kodi_playlist(playlist, pos, kodi_id=None, kodi_type=None, - file=None, xml_video_element=None): - """ - Adds an item to the KODI playlist only. WILL ALSO UPDATE OUR PLAYLISTS - - Returns the playlist item that was just added or raises PlaylistError - - file: str! - """ - LOG.debug('Adding new item kodi_id: %s, kodi_type: %s, file: %s to Kodi ' - 'only at position %s for %s', - kodi_id, kodi_type, file, pos, playlist) - params = { - 'playlistid': playlist.playlistid, - 'position': pos - } - if kodi_id is not None: - params['item'] = {'%sid' % kodi_type: int(kodi_id)} - else: - params['item'] = {'file': file} - reply = js.playlist_insert(params) - if reply.get('error') is not None: - raise PlaylistError('Could not add item to playlist. Kodi reply. %s', - reply) - if xml_video_element is not None: - item = playlist_item_from_xml(xml_video_element) - item.kodi_id = kodi_id - item.kodi_type = kodi_type - item.file = file - elif kodi_id is not None: - item = playlist_item_from_kodi( - {'id': kodi_id, 'type': kodi_type, 'file': file}) - if item.plex_id is not None: - xml = PF.GetPlexMetadata(item.plex_id) - item.xml = xml[-1] - playlist.items.insert(pos, item) - return item - - -def move_playlist_item(playlist, before_pos, after_pos): - """ - Moves playlist item from before_pos [int] to after_pos [int] for Plex only. - - WILL ALSO CHANGE OUR PLAYLISTS. - """ - LOG.debug('Moving item from %s to %s on the Plex side for %s', - before_pos, after_pos, playlist) - if after_pos == 0: - url = "{server}/%ss/%s/items/%s/move?after=0" % \ - (playlist.kind, - playlist.id, - playlist.items[before_pos].id) - else: - url = "{server}/%ss/%s/items/%s/move?after=%s" % \ - (playlist.kind, - playlist.id, - playlist.items[before_pos].id, - playlist.items[after_pos - 1].id) - # We need to increment the playlistVersion - _get_playListVersion_from_xml( - playlist, DU().downloadUrl(url, action_type="PUT")) - # Move our item's position in our internal playlist - playlist.items.insert(after_pos, playlist.items.pop(before_pos)) - LOG.debug('Done moving for %s', playlist) - - -def get_PMS_playlist(playlist, playlist_id=None): - """ - Fetches the PMS playlist/playqueue as an XML. Pass in playlist_id if we - need to fetch a new playlist - - Returns None if something went wrong - """ - playlist_id = playlist_id if playlist_id else playlist.id - if playlist.kind == 'playList': - xml = DU().downloadUrl("{server}/playlists/%s/items" % playlist_id) - else: - xml = DU().downloadUrl("{server}/playQueues/%s" % playlist_id) - try: - xml.attrib - except AttributeError: - xml = None - return xml - - -def refresh_playlist_from_PMS(playlist): - """ - Only updates the selected item from the PMS side (e.g. - playQueueSelectedItemID). Will NOT check whether items still make sense. - """ - get_playlist_details_from_xml(playlist, get_PMS_playlist(playlist)) - - -def delete_playlist_item_from_PMS(playlist, pos): - """ - Delete the item at position pos [int] on the Plex side and our playlists - """ - LOG.debug('Deleting position %s for %s on the Plex side', pos, playlist) - xml = DU().downloadUrl("{server}/%ss/%s/items/%s?repeat=%s" % - (playlist.kind, - playlist.id, - playlist.items[pos].id, - playlist.repeat), - action_type="DELETE") - _get_playListVersion_from_xml(playlist, xml) - del playlist.items[pos] - - -# Functions operating on the Kodi playlist objects ########## - -def add_to_Kodi_playlist(playlist, xml_video_element): - """ - Adds a new item to the Kodi playlist via JSON (at the end of the playlist). - Pass in the PMS xml's video element (one level underneath MediaContainer). - - Returns a Playlist_Item or raises PlaylistError - """ - item = playlist_item_from_xml(xml_video_element) - if item.kodi_id: - json_item = {'%sid' % item.kodi_type: item.kodi_id} - else: - json_item = {'file': item.file} - reply = js.playlist_add(playlist.playlistid, json_item) - if reply.get('error') is not None: - raise PlaylistError('Could not add item %s to Kodi playlist. Error: ' - '%s', xml_video_element, reply) - return item - - -def add_listitem_to_Kodi_playlist(playlist, pos, listitem, file, - xml_video_element=None, kodi_item=None): - """ - Adds an xbmc listitem to the Kodi playlist.xml_video_element - - WILL NOT UPDATE THE PLEX SIDE, BUT WILL UPDATE OUR PLAYLISTS - - file: string! - """ - LOG.debug('Insert listitem at position %s for Kodi only for %s', - pos, playlist) - # Add the item into Kodi playlist - playlist.kodi_pl.add(url=file, listitem=listitem, index=pos) - # We need to add this to our internal queue as well - if xml_video_element is not None: - item = playlist_item_from_xml(xml_video_element) - else: - item = playlist_item_from_kodi(kodi_item) - if file is not None: - item.file = file - playlist.items.insert(pos, item) - LOG.debug('Done inserting for %s', playlist) - return item - - -def remove_from_kodi_playlist(playlist, pos): - """ - Removes the item at position pos from the Kodi playlist using JSON. - - WILL NOT UPDATE THE PLEX SIDE, BUT WILL UPDATE OUR PLAYLISTS - """ - LOG.debug('Removing position %s from Kodi only from %s', pos, playlist) - reply = js.playlist_remove(playlist.playlistid, pos) - if reply.get('error') is not None: - LOG.error('Could not delete the item from the playlist. Error: %s', - reply) - return - try: - del playlist.items[pos] - except IndexError: - LOG.error('Cannot delete position %s for %s', pos, playlist) - - -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'}) - try: - xml.attrib - except AttributeError: - LOG.error('Could not download Plex playqueue %s', playqueue_id) - xml = None - return xml - - -def get_plextype_from_xml(xml): - """ - Needed if PMS returns an empty playqueue. Will get the Plex type from the - empty playlist playQueueSourceURI. Feed with (empty) etree xml - - returns None if unsuccessful - """ - try: - plex_id = utils.REGEX_PLEX_ID_FROM_URL.findall( - xml.attrib['playQueueSourceURI'])[0] - except IndexError: - LOG.error('Could not get plex_id from xml: %s', xml.attrib) - return - new_xml = PF.GetPlexMetadata(plex_id) - try: - new_xml[0].attrib - except (TypeError, IndexError, AttributeError): - LOG.error('Could not get plex metadata for plex id %s', plex_id) - return - return new_xml[0].attrib.get('type').decode('utf-8') diff --git a/resources/lib/playlists/db.py b/resources/lib/playlists/db.py index 51cde6902..255042af7 100644 --- a/resources/lib/playlists/db.py +++ b/resources/lib/playlists/db.py @@ -61,7 +61,8 @@ def get_playlist(path=None, kodi_hash=None, plex_id=None): def _m3u_iterator(text): """ - Yields e.g. plugin://plugin.video.plexkodiconnect.movies/?plex_id=xxx + Yields e.g. + http://127.0.0.1:/plex/kodi/movies/file.strm?plex_id=... """ lines = iter(text.split('\n')) for line in lines: diff --git a/resources/lib/playqueue/__init__.py b/resources/lib/playqueue/__init__.py new file mode 100644 index 000000000..3b6220e04 --- /dev/null +++ b/resources/lib/playqueue/__init__.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Monitors the Kodi playqueue and adjusts the Plex playqueue accordingly +""" +from __future__ import absolute_import, division, unicode_literals + +from .common import PlaylistItem, PlaylistItemDummy, PlayqueueError, PLAYQUEUES +from .playqueue import PlayQueue +from .monitor import PlayqueueMonitor +from .functions import init_playqueues, get_playqueue_from_type, \ + playqueue_from_plextype, playqueue_from_id, get_PMS_playlist, \ + init_playqueue_from_plex_children, get_pms_playqueue, \ + get_plextype_from_xml diff --git a/resources/lib/playqueue/common.py b/resources/lib/playqueue/common.py new file mode 100644 index 000000000..ca991f741 --- /dev/null +++ b/resources/lib/playqueue/common.py @@ -0,0 +1,302 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, unicode_literals + +from ..plex_db import PlexDB +from ..plex_api import API +from .. import plex_functions as PF, utils, kodi_db, variables as v, app + +# Our PKC playqueues (3 instances of PlayQueue()) +PLAYQUEUES = [] + + +class PlayqueueError(Exception): + """ + Exception for our playqueue constructs + """ + pass + + +class PlaylistItem(object): + """ + Object to fill our playqueues and playlists with. + + id = None [int] Plex playlist/playqueue id, e.g. playQueueItemID + plex_id = None [int] Plex unique item id, "ratingKey" + plex_type = None [str] Plex type, e.g. 'movie', 'clip' + plex_uuid = None [str] Plex librarySectionUUID + kodi_id = None [int] Kodi unique kodi id (unique only within type!) + kodi_type = None [str] Kodi type: 'movie' + file = None [str] Path to the item's file. STRING!! + uri = None [str] Weird Plex uri path involving plex_uuid. STRING! + guid = None [str] Weird Plex guid + xml = None [etree] XML from PMS, 1 lvl below + playmethod = None [str] either 'DirectPlay', 'DirectStream', 'Transcode' + playcount = None [int] how many times the item has already been played + offset = None [int] the item's view offset UPON START in Plex time + part = 0 [int] part number if Plex video consists of mult. parts + force_transcode [bool] defaults to False + + PlaylistItem compare as equal, if they + - have the same plex_id + - OR: have the same kodi_id AND kodi_type + - OR: have the same file + """ + def __init__(self, plex_id=None, plex_type=None, xml_video_element=None, + kodi_id=None, kodi_type=None, kodi_item=None, grab_xml=False, + lookup_kodi=True): + """ + Pass grab_xml=True in order to get Plex metadata from the PMS while + passing a plex_id. + Pass lookup_kodi=False to NOT check the plex.db for kodi_id and + kodi_type if they're missing (won't be done for clips anyway) + """ + self.name = None + self.id = None + self.plex_id = plex_id + self.plex_type = plex_type + self.plex_uuid = None + self.kodi_id = kodi_id + self.kodi_type = kodi_type + self.file = None + if kodi_item: + self.kodi_id = utils.cast(int, kodi_item.get('id')) + self.kodi_type = kodi_item.get('type') + self.file = kodi_item.get('file') + self.uri = None + self.guid = None + self.xml = None + self.playmethod = None + self.playcount = None + self.offset = None + self.part = 0 + self.force_transcode = False + # Shall we ask user to resume this item? + # None: ask user to resume + # False: do NOT resume, don't ask user + # True: do resume, don't ask user + self.resume = None + if self.plex_id is None: + self._from_plex_db() + if grab_xml and self.plex_id is not None and xml_video_element is None: + xml_video_element = PF.GetPlexMetadata(plex_id) + try: + xml_video_element = xml_video_element[0] + except (TypeError, IndexError): + xml_video_element = None + if xml_video_element is not None: + self.from_xml(xml_video_element) + if (lookup_kodi and (self.kodi_id is None or self.kodi_type is None) and + self.plex_type != v.PLEX_TYPE_CLIP): + with PlexDB(lock=False) as plexdb: + db_item = plexdb.item_by_id(self.plex_id, self.plex_type) + if db_item is not None: + self.kodi_id = db_item['kodi_id'] + self.kodi_type = db_item['kodi_type'] + self.plex_type = db_item['plex_type'] + self.plex_uuid = db_item['section_uuid'] + if (lookup_kodi and (self.kodi_id is None or self.kodi_type is None) and + self.plex_type != v.PLEX_TYPE_CLIP): + self._guess_id_from_file() + self._from_plex_db() + self._set_uri() + + def __eq__(self, other): + if self.plex_id is not None and other.plex_id is not None: + return self.plex_id == other.plex_id + elif (self.kodi_id is not None and other.kodi_id is not None and + self.kodi_type and other.kodi_type): + return (self.kodi_id == other.kodi_id and + self.kodi_type == other.kodi_type) + elif self.file and other.file: + return self.file == other.file + raise RuntimeError('PlaylistItems not fully defined: %s, %s' % + (self, other)) + + def __ne__(self, other): + return not self == other + + def __unicode__(self): + return ("{{" + "'name': '{self.name}', " + "'id': {self.id}, " + "'plex_id': {self.plex_id}, " + "'plex_type': '{self.plex_type}', " + "'kodi_id': {self.kodi_id}, " + "'kodi_type': '{self.kodi_type}', " + "'file': '{self.file}', " + "'uri': '{self.uri}', " + "'guid': '{self.guid}', " + "'playmethod': '{self.playmethod}', " + "'playcount': {self.playcount}, " + "'offset': {self.offset}, " + "'force_transcode': {self.force_transcode}, " + "'part': {self.part}" + "}}".format(self=self)) + + def __str__(self): + return unicode(self).encode('utf-8') + __repr__ = __str__ + + def _from_plex_db(self): + """ + Uses self.kodi_id and self.kodi_type to look up the item in the Plex + DB. Thus potentially sets self.plex_id, plex_type, plex_uuid + """ + if self.kodi_id is None or not self.kodi_type: + return + with PlexDB(lock=False) as plexdb: + db_item = plexdb.item_by_kodi_id(self.kodi_id, self.kodi_type) + if db_item: + self.plex_id = db_item['plex_id'] + self.plex_type = db_item['plex_type'] + self.plex_uuid = db_item['section_uuid'] + + def from_xml(self, xml_video_element): + """ + xml_video_element: etree xml piece 1 level underneath + item.id will only be set if you passed in an xml_video_element from + e.g. a playQueue + """ + api = API(xml_video_element) + self.name = api.title() + self.plex_id = api.plex_id() + self.plex_type = api.plex_type() + self.id = api.item_id() + self.guid = api.guid_html_escaped() + self.playcount = api.viewcount() + self.offset = api.resume_point() + self.xml = xml_video_element + if self.kodi_id is None or not self.kodi_type: + self._from_plex_db() + self._set_uri() + + def from_kodi(self, playlist_item): + """ + playlist_item: dict contains keys 'id', 'type', 'file' (if applicable) + + Will thus set the attributes kodi_id, kodi_type, file, if applicable + If kodi_id & kodi_type are provided, plex_id and plex_type will be + looked up (if not already set) + """ + self.kodi_id = utils.cast(int, playlist_item.get('id')) + self.kodi_type = playlist_item.get('type') + self.file = playlist_item.get('file') + if self.plex_id is None and self.kodi_id is not None and self.kodi_type: + self._from_plex_db() + if self.plex_id is None and self.file: + try: + query = self.file.split('?', 1)[1] + except IndexError: + query = '' + query = dict(utils.parse_qsl(query)) + self.plex_id = utils.cast(int, query.get('plex_id')) + self.plex_type = query.get('itemType') + self._set_uri() + + def _set_uri(self): + if self.plex_id is None and self.file is not None: + self.uri = ('library://whatever/item/%s' + % utils.quote(self.file, safe='')) + elif self.plex_id is not None and self.plex_uuid is not None: + self.uri = ('library://%s/item/library%%2Fmetadata%%2F%s' % + (self.plex_uuid, self.plex_id)) + elif self.plex_id is not None: + self.uri = ('library://%s/item/library%%2Fmetadata%%2F%s' % + (self.plex_id, self.plex_id)) + else: + self.uri = None + + def _guess_id_from_file(self): + """ + If self.file is set, will try to guess kodi_id and kodi_type from the + filename and path using the Kodi video and music databases + """ + if not self.file: + return + # Special case playlist startup - got type but no id + if (not app.SYNC.direct_paths and app.SYNC.enable_music and + self.kodi_type == v.KODI_TYPE_SONG and + self.file.startswith('http')): + self.kodi_id, _ = kodi_db.kodiid_from_filename(self.file, + v.KODI_TYPE_SONG) + return + # Need more info since we don't have kodi_id nor type. Use file path. + if (self.file.startswith('plugin') or + (self.file.startswith('http') and not + self.file.startswith('http://127.0.0.1:%s' % v.WEBSERVICE_PORT))): + return + # Try the VIDEO DB first - will find both movies and episodes + self.kodi_id, self.kodi_type = kodi_db.kodiid_from_filename( + self.file, db_type='video') + if self.kodi_id is None: + # No movie or episode found - try MUSIC DB now for songs + self.kodi_id, self.kodi_type = kodi_db.kodiid_from_filename( + self.file, db_type='music') + self.kodi_type = None if self.kodi_id is None else self.kodi_type + + def plex_stream_index(self, kodi_stream_index, stream_type): + """ + Pass in the kodi_stream_index [int] in order to receive the Plex stream + index. + + stream_type: 'video', 'audio', 'subtitle' + + Returns None if unsuccessful + """ + stream_type = v.PLEX_STREAM_TYPE_FROM_STREAM_TYPE[stream_type] + count = 0 + if kodi_stream_index == -1: + # Kodi telling us "it's the last one" + iterator = list(reversed(self.xml[0][self.part])) + kodi_stream_index = 0 + else: + iterator = self.xml[0][self.part] + # Kodi indexes differently than Plex + for stream in iterator: + if (stream.attrib['streamType'] == stream_type and + 'key' in stream.attrib): + if count == kodi_stream_index: + return stream.attrib['id'] + count += 1 + for stream in iterator: + if (stream.attrib['streamType'] == stream_type and + 'key' not in stream.attrib): + if count == kodi_stream_index: + return stream.attrib['id'] + count += 1 + + def kodi_stream_index(self, plex_stream_index, stream_type): + """ + Pass in the kodi_stream_index [int] in order to receive the Plex stream + index. + + stream_type: 'video', 'audio', 'subtitle' + + Returns None if unsuccessful + """ + stream_type = v.PLEX_STREAM_TYPE_FROM_STREAM_TYPE[stream_type] + count = 0 + for stream in self.xml[0][self.part]: + if (stream.attrib['streamType'] == stream_type and + 'key' in stream.attrib): + if stream.attrib['id'] == plex_stream_index: + return count + count += 1 + for stream in self.xml[0][self.part]: + if (stream.attrib['streamType'] == stream_type and + 'key' not in stream.attrib): + if stream.attrib['id'] == plex_stream_index: + return count + count += 1 + + +class PlaylistItemDummy(PlaylistItem): + """ + Let e.g. Kodimonitor detect that this is a dummy item + """ + def __init__(self, *args, **kwargs): + super(PlaylistItemDummy, self).__init__(*args, **kwargs) + self.name = 'PKC Dummy playqueue item' + self.id = 0 + self.plex_id = 0 diff --git a/resources/lib/playqueue/functions.py b/resources/lib/playqueue/functions.py new file mode 100644 index 000000000..5fc262045 --- /dev/null +++ b/resources/lib/playqueue/functions.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, unicode_literals +from logging import getLogger + +import xbmc + +from .common import PLAYQUEUES, PlaylistItem +from .playqueue import PlayQueue + +from ..downloadutils import DownloadUtils as DU +from .. import json_rpc as js, app, variables as v, plex_functions as PF +from .. import utils + +LOG = getLogger('PLEX.playqueue_functions') + + +def init_playqueues(): + """ + Call this once on startup to initialize the PKC playqueue objects in + the list PLAYQUEUES + """ + if PLAYQUEUES: + LOG.debug('Playqueues have already been initialized') + return + # Initialize Kodi playqueues + with app.APP.lock_playqueues: + for i in (0, 1, 2): + # Just in case the Kodi response is not sorted correctly + for queue in js.get_playlists(): + if queue['playlistid'] != i: + continue + playqueue = PlayQueue() + playqueue.playlistid = i + playqueue.type = queue['type'] + # Initialize each Kodi playlist + if playqueue.type == v.KODI_TYPE_AUDIO: + playqueue.kodi_pl = xbmc.PlayList(xbmc.PLAYLIST_MUSIC) + elif playqueue.type == v.KODI_TYPE_VIDEO: + playqueue.kodi_pl = xbmc.PlayList(xbmc.PLAYLIST_VIDEO) + else: + # Currently, only video or audio playqueues available + playqueue.kodi_pl = xbmc.PlayList(xbmc.PLAYLIST_VIDEO) + # Overwrite 'picture' with 'photo' + playqueue.type = v.KODI_TYPE_PHOTO + PLAYQUEUES.append(playqueue) + LOG.debug('Initialized the Kodi playqueues: %s', PLAYQUEUES) + + +def get_playqueue_from_type(kodi_playlist_type): + """ + Returns the playqueue according to the kodi_playlist_type ('video', + 'audio', 'picture') passed in + """ + for playqueue in PLAYQUEUES: + if playqueue.type == kodi_playlist_type: + break + else: + raise ValueError('Wrong playlist type passed in: %s' + % kodi_playlist_type) + return playqueue + + +def playqueue_from_plextype(plex_type): + if plex_type in v.PLEX_VIDEOTYPES: + plex_type = v.PLEX_TYPE_VIDEO_PLAYLIST + elif plex_type in v.PLEX_AUDIOTYPES: + plex_type = v.PLEX_TYPE_AUDIO_PLAYLIST + else: + plex_type = v.PLEX_TYPE_VIDEO_PLAYLIST + for playqueue in PLAYQUEUES: + if playqueue.type == plex_type: + break + return playqueue + + +def playqueue_from_id(kodi_playlist_id): + for playqueue in PLAYQUEUES: + if playqueue.playlistid == kodi_playlist_id: + break + else: + raise ValueError('Wrong playlist id passed in: %s of type %s' + % (kodi_playlist_id, type(kodi_playlist_id))) + return playqueue + + +def init_playqueue_from_plex_children(plex_id, transient_token=None): + """ + Init a new playqueue e.g. from an album. Alexa does this + + Returns the playqueue + """ + xml = PF.GetAllPlexChildren(plex_id) + try: + xml[0].attrib + except (TypeError, IndexError, AttributeError): + LOG.error('Could not download the PMS xml for %s', plex_id) + return + playqueue = get_playqueue_from_type( + v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[xml[0].attrib['type']]) + playqueue.clear() + for i, child in enumerate(xml): + playlistitem = PlaylistItem(xml_video_element=child) + playqueue.add_item(playlistitem, i) + playqueue.plex_transient_token = transient_token + LOG.debug('Firing up Kodi player') + app.APP.player.play(playqueue.kodi_pl, None, False, 0) + return playqueue + + +def get_PMS_playlist(playlist=None, playlist_id=None): + """ + Fetches the PMS playlist/playqueue as an XML. Pass in playlist_id if we + need to fetch a new playlist + + Returns None if something went wrong + """ + playlist_id = playlist_id if playlist_id else playlist.id + if playlist and playlist.kind == 'playList': + xml = DU().downloadUrl("{server}/playlists/%s/items" % playlist_id) + else: + xml = DU().downloadUrl("{server}/playQueues/%s" % playlist_id) + try: + xml.attrib + except AttributeError: + xml = None + return xml + + +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'}) + try: + xml.attrib + except AttributeError: + LOG.error('Could not download Plex playqueue %s', playqueue_id) + xml = None + return xml + + +def get_plextype_from_xml(xml): + """ + Needed if PMS returns an empty playqueue. Will get the Plex type from the + empty playlist playQueueSourceURI. Feed with (empty) etree xml + + returns None if unsuccessful + """ + try: + plex_id = utils.REGEX_PLEX_ID_FROM_URL.findall( + xml.attrib['playQueueSourceURI'])[0] + except IndexError: + LOG.error('Could not get plex_id from xml: %s', xml.attrib) + return + new_xml = PF.GetPlexMetadata(plex_id) + try: + new_xml[0].attrib + except (TypeError, IndexError, AttributeError): + LOG.error('Could not get plex metadata for plex id %s', plex_id) + return + return new_xml[0].attrib.get('type').decode('utf-8') diff --git a/resources/lib/playqueue.py b/resources/lib/playqueue/monitor.py similarity index 59% rename from resources/lib/playqueue.py rename to resources/lib/playqueue/monitor.py index 929ccc75d..911e9be7e 100644 --- a/resources/lib/playqueue.py +++ b/resources/lib/playqueue/monitor.py @@ -7,90 +7,11 @@ from logging import getLogger import copy -import xbmc +from .common import PlayqueueError, PlaylistItem, PLAYQUEUES +from .. import backgroundthread, json_rpc as js, utils, app -from .plex_api import API -from . import playlist_func as PL, plex_functions as PF -from . import backgroundthread, utils, json_rpc as js, app, variables as v -############################################################################### -LOG = getLogger('PLEX.playqueue') - -PLUGIN = 'plugin://%s' % v.ADDON_ID - -# Our PKC playqueues (3 instances of Playqueue_Object()) -PLAYQUEUES = [] -############################################################################### - - -def init_playqueues(): - """ - Call this once on startup to initialize the PKC playqueue objects in - the list PLAYQUEUES - """ - if PLAYQUEUES: - LOG.debug('Playqueues have already been initialized') - return - # Initialize Kodi playqueues - with app.APP.lock_playqueues: - for i in (0, 1, 2): - # Just in case the Kodi response is not sorted correctly - for queue in js.get_playlists(): - if queue['playlistid'] != i: - continue - playqueue = PL.Playqueue_Object() - playqueue.playlistid = i - playqueue.type = queue['type'] - # Initialize each Kodi playlist - if playqueue.type == v.KODI_TYPE_AUDIO: - playqueue.kodi_pl = xbmc.PlayList(xbmc.PLAYLIST_MUSIC) - elif playqueue.type == v.KODI_TYPE_VIDEO: - playqueue.kodi_pl = xbmc.PlayList(xbmc.PLAYLIST_VIDEO) - else: - # Currently, only video or audio playqueues available - playqueue.kodi_pl = xbmc.PlayList(xbmc.PLAYLIST_VIDEO) - # Overwrite 'picture' with 'photo' - playqueue.type = v.KODI_TYPE_PHOTO - PLAYQUEUES.append(playqueue) - LOG.debug('Initialized the Kodi playqueues: %s', PLAYQUEUES) - - -def get_playqueue_from_type(kodi_playlist_type): - """ - Returns the playqueue according to the kodi_playlist_type ('video', - 'audio', 'picture') passed in - """ - for playqueue in PLAYQUEUES: - if playqueue.type == kodi_playlist_type: - break - else: - raise ValueError('Wrong playlist type passed in: %s', - kodi_playlist_type) - return playqueue - - -def init_playqueue_from_plex_children(plex_id, transient_token=None): - """ - Init a new playqueue e.g. from an album. Alexa does this - - Returns the playqueue - """ - xml = PF.GetAllPlexChildren(plex_id) - try: - xml[0].attrib - except (TypeError, IndexError, AttributeError): - LOG.error('Could not download the PMS xml for %s', plex_id) - return - playqueue = get_playqueue_from_type( - v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[xml[0].attrib['type']]) - playqueue.clear() - for i, child in enumerate(xml): - api = API(child) - PL.add_item_to_playlist(playqueue, i, plex_id=api.plex_id()) - playqueue.plex_transient_token = transient_token - LOG.debug('Firing up Kodi player') - app.APP.player.play(playqueue.kodi_pl, None, False, 0) - return playqueue +LOG = getLogger('PLEX.playqueue_monitor') class PlayqueueMonitor(backgroundthread.KillableThread): @@ -146,26 +67,24 @@ def _compare_playqueues(self, playqueue, new_kodi_playqueue): LOG.debug('Playqueue item %s moved to position %s', i + j, i) try: - PL.move_playlist_item(playqueue, i + j, i) - except PL.PlaylistError: + playqueue.plex_move_item(i + j, i) + except PlayqueueError: LOG.error('Could not modify playqueue positions') LOG.error('This is likely caused by mixing audio and ' 'video tracks in the Kodi playqueue') del old[j], index[j] break else: + playlistitem = PlaylistItem(kodi_item=new_item) LOG.debug('Detected new Kodi element at position %s: %s ', - i, new_item) + i, playlistitem) try: if playqueue.id is None: - PL.init_plex_playqueue(playqueue, kodi_item=new_item) + playqueue.init(playlistitem) else: - PL.add_item_to_plex_playqueue(playqueue, - i, - kodi_item=new_item) - except PL.PlaylistError: - # Could not add the element - pass + playqueue.plex_add_item(playlistitem, i) + except PlayqueueError: + LOG.warn('Couldnt add new item to Plex: %s', playlistitem) except IndexError: # This is really a hack - happens when using Addon Paths # and repeatedly starting the same element. Kodi will then @@ -183,8 +102,8 @@ def _compare_playqueues(self, playqueue, new_kodi_playqueue): return LOG.debug('Detected deletion of playqueue element at pos %s', i) try: - PL.delete_playlist_item_from_PMS(playqueue, i) - except PL.PlaylistError: + playqueue.plex_remove_item(i) + except PlayqueueError: LOG.error('Could not delete PMS element from position %s', i) LOG.error('This is likely caused by mixing audio and ' 'video tracks in the Kodi playqueue') @@ -206,6 +125,8 @@ def _run(self): with app.APP.lock_playqueues: for playqueue in PLAYQUEUES: kodi_pl = js.playlist_get_items(playqueue.playlistid) + playqueue.old_kodi_pl = list(kodi_pl) + continue if playqueue.old_kodi_pl != kodi_pl: if playqueue.id is None and (not app.SYNC.direct_paths or app.PLAYSTATE.context_menu_play): @@ -215,5 +136,4 @@ def _run(self): else: # compare old and new playqueue self._compare_playqueues(playqueue, kodi_pl) - playqueue.old_kodi_pl = list(kodi_pl) app.APP.monitor.waitForAbort(0.2) diff --git a/resources/lib/playqueue/playqueue.py b/resources/lib/playqueue/playqueue.py new file mode 100644 index 000000000..2e26dc989 --- /dev/null +++ b/resources/lib/playqueue/playqueue.py @@ -0,0 +1,604 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, unicode_literals +from logging import getLogger +import threading + +from .common import PlaylistItem, PlaylistItemDummy, PlayqueueError + +from ..downloadutils import DownloadUtils as DU +from ..plex_api import API +from ..plex_db import PlexDB +from ..kodi_db import KodiVideoDB +from ..playutils import PlayUtils +from ..windows.resume import resume_dialog +from .. import plex_functions as PF, utils, widgets, variables as v, app +from .. import json_rpc as js + + +LOG = getLogger('PLEX.playqueue') + + +class PlayQueue(object): + """ + PKC object to represent PMS playQueues and Kodi playlist for queueing + + playlistid = None [int] Kodi playlist id (0, 1, 2) + type = None [str] Kodi type: 'audio', 'video', 'picture' + kodi_pl = None Kodi xbmc.PlayList object + items = [] [list] of PlaylistItem + id = None [str] Plex playQueueID, unique Plex identifier + version = None [int] Plex version of the playQueue + selectedItemID = None + [str] Plex selectedItemID, playing element in queue + selectedItemOffset = None + [str] Offset of the playing element in queue + shuffled = 0 [int] 0: not shuffled, 1: ??? 2: ??? + repeat = 0 [int] 0: not repeated, 1: ??? 2: ??? + + If Companion playback is initiated by another user: + plex_transient_token = None + """ + kind = 'playQueue' + + def __init__(self): + self.id = None + self.type = None + self.playlistid = None + self.kodi_pl = None + self.items = [] + self.version = None + self.selectedItemID = None + self.selectedItemOffset = None + self.shuffled = 0 + self.repeat = 0 + self.plex_transient_token = None + # Need a hack for detecting swaps of elements + self.old_kodi_pl = [] + # Did PKC itself just change the playqueue so the PKC playqueue monitor + # should not pick up any changes? + self.pkc_edit = False + # Workaround to avoid endless loops of detecting PL clears + self._clear_list = [] + # To keep track if Kodi playback was initiated from a Kodi playlist + # There are a couple of pitfalls, unfortunately... + self.kodi_playlist_playback = False + # Playlist position/index used when initiating the playqueue + self.index = None + self.force_transcode = None + + def __unicode__(self): + return ("{{" + "'playlistid': {self.playlistid}, " + "'id': {self.id}, " + "'version': {self.version}, " + "'type': '{self.type}', " + "'items': {items}, " + "'selectedItemID': {self.selectedItemID}, " + "'selectedItemOffset': {self.selectedItemOffset}, " + "'shuffled': {self.shuffled}, " + "'repeat': {self.repeat}, " + "'kodi_playlist_playback': {self.kodi_playlist_playback}, " + "'pkc_edit': {self.pkc_edit}, " + "}}").format(**{ + 'items': ['%s/%s: %s' % (x.plex_id, x.id, x.name) + for x in self.items], + 'self': self + }) + + def __str__(self): + return unicode(self).encode('utf-8') + __repr__ = __str__ + + def is_pkc_clear(self): + """ + Returns True if PKC has cleared the Kodi playqueue just recently. + Then this clear will be ignored from now on + """ + try: + self._clear_list.pop() + except IndexError: + return False + else: + return True + + def clear(self, kodi=True): + """ + Resets the playlist object to an empty playlist. + + Pass kodi=False in order to NOT clear the Kodi playqueue + """ + # kodi monitor's on_clear method will only be called if there were some + # items to begin with + if kodi and self.kodi_pl.size() != 0: + self._clear_list.append(None) + self.kodi_pl.clear() # Clear Kodi playlist object + self.items = [] + self.id = None + self.version = None + self.selectedItemID = None + self.selectedItemOffset = None + self.shuffled = 0 + self.repeat = 0 + self.plex_transient_token = None + self.old_kodi_pl = [] + self.kodi_playlist_playback = False + self.index = None + self.force_transcode = None + LOG.debug('Playlist cleared: %s', self) + + def init(self, playlistitem): + """ + Hit if Kodi initialized playback and we need to catch up on the PKC + and Plex side; e.g. for direct paths. + + Kodi side will NOT be changed, e.g. no trailers will be added, but Kodi + playqueue taken as-is + """ + LOG.debug('Playqueue init called') + self.clear(kodi=False) + if not isinstance(playlistitem, PlaylistItem) or playlistitem.uri is None: + raise RuntimeError('Didnt receive a valid PlaylistItem but %s: %s' + % (type(playlistitem), playlistitem)) + try: + params = { + 'next': 0, + 'type': self.type, + 'uri': playlistitem.uri + } + xml = DU().downloadUrl(url="{server}/%ss" % self.kind, + action_type="POST", + parameters=params) + self.update_details_from_xml(xml) + # Need to update the details for the playlist item + playlistitem.from_xml(xml[0]) + except (KeyError, IndexError, TypeError): + LOG.error('Could not init Plex playlist with %s', playlistitem) + raise PlayqueueError() + self.items.append(playlistitem) + LOG.debug('Initialized the playqueue on the Plex side: %s', self) + + def play(self, plex_id, plex_type=None, startpos=None, position=None, + synched=True, force_transcode=None): + """ + Initializes the playQueue with e.g. trailers and additional file parts + Pass synched=False if you're sure that this item has not been synched + to Kodi + + Or resolves webservice paths to actual paths + + Hit by webservice.py + """ + LOG.debug('Play called with plex_id %s, plex_type %s, position %s, ' + 'synched %s, force_transcode %s, startpos %s', plex_id, + plex_type, position, synched, force_transcode, startpos) + resolve = False + try: + if plex_id == self.items[startpos].plex_id: + resolve = True + except IndexError: + pass + if resolve: + LOG.info('Resolving playback') + self._resolve(plex_id, startpos) + else: + LOG.info('Initializing playback') + self._init(plex_id, + plex_type, + startpos, + position, + synched, + force_transcode) + + def _resolve(self, plex_id, startpos): + """ + The Plex playqueue has already been initialized. We resolve the path + from original webservice http://127.0.0.1 to the "correct" Plex one + """ + playlistitem = self.items[startpos] + # Add an additional item with the resolved path after the current one + self.index = startpos + 1 + xml = PF.GetPlexMetadata(plex_id) + if xml in (None, 401): + raise PlayqueueError('Could not get Plex metadata %s for %s', + plex_id, self.items[startpos]) + api = API(xml[0]) + if playlistitem.resume is None: + # Potentially ask user to resume + resume = self._resume_playback(None, xml[0]) + else: + # Do NOT ask user + resume = playlistitem.resume + # Use the original playlistitem to retain all info! + self._kodi_add_xml(xml[0], + api, + resume, + playlistitem=playlistitem) + # Add additional file parts, if any exist + self._add_additional_parts(xml) + # Note: the CURRENT playlistitem will be deleted through webservice.py + # once the path resolution has completed + + def _init(self, plex_id, plex_type=None, startpos=None, position=None, + synched=True, force_transcode=None): + """ + Initializes the Plex and PKC playqueue for playback. Possibly adds + additionals trailers + """ + self.index = position + while len(self.items) < self.kodi_pl.size(): + # The original item that Kodi put into the playlist, e.g. + # { + # u'title': u'', + # u'type': u'unknown', + # u'file': u'http://127.0.0.1:57578/plex/kodi/....', + # u'label': u'' + # } + # We CANNOT delete that item right now - so let's add a dummy + # on the PKC side to keep all indicees lined up. + # The failing item will be deleted in webservice.py + LOG.debug('Adding a dummy item to our playqueue') + self.items.insert(0, PlaylistItemDummy()) + self.force_transcode = force_transcode + if synched: + with PlexDB(lock=False) as plexdb: + db_item = plexdb.item_by_id(plex_id, plex_type) + else: + db_item = None + if db_item: + xml = None + section_uuid = db_item['section_uuid'] + plex_type = db_item['plex_type'] + else: + xml = PF.GetPlexMetadata(plex_id) + if xml in (None, 401): + raise PlayqueueError('Could not get Plex metadata %s', plex_id) + section_uuid = xml.get('librarySectionUUID') + api = API(xml[0]) + plex_type = api.plex_type() + resume = self._resume_playback(db_item, xml) + trailers = False + if (not resume and plex_type == v.PLEX_TYPE_MOVIE and + utils.settings('enableCinema') == 'true'): + if utils.settings('askCinema') == "true": + # "Play trailers?" + trailers = utils.yesno_dialog(utils.lang(29999), + utils.lang(33016)) or False + else: + trailers = True + LOG.debug('Playing trailers: %s', trailers) + xml = PF.init_plex_playqueue(plex_id, + section_uuid, + plex_type=plex_type, + trailers=trailers) + if xml is None: + LOG.error('Could not get playqueue for plex_id %s UUID %s for %s', + plex_id, section_uuid, self) + raise PlayqueueError('Could not get playqueue') + # See that we add trailers, if they exist in the xml return + self._add_intros(xml) + # Add the main item after the trailers + # Look at the LAST item + api = API(xml[-1]) + self._kodi_add_xml(xml[-1], api, resume) + # Add additional file parts, if any exist + self._add_additional_parts(xml) + self.update_details_from_xml(xml) + + @staticmethod + def _resume_playback(db_item=None, xml=None): + ''' + Pass in either db_item or xml + Resume item if available. Returns bool or raise a PlayqueueError if + resume was cancelled by user. + ''' + resume = app.PLAYSTATE.resume_playback + app.PLAYSTATE.resume_playback = None + if app.PLAYSTATE.autoplay: + resume = False + LOG.info('Skip resume for autoplay') + elif resume is None: + if db_item: + with KodiVideoDB(lock=False) as kodidb: + resume = kodidb.get_resume(db_item['kodi_fileid']) + else: + api = API(xml) + resume = api.resume_point() + if resume: + resume = resume_dialog(resume) + LOG.info('User chose resume: %s', resume) + if resume is None: + raise PlayqueueError('User backed out of resume dialog') + app.PLAYSTATE.autoplay = True + return resume + + def _add_intros(self, xml): + ''' + if we have any play them when the movie/show is not being resumed. + ''' + if not len(xml) > 1: + LOG.debug('No trailers returned from the PMS') + return + for i, intro in enumerate(xml): + if i + 1 == len(xml): + # The main item we're looking at - skip! + break + api = API(intro) + LOG.debug('Adding trailer: %s', api.title()) + self._kodi_add_xml(intro, api, resume=False) + + def _add_additional_parts(self, xml): + ''' Create listitems and add them to the stack of playlist. + ''' + api = API(xml[0]) + for part, _ in enumerate(xml[0][0]): + if part == 0: + # The first part that we've already added + continue + api.set_part_number(part) + LOG.debug('Adding addional part for %s: %s', api.title(), part) + self._kodi_add_xml(xml[0], api, resume=False) + + def _kodi_add_xml(self, xml, api, resume, playlistitem=None): + """ + Be careful what you pass as resume: + False: do not resume, do not subsequently ask user + True: do resume, do not subsequently ask user + """ + if not playlistitem: + playlistitem = PlaylistItem(xml_video_element=xml) + playlistitem.part = api.part + playlistitem.force_transcode = self.force_transcode + playlistitem.resume = resume + listitem = widgets.get_listitem(xml, resume=resume) + listitem.setSubtitles(api.cache_external_subs()) + play = PlayUtils(api, playlistitem) + url = play.getPlayUrl() + listitem.setPath(url.encode('utf-8')) + self.kodi_add_item(playlistitem, self.index, listitem) + self.items.insert(self.index, playlistitem) + self.index += 1 + + def update_details_from_xml(self, xml): + """ + Updates the playlist details from the xml provided + """ + self.id = utils.cast(int, xml.get('%sID' % self.kind)) + self.version = utils.cast(int, xml.get('%sVersion' % self.kind)) + self.shuffled = utils.cast(int, xml.get('%sShuffled' % self.kind)) + self.selectedItemID = utils.cast(int, + xml.get('%sSelectedItemID' % self.kind)) + self.selectedItemOffset = utils.cast(int, + xml.get('%sSelectedItemOffset' + % self.kind)) + LOG.debug('Updated playlist from xml: %s', self) + + def add_item(self, item, pos, listitem=None): + """ + Adds a PlaylistItem to both Kodi and Plex at position pos [int] + Also changes self.items + Raises PlayqueueError + """ + self.kodi_add_item(item, pos, listitem) + self.plex_add_item(item, pos) + + def kodi_add_item(self, item, pos, listitem=None): + """ + Adds a PlaylistItem to Kodi only. Will not change self.items + Raises PlayqueueError + """ + if not isinstance(item, PlaylistItem): + raise PlayqueueError('Wrong item %s of type %s received' + % (item, type(item))) + if pos > len(self.items): + raise PlayqueueError('Position %s too large for playlist length %s' + % (pos, len(self.items))) + LOG.debug('Adding item to Kodi playlist at position %s: %s', pos, item) + if listitem: + self.kodi_pl.add(url=listitem.getPath(), + listitem=listitem, + index=pos) + elif item.kodi_id is not None and item.kodi_type is not None: + # This method ensures we have full Kodi metadata, potentially + # with more artwork, for example, than Plex provides + if pos == len(self.items): + answ = js.playlist_add(self.playlistid, + {'%sid' % item.kodi_type: item.kodi_id}) + else: + answ = js.playlist_insert({'playlistid': self.playlistid, + 'position': pos, + 'item': {'%sid' % item.kodi_type: item.kodi_id}}) + if 'error' in answ: + raise PlayqueueError('Kodi did not add item to playlist: %s', + answ) + else: + if item.xml is None: + LOG.debug('Need to get metadata for item %s', item) + item.xml = PF.GetPlexMetadata(item.plex_id) + if item.xml in (None, 401): + raise PlayqueueError('Could not get metadata for %s', item) + api = API(item.xml[0]) + listitem = widgets.get_listitem(item.xml, resume=True) + url = 'http://127.0.0.1:%s/plex/play/file.strm' % v.WEBSERVICE_PORT + args = { + 'plex_id': item.plex_id, + 'plex_type': api.plex_type() + } + if item.force_transcode: + args['transcode'] = 'true' + url = utils.extend_url(url, args) + item.file = url + listitem.setPath(url.encode('utf-8')) + self.kodi_pl.add(url=url.encode('utf-8'), + listitem=listitem, + index=pos) + + def plex_add_item(self, item, pos): + """ + Adds a new PlaylistItem to the playlist at position pos [int] only on + the Plex side of things. Also changes self.items + Raises PlayqueueError + """ + if not isinstance(item, PlaylistItem) or not item.uri: + raise PlayqueueError('Wrong item %s of type %s received' + % (item, type(item))) + if pos > len(self.items): + raise PlayqueueError('Position %s too large for playlist length %s' + % (pos, len(self.items))) + LOG.debug('Adding item to Plex playlist at position %s: %s', pos, item) + url = '{server}/%ss/%s?uri=%s' % (self.kind, self.id, item.uri) + # Will usually put the new item at the end of the Plex playlist + xml = DU().downloadUrl(url, action_type='PUT') + try: + xml[0].attrib + except (TypeError, AttributeError, KeyError, IndexError): + raise PlayqueueError('Could not add item %s to playlist %s' + % (item, self)) + for actual_pos, xml_video_element in enumerate(xml): + api = API(xml_video_element) + if api.plex_id() == item.plex_id: + break + else: + raise PlayqueueError('Something went wrong - Plex id not found') + item.from_xml(xml[actual_pos]) + self.items.insert(actual_pos, item) + self.update_details_from_xml(xml) + if actual_pos != pos: + self.plex_move_item(actual_pos, pos) + LOG.debug('Added item %s on Plex side: %s', item, self) + + def kodi_remove_item(self, pos): + """ + Only manipulates the Kodi playlist. Won't change self.items + """ + LOG.debug('Removing position %s on the Kodi side for %s', pos, self) + answ = js.playlist_remove(self.playlistid, pos) + if 'error' in answ: + raise PlayqueueError('Could not remove item: %s' % answ['error']) + + def plex_remove_item(self, pos): + """ + Removes an item from Plex as well as our self.items item list + """ + LOG.debug('Deleting position %s on the Plex side for: %s', pos, self) + try: + xml = DU().downloadUrl("{server}/%ss/%s/items/%s?repeat=%s" % + (self.kind, + self.id, + self.items[pos].id, + self.repeat), + action_type="DELETE") + self.update_details_from_xml(xml) + del self.items[pos] + except IndexError: + LOG.error('Could not delete item at position %s on the Plex side', + pos) + raise PlayqueueError() + + def plex_move_item(self, before, after): + """ + Moves playlist item from before [int] to after [int] for Plex only. + + Will also change self.items + """ + if before > len(self.items) or after > len(self.items) or after == before: + raise PlayqueueError('Illegal original position %s and/or desired ' + 'position %s for playlist length %s' % + (before, after, len(self.items))) + LOG.debug('Moving item from %s to %s on the Plex side for %s', + before, after, self) + if after == 0: + url = "{server}/%ss/%s/items/%s/move?after=0" % \ + (self.kind, + self.id, + self.items[before].id) + elif after > before: + url = "{server}/%ss/%s/items/%s/move?after=%s" % \ + (self.kind, + self.id, + self.items[before].id, + self.items[after].id) + else: + url = "{server}/%ss/%s/items/%s/move?after=%s" % \ + (self.kind, + self.id, + self.items[before].id, + self.items[after - 1].id) + xml = DU().downloadUrl(url, action_type="PUT") + try: + xml[0].attrib + except (TypeError, IndexError, AttributeError): + raise PlayqueueError('Could not move playlist item from %s to %s ' + 'for %s' % (before, after, self)) + self.update_details_from_xml(xml) + self.items.insert(after, self.items.pop(before)) + LOG.debug('Done moving items for %s', self) + + def init_from_xml(self, xml, offset=None, start_plex_id=None, repeat=None, + transient_token=None): + """ + Play all items contained in the xml passed in. Called by Plex Companion. + Either supply the ratingKey of the starting Plex element. Or set + playqueue.selectedItemID + + offset [float]: will seek to position offset after playback start + start_plex_id [int]: the plex_id of the element that should be + played + repeat [int]: 0: don't repear + 1: repeat item + 2: repeat everything + transient_token [unicode]: temporary token received from the PMS + + Will stop current playback and start playback at the end + """ + LOG.debug("init_from_xml called with offset %s, start_plex_id %s", + offset, start_plex_id) + app.APP.player.stop() + self.clear() + self.update_details_from_xml(xml) + self.repeat = 0 if not repeat else repeat + self.plex_transient_token = transient_token + for pos, xml_video_element in enumerate(xml): + playlistitem = PlaylistItem(xml_video_element=xml_video_element) + self.kodi_add_item(playlistitem, pos) + self.items.append(playlistitem) + # Where do we start playback? + if start_plex_id is not None: + for startpos, item in enumerate(self.items): + if item.plex_id == start_plex_id: + break + else: + startpos = 0 + else: + for startpos, item in enumerate(self.items): + if item.id == self.selectedItemID: + break + else: + startpos = 0 + # Set resume for the item we should play - do NOT ask user since we + # initiated from the other Companion client + self.items[startpos].resume = True if offset else False + self.start_playback(pos=startpos, offset=offset) + + def start_playback(self, pos=0, offset=0): + """ + Seek immediately after kicking off playback is not reliable. + Threaded, since we need to return BEFORE seeking + """ + LOG.info('Starting playback at %s offset %s for %s', pos, offset, self) + thread = threading.Thread(target=self._threaded_playback, + args=(self.kodi_pl, pos, offset)) + thread.start() + + @staticmethod + def _threaded_playback(kodi_playlist, pos, offset): + app.APP.player.play(kodi_playlist, startpos=pos, windowed=False) + if offset: + i = 0 + while not app.APP.is_playing: + app.APP.monitor.waitForAbort(0.1) + i += 1 + if i > 50: + LOG.warn('Could not seek to %s', offset) + return + js.seek_to(offset) diff --git a/resources/lib/playstrm.py b/resources/lib/playstrm.py new file mode 100644 index 000000000..37e58f4c8 --- /dev/null +++ b/resources/lib/playstrm.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, unicode_literals +from logging import getLogger + +from . import app, utils, json_rpc, variables as v, playqueue as PQ + + +LOG = getLogger('PLEX.playstrm') + + +class PlayStrmException(Exception): + """ + Any Exception associated with playstrm + """ + pass + + +class PlayStrm(object): + ''' + Workflow: Strm that calls our webservice in database. When played, the + webserivce returns a dummy file to play. Meanwhile, PlayStrm adds the real + listitems for items to play to the playlist. + ''' + def __init__(self, params): + LOG.debug('Starting PlayStrm with params: %s', params) + self.plex_id = utils.cast(int, params['plex_id']) + self.plex_type = params.get('plex_type') + if params.get('synched') and params['synched'].lower() == 'false': + self.synched = False + else: + self.synched = True + self.kodi_id = utils.cast(int, params.get('kodi_id')) + self.kodi_type = params.get('kodi_type') + self.force_transcode = params.get('transcode') == 'true' + if app.PLAYSTATE.audioplaylist: + LOG.debug('Audio playlist detected') + self.playqueue = PQ.get_playqueue_from_type(v.KODI_TYPE_AUDIO) + else: + LOG.debug('Video playlist detected') + self.playqueue = PQ.get_playqueue_from_type(v.KODI_TYPE_VIDEO) + + def __unicode__(self): + return ("{{" + "'plex_id': {self.plex_id}, " + "'plex_type': '{self.plex_type}', " + "'kodi_id': {self.kodi_id}, " + "'kodi_type': '{self.kodi_type}', " + "}}").format(self=self) + + def __str__(self): + return unicode(self).encode('utf-8') + __repr__ = __str__ + + def play(self, start_position=None, delayed=True): + ''' + Create and add a single listitem to the Kodi playlist, potentially + with trailers and different file-parts + ''' + LOG.debug('play called with start_position %s, delayed %s', + start_position, delayed) + LOG.debug('Kodi playlist BEFORE: %s', + json_rpc.playlist_get_items(self.playqueue.playlistid)) + self.playqueue.init(self.plex_id, + plex_type=self.plex_type, + position=start_position, + synched=self.synched, + force_transcode=self.force_transcode) + LOG.info('Initiating play for %s', self) + LOG.debug('Kodi playlist AFTER: %s', + json_rpc.playlist_get_items(self.playqueue.playlistid)) + if not delayed: + self.playqueue.start_playback(start_position) + return self.playqueue.index + + def play_folder(self, position=None): + ''' + When an entire queue is requested, If requested from Kodi, kodi_type is + provided, add as Kodi would, otherwise queue playlist items using strm + links to setup playback later. + ''' + start_position = position or max(self.playqueue.kodi_pl.size(), 0) + index = start_position + 1 + LOG.info('Play folder plex_id %s, index: %s', self.plex_id, index) + item = PQ.PlaylistItem(plex_id=self.plex_id, + plex_type=self.plex_type, + kodi_id=self.kodi_id, + kodi_type=self.kodi_type) + self.playqueue.add_item(item, index) + index += 1 + return index - 1 diff --git a/resources/lib/playutils.py b/resources/lib/playutils.py index 773f9fb83..96a22ea05 100644 --- a/resources/lib/playutils.py +++ b/resources/lib/playutils.py @@ -14,13 +14,13 @@ class PlayUtils(): - def __init__(self, api, playqueue_item): + def __init__(self, api, playlistitem): """ init with api (PlexAPI wrapper of the PMS xml element) and - playqueue_item (Playlist_Item()) + playlistitem [PlaylistItem()] """ self.api = api - self.item = playqueue_item + self.item = playlistitem def getPlayUrl(self): """ diff --git a/resources/lib/plex_api.py b/resources/lib/plex_api.py index f8152abf1..d70872d80 100644 --- a/resources/lib/plex_api.py +++ b/resources/lib/plex_api.py @@ -133,12 +133,14 @@ def path(self, force_first_media=True, force_addon=False, # Set plugin path and media flags using real filename if self.plex_type() == v.PLEX_TYPE_EPISODE: # need to include the plex show id in the path - path = ('plugin://plugin.video.plexkodiconnect.tvshows/%s/' - % self.grandparent_id()) - else: - path = 'plugin://%s/' % v.ADDON_TYPE[self.plex_type()] - path = ('%s?plex_id=%s&plex_type=%s&mode=play&filename=%s' - % (path, self.plex_id(), self.plex_type(), filename)) + path = ('http://127.0.0.1:%s/plex/kodi/shows/%s' + % (v.WEBSERVICE_PORT, self.grandparent_id())) + elif self.plex_type() in (v.PLEX_TYPE_MOVIE, v.PLEX_TYPE_CLIP): + path = 'http://127.0.0.1:%s/plex/kodi/movies' % v.WEBSERVICE_PORT + elif self.plex_type() == v.PLEX_TYPE_SONG: + path = 'http://127.0.0.1:%s/plex/kodi/music' % v.WEBSERVICE_PORT + path = '{0}/{1}/file.strm?plex_id={1}&plex_type={2}'.format( + path, self.plex_id(), self.plex_type()) else: # Direct paths is set the Kodi way path = self.validate_playurl(filename, @@ -810,8 +812,8 @@ def trailer(self): elif not url: url = extra.get('ratingKey') if url: - url = ('plugin://%s.movies/?plex_id=%s&plex_type=%s&mode=play' - % (v.ADDON_ID, url, v.PLEX_TYPE_CLIP)) + url = 'http://127.0.0.1:{0}/plex/kodi/movies/{1}/file.strm?plex_id={1}&plex_type={2}'.format( + v.WEBSERVICE_PORT, url, v.PLEX_TYPE_CLIP) return url def mediastreams(self): diff --git a/resources/lib/plex_companion.py b/resources/lib/plex_companion.py index 267e42e02..ade780eba 100644 --- a/resources/lib/plex_companion.py +++ b/resources/lib/plex_companion.py @@ -14,8 +14,6 @@ from .plex_api import API from . import utils from . import plex_functions as PF -from . import playlist_func as PL -from . import playback from . import json_rpc as js from . import playqueue as PQ from . import variables as v @@ -40,6 +38,8 @@ def update_playqueue_from_PMS(playqueue, repeat = 0, 1, 2 offset = time offset in Plextime (milliseconds) + + Will (re)start playback """ LOG.info('New playqueue %s received from Plex companion with offset ' '%s, repeat %s', playqueue_id, offset, repeat) @@ -47,21 +47,15 @@ def update_playqueue_from_PMS(playqueue, if transient_token is None: transient_token = playqueue.plex_transient_token with app.APP.lock_playqueues: - xml = PL.get_PMS_playlist(playqueue, playqueue_id) - try: - xml.attrib - except AttributeError: + xml = PQ.get_PMS_playlist(playlist_id=playqueue_id) + if xml is None: LOG.error('Could now download playqueue %s', playqueue_id) - return - playqueue.clear() - try: - PL.get_playlist_details_from_xml(playqueue, xml) - except PL.PlaylistError: - LOG.error('Could not get playqueue ID %s', playqueue_id) - return - playqueue.repeat = 0 if not repeat else int(repeat) - playqueue.plex_transient_token = transient_token - playback.play_xml(playqueue, xml, offset) + raise PQ.PlayqueueError() + app.PLAYSTATE.initiated_by_plex = True + playqueue.init_from_xml(xml, + offset=offset, + repeat=0 if not repeat else int(repeat), + transient_token=transient_token) class PlexCompanion(backgroundthread.KillableThread): @@ -81,45 +75,47 @@ def __init__(self): @staticmethod def _process_alexa(data): + app.PLAYSTATE.initiated_by_plex = True xml = PF.GetPlexMetadata(data['key']) try: xml[0].attrib except (AttributeError, IndexError, TypeError): LOG.error('Could not download Plex metadata for: %s', data) - return + raise PQ.PlayqueueError() api = API(xml[0]) if api.plex_type() == v.PLEX_TYPE_ALBUM: LOG.debug('Plex music album detected') - PQ.init_playqueue_from_plex_children( - api.plex_id(), - transient_token=data.get('token')) + xml = PF.GetAllPlexChildren(api.plex_id()) + try: + xml[0].attrib + except (TypeError, IndexError, AttributeError): + LOG.error('Could not download the album xml for %s', data) + raise PQ.PlayqueueError() + playqueue = PQ.get_playqueue_from_type('audio') + playqueue.init_from_xml(xml, + transient_token=data.get('token')) elif data['containerKey'].startswith('/playQueues/'): _, container_key, _ = PF.ParseContainerKey(data['containerKey']) xml = PF.DownloadChunks('{server}/playQueues/%s' % container_key) if xml is None: - # "Play error" - utils.dialog('notification', - utils.lang(29999), - utils.lang(30128), - icon='{error}') - return + LOG.error('Could not get playqueue for %s', data) + raise PQ.PlayqueueError() playqueue = PQ.get_playqueue_from_type( v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[api.plex_type()]) - playqueue.clear() - PL.get_playlist_details_from_xml(playqueue, xml) - playqueue.plex_transient_token = data.get('token') - if data.get('offset') != '0': - offset = float(data['offset']) / 1000.0 - else: - offset = None - playback.play_xml(playqueue, xml, offset) + offset = utils.cast(float, data.get('offset')) or None + if offset: + offset = offset / 1000.0 + playqueue.init_from_xml(xml, + offset=offset, + transient_token=data.get('token')) else: app.CONN.plex_transient_token = data.get('token') - if data.get('offset') != '0': + if utils.cast(float, data.get('offset')): app.PLAYSTATE.resume_playback = True - playback.playback_triage(api.plex_id(), - api.plex_type(), - resolve=False) + path = ('http://127.0.0.1:%s/plex/play/file.strm?plex_id=%s' + % (v.WEBSERVICE_PORT, api.plex_id())) + path += '&plex_type=%s' % api.plex_type() + executebuiltin(('PlayMedia(%s)' % path).encode('utf-8')) @staticmethod def _process_node(data): @@ -150,14 +146,14 @@ def _process_playlist(data): xml[0].attrib except (AttributeError, IndexError, TypeError): LOG.error('Could not download Plex metadata') - return + raise PQ.PlayqueueError() api = API(xml[0]) playqueue = PQ.get_playqueue_from_type( v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[api.plex_type()]) update_playqueue_from_PMS(playqueue, playqueue_id=container_key, repeat=query.get('repeat'), - offset=data.get('offset'), + offset=utils.cast(float, data.get('offset')) or None, transient_token=data.get('token')) @staticmethod @@ -167,32 +163,35 @@ def _process_streams(data): """ playqueue = PQ.get_playqueue_from_type( v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[data['type']]) - pos = js.get_position(playqueue.playlistid) - if 'audioStreamID' in data: - index = playqueue.items[pos].kodi_stream_index( - data['audioStreamID'], 'audio') - app.APP.player.setAudioStream(index) - elif 'subtitleStreamID' in data: - if data['subtitleStreamID'] == '0': - app.APP.player.showSubtitles(False) - else: + try: + pos = js.get_position(playqueue.playlistid) + if 'audioStreamID' in data: index = playqueue.items[pos].kodi_stream_index( - data['subtitleStreamID'], 'subtitle') - app.APP.player.setSubtitleStream(index) - else: - LOG.error('Unknown setStreams command: %s', data) + data['audioStreamID'], 'audio') + app.APP.player.setAudioStream(index) + elif 'subtitleStreamID' in data: + if data['subtitleStreamID'] == '0': + app.APP.player.showSubtitles(False) + else: + index = playqueue.items[pos].kodi_stream_index( + data['subtitleStreamID'], 'subtitle') + app.APP.player.setSubtitleStream(index) + else: + LOG.error('Unknown setStreams command: %s', data) + except KeyError: + LOG.warn('Could not process stream data: %s', data) @staticmethod def _process_refresh(data): """ example data: {'playQueueID': '8475', 'commandID': '11'} """ - xml = PL.get_pms_playqueue(data['playQueueID']) + xml = PQ.get_pms_playqueue(data['playQueueID']) if xml is None: return if len(xml) == 0: LOG.debug('Empty playqueue received - clearing playqueue') - plex_type = PL.get_plextype_from_xml(xml) + plex_type = PQ.get_plextype_from_xml(xml) if plex_type is None: return playqueue = PQ.get_playqueue_from_type( @@ -220,23 +219,29 @@ def _process_tasks(self, task): """ LOG.debug('Processing: %s', task) data = task['data'] - if task['action'] == 'alexa': - with app.APP.lock_playqueues: - self._process_alexa(data) - elif (task['action'] == 'playlist' and - data.get('address') == 'node.plexapp.com'): - self._process_node(data) - elif task['action'] == 'playlist': - with app.APP.lock_playqueues: - self._process_playlist(data) - elif task['action'] == 'refreshPlayQueue': - with app.APP.lock_playqueues: - self._process_refresh(data) - elif task['action'] == 'setStreams': - try: + try: + if task['action'] == 'alexa': + with app.APP.lock_playqueues: + self._process_alexa(data) + elif (task['action'] == 'playlist' and + data.get('address') == 'node.plexapp.com'): + self._process_node(data) + elif task['action'] == 'playlist': + with app.APP.lock_playqueues: + self._process_playlist(data) + elif task['action'] == 'refreshPlayQueue': + with app.APP.lock_playqueues: + self._process_refresh(data) + elif task['action'] == 'setStreams': self._process_streams(data) - except KeyError: - pass + except PQ.PlayqueueError: + LOG.error('Could not process companion data: %s', data) + # "Play Error" + utils.dialog('notification', + utils.lang(29999), + utils.lang(30128), + icon='{error}') + app.PLAYSTATE.initiated_by_plex = False def run(self): """ diff --git a/resources/lib/plex_db/__init__.py b/resources/lib/plex_db/__init__.py index 53196e641..ee847db68 100644 --- a/resources/lib/plex_db/__init__.py +++ b/resources/lib/plex_db/__init__.py @@ -12,3 +12,18 @@ class PlexDB(PlexDBBase, TVShows, Movies, Music, Playlists, Sections): pass + + +def kodi_from_plex(plex_id, plex_type=None): + """ + Returns the tuple (kodi_id, kodi_type) for plex_id. Faster, if plex_type + is provided + + Returns (None, None) if unsuccessful + """ + with PlexDB(lock=False) as plexdb: + db_item = plexdb.item_by_id(plex_id, plex_type) + if db_item: + return (db_item['kodi_id'], db_item['kodi_type']) + else: + return None, None diff --git a/resources/lib/plex_db/playlists.py b/resources/lib/plex_db/playlists.py index 3c14f2596..37d4f7c8e 100644 --- a/resources/lib/plex_db/playlists.py +++ b/resources/lib/plex_db/playlists.py @@ -20,7 +20,7 @@ def kodi_playlist_paths(self): def delete_playlist(self, playlist): """ - Removes the entry for playlist [Playqueue_Object] from the Plex + Removes the entry for playlist [PlayQueue] from the Plex playlists table. Be sure to either set playlist.id or playlist.kodi_path """ diff --git a/resources/lib/plex_functions.py b/resources/lib/plex_functions.py index b70efe8c7..a2e412cd8 100644 --- a/resources/lib/plex_functions.py +++ b/resources/lib/plex_functions.py @@ -820,14 +820,14 @@ def get_plex_sections(): return xml -def init_plex_playqueue(plex_id, librarySectionUUID, mediatype='movie', +def init_plex_playqueue(plex_id, librarySectionUUID, plex_type='movie', trailers=False): """ Returns raw API metadata XML dump for a playlist with e.g. trailers. - """ + """ url = "{server}/playQueues" args = { - 'type': mediatype, + 'type': plex_type, 'uri': ('library://{0}/item/%2Flibrary%2Fmetadata%2F{1}'.format( librarySectionUUID, plex_id)), 'includeChapters': '1', diff --git a/resources/lib/plexbmchelper/subscribers.py b/resources/lib/plexbmchelper/subscribers.py index afa47fe51..2d6c291f8 100644 --- a/resources/lib/plexbmchelper/subscribers.py +++ b/resources/lib/plexbmchelper/subscribers.py @@ -159,18 +159,17 @@ def msg(self, players): v.PLEX_PLAYLIST_TYPE_AUDIO: None, v.PLEX_PLAYLIST_TYPE_PHOTO: None } - for typus in timelines: - if players.get(v.KODI_PLAYLIST_TYPE_FROM_PLEX_PLAYLIST_TYPE[typus]) is None: + for plex_type in timelines: + kodi_type = v.KODI_PLAYLIST_TYPE_FROM_PLEX_PLAYLIST_TYPE[plex_type] + if players.get(kodi_type) is None: timeline = { - 'controllable': CONTROLLABLE[typus], - 'type': typus, + 'controllable': CONTROLLABLE[plex_type], + 'type': plex_type, 'state': 'stopped' } else: - timeline = self._timeline_dict(players[ - v.KODI_PLAYLIST_TYPE_FROM_PLEX_PLAYLIST_TYPE[typus]], - typus) - timelines[typus] = self._dict_to_xml(timeline) + timeline = self._timeline_dict(players[kodi_type], plex_type) + timelines[plex_type] = self._dict_to_xml(timeline) timelines.update({'command_id': '{command_id}', 'location': self.location}) return answ.format(**timelines) @@ -302,7 +301,7 @@ def _plex_stream_index(self, playerid, stream_type): playqueue = PQ.PLAYQUEUES[playerid] info = app.PLAYSTATE.player_states[playerid] position = self._get_correct_position(info, playqueue) - if info[STREAM_DETAILS[stream_type]] == -1: + if info[STREAM_DETAILS[stream_type]] in (-1, None): kodi_stream_index = -1 else: kodi_stream_index = info[STREAM_DETAILS[stream_type]]['index'] diff --git a/resources/lib/service_entry.py b/resources/lib/service_entry.py index b00e879b6..b5f1c8a9a 100644 --- a/resources/lib/service_entry.py +++ b/resources/lib/service_entry.py @@ -10,6 +10,7 @@ from . import kodimonitor from . import sync, library_sync from . import websocket_client +from . import webservice from . import plex_companion from . import plex_functions as PF, playqueue as PQ from . import playback_starter @@ -433,6 +434,7 @@ def ServiceEntryPoint(self): self.setup.setup() # Initialize important threads + self.webservice = webservice.WebService() self.ws = websocket_client.PMS_Websocket() self.alexa = websocket_client.Alexa_Websocket() self.sync = sync.Sync() @@ -494,6 +496,14 @@ def ServiceEntryPoint(self): xbmc.sleep(100) continue + if self.webservice is not None and not self.webservice.is_alive(): + # TODO: Emby completely restarts Emby for Kodi at this point + # Check if this is really necessary + LOG.info('Restarting webservice') + self.webservice.abort() + self.webservice = webservice.WebService() + self.webservice.start() + # Before proceeding, need to make sure: # 1. Server is online # 2. User is set @@ -523,12 +533,15 @@ def ServiceEntryPoint(self): continue elif not self.startup_completed: self.startup_completed = True + LOG.debug('Starting service threads') + self.webservice.start() self.ws.start() self.sync.start() self.plexcompanion.start() self.playqueue.start() if utils.settings('enable_alexa') == 'true': self.alexa.start() + LOG.debug('Service threads started') xbmc.sleep(100) diff --git a/resources/lib/utils.py b/resources/lib/utils.py index afcafdb15..ae5baa585 100644 --- a/resources/lib/utils.py +++ b/resources/lib/utils.py @@ -69,6 +69,16 @@ def getGlobalProperty(key): 'Window(10000).Property(plugin.video.plexkodiconnect.{0})'.format(key)) +def dump_xml(xml): + tree = etree.ElementTree(xml) + i = 0 + while path_ops.exists(path_ops.path.join(v.ADDON_PROFILE, 'xml%s.xml' % i)): + i += 1 + tree.write(path_ops.path.join(v.ADDON_PROFILE, 'xml%s.xml' % i), + encoding='utf-8') + LOG.debug('Dumped to xml: %s', 'xml%s.xml' % i) + + def reboot_kodi(message=None): """ Displays an OK prompt with 'Kodi will now restart to apply the changes' diff --git a/resources/lib/variables.py b/resources/lib/variables.py index 46b808fba..70ec64823 100644 --- a/resources/lib/variables.py +++ b/resources/lib/variables.py @@ -92,6 +92,9 @@ def try_decode(string, encoding='utf-8'): COMPANION_PORT = int(_ADDON.getSetting('companionPort')) +# Port for the PKC webservice +WEBSERVICE_PORT = 57578 + # Unique ID for this Plex client; also see clientinfo.py PKC_MACHINE_IDENTIFIER = None diff --git a/resources/lib/webservice.py b/resources/lib/webservice.py new file mode 100644 index 000000000..793b8d1d2 --- /dev/null +++ b/resources/lib/webservice.py @@ -0,0 +1,455 @@ +# -*- coding: utf-8 -*- +''' +PKC-dedicated webserver. Listens to Kodi starting playback; will then hand-over +playback to plugin://video.plexkodiconnect +''' +from __future__ import absolute_import, division, unicode_literals +from logging import getLogger +import BaseHTTPServer +import httplib +import socket +import Queue + +import xbmc +import xbmcvfs + +from .plex_api import API +from .plex_db import PlexDB +from . import backgroundthread, utils, variables as v, app, playqueue as PQ +from . import json_rpc as js, plex_functions as PF + + +LOG = getLogger('PLEX.webservice') + + +class WebService(backgroundthread.KillableThread): + ''' Run a webservice to trigger playback. + ''' + def is_alive(self): + ''' Called to see if the webservice is still responding. + ''' + alive = True + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.connect(('127.0.0.1', v.WEBSERVICE_PORT)) + s.sendall('') + except Exception as error: + LOG.error('is_alive error: %s', error) + if 'Errno 61' in str(error): + alive = False + s.close() + return alive + + def abort(self): + ''' Called when the thread needs to stop + ''' + try: + conn = httplib.HTTPConnection('127.0.0.1:%d' % v.WEBSERVICE_PORT) + conn.request('QUIT', '/') + conn.getresponse() + except Exception as error: + xbmc.log('PLEX.webservice abort error: %s' % error, xbmc.LOGWARNING) + + def suspend(self): + """ + Called when thread needs to suspend - let's not do anything and keep + webservice up + """ + self.suspend_reached = True + + def resume(self): + """ + Called when thread needs to resume - let's not do anything and keep + webservice up + """ + self.suspend_reached = False + + def run(self): + ''' Called to start the webservice. + ''' + LOG.info('----===## Starting WebService on port %s ##===----', + v.WEBSERVICE_PORT) + app.APP.register_thread(self) + try: + server = HttpServer(('127.0.0.1', v.WEBSERVICE_PORT), + RequestHandler) + LOG.info('Serving http on %s', server.socket.getsockname()) + server.serve_forever() + except Exception as error: + LOG.error('Error encountered: %s', error) + if '10053' not in error: # ignore host diconnected errors + utils.ERROR() + finally: + app.APP.deregister_thread(self) + LOG.info('##===---- WebService stopped ----===##') + + +class HttpServer(BaseHTTPServer.HTTPServer): + ''' Http server that reacts to self.stop flag. + ''' + def __init__(self, *args, **kwargs): + self.stop = False + self.pending = [] + self.threads = [] + self.queue = Queue.Queue() + BaseHTTPServer.HTTPServer.__init__(self, *args, **kwargs) + + def serve_forever(self): + + ''' Handle one request at a time until stopped. + ''' + while not self.stop: + self.handle_request() + + +class RequestHandler(BaseHTTPServer.BaseHTTPRequestHandler): + ''' + Http request handler. Do not use LOG here, it will hang requests in Kodi > + show information dialog. + ''' + timeout = 0.5 + + def log_message(self, format, *args): + ''' Mute the webservice requests. + ''' + pass + + def handle(self): + ''' To quiet socket errors with 404. + ''' + try: + BaseHTTPServer.BaseHTTPRequestHandler.handle(self) + except Exception as error: + if '10054' in error: + # Silence "[Errno 10054] An existing connection was forcibly + # closed by the remote host" + return + xbmc.log('PLEX.webservice handle error: %s' % error, xbmc.LOGWARNING) + + def do_QUIT(self): + ''' send 200 OK response, and set server.stop to True + ''' + self.send_response(200) + self.end_headers() + self.server.stop = True + + def get_params(self): + ''' Get the params as a dict + ''' + try: + path = self.path[1:].decode('utf-8') + except IndexError: + path = '' + params = {} + if '?' in path: + path = path.split('?', 1)[1] + params = dict(utils.parse_qsl(path)) + if 'plex_id' not in params: + LOG.error('No plex_id received for path %s', path) + return + + if 'plex_type' in params and params['plex_type'].lower() == 'none': + del params['plex_type'] + if 'plex_type' not in params: + LOG.debug('Need to look-up plex_type') + with PlexDB(lock=False) as plexdb: + db_item = plexdb.item_by_id(params['plex_id']) + if db_item: + params['plex_type'] = db_item['plex_type'] + else: + LOG.debug('No plex_type found, using Kodi player id') + players = js.get_players() + if players: + params['plex_type'] = v.PLEX_TYPE_CLIP if 'video' in players \ + else v.PLEX_TYPE_SONG + LOG.debug('Using the following plex_type: %s', + params['plex_type']) + else: + xml = PF.GetPlexMetadata(params['plex_id']) + if xml in (None, 401): + LOG.error('Could not get metadata for %s', params) + return + api = API(xml[0]) + params['plex_type'] = api.plex_type() + LOG.debug('Got metadata, using plex_type %s', + params['plex_type']) + return params + + def do_HEAD(self): + ''' Called on HEAD requests + ''' + self.handle_request(True) + + def do_GET(self): + ''' Called on GET requests + ''' + self.handle_request() + + def handle_request(self, headers_only=False): + '''Send headers and reponse + ''' + xbmc.log('PLEX.webservice handle_request called. headers %s, path: %s' + % (headers_only, self.path), xbmc.LOGDEBUG) + try: + if b'extrafanart' in self.path or b'extrathumbs' in self.path: + raise Exception('unsupported artwork request') + + if headers_only: + self.send_response(200) + self.send_header(b'Content-type', b'text/html') + self.end_headers() + + elif b'file.strm' not in self.path: + self.images() + else: + self.strm() + + except Exception as error: + self.send_error(500, + b'PLEX.webservice: Exception occurred: %s' % error) + + def strm(self): + ''' Return a dummy video and and queue real items. + ''' + xbmc.log('PLEX.webservice: starting strm', xbmc.LOGDEBUG) + self.send_response(200) + self.send_header(b'Content-type', b'text/html') + self.end_headers() + + params = self.get_params() + + if b'kodi/movies' in self.path: + params['kodi_type'] = v.KODI_TYPE_MOVIE + elif b'kodi/tvshows' in self.path: + params['kodi_type'] = v.KODI_TYPE_EPISODE + # elif 'kodi/musicvideos' in self.path: + # params['MediaType'] = 'musicvideo' + + if utils.settings('pluginSingle.bool'): + path = 'plugin://plugin.video.plexkodiconnect?mode=playsingle&plex_id=%s' % params['plex_id'] + if params.get('server'): + path += '&server=%s' % params['server'] + if params.get('transcode'): + path += '&transcode=true' + if params.get('kodi_id'): + path += '&kodi_id=%s' % params['kodi_id'] + if params.get('kodi_type'): + path += '&kodi_type=%s' % params['kodi_type'] + self.wfile.write(bytes(path)) + return + + path = 'plugin://plugin.video.plexkodiconnect?mode=playstrm&plex_id=%s' % params['plex_id'] + self.wfile.write(bytes(path.encode('utf-8'))) + if params['plex_id'] not in self.server.pending: + self.server.pending.append(params['plex_id']) + self.server.queue.put(params) + if not len(self.server.threads): + queue = QueuePlay(self.server, params['plex_type']) + queue.start() + self.server.threads.append(queue) + + def images(self): + ''' Return a dummy image for unwanted images requests over the webservice. + Required to prevent freezing of widget playback if the file url has no + local textures cached yet. + ''' + image = xbmc.translatePath( + 'special://home/addons/plugin.video.plexkodiconnect/icon.png').decode('utf-8') + self.send_response(200) + self.send_header(b'Content-type', b'image/png') + modified = xbmcvfs.Stat(image).st_mtime() + self.send_header(b'Last-Modified', b'%s' % modified) + image = xbmcvfs.File(image) + size = image.size() + self.send_header(b'Content-Length', str(size)) + self.end_headers() + self.wfile.write(image.readBytes()) + image.close() + + +class QueuePlay(backgroundthread.KillableThread): + ''' Workflow for new playback: + + Queue up strm playback that was called in the webservice. Called + playstrm in default.py which will wait for our signal here. Downloads + plex information. Add content to the playlist after the strm file that + initiated playback from db. Start playback by telling playstrm waiting. + It will fail playback of the current strm and move to the next entry for + us. If play folder, playback starts here. + + Required delay for widgets, custom skin containers and non library + windows. Otherwise Kodi will freeze if no artwork textures are cached + yet in Textures13.db Will be skipped if the player already has media and + is playing. + + Why do all this instead of using plugin? Strms behaves better than + plugin in database. Allows to load chapter images with direct play. + Allows to have proper artwork for intros. Faster than resolving using + plugin, especially on low powered devices. Cons: Can't use external + players with this method. + ''' + + def __init__(self, server, plex_type): + self.server = server + self.plex_type = plex_type + self.plex_id = None + self.kodi_id = None + self.kodi_type = None + self.synched = True + self.force_transcode = False + super(QueuePlay, self).__init__() + + def load_params(self, params): + self.plex_id = utils.cast(int, params['plex_id']) + self.plex_type = params.get('plex_type') + self.kodi_id = utils.cast(int, params.get('kodi_id')) + self.kodi_type = params.get('kodi_type') + # Some cleanup + if params.get('transcode'): + self.force_transcode = params['transcode'].lower() == 'true' + if params.get('server') and params['server'].lower() == 'none': + self.server = None + if params.get('synched'): + self.synched = not params['synched'].lower() == 'false' + + def _get_playqueue(self): + playqueue = PQ.get_playqueue_from_type(v.KODI_TYPE_VIDEO) + if ((self.plex_type in v.PLEX_VIDEOTYPES and + not app.PLAYSTATE.initiated_by_plex and + xbmc.getCondVisibility('Window.IsVisible(Home.xml)'))): + # Video launched from a widget - which starts a Kodi AUDIO playlist + # We will empty everything and start with a fresh VIDEO playlist + LOG.debug('Widget video playback detected') + video_widget_playback = True + # Release default.py + utils.window('plex.playlist.play', value='true') + # The playlist will be ready anyway + app.PLAYSTATE.playlist_ready = True + playqueue = PQ.get_playqueue_from_type(v.KODI_TYPE_AUDIO) + playqueue.clear() + playqueue = PQ.get_playqueue_from_type(v.KODI_TYPE_VIDEO) + playqueue.clear() + # Wait for Kodi to catch up - xbmcplugin.setResolvedUrl() needs to + # have run its course and thus the original item needs to have + # failed before we start playback anew + xbmc.sleep(200) + else: + video_widget_playback = False + if self.plex_type in v.PLEX_VIDEOTYPES: + LOG.debug('Video playback detected') + playqueue = PQ.get_playqueue_from_type(v.KODI_TYPE_VIDEO) + else: + LOG.debug('Audio playback detected') + playqueue = PQ.get_playqueue_from_type(v.KODI_TYPE_AUDIO) + return playqueue, video_widget_playback + + def run(self): + with app.APP.lock_playqueues: + LOG.debug('##===---- Starting QueuePlay ----===##') + try: + self._run() + finally: + app.PLAYSTATE.playlist_ready = False + app.PLAYSTATE.playlist_start_pos = None + app.PLAYSTATE.initiated_by_plex = False + self.server.threads.remove(self) + self.server.pending = [] + LOG.debug('##===---- QueuePlay Stopped ----===##') + + def _run(self): + abort = False + play_folder = False + playqueue, video_widget_playback = self._get_playqueue() + # Position to start playback from (!!) + # Do NOT use kodi_pl.getposition() as that appears to be buggy + try: + start_position = max(js.get_position(playqueue.playlistid), 0) + except KeyError: + # Widgets: Since we've emptied the entire playlist, we won't get a + # position + start_position = 0 + # Position to add next element to queue - we're doing this at the end + # of our current playqueue + position = playqueue.kodi_pl.size() + # Set to start_position + 1 because first item will fail + app.PLAYSTATE.playlist_start_pos = start_position + 1 + LOG.debug('start_position %s, position %s for current playqueue: %s', + start_position, position, playqueue) + while True: + try: + try: + # We cannot know when Kodi will send the last item, e.g. + # when playing an entire folder + params = self.server.queue.get(timeout=0.01) + except Queue.Empty: + LOG.debug('Wrapping up') + if xbmc.getCondVisibility('VideoPlayer.Content(livetv)'): + # avoid issues with ongoing Live TV playback + app.APP.player.stop() + count = 50 + while not app.PLAYSTATE.playlist_ready: + xbmc.sleep(50) + if not count: + LOG.info('Playback aborted') + raise Exception('Playback aborted') + count -= 1 + if play_folder: + LOG.info('Start playing folder') + xbmc.executebuiltin('Dialog.Close(busydialognocancel)') + playqueue.start_playback(start_position) + elif video_widget_playback: + LOG.info('Start widget video playback') + playqueue.start_playback() + else: + LOG.info('Start normal playback') + # Release default.py + utils.window('plex.playlist.play', value='true') + if not app.PLAYSTATE.initiated_by_plex: + # Remove the playlist element we just added with the + # right path + xbmc.sleep(1000) + playqueue.kodi_remove_item(start_position) + del playqueue.items[start_position] + LOG.debug('Done wrapping up') + break + self.load_params(params) + if play_folder: + playlistitem = PQ.PlaylistItem(plex_id=self.plex_id, + plex_type=self.plex_type, + kodi_id=self.kodi_id, + kodi_type=self.kodi_type) + playlistitem.force_transcode = self.force_transcode + playqueue.add_item(playlistitem, position) + position += 1 + else: + if self.server.pending.count(params['plex_id']) != len(self.server.pending): + # E.g. when selecting "play" for an entire video genre + LOG.debug('Folder playback detected') + play_folder = True + xbmc.executebuiltin('Activateutils.window(busydialognocancel)') + playqueue.play(self.plex_id, + plex_type=self.plex_type, + startpos=start_position, + position=position, + synched=self.synched, + force_transcode=self.force_transcode) + # Do NOT start playback here - because Kodi already started + # it! + position = playqueue.index + except Exception: + abort = True + utils.ERROR(notify=True) + try: + self.server.queue.task_done() + except ValueError: + # "task_done() called too many times" when aborting + pass + if abort: + app.APP.player.stop() + playqueue.clear() + self.server.queue.queue.clear() + if play_folder: + xbmc.executebuiltin('Dialog.Close(busydialognocancel)') + else: + utils.window('plex.playlist.aborted', value='true') + break diff --git a/resources/lib/widgets.py b/resources/lib/widgets.py index 757b66faf..b9d67e27d 100644 --- a/resources/lib/widgets.py +++ b/resources/lib/widgets.py @@ -29,11 +29,22 @@ SECTION_ID = None APPEND_SHOW_TITLE = None APPEND_SXXEXX = None -SYNCHED = True # Need to chain the PMS keys KEY = None +def get_listitem(xml_element, resume=True): + """ + Returns a valid xbmcgui.ListItem() for xml_element. Pass in resume=False + to NOT set a resume point for this listitem + """ + item = generate_item(xml_element) + if not resume and 'resume' in item: + del item['resume'] + prepare_listitem(item) + return create_listitem(item, as_tuple=False) + + def process_method_on_list(method_to_run, items): """ helper method that processes a method on each listitem with pooling if the @@ -246,8 +257,6 @@ def attach_kodi_ids(xml): """ Attaches the kodi db_item to the xml's children, attribute 'pkc_db_item' """ - if not SYNCHED: - return with PlexDB(lock=False) as plexdb: for child in xml: api = API(child) diff --git a/resources/lib/windows/resume.py b/resources/lib/windows/resume.py new file mode 100644 index 000000000..a0d10db69 --- /dev/null +++ b/resources/lib/windows/resume.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, unicode_literals +from datetime import timedelta + +import xbmc +import xbmcgui +import xbmcaddon + +from logging import getLogger + + +LOG = getLogger('PLEX.windows.resume') + +XML_PATH = (xbmcaddon.Addon('plugin.video.plexkodiconnect').getAddonInfo('path'), + "default", + "1080i") + +ACTION_PARENT_DIR = 9 +ACTION_PREVIOUS_MENU = 10 +ACTION_BACK = 92 +RESUME = 3010 +START_BEGINNING = 3011 + + +class ResumeDialog(xbmcgui.WindowXMLDialog): + + _resume_point = None + selected_option = None + + def __init__(self, *args, **kwargs): + xbmcgui.WindowXMLDialog.__init__(self, *args, **kwargs) + + def set_resume_point(self, time): + self._resume_point = time + + def is_selected(self): + return True if self.selected_option is not None else False + + def get_selected(self): + return self.selected_option + + def onInit(self): + + self.getControl(RESUME).setLabel(self._resume_point) + self.getControl(START_BEGINNING).setLabel(xbmc.getLocalizedString(12021)) + + def onAction(self, action): + if action in (ACTION_BACK, ACTION_PARENT_DIR, ACTION_PREVIOUS_MENU): + self.close() + + def onClick(self, controlID): + if controlID == RESUME: + self.selected_option = 1 + self.close() + if controlID == START_BEGINNING: + self.selected_option = 0 + self.close() + + +def resume_dialog(seconds): + ''' + Base resume dialog based on Kodi settings + Returns True if PKC should resume, False if not, None if user backed out + of the dialog + ''' + LOG.info("Resume dialog called") + dialog = ResumeDialog("script-plex-resume.xml", *XML_PATH) + dialog.set_resume_point("Resume from %s" + % unicode(timedelta(seconds=seconds)).split(".")[0]) + dialog.doModal() + + if dialog.is_selected(): + if not dialog.get_selected(): + # Start from beginning selected + return False + else: + # User backed out + LOG.info("User exited without a selection") + return + return True diff --git a/resources/skins/default/1080i/script-plex-resume.xml b/resources/skins/default/1080i/script-plex-resume.xml new file mode 100644 index 000000000..43240c979 --- /dev/null +++ b/resources/skins/default/1080i/script-plex-resume.xml @@ -0,0 +1,112 @@ + + + 100 + + + + 0 + 0 + 0 + 0 + white.png + stretch + WindowOpen + WindowClose + + + Conditional + + + + + + + + + 50% + 50% + 20% + 90% + + vertical + 0 + 0 + auto + center + 0 + close + close + true + + 30 + + 20 + 100% + 25 + logo-white.png + keep + + + 20 + 100% + 25 + keep + $INFO[Window(Home).Property(EmbyUserImage)] + !String.IsEmpty(Window(Home).Property(EmbyUserImage)) + + + 20 + 100% + 25 + keep + userflyoutdefault.png + String.IsEmpty(Window(Home).Property(EmbyUserImage)) + + + + 100% + 10 + dialogs/menu_top.png + + + 100% + 65 + left + center + 20 + font13 + ffe1e1e1 + ffe1e1e1 + 66000000 + FF404040 + dialogs/menu_back.png + dialogs/menu_back.png + dialogs/menu_back.png + dialogs/menu_back.png + + + 100% + 65 + left + center + 20 + font13 + ffe1e1e1 + ffe1e1e1 + 66000000 + FF404040 + dialogs/menu_back.png + dialogs/menu_back.png + dialogs/menu_back.png + dialogs/menu_back.png + + + 100% + 10 + dialogs/menu_bottom.png + + + + + + diff --git a/resources/skins/default/media/dialogs/dialog_back.png b/resources/skins/default/media/dialogs/dialog_back.png new file mode 100644 index 000000000..6422ff5a2 Binary files /dev/null and b/resources/skins/default/media/dialogs/dialog_back.png differ diff --git a/resources/skins/default/media/dialogs/menu_back.png b/resources/skins/default/media/dialogs/menu_back.png new file mode 100644 index 000000000..0747aa019 Binary files /dev/null and b/resources/skins/default/media/dialogs/menu_back.png differ diff --git a/resources/skins/default/media/dialogs/menu_bottom.png b/resources/skins/default/media/dialogs/menu_bottom.png new file mode 100644 index 000000000..6638c2c36 Binary files /dev/null and b/resources/skins/default/media/dialogs/menu_bottom.png differ diff --git a/resources/skins/default/media/dialogs/menu_top.png b/resources/skins/default/media/dialogs/menu_top.png new file mode 100644 index 000000000..26bc7d15b Binary files /dev/null and b/resources/skins/default/media/dialogs/menu_top.png differ diff --git a/resources/skins/default/media/dialogs/white.jpg b/resources/skins/default/media/dialogs/white.jpg new file mode 100644 index 000000000..a12061550 Binary files /dev/null and b/resources/skins/default/media/dialogs/white.jpg differ