Merge pull request #1687 from croneter/py3-companion-poll-rework

Huge overhaul: completely new Plex Companion implementation. PKC is now available as a casting target for Plexamp. Includes refactoring of Skip Intro as well as Playqueues
This commit is contained in:
croneter 2021-11-03 07:59:33 +01:00 committed by GitHub
commit 06a85d41c5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 1383 additions and 1898 deletions

View file

@ -9,12 +9,14 @@ from .application import App
from .connection import Connection from .connection import Connection
from .libsync import Sync from .libsync import Sync
from .playstate import PlayState from .playstate import PlayState
from .playqueues import Playqueues
ACCOUNT = None ACCOUNT = None
APP = None APP = None
CONN = None CONN = None
SYNC = None SYNC = None
PLAYSTATE = None PLAYSTATE = None
PLAYQUEUES = None
def init(entrypoint=False): def init(entrypoint=False):
@ -22,13 +24,15 @@ def init(entrypoint=False):
entrypoint=True initiates only the bare minimum - for other PKC python entrypoint=True initiates only the bare minimum - for other PKC python
instances instances
""" """
global ACCOUNT, APP, CONN, SYNC, PLAYSTATE global ACCOUNT, APP, CONN, SYNC, PLAYSTATE, PLAYQUEUES
APP = App(entrypoint) APP = App(entrypoint)
CONN = Connection(entrypoint) CONN = Connection(entrypoint)
ACCOUNT = Account(entrypoint) ACCOUNT = Account(entrypoint)
SYNC = Sync(entrypoint) SYNC = Sync(entrypoint)
if not entrypoint: if not entrypoint:
PLAYSTATE = PlayState() PLAYSTATE = PlayState()
PLAYQUEUES = Playqueues()
def reload(): def reload():
""" """

View file

@ -0,0 +1,230 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from logging import getLogger
import xbmc
from .. import variables as v
LOG = getLogger('PLEX.playqueue')
class Playqueue(object):
"""
PKC object to represent PMS playQueues and Kodi playlist for queueing
playlistid = None [int] Kodi playlist id (0, 1, 2)
type = None [str] Kodi type: 'audio', 'video', 'picture'
kodi_pl = None Kodi xbmc.PlayList object
items = [] [list] of Playlist_Items
id = None [str] Plex playQueueID, unique Plex identifier
version = None [int] Plex version of the playQueue
selectedItemID = None
[str] Plex selectedItemID, playing element in queue
selectedItemOffset = None
[str] Offset of the playing element in queue
shuffled = 0 [int] 0: not shuffled, 1: ??? 2: ???
repeat = 0 [int] 0: not repeated, 1: ??? 2: ???
If Companion playback is initiated by another user:
plex_transient_token = None
"""
kind = 'playQueue'
def __init__(self):
self.id = None
self.type = None
self.playlistid = None
self.kodi_pl = None
self.items = []
self.version = None
self.selectedItemID = None
self.selectedItemOffset = None
self.shuffled = 0
self.repeat = 0
self.plex_transient_token = None
# Need a hack for detecting swaps of elements
self.old_kodi_pl = []
# Did PKC itself just change the playqueue so the PKC playqueue monitor
# should not pick up any changes?
self.pkc_edit = False
# Workaround to avoid endless loops of detecting PL clears
self._clear_list = []
# To keep track if Kodi playback was initiated from a Kodi playlist
# There are a couple of pitfalls, unfortunately...
self.kodi_playlist_playback = False
def __repr__(self):
answ = ("{{"
"'playlistid': {self.playlistid}, "
"'id': {self.id}, "
"'version': {self.version}, "
"'type': '{self.type}', "
"'selectedItemID': {self.selectedItemID}, "
"'selectedItemOffset': {self.selectedItemOffset}, "
"'shuffled': {self.shuffled}, "
"'repeat': {self.repeat}, "
"'kodi_playlist_playback': {self.kodi_playlist_playback}, "
"'pkc_edit': {self.pkc_edit}, ".format(self=self))
# Since list.__repr__ will return string, not unicode
return answ + "'items': {self.items}}}".format(self=self)
def is_pkc_clear(self):
"""
Returns True if PKC has cleared the Kodi playqueue just recently.
Then this clear will be ignored from now on
"""
try:
self._clear_list.pop()
except IndexError:
return False
else:
return True
def clear(self, kodi=True):
"""
Resets the playlist object to an empty playlist.
Pass kodi=False in order to NOT clear the Kodi playqueue
"""
# kodi monitor's on_clear method will only be called if there were some
# items to begin with
if kodi and self.kodi_pl.size() != 0:
self._clear_list.append(None)
self.kodi_pl.clear() # Clear Kodi playlist object
self.items = []
self.id = None
self.version = None
self.selectedItemID = None
self.selectedItemOffset = None
self.shuffled = 0
self.repeat = 0
self.plex_transient_token = None
self.old_kodi_pl = []
self.kodi_playlist_playback = False
LOG.debug('Playlist cleared: %s', self)
def position_from_plex_id(self, plex_id):
"""
Returns the position [int] for the very first item with plex_id [int]
(Plex seems uncapable of adding the same element multiple times to a
playqueue or playlist)
Raises KeyError if not found
"""
for position, item in enumerate(self.items):
if item.plex_id == plex_id:
break
else:
raise KeyError('Did not find plex_id %s in %s', plex_id, self)
return position
class Playqueues(list):
def __init__(self):
super().__init__()
for i, typus in enumerate((v.KODI_PLAYLIST_TYPE_AUDIO,
v.KODI_PLAYLIST_TYPE_VIDEO,
v.KODI_PLAYLIST_TYPE_PHOTO)):
playqueue = Playqueue()
playqueue.playlistid = i
playqueue.type = typus
# Initialize each Kodi playlist
if typus == v.KODI_PLAYLIST_TYPE_AUDIO:
playqueue.kodi_pl = xbmc.PlayList(xbmc.PLAYLIST_MUSIC)
elif typus == v.KODI_PLAYLIST_TYPE_VIDEO:
playqueue.kodi_pl = xbmc.PlayList(xbmc.PLAYLIST_VIDEO)
else:
# Currently, only video or audio playqueues available
playqueue.kodi_pl = xbmc.PlayList(xbmc.PLAYLIST_VIDEO)
# Overwrite 'picture' with 'photo'
playqueue.type = v.KODI_TYPE_PHOTO
self.append(playqueue)
@property
def audio(self):
return self[0]
@property
def video(self):
return self[1]
@property
def photo(self):
return self[2]
def from_kodi_playlist_type(self, kodi_playlist_type):
"""
Returns the playqueue according to the kodi_playlist_type ('video',
'audio', 'picture') passed in
"""
if kodi_playlist_type == v.KODI_PLAYLIST_TYPE_AUDIO:
return self[0]
elif kodi_playlist_type == v.KODI_PLAYLIST_TYPE_VIDEO:
return self[1]
elif kodi_playlist_type == v.KODI_PLAYLIST_TYPE_PHOTO:
return self[2]
else:
raise ValueError('Unknown kodi_playlist_type: %s' % kodi_playlist_type)
def from_kodi_type(self, kodi_type):
"""
Pass in the kodi_type (e.g. the string 'movie') to get the correct
playqueue (either video, audio or picture)
"""
if kodi_type == v.KODI_TYPE_VIDEO:
return self[1]
elif kodi_type == v.KODI_TYPE_MOVIE:
return self[1]
elif kodi_type == v.KODI_TYPE_EPISODE:
return self[1]
elif kodi_type == v.KODI_TYPE_SEASON:
return self[1]
elif kodi_type == v.KODI_TYPE_SHOW:
return self[1]
elif kodi_type == v.KODI_TYPE_CLIP:
return self[1]
elif kodi_type == v.KODI_TYPE_SONG:
return self[0]
elif kodi_type == v.KODI_TYPE_ALBUM:
return self[0]
elif kodi_type == v.KODI_TYPE_ARTIST:
return self[0]
elif kodi_type == v.KODI_TYPE_AUDIO:
return self[0]
elif kodi_type == v.KODI_TYPE_PHOTO:
return self[2]
else:
raise ValueError('Unknown kodi_type: %s' % kodi_type)
def from_plex_type(self, plex_type):
"""
Pass in the plex_type (e.g. the string 'movie') to get the correct
playqueue (either video, audio or picture)
"""
if plex_type == v.PLEX_TYPE_VIDEO:
return self[1]
elif plex_type == v.PLEX_TYPE_MOVIE:
return self[1]
elif plex_type == v.PLEX_TYPE_EPISODE:
return self[1]
elif plex_type == v.PLEX_TYPE_SEASON:
return self[1]
elif plex_type == v.PLEX_TYPE_SHOW:
return self[1]
elif plex_type == v.PLEX_TYPE_CLIP:
return self[1]
elif plex_type == v.PLEX_TYPE_SONG:
return self[0]
elif plex_type == v.PLEX_TYPE_ALBUM:
return self[0]
elif plex_type == v.PLEX_TYPE_ARTIST:
return self[0]
elif plex_type == v.PLEX_TYPE_AUDIO:
return self[0]
elif plex_type == v.PLEX_TYPE_PHOTO:
return self[2]
else:
raise ValueError('Unknown plex_type: %s' % plex_type)

