#!/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) if 'audioStreamID' in data: index = playqueue.items[pos].kodi_stream_index( data['audioStreamID'], 'audio') app.APP.player.setAudioStream(index) elif 'subtitleStreamID' in data: if data['subtitleStreamID'] == '0': app.APP.player.showSubtitles(False) else: index = playqueue.items[pos].kodi_stream_index( data['subtitleStreamID'], 'subtitle') app.APP.player.setSubtitleStream(index) else: LOG.error('Unknown setStreams command: %s', 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()