Merge pull request #1714 from croneter/py3-add-webserver

Re-add old Plex Companion mechanism as the new one sucks
This commit is contained in:
croneter 2021-11-21 14:43:05 +01:00 committed by GitHub
commit 3bac91044f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 898 additions and 359 deletions

View file

@ -80,6 +80,7 @@ def process_command(request_path, params):
js.set_volume(int(params['volume'])) js.set_volume(int(params['volume']))
else: else:
LOG.error('Unknown parameters: %s', params) LOG.error('Unknown parameters: %s', params)
return False
elif request_path == "player/playback/play": elif request_path == "player/playback/play":
js.play() js.play()
elif request_path == "player/playback/pause": elif request_path == "player/playback/pause":
@ -119,3 +120,5 @@ def process_command(request_path, params):
}) })
else: else:
LOG.error('Unknown request path: %s', request_path) LOG.error('Unknown request path: %s', request_path)
return False
return True

View file

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

View file

@ -1,7 +1,31 @@
#!/usr/bin/env python #!/usr/bin/env python
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import logging
from copy import deepcopy
import requests
import xml.etree.ElementTree as etree
from .. import variables as v from .. import variables as v
from .. import utils
from .. import app from .. import app
from .. import timing
# Disable annoying requests warnings
import requests.packages.urllib3
requests.packages.urllib3.disable_warnings()
log = logging.getLogger('PLEX.companion')
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 log_error(logger, error_message, response): def log_error(logger, error_message, response):
@ -31,3 +55,263 @@ def proxy_params():
if app.ACCOUNT.pms_token: if app.ACCOUNT.pms_token:
params['X-Plex-Token'] = app.ACCOUNT.pms_token params['X-Plex-Token'] = app.ACCOUNT.pms_token
return params return params
def player():
return {
'product': v.ADDON_NAME,
'deviceClass': 'pc',
'platform': v.PLATFORM,
'platformVersion': v.PLATFORM_VERSION,
'protocolVersion': '3',
'title': v.DEVICENAME,
'protocolCapabilities': 'timeline,playback,navigation,playqueues',
'machineIdentifier': v.PKC_MACHINE_IDENTIFIER,
}
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 create_requests_session():
s = requests.Session()
s.headers = proxy_headers()
s.verify = app.CONN.verify_ssl_cert
if app.CONN.ssl_cert_path:
s.cert = app.CONN.ssl_cert_path
s.params = proxy_params()
return s
def communicate(method, url, **kwargs):
req = method(url, **kwargs)
req.encoding = 'utf-8'
# To make sure that we release the socket, need to access content once
req.content
return req
def timeline_dict(playerid, typus):
with app.APP.lock_playqueues:
info = app.PLAYSTATE.player_states[playerid]
playqueue = app.PLAYQUEUES[playerid]
position = get_correct_position(info, playqueue)
try:
item = playqueue.items[position]
except IndexError:
# E.g. for direct path playback for single item
return {
'controllable': CONTROLLABLE[typus],
'type': typus,
'state': 'stopped'
}
if typus == v.PLEX_PLAYLIST_TYPE_VIDEO and not item.streams_initialized:
# Not ready yet to send updates
raise TypeError()
o = utils.urlparse(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': o.scheme,
'address': o.hostname,
'port': str(o.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:
item.current_kodi_video_stream = info['currentvideostream']['index']
item.current_kodi_audio_stream = info['currentaudiostream']['index']
item.current_kodi_sub_stream_enabled = info['subtitleenabled']
try:
item.current_kodi_sub_stream = info['currentsubtitle']['index']
except KeyError:
item.current_kodi_sub_stream = None
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 etree 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 b_ok_message():
"""
Returns a byte-encoded (b'') OK message XML for the PMS
"""
return etree.tostring(
etree.Element('Response', attrib={'code': '200', 'status': 'OK'}),
encoding='utf8')
class Subscriber(object):
def __init__(self, playstate_mgr, cmd=None, uuid=None, command_id=None,
url=None):
self.playstate_mgr = playstate_mgr
if cmd is not None:
self.uuid = cmd.get('clientIdentifier')
self.command_id = int(cmd.get('commandID', 0))
self.url = f'{app.CONN.server}/player/proxy/timeline'
else:
self.uuid = str(uuid)
self.command_id = command_id
self.url = f'{url}/:/timeline'
self.s = create_requests_session()
self._errors_left = 3
def __eq__(self, other):
if isinstance(other, str):
return self.uuid == other
elif isinstance(other, Subscriber):
return self.uuid == other.uuid
else:
return False
def __hash__(self):
return hash(self.uuid)
def __del__(self):
"""Make sure we are closing the Session() correctly."""
self.s.close()
def _on_error(self):
self._errors_left -= 1
if self._errors_left == 0:
log.warn('Too many issues contacting subscriber %s. Unsubscribing',
self.uuid)
self.playstate_mgr.unsubscribe(self)
def send_timeline(self, message, state):
message = deepcopy(message)
message.set('commandID', str(self.command_id + 1))
self.s.params['state'] = state
self.s.params['commandID'] = self.command_id + 1
# Send update
log.debug('Sending timeline update to %s with params %s',
self.uuid, self.s.params)
utils.log_xml(message, log.debug, logging.DEBUG)
try:
req = communicate(self.s.post,
self.url,
data=etree.tostring(message, encoding='utf8'),
timeout=TIMEOUT)
except requests.RequestException as error:
log.warn('Error sending timeline to Subscriber %s: %s: %s',
self.uuid, self.url, error)
self._on_error()
return
except SystemExit:
return
if not req.ok:
log_error(log.error,
'Unexpected Companion timeline response for player '
f'{self.uuid}: {self.url}',
req)
self._on_error()
class UUIDStr(str):
"""
Subclass of str in order to be able to compare to Subscriber objects
like this: if UUIDStr() in list(Subscriber(), Subscriber()): ...
"""
def __eq__(self, other):
if isinstance(other, Subscriber):
return self == other.uuid
else:
return super().__eq__(other)
def __hash__(self):
return super().__hash__()

View file

@ -2,10 +2,13 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from logging import getLogger from logging import getLogger
import requests import requests
import xml.etree.ElementTree as etree from threading import Thread
from .common import proxy_headers, proxy_params, log_error from .common import communicate, proxy_headers, proxy_params, log_error, \
UUIDStr, Subscriber, timeline, stopped_timeline
from .playqueue import compare_playqueues from .playqueue import compare_playqueues
from .webserver import ThreadedHTTPServer, CompanionHandlerClassFactory
from .plexgdm import plexgdm
from .. import json_rpc as js from .. import json_rpc as js
from .. import variables as v from .. import variables as v
@ -22,159 +25,9 @@ log = getLogger('PLEX.companion.playstate')
TIMEOUT = (5, 5) TIMEOUT = (5, 5)
# What is Companion controllable? # How many seconds do we wait until we check again whether we are registered
CONTROLLABLE = { # as a GDM Plex Companion Client?
v.PLEX_PLAYLIST_TYPE_VIDEO: 'playPause,stop,volume,shuffle,audioStream,' GDM_COMPANION_CHECK = 120
'subtitleStream,seekTo,skipPrevious,skipNext,'
'stepBack,stepForward',
v.PLEX_PLAYLIST_TYPE_AUDIO: 'playPause,stop,volume,shuffle,repeat,seekTo,'
'skipPrevious,skipNext,stepBack,stepForward',
v.PLEX_PLAYLIST_TYPE_PHOTO: 'playPause,stop,skipPrevious,skipNext'
}
def split_server_uri(server):
(protocol, url, port) = server.split(':')
url = url.replace('/', '')
return (protocol, url, port)
def get_correct_position(info, playqueue):
"""
Kodi tells us the PLAYLIST position, not PLAYQUEUE position, if the
user initiated playback of a playlist
"""
if playqueue.kodi_playlist_playback:
position = 0
else:
position = info['position'] or 0
return position
def timeline_dict(playerid, typus):
with app.APP.lock_playqueues:
info = app.PLAYSTATE.player_states[playerid]
playqueue = app.PLAYQUEUES[playerid]
position = get_correct_position(info, playqueue)
try:
item = playqueue.items[position]
except IndexError:
# E.g. for direct path playback for single item
return {
'controllable': CONTROLLABLE[typus],
'type': typus,
'state': 'stopped'
}
if typus == v.PLEX_PLAYLIST_TYPE_VIDEO and not item.streams_initialized:
# Not ready yet to send updates
raise TypeError()
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:
item.current_kodi_video_stream = info['currentvideostream']['index']
item.current_kodi_audio_stream = info['currentaudiostream']['index']
item.current_kodi_sub_stream_enabled = info['subtitleenabled']
try:
item.current_kodi_sub_stream = info['currentsubtitle']['index']
except KeyError:
item.current_kodi_sub_stream = None
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): def update_player_info(players):
@ -197,14 +50,35 @@ class PlaystateMgr(backgroundthread.KillableThread):
""" """
daemon = True daemon = True
def __init__(self): def __init__(self, companion_enabled):
self._subscribed = False self.companion_enabled = companion_enabled
self._command_id = None self.subscribers = dict()
self.s = None self.s = None
self.t = None self.httpd = None
self.stopped_timeline = stopped_timeline() self.stopped_timeline = stopped_timeline()
self.gdm = plexgdm()
super().__init__() super().__init__()
def _start_webserver(self):
if self.httpd is None and self.companion_enabled:
log.debug('Starting PKC Companion webserver on port %s', v.COMPANION_PORT)
server_address = ('', v.COMPANION_PORT)
HandlerClass = CompanionHandlerClassFactory(self)
self.httpd = ThreadedHTTPServer(server_address, HandlerClass)
self.httpd.timeout = 10.0
t = Thread(target=self.httpd.serve_forever)
t.start()
def _stop_webserver(self):
if self.httpd is not None:
log.debug('Shutting down PKC Companion webserver')
try:
self.httpd.shutdown()
except AttributeError:
# Ensure thread-safety
pass
self.httpd = None
def _get_requests_session(self): def _get_requests_session(self):
if self.s is None: if self.s is None:
log.debug('Creating new requests session') log.debug('Creating new requests session')
@ -216,122 +90,94 @@ class PlaystateMgr(backgroundthread.KillableThread):
self.s.params = proxy_params() self.s.params = proxy_params()
return self.s return self.s
def _get_requests_session_companion(self): def _close_requests_session(self):
if self.t is None: if self.s is not None:
log.debug('Creating new companion requests session') try:
self.t = requests.Session() self.s.close()
self.t.headers = proxy_headers() except AttributeError:
self.t.verify = app.CONN.verify_ssl_cert # "thread-safety" - Just in case s was set to None in the
if app.CONN.ssl_cert_path: # meantime
self.t.cert = app.CONN.ssl_cert_path pass
self.t.params = proxy_params() self.s = None
return self.t
def close_requests_session(self): def close_connections(self):
for session in (self.s, self.t): """May also be called from another thread"""
if session is not None: self._stop_webserver()
try: self._close_requests_session()
session.close() self.subscribers = dict()
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): def send_stop(self):
""" """
If we're still connected to a PMS, tells the PMS that playback stopped If we're still connected to a PMS, tells the PMS that playback stopped
""" """
if app.CONN.online and app.ACCOUNT.authenticated: self.pms_timeline(None, self.stopped_timeline)
# Only try to send something if we're connected self.companion_timeline(self.stopped_timeline)
self.pms_timeline(dict(), self.stopped_timeline)
self.companion_timeline(self.stopped_timeline)
def check_subscriber(self, cmd): def check_subscriber(self, cmd):
if cmd.get('path') == '/player/timeline/unsubscribe': if not cmd.get('clientIdentifier'):
log.info('Stop Plex Companion subscription') return
self._unsubscribe() uuid = UUIDStr(cmd.get('clientIdentifier'))
elif not self._subscribed: with app.APP.lock_subscriber:
log.info('Start Plex Companion subscription') if cmd.get('path') == '/player/timeline/unsubscribe':
self._subscribe(cmd) if uuid in self.subscribers:
else: log.debug('Stop Plex Companion subscription for %s', uuid)
del self.subscribers[uuid]
elif uuid not in self.subscribers:
log.debug('Start new Plex Companion subscription for %s', uuid)
self.subscribers[uuid] = Subscriber(self, cmd=cmd)
else:
try:
self.subscribers[uuid].command_id = int(cmd.get('commandID'))
except TypeError:
pass
def subscribe(self, uuid, command_id, url):
log.debug('New Plex Companion subscriber %s: %s', uuid, url)
with app.APP.lock_subscriber:
self.subscribers[UUIDStr(uuid)] = Subscriber(self,
cmd=None,
uuid=uuid,
command_id=command_id,
url=url)
def unsubscribe(self, uuid):
log.debug('Unsubscribing Plex Companion client %s', uuid)
with app.APP.lock_subscriber:
try: try:
self._command_id = int(cmd.get('commandID')) del self.subscribers[UUIDStr(uuid)]
except TypeError: except KeyError:
pass pass
def update_command_id(self, uuid, command_id):
with app.APP.lock_subscriber:
if uuid not in self.subscribers:
return False
self.subscribers[uuid].command_id = command_id
return True
def companion_timeline(self, message): 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' state = 'stopped'
for timeline in message: for entry in message:
if timeline.get('state') != 'stopped': if entry.get('state') != 'stopped':
state = timeline.get('state') state = entry.get('state')
self.t.params['state'] = state for subscriber in self.subscribers.values():
# Send update subscriber.send_timeline(message, state)
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): def pms_timeline_per_player(self, playerid, message):
""" """
Pass a really low timeout in seconds if shutting down Kodi and we don't Sending the "normal", non-Companion playstate to the PMS works a bit
need the PMS' response differently
""" """
url = f'{app.CONN.server}/:/timeline' url = f'{app.CONN.server}/:/timeline'
self._get_requests_session() self._get_requests_session()
self.s.params.update(message[playerid].attrib) self.s.params.update(message[playerid].attrib)
# Tell the PMS about our playstate progress # Tell the PMS about our playstate progress
try: try:
req = self.communicate(self.s.get, url, timeout=TIMEOUT) req = communicate(self.s.get, url, timeout=TIMEOUT)
except (requests.RequestException, SystemExit): except requests.RequestException as error:
log.error('Could not send the PMS timeline: %s', error)
return
except SystemExit:
return return
if not req.ok: if not req.ok:
log_error(log.error, 'Failed reporting playback progress', req) log_error(log.error, 'Failed reporting playback progress', req)
@ -342,6 +188,12 @@ class PlaystateMgr(backgroundthread.KillableThread):
for player in players.values(): for player in players.values():
self.pms_timeline_per_player(player['playerid'], message) self.pms_timeline_per_player(player['playerid'], message)
def wait_while_suspended(self):
should_shutdown = super().wait_while_suspended()
if not should_shutdown:
self._start_webserver()
return should_shutdown
def run(self): def run(self):
app.APP.register_thread(self) app.APP.register_thread(self)
log.info("----===## Starting PlaystateMgr ##===----") log.info("----===## Starting PlaystateMgr ##===----")
@ -351,16 +203,18 @@ class PlaystateMgr(backgroundthread.KillableThread):
# Make sure we're telling the PMS that playback will stop # Make sure we're telling the PMS that playback will stop
self.send_stop() self.send_stop()
# Cleanup # Cleanup
self.close_requests_session() self.close_connections()
app.APP.deregister_thread(self) app.APP.deregister_thread(self)
log.info("----===## PlaystateMgr stopped ##===----") log.info("----===## PlaystateMgr stopped ##===----")
def _run(self): def _run(self):
signaled_playback_stop = True signaled_playback_stop = True
self._start_webserver()
self.gdm.start()
last_check = timing.unix_timestamp()
while not self.should_cancel(): while not self.should_cancel():
if self.should_suspend(): if self.should_suspend():
self._unsubscribe() self.close_connections()
self.close_requests_session()
if self.wait_while_suspended(): if self.wait_while_suspended():
break break
# Check for Kodi playlist changes first # Check for Kodi playlist changes first
@ -377,6 +231,11 @@ class PlaystateMgr(backgroundthread.KillableThread):
# compare old and new playqueue # compare old and new playqueue
compare_playqueues(playqueue, kodi_pl) compare_playqueues(playqueue, kodi_pl)
playqueue.old_kodi_pl = list(kodi_pl) playqueue.old_kodi_pl = list(kodi_pl)
# Make sure we are registered as a player
now = timing.unix_timestamp()
if now - last_check > GDM_COMPANION_CHECK:
self.gdm.check_client_registration()
last_check = now
# Then check for Kodi playback # Then check for Kodi playback
players = js.get_players() players = js.get_players()
if not players and signaled_playback_stop: if not players and signaled_playback_stop:
@ -384,8 +243,8 @@ class PlaystateMgr(backgroundthread.KillableThread):
continue continue
elif not players: elif not players:
# Playback has just stopped, need to tell Plex # Playback has just stopped, need to tell Plex
signaled_playback_stop = True
self.send_stop() self.send_stop()
signaled_playback_stop = True
self.sleep(1) self.sleep(1)
continue continue
else: else:

View file

@ -0,0 +1,210 @@
#!/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
from ..downloadutils import DownloadUtils as DU
from .. import backgroundthread
from .. import utils, app, variables as v
log = logging.getLogger('PLEX.plexgdm')
class plexgdm(backgroundthread.KillableThread):
daemon = True
def __init__(self):
client = (
'Content-Type: plex/media-player\n'
f'Resource-Identifier: {v.PKC_MACHINE_IDENTIFIER}\n'
f'Name: {v.DEVICENAME}\n'
f'Port: {v.COMPANION_PORT}\n'
f'Product: {v.ADDON_NAME}\n'
f'Version: {v.ADDON_VERSION}\n'
'Protocol: plex\n'
'Protocol-Version: 3\n'
'Protocol-Capabilities: timeline,playback,navigation,playqueues\n'
'Device-Class: pc\n'
)
self.hello_msg = f'HELLO * HTTP/1.0\n{client}'.encode()
self.ok_msg = f'HTTP/1.0 200 OK\n{client}'.encode()
self.bye_msg = f'BYE * HTTP/1.0\n{client}'.encode()
self.socket = None
self.port = int(utils.settings('companionUpdatePort'))
self.multicast_address = '239.0.0.250'
self.client_register_group = (self.multicast_address, 32413)
super().__init__()
def on_bind_error(self):
self.socket = None
log.error('Unable to bind to port [%s] - Plex Companion will not '
'be registered. Change the Plex Companion update port!'
% self.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.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)')
def register_as_client(self):
'''
Registers PKC's Plex Companion to the PMS
'''
log.debug('Sending registration data: HELLO')
try:
self.socket.sendto(self.hello_msg, self.client_register_group)
except Exception as exc:
log.error('Unable to send registration message. Error: %s', exc)
def check_client_registration(self):
"""
Checks whetere we are registered as a Plex Companion casting target
(using the old "GDM method") on our PMS. If not, registers
"""
if self.socket is None:
return
log.debug('Checking whether we are still listed as GDM Plex Companion'
'client on our PMS')
xml = DU().downloadUrl('{server}/clients')
try:
xml[0].attrib
except (TypeError, IndexError, AttributeError):
log.error('Could not download GDM Plex Companion clients')
return False
for client in xml:
if (client.attrib.get('machineIdentifier') == v.PKC_MACHINE_IDENTIFIER):
break
else:
log.info('PKC not registered as a GDM Plex Companion client')
self.register_as_client()
def setup_socket(self):
self.socket = socket.socket(socket.AF_INET,
socket.SOCK_DGRAM,
socket.IPPROTO_UDP)
# Set socket reuse, may not work on all OSs.
try:
self.socket.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:
self.socket.bind(('0.0.0.0', self.port))
except Exception:
self.on_bind_error()
return False
self.socket.setsockopt(socket.IPPROTO_IP,
socket.IP_MULTICAST_TTL,
255)
self.socket.setsockopt(socket.IPPROTO_IP,
socket.IP_ADD_MEMBERSHIP,
socket.inet_aton(self.multicast_address) + socket.inet_aton('0.0.0.0'))
self.socket.setblocking(0)
return True
def teardown_socket(self):
'''
When we are finished, then send a final goodbye message to deregister
cleanly.
'''
if self.socket is None:
return
log.debug('Sending goodbye: BYE')
try:
self.socket.sendto(self.bye_msg, self.client_register_group)
except Exception:
log.error('Unable to send client goodbye message')
try:
self.socket.shutdown(socket.SHUT_RDWR)
except OSError:
# The server might already have closed the connection. On Windows,
# this may result in WSAEINVAL (error 10022): An invalid operation
# was attempted.
pass
finally:
self.socket.close()
self.socket = None
def reply(self, addr):
log.debug('Detected client discovery request from %s. Replying', addr)
try:
self.socket.sendto(self.ok_msg, addr)
except Exception as error:
log.error('Unable to send client update message to %s', addr)
log.error('Error encountered: %s: %s', type(error), error)
def wait_while_suspended(self):
should_shutdown = super().wait_while_suspended()
if not should_shutdown and not self.setup_socket():
raise RuntimeError('Could not bind socket to port %s' % self.port)
return should_shutdown
def run(self):
if not utils.settings('plexCompanion') == 'true':
return
log.info('----===## Starting PlexGDM client ##===----')
app.APP.register_thread(self)
try:
self._run()
finally:
self.teardown_socket()
app.APP.deregister_thread(self)
log.info('----===## Stopping PlexGDM client ##===----')
def _run(self):
if not self.setup_socket():
return
# Send initial client registration
self.register_as_client()
# Listen for Plex Companion client discovery reguests and respond
while not self.should_cancel():
if self.should_suspend():
self.teardown_socket()
if self.wait_while_suspended():
break
try:
data, addr = self.socket.recvfrom(1024)
except socket.error:
pass
else:
data = data.decode()
log.debug('Received UDP packet from [%s] containing [%s]'
% (addr, data.strip()))
if 'M-SEARCH * HTTP/1.' in data:
self.reply(addr)
self.sleep(0.5)