View file

@ -6,8 +6,10 @@ Processes Plex companion inputs from the plexbmchelper to Kodi commands
from logging import getLogger from logging import getLogger
from xbmc import Player from xbmc import Player
from . import playqueue as PQ, plex_functions as PF from . import plex_functions as PF
from . import json_rpc as js, variables as v, app from . import json_rpc as js
from . import variables as v
from . import app
############################################################################### ###############################################################################
@ -28,7 +30,7 @@ def skip_to(params):
playqueue_item_id, plex_id) playqueue_item_id, plex_id)
found = True found = True
for player in list(js.get_players().values()): for player in list(js.get_players().values()):
playqueue = PQ.PLAYQUEUES[player['playerid']] playqueue = app.PLAYQUEUES[player['playerid']]
for i, item in enumerate(playqueue.items): for i, item in enumerate(playqueue.items):
if item.id == playqueue_item_id: if item.id == playqueue_item_id:
found = True found = True

View file

@ -6,8 +6,11 @@ import xbmcgui
from .plex_api import API from .plex_api import API
from .plex_db import PlexDB from .plex_db import PlexDB
from . import context, plex_functions as PF, playqueue as PQ from . import context
from . import utils, variables as v, app from . import plex_functions as PF
from . import utils
from . import variables as v
from . import app
############################################################################### ###############################################################################
@ -137,8 +140,7 @@ class ContextMenu(object):
""" """
For using direct paths: Initiates playback using the PMS For using direct paths: Initiates playback using the PMS
""" """
playqueue = PQ.get_playqueue_from_type( playqueue = app.PLAYQUEUES.from_kodi_type(self.kodi_type)
v.KODI_PLAYLIST_TYPE_FROM_KODI_TYPE[self.kodi_type])
playqueue.clear() playqueue.clear()
app.PLAYSTATE.context_menu_play = True app.PLAYSTATE.context_menu_play = True
handle = self.api.fullpath(force_addon=True)[0] handle = self.api.fullpath(force_addon=True)[0]

View file

