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']))
else:
LOG.error('Unknown parameters: %s', params)
return False
elif request_path == "player/playback/play":
js.play()
elif request_path == "player/playback/pause":
@ -119,3 +120,5 @@ def process_command(request_path, params):
})
else:
LOG.error('Unknown request path: %s', request_path)
return False
return True

View file

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

View file

@ -1,7 +1,31 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import logging
from copy import deepcopy
import requests
import xml.etree.ElementTree as etree
from .. import variables as v
from .. import utils
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):
@ -31,3 +55,263 @@ def proxy_params():
if app.ACCOUNT.pms_token:
params['X-Plex-Token'] = app.ACCOUNT.pms_token
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 -*-
from logging import getLogger
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 .webserver import ThreadedHTTPServer, CompanionHandlerClassFactory
from .plexgdm import plexgdm
from .. import json_rpc as js
from .. import variables as v
@ -22,159 +25,9 @@ log = getLogger('PLEX.companion.playstate')
TIMEOUT = (5, 5)
# What is Companion controllable?
CONTROLLABLE = {
v.PLEX_PLAYLIST_TYPE_VIDEO: 'playPause,stop,volume,shuffle,audioStream,'
'subtitleStream,seekTo,skipPrevious,skipNext,'
'stepBack,stepForward',
v.PLEX_PLAYLIST_TYPE_AUDIO: 'playPause,stop,volume,shuffle,repeat,seekTo,'
'skipPrevious,skipNext,stepBack,stepForward',
v.PLEX_PLAYLIST_TYPE_PHOTO: 'playPause,stop,skipPrevious,skipNext'
}
def split_server_uri(server):
(protocol, url, port) = server.split(':')
url = url.replace('/', '')
return (protocol, url, port)
def get_correct_position(info, playqueue):
"""
Kodi tells us the PLAYLIST position, not PLAYQUEUE position, if the
user initiated playback of a playlist
"""
if playqueue.kodi_playlist_playback:
position = 0
else:
position = info['position'] or 0
return position
def timeline_dict(playerid, typus):
with app.APP.lock_playqueues:
info = app.PLAYSTATE.player_states[playerid]
playqueue = app.PLAYQUEUES[playerid]
position = get_correct_position(info, playqueue)
try:
item = playqueue.items[position]
except IndexError:
# E.g. for direct path playback for single item
return {
'controllable': CONTROLLABLE[typus],
'type': typus,
'state': 'stopped'
}
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
# How many seconds do we wait until we check again whether we are registered
# as a GDM Plex Companion Client?
GDM_COMPANION_CHECK = 120
def update_player_info(players):
@ -197,14 +50,35 @@ class PlaystateMgr(backgroundthread.KillableThread):
"""
daemon = True
def __init__(self):
self._subscribed = False
self._command_id = None
def __init__(self, companion_enabled):
self.companion_enabled = companion_enabled
self.subscribers = dict()
self.s = None
self.t = None
self.httpd = None
self.stopped_timeline = stopped_timeline()
self.gdm = plexgdm()
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):
if self.s is None:
log.debug('Creating new requests session')
@ -216,122 +90,94 @@ class PlaystateMgr(backgroundthread.KillableThread):
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):
if self.s is not None:
try:
self.s.close()
except AttributeError:
# "thread-safety" - Just in case s was set to None in the
# meantime
pass
self.s = None
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 close_connections(self):
"""May also be called from another thread"""
self._stop_webserver()
self._close_requests_session()
self.subscribers = dict()
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)
self.pms_timeline(None, 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:
if not cmd.get('clientIdentifier'):
return
uuid = UUIDStr(cmd.get('clientIdentifier'))
with app.APP.lock_subscriber:
if cmd.get('path') == '/player/timeline/unsubscribe':
if uuid in self.subscribers:
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:
self._command_id = int(cmd.get('commandID'))
except TypeError:
del self.subscribers[UUIDStr(uuid)]
except KeyError:
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):
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)
for entry in message:
if entry.get('state') != 'stopped':
state = entry.get('state')
for subscriber in self.subscribers.values():
subscriber.send_timeline(message, state)
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
Sending the "normal", non-Companion playstate to the PMS works a bit
differently
"""
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):
req = communicate(self.s.get, url, timeout=TIMEOUT)
except requests.RequestException as error:
log.error('Could not send the PMS timeline: %s', error)
return
except SystemExit:
return
if not req.ok:
log_error(log.error, 'Failed reporting playback progress', req)
@ -342,6 +188,12 @@ class PlaystateMgr(backgroundthread.KillableThread):
for player in players.values():
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):
app.APP.register_thread(self)
log.info("----===## Starting PlaystateMgr ##===----")
@ -351,16 +203,18 @@ class PlaystateMgr(backgroundthread.KillableThread):
# Make sure we're telling the PMS that playback will stop
self.send_stop()
# Cleanup
self.close_requests_session()
self.close_connections()
app.APP.deregister_thread(self)
log.info("----===## PlaystateMgr stopped ##===----")
def _run(self):
signaled_playback_stop = True
self._start_webserver()
self.gdm.start()
last_check = timing.unix_timestamp()
while not self.should_cancel():
if self.should_suspend():
self._unsubscribe()
self.close_requests_session()
self.close_connections()
if self.wait_while_suspended():
break
# Check for Kodi playlist changes first
@ -377,6 +231,11 @@ class PlaystateMgr(backgroundthread.KillableThread):
# compare old and new playqueue
compare_playqueues(playqueue, 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
players = js.get_players()
if not players and signaled_playback_stop:
@ -384,8 +243,8 @@ class PlaystateMgr(backgroundthread.KillableThread):
continue
elif not players:
# Playback has just stopped, need to tell Plex
signaled_playback_stop = True
self.send_stop()
signaled_playback_stop = True
self.sleep(1)
continue
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
# -*- coding: utf-8 -*-
from logging import getLogger
import logging
import requests
from .processing import process_proxy_xml
from .common import proxy_headers, proxy_params, log_error
from .processing import process_command
from .common import communicate, log_error, create_requests_session, \
b_ok_message
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, 4.0)
# Max. timeout for the Listener: 2 ^ MAX_TIMEOUT
# Max. timeout for the Polling: 2 ^ MAX_TIMEOUT
# Corresponds to 2 ^ 7 = 128 seconds
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
after ~20 seconds) and listens for any commands by the PMS. Listening
@ -39,17 +35,13 @@ class Listener(backgroundthread.KillableThread):
self.s = None
self.playstate_mgr = playstate_mgr
self._sleep_timer = 0
self.ok_msg = b_ok_message()
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()
self.s = create_requests_session()
return self.s
def close_requests_session(self):
@ -67,52 +59,24 @@ class Listener(backgroundthread.KillableThread):
'Plex Companion will not work.')
self.suspend()
def _on_connection_error(self, req=None):
def _on_connection_error(self, req=None, error=None):
if 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:
self._sleep_timer += 1
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):
req = communicate(self.s.post, url, data=self.ok_msg)
except (requests.RequestException, SystemExit) as error:
log.debug('Error replying with an OK message: %s', error)
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
log_error(log.error, 'Error replying with OK message', req)
def run(self):
"""
@ -140,14 +104,19 @@ class Listener(backgroundthread.KillableThread):
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:
req = communicate(self.s.get, url, timeout=TIMEOUT)
except (requests.exceptions.ProxyError,
requests.exceptions.SSLError) as error:
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
continue
except requests.RequestException:
self._on_connection_error()
except requests.RequestException as error:
self._on_connection_error(req=None, error=error)
continue
except SystemExit:
# We need to quit PKC entirely
@ -161,11 +130,11 @@ class Listener(backgroundthread.KillableThread):
self._unauthorized()
continue
elif not req.ok:
self._on_connection_error(req)
self._on_connection_error(req=req, error=None)
continue
elif not ('content-type' in req.headers
and 'xml' in req.headers['content-type']):
self._on_connection_error(req)
self._on_connection_error(req=req, error=None)
continue
if not req.text:
@ -184,13 +153,13 @@ class Listener(backgroundthread.KillableThread):
raise IndexError()
except (utils.ParseError, IndexError):
log.error('Could not parse the PMS xml:')
self._on_connection_error()
self._on_connection_error(req=req, error=None)
continue
# Do the work
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)
if process_proxy_xml(cmd):
if process_command(cmd):
self.ok_message(cmd.get('commandID'))
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')
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"""
path = cmd.get('path')
if (path == '/player/playback/playMedia'
and cmd.get('queryAddress') == 'node.plexapp.com'):
path = cmd.get('path') if cmd is not None else path
if not path.startswith('/'):
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'),
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'))
process_playlist(params.get('containerKey'),
params.get('type'),
params.get('key'),
params.get('offset'),
params.get('token'))
elif path == '/player/playback/refreshPlayQueue':
with app.APP.lock_playqueues:
process_refresh(cmd.get('queryPlayQueueID'))
process_refresh(params.get('playQueueID'))
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)
js.set_volume(int(params.get('volume')))
elif path == '/player/playback/play':
js.play()
elif path == '/player/playback/pause':
@ -196,7 +206,7 @@ def process_proxy_xml(cmd):
elif path == '/player/playback/stop':
js.stop()
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':
js.smallforward()
elif path == '/player/playback/stepBack':
@ -206,7 +216,7 @@ def process_proxy_xml(cmd):
elif path == '/player/playback/skipPrevious':
js.skipprevious()
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':
js.input_up()
elif path == '/player/navigation/moveDown':
@ -222,15 +232,19 @@ def process_proxy_xml(cmd):
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')))
process_streams(params.get('queryType'),
cast(int, params.get('videoStreamID')),
cast(int, params.get('audioStreamID')),
cast(int, params.get('subtitleStreamID')))
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)
if cmd is None:
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

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.alexa_ws = websocket_client.get_alexa_websocketapp()
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':
self.companion_listener = plex_companion.Listener(self.companion_playstate_mgr)
self.companion_polling = plex_companion.Polling(self.companion_playstate_mgr)
else:
self.companion_listener = None
self.companion_polling = None
# Main PKC program loop
while not self.should_cancel():
@ -551,8 +552,8 @@ class Service(object):
self.pms_ws.start()
self.sync.start()
self.companion_playstate_mgr.start()
if self.companion_listener is not None:
self.companion_listener.start()
if self.companion_polling is not None:
self.companion_polling.start()
self.alexa_ws.start()
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 = "%/:=&?~#+!$,;'@()*[]"
HTTP_DAV_FTP = re.compile(r'(http(s)?|dav(s)?|(s)?ftp)://((.+):(.+)@)?([\w\.]+)(:([\d]+))?/')
def garbageCollect():
gc.collect(2)
@ -556,13 +557,15 @@ def reset(ask_user=True):
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')
string = string.decode('utf-8')
logger('\n' + string)
if LOG.isEnabledFor(loglevel):
string = undefused_etree.tostring(xml, encoding='utf8')
string = string.decode('utf-8')
logger('\n' + string)
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 = {
'off': '0',
'one': '1',

View file

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