diff --git a/resources/lib/app/__init__.py b/resources/lib/app/__init__.py index 8243a64a..634fe004 100644 --- a/resources/lib/app/__init__.py +++ b/resources/lib/app/__init__.py @@ -9,12 +9,14 @@ from .application import App from .connection import Connection from .libsync import Sync from .playstate import PlayState +from .playqueues import Playqueues ACCOUNT = None APP = None CONN = None SYNC = None PLAYSTATE = None +PLAYQUEUES = None def init(entrypoint=False): @@ -22,13 +24,15 @@ def init(entrypoint=False): entrypoint=True initiates only the bare minimum - for other PKC python instances """ - global ACCOUNT, APP, CONN, SYNC, PLAYSTATE + global ACCOUNT, APP, CONN, SYNC, PLAYSTATE, PLAYQUEUES APP = App(entrypoint) CONN = Connection(entrypoint) ACCOUNT = Account(entrypoint) SYNC = Sync(entrypoint) if not entrypoint: PLAYSTATE = PlayState() + PLAYQUEUES = Playqueues() + def reload(): """ diff --git a/resources/lib/app/playqueues.py b/resources/lib/app/playqueues.py new file mode 100644 index 00000000..0f4b9c3f --- /dev/null +++ b/resources/lib/app/playqueues.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from logging import getLogger + +import xbmc + +from .. import variables as v + + +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 Playlist_Items + id = None [str] Plex playQueueID, unique Plex identifier + version = None [int] Plex version of the playQueue + selectedItemID = None + [str] Plex selectedItemID, playing element in queue + selectedItemOffset = None + [str] Offset of the playing element in queue + shuffled = 0 [int] 0: not shuffled, 1: ??? 2: ??? + repeat = 0 [int] 0: not repeated, 1: ??? 2: ??? + + If Companion playback is initiated by another user: + plex_transient_token = None + """ + kind = 'playQueue' + + def __init__(self): + self.id = None + self.type = None + self.playlistid = None + self.kodi_pl = None + self.items = [] + self.version = None + self.selectedItemID = None + self.selectedItemOffset = None + self.shuffled = 0 + self.repeat = 0 + self.plex_transient_token = None + # Need a hack for detecting swaps of elements + self.old_kodi_pl = [] + # Did PKC itself just change the playqueue so the PKC playqueue monitor + # should not pick up any changes? + self.pkc_edit = False + # Workaround to avoid endless loops of detecting PL clears + self._clear_list = [] + # To keep track if Kodi playback was initiated from a Kodi playlist + # There are a couple of pitfalls, unfortunately... + self.kodi_playlist_playback = False + + def __repr__(self): + answ = ("{{" + "'playlistid': {self.playlistid}, " + "'id': {self.id}, " + "'version': {self.version}, " + "'type': '{self.type}', " + "'selectedItemID': {self.selectedItemID}, " + "'selectedItemOffset': {self.selectedItemOffset}, " + "'shuffled': {self.shuffled}, " + "'repeat': {self.repeat}, " + "'kodi_playlist_playback': {self.kodi_playlist_playback}, " + "'pkc_edit': {self.pkc_edit}, ".format(self=self)) + # Since list.__repr__ will return string, not unicode + return answ + "'items': {self.items}}}".format(self=self) + + def is_pkc_clear(self): + """ + Returns True if PKC has cleared the Kodi playqueue just recently. + Then this clear will be ignored from now on + """ + try: + self._clear_list.pop() + except IndexError: + return False + else: + return True + + def clear(self, kodi=True): + """ + Resets the playlist object to an empty playlist. + + Pass kodi=False in order to NOT clear the Kodi playqueue + """ + # kodi monitor's on_clear method will only be called if there were some + # items to begin with + if kodi and self.kodi_pl.size() != 0: + self._clear_list.append(None) + self.kodi_pl.clear() # Clear Kodi playlist object + self.items = [] + self.id = None + self.version = None + self.selectedItemID = None + self.selectedItemOffset = None + self.shuffled = 0 + self.repeat = 0 + self.plex_transient_token = None + self.old_kodi_pl = [] + self.kodi_playlist_playback = False + LOG.debug('Playlist cleared: %s', self) + + def position_from_plex_id(self, plex_id): + """ + Returns the position [int] for the very first item with plex_id [int] + (Plex seems uncapable of adding the same element multiple times to a + playqueue or playlist) + + Raises KeyError if not found + """ + for position, item in enumerate(self.items): + if item.plex_id == plex_id: + break + else: + raise KeyError('Did not find plex_id %s in %s', plex_id, self) + return position + + +class Playqueues(list): + + def __init__(self): + super().__init__() + for i, typus in enumerate((v.KODI_PLAYLIST_TYPE_AUDIO, + v.KODI_PLAYLIST_TYPE_VIDEO, + v.KODI_PLAYLIST_TYPE_PHOTO)): + playqueue = Playqueue() + playqueue.playlistid = i + playqueue.type = typus + # Initialize each Kodi playlist + if typus == v.KODI_PLAYLIST_TYPE_AUDIO: + playqueue.kodi_pl = xbmc.PlayList(xbmc.PLAYLIST_MUSIC) + elif typus == v.KODI_PLAYLIST_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 + self.append(playqueue) + + @property + def audio(self): + return self[0] + + @property + def video(self): + return self[1] + + @property + def photo(self): + return self[2] + + def from_kodi_playlist_type(self, kodi_playlist_type): + """ + Returns the playqueue according to the kodi_playlist_type ('video', + 'audio', 'picture') passed in + """ + if kodi_playlist_type == v.KODI_PLAYLIST_TYPE_AUDIO: + return self[0] + elif kodi_playlist_type == v.KODI_PLAYLIST_TYPE_VIDEO: + return self[1] + elif kodi_playlist_type == v.KODI_PLAYLIST_TYPE_PHOTO: + return self[2] + else: + raise ValueError('Unknown kodi_playlist_type: %s' % kodi_playlist_type) + + def from_kodi_type(self, kodi_type): + """ + Pass in the kodi_type (e.g. the string 'movie') to get the correct + playqueue (either video, audio or picture) + """ + if kodi_type == v.KODI_TYPE_VIDEO: + return self[1] + elif kodi_type == v.KODI_TYPE_MOVIE: + return self[1] + elif kodi_type == v.KODI_TYPE_EPISODE: + return self[1] + elif kodi_type == v.KODI_TYPE_SEASON: + return self[1] + elif kodi_type == v.KODI_TYPE_SHOW: + return self[1] + elif kodi_type == v.KODI_TYPE_CLIP: + return self[1] + elif kodi_type == v.KODI_TYPE_SONG: + return self[0] + elif kodi_type == v.KODI_TYPE_ALBUM: + return self[0] + elif kodi_type == v.KODI_TYPE_ARTIST: + return self[0] + elif kodi_type == v.KODI_TYPE_AUDIO: + return self[0] + elif kodi_type == v.KODI_TYPE_PHOTO: + return self[2] + else: + raise ValueError('Unknown kodi_type: %s' % kodi_type) + + def from_plex_type(self, plex_type): + """ + Pass in the plex_type (e.g. the string 'movie') to get the correct + playqueue (either video, audio or picture) + """ + if plex_type == v.PLEX_TYPE_VIDEO: + return self[1] + elif plex_type == v.PLEX_TYPE_MOVIE: + return self[1] + elif plex_type == v.PLEX_TYPE_EPISODE: + return self[1] + elif plex_type == v.PLEX_TYPE_SEASON: + return self[1] + elif plex_type == v.PLEX_TYPE_SHOW: + return self[1] + elif plex_type == v.PLEX_TYPE_CLIP: + return self[1] + elif plex_type == v.PLEX_TYPE_SONG: + return self[0] + elif plex_type == v.PLEX_TYPE_ALBUM: + return self[0] + elif plex_type == v.PLEX_TYPE_ARTIST: + return self[0] + elif plex_type == v.PLEX_TYPE_AUDIO: + return self[0] + elif plex_type == v.PLEX_TYPE_PHOTO: + return self[2] + else: + raise ValueError('Unknown plex_type: %s' % plex_type) diff --git a/resources/lib/companion.py b/resources/lib/companion.py index 077474fd..cb630222 100644 --- a/resources/lib/companion.py +++ b/resources/lib/companion.py @@ -6,8 +6,10 @@ Processes Plex companion inputs from the plexbmchelper to Kodi commands from logging import getLogger from xbmc import Player -from . import playqueue as PQ, plex_functions as PF -from . import json_rpc as js, variables as v, app +from . import plex_functions as PF +from . import json_rpc as js +from . import variables as v +from . import app ############################################################################### @@ -28,7 +30,7 @@ def skip_to(params): playqueue_item_id, plex_id) found = True for player in list(js.get_players().values()): - playqueue = PQ.PLAYQUEUES[player['playerid']] + playqueue = app.PLAYQUEUES[player['playerid']] for i, item in enumerate(playqueue.items): if item.id == playqueue_item_id: found = True diff --git a/resources/lib/context_entry.py b/resources/lib/context_entry.py index 8195016b..6a6553ad 100644 --- a/resources/lib/context_entry.py +++ b/resources/lib/context_entry.py @@ -6,8 +6,11 @@ import xbmcgui from .plex_api import API from .plex_db import PlexDB -from . import context, plex_functions as PF, playqueue as PQ -from . import utils, variables as v, app +from . import context +from . import plex_functions as PF +from . import utils +from . import variables as v +from . import app ############################################################################### @@ -137,8 +140,7 @@ class ContextMenu(object): """ For using direct paths: Initiates playback using the PMS """ - playqueue = PQ.get_playqueue_from_type( - v.KODI_PLAYLIST_TYPE_FROM_KODI_TYPE[self.kodi_type]) + playqueue = app.PLAYQUEUES.from_kodi_type(self.kodi_type) playqueue.clear() app.PLAYSTATE.context_menu_play = True handle = self.api.fullpath(force_addon=True)[0] diff --git a/resources/lib/kodimonitor.py b/resources/lib/kodimonitor.py index 29669750..0d3bbe5f 100644 --- a/resources/lib/kodimonitor.py +++ b/resources/lib/kodimonitor.py @@ -17,7 +17,7 @@ from .kodi_db import KodiVideoDB 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 json_rpc as js, playlist_func as PL from . import backgroundthread, app, variables as v from . import exceptions @@ -140,7 +140,7 @@ class KodiMonitor(xbmc.Monitor): u'playlistid': 1, } """ - playqueue = PQ.PLAYQUEUES[data['playlistid']] + playqueue = app.PLAYQUEUES[data['playlistid']] if not playqueue.is_pkc_clear(): playqueue.pkc_edit = True playqueue.clear(kodi=False) @@ -256,7 +256,7 @@ class KodiMonitor(xbmc.Monitor): if not playerid: LOG.error('Coud not get playerid for data %s', data) return - playqueue = PQ.PLAYQUEUES[playerid] + playqueue = app.PLAYQUEUES[playerid] info = js.get_player_props(playerid) if playqueue.kodi_playlist_playback: # Kodi will tell us the wrong position - of the playlist, not the @@ -326,7 +326,7 @@ class KodiMonitor(xbmc.Monitor): container_key = None if info['playlistid'] != -1: # -1 is Kodi's answer if there is no playlist - container_key = PQ.PLAYQUEUES[playerid].id + container_key = app.PLAYQUEUES[playerid].id if container_key is not None: container_key = '/playQueues/%s' % container_key elif plex_id is not None: @@ -367,6 +367,11 @@ class KodiMonitor(xbmc.Monitor): # We need to switch to the Plex streams ONCE upon playback start if playerid == v.KODI_VIDEO_PLAYER_ID: + # The Kodi player takes forever to initialize all streams + # Especially subtitles, apparently. No way to tell when Kodi + # is done :-( + if app.APP.monitor.waitForAbort(5): + return item.init_kodi_streams() item.switch_to_plex_stream('video') if utils.settings('audioStreamPick') == '0': diff --git a/resources/lib/playback.py b/resources/lib/playback.py index a40d4685..3cd493ef 100644 --- a/resources/lib/playback.py +++ b/resources/lib/playback.py @@ -12,8 +12,12 @@ import xbmc from .plex_api import API from .plex_db import PlexDB from .kodi_db import KodiVideoDB -from . import plex_functions as PF, playlist_func as PL, playqueue as PQ -from . import json_rpc as js, variables as v, utils, transfer +from . import plex_functions as PF +from . import playlist_func as PL +from . import json_rpc as js +from . import variables as v +from . import utils +from . import transfer from . import playback_decision, app from . import exceptions @@ -74,20 +78,19 @@ def _playback_triage(plex_id, plex_type, path, resolve, resume): _ensure_resolve(abort=True) return with app.APP.lock_playqueues: - playqueue = PQ.get_playqueue_from_type( - v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[plex_type]) + playqueue = app.PLAYQUEUES.from_plex_type(plex_type) try: pos = js.get_position(playqueue.playlistid) except KeyError: # Kodi bug - Playlist plays (not Playqueue) will ALWAYS be audio for # add-on paths LOG.debug('No position returned from player! Assuming playlist') - playqueue = PQ.get_playqueue_from_type(v.KODI_PLAYLIST_TYPE_AUDIO) + playqueue = app.PLAYQUEUES.audio try: pos = js.get_position(playqueue.playlistid) except KeyError: LOG.debug('Assuming video instead of audio playlist playback') - playqueue = PQ.get_playqueue_from_type(v.KODI_PLAYLIST_TYPE_VIDEO) + playqueue = app.PLAYQUEUES.video try: pos = js.get_position(playqueue.playlistid) except KeyError: @@ -159,7 +162,7 @@ def _playlist_playback(plex_id): return # Kodi bug: playqueue will ALWAYS be audio playqueue UNTIL playback # has actually started. Need to tell Kodimonitor - playqueue = PQ.get_playqueue_from_type(v.KODI_PLAYLIST_TYPE_AUDIO) + playqueue = app.PLAYQUEUES.audio playqueue.clear(kodi=False) # Set the flag for the potentially WRONG audio playlist so Kodimonitor # can pick up on it @@ -499,8 +502,7 @@ def process_indirect(key, offset, resolve=True): api = API(xml[0]) listitem = api.listitem(listitem=transfer.PKCListItem, resume=False) - playqueue = PQ.get_playqueue_from_type( - v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[api.plex_type]) + playqueue = app.PLAYQUEUES.from_plex_type(api.plex_type) playqueue.clear() item = PL.playlist_item_from_xml(xml[0]) item.offset = offset diff --git a/resources/lib/playlist_func.py b/resources/lib/playlist_func.py index 697823b5..b472bb7b 100644 --- a/resources/lib/playlist_func.py +++ b/resources/lib/playlist_func.py @@ -22,117 +22,6 @@ from .subtitles import accessible_plex_subtitles LOG = getLogger('PLEX.playlist_func') -class Playqueue_Object(object): - """ - PKC object to represent PMS playQueues and Kodi playlist for queueing - - playlistid = None [int] Kodi playlist id (0, 1, 2) - type = None [str] Kodi type: 'audio', 'video', 'picture' - kodi_pl = None Kodi xbmc.PlayList object - items = [] [list] of Playlist_Items - id = None [str] Plex playQueueID, unique Plex identifier - version = None [int] Plex version of the playQueue - selectedItemID = None - [str] Plex selectedItemID, playing element in queue - selectedItemOffset = None - [str] Offset of the playing element in queue - shuffled = 0 [int] 0: not shuffled, 1: ??? 2: ??? - repeat = 0 [int] 0: not repeated, 1: ??? 2: ??? - - If Companion playback is initiated by another user: - plex_transient_token = None - """ - kind = 'playQueue' - - def __init__(self): - self.id = None - self.type = None - self.playlistid = None - self.kodi_pl = None - self.items = [] - self.version = None - self.selectedItemID = None - self.selectedItemOffset = None - self.shuffled = 0 - self.repeat = 0 - self.plex_transient_token = None - # Need a hack for detecting swaps of elements - self.old_kodi_pl = [] - # Did PKC itself just change the playqueue so the PKC playqueue monitor - # should not pick up any changes? - self.pkc_edit = False - # Workaround to avoid endless loops of detecting PL clears - self._clear_list = [] - # To keep track if Kodi playback was initiated from a Kodi playlist - # There are a couple of pitfalls, unfortunately... - self.kodi_playlist_playback = False - - def __repr__(self): - answ = ("{{" - "'playlistid': {self.playlistid}, " - "'id': {self.id}, " - "'version': {self.version}, " - "'type': '{self.type}', " - "'selectedItemID': {self.selectedItemID}, " - "'selectedItemOffset': {self.selectedItemOffset}, " - "'shuffled': {self.shuffled}, " - "'repeat': {self.repeat}, " - "'kodi_playlist_playback': {self.kodi_playlist_playback}, " - "'pkc_edit': {self.pkc_edit}, ".format(self=self)) - # Since list.__repr__ will return string, not unicode - return answ + "'items': {self.items}}}".format(self=self) - - def is_pkc_clear(self): - """ - Returns True if PKC has cleared the Kodi playqueue just recently. - Then this clear will be ignored from now on - """ - try: - self._clear_list.pop() - except IndexError: - return False - else: - return True - - def clear(self, kodi=True): - """ - Resets the playlist object to an empty playlist. - - Pass kodi=False in order to NOT clear the Kodi playqueue - """ - # kodi monitor's on_clear method will only be called if there were some - # items to begin with - if kodi and self.kodi_pl.size() != 0: - self._clear_list.append(None) - self.kodi_pl.clear() # Clear Kodi playlist object - self.items = [] - self.id = None - self.version = None - self.selectedItemID = None - self.selectedItemOffset = None - self.shuffled = 0 - self.repeat = 0 - self.plex_transient_token = None - self.old_kodi_pl = [] - self.kodi_playlist_playback = False - LOG.debug('Playlist cleared: %s', self) - - def position_from_plex_id(self, plex_id): - """ - Returns the position [int] for the very first item with plex_id [int] - (Plex seems uncapable of adding the same element multiple times to a - playqueue or playlist) - - Raises KeyError if not found - """ - for position, item in enumerate(self.items): - if item.plex_id == plex_id: - break - else: - raise KeyError('Did not find plex_id %s in %s', plex_id, self) - return position - - class PlaylistItem(object): """ Object to fill our playqueues and playlists with. @@ -281,14 +170,44 @@ class PlaylistItem(object): elif stream_type == 'video': return self.video_streams + @staticmethod + def _current_index(stream_type): + """ + Kodi might tell us the wrong index for any stream after playback start + Get the correct one! + """ + function = { + 'audio': js.get_current_audio_stream_index, + 'video': js.get_current_video_stream_index, + 'subtitle': js.get_current_subtitle_stream_index + }[stream_type] + i = 0 + while i < 30: + # Really annoying: Kodi might return wrong results directly after + # playback startup, e.g. a Kodi audio index of 1953718901 (!) + try: + index = function(v.KODI_VIDEO_PLAYER_ID) + except TypeError: + # No sensible reply yet + pass + else: + if index != 1953718901: + # Correct result! + return index + i += 1 + app.APP.monitor.waitForAbort(0.1) + else: + raise RuntimeError('Kodi did not tell us the correct index for %s' + % stream_type) + def init_kodi_streams(self): """ Initializes all streams after Kodi has started playing this video """ - self.current_kodi_video_stream = js.get_current_video_stream_index(v.KODI_VIDEO_PLAYER_ID) - self.current_kodi_audio_stream = js.get_current_audio_stream_index(v.KODI_VIDEO_PLAYER_ID) + self.current_kodi_video_stream = self._current_index('video') + self.current_kodi_audio_stream = self._current_index('audio') self.current_kodi_sub_stream = False if not js.get_subtitle_enabled(v.KODI_VIDEO_PLAYER_ID) \ - else js.get_current_subtitle_stream_index(v.KODI_VIDEO_PLAYER_ID) + else self._current_index('subtitle') def plex_stream_index(self, kodi_stream_index, stream_type): """ @@ -312,13 +231,16 @@ class PlaylistItem(object): Pass in the plex_stream_index [int] in order to receive the Kodi stream index [int]. stream_type: 'video', 'audio', 'subtitle' - Returns None if unsuccessful + Raises ValueError if unsuccessful """ - if plex_stream_index is None: - return + if not isinstance(plex_stream_index, int): + raise ValueError('%s plex_stream_index %s of type %s received' % + (stream_type, plex_stream_index, type(plex_stream_index))) for i, stream in enumerate(self._get_iterator(stream_type)): if cast(int, stream.get('id')) == plex_stream_index: return i + raise ValueError('No %s kodi_stream_index for plex_stream_index %s' % + (stream_type, plex_stream_index)) def active_plex_stream_index(self, stream_type): """ @@ -408,8 +330,10 @@ class PlaylistItem(object): return LOG.debug('The PMS wants to display %s stream with Plex id %s and ' 'languageTag %s', typus, plex_index, language_tag) - kodi_index = self.kodi_stream_index(plex_index, typus) - if kodi_index is None: + try: + kodi_index = self.kodi_stream_index(plex_index, typus) + except ValueError: + kodi_index = None LOG.debug('Leaving Kodi %s stream settings untouched since we ' 'could not parse Plex %s stream with id %s to a Kodi' ' index', typus, typus, plex_index) @@ -438,10 +362,28 @@ class PlaylistItem(object): Call this method if Kodi reports an "AV-Change" (event "Player.OnAVChange") """ - kodi_video_stream = js.get_current_video_stream_index(playerid) - kodi_audio_stream = js.get_current_audio_stream_index(playerid) + i = 0 + while i < 20: + # Really annoying: Kodi might return wrong results directly after + # playback startup, e.g. a Kodi audio index of 1953718901 (!) + kodi_video_stream = js.get_current_video_stream_index(playerid) + kodi_audio_stream = js.get_current_audio_stream_index(playerid) + if kodi_video_stream < len(self.video_streams) and kodi_audio_stream < len(self.audio_streams): + # Correct result! + break + i += 1 + if app.APP.monitor.waitForAbort(0.1): + # Need to quit PKC + return + else: + LOG.error('Could not get sensible Kodi indices! kodi_video_stream ' + '%s, kodi_audio_stream %s', + kodi_video_stream, kodi_audio_stream) + return + kodi_video_stream = self._current_index('video') + kodi_audio_stream = self._current_index('audio') sub_enabled = js.get_subtitle_enabled(playerid) - kodi_sub_stream = js.get_current_subtitle_stream_index(playerid) + kodi_sub_stream = self._current_index('subtitle') # Audio if kodi_audio_stream != self.current_kodi_audio_stream: self.on_kodi_audio_stream_change(kodi_audio_stream) @@ -457,28 +399,43 @@ class PlaylistItem(object): and kodi_sub_stream != self.current_kodi_sub_stream)): self.on_kodi_subtitle_stream_change(kodi_sub_stream, sub_enabled) - def on_plex_stream_change(self, plex_data): + def on_plex_stream_change(self, video_stream_id=None, audio_stream_id=None, + subtitle_stream_id=None): """ - Call this method if Plex Companion wants to change streams + Call this method if Plex Companion wants to change streams [ints] """ - if 'audioStreamID' in plex_data: - plex_index = int(plex_data['audioStreamID']) - kodi_index = self.kodi_stream_index(plex_index, 'audio') - self._set_kodi_stream_if_different(kodi_index, 'audio') - self.current_kodi_audio_stream = kodi_index - if 'videoStreamID' in plex_data: - plex_index = int(plex_data['videoStreamID']) - kodi_index = self.kodi_stream_index(plex_index, 'video') + if video_stream_id is not None: + try: + kodi_index = self.kodi_stream_index(video_stream_id, 'video') + except ValueError: + LOG.error('Unexpected Plex video_stream_id %s, not changing ' + 'the video stream!', video_stream_id) + return self._set_kodi_stream_if_different(kodi_index, 'video') self.current_kodi_video_stream = kodi_index - if 'subtitleStreamID' in plex_data: - plex_index = int(plex_data['subtitleStreamID']) - if plex_index == 0: + if audio_stream_id is not None: + try: + kodi_index = self.kodi_stream_index(audio_stream_id, 'audio') + except ValueError: + LOG.error('Unexpected Plex audio_stream_id %s, not changing ' + 'the video stream!', audio_stream_id) + return + self._set_kodi_stream_if_different(kodi_index, 'audio') + self.current_kodi_audio_stream = kodi_index + if subtitle_stream_id is not None: + if subtitle_stream_id == 0: app.APP.player.showSubtitles(False) kodi_index = False else: - kodi_index = self.kodi_stream_index(plex_index, 'subtitle') - if kodi_index: + try: + kodi_index = self.kodi_stream_index(subtitle_stream_id, + 'subtitle') + except ValueError: + kodi_index = None + LOG.debug('The PMS wanted to change subs, but we could not' + ' match the sub with id %s to a Kodi sub', + subtitle_stream_id) + else: app.APP.player.setSubtitleStream(kodi_index) app.APP.player.showSubtitles(True) self.current_kodi_sub_stream = kodi_index diff --git a/resources/lib/playqueue.py b/resources/lib/playqueue.py deleted file mode 100644 index 1f06cb32..00000000 --- a/resources/lib/playqueue.py +++ /dev/null @@ -1,232 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -""" -Monitors the Kodi playqueue and adjusts the Plex playqueue accordingly -""" -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 -from . import exceptions - -############################################################################### -LOG = getLogger('PLEX.playqueue') - -PLUGIN = 'plugin://%s' % v.ADDON_ID - -# Our PKC playqueues (3 instances of Playqueue_Object()) -PLAYQUEUES = [] -############################################################################### - - -def init_playqueues(): - """ - Call this once on startup to initialize the PKC playqueue objects in - the list PLAYQUEUES - """ - if PLAYQUEUES: - LOG.debug('Playqueues have already been initialized') - return - # Initialize Kodi playqueues - with app.APP.lock_playqueues: - for i in (0, 1, 2): - # Just in case the Kodi response is not sorted correctly - for queue in js.get_playlists(): - if queue['playlistid'] != i: - continue - playqueue = PL.Playqueue_Object() - playqueue.playlistid = i - playqueue.type = queue['type'] - # Initialize each Kodi playlist - if playqueue.type == v.KODI_TYPE_AUDIO: - playqueue.kodi_pl = xbmc.PlayList(xbmc.PLAYLIST_MUSIC) - elif playqueue.type == v.KODI_TYPE_VIDEO: - playqueue.kodi_pl = xbmc.PlayList(xbmc.PLAYLIST_VIDEO) - else: - # Currently, only video or audio playqueues available - playqueue.kodi_pl = xbmc.PlayList(xbmc.PLAYLIST_VIDEO) - # Overwrite 'picture' with 'photo' - playqueue.type = v.KODI_TYPE_PHOTO - PLAYQUEUES.append(playqueue) - LOG.debug('Initialized the Kodi playqueues: %s', PLAYQUEUES) - - -def get_playqueue_from_type(kodi_playlist_type): - """ - Returns the playqueue according to the kodi_playlist_type ('video', - 'audio', 'picture') passed in - """ - for playqueue in PLAYQUEUES: - if playqueue.type == kodi_playlist_type: - break - else: - raise ValueError('Wrong playlist type passed in: %s', - kodi_playlist_type) - return playqueue - - -def init_playqueue_from_plex_children(plex_id, transient_token=None): - """ - Init a new playqueue e.g. from an album. Alexa does this - - Returns the playqueue - """ - xml = PF.GetAllPlexChildren(plex_id) - try: - xml[0].attrib - except (TypeError, IndexError, AttributeError): - LOG.error('Could not download the PMS xml for %s', plex_id) - return - playqueue = get_playqueue_from_type( - v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[xml[0].attrib['type']]) - playqueue.clear() - for i, child in enumerate(xml): - api = API(child) - try: - PL.add_item_to_playlist(playqueue, i, plex_id=api.plex_id) - except exceptions.PlaylistError: - LOG.error('Could not add Plex item to our playlist: %s, %s', - child.tag, child.attrib) - playqueue.plex_transient_token = transient_token - LOG.debug('Firing up Kodi player') - app.APP.player.play(playqueue.kodi_pl, None, False, 0) - return playqueue - - -class PlayqueueMonitor(backgroundthread.KillableThread): - """ - Unfortunately, Kodi does not tell if items within a Kodi playqueue - (playlist) are swapped. This is what this monitor is for. Don't replace - this mechanism till Kodi's implementation of playlists has improved - """ - def _compare_playqueues(self, playqueue, new_kodi_playqueue): - """ - Used to poll the Kodi playqueue and update the Plex playqueue if needed - """ - old = list(playqueue.items) - # We might append to new_kodi_playqueue but will need the original - # still back in the main loop - new = copy.deepcopy(new_kodi_playqueue) - index = list(range(0, len(old))) - LOG.debug('Comparing new Kodi playqueue %s with our play queue %s', - new, old) - for i, new_item in enumerate(new): - if (new_item['file'].startswith('plugin://') and - not new_item['file'].startswith(PLUGIN)): - # Ignore new media added by other addons - continue - for j, old_item in enumerate(old): - if self.should_suspend() or self.should_cancel(): - # Chances are that we got an empty Kodi playlist due to - # Kodi exit - return - try: - if (old_item.file.startswith('plugin://') and - not old_item.file.startswith(PLUGIN)): - # Ignore media by other addons - continue - except AttributeError: - # were not passed a filename; ignore - pass - if 'id' in new_item: - identical = (old_item.kodi_id == new_item['id'] and - old_item.kodi_type == new_item['type']) - else: - try: - plex_id = int(utils.REGEX_PLEX_ID.findall(new_item['file'])[0]) - except IndexError: - LOG.debug('Comparing paths directly as a fallback') - identical = old_item.file == new_item['file'] - else: - identical = plex_id == old_item.plex_id - if j == 0 and identical: - del old[j], index[j] - break - elif identical: - LOG.debug('Playqueue item %s moved to position %s', - i + j, i) - try: - PL.move_playlist_item(playqueue, i + j, i) - except exceptions.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: - LOG.debug('Detected new Kodi element at position %s: %s ', - i, new_item) - try: - if playqueue.id is None: - PL.init_plex_playqueue(playqueue, kodi_item=new_item) - else: - PL.add_item_to_plex_playqueue(playqueue, - i, - kodi_item=new_item) - except exceptions.PlaylistError: - # Could not add the element - pass - except KeyError: - # Catches KeyError from PL.verify_kodi_item() - # Hack: Kodi already started playback of a new item and we - # started playback already using kodimonitors - # PlayBackStart(), but the Kodi playlist STILL only shows - # the old element. Hence ignore playlist difference here - LOG.debug('Detected an outdated Kodi playlist - ignoring') - return - except IndexError: - # This is really a hack - happens when using Addon Paths - # and repeatedly starting the same element. Kodi will then - # not pass kodi id nor file path AND will also not - # start-up playback. Hence kodimonitor kicks off playback. - # Also see kodimonitor.py - _playlist_onadd() - pass - else: - for j in range(i, len(index)): - index[j] += 1 - for i in reversed(index): - if self.should_suspend() or self.should_cancel(): - # Chances are that we got an empty Kodi playlist due to - # Kodi exit - return - LOG.debug('Detected deletion of playqueue element at pos %s', i) - try: - PL.delete_playlist_item_from_PMS(playqueue, i) - except exceptions.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') - LOG.debug('Done comparing playqueues') - - def run(self): - LOG.info("----===## Starting PlayqueueMonitor ##===----") - app.APP.register_thread(self) - try: - self._run() - finally: - app.APP.deregister_thread(self) - LOG.info("----===## PlayqueueMonitor stopped ##===----") - - def _run(self): - while not self.should_cancel(): - if self.should_suspend(): - if self.wait_while_suspended(): - return - with app.APP.lock_playqueues: - for playqueue in PLAYQUEUES: - kodi_pl = js.playlist_get_items(playqueue.playlistid) - if playqueue.old_kodi_pl != kodi_pl: - if playqueue.id is None and (not app.SYNC.direct_paths or - app.PLAYSTATE.context_menu_play): - # Only initialize if directly fired up using direct - # paths. Otherwise let default.py do its magic - LOG.debug('Not yet initiating playback') - else: - # compare old and new playqueue - self._compare_playqueues(playqueue, kodi_pl) - playqueue.old_kodi_pl = list(kodi_pl) - self.sleep(0.2) diff --git a/resources/lib/plex_companion.py b/resources/lib/plex_companion.py deleted file mode 100644 index e6400dd8..00000000 --- a/resources/lib/plex_companion.py +++ /dev/null @@ -1,362 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -""" -The Plex Companion master python file -""" -from logging import getLogger -from threading import Thread -from queue import Empty -from socket import SHUT_RDWR -from xbmc import executebuiltin - -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 playback -from . import json_rpc as js -from . import playqueue as PQ -from . import variables as v -from . import backgroundthread -from . import app -from . import exceptions - -############################################################################### - -LOG = getLogger('PLEX.plex_companion') - -############################################################################### - - -def update_playqueue_from_PMS(playqueue, - playqueue_id=None, - repeat=None, - offset=None, - transient_token=None, - start_plex_id=None): - """ - Completely updates the Kodi playqueue with the new Plex playqueue. Pass - in playqueue_id if we need to fetch a new playqueue - - repeat = 0, 1, 2 - offset = time offset in Plextime (milliseconds) - """ - LOG.info('New playqueue %s received from Plex companion with offset ' - '%s, repeat %s, start_plex_id %s', - playqueue_id, offset, repeat, start_plex_id) - # Safe transient token from being deleted - if transient_token is None: - transient_token = playqueue.plex_transient_token - with app.APP.lock_playqueues: - try: - xml = PL.get_PMS_playlist(playqueue, playqueue_id) - except exceptions.PlaylistError: - LOG.error('Could now download playqueue %s', playqueue_id) - return - if playqueue.id == playqueue_id: - # This seems to be happening ONLY if a Plex Companion device - # reconnects and Kodi is already playing something - silly, really - # For all other cases, a new playqueue is generated by Plex - LOG.debug('Update for existing playqueue detected') - return - playqueue.clear() - # Get new metadata for the playqueue first - try: - PL.get_playlist_details_from_xml(playqueue, xml) - except exceptions.PlaylistError: - LOG.error('Could not get playqueue ID %s', playqueue_id) - return - playqueue.repeat = 0 if not repeat else int(repeat) - playqueue.plex_transient_token = transient_token - playback.play_xml(playqueue, - xml, - offset=offset, - start_plex_id=start_plex_id) - - -class PlexCompanion(backgroundthread.KillableThread): - """ - Plex Companion monitoring class. Invoke only once - """ - def __init__(self): - LOG.info("----===## Starting PlexCompanion ##===----") - # Init Plex Companion queue - # Start GDM for server/client discovery - self.client = plexgdm.plexgdm() - self.client.clientDetails() - LOG.debug("Registration string is:\n%s", self.client.getClientDetails()) - self.httpd = False - self.subscription_manager = None - super(PlexCompanion, self).__init__() - - @staticmethod - def _process_alexa(data): - if 'key' not in data or 'containerKey' not in data: - LOG.error('Received malformed Alexa data: %s', data) - return - xml = PF.GetPlexMetadata(data['key']) - try: - xml[0].attrib - except (AttributeError, IndexError, TypeError): - LOG.error('Could not download Plex metadata for: %s', data) - return - api = API(xml[0]) - if api.plex_type == v.PLEX_TYPE_ALBUM: - LOG.debug('Plex music album detected') - PQ.init_playqueue_from_plex_children( - api.plex_id, - transient_token=data.get('token')) - elif data['containerKey'].startswith('/playQueues/'): - _, container_key, _ = PF.ParseContainerKey(data['containerKey']) - xml = PF.DownloadChunks('{server}/playQueues/%s' % container_key) - if xml is None: - # "Play error" - utils.dialog('notification', - utils.lang(29999), - utils.lang(30128), - icon='{error}') - return - playqueue = PQ.get_playqueue_from_type( - v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[api.plex_type]) - playqueue.clear() - PL.get_playlist_details_from_xml(playqueue, xml) - playqueue.plex_transient_token = data.get('token') - if data.get('offset') != '0': - offset = float(data['offset']) / 1000.0 - else: - offset = None - playback.play_xml(playqueue, xml, offset) - else: - app.CONN.plex_transient_token = data.get('token') - playback.playback_triage(api.plex_id, - api.plex_type, - resolve=False, - resume=data.get('offset') not in ('0', None)) - - @staticmethod - def _process_node(data): - """ - E.g. watch later initiated by Companion. Basically navigating Plex - """ - app.CONN.plex_transient_token = data.get('key') - params = { - 'mode': 'plex_node', - 'key': f"{{server}}{data.get('key')}", - 'offset': data.get('offset') - } - handle = f'RunPlugin(plugin://{utils.extend_url(v.ADDON_ID, params)})' - executebuiltin(handle) - - @staticmethod - def _process_playlist(data): - if 'containerKey' not in data: - LOG.error('Received malformed playlist data: %s', data) - return - # Get the playqueue ID - _, container_key, query = PF.ParseContainerKey(data['containerKey']) - try: - playqueue = PQ.get_playqueue_from_type( - v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[data['type']]) - except KeyError: - # E.g. Plex web does not supply the media type - # Still need to figure out the type (video vs. music vs. pix) - xml = PF.GetPlexMetadata(data['key']) - try: - xml[0].attrib - except (AttributeError, IndexError, TypeError): - LOG.error('Could not download Plex metadata') - return - api = API(xml[0]) - playqueue = PQ.get_playqueue_from_type( - v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[api.plex_type]) - key = data.get('key') - if key: - _, key, _ = PF.ParseContainerKey(key) - update_playqueue_from_PMS(playqueue, - playqueue_id=container_key, - repeat=query.get('repeat'), - offset=utils.cast(int, data.get('offset')), - transient_token=data.get('token'), - start_plex_id=key) - - @staticmethod - def _process_streams(data): - """ - Plex Companion client adjusted audio or subtitle stream - """ - if 'type' not in data: - LOG.error('Received malformed stream data: %s', data) - return - playqueue = PQ.get_playqueue_from_type( - v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[data['type']]) - pos = js.get_position(playqueue.playlistid) - playqueue.items[pos].on_plex_stream_change(data) - - @staticmethod - def _process_refresh(data): - """ - example data: {'playQueueID': '8475', 'commandID': '11'} - """ - if 'playQueueID' not in data: - LOG.error('Received malformed refresh data: %s', data) - return - xml = PL.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) - if plex_type is None: - return - playqueue = PQ.get_playqueue_from_type( - v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[plex_type]) - playqueue.clear() - return - playqueue = PQ.get_playqueue_from_type( - v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[xml[0].attrib['type']]) - update_playqueue_from_PMS(playqueue, data['playQueueID']) - - def _process_tasks(self, task): - """ - Processes tasks picked up e.g. by Companion listener, e.g. - {'action': 'playlist', - 'data': {'address': 'xyz.plex.direct', - 'commandID': '7', - 'containerKey': '/playQueues/6669?own=1&repeat=0&window=200', - 'key': '/library/metadata/220493', - 'machineIdentifier': 'xyz', - 'offset': '0', - 'port': '32400', - 'protocol': 'https', - 'token': 'transient-cd2527d1-0484-48e0-a5f7-f5caa7d591bd', - 'type': 'video'}} - """ - LOG.debug('Processing: %s', task) - data = task['data'] - if task['action'] == 'alexa': - with app.APP.lock_playqueues: - self._process_alexa(data) - elif (task['action'] == 'playlist' and - data.get('address') == 'node.plexapp.com'): - self._process_node(data) - elif task['action'] == 'playlist': - with app.APP.lock_playqueues: - self._process_playlist(data) - elif task['action'] == 'refreshPlayQueue': - with app.APP.lock_playqueues: - self._process_refresh(data) - elif task['action'] == 'setStreams': - try: - self._process_streams(data) - except KeyError: - pass - - def run(self): - """ - Ensure that sockets will be closed no matter what - """ - app.APP.register_thread(self) - try: - self._run() - finally: - try: - self.httpd.socket.shutdown(SHUT_RDWR) - except AttributeError: - pass - finally: - try: - self.httpd.socket.close() - except AttributeError: - pass - app.APP.deregister_thread(self) - LOG.info("----===## Plex Companion stopped ##===----") - - def _run(self): - httpd = self.httpd - # Cache for quicker while loops - client = self.client - - # Start up instances - request_mgr = httppersist.RequestMgr() - subscription_manager = subscribers.SubscriptionMgr(request_mgr, - app.APP.player) - self.subscription_manager = subscription_manager - - if utils.settings('plexCompanion') == 'true': - # Start up httpd - start_count = 0 - while True: - try: - httpd = listener.PKCHTTPServer( - client, - subscription_manager, - ('', v.COMPANION_PORT), - listener.MyHandler) - httpd.timeout = 10.0 - break - except Exception: - LOG.error("Unable to start PlexCompanion. Traceback:") - import traceback - LOG.error(traceback.print_exc()) - app.APP.monitor.waitForAbort(3) - if start_count == 3: - LOG.error("Error: Unable to start web helper.") - httpd = False - break - start_count += 1 - else: - LOG.info('User deactivated Plex Companion') - client.start_all() - message_count = 0 - if httpd: - thread = Thread(target=httpd.handle_request) - - while not self.should_cancel(): - # If we are not authorized, sleep - # Otherwise, we trigger a download which leads to a - # re-authorizations - if self.should_suspend(): - if self.wait_while_suspended(): - break - try: - message_count += 1 - if httpd: - if not thread.is_alive(): - # Use threads cause the method will stall - thread = Thread(target=httpd.handle_request) - thread.start() - - if message_count == 3000: - message_count = 0 - if client.check_client_registration(): - LOG.debug('Client is still registered') - else: - LOG.debug('Client is no longer registered. Plex ' - 'Companion still running on port %s', - v.COMPANION_PORT) - client.register_as_client() - # Get and set servers - if message_count % 30 == 0: - subscription_manager.serverlist = client.getServerList() - subscription_manager.notify() - if not httpd: - message_count = 0 - except Exception: - LOG.warn("Error in loop, continuing anyway. Traceback:") - import traceback - LOG.warn(traceback.format_exc()) - # See if there's anything we need to process - try: - task = app.APP.companion_queue.get(block=False) - except Empty: - pass - else: - # Got instructions, process them - self._process_tasks(task) - app.APP.companion_queue.task_done() - # Don't sleep - continue - self.sleep(0.05) - subscription_manager.signal_stop() - client.stop_all() diff --git a/resources/lib/plex_companion/__init__.py b/resources/lib/plex_companion/__init__.py new file mode 100644 index 00000000..ba2478a9 --- /dev/null +++ b/resources/lib/plex_companion/__init__.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +from .polling import Listener +from .playstate import PlaystateMgr diff --git a/resources/lib/plex_companion/common.py b/resources/lib/plex_companion/common.py new file mode 100644 index 00000000..14f6e4c1 --- /dev/null +++ b/resources/lib/plex_companion/common.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from .. import variables as v +from .. import app + + +def log_error(logger, error_message, response): + logger('%s: %s: %s', error_message, response.status_code, response.reason) + logger('headers received from the PMS: %s', response.headers) + logger('Message received from the PMS: %s', response.text) + + +def proxy_headers(): + return { + 'X-Plex-Client-Identifier': v.PKC_MACHINE_IDENTIFIER, + 'X-Plex-Product': v.ADDON_NAME, + 'X-Plex-Version': v.ADDON_VERSION, + 'X-Plex-Platform': v.PLATFORM, + 'X-Plex-Platform-Version': v.PLATFORM_VERSION, + 'X-Plex-Device-Name': v.DEVICENAME, + 'Content-Type': 'text/xml;charset=utf-8' + } + + +def proxy_params(): + params = { + 'deviceClass': 'pc', + 'protocolCapabilities': 'timeline,playback,navigation,playqueues', + 'protocolVersion': 3 + } + if app.ACCOUNT.pms_token: + params['X-Plex-Token'] = app.ACCOUNT.pms_token + return params diff --git a/resources/lib/plex_companion/playqueue.py b/resources/lib/plex_companion/playqueue.py new file mode 100644 index 00000000..064b7065 --- /dev/null +++ b/resources/lib/plex_companion/playqueue.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from logging import getLogger +import copy + +from ..plex_api import API +from .. import variables as v +from .. import app +from .. import utils +from .. import plex_functions as PF +from .. import playlist_func as PL +from .. import exceptions + +log = getLogger('PLEX.companion.playqueue') + +PLUGIN = 'plugin://%s' % v.ADDON_ID + + +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 = app.PLAYQUEUES.from_plex_type(xml[0].attrib['type']) + playqueue.clear() + for i, child in enumerate(xml): + api = API(child) + try: + PL.add_item_to_playlist(playqueue, i, plex_id=api.plex_id) + except exceptions.PlaylistError: + log.error('Could not add Plex item to our playlist: %s, %s', + child.tag, child.attrib) + 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 compare_playqueues(playqueue, new_kodi_playqueue): + """ + Used to poll the Kodi playqueue and update the Plex playqueue if needed + """ + old = list(playqueue.items) + # We might append to new_kodi_playqueue but will need the original + # still back in the main loop + new = copy.deepcopy(new_kodi_playqueue) + index = list(range(0, len(old))) + log.debug('Comparing new Kodi playqueue %s with our play queue %s', + new, old) + for i, new_item in enumerate(new): + if (new_item['file'].startswith('plugin://') and + not new_item['file'].startswith(PLUGIN)): + # Ignore new media added by other addons + continue + for j, old_item in enumerate(old): + + if app.APP.stop_pkc: + # Chances are that we got an empty Kodi playlist due to + # Kodi exit + return + try: + if (old_item.file.startswith('plugin://') and + not old_item.file.startswith(PLUGIN)): + # Ignore media by other addons + continue + except AttributeError: + # were not passed a filename; ignore + pass + if 'id' in new_item: + identical = (old_item.kodi_id == new_item['id'] and + old_item.kodi_type == new_item['type']) + else: + try: + plex_id = int(utils.REGEX_PLEX_ID.findall(new_item['file'])[0]) + except IndexError: + log.debug('Comparing paths directly as a fallback') + identical = old_item.file == new_item['file'] + else: + identical = plex_id == old_item.plex_id + if j == 0 and identical: + del old[j], index[j] + break + elif identical: + log.debug('Playqueue item %s moved to position %s', + i + j, i) + try: + PL.move_playlist_item(playqueue, i + j, i) + except exceptions.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: + log.debug('Detected new Kodi element at position %s: %s ', + i, new_item) + try: + if playqueue.id is None: + PL.init_plex_playqueue(playqueue, kodi_item=new_item) + else: + PL.add_item_to_plex_playqueue(playqueue, + i, + kodi_item=new_item) + except exceptions.PlaylistError: + # Could not add the element + pass + except KeyError: + # Catches KeyError from PL.verify_kodi_item() + # Hack: Kodi already started playback of a new item and we + # started playback already using kodimonitors + # PlayBackStart(), but the Kodi playlist STILL only shows + # the old element. Hence ignore playlist difference here + log.debug('Detected an outdated Kodi playlist - ignoring') + return + except IndexError: + # This is really a hack - happens when using Addon Paths + # and repeatedly starting the same element. Kodi will then + # not pass kodi id nor file path AND will also not + # start-up playback. Hence kodimonitor kicks off playback. + # Also see kodimonitor.py - _playlist_onadd() + pass + else: + for j in range(i, len(index)): + index[j] += 1 + for i in reversed(index): + if app.APP.stop_pkc: + # Chances are that we got an empty Kodi playlist due to + # Kodi exit + return + log.debug('Detected deletion of playqueue element at pos %s', i) + try: + PL.delete_playlist_item_from_PMS(playqueue, i) + except exceptions.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') + log.debug('Done comparing playqueues') diff --git a/resources/lib/plex_companion/playstate.py b/resources/lib/plex_companion/playstate.py new file mode 100644 index 00000000..16ba8417 --- /dev/null +++ b/resources/lib/plex_companion/playstate.py @@ -0,0 +1,411 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from logging import getLogger +import requests +import xml.etree.ElementTree as etree + +from .common import proxy_headers, proxy_params, log_error +from .playqueue import compare_playqueues + +from .. import json_rpc as js +from .. import variables as v +from .. import backgroundthread +from .. import app +from .. import timing +from .. import skip_plex_intro + + +# Disable annoying requests warnings +import requests.packages.urllib3 +requests.packages.urllib3.disable_warnings() + +log = getLogger('PLEX.companion.playstate') + +TIMEOUT = (5, 5) + +# What is Companion controllable? +CONTROLLABLE = { + v.PLEX_PLAYLIST_TYPE_VIDEO: 'playPause,stop,volume,shuffle,audioStream,' + 'subtitleStream,seekTo,skipPrevious,skipNext,' + 'stepBack,stepForward', + v.PLEX_PLAYLIST_TYPE_AUDIO: 'playPause,stop,volume,shuffle,repeat,seekTo,' + 'skipPrevious,skipNext,stepBack,stepForward', + v.PLEX_PLAYLIST_TYPE_PHOTO: 'playPause,stop,skipPrevious,skipNext' +} + + +def split_server_uri(server): + (protocol, url, port) = server.split(':') + url = url.replace('/', '') + return (protocol, url, port) + + +def get_correct_position(info, playqueue): + """ + Kodi tells us the PLAYLIST position, not PLAYQUEUE position, if the + user initiated playback of a playlist + """ + if playqueue.kodi_playlist_playback: + position = 0 + else: + position = info['position'] or 0 + return position + + +def timeline_dict(playerid, typus): + with app.APP.lock_playqueues: + info = app.PLAYSTATE.player_states[playerid] + playqueue = app.PLAYQUEUES[playerid] + position = get_correct_position(info, playqueue) + try: + item = playqueue.items[position] + except IndexError: + # E.g. for direct path playback for single item + return { + 'controllable': CONTROLLABLE[typus], + 'type': typus, + 'state': 'stopped' + } + protocol, url, port = split_server_uri(app.CONN.server) + status = 'paused' if int(info['speed']) == 0 else 'playing' + duration = timing.kodi_time_to_millis(info['totaltime']) + shuffle = '1' if info['shuffled'] else '0' + mute = '1' if info['muted'] is True else '0' + answ = { + 'controllable': CONTROLLABLE[typus], + 'protocol': protocol, + 'address': url, + 'port': port, + 'machineIdentifier': app.CONN.machine_identifier, + 'state': status, + 'type': typus, + 'itemType': typus, + 'time': str(timing.kodi_time_to_millis(info['time'])), + 'duration': str(duration), + 'seekRange': '0-%s' % duration, + 'shuffle': shuffle, + 'repeat': v.PLEX_REPEAT_FROM_KODI_REPEAT[info['repeat']], + 'volume': str(info['volume']), + 'mute': mute, + 'mediaIndex': '0', # Still to implement + 'partIndex': '0', + 'partCount': '1', + 'providerIdentifier': 'com.plexapp.plugins.library', + } + # Get the plex id from the PKC playqueue not info, as Kodi jumps to + # next playqueue element way BEFORE kodi monitor onplayback is + # called + if item.plex_id: + answ['key'] = '/library/metadata/%s' % item.plex_id + answ['ratingKey'] = str(item.plex_id) + # PlayQueue stuff + if info['container_key']: + answ['containerKey'] = info['container_key'] + if (info['container_key'] is not None and + info['container_key'].startswith('/playQueues')): + answ['playQueueID'] = str(playqueue.id) + answ['playQueueVersion'] = str(playqueue.version) + answ['playQueueItemID'] = str(item.id) + if playqueue.items[position].guid: + answ['guid'] = item.guid + # Temp. token set? + if app.CONN.plex_transient_token: + answ['token'] = app.CONN.plex_transient_token + elif playqueue.plex_transient_token: + answ['token'] = playqueue.plex_transient_token + # Process audio and subtitle streams + if typus == v.PLEX_PLAYLIST_TYPE_VIDEO: + answ['videoStreamID'] = str(item.current_plex_video_stream) + answ['audioStreamID'] = str(item.current_plex_audio_stream) + # Mind the zero - meaning subs are deactivated + answ['subtitleStreamID'] = str(item.current_plex_sub_stream or 0) + return answ + + +def timeline(players): + """ + Returns a timeline xml as str + (xml containing video, audio, photo player state) + """ + xml = etree.Element('MediaContainer') + location = 'navigation' + for typus in (v.PLEX_PLAYLIST_TYPE_AUDIO, + v.PLEX_PLAYLIST_TYPE_VIDEO, + v.PLEX_PLAYLIST_TYPE_PHOTO): + player = players.get(v.KODI_PLAYLIST_TYPE_FROM_PLEX_PLAYLIST_TYPE[typus]) + if player is None: + # Kodi player currently not actively playing, but stopped + timeline = { + 'controllable': CONTROLLABLE[typus], + 'type': typus, + 'state': 'stopped' + } + else: + # Active Kodi player, i.e. video, audio or picture player + timeline = timeline_dict(player['playerid'], typus) + if typus in (v.PLEX_PLAYLIST_TYPE_VIDEO, v.PLEX_PLAYLIST_TYPE_PHOTO): + location = 'fullScreenVideo' + etree.SubElement(xml, 'Timeline', attrib=timeline) + xml.set('location', location) + return xml + + +def stopped_timeline(): + """ + Returns an XML stating that all players have stopped playback + """ + xml = etree.Element('MediaContainer', attrib={'location': 'navigation'}) + for typus in (v.PLEX_PLAYLIST_TYPE_AUDIO, + v.PLEX_PLAYLIST_TYPE_VIDEO, + v.PLEX_PLAYLIST_TYPE_PHOTO): + # Kodi player currently not actively playing, but stopped + timeline = { + 'controllable': CONTROLLABLE[typus], + 'type': typus, + 'state': 'stopped' + } + etree.SubElement(xml, 'Timeline', attrib=timeline) + return xml + + +def update_player_info(players): + """ + Update the playstate info for other PKC "consumers" + """ + for player in players.values(): + playerid = player['playerid'] + app.PLAYSTATE.player_states[playerid].update(js.get_player_props(playerid)) + app.PLAYSTATE.player_states[playerid]['volume'] = js.get_volume() + app.PLAYSTATE.player_states[playerid]['muted'] = js.get_muted() + + +class PlaystateMgr(backgroundthread.KillableThread): + """ + If Kodi plays something, tell the PMS about it and - if a Companion client + is connected - tell the PMS Plex Companion piece of the PMS about it. + Also checks whether an intro is currently playing, enabling the user to + skip it. + """ + daemon = True + + def __init__(self): + self._subscribed = False + self._command_id = None + self.s = None + self.t = None + self.stopped_timeline = stopped_timeline() + super().__init__() + + def _get_requests_session(self): + if self.s is None: + log.debug('Creating new requests session') + self.s = requests.Session() + self.s.headers = proxy_headers() + self.s.verify = app.CONN.verify_ssl_cert + if app.CONN.ssl_cert_path: + self.s.cert = app.CONN.ssl_cert_path + self.s.params = proxy_params() + return self.s + + def _get_requests_session_companion(self): + if self.t is None: + log.debug('Creating new companion requests session') + self.t = requests.Session() + self.t.headers = proxy_headers() + self.t.verify = app.CONN.verify_ssl_cert + if app.CONN.ssl_cert_path: + self.t.cert = app.CONN.ssl_cert_path + self.t.params = proxy_params() + return self.t + + def close_requests_session(self): + for session in (self.s, self.t): + if session is not None: + try: + session.close() + except AttributeError: + # "thread-safety" - Just in case s was set to None in the + # meantime + pass + session = None + + @staticmethod + def communicate(method, url, **kwargs): + try: + # This will usually block until timeout is reached! + req = method(url, **kwargs) + except requests.ConnectTimeout: + # The request timed out while trying to connect to the PMS + log.error('Requests ConnectionTimeout!') + raise + except requests.ReadTimeout: + # The PMS did not send any data in the allotted amount of time + log.error('Requests ReadTimeout!') + raise + except requests.TooManyRedirects: + log.error('TooManyRedirects error!') + raise + except requests.HTTPError as error: + log.error('HTTPError: %s', error) + raise + except requests.ConnectionError as error: + log.error('ConnectionError: %s', error) + raise + req.encoding = 'utf-8' + # To make sure that we release the socket, need to access content once + req.content + return req + + def _subscribe(self, cmd): + self._command_id = int(cmd.get('commandID')) + self._subscribed = True + + def _unsubscribe(self): + self._subscribed = False + self._command_id = None + + def send_stop(self): + """ + If we're still connected to a PMS, tells the PMS that playback stopped + """ + if app.CONN.online and app.ACCOUNT.authenticated: + # Only try to send something if we're connected + self.pms_timeline(dict(), self.stopped_timeline) + self.companion_timeline(self.stopped_timeline) + + def check_subscriber(self, cmd): + if cmd.get('path') == '/player/timeline/unsubscribe': + log.info('Stop Plex Companion subscription') + self._unsubscribe() + elif not self._subscribed: + log.info('Start Plex Companion subscription') + self._subscribe(cmd) + else: + try: + self._command_id = int(cmd.get('commandID')) + except TypeError: + pass + + def companion_timeline(self, message): + if not self._subscribed: + return + url = f'{app.CONN.server}/player/proxy/timeline' + self._get_requests_session_companion() + self.t.params['commandID'] = self._command_id + message.set('commandID', str(self._command_id)) + # Get the correct playstate + state = 'stopped' + for timeline in message: + if timeline.get('state') != 'stopped': + state = timeline.get('state') + self.t.params['state'] = state + # Send update + try: + req = self.communicate(self.t.post, + url, + data=etree.tostring(message, + encoding='utf-8'), + timeout=TIMEOUT) + except (requests.RequestException, SystemExit): + return + if not req.ok: + log_error(log.error, 'Unexpected Companion timeline', req) + + def pms_timeline_per_player(self, playerid, message): + """ + Pass a really low timeout in seconds if shutting down Kodi and we don't + need the PMS' response + """ + url = f'{app.CONN.server}/:/timeline' + self._get_requests_session() + self.s.params.update(message[playerid].attrib) + # Tell the PMS about our playstate progress + try: + req = self.communicate(self.s.get, url, timeout=TIMEOUT) + except (requests.RequestException, SystemExit): + return + if not req.ok: + log_error(log.error, 'Failed reporting playback progress', req) + + def pms_timeline(self, players, message): + players = players if players else \ + {0: {'playerid': 0}, 1: {'playerid': 1}, 2: {'playerid': 2}} + for player in players.values(): + self.pms_timeline_per_player(player['playerid'], message) + + def run(self): + app.APP.register_thread(self) + log.info("----===## Starting PlaystateMgr ##===----") + try: + self._run() + finally: + # Make sure we're telling the PMS that playback will stop + self.send_stop() + # Cleanup + self.close_requests_session() + app.APP.deregister_thread(self) + log.info("----===## PlaystateMgr stopped ##===----") + + def _run(self): + signaled_playback_stop = True + while not self.should_cancel(): + if self.should_suspend(): + self._unsubscribe() + self.close_requests_session() + if self.wait_while_suspended(): + break + # Check for Kodi playlist changes first + with app.APP.lock_playqueues: + for playqueue in app.PLAYQUEUES: + kodi_pl = js.playlist_get_items(playqueue.playlistid) + if playqueue.old_kodi_pl != kodi_pl: + if playqueue.id is None and (not app.SYNC.direct_paths or + app.PLAYSTATE.context_menu_play): + # Only initialize if directly fired up using direct + # paths. Otherwise let default.py do its magic + log.debug('Not yet initiating playback') + else: + # compare old and new playqueue + compare_playqueues(playqueue, kodi_pl) + playqueue.old_kodi_pl = list(kodi_pl) + # Then check for Kodi playback + players = js.get_players() + if not players and signaled_playback_stop: + self.sleep(1) + continue + elif not players: + # Playback has just stopped, need to tell Plex + signaled_playback_stop = True + self.send_stop() + self.sleep(1) + continue + else: + # Update the playstate info, such as playback progress + update_player_info(players) + try: + message = timeline(players) + except TypeError: + # We haven't had a chance to set the kodi_stream_index for + # the currently playing item. Just skip for now + self.sleep(1) + continue + else: + # Kodi will started with 'stopped' - make sure we're + # waiting here until we got something playing or on pause. + for entry in message: + if entry.get('state') != 'stopped': + break + else: + continue + signaled_playback_stop = False + try: + # Check whether an intro is currently running + skip_plex_intro.check() + except IndexError: + # Playback might have already stopped + pass + # Send the playback progress info to the PMS + self.pms_timeline(players, message) + # Send the info to all Companion devices via the PMS + self.companion_timeline(message) + self.sleep(1) diff --git a/resources/lib/plex_companion/polling.py b/resources/lib/plex_companion/polling.py new file mode 100644 index 00000000..b9c29e38 --- /dev/null +++ b/resources/lib/plex_companion/polling.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from logging import getLogger +import requests + +from .processing import process_proxy_xml +from .common import proxy_headers, proxy_params, log_error + +from .. import utils +from .. import backgroundthread +from .. import app +from .. import variables as v + +# Disable annoying requests warnings +import requests.packages.urllib3 +requests.packages.urllib3.disable_warnings() + +# Timeout (connection timeout, read timeout) +# The later is up to 20 seconds, if the PMS has nothing to tell us +# THIS WILL PREVENT PKC FROM SHUTTING DOWN CORRECTLY +TIMEOUT = (5.0, 3.0) + +log = getLogger('PLEX.companion.listener') + + +class Listener(backgroundthread.KillableThread): + """ + Opens a GET HTTP connection to the current PMS (that will time-out PMS-wise + after ~20 seconds) and listens for any commands by the PMS. Listening + will cause this PKC client to be registered as a Plex Companien client. + """ + daemon = True + + def __init__(self, playstate_mgr): + self.s = None + self.playstate_mgr = playstate_mgr + super().__init__() + + def _get_requests_session(self): + if self.s is None: + log.debug('Creating new requests session') + self.s = requests.Session() + self.s.headers = proxy_headers() + self.s.verify = app.CONN.verify_ssl_cert + if app.CONN.ssl_cert_path: + self.s.cert = app.CONN.ssl_cert_path + self.s.params = proxy_params() + return self.s + + def close_requests_session(self): + try: + self.s.close() + except AttributeError: + # "thread-safety" - Just in case s was set to None in the + # meantime + pass + self.s = None + + def ok_message(self, command_id): + url = f'{app.CONN.server}/player/proxy/response?commandID={command_id}' + try: + req = self.communicate(self.s.post, + url, + data=v.COMPANION_OK_MESSAGE.encode('utf-8')) + except (requests.RequestException, SystemExit): + return + if not req.ok: + log_error(log.error, 'Error replying OK', req) + + @staticmethod + def communicate(method, url, **kwargs): + try: + req = method(url, **kwargs) + except requests.ConnectTimeout: + # The request timed out while trying to connect to the PMS + log.error('Requests ConnectionTimeout!') + raise + except requests.ReadTimeout: + # The PMS did not send any data in the allotted amount of time + log.error('Requests ReadTimeout!') + raise + except requests.TooManyRedirects: + log.error('TooManyRedirects error!') + raise + except requests.HTTPError as error: + log.error('HTTPError: %s', error) + raise + except requests.ConnectionError: + # Caused by PKC terminating the connection prematurely + # log.error('ConnectionError: %s', error) + raise + else: + req.encoding = 'utf-8' + # Access response content once in order to make sure to release the + # underlying sockets + req.content + return req + + def run(self): + """ + Ensure that sockets will be closed no matter what + """ + app.APP.register_thread(self) + log.info("----===## Starting PollCompanion ##===----") + try: + self._run() + finally: + self.close_requests_session() + app.APP.deregister_thread(self) + log.info("----===## PollCompanion stopped ##===----") + + def _run(self): + while not self.should_cancel(): + if self.should_suspend(): + self.close_requests_session() + if self.wait_while_suspended(): + break + # See if there's anything we need to process + # timeout=1 will cause the PMS to "hold" the connection for approx + # 20 seconds. This will BLOCK requests - not something we can + # circumvent. + url = app.CONN.server + '/player/proxy/poll?timeout=1' + self._get_requests_session() + try: + req = self.communicate(self.s.get, + url, + timeout=TIMEOUT) + except requests.ConnectionError: + # No command received from the PMS - try again immediately + continue + except requests.RequestException: + self.sleep(0.5) + continue + except SystemExit: + # We need to quit PKC entirely + break + + # Sanity checks + if not req.ok: + log_error(log.error, 'Error while contacting the PMS', req) + self.sleep(0.5) + continue + if not req.text: + # Means the connection timed-out (usually after 20 seconds), + # because there was no command from the PMS or a client to + # remote-control anything no the PKC-side + # Received an empty body, but still header Content-Type: xml + continue + if not ('content-type' in req.headers + and 'xml' in req.headers['content-type']): + log_error(log.error, 'Unexpected answer from the PMS', req) + self.sleep(0.5) + continue + + # Parsing + try: + xml = utils.etree.fromstring(req.content) + cmd = xml[0] + if len(xml) > 1: + # We should always just get ONE command per message + raise IndexError() + except (utils.ParseError, IndexError): + log_error(log.error, 'Could not parse the PMS xml:', req) + self.sleep(0.5) + continue + + # Do the work + log.debug('Received a Plex Companion command from the PMS:') + utils.log_xml(xml, log.debug) + self.playstate_mgr.check_subscriber(cmd) + if process_proxy_xml(cmd): + self.ok_message(cmd.get('commandID')) diff --git a/resources/lib/plex_companion/processing.py b/resources/lib/plex_companion/processing.py new file mode 100644 index 00000000..0f3de1f1 --- /dev/null +++ b/resources/lib/plex_companion/processing.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +The Plex Companion master python file +""" +from logging import getLogger + +import xbmc + +from ..plex_api import API +from .. import utils +from ..utils import cast +from .. import plex_functions as PF +from .. import playlist_func as PL +from .. import playback +from .. import json_rpc as js +from .. import variables as v +from .. import app +from .. import exceptions + + +log = getLogger('PLEX.companion.processing') + + +def update_playqueue_from_PMS(playqueue, + playqueue_id=None, + repeat=None, + offset=None, + transient_token=None, + start_plex_id=None): + """ + Completely updates the Kodi playqueue with the new Plex playqueue. Pass + in playqueue_id if we need to fetch a new playqueue + + repeat = 0, 1, 2 + offset = time offset in Plextime (milliseconds) + """ + log.info('New playqueue %s received from Plex companion with offset ' + '%s, repeat %s, start_plex_id %s', + playqueue_id, offset, repeat, start_plex_id) + # Safe transient token from being deleted + if transient_token is None: + transient_token = playqueue.plex_transient_token + with app.APP.lock_playqueues: + try: + xml = PL.get_PMS_playlist(playqueue, playqueue_id) + except exceptions.PlaylistError: + log.error('Could now download playqueue %s', playqueue_id) + return + if playqueue.id == playqueue_id: + # This seems to be happening ONLY if a Plex Companion device + # reconnects and Kodi is already playing something - silly, really + # For all other cases, a new playqueue is generated by Plex + log.debug('Update for existing playqueue detected') + return + playqueue.clear() + # Get new metadata for the playqueue first + try: + PL.get_playlist_details_from_xml(playqueue, xml) + except exceptions.PlaylistError: + log.error('Could not get playqueue ID %s', playqueue_id) + return + playqueue.repeat = 0 if not repeat else int(repeat) + playqueue.plex_transient_token = transient_token + playback.play_xml(playqueue, + xml, + offset=offset, + start_plex_id=start_plex_id) + + +def process_node(key, transient_token, offset): + """ + E.g. watch later initiated by Companion. Basically navigating Plex + """ + app.CONN.plex_transient_token = transient_token + params = { + 'mode': 'plex_node', + 'key': f'{{server}}{key}', + 'offset': offset + } + handle = f'RunPlugin(plugin://{utils.extend_url(v.ADDON_ID, params)})' + xbmc.executebuiltin(handle) + + +def process_playlist(containerKey, typus, key, offset, token): + # Get the playqueue ID + _, container_key, query = PF.ParseContainerKey(containerKey) + try: + playqueue = app.PLAYQUEUES.from_plex_type(typus) + except ValueError: + # E.g. Plex web does not supply the media type + # Still need to figure out the type (video vs. music vs. pix) + xml = PF.GetPlexMetadata(key) + try: + xml[0].attrib + except (AttributeError, IndexError, TypeError): + log.error('Could not download Plex metadata') + return + api = API(xml[0]) + playqueue = app.PLAYQUEUES.from_plex_type(api.plex_type) + if key: + _, key, _ = PF.ParseContainerKey(key) + update_playqueue_from_PMS(playqueue, + playqueue_id=container_key, + repeat=query.get('repeat'), + offset=utils.cast(int, offset), + transient_token=token, + start_plex_id=key) + + +def process_streams(plex_type, video_stream_id, audio_stream_id, + subtitle_stream_id): + """ + Plex Companion client adjusted audio or subtitle stream + """ + playqueue = app.PLAYQUEUES.from_plex_type(plex_type) + pos = js.get_position(playqueue.playlistid) + playqueue.items[pos].on_plex_stream_change(video_stream_id, + audio_stream_id, + subtitle_stream_id) + + +def process_refresh(playqueue_id): + """ + example data: {'playQueueID': '8475', 'commandID': '11'} + """ + xml = PL.get_pms_playqueue(playqueue_id) + if xml is None: + return + if len(xml) == 0: + log.debug('Empty playqueue received - clearing playqueue') + plex_type = PL.get_plextype_from_xml(xml) + if plex_type is None: + return + playqueue = app.PLAYQUEUES.from_plex_type(plex_type) + playqueue.clear() + return + playqueue = app.PLAYQUEUES.from_plex_type(xml[0].attrib['type']) + update_playqueue_from_PMS(playqueue, playqueue_id) + + +def skip_to(playqueue_item_id, key): + """ + Skip to a specific playlist position. + + Does not seem to be implemented yet by Plex! + """ + _, plex_id = PF.GetPlexKeyNumber(key) + log.debug('Skipping to playQueueItemID %s, plex_id %s', + playqueue_item_id, plex_id) + found = True + for player in list(js.get_players().values()): + playqueue = app.PLAYQUEUES[player['playerid']] + for i, item in enumerate(playqueue.items): + if item.id == playqueue_item_id: + found = True + break + else: + for i, item in enumerate(playqueue.items): + if item.plex_id == plex_id: + found = True + break + if found is True: + app.APP.player.play(playqueue.kodi_pl, None, False, i) + else: + log.error('Item not found to skip to') + + +def process_proxy_xml(cmd): + """cmd: a "Command" etree xml""" + path = cmd.get('path') + if (path == '/player/playback/playMedia' + and cmd.get('queryAddress') == 'node.plexapp.com'): + process_node(cmd.get('queryKey'), + cmd.get('queryToken'), + cmd.get('queryOffset') or 0) + elif path == '/player/playback/playMedia': + with app.APP.lock_playqueues: + process_playlist(cmd.get('queryContainerKey'), + cmd.get('queryType'), + cmd.get('queryKey'), + cmd.get('queryOffset'), + cmd.get('queryToken')) + elif path == '/player/playback/refreshPlayQueue': + with app.APP.lock_playqueues: + process_refresh(cmd.get('queryPlayQueueID')) + elif path == '/player/playback/setParameters': + if 'queryVolume' in cmd.attrib: + js.set_volume(int(cmd.get('queryVolume'))) + else: + log.error('Unknown command: %s: %s', cmd.tag, cmd.attrib) + elif path == '/player/playback/play': + js.play() + elif path == '/player/playback/pause': + js.pause() + elif path == '/player/playback/stop': + js.stop() + elif path == '/player/playback/seekTo': + js.seek_to(float(cmd.get('queryOffset', 0.0)) / 1000.0) + elif path == '/player/playback/stepForward': + js.smallforward() + elif path == '/player/playback/stepBack': + js.smallbackward() + elif path == '/player/playback/skipNext': + js.skipnext() + elif path == '/player/playback/skipPrevious': + js.skipprevious() + elif path == '/player/playback/skipTo': + skip_to(cmd.get('queryPlayQueueItemID'), cmd.get('queryKey')) + elif path == '/player/navigation/moveUp': + js.input_up() + elif path == '/player/navigation/moveDown': + js.input_down() + elif path == '/player/navigation/moveLeft': + js.input_left() + elif path == '/player/navigation/moveRight': + js.input_right() + elif path == '/player/navigation/select': + js.input_select() + elif path == '/player/navigation/home': + js.input_home() + elif path == '/player/navigation/back': + js.input_back() + elif path == '/player/playback/setStreams': + process_streams(cmd.get('queryType'), + cast(int, cmd.get('queryVideoStreamID')), + cast(int, cmd.get('queryAudioStreamID')), + cast(int, cmd.get('querySubtitleStreamID'))) + elif path == '/player/timeline/subscribe': + pass + elif path == '/player/timeline/unsubscribe': + pass + else: + log.error('Unknown Plex companion path/command: %s: %s', + cmd.tag, cmd.attrib) + return True diff --git a/resources/lib/plex_db/playlists.py b/resources/lib/plex_db/playlists.py index 57d3b085..5c318cf6 100644 --- a/resources/lib/plex_db/playlists.py +++ b/resources/lib/plex_db/playlists.py @@ -18,7 +18,7 @@ class Playlists(object): def delete_playlist(self, playlist): """ - Removes the entry for playlist [Playqueue_Object] from the Plex + Removes the entry for playlist [Playqueue()] from the Plex playlists table. Be sure to either set playlist.id or playlist.kodi_path """ diff --git a/resources/lib/plexbmchelper/__init__.py b/resources/lib/plexbmchelper/__init__.py deleted file mode 100644 index b93054b3..00000000 --- a/resources/lib/plexbmchelper/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Dummy file to make this directory a package. diff --git a/resources/lib/plexbmchelper/httppersist.py b/resources/lib/plexbmchelper/httppersist.py deleted file mode 100644 index 2c4735df..00000000 --- a/resources/lib/plexbmchelper/httppersist.py +++ /dev/null @@ -1,105 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -from logging import getLogger -import http.client -import traceback -import string -import errno -from socket import error as socket_error - -############################################################################### - -LOG = getLogger('PLEX.httppersist') - -############################################################################### - - -class RequestMgr(object): - def __init__(self): - self.conns = {} - - def getConnection(self, protocol, host, port): - conn = self.conns.get(protocol + host + str(port), False) - if not conn: - if protocol == "https": - conn = http.client.HTTPSConnection(host, port) - else: - conn = http.client.HTTPConnection(host, port) - self.conns[protocol + host + str(port)] = conn - return conn - - def closeConnection(self, protocol, host, port): - conn = self.conns.get(protocol + host + str(port), False) - if conn: - conn.close() - self.conns.pop(protocol + host + str(port), None) - - def dumpConnections(self): - for conn in list(self.conns.values()): - conn.close() - self.conns = {} - - def post(self, host, port, path, body, header={}, protocol="http"): - conn = None - try: - conn = self.getConnection(protocol, host, port) - header['Connection'] = "keep-alive" - conn.request("POST", path, body, header) - data = conn.getresponse() - if int(data.status) >= 400: - LOG.error("HTTP response error: %s" % str(data.status)) - # this should return false, but I'm hacking it since iOS - # returns 404 no matter what - return data.read() or True - else: - return data.read() or True - except socket_error as serr: - # Ignore remote close and connection refused (e.g. shutdown PKC) - if serr.errno in (errno.WSAECONNABORTED, errno.WSAECONNREFUSED): - pass - else: - LOG.error("Unable to connect to %s\nReason:" % host) - LOG.error(traceback.print_exc()) - self.conns.pop(protocol + host + str(port), None) - if conn: - conn.close() - return False - except Exception as e: - LOG.error("Exception encountered: %s", e) - # Close connection just in case - try: - conn.close() - except Exception: - pass - return False - - def getwithparams(self, host, port, path, params, header={}, - protocol="http"): - newpath = path + '?' - pairs = [] - for key in params: - pairs.append(str(key) + '=' + str(params[key])) - newpath += string.join(pairs, '&') - return self.get(host, port, newpath, header, protocol) - - def get(self, host, port, path, header={}, protocol="http"): - try: - conn = self.getConnection(protocol, host, port) - header['Connection'] = "keep-alive" - conn.request("GET", path, headers=header) - data = conn.getresponse() - if int(data.status) >= 400: - LOG.error("HTTP response error: %s", str(data.status)) - return False - else: - return data.read() or True - except socket_error as serr: - # Ignore remote close and connection refused (e.g. shutdown PKC) - if serr.errno in (errno.WSAECONNABORTED, errno.WSAECONNREFUSED): - pass - else: - LOG.error("Unable to connect to %s\nReason:", host) - LOG.error(traceback.print_exc()) - self.conns.pop(protocol + host + str(port), None) - conn.close() - return False diff --git a/resources/lib/plexbmchelper/listener.py b/resources/lib/plexbmchelper/listener.py deleted file mode 100644 index 6473bfed..00000000 --- a/resources/lib/plexbmchelper/listener.py +++ /dev/null @@ -1,238 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -""" -Plex Companion listener -""" -from logging import getLogger -from re import sub -from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler - -from .. import utils, companion, json_rpc as js, clientinfo, variables as v -from .. import app - -############################################################################### - -LOG = getLogger('PLEX.listener') - -# Hack we need in order to keep track of the open connections from Plex Web -CLIENT_DICT = {} - -############################################################################### - -RESOURCES_XML = ('%s\n' - ' \n' - '\n') % (v.XML_HEADER, - v.ADDON_NAME, - v.PLATFORM, - v.PLATFORM_VERSION) - - -class MyHandler(BaseHTTPRequestHandler): - """ - BaseHTTPRequestHandler implementation of Plex Companion listener - """ - protocol_version = 'HTTP/1.1' - - def __init__(self, *args, **kwargs): - self.serverlist = [] - super().__init__(*args, **kwargs) - - def log_message(self, format, *args): - ''' - Mute all requests, don't log 'em - ''' - pass - - def do_HEAD(self): - LOG.debug("Serving HEAD request...") - self.answer_request(0) - - def do_GET(self): - LOG.debug("Serving GET request...") - self.answer_request(1) - - def do_OPTIONS(self): - LOG.debug("Serving OPTIONS request...") - self.send_response(200) - self.send_header('Content-Length', '0') - self.send_header('X-Plex-Client-Identifier', v.PKC_MACHINE_IDENTIFIER) - self.send_header('Content-Type', 'text/plain') - self.send_header('Connection', 'close') - self.send_header('Access-Control-Max-Age', '1209600') - self.send_header('Access-Control-Allow-Origin', '*') - self.send_header('Access-Control-Allow-Methods', - 'POST, GET, OPTIONS, DELETE, PUT, HEAD') - self.send_header( - 'Access-Control-Allow-Headers', - 'x-plex-version, x-plex-platform-version, x-plex-username, ' - 'x-plex-client-identifier, x-plex-target-client-identifier, ' - 'x-plex-device-name, x-plex-platform, x-plex-product, accept, ' - 'x-plex-device, x-plex-device-screen-resolution') - self.end_headers() - - def response(self, body, headers=None, code=200): - headers = {} if headers is None else headers - self.send_response(code) - for key in headers: - self.send_header(key, headers[key]) - self.send_header('Content-Length', len(body)) - self.end_headers() - if body: - self.wfile.write(body.encode('utf-8')) - - def answer_request(self, send_data): - self.serverlist = self.server.client.getServerList() - sub_mgr = self.server.subscription_manager - - request_path = self.path[1:] - request_path = sub(r"\?.*", "", request_path) - parseresult = utils.urlparse(self.path) - paramarrays = utils.parse_qs(parseresult.query) - params = {} - for key in paramarrays: - params[key] = paramarrays[key][0] - LOG.debug("remote request_path: %s, received from %s with headers: %s", - request_path, self.client_address, self.headers.items()) - LOG.debug("params received from remote: %s", params) - sub_mgr.update_command_id(self.headers.get( - 'X-Plex-Client-Identifier', self.client_address[0]), - params.get('commandID')) - - conntype = self.headers.get('Connection', '') - if conntype.lower() == 'keep-alive': - headers = { - 'Connection': 'Keep-Alive', - 'Keep-Alive': 'timeout=20' - } - else: - headers = {'Connection': 'Close'} - - if request_path == "version": - self.response( - "PlexKodiConnect Plex Companion: Running\nVersion: %s" - % v.ADDON_VERSION, - headers) - elif request_path == "verify": - self.response("XBMC JSON connection test:\n" + js.ping(), - headers) - elif request_path == 'resources': - self.response( - RESOURCES_XML.format( - title=v.DEVICENAME, - machineIdentifier=v.PKC_MACHINE_IDENTIFIER), - clientinfo.getXArgsDeviceInfo(options=headers, - include_token=False)) - elif request_path == 'player/timeline/poll': - # Plex web does polling if connected to PKC via Companion - # Only reply if there is indeed something playing - # Otherwise, all clients seem to keep connection open - if params.get('wait') == '1': - app.APP.monitor.waitForAbort(0.95) - if self.client_address[0] not in CLIENT_DICT: - CLIENT_DICT[self.client_address[0]] = [] - tracker = CLIENT_DICT[self.client_address[0]] - tracker.append(self.client_address[1]) - while (not app.APP.is_playing and - not app.APP.monitor.abortRequested() and - sub_mgr.stop_sent_to_web and not - (len(tracker) >= 4 and - tracker[0] == self.client_address[1])): - # Keep at most 3 connections open, then drop the first one - # Doesn't need to be thread-save - # Silly stuff really - app.APP.monitor.waitForAbort(1) - # Let PKC know that we're releasing this connection - tracker.pop(0) - msg = sub_mgr.msg(js.get_players()).format( - command_id=params.get('commandID', 0)) - if sub_mgr.isplaying: - self.response( - msg, - { - 'X-Plex-Client-Identifier': v.PKC_MACHINE_IDENTIFIER, - 'X-Plex-Protocol': '1.0', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Max-Age': '1209600', - 'Access-Control-Expose-Headers': - 'X-Plex-Client-Identifier', - 'Content-Type': 'text/xml;charset=utf-8' - }.update(headers)) - elif not sub_mgr.stop_sent_to_web: - sub_mgr.stop_sent_to_web = True - LOG.debug('Signaling STOP to Plex Web') - self.response( - msg, - { - 'X-Plex-Client-Identifier': v.PKC_MACHINE_IDENTIFIER, - 'X-Plex-Protocol': '1.0', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Max-Age': '1209600', - 'Access-Control-Expose-Headers': - 'X-Plex-Client-Identifier', - 'Content-Type': 'text/xml;charset=utf-8' - }.update(headers)) - else: - # We're not playing anything yet, just reply with a 200 - self.response( - msg, - { - 'X-Plex-Client-Identifier': v.PKC_MACHINE_IDENTIFIER, - 'X-Plex-Protocol': '1.0', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Max-Age': '1209600', - 'Access-Control-Expose-Headers': - 'X-Plex-Client-Identifier', - 'Content-Type': 'text/xml;charset=utf-8' - }.update(headers)) - elif "/subscribe" in request_path: - headers['Content-Type'] = 'text/xml;charset=utf-8' - headers = clientinfo.getXArgsDeviceInfo(options=headers, - include_token=False) - self.response(v.COMPANION_OK_MESSAGE, headers) - protocol = params.get('protocol') - host = self.client_address[0] - port = params.get('port') - uuid = self.headers.get('X-Plex-Client-Identifier') - command_id = params.get('commandID', 0) - sub_mgr.add_subscriber(protocol, - host, - port, - uuid, - command_id) - elif "/unsubscribe" in request_path: - headers['Content-Type'] = 'text/xml;charset=utf-8' - headers = clientinfo.getXArgsDeviceInfo(options=headers, - include_token=False) - self.response(v.COMPANION_OK_MESSAGE, headers) - uuid = self.headers.get('X-Plex-Client-Identifier') \ - or self.client_address[0] - sub_mgr.remove_subscriber(uuid) - else: - # Throw it to companion.py - companion.process_command(request_path, params) - headers['Content-Type'] = 'text/xml;charset=utf-8' - headers = clientinfo.getXArgsDeviceInfo(options=headers, - include_token=False) - self.response(v.COMPANION_OK_MESSAGE, headers) - - -class PKCHTTPServer(ThreadingHTTPServer): - def __init__(self, client, subscription_manager, *args, **kwargs): - """ - client: Class handle to plexgdm.plexgdm. We can thus ask for an up-to- - date serverlist without instantiating anything - - same for SubscriptionMgr - """ - self.client = client - self.subscription_manager = subscription_manager - super().__init__(*args, **kwargs) diff --git a/resources/lib/plexbmchelper/plexgdm.py b/resources/lib/plexbmchelper/plexgdm.py deleted file mode 100644 index 652d1bbc..00000000 --- a/resources/lib/plexbmchelper/plexgdm.py +++ /dev/null @@ -1,314 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -""" -PlexGDM.py - Version 0.2 - -This class implements the Plex GDM (G'Day Mate) protocol to discover -local Plex Media Servers. Also allow client registration into all local -media servers. - - -This program is free software; you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation; either version 2 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program; if not, write to the Free Software -Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, -MA 02110-1301, USA. -""" -import logging -import socket -import threading -import time - -from ..downloadutils import DownloadUtils as DU -from .. import utils, app, variables as v - -############################################################################### - -log = logging.getLogger('PLEX.plexgdm') - -############################################################################### - - -class plexgdm(object): - - def __init__(self): - self.discover_message = 'M-SEARCH * HTTP/1.0' - self.client_header = '* HTTP/1.0' - self.client_data = None - self.update_sock = None - self.discover_t = None - self.register_t = None - - self._multicast_address = '239.0.0.250' - self.discover_group = (self._multicast_address, 32414) - self.client_register_group = (self._multicast_address, 32413) - self.client_update_port = int(utils.settings('companionUpdatePort')) - - self.server_list = [] - self.discovery_interval = 120 - - self._discovery_is_running = False - self._registration_is_running = False - - self.client_registered = False - self.download = DU().downloadUrl - - def clientDetails(self): - self.client_data = ( - "Content-Type: plex/media-player\n" - "Resource-Identifier: %s\n" - "Name: %s\n" - "Port: %s\n" - "Product: %s\n" - "Version: %s\n" - "Protocol: plex\n" - "Protocol-Version: 1\n" - "Protocol-Capabilities: timeline,playback,navigation," - "playqueues\n" - "Device-Class: HTPC\n" - ) % ( - v.PKC_MACHINE_IDENTIFIER, - v.DEVICENAME, - v.COMPANION_PORT, - v.ADDON_NAME, - v.ADDON_VERSION - ) - - def getClientDetails(self): - return self.client_data - - def register_as_client(self): - """ - Registers PKC's Plex Companion to the PMS - """ - try: - log.debug("Sending registration data: HELLO %s\n%s" - % (self.client_header, self.client_data)) - msg = 'HELLO {}\n{}'.format(self.client_header, self.client_data) - self.update_sock.sendto(msg.encode('utf-8'), - self.client_register_group) - log.debug('(Re-)registering PKC Plex Companion successful') - except Exception as exc: - log.error("Unable to send registration message. Error: %s", exc) - - def client_update(self): - self.update_sock = socket.socket(socket.AF_INET, - socket.SOCK_DGRAM, - socket.IPPROTO_UDP) - update_sock = self.update_sock - - # Set socket reuse, may not work on all OSs. - try: - update_sock.setsockopt(socket.SOL_SOCKET, - socket.SO_REUSEADDR, - 1) - except Exception: - pass - - # Attempt to bind to the socket to recieve and send data. If we cant - # do this, then we cannot send registration - try: - update_sock.bind(('0.0.0.0', self.client_update_port)) - except Exception: - log.error("Unable to bind to port [%s] - Plex Companion will not " - "be registered. Change the Plex Companion update port!" - % self.client_update_port) - if utils.settings('companion_show_gdm_port_warning') == 'true': - from ..windows import optionsdialog - # Plex Companion could not open the GDM port. Please change it - # in the PKC settings. - if optionsdialog.show(utils.lang(29999), - 'Port %s\n%s' % (self.client_update_port, - utils.lang(39079)), - utils.lang(30013), # Never show again - utils.lang(186)) == 0: - utils.settings('companion_show_gdm_port_warning', - value='false') - from xbmc import executebuiltin - executebuiltin( - 'Addon.OpenSettings(plugin.video.plexkodiconnect)') - return - - update_sock.setsockopt(socket.IPPROTO_IP, - socket.IP_MULTICAST_TTL, - 255) - update_sock.setsockopt(socket.IPPROTO_IP, - socket.IP_ADD_MEMBERSHIP, - socket.inet_aton( - self._multicast_address) + - socket.inet_aton('0.0.0.0')) - update_sock.setblocking(0) - - # Send initial client registration - self.register_as_client() - - # Now, listen format client discovery reguests and respond. - while self._registration_is_running: - try: - data, addr = update_sock.recvfrom(1024) - data = data.decode() - log.debug("Recieved UDP packet from [%s] containing [%s]" - % (addr, data.strip())) - except socket.error: - pass - else: - if "M-SEARCH * HTTP/1." in data: - log.debug('Detected client discovery request from %s. ' - 'Replying', addr) - message = f'HTTP/1.0 200 OK\n{self.client_data}'.encode() - try: - update_sock.sendto(message, addr) - except Exception: - log.error("Unable to send client update message") - else: - log.debug("Sent registration data HTTP/1.0 200 OK") - self.client_registered = True - app.APP.monitor.waitForAbort(0.5) - log.info("Client Update loop stopped") - # When we are finished, then send a final goodbye message to - # deregister cleanly. - log.debug("Sending registration data: BYE %s\n%s" - % (self.client_header, self.client_data)) - try: - update_sock.sendto("BYE %s\n%s" - % (self.client_header, self.client_data), - self.client_register_group) - except Exception: - log.error("Unable to send client update message") - self.client_registered = False - - def check_client_registration(self): - if not self.client_registered: - log.debug('Client has not been marked as registered') - return False - if not self.server_list: - log.info("Server list is empty. Unable to check") - return False - for server in self.server_list: - if server['uuid'] == app.CONN.machine_identifier: - media_server = server['server'] - media_port = server['port'] - scheme = server['protocol'] - break - else: - log.info("Did not find our server!") - return False - - log.debug("Checking server [%s] on port [%s]" - % (media_server, media_port)) - xml = self.download( - '%s://%s:%s/clients' % (scheme, media_server, media_port)) - try: - xml[0].attrib - except (TypeError, IndexError, AttributeError): - log.error('Could not download clients for %s' % media_server) - return False - registered = False - for client in xml: - if (client.attrib.get('machineIdentifier') == - v.PKC_MACHINE_IDENTIFIER): - registered = True - if registered: - return True - else: - log.info("Client registration not found. " - "Client data is: %s" % xml) - return False - - def getServerList(self): - return self.server_list - - def discover(self): - currServer = app.CONN.server - if not currServer: - return - currServerProt, currServerIP, currServerPort = \ - currServer.split(':') - currServerIP = currServerIP.replace('/', '') - # Currently active server was not discovered via GDM; ADD - self.server_list = [{ - 'port': currServerPort, - 'protocol': currServerProt, - 'class': None, - 'content-type': 'plex/media-server', - 'discovery': 'auto', - 'master': 1, - 'owned': '1', - 'role': 'master', - 'server': currServerIP, - 'serverName': app.CONN.server_name, - 'updated': int(time.time()), - 'uuid': app.CONN.machine_identifier, - 'version': 'irrelevant' - }] - - def setInterval(self, interval): - self.discovery_interval = interval - - def stop_all(self): - self.stop_discovery() - self.stop_registration() - - def stop_discovery(self): - if self._discovery_is_running: - log.info("Discovery shutting down") - self._discovery_is_running = False - self.discover_t.join() - del self.discover_t - else: - log.info("Discovery not running") - - def stop_registration(self): - if self._registration_is_running: - log.info("Registration shutting down") - self._registration_is_running = False - self.register_t.join() - del self.register_t - else: - log.info("Registration not running") - - def run_discovery_loop(self): - # Run initial discovery - self.discover() - - discovery_count = 0 - while self._discovery_is_running: - discovery_count += 1 - if discovery_count > self.discovery_interval: - self.discover() - discovery_count = 0 - app.APP.monitor.waitForAbort(0.5) - - def start_discovery(self, daemon=False): - if not self._discovery_is_running: - log.info("Discovery starting up") - self._discovery_is_running = True - self.discover_t = threading.Thread(target=self.run_discovery_loop) - self.discover_t.setDaemon(daemon) - self.discover_t.start() - else: - log.info("Discovery already running") - - def start_registration(self, daemon=False): - if not self._registration_is_running: - log.info("Registration starting up") - self._registration_is_running = True - self.register_t = threading.Thread(target=self.client_update) - self.register_t.setDaemon(daemon) - self.register_t.start() - else: - log.info("Registration already running") - - def start_all(self, daemon=False): - self.start_discovery(daemon) - if utils.settings('plexCompanion') == 'true': - self.start_registration(daemon) diff --git a/resources/lib/plexbmchelper/subscribers.py b/resources/lib/plexbmchelper/subscribers.py deleted file mode 100644 index 4e9767f2..00000000 --- a/resources/lib/plexbmchelper/subscribers.py +++ /dev/null @@ -1,470 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -""" -Manages getting playstate from Kodi and sending it to the PMS as well as -subscribed Plex Companion clients. -""" -from logging import getLogger -from threading import Thread - -from ..downloadutils import DownloadUtils as DU -from .. import timing -from .. import app -from .. import variables as v -from .. import json_rpc as js -from .. import playqueue as PQ - -############################################################################### -LOG = getLogger('PLEX.subscribers') -############################################################################### - -# What is Companion controllable? -CONTROLLABLE = { - v.PLEX_PLAYLIST_TYPE_VIDEO: 'playPause,stop,volume,shuffle,audioStream,' - 'subtitleStream,seekTo,skipPrevious,skipNext,' - 'stepBack,stepForward', - v.PLEX_PLAYLIST_TYPE_AUDIO: 'playPause,stop,volume,shuffle,repeat,seekTo,' - 'skipPrevious,skipNext,stepBack,stepForward', - v.PLEX_PLAYLIST_TYPE_PHOTO: 'playPause,stop,skipPrevious,skipNext' -} - -STREAM_DETAILS = { - 'video': 'currentvideostream', - 'audio': 'currentaudiostream', - 'subtitle': 'currentsubtitle' -} - -XML = ('%s\n' - ' \n' - ' \n' - ' \n' - '\n') % (v.XML_HEADER, - v.PLEX_PLAYLIST_TYPE_VIDEO, - v.PLEX_PLAYLIST_TYPE_AUDIO, - v.PLEX_PLAYLIST_TYPE_PHOTO) - -# Headers are different for Plex Companion - use these for PMS notifications -HEADERS_PMS = { - 'Connection': 'keep-alive', - 'Accept': 'text/plain, */*; q=0.01', - 'Accept-Language': 'en', - 'Accept-Encoding': 'gzip, deflate', - 'User-Agent': '%s %s (%s)' % (v.ADDON_NAME, v.ADDON_VERSION, v.DEVICE) -} - - -def params_pms(): - """ - Returns the url parameters for communicating with the PMS - """ - return { - 'X-Plex-Client-Identifier': v.PKC_MACHINE_IDENTIFIER, - 'X-Plex-Device': v.DEVICE, - 'X-Plex-Device-Name': v.DEVICENAME, - 'X-Plex-Model': v.MODEL, - 'X-Plex-Platform': v.PLATFORM, - 'X-Plex-Platform-Version': v.PLATFORM_VERSION, - 'X-Plex-Product': v.ADDON_NAME, - 'X-Plex-Version': v.ADDON_VERSION, - } - - -def headers_companion_client(): - """ - Headers are different for Plex Companion - use these for a Plex Companion - client - """ - return { - 'Content-Type': 'application/xml', - 'Connection': 'Keep-Alive', - 'X-Plex-Client-Identifier': v.PKC_MACHINE_IDENTIFIER, - 'X-Plex-Device-Name': v.DEVICENAME, - 'X-Plex-Platform': v.PLATFORM, - 'X-Plex-Platform-Version': v.PLATFORM_VERSION, - 'X-Plex-Product': v.ADDON_NAME, - 'X-Plex-Version': v.ADDON_VERSION, - 'Accept-Encoding': 'gzip, deflate', - 'Accept-Language': 'en,*' - } - - -def update_player_info(playerid): - """ - Updates all player info for playerid [int] in state.py. - """ - app.PLAYSTATE.player_states[playerid].update(js.get_player_props(playerid)) - app.PLAYSTATE.player_states[playerid]['volume'] = js.get_volume() - app.PLAYSTATE.player_states[playerid]['muted'] = js.get_muted() - - -class SubscriptionMgr(object): - """ - Manages Plex companion subscriptions - """ - def __init__(self, request_mgr, player): - self.serverlist = [] - self.subscribers = {} - self.info = {} - self.server = "" - self.protocol = "http" - self.port = "" - self.isplaying = False - self.location = 'navigation' - # In order to be able to signal a stop at the end - self.last_params = {} - self.lastplayers = {} - # In order to signal a stop to Plex Web ONCE on playback stop - self.stop_sent_to_web = True - self.request_mgr = request_mgr - - def _server_by_host(self, host): - if len(self.serverlist) == 1: - return self.serverlist[0] - for server in self.serverlist: - if (server.get('serverName') in host or - server.get('server') in host): - return server - return {} - - @staticmethod - def _get_correct_position(info, playqueue): - """ - Kodi tells us the PLAYLIST position, not PLAYQUEUE position, if the - user initiated playback of a playlist - """ - if playqueue.kodi_playlist_playback: - position = 0 - else: - position = info['position'] or 0 - return position - - def msg(self, players): - """ - Returns a timeline xml as str - (xml containing video, audio, photo player state) - """ - self.isplaying = False - self.location = 'navigation' - answ = str(XML) - timelines = { - v.PLEX_PLAYLIST_TYPE_VIDEO: None, - v.PLEX_PLAYLIST_TYPE_AUDIO: None, - v.PLEX_PLAYLIST_TYPE_PHOTO: None - } - for typus in timelines: - if players.get(v.KODI_PLAYLIST_TYPE_FROM_PLEX_PLAYLIST_TYPE[typus]) is None: - timeline = { - 'controllable': CONTROLLABLE[typus], - 'type': typus, - 'state': 'stopped' - } - else: - timeline = self._timeline_dict(players[ - v.KODI_PLAYLIST_TYPE_FROM_PLEX_PLAYLIST_TYPE[typus]], - typus) - timelines[typus] = self._dict_to_xml(timeline) - timelines.update({'command_id': '{command_id}', - 'location': self.location}) - return answ.format(**timelines) - - @staticmethod - def _dict_to_xml(dictionary): - """ - Returns the string 'key1="value1" key2="value2" ...' for dictionary - """ - answ = '' - for key, value in dictionary.items(): - answ += '%s="%s" ' % (key, value) - return answ - - def _timeline_dict(self, player, ptype): - with app.APP.lock_playqueues: - playerid = player['playerid'] - info = app.PLAYSTATE.player_states[playerid] - playqueue = PQ.PLAYQUEUES[playerid] - position = self._get_correct_position(info, playqueue) - try: - item = playqueue.items[position] - except IndexError: - # E.g. for direct path playback for single item - return { - 'controllable': CONTROLLABLE[ptype], - 'type': ptype, - 'state': 'stopped' - } - self.isplaying = True - self.stop_sent_to_web = False - if ptype in (v.PLEX_PLAYLIST_TYPE_VIDEO, - v.PLEX_PLAYLIST_TYPE_PHOTO): - self.location = 'fullScreenVideo' - pbmc_server = app.CONN.server - if pbmc_server: - (self.protocol, self.server, self.port) = pbmc_server.split(':') - self.server = self.server.replace('/', '') - status = 'paused' if int(info['speed']) == 0 else 'playing' - duration = timing.kodi_time_to_millis(info['totaltime']) - shuffle = '1' if info['shuffled'] else '0' - mute = '1' if info['muted'] is True else '0' - answ = { - 'controllable': CONTROLLABLE[ptype], - 'protocol': self.protocol, - 'address': self.server, - 'port': self.port, - 'machineIdentifier': app.CONN.machine_identifier, - 'state': status, - 'type': ptype, - 'itemType': ptype, - 'time': timing.kodi_time_to_millis(info['time']), - 'duration': duration, - 'seekRange': '0-%s' % duration, - 'shuffle': shuffle, - 'repeat': v.PLEX_REPEAT_FROM_KODI_REPEAT[info['repeat']], - 'volume': info['volume'], - 'mute': mute, - 'mediaIndex': 0, # Still to implement from here - 'partIndex': 0, - 'partCount': 1, - 'providerIdentifier': 'com.plexapp.plugins.library', - } - # Get the plex id from the PKC playqueue not info, as Kodi jumps to - # next playqueue element way BEFORE kodi monitor onplayback is - # called - if item.plex_id: - answ['key'] = '/library/metadata/%s' % item.plex_id - answ['ratingKey'] = item.plex_id - # PlayQueue stuff - if info['container_key']: - answ['containerKey'] = info['container_key'] - if (info['container_key'] is not None and - info['container_key'].startswith('/playQueues')): - answ['playQueueID'] = playqueue.id - answ['playQueueVersion'] = playqueue.version - answ['playQueueItemID'] = item.id - if playqueue.items[position].guid: - answ['guid'] = item.guid - # Temp. token set? - if app.CONN.plex_transient_token: - answ['token'] = app.CONN.plex_transient_token - elif playqueue.plex_transient_token: - answ['token'] = playqueue.plex_transient_token - # Process audio and subtitle streams - if ptype == v.PLEX_PLAYLIST_TYPE_VIDEO: - answ['videoStreamID'] = str(item.current_plex_video_stream) - answ['audioStreamID'] = str(item.current_plex_audio_stream) - # Mind the zero - meaning subs are deactivated - answ['subtitleStreamID'] = str(item.current_plex_sub_stream or 0) - return answ - - def signal_stop(self): - """ - Externally called on PKC shutdown to ensure that PKC signals a stop to - the PMS. Otherwise, PKC might be stuck at "currently playing" - """ - LOG.info('Signaling a complete stop to PMS') - # To avoid RuntimeError, don't use self.lastplayers - for playerid in (0, 1, 2): - self.last_params['state'] = 'stopped' - self._send_pms_notification(playerid, - self.last_params, - timeout=0.0001) - - def update_command_id(self, uuid, command_id): - """ - Updates the Plex Companien client with the machine identifier uuid with - command_id - """ - with app.APP.lock_subscriber: - if command_id and self.subscribers.get(uuid): - self.subscribers[uuid].command_id = int(command_id) - - def _playqueue_init_done(self, players): - """ - update_player_info() can result in values BEFORE kodi monitor is called. - Hence we'd have a missmatch between the state.PLAYER_STATES and our - playqueues. - """ - for player in list(players.values()): - info = app.PLAYSTATE.player_states[player['playerid']] - playqueue = PQ.PLAYQUEUES[player['playerid']] - position = self._get_correct_position(info, playqueue) - try: - item = playqueue.items[position] - except IndexError: - # E.g. for direct path playback for single item - return False - if item.plex_id != info['plex_id']: - # Kodi playqueue already progressed; need to wait until - # everything is loaded - return False - return True - - def notify(self): - """ - Causes PKC to tell the PMS and Plex Companion players to receive a - notification what's being played. - """ - with app.APP.lock_subscriber: - self._cleanup() - # Get all the active/playing Kodi players (video, audio, pictures) - players = js.get_players() - # Update the PKC info with what's playing on the Kodi side - for player in list(players.values()): - update_player_info(player['playerid']) - # Check whether we can use the CURRENT info or whether PKC is still - # initializing - if self._playqueue_init_done(players) is False: - LOG.debug('PKC playqueue is still initializing - skip update') - return - self._notify_server(players) - if self.subscribers: - msg = self.msg(players) - for subscriber in list(self.subscribers.values()): - subscriber.send_update(msg) - self.lastplayers = players - - def _notify_server(self, players): - for typus, player in players.items(): - self._send_pms_notification( - player['playerid'], self._get_pms_params(player['playerid'])) - try: - del self.lastplayers[typus] - except KeyError: - pass - # Process the players we have left (to signal a stop) - for player in list(self.lastplayers.values()): - self.last_params['state'] = 'stopped' - self._send_pms_notification(player['playerid'], self.last_params) - - def _get_pms_params(self, playerid): - info = app.PLAYSTATE.player_states[playerid] - playqueue = PQ.PLAYQUEUES[playerid] - position = self._get_correct_position(info, playqueue) - try: - item = playqueue.items[position] - except IndexError: - return self.last_params - status = 'paused' if int(info['speed']) == 0 else 'playing' - params = { - 'state': status, - 'ratingKey': item.plex_id, - 'key': '/library/metadata/%s' % item.plex_id, - 'time': timing.kodi_time_to_millis(info['time']), - 'duration': timing.kodi_time_to_millis(info['totaltime']) - } - if info['container_key'] is not None: - # params['containerKey'] = info['container_key'] - if info['container_key'].startswith('/playQueues/'): - # params['playQueueVersion'] = playqueue.version - # params['playQueueID'] = playqueue.id - params['playQueueItemID'] = item.id - self.last_params = params - return params - - def _send_pms_notification(self, playerid, params, timeout=None): - """ - Pass a really low timeout in seconds if shutting down Kodi and we don't - need the PMS' response - """ - serv = self._server_by_host(self.server) - playqueue = PQ.PLAYQUEUES[playerid] - xargs = params_pms() - xargs.update(params) - if app.CONN.plex_transient_token: - xargs['X-Plex-Token'] = app.CONN.plex_transient_token - elif playqueue.plex_transient_token: - xargs['X-Plex-Token'] = playqueue.plex_transient_token - elif app.ACCOUNT.pms_token: - xargs['X-Plex-Token'] = app.ACCOUNT.pms_token - url = '%s://%s:%s/:/timeline' % (serv.get('protocol', 'http'), - serv.get('server', 'localhost'), - serv.get('port', '32400')) - DU().downloadUrl(url, - authenticate=False, - parameters=xargs, - headerOverride=HEADERS_PMS, - timeout=timeout) - LOG.debug("Sent server notification with parameters: %s to %s", - xargs, url) - - def add_subscriber(self, protocol, host, port, uuid, command_id): - """ - Adds a new Plex Companion subscriber to PKC. - """ - subscriber = Subscriber(protocol, - host, - port, - uuid, - command_id, - self, - self.request_mgr) - with app.APP.lock_subscriber: - self.subscribers[subscriber.uuid] = subscriber - return subscriber - - def remove_subscriber(self, uuid): - """ - Removes a connected Plex Companion subscriber with machine identifier - uuid from PKC notifications. - (Calls the cleanup() method of the subscriber) - """ - with app.APP.lock_subscriber: - for subscriber in list(self.subscribers.values()): - if subscriber.uuid == uuid or subscriber.host == uuid: - subscriber.cleanup() - del self.subscribers[subscriber.uuid] - - def _cleanup(self): - for subscriber in list(self.subscribers.values()): - if subscriber.age > 30: - subscriber.cleanup() - del self.subscribers[subscriber.uuid] - - -class Subscriber(object): - """ - Plex Companion subscribing device - """ - def __init__(self, protocol, host, port, uuid, command_id, sub_mgr, - request_mgr): - self.protocol = protocol or "http" - self.host = host - self.port = port or 32400 - self.uuid = uuid or host - self.command_id = int(command_id) or 0 - self.age = 0 - self.sub_mgr = sub_mgr - self.request_mgr = request_mgr - - def __eq__(self, other): - return self.uuid == other.uuid - - def cleanup(self): - """ - Closes the connection to the Plex Companion client - """ - self.request_mgr.closeConnection(self.protocol, self.host, self.port) - - def send_update(self, msg): - """ - Sends msg to the Plex Companion client (via .../:/timeline) - """ - self.age += 1 - msg = msg.format(command_id=self.command_id) - LOG.debug("sending xml to subscriber uuid=%s,commandID=%i:\n%s", - self.uuid, self.command_id, msg) - url = '%s://%s:%s/:/timeline' % (self.protocol, self.host, self.port) - thread = Thread(target=self._threaded_send, args=(url, msg)) - thread.start() - - def _threaded_send(self, url, msg): - """ - Threaded POST request, because they stall due to response missing - the Content-Length header :-( - """ - response = DU().downloadUrl(url, - action_type="POST", - postBody=msg, - authenticate=False, - headerOverride=headers_companion_client()) - if response in (False, None, 401): - self.sub_mgr.remove_subscriber(self.uuid) diff --git a/resources/lib/service_entry.py b/resources/lib/service_entry.py index ce3e9678..033613e5 100644 --- a/resources/lib/service_entry.py +++ b/resources/lib/service_entry.py @@ -2,6 +2,7 @@ # -*- coding: utf-8 -*- import logging import sys + import xbmc import xbmcvfs @@ -11,14 +12,12 @@ from . import kodimonitor from . import sync, library_sync from . import websocket_client from . import plex_companion -from . import plex_functions as PF, playqueue as PQ +from . import plex_functions as PF from . import playback_starter -from . import playqueue from . import variables as v from . import app from . import loghandler from . import backgroundthread -from . import skip_plex_intro from .windows import userselect ############################################################################### @@ -34,7 +33,6 @@ WINDOW_PROPERTIES = ( class Service(object): ws = None sync = None - plexcompanion = None def __init__(self): self._init_done = False @@ -100,7 +98,6 @@ class Service(object): self.setup = None self.pms_ws = None self.alexa_ws = None - self.playqueue = None # Flags for other threads self.connection_check_running = False self.auth_running = False @@ -437,8 +434,6 @@ class Service(object): app.init() app.APP.monitor = kodimonitor.KodiMonitor() app.APP.player = xbmc.Player() - # Initialize the PKC playqueues - PQ.init_playqueues() # Server auto-detect self.setup = initialsetup.InitialSetup() @@ -448,8 +443,11 @@ class Service(object): self.pms_ws = websocket_client.get_pms_websocketapp() self.alexa_ws = websocket_client.get_alexa_websocketapp() self.sync = sync.Sync() - self.plexcompanion = plex_companion.PlexCompanion() - self.playqueue = playqueue.PlayqueueMonitor() + self.companion_playstate_mgr = plex_companion.PlaystateMgr() + if utils.settings('plexCompanion') == 'true': + self.companion_listener = plex_companion.Listener(self.companion_playstate_mgr) + else: + self.companion_listener = None # Main PKC program loop while not self.should_cancel(): @@ -548,13 +546,11 @@ class Service(object): self.startup_completed = True self.pms_ws.start() self.sync.start() - self.plexcompanion.start() - self.playqueue.start() + self.companion_playstate_mgr.start() + if self.companion_listener is not None: + self.companion_listener.start() self.alexa_ws.start() - elif app.APP.is_playing: - skip_plex_intro.check() - xbmc.sleep(200) # EXITING PKC diff --git a/resources/lib/skip_plex_intro.py b/resources/lib/skip_plex_intro.py index 3ab4cddc..ec6d76ad 100644 --- a/resources/lib/skip_plex_intro.py +++ b/resources/lib/skip_plex_intro.py @@ -32,8 +32,6 @@ def skip_intro(intros): def check(): with app.APP.lock_playqueues: - if len(app.PLAYSTATE.active_players) != 1: - return playerid = list(app.PLAYSTATE.active_players)[0] intros = app.PLAYSTATE.player_states[playerid]['intro_markers'] if not intros: diff --git a/resources/lib/utils.py b/resources/lib/utils.py index 11db93c0..9355c5b8 100644 --- a/resources/lib/utils.py +++ b/resources/lib/utils.py @@ -556,6 +556,15 @@ def reset(ask_user=True): reboot_kodi() +def log_xml(xml, logger): + """ + Logs an etree xml + """ + string = undefused_etree.tostring(xml, encoding='utf-8') + string = string.decode('utf-8') + logger('\n' + string) + + def compare_version(current, minimum): """ Returns True if current is >= then minimum. False otherwise. Returns True diff --git a/resources/lib/variables.py b/resources/lib/variables.py index 8174c5ea..ada62b23 100644 --- a/resources/lib/variables.py +++ b/resources/lib/variables.py @@ -30,8 +30,9 @@ ADDON_FOLDER = xbmcvfs.translatePath('special://home') ADDON_PROFILE = xbmcvfs.translatePath(_ADDON.getAddonInfo('profile')) # Used e.g. for json_rpc -KODI_VIDEO_PLAYER_ID = 1 KODI_AUDIO_PLAYER_ID = 0 +KODI_VIDEO_PLAYER_ID = 1 +KODI_PHOTO_PLAYER_ID = 2 KODILANGUAGE = xbmc.getLanguage(xbmc.ISO_639_1) KODIVERSION = int(xbmc.getInfoLabel("System.BuildVersion")[:2])