View file

@ -1,33 +1,29 @@
#!/usr/bin/env python #!/usr/bin/env python
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from logging import getLogger import logging
import requests import requests
from .processing import process_proxy_xml from .processing import process_command
from .common import proxy_headers, proxy_params, log_error from .common import communicate, log_error, create_requests_session, \
b_ok_message
from .. import utils from .. import utils
from .. import backgroundthread from .. import backgroundthread
from .. import app 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) # Timeout (connection timeout, read timeout)
# The later is up to 20 seconds, if the PMS has nothing to tell us # The later is up to 20 seconds, if the PMS has nothing to tell us
# THIS WILL PREVENT PKC FROM SHUTTING DOWN CORRECTLY # THIS WILL PREVENT PKC FROM SHUTTING DOWN CORRECTLY
TIMEOUT = (5.0, 4.0) TIMEOUT = (5.0, 4.0)
# Max. timeout for the Listener: 2 ^ MAX_TIMEOUT # Max. timeout for the Polling: 2 ^ MAX_TIMEOUT
# Corresponds to 2 ^ 7 = 128 seconds # Corresponds to 2 ^ 7 = 128 seconds
MAX_TIMEOUT = 7 MAX_TIMEOUT = 7
log = getLogger('PLEX.companion.listener') log = logging.getLogger('PLEX.companion.polling')
class Listener(backgroundthread.KillableThread): class Polling(backgroundthread.KillableThread):
""" """
Opens a GET HTTP connection to the current PMS (that will time-out PMS-wise 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 after ~20 seconds) and listens for any commands by the PMS. Listening
@ -39,17 +35,13 @@ class Listener(backgroundthread.KillableThread):
self.s = None self.s = None
self.playstate_mgr = playstate_mgr self.playstate_mgr = playstate_mgr
self._sleep_timer = 0 self._sleep_timer = 0
self.ok_msg = b_ok_message()
super().__init__() super().__init__()
def _get_requests_session(self): def _get_requests_session(self):
if self.s is None: if self.s is None:
log.debug('Creating new requests session') log.debug('Creating new requests session')
self.s = requests.Session() self.s = create_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 return self.s
def close_requests_session(self): def close_requests_session(self):
@ -67,52 +59,24 @@ class Listener(backgroundthread.KillableThread):
'Plex Companion will not work.') 'Plex Companion will not work.')
self.suspend() self.suspend()
def _on_connection_error(self, req=None): def _on_connection_error(self, req=None, error=None):
if req: if req:
log_error(log.error, 'Error while contacting the PMS', req) log_error(log.error, 'Error while contacting the PMS', req)
self.sleep(2 ^ self._sleep_timer) if error:
log.error('Error encountered: %s: %s', type(error), error)
self.sleep(2 ** self._sleep_timer)
if self._sleep_timer < MAX_TIMEOUT: if self._sleep_timer < MAX_TIMEOUT:
self._sleep_timer += 1 self._sleep_timer += 1
def ok_message(self, command_id): def ok_message(self, command_id):
url = f'{app.CONN.server}/player/proxy/response?commandID={command_id}' url = f'{app.CONN.server}/player/proxy/response?commandID={command_id}'
try: try:
req = self.communicate(self.s.post, req = communicate(self.s.post, url, data=self.ok_msg)
url, except (requests.RequestException, SystemExit) as error:
data=v.COMPANION_OK_MESSAGE.encode('utf-8')) log.debug('Error replying with an OK message: %s', error)
except (requests.RequestException, SystemExit):
return return
if not req.ok: if not req.ok:
log_error(log.error, 'Error replying OK', req) log_error(log.error, 'Error replying with OK message', 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): def run(self):
""" """
@ -140,14 +104,19 @@ class Listener(backgroundthread.KillableThread):
url = app.CONN.server + '/player/proxy/poll?timeout=1' url = app.CONN.server + '/player/proxy/poll?timeout=1'
self._get_requests_session() self._get_requests_session()
try: try:
req = self.communicate(self.s.get, req = communicate(self.s.get, url, timeout=TIMEOUT)
url, except (requests.exceptions.ProxyError,
timeout=TIMEOUT) requests.exceptions.SSLError) as error:
except requests.ConnectionError: self._on_connection_error(req=None, error=error)
continue
except (requests.ConnectionError,
requests.Timeout,
requests.exceptions.ChunkedEncodingError):
# Expected due to timeout and the PMS not having to reply
# No command received from the PMS - try again immediately # No command received from the PMS - try again immediately
continue continue
except requests.RequestException: except requests.RequestException as error:
self._on_connection_error() self._on_connection_error(req=None, error=error)
continue continue
except SystemExit: except SystemExit:
# We need to quit PKC entirely # We need to quit PKC entirely
@ -161,11 +130,11 @@ class Listener(backgroundthread.KillableThread):
self._unauthorized() self._unauthorized()
continue continue
elif not req.ok: elif not req.ok:
self._on_connection_error(req) self._on_connection_error(req=req, error=None)
continue continue
elif not ('content-type' in req.headers elif not ('content-type' in req.headers
and 'xml' in req.headers['content-type']): and 'xml' in req.headers['content-type']):
self._on_connection_error(req) self._on_connection_error(req=req, error=None)
continue continue
if not req.text: if not req.text:
@ -184,13 +153,13 @@ class Listener(backgroundthread.KillableThread):
raise IndexError() raise IndexError()
except (utils.ParseError, IndexError): except (utils.ParseError, IndexError):
log.error('Could not parse the PMS xml:') log.error('Could not parse the PMS xml:')
self._on_connection_error() self._on_connection_error(req=req, error=None)
continue continue
# Do the work # Do the work
log.debug('Received a Plex Companion command from the PMS:') log.debug('Received a Plex Companion command from the PMS:')
utils.log_xml(xml, log.debug) utils.log_xml(xml, log.debug, logging.DEBUG)
self.playstate_mgr.check_subscriber(cmd) self.playstate_mgr.check_subscriber(cmd)
if process_proxy_xml(cmd): if process_command(cmd):
self.ok_message(cmd.get('commandID')) self.ok_message(cmd.get('commandID'))
self._sleep_timer = 0 self._sleep_timer = 0

