Completely new implementation for Plex Companion
This commit is contained in:
parent
16f3605dff
commit
2c74ddde88
15 changed files with 880 additions and 1511 deletions
|
@ -312,13 +312,16 @@ class PlaylistItem(object):
|
|||
Pass in the plex_stream_index [int] in order to receive the Kodi stream
|
||||
index [int].
|
||||
stream_type: 'video', 'audio', 'subtitle'
|
||||
Returns None if unsuccessful
|
||||
Raises ValueError if unsuccessful
|
||||
"""
|
||||
if plex_stream_index is None:
|
||||
return
|
||||
if not isinstance(plex_stream_index, int):
|
||||
raise ValueError('%s plex_stream_index %s of type %s received' %
|
||||
(stream_type, plex_stream_index, type(plex_stream_index)))
|
||||
for i, stream in enumerate(self._get_iterator(stream_type)):
|
||||
if cast(int, stream.get('id')) == plex_stream_index:
|
||||
return i
|
||||
raise ValueError('No %s kodi_stream_index for plex_stream_index %s' %
|
||||
(stream_type, plex_stream_index))
|
||||
|
||||
def active_plex_stream_index(self, stream_type):
|
||||
"""
|
||||
|
@ -457,27 +460,26 @@ class PlaylistItem(object):
|
|||
and kodi_sub_stream != self.current_kodi_sub_stream)):
|
||||
self.on_kodi_subtitle_stream_change(kodi_sub_stream, sub_enabled)
|
||||
|
||||
def on_plex_stream_change(self, plex_data):
|
||||
def on_plex_stream_change(self, video_stream_id=None, audio_stream_id=None,
|
||||
subtitle_stream_id=None):
|
||||
"""
|
||||
Call this method if Plex Companion wants to change streams
|
||||
Call this method if Plex Companion wants to change streams [ints]
|
||||
"""
|
||||
if 'audioStreamID' in plex_data:
|
||||
plex_index = int(plex_data['audioStreamID'])
|
||||
kodi_index = self.kodi_stream_index(plex_index, 'audio')
|
||||
self._set_kodi_stream_if_different(kodi_index, 'audio')
|
||||
self.current_kodi_audio_stream = kodi_index
|
||||
if 'videoStreamID' in plex_data:
|
||||
plex_index = int(plex_data['videoStreamID'])
|
||||
kodi_index = self.kodi_stream_index(plex_index, 'video')
|
||||
if video_stream_id is not None:
|
||||
kodi_index = self.kodi_stream_index(video_stream_id, 'video')
|
||||
self._set_kodi_stream_if_different(kodi_index, 'video')
|
||||
self.current_kodi_video_stream = kodi_index
|
||||
if 'subtitleStreamID' in plex_data:
|
||||
plex_index = int(plex_data['subtitleStreamID'])
|
||||
if plex_index == 0:
|
||||
if audio_stream_id is not None:
|
||||
kodi_index = self.kodi_stream_index(audio_stream_id, 'audio')
|
||||
self._set_kodi_stream_if_different(kodi_index, 'audio')
|
||||
self.current_kodi_audio_stream = kodi_index
|
||||
if subtitle_stream_id is not None:
|
||||
if subtitle_stream_id == 0:
|
||||
app.APP.player.showSubtitles(False)
|
||||
kodi_index = False
|
||||
else:
|
||||
kodi_index = self.kodi_stream_index(plex_index, 'subtitle')
|
||||
kodi_index = self.kodi_stream_index(subtitle_stream_id,
|
||||
'subtitle')
|
||||
if kodi_index:
|
||||
app.APP.player.setSubtitleStream(kodi_index)
|
||||
app.APP.player.showSubtitles(True)
|
||||
|
|
|
@ -1,362 +0,0 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
The Plex Companion master python file
|
||||
"""
|
||||
from logging import getLogger
|
||||
from threading import Thread
|
||||
from queue import Empty
|
||||
from socket import SHUT_RDWR
|
||||
from xbmc import executebuiltin
|
||||
|
||||
from .plexbmchelper import listener, plexgdm, subscribers, httppersist
|
||||
from .plex_api import API
|
||||
from . import utils
|
||||
from . import plex_functions as PF
|
||||
from . import playlist_func as PL
|
||||
from . import playback
|
||||
from . import json_rpc as js
|
||||
from . import playqueue as PQ
|
||||
from . import variables as v
|
||||
from . import backgroundthread
|
||||
from . import app
|
||||
from . import exceptions
|
||||
|
||||
###############################################################################
|
||||
|
||||
LOG = getLogger('PLEX.plex_companion')
|
||||
|
||||
###############################################################################
|
||||
|
||||
|
||||
def update_playqueue_from_PMS(playqueue,
|
||||
playqueue_id=None,
|
||||
repeat=None,
|
||||
offset=None,
|
||||
transient_token=None,
|
||||
start_plex_id=None):
|
||||
"""
|
||||
Completely updates the Kodi playqueue with the new Plex playqueue. Pass
|
||||
in playqueue_id if we need to fetch a new playqueue
|
||||
|
||||
repeat = 0, 1, 2
|
||||
offset = time offset in Plextime (milliseconds)
|
||||
"""
|
||||
LOG.info('New playqueue %s received from Plex companion with offset '
|
||||
'%s, repeat %s, start_plex_id %s',
|
||||
playqueue_id, offset, repeat, start_plex_id)
|
||||
# Safe transient token from being deleted
|
||||
if transient_token is None:
|
||||
transient_token = playqueue.plex_transient_token
|
||||
with app.APP.lock_playqueues:
|
||||
try:
|
||||
xml = PL.get_PMS_playlist(playqueue, playqueue_id)
|
||||
except exceptions.PlaylistError:
|
||||
LOG.error('Could now download playqueue %s', playqueue_id)
|
||||
return
|
||||
if playqueue.id == playqueue_id:
|
||||
# This seems to be happening ONLY if a Plex Companion device
|
||||
# reconnects and Kodi is already playing something - silly, really
|
||||
# For all other cases, a new playqueue is generated by Plex
|
||||
LOG.debug('Update for existing playqueue detected')
|
||||
return
|
||||
playqueue.clear()
|
||||
# Get new metadata for the playqueue first
|
||||
try:
|
||||
PL.get_playlist_details_from_xml(playqueue, xml)
|
||||
except exceptions.PlaylistError:
|
||||
LOG.error('Could not get playqueue ID %s', playqueue_id)
|
||||
return
|
||||
playqueue.repeat = 0 if not repeat else int(repeat)
|
||||
playqueue.plex_transient_token = transient_token
|
||||
playback.play_xml(playqueue,
|
||||
xml,
|
||||
offset=offset,
|
||||
start_plex_id=start_plex_id)
|
||||
|
||||
|
||||
class PlexCompanion(backgroundthread.KillableThread):
|
||||
"""
|
||||
Plex Companion monitoring class. Invoke only once
|
||||
"""
|
||||
def __init__(self):
|
||||
LOG.info("----===## Starting PlexCompanion ##===----")
|
||||
# Init Plex Companion queue
|
||||
# Start GDM for server/client discovery
|
||||
self.client = plexgdm.plexgdm()
|
||||
self.client.clientDetails()
|
||||
LOG.debug("Registration string is:\n%s", self.client.getClientDetails())
|
||||
self.httpd = False
|
||||
self.subscription_manager = None
|
||||
super(PlexCompanion, self).__init__()
|
||||
|
||||
@staticmethod
|
||||
def _process_alexa(data):
|
||||
if 'key' not in data or 'containerKey' not in data:
|
||||
LOG.error('Received malformed Alexa data: %s', data)
|
||||
return
|
||||
xml = PF.GetPlexMetadata(data['key'])
|
||||
try:
|
||||
xml[0].attrib
|
||||
except (AttributeError, IndexError, TypeError):
|
||||
LOG.error('Could not download Plex metadata for: %s', data)
|
||||
return
|
||||
api = API(xml[0])
|
||||
if api.plex_type == v.PLEX_TYPE_ALBUM:
|
||||
LOG.debug('Plex music album detected')
|
||||
PQ.init_playqueue_from_plex_children(
|
||||
api.plex_id,
|
||||
transient_token=data.get('token'))
|
||||
elif data['containerKey'].startswith('/playQueues/'):
|
||||
_, container_key, _ = PF.ParseContainerKey(data['containerKey'])
|
||||
xml = PF.DownloadChunks('{server}/playQueues/%s' % container_key)
|
||||
if xml is None:
|
||||
# "Play error"
|
||||
utils.dialog('notification',
|
||||
utils.lang(29999),
|
||||
utils.lang(30128),
|
||||
icon='{error}')
|
||||
return
|
||||
playqueue = PQ.get_playqueue_from_type(
|
||||
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[api.plex_type])
|
||||
playqueue.clear()
|
||||
PL.get_playlist_details_from_xml(playqueue, xml)
|
||||
playqueue.plex_transient_token = data.get('token')
|
||||
if data.get('offset') != '0':
|
||||
offset = float(data['offset']) / 1000.0
|
||||
else:
|
||||
offset = None
|
||||
playback.play_xml(playqueue, xml, offset)
|
||||
else:
|
||||
app.CONN.plex_transient_token = data.get('token')
|
||||
playback.playback_triage(api.plex_id,
|
||||
api.plex_type,
|
||||
resolve=False,
|
||||
resume=data.get('offset') not in ('0', None))
|
||||
|
||||
@staticmethod
|
||||
def _process_node(data):
|
||||
"""
|
||||
E.g. watch later initiated by Companion. Basically navigating Plex
|
||||
"""
|
||||
app.CONN.plex_transient_token = data.get('key')
|
||||
params = {
|
||||
'mode': 'plex_node',
|
||||
'key': f"{{server}}{data.get('key')}",
|
||||
'offset': data.get('offset')
|
||||
}
|
||||
handle = f'RunPlugin(plugin://{utils.extend_url(v.ADDON_ID, params)})'
|
||||
executebuiltin(handle)
|
||||
|
||||
@staticmethod
|
||||
def _process_playlist(data):
|
||||
if 'containerKey' not in data:
|
||||
LOG.error('Received malformed playlist data: %s', data)
|
||||
return
|
||||
# Get the playqueue ID
|
||||
_, container_key, query = PF.ParseContainerKey(data['containerKey'])
|
||||
try:
|
||||
playqueue = PQ.get_playqueue_from_type(
|
||||
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[data['type']])
|
||||
except KeyError:
|
||||
# E.g. Plex web does not supply the media type
|
||||
# Still need to figure out the type (video vs. music vs. pix)
|
||||
xml = PF.GetPlexMetadata(data['key'])
|
||||
try:
|
||||
xml[0].attrib
|
||||
except (AttributeError, IndexError, TypeError):
|
||||
LOG.error('Could not download Plex metadata')
|
||||
return
|
||||
api = API(xml[0])
|
||||
playqueue = PQ.get_playqueue_from_type(
|
||||
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[api.plex_type])
|
||||
key = data.get('key')
|
||||
if key:
|
||||
_, key, _ = PF.ParseContainerKey(key)
|
||||
update_playqueue_from_PMS(playqueue,
|
||||
playqueue_id=container_key,
|
||||
repeat=query.get('repeat'),
|
||||
offset=utils.cast(int, data.get('offset')),
|
||||
transient_token=data.get('token'),
|
||||
start_plex_id=key)
|
||||
|
||||
@staticmethod
|
||||
def _process_streams(data):
|
||||
"""
|
||||
Plex Companion client adjusted audio or subtitle stream
|
||||
"""
|
||||
if 'type' not in data:
|
||||
LOG.error('Received malformed stream data: %s', data)
|
||||
return
|
||||
playqueue = PQ.get_playqueue_from_type(
|
||||
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[data['type']])
|
||||
pos = js.get_position(playqueue.playlistid)
|
||||
playqueue.items[pos].on_plex_stream_change(data)
|
||||
|
||||
@staticmethod
|
||||
def _process_refresh(data):
|
||||
"""
|
||||
example data: {'playQueueID': '8475', 'commandID': '11'}
|
||||
"""
|
||||
if 'playQueueID' not in data:
|
||||
LOG.error('Received malformed refresh data: %s', data)
|
||||
return
|
||||
xml = PL.get_pms_playqueue(data['playQueueID'])
|
||||
if xml is None:
|
||||
return
|
||||
if len(xml) == 0:
|
||||
LOG.debug('Empty playqueue received - clearing playqueue')
|
||||
plex_type = PL.get_plextype_from_xml(xml)
|
||||
if plex_type is None:
|
||||
return
|
||||
playqueue = PQ.get_playqueue_from_type(
|
||||
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[plex_type])
|
||||
playqueue.clear()
|
||||
return
|
||||
playqueue = PQ.get_playqueue_from_type(
|
||||
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[xml[0].attrib['type']])
|
||||
update_playqueue_from_PMS(playqueue, data['playQueueID'])
|
||||
|
||||
def _process_tasks(self, task):
|
||||
"""
|
||||
Processes tasks picked up e.g. by Companion listener, e.g.
|
||||
{'action': 'playlist',
|
||||
'data': {'address': 'xyz.plex.direct',
|
||||
'commandID': '7',
|
||||
'containerKey': '/playQueues/6669?own=1&repeat=0&window=200',
|
||||
'key': '/library/metadata/220493',
|
||||
'machineIdentifier': 'xyz',
|
||||
'offset': '0',
|
||||
'port': '32400',
|
||||
'protocol': 'https',
|
||||
'token': 'transient-cd2527d1-0484-48e0-a5f7-f5caa7d591bd',
|
||||
'type': 'video'}}
|
||||
"""
|
||||
LOG.debug('Processing: %s', task)
|
||||
data = task['data']
|
||||
if task['action'] == 'alexa':
|
||||
with app.APP.lock_playqueues:
|
||||
self._process_alexa(data)
|
||||
elif (task['action'] == 'playlist' and
|
||||
data.get('address') == 'node.plexapp.com'):
|
||||
self._process_node(data)
|
||||
elif task['action'] == 'playlist':
|
||||
with app.APP.lock_playqueues:
|
||||
self._process_playlist(data)
|
||||
elif task['action'] == 'refreshPlayQueue':
|
||||
with app.APP.lock_playqueues:
|
||||
self._process_refresh(data)
|
||||
elif task['action'] == 'setStreams':
|
||||
try:
|
||||
self._process_streams(data)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
def run(self):
|
||||
"""
|
||||
Ensure that sockets will be closed no matter what
|
||||
"""
|
||||
app.APP.register_thread(self)
|
||||
try:
|
||||
self._run()
|
||||
finally:
|
||||
try:
|
||||
self.httpd.socket.shutdown(SHUT_RDWR)
|
||||
except AttributeError:
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
self.httpd.socket.close()
|
||||
except AttributeError:
|
||||
pass
|
||||
app.APP.deregister_thread(self)
|
||||
LOG.info("----===## Plex Companion stopped ##===----")
|
||||
|
||||
def _run(self):
|
||||
httpd = self.httpd
|
||||
# Cache for quicker while loops
|
||||
client = self.client
|
||||
|
||||
# Start up instances
|
||||
request_mgr = httppersist.RequestMgr()
|
||||
subscription_manager = subscribers.SubscriptionMgr(request_mgr,
|
||||
app.APP.player)
|
||||
self.subscription_manager = subscription_manager
|
||||
|
||||
if utils.settings('plexCompanion') == 'true':
|
||||
# Start up httpd
|
||||
start_count = 0
|
||||
while True:
|
||||
try:
|
||||
httpd = listener.PKCHTTPServer(
|
||||
client,
|
||||
subscription_manager,
|
||||
('', v.COMPANION_PORT),
|
||||
listener.MyHandler)
|
||||
httpd.timeout = 10.0
|
||||
break
|
||||
except Exception:
|
||||
LOG.error("Unable to start PlexCompanion. Traceback:")
|
||||
import traceback
|
||||
LOG.error(traceback.print_exc())
|
||||
app.APP.monitor.waitForAbort(3)
|
||||
if start_count == 3:
|
||||
LOG.error("Error: Unable to start web helper.")
|
||||
httpd = False
|
||||
break
|
||||
start_count += 1
|
||||
else:
|
||||
LOG.info('User deactivated Plex Companion')
|
||||
client.start_all()
|
||||
message_count = 0
|
||||
if httpd:
|
||||
thread = Thread(target=httpd.handle_request)
|
||||
|
||||
while not self.should_cancel():
|
||||
# If we are not authorized, sleep
|
||||
# Otherwise, we trigger a download which leads to a
|
||||
# re-authorizations
|
||||
if self.should_suspend():
|
||||
if self.wait_while_suspended():
|
||||
break
|
||||
try:
|
||||
message_count += 1
|
||||
if httpd:
|
||||
if not thread.is_alive():
|
||||
# Use threads cause the method will stall
|
||||
thread = Thread(target=httpd.handle_request)
|
||||
thread.start()
|
||||
|
||||
if message_count == 3000:
|
||||
message_count = 0
|
||||
if client.check_client_registration():
|
||||
LOG.debug('Client is still registered')
|
||||
else:
|
||||
LOG.debug('Client is no longer registered. Plex '
|
||||
'Companion still running on port %s',
|
||||
v.COMPANION_PORT)
|
||||
client.register_as_client()
|
||||
# Get and set servers
|
||||
if message_count % 30 == 0:
|
||||
subscription_manager.serverlist = client.getServerList()
|
||||
subscription_manager.notify()
|
||||
if not httpd:
|
||||
message_count = 0
|
||||
except Exception:
|
||||
LOG.warn("Error in loop, continuing anyway. Traceback:")
|
||||
import traceback
|
||||
LOG.warn(traceback.format_exc())
|
||||
# See if there's anything we need to process
|
||||
try:
|
||||
task = app.APP.companion_queue.get(block=False)
|
||||
except Empty:
|
||||
pass
|
||||
else:
|
||||
# Got instructions, process them
|
||||
self._process_tasks(task)
|
||||
app.APP.companion_queue.task_done()
|
||||
# Don't sleep
|
||||
continue
|
||||
self.sleep(0.05)
|
||||
subscription_manager.signal_stop()
|
||||
client.stop_all()
|
5
resources/lib/plex_companion/__init__.py
Normal file
5
resources/lib/plex_companion/__init__.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from .polling import Listener
|
||||
from .playstate import PlaystateMgr
|
33
resources/lib/plex_companion/common.py
Normal file
33
resources/lib/plex_companion/common.py
Normal file
|
@ -0,0 +1,33 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
from .. import variables as v
|
||||
from .. import app
|
||||
|
||||
|
||||
def log_error(logger, error_message, response):
|
||||
logger('%s: %s: %s', error_message, response.status_code, response.reason)
|
||||
logger('headers received from the PMS: %s', response.headers)
|
||||
logger('Message received from the PMS: %s', response.text)
|
||||
|
||||
|
||||
def proxy_headers():
|
||||
return {
|
||||
'X-Plex-Client-Identifier': v.PKC_MACHINE_IDENTIFIER,
|
||||
'X-Plex-Product': v.ADDON_NAME,
|
||||
'X-Plex-Version': v.ADDON_VERSION,
|
||||
'X-Plex-Platform': v.PLATFORM,
|
||||
'X-Plex-Platform-Version': v.PLATFORM_VERSION,
|
||||
'X-Plex-Device-Name': v.DEVICENAME,
|
||||
'Content-Type': 'text/xml;charset=utf-8'
|
||||
}
|
||||
|
||||
|
||||
def proxy_params():
|
||||
params = {
|
||||
'deviceClass': 'pc',
|
||||
'protocolCapabilities': 'timeline,playback,navigation,playqueues',
|
||||
'protocolVersion': 3
|
||||
}
|
||||
if app.ACCOUNT.pms_token:
|
||||
params['X-Plex-Token'] = app.ACCOUNT.pms_token
|
||||
return params
|
390
resources/lib/plex_companion/playstate.py
Normal file
390
resources/lib/plex_companion/playstate.py
Normal file
|
@ -0,0 +1,390 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
from logging import getLogger
|
||||
import requests
|
||||
import xml.etree.ElementTree as etree
|
||||
|
||||
from .common import proxy_headers, proxy_params, log_error
|
||||
|
||||
from .. import json_rpc as js
|
||||
from .. import variables as v
|
||||
from .. import backgroundthread
|
||||
from .. import app
|
||||
from .. import timing
|
||||
from .. import playqueue as PQ
|
||||
|
||||
|
||||
# Disable annoying requests warnings
|
||||
import requests.packages.urllib3
|
||||
requests.packages.urllib3.disable_warnings()
|
||||
|
||||
log = getLogger('PLEX.companion.playstate')
|
||||
|
||||
TIMEOUT = (5, 5)
|
||||
|
||||
# What is Companion controllable?
|
||||
CONTROLLABLE = {
|
||||
v.PLEX_PLAYLIST_TYPE_VIDEO: 'playPause,stop,volume,shuffle,audioStream,'
|
||||
'subtitleStream,seekTo,skipPrevious,skipNext,'
|
||||
'stepBack,stepForward',
|
||||
v.PLEX_PLAYLIST_TYPE_AUDIO: 'playPause,stop,volume,shuffle,repeat,seekTo,'
|
||||
'skipPrevious,skipNext,stepBack,stepForward',
|
||||
v.PLEX_PLAYLIST_TYPE_PHOTO: 'playPause,stop,skipPrevious,skipNext'
|
||||
}
|
||||
|
||||
|
||||
def split_server_uri(server):
|
||||
(protocol, url, port) = server.split(':')
|
||||
url = url.replace('/', '')
|
||||
return (protocol, url, port)
|
||||
|
||||
|
||||
def get_correct_position(info, playqueue):
|
||||
"""
|
||||
Kodi tells us the PLAYLIST position, not PLAYQUEUE position, if the
|
||||
user initiated playback of a playlist
|
||||
"""
|
||||
if playqueue.kodi_playlist_playback:
|
||||
position = 0
|
||||
else:
|
||||
position = info['position'] or 0
|
||||
return position
|
||||
|
||||
|
||||
def timeline_dict(playerid, typus):
|
||||
with app.APP.lock_playqueues:
|
||||
info = app.PLAYSTATE.player_states[playerid]
|
||||
playqueue = PQ.PLAYQUEUES[playerid]
|
||||
position = get_correct_position(info, playqueue)
|
||||
try:
|
||||
item = playqueue.items[position]
|
||||
except IndexError:
|
||||
# E.g. for direct path playback for single item
|
||||
return {
|
||||
'controllable': CONTROLLABLE[typus],
|
||||
'type': typus,
|
||||
'state': 'stopped'
|
||||
}
|
||||
protocol, url, port = split_server_uri(app.CONN.server)
|
||||
status = 'paused' if int(info['speed']) == 0 else 'playing'
|
||||
duration = timing.kodi_time_to_millis(info['totaltime'])
|
||||
shuffle = '1' if info['shuffled'] else '0'
|
||||
mute = '1' if info['muted'] is True else '0'
|
||||
answ = {
|
||||
'controllable': CONTROLLABLE[typus],
|
||||
'protocol': protocol,
|
||||
'address': url,
|
||||
'port': port,
|
||||
'machineIdentifier': app.CONN.machine_identifier,
|
||||
'state': status,
|
||||
'type': typus,
|
||||
'itemType': typus,
|
||||
'time': str(timing.kodi_time_to_millis(info['time'])),
|
||||
'duration': str(duration),
|
||||
'seekRange': '0-%s' % duration,
|
||||
'shuffle': shuffle,
|
||||
'repeat': v.PLEX_REPEAT_FROM_KODI_REPEAT[info['repeat']],
|
||||
'volume': str(info['volume']),
|
||||
'mute': mute,
|
||||
'mediaIndex': '0', # Still to implement
|
||||
'partIndex': '0',
|
||||
'partCount': '1',
|
||||
'providerIdentifier': 'com.plexapp.plugins.library',
|
||||
}
|
||||
# Get the plex id from the PKC playqueue not info, as Kodi jumps to
|
||||
# next playqueue element way BEFORE kodi monitor onplayback is
|
||||
# called
|
||||
if item.plex_id:
|
||||
answ['key'] = '/library/metadata/%s' % item.plex_id
|
||||
answ['ratingKey'] = str(item.plex_id)
|
||||
# PlayQueue stuff
|
||||
if info['container_key']:
|
||||
answ['containerKey'] = info['container_key']
|
||||
if (info['container_key'] is not None and
|
||||
info['container_key'].startswith('/playQueues')):
|
||||
answ['playQueueID'] = str(playqueue.id)
|
||||
answ['playQueueVersion'] = str(playqueue.version)
|
||||
answ['playQueueItemID'] = str(item.id)
|
||||
if playqueue.items[position].guid:
|
||||
answ['guid'] = item.guid
|
||||
# Temp. token set?
|
||||
if app.CONN.plex_transient_token:
|
||||
answ['token'] = app.CONN.plex_transient_token
|
||||
elif playqueue.plex_transient_token:
|
||||
answ['token'] = playqueue.plex_transient_token
|
||||
# Process audio and subtitle streams
|
||||
if typus == v.PLEX_PLAYLIST_TYPE_VIDEO:
|
||||
answ['videoStreamID'] = str(item.current_plex_video_stream)
|
||||
answ['audioStreamID'] = str(item.current_plex_audio_stream)
|
||||
# Mind the zero - meaning subs are deactivated
|
||||
answ['subtitleStreamID'] = str(item.current_plex_sub_stream or 0)
|
||||
return answ
|
||||
|
||||
|
||||
def timeline(players):
|
||||
"""
|
||||
Returns a timeline xml as str
|
||||
(xml containing video, audio, photo player state)
|
||||
"""
|
||||
xml = etree.Element('MediaContainer')
|
||||
location = 'navigation'
|
||||
for typus in (v.PLEX_PLAYLIST_TYPE_AUDIO,
|
||||
v.PLEX_PLAYLIST_TYPE_VIDEO,
|
||||
v.PLEX_PLAYLIST_TYPE_PHOTO):
|
||||
player = players.get(v.KODI_PLAYLIST_TYPE_FROM_PLEX_PLAYLIST_TYPE[typus])
|
||||
if player is None:
|
||||
# Kodi player currently not actively playing, but stopped
|
||||
timeline = {
|
||||
'controllable': CONTROLLABLE[typus],
|
||||
'type': typus,
|
||||
'state': 'stopped'
|
||||
}
|
||||
else:
|
||||
# Active Kodi player, i.e. video, audio or picture player
|
||||
timeline = timeline_dict(player['playerid'], typus)
|
||||
if typus in (v.PLEX_PLAYLIST_TYPE_VIDEO, v.PLEX_PLAYLIST_TYPE_PHOTO):
|
||||
location = 'fullScreenVideo'
|
||||
etree.SubElement(xml, 'Timeline', attrib=timeline)
|
||||
xml.set('location', location)
|
||||
return xml
|
||||
|
||||
|
||||
def stopped_timeline():
|
||||
"""
|
||||
Returns an XML stating that all players have stopped playback
|
||||
"""
|
||||
xml = etree.Element('MediaContainer', attrib={'location': 'navigation'})
|
||||
for typus in (v.PLEX_PLAYLIST_TYPE_AUDIO,
|
||||
v.PLEX_PLAYLIST_TYPE_VIDEO,
|
||||
v.PLEX_PLAYLIST_TYPE_PHOTO):
|
||||
# Kodi player currently not actively playing, but stopped
|
||||
timeline = {
|
||||
'controllable': CONTROLLABLE[typus],
|
||||
'type': typus,
|
||||
'state': 'stopped'
|
||||
}
|
||||
etree.SubElement(xml, 'Timeline', attrib=timeline)
|
||||
return xml
|
||||
|
||||
|
||||
def update_player_info(players):
|
||||
"""
|
||||
Update the playstate info for other PKC "consumers"
|
||||
"""
|
||||
for player in players.values():
|
||||
playerid = player['playerid']
|
||||
app.PLAYSTATE.player_states[playerid].update(js.get_player_props(playerid))
|
||||
app.PLAYSTATE.player_states[playerid]['volume'] = js.get_volume()
|
||||
app.PLAYSTATE.player_states[playerid]['muted'] = js.get_muted()
|
||||
|
||||
|
||||
class PlaystateMgr(backgroundthread.KillableThread):
|
||||
"""
|
||||
If Kodi plays something, tell the PMS about it and - if a Companion client
|
||||
is connected - tell the PMS Plex Companion piece of the PMS about it.
|
||||
Also checks whether an intro is currently playing, enabling the user to
|
||||
skip it.
|
||||
"""
|
||||
daemon = True
|
||||
|
||||
def __init__(self):
|
||||
self._subscribed = False
|
||||
self._command_id = None
|
||||
self.s = None
|
||||
self.t = None
|
||||
self.stopped_timeline = stopped_timeline()
|
||||
super().__init__()
|
||||
|
||||
def _get_requests_session(self):
|
||||
if self.s is None:
|
||||
log.debug('Creating new requests session')
|
||||
self.s = requests.Session()
|
||||
self.s.headers = proxy_headers()
|
||||
self.s.verify = app.CONN.verify_ssl_cert
|
||||
if app.CONN.ssl_cert_path:
|
||||
self.s.cert = app.CONN.ssl_cert_path
|
||||
self.s.params = proxy_params()
|
||||
return self.s
|
||||
|
||||
def _get_requests_session_companion(self):
|
||||
if self.t is None:
|
||||
log.debug('Creating new companion requests session')
|
||||
self.t = requests.Session()
|
||||
self.t.headers = proxy_headers()
|
||||
self.t.verify = app.CONN.verify_ssl_cert
|
||||
if app.CONN.ssl_cert_path:
|
||||
self.t.cert = app.CONN.ssl_cert_path
|
||||
self.t.params = proxy_params()
|
||||
return self.t
|
||||
|
||||
def close_requests_session(self):
|
||||
for session in (self.s, self.t):
|
||||
if session is not None:
|
||||
try:
|
||||
session.close()
|
||||
except AttributeError:
|
||||
# "thread-safety" - Just in case s was set to None in the
|
||||
# meantime
|
||||
pass
|
||||
session = None
|
||||
|
||||
@staticmethod
|
||||
def communicate(method, url, **kwargs):
|
||||
try:
|
||||
# This will usually block until timeout is reached!
|
||||
req = method(url, **kwargs)
|
||||
except requests.ConnectTimeout:
|
||||
# The request timed out while trying to connect to the PMS
|
||||
log.error('Requests ConnectionTimeout!')
|
||||
raise
|
||||
except requests.ReadTimeout:
|
||||
# The PMS did not send any data in the allotted amount of time
|
||||
log.error('Requests ReadTimeout!')
|
||||
raise
|
||||
except requests.TooManyRedirects:
|
||||
log.error('TooManyRedirects error!')
|
||||
raise
|
||||
except requests.HTTPError as error:
|
||||
log.error('HTTPError: %s', error)
|
||||
raise
|
||||
except requests.ConnectionError as error:
|
||||
log.error('ConnectionError: %s', error)
|
||||
raise
|
||||
req.encoding = 'utf-8'
|
||||
# To make sure that we release the socket, need to access content once
|
||||
req.content
|
||||
return req
|
||||
|
||||
def _subscribe(self, cmd):
|
||||
self._command_id = int(cmd.get('commandID'))
|
||||
self._subscribed = True
|
||||
|
||||
def _unsubscribe(self):
|
||||
self._subscribed = False
|
||||
self._command_id = None
|
||||
|
||||
def send_stop(self):
|
||||
"""
|
||||
If we're still connected to a PMS, tells the PMS that playback stopped
|
||||
"""
|
||||
if app.CONN.online and app.ACCOUNT.authenticated:
|
||||
# Only try to send something if we're connected
|
||||
self.pms_timeline(dict(), self.stopped_timeline)
|
||||
self.companion_timeline(self.stopped_timeline)
|
||||
|
||||
def check_subscriber(self, cmd):
|
||||
if cmd.get('path') == '/player/timeline/unsubscribe':
|
||||
log.info('Stop Plex Companion subscription')
|
||||
self._unsubscribe()
|
||||
elif not self._subscribed:
|
||||
log.info('Start Plex Companion subscription')
|
||||
self._subscribe(cmd)
|
||||
else:
|
||||
try:
|
||||
self._command_id = int(cmd.get('commandID'))
|
||||
except TypeError:
|
||||
pass
|
||||
|
||||
def companion_timeline(self, message):
|
||||
if not self._subscribed:
|
||||
return
|
||||
url = f'{app.CONN.server}/player/proxy/timeline'
|
||||
self._get_requests_session_companion()
|
||||
self.t.params['commandID'] = self._command_id
|
||||
message.set('commandID', str(self._command_id))
|
||||
# Get the correct playstate
|
||||
state = 'stopped'
|
||||
for timeline in message:
|
||||
if timeline.get('state') != 'stopped':
|
||||
state = timeline.get('state')
|
||||
self.t.params['state'] = state
|
||||
# Send update
|
||||
try:
|
||||
req = self.communicate(self.t.post,
|
||||
url,
|
||||
data=etree.tostring(message,
|
||||
encoding='utf-8'),
|
||||
timeout=TIMEOUT)
|
||||
except (requests.RequestException, SystemExit):
|
||||
return
|
||||
if not req.ok:
|
||||
log_error(log.error, 'Unexpected Companion timeline', req)
|
||||
|
||||
def pms_timeline_per_player(self, playerid, message):
|
||||
"""
|
||||
Pass a really low timeout in seconds if shutting down Kodi and we don't
|
||||
need the PMS' response
|
||||
"""
|
||||
url = f'{app.CONN.server}/:/timeline'
|
||||
self._get_requests_session()
|
||||
self.s.params.update(message[playerid].attrib)
|
||||
# Tell the PMS about our playstate progress
|
||||
try:
|
||||
req = self.communicate(self.s.get, url, timeout=TIMEOUT)
|
||||
except (requests.RequestException, SystemExit):
|
||||
return
|
||||
if not req.ok:
|
||||
log_error(log.error, 'Failed reporting playback progress', req)
|
||||
|
||||
def pms_timeline(self, players, message):
|
||||
players = players if players else \
|
||||
{0: {'playerid': 0}, 1: {'playerid': 1}, 2: {'playerid': 2}}
|
||||
for player in players.values():
|
||||
self.pms_timeline_per_player(player['playerid'], message)
|
||||
|
||||
def run(self):
|
||||
app.APP.register_thread(self)
|
||||
log.info("----===## Starting PlaystateMgr ##===----")
|
||||
try:
|
||||
self._run()
|
||||
finally:
|
||||
# Make sure we're telling the PMS that playback will stop
|
||||
self.send_stop()
|
||||
# Cleanup
|
||||
self.close_requests_session()
|
||||
app.APP.deregister_thread(self)
|
||||
log.info("----===## PlaystateMgr stopped ##===----")
|
||||
|
||||
def _run(self):
|
||||
signaled_playback_stop = True
|
||||
while not self.should_cancel():
|
||||
if self.should_suspend():
|
||||
self._unsubscribe()
|
||||
self.close_requests_session()
|
||||
if self.wait_while_suspended():
|
||||
break
|
||||
# We will only become active if there's Kodi playback going on
|
||||
players = js.get_players()
|
||||
if not players and signaled_playback_stop:
|
||||
self.sleep(1)
|
||||
continue
|
||||
elif not players:
|
||||
# Playback has just stopped, need to tell Plex
|
||||
signaled_playback_stop = True
|
||||
self.send_stop()
|
||||
self.sleep(1)
|
||||
continue
|
||||
else:
|
||||
# Update the playstate info, such as playback progress
|
||||
update_player_info(players)
|
||||
try:
|
||||
message = timeline(players)
|
||||
except TypeError:
|
||||
# We haven't had a chance to set the kodi_stream_index for
|
||||
# the currently playing item. Just skip for now
|
||||
self.sleep(1)
|
||||
continue
|
||||
else:
|
||||
# Kodi will started with 'stopped' - make sure we're
|
||||
# waiting here until we got something playing or on pause.
|
||||
for entry in message:
|
||||
if entry.get('state') != 'stopped':
|
||||
break
|
||||
else:
|
||||
continue
|
||||
signaled_playback_stop = False
|
||||
# Send the playback progress info to the PMS
|
||||
self.pms_timeline(players, message)
|
||||
# Send the info to all Companion devices via the PMS
|
||||
self.companion_timeline(message)
|
||||
self.sleep(1)
|
172
resources/lib/plex_companion/polling.py
Normal file
172
resources/lib/plex_companion/polling.py
Normal file
|
@ -0,0 +1,172 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
from logging import getLogger
|
||||
import requests
|
||||
|
||||
from .processing import process_proxy_xml
|
||||
from .common import proxy_headers, proxy_params, log_error
|
||||
|
||||
from .. import utils
|
||||
from .. import backgroundthread
|
||||
from .. import app
|
||||
from .. import variables as v
|
||||
|
||||
# Disable annoying requests warnings
|
||||
import requests.packages.urllib3
|
||||
requests.packages.urllib3.disable_warnings()
|
||||
|
||||
# Timeout (connection timeout, read timeout)
|
||||
# The later is up to 20 seconds, if the PMS has nothing to tell us
|
||||
# THIS WILL PREVENT PKC FROM SHUTTING DOWN CORRECTLY
|
||||
TIMEOUT = (5.0, 3.0)
|
||||
|
||||
log = getLogger('PLEX.companion.listener')
|
||||
|
||||
|
||||
class Listener(backgroundthread.KillableThread):
|
||||
"""
|
||||
Opens a GET HTTP connection to the current PMS (that will time-out PMS-wise
|
||||
after ~20 seconds) and listens for any commands by the PMS. Listening
|
||||
will cause this PKC client to be registered as a Plex Companien client.
|
||||
"""
|
||||
daemon = True
|
||||
|
||||
def __init__(self, playstate_mgr):
|
||||
self.s = None
|
||||
self.playstate_mgr = playstate_mgr
|
||||
super().__init__()
|
||||
|
||||
def _get_requests_session(self):
|
||||
if self.s is None:
|
||||
log.debug('Creating new requests session')
|
||||
self.s = requests.Session()
|
||||
self.s.headers = proxy_headers()
|
||||
self.s.verify = app.CONN.verify_ssl_cert
|
||||
if app.CONN.ssl_cert_path:
|
||||
self.s.cert = app.CONN.ssl_cert_path
|
||||
self.s.params = proxy_params()
|
||||
return self.s
|
||||
|
||||
def close_requests_session(self):
|
||||
try:
|
||||
self.s.close()
|
||||
except AttributeError:
|
||||
# "thread-safety" - Just in case s was set to None in the
|
||||
# meantime
|
||||
pass
|
||||
self.s = None
|
||||
|
||||
def ok_message(self, command_id):
|
||||
url = f'{app.CONN.server}/player/proxy/response?commandID={command_id}'
|
||||
try:
|
||||
req = self.communicate(self.s.post,
|
||||
url,
|
||||
data=v.COMPANION_OK_MESSAGE.encode('utf-8'))
|
||||
except (requests.RequestException, SystemExit):
|
||||
return
|
||||
if not req.ok:
|
||||
log_error(log.error, 'Error replying OK', req)
|
||||
|
||||
@staticmethod
|
||||
def communicate(method, url, **kwargs):
|
||||
try:
|
||||
req = method(url, **kwargs)
|
||||
except requests.ConnectTimeout:
|
||||
# The request timed out while trying to connect to the PMS
|
||||
log.error('Requests ConnectionTimeout!')
|
||||
raise
|
||||
except requests.ReadTimeout:
|
||||
# The PMS did not send any data in the allotted amount of time
|
||||
log.error('Requests ReadTimeout!')
|
||||
raise
|
||||
except requests.TooManyRedirects:
|
||||
log.error('TooManyRedirects error!')
|
||||
raise
|
||||
except requests.HTTPError as error:
|
||||
log.error('HTTPError: %s', error)
|
||||
raise
|
||||
except requests.ConnectionError:
|
||||
# Caused by PKC terminating the connection prematurely
|
||||
# log.error('ConnectionError: %s', error)
|
||||
raise
|
||||
else:
|
||||
req.encoding = 'utf-8'
|
||||
# Access response content once in order to make sure to release the
|
||||
# underlying sockets
|
||||
req.content
|
||||
return req
|
||||
|
||||
def run(self):
|
||||
"""
|
||||
Ensure that sockets will be closed no matter what
|
||||
"""
|
||||
app.APP.register_thread(self)
|
||||
log.info("----===## Starting PollCompanion ##===----")
|
||||
try:
|
||||
self._run()
|
||||
finally:
|
||||
self.close_requests_session()
|
||||
app.APP.deregister_thread(self)
|
||||
log.info("----===## PollCompanion stopped ##===----")
|
||||
|
||||
def _run(self):
|
||||
while not self.should_cancel():
|
||||
if self.should_suspend():
|
||||
self.close_requests_session()
|
||||
if self.wait_while_suspended():
|
||||
break
|
||||
# See if there's anything we need to process
|
||||
# timeout=1 will cause the PMS to "hold" the connection for approx
|
||||
# 20 seconds. This will BLOCK requests - not something we can
|
||||
# circumvent.
|
||||
url = app.CONN.server + '/player/proxy/poll?timeout=1'
|
||||
self._get_requests_session()
|
||||
try:
|
||||
req = self.communicate(self.s.get,
|
||||
url,
|
||||
timeout=TIMEOUT)
|
||||
except requests.ConnectionError:
|
||||
# No command received from the PMS - try again immediately
|
||||
continue
|
||||
except requests.RequestException:
|
||||
self.sleep(0.5)
|
||||
continue
|
||||
except SystemExit:
|
||||
# We need to quit PKC entirely
|
||||
break
|
||||
|
||||
# Sanity checks
|
||||
if not req.ok:
|
||||
log_error(log.error, 'Error while contacting the PMS', req)
|
||||
self.sleep(0.5)
|
||||
continue
|
||||
if not req.text:
|
||||
# Means the connection timed-out (usually after 20 seconds),
|
||||
# because there was no command from the PMS or a client to
|
||||
# remote-control anything no the PKC-side
|
||||
# Received an empty body, but still header Content-Type: xml
|
||||
continue
|
||||
if not ('content-type' in req.headers
|
||||
and 'xml' in req.headers['content-type']):
|
||||
log_error(log.error, 'Unexpected answer from the PMS', req)
|
||||
self.sleep(0.5)
|
||||
continue
|
||||
|
||||
# Parsing
|
||||
try:
|
||||
xml = utils.etree.fromstring(req.content)
|
||||
cmd = xml[0]
|
||||
if len(xml) > 1:
|
||||
# We should always just get ONE command per message
|
||||
raise IndexError()
|
||||
except (utils.ParseError, IndexError):
|
||||
log_error(log.error, 'Could not parse the PMS xml:', req)
|
||||
self.sleep(0.5)
|
||||
continue
|
||||
|
||||
# Do the work
|
||||
log.debug('Received a Plex Companion command from the PMS:')
|
||||
utils.log_xml(xml, log.debug)
|
||||
self.playstate_mgr.check_subscriber(cmd)
|
||||
if process_proxy_xml(cmd):
|
||||
self.ok_message(cmd.get('commandID'))
|
241
resources/lib/plex_companion/processing.py
Normal file
241
resources/lib/plex_companion/processing.py
Normal file
|
@ -0,0 +1,241 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
The Plex Companion master python file
|
||||
"""
|
||||
from logging import getLogger
|
||||
|
||||
import xbmc
|
||||
|
||||
from ..plex_api import API
|
||||
from .. import utils
|
||||
from ..utils import cast
|
||||
from .. import plex_functions as PF
|
||||
from .. import playlist_func as PL
|
||||
from .. import playback
|
||||
from .. import json_rpc as js
|
||||
from .. import playqueue as PQ
|
||||
from .. import variables as v
|
||||
from .. import app
|
||||
from .. import exceptions
|
||||
|
||||
|
||||
log = getLogger('PLEX.companion.processing')
|
||||
|
||||
|
||||
def update_playqueue_from_PMS(playqueue,
|
||||
playqueue_id=None,
|
||||
repeat=None,
|
||||
offset=None,
|
||||
transient_token=None,
|
||||
start_plex_id=None):
|
||||
"""
|
||||
Completely updates the Kodi playqueue with the new Plex playqueue. Pass
|
||||
in playqueue_id if we need to fetch a new playqueue
|
||||
|
||||
repeat = 0, 1, 2
|
||||
offset = time offset in Plextime (milliseconds)
|
||||
"""
|
||||
log.info('New playqueue %s received from Plex companion with offset '
|
||||
'%s, repeat %s, start_plex_id %s',
|
||||
playqueue_id, offset, repeat, start_plex_id)
|
||||
# Safe transient token from being deleted
|
||||
if transient_token is None:
|
||||
transient_token = playqueue.plex_transient_token
|
||||
with app.APP.lock_playqueues:
|
||||
try:
|
||||
xml = PL.get_PMS_playlist(playqueue, playqueue_id)
|
||||
except exceptions.PlaylistError:
|
||||
log.error('Could now download playqueue %s', playqueue_id)
|
||||
return
|
||||
if playqueue.id == playqueue_id:
|
||||
# This seems to be happening ONLY if a Plex Companion device
|
||||
# reconnects and Kodi is already playing something - silly, really
|
||||
# For all other cases, a new playqueue is generated by Plex
|
||||
log.debug('Update for existing playqueue detected')
|
||||
return
|
||||
playqueue.clear()
|
||||
# Get new metadata for the playqueue first
|
||||
try:
|
||||
PL.get_playlist_details_from_xml(playqueue, xml)
|
||||
except exceptions.PlaylistError:
|
||||
log.error('Could not get playqueue ID %s', playqueue_id)
|
||||
return
|
||||
playqueue.repeat = 0 if not repeat else int(repeat)
|
||||
playqueue.plex_transient_token = transient_token
|
||||
playback.play_xml(playqueue,
|
||||
xml,
|
||||
offset=offset,
|
||||
start_plex_id=start_plex_id)
|
||||
|
||||
|
||||
def process_node(key, transient_token, offset):
|
||||
"""
|
||||
E.g. watch later initiated by Companion. Basically navigating Plex
|
||||
"""
|
||||
app.CONN.plex_transient_token = transient_token
|
||||
params = {
|
||||
'mode': 'plex_node',
|
||||
'key': f'{{server}}{key}',
|
||||
'offset': offset
|
||||
}
|
||||
handle = f'RunPlugin(plugin://{utils.extend_url(v.ADDON_ID, params)})'
|
||||
xbmc.executebuiltin(handle)
|
||||
|
||||
|
||||
def process_playlist(containerKey, typus, key, offset, token):
|
||||
# Get the playqueue ID
|
||||
_, container_key, query = PF.ParseContainerKey(containerKey)
|
||||
try:
|
||||
playqueue = PQ.get_playqueue_from_type(
|
||||
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[typus])
|
||||
except KeyError:
|
||||
# E.g. Plex web does not supply the media type
|
||||
# Still need to figure out the type (video vs. music vs. pix)
|
||||
xml = PF.GetPlexMetadata(key)
|
||||
try:
|
||||
xml[0].attrib
|
||||
except (AttributeError, IndexError, TypeError):
|
||||
log.error('Could not download Plex metadata')
|
||||
return
|
||||
api = API(xml[0])
|
||||
playqueue = PQ.get_playqueue_from_type(
|
||||
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[api.plex_type])
|
||||
if key:
|
||||
_, key, _ = PF.ParseContainerKey(key)
|
||||
update_playqueue_from_PMS(playqueue,
|
||||
playqueue_id=container_key,
|
||||
repeat=query.get('repeat'),
|
||||
offset=utils.cast(int, offset),
|
||||
transient_token=token,
|
||||
start_plex_id=key)
|
||||
|
||||
|
||||
def process_streams(typus, video_stream_id, audio_stream_id, subtitle_stream_id):
|
||||
"""
|
||||
Plex Companion client adjusted audio or subtitle stream
|
||||
"""
|
||||
playqueue = PQ.get_playqueue_from_type(
|
||||
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[typus])
|
||||
pos = js.get_position(playqueue.playlistid)
|
||||
playqueue.items[pos].on_plex_stream_change(video_stream_id,
|
||||
audio_stream_id,
|
||||
subtitle_stream_id)
|
||||
|
||||
|
||||
def process_refresh(playqueue_id):
|
||||
"""
|
||||
example data: {'playQueueID': '8475', 'commandID': '11'}
|
||||
"""
|
||||
xml = PL.get_pms_playqueue(playqueue_id)
|
||||
if xml is None:
|
||||
return
|
||||
if len(xml) == 0:
|
||||
log.debug('Empty playqueue received - clearing playqueue')
|
||||
plex_type = PL.get_plextype_from_xml(xml)
|
||||
if plex_type is None:
|
||||
return
|
||||
playqueue = PQ.get_playqueue_from_type(
|
||||
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[plex_type])
|
||||
playqueue.clear()
|
||||
return
|
||||
playqueue = PQ.get_playqueue_from_type(
|
||||
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[xml[0].attrib['type']])
|
||||
update_playqueue_from_PMS(playqueue, playqueue_id)
|
||||
|
||||
|
||||
def skip_to(playqueue_item_id, key):
|
||||
"""
|
||||
Skip to a specific playlist position.
|
||||
|
||||
Does not seem to be implemented yet by Plex!
|
||||
"""
|
||||
_, plex_id = PF.GetPlexKeyNumber(key)
|
||||
log.debug('Skipping to playQueueItemID %s, plex_id %s',
|
||||
playqueue_item_id, plex_id)
|
||||
found = True
|
||||
for player in list(js.get_players().values()):
|
||||
playqueue = PQ.PLAYQUEUES[player['playerid']]
|
||||
for i, item in enumerate(playqueue.items):
|
||||
if item.id == playqueue_item_id:
|
||||
found = True
|
||||
break
|
||||
else:
|
||||
for i, item in enumerate(playqueue.items):
|
||||
if item.plex_id == plex_id:
|
||||
found = True
|
||||
break
|
||||
if found is True:
|
||||
app.APP.player.play(playqueue.kodi_pl, None, False, i)
|
||||
else:
|
||||
log.error('Item not found to skip to')
|
||||
|
||||
|
||||
def process_proxy_xml(cmd):
|
||||
"""cmd: a "Command" etree xml"""
|
||||
path = cmd.get('path')
|
||||
if (path == '/player/playback/playMedia'
|
||||
and cmd.get('queryAddress') == 'node.plexapp.com'):
|
||||
process_node(cmd.get('queryKey'),
|
||||
cmd.get('queryToken'),
|
||||
cmd.get('queryOffset') or 0)
|
||||
elif path == '/player/playback/playMedia':
|
||||
with app.APP.lock_playqueues:
|
||||
process_playlist(cmd.get('queryContainerKey'),
|
||||
cmd.get('queryType'),
|
||||
cmd.get('queryKey'),
|
||||
cmd.get('queryOffset'),
|
||||
cmd.get('queryToken'))
|
||||
elif path == '/player/playback/refreshPlayQueue':
|
||||
with app.APP.lock_playqueues:
|
||||
process_refresh(cmd.get('queryPlayQueueID'))
|
||||
elif path == '/player/playback/setParameters':
|
||||
if 'queryVolume' in cmd.attrib:
|
||||
js.set_volume(int(cmd.get('queryVolume')))
|
||||
else:
|
||||
log.error('Unknown command: %s: %s', cmd.tag, cmd.attrib)
|
||||
elif path == '/player/playback/play':
|
||||
js.play()
|
||||
elif path == '/player/playback/pause':
|
||||
js.pause()
|
||||
elif path == '/player/playback/stop':
|
||||
js.stop()
|
||||
elif path == '/player/playback/seekTo':
|
||||
js.seek_to(float(cmd.get('queryOffset', 0.0)) / 1000.0)
|
||||
elif path == '/player/playback/stepForward':
|
||||
js.smallforward()
|
||||
elif path == '/player/playback/stepBack':
|
||||
js.smallbackward()
|
||||
elif path == '/player/playback/skipNext':
|
||||
js.skipnext()
|
||||
elif path == '/player/playback/skipPrevious':
|
||||
js.skipprevious()
|
||||
elif path == '/player/playback/skipTo':
|
||||
skip_to(cmd.get('queryPlayQueueItemID'), cmd.get('queryKey'))
|
||||
elif path == '/player/navigation/moveUp':
|
||||
js.input_up()
|
||||
elif path == '/player/navigation/moveDown':
|
||||
js.input_down()
|
||||
elif path == '/player/navigation/moveLeft':
|
||||
js.input_left()
|
||||
elif path == '/player/navigation/moveRight':
|
||||
js.input_right()
|
||||
elif path == '/player/navigation/select':
|
||||
js.input_select()
|
||||
elif path == '/player/navigation/home':
|
||||
js.input_home()
|
||||
elif path == '/player/navigation/back':
|
||||
js.input_back()
|
||||
elif path == '/player/playback/setStreams':
|
||||
process_streams(cmd.get('queryType'),
|
||||
cast(int, cmd.get('queryVideoStreamID')),
|
||||
cast(int, cmd.get('queryAudioStreamID')),
|
||||
cast(int, cmd.get('querySubtitleStreamID')))
|
||||
elif path == '/player/timeline/subscribe':
|
||||
pass
|
||||
elif path == '/player/timeline/unsubscribe':
|
||||
pass
|
||||
else:
|
||||
log.error('Unknown Plex companion path/command: %s: %s',
|
||||
cmd.tag, cmd.attrib)
|
||||
return True
|
|
@ -1 +0,0 @@
|
|||
# Dummy file to make this directory a package.
|
|
@ -1,105 +0,0 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
from logging import getLogger
|
||||
import http.client
|
||||
import traceback
|
||||
import string
|
||||
import errno
|
||||
from socket import error as socket_error
|
||||
|
||||
###############################################################################
|
||||
|
||||
LOG = getLogger('PLEX.httppersist')
|
||||
|
||||
###############################################################################
|
||||
|
||||
|
||||
class RequestMgr(object):
|
||||
def __init__(self):
|
||||
self.conns = {}
|
||||
|
||||
def getConnection(self, protocol, host, port):
|
||||
conn = self.conns.get(protocol + host + str(port), False)
|
||||
if not conn:
|
||||
if protocol == "https":
|
||||
conn = http.client.HTTPSConnection(host, port)
|
||||
else:
|
||||
conn = http.client.HTTPConnection(host, port)
|
||||
self.conns[protocol + host + str(port)] = conn
|
||||
return conn
|
||||
|
||||
def closeConnection(self, protocol, host, port):
|
||||
conn = self.conns.get(protocol + host + str(port), False)
|
||||
if conn:
|
||||
conn.close()
|
||||
self.conns.pop(protocol + host + str(port), None)
|
||||
|
||||
def dumpConnections(self):
|
||||
for conn in list(self.conns.values()):
|
||||
conn.close()
|
||||
self.conns = {}
|
||||
|
||||
def post(self, host, port, path, body, header={}, protocol="http"):
|
||||
conn = None
|
||||
try:
|
||||
conn = self.getConnection(protocol, host, port)
|
||||
header['Connection'] = "keep-alive"
|
||||
conn.request("POST", path, body, header)
|
||||
data = conn.getresponse()
|
||||
if int(data.status) >= 400:
|
||||
LOG.error("HTTP response error: %s" % str(data.status))
|
||||
# this should return false, but I'm hacking it since iOS
|
||||
# returns 404 no matter what
|
||||
return data.read() or True
|
||||
else:
|
||||
return data.read() or True
|
||||
except socket_error as serr:
|
||||
# Ignore remote close and connection refused (e.g. shutdown PKC)
|
||||
if serr.errno in (errno.WSAECONNABORTED, errno.WSAECONNREFUSED):
|
||||
pass
|
||||
else:
|
||||
LOG.error("Unable to connect to %s\nReason:" % host)
|
||||
LOG.error(traceback.print_exc())
|
||||
self.conns.pop(protocol + host + str(port), None)
|
||||
if conn:
|
||||
conn.close()
|
||||
return False
|
||||
except Exception as e:
|
||||
LOG.error("Exception encountered: %s", e)
|
||||
# Close connection just in case
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
def getwithparams(self, host, port, path, params, header={},
|
||||
protocol="http"):
|
||||
newpath = path + '?'
|
||||
pairs = []
|
||||
for key in params:
|
||||
pairs.append(str(key) + '=' + str(params[key]))
|
||||
newpath += string.join(pairs, '&')
|
||||
return self.get(host, port, newpath, header, protocol)
|
||||
|
||||
def get(self, host, port, path, header={}, protocol="http"):
|
||||
try:
|
||||
conn = self.getConnection(protocol, host, port)
|
||||
header['Connection'] = "keep-alive"
|
||||
conn.request("GET", path, headers=header)
|
||||
data = conn.getresponse()
|
||||
if int(data.status) >= 400:
|
||||
LOG.error("HTTP response error: %s", str(data.status))
|
||||
return False
|
||||
else:
|
||||
return data.read() or True
|
||||
except socket_error as serr:
|
||||
# Ignore remote close and connection refused (e.g. shutdown PKC)
|
||||
if serr.errno in (errno.WSAECONNABORTED, errno.WSAECONNREFUSED):
|
||||
pass
|
||||
else:
|
||||
LOG.error("Unable to connect to %s\nReason:", host)
|
||||
LOG.error(traceback.print_exc())
|
||||
self.conns.pop(protocol + host + str(port), None)
|
||||
conn.close()
|
||||
return False
|
|
@ -1,238 +0,0 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Plex Companion listener
|
||||
"""
|
||||
from logging import getLogger
|
||||
from re import sub
|
||||
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
|
||||
|
||||
from .. import utils, companion, json_rpc as js, clientinfo, variables as v
|
||||
from .. import app
|
||||
|
||||
###############################################################################
|
||||
|
||||
LOG = getLogger('PLEX.listener')
|
||||
|
||||
# Hack we need in order to keep track of the open connections from Plex Web
|
||||
CLIENT_DICT = {}
|
||||
|
||||
###############################################################################
|
||||
|
||||
RESOURCES_XML = ('%s<MediaContainer>\n'
|
||||
' <Player'
|
||||
' title="{title}"'
|
||||
' protocol="plex"'
|
||||
' protocolVersion="1"'
|
||||
' protocolCapabilities="timeline,playback,navigation,playqueues"'
|
||||
' machineIdentifier="{machineIdentifier}"'
|
||||
' product="%s"'
|
||||
' platform="%s"'
|
||||
' platformVersion="%s"'
|
||||
' deviceClass="pc"/>\n'
|
||||
'</MediaContainer>\n') % (v.XML_HEADER,
|
||||
v.ADDON_NAME,
|
||||
v.PLATFORM,
|
||||
v.PLATFORM_VERSION)
|
||||
|
||||
|
||||
class MyHandler(BaseHTTPRequestHandler):
|
||||
"""
|
||||
BaseHTTPRequestHandler implementation of Plex Companion listener
|
||||
"""
|
||||
protocol_version = 'HTTP/1.1'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.serverlist = []
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def log_message(self, format, *args):
|
||||
'''
|
||||
Mute all requests, don't log 'em
|
||||
'''
|
||||
pass
|
||||
|
||||
def do_HEAD(self):
|
||||
LOG.debug("Serving HEAD request...")
|
||||
self.answer_request(0)
|
||||
|
||||
def do_GET(self):
|
||||
LOG.debug("Serving GET request...")
|
||||
self.answer_request(1)
|
||||
|
||||
def do_OPTIONS(self):
|
||||
LOG.debug("Serving OPTIONS request...")
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Length', '0')
|
||||
self.send_header('X-Plex-Client-Identifier', v.PKC_MACHINE_IDENTIFIER)
|
||||
self.send_header('Content-Type', 'text/plain')
|
||||
self.send_header('Connection', 'close')
|
||||
self.send_header('Access-Control-Max-Age', '1209600')
|
||||
self.send_header('Access-Control-Allow-Origin', '*')
|
||||
self.send_header('Access-Control-Allow-Methods',
|
||||
'POST, GET, OPTIONS, DELETE, PUT, HEAD')
|
||||
self.send_header(
|
||||
'Access-Control-Allow-Headers',
|
||||
'x-plex-version, x-plex-platform-version, x-plex-username, '
|
||||
'x-plex-client-identifier, x-plex-target-client-identifier, '
|
||||
'x-plex-device-name, x-plex-platform, x-plex-product, accept, '
|
||||
'x-plex-device, x-plex-device-screen-resolution')
|
||||
self.end_headers()
|
||||
|
||||
def response(self, body, headers=None, code=200):
|
||||
headers = {} if headers is None else headers
|
||||
self.send_response(code)
|
||||
for key in headers:
|
||||
self.send_header(key, headers[key])
|
||||
self.send_header('Content-Length', len(body))
|
||||
self.end_headers()
|
||||
if body:
|
||||
self.wfile.write(body.encode('utf-8'))
|
||||
|
||||
def answer_request(self, send_data):
|
||||
self.serverlist = self.server.client.getServerList()
|
||||
sub_mgr = self.server.subscription_manager
|
||||
|
||||
request_path = self.path[1:]
|
||||
request_path = sub(r"\?.*", "", request_path)
|
||||
parseresult = utils.urlparse(self.path)
|
||||
paramarrays = utils.parse_qs(parseresult.query)
|
||||
params = {}
|
||||
for key in paramarrays:
|
||||
params[key] = paramarrays[key][0]
|
||||
LOG.debug("remote request_path: %s, received from %s with headers: %s",
|
||||
request_path, self.client_address, self.headers.items())
|
||||
LOG.debug("params received from remote: %s", params)
|
||||
sub_mgr.update_command_id(self.headers.get(
|
||||
'X-Plex-Client-Identifier', self.client_address[0]),
|
||||
params.get('commandID'))
|
||||
|
||||
conntype = self.headers.get('Connection', '')
|
||||
if conntype.lower() == 'keep-alive':
|
||||
headers = {
|
||||
'Connection': 'Keep-Alive',
|
||||
'Keep-Alive': 'timeout=20'
|
||||
}
|
||||
else:
|
||||
headers = {'Connection': 'Close'}
|
||||
|
||||
if request_path == "version":
|
||||
self.response(
|
||||
"PlexKodiConnect Plex Companion: Running\nVersion: %s"
|
||||
% v.ADDON_VERSION,
|
||||
headers)
|
||||
elif request_path == "verify":
|
||||
self.response("XBMC JSON connection test:\n" + js.ping(),
|
||||
headers)
|
||||
elif request_path == 'resources':
|
||||
self.response(
|
||||
RESOURCES_XML.format(
|
||||
title=v.DEVICENAME,
|
||||
machineIdentifier=v.PKC_MACHINE_IDENTIFIER),
|
||||
clientinfo.getXArgsDeviceInfo(options=headers,
|
||||
include_token=False))
|
||||
elif request_path == 'player/timeline/poll':
|
||||
# Plex web does polling if connected to PKC via Companion
|
||||
# Only reply if there is indeed something playing
|
||||
# Otherwise, all clients seem to keep connection open
|
||||
if params.get('wait') == '1':
|
||||
app.APP.monitor.waitForAbort(0.95)
|
||||
if self.client_address[0] not in CLIENT_DICT:
|
||||
CLIENT_DICT[self.client_address[0]] = []
|
||||
tracker = CLIENT_DICT[self.client_address[0]]
|
||||
tracker.append(self.client_address[1])
|
||||
while (not app.APP.is_playing and
|
||||
not app.APP.monitor.abortRequested() and
|
||||
sub_mgr.stop_sent_to_web and not
|
||||
(len(tracker) >= 4 and
|
||||
tracker[0] == self.client_address[1])):
|
||||
# Keep at most 3 connections open, then drop the first one
|
||||
# Doesn't need to be thread-save
|
||||
# Silly stuff really
|
||||
app.APP.monitor.waitForAbort(1)
|
||||
# Let PKC know that we're releasing this connection
|
||||
tracker.pop(0)
|
||||
msg = sub_mgr.msg(js.get_players()).format(
|
||||
command_id=params.get('commandID', 0))
|
||||
if sub_mgr.isplaying:
|
||||
self.response(
|
||||
msg,
|
||||
{
|
||||
'X-Plex-Client-Identifier': v.PKC_MACHINE_IDENTIFIER,
|
||||
'X-Plex-Protocol': '1.0',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Max-Age': '1209600',
|
||||
'Access-Control-Expose-Headers':
|
||||
'X-Plex-Client-Identifier',
|
||||
'Content-Type': 'text/xml;charset=utf-8'
|
||||
}.update(headers))
|
||||
elif not sub_mgr.stop_sent_to_web:
|
||||
sub_mgr.stop_sent_to_web = True
|
||||
LOG.debug('Signaling STOP to Plex Web')
|
||||
self.response(
|
||||
msg,
|
||||
{
|
||||
'X-Plex-Client-Identifier': v.PKC_MACHINE_IDENTIFIER,
|
||||
'X-Plex-Protocol': '1.0',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Max-Age': '1209600',
|
||||
'Access-Control-Expose-Headers':
|
||||
'X-Plex-Client-Identifier',
|
||||
'Content-Type': 'text/xml;charset=utf-8'
|
||||
}.update(headers))
|
||||
else:
|
||||
# We're not playing anything yet, just reply with a 200
|
||||
self.response(
|
||||
msg,
|
||||
{
|
||||
'X-Plex-Client-Identifier': v.PKC_MACHINE_IDENTIFIER,
|
||||
'X-Plex-Protocol': '1.0',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Max-Age': '1209600',
|
||||
'Access-Control-Expose-Headers':
|
||||
'X-Plex-Client-Identifier',
|
||||
'Content-Type': 'text/xml;charset=utf-8'
|
||||
}.update(headers))
|
||||
elif "/subscribe" in request_path:
|
||||
headers['Content-Type'] = 'text/xml;charset=utf-8'
|
||||
headers = clientinfo.getXArgsDeviceInfo(options=headers,
|
||||
include_token=False)
|
||||
self.response(v.COMPANION_OK_MESSAGE, headers)
|
||||
protocol = params.get('protocol')
|
||||
host = self.client_address[0]
|
||||
port = params.get('port')
|
||||
uuid = self.headers.get('X-Plex-Client-Identifier')
|
||||
command_id = params.get('commandID', 0)
|
||||
sub_mgr.add_subscriber(protocol,
|
||||
host,
|
||||
port,
|
||||
uuid,
|
||||
command_id)
|
||||
elif "/unsubscribe" in request_path:
|
||||
headers['Content-Type'] = 'text/xml;charset=utf-8'
|
||||
headers = clientinfo.getXArgsDeviceInfo(options=headers,
|
||||
include_token=False)
|
||||
self.response(v.COMPANION_OK_MESSAGE, headers)
|
||||
uuid = self.headers.get('X-Plex-Client-Identifier') \
|
||||
or self.client_address[0]
|
||||
sub_mgr.remove_subscriber(uuid)
|
||||
else:
|
||||
# Throw it to companion.py
|
||||
companion.process_command(request_path, params)
|
||||
headers['Content-Type'] = 'text/xml;charset=utf-8'
|
||||
headers = clientinfo.getXArgsDeviceInfo(options=headers,
|
||||
include_token=False)
|
||||
self.response(v.COMPANION_OK_MESSAGE, headers)
|
||||
|
||||
|
||||
class PKCHTTPServer(ThreadingHTTPServer):
|
||||
def __init__(self, client, subscription_manager, *args, **kwargs):
|
||||
"""
|
||||
client: Class handle to plexgdm.plexgdm. We can thus ask for an up-to-
|
||||
date serverlist without instantiating anything
|
||||
|
||||
same for SubscriptionMgr
|
||||
"""
|
||||
self.client = client
|
||||
self.subscription_manager = subscription_manager
|
||||
super().__init__(*args, **kwargs)
|
|
@ -1,314 +0,0 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
PlexGDM.py - Version 0.2
|
||||
|
||||
This class implements the Plex GDM (G'Day Mate) protocol to discover
|
||||
local Plex Media Servers. Also allow client registration into all local
|
||||
media servers.
|
||||
|
||||
|
||||
This program is free software; you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program; if not, write to the Free Software
|
||||
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
|
||||
MA 02110-1301, USA.
|
||||
"""
|
||||
import logging
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
|
||||
from ..downloadutils import DownloadUtils as DU
|
||||
from .. import utils, app, variables as v
|
||||
|
||||
###############################################################################
|
||||
|
||||
log = logging.getLogger('PLEX.plexgdm')
|
||||
|
||||
###############################################################################
|
||||
|
||||
|
||||
class plexgdm(object):
|
||||
|
||||
def __init__(self):
|
||||
self.discover_message = 'M-SEARCH * HTTP/1.0'
|
||||
self.client_header = '* HTTP/1.0'
|
||||
self.client_data = None
|
||||
self.update_sock = None
|
||||
self.discover_t = None
|
||||
self.register_t = None
|
||||
|
||||
self._multicast_address = '239.0.0.250'
|
||||
self.discover_group = (self._multicast_address, 32414)
|
||||
self.client_register_group = (self._multicast_address, 32413)
|
||||
self.client_update_port = int(utils.settings('companionUpdatePort'))
|
||||
|
||||
self.server_list = []
|
||||
self.discovery_interval = 120
|
||||
|
||||
self._discovery_is_running = False
|
||||
self._registration_is_running = False
|
||||
|
||||
self.client_registered = False
|
||||
self.download = DU().downloadUrl
|
||||
|
||||
def clientDetails(self):
|
||||
self.client_data = (
|
||||
"Content-Type: plex/media-player\n"
|
||||
"Resource-Identifier: %s\n"
|
||||
"Name: %s\n"
|
||||
"Port: %s\n"
|
||||
"Product: %s\n"
|
||||
"Version: %s\n"
|
||||
"Protocol: plex\n"
|
||||
"Protocol-Version: 1\n"
|
||||
"Protocol-Capabilities: timeline,playback,navigation,"
|
||||
"playqueues\n"
|
||||
"Device-Class: HTPC\n"
|
||||
) % (
|
||||
v.PKC_MACHINE_IDENTIFIER,
|
||||
v.DEVICENAME,
|
||||
v.COMPANION_PORT,
|
||||
v.ADDON_NAME,
|
||||
v.ADDON_VERSION
|
||||
)
|
||||
|
||||
def getClientDetails(self):
|
||||
return self.client_data
|
||||
|
||||
def register_as_client(self):
|
||||
"""
|
||||
Registers PKC's Plex Companion to the PMS
|
||||
"""
|
||||
try:
|
||||
log.debug("Sending registration data: HELLO %s\n%s"
|
||||
% (self.client_header, self.client_data))
|
||||
msg = 'HELLO {}\n{}'.format(self.client_header, self.client_data)
|
||||
self.update_sock.sendto(msg.encode('utf-8'),
|
||||
self.client_register_group)
|
||||
log.debug('(Re-)registering PKC Plex Companion successful')
|
||||
except Exception as exc:
|
||||
log.error("Unable to send registration message. Error: %s", exc)
|
||||
|
||||
def client_update(self):
|
||||
self.update_sock = socket.socket(socket.AF_INET,
|
||||
socket.SOCK_DGRAM,
|
||||
socket.IPPROTO_UDP)
|
||||
update_sock = self.update_sock
|
||||
|
||||
# Set socket reuse, may not work on all OSs.
|
||||
try:
|
||||
update_sock.setsockopt(socket.SOL_SOCKET,
|
||||
socket.SO_REUSEADDR,
|
||||
1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Attempt to bind to the socket to recieve and send data. If we cant
|
||||
# do this, then we cannot send registration
|
||||
try:
|
||||
update_sock.bind(('0.0.0.0', self.client_update_port))
|
||||
except Exception:
|
||||
log.error("Unable to bind to port [%s] - Plex Companion will not "
|
||||
"be registered. Change the Plex Companion update port!"
|
||||
% self.client_update_port)
|
||||
if utils.settings('companion_show_gdm_port_warning') == 'true':
|
||||
from ..windows import optionsdialog
|
||||
# Plex Companion could not open the GDM port. Please change it
|
||||
# in the PKC settings.
|
||||
if optionsdialog.show(utils.lang(29999),
|
||||
'Port %s\n%s' % (self.client_update_port,
|
||||
utils.lang(39079)),
|
||||
utils.lang(30013), # Never show again
|
||||
utils.lang(186)) == 0:
|
||||
utils.settings('companion_show_gdm_port_warning',
|
||||
value='false')
|
||||
from xbmc import executebuiltin
|
||||
executebuiltin(
|
||||
'Addon.OpenSettings(plugin.video.plexkodiconnect)')
|
||||
return
|
||||
|
||||
update_sock.setsockopt(socket.IPPROTO_IP,
|
||||
socket.IP_MULTICAST_TTL,
|
||||
255)
|
||||
update_sock.setsockopt(socket.IPPROTO_IP,
|
||||
socket.IP_ADD_MEMBERSHIP,
|
||||
socket.inet_aton(
|
||||
self._multicast_address) +
|
||||
socket.inet_aton('0.0.0.0'))
|
||||
update_sock.setblocking(0)
|
||||
|
||||
# Send initial client registration
|
||||
self.register_as_client()
|
||||
|
||||
# Now, listen format client discovery reguests and respond.
|
||||
while self._registration_is_running:
|
||||
try:
|
||||
data, addr = update_sock.recvfrom(1024)
|
||||
data = data.decode()
|
||||
log.debug("Recieved UDP packet from [%s] containing [%s]"
|
||||
% (addr, data.strip()))
|
||||
except socket.error:
|
||||
pass
|
||||
else:
|
||||
if "M-SEARCH * HTTP/1." in data:
|
||||
log.debug('Detected client discovery request from %s. '
|
||||
'Replying', addr)
|
||||
message = f'HTTP/1.0 200 OK\n{self.client_data}'.encode()
|
||||
try:
|
||||
update_sock.sendto(message, addr)
|
||||
except Exception:
|
||||
log.error("Unable to send client update message")
|
||||
else:
|
||||
log.debug("Sent registration data HTTP/1.0 200 OK")
|
||||
self.client_registered = True
|
||||
app.APP.monitor.waitForAbort(0.5)
|
||||
log.info("Client Update loop stopped")
|
||||
# When we are finished, then send a final goodbye message to
|
||||
# deregister cleanly.
|
||||
log.debug("Sending registration data: BYE %s\n%s"
|
||||
% (self.client_header, self.client_data))
|
||||
try:
|
||||
update_sock.sendto("BYE %s\n%s"
|
||||
% (self.client_header, self.client_data),
|
||||
self.client_register_group)
|
||||
except Exception:
|
||||
log.error("Unable to send client update message")
|
||||
self.client_registered = False
|
||||
|
||||
def check_client_registration(self):
|
||||
if not self.client_registered:
|
||||
log.debug('Client has not been marked as registered')
|
||||
return False
|
||||
if not self.server_list:
|
||||
log.info("Server list is empty. Unable to check")
|
||||
return False
|
||||
for server in self.server_list:
|
||||
if server['uuid'] == app.CONN.machine_identifier:
|
||||
media_server = server['server']
|
||||
media_port = server['port']
|
||||
scheme = server['protocol']
|
||||
break
|
||||
else:
|
||||
log.info("Did not find our server!")
|
||||
return False
|
||||
|
||||
log.debug("Checking server [%s] on port [%s]"
|
||||
% (media_server, media_port))
|
||||
xml = self.download(
|
||||
'%s://%s:%s/clients' % (scheme, media_server, media_port))
|
||||
try:
|
||||
xml[0].attrib
|
||||
except (TypeError, IndexError, AttributeError):
|
||||
log.error('Could not download clients for %s' % media_server)
|
||||
return False
|
||||
registered = False
|
||||
for client in xml:
|
||||
if (client.attrib.get('machineIdentifier') ==
|
||||
v.PKC_MACHINE_IDENTIFIER):
|
||||
registered = True
|
||||
if registered:
|
||||
return True
|
||||
else:
|
||||
log.info("Client registration not found. "
|
||||
"Client data is: %s" % xml)
|
||||
return False
|
||||
|
||||
def getServerList(self):
|
||||
return self.server_list
|
||||
|
||||
def discover(self):
|
||||
currServer = app.CONN.server
|
||||
if not currServer:
|
||||
return
|
||||
currServerProt, currServerIP, currServerPort = \
|
||||
currServer.split(':')
|
||||
currServerIP = currServerIP.replace('/', '')
|
||||
# Currently active server was not discovered via GDM; ADD
|
||||
self.server_list = [{
|
||||
'port': currServerPort,
|
||||
'protocol': currServerProt,
|
||||
'class': None,
|
||||
'content-type': 'plex/media-server',
|
||||
'discovery': 'auto',
|
||||
'master': 1,
|
||||
'owned': '1',
|
||||
'role': 'master',
|
||||
'server': currServerIP,
|
||||
'serverName': app.CONN.server_name,
|
||||
'updated': int(time.time()),
|
||||
'uuid': app.CONN.machine_identifier,
|
||||
'version': 'irrelevant'
|
||||
}]
|
||||
|
||||
def setInterval(self, interval):
|
||||
self.discovery_interval = interval
|
||||
|
||||
def stop_all(self):
|
||||
self.stop_discovery()
|
||||
self.stop_registration()
|
||||
|
||||
def stop_discovery(self):
|
||||
if self._discovery_is_running:
|
||||
log.info("Discovery shutting down")
|
||||
self._discovery_is_running = False
|
||||
self.discover_t.join()
|
||||
del self.discover_t
|
||||
else:
|
||||
log.info("Discovery not running")
|
||||
|
||||
def stop_registration(self):
|
||||
if self._registration_is_running:
|
||||
log.info("Registration shutting down")
|
||||
self._registration_is_running = False
|
||||
self.register_t.join()
|
||||
del self.register_t
|
||||
else:
|
||||
log.info("Registration not running")
|
||||
|
||||
def run_discovery_loop(self):
|
||||
# Run initial discovery
|
||||
self.discover()
|
||||
|
||||
discovery_count = 0
|
||||
while self._discovery_is_running:
|
||||
discovery_count += 1
|
||||
if discovery_count > self.discovery_interval:
|
||||
self.discover()
|
||||
discovery_count = 0
|
||||
app.APP.monitor.waitForAbort(0.5)
|
||||
|
||||
def start_discovery(self, daemon=False):
|
||||
if not self._discovery_is_running:
|
||||
log.info("Discovery starting up")
|
||||
self._discovery_is_running = True
|
||||
self.discover_t = threading.Thread(target=self.run_discovery_loop)
|
||||
self.discover_t.setDaemon(daemon)
|
||||
self.discover_t.start()
|
||||
else:
|
||||
log.info("Discovery already running")
|
||||
|
||||
def start_registration(self, daemon=False):
|
||||
if not self._registration_is_running:
|
||||
log.info("Registration starting up")
|
||||
self._registration_is_running = True
|
||||
self.register_t = threading.Thread(target=self.client_update)
|
||||
self.register_t.setDaemon(daemon)
|
||||
self.register_t.start()
|
||||
else:
|
||||
log.info("Registration already running")
|
||||
|
||||
def start_all(self, daemon=False):
|
||||
self.start_discovery(daemon)
|
||||
if utils.settings('plexCompanion') == 'true':
|
||||
self.start_registration(daemon)
|
|
@ -1,470 +0,0 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Manages getting playstate from Kodi and sending it to the PMS as well as
|
||||
subscribed Plex Companion clients.
|
||||
"""
|
||||
from logging import getLogger
|
||||
from threading import Thread
|
||||
|
||||
from ..downloadutils import DownloadUtils as DU
|
||||
from .. import timing
|
||||
from .. import app
|
||||
from .. import variables as v
|
||||
from .. import json_rpc as js
|
||||
from .. import playqueue as PQ
|
||||
|
||||
###############################################################################
|
||||
LOG = getLogger('PLEX.subscribers')
|
||||
###############################################################################
|
||||
|
||||
# What is Companion controllable?
|
||||
CONTROLLABLE = {
|
||||
v.PLEX_PLAYLIST_TYPE_VIDEO: 'playPause,stop,volume,shuffle,audioStream,'
|
||||
'subtitleStream,seekTo,skipPrevious,skipNext,'
|
||||
'stepBack,stepForward',
|
||||
v.PLEX_PLAYLIST_TYPE_AUDIO: 'playPause,stop,volume,shuffle,repeat,seekTo,'
|
||||
'skipPrevious,skipNext,stepBack,stepForward',
|
||||
v.PLEX_PLAYLIST_TYPE_PHOTO: 'playPause,stop,skipPrevious,skipNext'
|
||||
}
|
||||
|
||||
STREAM_DETAILS = {
|
||||
'video': 'currentvideostream',
|
||||
'audio': 'currentaudiostream',
|
||||
'subtitle': 'currentsubtitle'
|
||||
}
|
||||
|
||||
XML = ('%s<MediaContainer commandID="{command_id}" location="{location}">\n'
|
||||
' <Timeline {%s}/>\n'
|
||||
' <Timeline {%s}/>\n'
|
||||
' <Timeline {%s}/>\n'
|
||||
'</MediaContainer>\n') % (v.XML_HEADER,
|
||||
v.PLEX_PLAYLIST_TYPE_VIDEO,
|
||||
v.PLEX_PLAYLIST_TYPE_AUDIO,
|
||||
v.PLEX_PLAYLIST_TYPE_PHOTO)
|
||||
|
||||
# Headers are different for Plex Companion - use these for PMS notifications
|
||||
HEADERS_PMS = {
|
||||
'Connection': 'keep-alive',
|
||||
'Accept': 'text/plain, */*; q=0.01',
|
||||
'Accept-Language': 'en',
|
||||
'Accept-Encoding': 'gzip, deflate',
|
||||
'User-Agent': '%s %s (%s)' % (v.ADDON_NAME, v.ADDON_VERSION, v.DEVICE)
|
||||
}
|
||||
|
||||
|
||||
def params_pms():
|
||||
"""
|
||||
Returns the url parameters for communicating with the PMS
|
||||
"""
|
||||
return {
|
||||
'X-Plex-Client-Identifier': v.PKC_MACHINE_IDENTIFIER,
|
||||
'X-Plex-Device': v.DEVICE,
|
||||
'X-Plex-Device-Name': v.DEVICENAME,
|
||||
'X-Plex-Model': v.MODEL,
|
||||
'X-Plex-Platform': v.PLATFORM,
|
||||
'X-Plex-Platform-Version': v.PLATFORM_VERSION,
|
||||
'X-Plex-Product': v.ADDON_NAME,
|
||||
'X-Plex-Version': v.ADDON_VERSION,
|
||||
}
|
||||
|
||||
|
||||
def headers_companion_client():
|
||||
"""
|
||||
Headers are different for Plex Companion - use these for a Plex Companion
|
||||
client
|
||||
"""
|
||||
return {
|
||||
'Content-Type': 'application/xml',
|
||||
'Connection': 'Keep-Alive',
|
||||
'X-Plex-Client-Identifier': v.PKC_MACHINE_IDENTIFIER,
|
||||
'X-Plex-Device-Name': v.DEVICENAME,
|
||||
'X-Plex-Platform': v.PLATFORM,
|
||||
'X-Plex-Platform-Version': v.PLATFORM_VERSION,
|
||||
'X-Plex-Product': v.ADDON_NAME,
|
||||
'X-Plex-Version': v.ADDON_VERSION,
|
||||
'Accept-Encoding': 'gzip, deflate',
|
||||
'Accept-Language': 'en,*'
|
||||
}
|
||||
|
||||
|
||||
def update_player_info(playerid):
|
||||
"""
|
||||
Updates all player info for playerid [int] in state.py.
|
||||
"""
|
||||
app.PLAYSTATE.player_states[playerid].update(js.get_player_props(playerid))
|
||||
app.PLAYSTATE.player_states[playerid]['volume'] = js.get_volume()
|
||||
app.PLAYSTATE.player_states[playerid]['muted'] = js.get_muted()
|
||||
|
||||
|
||||
class SubscriptionMgr(object):
|
||||
"""
|
||||
Manages Plex companion subscriptions
|
||||
"""
|
||||
def __init__(self, request_mgr, player):
|
||||
self.serverlist = []
|
||||
self.subscribers = {}
|
||||
self.info = {}
|
||||
self.server = ""
|
||||
self.protocol = "http"
|
||||
self.port = ""
|
||||
self.isplaying = False
|
||||
self.location = 'navigation'
|
||||
# In order to be able to signal a stop at the end
|
||||
self.last_params = {}
|
||||
self.lastplayers = {}
|
||||
# In order to signal a stop to Plex Web ONCE on playback stop
|
||||
self.stop_sent_to_web = True
|
||||
self.request_mgr = request_mgr
|
||||
|
||||
def _server_by_host(self, host):
|
||||
if len(self.serverlist) == 1:
|
||||
return self.serverlist[0]
|
||||
for server in self.serverlist:
|
||||
if (server.get('serverName') in host or
|
||||
server.get('server') in host):
|
||||
return server
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _get_correct_position(info, playqueue):
|
||||
"""
|
||||
Kodi tells us the PLAYLIST position, not PLAYQUEUE position, if the
|
||||
user initiated playback of a playlist
|
||||
"""
|
||||
if playqueue.kodi_playlist_playback:
|
||||
position = 0
|
||||
else:
|
||||
position = info['position'] or 0
|
||||
return position
|
||||
|
||||
def msg(self, players):
|
||||
"""
|
||||
Returns a timeline xml as str
|
||||
(xml containing video, audio, photo player state)
|
||||
"""
|
||||
self.isplaying = False
|
||||
self.location = 'navigation'
|
||||
answ = str(XML)
|
||||
timelines = {
|
||||
v.PLEX_PLAYLIST_TYPE_VIDEO: None,
|
||||
v.PLEX_PLAYLIST_TYPE_AUDIO: None,
|
||||
v.PLEX_PLAYLIST_TYPE_PHOTO: None
|
||||
}
|
||||
for typus in timelines:
|
||||
if players.get(v.KODI_PLAYLIST_TYPE_FROM_PLEX_PLAYLIST_TYPE[typus]) is None:
|
||||
timeline = {
|
||||
'controllable': CONTROLLABLE[typus],
|
||||
'type': typus,
|
||||
'state': 'stopped'
|
||||
}
|
||||
else:
|
||||
timeline = self._timeline_dict(players[
|
||||
v.KODI_PLAYLIST_TYPE_FROM_PLEX_PLAYLIST_TYPE[typus]],
|
||||
typus)
|
||||
timelines[typus] = self._dict_to_xml(timeline)
|
||||
timelines.update({'command_id': '{command_id}',
|
||||
'location': self.location})
|
||||
return answ.format(**timelines)
|
||||
|
||||
@staticmethod
|
||||
def _dict_to_xml(dictionary):
|
||||
"""
|
||||
Returns the string 'key1="value1" key2="value2" ...' for dictionary
|
||||
"""
|
||||
answ = ''
|
||||
for key, value in dictionary.items():
|
||||
answ += '%s="%s" ' % (key, value)
|
||||
return answ
|
||||
|
||||
def _timeline_dict(self, player, ptype):
|
||||
with app.APP.lock_playqueues:
|
||||
playerid = player['playerid']
|
||||
info = app.PLAYSTATE.player_states[playerid]
|
||||
playqueue = PQ.PLAYQUEUES[playerid]
|
||||
position = self._get_correct_position(info, playqueue)
|
||||
try:
|
||||
item = playqueue.items[position]
|
||||
except IndexError:
|
||||
# E.g. for direct path playback for single item
|
||||
return {
|
||||
'controllable': CONTROLLABLE[ptype],
|
||||
'type': ptype,
|
||||
'state': 'stopped'
|
||||
}
|
||||
self.isplaying = True
|
||||
self.stop_sent_to_web = False
|
||||
if ptype in (v.PLEX_PLAYLIST_TYPE_VIDEO,
|
||||
v.PLEX_PLAYLIST_TYPE_PHOTO):
|
||||
self.location = 'fullScreenVideo'
|
||||
pbmc_server = app.CONN.server
|
||||
if pbmc_server:
|
||||
(self.protocol, self.server, self.port) = pbmc_server.split(':')
|
||||
self.server = self.server.replace('/', '')
|
||||
status = 'paused' if int(info['speed']) == 0 else 'playing'
|
||||
duration = timing.kodi_time_to_millis(info['totaltime'])
|
||||
shuffle = '1' if info['shuffled'] else '0'
|
||||
mute = '1' if info['muted'] is True else '0'
|
||||
answ = {
|
||||
'controllable': CONTROLLABLE[ptype],
|
||||
'protocol': self.protocol,
|
||||
'address': self.server,
|
||||
'port': self.port,
|
||||
'machineIdentifier': app.CONN.machine_identifier,
|
||||
'state': status,
|
||||
'type': ptype,
|
||||
'itemType': ptype,
|
||||
'time': timing.kodi_time_to_millis(info['time']),
|
||||
'duration': duration,
|
||||
'seekRange': '0-%s' % duration,
|
||||
'shuffle': shuffle,
|
||||
'repeat': v.PLEX_REPEAT_FROM_KODI_REPEAT[info['repeat']],
|
||||
'volume': info['volume'],
|
||||
'mute': mute,
|
||||
'mediaIndex': 0, # Still to implement from here
|
||||
'partIndex': 0,
|
||||
'partCount': 1,
|
||||
'providerIdentifier': 'com.plexapp.plugins.library',
|
||||
}
|
||||
# Get the plex id from the PKC playqueue not info, as Kodi jumps to
|
||||
# next playqueue element way BEFORE kodi monitor onplayback is
|
||||
# called
|
||||
if item.plex_id:
|
||||
answ['key'] = '/library/metadata/%s' % item.plex_id
|
||||
answ['ratingKey'] = item.plex_id
|
||||
# PlayQueue stuff
|
||||
if info['container_key']:
|
||||
answ['containerKey'] = info['container_key']
|
||||
if (info['container_key'] is not None and
|
||||
info['container_key'].startswith('/playQueues')):
|
||||
answ['playQueueID'] = playqueue.id
|
||||
answ['playQueueVersion'] = playqueue.version
|
||||
answ['playQueueItemID'] = item.id
|
||||
if playqueue.items[position].guid:
|
||||
answ['guid'] = item.guid
|
||||
# Temp. token set?
|
||||
if app.CONN.plex_transient_token:
|
||||
answ['token'] = app.CONN.plex_transient_token
|
||||
elif playqueue.plex_transient_token:
|
||||
answ['token'] = playqueue.plex_transient_token
|
||||
# Process audio and subtitle streams
|
||||
if ptype == v.PLEX_PLAYLIST_TYPE_VIDEO:
|
||||
answ['videoStreamID'] = str(item.current_plex_video_stream)
|
||||
answ['audioStreamID'] = str(item.current_plex_audio_stream)
|
||||
# Mind the zero - meaning subs are deactivated
|
||||
answ['subtitleStreamID'] = str(item.current_plex_sub_stream or 0)
|
||||
return answ
|
||||
|
||||
def signal_stop(self):
|
||||
"""
|
||||
Externally called on PKC shutdown to ensure that PKC signals a stop to
|
||||
the PMS. Otherwise, PKC might be stuck at "currently playing"
|
||||
"""
|
||||
LOG.info('Signaling a complete stop to PMS')
|
||||
# To avoid RuntimeError, don't use self.lastplayers
|
||||
for playerid in (0, 1, 2):
|
||||
self.last_params['state'] = 'stopped'
|
||||
self._send_pms_notification(playerid,
|
||||
self.last_params,
|
||||
timeout=0.0001)
|
||||
|
||||
def update_command_id(self, uuid, command_id):
|
||||
"""
|
||||
Updates the Plex Companien client with the machine identifier uuid with
|
||||
command_id
|
||||
"""
|
||||
with app.APP.lock_subscriber:
|
||||
if command_id and self.subscribers.get(uuid):
|
||||
self.subscribers[uuid].command_id = int(command_id)
|
||||
|
||||
def _playqueue_init_done(self, players):
|
||||
"""
|
||||
update_player_info() can result in values BEFORE kodi monitor is called.
|
||||
Hence we'd have a missmatch between the state.PLAYER_STATES and our
|
||||
playqueues.
|
||||
"""
|
||||
for player in list(players.values()):
|
||||
info = app.PLAYSTATE.player_states[player['playerid']]
|
||||
playqueue = PQ.PLAYQUEUES[player['playerid']]
|
||||
position = self._get_correct_position(info, playqueue)
|
||||
try:
|
||||
item = playqueue.items[position]
|
||||
except IndexError:
|
||||
# E.g. for direct path playback for single item
|
||||
return False
|
||||
if item.plex_id != info['plex_id']:
|
||||
# Kodi playqueue already progressed; need to wait until
|
||||
# everything is loaded
|
||||
return False
|
||||
return True
|
||||
|
||||
def notify(self):
|
||||
"""
|
||||
Causes PKC to tell the PMS and Plex Companion players to receive a
|
||||
notification what's being played.
|
||||
"""
|
||||
with app.APP.lock_subscriber:
|
||||
self._cleanup()
|
||||
# Get all the active/playing Kodi players (video, audio, pictures)
|
||||
players = js.get_players()
|
||||
# Update the PKC info with what's playing on the Kodi side
|
||||
for player in list(players.values()):
|
||||
update_player_info(player['playerid'])
|
||||
# Check whether we can use the CURRENT info or whether PKC is still
|
||||
# initializing
|
||||
if self._playqueue_init_done(players) is False:
|
||||
LOG.debug('PKC playqueue is still initializing - skip update')
|
||||
return
|
||||
self._notify_server(players)
|
||||
if self.subscribers:
|
||||
msg = self.msg(players)
|
||||
for subscriber in list(self.subscribers.values()):
|
||||
subscriber.send_update(msg)
|
||||
self.lastplayers = players
|
||||
|
||||
def _notify_server(self, players):
|
||||
for typus, player in players.items():
|
||||
self._send_pms_notification(
|
||||
player['playerid'], self._get_pms_params(player['playerid']))
|
||||
try:
|
||||
del self.lastplayers[typus]
|
||||
except KeyError:
|
||||
pass
|
||||
# Process the players we have left (to signal a stop)
|
||||
for player in list(self.lastplayers.values()):
|
||||
self.last_params['state'] = 'stopped'
|
||||
self._send_pms_notification(player['playerid'], self.last_params)
|
||||
|
||||
def _get_pms_params(self, playerid):
|
||||
info = app.PLAYSTATE.player_states[playerid]
|
||||
playqueue = PQ.PLAYQUEUES[playerid]
|
||||
position = self._get_correct_position(info, playqueue)
|
||||
try:
|
||||
item = playqueue.items[position]
|
||||
except IndexError:
|
||||
return self.last_params
|
||||
status = 'paused' if int(info['speed']) == 0 else 'playing'
|
||||
params = {
|
||||
'state': status,
|
||||
'ratingKey': item.plex_id,
|
||||
'key': '/library/metadata/%s' % item.plex_id,
|
||||
'time': timing.kodi_time_to_millis(info['time']),
|
||||
'duration': timing.kodi_time_to_millis(info['totaltime'])
|
||||
}
|
||||
if info['container_key'] is not None:
|
||||
# params['containerKey'] = info['container_key']
|
||||
if info['container_key'].startswith('/playQueues/'):
|
||||
# params['playQueueVersion'] = playqueue.version
|
||||
# params['playQueueID'] = playqueue.id
|
||||
params['playQueueItemID'] = item.id
|
||||
self.last_params = params
|
||||
return params
|
||||
|
||||
def _send_pms_notification(self, playerid, params, timeout=None):
|
||||
"""
|
||||
Pass a really low timeout in seconds if shutting down Kodi and we don't
|
||||
need the PMS' response
|
||||
"""
|
||||
serv = self._server_by_host(self.server)
|
||||
playqueue = PQ.PLAYQUEUES[playerid]
|
||||
xargs = params_pms()
|
||||
xargs.update(params)
|
||||
if app.CONN.plex_transient_token:
|
||||
xargs['X-Plex-Token'] = app.CONN.plex_transient_token
|
||||
elif playqueue.plex_transient_token:
|
||||
xargs['X-Plex-Token'] = playqueue.plex_transient_token
|
||||
elif app.ACCOUNT.pms_token:
|
||||
xargs['X-Plex-Token'] = app.ACCOUNT.pms_token
|
||||
url = '%s://%s:%s/:/timeline' % (serv.get('protocol', 'http'),
|
||||
serv.get('server', 'localhost'),
|
||||
serv.get('port', '32400'))
|
||||
DU().downloadUrl(url,
|
||||
authenticate=False,
|
||||
parameters=xargs,
|
||||
headerOverride=HEADERS_PMS,
|
||||
timeout=timeout)
|
||||
LOG.debug("Sent server notification with parameters: %s to %s",
|
||||
xargs, url)
|
||||
|
||||
def add_subscriber(self, protocol, host, port, uuid, command_id):
|
||||
"""
|
||||
Adds a new Plex Companion subscriber to PKC.
|
||||
"""
|
||||
subscriber = Subscriber(protocol,
|
||||
host,
|
||||
port,
|
||||
uuid,
|
||||
command_id,
|
||||
self,
|
||||
self.request_mgr)
|
||||
with app.APP.lock_subscriber:
|
||||
self.subscribers[subscriber.uuid] = subscriber
|
||||
return subscriber
|
||||
|
||||
def remove_subscriber(self, uuid):
|
||||
"""
|
||||
Removes a connected Plex Companion subscriber with machine identifier
|
||||
uuid from PKC notifications.
|
||||
(Calls the cleanup() method of the subscriber)
|
||||
"""
|
||||
with app.APP.lock_subscriber:
|
||||
for subscriber in list(self.subscribers.values()):
|
||||
if subscriber.uuid == uuid or subscriber.host == uuid:
|
||||
subscriber.cleanup()
|
||||
del self.subscribers[subscriber.uuid]
|
||||
|
||||
def _cleanup(self):
|
||||
for subscriber in list(self.subscribers.values()):
|
||||
if subscriber.age > 30:
|
||||
subscriber.cleanup()
|
||||
del self.subscribers[subscriber.uuid]
|
||||
|
||||
|
||||
class Subscriber(object):
|
||||
"""
|
||||
Plex Companion subscribing device
|
||||
"""
|
||||
def __init__(self, protocol, host, port, uuid, command_id, sub_mgr,
|
||||
request_mgr):
|
||||
self.protocol = protocol or "http"
|
||||
self.host = host
|
||||
self.port = port or 32400
|
||||
self.uuid = uuid or host
|
||||
self.command_id = int(command_id) or 0
|
||||
self.age = 0
|
||||
self.sub_mgr = sub_mgr
|
||||
self.request_mgr = request_mgr
|
||||
|
||||
def __eq__(self, other):
|
||||
return self.uuid == other.uuid
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Closes the connection to the Plex Companion client
|
||||
"""
|
||||
self.request_mgr.closeConnection(self.protocol, self.host, self.port)
|
||||
|
||||
def send_update(self, msg):
|
||||
"""
|
||||
Sends msg to the Plex Companion client (via .../:/timeline)
|
||||
"""
|
||||
self.age += 1
|
||||
msg = msg.format(command_id=self.command_id)
|
||||
LOG.debug("sending xml to subscriber uuid=%s,commandID=%i:\n%s",
|
||||
self.uuid, self.command_id, msg)
|
||||
url = '%s://%s:%s/:/timeline' % (self.protocol, self.host, self.port)
|
||||
thread = Thread(target=self._threaded_send, args=(url, msg))
|
||||
thread.start()
|
||||
|
||||
def _threaded_send(self, url, msg):
|
||||
"""
|
||||
Threaded POST request, because they stall due to response missing
|
||||
the Content-Length header :-(
|
||||
"""
|
||||
response = DU().downloadUrl(url,
|
||||
action_type="POST",
|
||||
postBody=msg,
|
||||
authenticate=False,
|
||||
headerOverride=headers_companion_client())
|
||||
if response in (False, None, 401):
|
||||
self.sub_mgr.remove_subscriber(self.uuid)
|
|
@ -2,6 +2,7 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
import sys
|
||||
|
||||
import xbmc
|
||||
import xbmcvfs
|
||||
|
||||
|
@ -34,7 +35,6 @@ WINDOW_PROPERTIES = (
|
|||
class Service(object):
|
||||
ws = None
|
||||
sync = None
|
||||
plexcompanion = None
|
||||
|
||||
def __init__(self):
|
||||
self._init_done = False
|
||||
|
@ -448,7 +448,11 @@ class Service(object):
|
|||
self.pms_ws = websocket_client.get_pms_websocketapp()
|
||||
self.alexa_ws = websocket_client.get_alexa_websocketapp()
|
||||
self.sync = sync.Sync()
|
||||
self.plexcompanion = plex_companion.PlexCompanion()
|
||||
self.companion_playstate_mgr = plex_companion.PlaystateMgr()
|
||||
if utils.settings('plexCompanion') == 'true':
|
||||
self.companion_listener = plex_companion.Listener(self.companion_playstate_mgr)
|
||||
else:
|
||||
self.companion_listener = None
|
||||
self.playqueue = playqueue.PlayqueueMonitor()
|
||||
|
||||
# Main PKC program loop
|
||||
|
@ -548,7 +552,9 @@ class Service(object):
|
|||
self.startup_completed = True
|
||||
self.pms_ws.start()
|
||||
self.sync.start()
|
||||
self.plexcompanion.start()
|
||||
self.companion_playstate_mgr.start()
|
||||
if self.companion_listener is not None:
|
||||
self.companion_listener.start()
|
||||
self.playqueue.start()
|
||||
self.alexa_ws.start()
|
||||
|
||||
|
|
|
@ -556,6 +556,15 @@ def reset(ask_user=True):
|
|||
reboot_kodi()
|
||||
|
||||
|
||||
def log_xml(xml, logger):
|
||||
"""
|
||||
Logs an etree xml
|
||||
"""
|
||||
string = undefused_etree.tostring(xml, encoding='utf-8')
|
||||
string = string.decode('utf-8')
|
||||
logger('\n' + string)
|
||||
|
||||
|
||||
def compare_version(current, minimum):
|
||||
"""
|
||||
Returns True if current is >= then minimum. False otherwise. Returns True
|
||||
|
|
|
@ -30,8 +30,9 @@ ADDON_FOLDER = xbmcvfs.translatePath('special://home')
|
|||
ADDON_PROFILE = xbmcvfs.translatePath(_ADDON.getAddonInfo('profile'))
|
||||
|
||||
# Used e.g. for json_rpc
|
||||
KODI_VIDEO_PLAYER_ID = 1
|
||||
KODI_AUDIO_PLAYER_ID = 0
|
||||
KODI_VIDEO_PLAYER_ID = 1
|
||||
KODI_PHOTO_PLAYER_ID = 2
|
||||
|
||||
KODILANGUAGE = xbmc.getLanguage(xbmc.ISO_639_1)
|
||||
KODIVERSION = int(xbmc.getInfoLabel("System.BuildVersion")[:2])
|
||||
|
|
Loading…
Reference in a new issue