Merge pull request #1747 from croneter/python3-beta

Bump Python 3 Master
This commit is contained in:
croneter 2021-12-22 14:50:09 +01:00 committed by GitHub
commit 1498eaa8ff
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
65 changed files with 2381 additions and 2111 deletions

View file

@ -1,10 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<addon id="plugin.video.plexkodiconnect" name="PlexKodiConnect" version="3.5.8" provider-name="croneter">
<addon id="plugin.video.plexkodiconnect" name="PlexKodiConnect" version="3.6.0" provider-name="croneter">
<requires>
<import addon="xbmc.python" version="3.0.0"/>
<import addon="script.module.requests" version="2.22.0+matrix.1" />
<import addon="plugin.video.plexkodiconnect.movies" version="3.0.1" />
<import addon="plugin.video.plexkodiconnect.tvshows" version="3.0.1" />
<import addon="plugin.video.plexkodiconnect.movies" version="3.0.2" />
<import addon="plugin.video.plexkodiconnect.tvshows" version="3.0.2" />
<import addon="metadata.themoviedb.org.python" version="1.3.1+matrix.1" />
</requires>
<extension point="xbmc.python.pluginsource" library="default.py">
@ -20,6 +20,8 @@
</item>
</extension>
<extension point="xbmc.addon.metadata">
<!-- see e.g. https://github.com/xbmc/xbmc/pull/14136 -->
<reuselanguageinvoker>true</reuselanguageinvoker>
<assets>
<icon>icon.png</icon>
<fanart>fanart.jpg</fanart>
@ -91,7 +93,51 @@
<summary lang="ko_KR">Plex를 Kodi에 기본 통합</summary>
<description lang="ko_KR">Kodi를 Plex Media Server에 연결합니다. 이 플러그인은 Plex로 모든 비디오를 관리하고 Kodi로는 관리하지 않는다고 가정합니다. Kodi 비디오 및 음악 데이터베이스에 이미 저장된 데이터가 손실 될 수 있습니다 (이 플러그인이 직접 변경하므로). 자신의 책임하에 사용하십시오!</description>
<disclaimer lang="ko_KR">자신의 책임하에 사용</disclaimer>
<news>version 3.5.8:
<news>version 3.6.0:
- versions 3.5.9 - 3.5.17 for everyone
- Fix media not showing up in the library if Kodi masterlock has been activated. Kodi bugfix is also necessary, hopefully coming with Kodi 19.4, otherwise switch to Kodi 19.1
- Fix PKC sometimes selecting the wrong subtitle for direct paths
- Fix additional artwork download crashing if there was no sensible reply
- Fix PKC changing subtitles on playback start unnecessarily
- Fix Plex Companion not able to switch streams
version 3.5.17 (beta only):
- Use addon.xml `reuselanguageinvoker` to turn add-on snappier
- Fix detection of playqueue order. Thus fix PKC reporting back the playing of an old episode when using UpNext
- Fix logging for playlist items not working correctly
version 3.5.16 (beta only):
- Fix playback report for widget not working if direct paths are used
version 3.5.15 (beta only):
- Re-add old Plex Companion mechanism as the new one sucks
- Fix KeyError on playback startup
version 3.5.14 (beta only):
- Fix PKC not being able to connect to plex.tv after installation
version 3.5.13 (beta only):
- Fix Kodi getting blocked and losing PMS access e.g. due to cloudflare
version 3.5.12 (beta only):
- Fix skip intro not working
- Fix playback report not working due to an IndexError
- Fix rare IndexError when trying to delete a playlist item
version 3.5.11 (beta only):
- Fix playback startup and AttributeError: 'bool' object has no attribute 'get'
version 3.5.10 (beta only):
- Tell the PMS and Plex Companion about any stream changes on the Kodi side
version 3.5.9 (beta only):
- 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
- Add auto skip intro functionality
- Fix streams for videos not being set-up
- Fix generating new unique device ID for PKC not working
- Make PKC compatible with Python 3.6 again
version 3.5.8:
- Fix UnboundLocalError: local variable 'identifier' referenced before assignment
- versions 3.5.6-3.5.7 for everyone

View file

@ -1,3 +1,47 @@
version 3.6.0:
- versions 3.5.9 - 3.5.17 for everyone
- Fix media not showing up in the library if Kodi masterlock has been activated. Kodi bugfix is also necessary, hopefully coming with Kodi 19.4, otherwise switch to Kodi 19.1
- Fix PKC sometimes selecting the wrong subtitle for direct paths
- Fix additional artwork download crashing if there was no sensible reply
- Fix PKC changing subtitles on playback start unnecessarily
- Fix Plex Companion not able to switch streams
version 3.5.17 (beta only):
- Use addon.xml `reuselanguageinvoker` to turn add-on snappier
- Fix detection of playqueue order. Thus fix PKC reporting back the playing of an old episode when using UpNext
- Fix logging for playlist items not working correctly
version 3.5.16 (beta only):
- Fix playback report for widget not working if direct paths are used
version 3.5.15 (beta only):
- Re-add old Plex Companion mechanism as the new one sucks
- Fix KeyError on playback startup
version 3.5.14 (beta only):
- Fix PKC not being able to connect to plex.tv after installation
version 3.5.13 (beta only):
- Fix Kodi getting blocked and losing PMS access e.g. due to cloudflare
version 3.5.12 (beta only):
- Fix skip intro not working
- Fix playback report not working due to an IndexError
- Fix rare IndexError when trying to delete a playlist item
version 3.5.11 (beta only):
- Fix playback startup and AttributeError: 'bool' object has no attribute 'get'
version 3.5.10 (beta only):
- Tell the PMS and Plex Companion about any stream changes on the Kodi side
version 3.5.9 (beta only):
- 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
- Add auto skip intro functionality
- Fix streams for videos not being set-up
- Fix generating new unique device ID for PKC not working
- Make PKC compatible with Python 3.6 again
version 3.5.8:
- Fix UnboundLocalError: local variable 'identifier' referenced before assignment
- versions 3.5.6-3.5.7 for everyone

View file

@ -1,6 +1,4 @@
# -*- coding: utf-8 -*-
###############################################################################
from builtins import object
import logging
from sys import argv
@ -12,15 +10,10 @@ import xbmcplugin
from resources.lib import entrypoint, utils, transfer, variables as v, loghandler
###############################################################################
loghandler.config()
LOG = logging.getLogger('PLEX.default')
###############################################################################
HANDLE = int(argv[1])
class Main(object):
# MAIN ENTRY POINT
@ -117,7 +110,8 @@ class Main(object):
transfer.plex_command('choose_pms_server')
elif mode == 'deviceid':
self.deviceid()
LOG.info('New PKC UUID / unique device id requested')
transfer.plex_command('generate_new_uuid')
elif mode == 'fanart':
LOG.info('User requested fanarttv refresh')
@ -157,38 +151,21 @@ class Main(object):
"""
Start up playback_starter in main Python thread
"""
request = '%s&handle=%s' % (argv[2], HANDLE)
request = '%s&handle=%s' % (argv[2], int(argv[1]))
# Put the request into the 'queue'
transfer.plex_command('PLAY-%s' % request)
if HANDLE == -1:
if int(argv[1]) == -1:
# Handle -1 received, not waiting for main thread
return
# Wait for the result from the main PKC thread
result = transfer.wait_for_transfer(source='main')
if result is True:
xbmcplugin.setResolvedUrl(HANDLE, False, xbmcgui.ListItem())
xbmcplugin.setResolvedUrl(int(argv[1]), False, xbmcgui.ListItem())
# Tell main thread that we're done
transfer.send(True, target='main')
else:
# Received a xbmcgui.ListItem()
xbmcplugin.setResolvedUrl(HANDLE, True, result)
@staticmethod
def deviceid():
window = xbmcgui.Window(10000)
deviceId_old = window.getProperty('plex_client_Id')
from resources.lib import clientinfo
try:
deviceId = clientinfo.getDeviceId(reset=True)
except Exception as e:
LOG.error('Failed to generate a new device Id: %s' % e)
utils.messageDialog(utils.lang(29999), utils.lang(33032))
else:
LOG.info('Successfully removed old device ID: %s New deviceId:'
'%s' % (deviceId_old, deviceId))
# 'Kodi will now restart to apply the changes'
utils.messageDialog(utils.lang(29999), utils.lang(33033))
xbmc.executebuiltin('RestartApp')
xbmcplugin.setResolvedUrl(int(argv[1]), True, result)
if __name__ == '__main__':

View file

@ -1676,3 +1676,8 @@ msgstr ""
msgctxt "#39719"
msgid "Replace user ratings with number of media versions"
msgstr "Nahradit uživatelské hodnocení počtem verzí média"
# PKC Settings - Playback
msgctxt "#39720"
msgid "Auto skip intro"
msgstr ""

View file

@ -1684,3 +1684,8 @@ msgstr ""
msgctxt "#39719"
msgid "Replace user ratings with number of media versions"
msgstr "Erstat brugerbedømmelser med antal af medieversioner"
# PKC Settings - Playback
msgctxt "#39720"
msgid "Auto skip intro"
msgstr ""

View file

@ -1713,3 +1713,8 @@ msgstr ""
msgctxt "#39719"
msgid "Replace user ratings with number of media versions"
msgstr "Benutzerbewertungen durch verfügbare Anzahl Versionen ersetzen"
# PKC Settings - Playback
msgctxt "#39720"
msgid "Auto skip intro"
msgstr ""

View file

@ -1601,3 +1601,8 @@ msgstr ""
msgctxt "#39719"
msgid "Replace user ratings with number of media versions"
msgstr ""
# PKC Settings - Playback
msgctxt "#39720"
msgid "Auto skip intro"
msgstr ""

View file

@ -1501,3 +1501,8 @@ msgstr ""
msgctxt "#39719"
msgid "Replace user ratings with number of media versions"
msgstr ""
# PKC Settings - Playback
msgctxt "#39720"
msgid "Auto skip intro"
msgstr ""

View file

@ -1699,3 +1699,8 @@ msgctxt "#39719"
msgid "Replace user ratings with number of media versions"
msgstr ""
"Reemplazar valoraciones de usuario con cantidad de versiones de medios"
# PKC Settings - Playback
msgctxt "#39720"
msgid "Auto skip intro"
msgstr ""

View file

@ -1701,3 +1701,8 @@ msgctxt "#39719"
msgid "Replace user ratings with number of media versions"
msgstr ""
"Reemplazar valoraciones de usuario con cantidad de versiones de medios"
# PKC Settings - Playback
msgctxt "#39720"
msgid "Auto skip intro"
msgstr ""

View file

@ -1699,3 +1699,8 @@ msgctxt "#39719"
msgid "Replace user ratings with number of media versions"
msgstr ""
"Reemplazar valoraciones de usuario con cantidad de versiones de medios"
# PKC Settings - Playback
msgctxt "#39720"
msgid "Auto skip intro"
msgstr ""

View file

@ -1719,3 +1719,8 @@ msgstr ""
msgctxt "#39719"
msgid "Replace user ratings with number of media versions"
msgstr "Remplacer les notes d'utilisateurs par le nombre de versions du média"
# PKC Settings - Playback
msgctxt "#39720"
msgid "Auto skip intro"
msgstr ""

View file

@ -1723,3 +1723,8 @@ msgstr ""
msgctxt "#39719"
msgid "Replace user ratings with number of media versions"
msgstr "Remplacer les notes d'utilisateurs par le nombre de versions du média"
# PKC Settings - Playback
msgctxt "#39720"
msgid "Auto skip intro"
msgstr ""

View file

@ -1712,3 +1712,8 @@ msgstr ""
msgctxt "#39719"
msgid "Replace user ratings with number of media versions"
msgstr "Felhasználói osztályzatok lecserélése a médiaverziók számára"
# PKC Settings - Playback
msgctxt "#39720"
msgid "Auto skip intro"
msgstr ""

View file

@ -1711,3 +1711,8 @@ msgid "Replace user ratings with number of media versions"
msgstr ""
"Sostituisci la valutazione contenuti con il numero delle versioni del "
"contenuto disponibili"
# PKC Settings - Playback
msgctxt "#39720"
msgid "Auto skip intro"
msgstr ""

View file

@ -1632,3 +1632,8 @@ msgstr "사용자 지정 사용자 등급을 보유하고있는 미디어 항목
msgctxt "#39719"
msgid "Replace user ratings with number of media versions"
msgstr "사용자 등급을 미디어 버전 수로 대체"
# PKC Settings - Playback
msgctxt "#39720"
msgid "Auto skip intro"
msgstr ""

View file

@ -1697,3 +1697,8 @@ msgstr ""
msgctxt "#39719"
msgid "Replace user ratings with number of media versions"
msgstr "Pakeiskite naudotojų reitingus medijos versijų skaičiumi"
# PKC Settings - Playback
msgctxt "#39720"
msgid "Auto skip intro"
msgstr ""

View file

@ -1632,3 +1632,8 @@ msgstr ""
msgctxt "#39719"
msgid "Replace user ratings with number of media versions"
msgstr ""
# PKC Settings - Playback
msgctxt "#39720"
msgid "Auto skip intro"
msgstr ""

View file

@ -1683,3 +1683,8 @@ msgstr ""
msgctxt "#39719"
msgid "Replace user ratings with number of media versions"
msgstr "Vervang ratings met aantal media versies"
# PKC Settings - Playback
msgctxt "#39720"
msgid "Auto skip intro"
msgstr ""

View file

@ -1677,3 +1677,8 @@ msgstr ""
msgctxt "#39719"
msgid "Replace user ratings with number of media versions"
msgstr "Erstatt rating med antall versjoner av media"
# PKC Settings - Playback
msgctxt "#39720"
msgid "Auto skip intro"
msgstr ""

View file

@ -1606,3 +1606,8 @@ msgstr ""
msgctxt "#39719"
msgid "Replace user ratings with number of media versions"
msgstr ""
# PKC Settings - Playback
msgctxt "#39720"
msgid "Auto skip intro"
msgstr ""

View file

@ -1674,3 +1674,8 @@ msgctxt "#39719"
msgid "Replace user ratings with number of media versions"
msgstr ""
"Substituir classificações do utilizador com numero de versões de média"
# PKC Settings - Playback
msgctxt "#39720"
msgid "Auto skip intro"
msgstr ""

View file

@ -1677,3 +1677,8 @@ msgctxt "#39719"
msgid "Replace user ratings with number of media versions"
msgstr ""
"Substituir classificações do utilizador com numero de versões de média"
# PKC Settings - Playback
msgctxt "#39720"
msgid "Auto skip intro"
msgstr ""

View file

@ -1691,3 +1691,8 @@ msgstr ""
msgctxt "#39719"
msgid "Replace user ratings with number of media versions"
msgstr "Заменить пользовательский рейтинг счетчиком версий элемента"
# PKC Settings - Playback
msgctxt "#39720"
msgid "Auto skip intro"
msgstr ""

View file

@ -1685,3 +1685,8 @@ msgstr ""
msgctxt "#39719"
msgid "Replace user ratings with number of media versions"
msgstr "Ersätt användarbetyg med antalet mediaobjekt"
# PKC Settings - Playback
msgctxt "#39720"
msgid "Auto skip intro"
msgstr ""

View file

