From 9d517c2c3d971868c5422a606033ea72f19d2b6c Mon Sep 17 00:00:00 2001 From: croneter Date: Sat, 25 May 2019 20:49:29 +0200 Subject: [PATCH] Refactoring --- resources/lib/kodimonitor.py | 15 +- resources/lib/playback_starter.py | 4 +- resources/lib/playlist_func.py | 1337 ----------------- resources/lib/playqueue/__init__.py | 14 + resources/lib/playqueue/common.py | 301 ++++ resources/lib/playqueue/functions.py | 164 ++ .../{playqueue.py => playqueue/monitor.py} | 130 +- resources/lib/playqueue/playqueue.py | 601 ++++++++ resources/lib/playqueue/queue.py | 0 resources/lib/playstrm.py | 5 +- resources/lib/plex_companion.py | 19 +- resources/lib/webservice.py | 4 +- 12 files changed, 1116 insertions(+), 1478 deletions(-) delete mode 100644 resources/lib/playlist_func.py create mode 100644 resources/lib/playqueue/__init__.py create mode 100644 resources/lib/playqueue/common.py create mode 100644 resources/lib/playqueue/functions.py rename resources/lib/{playqueue.py => playqueue/monitor.py} (56%) create mode 100644 resources/lib/playqueue/playqueue.py create mode 100644 resources/lib/playqueue/queue.py diff --git a/resources/lib/kodimonitor.py b/resources/lib/kodimonitor.py index 21e58c0f..78846b7f 100644 --- a/resources/lib/kodimonitor.py +++ b/resources/lib/kodimonitor.py @@ -14,9 +14,8 @@ import xbmcgui from .plex_db import PlexDB from . import kodi_db from .downloadutils import DownloadUtils as DU -from . import utils, timing, plex_functions as PF -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') @@ -256,8 +255,8 @@ class KodiMonitor(xbmc.Monitor): 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 = PL.PlaylistItem(kodi_item=kodi_playlist[position]) - if isinstance(self.playqueue.items[0], PL.PlaylistItemDummy): + 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') @@ -328,8 +327,10 @@ class KodiMonitor(xbmc.Monitor): 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: diff --git a/resources/lib/playback_starter.py b/resources/lib/playback_starter.py index 00a08835..974c2ba7 100644 --- a/resources/lib/playback_starter.py +++ b/resources/lib/playback_starter.py @@ -5,7 +5,7 @@ from logging import getLogger 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, playlist_func as PL +from . import app, plex_functions as PF, playqueue as PQ ############################################################################### @@ -81,7 +81,7 @@ def process_indirect(key, offset, resolve=True): playqueue = PQ.get_playqueue_from_type( v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[api.plex_type()]) playqueue.clear() - item = PL.PlaylistItem(xml_video_element=xml[0]) + item = PQ.PlaylistItem(xml_video_element=xml[0]) item.offset = offset item.playmethod = 'DirectStream' diff --git a/resources/lib/playlist_func.py b/resources/lib/playlist_func.py deleted file mode 100644 index 981c086f..00000000 --- a/resources/lib/playlist_func.py +++ /dev/null @@ -1,1337 +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 -import threading - -from .plex_api import API -from .plex_db import PlexDB -from . import plex_functions as PF -from .playutils import PlayUtils -from .kodi_db import kodiid_from_filename, KodiVideoDB -from .downloadutils import DownloadUtils as DU -from . import utils, json_rpc as js, variables as v, app, widgets -from .windows.resume import resume_dialog - -############################################################################### - -LOG = getLogger('PLEX.playlist_func') - -############################################################################### - - -class PlaylistError(Exception): - """ - Exception for our playlist constructs - """ - pass - - -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 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 - """ - 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 PlaylistError('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 - """ - 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 PlaylistError('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 PlaylistError('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 an PlayStrmException 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 PlaylistError('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) - - 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) - - def _kodi_add_xml(self, xml, api, resume=False, playlistitem=None): - if not playlistitem: - playlistitem = PlaylistItem(xml_video_element=xml) - playlistitem.part = api.part - playlistitem.force_transcode = self.force_transcode - listitem = widgets.get_listitem(xml, resume=True) - 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 PlaylistError - """ - 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 PlaylistError - """ - if not isinstance(item, PlaylistItem): - raise PlaylistError('Wrong item %s of type %s received' - % (item, type(item))) - if pos > len(self.items): - raise PlaylistError('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 PlaylistError('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 PlaylistError('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 PlaylistError - """ - if not isinstance(item, PlaylistItem) or not item.uri: - raise PlaylistError('Wrong item %s of type %s received' - % (item, type(item))) - if pos > len(self.items): - raise PlaylistError('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 PlaylistError('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 PlaylistError('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 PlaylistError('Could not remove item: %s' % answ['error']) - - 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 PlaylistError('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 PlaylistError('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) - - -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 - if kodi_item: - self.kodi_id = kodi_item['id'] - self.kodi_type = kodi_item['type'] - self.file = kodi_item.get('file') - else: - self.kodi_id = kodi_id - self.kodi_type = kodi_type - self.file = None - 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 grab_xml and 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_uuid = db_item['section_uuid'] - 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_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 - - 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 = 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: - 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'] - if self.plex_id is None and self.file is not None: - 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() - LOG.debug('Made playlist item from Kodi: %s', self) - - 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: - # TO BE VERIFIED - PLEX DOESN'T LIKE PLAYLIST ADDS IN THIS MANNER - 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 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 = 'dummy item' - self.id = 0 - self.plex_id = 0 - - -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 = PlaylistItem() - 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['section_uuid'] - 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 = PlaylistItem() - 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 = PlaylistItem() - 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 and kodi_type is not None: - item.kodi_id = kodi_id - item.kodi_type = 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 - """ - LOG.debug('Adding item to Plex playqueue with plex id %s, kodi_item %s at ' - 'position %s', plex_id, kodi_item, pos) - 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)) - if len(xml) != len(playlist.items) + 1: - raise PlaylistError('Couldnt add item %s to playlist %s - wrong length' - % (kodi_item, playlist)) - for actual_pos, xml_video_element in enumerate(xml): - api = API(xml_video_element) - if api.plex_id() == item.plex_id: - break - else: - raise PlaylistError('Something went terribly wrong!') - utils.dump_xml(xml) - LOG.debug('Plex added the new item at position %s', actual_pos) - item.xml = xml[actual_pos] - item.id = api.item_id() - item.guid = api.guid_html_escaped() - item.offset = api.resume_point() - item.playcount = api.viewcount() - playlist.items.insert(actual_pos, item) - _get_playListVersion_from_xml(playlist, xml) - if actual_pos != pos: - # Move the new item to the correct position - move_playlist_item(playlist, actual_pos, 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 - xml = DU().downloadUrl(url, action_type="PUT") - try: - xml[0].attrib - except (TypeError, IndexError, AttributeError): - LOG.error('Could not move playlist item') - return - _get_playListVersion_from_xml(playlist, xml) - utils.dump_xml(xml) - # 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=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 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/playqueue/__init__.py b/resources/lib/playqueue/__init__.py new file mode 100644 index 00000000..88cdade0 --- /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, PlaylistError, 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 00000000..37c5d1b8 --- /dev/null +++ b/resources/lib/playqueue/common.py @@ -0,0 +1,301 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, unicode_literals +from logging import getLogger + +from ..plex_db import PlexDB +from ..plex_api import API +from .. import plex_functions as PF, utils, kodi_db, variables as v, app + +LOG = getLogger('PLEX.playqueue') + +# Our PKC playqueues (3 instances PlayQueue()) +PLAYQUEUES = [] + + +class PlaylistError(Exception): + """ + Exception for our playlist 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 = 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 and + (self.kodi_id is not None and self.kodi_type is not None)): + 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'] + if grab_xml and 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_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.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_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 + 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 = 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: + 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'] + if self.plex_id is None and self.file is not None: + 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() + LOG.debug('Made playlist item from Kodi: %s', self) + + 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: + # TO BE VERIFIED - PLEX DOESN'T LIKE PLAYLIST ADDS IN THIS MANNER + 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 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) + LOG.debug('Detected song. Research results: %s', self) + 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 + LOG.debug('Starting research for Kodi id since we didnt get one') + # 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 + LOG.debug('Research results for guessing Kodi id: %s', self) + + 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 = 'dummy 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 00000000..5fc26204 --- /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 56% rename from resources/lib/playqueue.py rename to resources/lib/playqueue/monitor.py index ccd0fa9f..957cc9ba 100644 --- a/resources/lib/playqueue.py +++ b/resources/lib/playqueue/monitor.py @@ -7,113 +7,11 @@ from __future__ import absolute_import, division, unicode_literals from logging import getLogger import copy -import xbmc - -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 PlayQueue()) -PLAYQUEUES = [] -############################################################################### +from .common import PlaylistError, PlaylistItem, PLAYQUEUES +from .. import backgroundthread, json_rpc as js, utils, app -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() - 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): - 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): @@ -169,26 +67,24 @@ class PlayqueueMonitor(backgroundthread.KillableThread): 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 PlaylistError: 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 PlaylistError: + 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 @@ -206,8 +102,8 @@ class PlayqueueMonitor(backgroundthread.KillableThread): 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 PlaylistError: 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') diff --git a/resources/lib/playqueue/playqueue.py b/resources/lib/playqueue/playqueue.py new file mode 100644 index 00000000..7114c75b --- /dev/null +++ b/resources/lib/playqueue/playqueue.py @@ -0,0 +1,601 @@ +#!/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 logging import getLogger +import threading + +from .common import PlaylistItem, PlaylistItemDummy, PlaylistError + +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 PlaylistError() + 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 PlaylistError('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 PlaylistError('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 PlaylistError('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 an PlayStrmException 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 PlaylistError('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) + + 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) + + def _kodi_add_xml(self, xml, api, resume=False, playlistitem=None): + if not playlistitem: + playlistitem = PlaylistItem(xml_video_element=xml) + playlistitem.part = api.part + playlistitem.force_transcode = self.force_transcode + listitem = widgets.get_listitem(xml, resume=True) + 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 PlaylistError + """ + 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 PlaylistError + """ + if not isinstance(item, PlaylistItem): + raise PlaylistError('Wrong item %s of type %s received' + % (item, type(item))) + if pos > len(self.items): + raise PlaylistError('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 PlaylistError('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 PlaylistError('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 PlaylistError + """ + if not isinstance(item, PlaylistItem) or not item.uri: + raise PlaylistError('Wrong item %s of type %s received' + % (item, type(item))) + if pos > len(self.items): + raise PlaylistError('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 PlaylistError('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 PlaylistError('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 PlaylistError('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 PlaylistError() + + 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 PlaylistError('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 PlaylistError('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/playqueue/queue.py b/resources/lib/playqueue/queue.py new file mode 100644 index 00000000..e69de29b diff --git a/resources/lib/playstrm.py b/resources/lib/playstrm.py index 55b7f338..37e58f4c 100644 --- a/resources/lib/playstrm.py +++ b/resources/lib/playstrm.py @@ -2,8 +2,7 @@ from __future__ import absolute_import, division, unicode_literals from logging import getLogger -from . import app, utils, json_rpc, variables as v, playlist_func as PL, \ - playqueue as PQ +from . import app, utils, json_rpc, variables as v, playqueue as PQ LOG = getLogger('PLEX.playstrm') @@ -82,7 +81,7 @@ class PlayStrm(object): 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 = PL.PlaylistItem(plex_id=self.plex_id, + item = PQ.PlaylistItem(plex_id=self.plex_id, plex_type=self.plex_type, kodi_id=self.kodi_id, kodi_type=self.kodi_type) diff --git a/resources/lib/plex_companion.py b/resources/lib/plex_companion.py index a55d442b..8d2728f9 100644 --- a/resources/lib/plex_companion.py +++ b/resources/lib/plex_companion.py @@ -14,7 +14,6 @@ from .plexbmchelper import listener, plexgdm, subscribers, httppersist from .plex_api import API from . import utils from . import plex_functions as PF -from . import playlist_func as PL from . import json_rpc as js from . import playqueue as PQ from . import variables as v @@ -48,10 +47,10 @@ 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(playlist_id=playqueue_id) + xml = PQ.get_PMS_playlist(playlist_id=playqueue_id) if xml is None: LOG.error('Could now download playqueue %s', playqueue_id) - raise PL.PlaylistError() + raise PQ.PlaylistError() app.PLAYSTATE.initiated_by_plex = True playqueue.init_from_xml(xml, offset=offset, @@ -82,7 +81,7 @@ class PlexCompanion(backgroundthread.KillableThread): xml[0].attrib except (AttributeError, IndexError, TypeError): LOG.error('Could not download Plex metadata for: %s', data) - raise PL.PlaylistError() + raise PQ.PlaylistError() api = API(xml[0]) if api.plex_type() == v.PLEX_TYPE_ALBUM: LOG.debug('Plex music album detected') @@ -91,7 +90,7 @@ class PlexCompanion(backgroundthread.KillableThread): xml[0].attrib except (TypeError, IndexError, AttributeError): LOG.error('Could not download the album xml for %s', data) - raise PL.PlaylistError() + raise PQ.PlaylistError() playqueue = PQ.get_playqueue_from_type('audio') playqueue.init_from_xml(xml, transient_token=data.get('token')) @@ -100,7 +99,7 @@ class PlexCompanion(backgroundthread.KillableThread): xml = PF.DownloadChunks('{server}/playQueues/%s' % container_key) if xml is None: LOG.error('Could not get playqueue for %s', data) - raise PL.PlaylistError() + raise PQ.PlaylistError() playqueue = PQ.get_playqueue_from_type( v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[api.plex_type()]) offset = utils.cast(float, data.get('offset')) or None @@ -147,7 +146,7 @@ class PlexCompanion(backgroundthread.KillableThread): xml[0].attrib except (AttributeError, IndexError, TypeError): LOG.error('Could not download Plex metadata') - raise PL.PlaylistError() + raise PQ.PlaylistError() api = API(xml[0]) playqueue = PQ.get_playqueue_from_type( v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[api.plex_type()]) @@ -187,12 +186,12 @@ class PlexCompanion(backgroundthread.KillableThread): """ 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( @@ -235,7 +234,7 @@ class PlexCompanion(backgroundthread.KillableThread): self._process_refresh(data) elif task['action'] == 'setStreams': self._process_streams(data) - except PL.PlaylistError: + except PQ.PlaylistError: LOG.error('Could not process companion data: %s', data) # "Play Error" utils.dialog('notification', diff --git a/resources/lib/webservice.py b/resources/lib/webservice.py index 400bd9b7..937a6703 100644 --- a/resources/lib/webservice.py +++ b/resources/lib/webservice.py @@ -16,7 +16,7 @@ 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 playlist_func as PL, json_rpc as js, plex_functions as PF +from . import json_rpc as js, plex_functions as PF LOG = getLogger('PLEX.webservice') @@ -416,7 +416,7 @@ class QueuePlay(backgroundthread.KillableThread): break self.load_params(params) if play_folder: - playlistitem = PL.PlaylistItem(plex_id=self.plex_id, + playlistitem = PQ.PlaylistItem(plex_id=self.plex_id, plex_type=self.plex_type, kodi_id=self.kodi_id, kodi_type=self.kodi_type)