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:
commit
06a85d41c5
25 changed files with 1383 additions and 1898 deletions
|
@ -9,12 +9,14 @@ from .application import App
|
|||
from .connection import Connection
|
||||
from .libsync import Sync
|
||||
from .playstate import PlayState
|
||||
from .playqueues import Playqueues
|
||||
|
||||
ACCOUNT = None
|
||||
APP = None
|
||||
CONN = None
|
||||
SYNC = None
|
||||
PLAYSTATE = None
|
||||
PLAYQUEUES = None
|
||||
|
||||
|
||||
def init(entrypoint=False):
|
||||
|
@ -22,13 +24,15 @@ def init(entrypoint=False):
|
|||
entrypoint=True initiates only the bare minimum - for other PKC python
|
||||
instances
|
||||
"""
|
||||
global ACCOUNT, APP, CONN, SYNC, PLAYSTATE
|
||||
global ACCOUNT, APP, CONN, SYNC, PLAYSTATE, PLAYQUEUES
|
||||
APP = App(entrypoint)
|
||||
CONN = Connection(entrypoint)
|
||||
ACCOUNT = Account(entrypoint)
|
||||
SYNC = Sync(entrypoint)
|
||||
if not entrypoint:
|
||||
PLAYSTATE = PlayState()
|
||||
PLAYQUEUES = Playqueues()
|
||||
|
||||
|
||||
def reload():
|
||||
"""
|
||||
|
|
230
resources/lib/app/playqueues.py
Normal file
230
resources/lib/app/playqueues.py
Normal 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)
|
|
@ -6,8 +6,10 @@ Processes Plex companion inputs from the plexbmchelper to Kodi commands
|
|||
from logging import getLogger
|
||||
from xbmc import Player
|
||||
|
||||
from . import playqueue as PQ, plex_functions as PF
|
||||
from . import json_rpc as js, variables as v, app
|
||||
from . import plex_functions as PF
|
||||
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)
|
||||
found = True
|
||||
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):
|
||||
if item.id == playqueue_item_id:
|
||||
found = True
|
||||
|
|
|
@ -6,8 +6,11 @@ import xbmcgui
|
|||
|
||||
from .plex_api import API
|
||||
from .plex_db import PlexDB
|
||||
from . import context, plex_functions as PF, playqueue as PQ
|
||||
from . import utils, variables as v, app
|
||||
from . import context
|
||||
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
|
||||
"""
|
||||
playqueue = PQ.get_playqueue_from_type(
|
||||
v.KODI_PLAYLIST_TYPE_FROM_KODI_TYPE[self.kodi_type])
|
||||
playqueue = app.PLAYQUEUES.from_kodi_type(self.kodi_type)
|
||||
playqueue.clear()
|
||||
app.PLAYSTATE.context_menu_play = True
|
||||
handle = self.api.fullpath(force_addon=True)[0]
|
||||
|
|
|
@ -17,7 +17,7 @@ from .kodi_db import KodiVideoDB
|
|||
from . import kodi_db
|
||||
from .downloadutils import DownloadUtils as DU
|
||||
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 exceptions
|
||||
|
||||
|
@ -140,7 +140,7 @@ class KodiMonitor(xbmc.Monitor):
|
|||
u'playlistid': 1,
|
||||
}
|
||||
"""
|
||||
playqueue = PQ.PLAYQUEUES[data['playlistid']]
|
||||
playqueue = app.PLAYQUEUES[data['playlistid']]
|
||||
if not playqueue.is_pkc_clear():
|
||||
playqueue.pkc_edit = True
|
||||
playqueue.clear(kodi=False)
|
||||
|
@ -256,7 +256,7 @@ class KodiMonitor(xbmc.Monitor):
|
|||
if not playerid:
|
||||
LOG.error('Coud not get playerid for data %s', data)
|
||||
return
|
||||
playqueue = PQ.PLAYQUEUES[playerid]
|
||||
playqueue = app.PLAYQUEUES[playerid]
|
||||
info = js.get_player_props(playerid)
|
||||
if playqueue.kodi_playlist_playback:
|
||||
# Kodi will tell us the wrong position - of the playlist, not the
|
||||
|
@ -326,7 +326,7 @@ class KodiMonitor(xbmc.Monitor):
|
|||
container_key = None
|
||||
if info['playlistid'] != -1:
|
||||
# -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:
|
||||
container_key = '/playQueues/%s' % container_key
|
||||
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
|
||||
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.switch_to_plex_stream('video')
|
||||
if utils.settings('audioStreamPick') == '0':
|
||||
|
|
|
@ -12,8 +12,12 @@ import xbmc
|
|||
from .plex_api import API
|
||||
from .plex_db import PlexDB
|
||||
from .kodi_db import KodiVideoDB
|
||||
from . import plex_functions as PF, playlist_func as PL, playqueue as PQ
|
||||
from . import json_rpc as js, variables as v, utils, transfer
|
||||
from . import plex_functions as PF
|
||||
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 exceptions
|
||||
|
||||
|
@ -74,20 +78,19 @@ def _playback_triage(plex_id, plex_type, path, resolve, resume):
|
|||
_ensure_resolve(abort=True)
|
||||
return
|
||||
with app.APP.lock_playqueues:
|
||||
playqueue = PQ.get_playqueue_from_type(
|
||||
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[plex_type])
|
||||
playqueue = app.PLAYQUEUES.from_plex_type(plex_type)
|
||||
try:
|
||||
pos = js.get_position(playqueue.playlistid)
|
||||
except KeyError:
|
||||
# Kodi bug - Playlist plays (not Playqueue) will ALWAYS be audio for
|
||||
# add-on paths
|
||||
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:
|
||||
pos = js.get_position(playqueue.playlistid)
|
||||
except KeyError:
|
||||
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:
|
||||
pos = js.get_position(playqueue.playlistid)
|
||||
except KeyError:
|
||||
|
@ -159,7 +162,7 @@ def _playlist_playback(plex_id):
|
|||
return
|
||||
# Kodi bug: playqueue will ALWAYS be audio playqueue UNTIL playback
|
||||
# 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)
|
||||
# Set the flag for the potentially WRONG audio playlist so Kodimonitor
|
||||
# can pick up on it
|
||||
|
@ -499,8 +502,7 @@ def process_indirect(key, offset, resolve=True):
|
|||
|
||||
api = API(xml[0])
|
||||
listitem = api.listitem(listitem=transfer.PKCListItem, resume=False)
|
||||
playqueue = PQ.get_playqueue_from_type(
|
||||
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[api.plex_type])
|
||||
playqueue = app.PLAYQUEUES.from_plex_type(api.plex_type)
|
||||
playqueue.clear()
|
||||
item = PL.playlist_item_from_xml(xml[0])
|
||||
item.offset = offset
|
||||
|
|
|
@ -22,117 +22,6 @@ from .subtitles import accessible_plex_subtitles
|
|||
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):
|
||||
"""
|
||||
Object to fill our playqueues and playlists with.
|
||||
|
@ -281,14 +170,44 @@ class PlaylistItem(object):
|
|||
elif stream_type == 'video':
|
||||
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):
|
||||
"""
|
||||
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_audio_stream = js.get_current_audio_stream_index(v.KODI_VIDEO_PLAYER_ID)
|
||||
self.current_kodi_video_stream = self._current_index('video')
|
||||
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) \
|
||||
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):
|
||||
"""
|
||||
|
@ -312,13 +231,16 @@ class PlaylistItem(object):
|
|||
Pass in the plex_stream_index [int] in order to receive the Kodi stream
|
||||
index [int].
|
||||
stream_type: 'video', 'audio', 'subtitle'
|
||||
Returns None if unsuccessful
|
||||
Raises ValueError if unsuccessful
|
||||
"""
|
||||
if plex_stream_index is None:
|
||||
return
|
||||
if not isinstance(plex_stream_index, int):
|
||||
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)):
|
||||
if cast(int, stream.get('id')) == plex_stream_index:
|
||||
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):
|
||||
"""
|
||||
|
@ -408,8 +330,10 @@ class PlaylistItem(object):
|
|||
return
|
||||
LOG.debug('The PMS wants to display %s stream with Plex id %s and '
|
||||
'languageTag %s', typus, plex_index, language_tag)
|
||||
try:
|
||||
kodi_index = self.kodi_stream_index(plex_index, typus)
|
||||
if kodi_index is None:
|
||||
except ValueError:
|
||||
kodi_index = None
|
||||
LOG.debug('Leaving Kodi %s stream settings untouched since we '
|
||||
'could not parse Plex %s stream with id %s to a Kodi'
|
||||
' index', typus, typus, plex_index)
|
||||
|
@ -438,10 +362,28 @@ class PlaylistItem(object):
|
|||
Call this method if Kodi reports an "AV-Change"
|
||||
(event "Player.OnAVChange")
|
||||
"""
|
||||
i = 0
|
||||
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)
|
||||
kodi_sub_stream = js.get_current_subtitle_stream_index(playerid)
|
||||
kodi_sub_stream = self._current_index('subtitle')
|
||||
# Audio
|
||||
if kodi_audio_stream != self.current_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)):
|
||||
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:
|
||||
plex_index = int(plex_data['audioStreamID'])
|
||||
kodi_index = self.kodi_stream_index(plex_index, 'audio')
|
||||
self._set_kodi_stream_if_different(kodi_index, 'audio')
|
||||
self.current_kodi_audio_stream = kodi_index
|
||||
if 'videoStreamID' in plex_data:
|
||||
plex_index = int(plex_data['videoStreamID'])
|
||||
kodi_index = self.kodi_stream_index(plex_index, 'video')
|
||||
if video_stream_id is not None:
|
||||
try:
|
||||
kodi_index = self.kodi_stream_index(video_stream_id, 'video')
|
||||
except ValueError:
|
||||
LOG.error('Unexpected Plex video_stream_id %s, not changing '
|
||||
'the video stream!', video_stream_id)
|
||||
return
|
||||
self._set_kodi_stream_if_different(kodi_index, 'video')
|
||||
self.current_kodi_video_stream = kodi_index
|
||||
if 'subtitleStreamID' in plex_data:
|
||||
plex_index = int(plex_data['subtitleStreamID'])
|
||||
if plex_index == 0:
|
||||
if audio_stream_id is not None:
|
||||
try:
|
||||
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)
|
||||
kodi_index = False
|
||||
else:
|
||||
kodi_index = self.kodi_stream_index(plex_index, 'subtitle')
|
||||
if kodi_index:
|
||||
try:
|
||||
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.showSubtitles(True)
|
||||
self.current_kodi_sub_stream = kodi_index
|
||||
|
|
|
@ -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)
|
|
@ -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()
|
5
resources/lib/plex_companion/__init__.py
Normal file
5
resources/lib/plex_companion/__init__.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from .polling import Listener
|
||||
from .playstate import PlaystateMgr
|
33
resources/lib/plex_companion/common.py
Normal file
33
resources/lib/plex_companion/common.py
Normal 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
|
144
resources/lib/plex_companion/playqueue.py
Normal file
144
resources/lib/plex_companion/playqueue.py
Normal 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')
|
411
resources/lib/plex_companion/playstate.py
Normal file
411
resources/lib/plex_companion/playstate.py
Normal 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)
|
172
resources/lib/plex_companion/polling.py
Normal file
172
resources/lib/plex_companion/polling.py
Normal 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'))
|
236
resources/lib/plex_companion/processing.py
Normal file
236
resources/lib/plex_companion/processing.py
Normal 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
|
|
@ -18,7 +18,7 @@ class Playlists(object):
|
|||
|
||||
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.
|
||||
Be sure to either set playlist.id or playlist.kodi_path
|
||||
"""
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
# Dummy file to make this directory a package.
|
|
@ -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
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -2,6 +2,7 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
import sys
|
||||
|
||||
import xbmc
|
||||
import xbmcvfs
|
||||
|
||||
|
@ -11,14 +12,12 @@ from . import kodimonitor
|
|||
from . import sync, library_sync
|
||||
from . import websocket_client
|
||||
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 playqueue
|
||||
from . import variables as v
|
||||
from . import app
|
||||
from . import loghandler
|
||||
from . import backgroundthread
|
||||
from . import skip_plex_intro
|
||||
from .windows import userselect
|
||||
|
||||
###############################################################################
|
||||
|
@ -34,7 +33,6 @@ WINDOW_PROPERTIES = (
|
|||
class Service(object):
|
||||
ws = None
|
||||
sync = None
|
||||
plexcompanion = None
|
||||
|
||||
def __init__(self):
|
||||
self._init_done = False
|
||||
|
@ -100,7 +98,6 @@ class Service(object):
|
|||
self.setup = None
|
||||
self.pms_ws = None
|
||||
self.alexa_ws = None
|
||||
self.playqueue = None
|
||||
# Flags for other threads
|
||||
self.connection_check_running = False
|
||||
self.auth_running = False
|
||||
|
@ -437,8 +434,6 @@ class Service(object):
|
|||
app.init()
|
||||
app.APP.monitor = kodimonitor.KodiMonitor()
|
||||
app.APP.player = xbmc.Player()
|
||||
# Initialize the PKC playqueues
|
||||
PQ.init_playqueues()
|
||||
|
||||
# Server auto-detect
|
||||
self.setup = initialsetup.InitialSetup()
|
||||
|
@ -448,8 +443,11 @@ class Service(object):
|
|||
self.pms_ws = websocket_client.get_pms_websocketapp()
|
||||
self.alexa_ws = websocket_client.get_alexa_websocketapp()
|
||||
self.sync = sync.Sync()
|
||||
self.plexcompanion = plex_companion.PlexCompanion()
|
||||
self.playqueue = playqueue.PlayqueueMonitor()
|
||||
self.companion_playstate_mgr = plex_companion.PlaystateMgr()
|
||||
if utils.settings('plexCompanion') == 'true':
|
||||
self.companion_listener = plex_companion.Listener(self.companion_playstate_mgr)
|
||||
else:
|
||||
self.companion_listener = None
|
||||
|
||||
# Main PKC program loop
|
||||
while not self.should_cancel():
|
||||
|
@ -548,13 +546,11 @@ class Service(object):
|
|||
self.startup_completed = True
|
||||
self.pms_ws.start()
|
||||
self.sync.start()
|
||||
self.plexcompanion.start()
|
||||
self.playqueue.start()
|
||||
self.companion_playstate_mgr.start()
|
||||
if self.companion_listener is not None:
|
||||
self.companion_listener.start()
|
||||
self.alexa_ws.start()
|
||||
|
||||
elif app.APP.is_playing:
|
||||
skip_plex_intro.check()
|
||||
|
||||
xbmc.sleep(200)
|
||||
|
||||
# EXITING PKC
|
||||
|
|
|
@ -32,8 +32,6 @@ def skip_intro(intros):
|
|||
|
||||
def check():
|
||||
with app.APP.lock_playqueues:
|
||||
if len(app.PLAYSTATE.active_players) != 1:
|
||||
return
|
||||
playerid = list(app.PLAYSTATE.active_players)[0]
|
||||
intros = app.PLAYSTATE.player_states[playerid]['intro_markers']
|
||||
if not intros:
|
||||
|
|
|
@ -556,6 +556,15 @@ def reset(ask_user=True):
|
|||
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):
|
||||
"""
|
||||
Returns True if current is >= then minimum. False otherwise. Returns True
|
||||
|
|
|
@ -30,8 +30,9 @@ ADDON_FOLDER = xbmcvfs.translatePath('special://home')
|
|||
ADDON_PROFILE = xbmcvfs.translatePath(_ADDON.getAddonInfo('profile'))
|
||||
|
||||
# Used e.g. for json_rpc
|
||||
KODI_VIDEO_PLAYER_ID = 1
|
||||
KODI_AUDIO_PLAYER_ID = 0
|
||||
KODI_VIDEO_PLAYER_ID = 1
|
||||
KODI_PHOTO_PLAYER_ID = 2
|
||||
|
||||
KODILANGUAGE = xbmc.getLanguage(xbmc.ISO_639_1)
|
||||
KODIVERSION = int(xbmc.getInfoLabel("System.BuildVersion")[:2])
|
||||
|
|
Loading…
Reference in a new issue