Completely new implementation for Plex Companion

This commit is contained in:
croneter 2021-10-22 08:40:25 +02:00
parent 16f3605dff
commit 2c74ddde88
15 changed files with 880 additions and 1511 deletions

View File

@ -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)

View File

@ -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()

View File

@ -0,0 +1,5 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from .polling import Listener
from .playstate import PlaystateMgr

View File

@ -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

View File

@ -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)

View File

@ -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'))

View File

@ -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

View File

@ -1 +0,0 @@
# Dummy file to make this directory a package.

View File

@ -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

View File

@ -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<MediaContainer>\n'
' <Player'
' title="{title}"'
' protocol="plex"'
' protocolVersion="1"'
' protocolCapabilities="timeline,playback,navigation,playqueues"'
' machineIdentifier="{machineIdentifier}"'
' product="%s"'
' platform="%s"'
' platformVersion="%s"'
' deviceClass="pc"/>\n'
'</MediaContainer>\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)

View File

@ -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)

View File

@ -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<MediaContainer commandID="{command_id}" location="{location}">\n'
' <Timeline {%s}/>\n'
' <Timeline {%s}/>\n'
' <Timeline {%s}/>\n'
'</MediaContainer>\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)

View File

@ -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()

View File

@ -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

View File

@ -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])