View file

@ -166,29 +166,39 @@ def skip_to(playqueue_item_id, key):
log.error('Item not found to skip to') log.error('Item not found to skip to')
def process_proxy_xml(cmd): def convert_xml_to_params(xml):
new_params = dict(xml.attrib)
for key in xml.attrib:
if key.startswith('query'):
new_params[key[5].lower() + key[6:]] = xml.get(key)
del new_params[key]
return new_params
def process_command(cmd=None, path=None, params=None):
"""cmd: a "Command" etree xml""" """cmd: a "Command" etree xml"""
path = cmd.get('path') path = cmd.get('path') if cmd is not None else path
if (path == '/player/playback/playMedia' if not path.startswith('/'):
and cmd.get('queryAddress') == 'node.plexapp.com'): path = '/' + path
if params is None:
params = convert_xml_to_params(cmd)
if path == '/player/playback/playMedia' and \
params.get('address') == 'node.plexapp.com':
process_node(cmd.get('queryKey'), process_node(cmd.get('queryKey'),
cmd.get('queryToken'), cmd.get('queryToken'),
cmd.get('queryOffset') or 0) cmd.get('queryOffset') or 0)
elif path == '/player/playback/playMedia': elif path == '/player/playback/playMedia':
with app.APP.lock_playqueues: with app.APP.lock_playqueues:
process_playlist(cmd.get('queryContainerKey'), process_playlist(params.get('containerKey'),
cmd.get('queryType'), params.get('type'),
cmd.get('queryKey'), params.get('key'),
cmd.get('queryOffset'), params.get('offset'),
cmd.get('queryToken')) params.get('token'))
elif path == '/player/playback/refreshPlayQueue': elif path == '/player/playback/refreshPlayQueue':
with app.APP.lock_playqueues: with app.APP.lock_playqueues:
process_refresh(cmd.get('queryPlayQueueID')) process_refresh(params.get('playQueueID'))
elif path == '/player/playback/setParameters': elif path == '/player/playback/setParameters':
if 'queryVolume' in cmd.attrib: js.set_volume(int(params.get('volume')))
js.set_volume(int(cmd.get('queryVolume')))
else:
log.error('Unknown command: %s: %s', cmd.tag, cmd.attrib)
elif path == '/player/playback/play': elif path == '/player/playback/play':
js.play() js.play()
elif path == '/player/playback/pause': elif path == '/player/playback/pause':
@ -196,7 +206,7 @@ def process_proxy_xml(cmd):
elif path == '/player/playback/stop': elif path == '/player/playback/stop':
js.stop() js.stop()
elif path == '/player/playback/seekTo': elif path == '/player/playback/seekTo':
js.seek_to(float(cmd.get('queryOffset', 0.0)) / 1000.0) js.seek_to(float(params.get('offset', 0.0)) / 1000.0)
elif path == '/player/playback/stepForward': elif path == '/player/playback/stepForward':
js.smallforward() js.smallforward()
elif path == '/player/playback/stepBack': elif path == '/player/playback/stepBack':
@ -206,7 +216,7 @@ def process_proxy_xml(cmd):
elif path == '/player/playback/skipPrevious': elif path == '/player/playback/skipPrevious':
js.skipprevious() js.skipprevious()
elif path == '/player/playback/skipTo': elif path == '/player/playback/skipTo':
skip_to(cmd.get('queryPlayQueueItemID'), cmd.get('queryKey')) skip_to(params.get('playQueueItemID'), params.get('key'))
elif path == '/player/navigation/moveUp': elif path == '/player/navigation/moveUp':
js.input_up() js.input_up()
elif path == '/player/navigation/moveDown': elif path == '/player/navigation/moveDown':
@ -222,15 +232,19 @@ def process_proxy_xml(cmd):
elif path == '/player/navigation/back': elif path == '/player/navigation/back':
js.input_back() js.input_back()
elif path == '/player/playback/setStreams': elif path == '/player/playback/setStreams':
process_streams(cmd.get('queryType'), process_streams(params.get('queryType'),
cast(int, cmd.get('queryVideoStreamID')), cast(int, params.get('videoStreamID')),
cast(int, cmd.get('queryAudioStreamID')), cast(int, params.get('audioStreamID')),
cast(int, cmd.get('querySubtitleStreamID'))) cast(int, params.get('subtitleStreamID')))
elif path == '/player/timeline/subscribe': elif path == '/player/timeline/subscribe':
pass pass
elif path == '/player/timeline/unsubscribe': elif path == '/player/timeline/unsubscribe':
pass pass
else: else:
log.error('Unknown Plex companion path/command: %s: %s', if cmd is None:
cmd.tag, cmd.attrib) log.error('Unknown request_path: %s with params %s', path, params)
else:
log.error('Unknown Plex companion path/command: %s: %s',
cmd.tag, cmd.attrib)
return False
return True return True