@ -17,7 +17,7 @@ from .kodi_db import KodiVideoDB
from . import kodi_db from . import kodi_db
from .downloadutils import DownloadUtils as DU from .downloadutils import DownloadUtils as DU
from . import utils, timing, plex_functions as PF from . import utils, timing, plex_functions as PF
from . import json_rpc as js, playqueue as PQ, playlist_func as PL from . import json_rpc as js, playlist_func as PL
from . import backgroundthread, app, variables as v from . import backgroundthread, app, variables as v
from . import exceptions from . import exceptions
@ -140,7 +140,7 @@ class KodiMonitor(xbmc.Monitor):
u'playlistid': 1, u'playlistid': 1,
} }
""" """
playqueue = PQ.PLAYQUEUES[data['playlistid']] playqueue = app.PLAYQUEUES[data['playlistid']]
if not playqueue.is_pkc_clear(): if not playqueue.is_pkc_clear():
playqueue.pkc_edit = True playqueue.pkc_edit = True
playqueue.clear(kodi=False) playqueue.clear(kodi=False)
@ -256,7 +256,7 @@ class KodiMonitor(xbmc.Monitor):
if not playerid: if not playerid:
LOG.error('Coud not get playerid for data %s', data) LOG.error('Coud not get playerid for data %s', data)
return return
playqueue = PQ.PLAYQUEUES[playerid] playqueue = app.PLAYQUEUES[playerid]
info = js.get_player_props(playerid) info = js.get_player_props(playerid)
if playqueue.kodi_playlist_playback: if playqueue.kodi_playlist_playback:
# Kodi will tell us the wrong position - of the playlist, not the # Kodi will tell us the wrong position - of the playlist, not the
@ -326,7 +326,7 @@ class KodiMonitor(xbmc.Monitor):
container_key = None container_key = None
if info['playlistid'] != -1: if info['playlistid'] != -1:
# -1 is Kodi's answer if there is no playlist # -1 is Kodi's answer if there is no playlist
container_key = PQ.PLAYQUEUES[playerid].id container_key = app.PLAYQUEUES[playerid].id
if container_key is not None: if container_key is not None:
container_key = '/playQueues/%s' % container_key container_key = '/playQueues/%s' % container_key
elif plex_id is not None: elif plex_id is not None:
@ -367,6 +367,11 @@ class KodiMonitor(xbmc.Monitor):
# We need to switch to the Plex streams ONCE upon playback start # We need to switch to the Plex streams ONCE upon playback start
if playerid == v.KODI_VIDEO_PLAYER_ID: if playerid == v.KODI_VIDEO_PLAYER_ID:
# The Kodi player takes forever to initialize all streams
# Especially subtitles, apparently. No way to tell when Kodi
# is done :-(
if app.APP.monitor.waitForAbort(5):
return
item.init_kodi_streams() item.init_kodi_streams()
item.switch_to_plex_stream('video') item.switch_to_plex_stream('video')
if utils.settings('audioStreamPick') == '0': if utils.settings('audioStreamPick') == '0':

View file

@ -12,8 +12,12 @@ import xbmc
from .plex_api import API from .plex_api import API
from .plex_db import PlexDB from .plex_db import PlexDB
from .kodi_db import KodiVideoDB from .kodi_db import KodiVideoDB
from . import plex_functions as PF, playlist_func as PL, playqueue as PQ from . import plex_functions as PF
from . import json_rpc as js, variables as v, utils, transfer from . import playlist_func as PL
from . import json_rpc as js
from . import variables as v
from . import utils
from . import transfer
from . import playback_decision, app from . import playback_decision, app
from . import exceptions from . import exceptions
@ -74,20 +78,19 @@ def _playback_triage(plex_id, plex_type, path, resolve, resume):
_ensure_resolve(abort=True) _ensure_resolve(abort=True)
return return
with app.APP.lock_playqueues: with app.APP.lock_playqueues:
playqueue = PQ.get_playqueue_from_type( playqueue = app.PLAYQUEUES.from_plex_type(plex_type)
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[plex_type])
try: try:
pos = js.get_position(playqueue.playlistid) pos = js.get_position(playqueue.playlistid)
except KeyError: except KeyError:
# Kodi bug - Playlist plays (not Playqueue) will ALWAYS be audio for # Kodi bug - Playlist plays (not Playqueue) will ALWAYS be audio for
# add-on paths # add-on paths
LOG.debug('No position returned from player! Assuming playlist') LOG.debug('No position returned from player! Assuming playlist')
playqueue = PQ.get_playqueue_from_type(v.KODI_PLAYLIST_TYPE_AUDIO) playqueue = app.PLAYQUEUES.audio
try: try:
pos = js.get_position(playqueue.playlistid) pos = js.get_position(playqueue.playlistid)
except KeyError: except KeyError:
LOG.debug('Assuming video instead of audio playlist playback') LOG.debug('Assuming video instead of audio playlist playback')
playqueue = PQ.get_playqueue_from_type(v.KODI_PLAYLIST_TYPE_VIDEO) playqueue = app.PLAYQUEUES.video
try: try:
pos = js.get_position(playqueue.playlistid) pos = js.get_position(playqueue.playlistid)
except KeyError: except KeyError:
@ -159,7 +162,7 @@ def _playlist_playback(plex_id):
return return
# Kodi bug: playqueue will ALWAYS be audio playqueue UNTIL playback # Kodi bug: playqueue will ALWAYS be audio playqueue UNTIL playback
# has actually started. Need to tell Kodimonitor # has actually started. Need to tell Kodimonitor
playqueue = PQ.get_playqueue_from_type(v.KODI_PLAYLIST_TYPE_AUDIO) playqueue = app.PLAYQUEUES.audio
playqueue.clear(kodi=False) playqueue.clear(kodi=False)
# Set the flag for the potentially WRONG audio playlist so Kodimonitor # Set the flag for the potentially WRONG audio playlist so Kodimonitor
# can pick up on it # can pick up on it
@ -499,8 +502,7 @@ def process_indirect(key, offset, resolve=True):
api = API(xml[0]) api = API(xml[0])
listitem = api.listitem(listitem=transfer.PKCListItem, resume=False) listitem = api.listitem(listitem=transfer.PKCListItem, resume=False)
playqueue = PQ.get_playqueue_from_type( playqueue = app.PLAYQUEUES.from_plex_type(api.plex_type)
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[api.plex_type])
playqueue.clear() playqueue.clear()
item = PL.playlist_item_from_xml(xml[0]) item = PL.playlist_item_from_xml(xml[0])
item.offset = offset item.offset = offset

