PlexKodiConnect/resources/lib/plexbmchelper/subscribers.py

403 lines
16 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
2017-12-21 19:28:06 +11:00
from threading import Thread, Lock
2017-12-09 23:47:19 +11:00
2017-12-14 18:29:38 +11:00
from downloadutils import DownloadUtils as DU
2017-12-21 19:28:06 +11:00
from utils import window, kodi_time_to_millis, Lock_Function
from playlist_func import init_Plex_playlist
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__)
2017-12-21 19:28:06 +11:00
# Need to lock all methods and functions messing with subscribers or state
LOCK = Lock()
LOCKER = Lock_Function(LOCK)
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-21 19:28:06 +11:00
self.isplaying = False
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 {}
2017-12-21 19:28:06 +11:00
@LOCKER.lockthis
def msg(self, players):
"""
Returns a timeline xml as str
(xml containing video, audio, photo player state)
"""
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>"
2017-12-21 19:28:06 +11:00
LOG.debug('Our PKC message 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')
2017-12-16 02:08:20 +11:00
# To avoid RuntimeError, don't use self.lastplayers
for playerid in (0, 1, 2):
self.last_params['state'] = 'stopped'
2017-12-16 02:08:20 +11:00
self._send_pms_notification(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
2017-12-21 19:28:06 +11:00
def _plex_stream_index(self, playerid, stream_type):
"""
2017-12-21 19:28:06 +11:00
Returns the current Plex stream index [str] 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-21 19:28:06 +11:00
@staticmethod
def _player_info(playerid):
"""
Grabs all player info again for playerid [int].
Returns the dict state.PLAYER_STATES[playerid]
"""
# Update our PKC state of how the player actually looks like
state.PLAYER_STATES[playerid].update(js.get_player_props(playerid))
state.PLAYER_STATES[playerid]['volume'] = js.get_volume()
state.PLAYER_STATES[playerid]['muted'] = js.get_muted()
return state.PLAYER_STATES[playerid]
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']
2017-12-21 19:28:06 +11:00
info = self._player_info(playerid)
playqueue = self.playqueue.playqueues[playerid]
pos = info['position']
try:
playqueue.items[pos]
except IndexError:
# E.g. for direct path playback for single item
return ' <Timeline state="stopped" controllable="%s" type="%s" ' \
'itemType="%s" />\n' % (CONTROLLABLE[ptype], ptype, ptype)
LOG.debug('INFO: %s', info)
LOG.debug('playqueue: %s', playqueue)
2017-12-14 06:14:27 +11:00
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
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
2017-12-21 19:28:06 +11:00
# Process audio and subtitle streams
if ptype != v.KODI_TYPE_PHOTO:
2017-12-21 19:28:06 +11:00
strm_id = self._plex_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:
2017-12-21 19:28:06 +11:00
strm_id = self._plex_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-21 19:28:06 +11:00
self.isplaying = True
return ret + '/>\n'
2016-02-07 22:38:50 +11:00
2017-12-21 19:28:06 +11:00
@LOCKER.lockthis
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.
"""
2017-12-21 19:28:06 +11:00
with LOCK:
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()
2017-12-21 19:28:06 +11:00
# fetch the message, subscribers or not, since the server will need the
# info anyway
self.isplaying = False
msg = self.msg(players)
2017-12-21 19:28:06 +11:00
with LOCK:
if self.isplaying is True:
# If we don't check here, Plex Companion devices will simply
# drop out of the Plex Companion playback screen
2017-12-14 06:14:27 +11:00
for subscriber in self.subscribers.values():
2017-12-14 18:29:38 +11:00
subscriber.send_update(msg, not players)
2017-12-21 19:28:06 +11:00
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
2017-12-21 19:28:06 +11:00
elif state.PLEX_TOKEN:
xargs['X-Plex-Token'] = state.PLEX_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
LOG.debug("Sent server notification with parameters: %s to %s",
params, url)
2016-01-27 22:18:54 +11:00
2017-12-21 19:28:06 +11:00
@LOCKER.lockthis
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-21 19:28:06 +11:00
self.subscribers[subscriber.uuid] = subscriber
2017-12-14 06:14:27 +11:00
return subscriber
2017-12-21 19:28:06 +11:00
@LOCKER.lockthis
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-21 19:28:06 +11:00
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-21 19:28:06 +11:00
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)