2021-10-22 17:40:25 +11:00
|
|
|
#!/usr/bin/env python
|
|
|
|
# -*- coding: utf-8 -*-
|
2021-11-22 00:40:56 +11:00
|
|
|
import logging
|
|
|
|
from copy import deepcopy
|
|
|
|
import requests
|
|
|
|
import xml.etree.ElementTree as etree
|
|
|
|
|
2021-10-22 17:40:25 +11:00
|
|
|
from .. import variables as v
|
2021-11-22 00:40:56 +11:00
|
|
|
from .. import utils
|
2021-10-22 17:40:25 +11:00
|
|
|
from .. import app
|
2021-11-22 00:40:56 +11:00
|
|
|
from .. import timing
|
|
|
|
|
|
|
|
# Disable annoying requests warnings
|
|
|
|
import requests.packages.urllib3
|
|
|
|
requests.packages.urllib3.disable_warnings()
|
|
|
|
|
|
|
|
log = logging.getLogger('PLEX.companion')
|
|
|
|
|
|
|
|
TIMEOUT = (5, 5)
|
|
|
|
|
|
|
|
# What is Companion controllable?
|
|
|
|
CONTROLLABLE = {
|
|
|
|
v.PLEX_PLAYLIST_TYPE_VIDEO: 'playPause,stop,volume,shuffle,audioStream,'
|
|
|
|
'subtitleStream,seekTo,skipPrevious,skipNext,stepBack,stepForward',
|
|
|
|
v.PLEX_PLAYLIST_TYPE_AUDIO: 'playPause,stop,volume,shuffle,repeat,seekTo,'
|
|
|
|
'skipPrevious,skipNext,stepBack,stepForward',
|
|
|
|
v.PLEX_PLAYLIST_TYPE_PHOTO: 'playPause,stop,skipPrevious,skipNext'
|
|
|
|
}
|
2021-10-22 17:40:25 +11:00
|
|
|
|
|
|
|
|
|
|
|
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',
|
2021-11-07 21:48:58 +11:00
|
|
|
'protocolCapabilities': 'timeline,playback,navigation,playqueues',
|
2021-10-22 17:40:25 +11:00
|
|
|
'protocolVersion': 3
|
|
|
|
}
|
|
|
|
if app.ACCOUNT.pms_token:
|
|
|
|
params['X-Plex-Token'] = app.ACCOUNT.pms_token
|
|
|
|
return params
|
2021-11-22 00:40:56 +11:00
|
|
|
|
|
|
|
|
|
|
|
def player():
|
|
|
|
return {
|
|
|
|
'product': v.ADDON_NAME,
|
|
|
|
'deviceClass': 'pc',
|
|
|
|
'platform': v.PLATFORM,
|
|
|
|
'platformVersion': v.PLATFORM_VERSION,
|
|
|
|
'protocolVersion': '3',
|
|
|
|
'title': v.DEVICENAME,
|
|
|
|
'protocolCapabilities': 'timeline,playback,navigation,playqueues',
|
|
|
|
'machineIdentifier': v.PKC_MACHINE_IDENTIFIER,
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
def get_correct_position(info, playqueue):
|
|
|
|
"""
|
|
|
|
Kodi tells us the PLAYLIST position, not PLAYQUEUE position, if the
|
|
|
|
user initiated playback of a playlist
|
|
|
|
"""
|
|
|
|
if playqueue.kodi_playlist_playback:
|
|
|
|
position = 0
|
|
|
|
else:
|
|
|
|
position = info['position'] or 0
|
|
|
|
return position
|
|
|
|
|
|
|
|
|
|
|
|
def create_requests_session():
|
|
|
|
s = requests.Session()
|
|
|
|
s.headers = proxy_headers()
|
|
|
|
s.verify = app.CONN.verify_ssl_cert
|
|
|
|
if app.CONN.ssl_cert_path:
|
|
|
|
s.cert = app.CONN.ssl_cert_path
|
|
|
|
s.params = proxy_params()
|
|
|
|
return s
|
|
|
|
|
|
|
|
|
|
|
|
def communicate(method, url, **kwargs):
|
|
|
|
req = method(url, **kwargs)
|
|
|
|
req.encoding = 'utf-8'
|
|
|
|
# To make sure that we release the socket, need to access content once
|
|
|
|
req.content
|
|
|
|
return req
|
|
|
|
|
|
|
|
|
|
|
|
def timeline_dict(playerid, typus):
|
|
|
|
with app.APP.lock_playqueues:
|
|
|
|
info = app.PLAYSTATE.player_states[playerid]
|
|
|
|
playqueue = app.PLAYQUEUES[playerid]
|
|
|
|
position = get_correct_position(info, playqueue)
|
|
|
|
try:
|
|
|
|
item = playqueue.items[position]
|
|
|
|
except IndexError:
|
|
|
|
# E.g. for direct path playback for single item
|
|
|
|
return {
|
|
|
|
'controllable': CONTROLLABLE[typus],
|
|
|
|
'type': typus,
|
|
|
|
'state': 'stopped'
|
|
|
|
}
|
|
|
|
if typus == v.PLEX_PLAYLIST_TYPE_VIDEO and not item.streams_initialized:
|
|
|
|
# Not ready yet to send updates
|
|
|
|
raise TypeError()
|
|
|
|
o = utils.urlparse(app.CONN.server)
|
|
|
|
status = 'paused' if int(info['speed']) == 0 else 'playing'
|
|
|
|
duration = timing.kodi_time_to_millis(info['totaltime'])
|
|
|
|
shuffle = '1' if info['shuffled'] else '0'
|
|
|
|
mute = '1' if info['muted'] is True else '0'
|
|
|
|
answ = {
|
|
|
|
'controllable': CONTROLLABLE[typus],
|
|
|
|
'protocol': o.scheme,
|
|
|
|
'address': o.hostname,
|
|
|
|
'port': str(o.port),
|
|
|
|
'machineIdentifier': app.CONN.machine_identifier,
|
|
|
|
'state': status,
|
|
|
|
'type': typus,
|
|
|
|
'itemType': typus,
|
|
|
|
'time': str(timing.kodi_time_to_millis(info['time'])),
|
|
|
|
'duration': str(duration),
|
|
|
|
'seekRange': '0-%s' % duration,
|
|
|
|
'shuffle': shuffle,
|
|
|
|
'repeat': v.PLEX_REPEAT_FROM_KODI_REPEAT[info['repeat']],
|
|
|
|
'volume': str(info['volume']),
|
|
|
|
'mute': mute,
|
|
|
|
'mediaIndex': '0', # Still to implement
|
|
|
|
'partIndex': '0',
|
|
|
|
'partCount': '1',
|
|
|
|
'providerIdentifier': 'com.plexapp.plugins.library',
|
|
|
|
}
|
|
|
|
# Get the plex id from the PKC playqueue not info, as Kodi jumps to
|
|
|
|
# next playqueue element way BEFORE kodi monitor onplayback is
|
|
|
|
# called
|
|
|
|
if item.plex_id:
|
|
|
|
answ['key'] = '/library/metadata/%s' % item.plex_id
|
|
|
|
answ['ratingKey'] = str(item.plex_id)
|
|
|
|
# PlayQueue stuff
|
|
|
|
if info['container_key']:
|
|
|
|
answ['containerKey'] = info['container_key']
|
|
|
|
if (info['container_key'] is not None and
|
|
|
|
info['container_key'].startswith('/playQueues')):
|
|
|
|
answ['playQueueID'] = str(playqueue.id)
|
|
|
|
answ['playQueueVersion'] = str(playqueue.version)
|
|
|
|
answ['playQueueItemID'] = str(item.id)
|
|
|
|
if playqueue.items[position].guid:
|
|
|
|
answ['guid'] = item.guid
|
|
|
|
# Temp. token set?
|
|
|
|
if app.CONN.plex_transient_token:
|
|
|
|
answ['token'] = app.CONN.plex_transient_token
|
|
|
|
elif playqueue.plex_transient_token:
|
|
|
|
answ['token'] = playqueue.plex_transient_token
|
|
|
|
# Process audio and subtitle streams
|
|
|
|
if typus == v.PLEX_PLAYLIST_TYPE_VIDEO:
|
|
|
|
item.current_kodi_video_stream = info['currentvideostream']['index']
|
|
|
|
item.current_kodi_audio_stream = info['currentaudiostream']['index']
|
|
|
|
item.current_kodi_sub_stream_enabled = info['subtitleenabled']
|
|
|
|
try:
|
|
|
|
item.current_kodi_sub_stream = info['currentsubtitle']['index']
|
|
|
|
except KeyError:
|
|
|
|
item.current_kodi_sub_stream = None
|
|
|
|
answ['videoStreamID'] = str(item.current_plex_video_stream)
|
|
|
|
answ['audioStreamID'] = str(item.current_plex_audio_stream)
|
|
|
|
# Mind the zero - meaning subs are deactivated
|
|
|
|
answ['subtitleStreamID'] = str(item.current_plex_sub_stream or 0)
|
|
|
|
return answ
|
|
|
|
|
|
|
|
|
|
|
|
def timeline(players):
|
|
|
|
"""
|
|
|
|
Returns a timeline xml as str
|
|
|
|
(xml containing video, audio, photo player state)
|
|
|
|
"""
|
|
|
|
xml = etree.Element('MediaContainer')
|
|
|
|
location = 'navigation'
|
|
|
|
for typus in (v.PLEX_PLAYLIST_TYPE_AUDIO,
|
|
|
|
v.PLEX_PLAYLIST_TYPE_VIDEO,
|
|
|
|
v.PLEX_PLAYLIST_TYPE_PHOTO):
|
|
|
|
player = players.get(v.KODI_PLAYLIST_TYPE_FROM_PLEX_PLAYLIST_TYPE[typus])
|
|
|
|
if player is None:
|
|
|
|
# Kodi player currently not actively playing, but stopped
|
|
|
|
timeline = {
|
|
|
|
'controllable': CONTROLLABLE[typus],
|
|
|
|
'type': typus,
|
|
|
|
'state': 'stopped'
|
|
|
|
}
|
|
|
|
else:
|
|
|
|
# Active Kodi player, i.e. video, audio or picture player
|
|
|
|
timeline = timeline_dict(player['playerid'], typus)
|
|
|
|
if typus in (v.PLEX_PLAYLIST_TYPE_VIDEO, v.PLEX_PLAYLIST_TYPE_PHOTO):
|
|
|
|
location = 'fullScreenVideo'
|
|
|
|
etree.SubElement(xml, 'Timeline', attrib=timeline)
|
|
|
|
xml.set('location', location)
|
|
|
|
return xml
|
|
|
|
|
|
|
|
|
|
|
|
def stopped_timeline():
|
|
|
|
"""
|
|
|
|
Returns an etree XML stating that all players have stopped playback
|
|
|
|
"""
|
|
|
|
xml = etree.Element('MediaContainer', attrib={'location': 'navigation'})
|
|
|
|
for typus in (v.PLEX_PLAYLIST_TYPE_AUDIO,
|
|
|
|
v.PLEX_PLAYLIST_TYPE_VIDEO,
|
|
|
|
v.PLEX_PLAYLIST_TYPE_PHOTO):
|
|
|
|
# Kodi player currently not actively playing, but stopped
|
|
|
|
timeline = {
|
|
|
|
'controllable': CONTROLLABLE[typus],
|
|
|
|
'type': typus,
|
|
|
|
'state': 'stopped'
|
|
|
|
}
|
|
|
|
etree.SubElement(xml, 'Timeline', attrib=timeline)
|
|
|
|
return xml
|
|
|
|
|
|
|
|
|
|
|
|
def b_ok_message():
|
|
|
|
"""
|
|
|
|
Returns a byte-encoded (b'') OK message XML for the PMS
|
|
|
|
"""
|
|
|
|
return etree.tostring(
|
|
|
|
etree.Element('Response', attrib={'code': '200', 'status': 'OK'}),
|
|
|
|
encoding='utf8')
|
|
|
|
|
|
|
|
|
|
|
|
class Subscriber(object):
|
|
|
|
def __init__(self, playstate_mgr, cmd=None, uuid=None, command_id=None,
|
|
|
|
url=None):
|
|
|
|
self.playstate_mgr = playstate_mgr
|
|
|
|
if cmd is not None:
|
|
|
|
self.uuid = cmd.get('clientIdentifier')
|
|
|
|
self.command_id = int(cmd.get('commandID', 0))
|
|
|
|
self.url = f'{app.CONN.server}/player/proxy/timeline'
|
|
|
|
else:
|
|
|
|
self.uuid = str(uuid)
|
|
|
|
self.command_id = command_id
|
|
|
|
self.url = f'{url}/:/timeline'
|
|
|
|
self.s = create_requests_session()
|
|
|
|
self._errors_left = 3
|
|
|
|
|
|
|
|
def __eq__(self, other):
|
|
|
|
if isinstance(other, str):
|
|
|
|
return self.uuid == other
|
|
|
|
elif isinstance(other, Subscriber):
|
|
|
|
return self.uuid == other.uuid
|
|
|
|
else:
|
|
|
|
return False
|
|
|
|
|
|
|
|
def __hash__(self):
|
|
|
|
return hash(self.uuid)
|
|
|
|
|
|
|
|
def __del__(self):
|
|
|
|
"""Make sure we are closing the Session() correctly."""
|
|
|
|
self.s.close()
|
|
|
|
|
|
|
|
def _on_error(self):
|
|
|
|
self._errors_left -= 1
|
|
|
|
if self._errors_left == 0:
|
|
|
|
log.warn('Too many issues contacting subscriber %s. Unsubscribing',
|
|
|
|
self.uuid)
|
|
|
|
self.playstate_mgr.unsubscribe(self)
|
|
|
|
|
|
|
|
def send_timeline(self, message, state):
|
|
|
|
message = deepcopy(message)
|
|
|
|
message.set('commandID', str(self.command_id + 1))
|
|
|
|
self.s.params['state'] = state
|
|
|
|
self.s.params['commandID'] = self.command_id + 1
|
|
|
|
# Send update
|
|
|
|
log.debug('Sending timeline update to %s with params %s',
|
|
|
|
self.uuid, self.s.params)
|
|
|
|
utils.log_xml(message, log.debug, logging.DEBUG)
|
|
|
|
try:
|
|
|
|
req = communicate(self.s.post,
|
|
|
|
self.url,
|
|
|
|
data=etree.tostring(message, encoding='utf8'),
|
|
|
|
timeout=TIMEOUT)
|
|
|
|
except requests.RequestException as error:
|
|
|
|
log.warn('Error sending timeline to Subscriber %s: %s: %s',
|
|
|
|
self.uuid, self.url, error)
|
|
|
|
self._on_error()
|
|
|
|
return
|
|
|
|
except SystemExit:
|
|
|
|
return
|
|
|
|
if not req.ok:
|
|
|
|
log_error(log.error,
|
|
|
|
'Unexpected Companion timeline response for player '
|
|
|
|
f'{self.uuid}: {self.url}',
|
|
|
|
req)
|
|
|
|
self._on_error()
|
|
|
|
|
|
|
|
|
|
|
|
class UUIDStr(str):
|
|
|
|
"""
|
|
|
|
Subclass of str in order to be able to compare to Subscriber objects
|
|
|
|
like this: if UUIDStr() in list(Subscriber(), Subscriber()): ...
|
|
|
|
"""
|
|
|
|
|
|
|
|
def __eq__(self, other):
|
|
|
|
if isinstance(other, Subscriber):
|
|
|
|
return self == other.uuid
|
|
|
|
else:
|
|
|
|
return super().__eq__(other)
|
|
|
|
|
|
|
|
def __hash__(self):
|
|
|
|
return super().__hash__()
|