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:
commit
3bac91044f
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']))
|
js.set_volume(int(params['volume']))
|
||||||
else:
|
else:
|
||||||
LOG.error('Unknown parameters: %s', params)
|
LOG.error('Unknown parameters: %s', params)
|
||||||
|
return False
|
||||||
elif request_path == "player/playback/play":
|
elif request_path == "player/playback/play":
|
||||||
js.play()
|
js.play()
|
||||||
elif request_path == "player/playback/pause":
|
elif request_path == "player/playback/pause":
|
||||||
|
@ -119,3 +120,5 @@ def process_command(request_path, params):
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
LOG.error('Unknown request path: %s', request_path)
|
LOG.error('Unknown request path: %s', request_path)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
from .polling import Listener
|
from .polling import Polling
|
||||||
from .playstate import PlaystateMgr
|
from .playstate import PlaystateMgr
|
||||||
|
|
|
@ -1,7 +1,31 @@
|
||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
import logging
|
||||||
|
from copy import deepcopy
|
||||||
|
import requests
|
||||||
|
import xml.etree.ElementTree as etree
|
||||||
|
|
||||||
from .. import variables as v
|
from .. import variables as v
|
||||||
|
from .. import utils
|
||||||
from .. import app
|
from .. import app
|
||||||
|
from .. import timing
|
||||||
|
|
||||||
|
# Disable annoying requests warnings
|
||||||
|
import requests.packages.urllib3
|
||||||
|
requests.packages.urllib3.disable_warnings()
|
||||||
|
|
||||||
|
log = logging.getLogger('PLEX.companion')
|
||||||
|
|
||||||
|
TIMEOUT = (5, 5)
|
||||||
|
|
||||||
|
# What is Companion controllable?
|
||||||
|
CONTROLLABLE = {
|
||||||
|
v.PLEX_PLAYLIST_TYPE_VIDEO: 'playPause,stop,volume,shuffle,audioStream,'
|
||||||
|
'subtitleStream,seekTo,skipPrevious,skipNext,stepBack,stepForward',
|
||||||
|
v.PLEX_PLAYLIST_TYPE_AUDIO: 'playPause,stop,volume,shuffle,repeat,seekTo,'
|
||||||
|
'skipPrevious,skipNext,stepBack,stepForward',
|
||||||
|
v.PLEX_PLAYLIST_TYPE_PHOTO: 'playPause,stop,skipPrevious,skipNext'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def log_error(logger, error_message, response):
|
def log_error(logger, error_message, response):
|
||||||
|
@ -31,3 +55,263 @@ def proxy_params():
|
||||||
if app.ACCOUNT.pms_token:
|
if app.ACCOUNT.pms_token:
|
||||||
params['X-Plex-Token'] = app.ACCOUNT.pms_token
|
params['X-Plex-Token'] = app.ACCOUNT.pms_token
|
||||||
return params
|
return params
|
||||||
|
|
||||||
|
|
||||||
|
def player():
|
||||||
|
return {
|
||||||
|
'product': v.ADDON_NAME,
|
||||||
|
'deviceClass': 'pc',
|
||||||
|
'platform': v.PLATFORM,
|
||||||
|
'platformVersion': v.PLATFORM_VERSION,
|
||||||
|
'protocolVersion': '3',
|
||||||
|
'title': v.DEVICENAME,
|
||||||
|
'protocolCapabilities': 'timeline,playback,navigation,playqueues',
|
||||||
|
'machineIdentifier': v.PKC_MACHINE_IDENTIFIER,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_correct_position(info, playqueue):
|
||||||
|
"""
|
||||||
|
Kodi tells us the PLAYLIST position, not PLAYQUEUE position, if the
|
||||||
|
user initiated playback of a playlist
|
||||||
|
"""
|
||||||
|
if playqueue.kodi_playlist_playback:
|
||||||
|
position = 0
|
||||||
|
else:
|
||||||
|
position = info['position'] or 0
|
||||||
|
return position
|
||||||
|
|
||||||
|
|
||||||
|
def create_requests_session():
|
||||||
|
s = requests.Session()
|
||||||
|
s.headers = proxy_headers()
|
||||||
|
s.verify = app.CONN.verify_ssl_cert
|
||||||
|
if app.CONN.ssl_cert_path:
|
||||||
|
s.cert = app.CONN.ssl_cert_path
|
||||||
|
s.params = proxy_params()
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def communicate(method, url, **kwargs):
|
||||||
|
req = method(url, **kwargs)
|
||||||
|
req.encoding = 'utf-8'
|
||||||
|
# To make sure that we release the socket, need to access content once
|
||||||
|
req.content
|
||||||
|
return req
|
||||||
|
|
||||||
|
|
||||||
|
def timeline_dict(playerid, typus):
|
||||||
|
with app.APP.lock_playqueues:
|
||||||
|
info = app.PLAYSTATE.player_states[playerid]
|
||||||
|
playqueue = app.PLAYQUEUES[playerid]
|
||||||
|
position = get_correct_position(info, playqueue)
|
||||||
|
try:
|
||||||
|
item = playqueue.items[position]
|
||||||
|
except IndexError:
|
||||||
|
# E.g. for direct path playback for single item
|
||||||
|
return {
|
||||||
|
'controllable': CONTROLLABLE[typus],
|
||||||
|
'type': typus,
|
||||||
|
'state': 'stopped'
|
||||||
|
}
|
||||||
|
if typus == v.PLEX_PLAYLIST_TYPE_VIDEO and not item.streams_initialized:
|
||||||
|
# Not ready yet to send updates
|
||||||
|
raise TypeError()
|
||||||
|
o = utils.urlparse(app.CONN.server)
|
||||||
|
status = 'paused' if int(info['speed']) == 0 else 'playing'
|
||||||
|
duration = timing.kodi_time_to_millis(info['totaltime'])
|
||||||
|
shuffle = '1' if info['shuffled'] else '0'
|
||||||
|
mute = '1' if info['muted'] is True else '0'
|
||||||
|
answ = {
|
||||||
|
'controllable': CONTROLLABLE[typus],
|
||||||
|
'protocol': o.scheme,
|
||||||
|
'address': o.hostname,
|
||||||
|
'port': str(o.port),
|
||||||
|
'machineIdentifier': app.CONN.machine_identifier,
|
||||||
|
'state': status,
|
||||||
|
'type': typus,
|
||||||
|
'itemType': typus,
|
||||||
|
'time': str(timing.kodi_time_to_millis(info['time'])),
|
||||||
|
'duration': str(duration),
|
||||||
|
'seekRange': '0-%s' % duration,
|
||||||
|
'shuffle': shuffle,
|
||||||
|
'repeat': v.PLEX_REPEAT_FROM_KODI_REPEAT[info['repeat']],
|
||||||
|
'volume': str(info['volume']),
|
||||||
|
'mute': mute,
|
||||||
|
'mediaIndex': '0', # Still to implement
|
||||||
|
'partIndex': '0',
|
||||||
|
'partCount': '1',
|
||||||
|
'providerIdentifier': 'com.plexapp.plugins.library',
|
||||||
|
}
|
||||||
|
# Get the plex id from the PKC playqueue not info, as Kodi jumps to
|
||||||
|
# next playqueue element way BEFORE kodi monitor onplayback is
|
||||||
|
# called
|
||||||
|
if item.plex_id:
|
||||||
|
answ['key'] = '/library/metadata/%s' % item.plex_id
|
||||||
|
answ['ratingKey'] = str(item.plex_id)
|
||||||
|
# PlayQueue stuff
|
||||||
|
if info['container_key']:
|
||||||
|
answ['containerKey'] = info['container_key']
|
||||||
|
if (info['container_key'] is not None and
|
||||||
|
info['container_key'].startswith('/playQueues')):
|
||||||
|
answ['playQueueID'] = str(playqueue.id)
|
||||||
|
answ['playQueueVersion'] = str(playqueue.version)
|
||||||
|
answ['playQueueItemID'] = str(item.id)
|
||||||
|
if playqueue.items[position].guid:
|
||||||
|
answ['guid'] = item.guid
|
||||||
|
# Temp. token set?
|
||||||
|
if app.CONN.plex_transient_token:
|
||||||
|
answ['token'] = app.CONN.plex_transient_token
|
||||||
|
elif playqueue.plex_transient_token:
|
||||||
|
answ['token'] = playqueue.plex_transient_token
|
||||||
|
# Process audio and subtitle streams
|
||||||
|
if typus == v.PLEX_PLAYLIST_TYPE_VIDEO:
|
||||||
|
item.current_kodi_video_stream = info['currentvideostream']['index']
|
||||||
|
item.current_kodi_audio_stream = info['currentaudiostream']['index']
|
||||||
|
item.current_kodi_sub_stream_enabled = info['subtitleenabled']
|
||||||
|
try:
|
||||||
|
item.current_kodi_sub_stream = info['currentsubtitle']['index']
|
||||||
|
except KeyError:
|
||||||
|
item.current_kodi_sub_stream = None
|
||||||
|
answ['videoStreamID'] = str(item.current_plex_video_stream)
|
||||||
|
answ['audioStreamID'] = str(item.current_plex_audio_stream)
|
||||||
|
# Mind the zero - meaning subs are deactivated
|
||||||
|
answ['subtitleStreamID'] = str(item.current_plex_sub_stream or 0)
|
||||||
|
return answ
|
||||||
|
|
||||||
|
|
||||||
|
def timeline(players):
|
||||||
|
"""
|
||||||
|
Returns a timeline xml as str
|
||||||
|
(xml containing video, audio, photo player state)
|
||||||
|
"""
|
||||||
|
xml = etree.Element('MediaContainer')
|
||||||
|
location = 'navigation'
|
||||||
|
for typus in (v.PLEX_PLAYLIST_TYPE_AUDIO,
|
||||||
|
v.PLEX_PLAYLIST_TYPE_VIDEO,
|
||||||
|
v.PLEX_PLAYLIST_TYPE_PHOTO):
|
||||||
|
player = players.get(v.KODI_PLAYLIST_TYPE_FROM_PLEX_PLAYLIST_TYPE[typus])
|
||||||
|
if player is None:
|
||||||
|
# Kodi player currently not actively playing, but stopped
|
||||||
|
timeline = {
|
||||||
|
'controllable': CONTROLLABLE[typus],
|
||||||
|
'type': typus,
|
||||||
|
'state': 'stopped'
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Active Kodi player, i.e. video, audio or picture player
|
||||||
|
timeline = timeline_dict(player['playerid'], typus)
|
||||||
|
if typus in (v.PLEX_PLAYLIST_TYPE_VIDEO, v.PLEX_PLAYLIST_TYPE_PHOTO):
|
||||||
|
location = 'fullScreenVideo'
|
||||||
|
etree.SubElement(xml, 'Timeline', attrib=timeline)
|
||||||
|
xml.set('location', location)
|
||||||
|
return xml
|
||||||
|
|
||||||
|
|
||||||
|
def stopped_timeline():
|
||||||
|
"""
|
||||||
|
Returns an etree XML stating that all players have stopped playback
|
||||||
|
"""
|
||||||
|
xml = etree.Element('MediaContainer', attrib={'location': 'navigation'})
|
||||||
|
for typus in (v.PLEX_PLAYLIST_TYPE_AUDIO,
|
||||||
|
v.PLEX_PLAYLIST_TYPE_VIDEO,
|
||||||
|
v.PLEX_PLAYLIST_TYPE_PHOTO):
|
||||||
|
# Kodi player currently not actively playing, but stopped
|
||||||
|
timeline = {
|
||||||
|
'controllable': CONTROLLABLE[typus],
|
||||||
|
'type': typus,
|
||||||
|
'state': 'stopped'
|
||||||
|
}
|
||||||
|
etree.SubElement(xml, 'Timeline', attrib=timeline)
|
||||||
|
return xml
|
||||||
|
|
||||||
|
|
||||||
|
def b_ok_message():
|
||||||
|
"""
|
||||||
|
Returns a byte-encoded (b'') OK message XML for the PMS
|
||||||
|
"""
|
||||||
|
return etree.tostring(
|
||||||
|
etree.Element('Response', attrib={'code': '200', 'status': 'OK'}),
|
||||||
|
encoding='utf8')
|
||||||
|
|
||||||
|
|
||||||
|
class Subscriber(object):
|
||||||
|
def __init__(self, playstate_mgr, cmd=None, uuid=None, command_id=None,
|
||||||
|
url=None):
|
||||||
|
self.playstate_mgr = playstate_mgr
|
||||||
|
if cmd is not None:
|
||||||
|
self.uuid = cmd.get('clientIdentifier')
|
||||||
|
self.command_id = int(cmd.get('commandID', 0))
|
||||||
|
self.url = f'{app.CONN.server}/player/proxy/timeline'
|
||||||
|
else:
|
||||||
|
self.uuid = str(uuid)
|
||||||
|
self.command_id = command_id
|
||||||
|
self.url = f'{url}/:/timeline'
|
||||||
|
self.s = create_requests_session()
|
||||||
|
self._errors_left = 3
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
if isinstance(other, str):
|
||||||
|
return self.uuid == other
|
||||||
|
elif isinstance(other, Subscriber):
|
||||||
|
return self.uuid == other.uuid
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def __hash__(self):
|
||||||
|
return hash(self.uuid)
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
"""Make sure we are closing the Session() correctly."""
|
||||||
|
self.s.close()
|
||||||
|
|
||||||
|
def _on_error(self):
|
||||||
|
self._errors_left -= 1
|
||||||
|
if self._errors_left == 0:
|
||||||
|
log.warn('Too many issues contacting subscriber %s. Unsubscribing',
|
||||||
|
self.uuid)
|
||||||
|
self.playstate_mgr.unsubscribe(self)
|
||||||
|
|
||||||
|
def send_timeline(self, message, state):
|
||||||
|
message = deepcopy(message)
|
||||||
|
message.set('commandID', str(self.command_id + 1))
|
||||||
|
self.s.params['state'] = state
|
||||||
|
self.s.params['commandID'] = self.command_id + 1
|
||||||
|
# Send update
|
||||||
|
log.debug('Sending timeline update to %s with params %s',
|
||||||
|
self.uuid, self.s.params)
|
||||||
|
utils.log_xml(message, log.debug, logging.DEBUG)
|
||||||
|
try:
|
||||||
|
req = communicate(self.s.post,
|
||||||
|
self.url,
|
||||||
|
data=etree.tostring(message, encoding='utf8'),
|
||||||
|
timeout=TIMEOUT)
|
||||||
|
except requests.RequestException as error:
|
||||||
|
log.warn('Error sending timeline to Subscriber %s: %s: %s',
|
||||||
|
self.uuid, self.url, error)
|
||||||
|
self._on_error()
|
||||||
|
return
|
||||||
|
except SystemExit:
|
||||||
|
return
|
||||||
|
if not req.ok:
|
||||||
|
log_error(log.error,
|
||||||
|
'Unexpected Companion timeline response for player '
|
||||||
|
f'{self.uuid}: {self.url}',
|
||||||
|
req)
|
||||||
|
self._on_error()
|
||||||
|
|
||||||
|
|
||||||
|
class UUIDStr(str):
|
||||||
|
"""
|
||||||
|
Subclass of str in order to be able to compare to Subscriber objects
|
||||||
|
like this: if UUIDStr() in list(Subscriber(), Subscriber()): ...
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
if isinstance(other, Subscriber):
|
||||||
|
return self == other.uuid
|
||||||
|
else:
|
||||||
|
return super().__eq__(other)
|
||||||
|
|
||||||
|
def __hash__(self):
|
||||||
|
return super().__hash__()
|
||||||
|
|
|
@ -2,10 +2,13 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
import requests
|
import requests
|
||||||
import xml.etree.ElementTree as etree
|
from threading import Thread
|
||||||
|
|
||||||
from .common import proxy_headers, proxy_params, log_error
|
from .common import communicate, proxy_headers, proxy_params, log_error, \
|
||||||
|
UUIDStr, Subscriber, timeline, stopped_timeline
|
||||||
from .playqueue import compare_playqueues
|
from .playqueue import compare_playqueues
|
||||||
|
from .webserver import ThreadedHTTPServer, CompanionHandlerClassFactory
|
||||||
|
from .plexgdm import plexgdm
|
||||||
|
|
||||||
from .. import json_rpc as js
|
from .. import json_rpc as js
|
||||||
from .. import variables as v
|
from .. import variables as v
|
||||||
|
@ -22,159 +25,9 @@ log = getLogger('PLEX.companion.playstate')
|
||||||
|
|
||||||
TIMEOUT = (5, 5)
|
TIMEOUT = (5, 5)
|
||||||
|
|
||||||
# What is Companion controllable?
|
# How many seconds do we wait until we check again whether we are registered
|
||||||
CONTROLLABLE = {
|
# as a GDM Plex Companion Client?
|
||||||
v.PLEX_PLAYLIST_TYPE_VIDEO: 'playPause,stop,volume,shuffle,audioStream,'
|
GDM_COMPANION_CHECK = 120
|
||||||
'subtitleStream,seekTo,skipPrevious,skipNext,'
|
|
||||||
'stepBack,stepForward',
|
|
||||||
v.PLEX_PLAYLIST_TYPE_AUDIO: 'playPause,stop,volume,shuffle,repeat,seekTo,'
|
|
||||||
'skipPrevious,skipNext,stepBack,stepForward',
|
|
||||||
v.PLEX_PLAYLIST_TYPE_PHOTO: 'playPause,stop,skipPrevious,skipNext'
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def split_server_uri(server):
|
|
||||||
(protocol, url, port) = server.split(':')
|
|
||||||
url = url.replace('/', '')
|
|
||||||
return (protocol, url, port)
|
|
||||||
|
|
||||||
|
|
||||||
def get_correct_position(info, playqueue):
|
|
||||||
"""
|
|
||||||
Kodi tells us the PLAYLIST position, not PLAYQUEUE position, if the
|
|
||||||
user initiated playback of a playlist
|
|
||||||
"""
|
|
||||||
if playqueue.kodi_playlist_playback:
|
|
||||||
position = 0
|
|
||||||
else:
|
|
||||||
position = info['position'] or 0
|
|
||||||
return position
|
|
||||||
|
|
||||||
|
|
||||||
def timeline_dict(playerid, typus):
|
|
||||||
with app.APP.lock_playqueues:
|
|
||||||
info = app.PLAYSTATE.player_states[playerid]
|
|
||||||
playqueue = app.PLAYQUEUES[playerid]
|
|
||||||
position = get_correct_position(info, playqueue)
|
|
||||||
try:
|
|
||||||
item = playqueue.items[position]
|
|
||||||
except IndexError:
|
|
||||||
# E.g. for direct path playback for single item
|
|
||||||
return {
|
|
||||||
'controllable': CONTROLLABLE[typus],
|
|
||||||
'type': typus,
|
|
||||||
'state': 'stopped'
|
|
||||||
}
|
|
||||||
if typus == v.PLEX_PLAYLIST_TYPE_VIDEO and not item.streams_initialized:
|
|
||||||
# Not ready yet to send updates
|
|
||||||
raise TypeError()
|
|
||||||
protocol, url, port = split_server_uri(app.CONN.server)
|
|
||||||
status = 'paused' if int(info['speed']) == 0 else 'playing'
|
|
||||||
duration = timing.kodi_time_to_millis(info['totaltime'])
|
|
||||||
shuffle = '1' if info['shuffled'] else '0'
|
|
||||||
mute = '1' if info['muted'] is True else '0'
|
|
||||||
answ = {
|
|
||||||
'controllable': CONTROLLABLE[typus],
|
|
||||||
'protocol': protocol,
|
|
||||||
'address': url,
|
|
||||||
'port': port,
|
|
||||||
'machineIdentifier': app.CONN.machine_identifier,
|
|
||||||
'state': status,
|
|
||||||
'type': typus,
|
|
||||||
'itemType': typus,
|
|
||||||
'time': str(timing.kodi_time_to_millis(info['time'])),
|
|
||||||
'duration': str(duration),
|
|
||||||
'seekRange': '0-%s' % duration,
|
|
||||||
'shuffle': shuffle,
|
|
||||||
'repeat': v.PLEX_REPEAT_FROM_KODI_REPEAT[info['repeat']],
|
|
||||||
'volume': str(info['volume']),
|
|
||||||
'mute': mute,
|
|
||||||
'mediaIndex': '0', # Still to implement
|
|
||||||
'partIndex': '0',
|
|
||||||
'partCount': '1',
|
|
||||||
'providerIdentifier': 'com.plexapp.plugins.library',
|
|
||||||
}
|
|
||||||
# Get the plex id from the PKC playqueue not info, as Kodi jumps to
|
|
||||||
# next playqueue element way BEFORE kodi monitor onplayback is
|
|
||||||
# called
|
|
||||||
if item.plex_id:
|
|
||||||
answ['key'] = '/library/metadata/%s' % item.plex_id
|
|
||||||
answ['ratingKey'] = str(item.plex_id)
|
|
||||||
# PlayQueue stuff
|
|
||||||
if info['container_key']:
|
|
||||||
answ['containerKey'] = info['container_key']
|
|
||||||
if (info['container_key'] is not None and
|
|
||||||
info['container_key'].startswith('/playQueues')):
|
|
||||||
answ['playQueueID'] = str(playqueue.id)
|
|
||||||
answ['playQueueVersion'] = str(playqueue.version)
|
|
||||||
answ['playQueueItemID'] = str(item.id)
|
|
||||||
if playqueue.items[position].guid:
|
|
||||||
answ['guid'] = item.guid
|
|
||||||
# Temp. token set?
|
|
||||||
if app.CONN.plex_transient_token:
|
|
||||||
answ['token'] = app.CONN.plex_transient_token
|
|
||||||
elif playqueue.plex_transient_token:
|
|
||||||
answ['token'] = playqueue.plex_transient_token
|
|
||||||
# Process audio and subtitle streams
|
|
||||||
if typus == v.PLEX_PLAYLIST_TYPE_VIDEO:
|
|
||||||
item.current_kodi_video_stream = info['currentvideostream']['index']
|
|
||||||
item.current_kodi_audio_stream = info['currentaudiostream']['index']
|
|
||||||
item.current_kodi_sub_stream_enabled = info['subtitleenabled']
|
|
||||||
try:
|
|
||||||
item.current_kodi_sub_stream = info['currentsubtitle']['index']
|
|
||||||
except KeyError:
|
|
||||||
item.current_kodi_sub_stream = None
|
|
||||||
answ['videoStreamID'] = str(item.current_plex_video_stream)
|
|
||||||
answ['audioStreamID'] = str(item.current_plex_audio_stream)
|
|
||||||
# Mind the zero - meaning subs are deactivated
|
|
||||||
answ['subtitleStreamID'] = str(item.current_plex_sub_stream or 0)
|
|
||||||
return answ
|
|
||||||
|
|
||||||
|
|
||||||
def timeline(players):
|
|
||||||
"""
|
|
||||||
Returns a timeline xml as str
|
|
||||||
(xml containing video, audio, photo player state)
|
|
||||||
"""
|
|
||||||
xml = etree.Element('MediaContainer')
|
|
||||||
location = 'navigation'
|
|
||||||
for typus in (v.PLEX_PLAYLIST_TYPE_AUDIO,
|
|
||||||
v.PLEX_PLAYLIST_TYPE_VIDEO,
|
|
||||||
v.PLEX_PLAYLIST_TYPE_PHOTO):
|
|
||||||
player = players.get(v.KODI_PLAYLIST_TYPE_FROM_PLEX_PLAYLIST_TYPE[typus])
|
|
||||||
if player is None:
|
|
||||||
# Kodi player currently not actively playing, but stopped
|
|
||||||
timeline = {
|
|
||||||
'controllable': CONTROLLABLE[typus],
|
|
||||||
'type': typus,
|
|
||||||
'state': 'stopped'
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
# Active Kodi player, i.e. video, audio or picture player
|
|
||||||
timeline = timeline_dict(player['playerid'], typus)
|
|
||||||
if typus in (v.PLEX_PLAYLIST_TYPE_VIDEO, v.PLEX_PLAYLIST_TYPE_PHOTO):
|
|
||||||
location = 'fullScreenVideo'
|
|
||||||
etree.SubElement(xml, 'Timeline', attrib=timeline)
|
|
||||||
xml.set('location', location)
|
|
||||||
return xml
|
|
||||||
|
|
||||||
|
|
||||||
def stopped_timeline():
|
|
||||||
"""
|
|
||||||
Returns an XML stating that all players have stopped playback
|
|
||||||
"""
|
|
||||||
xml = etree.Element('MediaContainer', attrib={'location': 'navigation'})
|
|
||||||
for typus in (v.PLEX_PLAYLIST_TYPE_AUDIO,
|
|
||||||
v.PLEX_PLAYLIST_TYPE_VIDEO,
|
|
||||||
v.PLEX_PLAYLIST_TYPE_PHOTO):
|
|
||||||
# Kodi player currently not actively playing, but stopped
|
|
||||||
timeline = {
|
|
||||||
'controllable': CONTROLLABLE[typus],
|
|
||||||
'type': typus,
|
|
||||||
'state': 'stopped'
|
|
||||||
}
|
|
||||||
etree.SubElement(xml, 'Timeline', attrib=timeline)
|
|
||||||
return xml
|
|
||||||
|
|
||||||
|
|
||||||
def update_player_info(players):
|
def update_player_info(players):
|
||||||
|
@ -197,14 +50,35 @@ class PlaystateMgr(backgroundthread.KillableThread):
|
||||||
"""
|
"""
|
||||||
daemon = True
|
daemon = True
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, companion_enabled):
|
||||||
self._subscribed = False
|
self.companion_enabled = companion_enabled
|
||||||
self._command_id = None
|
self.subscribers = dict()
|
||||||
self.s = None
|
self.s = None
|
||||||
self.t = None
|
self.httpd = None
|
||||||
self.stopped_timeline = stopped_timeline()
|
self.stopped_timeline = stopped_timeline()
|
||||||
|
self.gdm = plexgdm()
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
|
def _start_webserver(self):
|
||||||
|
if self.httpd is None and self.companion_enabled:
|
||||||
|
log.debug('Starting PKC Companion webserver on port %s', v.COMPANION_PORT)
|
||||||
|
server_address = ('', v.COMPANION_PORT)
|
||||||
|
HandlerClass = CompanionHandlerClassFactory(self)
|
||||||
|
self.httpd = ThreadedHTTPServer(server_address, HandlerClass)
|
||||||
|
self.httpd.timeout = 10.0
|
||||||
|
t = Thread(target=self.httpd.serve_forever)
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
def _stop_webserver(self):
|
||||||
|
if self.httpd is not None:
|
||||||
|
log.debug('Shutting down PKC Companion webserver')
|
||||||
|
try:
|
||||||
|
self.httpd.shutdown()
|
||||||
|
except AttributeError:
|
||||||
|
# Ensure thread-safety
|
||||||
|
pass
|
||||||
|
self.httpd = None
|
||||||
|
|
||||||
def _get_requests_session(self):
|
def _get_requests_session(self):
|
||||||
if self.s is None:
|
if self.s is None:
|
||||||
log.debug('Creating new requests session')
|
log.debug('Creating new requests session')
|
||||||
|
@ -216,122 +90,94 @@ class PlaystateMgr(backgroundthread.KillableThread):
|
||||||
self.s.params = proxy_params()
|
self.s.params = proxy_params()
|
||||||
return self.s
|
return self.s
|
||||||
|
|
||||||
def _get_requests_session_companion(self):
|
def _close_requests_session(self):
|
||||||
if self.t is None:
|
if self.s is not None:
|
||||||
log.debug('Creating new companion requests session')
|
|
||||||
self.t = requests.Session()
|
|
||||||
self.t.headers = proxy_headers()
|
|
||||||
self.t.verify = app.CONN.verify_ssl_cert
|
|
||||||
if app.CONN.ssl_cert_path:
|
|
||||||
self.t.cert = app.CONN.ssl_cert_path
|
|
||||||
self.t.params = proxy_params()
|
|
||||||
return self.t
|
|
||||||
|
|
||||||
def close_requests_session(self):
|
|
||||||
for session in (self.s, self.t):
|
|
||||||
if session is not None:
|
|
||||||
try:
|
try:
|
||||||
session.close()
|
self.s.close()
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
# "thread-safety" - Just in case s was set to None in the
|
# "thread-safety" - Just in case s was set to None in the
|
||||||
# meantime
|
# meantime
|
||||||
pass
|
pass
|
||||||
session = None
|
self.s = None
|
||||||
|
|
||||||
@staticmethod
|
def close_connections(self):
|
||||||
def communicate(method, url, **kwargs):
|
"""May also be called from another thread"""
|
||||||
try:
|
self._stop_webserver()
|
||||||
# This will usually block until timeout is reached!
|
self._close_requests_session()
|
||||||
req = method(url, **kwargs)
|
self.subscribers = dict()
|
||||||
except requests.ConnectTimeout:
|
|
||||||
# The request timed out while trying to connect to the PMS
|
|
||||||
log.error('Requests ConnectionTimeout!')
|
|
||||||
raise
|
|
||||||
except requests.ReadTimeout:
|
|
||||||
# The PMS did not send any data in the allotted amount of time
|
|
||||||
log.error('Requests ReadTimeout!')
|
|
||||||
raise
|
|
||||||
except requests.TooManyRedirects:
|
|
||||||
log.error('TooManyRedirects error!')
|
|
||||||
raise
|
|
||||||
except requests.HTTPError as error:
|
|
||||||
log.error('HTTPError: %s', error)
|
|
||||||
raise
|
|
||||||
except requests.ConnectionError as error:
|
|
||||||
log.error('ConnectionError: %s', error)
|
|
||||||
raise
|
|
||||||
req.encoding = 'utf-8'
|
|
||||||
# To make sure that we release the socket, need to access content once
|
|
||||||
req.content
|
|
||||||
return req
|
|
||||||
|
|
||||||
def _subscribe(self, cmd):
|
|
||||||
self._command_id = int(cmd.get('commandID'))
|
|
||||||
self._subscribed = True
|
|
||||||
|
|
||||||
def _unsubscribe(self):
|
|
||||||
self._subscribed = False
|
|
||||||
self._command_id = None
|
|
||||||
|
|
||||||
def send_stop(self):
|
def send_stop(self):
|
||||||
"""
|
"""
|
||||||
If we're still connected to a PMS, tells the PMS that playback stopped
|
If we're still connected to a PMS, tells the PMS that playback stopped
|
||||||
"""
|
"""
|
||||||
if app.CONN.online and app.ACCOUNT.authenticated:
|
self.pms_timeline(None, self.stopped_timeline)
|
||||||
# Only try to send something if we're connected
|
|
||||||
self.pms_timeline(dict(), self.stopped_timeline)
|
|
||||||
self.companion_timeline(self.stopped_timeline)
|
self.companion_timeline(self.stopped_timeline)
|
||||||
|
|
||||||
def check_subscriber(self, cmd):
|
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':
|
if cmd.get('path') == '/player/timeline/unsubscribe':
|
||||||
log.info('Stop Plex Companion subscription')
|
if uuid in self.subscribers:
|
||||||
self._unsubscribe()
|
log.debug('Stop Plex Companion subscription for %s', uuid)
|
||||||
elif not self._subscribed:
|
del self.subscribers[uuid]
|
||||||
log.info('Start Plex Companion subscription')
|
elif uuid not in self.subscribers:
|
||||||
self._subscribe(cmd)
|
log.debug('Start new Plex Companion subscription for %s', uuid)
|
||||||
|
self.subscribers[uuid] = Subscriber(self, cmd=cmd)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
self._command_id = int(cmd.get('commandID'))
|
self.subscribers[uuid].command_id = int(cmd.get('commandID'))
|
||||||
except TypeError:
|
except TypeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def companion_timeline(self, message):
|
def subscribe(self, uuid, command_id, url):
|
||||||
if not self._subscribed:
|
log.debug('New Plex Companion subscriber %s: %s', uuid, url)
|
||||||
return
|
with app.APP.lock_subscriber:
|
||||||
url = f'{app.CONN.server}/player/proxy/timeline'
|
self.subscribers[UUIDStr(uuid)] = Subscriber(self,
|
||||||
self._get_requests_session_companion()
|
cmd=None,
|
||||||
self.t.params['commandID'] = self._command_id
|
uuid=uuid,
|
||||||
message.set('commandID', str(self._command_id))
|
command_id=command_id,
|
||||||
# Get the correct playstate
|
url=url)
|
||||||
state = 'stopped'
|
|
||||||
for timeline in message:
|
def unsubscribe(self, uuid):
|
||||||
if timeline.get('state') != 'stopped':
|
log.debug('Unsubscribing Plex Companion client %s', uuid)
|
||||||
state = timeline.get('state')
|
with app.APP.lock_subscriber:
|
||||||
self.t.params['state'] = state
|
|
||||||
# Send update
|
|
||||||
try:
|
try:
|
||||||
req = self.communicate(self.t.post,
|
del self.subscribers[UUIDStr(uuid)]
|
||||||
url,
|
except KeyError:
|
||||||
data=etree.tostring(message,
|
pass
|
||||||
encoding='utf-8'),
|
|
||||||
timeout=TIMEOUT)
|
def update_command_id(self, uuid, command_id):
|
||||||
except (requests.RequestException, SystemExit):
|
with app.APP.lock_subscriber:
|
||||||
return
|
if uuid not in self.subscribers:
|
||||||
if not req.ok:
|
return False
|
||||||
log_error(log.error, 'Unexpected Companion timeline', req)
|
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):
|
def pms_timeline_per_player(self, playerid, message):
|
||||||
"""
|
"""
|
||||||
Pass a really low timeout in seconds if shutting down Kodi and we don't
|
Sending the "normal", non-Companion playstate to the PMS works a bit
|
||||||
need the PMS' response
|
differently
|
||||||
"""
|
"""
|
||||||
url = f'{app.CONN.server}/:/timeline'
|
url = f'{app.CONN.server}/:/timeline'
|
||||||
self._get_requests_session()
|
self._get_requests_session()
|
||||||
self.s.params.update(message[playerid].attrib)
|
self.s.params.update(message[playerid].attrib)
|
||||||
# Tell the PMS about our playstate progress
|
# Tell the PMS about our playstate progress
|
||||||
try:
|
try:
|
||||||
req = self.communicate(self.s.get, url, timeout=TIMEOUT)
|
req = communicate(self.s.get, url, timeout=TIMEOUT)
|
||||||
except (requests.RequestException, SystemExit):
|
except requests.RequestException as error:
|
||||||
|
log.error('Could not send the PMS timeline: %s', error)
|
||||||
|
return
|
||||||
|
except SystemExit:
|
||||||
return
|
return
|
||||||
if not req.ok:
|
if not req.ok:
|
||||||
log_error(log.error, 'Failed reporting playback progress', req)
|
log_error(log.error, 'Failed reporting playback progress', req)
|
||||||
|
@ -342,6 +188,12 @@ class PlaystateMgr(backgroundthread.KillableThread):
|
||||||
for player in players.values():
|
for player in players.values():
|
||||||
self.pms_timeline_per_player(player['playerid'], message)
|
self.pms_timeline_per_player(player['playerid'], message)
|
||||||
|
|
||||||
|
def wait_while_suspended(self):
|
||||||
|
should_shutdown = super().wait_while_suspended()
|
||||||
|
if not should_shutdown:
|
||||||
|
self._start_webserver()
|
||||||
|
return should_shutdown
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
app.APP.register_thread(self)
|
app.APP.register_thread(self)
|
||||||
log.info("----===## Starting PlaystateMgr ##===----")
|
log.info("----===## Starting PlaystateMgr ##===----")
|
||||||
|
@ -351,16 +203,18 @@ class PlaystateMgr(backgroundthread.KillableThread):
|
||||||
# Make sure we're telling the PMS that playback will stop
|
# Make sure we're telling the PMS that playback will stop
|
||||||
self.send_stop()
|
self.send_stop()
|
||||||
# Cleanup
|
# Cleanup
|
||||||
self.close_requests_session()
|
self.close_connections()
|
||||||
app.APP.deregister_thread(self)
|
app.APP.deregister_thread(self)
|
||||||
log.info("----===## PlaystateMgr stopped ##===----")
|
log.info("----===## PlaystateMgr stopped ##===----")
|
||||||
|
|
||||||
def _run(self):
|
def _run(self):
|
||||||
signaled_playback_stop = True
|
signaled_playback_stop = True
|
||||||
|
self._start_webserver()
|
||||||
|
self.gdm.start()
|
||||||
|
last_check = timing.unix_timestamp()
|
||||||
while not self.should_cancel():
|
while not self.should_cancel():
|
||||||
if self.should_suspend():
|
if self.should_suspend():
|
||||||
self._unsubscribe()
|
self.close_connections()
|
||||||
self.close_requests_session()
|
|
||||||
if self.wait_while_suspended():
|
if self.wait_while_suspended():
|
||||||
break
|
break
|
||||||
# Check for Kodi playlist changes first
|
# Check for Kodi playlist changes first
|
||||||
|
@ -377,6 +231,11 @@ class PlaystateMgr(backgroundthread.KillableThread):
|
||||||
# compare old and new playqueue
|
# compare old and new playqueue
|
||||||
compare_playqueues(playqueue, kodi_pl)
|
compare_playqueues(playqueue, kodi_pl)
|
||||||
playqueue.old_kodi_pl = list(kodi_pl)
|
playqueue.old_kodi_pl = list(kodi_pl)
|
||||||
|
# Make sure we are registered as a player
|
||||||
|
now = timing.unix_timestamp()
|
||||||
|
if now - last_check > GDM_COMPANION_CHECK:
|
||||||
|
self.gdm.check_client_registration()
|
||||||
|
last_check = now
|
||||||
# Then check for Kodi playback
|
# Then check for Kodi playback
|
||||||
players = js.get_players()
|
players = js.get_players()
|
||||||
if not players and signaled_playback_stop:
|
if not players and signaled_playback_stop:
|
||||||
|
@ -384,8 +243,8 @@ class PlaystateMgr(backgroundthread.KillableThread):
|
||||||
continue
|
continue
|
||||||
elif not players:
|
elif not players:
|
||||||
# Playback has just stopped, need to tell Plex
|
# Playback has just stopped, need to tell Plex
|
||||||
signaled_playback_stop = True
|
|
||||||
self.send_stop()
|
self.send_stop()
|
||||||
|
signaled_playback_stop = True
|
||||||
self.sleep(1)
|
self.sleep(1)
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
|
|
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
|
#!/usr/bin/env python
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from logging import getLogger
|
import logging
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from .processing import process_proxy_xml
|
from .processing import process_command
|
||||||
from .common import proxy_headers, proxy_params, log_error
|
from .common import communicate, log_error, create_requests_session, \
|
||||||
|
b_ok_message
|
||||||
|
|
||||||
from .. import utils
|
from .. import utils
|
||||||
from .. import backgroundthread
|
from .. import backgroundthread
|
||||||
from .. import app
|
from .. import app
|
||||||
from .. import variables as v
|
|
||||||
|
|
||||||
# Disable annoying requests warnings
|
|
||||||
import requests.packages.urllib3
|
|
||||||
requests.packages.urllib3.disable_warnings()
|
|
||||||
|
|
||||||
# Timeout (connection timeout, read timeout)
|
# Timeout (connection timeout, read timeout)
|
||||||
# The later is up to 20 seconds, if the PMS has nothing to tell us
|
# The later is up to 20 seconds, if the PMS has nothing to tell us
|
||||||
# THIS WILL PREVENT PKC FROM SHUTTING DOWN CORRECTLY
|
# THIS WILL PREVENT PKC FROM SHUTTING DOWN CORRECTLY
|
||||||
TIMEOUT = (5.0, 4.0)
|
TIMEOUT = (5.0, 4.0)
|
||||||
|
|
||||||
# Max. timeout for the Listener: 2 ^ MAX_TIMEOUT
|
# Max. timeout for the Polling: 2 ^ MAX_TIMEOUT
|
||||||
# Corresponds to 2 ^ 7 = 128 seconds
|
# Corresponds to 2 ^ 7 = 128 seconds
|
||||||
MAX_TIMEOUT = 7
|
MAX_TIMEOUT = 7
|
||||||
|
|
||||||
log = getLogger('PLEX.companion.listener')
|
log = logging.getLogger('PLEX.companion.polling')
|
||||||
|
|
||||||
|
|
||||||
class Listener(backgroundthread.KillableThread):
|
class Polling(backgroundthread.KillableThread):
|
||||||
"""
|
"""
|
||||||
Opens a GET HTTP connection to the current PMS (that will time-out PMS-wise
|
Opens a GET HTTP connection to the current PMS (that will time-out PMS-wise
|
||||||
after ~20 seconds) and listens for any commands by the PMS. Listening
|
after ~20 seconds) and listens for any commands by the PMS. Listening
|
||||||
|
@ -39,17 +35,13 @@ class Listener(backgroundthread.KillableThread):
|
||||||
self.s = None
|
self.s = None
|
||||||
self.playstate_mgr = playstate_mgr
|
self.playstate_mgr = playstate_mgr
|
||||||
self._sleep_timer = 0
|
self._sleep_timer = 0
|
||||||
|
self.ok_msg = b_ok_message()
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
def _get_requests_session(self):
|
def _get_requests_session(self):
|
||||||
if self.s is None:
|
if self.s is None:
|
||||||
log.debug('Creating new requests session')
|
log.debug('Creating new requests session')
|
||||||
self.s = requests.Session()
|
self.s = create_requests_session()
|
||||||
self.s.headers = proxy_headers()
|
|
||||||
self.s.verify = app.CONN.verify_ssl_cert
|
|
||||||
if app.CONN.ssl_cert_path:
|
|
||||||
self.s.cert = app.CONN.ssl_cert_path
|
|
||||||
self.s.params = proxy_params()
|
|
||||||
return self.s
|
return self.s
|
||||||
|
|
||||||
def close_requests_session(self):
|
def close_requests_session(self):
|
||||||
|
@ -67,52 +59,24 @@ class Listener(backgroundthread.KillableThread):
|
||||||
'Plex Companion will not work.')
|
'Plex Companion will not work.')
|
||||||
self.suspend()
|
self.suspend()
|
||||||
|
|
||||||
def _on_connection_error(self, req=None):
|
def _on_connection_error(self, req=None, error=None):
|
||||||
if req:
|
if req:
|
||||||
log_error(log.error, 'Error while contacting the PMS', req)
|
log_error(log.error, 'Error while contacting the PMS', req)
|
||||||
self.sleep(2 ^ self._sleep_timer)
|
if error:
|
||||||
|
log.error('Error encountered: %s: %s', type(error), error)
|
||||||
|
self.sleep(2 ** self._sleep_timer)
|
||||||
if self._sleep_timer < MAX_TIMEOUT:
|
if self._sleep_timer < MAX_TIMEOUT:
|
||||||
self._sleep_timer += 1
|
self._sleep_timer += 1
|
||||||
|
|
||||||
def ok_message(self, command_id):
|
def ok_message(self, command_id):
|
||||||
url = f'{app.CONN.server}/player/proxy/response?commandID={command_id}'
|
url = f'{app.CONN.server}/player/proxy/response?commandID={command_id}'
|
||||||
try:
|
try:
|
||||||
req = self.communicate(self.s.post,
|
req = communicate(self.s.post, url, data=self.ok_msg)
|
||||||
url,
|
except (requests.RequestException, SystemExit) as error:
|
||||||
data=v.COMPANION_OK_MESSAGE.encode('utf-8'))
|
log.debug('Error replying with an OK message: %s', error)
|
||||||
except (requests.RequestException, SystemExit):
|
|
||||||
return
|
return
|
||||||
if not req.ok:
|
if not req.ok:
|
||||||
log_error(log.error, 'Error replying OK', req)
|
log_error(log.error, 'Error replying with OK message', req)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def communicate(method, url, **kwargs):
|
|
||||||
try:
|
|
||||||
req = method(url, **kwargs)
|
|
||||||
except requests.ConnectTimeout:
|
|
||||||
# The request timed out while trying to connect to the PMS
|
|
||||||
log.error('Requests ConnectionTimeout!')
|
|
||||||
raise
|
|
||||||
except requests.ReadTimeout:
|
|
||||||
# The PMS did not send any data in the allotted amount of time
|
|
||||||
log.error('Requests ReadTimeout!')
|
|
||||||
raise
|
|
||||||
except requests.TooManyRedirects:
|
|
||||||
log.error('TooManyRedirects error!')
|
|
||||||
raise
|
|
||||||
except requests.HTTPError as error:
|
|
||||||
log.error('HTTPError: %s', error)
|
|
||||||
raise
|
|
||||||
except requests.ConnectionError:
|
|
||||||
# Caused by PKC terminating the connection prematurely
|
|
||||||
# log.error('ConnectionError: %s', error)
|
|
||||||
raise
|
|
||||||
else:
|
|
||||||
req.encoding = 'utf-8'
|
|
||||||
# Access response content once in order to make sure to release the
|
|
||||||
# underlying sockets
|
|
||||||
req.content
|
|
||||||
return req
|
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
"""
|
"""
|
||||||
|
@ -140,14 +104,19 @@ class Listener(backgroundthread.KillableThread):
|
||||||
url = app.CONN.server + '/player/proxy/poll?timeout=1'
|
url = app.CONN.server + '/player/proxy/poll?timeout=1'
|
||||||
self._get_requests_session()
|
self._get_requests_session()
|
||||||
try:
|
try:
|
||||||
req = self.communicate(self.s.get,
|
req = communicate(self.s.get, url, timeout=TIMEOUT)
|
||||||
url,
|
except (requests.exceptions.ProxyError,
|
||||||
timeout=TIMEOUT)
|
requests.exceptions.SSLError) as error:
|
||||||
except requests.ConnectionError:
|
self._on_connection_error(req=None, error=error)
|
||||||
|
continue
|
||||||
|
except (requests.ConnectionError,
|
||||||
|
requests.Timeout,
|
||||||
|
requests.exceptions.ChunkedEncodingError):
|
||||||
|
# Expected due to timeout and the PMS not having to reply
|
||||||
# No command received from the PMS - try again immediately
|
# No command received from the PMS - try again immediately
|
||||||
continue
|
continue
|
||||||
except requests.RequestException:
|
except requests.RequestException as error:
|
||||||
self._on_connection_error()
|
self._on_connection_error(req=None, error=error)
|
||||||
continue
|
continue
|
||||||
except SystemExit:
|
except SystemExit:
|
||||||
# We need to quit PKC entirely
|
# We need to quit PKC entirely
|
||||||
|
@ -161,11 +130,11 @@ class Listener(backgroundthread.KillableThread):
|
||||||
self._unauthorized()
|
self._unauthorized()
|
||||||
continue
|
continue
|
||||||
elif not req.ok:
|
elif not req.ok:
|
||||||
self._on_connection_error(req)
|
self._on_connection_error(req=req, error=None)
|
||||||
continue
|
continue
|
||||||
elif not ('content-type' in req.headers
|
elif not ('content-type' in req.headers
|
||||||
and 'xml' in req.headers['content-type']):
|
and 'xml' in req.headers['content-type']):
|
||||||
self._on_connection_error(req)
|
self._on_connection_error(req=req, error=None)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not req.text:
|
if not req.text:
|
||||||
|
@ -184,13 +153,13 @@ class Listener(backgroundthread.KillableThread):
|
||||||
raise IndexError()
|
raise IndexError()
|
||||||
except (utils.ParseError, IndexError):
|
except (utils.ParseError, IndexError):
|
||||||
log.error('Could not parse the PMS xml:')
|
log.error('Could not parse the PMS xml:')
|
||||||
self._on_connection_error()
|
self._on_connection_error(req=req, error=None)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Do the work
|
# Do the work
|
||||||
log.debug('Received a Plex Companion command from the PMS:')
|
log.debug('Received a Plex Companion command from the PMS:')
|
||||||
utils.log_xml(xml, log.debug)
|
utils.log_xml(xml, log.debug, logging.DEBUG)
|
||||||
self.playstate_mgr.check_subscriber(cmd)
|
self.playstate_mgr.check_subscriber(cmd)
|
||||||
if process_proxy_xml(cmd):
|
if process_command(cmd):
|
||||||
self.ok_message(cmd.get('commandID'))
|
self.ok_message(cmd.get('commandID'))
|
||||||
self._sleep_timer = 0
|
self._sleep_timer = 0
|
||||||
|
|
|
@ -166,29 +166,39 @@ def skip_to(playqueue_item_id, key):
|
||||||
log.error('Item not found to skip to')
|
log.error('Item not found to skip to')
|
||||||
|
|
||||||
|
|
||||||
def process_proxy_xml(cmd):
|
def convert_xml_to_params(xml):
|
||||||
|
new_params = dict(xml.attrib)
|
||||||
|
for key in xml.attrib:
|
||||||
|
if key.startswith('query'):
|
||||||
|
new_params[key[5].lower() + key[6:]] = xml.get(key)
|
||||||
|
del new_params[key]
|
||||||
|
return new_params
|
||||||
|
|
||||||
|
|
||||||
|
def process_command(cmd=None, path=None, params=None):
|
||||||
"""cmd: a "Command" etree xml"""
|
"""cmd: a "Command" etree xml"""
|
||||||
path = cmd.get('path')
|
path = cmd.get('path') if cmd is not None else path
|
||||||
if (path == '/player/playback/playMedia'
|
if not path.startswith('/'):
|
||||||
and cmd.get('queryAddress') == 'node.plexapp.com'):
|
path = '/' + path
|
||||||
|
if params is None:
|
||||||
|
params = convert_xml_to_params(cmd)
|
||||||
|
if path == '/player/playback/playMedia' and \
|
||||||
|
params.get('address') == 'node.plexapp.com':
|
||||||
process_node(cmd.get('queryKey'),
|
process_node(cmd.get('queryKey'),
|
||||||
cmd.get('queryToken'),
|
cmd.get('queryToken'),
|
||||||
cmd.get('queryOffset') or 0)
|
cmd.get('queryOffset') or 0)
|
||||||
elif path == '/player/playback/playMedia':
|
elif path == '/player/playback/playMedia':
|
||||||
with app.APP.lock_playqueues:
|
with app.APP.lock_playqueues:
|
||||||
process_playlist(cmd.get('queryContainerKey'),
|
process_playlist(params.get('containerKey'),
|
||||||
cmd.get('queryType'),
|
params.get('type'),
|
||||||
cmd.get('queryKey'),
|
params.get('key'),
|
||||||
cmd.get('queryOffset'),
|
params.get('offset'),
|
||||||
cmd.get('queryToken'))
|
params.get('token'))
|
||||||
elif path == '/player/playback/refreshPlayQueue':
|
elif path == '/player/playback/refreshPlayQueue':
|
||||||
with app.APP.lock_playqueues:
|
with app.APP.lock_playqueues:
|
||||||
process_refresh(cmd.get('queryPlayQueueID'))
|
process_refresh(params.get('playQueueID'))
|
||||||
elif path == '/player/playback/setParameters':
|
elif path == '/player/playback/setParameters':
|
||||||
if 'queryVolume' in cmd.attrib:
|
js.set_volume(int(params.get('volume')))
|
||||||
js.set_volume(int(cmd.get('queryVolume')))
|
|
||||||
else:
|
|
||||||
log.error('Unknown command: %s: %s', cmd.tag, cmd.attrib)
|
|
||||||
elif path == '/player/playback/play':
|
elif path == '/player/playback/play':
|
||||||
js.play()
|
js.play()
|
||||||
elif path == '/player/playback/pause':
|
elif path == '/player/playback/pause':
|
||||||
|
@ -196,7 +206,7 @@ def process_proxy_xml(cmd):
|
||||||
elif path == '/player/playback/stop':
|
elif path == '/player/playback/stop':
|
||||||
js.stop()
|
js.stop()
|
||||||
elif path == '/player/playback/seekTo':
|
elif path == '/player/playback/seekTo':
|
||||||
js.seek_to(float(cmd.get('queryOffset', 0.0)) / 1000.0)
|
js.seek_to(float(params.get('offset', 0.0)) / 1000.0)
|
||||||
elif path == '/player/playback/stepForward':
|
elif path == '/player/playback/stepForward':
|
||||||
js.smallforward()
|
js.smallforward()
|
||||||
elif path == '/player/playback/stepBack':
|
elif path == '/player/playback/stepBack':
|
||||||
|
@ -206,7 +216,7 @@ def process_proxy_xml(cmd):
|
||||||
elif path == '/player/playback/skipPrevious':
|
elif path == '/player/playback/skipPrevious':
|
||||||
js.skipprevious()
|
js.skipprevious()
|
||||||
elif path == '/player/playback/skipTo':
|
elif path == '/player/playback/skipTo':
|
||||||
skip_to(cmd.get('queryPlayQueueItemID'), cmd.get('queryKey'))
|
skip_to(params.get('playQueueItemID'), params.get('key'))
|
||||||
elif path == '/player/navigation/moveUp':
|
elif path == '/player/navigation/moveUp':
|
||||||
js.input_up()
|
js.input_up()
|
||||||
elif path == '/player/navigation/moveDown':
|
elif path == '/player/navigation/moveDown':
|
||||||
|
@ -222,15 +232,19 @@ def process_proxy_xml(cmd):
|
||||||
elif path == '/player/navigation/back':
|
elif path == '/player/navigation/back':
|
||||||
js.input_back()
|
js.input_back()
|
||||||
elif path == '/player/playback/setStreams':
|
elif path == '/player/playback/setStreams':
|
||||||
process_streams(cmd.get('queryType'),
|
process_streams(params.get('queryType'),
|
||||||
cast(int, cmd.get('queryVideoStreamID')),
|
cast(int, params.get('videoStreamID')),
|
||||||
cast(int, cmd.get('queryAudioStreamID')),
|
cast(int, params.get('audioStreamID')),
|
||||||
cast(int, cmd.get('querySubtitleStreamID')))
|
cast(int, params.get('subtitleStreamID')))
|
||||||
elif path == '/player/timeline/subscribe':
|
elif path == '/player/timeline/subscribe':
|
||||||
pass
|
pass
|
||||||
elif path == '/player/timeline/unsubscribe':
|
elif path == '/player/timeline/unsubscribe':
|
||||||
pass
|
pass
|
||||||
|
else:
|
||||||
|
if cmd is None:
|
||||||
|
log.error('Unknown request_path: %s with params %s', path, params)
|
||||||
else:
|
else:
|
||||||
log.error('Unknown Plex companion path/command: %s: %s',
|
log.error('Unknown Plex companion path/command: %s: %s',
|
||||||
cmd.tag, cmd.attrib)
|
cmd.tag, cmd.attrib)
|
||||||
|
return False
|
||||||
return True
|
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.pms_ws = websocket_client.get_pms_websocketapp()
|
||||||
self.alexa_ws = websocket_client.get_alexa_websocketapp()
|
self.alexa_ws = websocket_client.get_alexa_websocketapp()
|
||||||
self.sync = sync.Sync()
|
self.sync = sync.Sync()
|
||||||
self.companion_playstate_mgr = plex_companion.PlaystateMgr()
|
self.companion_playstate_mgr = plex_companion.PlaystateMgr(
|
||||||
|
companion_enabled=utils.settings('plexCompanion') == 'true')
|
||||||
if utils.settings('plexCompanion') == 'true':
|
if utils.settings('plexCompanion') == 'true':
|
||||||
self.companion_listener = plex_companion.Listener(self.companion_playstate_mgr)
|
self.companion_polling = plex_companion.Polling(self.companion_playstate_mgr)
|
||||||
else:
|
else:
|
||||||
self.companion_listener = None
|
self.companion_polling = None
|
||||||
|
|
||||||
# Main PKC program loop
|
# Main PKC program loop
|
||||||
while not self.should_cancel():
|
while not self.should_cancel():
|
||||||
|
@ -551,8 +552,8 @@ class Service(object):
|
||||||
self.pms_ws.start()
|
self.pms_ws.start()
|
||||||
self.sync.start()
|
self.sync.start()
|
||||||
self.companion_playstate_mgr.start()
|
self.companion_playstate_mgr.start()
|
||||||
if self.companion_listener is not None:
|
if self.companion_polling is not None:
|
||||||
self.companion_listener.start()
|
self.companion_polling.start()
|
||||||
self.alexa_ws.start()
|
self.alexa_ws.start()
|
||||||
|
|
||||||
elif app.APP.is_playing:
|
elif app.APP.is_playing:
|
||||||
|
|
|
@ -55,6 +55,7 @@ REGEX_PLEX_ID_FROM_URL = re.compile(r'''metadata%2F(\d+)''')
|
||||||
SAFE_URL_CHARACTERS = "%/:=&?~#+!$,;'@()*[]"
|
SAFE_URL_CHARACTERS = "%/:=&?~#+!$,;'@()*[]"
|
||||||
HTTP_DAV_FTP = re.compile(r'(http(s)?|dav(s)?|(s)?ftp)://((.+):(.+)@)?([\w\.]+)(:([\d]+))?/')
|
HTTP_DAV_FTP = re.compile(r'(http(s)?|dav(s)?|(s)?ftp)://((.+):(.+)@)?([\w\.]+)(:([\d]+))?/')
|
||||||
|
|
||||||
|
|
||||||
def garbageCollect():
|
def garbageCollect():
|
||||||
gc.collect(2)
|
gc.collect(2)
|
||||||
|
|
||||||
|
@ -556,11 +557,13 @@ def reset(ask_user=True):
|
||||||
reboot_kodi()
|
reboot_kodi()
|
||||||
|
|
||||||
|
|
||||||
def log_xml(xml, logger):
|
def log_xml(xml, logger, loglevel):
|
||||||
"""
|
"""
|
||||||
Logs an etree xml
|
Logs an etree xml. Pass the loglevel for which logging will happen, e.g.
|
||||||
|
loglevel=logging.DEBUG
|
||||||
"""
|
"""
|
||||||
string = undefused_etree.tostring(xml, encoding='utf-8')
|
if LOG.isEnabledFor(loglevel):
|
||||||
|
string = undefused_etree.tostring(xml, encoding='utf8')
|
||||||
string = string.decode('utf-8')
|
string = string.decode('utf-8')
|
||||||
logger('\n' + string)
|
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 = {
|
PLEX_REPEAT_FROM_KODI_REPEAT = {
|
||||||
'off': '0',
|
'off': '0',
|
||||||
'one': '1',
|
'one': '1',
|
||||||
|
|
|
@ -126,7 +126,6 @@ def on_error(ws, error):
|
||||||
ws.name, type(error), error)
|
ws.name, type(error), error)
|
||||||
# Status = Error
|
# Status = Error
|
||||||
utils.settings(status, value=utils.lang(257))
|
utils.settings(status, value=utils.lang(257))
|
||||||
raise RuntimeError
|
|
||||||
|
|
||||||
|
|
||||||
def on_close(ws):
|
def on_close(ws):
|
||||||
|
|
Loading…
Reference in a new issue