View file

@ -0,0 +1,201 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Plex Companion listener
"""
from logging import getLogger
from re import sub
from socketserver import ThreadingMixIn
from http.server import HTTPServer, BaseHTTPRequestHandler
import xml.etree.ElementTree as etree
from . import common
from .processing import process_command
from .. import utils, variables as v
from .. import json_rpc as js
from .. import app
log = getLogger('PLEX.companion.webserver')
def CompanionHandlerClassFactory(playstate_mgr):
"""
This class factory makes playstate_mgr available for CompanionHandler
"""
class CompanionHandler(BaseHTTPRequestHandler):
"""
BaseHTTPRequestHandler implementation of Plex Companion listener
"""
protocol_version = 'HTTP/1.1'
def __init__(self, *args, **kwargs):
self.ok_msg = common.b_ok_message()
self.sending_headers = common.proxy_headers()
super().__init__(*args, **kwargs)
def log_message(self, *args, **kwargs):
"""Mute all requests, don't log them."""
pass
def handle_one_request(self):
try:
super().handle_one_request()
except ConnectionError as error:
# Catches e.g. ConnectionResetError: [WinError 10054]
log.debug('Silencing error: %s: %s', type(error), error)
self.close_connection = True
except Exception as error:
# Catch anything in order to not let our web server crash
log.error('Webserver ignored the following exception: %s, %s',
type(error), error)
self.close_connection = True
def do_HEAD(self):
log.debug("Serving HEAD request...")
self.answer_request()
def do_GET(self):
log.debug("Serving GET request...")
self.answer_request()
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, code=200):
self.send_response(code)
for key, value in self.sending_headers.items():
self.send_header(key, value)
self.send_header('Content-Length', len(body) if body else 0)
self.end_headers()
if body:
self.wfile.write(body)
def ok_message(self):
self.response(self.ok_msg, code=200)
def nok_message(self, error_message, code):
log.warn('Sending Not OK message: %s', error_message)
self.response(f'Failure: {error_message}'.encode('utf8'),
code=code)
def poll(self, params):
"""
Case for Plex Web contacting us via Plex Companion
Let's NOT register this Companion client - it will poll us
continuously
"""
if params.get('wait') == '1':
# Plex Web asks us to wait until we start playback
i = 20
while not app.APP.is_playing and i > 0:
if app.APP.monitor.waitForAbort(1):
return
i -= 1
message = common.timeline(js.get_players())
self.response(etree.tostring(message, encoding='utf8'),
code=200)
def send_resources_xml(self):
xml = etree.Element('MediaContainer', attrib={'size': '1'})
etree.SubElement(xml, 'Player', attrib=common.player())
self.response(etree.tostring(xml, encoding='utf8'), code=200)
def check_subscription(self, params):
if self.uuid in playstate_mgr.subscribers:
return True
protocol = params.get('protocol')
port = params.get('port')
if protocol is None or port is None:
log.error('Received invalid params for subscription: %s',
params)
return False
url = f"{protocol}://{self.client_address[0]}:{port}"
playstate_mgr.subscribe(self.uuid, self.command_id, url)
return True
def answer_request(self):
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. headers: %s',
request_path, self.client_address, self.headers.items())
log.debug('params received from remote: %s', params)
conntype = self.headers.get('Connection', '')
if conntype.lower() == 'keep-alive':
self.sending_headers['Connection'] = 'Keep-Alive'
self.sending_headers['Keep-Alive'] = 'timeout=20'
else:
self.sending_headers['Connection'] = 'Close'
self.command_id = int(params.get('commandID', 0))
uuid = self.headers.get('X-Plex-Client-Identifier')
if uuid is None:
log.error('No X-Plex-Client-Identifier received')
self.nok_message('No X-Plex-Client-Identifier received',
code=400)
return
self.uuid = common.UUIDStr(uuid)
# Here we DO NOT track subscribers
if request_path == 'player/timeline/poll':
# This seems to be only done by Plex Web, polling us
# continuously
self.poll(params)
return
elif request_path == 'resources':
self.send_resources_xml()
return
# Here we TRACK subscribers
if request_path == 'player/timeline/subscribe':
if self.check_subscription(params):
self.ok_message()
else:
self.nok_message(f'Received invalid parameters: {params}',
code=400)
return
elif request_path == 'player/timeline/unsubscribe':
playstate_mgr.unsubscribe(self.uuid)
self.ok_message()
else:
if not playstate_mgr.update_command_id(self.uuid,
self.command_id):
self.nok_message(f'Plex Companion Client not yet registered',
code=500)
return
if process_command(cmd=None, path=request_path, params=params):
self.ok_message()
else:
self.nok_message(f'Unknown request path: {request_path}',
code=500)
return CompanionHandler
class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
"""
Using ThreadingMixIn Thread magic
"""
daemon_threads = True

