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 .connection import Connection
|
||||||
from .libsync import Sync
|
from .libsync import Sync
|
||||||
from .playstate import PlayState
|
from .playstate import PlayState
|
||||||
|
from .playqueues import Playqueues
|
||||||
|
|
||||||
ACCOUNT = None
|
ACCOUNT = None
|
||||||
APP = None
|
APP = None
|
||||||
CONN = None
|
CONN = None
|
||||||
SYNC = None
|
SYNC = None
|
||||||
PLAYSTATE = None
|
PLAYSTATE = None
|
||||||
|
PLAYQUEUES = None
|
||||||
|
|
||||||
|
|
||||||
def init(entrypoint=False):
|
def init(entrypoint=False):
|
||||||
|
@ -22,13 +24,15 @@ def init(entrypoint=False):
|
||||||
entrypoint=True initiates only the bare minimum - for other PKC python
|
entrypoint=True initiates only the bare minimum - for other PKC python
|
||||||
instances
|
instances
|
||||||
"""
|
"""
|
||||||
global ACCOUNT, APP, CONN, SYNC, PLAYSTATE
|
global ACCOUNT, APP, CONN, SYNC, PLAYSTATE, PLAYQUEUES
|
||||||
APP = App(entrypoint)
|
APP = App(entrypoint)
|
||||||
CONN = Connection(entrypoint)
|
CONN = Connection(entrypoint)
|
||||||
ACCOUNT = Account(entrypoint)
|
ACCOUNT = Account(entrypoint)
|
||||||
SYNC = Sync(entrypoint)
|
SYNC = Sync(entrypoint)
|
||||||
if not entrypoint:
|
if not entrypoint:
|
||||||
PLAYSTATE = PlayState()
|
PLAYSTATE = PlayState()
|
||||||
|
PLAYQUEUES = Playqueues()
|
||||||
|
|
||||||
|
|
||||||
def reload():
|
def reload():
|
||||||
"""
|
"""
|
||||||
|
|
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 logging import getLogger
|
||||||
from xbmc import Player
|
from xbmc import Player
|
||||||
|
|
||||||
from . import playqueue as PQ, plex_functions as PF
|
from . import plex_functions as PF
|
||||||
from . import json_rpc as js, variables as v, app
|
from . import json_rpc as js
|
||||||
|
from . import variables as v
|
||||||
|
from . import app
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
|
|
||||||
|
@ -28,7 +30,7 @@ def skip_to(params):
|
||||||
playqueue_item_id, plex_id)
|
playqueue_item_id, plex_id)
|
||||||
found = True
|
found = True
|
||||||
for player in list(js.get_players().values()):
|
for player in list(js.get_players().values()):
|
||||||
playqueue = PQ.PLAYQUEUES[player['playerid']]
|
playqueue = app.PLAYQUEUES[player['playerid']]
|
||||||
for i, item in enumerate(playqueue.items):
|
for i, item in enumerate(playqueue.items):
|
||||||
if item.id == playqueue_item_id:
|
if item.id == playqueue_item_id:
|
||||||
found = True
|
found = True
|
||||||
|
|
|
@ -6,8 +6,11 @@ import xbmcgui
|
||||||
|
|
||||||
from .plex_api import API
|
from .plex_api import API
|
||||||
from .plex_db import PlexDB
|
from .plex_db import PlexDB
|
||||||
from . import context, plex_functions as PF, playqueue as PQ
|
from . import context
|
||||||
from . import utils, variables as v, app
|
from . import plex_functions as PF
|
||||||
|
from . import utils
|
||||||
|
from . import variables as v
|
||||||
|
from . import app
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
|
|
||||||
|
@ -137,8 +140,7 @@ class ContextMenu(object):
|
||||||
"""
|
"""
|
||||||
For using direct paths: Initiates playback using the PMS
|
For using direct paths: Initiates playback using the PMS
|
||||||
"""
|
"""
|
||||||
playqueue = PQ.get_playqueue_from_type(
|
playqueue = app.PLAYQUEUES.from_kodi_type(self.kodi_type)
|
||||||
v.KODI_PLAYLIST_TYPE_FROM_KODI_TYPE[self.kodi_type])
|
|
||||||
playqueue.clear()
|
playqueue.clear()
|
||||||
app.PLAYSTATE.context_menu_play = True
|
app.PLAYSTATE.context_menu_play = True
|
||||||
handle = self.api.fullpath(force_addon=True)[0]
|
handle = self.api.fullpath(force_addon=True)[0]
|
||||||
|
|
|
@ -17,7 +17,7 @@ from .kodi_db import KodiVideoDB
|
||||||
from . import kodi_db
|
from . import kodi_db
|
||||||
from .downloadutils import DownloadUtils as DU
|
from .downloadutils import DownloadUtils as DU
|
||||||
from . import utils, timing, plex_functions as PF
|
from . import utils, timing, plex_functions as PF
|
||||||
from . import json_rpc as js, playqueue as PQ, playlist_func as PL
|
from . import json_rpc as js, playlist_func as PL
|
||||||
from . import backgroundthread, app, variables as v
|
from . import backgroundthread, app, variables as v
|
||||||
from . import exceptions
|
from . import exceptions
|
||||||
|
|
||||||
|
@ -140,7 +140,7 @@ class KodiMonitor(xbmc.Monitor):
|
||||||
u'playlistid': 1,
|
u'playlistid': 1,
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
playqueue = PQ.PLAYQUEUES[data['playlistid']]
|
playqueue = app.PLAYQUEUES[data['playlistid']]
|
||||||
if not playqueue.is_pkc_clear():
|
if not playqueue.is_pkc_clear():
|
||||||
playqueue.pkc_edit = True
|
playqueue.pkc_edit = True
|
||||||
playqueue.clear(kodi=False)
|
playqueue.clear(kodi=False)
|
||||||
|
@ -256,7 +256,7 @@ class KodiMonitor(xbmc.Monitor):
|
||||||
if not playerid:
|
if not playerid:
|
||||||
LOG.error('Coud not get playerid for data %s', data)
|
LOG.error('Coud not get playerid for data %s', data)
|
||||||
return
|
return
|
||||||
playqueue = PQ.PLAYQUEUES[playerid]
|
playqueue = app.PLAYQUEUES[playerid]
|
||||||
info = js.get_player_props(playerid)
|
info = js.get_player_props(playerid)
|
||||||
if playqueue.kodi_playlist_playback:
|
if playqueue.kodi_playlist_playback:
|
||||||
# Kodi will tell us the wrong position - of the playlist, not the
|
# Kodi will tell us the wrong position - of the playlist, not the
|
||||||
|
@ -326,7 +326,7 @@ class KodiMonitor(xbmc.Monitor):
|
||||||
container_key = None
|
container_key = None
|
||||||
if info['playlistid'] != -1:
|
if info['playlistid'] != -1:
|
||||||
# -1 is Kodi's answer if there is no playlist
|
# -1 is Kodi's answer if there is no playlist
|
||||||
container_key = PQ.PLAYQUEUES[playerid].id
|
container_key = app.PLAYQUEUES[playerid].id
|
||||||
if container_key is not None:
|
if container_key is not None:
|
||||||
container_key = '/playQueues/%s' % container_key
|
container_key = '/playQueues/%s' % container_key
|
||||||
elif plex_id is not None:
|
elif plex_id is not None:
|
||||||
|
@ -367,6 +367,11 @@ class KodiMonitor(xbmc.Monitor):
|
||||||
|
|
||||||
# We need to switch to the Plex streams ONCE upon playback start
|
# We need to switch to the Plex streams ONCE upon playback start
|
||||||
if playerid == v.KODI_VIDEO_PLAYER_ID:
|
if playerid == v.KODI_VIDEO_PLAYER_ID:
|
||||||
|
# The Kodi player takes forever to initialize all streams
|
||||||
|
# Especially subtitles, apparently. No way to tell when Kodi
|
||||||
|
# is done :-(
|
||||||
|
if app.APP.monitor.waitForAbort(5):
|
||||||
|
return
|
||||||
item.init_kodi_streams()
|
item.init_kodi_streams()
|
||||||
item.switch_to_plex_stream('video')
|
item.switch_to_plex_stream('video')
|
||||||
if utils.settings('audioStreamPick') == '0':
|
if utils.settings('audioStreamPick') == '0':
|
||||||
|
|
|
@ -12,8 +12,12 @@ import xbmc
|
||||||
from .plex_api import API
|
from .plex_api import API
|
||||||
from .plex_db import PlexDB
|
from .plex_db import PlexDB
|
||||||
from .kodi_db import KodiVideoDB
|
from .kodi_db import KodiVideoDB
|
||||||
from . import plex_functions as PF, playlist_func as PL, playqueue as PQ
|
from . import plex_functions as PF
|
||||||
from . import json_rpc as js, variables as v, utils, transfer
|
from . import playlist_func as PL
|
||||||
|
from . import json_rpc as js
|
||||||
|
from . import variables as v
|
||||||
|
from . import utils
|
||||||
|
from . import transfer
|
||||||
from . import playback_decision, app
|
from . import playback_decision, app
|
||||||
from . import exceptions
|
from . import exceptions
|
||||||
|
|
||||||
|
@ -74,20 +78,19 @@ def _playback_triage(plex_id, plex_type, path, resolve, resume):
|
||||||
_ensure_resolve(abort=True)
|
_ensure_resolve(abort=True)
|
||||||
return
|
return
|
||||||
with app.APP.lock_playqueues:
|
with app.APP.lock_playqueues:
|
||||||
playqueue = PQ.get_playqueue_from_type(
|
playqueue = app.PLAYQUEUES.from_plex_type(plex_type)
|
||||||
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[plex_type])
|
|
||||||
try:
|
try:
|
||||||
pos = js.get_position(playqueue.playlistid)
|
pos = js.get_position(playqueue.playlistid)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
# Kodi bug - Playlist plays (not Playqueue) will ALWAYS be audio for
|
# Kodi bug - Playlist plays (not Playqueue) will ALWAYS be audio for
|
||||||
# add-on paths
|
# add-on paths
|
||||||
LOG.debug('No position returned from player! Assuming playlist')
|
LOG.debug('No position returned from player! Assuming playlist')
|
||||||
playqueue = PQ.get_playqueue_from_type(v.KODI_PLAYLIST_TYPE_AUDIO)
|
playqueue = app.PLAYQUEUES.audio
|
||||||
try:
|
try:
|
||||||
pos = js.get_position(playqueue.playlistid)
|
pos = js.get_position(playqueue.playlistid)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
LOG.debug('Assuming video instead of audio playlist playback')
|
LOG.debug('Assuming video instead of audio playlist playback')
|
||||||
playqueue = PQ.get_playqueue_from_type(v.KODI_PLAYLIST_TYPE_VIDEO)
|
playqueue = app.PLAYQUEUES.video
|
||||||
try:
|
try:
|
||||||
pos = js.get_position(playqueue.playlistid)
|
pos = js.get_position(playqueue.playlistid)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
|
@ -159,7 +162,7 @@ def _playlist_playback(plex_id):
|
||||||
return
|
return
|
||||||
# Kodi bug: playqueue will ALWAYS be audio playqueue UNTIL playback
|
# Kodi bug: playqueue will ALWAYS be audio playqueue UNTIL playback
|
||||||
# has actually started. Need to tell Kodimonitor
|
# has actually started. Need to tell Kodimonitor
|
||||||
playqueue = PQ.get_playqueue_from_type(v.KODI_PLAYLIST_TYPE_AUDIO)
|
playqueue = app.PLAYQUEUES.audio
|
||||||
playqueue.clear(kodi=False)
|
playqueue.clear(kodi=False)
|
||||||
# Set the flag for the potentially WRONG audio playlist so Kodimonitor
|
# Set the flag for the potentially WRONG audio playlist so Kodimonitor
|
||||||
# can pick up on it
|
# can pick up on it
|
||||||
|
@ -499,8 +502,7 @@ def process_indirect(key, offset, resolve=True):
|
||||||
|
|
||||||
api = API(xml[0])
|
api = API(xml[0])
|
||||||
listitem = api.listitem(listitem=transfer.PKCListItem, resume=False)
|
listitem = api.listitem(listitem=transfer.PKCListItem, resume=False)
|
||||||
playqueue = PQ.get_playqueue_from_type(
|
playqueue = app.PLAYQUEUES.from_plex_type(api.plex_type)
|
||||||
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[api.plex_type])
|
|
||||||
playqueue.clear()
|
playqueue.clear()
|
||||||
item = PL.playlist_item_from_xml(xml[0])
|
item = PL.playlist_item_from_xml(xml[0])
|
||||||
item.offset = offset
|
item.offset = offset
|
||||||
|
|
|
@ -22,117 +22,6 @@ from .subtitles import accessible_plex_subtitles
|
||||||
LOG = getLogger('PLEX.playlist_func')
|
LOG = getLogger('PLEX.playlist_func')
|
||||||
|
|
||||||
|
|
||||||
class Playqueue_Object(object):
|
|
||||||
"""
|
|
||||||
PKC object to represent PMS playQueues and Kodi playlist for queueing
|
|
||||||
|
|
||||||
playlistid = None [int] Kodi playlist id (0, 1, 2)
|
|
||||||
type = None [str] Kodi type: 'audio', 'video', 'picture'
|
|
||||||
kodi_pl = None Kodi xbmc.PlayList object
|
|
||||||
items = [] [list] of Playlist_Items
|
|
||||||
id = None [str] Plex playQueueID, unique Plex identifier
|
|
||||||
version = None [int] Plex version of the playQueue
|
|
||||||
selectedItemID = None
|
|
||||||
[str] Plex selectedItemID, playing element in queue
|
|
||||||
selectedItemOffset = None
|
|
||||||
[str] Offset of the playing element in queue
|
|
||||||
shuffled = 0 [int] 0: not shuffled, 1: ??? 2: ???
|
|
||||||
repeat = 0 [int] 0: not repeated, 1: ??? 2: ???
|
|
||||||
|
|
||||||
If Companion playback is initiated by another user:
|
|
||||||
plex_transient_token = None
|
|
||||||
"""
|
|
||||||
kind = 'playQueue'
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.id = None
|
|
||||||
self.type = None
|
|
||||||
self.playlistid = None
|
|
||||||
self.kodi_pl = None
|
|
||||||
self.items = []
|
|
||||||
self.version = None
|
|
||||||
self.selectedItemID = None
|
|
||||||
self.selectedItemOffset = None
|
|
||||||
self.shuffled = 0
|
|
||||||
self.repeat = 0
|
|
||||||
self.plex_transient_token = None
|
|
||||||
# Need a hack for detecting swaps of elements
|
|
||||||
self.old_kodi_pl = []
|
|
||||||
# Did PKC itself just change the playqueue so the PKC playqueue monitor
|
|
||||||
# should not pick up any changes?
|
|
||||||
self.pkc_edit = False
|
|
||||||
# Workaround to avoid endless loops of detecting PL clears
|
|
||||||
self._clear_list = []
|
|
||||||
# To keep track if Kodi playback was initiated from a Kodi playlist
|
|
||||||
# There are a couple of pitfalls, unfortunately...
|
|
||||||
self.kodi_playlist_playback = False
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
answ = ("{{"
|
|
||||||
"'playlistid': {self.playlistid}, "
|
|
||||||
"'id': {self.id}, "
|
|
||||||
"'version': {self.version}, "
|
|
||||||
"'type': '{self.type}', "
|
|
||||||
"'selectedItemID': {self.selectedItemID}, "
|
|
||||||
"'selectedItemOffset': {self.selectedItemOffset}, "
|
|
||||||
"'shuffled': {self.shuffled}, "
|
|
||||||
"'repeat': {self.repeat}, "
|
|
||||||
"'kodi_playlist_playback': {self.kodi_playlist_playback}, "
|
|
||||||
"'pkc_edit': {self.pkc_edit}, ".format(self=self))
|
|
||||||
# Since list.__repr__ will return string, not unicode
|
|
||||||
return answ + "'items': {self.items}}}".format(self=self)
|
|
||||||
|
|
||||||
def is_pkc_clear(self):
|
|
||||||
"""
|
|
||||||
Returns True if PKC has cleared the Kodi playqueue just recently.
|
|
||||||
Then this clear will be ignored from now on
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
self._clear_list.pop()
|
|
||||||
except IndexError:
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
return True
|
|
||||||
|
|
||||||
def clear(self, kodi=True):
|
|
||||||
"""
|
|
||||||
Resets the playlist object to an empty playlist.
|
|
||||||
|
|
||||||
Pass kodi=False in order to NOT clear the Kodi playqueue
|
|
||||||
"""
|
|
||||||
# kodi monitor's on_clear method will only be called if there were some
|
|
||||||
# items to begin with
|
|
||||||
if kodi and self.kodi_pl.size() != 0:
|
|
||||||
self._clear_list.append(None)
|
|
||||||
self.kodi_pl.clear() # Clear Kodi playlist object
|
|
||||||
self.items = []
|
|
||||||
self.id = None
|
|
||||||
self.version = None
|
|
||||||
self.selectedItemID = None
|
|
||||||
self.selectedItemOffset = None
|
|
||||||
self.shuffled = 0
|
|
||||||
self.repeat = 0
|
|
||||||
self.plex_transient_token = None
|
|
||||||
self.old_kodi_pl = []
|
|
||||||
self.kodi_playlist_playback = False
|
|
||||||
LOG.debug('Playlist cleared: %s', self)
|
|
||||||
|
|
||||||
def position_from_plex_id(self, plex_id):
|
|
||||||
"""
|
|
||||||
Returns the position [int] for the very first item with plex_id [int]
|
|
||||||
(Plex seems uncapable of adding the same element multiple times to a
|
|
||||||
playqueue or playlist)
|
|
||||||
|
|
||||||
Raises KeyError if not found
|
|
||||||
"""
|
|
||||||
for position, item in enumerate(self.items):
|
|
||||||
if item.plex_id == plex_id:
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
raise KeyError('Did not find plex_id %s in %s', plex_id, self)
|
|
||||||
return position
|
|
||||||
|
|
||||||
|
|
||||||
class PlaylistItem(object):
|
class PlaylistItem(object):
|
||||||
"""
|
"""
|
||||||
Object to fill our playqueues and playlists with.
|
Object to fill our playqueues and playlists with.
|
||||||
|
@ -281,14 +170,44 @@ class PlaylistItem(object):
|
||||||
elif stream_type == 'video':
|
elif stream_type == 'video':
|
||||||
return self.video_streams
|
return self.video_streams
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _current_index(stream_type):
|
||||||
|
"""
|
||||||
|
Kodi might tell us the wrong index for any stream after playback start
|
||||||
|
Get the correct one!
|
||||||
|
"""
|
||||||
|
function = {
|
||||||
|
'audio': js.get_current_audio_stream_index,
|
||||||
|
'video': js.get_current_video_stream_index,
|
||||||
|
'subtitle': js.get_current_subtitle_stream_index
|
||||||
|
}[stream_type]
|
||||||
|
i = 0
|
||||||
|
while i < 30:
|
||||||
|
# Really annoying: Kodi might return wrong results directly after
|
||||||
|
# playback startup, e.g. a Kodi audio index of 1953718901 (!)
|
||||||
|
try:
|
||||||
|
index = function(v.KODI_VIDEO_PLAYER_ID)
|
||||||
|
except TypeError:
|
||||||
|
# No sensible reply yet
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
if index != 1953718901:
|
||||||
|
# Correct result!
|
||||||
|
return index
|
||||||
|
i += 1
|
||||||
|
app.APP.monitor.waitForAbort(0.1)
|
||||||
|
else:
|
||||||
|
raise RuntimeError('Kodi did not tell us the correct index for %s'
|
||||||
|
% stream_type)
|
||||||
|
|
||||||
def init_kodi_streams(self):
|
def init_kodi_streams(self):
|
||||||
"""
|
"""
|
||||||
Initializes all streams after Kodi has started playing this video
|
Initializes all streams after Kodi has started playing this video
|
||||||
"""
|
"""
|
||||||
self.current_kodi_video_stream = js.get_current_video_stream_index(v.KODI_VIDEO_PLAYER_ID)
|
self.current_kodi_video_stream = self._current_index('video')
|
||||||
self.current_kodi_audio_stream = js.get_current_audio_stream_index(v.KODI_VIDEO_PLAYER_ID)
|
self.current_kodi_audio_stream = self._current_index('audio')
|
||||||
self.current_kodi_sub_stream = False if not js.get_subtitle_enabled(v.KODI_VIDEO_PLAYER_ID) \
|
self.current_kodi_sub_stream = False if not js.get_subtitle_enabled(v.KODI_VIDEO_PLAYER_ID) \
|
||||||
else js.get_current_subtitle_stream_index(v.KODI_VIDEO_PLAYER_ID)
|
else self._current_index('subtitle')
|
||||||
|
|
||||||
def plex_stream_index(self, kodi_stream_index, stream_type):
|
def plex_stream_index(self, kodi_stream_index, stream_type):
|
||||||
"""
|
"""
|
||||||
|
@ -312,13 +231,16 @@ class PlaylistItem(object):
|
||||||
Pass in the plex_stream_index [int] in order to receive the Kodi stream
|
Pass in the plex_stream_index [int] in order to receive the Kodi stream
|
||||||
index [int].
|
index [int].
|
||||||
stream_type: 'video', 'audio', 'subtitle'
|
stream_type: 'video', 'audio', 'subtitle'
|
||||||
Returns None if unsuccessful
|
Raises ValueError if unsuccessful
|
||||||
"""
|
"""
|
||||||
if plex_stream_index is None:
|
if not isinstance(plex_stream_index, int):
|
||||||
return
|
raise ValueError('%s plex_stream_index %s of type %s received' %
|
||||||
|
(stream_type, plex_stream_index, type(plex_stream_index)))
|
||||||
for i, stream in enumerate(self._get_iterator(stream_type)):
|
for i, stream in enumerate(self._get_iterator(stream_type)):
|
||||||
if cast(int, stream.get('id')) == plex_stream_index:
|
if cast(int, stream.get('id')) == plex_stream_index:
|
||||||
return i
|
return i
|
||||||
|
raise ValueError('No %s kodi_stream_index for plex_stream_index %s' %
|
||||||
|
(stream_type, plex_stream_index))
|
||||||
|
|
||||||
def active_plex_stream_index(self, stream_type):
|
def active_plex_stream_index(self, stream_type):
|
||||||
"""
|
"""
|
||||||
|
@ -408,8 +330,10 @@ class PlaylistItem(object):
|
||||||
return
|
return
|
||||||
LOG.debug('The PMS wants to display %s stream with Plex id %s and '
|
LOG.debug('The PMS wants to display %s stream with Plex id %s and '
|
||||||
'languageTag %s', typus, plex_index, language_tag)
|
'languageTag %s', typus, plex_index, language_tag)
|
||||||
kodi_index = self.kodi_stream_index(plex_index, typus)
|
try:
|
||||||
if kodi_index is None:
|
kodi_index = self.kodi_stream_index(plex_index, typus)
|
||||||
|
except ValueError:
|
||||||
|
kodi_index = None
|
||||||
LOG.debug('Leaving Kodi %s stream settings untouched since we '
|
LOG.debug('Leaving Kodi %s stream settings untouched since we '
|
||||||
'could not parse Plex %s stream with id %s to a Kodi'
|
'could not parse Plex %s stream with id %s to a Kodi'
|
||||||
' index', typus, typus, plex_index)
|
' index', typus, typus, plex_index)
|
||||||
|
@ -438,10 +362,28 @@ class PlaylistItem(object):
|
||||||
Call this method if Kodi reports an "AV-Change"
|
Call this method if Kodi reports an "AV-Change"
|
||||||
(event "Player.OnAVChange")
|
(event "Player.OnAVChange")
|
||||||
"""
|
"""
|
||||||
kodi_video_stream = js.get_current_video_stream_index(playerid)
|
i = 0
|
||||||
kodi_audio_stream = js.get_current_audio_stream_index(playerid)
|
while i < 20:
|
||||||
|
# Really annoying: Kodi might return wrong results directly after
|
||||||
|
# playback startup, e.g. a Kodi audio index of 1953718901 (!)
|
||||||
|
kodi_video_stream = js.get_current_video_stream_index(playerid)
|
||||||
|
kodi_audio_stream = js.get_current_audio_stream_index(playerid)
|
||||||
|
if kodi_video_stream < len(self.video_streams) and kodi_audio_stream < len(self.audio_streams):
|
||||||
|
# Correct result!
|
||||||
|
break
|
||||||
|
i += 1
|
||||||
|
if app.APP.monitor.waitForAbort(0.1):
|
||||||
|
# Need to quit PKC
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
LOG.error('Could not get sensible Kodi indices! kodi_video_stream '
|
||||||
|
'%s, kodi_audio_stream %s',
|
||||||
|
kodi_video_stream, kodi_audio_stream)
|
||||||
|
return
|
||||||
|
kodi_video_stream = self._current_index('video')
|
||||||
|
kodi_audio_stream = self._current_index('audio')
|
||||||
sub_enabled = js.get_subtitle_enabled(playerid)
|
sub_enabled = js.get_subtitle_enabled(playerid)
|
||||||
kodi_sub_stream = js.get_current_subtitle_stream_index(playerid)
|
kodi_sub_stream = self._current_index('subtitle')
|
||||||
# Audio
|
# Audio
|
||||||
if kodi_audio_stream != self.current_kodi_audio_stream:
|
if kodi_audio_stream != self.current_kodi_audio_stream:
|
||||||
self.on_kodi_audio_stream_change(kodi_audio_stream)
|
self.on_kodi_audio_stream_change(kodi_audio_stream)
|
||||||
|
@ -457,28 +399,43 @@ class PlaylistItem(object):
|
||||||
and kodi_sub_stream != self.current_kodi_sub_stream)):
|
and kodi_sub_stream != self.current_kodi_sub_stream)):
|
||||||
self.on_kodi_subtitle_stream_change(kodi_sub_stream, sub_enabled)
|
self.on_kodi_subtitle_stream_change(kodi_sub_stream, sub_enabled)
|
||||||
|
|
||||||
def on_plex_stream_change(self, plex_data):
|
def on_plex_stream_change(self, video_stream_id=None, audio_stream_id=None,
|
||||||
|
subtitle_stream_id=None):
|
||||||
"""
|
"""
|
||||||
Call this method if Plex Companion wants to change streams
|
Call this method if Plex Companion wants to change streams [ints]
|
||||||
"""
|
"""
|
||||||
if 'audioStreamID' in plex_data:
|
if video_stream_id is not None:
|
||||||
plex_index = int(plex_data['audioStreamID'])
|
try:
|
||||||
kodi_index = self.kodi_stream_index(plex_index, 'audio')
|
kodi_index = self.kodi_stream_index(video_stream_id, 'video')
|
||||||
self._set_kodi_stream_if_different(kodi_index, 'audio')
|
except ValueError:
|
||||||
self.current_kodi_audio_stream = kodi_index
|
LOG.error('Unexpected Plex video_stream_id %s, not changing '
|
||||||
if 'videoStreamID' in plex_data:
|
'the video stream!', video_stream_id)
|
||||||
plex_index = int(plex_data['videoStreamID'])
|
return
|
||||||
kodi_index = self.kodi_stream_index(plex_index, 'video')
|
|
||||||
self._set_kodi_stream_if_different(kodi_index, 'video')
|
self._set_kodi_stream_if_different(kodi_index, 'video')
|
||||||
self.current_kodi_video_stream = kodi_index
|
self.current_kodi_video_stream = kodi_index
|
||||||
if 'subtitleStreamID' in plex_data:
|
if audio_stream_id is not None:
|
||||||
plex_index = int(plex_data['subtitleStreamID'])
|
try:
|
||||||
if plex_index == 0:
|
kodi_index = self.kodi_stream_index(audio_stream_id, 'audio')
|
||||||
|
except ValueError:
|
||||||
|
LOG.error('Unexpected Plex audio_stream_id %s, not changing '
|
||||||
|
'the video stream!', audio_stream_id)
|
||||||
|
return
|
||||||
|
self._set_kodi_stream_if_different(kodi_index, 'audio')
|
||||||
|
self.current_kodi_audio_stream = kodi_index
|
||||||
|
if subtitle_stream_id is not None:
|
||||||
|
if subtitle_stream_id == 0:
|
||||||
app.APP.player.showSubtitles(False)
|
app.APP.player.showSubtitles(False)
|
||||||
kodi_index = False
|
kodi_index = False
|
||||||
else:
|
else:
|
||||||
kodi_index = self.kodi_stream_index(plex_index, 'subtitle')
|
try:
|
||||||
if kodi_index:
|
kodi_index = self.kodi_stream_index(subtitle_stream_id,
|
||||||
|
'subtitle')
|
||||||
|
except ValueError:
|
||||||
|
kodi_index = None
|
||||||
|
LOG.debug('The PMS wanted to change subs, but we could not'
|
||||||
|
' match the sub with id %s to a Kodi sub',
|
||||||
|
subtitle_stream_id)
|
||||||
|
else:
|
||||||
app.APP.player.setSubtitleStream(kodi_index)
|
app.APP.player.setSubtitleStream(kodi_index)
|
||||||
app.APP.player.showSubtitles(True)
|
app.APP.player.showSubtitles(True)
|
||||||
self.current_kodi_sub_stream = kodi_index
|
self.current_kodi_sub_stream = kodi_index
|
||||||
|
|
|
@ -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):
|
def delete_playlist(self, playlist):
|
||||||
"""
|
"""
|
||||||
Removes the entry for playlist [Playqueue_Object] from the Plex
|
Removes the entry for playlist [Playqueue()] from the Plex
|
||||||
playlists table.
|
playlists table.
|
||||||
Be sure to either set playlist.id or playlist.kodi_path
|
Be sure to either set playlist.id or playlist.kodi_path
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -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 -*-
|
# -*- coding: utf-8 -*-
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import xbmc
|
import xbmc
|
||||||
import xbmcvfs
|
import xbmcvfs
|
||||||
|
|
||||||
|
@ -11,14 +12,12 @@ from . import kodimonitor
|
||||||
from . import sync, library_sync
|
from . import sync, library_sync
|
||||||
from . import websocket_client
|
from . import websocket_client
|
||||||
from . import plex_companion
|
from . import plex_companion
|
||||||
from . import plex_functions as PF, playqueue as PQ
|
from . import plex_functions as PF
|
||||||
from . import playback_starter
|
from . import playback_starter
|
||||||
from . import playqueue
|
|
||||||
from . import variables as v
|
from . import variables as v
|
||||||
from . import app
|
from . import app
|
||||||
from . import loghandler
|
from . import loghandler
|
||||||
from . import backgroundthread
|
from . import backgroundthread
|
||||||
from . import skip_plex_intro
|
|
||||||
from .windows import userselect
|
from .windows import userselect
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
|
@ -34,7 +33,6 @@ WINDOW_PROPERTIES = (
|
||||||
class Service(object):
|
class Service(object):
|
||||||
ws = None
|
ws = None
|
||||||
sync = None
|
sync = None
|
||||||
plexcompanion = None
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._init_done = False
|
self._init_done = False
|
||||||
|
@ -100,7 +98,6 @@ class Service(object):
|
||||||
self.setup = None
|
self.setup = None
|
||||||
self.pms_ws = None
|
self.pms_ws = None
|
||||||
self.alexa_ws = None
|
self.alexa_ws = None
|
||||||
self.playqueue = None
|
|
||||||
# Flags for other threads
|
# Flags for other threads
|
||||||
self.connection_check_running = False
|
self.connection_check_running = False
|
||||||
self.auth_running = False
|
self.auth_running = False
|
||||||
|
@ -437,8 +434,6 @@ class Service(object):
|
||||||
app.init()
|
app.init()
|
||||||
app.APP.monitor = kodimonitor.KodiMonitor()
|
app.APP.monitor = kodimonitor.KodiMonitor()
|
||||||
app.APP.player = xbmc.Player()
|
app.APP.player = xbmc.Player()
|
||||||
# Initialize the PKC playqueues
|
|
||||||
PQ.init_playqueues()
|
|
||||||
|
|
||||||
# Server auto-detect
|
# Server auto-detect
|
||||||
self.setup = initialsetup.InitialSetup()
|
self.setup = initialsetup.InitialSetup()
|
||||||
|
@ -448,8 +443,11 @@ class Service(object):
|
||||||
self.pms_ws = websocket_client.get_pms_websocketapp()
|
self.pms_ws = websocket_client.get_pms_websocketapp()
|
||||||
self.alexa_ws = websocket_client.get_alexa_websocketapp()
|
self.alexa_ws = websocket_client.get_alexa_websocketapp()
|
||||||
self.sync = sync.Sync()
|
self.sync = sync.Sync()
|
||||||
self.plexcompanion = plex_companion.PlexCompanion()
|
self.companion_playstate_mgr = plex_companion.PlaystateMgr()
|
||||||
self.playqueue = playqueue.PlayqueueMonitor()
|
if utils.settings('plexCompanion') == 'true':
|
||||||
|
self.companion_listener = plex_companion.Listener(self.companion_playstate_mgr)
|
||||||
|
else:
|
||||||
|
self.companion_listener = None
|
||||||
|
|
||||||
# Main PKC program loop
|
# Main PKC program loop
|
||||||
while not self.should_cancel():
|
while not self.should_cancel():
|
||||||
|
@ -548,13 +546,11 @@ class Service(object):
|
||||||
self.startup_completed = True
|
self.startup_completed = True
|
||||||
self.pms_ws.start()
|
self.pms_ws.start()
|
||||||
self.sync.start()
|
self.sync.start()
|
||||||
self.plexcompanion.start()
|
self.companion_playstate_mgr.start()
|
||||||
self.playqueue.start()
|
if self.companion_listener is not None:
|
||||||
|
self.companion_listener.start()
|
||||||
self.alexa_ws.start()
|
self.alexa_ws.start()
|
||||||
|
|
||||||
elif app.APP.is_playing:
|
|
||||||
skip_plex_intro.check()
|
|
||||||
|
|
||||||
xbmc.sleep(200)
|
xbmc.sleep(200)
|
||||||
|
|
||||||
# EXITING PKC
|
# EXITING PKC
|
||||||
|
|
|
@ -32,8 +32,6 @@ def skip_intro(intros):
|
||||||
|
|
||||||
def check():
|
def check():
|
||||||
with app.APP.lock_playqueues:
|
with app.APP.lock_playqueues:
|
||||||
if len(app.PLAYSTATE.active_players) != 1:
|
|
||||||
return
|
|
||||||
playerid = list(app.PLAYSTATE.active_players)[0]
|
playerid = list(app.PLAYSTATE.active_players)[0]
|
||||||
intros = app.PLAYSTATE.player_states[playerid]['intro_markers']
|
intros = app.PLAYSTATE.player_states[playerid]['intro_markers']
|
||||||
if not intros:
|
if not intros:
|
||||||
|
|
|
@ -556,6 +556,15 @@ def reset(ask_user=True):
|
||||||
reboot_kodi()
|
reboot_kodi()
|
||||||
|
|
||||||
|
|
||||||
|
def log_xml(xml, logger):
|
||||||
|
"""
|
||||||
|
Logs an etree xml
|
||||||
|
"""
|
||||||
|
string = undefused_etree.tostring(xml, encoding='utf-8')
|
||||||
|
string = string.decode('utf-8')
|
||||||
|
logger('\n' + string)
|
||||||
|
|
||||||
|
|
||||||
def compare_version(current, minimum):
|
def compare_version(current, minimum):
|
||||||
"""
|
"""
|
||||||
Returns True if current is >= then minimum. False otherwise. Returns True
|
Returns True if current is >= then minimum. False otherwise. Returns True
|
||||||
|
|
|
@ -30,8 +30,9 @@ ADDON_FOLDER = xbmcvfs.translatePath('special://home')
|
||||||
ADDON_PROFILE = xbmcvfs.translatePath(_ADDON.getAddonInfo('profile'))
|
ADDON_PROFILE = xbmcvfs.translatePath(_ADDON.getAddonInfo('profile'))
|
||||||
|
|
||||||
# Used e.g. for json_rpc
|
# Used e.g. for json_rpc
|
||||||
KODI_VIDEO_PLAYER_ID = 1
|
|
||||||
KODI_AUDIO_PLAYER_ID = 0
|
KODI_AUDIO_PLAYER_ID = 0
|
||||||
|
KODI_VIDEO_PLAYER_ID = 1
|
||||||
|
KODI_PHOTO_PLAYER_ID = 2
|
||||||
|
|
||||||
KODILANGUAGE = xbmc.getLanguage(xbmc.ISO_639_1)
|
KODILANGUAGE = xbmc.getLanguage(xbmc.ISO_639_1)
|
||||||
KODIVERSION = int(xbmc.getInfoLabel("System.BuildVersion")[:2])
|
KODIVERSION = int(xbmc.getInfoLabel("System.BuildVersion")[:2])
|
||||||
|
|
Loading…
Reference in a new issue