View file

@ -22,117 +22,6 @@ from .subtitles import accessible_plex_subtitles
LOG = getLogger('PLEX.playlist_func') LOG = getLogger('PLEX.playlist_func')
class Playqueue_Object(object):
"""
PKC object to represent PMS playQueues and Kodi playlist for queueing
playlistid = None [int] Kodi playlist id (0, 1, 2)
type = None [str] Kodi type: 'audio', 'video', 'picture'
kodi_pl = None Kodi xbmc.PlayList object
items = [] [list] of Playlist_Items
id = None [str] Plex playQueueID, unique Plex identifier
version = None [int] Plex version of the playQueue
selectedItemID = None
[str] Plex selectedItemID, playing element in queue
selectedItemOffset = None
[str] Offset of the playing element in queue
shuffled = 0 [int] 0: not shuffled, 1: ??? 2: ???
repeat = 0 [int] 0: not repeated, 1: ??? 2: ???
If Companion playback is initiated by another user:
plex_transient_token = None
"""
kind = 'playQueue'
def __init__(self):
self.id = None
self.type = None
self.playlistid = None
self.kodi_pl = None
self.items = []
self.version = None
self.selectedItemID = None
self.selectedItemOffset = None
self.shuffled = 0
self.repeat = 0
self.plex_transient_token = None
# Need a hack for detecting swaps of elements
self.old_kodi_pl = []
# Did PKC itself just change the playqueue so the PKC playqueue monitor
# should not pick up any changes?
self.pkc_edit = False
# Workaround to avoid endless loops of detecting PL clears
self._clear_list = []
# To keep track if Kodi playback was initiated from a Kodi playlist
# There are a couple of pitfalls, unfortunately...
self.kodi_playlist_playback = False
def __repr__(self):
answ = ("{{"
"'playlistid': {self.playlistid}, "
"'id': {self.id}, "
"'version': {self.version}, "
"'type': '{self.type}', "
"'selectedItemID': {self.selectedItemID}, "
"'selectedItemOffset': {self.selectedItemOffset}, "
"'shuffled': {self.shuffled}, "
"'repeat': {self.repeat}, "
"'kodi_playlist_playback': {self.kodi_playlist_playback}, "
"'pkc_edit': {self.pkc_edit}, ".format(self=self))
# Since list.__repr__ will return string, not unicode
return answ + "'items': {self.items}}}".format(self=self)
def is_pkc_clear(self):
"""
Returns True if PKC has cleared the Kodi playqueue just recently.
Then this clear will be ignored from now on
"""
try:
self._clear_list.pop()
except IndexError:
return False
else:
return True
def clear(self, kodi=True):
"""
Resets the playlist object to an empty playlist.
Pass kodi=False in order to NOT clear the Kodi playqueue
"""
# kodi monitor's on_clear method will only be called if there were some
# items to begin with
if kodi and self.kodi_pl.size() != 0:
self._clear_list.append(None)
self.kodi_pl.clear() # Clear Kodi playlist object
self.items = []
self.id = None
self.version = None
self.selectedItemID = None
self.selectedItemOffset = None
self.shuffled = 0
self.repeat = 0
self.plex_transient_token = None
self.old_kodi_pl = []
self.kodi_playlist_playback = False
LOG.debug('Playlist cleared: %s', self)
def position_from_plex_id(self, plex_id):
"""
Returns the position [int] for the very first item with plex_id [int]
(Plex seems uncapable of adding the same element multiple times to a
playqueue or playlist)
Raises KeyError if not found
"""
for position, item in enumerate(self.items):
if item.plex_id == plex_id:
break
else:
raise KeyError('Did not find plex_id %s in %s', plex_id, self)
return position
class PlaylistItem(object): class PlaylistItem(object):
""" """
Object to fill our playqueues and playlists with. Object to fill our playqueues and playlists with.
@ -281,14 +170,44 @@ class PlaylistItem(object):
elif stream_type == 'video': elif stream_type == 'video':
return self.video_streams return self.video_streams
@staticmethod
def _current_index(stream_type):
"""
Kodi might tell us the wrong index for any stream after playback start
Get the correct one!
"""
function = {
'audio': js.get_current_audio_stream_index,
'video': js.get_current_video_stream_index,
'subtitle': js.get_current_subtitle_stream_index
}[stream_type]
i = 0
while i < 30:
# Really annoying: Kodi might return wrong results directly after
# playback startup, e.g. a Kodi audio index of 1953718901 (!)
try:
index = function(v.KODI_VIDEO_PLAYER_ID)
except TypeError:
# No sensible reply yet
pass
else:
if index != 1953718901:
# Correct result!
return index
i += 1
app.APP.monitor.waitForAbort(0.1)
else:
raise RuntimeError('Kodi did not tell us the correct index for %s'
% stream_type)
def init_kodi_streams(self): def init_kodi_streams(self):
""" """
Initializes all streams after Kodi has started playing this video Initializes all streams after Kodi has started playing this video
""" """
self.current_kodi_video_stream = js.get_current_video_stream_index(v.KODI_VIDEO_PLAYER_ID) self.current_kodi_video_stream = self._current_index('video')
self.current_kodi_audio_stream = js.get_current_audio_stream_index(v.KODI_VIDEO_PLAYER_ID) self.current_kodi_audio_stream = self._current_index('audio')
self.current_kodi_sub_stream = False if not js.get_subtitle_enabled(v.KODI_VIDEO_PLAYER_ID) \ self.current_kodi_sub_stream = False if not js.get_subtitle_enabled(v.KODI_VIDEO_PLAYER_ID) \
else js.get_current_subtitle_stream_index(v.KODI_VIDEO_PLAYER_ID) else self._current_index('subtitle')
def plex_stream_index(self, kodi_stream_index, stream_type): def plex_stream_index(self, kodi_stream_index, stream_type):
""" """
@ -312,13 +231,16 @@ class PlaylistItem(object):
Pass in the plex_stream_index [int] in order to receive the Kodi stream Pass in the plex_stream_index [int] in order to receive the Kodi stream
index [int]. index [int].
stream_type: 'video', 'audio', 'subtitle' stream_type: 'video', 'audio', 'subtitle'
Returns None if unsuccessful Raises ValueError if unsuccessful
""" """
if plex_stream_index is None: if not isinstance(plex_stream_index, int):
return 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)): for i, stream in enumerate(self._get_iterator(stream_type)):
if cast(int, stream.get('id')) == plex_stream_index: if cast(int, stream.get('id')) == plex_stream_index:
return i 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): def active_plex_stream_index(self, stream_type):
""" """
@ -408,8 +330,10 @@ class PlaylistItem(object):
return return
LOG.debug('The PMS wants to display %s stream with Plex id %s and ' LOG.debug('The PMS wants to display %s stream with Plex id %s and '
'languageTag %s', typus, plex_index, language_tag) 'languageTag %s', typus, plex_index, language_tag)
kodi_index = self.kodi_stream_index(plex_index, typus) try:
if kodi_index is None: kodi_index = self.kodi_stream_index(plex_index, typus)
except ValueError:
kodi_index = None
LOG.debug('Leaving Kodi %s stream settings untouched since we ' LOG.debug('Leaving Kodi %s stream settings untouched since we '
'could not parse Plex %s stream with id %s to a Kodi' 'could not parse Plex %s stream with id %s to a Kodi'
' index', typus, typus, plex_index) ' index', typus, typus, plex_index)
@ -438,10 +362,28 @@ class PlaylistItem(object):
Call this method if Kodi reports an "AV-Change" Call this method if Kodi reports an "AV-Change"
(event "Player.OnAVChange") (event "Player.OnAVChange")
""" """
kodi_video_stream = js.get_current_video_stream_index(playerid) i = 0
kodi_audio_stream = js.get_current_audio_stream_index(playerid) while i < 20:
# Really annoying: Kodi might return wrong results directly after
# playback startup, e.g. a Kodi audio index of 1953718901 (!)
kodi_video_stream = js.get_current_video_stream_index(playerid)
kodi_audio_stream = js.get_current_audio_stream_index(playerid)
if kodi_video_stream < len(self.video_streams) and kodi_audio_stream < len(self.audio_streams):
# Correct result!
break
i += 1
if app.APP.monitor.waitForAbort(0.1):
# Need to quit PKC
return
else:
LOG.error('Could not get sensible Kodi indices! kodi_video_stream '
'%s, kodi_audio_stream %s',
kodi_video_stream, kodi_audio_stream)
return
kodi_video_stream = self._current_index('video')
kodi_audio_stream = self._current_index('audio')
sub_enabled = js.get_subtitle_enabled(playerid) sub_enabled = js.get_subtitle_enabled(playerid)
kodi_sub_stream = js.get_current_subtitle_stream_index(playerid) kodi_sub_stream = self._current_index('subtitle')
# Audio # Audio
if kodi_audio_stream != self.current_kodi_audio_stream: if kodi_audio_stream != self.current_kodi_audio_stream:
self.on_kodi_audio_stream_change(kodi_audio_stream) self.on_kodi_audio_stream_change(kodi_audio_stream)
@ -457,28 +399,43 @@ class PlaylistItem(object):
and kodi_sub_stream != self.current_kodi_sub_stream)): and kodi_sub_stream != self.current_kodi_sub_stream)):
self.on_kodi_subtitle_stream_change(kodi_sub_stream, sub_enabled) 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: if video_stream_id is not None:
plex_index = int(plex_data['audioStreamID']) try:
kodi_index = self.kodi_stream_index(plex_index, 'audio') kodi_index = self.kodi_stream_index(video_stream_id, 'video')
self._set_kodi_stream_if_different(kodi_index, 'audio') except ValueError:
self.current_kodi_audio_stream = kodi_index LOG.error('Unexpected Plex video_stream_id %s, not changing '
if 'videoStreamID' in plex_data: 'the video stream!', video_stream_id)
plex_index = int(plex_data['videoStreamID']) return
kodi_index = self.kodi_stream_index(plex_index, 'video')
self._set_kodi_stream_if_different(kodi_index, 'video') self._set_kodi_stream_if_different(kodi_index, 'video')
self.current_kodi_video_stream = kodi_index self.current_kodi_video_stream = kodi_index
if 'subtitleStreamID' in plex_data: if audio_stream_id is not None:
plex_index = int(plex_data['subtitleStreamID']) try:
if plex_index == 0: kodi_index = self.kodi_stream_index(audio_stream_id, 'audio')
except ValueError:
LOG.error('Unexpected Plex audio_stream_id %s, not changing '
'the video stream!', audio_stream_id)
return
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) app.APP.player.showSubtitles(False)
kodi_index = False kodi_index = False
else: else:
kodi_index = self.kodi_stream_index(plex_index, 'subtitle') try:
if kodi_index: kodi_index = self.kodi_stream_index(subtitle_stream_id,
'subtitle')
except ValueError:
kodi_index = None
LOG.debug('The PMS wanted to change subs, but we could not'
' match the sub with id %s to a Kodi sub',
subtitle_stream_id)
else:
app.APP.player.setSubtitleStream(kodi_index) app.APP.player.setSubtitleStream(kodi_index)
app.APP.player.showSubtitles(True) app.APP.player.showSubtitles(True)
self.current_kodi_sub_stream = kodi_index self.current_kodi_sub_stream = kodi_index

