414 lines
16 KiB
Python
414 lines
16 KiB
Python
#!/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 .playqueue import compare_playqueues
|
|
|
|
from .. import json_rpc as js
|
|
from .. import variables as v
|
|
from .. import backgroundthread
|
|
from .. import app
|
|
from .. import timing
|
|
|
|
|
|
# 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 = app.PLAYQUEUES[playerid]
|
|
position = get_correct_position(info, playqueue)
|
|
try:
|
|
item = playqueue.items[position]
|
|
except IndexError:
|
|
# E.g. for direct path playback for single item
|
|
return {
|
|
'controllable': CONTROLLABLE[typus],
|
|
'type': typus,
|
|
'state': 'stopped'
|
|
}
|
|
if typus == v.PLEX_PLAYLIST_TYPE_VIDEO and not item.streams_initialized:
|
|
# Not ready yet to send updates
|
|
raise TypeError()
|
|
protocol, url, port = split_server_uri(app.CONN.server)
|
|
status = 'paused' if int(info['speed']) == 0 else 'playing'
|
|
duration = timing.kodi_time_to_millis(info['totaltime'])
|
|
shuffle = '1' if info['shuffled'] else '0'
|
|
mute = '1' if info['muted'] is True else '0'
|
|
answ = {
|
|
'controllable': CONTROLLABLE[typus],
|
|
'protocol': protocol,
|
|
'address': url,
|
|
'port': port,
|
|
'machineIdentifier': app.CONN.machine_identifier,
|
|
'state': status,
|
|
'type': typus,
|
|
'itemType': typus,
|
|
'time': str(timing.kodi_time_to_millis(info['time'])),
|
|
'duration': str(duration),
|
|
'seekRange': '0-%s' % duration,
|
|
'shuffle': shuffle,
|
|
'repeat': v.PLEX_REPEAT_FROM_KODI_REPEAT[info['repeat']],
|
|
'volume': str(info['volume']),
|
|
'mute': mute,
|
|
'mediaIndex': '0', # Still to implement
|
|
'partIndex': '0',
|
|
'partCount': '1',
|
|
'providerIdentifier': 'com.plexapp.plugins.library',
|
|
}
|
|
# Get the plex id from the PKC playqueue not info, as Kodi jumps to
|
|
# next playqueue element way BEFORE kodi monitor onplayback is
|
|
# called
|
|
if item.plex_id:
|
|
answ['key'] = '/library/metadata/%s' % item.plex_id
|
|
answ['ratingKey'] = str(item.plex_id)
|
|
# PlayQueue stuff
|
|
if info['container_key']:
|
|
answ['containerKey'] = info['container_key']
|
|
if (info['container_key'] is not None and
|
|
info['container_key'].startswith('/playQueues')):
|
|
answ['playQueueID'] = str(playqueue.id)
|
|
answ['playQueueVersion'] = str(playqueue.version)
|
|
answ['playQueueItemID'] = str(item.id)
|
|
if playqueue.items[position].guid:
|
|
answ['guid'] = item.guid
|
|
# Temp. token set?
|
|
if app.CONN.plex_transient_token:
|
|
answ['token'] = app.CONN.plex_transient_token
|
|
elif playqueue.plex_transient_token:
|
|
answ['token'] = playqueue.plex_transient_token
|
|
# Process audio and subtitle streams
|
|
if typus == v.PLEX_PLAYLIST_TYPE_VIDEO:
|
|
item.current_kodi_video_stream = info['currentvideostream']['index']
|
|
item.current_kodi_audio_stream = info['currentaudiostream']['index']
|
|
item.current_kodi_sub_stream_enabled = info['subtitleenabled']
|
|
try:
|
|
item.current_kodi_sub_stream = info['currentsubtitle']['index']
|
|
except KeyError:
|
|
item.current_kodi_sub_stream = None
|
|
answ['videoStreamID'] = str(item.current_plex_video_stream)
|
|
answ['audioStreamID'] = str(item.current_plex_audio_stream)
|
|
# Mind the zero - meaning subs are deactivated
|
|
answ['subtitleStreamID'] = str(item.current_plex_sub_stream or 0)
|
|
return answ
|
|
|
|
|
|
def timeline(players):
|
|
"""
|
|
Returns a timeline xml as str
|
|
(xml containing video, audio, photo player state)
|
|
"""
|
|
xml = etree.Element('MediaContainer')
|
|
location = 'navigation'
|
|
for typus in (v.PLEX_PLAYLIST_TYPE_AUDIO,
|
|
v.PLEX_PLAYLIST_TYPE_VIDEO,
|
|
v.PLEX_PLAYLIST_TYPE_PHOTO):
|
|
player = players.get(v.KODI_PLAYLIST_TYPE_FROM_PLEX_PLAYLIST_TYPE[typus])
|
|
if player is None:
|
|
# Kodi player currently not actively playing, but stopped
|
|
timeline = {
|
|
'controllable': CONTROLLABLE[typus],
|
|
'type': typus,
|
|
'state': 'stopped'
|
|
}
|
|
else:
|
|
# Active Kodi player, i.e. video, audio or picture player
|
|
timeline = timeline_dict(player['playerid'], typus)
|
|
if typus in (v.PLEX_PLAYLIST_TYPE_VIDEO, v.PLEX_PLAYLIST_TYPE_PHOTO):
|
|
location = 'fullScreenVideo'
|
|
etree.SubElement(xml, 'Timeline', attrib=timeline)
|
|
xml.set('location', location)
|
|
return xml
|
|
|
|
|
|
def stopped_timeline():
|
|
"""
|
|
Returns an XML stating that all players have stopped playback
|
|
"""
|
|
xml = etree.Element('MediaContainer', attrib={'location': 'navigation'})
|
|
for typus in (v.PLEX_PLAYLIST_TYPE_AUDIO,
|
|
v.PLEX_PLAYLIST_TYPE_VIDEO,
|
|
v.PLEX_PLAYLIST_TYPE_PHOTO):
|
|
# Kodi player currently not actively playing, but stopped
|
|
timeline = {
|
|
'controllable': CONTROLLABLE[typus],
|
|
'type': typus,
|
|
'state': 'stopped'
|
|
}
|
|
etree.SubElement(xml, 'Timeline', attrib=timeline)
|
|
return xml
|
|
|
|
|
|
def update_player_info(players):
|
|
"""
|
|
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
|
|
# Check for Kodi playlist changes first
|
|
with app.APP.lock_playqueues:
|
|
for playqueue in app.PLAYQUEUES:
|
|
kodi_pl = js.playlist_get_items(playqueue.playlistid)
|
|
if playqueue.old_kodi_pl != kodi_pl:
|
|
if playqueue.id is None and (not app.SYNC.direct_paths or
|
|
app.PLAYSTATE.context_menu_play):
|
|
# Only initialize if directly fired up using direct
|
|
# paths. Otherwise let default.py do its magic
|
|
log.debug('Not yet initiating playback')
|
|
else:
|
|
# compare old and new playqueue
|
|
compare_playqueues(playqueue, kodi_pl)
|
|
playqueue.old_kodi_pl = list(kodi_pl)
|
|
# Then check for Kodi playback
|
|
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)
|