diff --git a/resources/lib/playlist_func.py b/resources/lib/playlist_func.py index 697823b5..f5e4a145 100644 --- a/resources/lib/playlist_func.py +++ b/resources/lib/playlist_func.py @@ -312,13 +312,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): """ @@ -457,27 +460,26 @@ 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: + kodi_index = self.kodi_stream_index(video_stream_id, 'video') 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: + kodi_index = self.kodi_stream_index(audio_stream_id, 'audio') + 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') + kodi_index = self.kodi_stream_index(subtitle_stream_id, + 'subtitle') if kodi_index: app.APP.player.setSubtitleStream(kodi_index) app.APP.player.showSubtitles(True) 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/playstate.py b/resources/lib/plex_companion/playstate.py new file mode 100644 index 00000000..853e58af --- /dev/null +++ b/resources/lib/plex_companion/playstate.py @@ -0,0 +1,390 @@ +#!/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 .. import json_rpc as js +from .. import variables as v +from .. import backgroundthread +from .. import app +from .. import timing +from .. import playqueue as PQ + + +# 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 = PQ.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 + # We will only become active if there's Kodi playback going on + 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 + # 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..b984f7f3 --- /dev/null +++ b/resources/lib/plex_companion/processing.py @@ -0,0 +1,241 @@ +#!/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 playqueue as PQ +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 = PQ.get_playqueue_from_type( + v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[typus]) + 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(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]) + 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(typus, video_stream_id, audio_stream_id, subtitle_stream_id): + """ + Plex Companion client adjusted audio or subtitle stream + """ + playqueue = PQ.get_playqueue_from_type( + v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[typus]) + 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 = 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, 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 = PQ.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/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..4cf8ac34 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 @@ -34,7 +35,6 @@ WINDOW_PROPERTIES = ( class Service(object): ws = None sync = None - plexcompanion = None def __init__(self): self._init_done = False @@ -448,7 +448,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.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 self.playqueue = playqueue.PlayqueueMonitor() # Main PKC program loop @@ -548,7 +552,9 @@ class Service(object): self.startup_completed = True self.pms_ws.start() self.sync.start() - self.plexcompanion.start() + self.companion_playstate_mgr.start() + if self.companion_listener is not None: + self.companion_listener.start() self.playqueue.start() self.alexa_ws.start() 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])