View file

@ -444,11 +444,12 @@ class Service(object):
self.pms_ws = websocket_client.get_pms_websocketapp() self.pms_ws = websocket_client.get_pms_websocketapp()
self.alexa_ws = websocket_client.get_alexa_websocketapp() self.alexa_ws = websocket_client.get_alexa_websocketapp()
self.sync = sync.Sync() self.sync = sync.Sync()
self.companion_playstate_mgr = plex_companion.PlaystateMgr() self.companion_playstate_mgr = plex_companion.PlaystateMgr(
companion_enabled=utils.settings('plexCompanion') == 'true')
if utils.settings('plexCompanion') == 'true': if utils.settings('plexCompanion') == 'true':
self.companion_listener = plex_companion.Listener(self.companion_playstate_mgr) self.companion_polling = plex_companion.Polling(self.companion_playstate_mgr)
else: else:
self.companion_listener = None self.companion_polling = None
# Main PKC program loop # Main PKC program loop
while not self.should_cancel(): while not self.should_cancel():
@ -551,8 +552,8 @@ class Service(object):
self.pms_ws.start() self.pms_ws.start()
self.sync.start() self.sync.start()
self.companion_playstate_mgr.start() self.companion_playstate_mgr.start()
if self.companion_listener is not None: if self.companion_polling is not None:
self.companion_listener.start() self.companion_polling.start()
self.alexa_ws.start() self.alexa_ws.start()
elif app.APP.is_playing: elif app.APP.is_playing:

View file

@ -55,6 +55,7 @@ REGEX_PLEX_ID_FROM_URL = re.compile(r'''metadata%2F(\d+)''')
SAFE_URL_CHARACTERS = "%/:=&?~#+!$,;'@()*[]" SAFE_URL_CHARACTERS = "%/:=&?~#+!$,;'@()*[]"
HTTP_DAV_FTP = re.compile(r'(http(s)?|dav(s)?|(s)?ftp)://((.+):(.+)@)?([\w\.]+)(:([\d]+))?/') HTTP_DAV_FTP = re.compile(r'(http(s)?|dav(s)?|(s)?ftp)://((.+):(.+)@)?([\w\.]+)(:([\d]+))?/')
def garbageCollect(): def garbageCollect():
gc.collect(2) gc.collect(2)
@ -556,13 +557,15 @@ def reset(ask_user=True):
reboot_kodi() reboot_kodi()
def log_xml(xml, logger): def log_xml(xml, logger, loglevel):
""" """
Logs an etree xml Logs an etree xml. Pass the loglevel for which logging will happen, e.g.
loglevel=logging.DEBUG
""" """
string = undefused_etree.tostring(xml, encoding='utf-8') if LOG.isEnabledFor(loglevel):
string = string.decode('utf-8') string = undefused_etree.tostring(xml, encoding='utf8')
logger('\n' + string) string = string.decode('utf-8')
logger('\n' + string)
def compare_version(current, minimum): def compare_version(current, minimum):

View file

@ -656,10 +656,6 @@ SORT_METHODS_ALBUMS = (
) )
XML_HEADER = '<?xml version="1.0" encoding="UTF-8"?>\n'
COMPANION_OK_MESSAGE = XML_HEADER + '<Response code="200" status="OK" />'
PLEX_REPEAT_FROM_KODI_REPEAT = { PLEX_REPEAT_FROM_KODI_REPEAT = {
'off': '0', 'off': '0',
'one': '1', 'one': '1',

View file

@ -126,7 +126,6 @@ def on_error(ws, error):
ws.name, type(error), error) ws.name, type(error), error)
# Status = Error # Status = Error
utils.settings(status, value=utils.lang(257)) utils.settings(status, value=utils.lang(257))
raise RuntimeError
def on_close(ws): def on_close(ws):