@ -1691,3 +1691,8 @@ msgstr ""
msgctxt "#39719"
msgid "Replace user ratings with number of media versions"
msgstr "Замінити користувацький рейтинг лічильником версій елемента"
# PKC Settings - Playback
msgctxt "#39720"
msgid "Auto skip intro"
msgstr ""

View file

@ -1602,3 +1602,8 @@ msgstr ""
msgctxt "#39719"
msgid "Replace user ratings with number of media versions"
msgstr ""
# PKC Settings - Playback
msgctxt "#39720"
msgid "Auto skip intro"
msgstr ""

View file

@ -1598,3 +1598,8 @@ msgstr ""
msgctxt "#39719"
msgid "Replace user ratings with number of media versions"
msgstr ""
# PKC Settings - Playback
msgctxt "#39720"
msgid "Auto skip intro"
msgstr ""

View file

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

View file

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

View file

@ -44,12 +44,6 @@ class PlayState(object):
1: {},
2: {}
}
# The LAST playstate once playback is finished
self.old_player_states = {
0: {},
1: {},
2: {}
}
self.played_info = {}
# Currently playing PKC item, a PlaylistItem()

View file

@ -51,6 +51,22 @@ def getXArgsDeviceInfo(options=None, include_token=True):
return xargs
def generate_device_id():
LOG.info("Generating a new deviceid.")
from uuid import uuid4
client_id = str(uuid4())
utils.settings('plex_client_Id', value=client_id)
v.PKC_MACHINE_IDENTIFIER = client_id
utils.window('plex_client_Id', value=client_id)
LOG.info("Unique device Id plex_client_Id generated: %s", client_id)
# IF WE EXIT KODI NOW, THE SETTING WON'T STICK!
# 'Kodi will now restart to apply the changes'
# utils.messageDialog(utils.lang(29999), utils.lang(33033))
# xbmc.executebuiltin('RestartApp')
utils.messageDialog(utils.lang(29999), 'Please restart Kodi now!')
return client_id
def getDeviceId(reset=False):
"""
Returns a unique Plex client id "X-Plex-Client-Identifier" from Kodi
@ -59,28 +75,18 @@ def getDeviceId(reset=False):
If id does not exist, create one and save in Kodi settings file.
"""
if reset is True:
v.PKC_MACHINE_IDENTIFIER = None
utils.window('plex_client_Id', clear=True)
utils.settings('plex_client_Id', value="")
if reset:
return generate_device_id()
client_id = v.PKC_MACHINE_IDENTIFIER
if client_id:
return client_id
client_id = utils.settings('plex_client_Id')
# Because Kodi appears to cache file settings!!
if client_id != "" and reset is False:
if client_id != "":
v.PKC_MACHINE_IDENTIFIER = client_id
utils.window('plex_client_Id', value=client_id)
LOG.info("Unique device Id plex_client_Id loaded: %s", client_id)
return client_id
LOG.info("Generating a new deviceid.")
from uuid import uuid4
client_id = str(uuid4())
utils.settings('plex_client_Id', value=client_id)
v.PKC_MACHINE_IDENTIFIER = client_id
utils.window('plex_client_Id', value=client_id)
LOG.info("Unique device Id plex_client_Id generated: %s", client_id)
return client_id
else:
return generate_device_id()

View file

@ -6,8 +6,10 @@ Processes Plex companion inputs from the plexbmchelper to Kodi commands
from logging import getLogger
from xbmc import Player
from . import playqueue as PQ, plex_functions as PF
from . import json_rpc as js, variables as v, app
from . import plex_functions as PF
from . import json_rpc as js
from . import variables as v
from . import app
###############################################################################
@ -28,7 +30,7 @@ def skip_to(params):
playqueue_item_id, plex_id)
found = True
for player in list(js.get_players().values()):
playqueue = PQ.PLAYQUEUES[player['playerid']]
playqueue = app.PLAYQUEUES[player['playerid']]
for i, item in enumerate(playqueue.items):
if item.id == playqueue_item_id:
found = True
@ -78,6 +80,7 @@ def process_command(request_path, params):
js.set_volume(int(params['volume']))
else:
LOG.error('Unknown parameters: %s', params)
return False
elif request_path == "player/playback/play":
js.play()
elif request_path == "player/playback/pause":
@ -117,3 +120,5 @@ def process_command(request_path, params):
})
else:
LOG.error('Unknown request path: %s', request_path)
return False
return True

View file

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

View file

@ -526,7 +526,7 @@ class InitialSetup(object):
force_create=True,
top_element='sources') as xml:
changed = False
for extension in ('smb://', 'nfs://'):
for extension in ('smb://', 'nfs://', 'plugin://'):
root = xml.set_setting(['video'])
changed = self._add_sources(root, extension) or changed
if changed:

View file

