PlexKodiConnect/resources/lib/plexbmchelper/subscribers.py

373 lines
14 KiB
Python
Raw Normal View History

2017-12-14 06:14:27 +11:00
"""
Manages getting playstate from Kodi and sending it to the PMS as well as
subscribed Plex Companion clients.
"""
from logging import getLogger
from re import sub
from threading import Thread, RLock
2017-12-09 23:47:19 +11:00
2017-12-14 18:29:38 +11:00
from downloadutils import DownloadUtils as DU
2017-12-09 23:47:19 +11:00
from utils import window, kodi_time_to_millis
2017-05-18 04:22:16 +10:00
import state
2017-12-09 23:47:19 +11:00
import variables as v
import json_rpc as js
2016-01-15 22:12:52 +11:00
2016-09-03 01:20:19 +10:00
###############################################################################
2017-12-14 06:14:27 +11:00
LOG = getLogger("PLEX." + __name__)
2016-09-03 01:20:19 +10:00
###############################################################################
2017-12-10 03:23:50 +11:00
# What is Companion controllable?
CONTROLLABLE = {
v.PLEX_TYPE_PHOTO: 'skipPrevious,skipNext,stop',
2017-12-14 06:14:27 +11:00
v.PLEX_TYPE_AUDIO: 'playPause,stop,volume,shuffle,repeat,seekTo,'
2017-12-14 18:29:38 +11:00
'skipPrevious,skipNext,stepBack,stepForward',
2017-12-14 06:14:27 +11:00
v.PLEX_TYPE_VIDEO: 'playPause,stop,volume,shuffle,audioStream,'
2017-12-14 18:29:38 +11:00
'subtitleStream,seekTo,skipPrevious,skipNext,'
'stepBack,stepForward'
2017-12-10 03:23:50 +11:00
}
STREAM_DETAILS = {
'video': 'currentvideostream',
'audio': 'currentaudiostream',
'subtitle': 'currentsubtitle'
}
2017-12-14 18:29:38 +11:00
class SubscriptionMgr(object):
2017-12-14 06:14:27 +11:00
"""
Manages Plex companion subscriptions
"""
2017-12-14 18:29:38 +11:00
def __init__(self, request_mgr, player, mgr):
self.serverlist = []
2016-01-15 22:12:52 +11:00
self.subscribers = {}
self.info = {}
2017-12-14 18:29:38 +11:00
self.container_key = None
2017-12-14 06:14:27 +11:00
self.ratingkey = None
2016-01-15 22:12:52 +11:00
self.server = ""
self.protocol = "http"
self.port = ""
2017-12-14 06:14:27 +11:00
# In order to be able to signal a stop at the end
self.last_params = {}
self.lastplayers = {}
2016-08-07 23:33:36 +10:00
self.xbmcplayer = player
2016-12-28 23:14:21 +11:00
self.playqueue = mgr.playqueue
2017-12-14 18:29:38 +11:00
self.request_mgr = request_mgr
2017-12-14 06:14:27 +11:00
@staticmethod
def _headers():
"""
Headers are different for Plex Companion!
"""
return {
'Content-type': 'text/plain',
'Connection': 'Keep-Alive',
'Keep-Alive': 'timeout=20',
'X-Plex-Client-Identifier': v.PKC_MACHINE_IDENTIFIER,
'Access-Control-Expose-Headers': 'X-Plex-Client-Identifier',
'X-Plex-Protocol': "1.0"
}
2017-12-14 18:29:38 +11:00
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 {}
def msg(self, players):
"""
Returns a timeline xml as str
(xml containing video, audio, photo player state)
"""
2017-12-14 06:14:27 +11:00
LOG.debug('players: %s', players)
2017-12-09 23:47:19 +11:00
msg = v.XML_HEADER
2017-05-07 02:42:43 +10:00
msg += '<MediaContainer size="3" commandID="INSERTCOMMANDID"'
2017-12-14 06:14:27 +11:00
msg += ' machineIdentifier="%s">\n' % v.PKC_MACHINE_IDENTIFIER
2017-12-14 18:29:38 +11:00
msg += self._timeline_xml(players.get(v.KODI_TYPE_AUDIO),
v.PLEX_TYPE_AUDIO)
msg += self._timeline_xml(players.get(v.KODI_TYPE_PHOTO),
v.PLEX_TYPE_PHOTO)
msg += self._timeline_xml(players.get(v.KODI_TYPE_VIDEO),
v.PLEX_TYPE_VIDEO)
2017-12-14 06:14:27 +11:00
msg += "</MediaContainer>"
LOG.debug('msg is: %s', msg)
2016-01-15 22:12:52 +11:00
return msg
2016-08-11 03:03:37 +10:00
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')
for _, player in self.lastplayers.iteritems():
self.last_params['state'] = 'stopped'
self._send_pms_notification(player['playerid'], self.last_params)
2017-12-14 06:14:27 +11:00
def _get_container_key(self, playerid):
key = None
playlistid = state.PLAYER_STATES[playerid]['playlistid']
if playlistid != -1:
# -1 is Kodi's answer if there is no playlist
try:
key = self.playqueue.playqueues[playlistid].id
except (KeyError, IndexError, TypeError):
pass
if key is not None:
key = '/playQueues/%s' % key
2017-12-09 23:47:19 +11:00
else:
2017-12-14 06:14:27 +11:00
if state.PLAYER_STATES[playerid]['plex_id']:
key = '/library/metadata/%s' % \
state.PLAYER_STATES[playerid]['plex_id']
return key
2016-02-07 22:38:50 +11:00
def _kodi_stream_index(self, playerid, stream_type):
"""
Returns the current Kodi stream index [int] for the player playerid
stream_type: 'video', 'audio', 'subtitle'
"""
playqueue = self.playqueue.playqueues[playerid]
info = state.PLAYER_STATES[playerid]
return playqueue.items[info['position']].plex_stream_index(
info[STREAM_DETAILS[stream_type]]['index'], stream_type)
2017-12-14 18:29:38 +11:00
def _timeline_xml(self, player, ptype):
2017-12-14 06:14:27 +11:00
if player is None:
return ' <Timeline state="stopped" controllable="%s" type="%s" ' \
'itemType="%s" />\n' % (CONTROLLABLE[ptype], ptype, ptype)
playerid = player['playerid']
state.PLAYER_STATES[playerid].update(js.get_player_props(playerid))
2017-12-14 06:14:27 +11:00
info = state.PLAYER_STATES[playerid]
status = 'paused' if info['speed'] == '0' else 'playing'
ret = ' <Timeline state="%s"' % status
ret += ' controllable="%s"' % CONTROLLABLE[ptype]
ret += ' type="%s" itemType="%s"' % (ptype, ptype)
2017-12-11 05:01:22 +11:00
ret += ' time="%s"' % kodi_time_to_millis(info['time'])
ret += ' duration="%s"' % kodi_time_to_millis(info['totaltime'])
2017-12-14 06:14:27 +11:00
shuffled = '1' if info['shuffled'] else '0'
ret += ' shuffle="%s"' % shuffled
2017-12-11 05:01:22 +11:00
ret += ' repeat="%s"' % v.PLEX_REPEAT_FROM_KODI_REPEAT[info['repeat']]
if ptype != v.KODI_TYPE_PHOTO:
ret += ' volume="%s"' % info['volume']
2017-12-14 06:14:27 +11:00
muted = '1' if info['muted'] is True else '0'
ret += ' mute="%s"' % muted
2016-03-23 01:34:59 +11:00
pbmc_server = window('pms_server')
2017-12-14 18:29:38 +11:00
server = self._server_by_host(self.server)
if pbmc_server:
2017-12-11 05:01:22 +11:00
(self.protocol, self.server, self.port) = pbmc_server.split(':')
self.server = self.server.replace('/', '')
2017-12-11 05:01:22 +11:00
if info['plex_id']:
self.ratingkey = info['plex_id']
ret += ' key="/library/metadata/%s"' % info['plex_id']
ret += ' ratingKey="%s"' % info['plex_id']
# PlayQueue stuff
playqueue = self.playqueue.playqueues[playerid]
pos = info['position']
2017-12-14 06:14:27 +11:00
key = self._get_container_key(playerid)
if key is not None and key.startswith('/playQueues'):
2017-12-14 18:29:38 +11:00
self.container_key = key
ret += ' containerKey="%s"' % self.container_key
2017-12-14 06:14:27 +11:00
ret += ' playQueueItemID="%s"' % playqueue.items[pos].id or 'null'
ret += ' playQueueID="%s"' % playqueue.id or 'null'
ret += ' playQueueVersion="%s"' % playqueue.version or 'null'
2017-12-11 05:01:22 +11:00
ret += ' guid="%s"' % playqueue.items[pos].guid or 'null'
2017-12-14 06:14:27 +11:00
elif key:
2017-12-14 18:29:38 +11:00
self.container_key = key
ret += ' containerKey="%s"' % self.container_key
2017-12-11 05:01:22 +11:00
ret += ' machineIdentifier="%s"' % server.get('uuid', "")
ret += ' protocol="%s"' % server.get('protocol', 'http')
ret += ' address="%s"' % server.get('server', self.server)
ret += ' port="%s"' % server.get('port', self.port)
# Temp. token set?
2017-05-18 04:22:16 +10:00
if state.PLEX_TRANSIENT_TOKEN:
ret += ' token="%s"' % state.PLEX_TRANSIENT_TOKEN
2017-12-11 05:01:22 +11:00
elif playqueue.plex_transient_token:
ret += ' token="%s"' % playqueue.plex_transient_token
2016-02-07 22:38:50 +11:00
# Might need an update in the future
if ptype != v.KODI_TYPE_PHOTO:
strm_id = self._kodi_stream_index(playerid, 'audio')
if strm_id is not None:
ret += ' audioStreamID="%s"' % strm_id
else:
LOG.error('We could not select a Plex audiostream')
if ptype == v.KODI_TYPE_VIDEO and info['subtitleenabled']:
try:
strm_id = self._kodi_stream_index(playerid, 'subtitle')
except KeyError:
# subtitleenabled can be True while currentsubtitle can be {}
strm_id = None
if strm_id is not None:
# If None, then the subtitle is only present on Kodi side
ret += ' subtitleStreamID="%s"' % strm_id
2017-12-14 06:14:27 +11:00
ret += '/>\n'
2016-01-15 22:12:52 +11:00
return ret
2016-02-07 22:38:50 +11:00
2017-12-14 18:29:38 +11:00
def update_command_id(self, uuid, command_id):
"""
Updates the Plex Companien client with the machine identifier uuid with
command_id
"""
if command_id and self.subscribers.get(uuid):
self.subscribers[uuid].command_id = int(command_id)
2016-08-11 03:03:37 +10:00
2017-12-14 18:29:38 +11:00
def notify(self):
"""
Causes PKC to tell the PMS and Plex Companion players to receive a
notification what's being played.
"""
self._cleanup()
2017-12-14 06:14:27 +11:00
# Do we need a check to NOT tell about e.g. PVR/TV and Addon playback?
2017-12-09 23:47:19 +11:00
players = js.get_players()
2016-01-15 22:12:52 +11:00
# fetch the message, subscribers or not, since the server
# will need the info anyway
msg = self.msg(players)
2016-01-15 22:12:52 +11:00
if self.subscribers:
2017-12-14 06:14:27 +11:00
with RLock():
for subscriber in self.subscribers.values():
2017-12-14 18:29:38 +11:00
subscriber.send_update(msg, not players)
self._notify_server(players)
self.lastplayers = players
2016-01-15 22:12:52 +11:00
return True
2016-08-11 03:03:37 +10:00
2017-12-14 18:29:38 +11:00
def _notify_server(self, players):
2017-12-14 06:14:27 +11:00
for typus, player in players.iteritems():
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 self.lastplayers.iteritems():
2017-12-14 06:14:27 +11:00
self.last_params['state'] = 'stopped'
self._send_pms_notification(player['playerid'], self.last_params)
2017-12-14 06:14:27 +11:00
def _get_pms_params(self, playerid):
info = state.PLAYER_STATES[playerid]
status = 'paused' if info['speed'] == '0' else 'playing'
2017-12-14 06:41:29 +11:00
params = {
'state': status,
2017-12-14 06:14:27 +11:00
'ratingKey': self.ratingkey,
'key': '/library/metadata/%s' % self.ratingkey,
'time': kodi_time_to_millis(info['time']),
'duration': kodi_time_to_millis(info['totaltime'])
}
2017-12-14 18:29:38 +11:00
if self.container_key:
params['containerKey'] = self.container_key
if self.container_key is not None and \
self.container_key.startswith('/playQueues/'):
2017-12-14 06:41:29 +11:00
playqueue = self.playqueue.playqueues[playerid]
params['playQueueVersion'] = playqueue.version
params['playQueueItemID'] = playqueue.id
2017-12-14 06:14:27 +11:00
self.last_params = params
return params
def _send_pms_notification(self, playerid, params):
2017-12-14 18:29:38 +11:00
serv = self._server_by_host(self.server)
2017-12-14 06:14:27 +11:00
xargs = self._headers()
playqueue = self.playqueue.playqueues[playerid]
2017-05-18 04:22:16 +10:00
if state.PLEX_TRANSIENT_TOKEN:
xargs['X-Plex-Token'] = state.PLEX_TRANSIENT_TOKEN
elif playqueue.plex_transient_token:
xargs['X-Plex-Token'] = playqueue.plex_transient_token
url = '%s://%s:%s/:/timeline' % (serv.get('protocol', 'http'),
serv.get('server', 'localhost'),
serv.get('port', '32400'))
2017-12-14 18:29:38 +11:00
DU().downloadUrl(url, parameters=params, headerOptions=xargs)
2017-12-14 06:14:27 +11:00
# Save to be able to signal a stop at the end
LOG.debug("Sent server notification with parameters: %s to %s",
params, url)
2016-01-27 22:18:54 +11:00
2017-12-14 18:29:38 +11:00
def add_subscriber(self, protocol, host, port, uuid, command_id):
"""
Adds a new Plex Companion subscriber to PKC.
"""
2017-12-14 06:14:27 +11:00
subscriber = Subscriber(protocol,
host,
port,
uuid,
2017-12-14 18:29:38 +11:00
command_id,
2017-12-14 06:14:27 +11:00
self,
2017-12-14 18:29:38 +11:00
self.request_mgr)
2017-12-14 06:14:27 +11:00
with RLock():
self.subscribers[subscriber.uuid] = subscriber
return subscriber
2017-12-14 18:29:38 +11:00
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)
"""
2017-12-14 06:14:27 +11:00
with RLock():
for subscriber in self.subscribers.values():
if subscriber.uuid == uuid or subscriber.host == uuid:
subscriber.cleanup()
del self.subscribers[subscriber.uuid]
2017-12-14 18:29:38 +11:00
def _cleanup(self):
2017-12-14 06:14:27 +11:00
with RLock():
for subscriber in self.subscribers.values():
if subscriber.age > 30:
subscriber.cleanup()
del self.subscribers[subscriber.uuid]
2016-01-15 22:12:52 +11:00
2017-12-14 18:29:38 +11:00
class Subscriber(object):
"""
Plex Companion subscribing device
"""
def __init__(self, protocol, host, port, uuid, command_id, sub_mgr,
request_mgr):
2016-01-15 22:12:52 +11:00
self.protocol = protocol or "http"
self.host = host
self.port = port or 32400
self.uuid = uuid or host
2017-12-14 18:29:38 +11:00
self.command_id = int(command_id) or 0
2016-01-15 22:12:52 +11:00
self.navlocationsent = False
self.age = 0
2017-12-14 18:29:38 +11:00
self.sub_mgr = sub_mgr
self.request_mgr = request_mgr
2016-01-15 22:12:52 +11:00
def __eq__(self, other):
return self.uuid == other.uuid
2016-01-15 22:12:52 +11:00
def cleanup(self):
2017-12-14 18:29:38 +11:00
"""
Closes the connection to the Plex Companion client
"""
self.request_mgr.closeConnection(self.protocol, self.host, self.port)
2016-01-15 22:12:52 +11:00
def send_update(self, msg, is_nav):
2017-12-14 18:29:38 +11:00
"""
Sends msg to the Plex Companion client (via .../:/timeline)
"""
2016-01-15 22:12:52 +11:00
self.age += 1
if not is_nav:
self.navlocationsent = False
elif self.navlocationsent:
return True
else:
self.navlocationsent = True
2017-12-14 18:29:38 +11:00
msg = sub(r"INSERTCOMMANDID", str(self.command_id), msg)
2017-12-14 06:14:27 +11:00
LOG.debug("sending xml to subscriber uuid=%s,commandID=%i:\n%s",
2017-12-14 18:29:38 +11:00
self.uuid, self.command_id, msg)
url = self.protocol + '://' + self.host + ':' + self.port \
+ "/:/timeline"
2017-12-14 18:29:38 +11:00
thread = Thread(target=self._threaded_send, args=(url, msg))
thread.start()
2016-02-07 22:38:50 +11:00
2017-12-14 18:29:38 +11:00
def _threaded_send(self, url, msg):
"""
Threaded POST request, because they stall due to PMS response missing
the Content-Length header :-(
"""
2017-12-14 18:29:38 +11:00
response = DU().downloadUrl(url, postBody=msg, action_type="POST")
if response in (False, None, 401):
self.sub_mgr.remove_subscriber(self.uuid)