Re-add old Plex Companion mechanism as the new one sucks
This commit is contained in:
parent
d4b26b76d4
commit
af793c335e
12 changed files with 898 additions and 359 deletions
|
@ -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
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from .polling import Listener
|
||||
from .polling import Polling
|
||||
from .playstate import PlaystateMgr
|
||||
|
|
|
@ -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__()
|
||||
|
|
|
@ -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):
|
||||
for session in (self.s, self.t):
|
||||
if session is not None:
|
||||
def _close_requests_session(self):
|
||||
if self.s is not None:
|
||||
try:
|
||||
session.close()
|
||||
self.s.close()
|
||||
except AttributeError:
|
||||
# "thread-safety" - Just in case s was set to None in the
|
||||
# meantime
|
||||
pass
|
||||
session = None
|
||||
self.s = 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.pms_timeline(None, self.stopped_timeline)
|
||||
self.companion_timeline(self.stopped_timeline)
|
||||
|
||||
def check_subscriber(self, cmd):
|
||||
if not cmd.get('clientIdentifier'):
|
||||
return
|
||||
uuid = UUIDStr(cmd.get('clientIdentifier'))
|
||||
with app.APP.lock_subscriber:
|
||||
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)
|
||||
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._command_id = int(cmd.get('commandID'))
|
||||
self.subscribers[uuid].command_id = int(cmd.get('commandID'))
|
||||
except TypeError:
|
||||
pass
|
||||
|
||||
def companion_timeline(self, message):
|
||||
if not self._subscribed:
|
||||
return
|
||||
url = f'{app.CONN.server}/player/proxy/timeline'
|
||||
self._get_requests_session_companion()
|
||||
self.t.params['commandID'] = self._command_id
|
||||
message.set('commandID', str(self._command_id))
|
||||
# Get the correct playstate
|
||||
state = 'stopped'
|
||||
for timeline in message:
|
||||
if timeline.get('state') != 'stopped':
|
||||
state = timeline.get('state')
|
||||
self.t.params['state'] = state
|
||||
# Send update
|
||||
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:
|
||||
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)
|
||||
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):
|
||||
state = 'stopped'
|
||||
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:
|
||||
|
|
210
resources/lib/plex_companion/plexgdm.py
Normal file
210
resources/lib/plex_companion/plexgdm.py
Normal 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)
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
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
|
||||
|
|
201
resources/lib/plex_companion/webserver.py
Normal file
201
resources/lib/plex_companion/webserver.py
Normal 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
|
|
@ -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:
|
||||
|
|
|
@ -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,11 +557,13 @@ 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')
|
||||
if LOG.isEnabledFor(loglevel):
|
||||
string = undefused_etree.tostring(xml, encoding='utf8')
|
||||
string = string.decode('utf-8')
|
||||
logger('\n' + string)
|
||||
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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):
|
||||
|
|
Loading…
Reference in a new issue