@ -442,8 +442,6 @@ def get_current_subtitle_stream_index(playerid):
"""
Returns the currently active subtitle stream index [int] or None if there
are no subs
PICKING UP CHANGES ON SUBTITLES IS CURRENTLY BROKEN ON THE KODI SIDE! The
JSON reply won't change even though subtitles are changed :-(
"""
try:
return JsonRPC('Player.GetProperties').execute({
@ -456,8 +454,6 @@ def get_current_subtitle_stream_index(playerid):
def get_subtitle_enabled(playerid):
"""
Returns True if a subtitle is currently enabled, False otherwise.
PICKING UP CHANGES ON SUBTITLES IS CURRENTLY BROKEN ON THE KODI SIDE! The
JSON reply won't change even though subtitles are changed :-(
"""
return JsonRPC('Player.GetProperties').execute({
'playerid': playerid,

View file

@ -17,7 +17,7 @@ from .kodi_db import KodiVideoDB
from . import kodi_db
from .downloadutils import DownloadUtils as DU
from . import utils, timing, plex_functions as PF
from . import json_rpc as js, playqueue as PQ, playlist_func as PL
from . import json_rpc as js, playlist_func as PL
from . import backgroundthread, app, variables as v
from . import exceptions
@ -31,11 +31,9 @@ class KodiMonitor(xbmc.Monitor):
def __init__(self):
self._already_slept = False
self._switched_to_plex_streams = True
xbmc.Monitor.__init__(self)
for playerid in app.PLAYSTATE.player_states:
app.PLAYSTATE.player_states[playerid] = copy.deepcopy(app.PLAYSTATE.template)
app.PLAYSTATE.old_player_states[playerid] = copy.deepcopy(app.PLAYSTATE.template)
LOG.info("Kodi monitor started.")
def onScanStarted(self, library):
@ -141,7 +139,7 @@ class KodiMonitor(xbmc.Monitor):
u'playlistid': 1,
}
"""
playqueue = PQ.PLAYQUEUES[data['playlistid']]
playqueue = app.PLAYQUEUES[data['playlistid']]
if not playqueue.is_pkc_clear():
playqueue.pkc_edit = True
playqueue.clear(kodi=False)
@ -257,7 +255,7 @@ class KodiMonitor(xbmc.Monitor):
if not playerid:
LOG.error('Coud not get playerid for data %s', data)
return
playqueue = PQ.PLAYQUEUES[playerid]
playqueue = app.PLAYQUEUES[playerid]
info = js.get_player_props(playerid)
if playqueue.kodi_playlist_playback:
# Kodi will tell us the wrong position - of the playlist, not the
@ -310,7 +308,7 @@ class KodiMonitor(xbmc.Monitor):
initialize = False
if initialize:
LOG.debug('Need to initialize Plex and PKC playqueue')
if not kodi_id or not kodi_type:
if not kodi_id or not kodi_type or not path:
kodi_id, kodi_type, path = self._json_item(playerid)
plex_id, plex_type = self._get_ids(kodi_id, kodi_type, path)
if not plex_id:
@ -327,7 +325,7 @@ class KodiMonitor(xbmc.Monitor):
container_key = None
if info['playlistid'] != -1:
# -1 is Kodi's answer if there is no playlist
container_key = PQ.PLAYQUEUES[playerid].id
container_key = app.PLAYQUEUES[playerid].id
if container_key is not None:
container_key = '/playQueues/%s' % container_key
elif plex_id is not None:
@ -345,6 +343,9 @@ class KodiMonitor(xbmc.Monitor):
# Mechanik for Plex skip intro feature
if utils.settings('enableSkipIntro') == 'true':
status['intro_markers'] = item.api.intro_markers()
if item.playmethod is None and not path.startswith('plugin://'):
item.playmethod = v.PLAYBACK_METHOD_DIRECT_PATH
item.playerid = playerid
# Remember the currently playing item
app.PLAYSTATE.item = item
# Remember that this player has been active
@ -365,7 +366,10 @@ class KodiMonitor(xbmc.Monitor):
# Workaround for the Kodi add-on Up Next
if not app.SYNC.direct_paths:
_notify_upnext(item)
self._switched_to_plex_streams = False
if playerid == v.KODI_VIDEO_PLAYER_ID:
task = InitVideoStreams(item)
backgroundthread.BGThreader.addTask(task)
def _on_av_change(self, data):
"""
@ -375,34 +379,8 @@ class KodiMonitor(xbmc.Monitor):
Example data as returned by Kodi:
{'item': {'id': 5, 'type': 'movie'},
'player': {'playerid': 1, 'speed': 1}}
PICKING UP CHANGES ON SUBTITLES IS CURRENTLY BROKEN ON THE KODI SIDE!
Kodi subs will never change. Also see json_rpc.py
"""
playerid = data['player']['playerid']
if not playerid == v.KODI_VIDEO_PLAYER_ID:
# We're just messing with Kodi's videoplayer
return
item = app.PLAYSTATE.item
if item is None:
# Player might've quit
return
if not self._switched_to_plex_streams:
# We need to switch to the Plex streams ONCE upon playback start
# after onavchange has been fired
# Wait a bit because JSON responses won't be ready otherwise
if app.APP.monitor.waitForAbort(2):
# In case PKC needs to quit
return
item.init_kodi_streams()
item.switch_to_plex_stream('video')
if utils.settings('audioStreamPick') == '0':
item.switch_to_plex_stream('audio')
if utils.settings('subtitleStreamPick') == '0':
item.switch_to_plex_stream('subtitle')
self._switched_to_plex_streams = True
else:
item.on_av_change(playerid)
pass
def _playback_cleanup(ended=False):
@ -421,8 +399,6 @@ def _playback_cleanup(ended=False):
app.CONN.plex_transient_token = None
for playerid in app.PLAYSTATE.active_players:
status = app.PLAYSTATE.player_states[playerid]
# Remember the last played item later
app.PLAYSTATE.old_player_states[playerid] = copy.deepcopy(status)
# Stop transcoding
if status['playmethod'] == v.PLAYBACK_METHOD_TRANSCODE:
LOG.debug('Tell the PMS to stop transcoding')
@ -688,3 +664,19 @@ def _videolibrary_onupdate(data):
PF.scrobble(db_item['plex_id'], 'watched')
else:
PF.scrobble(db_item['plex_id'], 'unwatched')
class InitVideoStreams(backgroundthread.Task):
"""
The Kodi player takes forever to initialize all streams Especially
subtitles, apparently. No way to tell when Kodi is done :-(
"""
def __init__(self, item):
self.item = item
super().__init__()
def run(self):
if app.APP.monitor.waitForAbort(5):
return
self.item.init_streams()

View file

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

View file

@ -22,117 +22,6 @@ from .subtitles import accessible_plex_subtitles
LOG = getLogger('PLEX.playlist_func')
class Playqueue_Object(object):
"""
PKC object to represent PMS playQueues and Kodi playlist for queueing
playlistid = None [int] Kodi playlist id (0, 1, 2)
type = None [str] Kodi type: 'audio', 'video', 'picture'
kodi_pl = None Kodi xbmc.PlayList object
items = [] [list] of Playlist_Items
id = None [str] Plex playQueueID, unique Plex identifier
version = None [int] Plex version of the playQueue
selectedItemID = None
[str] Plex selectedItemID, playing element in queue
selectedItemOffset = None
[str] Offset of the playing element in queue
shuffled = 0 [int] 0: not shuffled, 1: ??? 2: ???
repeat = 0 [int] 0: not repeated, 1: ??? 2: ???
If Companion playback is initiated by another user:
plex_transient_token = None
"""
kind = 'playQueue'
def __init__(self):
self.id = None
self.type = None
self.playlistid = None
self.kodi_pl = None
self.items = []
self.version = None
self.selectedItemID = None
self.selectedItemOffset = None
self.shuffled = 0
self.repeat = 0
self.plex_transient_token = None
# Need a hack for detecting swaps of elements
self.old_kodi_pl = []
# Did PKC itself just change the playqueue so the PKC playqueue monitor
# should not pick up any changes?
self.pkc_edit = False
# Workaround to avoid endless loops of detecting PL clears
self._clear_list = []
# To keep track if Kodi playback was initiated from a Kodi playlist
# There are a couple of pitfalls, unfortunately...
self.kodi_playlist_playback = False
def __repr__(self):
answ = ("{{"
"'playlistid': {self.playlistid}, "
"'id': {self.id}, "
"'version': {self.version}, "
"'type': '{self.type}', "
"'selectedItemID': {self.selectedItemID}, "
"'selectedItemOffset': {self.selectedItemOffset}, "
"'shuffled': {self.shuffled}, "
"'repeat': {self.repeat}, "
"'kodi_playlist_playback': {self.kodi_playlist_playback}, "
"'pkc_edit': {self.pkc_edit}, ".format(self=self))
# Since list.__repr__ will return string, not unicode
return answ + "'items': {self.items}}}".format(self=self)
def is_pkc_clear(self):
"""
Returns True if PKC has cleared the Kodi playqueue just recently.
Then this clear will be ignored from now on
"""
try:
self._clear_list.pop()
except IndexError:
return False
else:
return True
def clear(self, kodi=True):
"""
Resets the playlist object to an empty playlist.
Pass kodi=False in order to NOT clear the Kodi playqueue
"""
# kodi monitor's on_clear method will only be called if there were some
# items to begin with
if kodi and self.kodi_pl.size() != 0:
self._clear_list.append(None)
self.kodi_pl.clear() # Clear Kodi playlist object
self.items = []
self.id = None
self.version = None
self.selectedItemID = None
self.selectedItemOffset = None
self.shuffled = 0
self.repeat = 0
self.plex_transient_token = None
self.old_kodi_pl = []
self.kodi_playlist_playback = False
LOG.debug('Playlist cleared: %s', self)
def position_from_plex_id(self, plex_id):
"""
Returns the position [int] for the very first item with plex_id [int]
(Plex seems uncapable of adding the same element multiple times to a
playqueue or playlist)
Raises KeyError if not found
"""
for position, item in enumerate(self.items):
if item.plex_id == plex_id:
break
else:
raise KeyError('Did not find plex_id %s in %s', plex_id, self)
return position
class PlaylistItem(object):
"""
Object to fill our playqueues and playlists with.
@ -166,6 +55,7 @@ class PlaylistItem(object):
self.playmethod = None
self.playcount = None
self.offset = None
self.playerid = None
# Transcoding quality, if needed
self.quality = None
# If Plex video consists of several parts; part number
@ -183,11 +73,12 @@ class PlaylistItem(object):
self._audio_streams = None
self._subtitle_streams = None
# Which Kodi streams are active?
self.current_kodi_video_stream = None
self.current_kodi_audio_stream = None
# False means "deactivated", None means "we do not have a Kodi
# equivalent for this Plex subtitle"
self.current_kodi_sub_stream = None
self._current_kodi_video_stream = None
self._current_kodi_audio_stream = None
# Kodi subs can be turned on/off additionally!
self._current_kodi_sub_stream = None
self._current_kodi_sub_stream_enabled = None
self.streams_initialized = False
@property
def plex_id(self):
@ -221,6 +112,48 @@ class PlaylistItem(object):
self._process_streams()
return self._subtitle_streams
@property
def current_kodi_video_stream(self):
return self._current_kodi_video_stream
@current_kodi_video_stream.setter
def current_kodi_video_stream(self, value):
if value != self._current_kodi_video_stream:
self.on_kodi_video_stream_change(value)
self._current_kodi_video_stream = value
@property
def current_kodi_audio_stream(self):
return self._current_kodi_audio_stream
@current_kodi_audio_stream.setter
def current_kodi_audio_stream(self, value):
if value != self._current_kodi_audio_stream:
self.on_kodi_audio_stream_change(value)
self._current_kodi_audio_stream = value
@property
def current_kodi_sub_stream_enabled(self):
return self._current_kodi_sub_stream_enabled
@current_kodi_sub_stream_enabled.setter
def current_kodi_sub_stream_enabled(self, value):
if value != self._current_kodi_sub_stream_enabled:
self.on_kodi_subtitle_stream_change(self.current_kodi_sub_stream,
value)
self._current_kodi_sub_stream_enabled = value
@property
def current_kodi_sub_stream(self):
return self._current_kodi_sub_stream
@current_kodi_sub_stream.setter
def current_kodi_sub_stream(self, value):
if value != self._current_kodi_sub_stream:
self.on_kodi_subtitle_stream_change(value,
self.current_kodi_sub_stream_enabled)
self._current_kodi_sub_stream = value
@property
def current_plex_video_stream(self):
return self.plex_stream_index(self.current_kodi_video_stream, 'video')
@ -247,7 +180,7 @@ class PlaylistItem(object):
"'resume': {self.resume},"
"'offset': {self.offset}, "
"'force_transcode': {self.force_transcode}, "
"'part': {self.part}".format(self=self))
"'part': {self.part}}}".format(self=self))
def _process_streams(self):
"""
@ -281,44 +214,96 @@ class PlaylistItem(object):
elif stream_type == 'video':
return self.video_streams
def init_kodi_streams(self):
def _current_index(self, 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(self.playerid)
except (TypeError, IndexError, KeyError):
# 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_streams(self):
"""
Initializes all streams after Kodi has started playing this video
WARNING: KODI TAKES FOREVER TO INITIALIZE STREAMS AFTER PLAYBACK
STARTUP. YOU WONT GET THE CORRECT NUMBER OFAUDIO AND SUB STREAMS RIGHT
AFTER STARTUP. Seems like you need to wait a couple of seconds
"""
self.current_kodi_video_stream = js.get_current_video_stream_index(v.KODI_VIDEO_PLAYER_ID)
self.current_kodi_audio_stream = js.get_current_audio_stream_index(v.KODI_VIDEO_PLAYER_ID)
self.current_kodi_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)
if not app.PLAYSTATE.item == self:
# Already stopped playback or skipped to the next one
LOG.warn('Skipping init_streams!')
return
self.init_kodi_streams()
self.switch_to_plex_stream('video')
if utils.settings('audioStreamPick') == '0':
self.switch_to_plex_stream('audio')
if utils.settings('subtitleStreamPick') == '0':
self.switch_to_plex_stream('subtitle')
self.streams_initialized = True
def init_kodi_streams(self):
self._current_kodi_video_stream = self._current_index('video')
self._current_kodi_audio_stream = self._current_index('audio')
self._current_kodi_sub_stream_enabled = js.get_subtitle_enabled(self.playerid)
self._current_kodi_sub_stream = self._current_index('subtitle')
def plex_stream_index(self, kodi_stream_index, stream_type):
"""
Pass in the kodi_stream_index [int] in order to receive the Plex stream
index [int].
stream_type: 'video', 'audio', 'subtitle'
Returns None if unsuccessful
"""
if stream_type == 'audio':
return int(self.audio_streams[kodi_stream_index].get('id'))
elif stream_type == 'video':
return int(self.video_streams[kodi_stream_index].get('id'))
elif stream_type == 'subtitle':
if self.current_kodi_sub_stream_enabled:
try:
return int(self.subtitle_streams[kodi_stream_index].get('id'))
except (IndexError, TypeError):
pass
# A subtitle that is not available on the Plex side
# deactivating subs
return 0
else:
return 0
def kodi_stream_index(self, plex_stream_index, stream_type):
"""
Pass in the plex_stream_index [int] in order to receive the Kodi stream
index [int].
stream_type: 'video', 'audio', 'subtitle'
Returns None if unsuccessful
Raises ValueError if unsuccessful
"""
if plex_stream_index is None:
return
if not isinstance(plex_stream_index, int):
raise ValueError('%s plex_stream_index %s of type %s received' %
(stream_type, plex_stream_index, type(plex_stream_index)))
for i, stream in enumerate(self._get_iterator(stream_type)):
if cast(int, stream.get('id')) == plex_stream_index:
return i
raise ValueError('No %s kodi_stream_index for plex_stream_index %s' %
(stream_type, plex_stream_index))
def active_plex_stream_index(self, stream_type):
"""
@ -341,16 +326,14 @@ class PlaylistItem(object):
except (IndexError, TypeError):
LOG.debug('Kodi subtitle change detected to a sub %s that is '
'NOT available on the Plex side', kodi_stream_index)
self.current_kodi_sub_stream = None
return
plex_stream_index = 0
else:
LOG.debug('Kodi subtitle change detected: telling Plex about '
'switch to index %s, Plex stream id %s',
kodi_stream_index, plex_stream_index)
self.current_kodi_sub_stream = kodi_stream_index
else:
plex_stream_index = 0
LOG.debug('Kodi subtitle has been deactivated, telling Plex')
self.current_kodi_sub_stream = False
PF.change_subtitle(plex_stream_index, self.api.part_id())
def on_kodi_audio_stream_change(self, kodi_stream_index):
@ -362,7 +345,6 @@ class PlaylistItem(object):
LOG.debug('Changing Plex audio stream to %s, Kodi index %s',
plex_stream_index, kodi_stream_index)
PF.change_audio_stream(plex_stream_index, self.api.part_id())
self.current_kodi_audio_stream = kodi_stream_index
def on_kodi_video_stream_change(self, kodi_stream_index):
"""
@ -373,43 +355,55 @@ class PlaylistItem(object):
LOG.debug('Changing Plex video stream to %s, Kodi index %s',
plex_stream_index, kodi_stream_index)
PF.change_video_stream(plex_stream_index, self.api.part_id())
self.current_kodi_video_stream = kodi_stream_index
def switch_to_plex_streams(self):
self.switch_to_plex_stream('video')
self.switch_to_plex_stream('audio')
self.switch_to_plex_stream('subtitle')
@staticmethod
def _set_kodi_stream_if_different(kodi_index, typus):
def _set_kodi_stream_if_different(self, kodi_index, typus):
"""Will always activate subtitles."""
if typus == 'video':
current = js.get_current_video_stream_index(v.KODI_VIDEO_PLAYER_ID)
current = js.get_current_video_stream_index(self.playerid)
if current != kodi_index:
LOG.debug('Switching video stream')
app.APP.player.setVideoStream(kodi_index)
else:
LOG.debug('Not switching video stream (no change)')
elif typus == 'audio':
current = js.get_current_audio_stream_index(v.KODI_VIDEO_PLAYER_ID)
current = js.get_current_audio_stream_index(self.playerid)
if current != kodi_index:
LOG.debug('Switching audio stream')
app.APP.player.setAudioStream(kodi_index)
else:
LOG.debug('Not switching audio stream (no change)')
elif typus == 'subtitle':
current = js.get_current_subtitle_stream_index(self.playerid)
enabled = js.get_subtitle_enabled(self.playerid)
if current != kodi_index:
LOG.debug('Switching subtitle stream')
app.APP.player.setAudioStream(kodi_index)
else:
LOG.debug('Not switching subtitle stream (no change)')
if not enabled:
LOG.debug('Enabling subtitles')
app.APP.player.showSubtitles(True)
else:
raise RuntimeError('Unknown stream type %s' % typus)
def switch_to_plex_stream(self, typus):
try:
plex_index, language_tag = self.active_plex_stream_index(typus)
except TypeError:
# Only happens if Plex did not provide us with a suitable sub
# Meaning Plex tells us to deactivate subs
LOG.debug('Deactivating Kodi subtitles because the PMS '
'told us to not show any subtitles')
app.APP.player.showSubtitles(False)
self.current_kodi_sub_stream = False
self._current_kodi_sub_stream_enabled = False
return
# Rest: video, audio and activated subs
LOG.debug('The PMS wants to display %s stream with Plex id %s and '
'languageTag %s', typus, plex_index, language_tag)
try:
kodi_index = self.kodi_stream_index(plex_index, typus)
if kodi_index is None:
except ValueError:
kodi_index = None
LOG.debug('Leaving Kodi %s stream settings untouched since we '
'could not parse Plex %s stream with id %s to a Kodi'
' index', typus, typus, plex_index)
@ -419,69 +413,55 @@ class PlaylistItem(object):
typus, kodi_index, plex_index)
# If we're choosing an "illegal" index, this function does
# need seem to fail nor log any errors
self._set_kodi_stream_if_different(kodi_index, typus)
if typus == 'audio':
self._set_kodi_stream_if_different(kodi_index, 'audio')
self._current_kodi_audio_stream = kodi_index
elif typus == 'subtitle':
app.APP.player.setSubtitleStream(kodi_index)
app.APP.player.showSubtitles(True)
self._current_kodi_sub_stream_enabled = True
self._current_kodi_sub_stream = kodi_index
elif typus == 'video':
self._current_kodi_video_stream = kodi_index
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 [ints]
"""
if video_stream_id is not None:
try:
kodi_index = self.kodi_stream_index(video_stream_id, 'video')
except ValueError:
LOG.error('Unexpected Plex video_stream_id %s, not changing '
'the video stream!', video_stream_id)
return
self._set_kodi_stream_if_different(kodi_index, 'video')
if typus == 'audio':
self.current_kodi_audio_stream = kodi_index
elif typus == 'subtitle':
self.current_kodi_sub_stream = kodi_index
elif typus == 'video':
self.current_kodi_video_stream = kodi_index
def on_av_change(self, playerid):
"""
Call this method if Kodi reports an "AV-Change"
(event "Player.OnAVChange")
"""
kodi_video_stream = js.get_current_video_stream_index(playerid)
kodi_audio_stream = js.get_current_audio_stream_index(playerid)
sub_enabled = js.get_subtitle_enabled(playerid)
kodi_sub_stream = js.get_current_subtitle_stream_index(playerid)
# Audio
if kodi_audio_stream != self.current_kodi_audio_stream:
self.on_kodi_audio_stream_change(kodi_audio_stream)
# Video
if kodi_video_stream != self.current_kodi_video_stream:
self.on_kodi_video_stream_change(kodi_audio_stream)
# Subtitles - CURRENTLY BROKEN ON THE KODI SIDE!
# current_kodi_sub_stream may also be zero
subs_off = (None, False)
if ((sub_enabled and self.current_kodi_sub_stream in subs_off)
or (not sub_enabled and self.current_kodi_sub_stream not in subs_off)
or (kodi_sub_stream is not None
and kodi_sub_stream != self.current_kodi_sub_stream)):
self.on_kodi_subtitle_stream_change(kodi_sub_stream, sub_enabled)
def on_plex_stream_change(self, plex_data):
"""
Call this method if Plex Companion wants to change streams
"""
if 'audioStreamID' in plex_data:
plex_index = int(plex_data['audioStreamID'])
kodi_index = self.kodi_stream_index(plex_index, 'audio')
self._current_kodi_video_stream = kodi_index
if audio_stream_id is not None:
try:
kodi_index = self.kodi_stream_index(audio_stream_id, 'audio')
except ValueError:
LOG.error('Unexpected Plex audio_stream_id %s, not changing '
'the video stream!', audio_stream_id)
return
self._set_kodi_stream_if_different(kodi_index, 'audio')
self.current_kodi_audio_stream = kodi_index
if 'videoStreamID' in plex_data:
plex_index = int(plex_data['videoStreamID'])
kodi_index = self.kodi_stream_index(plex_index, 'video')
self._set_kodi_stream_if_different(kodi_index, 'video')
self.current_kodi_video_stream = kodi_index
if 'subtitleStreamID' in plex_data:
plex_index = int(plex_data['subtitleStreamID'])
if plex_index == 0:
self._current_kodi_audio_stream = kodi_index
if subtitle_stream_id is not None:
if subtitle_stream_id == 0:
app.APP.player.showSubtitles(False)
kodi_index = False
self._current_kodi_sub_stream_enabled = False
else:
try:
kodi_index = self.kodi_stream_index(subtitle_stream_id,
'subtitle')
except ValueError:
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:
kodi_index = self.kodi_stream_index(plex_index, 'subtitle')
if kodi_index:
app.APP.player.setSubtitleStream(kodi_index)
app.APP.player.showSubtitles(True)
self.current_kodi_sub_stream = kodi_index
self._current_kodi_sub_stream_enabled = True
self._current_kodi_sub_stream = kodi_index
def playlist_item_from_kodi(kodi_item):
@ -911,12 +891,16 @@ def delete_playlist_item_from_PMS(playlist, pos):
Delete the item at position pos [int] on the Plex side and our playlists
"""
LOG.debug('Deleting position %s for %s on the Plex side', pos, playlist)
try:
xml = DU().downloadUrl("{server}/%ss/%s/items/%s?repeat=%s" %
(playlist.kind,
playlist.id,
playlist.items[pos].id,
playlist.repeat),
action_type="DELETE")
except IndexError:
raise PlaylistError('Position %s out of bound for %s' % (pos, playlist))
else:
del playlist.items[pos]
_update_playlist_version(playlist, xml)

View file

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

View file

@ -224,7 +224,7 @@ class Artwork(object):
authenticate=False,
timeout=15,
return_response=True)
if not data.ok:
if data in (None, 401) or not data.ok:
LOG.debug('Could not download data from FanartTV')
return artworks
data = data.json()

View file

@ -1,362 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
The Plex Companion master python file
"""
from logging import getLogger
from threading import Thread
from queue import Empty
from socket import SHUT_RDWR
from xbmc import executebuiltin
from .plexbmchelper import listener, plexgdm, subscribers, httppersist
from .plex_api import API
from . import utils
from . import plex_functions as PF
from . import playlist_func as PL
from . import playback
from . import json_rpc as js
from . import playqueue as PQ
from . import variables as v
from . import backgroundthread
from . import app
from . import exceptions
###############################################################################
LOG = getLogger('PLEX.plex_companion')
###############################################################################
def update_playqueue_from_PMS(playqueue,
playqueue_id=None,
repeat=None,
offset=None,
transient_token=None,
start_plex_id=None):
"""
Completely updates the Kodi playqueue with the new Plex playqueue. Pass
in playqueue_id if we need to fetch a new playqueue
repeat = 0, 1, 2
offset = time offset in Plextime (milliseconds)
"""
LOG.info('New playqueue %s received from Plex companion with offset '
'%s, repeat %s, start_plex_id %s',
playqueue_id, offset, repeat, start_plex_id)
# Safe transient token from being deleted
if transient_token is None:
transient_token = playqueue.plex_transient_token
with app.APP.lock_playqueues:
try:
xml = PL.get_PMS_playlist(playqueue, playqueue_id)
except exceptions.PlaylistError:
LOG.error('Could now download playqueue %s', playqueue_id)
return
if playqueue.id == playqueue_id:
# This seems to be happening ONLY if a Plex Companion device
# reconnects and Kodi is already playing something - silly, really
# For all other cases, a new playqueue is generated by Plex
LOG.debug('Update for existing playqueue detected')
return
playqueue.clear()
# Get new metadata for the playqueue first
try:
PL.get_playlist_details_from_xml(playqueue, xml)
except exceptions.PlaylistError:
LOG.error('Could not get playqueue ID %s', playqueue_id)
return
playqueue.repeat = 0 if not repeat else int(repeat)
playqueue.plex_transient_token = transient_token
playback.play_xml(playqueue,
xml,
offset=offset,
start_plex_id=start_plex_id)
class PlexCompanion(backgroundthread.KillableThread):
"""
Plex Companion monitoring class. Invoke only once
"""
def __init__(self):
LOG.info("----===## Starting PlexCompanion ##===----")
# Init Plex Companion queue
# Start GDM for server/client discovery
self.client = plexgdm.plexgdm()
self.client.clientDetails()
LOG.debug("Registration string is:\n%s", self.client.getClientDetails())
self.httpd = False
self.subscription_manager = None
super(PlexCompanion, self).__init__()
@staticmethod
def _process_alexa(data):
if 'key' not in data or 'containerKey' not in data:
LOG.error('Received malformed Alexa data: %s', data)
return
xml = PF.GetPlexMetadata(data['key'])
try:
xml[0].attrib
except (AttributeError, IndexError, TypeError):
LOG.error('Could not download Plex metadata for: %s', data)
return
api = API(xml[0])
if api.plex_type == v.PLEX_TYPE_ALBUM:
LOG.debug('Plex music album detected')
PQ.init_playqueue_from_plex_children(
api.plex_id,
transient_token=data.get('token'))
elif data['containerKey'].startswith('/playQueues/'):
_, container_key, _ = PF.ParseContainerKey(data['containerKey'])
xml = PF.DownloadChunks('{server}/playQueues/%s' % container_key)
if xml is None:
# "Play error"
utils.dialog('notification',
utils.lang(29999),
utils.lang(30128),
icon='{error}')
return
playqueue = PQ.get_playqueue_from_type(
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[api.plex_type])
playqueue.clear()
PL.get_playlist_details_from_xml(playqueue, xml)
playqueue.plex_transient_token = data.get('token')
if data.get('offset') != '0':
offset = float(data['offset']) / 1000.0
else:
offset = None
playback.play_xml(playqueue, xml, offset)
else:
app.CONN.plex_transient_token = data.get('token')
playback.playback_triage(api.plex_id,
api.plex_type,
resolve=False,
resume=data.get('offset') not in ('0', None))
@staticmethod
def _process_node(data):
"""
E.g. watch later initiated by Companion. Basically navigating Plex
"""
app.CONN.plex_transient_token = data.get('key')
params = {
'mode': 'plex_node',
'key': f"{{server}}{data.get('key')}",
'offset': data.get('offset')
}
handle = f'RunPlugin(plugin://{utils.extend_url(v.ADDON_ID, params)})'
executebuiltin(handle)
@staticmethod
def _process_playlist(data):
if 'containerKey' not in data:
LOG.error('Received malformed playlist data: %s', data)
return
# Get the playqueue ID
_, container_key, query = PF.ParseContainerKey(data['containerKey'])
try:
playqueue = PQ.get_playqueue_from_type(
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[data['type']])
except KeyError:
# E.g. Plex web does not supply the media type
# Still need to figure out the type (video vs. music vs. pix)
xml = PF.GetPlexMetadata(data['key'])
try:
xml[0].attrib
except (AttributeError, IndexError, TypeError):
LOG.error('Could not download Plex metadata')
return
api = API(xml[0])
playqueue = PQ.get_playqueue_from_type(
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[api.plex_type])
key = data.get('key')
if key:
_, key, _ = PF.ParseContainerKey(key)
update_playqueue_from_PMS(playqueue,
playqueue_id=container_key,
repeat=query.get('repeat'),
offset=utils.cast(int, data.get('offset')),
transient_token=data.get('token'),
start_plex_id=key)
@staticmethod
def _process_streams(data):
"""
Plex Companion client adjusted audio or subtitle stream
"""
if 'type' not in data:
LOG.error('Received malformed stream data: %s', data)
return
playqueue = PQ.get_playqueue_from_type(
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[data['type']])
pos = js.get_position(playqueue.playlistid)
playqueue.items[pos].on_plex_stream_change(data)
@staticmethod
def _process_refresh(data):
"""
example data: {'playQueueID': '8475', 'commandID': '11'}
"""
if 'playQueueID' not in data:
LOG.error('Received malformed refresh data: %s', data)
return
xml = PL.get_pms_playqueue(data['playQueueID'])
if xml is None:
return
if len(xml) == 0:
LOG.debug('Empty playqueue received - clearing playqueue')
plex_type = PL.get_plextype_from_xml(xml)
if plex_type is None:
return
playqueue = PQ.get_playqueue_from_type(
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[plex_type])
playqueue.clear()
return
playqueue = PQ.get_playqueue_from_type(
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[xml[0].attrib['type']])
update_playqueue_from_PMS(playqueue, data['playQueueID'])
def _process_tasks(self, task):
"""
Processes tasks picked up e.g. by Companion listener, e.g.
{'action': 'playlist',
'data': {'address': 'xyz.plex.direct',
'commandID': '7',
'containerKey': '/playQueues/6669?own=1&repeat=0&window=200',
'key': '/library/metadata/220493',
'machineIdentifier': 'xyz',
'offset': '0',
'port': '32400',
'protocol': 'https',
'token': 'transient-cd2527d1-0484-48e0-a5f7-f5caa7d591bd',
'type': 'video'}}
"""
LOG.debug('Processing: %s', task)
data = task['data']
if task['action'] == 'alexa':
with app.APP.lock_playqueues:
self._process_alexa(data)
elif (task['action'] == 'playlist' and
data.get('address') == 'node.plexapp.com'):
self._process_node(data)
elif task['action'] == 'playlist':
with app.APP.lock_playqueues:
self._process_playlist(data)
elif task['action'] == 'refreshPlayQueue':
with app.APP.lock_playqueues:
self._process_refresh(data)
elif task['action'] == 'setStreams':
try:
self._process_streams(data)
except KeyError:
pass
def run(self):
"""
Ensure that sockets will be closed no matter what
"""
app.APP.register_thread(self)
try:
self._run()
finally:
try:
self.httpd.socket.shutdown(SHUT_RDWR)
except AttributeError:
pass
finally:
try:
self.httpd.socket.close()
except AttributeError:
pass
app.APP.deregister_thread(self)
LOG.info("----===## Plex Companion stopped ##===----")
def _run(self):
httpd = self.httpd
# Cache for quicker while loops
client = self.client
# Start up instances
request_mgr = httppersist.RequestMgr()
subscription_manager = subscribers.SubscriptionMgr(request_mgr,
app.APP.player)
self.subscription_manager = subscription_manager
if utils.settings('plexCompanion') == 'true':
# Start up httpd
start_count = 0
while True:
try:
httpd = listener.PKCHTTPServer(
client,
subscription_manager,
('', v.COMPANION_PORT),
listener.MyHandler)
httpd.timeout = 10.0
break
except Exception:
LOG.error("Unable to start PlexCompanion. Traceback:")
import traceback
LOG.error(traceback.print_exc())
app.APP.monitor.waitForAbort(3)
if start_count == 3:
LOG.error("Error: Unable to start web helper.")
httpd = False
break
start_count += 1
else:
LOG.info('User deactivated Plex Companion')
client.start_all()
message_count = 0
if httpd:
thread = Thread(target=httpd.handle_request)
while not self.should_cancel():
# If we are not authorized, sleep
# Otherwise, we trigger a download which leads to a
# re-authorizations
if self.should_suspend():
if self.wait_while_suspended():
break
try:
message_count += 1
if httpd:
if not thread.is_alive():
# Use threads cause the method will stall
thread = Thread(target=httpd.handle_request)
thread.start()
if message_count == 3000:
message_count = 0
if client.check_client_registration():
LOG.debug('Client is still registered')
else:
LOG.debug('Client is no longer registered. Plex '
'Companion still running on port %s',
v.COMPANION_PORT)
client.register_as_client()
# Get and set servers
if message_count % 30 == 0:
subscription_manager.serverlist = client.getServerList()
subscription_manager.notify()
if not httpd:
message_count = 0
except Exception:
LOG.warn("Error in loop, continuing anyway. Traceback:")
import traceback
LOG.warn(traceback.format_exc())
# See if there's anything we need to process
try:
task = app.APP.companion_queue.get(block=False)
except Empty:
pass
else:
# Got instructions, process them
self._process_tasks(task)
app.APP.companion_queue.task_done()
# Don't sleep
continue
self.sleep(0.05)
subscription_manager.signal_stop()
client.stop_all()

View file

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

View file

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

View file

@ -0,0 +1,143 @@
#!/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[0], index[0]
break
elif identical:
log.debug('Playqueue item %s moved to position %s',
index[j], i)
try:
PL.move_playlist_item(playqueue, index[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[i]
break
else:
log.debug('Detected new Kodi element at position %s: %s ',
i, new_item)
try:
if playqueue.id is None:
PL.init_plex_playqueue(playqueue, kodi_item=new_item)
else:
PL.add_item_to_plex_playqueue(playqueue,
i,
kodi_item=new_item)
except exceptions.PlaylistError:
# Could not add the element
pass
except KeyError:
# Catches KeyError from PL.verify_kodi_item()
# Hack: Kodi already started playback of a new item and we
# started playback already using kodimonitors
# PlayBackStart(), but the Kodi playlist STILL only shows
# the old element. Hence ignore playlist difference here
log.debug('Detected an outdated Kodi playlist - ignoring')
return
except IndexError:
# This is really a hack - happens when using Addon Paths
# and repeatedly starting the same element. Kodi will then
# not pass kodi id nor file path AND will also not
# start-up playback. Hence kodimonitor kicks off playback.
# Also see kodimonitor.py - _playlist_onadd()
pass
else:
for j in range(i, len(index)):
index[j] += 1
for i in reversed(index):
if app.APP.stop_pkc:
# Chances are that we got an empty Kodi playlist due to
# Kodi exit
return
log.debug('Detected deletion of playqueue element at pos %s', i)
try:
PL.delete_playlist_item_from_PMS(playqueue, i)
except exceptions.PlaylistError:
log.error('Could not delete PMS element from position %s', i)
log.error('This is likely caused by mixing audio and '
'video tracks in the Kodi playqueue')
log.debug('Done comparing playqueues')

View file

@ -0,0 +1,267 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from logging import getLogger
import requests
from threading import Thread
from .common import communicate, log_error, UUIDStr, Subscriber, timeline, \
stopped_timeline, create_requests_session
from .playqueue import compare_playqueues
from .webserver import ThreadedHTTPServer, CompanionHandlerClassFactory
from .plexgdm import plexgdm
from .. import json_rpc as js
from .. import variables as v
from .. import backgroundthread
from .. import app
from .. import timing
# Disable annoying requests warnings
import requests.packages.urllib3
requests.packages.urllib3.disable_warnings()
log = getLogger('PLEX.companion.playstate')
TIMEOUT = (5, 5)
# How many seconds do we wait until we check again whether we are registered
# as a GDM Plex Companion Client?
GDM_COMPANION_CHECK = 120
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, companion_enabled):
self.companion_enabled = companion_enabled
self.subscribers = dict()
self.s = None
self.httpd = None
self.stopped_timeline = stopped_timeline()
self.gdm = plexgdm()
super().__init__()
def _start_webserver(self):
if self.httpd is None and self.companion_enabled:
log.debug('Starting PKC Companion webserver on port %s', v.COMPANION_PORT)
server_address = ('', v.COMPANION_PORT)
HandlerClass = CompanionHandlerClassFactory(self)
self.httpd = ThreadedHTTPServer(server_address, HandlerClass)
self.httpd.timeout = 10.0
t = Thread(target=self.httpd.serve_forever)
t.start()
def _stop_webserver(self):
if self.httpd is not None:
log.debug('Shutting down PKC Companion webserver')
try:
self.httpd.shutdown()
except AttributeError:
# Ensure thread-safety
pass
self.httpd = None
def _get_requests_session(self):
if self.s is None:
self.s = create_requests_session()
return self.s
def _close_requests_session(self):
if self.s is not None:
try:
self.s.close()
except AttributeError:
# "thread-safety" - Just in case s was set to None in the
# meantime
pass
self.s = None
def close_connections(self):
"""May also be called from another thread"""
self._stop_webserver()
self._close_requests_session()
self.subscribers = dict()
def send_stop(self):
"""
If we're still connected to a PMS, tells the PMS that playback stopped
"""
self.pms_timeline(None, self.stopped_timeline)
self.companion_timeline(self.stopped_timeline)
def check_subscriber(self, cmd):
if not cmd.get('clientIdentifier'):
return
uuid = UUIDStr(cmd.get('clientIdentifier'))
with app.APP.lock_subscriber:
if cmd.get('path') == '/player/timeline/unsubscribe':
if uuid in self.subscribers:
log.debug('Stop Plex Companion subscription for %s', uuid)
del self.subscribers[uuid]
elif uuid not in self.subscribers:
log.debug('Start new Plex Companion subscription for %s', uuid)
self.subscribers[uuid] = Subscriber(self, cmd=cmd)
else:
try:
self.subscribers[uuid].command_id = int(cmd.get('commandID'))
except TypeError:
pass
def subscribe(self, uuid, command_id, url):
log.debug('New Plex Companion subscriber %s: %s', uuid, url)
with app.APP.lock_subscriber:
self.subscribers[UUIDStr(uuid)] = Subscriber(self,
cmd=None,
uuid=uuid,
command_id=command_id,
url=url)
def unsubscribe(self, uuid):
log.debug('Unsubscribing Plex Companion client %s', uuid)
with app.APP.lock_subscriber:
try:
del self.subscribers[UUIDStr(uuid)]
except KeyError:
pass
def update_command_id(self, uuid, command_id):
with app.APP.lock_subscriber:
if uuid not in self.subscribers:
return False
self.subscribers[uuid].command_id = command_id
return True
def companion_timeline(self, message):
state = 'stopped'
for entry in message:
if entry.get('state') != 'stopped':
state = entry.get('state')
for subscriber in self.subscribers.values():
subscriber.send_timeline(message, state)
def pms_timeline_per_player(self, playerid, message):
"""
Sending the "normal", non-Companion playstate to the PMS works a bit
differently
"""
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 = communicate(self.s.get, url, timeout=TIMEOUT)
except requests.RequestException as error:
log.error('Could not send the PMS timeline: %s', error)
return
except 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 wait_while_suspended(self):
should_shutdown = super().wait_while_suspended()
if not should_shutdown:
self._start_webserver()
return should_shutdown
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_connections()
app.APP.deregister_thread(self)
log.info("----===## PlaystateMgr stopped ##===----")
def _run(self):
signaled_playback_stop = True
self._start_webserver()
self.gdm.start()
last_check = timing.unix_timestamp()
while not self.should_cancel():
if self.should_suspend():
self.close_connections()
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)
# Make sure we are registered as a player
now = timing.unix_timestamp()
if now - last_check > GDM_COMPANION_CHECK:
self.gdm.check_client_registration()
last_check = now
# 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
self.send_stop()
signaled_playback_stop = True
self.sleep(1)
continue
else:
# Update the playstate info, such as playback progress
update_player_info(players)
try:
message = timeline(players)
except TypeError:
# We haven't had a chance to set the kodi_stream_index for
# the currently playing item. Just skip for now
self.sleep(1)
continue
else:
# Kodi will started with 'stopped' - make sure we're
# waiting here until we got something playing or on pause.
for entry in message:
if entry.get('state') != 'stopped':
break
else:
continue
signaled_playback_stop = False
# Send the playback progress info to the PMS
self.pms_timeline(players, message)
# Send the info to all Companion devices via the PMS
self.companion_timeline(message)
self.sleep(1)

View file

@ -0,0 +1,210 @@
#!/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
from ..downloadutils import DownloadUtils as DU
from .. import backgroundthread
from .. import utils, app, variables as v
log = logging.getLogger('PLEX.plexgdm')
class plexgdm(backgroundthread.KillableThread):
daemon = True
def __init__(self):
client = (
'Content-Type: plex/media-player\n'
f'Resource-Identifier: {v.PKC_MACHINE_IDENTIFIER}\n'
f'Name: {v.DEVICENAME}\n'
f'Port: {v.COMPANION_PORT}\n'
f'Product: {v.ADDON_NAME}\n'
f'Version: {v.ADDON_VERSION}\n'
'Protocol: plex\n'
'Protocol-Version: 3\n'
'Protocol-Capabilities: timeline,playback,navigation,playqueues\n'
'Device-Class: pc\n'
)
self.hello_msg = f'HELLO * HTTP/1.0\n{client}'.encode()
self.ok_msg = f'HTTP/1.0 200 OK\n{client}'.encode()
self.bye_msg = f'BYE * HTTP/1.0\n{client}'.encode()
self.socket = None
self.port = int(utils.settings('companionUpdatePort'))
self.multicast_address = '239.0.0.250'
self.client_register_group = (self.multicast_address, 32413)
super().__init__()
def on_bind_error(self):
self.socket = None
log.error('Unable to bind to port [%s] - Plex Companion will not '
'be registered. Change the Plex Companion update port!'
% self.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.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)')
def register_as_client(self):
'''
Registers PKC's Plex Companion to the PMS
'''
log.debug('Sending registration data: HELLO')
try:
self.socket.sendto(self.hello_msg, self.client_register_group)
except Exception as exc:
log.error('Unable to send registration message. Error: %s', exc)
def check_client_registration(self):
"""
Checks whetere we are registered as a Plex Companion casting target
(using the old "GDM method") on our PMS. If not, registers
"""
if self.socket is None:
return
log.debug('Checking whether we are still listed as GDM Plex Companion'
'client on our PMS')
xml = DU().downloadUrl('{server}/clients')
try:
xml[0].attrib
except (TypeError, IndexError, AttributeError):
log.error('Could not download GDM Plex Companion clients')
return False
for client in xml:
if (client.attrib.get('machineIdentifier') == v.PKC_MACHINE_IDENTIFIER):
break
else:
log.info('PKC not registered as a GDM Plex Companion client')
self.register_as_client()
def setup_socket(self):
self.socket = socket.socket(socket.AF_INET,
socket.SOCK_DGRAM,
socket.IPPROTO_UDP)
# Set socket reuse, may not work on all OSs.
try:
self.socket.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:
self.socket.bind(('0.0.0.0', self.port))
except Exception:
self.on_bind_error()
return False
self.socket.setsockopt(socket.IPPROTO_IP,
socket.IP_MULTICAST_TTL,
255)
self.socket.setsockopt(socket.IPPROTO_IP,
socket.IP_ADD_MEMBERSHIP,
socket.inet_aton(self.multicast_address) + socket.inet_aton('0.0.0.0'))
self.socket.setblocking(0)
return True
def teardown_socket(self):
'''
When we are finished, then send a final goodbye message to deregister
cleanly.
'''
if self.socket is None:
return
log.debug('Sending goodbye: BYE')
try:
self.socket.sendto(self.bye_msg, self.client_register_group)
except Exception:
log.error('Unable to send client goodbye message')
try:
self.socket.shutdown(socket.SHUT_RDWR)
except OSError:
# The server might already have closed the connection. On Windows,
# this may result in WSAEINVAL (error 10022): An invalid operation
# was attempted.
pass
finally:
self.socket.close()
self.socket = None
def reply(self, addr):
log.debug('Detected client discovery request from %s. Replying', addr)
try:
self.socket.sendto(self.ok_msg, addr)
except Exception as error:
log.error('Unable to send client update message to %s', addr)
log.error('Error encountered: %s: %s', type(error), error)
def wait_while_suspended(self):
should_shutdown = super().wait_while_suspended()
if not should_shutdown and not self.setup_socket():
raise RuntimeError('Could not bind socket to port %s' % self.port)
return should_shutdown
def run(self):
if not utils.settings('plexCompanion') == 'true':
return
log.info('----===## Starting PlexGDM client ##===----')
app.APP.register_thread(self)
try:
self._run()
finally:
self.teardown_socket()
app.APP.deregister_thread(self)
log.info('----===## Stopping PlexGDM client ##===----')
def _run(self):
if not self.setup_socket():
return
# Send initial client registration
self.register_as_client()
# Listen for Plex Companion client discovery reguests and respond
while not self.should_cancel():
if self.should_suspend():
self.teardown_socket()
if self.wait_while_suspended():
break
try:
data, addr = self.socket.recvfrom(1024)
except socket.error:
pass
else:
data = data.decode()
log.debug('Received UDP packet from [%s] containing [%s]'
% (addr, data.strip()))
if 'M-SEARCH * HTTP/1.' in data:
self.reply(addr)
self.sleep(0.5)

View file

@ -0,0 +1,165 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import logging
import requests
from .processing import process_command
from .common import communicate, log_error, create_requests_session, \
b_ok_message
from .. import utils
from .. import backgroundthread
from .. import app
# 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, 4.0)
# Max. timeout for the Polling: 2 ^ MAX_TIMEOUT
# Corresponds to 2 ^ 7 = 128 seconds
MAX_TIMEOUT = 7
log = logging.getLogger('PLEX.companion.polling')
class Polling(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
self._sleep_timer = 0
self.ok_msg = b_ok_message()
super().__init__()
def _get_requests_session(self):
if self.s is None:
log.debug('Creating new requests session')
self.s = create_requests_session()
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 _unauthorized(self):
"""Puts this thread to sleep until e.g. a PMS changes wakes it up"""
log.warn('We are not authorized to poll the PMS (http error 401). '
'Plex Companion will not work.')
self.suspend()
def _on_connection_error(self, req=None, error=None):
if req:
log_error(log.error, 'Error while contacting the PMS', req)
if error:
log.error('Error encountered: %s: %s', type(error), error)
self.sleep(2 ** self._sleep_timer)
if self._sleep_timer < MAX_TIMEOUT:
self._sleep_timer += 1
def ok_message(self, command_id):
url = f'{app.CONN.server}/player/proxy/response?commandID={command_id}'
try:
req = communicate(self.s.post, url, data=self.ok_msg)
except (requests.RequestException, SystemExit) as error:
log.debug('Error replying with an OK message: %s', error)
return
if not req.ok:
log_error(log.error, 'Error replying with OK message', 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 = communicate(self.s.get, url, timeout=TIMEOUT)
except (requests.exceptions.ProxyError,
requests.exceptions.SSLError) as error:
self._on_connection_error(req=None, error=error)
continue
except (requests.ConnectionError,
requests.Timeout,
requests.exceptions.ChunkedEncodingError):
# Expected due to timeout and the PMS not having to reply
# No command received from the PMS - try again immediately
continue
except requests.RequestException as error:
self._on_connection_error(req=None, error=error)
continue
except SystemExit:
# We need to quit PKC entirely
break
# Sanity checks
if req.status_code == 401:
# We can't reach a PMS that is not in the local LAN
# This might even lead to e.g. cloudflare blocking us, thinking
# we're staging a DOS attach
self._unauthorized()
continue
elif not req.ok:
self._on_connection_error(req=req, error=None)
continue
elif not ('content-type' in req.headers
and 'xml' in req.headers['content-type']):
self._on_connection_error(req=req, error=None)
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
# 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('Could not parse the PMS xml:')
self._on_connection_error(req=req, error=None)
continue
# Do the work
log.debug('Received a Plex Companion command from the PMS:')
utils.log_xml(xml, log.debug, logging.DEBUG)
self.playstate_mgr.check_subscriber(cmd)
if process_command(cmd):
self.ok_message(cmd.get('commandID'))
self._sleep_timer = 0

View file

@ -0,0 +1,250 @@
#!/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 convert_xml_to_params(xml):
new_params = dict(xml.attrib)
for key in xml.attrib:
if key.startswith('query'):
new_params[key[5].lower() + key[6:]] = xml.get(key)
del new_params[key]
return new_params
def process_command(cmd=None, path=None, params=None):
"""cmd: a "Command" etree xml"""
path = cmd.get('path') if cmd is not None else path
if not path.startswith('/'):
path = '/' + path
if params is None:
params = convert_xml_to_params(cmd)
if path == '/player/playback/playMedia' and \
params.get('address') == '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(params.get('containerKey'),
params.get('type'),
params.get('key'),
params.get('offset'),
params.get('token'))
elif path == '/player/playback/refreshPlayQueue':
with app.APP.lock_playqueues:
process_refresh(params.get('playQueueID'))
elif path == '/player/playback/setParameters':
js.set_volume(int(params.get('volume')))
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(params.get('offset', 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(params.get('playQueueItemID'), params.get('key'))
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(params.get('type'),
cast(int, params.get('videoStreamID')),
cast(int, params.get('audioStreamID')),
cast(int, params.get('subtitleStreamID')))
elif path == '/player/timeline/subscribe':
pass
elif path == '/player/timeline/unsubscribe':
pass
else:
if cmd is None:
log.error('Unknown request_path: %s with params %s', path, params)
else:
log.error('Unknown Plex companion path/command: %s: %s',
cmd.tag, cmd.attrib)
return False
return True

View file

@ -0,0 +1,201 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Plex Companion listener
"""
from logging import getLogger
from re import sub
from socketserver import ThreadingMixIn
from http.server import HTTPServer, BaseHTTPRequestHandler
import xml.etree.ElementTree as etree
from . import common
from .processing import process_command
from .. import utils, variables as v
from .. import json_rpc as js
from .. import app
log = getLogger('PLEX.companion.webserver')
def CompanionHandlerClassFactory(playstate_mgr):
"""
This class factory makes playstate_mgr available for CompanionHandler
"""
class CompanionHandler(BaseHTTPRequestHandler):
"""
BaseHTTPRequestHandler implementation of Plex Companion listener
"""
protocol_version = 'HTTP/1.1'
def __init__(self, *args, **kwargs):
self.ok_msg = common.b_ok_message()
self.sending_headers = common.proxy_headers()
super().__init__(*args, **kwargs)
def log_message(self, *args, **kwargs):
"""Mute all requests, don't log them."""
pass
def handle_one_request(self):
try:
super().handle_one_request()
except ConnectionError as error:
# Catches e.g. ConnectionResetError: [WinError 10054]
log.debug('Silencing error: %s: %s', type(error), error)
self.close_connection = True
except Exception as error:
# Catch anything in order to not let our web server crash
log.error('Webserver ignored the following exception: %s, %s',
type(error), error)
self.close_connection = True
def do_HEAD(self):
log.debug("Serving HEAD request...")
self.answer_request()
def do_GET(self):
log.debug("Serving GET request...")
self.answer_request()
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, code=200):
self.send_response(code)
for key, value in self.sending_headers.items():
self.send_header(key, value)
self.send_header('Content-Length', len(body) if body else 0)
self.end_headers()
if body:
self.wfile.write(body)
def ok_message(self):
self.response(self.ok_msg, code=200)
def nok_message(self, error_message, code):
log.warn('Sending Not OK message: %s', error_message)
self.response(f'Failure: {error_message}'.encode('utf8'),
code=code)
def poll(self, params):
"""
Case for Plex Web contacting us via Plex Companion
Let's NOT register this Companion client - it will poll us
continuously
"""
if params.get('wait') == '1':
# Plex Web asks us to wait until we start playback
i = 20
while not app.APP.is_playing and i > 0:
if app.APP.monitor.waitForAbort(1):
return
i -= 1
message = common.timeline(js.get_players())
self.response(etree.tostring(message, encoding='utf8'),
code=200)
def send_resources_xml(self):
xml = etree.Element('MediaContainer', attrib={'size': '1'})
etree.SubElement(xml, 'Player', attrib=common.player())
self.response(etree.tostring(xml, encoding='utf8'), code=200)
def check_subscription(self, params):
if self.uuid in playstate_mgr.subscribers:
return True
protocol = params.get('protocol')
port = params.get('port')
if protocol is None or port is None:
log.error('Received invalid params for subscription: %s',
params)
return False
url = f"{protocol}://{self.client_address[0]}:{port}"
playstate_mgr.subscribe(self.uuid, self.command_id, url)
return True
def answer_request(self):
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. headers: %s',
request_path, self.client_address, self.headers.items())
log.debug('params received from remote: %s', params)
conntype = self.headers.get('Connection', '')
if conntype.lower() == 'keep-alive':
self.sending_headers['Connection'] = 'Keep-Alive'
self.sending_headers['Keep-Alive'] = 'timeout=20'
else:
self.sending_headers['Connection'] = 'Close'
self.command_id = int(params.get('commandID', 0))
uuid = self.headers.get('X-Plex-Client-Identifier')
if uuid is None:
log.error('No X-Plex-Client-Identifier received')
self.nok_message('No X-Plex-Client-Identifier received',
code=400)
return
self.uuid = common.UUIDStr(uuid)
# Here we DO NOT track subscribers
if request_path == 'player/timeline/poll':
# This seems to be only done by Plex Web, polling us
# continuously
self.poll(params)
return
elif request_path == 'resources':
self.send_resources_xml()
return
# Here we TRACK subscribers
if request_path == 'player/timeline/subscribe':
if self.check_subscription(params):
self.ok_message()
else:
self.nok_message(f'Received invalid parameters: {params}',
code=400)
return
elif request_path == 'player/timeline/unsubscribe':
playstate_mgr.unsubscribe(self.uuid)
self.ok_message()
else:
if not playstate_mgr.update_command_id(self.uuid,
self.command_id):
self.nok_message(f'Plex Companion Client not yet registered',
code=500)
return
if process_command(cmd=None, path=request_path, params=params):
self.ok_message()
else:
self.nok_message(f'Unknown request path: {request_path}',
code=500)
return CompanionHandler
class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
"""
Using ThreadingMixIn Thread magic
"""
daemon_threads = True

View file

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

View file

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

View file

@ -1,105 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from logging import getLogger
import http.client
import traceback
import string
import errno
from socket import error as socket_error
###############################################################################
LOG = getLogger('PLEX.httppersist')
###############################################################################
class RequestMgr(object):
def __init__(self):
self.conns = {}
def getConnection(self, protocol, host, port):
conn = self.conns.get(protocol + host + str(port), False)
if not conn:
if protocol == "https":
conn = http.client.HTTPSConnection(host, port)
else:
conn = http.client.HTTPConnection(host, port)
self.conns[protocol + host + str(port)] = conn
return conn
def closeConnection(self, protocol, host, port):
conn = self.conns.get(protocol + host + str(port), False)
if conn:
conn.close()
self.conns.pop(protocol + host + str(port), None)
def dumpConnections(self):
for conn in list(self.conns.values()):
conn.close()
self.conns = {}
def post(self, host, port, path, body, header={}, protocol="http"):
conn = None
try:
conn = self.getConnection(protocol, host, port)
header['Connection'] = "keep-alive"
conn.request("POST", path, body, header)
data = conn.getresponse()
if int(data.status) >= 400:
LOG.error("HTTP response error: %s" % str(data.status))
# this should return false, but I'm hacking it since iOS
# returns 404 no matter what
return data.read() or True
else:
return data.read() or True
except socket_error as serr:
# Ignore remote close and connection refused (e.g. shutdown PKC)
if serr.errno in (errno.WSAECONNABORTED, errno.WSAECONNREFUSED):
pass
else:
LOG.error("Unable to connect to %s\nReason:" % host)
LOG.error(traceback.print_exc())
self.conns.pop(protocol + host + str(port), None)
if conn:
conn.close()
return False
except Exception as e:
LOG.error("Exception encountered: %s", e)
# Close connection just in case
try:
conn.close()
except Exception:
pass
return False
def getwithparams(self, host, port, path, params, header={},
protocol="http"):
newpath = path + '?'
pairs = []
for key in params:
pairs.append(str(key) + '=' + str(params[key]))
newpath += string.join(pairs, '&')
return self.get(host, port, newpath, header, protocol)
def get(self, host, port, path, header={}, protocol="http"):
try:
conn = self.getConnection(protocol, host, port)
header['Connection'] = "keep-alive"
conn.request("GET", path, headers=header)
data = conn.getresponse()
if int(data.status) >= 400:
LOG.error("HTTP response error: %s", str(data.status))
return False
else:
return data.read() or True
except socket_error as serr:
# Ignore remote close and connection refused (e.g. shutdown PKC)
if serr.errno in (errno.WSAECONNABORTED, errno.WSAECONNREFUSED):
pass
else:
LOG.error("Unable to connect to %s\nReason:", host)
LOG.error(traceback.print_exc())
self.conns.pop(protocol + host + str(port), None)
conn.close()
return False

View file

@ -1,238 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Plex Companion listener
"""
from logging import getLogger
from re import sub
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
from .. import utils, companion, json_rpc as js, clientinfo, variables as v
from .. import app
###############################################################################
LOG = getLogger('PLEX.listener')
# Hack we need in order to keep track of the open connections from Plex Web
CLIENT_DICT = {}
###############################################################################
RESOURCES_XML = ('%s<MediaContainer>\n'
' <Player'
' title="{title}"'
' protocol="plex"'
' protocolVersion="1"'
' protocolCapabilities="timeline,playback,navigation,playqueues"'
' machineIdentifier="{machineIdentifier}"'
' product="%s"'
' platform="%s"'
' platformVersion="%s"'
' deviceClass="pc"/>\n'
'</MediaContainer>\n') % (v.XML_HEADER,
v.ADDON_NAME,
v.PLATFORM,
v.PLATFORM_VERSION)
class MyHandler(BaseHTTPRequestHandler):
"""
BaseHTTPRequestHandler implementation of Plex Companion listener
"""
protocol_version = 'HTTP/1.1'
def __init__(self, *args, **kwargs):
self.serverlist = []
super().__init__(*args, **kwargs)
def log_message(self, format, *args):
'''
Mute all requests, don't log 'em
'''
pass
def do_HEAD(self):
LOG.debug("Serving HEAD request...")
self.answer_request(0)
def do_GET(self):
LOG.debug("Serving GET request...")
self.answer_request(1)
def do_OPTIONS(self):
LOG.debug("Serving OPTIONS request...")
self.send_response(200)
self.send_header('Content-Length', '0')
self.send_header('X-Plex-Client-Identifier', v.PKC_MACHINE_IDENTIFIER)
self.send_header('Content-Type', 'text/plain')
self.send_header('Connection', 'close')
self.send_header('Access-Control-Max-Age', '1209600')
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Methods',
'POST, GET, OPTIONS, DELETE, PUT, HEAD')
self.send_header(
'Access-Control-Allow-Headers',
'x-plex-version, x-plex-platform-version, x-plex-username, '
'x-plex-client-identifier, x-plex-target-client-identifier, '
'x-plex-device-name, x-plex-platform, x-plex-product, accept, '
'x-plex-device, x-plex-device-screen-resolution')
self.end_headers()
def response(self, body, headers=None, code=200):
headers = {} if headers is None else headers
self.send_response(code)
for key in headers:
self.send_header(key, headers[key])
self.send_header('Content-Length', len(body))
self.end_headers()
if body:
self.wfile.write(body.encode('utf-8'))
def answer_request(self, send_data):
self.serverlist = self.server.client.getServerList()
sub_mgr = self.server.subscription_manager
request_path = self.path[1:]
request_path = sub(r"\?.*", "", request_path)
parseresult = utils.urlparse(self.path)
paramarrays = utils.parse_qs(parseresult.query)
params = {}
for key in paramarrays:
params[key] = paramarrays[key][0]
LOG.debug("remote request_path: %s, received from %s with headers: %s",
request_path, self.client_address, self.headers.items())
LOG.debug("params received from remote: %s", params)
sub_mgr.update_command_id(self.headers.get(
'X-Plex-Client-Identifier', self.client_address[0]),
params.get('commandID'))
conntype = self.headers.get('Connection', '')
if conntype.lower() == 'keep-alive':
headers = {
'Connection': 'Keep-Alive',
'Keep-Alive': 'timeout=20'
}
else:
headers = {'Connection': 'Close'}
if request_path == "version":
self.response(
"PlexKodiConnect Plex Companion: Running\nVersion: %s"
% v.ADDON_VERSION,
headers)
elif request_path == "verify":
self.response("XBMC JSON connection test:\n" + js.ping(),
headers)
elif request_path == 'resources':
self.response(
RESOURCES_XML.format(
title=v.DEVICENAME,
machineIdentifier=v.PKC_MACHINE_IDENTIFIER),
clientinfo.getXArgsDeviceInfo(options=headers,
include_token=False))
elif request_path == 'player/timeline/poll':
# Plex web does polling if connected to PKC via Companion
# Only reply if there is indeed something playing
# Otherwise, all clients seem to keep connection open
if params.get('wait') == '1':
app.APP.monitor.waitForAbort(0.95)
if self.client_address[0] not in CLIENT_DICT:
CLIENT_DICT[self.client_address[0]] = []
tracker = CLIENT_DICT[self.client_address[0]]
tracker.append(self.client_address[1])
while (not app.APP.is_playing and
not app.APP.monitor.abortRequested() and
sub_mgr.stop_sent_to_web and not
(len(tracker) >= 4 and
tracker[0] == self.client_address[1])):
# Keep at most 3 connections open, then drop the first one
# Doesn't need to be thread-save
# Silly stuff really
app.APP.monitor.waitForAbort(1)
# Let PKC know that we're releasing this connection
tracker.pop(0)
msg = sub_mgr.msg(js.get_players()).format(
command_id=params.get('commandID', 0))
if sub_mgr.isplaying:
self.response(
msg,
{
'X-Plex-Client-Identifier': v.PKC_MACHINE_IDENTIFIER,
'X-Plex-Protocol': '1.0',
'Access-Control-Allow-Origin': '*',
'Access-Control-Max-Age': '1209600',
'Access-Control-Expose-Headers':
'X-Plex-Client-Identifier',
'Content-Type': 'text/xml;charset=utf-8'
}.update(headers))
elif not sub_mgr.stop_sent_to_web:
sub_mgr.stop_sent_to_web = True
LOG.debug('Signaling STOP to Plex Web')
self.response(
msg,
{
'X-Plex-Client-Identifier': v.PKC_MACHINE_IDENTIFIER,
'X-Plex-Protocol': '1.0',
'Access-Control-Allow-Origin': '*',
'Access-Control-Max-Age': '1209600',
'Access-Control-Expose-Headers':
'X-Plex-Client-Identifier',
'Content-Type': 'text/xml;charset=utf-8'
}.update(headers))
else:
# We're not playing anything yet, just reply with a 200
self.response(
msg,
{
'X-Plex-Client-Identifier': v.PKC_MACHINE_IDENTIFIER,
'X-Plex-Protocol': '1.0',
'Access-Control-Allow-Origin': '*',
'Access-Control-Max-Age': '1209600',
'Access-Control-Expose-Headers':
'X-Plex-Client-Identifier',
'Content-Type': 'text/xml;charset=utf-8'
}.update(headers))
elif "/subscribe" in request_path:
headers['Content-Type'] = 'text/xml;charset=utf-8'
headers = clientinfo.getXArgsDeviceInfo(options=headers,
include_token=False)
self.response(v.COMPANION_OK_MESSAGE, headers)
protocol = params.get('protocol')
host = self.client_address[0]
port = params.get('port')
uuid = self.headers.get('X-Plex-Client-Identifier')
command_id = params.get('commandID', 0)
sub_mgr.add_subscriber(protocol,
host,
port,
uuid,
command_id)
elif "/unsubscribe" in request_path:
headers['Content-Type'] = 'text/xml;charset=utf-8'
headers = clientinfo.getXArgsDeviceInfo(options=headers,
include_token=False)
self.response(v.COMPANION_OK_MESSAGE, headers)
uuid = self.headers.get('X-Plex-Client-Identifier') \
or self.client_address[0]
sub_mgr.remove_subscriber(uuid)
else:
# Throw it to companion.py
companion.process_command(request_path, params)
headers['Content-Type'] = 'text/xml;charset=utf-8'
headers = clientinfo.getXArgsDeviceInfo(options=headers,
include_token=False)
self.response(v.COMPANION_OK_MESSAGE, headers)
class PKCHTTPServer(ThreadingHTTPServer):
def __init__(self, client, subscription_manager, *args, **kwargs):
"""
client: Class handle to plexgdm.plexgdm. We can thus ask for an up-to-
date serverlist without instantiating anything
same for SubscriptionMgr
"""
self.client = client
self.subscription_manager = subscription_manager
super().__init__(*args, **kwargs)

View file

@ -1,314 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
PlexGDM.py - Version 0.2
This class implements the Plex GDM (G'Day Mate) protocol to discover
local Plex Media Servers. Also allow client registration into all local
media servers.
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
MA 02110-1301, USA.
"""
import logging
import socket
import threading
import time
from ..downloadutils import DownloadUtils as DU
from .. import utils, app, variables as v
###############################################################################
log = logging.getLogger('PLEX.plexgdm')
###############################################################################
class plexgdm(object):
def __init__(self):
self.discover_message = 'M-SEARCH * HTTP/1.0'
self.client_header = '* HTTP/1.0'
self.client_data = None
self.update_sock = None
self.discover_t = None
self.register_t = None
self._multicast_address = '239.0.0.250'
self.discover_group = (self._multicast_address, 32414)
self.client_register_group = (self._multicast_address, 32413)
self.client_update_port = int(utils.settings('companionUpdatePort'))
self.server_list = []
self.discovery_interval = 120
self._discovery_is_running = False
self._registration_is_running = False
self.client_registered = False
self.download = DU().downloadUrl
def clientDetails(self):
self.client_data = (
"Content-Type: plex/media-player\n"
"Resource-Identifier: %s\n"
"Name: %s\n"
"Port: %s\n"
"Product: %s\n"
"Version: %s\n"
"Protocol: plex\n"
"Protocol-Version: 1\n"
"Protocol-Capabilities: timeline,playback,navigation,"
"playqueues\n"
"Device-Class: HTPC\n"
) % (
v.PKC_MACHINE_IDENTIFIER,
v.DEVICENAME,
v.COMPANION_PORT,
v.ADDON_NAME,
v.ADDON_VERSION
)
def getClientDetails(self):
return self.client_data
def register_as_client(self):
"""
Registers PKC's Plex Companion to the PMS
"""
try:
log.debug("Sending registration data: HELLO %s\n%s"
% (self.client_header, self.client_data))
msg = 'HELLO {}\n{}'.format(self.client_header, self.client_data)
self.update_sock.sendto(msg.encode('utf-8'),
self.client_register_group)
log.debug('(Re-)registering PKC Plex Companion successful')
except Exception as exc:
log.error("Unable to send registration message. Error: %s", exc)
def client_update(self):
self.update_sock = socket.socket(socket.AF_INET,
socket.SOCK_DGRAM,
socket.IPPROTO_UDP)
update_sock = self.update_sock
# Set socket reuse, may not work on all OSs.
try:
update_sock.setsockopt(socket.SOL_SOCKET,
socket.SO_REUSEADDR,
1)
except Exception:
pass
# Attempt to bind to the socket to recieve and send data. If we cant
# do this, then we cannot send registration
try:
update_sock.bind(('0.0.0.0', self.client_update_port))
except Exception:
log.error("Unable to bind to port [%s] - Plex Companion will not "
"be registered. Change the Plex Companion update port!"
% self.client_update_port)
if utils.settings('companion_show_gdm_port_warning') == 'true':
from ..windows import optionsdialog
# Plex Companion could not open the GDM port. Please change it
# in the PKC settings.
if optionsdialog.show(utils.lang(29999),
'Port %s\n%s' % (self.client_update_port,
utils.lang(39079)),
utils.lang(30013), # Never show again
utils.lang(186)) == 0:
utils.settings('companion_show_gdm_port_warning',
value='false')
from xbmc import executebuiltin
executebuiltin(
'Addon.OpenSettings(plugin.video.plexkodiconnect)')
return
update_sock.setsockopt(socket.IPPROTO_IP,
socket.IP_MULTICAST_TTL,
255)
update_sock.setsockopt(socket.IPPROTO_IP,
socket.IP_ADD_MEMBERSHIP,
socket.inet_aton(
self._multicast_address) +
socket.inet_aton('0.0.0.0'))
update_sock.setblocking(0)
# Send initial client registration
self.register_as_client()
# Now, listen format client discovery reguests and respond.
while self._registration_is_running:
try:
data, addr = update_sock.recvfrom(1024)
data = data.decode()
log.debug("Recieved UDP packet from [%s] containing [%s]"
% (addr, data.strip()))
except socket.error:
pass
else:
if "M-SEARCH * HTTP/1." in data:
log.debug('Detected client discovery request from %s. '
'Replying', addr)
message = f'HTTP/1.0 200 OK\n{self.client_data}'.encode()
try:
update_sock.sendto(message, addr)
except Exception:
log.error("Unable to send client update message")
else:
log.debug("Sent registration data HTTP/1.0 200 OK")
self.client_registered = True
app.APP.monitor.waitForAbort(0.5)
log.info("Client Update loop stopped")
# When we are finished, then send a final goodbye message to
# deregister cleanly.
log.debug("Sending registration data: BYE %s\n%s"
% (self.client_header, self.client_data))
try:
update_sock.sendto("BYE %s\n%s"
% (self.client_header, self.client_data),
self.client_register_group)
except Exception:
log.error("Unable to send client update message")
self.client_registered = False
def check_client_registration(self):
if not self.client_registered:
log.debug('Client has not been marked as registered')
return False
if not self.server_list:
log.info("Server list is empty. Unable to check")
return False
for server in self.server_list:
if server['uuid'] == app.CONN.machine_identifier:
media_server = server['server']
media_port = server['port']
scheme = server['protocol']
break
else:
log.info("Did not find our server!")
return False
log.debug("Checking server [%s] on port [%s]"
% (media_server, media_port))
xml = self.download(
'%s://%s:%s/clients' % (scheme, media_server, media_port))
try:
xml[0].attrib
except (TypeError, IndexError, AttributeError):
log.error('Could not download clients for %s' % media_server)
return False
registered = False
for client in xml:
if (client.attrib.get('machineIdentifier') ==
v.PKC_MACHINE_IDENTIFIER):
registered = True
if registered:
return True
else:
log.info("Client registration not found. "
"Client data is: %s" % xml)
return False
def getServerList(self):
return self.server_list
def discover(self):
currServer = app.CONN.server
if not currServer:
return
currServerProt, currServerIP, currServerPort = \
currServer.split(':')
currServerIP = currServerIP.replace('/', '')
# Currently active server was not discovered via GDM; ADD
self.server_list = [{
'port': currServerPort,
'protocol': currServerProt,
'class': None,
'content-type': 'plex/media-server',
'discovery': 'auto',
'master': 1,
'owned': '1',
'role': 'master',
'server': currServerIP,
'serverName': app.CONN.server_name,
'updated': int(time.time()),
'uuid': app.CONN.machine_identifier,
'version': 'irrelevant'
}]
def setInterval(self, interval):
self.discovery_interval = interval
def stop_all(self):
self.stop_discovery()
self.stop_registration()
def stop_discovery(self):
if self._discovery_is_running:
log.info("Discovery shutting down")
self._discovery_is_running = False
self.discover_t.join()
del self.discover_t
else:
log.info("Discovery not running")
def stop_registration(self):
if self._registration_is_running:
log.info("Registration shutting down")
self._registration_is_running = False
self.register_t.join()
del self.register_t
else:
log.info("Registration not running")
def run_discovery_loop(self):
# Run initial discovery
self.discover()
discovery_count = 0
while self._discovery_is_running:
discovery_count += 1
if discovery_count > self.discovery_interval:
self.discover()
discovery_count = 0
app.APP.monitor.waitForAbort(0.5)
def start_discovery(self, daemon=False):
if not self._discovery_is_running:
log.info("Discovery starting up")
self._discovery_is_running = True
self.discover_t = threading.Thread(target=self.run_discovery_loop)
self.discover_t.setDaemon(daemon)
self.discover_t.start()
else:
log.info("Discovery already running")
def start_registration(self, daemon=False):
if not self._registration_is_running:
log.info("Registration starting up")
self._registration_is_running = True
self.register_t = threading.Thread(target=self.client_update)
self.register_t.setDaemon(daemon)
self.register_t.start()
else:
log.info("Registration already running")
def start_all(self, daemon=False):
self.start_discovery(daemon)
if utils.settings('plexCompanion') == 'true':
self.start_registration(daemon)

View file

@ -1,470 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Manages getting playstate from Kodi and sending it to the PMS as well as
subscribed Plex Companion clients.
"""
from logging import getLogger
from threading import Thread
from ..downloadutils import DownloadUtils as DU
from .. import timing
from .. import app
from .. import variables as v
from .. import json_rpc as js
from .. import playqueue as PQ
###############################################################################
LOG = getLogger('PLEX.subscribers')
###############################################################################
# What is Companion controllable?
CONTROLLABLE = {
v.PLEX_PLAYLIST_TYPE_VIDEO: 'playPause,stop,volume,shuffle,audioStream,'
'subtitleStream,seekTo,skipPrevious,skipNext,'
'stepBack,stepForward',
v.PLEX_PLAYLIST_TYPE_AUDIO: 'playPause,stop,volume,shuffle,repeat,seekTo,'
'skipPrevious,skipNext,stepBack,stepForward',
v.PLEX_PLAYLIST_TYPE_PHOTO: 'playPause,stop,skipPrevious,skipNext'
}
STREAM_DETAILS = {
'video': 'currentvideostream',
'audio': 'currentaudiostream',
'subtitle': 'currentsubtitle'
}
XML = ('%s<MediaContainer commandID="{command_id}" location="{location}">\n'
' <Timeline {%s}/>\n'
' <Timeline {%s}/>\n'
' <Timeline {%s}/>\n'
'</MediaContainer>\n') % (v.XML_HEADER,
v.PLEX_PLAYLIST_TYPE_VIDEO,
v.PLEX_PLAYLIST_TYPE_AUDIO,
v.PLEX_PLAYLIST_TYPE_PHOTO)
# Headers are different for Plex Companion - use these for PMS notifications
HEADERS_PMS = {
'Connection': 'keep-alive',
'Accept': 'text/plain, */*; q=0.01',
'Accept-Language': 'en',
'Accept-Encoding': 'gzip, deflate',
'User-Agent': '%s %s (%s)' % (v.ADDON_NAME, v.ADDON_VERSION, v.DEVICE)
}
def params_pms():
"""
Returns the url parameters for communicating with the PMS
"""
return {
'X-Plex-Client-Identifier': v.PKC_MACHINE_IDENTIFIER,
'X-Plex-Device': v.DEVICE,
'X-Plex-Device-Name': v.DEVICENAME,
'X-Plex-Model': v.MODEL,
'X-Plex-Platform': v.PLATFORM,
'X-Plex-Platform-Version': v.PLATFORM_VERSION,
'X-Plex-Product': v.ADDON_NAME,
'X-Plex-Version': v.ADDON_VERSION,
}
def headers_companion_client():
"""
Headers are different for Plex Companion - use these for a Plex Companion
client
"""
return {
'Content-Type': 'application/xml',
'Connection': 'Keep-Alive',
'X-Plex-Client-Identifier': v.PKC_MACHINE_IDENTIFIER,
'X-Plex-Device-Name': v.DEVICENAME,
'X-Plex-Platform': v.PLATFORM,
'X-Plex-Platform-Version': v.PLATFORM_VERSION,
'X-Plex-Product': v.ADDON_NAME,
'X-Plex-Version': v.ADDON_VERSION,
'Accept-Encoding': 'gzip, deflate',
'Accept-Language': 'en,*'
}
def update_player_info(playerid):
"""
Updates all player info for playerid [int] in state.py.
"""
app.PLAYSTATE.player_states[playerid].update(js.get_player_props(playerid))
app.PLAYSTATE.player_states[playerid]['volume'] = js.get_volume()
app.PLAYSTATE.player_states[playerid]['muted'] = js.get_muted()
class SubscriptionMgr(object):
"""
Manages Plex companion subscriptions
"""
def __init__(self, request_mgr, player):
self.serverlist = []
self.subscribers = {}
self.info = {}
self.server = ""
self.protocol = "http"
self.port = ""
self.isplaying = False
self.location = 'navigation'
# In order to be able to signal a stop at the end
self.last_params = {}
self.lastplayers = {}
# In order to signal a stop to Plex Web ONCE on playback stop
self.stop_sent_to_web = True
self.request_mgr = request_mgr
def _server_by_host(self, host):
if len(self.serverlist) == 1:
return self.serverlist[0]
for server in self.serverlist:
if (server.get('serverName') in host or
server.get('server') in host):
return server
return {}
@staticmethod
def _get_correct_position(info, playqueue):
"""
Kodi tells us the PLAYLIST position, not PLAYQUEUE position, if the
user initiated playback of a playlist
"""
if playqueue.kodi_playlist_playback:
position = 0
else:
position = info['position'] or 0
return position
def msg(self, players):
"""
Returns a timeline xml as str
(xml containing video, audio, photo player state)
"""
self.isplaying = False
self.location = 'navigation'
answ = str(XML)
timelines = {
v.PLEX_PLAYLIST_TYPE_VIDEO: None,
v.PLEX_PLAYLIST_TYPE_AUDIO: None,
v.PLEX_PLAYLIST_TYPE_PHOTO: None
}
for typus in timelines:
if players.get(v.KODI_PLAYLIST_TYPE_FROM_PLEX_PLAYLIST_TYPE[typus]) is None:
timeline = {
'controllable': CONTROLLABLE[typus],
'type': typus,
'state': 'stopped'
}
else:
timeline = self._timeline_dict(players[
v.KODI_PLAYLIST_TYPE_FROM_PLEX_PLAYLIST_TYPE[typus]],
typus)
timelines[typus] = self._dict_to_xml(timeline)
timelines.update({'command_id': '{command_id}',
'location': self.location})
return answ.format(**timelines)
@staticmethod
def _dict_to_xml(dictionary):
"""
Returns the string 'key1="value1" key2="value2" ...' for dictionary
"""
answ = ''
for key, value in dictionary.items():
answ += '%s="%s" ' % (key, value)
return answ
def _timeline_dict(self, player, ptype):
with app.APP.lock_playqueues:
playerid = player['playerid']
info = app.PLAYSTATE.player_states[playerid]
playqueue = PQ.PLAYQUEUES[playerid]
position = self._get_correct_position(info, playqueue)
try:
item = playqueue.items[position]
except IndexError:
# E.g. for direct path playback for single item
return {
'controllable': CONTROLLABLE[ptype],
'type': ptype,
'state': 'stopped'
}
self.isplaying = True
self.stop_sent_to_web = False
if ptype in (v.PLEX_PLAYLIST_TYPE_VIDEO,
v.PLEX_PLAYLIST_TYPE_PHOTO):
self.location = 'fullScreenVideo'
pbmc_server = app.CONN.server
if pbmc_server:
(self.protocol, self.server, self.port) = pbmc_server.split(':')
self.server = self.server.replace('/', '')
status = 'paused' if int(info['speed']) == 0 else 'playing'
duration = timing.kodi_time_to_millis(info['totaltime'])
shuffle = '1' if info['shuffled'] else '0'
mute = '1' if info['muted'] is True else '0'
answ = {
'controllable': CONTROLLABLE[ptype],
'protocol': self.protocol,
'address': self.server,
'port': self.port,
'machineIdentifier': app.CONN.machine_identifier,
'state': status,
'type': ptype,
'itemType': ptype,
'time': timing.kodi_time_to_millis(info['time']),
'duration': duration,
'seekRange': '0-%s' % duration,
'shuffle': shuffle,
'repeat': v.PLEX_REPEAT_FROM_KODI_REPEAT[info['repeat']],
'volume': info['volume'],
'mute': mute,
'mediaIndex': 0, # Still to implement from here
'partIndex': 0,
'partCount': 1,
'providerIdentifier': 'com.plexapp.plugins.library',
}
# Get the plex id from the PKC playqueue not info, as Kodi jumps to
# next playqueue element way BEFORE kodi monitor onplayback is
# called
if item.plex_id:
answ['key'] = '/library/metadata/%s' % item.plex_id
answ['ratingKey'] = item.plex_id
# PlayQueue stuff
if info['container_key']:
answ['containerKey'] = info['container_key']
if (info['container_key'] is not None and
info['container_key'].startswith('/playQueues')):
answ['playQueueID'] = playqueue.id
answ['playQueueVersion'] = playqueue.version
answ['playQueueItemID'] = item.id
if playqueue.items[position].guid:
answ['guid'] = item.guid
# Temp. token set?
if app.CONN.plex_transient_token:
answ['token'] = app.CONN.plex_transient_token
elif playqueue.plex_transient_token:
answ['token'] = playqueue.plex_transient_token
# Process audio and subtitle streams
if ptype == v.PLEX_PLAYLIST_TYPE_VIDEO:
answ['videoStreamID'] = str(item.current_plex_video_stream)
answ['audioStreamID'] = str(item.current_plex_audio_stream)
# Mind the zero - meaning subs are deactivated
answ['subtitleStreamID'] = str(item.current_plex_sub_stream or 0)
return answ
def signal_stop(self):
"""
Externally called on PKC shutdown to ensure that PKC signals a stop to
the PMS. Otherwise, PKC might be stuck at "currently playing"
"""
LOG.info('Signaling a complete stop to PMS')
# To avoid RuntimeError, don't use self.lastplayers
for playerid in (0, 1, 2):
self.last_params['state'] = 'stopped'
self._send_pms_notification(playerid,
self.last_params,
timeout=0.0001)
def update_command_id(self, uuid, command_id):
"""
Updates the Plex Companien client with the machine identifier uuid with
command_id
"""
with app.APP.lock_subscriber:
if command_id and self.subscribers.get(uuid):
self.subscribers[uuid].command_id = int(command_id)
def _playqueue_init_done(self, players):
"""
update_player_info() can result in values BEFORE kodi monitor is called.
Hence we'd have a missmatch between the state.PLAYER_STATES and our
playqueues.
"""
for player in list(players.values()):
info = app.PLAYSTATE.player_states[player['playerid']]
playqueue = PQ.PLAYQUEUES[player['playerid']]
position = self._get_correct_position(info, playqueue)
try:
item = playqueue.items[position]
except IndexError:
# E.g. for direct path playback for single item
return False
if item.plex_id != info['plex_id']:
# Kodi playqueue already progressed; need to wait until
# everything is loaded
return False
return True
def notify(self):
"""
Causes PKC to tell the PMS and Plex Companion players to receive a
notification what's being played.
"""
with app.APP.lock_subscriber:
self._cleanup()
# Get all the active/playing Kodi players (video, audio, pictures)
players = js.get_players()
# Update the PKC info with what's playing on the Kodi side
for player in list(players.values()):
update_player_info(player['playerid'])
# Check whether we can use the CURRENT info or whether PKC is still
# initializing
if self._playqueue_init_done(players) is False:
LOG.debug('PKC playqueue is still initializing - skip update')
return
self._notify_server(players)
if self.subscribers:
msg = self.msg(players)
for subscriber in list(self.subscribers.values()):
subscriber.send_update(msg)
self.lastplayers = players
def _notify_server(self, players):
for typus, player in players.items():
self._send_pms_notification(
player['playerid'], self._get_pms_params(player['playerid']))
try:
del self.lastplayers[typus]
except KeyError:
pass
# Process the players we have left (to signal a stop)
for player in list(self.lastplayers.values()):
self.last_params['state'] = 'stopped'
self._send_pms_notification(player['playerid'], self.last_params)
def _get_pms_params(self, playerid):
info = app.PLAYSTATE.player_states[playerid]
playqueue = PQ.PLAYQUEUES[playerid]
position = self._get_correct_position(info, playqueue)
try:
item = playqueue.items[position]
except IndexError:
return self.last_params
status = 'paused' if int(info['speed']) == 0 else 'playing'
params = {
'state': status,
'ratingKey': item.plex_id,
'key': '/library/metadata/%s' % item.plex_id,
'time': timing.kodi_time_to_millis(info['time']),
'duration': timing.kodi_time_to_millis(info['totaltime'])
}
if info['container_key'] is not None:
# params['containerKey'] = info['container_key']
if info['container_key'].startswith('/playQueues/'):
# params['playQueueVersion'] = playqueue.version
# params['playQueueID'] = playqueue.id
params['playQueueItemID'] = item.id
self.last_params = params
return params
def _send_pms_notification(self, playerid, params, timeout=None):
"""
Pass a really low timeout in seconds if shutting down Kodi and we don't
need the PMS' response
"""
serv = self._server_by_host(self.server)
playqueue = PQ.PLAYQUEUES[playerid]
xargs = params_pms()
xargs.update(params)
if app.CONN.plex_transient_token:
xargs['X-Plex-Token'] = app.CONN.plex_transient_token
elif playqueue.plex_transient_token:
xargs['X-Plex-Token'] = playqueue.plex_transient_token
elif app.ACCOUNT.pms_token:
xargs['X-Plex-Token'] = app.ACCOUNT.pms_token
url = '%s://%s:%s/:/timeline' % (serv.get('protocol', 'http'),
serv.get('server', 'localhost'),
serv.get('port', '32400'))
DU().downloadUrl(url,
authenticate=False,
parameters=xargs,
headerOverride=HEADERS_PMS,
timeout=timeout)
LOG.debug("Sent server notification with parameters: %s to %s",
xargs, url)
def add_subscriber(self, protocol, host, port, uuid, command_id):
"""
Adds a new Plex Companion subscriber to PKC.
"""
subscriber = Subscriber(protocol,
host,
port,
uuid,
command_id,
self,
self.request_mgr)
with app.APP.lock_subscriber:
self.subscribers[subscriber.uuid] = subscriber
return subscriber
def remove_subscriber(self, uuid):
"""
Removes a connected Plex Companion subscriber with machine identifier
uuid from PKC notifications.
(Calls the cleanup() method of the subscriber)
"""
with app.APP.lock_subscriber:
for subscriber in list(self.subscribers.values()):
if subscriber.uuid == uuid or subscriber.host == uuid:
subscriber.cleanup()
del self.subscribers[subscriber.uuid]
def _cleanup(self):
for subscriber in list(self.subscribers.values()):
if subscriber.age > 30:
subscriber.cleanup()
del self.subscribers[subscriber.uuid]
class Subscriber(object):
"""
Plex Companion subscribing device
"""
def __init__(self, protocol, host, port, uuid, command_id, sub_mgr,
request_mgr):
self.protocol = protocol or "http"
self.host = host
self.port = port or 32400
self.uuid = uuid or host
self.command_id = int(command_id) or 0
self.age = 0
self.sub_mgr = sub_mgr
self.request_mgr = request_mgr
def __eq__(self, other):
return self.uuid == other.uuid
def cleanup(self):
"""
Closes the connection to the Plex Companion client
"""
self.request_mgr.closeConnection(self.protocol, self.host, self.port)
def send_update(self, msg):
"""
Sends msg to the Plex Companion client (via .../:/timeline)
"""
self.age += 1
msg = msg.format(command_id=self.command_id)
LOG.debug("sending xml to subscriber uuid=%s,commandID=%i:\n%s",
self.uuid, self.command_id, msg)
url = '%s://%s:%s/:/timeline' % (self.protocol, self.host, self.port)
thread = Thread(target=self._threaded_send, args=(url, msg))
thread.start()
def _threaded_send(self, url, msg):
"""
Threaded POST request, because they stall due to response missing
the Content-Length header :-(
"""
response = DU().downloadUrl(url,
action_type="POST",
postBody=msg,
authenticate=False,
headerOverride=headers_companion_client())
if response in (False, None, 401):
self.sub_mgr.remove_subscriber(self.uuid)

View file

@ -2,6 +2,7 @@
# -*- coding: utf-8 -*-
import logging
import sys
import xbmc
import xbmcvfs
@ -11,9 +12,8 @@ from . import kodimonitor
from . import sync, library_sync
from . import websocket_client
from . import plex_companion
from . import plex_functions as PF, playqueue as PQ
from . import plex_functions as PF
from . import playback_starter
from . import playqueue
from . import variables as v
from . import app
from . import loghandler
@ -34,7 +34,6 @@ WINDOW_PROPERTIES = (
class Service(object):
ws = None
sync = None
plexcompanion = None
def __init__(self):
self._init_done = False
@ -100,7 +99,6 @@ class Service(object):
self.setup = None
self.pms_ws = None
self.alexa_ws = None
self.playqueue = None
# Flags for other threads
self.connection_check_running = False
self.auth_running = False
@ -437,8 +435,6 @@ class Service(object):
app.init()
app.APP.monitor = kodimonitor.KodiMonitor()
app.APP.player = xbmc.Player()
# Initialize the PKC playqueues
PQ.init_playqueues()
# Server auto-detect
self.setup = initialsetup.InitialSetup()
@ -448,8 +444,12 @@ class Service(object):
self.pms_ws = websocket_client.get_pms_websocketapp()
self.alexa_ws = websocket_client.get_alexa_websocketapp()
self.sync = sync.Sync()
self.plexcompanion = plex_companion.PlexCompanion()
self.playqueue = playqueue.PlayqueueMonitor()
self.companion_playstate_mgr = plex_companion.PlaystateMgr(
companion_enabled=utils.settings('plexCompanion') == 'true')
if utils.settings('plexCompanion') == 'true':
self.companion_polling = plex_companion.Polling(self.companion_playstate_mgr)
else:
self.companion_polling = None
# Main PKC program loop
while not self.should_cancel():
@ -498,6 +498,9 @@ class Service(object):
elif plex_command == 'EXIT-PKC':
LOG.info('Received command from another instance to quit')
app.APP.stop_pkc = True
elif plex_command == 'generate_new_uuid':
LOG.info('Generating new UUID for PKC')
clientinfo.getDeviceId(reset=True)
else:
raise RuntimeError('Unknown command: %s', plex_command)
if task:
@ -548,8 +551,9 @@ class Service(object):
self.startup_completed = True
self.pms_ws.start()
self.sync.start()
self.plexcompanion.start()
self.playqueue.start()
self.companion_playstate_mgr.start()
if self.companion_polling is not None:
self.companion_polling.start()
self.alexa_ws.start()
elif app.APP.is_playing:

View file

@ -1,7 +1,7 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from .windows.skip_intro import SkipIntroDialog
from . import app, variables as v
from . import app, utils, variables as v
def skip_intro(intros):
@ -15,11 +15,17 @@ def skip_intro(intros):
if start <= progress < end:
in_intro = True
if in_intro and app.APP.skip_intro_dialog is None:
# WARNING: This Dialog only seems to work if called from the main
# thread. Otherwise, onClick and onAction won't work
app.APP.skip_intro_dialog = SkipIntroDialog('script-plex-skip_intro.xml',
v.ADDON_PATH,
'default',
'1080i',
intro_end=end)
if utils.settings('enableAutoSkipIntro') == "true":
app.APP.skip_intro_dialog.seekTimeToIntroEnd()
else:
app.APP.skip_intro_dialog.show()
elif not in_intro and app.APP.skip_intro_dialog is not None:
app.APP.skip_intro_dialog.close()

View file

@ -214,7 +214,8 @@ LANGUAGE_ISO_CODES = (
def accessible_plex_subtitles(playmethod, playing_file, xml_streams):
if not playmethod == v.PLAYBACK_METHOD_DIRECT_PATH:
if playmethod not in (v.PLAYBACK_METHOD_DIRECT_PATH,
v.PLAYBACK_METHOD_DIRECT_PLAY):
# We can access all subtitles because we're downloading additional
# external ones into the Kodi PKC add-on directory
streams = []

View file

@ -55,6 +55,7 @@ REGEX_PLEX_ID_FROM_URL = re.compile(r'''metadata%2F(\d+)''')
SAFE_URL_CHARACTERS = "%/:=&?~#+!$,;'@()*[]"
HTTP_DAV_FTP = re.compile(r'(http(s)?|dav(s)?|(s)?ftp)://((.+):(.+)@)?([\w\.]+)(:([\d]+))?/')
def garbageCollect():
gc.collect(2)
@ -114,7 +115,7 @@ def settings(setting, value=None):
"""
# We need to instantiate every single time to read changed variables!
with SETTINGS_LOCK:
addon = xbmcaddon.Addon()
addon = xbmcaddon.Addon('plugin.video.plexkodiconnect')
if value is not None:
# Takes string or unicode by default!
addon.setSetting(setting, value)
@ -556,6 +557,17 @@ def reset(ask_user=True):
reboot_kodi()
def log_xml(xml, logger, loglevel):
"""
Logs an etree xml. Pass the loglevel for which logging will happen, e.g.
loglevel=logging.DEBUG
"""
if LOG.isEnabledFor(loglevel):
string = undefused_etree.tostring(xml, encoding='utf8')
string = string.decode('utf-8')
logger('\n' + string)
def compare_version(current, minimum):
"""
Returns True if current is >= then minimum. False otherwise. Returns True

View file

@ -21,7 +21,7 @@ MARK_PLAYED_AT = 0.9
# watched?
IGNORE_SECONDS_AT_START = 60
_ADDON = Addon()
_ADDON = Addon('plugin.video.plexkodiconnect')
ADDON_NAME = 'PlexKodiConnect'
ADDON_ID = 'plugin.video.plexkodiconnect'
ADDON_VERSION = _ADDON.getAddonInfo('version')
@ -30,8 +30,9 @@ ADDON_FOLDER = xbmcvfs.translatePath('special://home')
ADDON_PROFILE = xbmcvfs.translatePath(_ADDON.getAddonInfo('profile'))
# Used e.g. for json_rpc
KODI_VIDEO_PLAYER_ID = 1
KODI_AUDIO_PLAYER_ID = 0
KODI_VIDEO_PLAYER_ID = 1
KODI_PHOTO_PLAYER_ID = 2
KODILANGUAGE = xbmc.getLanguage(xbmc.ISO_639_1)
KODIVERSION = int(xbmc.getInfoLabel("System.BuildVersion")[:2])
@ -655,10 +656,6 @@ SORT_METHODS_ALBUMS = (
)
XML_HEADER = '<?xml version="1.0" encoding="UTF-8"?>\n'
COMPANION_OK_MESSAGE = XML_HEADER + '<Response code="200" status="OK" />'
PLEX_REPEAT_FROM_KODI_REPEAT = {
'off': '0',
'one': '1',

View file

@ -126,7 +126,6 @@ def on_error(ws, error):
ws.name, type(error), error)
# Status = Error
utils.settings(status, value=utils.lang(257))
raise RuntimeError
def on_close(ws):
@ -198,14 +197,19 @@ class PlexWebSocketApp(websocket.WebSocketApp,
log.exception('Exception of type %s occured: %s', type(err), err)
finally:
self.close()
if self._enabled:
# Status = Not connected
message = utils.lang(15208)
else:
# Status = Disabled
message = utils.lang(24023)
utils.settings(self.name + SETTINGS_STRING,
value=utils.lang(15208))
value=message)
app.APP.deregister_thread(self)
log.info("----===## %s stopped ##===----", self.name)
def _run(self):
while not self.should_cancel():
while not self.should_cancel() and self._enabled:
# In the event the server goes offline
while self.should_suspend():
# We will be caught in this loop if either another thread
@ -231,16 +235,13 @@ class PlexWebSocketApp(websocket.WebSocketApp,
class PMSWebsocketApp(PlexWebSocketApp):
name = 'pms_websocket'
def __init__(self, *args, **kwargs):
self._enabled = utils.settings('enableBackgroundSync') == 'true'
super().__init__(*args, **kwargs)
def get_uri(self):
return get_pms_uri()
def should_suspend(self):
"""
Returns True if the thread needs to suspend.
"""
return (self._suspended or
utils.settings('enableBackgroundSync') != 'true')
def set_suspension_settings_status(self):
if utils.settings('enableBackgroundSync') != 'true':
# Status = Disabled
@ -255,6 +256,10 @@ class PMSWebsocketApp(PlexWebSocketApp):
class AlexaWebsocketApp(PlexWebSocketApp):
name = 'alexa_websocket'
def __init__(self, *args, **kwargs):
self._enabled = utils.settings('enable_alexa') == 'true'
super().__init__(*args, **kwargs)
def get_uri(self):
return get_alexa_uri()
@ -263,7 +268,6 @@ class AlexaWebsocketApp(PlexWebSocketApp):
Returns True if the thread needs to suspend.
"""
return self._suspended or \
utils.settings('enable_alexa') != 'true' or \
app.ACCOUNT.restricted_user or \
not app.ACCOUNT.plex_token

View file

@ -994,8 +994,7 @@ class WindowProperty(object):
class GlobalProperty(object):
def __init__(self, prop, val='1', end=None):
import xbmcaddon
self._addonID = xbmcaddon.Addon().getAddonInfo('id')
self._addonID = 'plugin.video.plexkodiconnect'
self.prop = prop
self.val = val
self.end = end

View file

@ -36,12 +36,15 @@ class SkipIntroDialog(WindowXMLDialog):
logger.debug('Closing dialog')
WindowXMLDialog.close(self)
def seekTimeToIntroEnd(self):
logger.info('Skipping intro, seeking to %s', self.intro_end)
app.APP.player.seekTime(self.intro_end)
def onClick(self, control_id): # pylint: disable=invalid-name
if self.intro_end and control_id == 3002: # 3002 = Skip Intro button
if app.APP.is_playing:
self.on_hold = True
logger.info('Skipping intro, seeking to %s', self.intro_end)
app.APP.player.seekTime(self.intro_end)
self.seekTimeToIntroEnd()
self.close()
def onAction(self, action): # pylint: disable=invalid-name

View file

@ -114,6 +114,7 @@
<setting id="askCinema" type="bool" label="30519" default="false" visible="eq(-1,true)" subsetting="true" />
<setting id="trailerNumber" type="slider" label="39000" default="3" visible="eq(-2,true)" range="1,1,15" option="int" />
<setting id="enableSkipIntro" type="bool" label="30525" default="true" /><!-- Enable skipping of intros -->
<setting id="enableAutoSkipIntro" type="bool" label="39720" default="false" visible="eq(-1,true)" subsetting="true" /><!-- Enable auto skipping of intros -->
<setting id="firstVideoStream" type="bool" label="30546" default="false" /><!-- Pick the first video if several versions are present -->
<setting id="audioStreamPick" type="enum" label="30547" values="Plex|Kodi" default="0" /><!-- Who picks the audio stream on playback start? -->
<setting id="subtitleStreamPick" type="enum" label="30548" values="Plex|Kodi" default="0" /><!-- Who picks subtitles on playback start? -->