View file

@ -1,232 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Monitors the Kodi playqueue and adjusts the Plex playqueue accordingly
"""
from logging import getLogger
import copy
import xbmc
from .plex_api import API
from . import playlist_func as PL, plex_functions as PF
from . import backgroundthread, utils, json_rpc as js, app, variables as v
from . import exceptions
###############################################################################
LOG = getLogger('PLEX.playqueue')
PLUGIN = 'plugin://%s' % v.ADDON_ID
# Our PKC playqueues (3 instances of Playqueue_Object())
PLAYQUEUES = []
###############################################################################
def init_playqueues():
"""
Call this once on startup to initialize the PKC playqueue objects in
the list PLAYQUEUES
"""
if PLAYQUEUES:
LOG.debug('Playqueues have already been initialized')
return
# Initialize Kodi playqueues
with app.APP.lock_playqueues:
for i in (0, 1, 2):
# Just in case the Kodi response is not sorted correctly
for queue in js.get_playlists():
if queue['playlistid'] != i:
continue
playqueue = PL.Playqueue_Object()
playqueue.playlistid = i
playqueue.type = queue['type']
# Initialize each Kodi playlist
if playqueue.type == v.KODI_TYPE_AUDIO:
playqueue.kodi_pl = xbmc.PlayList(xbmc.PLAYLIST_MUSIC)
elif playqueue.type == v.KODI_TYPE_VIDEO:
playqueue.kodi_pl = xbmc.PlayList(xbmc.PLAYLIST_VIDEO)
else:
# Currently, only video or audio playqueues available
playqueue.kodi_pl = xbmc.PlayList(xbmc.PLAYLIST_VIDEO)
# Overwrite 'picture' with 'photo'
playqueue.type = v.KODI_TYPE_PHOTO
PLAYQUEUES.append(playqueue)
LOG.debug('Initialized the Kodi playqueues: %s', PLAYQUEUES)
def get_playqueue_from_type(kodi_playlist_type):
"""
Returns the playqueue according to the kodi_playlist_type ('video',
'audio', 'picture') passed in
"""
for playqueue in PLAYQUEUES:
if playqueue.type == kodi_playlist_type:
break
else:
raise ValueError('Wrong playlist type passed in: %s',
kodi_playlist_type)
return playqueue
def init_playqueue_from_plex_children(plex_id, transient_token=None):
"""
Init a new playqueue e.g. from an album. Alexa does this
Returns the playqueue
"""
xml = PF.GetAllPlexChildren(plex_id)
try:
xml[0].attrib
except (TypeError, IndexError, AttributeError):
LOG.error('Could not download the PMS xml for %s', plex_id)
return
playqueue = get_playqueue_from_type(
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[xml[0].attrib['type']])
playqueue.clear()
for i, child in enumerate(xml):
api = API(child)
try:
PL.add_item_to_playlist(playqueue, i, plex_id=api.plex_id)
except exceptions.PlaylistError:
LOG.error('Could not add Plex item to our playlist: %s, %s',
child.tag, child.attrib)
playqueue.plex_transient_token = transient_token
LOG.debug('Firing up Kodi player')
app.APP.player.play(playqueue.kodi_pl, None, False, 0)
return playqueue
class PlayqueueMonitor(backgroundthread.KillableThread):
"""
Unfortunately, Kodi does not tell if items within a Kodi playqueue
(playlist) are swapped. This is what this monitor is for. Don't replace
this mechanism till Kodi's implementation of playlists has improved
"""
def _compare_playqueues(self, playqueue, new_kodi_playqueue):
"""
Used to poll the Kodi playqueue and update the Plex playqueue if needed
"""
old = list(playqueue.items)
# We might append to new_kodi_playqueue but will need the original
# still back in the main loop
new = copy.deepcopy(new_kodi_playqueue)
index = list(range(0, len(old)))
LOG.debug('Comparing new Kodi playqueue %s with our play queue %s',
new, old)
for i, new_item in enumerate(new):
if (new_item['file'].startswith('plugin://') and
not new_item['file'].startswith(PLUGIN)):
# Ignore new media added by other addons
continue
for j, old_item in enumerate(old):
if self.should_suspend() or self.should_cancel():
# Chances are that we got an empty Kodi playlist due to
# Kodi exit
return
try:
if (old_item.file.startswith('plugin://') and
not old_item.file.startswith(PLUGIN)):
# Ignore media by other addons
continue
except AttributeError:
# were not passed a filename; ignore
pass
if 'id' in new_item:
identical = (old_item.kodi_id == new_item['id'] and
old_item.kodi_type == new_item['type'])
else:
try:
plex_id = int(utils.REGEX_PLEX_ID.findall(new_item['file'])[0])
except IndexError:
LOG.debug('Comparing paths directly as a fallback')
identical = old_item.file == new_item['file']
else:
identical = plex_id == old_item.plex_id
if j == 0 and identical:
del old[j], index[j]
break
elif identical:
LOG.debug('Playqueue item %s moved to position %s',
i + j, i)
try:
PL.move_playlist_item(playqueue, i + j, i)
except exceptions.PlaylistError:
LOG.error('Could not modify playqueue positions')
LOG.error('This is likely caused by mixing audio and '
'video tracks in the Kodi playqueue')
del old[j], index[j]
break
else:
LOG.debug('Detected new Kodi element at position %s: %s ',
i, new_item)
try:
if playqueue.id is None:
PL.init_plex_playqueue(playqueue, kodi_item=new_item)
else:
PL.add_item_to_plex_playqueue(playqueue,
i,
kodi_item=new_item)
except exceptions.PlaylistError:
# Could not add the element
pass
except KeyError:
# Catches KeyError from PL.verify_kodi_item()
# Hack: Kodi already started playback of a new item and we
# started playback already using kodimonitors
# PlayBackStart(), but the Kodi playlist STILL only shows
# the old element. Hence ignore playlist difference here
LOG.debug('Detected an outdated Kodi playlist - ignoring')
return
except IndexError:
# This is really a hack - happens when using Addon Paths
# and repeatedly starting the same element. Kodi will then
# not pass kodi id nor file path AND will also not
# start-up playback. Hence kodimonitor kicks off playback.
# Also see kodimonitor.py - _playlist_onadd()
pass
else:
for j in range(i, len(index)):
index[j] += 1
for i in reversed(index):
if self.should_suspend() or self.should_cancel():
# Chances are that we got an empty Kodi playlist due to
# Kodi exit
return
LOG.debug('Detected deletion of playqueue element at pos %s', i)
try:
PL.delete_playlist_item_from_PMS(playqueue, i)
except exceptions.PlaylistError:
LOG.error('Could not delete PMS element from position %s', i)
LOG.error('This is likely caused by mixing audio and '
'video tracks in the Kodi playqueue')
LOG.debug('Done comparing playqueues')
def run(self):
LOG.info("----===## Starting PlayqueueMonitor ##===----")
app.APP.register_thread(self)
try:
self._run()
finally:
app.APP.deregister_thread(self)
LOG.info("----===## PlayqueueMonitor stopped ##===----")
def _run(self):
while not self.should_cancel():
if self.should_suspend():
if self.wait_while_suspended():
return
with app.APP.lock_playqueues:
for playqueue in 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
self._compare_playqueues(playqueue, kodi_pl)
playqueue.old_kodi_pl = list(kodi_pl)
self.sleep(0.2)

View file

@ -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()

View file

@ -0,0 +1,5 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from .polling import Listener
from .playstate import PlaystateMgr

View 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

View file

@ -0,0 +1,144 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from logging import getLogger
import copy
from ..plex_api import API
from .. import variables as v
from .. import app
from .. import utils
from .. import plex_functions as PF
from .. import playlist_func as PL
from .. import exceptions
log = getLogger('PLEX.companion.playqueue')
PLUGIN = 'plugin://%s' % v.ADDON_ID
def init_playqueue_from_plex_children(plex_id, transient_token=None):
"""
Init a new playqueue e.g. from an album. Alexa does this
Returns the playqueue
"""
xml = PF.GetAllPlexChildren(plex_id)
try:
xml[0].attrib
except (TypeError, IndexError, AttributeError):
log.error('Could not download the PMS xml for %s', plex_id)
return
playqueue = app.PLAYQUEUES.from_plex_type(xml[0].attrib['type'])
playqueue.clear()
for i, child in enumerate(xml):
api = API(child)
try:
PL.add_item_to_playlist(playqueue, i, plex_id=api.plex_id)
except exceptions.PlaylistError:
log.error('Could not add Plex item to our playlist: %s, %s',
child.tag, child.attrib)
playqueue.plex_transient_token = transient_token
log.debug('Firing up Kodi player')
app.APP.player.play(playqueue.kodi_pl, None, False, 0)
return playqueue
def compare_playqueues(playqueue, new_kodi_playqueue):
"""
Used to poll the Kodi playqueue and update the Plex playqueue if needed
"""
old = list(playqueue.items)
# We might append to new_kodi_playqueue but will need the original
# still back in the main loop
new = copy.deepcopy(new_kodi_playqueue)
index = list(range(0, len(old)))
log.debug('Comparing new Kodi playqueue %s with our play queue %s',
new, old)
for i, new_item in enumerate(new):
if (new_item['file'].startswith('plugin://') and
not new_item['file'].startswith(PLUGIN)):
# Ignore new media added by other addons
continue
for j, old_item in enumerate(old):
if app.APP.stop_pkc:
# Chances are that we got an empty Kodi playlist due to
# Kodi exit
return
try:
if (old_item.file.startswith('plugin://') and
not old_item.file.startswith(PLUGIN)):
# Ignore media by other addons
continue
except AttributeError:
# were not passed a filename; ignore
pass
if 'id' in new_item:
identical = (old_item.kodi_id == new_item['id'] and
old_item.kodi_type == new_item['type'])
else:
try:
plex_id = int(utils.REGEX_PLEX_ID.findall(new_item['file'])[0])
except IndexError:
log.debug('Comparing paths directly as a fallback')
identical = old_item.file == new_item['file']
else:
identical = plex_id == old_item.plex_id
if j == 0 and identical:
del old[j], index[j]
break
elif identical:
log.debug('Playqueue item %s moved to position %s',
i + j, i)
try:
PL.move_playlist_item(playqueue, i + j, i)
except exceptions.PlaylistError:
log.error('Could not modify playqueue positions')
log.error('This is likely caused by mixing audio and '
'video tracks in the Kodi playqueue')
del old[j], index[j]
break
else:
log.debug('Detected new Kodi element at position %s: %s ',
i, new_item)
try:
if playqueue.id is None:
PL.init_plex_playqueue(playqueue, kodi_item=new_item)
else:
PL.add_item_to_plex_playqueue(playqueue,
i,
kodi_item=new_item)
except exceptions.PlaylistError:
# Could not add the element
pass
except KeyError:
# Catches KeyError from PL.verify_kodi_item()
# Hack: Kodi already started playback of a new item and we
# started playback already using kodimonitors
# PlayBackStart(), but the Kodi playlist STILL only shows
# the old element. Hence ignore playlist difference here
log.debug('Detected an outdated Kodi playlist - ignoring')
return
except IndexError:
# This is really a hack - happens when using Addon Paths
# and repeatedly starting the same element. Kodi will then
# not pass kodi id nor file path AND will also not
# start-up playback. Hence kodimonitor kicks off playback.
# Also see kodimonitor.py - _playlist_onadd()
pass
else:
for j in range(i, len(index)):
index[j] += 1
for i in reversed(index):
if app.APP.stop_pkc:
# Chances are that we got an empty Kodi playlist due to
# Kodi exit
return
log.debug('Detected deletion of playqueue element at pos %s', i)
try:
PL.delete_playlist_item_from_PMS(playqueue, i)
except exceptions.PlaylistError:
log.error('Could not delete PMS element from position %s', i)
log.error('This is likely caused by mixing audio and '
'video tracks in the Kodi playqueue')
log.debug('Done comparing playqueues')

View file

@ -0,0 +1,411 @@
#!/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
from .. import skip_plex_intro
# 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'
}
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
# 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
try:
# Check whether an intro is currently running
skip_plex_intro.check()
except IndexError:
# Playback might have already stopped
pass
# 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)

View 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'))

View file

@ -0,0 +1,236 @@
#!/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 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 = app.PLAYQUEUES.from_plex_type(typus)
except ValueError:
# 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 = app.PLAYQUEUES.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(plex_type, video_stream_id, audio_stream_id,
subtitle_stream_id):
"""
Plex Companion client adjusted audio or subtitle stream
"""
playqueue = app.PLAYQUEUES.from_plex_type(plex_type)
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 = app.PLAYQUEUES.from_plex_type(plex_type)
playqueue.clear()
return
playqueue = app.PLAYQUEUES.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 = app.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

View file

@ -18,7 +18,7 @@ class Playlists(object):
def delete_playlist(self, playlist): def delete_playlist(self, playlist):
""" """
Removes the entry for playlist [Playqueue_Object] from the Plex Removes the entry for playlist [Playqueue()] from the Plex
playlists table. playlists table.
Be sure to either set playlist.id or playlist.kodi_path Be sure to either set playlist.id or playlist.kodi_path
""" """

View file

@ -1 +0,0 @@
# Dummy file to make this directory a package.

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -2,6 +2,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import logging import logging
import sys import sys
import xbmc import xbmc
import xbmcvfs import xbmcvfs
@ -11,14 +12,12 @@ from . import kodimonitor
from . import sync, library_sync from . import sync, library_sync
from . import websocket_client from . import websocket_client
from . import plex_companion from . import plex_companion
from . import plex_functions as PF, playqueue as PQ from . import plex_functions as PF
from . import playback_starter from . import playback_starter
from . import playqueue
from . import variables as v from . import variables as v
from . import app from . import app
from . import loghandler from . import loghandler
from . import backgroundthread from . import backgroundthread
from . import skip_plex_intro
from .windows import userselect from .windows import userselect
############################################################################### ###############################################################################
@ -34,7 +33,6 @@ WINDOW_PROPERTIES = (
class Service(object): class Service(object):
ws = None ws = None
sync = None sync = None
plexcompanion = None
def __init__(self): def __init__(self):
self._init_done = False self._init_done = False
@ -100,7 +98,6 @@ class Service(object):
self.setup = None self.setup = None
self.pms_ws = None self.pms_ws = None
self.alexa_ws = None self.alexa_ws = None
self.playqueue = None
# Flags for other threads # Flags for other threads
self.connection_check_running = False self.connection_check_running = False
self.auth_running = False self.auth_running = False
@ -437,8 +434,6 @@ class Service(object):
app.init() app.init()
app.APP.monitor = kodimonitor.KodiMonitor() app.APP.monitor = kodimonitor.KodiMonitor()
app.APP.player = xbmc.Player() app.APP.player = xbmc.Player()
# Initialize the PKC playqueues
PQ.init_playqueues()
# Server auto-detect # Server auto-detect
self.setup = initialsetup.InitialSetup() self.setup = initialsetup.InitialSetup()
@ -448,8 +443,11 @@ class Service(object):
self.pms_ws = websocket_client.get_pms_websocketapp() self.pms_ws = websocket_client.get_pms_websocketapp()
self.alexa_ws = websocket_client.get_alexa_websocketapp() self.alexa_ws = websocket_client.get_alexa_websocketapp()
self.sync = sync.Sync() self.sync = sync.Sync()
self.plexcompanion = plex_companion.PlexCompanion() self.companion_playstate_mgr = plex_companion.PlaystateMgr()
self.playqueue = playqueue.PlayqueueMonitor() if utils.settings('plexCompanion') == 'true':
self.companion_listener = plex_companion.Listener(self.companion_playstate_mgr)
else:
self.companion_listener = None
# Main PKC program loop # Main PKC program loop
while not self.should_cancel(): while not self.should_cancel():
@ -548,13 +546,11 @@ class Service(object):
self.startup_completed = True self.startup_completed = True
self.pms_ws.start() self.pms_ws.start()
self.sync.start() self.sync.start()
self.plexcompanion.start() self.companion_playstate_mgr.start()
self.playqueue.start() if self.companion_listener is not None:
self.companion_listener.start()
self.alexa_ws.start() self.alexa_ws.start()
elif app.APP.is_playing:
skip_plex_intro.check()
xbmc.sleep(200) xbmc.sleep(200)
# EXITING PKC # EXITING PKC

View file

@ -32,8 +32,6 @@ def skip_intro(intros):
def check(): def check():
with app.APP.lock_playqueues: with app.APP.lock_playqueues:
if len(app.PLAYSTATE.active_players) != 1:
return
playerid = list(app.PLAYSTATE.active_players)[0] playerid = list(app.PLAYSTATE.active_players)[0]
intros = app.PLAYSTATE.player_states[playerid]['intro_markers'] intros = app.PLAYSTATE.player_states[playerid]['intro_markers']
if not intros: if not intros:

View file

@ -556,6 +556,15 @@ def reset(ask_user=True):
reboot_kodi() 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): def compare_version(current, minimum):
""" """
Returns True if current is >= then minimum. False otherwise. Returns True Returns True if current is >= then minimum. False otherwise. Returns True

View file

@ -30,8 +30,9 @@ ADDON_FOLDER = xbmcvfs.translatePath('special://home')
ADDON_PROFILE = xbmcvfs.translatePath(_ADDON.getAddonInfo('profile')) ADDON_PROFILE = xbmcvfs.translatePath(_ADDON.getAddonInfo('profile'))
# Used e.g. for json_rpc # Used e.g. for json_rpc
KODI_VIDEO_PLAYER_ID = 1
KODI_AUDIO_PLAYER_ID = 0 KODI_AUDIO_PLAYER_ID = 0
KODI_VIDEO_PLAYER_ID = 1
KODI_PHOTO_PLAYER_ID = 2
KODILANGUAGE = xbmc.getLanguage(xbmc.ISO_639_1) KODILANGUAGE = xbmc.getLanguage(xbmc.ISO_639_1)
KODIVERSION = int(xbmc.getInfoLabel("System.BuildVersion")[:2]) KODIVERSION = int(xbmc.getInfoLabel("System.BuildVersion")[:2])