Merge pull request #1747 from croneter/python3-beta
Bump Python 3 Master
This commit is contained in:
commit
1498eaa8ff
65 changed files with 2381 additions and 2111 deletions
54
addon.xml
54
addon.xml
|
@ -1,10 +1,10 @@
|
||||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
<?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>
|
<requires>
|
||||||
<import addon="xbmc.python" version="3.0.0"/>
|
<import addon="xbmc.python" version="3.0.0"/>
|
||||||
<import addon="script.module.requests" version="2.22.0+matrix.1" />
|
<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.movies" version="3.0.2" />
|
||||||
<import addon="plugin.video.plexkodiconnect.tvshows" version="3.0.1" />
|
<import addon="plugin.video.plexkodiconnect.tvshows" version="3.0.2" />
|
||||||
<import addon="metadata.themoviedb.org.python" version="1.3.1+matrix.1" />
|
<import addon="metadata.themoviedb.org.python" version="1.3.1+matrix.1" />
|
||||||
</requires>
|
</requires>
|
||||||
<extension point="xbmc.python.pluginsource" library="default.py">
|
<extension point="xbmc.python.pluginsource" library="default.py">
|
||||||
|
@ -20,6 +20,8 @@
|
||||||
</item>
|
</item>
|
||||||
</extension>
|
</extension>
|
||||||
<extension point="xbmc.addon.metadata">
|
<extension point="xbmc.addon.metadata">
|
||||||
|
<!-- see e.g. https://github.com/xbmc/xbmc/pull/14136 -->
|
||||||
|
<reuselanguageinvoker>true</reuselanguageinvoker>
|
||||||
<assets>
|
<assets>
|
||||||
<icon>icon.png</icon>
|
<icon>icon.png</icon>
|
||||||
<fanart>fanart.jpg</fanart>
|
<fanart>fanart.jpg</fanart>
|
||||||
|
@ -91,7 +93,51 @@
|
||||||
<summary lang="ko_KR">Plex를 Kodi에 기본 통합</summary>
|
<summary lang="ko_KR">Plex를 Kodi에 기본 통합</summary>
|
||||||
<description lang="ko_KR">Kodi를 Plex Media Server에 연결합니다. 이 플러그인은 Plex로 모든 비디오를 관리하고 Kodi로는 관리하지 않는다고 가정합니다. Kodi 비디오 및 음악 데이터베이스에 이미 저장된 데이터가 손실 될 수 있습니다 (이 플러그인이 직접 변경하므로). 자신의 책임하에 사용하십시오!</description>
|
<description lang="ko_KR">Kodi를 Plex Media Server에 연결합니다. 이 플러그인은 Plex로 모든 비디오를 관리하고 Kodi로는 관리하지 않는다고 가정합니다. Kodi 비디오 및 음악 데이터베이스에 이미 저장된 데이터가 손실 될 수 있습니다 (이 플러그인이 직접 변경하므로). 자신의 책임하에 사용하십시오!</description>
|
||||||
<disclaimer lang="ko_KR">자신의 책임하에 사용</disclaimer>
|
<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
|
- Fix UnboundLocalError: local variable 'identifier' referenced before assignment
|
||||||
- versions 3.5.6-3.5.7 for everyone
|
- versions 3.5.6-3.5.7 for everyone
|
||||||
|
|
||||||
|
|
|
@ -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:
|
version 3.5.8:
|
||||||
- Fix UnboundLocalError: local variable 'identifier' referenced before assignment
|
- Fix UnboundLocalError: local variable 'identifier' referenced before assignment
|
||||||
- versions 3.5.6-3.5.7 for everyone
|
- versions 3.5.6-3.5.7 for everyone
|
||||||
|
|
35
default.py
35
default.py
|
@ -1,6 +1,4 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
###############################################################################
|
|
||||||
from builtins import object
|
from builtins import object
|
||||||
import logging
|
import logging
|
||||||
from sys import argv
|
from sys import argv
|
||||||
|
@ -12,15 +10,10 @@ import xbmcplugin
|
||||||
|
|
||||||
from resources.lib import entrypoint, utils, transfer, variables as v, loghandler
|
from resources.lib import entrypoint, utils, transfer, variables as v, loghandler
|
||||||
|
|
||||||
###############################################################################
|
|
||||||
|
|
||||||
loghandler.config()
|
loghandler.config()
|
||||||
LOG = logging.getLogger('PLEX.default')
|
LOG = logging.getLogger('PLEX.default')
|
||||||
|
|
||||||
###############################################################################
|
|
||||||
|
|
||||||
HANDLE = int(argv[1])
|
|
||||||
|
|
||||||
|
|
||||||
class Main(object):
|
class Main(object):
|
||||||
# MAIN ENTRY POINT
|
# MAIN ENTRY POINT
|
||||||
|
@ -117,7 +110,8 @@ class Main(object):
|
||||||
transfer.plex_command('choose_pms_server')
|
transfer.plex_command('choose_pms_server')
|
||||||
|
|
||||||
elif mode == 'deviceid':
|
elif mode == 'deviceid':
|
||||||
self.deviceid()
|
LOG.info('New PKC UUID / unique device id requested')
|
||||||
|
transfer.plex_command('generate_new_uuid')
|
||||||
|
|
||||||
elif mode == 'fanart':
|
elif mode == 'fanart':
|
||||||
LOG.info('User requested fanarttv refresh')
|
LOG.info('User requested fanarttv refresh')
|
||||||
|
@ -157,38 +151,21 @@ class Main(object):
|
||||||
"""
|
"""
|
||||||
Start up playback_starter in main Python thread
|
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'
|
# Put the request into the 'queue'
|
||||||
transfer.plex_command('PLAY-%s' % request)
|
transfer.plex_command('PLAY-%s' % request)
|
||||||
if HANDLE == -1:
|
if int(argv[1]) == -1:
|
||||||
# Handle -1 received, not waiting for main thread
|
# Handle -1 received, not waiting for main thread
|
||||||
return
|
return
|
||||||
# Wait for the result from the main PKC thread
|
# Wait for the result from the main PKC thread
|
||||||
result = transfer.wait_for_transfer(source='main')
|
result = transfer.wait_for_transfer(source='main')
|
||||||
if result is True:
|
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
|
# Tell main thread that we're done
|
||||||
transfer.send(True, target='main')
|
transfer.send(True, target='main')
|
||||||
else:
|
else:
|
||||||
# Received a xbmcgui.ListItem()
|
# Received a xbmcgui.ListItem()
|
||||||
xbmcplugin.setResolvedUrl(HANDLE, True, result)
|
xbmcplugin.setResolvedUrl(int(argv[1]), 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')
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|
|
@ -1676,3 +1676,8 @@ msgstr ""
|
||||||
msgctxt "#39719"
|
msgctxt "#39719"
|
||||||
msgid "Replace user ratings with number of media versions"
|
msgid "Replace user ratings with number of media versions"
|
||||||
msgstr "Nahradit uživatelské hodnocení počtem verzí média"
|
msgstr "Nahradit uživatelské hodnocení počtem verzí média"
|
||||||
|
|
||||||
|
# PKC Settings - Playback
|
||||||
|
msgctxt "#39720"
|
||||||
|
msgid "Auto skip intro"
|
||||||
|
msgstr ""
|
||||||
|
|
|
@ -1684,3 +1684,8 @@ msgstr ""
|
||||||
msgctxt "#39719"
|
msgctxt "#39719"
|
||||||
msgid "Replace user ratings with number of media versions"
|
msgid "Replace user ratings with number of media versions"
|
||||||
msgstr "Erstat brugerbedømmelser med antal af medieversioner"
|
msgstr "Erstat brugerbedømmelser med antal af medieversioner"
|
||||||
|
|
||||||
|
# PKC Settings - Playback
|
||||||
|
msgctxt "#39720"
|
||||||
|
msgid "Auto skip intro"
|
||||||
|
msgstr ""
|
||||||
|
|
|
@ -1713,3 +1713,8 @@ msgstr ""
|
||||||
msgctxt "#39719"
|
msgctxt "#39719"
|
||||||
msgid "Replace user ratings with number of media versions"
|
msgid "Replace user ratings with number of media versions"
|
||||||
msgstr "Benutzerbewertungen durch verfügbare Anzahl Versionen ersetzen"
|
msgstr "Benutzerbewertungen durch verfügbare Anzahl Versionen ersetzen"
|
||||||
|
|
||||||
|
# PKC Settings - Playback
|
||||||
|
msgctxt "#39720"
|
||||||
|
msgid "Auto skip intro"
|
||||||
|
msgstr ""
|
||||||
|
|
|
@ -1601,3 +1601,8 @@ msgstr ""
|
||||||
msgctxt "#39719"
|
msgctxt "#39719"
|
||||||
msgid "Replace user ratings with number of media versions"
|
msgid "Replace user ratings with number of media versions"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
# PKC Settings - Playback
|
||||||
|
msgctxt "#39720"
|
||||||
|
msgid "Auto skip intro"
|
||||||
|
msgstr ""
|
||||||
|
|
|
@ -1501,3 +1501,8 @@ msgstr ""
|
||||||
msgctxt "#39719"
|
msgctxt "#39719"
|
||||||
msgid "Replace user ratings with number of media versions"
|
msgid "Replace user ratings with number of media versions"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
# PKC Settings - Playback
|
||||||
|
msgctxt "#39720"
|
||||||
|
msgid "Auto skip intro"
|
||||||
|
msgstr ""
|
||||||
|
|
|
@ -1699,3 +1699,8 @@ msgctxt "#39719"
|
||||||
msgid "Replace user ratings with number of media versions"
|
msgid "Replace user ratings with number of media versions"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Reemplazar valoraciones de usuario con cantidad de versiones de medios"
|
"Reemplazar valoraciones de usuario con cantidad de versiones de medios"
|
||||||
|
|
||||||
|
# PKC Settings - Playback
|
||||||
|
msgctxt "#39720"
|
||||||
|
msgid "Auto skip intro"
|
||||||
|
msgstr ""
|
||||||
|
|
|
@ -1701,3 +1701,8 @@ msgctxt "#39719"
|
||||||
msgid "Replace user ratings with number of media versions"
|
msgid "Replace user ratings with number of media versions"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Reemplazar valoraciones de usuario con cantidad de versiones de medios"
|
"Reemplazar valoraciones de usuario con cantidad de versiones de medios"
|
||||||
|
|
||||||
|
# PKC Settings - Playback
|
||||||
|
msgctxt "#39720"
|
||||||
|
msgid "Auto skip intro"
|
||||||
|
msgstr ""
|
||||||
|
|
|
@ -1699,3 +1699,8 @@ msgctxt "#39719"
|
||||||
msgid "Replace user ratings with number of media versions"
|
msgid "Replace user ratings with number of media versions"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Reemplazar valoraciones de usuario con cantidad de versiones de medios"
|
"Reemplazar valoraciones de usuario con cantidad de versiones de medios"
|
||||||
|
|
||||||
|
# PKC Settings - Playback
|
||||||
|
msgctxt "#39720"
|
||||||
|
msgid "Auto skip intro"
|
||||||
|
msgstr ""
|
||||||
|
|
|
@ -1719,3 +1719,8 @@ msgstr ""
|
||||||
msgctxt "#39719"
|
msgctxt "#39719"
|
||||||
msgid "Replace user ratings with number of media versions"
|
msgid "Replace user ratings with number of media versions"
|
||||||
msgstr "Remplacer les notes d'utilisateurs par le nombre de versions du média"
|
msgstr "Remplacer les notes d'utilisateurs par le nombre de versions du média"
|
||||||
|
|
||||||
|
# PKC Settings - Playback
|
||||||
|
msgctxt "#39720"
|
||||||
|
msgid "Auto skip intro"
|
||||||
|
msgstr ""
|
||||||
|
|
|
@ -1723,3 +1723,8 @@ msgstr ""
|
||||||
msgctxt "#39719"
|
msgctxt "#39719"
|
||||||
msgid "Replace user ratings with number of media versions"
|
msgid "Replace user ratings with number of media versions"
|
||||||
msgstr "Remplacer les notes d'utilisateurs par le nombre de versions du média"
|
msgstr "Remplacer les notes d'utilisateurs par le nombre de versions du média"
|
||||||
|
|
||||||
|
# PKC Settings - Playback
|
||||||
|
msgctxt "#39720"
|
||||||
|
msgid "Auto skip intro"
|
||||||
|
msgstr ""
|
||||||
|
|
|
@ -1712,3 +1712,8 @@ msgstr ""
|
||||||
msgctxt "#39719"
|
msgctxt "#39719"
|
||||||
msgid "Replace user ratings with number of media versions"
|
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"
|
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 ""
|
||||||
|
|
|
@ -1711,3 +1711,8 @@ msgid "Replace user ratings with number of media versions"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Sostituisci la valutazione contenuti con il numero delle versioni del "
|
"Sostituisci la valutazione contenuti con il numero delle versioni del "
|
||||||
"contenuto disponibili"
|
"contenuto disponibili"
|
||||||
|
|
||||||
|
# PKC Settings - Playback
|
||||||
|
msgctxt "#39720"
|
||||||
|
msgid "Auto skip intro"
|
||||||
|
msgstr ""
|
||||||
|
|
|
@ -1632,3 +1632,8 @@ msgstr "사용자 지정 사용자 등급을 보유하고있는 미디어 항목
|
||||||
msgctxt "#39719"
|
msgctxt "#39719"
|
||||||
msgid "Replace user ratings with number of media versions"
|
msgid "Replace user ratings with number of media versions"
|
||||||
msgstr "사용자 등급을 미디어 버전 수로 대체"
|
msgstr "사용자 등급을 미디어 버전 수로 대체"
|
||||||
|
|
||||||
|
# PKC Settings - Playback
|
||||||
|
msgctxt "#39720"
|
||||||
|
msgid "Auto skip intro"
|
||||||
|
msgstr ""
|
||||||
|
|
|
@ -1697,3 +1697,8 @@ msgstr ""
|
||||||
msgctxt "#39719"
|
msgctxt "#39719"
|
||||||
msgid "Replace user ratings with number of media versions"
|
msgid "Replace user ratings with number of media versions"
|
||||||
msgstr "Pakeiskite naudotojų reitingus medijos versijų skaičiumi"
|
msgstr "Pakeiskite naudotojų reitingus medijos versijų skaičiumi"
|
||||||
|
|
||||||
|
# PKC Settings - Playback
|
||||||
|
msgctxt "#39720"
|
||||||
|
msgid "Auto skip intro"
|
||||||
|
msgstr ""
|
||||||
|
|
|
@ -1632,3 +1632,8 @@ msgstr ""
|
||||||
msgctxt "#39719"
|
msgctxt "#39719"
|
||||||
msgid "Replace user ratings with number of media versions"
|
msgid "Replace user ratings with number of media versions"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
# PKC Settings - Playback
|
||||||
|
msgctxt "#39720"
|
||||||
|
msgid "Auto skip intro"
|
||||||
|
msgstr ""
|
||||||
|
|
|
@ -1683,3 +1683,8 @@ msgstr ""
|
||||||
msgctxt "#39719"
|
msgctxt "#39719"
|
||||||
msgid "Replace user ratings with number of media versions"
|
msgid "Replace user ratings with number of media versions"
|
||||||
msgstr "Vervang ratings met aantal media versies"
|
msgstr "Vervang ratings met aantal media versies"
|
||||||
|
|
||||||
|
# PKC Settings - Playback
|
||||||
|
msgctxt "#39720"
|
||||||
|
msgid "Auto skip intro"
|
||||||
|
msgstr ""
|
||||||
|
|
|
@ -1677,3 +1677,8 @@ msgstr ""
|
||||||
msgctxt "#39719"
|
msgctxt "#39719"
|
||||||
msgid "Replace user ratings with number of media versions"
|
msgid "Replace user ratings with number of media versions"
|
||||||
msgstr "Erstatt rating med antall versjoner av media"
|
msgstr "Erstatt rating med antall versjoner av media"
|
||||||
|
|
||||||
|
# PKC Settings - Playback
|
||||||
|
msgctxt "#39720"
|
||||||
|
msgid "Auto skip intro"
|
||||||
|
msgstr ""
|
||||||
|
|
|
@ -1606,3 +1606,8 @@ msgstr ""
|
||||||
msgctxt "#39719"
|
msgctxt "#39719"
|
||||||
msgid "Replace user ratings with number of media versions"
|
msgid "Replace user ratings with number of media versions"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
# PKC Settings - Playback
|
||||||
|
msgctxt "#39720"
|
||||||
|
msgid "Auto skip intro"
|
||||||
|
msgstr ""
|
||||||
|
|
|
@ -1674,3 +1674,8 @@ msgctxt "#39719"
|
||||||
msgid "Replace user ratings with number of media versions"
|
msgid "Replace user ratings with number of media versions"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Substituir classificações do utilizador com numero de versões de média"
|
"Substituir classificações do utilizador com numero de versões de média"
|
||||||
|
|
||||||
|
# PKC Settings - Playback
|
||||||
|
msgctxt "#39720"
|
||||||
|
msgid "Auto skip intro"
|
||||||
|
msgstr ""
|
||||||
|
|
|
@ -1677,3 +1677,8 @@ msgctxt "#39719"
|
||||||
msgid "Replace user ratings with number of media versions"
|
msgid "Replace user ratings with number of media versions"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Substituir classificações do utilizador com numero de versões de média"
|
"Substituir classificações do utilizador com numero de versões de média"
|
||||||
|
|
||||||
|
# PKC Settings - Playback
|
||||||
|
msgctxt "#39720"
|
||||||
|
msgid "Auto skip intro"
|
||||||
|
msgstr ""
|
||||||
|
|
|
@ -1691,3 +1691,8 @@ msgstr ""
|
||||||
msgctxt "#39719"
|
msgctxt "#39719"
|
||||||
msgid "Replace user ratings with number of media versions"
|
msgid "Replace user ratings with number of media versions"
|
||||||
msgstr "Заменить пользовательский рейтинг счетчиком версий элемента"
|
msgstr "Заменить пользовательский рейтинг счетчиком версий элемента"
|
||||||
|
|
||||||
|
# PKC Settings - Playback
|
||||||
|
msgctxt "#39720"
|
||||||
|
msgid "Auto skip intro"
|
||||||
|
msgstr ""
|
||||||
|
|
|
@ -1685,3 +1685,8 @@ msgstr ""
|
||||||
msgctxt "#39719"
|
msgctxt "#39719"
|
||||||
msgid "Replace user ratings with number of media versions"
|
msgid "Replace user ratings with number of media versions"
|
||||||
msgstr "Ersätt användarbetyg med antalet mediaobjekt"
|
msgstr "Ersätt användarbetyg med antalet mediaobjekt"
|
||||||
|
|
||||||
|
# PKC Settings - Playback
|
||||||
|
msgctxt "#39720"
|
||||||
|
msgid "Auto skip intro"
|
||||||
|
msgstr ""
|
||||||
|
|
|
@ -1691,3 +1691,8 @@ msgstr ""
|
||||||
msgctxt "#39719"
|
msgctxt "#39719"
|
||||||
msgid "Replace user ratings with number of media versions"
|
msgid "Replace user ratings with number of media versions"
|
||||||
msgstr "Замінити користувацький рейтинг лічильником версій елемента"
|
msgstr "Замінити користувацький рейтинг лічильником версій елемента"
|
||||||
|
|
||||||
|
# PKC Settings - Playback
|
||||||
|
msgctxt "#39720"
|
||||||
|
msgid "Auto skip intro"
|
||||||
|
msgstr ""
|
||||||
|
|
|
@ -1602,3 +1602,8 @@ msgstr ""
|
||||||
msgctxt "#39719"
|
msgctxt "#39719"
|
||||||
msgid "Replace user ratings with number of media versions"
|
msgid "Replace user ratings with number of media versions"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
# PKC Settings - Playback
|
||||||
|
msgctxt "#39720"
|
||||||
|
msgid "Auto skip intro"
|
||||||
|
msgstr ""
|
||||||
|
|
|
@ -1598,3 +1598,8 @@ msgstr ""
|
||||||
msgctxt "#39719"
|
msgctxt "#39719"
|
||||||
msgid "Replace user ratings with number of media versions"
|
msgid "Replace user ratings with number of media versions"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
# PKC Settings - Playback
|
||||||
|
msgctxt "#39720"
|
||||||
|
msgid "Auto skip intro"
|
||||||
|
msgstr ""
|
||||||
|
|
|
@ -9,12 +9,14 @@ from .application import App
|
||||||
from .connection import Connection
|
from .connection import Connection
|
||||||
from .libsync import Sync
|
from .libsync import Sync
|
||||||
from .playstate import PlayState
|
from .playstate import PlayState
|
||||||
|
from .playqueues import Playqueues
|
||||||
|
|
||||||
ACCOUNT = None
|
ACCOUNT = None
|
||||||
APP = None
|
APP = None
|
||||||
CONN = None
|
CONN = None
|
||||||
SYNC = None
|
SYNC = None
|
||||||
PLAYSTATE = None
|
PLAYSTATE = None
|
||||||
|
PLAYQUEUES = None
|
||||||
|
|
||||||
|
|
||||||
def init(entrypoint=False):
|
def init(entrypoint=False):
|
||||||
|
@ -22,13 +24,15 @@ def init(entrypoint=False):
|
||||||
entrypoint=True initiates only the bare minimum - for other PKC python
|
entrypoint=True initiates only the bare minimum - for other PKC python
|
||||||
instances
|
instances
|
||||||
"""
|
"""
|
||||||
global ACCOUNT, APP, CONN, SYNC, PLAYSTATE
|
global ACCOUNT, APP, CONN, SYNC, PLAYSTATE, PLAYQUEUES
|
||||||
APP = App(entrypoint)
|
APP = App(entrypoint)
|
||||||
CONN = Connection(entrypoint)
|
CONN = Connection(entrypoint)
|
||||||
ACCOUNT = Account(entrypoint)
|
ACCOUNT = Account(entrypoint)
|
||||||
SYNC = Sync(entrypoint)
|
SYNC = Sync(entrypoint)
|
||||||
if not entrypoint:
|
if not entrypoint:
|
||||||
PLAYSTATE = PlayState()
|
PLAYSTATE = PlayState()
|
||||||
|
PLAYQUEUES = Playqueues()
|
||||||
|
|
||||||
|
|
||||||
def reload():
|
def reload():
|
||||||
"""
|
"""
|
||||||
|
|
230
resources/lib/app/playqueues.py
Normal file
230
resources/lib/app/playqueues.py
Normal file
|
@ -0,0 +1,230 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from logging import getLogger
|
||||||
|
|
||||||
|
import xbmc
|
||||||
|
|
||||||
|
from .. import variables as v
|
||||||
|
|
||||||
|
|
||||||
|
LOG = getLogger('PLEX.playqueue')
|
||||||
|
|
||||||
|
|
||||||
|
class Playqueue(object):
|
||||||
|
"""
|
||||||
|
PKC object to represent PMS playQueues and Kodi playlist for queueing
|
||||||
|
|
||||||
|
playlistid = None [int] Kodi playlist id (0, 1, 2)
|
||||||
|
type = None [str] Kodi type: 'audio', 'video', 'picture'
|
||||||
|
kodi_pl = None Kodi xbmc.PlayList object
|
||||||
|
items = [] [list] of Playlist_Items
|
||||||
|
id = None [str] Plex playQueueID, unique Plex identifier
|
||||||
|
version = None [int] Plex version of the playQueue
|
||||||
|
selectedItemID = None
|
||||||
|
[str] Plex selectedItemID, playing element in queue
|
||||||
|
selectedItemOffset = None
|
||||||
|
[str] Offset of the playing element in queue
|
||||||
|
shuffled = 0 [int] 0: not shuffled, 1: ??? 2: ???
|
||||||
|
repeat = 0 [int] 0: not repeated, 1: ??? 2: ???
|
||||||
|
|
||||||
|
If Companion playback is initiated by another user:
|
||||||
|
plex_transient_token = None
|
||||||
|
"""
|
||||||
|
kind = 'playQueue'
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.id = None
|
||||||
|
self.type = None
|
||||||
|
self.playlistid = None
|
||||||
|
self.kodi_pl = None
|
||||||
|
self.items = []
|
||||||
|
self.version = None
|
||||||
|
self.selectedItemID = None
|
||||||
|
self.selectedItemOffset = None
|
||||||
|
self.shuffled = 0
|
||||||
|
self.repeat = 0
|
||||||
|
self.plex_transient_token = None
|
||||||
|
# Need a hack for detecting swaps of elements
|
||||||
|
self.old_kodi_pl = []
|
||||||
|
# Did PKC itself just change the playqueue so the PKC playqueue monitor
|
||||||
|
# should not pick up any changes?
|
||||||
|
self.pkc_edit = False
|
||||||
|
# Workaround to avoid endless loops of detecting PL clears
|
||||||
|
self._clear_list = []
|
||||||
|
# To keep track if Kodi playback was initiated from a Kodi playlist
|
||||||
|
# There are a couple of pitfalls, unfortunately...
|
||||||
|
self.kodi_playlist_playback = False
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
answ = ("{{"
|
||||||
|
"'playlistid': {self.playlistid}, "
|
||||||
|
"'id': {self.id}, "
|
||||||
|
"'version': {self.version}, "
|
||||||
|
"'type': '{self.type}', "
|
||||||
|
"'selectedItemID': {self.selectedItemID}, "
|
||||||
|
"'selectedItemOffset': {self.selectedItemOffset}, "
|
||||||
|
"'shuffled': {self.shuffled}, "
|
||||||
|
"'repeat': {self.repeat}, "
|
||||||
|
"'kodi_playlist_playback': {self.kodi_playlist_playback}, "
|
||||||
|
"'pkc_edit': {self.pkc_edit}, ".format(self=self))
|
||||||
|
# Since list.__repr__ will return string, not unicode
|
||||||
|
return answ + "'items': {self.items}}}".format(self=self)
|
||||||
|
|
||||||
|
def is_pkc_clear(self):
|
||||||
|
"""
|
||||||
|
Returns True if PKC has cleared the Kodi playqueue just recently.
|
||||||
|
Then this clear will be ignored from now on
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self._clear_list.pop()
|
||||||
|
except IndexError:
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def clear(self, kodi=True):
|
||||||
|
"""
|
||||||
|
Resets the playlist object to an empty playlist.
|
||||||
|
|
||||||
|
Pass kodi=False in order to NOT clear the Kodi playqueue
|
||||||
|
"""
|
||||||
|
# kodi monitor's on_clear method will only be called if there were some
|
||||||
|
# items to begin with
|
||||||
|
if kodi and self.kodi_pl.size() != 0:
|
||||||
|
self._clear_list.append(None)
|
||||||
|
self.kodi_pl.clear() # Clear Kodi playlist object
|
||||||
|
self.items = []
|
||||||
|
self.id = None
|
||||||
|
self.version = None
|
||||||
|
self.selectedItemID = None
|
||||||
|
self.selectedItemOffset = None
|
||||||
|
self.shuffled = 0
|
||||||
|
self.repeat = 0
|
||||||
|
self.plex_transient_token = None
|
||||||
|
self.old_kodi_pl = []
|
||||||
|
self.kodi_playlist_playback = False
|
||||||
|
LOG.debug('Playlist cleared: %s', self)
|
||||||
|
|
||||||
|
def position_from_plex_id(self, plex_id):
|
||||||
|
"""
|
||||||
|
Returns the position [int] for the very first item with plex_id [int]
|
||||||
|
(Plex seems uncapable of adding the same element multiple times to a
|
||||||
|
playqueue or playlist)
|
||||||
|
|
||||||
|
Raises KeyError if not found
|
||||||
|
"""
|
||||||
|
for position, item in enumerate(self.items):
|
||||||
|
if item.plex_id == plex_id:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise KeyError('Did not find plex_id %s in %s', plex_id, self)
|
||||||
|
return position
|
||||||
|
|
||||||
|
|
||||||
|
class Playqueues(list):
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
for i, typus in enumerate((v.KODI_PLAYLIST_TYPE_AUDIO,
|
||||||
|
v.KODI_PLAYLIST_TYPE_VIDEO,
|
||||||
|
v.KODI_PLAYLIST_TYPE_PHOTO)):
|
||||||
|
playqueue = Playqueue()
|
||||||
|
playqueue.playlistid = i
|
||||||
|
playqueue.type = typus
|
||||||
|
# Initialize each Kodi playlist
|
||||||
|
if typus == v.KODI_PLAYLIST_TYPE_AUDIO:
|
||||||
|
playqueue.kodi_pl = xbmc.PlayList(xbmc.PLAYLIST_MUSIC)
|
||||||
|
elif typus == v.KODI_PLAYLIST_TYPE_VIDEO:
|
||||||
|
playqueue.kodi_pl = xbmc.PlayList(xbmc.PLAYLIST_VIDEO)
|
||||||
|
else:
|
||||||
|
# Currently, only video or audio playqueues available
|
||||||
|
playqueue.kodi_pl = xbmc.PlayList(xbmc.PLAYLIST_VIDEO)
|
||||||
|
# Overwrite 'picture' with 'photo'
|
||||||
|
playqueue.type = v.KODI_TYPE_PHOTO
|
||||||
|
self.append(playqueue)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def audio(self):
|
||||||
|
return self[0]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def video(self):
|
||||||
|
return self[1]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def photo(self):
|
||||||
|
return self[2]
|
||||||
|
|
||||||
|
def from_kodi_playlist_type(self, kodi_playlist_type):
|
||||||
|
"""
|
||||||
|
Returns the playqueue according to the kodi_playlist_type ('video',
|
||||||
|
'audio', 'picture') passed in
|
||||||
|
"""
|
||||||
|
if kodi_playlist_type == v.KODI_PLAYLIST_TYPE_AUDIO:
|
||||||
|
return self[0]
|
||||||
|
elif kodi_playlist_type == v.KODI_PLAYLIST_TYPE_VIDEO:
|
||||||
|
return self[1]
|
||||||
|
elif kodi_playlist_type == v.KODI_PLAYLIST_TYPE_PHOTO:
|
||||||
|
return self[2]
|
||||||
|
else:
|
||||||
|
raise ValueError('Unknown kodi_playlist_type: %s' % kodi_playlist_type)
|
||||||
|
|
||||||
|
def from_kodi_type(self, kodi_type):
|
||||||
|
"""
|
||||||
|
Pass in the kodi_type (e.g. the string 'movie') to get the correct
|
||||||
|
playqueue (either video, audio or picture)
|
||||||
|
"""
|
||||||
|
if kodi_type == v.KODI_TYPE_VIDEO:
|
||||||
|
return self[1]
|
||||||
|
elif kodi_type == v.KODI_TYPE_MOVIE:
|
||||||
|
return self[1]
|
||||||
|
elif kodi_type == v.KODI_TYPE_EPISODE:
|
||||||
|
return self[1]
|
||||||
|
elif kodi_type == v.KODI_TYPE_SEASON:
|
||||||
|
return self[1]
|
||||||
|
elif kodi_type == v.KODI_TYPE_SHOW:
|
||||||
|
return self[1]
|
||||||
|
elif kodi_type == v.KODI_TYPE_CLIP:
|
||||||
|
return self[1]
|
||||||
|
elif kodi_type == v.KODI_TYPE_SONG:
|
||||||
|
return self[0]
|
||||||
|
elif kodi_type == v.KODI_TYPE_ALBUM:
|
||||||
|
return self[0]
|
||||||
|
elif kodi_type == v.KODI_TYPE_ARTIST:
|
||||||
|
return self[0]
|
||||||
|
elif kodi_type == v.KODI_TYPE_AUDIO:
|
||||||
|
return self[0]
|
||||||
|
elif kodi_type == v.KODI_TYPE_PHOTO:
|
||||||
|
return self[2]
|
||||||
|
else:
|
||||||
|
raise ValueError('Unknown kodi_type: %s' % kodi_type)
|
||||||
|
|
||||||
|
def from_plex_type(self, plex_type):
|
||||||
|
"""
|
||||||
|
Pass in the plex_type (e.g. the string 'movie') to get the correct
|
||||||
|
playqueue (either video, audio or picture)
|
||||||
|
"""
|
||||||
|
if plex_type == v.PLEX_TYPE_VIDEO:
|
||||||
|
return self[1]
|
||||||
|
elif plex_type == v.PLEX_TYPE_MOVIE:
|
||||||
|
return self[1]
|
||||||
|
elif plex_type == v.PLEX_TYPE_EPISODE:
|
||||||
|
return self[1]
|
||||||
|
elif plex_type == v.PLEX_TYPE_SEASON:
|
||||||
|
return self[1]
|
||||||
|
elif plex_type == v.PLEX_TYPE_SHOW:
|
||||||
|
return self[1]
|
||||||
|
elif plex_type == v.PLEX_TYPE_CLIP:
|
||||||
|
return self[1]
|
||||||
|
elif plex_type == v.PLEX_TYPE_SONG:
|
||||||
|
return self[0]
|
||||||
|
elif plex_type == v.PLEX_TYPE_ALBUM:
|
||||||
|
return self[0]
|
||||||
|
elif plex_type == v.PLEX_TYPE_ARTIST:
|
||||||
|
return self[0]
|
||||||
|
elif plex_type == v.PLEX_TYPE_AUDIO:
|
||||||
|
return self[0]
|
||||||
|
elif plex_type == v.PLEX_TYPE_PHOTO:
|
||||||
|
return self[2]
|
||||||
|
else:
|
||||||
|
raise ValueError('Unknown plex_type: %s' % plex_type)
|
|
@ -44,12 +44,6 @@ class PlayState(object):
|
||||||
1: {},
|
1: {},
|
||||||
2: {}
|
2: {}
|
||||||
}
|
}
|
||||||
# The LAST playstate once playback is finished
|
|
||||||
self.old_player_states = {
|
|
||||||
0: {},
|
|
||||||
1: {},
|
|
||||||
2: {}
|
|
||||||
}
|
|
||||||
self.played_info = {}
|
self.played_info = {}
|
||||||
|
|
||||||
# Currently playing PKC item, a PlaylistItem()
|
# Currently playing PKC item, a PlaylistItem()
|
||||||
|
|
|
@ -51,6 +51,22 @@ def getXArgsDeviceInfo(options=None, include_token=True):
|
||||||
return xargs
|
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):
|
def getDeviceId(reset=False):
|
||||||
"""
|
"""
|
||||||
Returns a unique Plex client id "X-Plex-Client-Identifier" from Kodi
|
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 id does not exist, create one and save in Kodi settings file.
|
||||||
"""
|
"""
|
||||||
if reset is True:
|
if reset:
|
||||||
v.PKC_MACHINE_IDENTIFIER = None
|
return generate_device_id()
|
||||||
utils.window('plex_client_Id', clear=True)
|
|
||||||
utils.settings('plex_client_Id', value="")
|
|
||||||
|
|
||||||
client_id = v.PKC_MACHINE_IDENTIFIER
|
client_id = v.PKC_MACHINE_IDENTIFIER
|
||||||
if client_id:
|
if client_id:
|
||||||
return client_id
|
return client_id
|
||||||
|
|
||||||
client_id = utils.settings('plex_client_Id')
|
client_id = utils.settings('plex_client_Id')
|
||||||
# Because Kodi appears to cache file settings!!
|
if client_id != "":
|
||||||
if client_id != "" and reset is False:
|
|
||||||
v.PKC_MACHINE_IDENTIFIER = client_id
|
v.PKC_MACHINE_IDENTIFIER = client_id
|
||||||
utils.window('plex_client_Id', value=client_id)
|
utils.window('plex_client_Id', value=client_id)
|
||||||
LOG.info("Unique device Id plex_client_Id loaded: %s", client_id)
|
LOG.info("Unique device Id plex_client_Id loaded: %s", client_id)
|
||||||
return client_id
|
return client_id
|
||||||
|
else:
|
||||||
LOG.info("Generating a new deviceid.")
|
return generate_device_id()
|
||||||
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
|
|
||||||
|
|
|
@ -6,8 +6,10 @@ Processes Plex companion inputs from the plexbmchelper to Kodi commands
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from xbmc import Player
|
from xbmc import Player
|
||||||
|
|
||||||
from . import playqueue as PQ, plex_functions as PF
|
from . import plex_functions as PF
|
||||||
from . import json_rpc as js, variables as v, app
|
from . import json_rpc as js
|
||||||
|
from . import variables as v
|
||||||
|
from . import app
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
|
|
||||||
|
@ -28,7 +30,7 @@ def skip_to(params):
|
||||||
playqueue_item_id, plex_id)
|
playqueue_item_id, plex_id)
|
||||||
found = True
|
found = True
|
||||||
for player in list(js.get_players().values()):
|
for player in list(js.get_players().values()):
|
||||||
playqueue = PQ.PLAYQUEUES[player['playerid']]
|
playqueue = app.PLAYQUEUES[player['playerid']]
|
||||||
for i, item in enumerate(playqueue.items):
|
for i, item in enumerate(playqueue.items):
|
||||||
if item.id == playqueue_item_id:
|
if item.id == playqueue_item_id:
|
||||||
found = True
|
found = True
|
||||||
|
@ -78,6 +80,7 @@ def process_command(request_path, params):
|
||||||
js.set_volume(int(params['volume']))
|
js.set_volume(int(params['volume']))
|
||||||
else:
|
else:
|
||||||
LOG.error('Unknown parameters: %s', params)
|
LOG.error('Unknown parameters: %s', params)
|
||||||
|
return False
|
||||||
elif request_path == "player/playback/play":
|
elif request_path == "player/playback/play":
|
||||||
js.play()
|
js.play()
|
||||||
elif request_path == "player/playback/pause":
|
elif request_path == "player/playback/pause":
|
||||||
|
@ -117,3 +120,5 @@ def process_command(request_path, params):
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
LOG.error('Unknown request path: %s', request_path)
|
LOG.error('Unknown request path: %s', request_path)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
|
@ -6,8 +6,11 @@ import xbmcgui
|
||||||
|
|
||||||
from .plex_api import API
|
from .plex_api import API
|
||||||
from .plex_db import PlexDB
|
from .plex_db import PlexDB
|
||||||
from . import context, plex_functions as PF, playqueue as PQ
|
from . import context
|
||||||
from . import utils, variables as v, app
|
from . import plex_functions as PF
|
||||||
|
from . import utils
|
||||||
|
from . import variables as v
|
||||||
|
from . import app
|
||||||
|
|
||||||
###############################################################################
|
###############################################################################
|
||||||
|
|
||||||
|
@ -137,8 +140,7 @@ class ContextMenu(object):
|
||||||
"""
|
"""
|
||||||
For using direct paths: Initiates playback using the PMS
|
For using direct paths: Initiates playback using the PMS
|
||||||
"""
|
"""
|
||||||
playqueue = PQ.get_playqueue_from_type(
|
playqueue = app.PLAYQUEUES.from_kodi_type(self.kodi_type)
|
||||||
v.KODI_PLAYLIST_TYPE_FROM_KODI_TYPE[self.kodi_type])
|
|
||||||
playqueue.clear()
|
playqueue.clear()
|
||||||
app.PLAYSTATE.context_menu_play = True
|
app.PLAYSTATE.context_menu_play = True
|
||||||
handle = self.api.fullpath(force_addon=True)[0]
|
handle = self.api.fullpath(force_addon=True)[0]
|
||||||
|
|
|
@ -526,7 +526,7 @@ class InitialSetup(object):
|
||||||
force_create=True,
|
force_create=True,
|
||||||
top_element='sources') as xml:
|
top_element='sources') as xml:
|
||||||
changed = False
|
changed = False
|
||||||
for extension in ('smb://', 'nfs://'):
|
for extension in ('smb://', 'nfs://', 'plugin://'):
|
||||||
root = xml.set_setting(['video'])
|
root = xml.set_setting(['video'])
|
||||||
changed = self._add_sources(root, extension) or changed
|
changed = self._add_sources(root, extension) or changed
|
||||||
if changed:
|
if changed:
|
||||||
|
|
|
@ -442,8 +442,6 @@ def get_current_subtitle_stream_index(playerid):
|
||||||
"""
|
"""
|
||||||
Returns the currently active subtitle stream index [int] or None if there
|
Returns the currently active subtitle stream index [int] or None if there
|
||||||
are no subs
|
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:
|
try:
|
||||||
return JsonRPC('Player.GetProperties').execute({
|
return JsonRPC('Player.GetProperties').execute({
|
||||||
|
@ -456,8 +454,6 @@ def get_current_subtitle_stream_index(playerid):
|
||||||
def get_subtitle_enabled(playerid):
|
def get_subtitle_enabled(playerid):
|
||||||
"""
|
"""
|
||||||
Returns True if a subtitle is currently enabled, False otherwise.
|
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({
|
return JsonRPC('Player.GetProperties').execute({
|
||||||
'playerid': playerid,
|
'playerid': playerid,
|
||||||
|
|
|
@ -17,7 +17,7 @@ from .kodi_db import KodiVideoDB
|
||||||
from . import kodi_db
|
from . import kodi_db
|
||||||
from .downloadutils import DownloadUtils as DU
|
from .downloadutils import DownloadUtils as DU
|
||||||
from . import utils, timing, plex_functions as PF
|
from . import utils, timing, plex_functions as PF
|
||||||
from . import json_rpc as js, playqueue as PQ, playlist_func as PL
|
from . import json_rpc as js, playlist_func as PL
|
||||||
from . import backgroundthread, app, variables as v
|
from . import backgroundthread, app, variables as v
|
||||||
from . import exceptions
|
from . import exceptions
|
||||||
|
|
||||||
|
@ -31,11 +31,9 @@ class KodiMonitor(xbmc.Monitor):
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._already_slept = False
|
self._already_slept = False
|
||||||
self._switched_to_plex_streams = True
|
|
||||||
xbmc.Monitor.__init__(self)
|
xbmc.Monitor.__init__(self)
|
||||||
for playerid in app.PLAYSTATE.player_states:
|
for playerid in app.PLAYSTATE.player_states:
|
||||||
app.PLAYSTATE.player_states[playerid] = copy.deepcopy(app.PLAYSTATE.template)
|
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.")
|
LOG.info("Kodi monitor started.")
|
||||||
|
|
||||||
def onScanStarted(self, library):
|
def onScanStarted(self, library):
|
||||||
|
@ -141,7 +139,7 @@ class KodiMonitor(xbmc.Monitor):
|
||||||
u'playlistid': 1,
|
u'playlistid': 1,
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
playqueue = PQ.PLAYQUEUES[data['playlistid']]
|
playqueue = app.PLAYQUEUES[data['playlistid']]
|
||||||
if not playqueue.is_pkc_clear():
|
if not playqueue.is_pkc_clear():
|
||||||
playqueue.pkc_edit = True
|
playqueue.pkc_edit = True
|
||||||
playqueue.clear(kodi=False)
|
playqueue.clear(kodi=False)
|
||||||
|
@ -257,7 +255,7 @@ class KodiMonitor(xbmc.Monitor):
|
||||||
if not playerid:
|
if not playerid:
|
||||||
LOG.error('Coud not get playerid for data %s', data)
|
LOG.error('Coud not get playerid for data %s', data)
|
||||||
return
|
return
|
||||||
playqueue = PQ.PLAYQUEUES[playerid]
|
playqueue = app.PLAYQUEUES[playerid]
|
||||||
info = js.get_player_props(playerid)
|
info = js.get_player_props(playerid)
|
||||||
if playqueue.kodi_playlist_playback:
|
if playqueue.kodi_playlist_playback:
|
||||||
# Kodi will tell us the wrong position - of the playlist, not the
|
# Kodi will tell us the wrong position - of the playlist, not the
|
||||||
|
@ -310,7 +308,7 @@ class KodiMonitor(xbmc.Monitor):
|
||||||
initialize = False
|
initialize = False
|
||||||
if initialize:
|
if initialize:
|
||||||
LOG.debug('Need to initialize Plex and PKC playqueue')
|
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)
|
kodi_id, kodi_type, path = self._json_item(playerid)
|
||||||
plex_id, plex_type = self._get_ids(kodi_id, kodi_type, path)
|
plex_id, plex_type = self._get_ids(kodi_id, kodi_type, path)
|
||||||
if not plex_id:
|
if not plex_id:
|
||||||
|
@ -327,7 +325,7 @@ class KodiMonitor(xbmc.Monitor):
|
||||||
container_key = None
|
container_key = None
|
||||||
if info['playlistid'] != -1:
|
if info['playlistid'] != -1:
|
||||||
# -1 is Kodi's answer if there is no playlist
|
# -1 is Kodi's answer if there is no playlist
|
||||||
container_key = PQ.PLAYQUEUES[playerid].id
|
container_key = app.PLAYQUEUES[playerid].id
|
||||||
if container_key is not None:
|
if container_key is not None:
|
||||||
container_key = '/playQueues/%s' % container_key
|
container_key = '/playQueues/%s' % container_key
|
||||||
elif plex_id is not None:
|
elif plex_id is not None:
|
||||||
|
@ -345,6 +343,9 @@ class KodiMonitor(xbmc.Monitor):
|
||||||
# Mechanik for Plex skip intro feature
|
# Mechanik for Plex skip intro feature
|
||||||
if utils.settings('enableSkipIntro') == 'true':
|
if utils.settings('enableSkipIntro') == 'true':
|
||||||
status['intro_markers'] = item.api.intro_markers()
|
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
|
# Remember the currently playing item
|
||||||
app.PLAYSTATE.item = item
|
app.PLAYSTATE.item = item
|
||||||
# Remember that this player has been active
|
# Remember that this player has been active
|
||||||
|
@ -365,7 +366,10 @@ class KodiMonitor(xbmc.Monitor):
|
||||||
# Workaround for the Kodi add-on Up Next
|
# Workaround for the Kodi add-on Up Next
|
||||||
if not app.SYNC.direct_paths:
|
if not app.SYNC.direct_paths:
|
||||||
_notify_upnext(item)
|
_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):
|
def _on_av_change(self, data):
|
||||||
"""
|
"""
|
||||||
|
@ -375,34 +379,8 @@ class KodiMonitor(xbmc.Monitor):
|
||||||
Example data as returned by Kodi:
|
Example data as returned by Kodi:
|
||||||
{'item': {'id': 5, 'type': 'movie'},
|
{'item': {'id': 5, 'type': 'movie'},
|
||||||
'player': {'playerid': 1, 'speed': 1}}
|
'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']
|
pass
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
def _playback_cleanup(ended=False):
|
def _playback_cleanup(ended=False):
|
||||||
|
@ -421,8 +399,6 @@ def _playback_cleanup(ended=False):
|
||||||
app.CONN.plex_transient_token = None
|
app.CONN.plex_transient_token = None
|
||||||
for playerid in app.PLAYSTATE.active_players:
|
for playerid in app.PLAYSTATE.active_players:
|
||||||
status = app.PLAYSTATE.player_states[playerid]
|
status = app.PLAYSTATE.player_states[playerid]
|
||||||
# Remember the last played item later
|
|
||||||
app.PLAYSTATE.old_player_states[playerid] = copy.deepcopy(status)
|
|
||||||
# Stop transcoding
|
# Stop transcoding
|
||||||
if status['playmethod'] == v.PLAYBACK_METHOD_TRANSCODE:
|
if status['playmethod'] == v.PLAYBACK_METHOD_TRANSCODE:
|
||||||
LOG.debug('Tell the PMS to stop transcoding')
|
LOG.debug('Tell the PMS to stop transcoding')
|
||||||
|
@ -688,3 +664,19 @@ def _videolibrary_onupdate(data):
|
||||||
PF.scrobble(db_item['plex_id'], 'watched')
|
PF.scrobble(db_item['plex_id'], 'watched')
|
||||||
else:
|
else:
|
||||||
PF.scrobble(db_item['plex_id'], 'unwatched')
|
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()
|
||||||
|
|
|
@ -12,8 +12,12 @@ import xbmc
|
||||||
from .plex_api import API
|
from .plex_api import API
|
||||||
from .plex_db import PlexDB
|
from .plex_db import PlexDB
|
||||||
from .kodi_db import KodiVideoDB
|
from .kodi_db import KodiVideoDB
|
||||||
from . import plex_functions as PF, playlist_func as PL, playqueue as PQ
|
from . import plex_functions as PF
|
||||||
from . import json_rpc as js, variables as v, utils, transfer
|
from . import playlist_func as PL
|
||||||
|
from . import json_rpc as js
|
||||||
|
from . import variables as v
|
||||||
|
from . import utils
|
||||||
|
from . import transfer
|
||||||
from . import playback_decision, app
|
from . import playback_decision, app
|
||||||
from . import exceptions
|
from . import exceptions
|
||||||
|
|
||||||
|
@ -74,20 +78,19 @@ def _playback_triage(plex_id, plex_type, path, resolve, resume):
|
||||||
_ensure_resolve(abort=True)
|
_ensure_resolve(abort=True)
|
||||||
return
|
return
|
||||||
with app.APP.lock_playqueues:
|
with app.APP.lock_playqueues:
|
||||||
playqueue = PQ.get_playqueue_from_type(
|
playqueue = app.PLAYQUEUES.from_plex_type(plex_type)
|
||||||
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[plex_type])
|
|
||||||
try:
|
try:
|
||||||
pos = js.get_position(playqueue.playlistid)
|
pos = js.get_position(playqueue.playlistid)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
# Kodi bug - Playlist plays (not Playqueue) will ALWAYS be audio for
|
# Kodi bug - Playlist plays (not Playqueue) will ALWAYS be audio for
|
||||||
# add-on paths
|
# add-on paths
|
||||||
LOG.debug('No position returned from player! Assuming playlist')
|
LOG.debug('No position returned from player! Assuming playlist')
|
||||||
playqueue = PQ.get_playqueue_from_type(v.KODI_PLAYLIST_TYPE_AUDIO)
|
playqueue = app.PLAYQUEUES.audio
|
||||||
try:
|
try:
|
||||||
pos = js.get_position(playqueue.playlistid)
|
pos = js.get_position(playqueue.playlistid)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
LOG.debug('Assuming video instead of audio playlist playback')
|
LOG.debug('Assuming video instead of audio playlist playback')
|
||||||
playqueue = PQ.get_playqueue_from_type(v.KODI_PLAYLIST_TYPE_VIDEO)
|
playqueue = app.PLAYQUEUES.video
|
||||||
try:
|
try:
|
||||||
pos = js.get_position(playqueue.playlistid)
|
pos = js.get_position(playqueue.playlistid)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
|
@ -159,7 +162,7 @@ def _playlist_playback(plex_id):
|
||||||
return
|
return
|
||||||
# Kodi bug: playqueue will ALWAYS be audio playqueue UNTIL playback
|
# Kodi bug: playqueue will ALWAYS be audio playqueue UNTIL playback
|
||||||
# has actually started. Need to tell Kodimonitor
|
# has actually started. Need to tell Kodimonitor
|
||||||
playqueue = PQ.get_playqueue_from_type(v.KODI_PLAYLIST_TYPE_AUDIO)
|
playqueue = app.PLAYQUEUES.audio
|
||||||
playqueue.clear(kodi=False)
|
playqueue.clear(kodi=False)
|
||||||
# Set the flag for the potentially WRONG audio playlist so Kodimonitor
|
# Set the flag for the potentially WRONG audio playlist so Kodimonitor
|
||||||
# can pick up on it
|
# can pick up on it
|
||||||
|
@ -499,8 +502,7 @@ def process_indirect(key, offset, resolve=True):
|
||||||
|
|
||||||
api = API(xml[0])
|
api = API(xml[0])
|
||||||
listitem = api.listitem(listitem=transfer.PKCListItem, resume=False)
|
listitem = api.listitem(listitem=transfer.PKCListItem, resume=False)
|
||||||
playqueue = PQ.get_playqueue_from_type(
|
playqueue = app.PLAYQUEUES.from_plex_type(api.plex_type)
|
||||||
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[api.plex_type])
|
|
||||||
playqueue.clear()
|
playqueue.clear()
|
||||||
item = PL.playlist_item_from_xml(xml[0])
|
item = PL.playlist_item_from_xml(xml[0])
|
||||||
item.offset = offset
|
item.offset = offset
|
||||||
|
|
|
@ -22,117 +22,6 @@ from .subtitles import accessible_plex_subtitles
|
||||||
LOG = getLogger('PLEX.playlist_func')
|
LOG = getLogger('PLEX.playlist_func')
|
||||||
|
|
||||||
|
|
||||||
class Playqueue_Object(object):
|
|
||||||
"""
|
|
||||||
PKC object to represent PMS playQueues and Kodi playlist for queueing
|
|
||||||
|
|
||||||
playlistid = None [int] Kodi playlist id (0, 1, 2)
|
|
||||||
type = None [str] Kodi type: 'audio', 'video', 'picture'
|
|
||||||
kodi_pl = None Kodi xbmc.PlayList object
|
|
||||||
items = [] [list] of Playlist_Items
|
|
||||||
id = None [str] Plex playQueueID, unique Plex identifier
|
|
||||||
version = None [int] Plex version of the playQueue
|
|
||||||
selectedItemID = None
|
|
||||||
[str] Plex selectedItemID, playing element in queue
|
|
||||||
selectedItemOffset = None
|
|
||||||
[str] Offset of the playing element in queue
|
|
||||||
shuffled = 0 [int] 0: not shuffled, 1: ??? 2: ???
|
|
||||||
repeat = 0 [int] 0: not repeated, 1: ??? 2: ???
|
|
||||||
|
|
||||||
If Companion playback is initiated by another user:
|
|
||||||
plex_transient_token = None
|
|
||||||
"""
|
|
||||||
kind = 'playQueue'
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.id = None
|
|
||||||
self.type = None
|
|
||||||
self.playlistid = None
|
|
||||||
self.kodi_pl = None
|
|
||||||
self.items = []
|
|
||||||
self.version = None
|
|
||||||
self.selectedItemID = None
|
|
||||||
self.selectedItemOffset = None
|
|
||||||
self.shuffled = 0
|
|
||||||
self.repeat = 0
|
|
||||||
self.plex_transient_token = None
|
|
||||||
# Need a hack for detecting swaps of elements
|
|
||||||
self.old_kodi_pl = []
|
|
||||||
# Did PKC itself just change the playqueue so the PKC playqueue monitor
|
|
||||||
# should not pick up any changes?
|
|
||||||
self.pkc_edit = False
|
|
||||||
# Workaround to avoid endless loops of detecting PL clears
|
|
||||||
self._clear_list = []
|
|
||||||
# To keep track if Kodi playback was initiated from a Kodi playlist
|
|
||||||
# There are a couple of pitfalls, unfortunately...
|
|
||||||
self.kodi_playlist_playback = False
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
answ = ("{{"
|
|
||||||
"'playlistid': {self.playlistid}, "
|
|
||||||
"'id': {self.id}, "
|
|
||||||
"'version': {self.version}, "
|
|
||||||
"'type': '{self.type}', "
|
|
||||||
"'selectedItemID': {self.selectedItemID}, "
|
|
||||||
"'selectedItemOffset': {self.selectedItemOffset}, "
|
|
||||||
"'shuffled': {self.shuffled}, "
|
|
||||||
"'repeat': {self.repeat}, "
|
|
||||||
"'kodi_playlist_playback': {self.kodi_playlist_playback}, "
|
|
||||||
"'pkc_edit': {self.pkc_edit}, ".format(self=self))
|
|
||||||
# Since list.__repr__ will return string, not unicode
|
|
||||||
return answ + "'items': {self.items}}}".format(self=self)
|
|
||||||
|
|
||||||
def is_pkc_clear(self):
|
|
||||||
"""
|
|
||||||
Returns True if PKC has cleared the Kodi playqueue just recently.
|
|
||||||
Then this clear will be ignored from now on
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
self._clear_list.pop()
|
|
||||||
except IndexError:
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
return True
|
|
||||||
|
|
||||||
def clear(self, kodi=True):
|
|
||||||
"""
|
|
||||||
Resets the playlist object to an empty playlist.
|
|
||||||
|
|
||||||
Pass kodi=False in order to NOT clear the Kodi playqueue
|
|
||||||
"""
|
|
||||||
# kodi monitor's on_clear method will only be called if there were some
|
|
||||||
# items to begin with
|
|
||||||
if kodi and self.kodi_pl.size() != 0:
|
|
||||||
self._clear_list.append(None)
|
|
||||||
self.kodi_pl.clear() # Clear Kodi playlist object
|
|
||||||
self.items = []
|
|
||||||
self.id = None
|
|
||||||
self.version = None
|
|
||||||
self.selectedItemID = None
|
|
||||||
self.selectedItemOffset = None
|
|
||||||
self.shuffled = 0
|
|
||||||
self.repeat = 0
|
|
||||||
self.plex_transient_token = None
|
|
||||||
self.old_kodi_pl = []
|
|
||||||
self.kodi_playlist_playback = False
|
|
||||||
LOG.debug('Playlist cleared: %s', self)
|
|
||||||
|
|
||||||
def position_from_plex_id(self, plex_id):
|
|
||||||
"""
|
|
||||||
Returns the position [int] for the very first item with plex_id [int]
|
|
||||||
(Plex seems uncapable of adding the same element multiple times to a
|
|
||||||
playqueue or playlist)
|
|
||||||
|
|
||||||
Raises KeyError if not found
|
|
||||||
"""
|
|
||||||
for position, item in enumerate(self.items):
|
|
||||||
if item.plex_id == plex_id:
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
raise KeyError('Did not find plex_id %s in %s', plex_id, self)
|
|
||||||
return position
|
|
||||||
|
|
||||||
|
|
||||||
class PlaylistItem(object):
|
class PlaylistItem(object):
|
||||||
"""
|
"""
|
||||||
Object to fill our playqueues and playlists with.
|
Object to fill our playqueues and playlists with.
|
||||||
|
@ -166,6 +55,7 @@ class PlaylistItem(object):
|
||||||
self.playmethod = None
|
self.playmethod = None
|
||||||
self.playcount = None
|
self.playcount = None
|
||||||
self.offset = None
|
self.offset = None
|
||||||
|
self.playerid = None
|
||||||
# Transcoding quality, if needed
|
# Transcoding quality, if needed
|
||||||
self.quality = None
|
self.quality = None
|
||||||
# If Plex video consists of several parts; part number
|
# If Plex video consists of several parts; part number
|
||||||
|
@ -183,11 +73,12 @@ class PlaylistItem(object):
|
||||||
self._audio_streams = None
|
self._audio_streams = None
|
||||||
self._subtitle_streams = None
|
self._subtitle_streams = None
|
||||||
# Which Kodi streams are active?
|
# Which Kodi streams are active?
|
||||||
self.current_kodi_video_stream = None
|
self._current_kodi_video_stream = None
|
||||||
self.current_kodi_audio_stream = None
|
self._current_kodi_audio_stream = None
|
||||||
# False means "deactivated", None means "we do not have a Kodi
|
# Kodi subs can be turned on/off additionally!
|
||||||
# equivalent for this Plex subtitle"
|
self._current_kodi_sub_stream = None
|
||||||
self.current_kodi_sub_stream = None
|
self._current_kodi_sub_stream_enabled = None
|
||||||
|
self.streams_initialized = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def plex_id(self):
|
def plex_id(self):
|
||||||
|
@ -221,6 +112,48 @@ class PlaylistItem(object):
|
||||||
self._process_streams()
|
self._process_streams()
|
||||||
return self._subtitle_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
|
@property
|
||||||
def current_plex_video_stream(self):
|
def current_plex_video_stream(self):
|
||||||
return self.plex_stream_index(self.current_kodi_video_stream, 'video')
|
return self.plex_stream_index(self.current_kodi_video_stream, 'video')
|
||||||
|
@ -247,7 +180,7 @@ class PlaylistItem(object):
|
||||||
"'resume': {self.resume},"
|
"'resume': {self.resume},"
|
||||||
"'offset': {self.offset}, "
|
"'offset': {self.offset}, "
|
||||||
"'force_transcode': {self.force_transcode}, "
|
"'force_transcode': {self.force_transcode}, "
|
||||||
"'part': {self.part}".format(self=self))
|
"'part': {self.part}}}".format(self=self))
|
||||||
|
|
||||||
def _process_streams(self):
|
def _process_streams(self):
|
||||||
"""
|
"""
|
||||||
|
@ -281,44 +214,96 @@ class PlaylistItem(object):
|
||||||
elif stream_type == 'video':
|
elif stream_type == 'video':
|
||||||
return self.video_streams
|
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
|
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)
|
if not app.PLAYSTATE.item == self:
|
||||||
self.current_kodi_audio_stream = js.get_current_audio_stream_index(v.KODI_VIDEO_PLAYER_ID)
|
# Already stopped playback or skipped to the next one
|
||||||
self.current_kodi_sub_stream = False if not js.get_subtitle_enabled(v.KODI_VIDEO_PLAYER_ID) \
|
LOG.warn('Skipping init_streams!')
|
||||||
else js.get_current_subtitle_stream_index(v.KODI_VIDEO_PLAYER_ID)
|
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):
|
def plex_stream_index(self, kodi_stream_index, stream_type):
|
||||||
"""
|
"""
|
||||||
Pass in the kodi_stream_index [int] in order to receive the Plex stream
|
Pass in the kodi_stream_index [int] in order to receive the Plex stream
|
||||||
index [int].
|
index [int].
|
||||||
stream_type: 'video', 'audio', 'subtitle'
|
stream_type: 'video', 'audio', 'subtitle'
|
||||||
Returns None if unsuccessful
|
|
||||||
"""
|
"""
|
||||||
if stream_type == 'audio':
|
if stream_type == 'audio':
|
||||||
return int(self.audio_streams[kodi_stream_index].get('id'))
|
return int(self.audio_streams[kodi_stream_index].get('id'))
|
||||||
elif stream_type == 'video':
|
elif stream_type == 'video':
|
||||||
return int(self.video_streams[kodi_stream_index].get('id'))
|
return int(self.video_streams[kodi_stream_index].get('id'))
|
||||||
elif stream_type == 'subtitle':
|
elif stream_type == 'subtitle':
|
||||||
try:
|
if self.current_kodi_sub_stream_enabled:
|
||||||
return int(self.subtitle_streams[kodi_stream_index].get('id'))
|
try:
|
||||||
except (IndexError, TypeError):
|
return int(self.subtitle_streams[kodi_stream_index].get('id'))
|
||||||
pass
|
except (IndexError, TypeError):
|
||||||
|
# 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):
|
def kodi_stream_index(self, plex_stream_index, stream_type):
|
||||||
"""
|
"""
|
||||||
Pass in the plex_stream_index [int] in order to receive the Kodi stream
|
Pass in the plex_stream_index [int] in order to receive the Kodi stream
|
||||||
index [int].
|
index [int].
|
||||||
stream_type: 'video', 'audio', 'subtitle'
|
stream_type: 'video', 'audio', 'subtitle'
|
||||||
Returns None if unsuccessful
|
Raises ValueError if unsuccessful
|
||||||
"""
|
"""
|
||||||
if plex_stream_index is None:
|
if not isinstance(plex_stream_index, int):
|
||||||
return
|
raise ValueError('%s plex_stream_index %s of type %s received' %
|
||||||
|
(stream_type, plex_stream_index, type(plex_stream_index)))
|
||||||
for i, stream in enumerate(self._get_iterator(stream_type)):
|
for i, stream in enumerate(self._get_iterator(stream_type)):
|
||||||
if cast(int, stream.get('id')) == plex_stream_index:
|
if cast(int, stream.get('id')) == plex_stream_index:
|
||||||
return i
|
return i
|
||||||
|
raise ValueError('No %s kodi_stream_index for plex_stream_index %s' %
|
||||||
|
(stream_type, plex_stream_index))
|
||||||
|
|
||||||
def active_plex_stream_index(self, stream_type):
|
def active_plex_stream_index(self, stream_type):
|
||||||
"""
|
"""
|
||||||
|
@ -341,16 +326,14 @@ class PlaylistItem(object):
|
||||||
except (IndexError, TypeError):
|
except (IndexError, TypeError):
|
||||||
LOG.debug('Kodi subtitle change detected to a sub %s that is '
|
LOG.debug('Kodi subtitle change detected to a sub %s that is '
|
||||||
'NOT available on the Plex side', kodi_stream_index)
|
'NOT available on the Plex side', kodi_stream_index)
|
||||||
self.current_kodi_sub_stream = None
|
plex_stream_index = 0
|
||||||
return
|
else:
|
||||||
LOG.debug('Kodi subtitle change detected: telling Plex about '
|
LOG.debug('Kodi subtitle change detected: telling Plex about '
|
||||||
'switch to index %s, Plex stream id %s',
|
'switch to index %s, Plex stream id %s',
|
||||||
kodi_stream_index, plex_stream_index)
|
kodi_stream_index, plex_stream_index)
|
||||||
self.current_kodi_sub_stream = kodi_stream_index
|
|
||||||
else:
|
else:
|
||||||
plex_stream_index = 0
|
plex_stream_index = 0
|
||||||
LOG.debug('Kodi subtitle has been deactivated, telling Plex')
|
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())
|
PF.change_subtitle(plex_stream_index, self.api.part_id())
|
||||||
|
|
||||||
def on_kodi_audio_stream_change(self, kodi_stream_index):
|
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',
|
LOG.debug('Changing Plex audio stream to %s, Kodi index %s',
|
||||||
plex_stream_index, kodi_stream_index)
|
plex_stream_index, kodi_stream_index)
|
||||||
PF.change_audio_stream(plex_stream_index, self.api.part_id())
|
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):
|
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',
|
LOG.debug('Changing Plex video stream to %s, Kodi index %s',
|
||||||
plex_stream_index, kodi_stream_index)
|
plex_stream_index, kodi_stream_index)
|
||||||
PF.change_video_stream(plex_stream_index, self.api.part_id())
|
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):
|
def _set_kodi_stream_if_different(self, kodi_index, typus):
|
||||||
self.switch_to_plex_stream('video')
|
"""Will always activate subtitles."""
|
||||||
self.switch_to_plex_stream('audio')
|
|
||||||
self.switch_to_plex_stream('subtitle')
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _set_kodi_stream_if_different(kodi_index, typus):
|
|
||||||
if typus == 'video':
|
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:
|
if current != kodi_index:
|
||||||
LOG.debug('Switching video stream')
|
LOG.debug('Switching video stream')
|
||||||
app.APP.player.setVideoStream(kodi_index)
|
app.APP.player.setVideoStream(kodi_index)
|
||||||
else:
|
else:
|
||||||
LOG.debug('Not switching video stream (no change)')
|
LOG.debug('Not switching video stream (no change)')
|
||||||
elif typus == 'audio':
|
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:
|
if current != kodi_index:
|
||||||
LOG.debug('Switching audio stream')
|
LOG.debug('Switching audio stream')
|
||||||
app.APP.player.setAudioStream(kodi_index)
|
app.APP.player.setAudioStream(kodi_index)
|
||||||
else:
|
else:
|
||||||
LOG.debug('Not switching audio stream (no change)')
|
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):
|
def switch_to_plex_stream(self, typus):
|
||||||
try:
|
try:
|
||||||
plex_index, language_tag = self.active_plex_stream_index(typus)
|
plex_index, language_tag = self.active_plex_stream_index(typus)
|
||||||
except TypeError:
|
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 '
|
LOG.debug('Deactivating Kodi subtitles because the PMS '
|
||||||
'told us to not show any subtitles')
|
'told us to not show any subtitles')
|
||||||
app.APP.player.showSubtitles(False)
|
app.APP.player.showSubtitles(False)
|
||||||
self.current_kodi_sub_stream = False
|
self._current_kodi_sub_stream_enabled = False
|
||||||
return
|
return
|
||||||
|
# Rest: video, audio and activated subs
|
||||||
LOG.debug('The PMS wants to display %s stream with Plex id %s and '
|
LOG.debug('The PMS wants to display %s stream with Plex id %s and '
|
||||||
'languageTag %s', typus, plex_index, language_tag)
|
'languageTag %s', typus, plex_index, language_tag)
|
||||||
kodi_index = self.kodi_stream_index(plex_index, typus)
|
try:
|
||||||
if kodi_index is None:
|
kodi_index = self.kodi_stream_index(plex_index, typus)
|
||||||
|
except ValueError:
|
||||||
|
kodi_index = None
|
||||||
LOG.debug('Leaving Kodi %s stream settings untouched since we '
|
LOG.debug('Leaving Kodi %s stream settings untouched since we '
|
||||||
'could not parse Plex %s stream with id %s to a Kodi'
|
'could not parse Plex %s stream with id %s to a Kodi'
|
||||||
' index', typus, typus, plex_index)
|
' index', typus, typus, plex_index)
|
||||||
|
@ -419,69 +413,55 @@ class PlaylistItem(object):
|
||||||
typus, kodi_index, plex_index)
|
typus, kodi_index, plex_index)
|
||||||
# If we're choosing an "illegal" index, this function does
|
# If we're choosing an "illegal" index, this function does
|
||||||
# need seem to fail nor log any errors
|
# need seem to fail nor log any errors
|
||||||
if typus == 'audio':
|
self._set_kodi_stream_if_different(kodi_index, typus)
|
||||||
self._set_kodi_stream_if_different(kodi_index, 'audio')
|
|
||||||
elif typus == 'subtitle':
|
|
||||||
app.APP.player.setSubtitleStream(kodi_index)
|
|
||||||
app.APP.player.showSubtitles(True)
|
|
||||||
elif typus == 'video':
|
|
||||||
self._set_kodi_stream_if_different(kodi_index, 'video')
|
|
||||||
if typus == 'audio':
|
if typus == 'audio':
|
||||||
self.current_kodi_audio_stream = kodi_index
|
self._current_kodi_audio_stream = kodi_index
|
||||||
elif typus == 'subtitle':
|
elif typus == 'subtitle':
|
||||||
self.current_kodi_sub_stream = kodi_index
|
self._current_kodi_sub_stream_enabled = True
|
||||||
|
self._current_kodi_sub_stream = kodi_index
|
||||||
elif typus == 'video':
|
elif typus == 'video':
|
||||||
self.current_kodi_video_stream = kodi_index
|
self._current_kodi_video_stream = kodi_index
|
||||||
|
|
||||||
def on_av_change(self, playerid):
|
def on_plex_stream_change(self, video_stream_id=None, audio_stream_id=None,
|
||||||
|
subtitle_stream_id=None):
|
||||||
"""
|
"""
|
||||||
Call this method if Kodi reports an "AV-Change"
|
Call this method if Plex Companion wants to change streams [ints]
|
||||||
(event "Player.OnAVChange")
|
|
||||||
"""
|
"""
|
||||||
kodi_video_stream = js.get_current_video_stream_index(playerid)
|
if video_stream_id is not None:
|
||||||
kodi_audio_stream = js.get_current_audio_stream_index(playerid)
|
try:
|
||||||
sub_enabled = js.get_subtitle_enabled(playerid)
|
kodi_index = self.kodi_stream_index(video_stream_id, 'video')
|
||||||
kodi_sub_stream = js.get_current_subtitle_stream_index(playerid)
|
except ValueError:
|
||||||
# Audio
|
LOG.error('Unexpected Plex video_stream_id %s, not changing '
|
||||||
if kodi_audio_stream != self.current_kodi_audio_stream:
|
'the video stream!', video_stream_id)
|
||||||
self.on_kodi_audio_stream_change(kodi_audio_stream)
|
return
|
||||||
# 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._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._set_kodi_stream_if_different(kodi_index, 'video')
|
||||||
self.current_kodi_video_stream = kodi_index
|
self._current_kodi_video_stream = kodi_index
|
||||||
if 'subtitleStreamID' in plex_data:
|
if audio_stream_id is not None:
|
||||||
plex_index = int(plex_data['subtitleStreamID'])
|
try:
|
||||||
if plex_index == 0:
|
kodi_index = self.kodi_stream_index(audio_stream_id, 'audio')
|
||||||
|
except ValueError:
|
||||||
|
LOG.error('Unexpected Plex audio_stream_id %s, not changing '
|
||||||
|
'the video stream!', audio_stream_id)
|
||||||
|
return
|
||||||
|
self._set_kodi_stream_if_different(kodi_index, 'audio')
|
||||||
|
self._current_kodi_audio_stream = kodi_index
|
||||||
|
if subtitle_stream_id is not None:
|
||||||
|
if subtitle_stream_id == 0:
|
||||||
app.APP.player.showSubtitles(False)
|
app.APP.player.showSubtitles(False)
|
||||||
kodi_index = False
|
self._current_kodi_sub_stream_enabled = False
|
||||||
else:
|
else:
|
||||||
kodi_index = self.kodi_stream_index(plex_index, 'subtitle')
|
try:
|
||||||
if kodi_index:
|
kodi_index = self.kodi_stream_index(subtitle_stream_id,
|
||||||
|
'subtitle')
|
||||||
|
except ValueError:
|
||||||
|
LOG.debug('The PMS wanted to change subs, but we could not'
|
||||||
|
' match the sub with id %s to a Kodi sub',
|
||||||
|
subtitle_stream_id)
|
||||||
|
else:
|
||||||
app.APP.player.setSubtitleStream(kodi_index)
|
app.APP.player.setSubtitleStream(kodi_index)
|
||||||
app.APP.player.showSubtitles(True)
|
app.APP.player.showSubtitles(True)
|
||||||
self.current_kodi_sub_stream = kodi_index
|
self._current_kodi_sub_stream_enabled = True
|
||||||
|
self._current_kodi_sub_stream = kodi_index
|
||||||
|
|
||||||
|
|
||||||
def playlist_item_from_kodi(kodi_item):
|
def playlist_item_from_kodi(kodi_item):
|
||||||
|
@ -911,14 +891,18 @@ def delete_playlist_item_from_PMS(playlist, pos):
|
||||||
Delete the item at position pos [int] on the Plex side and our playlists
|
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)
|
LOG.debug('Deleting position %s for %s on the Plex side', pos, playlist)
|
||||||
xml = DU().downloadUrl("{server}/%ss/%s/items/%s?repeat=%s" %
|
try:
|
||||||
(playlist.kind,
|
xml = DU().downloadUrl("{server}/%ss/%s/items/%s?repeat=%s" %
|
||||||
playlist.id,
|
(playlist.kind,
|
||||||
playlist.items[pos].id,
|
playlist.id,
|
||||||
playlist.repeat),
|
playlist.items[pos].id,
|
||||||
action_type="DELETE")
|
playlist.repeat),
|
||||||
del playlist.items[pos]
|
action_type="DELETE")
|
||||||
_update_playlist_version(playlist, xml)
|
except IndexError:
|
||||||
|
raise PlaylistError('Position %s out of bound for %s' % (pos, playlist))
|
||||||
|
else:
|
||||||
|
del playlist.items[pos]
|
||||||
|
_update_playlist_version(playlist, xml)
|
||||||
|
|
||||||
|
|
||||||
# Functions operating on the Kodi playlist objects ##########
|
# Functions operating on the Kodi playlist objects ##########
|
||||||
|
|
|
@ -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)
|
|
|
@ -224,7 +224,7 @@ class Artwork(object):
|
||||||
authenticate=False,
|
authenticate=False,
|
||||||
timeout=15,
|
timeout=15,
|
||||||
return_response=True)
|
return_response=True)
|
||||||
if not data.ok:
|
if data in (None, 401) or not data.ok:
|
||||||
LOG.debug('Could not download data from FanartTV')
|
LOG.debug('Could not download data from FanartTV')
|
||||||
return artworks
|
return artworks
|
||||||
data = data.json()
|
data = data.json()
|
||||||
|
|
|
@ -1,362 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
The Plex Companion master python file
|
|
||||||
"""
|
|
||||||
from logging import getLogger
|
|
||||||
from threading import Thread
|
|
||||||
from queue import Empty
|
|
||||||
from socket import SHUT_RDWR
|
|
||||||
from xbmc import executebuiltin
|
|
||||||
|
|
||||||
from .plexbmchelper import listener, plexgdm, subscribers, httppersist
|
|
||||||
from .plex_api import API
|
|
||||||
from . import utils
|
|
||||||
from . import plex_functions as PF
|
|
||||||
from . import playlist_func as PL
|
|
||||||
from . import playback
|
|
||||||
from . import json_rpc as js
|
|
||||||
from . import playqueue as PQ
|
|
||||||
from . import variables as v
|
|
||||||
from . import backgroundthread
|
|
||||||
from . import app
|
|
||||||
from . import exceptions
|
|
||||||
|
|
||||||
###############################################################################
|
|
||||||
|
|
||||||
LOG = getLogger('PLEX.plex_companion')
|
|
||||||
|
|
||||||
###############################################################################
|
|
||||||
|
|
||||||
|
|
||||||
def update_playqueue_from_PMS(playqueue,
|
|
||||||
playqueue_id=None,
|
|
||||||
repeat=None,
|
|
||||||
offset=None,
|
|
||||||
transient_token=None,
|
|
||||||
start_plex_id=None):
|
|
||||||
"""
|
|
||||||
Completely updates the Kodi playqueue with the new Plex playqueue. Pass
|
|
||||||
in playqueue_id if we need to fetch a new playqueue
|
|
||||||
|
|
||||||
repeat = 0, 1, 2
|
|
||||||
offset = time offset in Plextime (milliseconds)
|
|
||||||
"""
|
|
||||||
LOG.info('New playqueue %s received from Plex companion with offset '
|
|
||||||
'%s, repeat %s, start_plex_id %s',
|
|
||||||
playqueue_id, offset, repeat, start_plex_id)
|
|
||||||
# Safe transient token from being deleted
|
|
||||||
if transient_token is None:
|
|
||||||
transient_token = playqueue.plex_transient_token
|
|
||||||
with app.APP.lock_playqueues:
|
|
||||||
try:
|
|
||||||
xml = PL.get_PMS_playlist(playqueue, playqueue_id)
|
|
||||||
except exceptions.PlaylistError:
|
|
||||||
LOG.error('Could now download playqueue %s', playqueue_id)
|
|
||||||
return
|
|
||||||
if playqueue.id == playqueue_id:
|
|
||||||
# This seems to be happening ONLY if a Plex Companion device
|
|
||||||
# reconnects and Kodi is already playing something - silly, really
|
|
||||||
# For all other cases, a new playqueue is generated by Plex
|
|
||||||
LOG.debug('Update for existing playqueue detected')
|
|
||||||
return
|
|
||||||
playqueue.clear()
|
|
||||||
# Get new metadata for the playqueue first
|
|
||||||
try:
|
|
||||||
PL.get_playlist_details_from_xml(playqueue, xml)
|
|
||||||
except exceptions.PlaylistError:
|
|
||||||
LOG.error('Could not get playqueue ID %s', playqueue_id)
|
|
||||||
return
|
|
||||||
playqueue.repeat = 0 if not repeat else int(repeat)
|
|
||||||
playqueue.plex_transient_token = transient_token
|
|
||||||
playback.play_xml(playqueue,
|
|
||||||
xml,
|
|
||||||
offset=offset,
|
|
||||||
start_plex_id=start_plex_id)
|
|
||||||
|
|
||||||
|
|
||||||
class PlexCompanion(backgroundthread.KillableThread):
|
|
||||||
"""
|
|
||||||
Plex Companion monitoring class. Invoke only once
|
|
||||||
"""
|
|
||||||
def __init__(self):
|
|
||||||
LOG.info("----===## Starting PlexCompanion ##===----")
|
|
||||||
# Init Plex Companion queue
|
|
||||||
# Start GDM for server/client discovery
|
|
||||||
self.client = plexgdm.plexgdm()
|
|
||||||
self.client.clientDetails()
|
|
||||||
LOG.debug("Registration string is:\n%s", self.client.getClientDetails())
|
|
||||||
self.httpd = False
|
|
||||||
self.subscription_manager = None
|
|
||||||
super(PlexCompanion, self).__init__()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _process_alexa(data):
|
|
||||||
if 'key' not in data or 'containerKey' not in data:
|
|
||||||
LOG.error('Received malformed Alexa data: %s', data)
|
|
||||||
return
|
|
||||||
xml = PF.GetPlexMetadata(data['key'])
|
|
||||||
try:
|
|
||||||
xml[0].attrib
|
|
||||||
except (AttributeError, IndexError, TypeError):
|
|
||||||
LOG.error('Could not download Plex metadata for: %s', data)
|
|
||||||
return
|
|
||||||
api = API(xml[0])
|
|
||||||
if api.plex_type == v.PLEX_TYPE_ALBUM:
|
|
||||||
LOG.debug('Plex music album detected')
|
|
||||||
PQ.init_playqueue_from_plex_children(
|
|
||||||
api.plex_id,
|
|
||||||
transient_token=data.get('token'))
|
|
||||||
elif data['containerKey'].startswith('/playQueues/'):
|
|
||||||
_, container_key, _ = PF.ParseContainerKey(data['containerKey'])
|
|
||||||
xml = PF.DownloadChunks('{server}/playQueues/%s' % container_key)
|
|
||||||
if xml is None:
|
|
||||||
# "Play error"
|
|
||||||
utils.dialog('notification',
|
|
||||||
utils.lang(29999),
|
|
||||||
utils.lang(30128),
|
|
||||||
icon='{error}')
|
|
||||||
return
|
|
||||||
playqueue = PQ.get_playqueue_from_type(
|
|
||||||
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[api.plex_type])
|
|
||||||
playqueue.clear()
|
|
||||||
PL.get_playlist_details_from_xml(playqueue, xml)
|
|
||||||
playqueue.plex_transient_token = data.get('token')
|
|
||||||
if data.get('offset') != '0':
|
|
||||||
offset = float(data['offset']) / 1000.0
|
|
||||||
else:
|
|
||||||
offset = None
|
|
||||||
playback.play_xml(playqueue, xml, offset)
|
|
||||||
else:
|
|
||||||
app.CONN.plex_transient_token = data.get('token')
|
|
||||||
playback.playback_triage(api.plex_id,
|
|
||||||
api.plex_type,
|
|
||||||
resolve=False,
|
|
||||||
resume=data.get('offset') not in ('0', None))
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _process_node(data):
|
|
||||||
"""
|
|
||||||
E.g. watch later initiated by Companion. Basically navigating Plex
|
|
||||||
"""
|
|
||||||
app.CONN.plex_transient_token = data.get('key')
|
|
||||||
params = {
|
|
||||||
'mode': 'plex_node',
|
|
||||||
'key': f"{{server}}{data.get('key')}",
|
|
||||||
'offset': data.get('offset')
|
|
||||||
}
|
|
||||||
handle = f'RunPlugin(plugin://{utils.extend_url(v.ADDON_ID, params)})'
|
|
||||||
executebuiltin(handle)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _process_playlist(data):
|
|
||||||
if 'containerKey' not in data:
|
|
||||||
LOG.error('Received malformed playlist data: %s', data)
|
|
||||||
return
|
|
||||||
# Get the playqueue ID
|
|
||||||
_, container_key, query = PF.ParseContainerKey(data['containerKey'])
|
|
||||||
try:
|
|
||||||
playqueue = PQ.get_playqueue_from_type(
|
|
||||||
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[data['type']])
|
|
||||||
except KeyError:
|
|
||||||
# E.g. Plex web does not supply the media type
|
|
||||||
# Still need to figure out the type (video vs. music vs. pix)
|
|
||||||
xml = PF.GetPlexMetadata(data['key'])
|
|
||||||
try:
|
|
||||||
xml[0].attrib
|
|
||||||
except (AttributeError, IndexError, TypeError):
|
|
||||||
LOG.error('Could not download Plex metadata')
|
|
||||||
return
|
|
||||||
api = API(xml[0])
|
|
||||||
playqueue = PQ.get_playqueue_from_type(
|
|
||||||
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[api.plex_type])
|
|
||||||
key = data.get('key')
|
|
||||||
if key:
|
|
||||||
_, key, _ = PF.ParseContainerKey(key)
|
|
||||||
update_playqueue_from_PMS(playqueue,
|
|
||||||
playqueue_id=container_key,
|
|
||||||
repeat=query.get('repeat'),
|
|
||||||
offset=utils.cast(int, data.get('offset')),
|
|
||||||
transient_token=data.get('token'),
|
|
||||||
start_plex_id=key)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _process_streams(data):
|
|
||||||
"""
|
|
||||||
Plex Companion client adjusted audio or subtitle stream
|
|
||||||
"""
|
|
||||||
if 'type' not in data:
|
|
||||||
LOG.error('Received malformed stream data: %s', data)
|
|
||||||
return
|
|
||||||
playqueue = PQ.get_playqueue_from_type(
|
|
||||||
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[data['type']])
|
|
||||||
pos = js.get_position(playqueue.playlistid)
|
|
||||||
playqueue.items[pos].on_plex_stream_change(data)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _process_refresh(data):
|
|
||||||
"""
|
|
||||||
example data: {'playQueueID': '8475', 'commandID': '11'}
|
|
||||||
"""
|
|
||||||
if 'playQueueID' not in data:
|
|
||||||
LOG.error('Received malformed refresh data: %s', data)
|
|
||||||
return
|
|
||||||
xml = PL.get_pms_playqueue(data['playQueueID'])
|
|
||||||
if xml is None:
|
|
||||||
return
|
|
||||||
if len(xml) == 0:
|
|
||||||
LOG.debug('Empty playqueue received - clearing playqueue')
|
|
||||||
plex_type = PL.get_plextype_from_xml(xml)
|
|
||||||
if plex_type is None:
|
|
||||||
return
|
|
||||||
playqueue = PQ.get_playqueue_from_type(
|
|
||||||
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[plex_type])
|
|
||||||
playqueue.clear()
|
|
||||||
return
|
|
||||||
playqueue = PQ.get_playqueue_from_type(
|
|
||||||
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[xml[0].attrib['type']])
|
|
||||||
update_playqueue_from_PMS(playqueue, data['playQueueID'])
|
|
||||||
|
|
||||||
def _process_tasks(self, task):
|
|
||||||
"""
|
|
||||||
Processes tasks picked up e.g. by Companion listener, e.g.
|
|
||||||
{'action': 'playlist',
|
|
||||||
'data': {'address': 'xyz.plex.direct',
|
|
||||||
'commandID': '7',
|
|
||||||
'containerKey': '/playQueues/6669?own=1&repeat=0&window=200',
|
|
||||||
'key': '/library/metadata/220493',
|
|
||||||
'machineIdentifier': 'xyz',
|
|
||||||
'offset': '0',
|
|
||||||
'port': '32400',
|
|
||||||
'protocol': 'https',
|
|
||||||
'token': 'transient-cd2527d1-0484-48e0-a5f7-f5caa7d591bd',
|
|
||||||
'type': 'video'}}
|
|
||||||
"""
|
|
||||||
LOG.debug('Processing: %s', task)
|
|
||||||
data = task['data']
|
|
||||||
if task['action'] == 'alexa':
|
|
||||||
with app.APP.lock_playqueues:
|
|
||||||
self._process_alexa(data)
|
|
||||||
elif (task['action'] == 'playlist' and
|
|
||||||
data.get('address') == 'node.plexapp.com'):
|
|
||||||
self._process_node(data)
|
|
||||||
elif task['action'] == 'playlist':
|
|
||||||
with app.APP.lock_playqueues:
|
|
||||||
self._process_playlist(data)
|
|
||||||
elif task['action'] == 'refreshPlayQueue':
|
|
||||||
with app.APP.lock_playqueues:
|
|
||||||
self._process_refresh(data)
|
|
||||||
elif task['action'] == 'setStreams':
|
|
||||||
try:
|
|
||||||
self._process_streams(data)
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def run(self):
|
|
||||||
"""
|
|
||||||
Ensure that sockets will be closed no matter what
|
|
||||||
"""
|
|
||||||
app.APP.register_thread(self)
|
|
||||||
try:
|
|
||||||
self._run()
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
self.httpd.socket.shutdown(SHUT_RDWR)
|
|
||||||
except AttributeError:
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
self.httpd.socket.close()
|
|
||||||
except AttributeError:
|
|
||||||
pass
|
|
||||||
app.APP.deregister_thread(self)
|
|
||||||
LOG.info("----===## Plex Companion stopped ##===----")
|
|
||||||
|
|
||||||
def _run(self):
|
|
||||||
httpd = self.httpd
|
|
||||||
# Cache for quicker while loops
|
|
||||||
client = self.client
|
|
||||||
|
|
||||||
# Start up instances
|
|
||||||
request_mgr = httppersist.RequestMgr()
|
|
||||||
subscription_manager = subscribers.SubscriptionMgr(request_mgr,
|
|
||||||
app.APP.player)
|
|
||||||
self.subscription_manager = subscription_manager
|
|
||||||
|
|
||||||
if utils.settings('plexCompanion') == 'true':
|
|
||||||
# Start up httpd
|
|
||||||
start_count = 0
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
httpd = listener.PKCHTTPServer(
|
|
||||||
client,
|
|
||||||
subscription_manager,
|
|
||||||
('', v.COMPANION_PORT),
|
|
||||||
listener.MyHandler)
|
|
||||||
httpd.timeout = 10.0
|
|
||||||
break
|
|
||||||
except Exception:
|
|
||||||
LOG.error("Unable to start PlexCompanion. Traceback:")
|
|
||||||
import traceback
|
|
||||||
LOG.error(traceback.print_exc())
|
|
||||||
app.APP.monitor.waitForAbort(3)
|
|
||||||
if start_count == 3:
|
|
||||||
LOG.error("Error: Unable to start web helper.")
|
|
||||||
httpd = False
|
|
||||||
break
|
|
||||||
start_count += 1
|
|
||||||
else:
|
|
||||||
LOG.info('User deactivated Plex Companion')
|
|
||||||
client.start_all()
|
|
||||||
message_count = 0
|
|
||||||
if httpd:
|
|
||||||
thread = Thread(target=httpd.handle_request)
|
|
||||||
|
|
||||||
while not self.should_cancel():
|
|
||||||
# If we are not authorized, sleep
|
|
||||||
# Otherwise, we trigger a download which leads to a
|
|
||||||
# re-authorizations
|
|
||||||
if self.should_suspend():
|
|
||||||
if self.wait_while_suspended():
|
|
||||||
break
|
|
||||||
try:
|
|
||||||
message_count += 1
|
|
||||||
if httpd:
|
|
||||||
if not thread.is_alive():
|
|
||||||
# Use threads cause the method will stall
|
|
||||||
thread = Thread(target=httpd.handle_request)
|
|
||||||
thread.start()
|
|
||||||
|
|
||||||
if message_count == 3000:
|
|
||||||
message_count = 0
|
|
||||||
if client.check_client_registration():
|
|
||||||
LOG.debug('Client is still registered')
|
|
||||||
else:
|
|
||||||
LOG.debug('Client is no longer registered. Plex '
|
|
||||||
'Companion still running on port %s',
|
|
||||||
v.COMPANION_PORT)
|
|
||||||
client.register_as_client()
|
|
||||||
# Get and set servers
|
|
||||||
if message_count % 30 == 0:
|
|
||||||
subscription_manager.serverlist = client.getServerList()
|
|
||||||
subscription_manager.notify()
|
|
||||||
if not httpd:
|
|
||||||
message_count = 0
|
|
||||||
except Exception:
|
|
||||||
LOG.warn("Error in loop, continuing anyway. Traceback:")
|
|
||||||
import traceback
|
|
||||||
LOG.warn(traceback.format_exc())
|
|
||||||
# See if there's anything we need to process
|
|
||||||
try:
|
|
||||||
task = app.APP.companion_queue.get(block=False)
|
|
||||||
except Empty:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
# Got instructions, process them
|
|
||||||
self._process_tasks(task)
|
|
||||||
app.APP.companion_queue.task_done()
|
|
||||||
# Don't sleep
|
|
||||||
continue
|
|
||||||
self.sleep(0.05)
|
|
||||||
subscription_manager.signal_stop()
|
|
||||||
client.stop_all()
|
|
5
resources/lib/plex_companion/__init__.py
Normal file
5
resources/lib/plex_companion/__init__.py
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
from .polling import Polling
|
||||||
|
from .playstate import PlaystateMgr
|
317
resources/lib/plex_companion/common.py
Normal file
317
resources/lib/plex_companion/common.py
Normal 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__()
|
143
resources/lib/plex_companion/playqueue.py
Normal file
143
resources/lib/plex_companion/playqueue.py
Normal 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')
|
267
resources/lib/plex_companion/playstate.py
Normal file
267
resources/lib/plex_companion/playstate.py
Normal 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)
|
210
resources/lib/plex_companion/plexgdm.py
Normal file
210
resources/lib/plex_companion/plexgdm.py
Normal 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)
|
165
resources/lib/plex_companion/polling.py
Normal file
165
resources/lib/plex_companion/polling.py
Normal 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
|
250
resources/lib/plex_companion/processing.py
Normal file
250
resources/lib/plex_companion/processing.py
Normal 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
|
201
resources/lib/plex_companion/webserver.py
Normal file
201
resources/lib/plex_companion/webserver.py
Normal 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
|
|
@ -18,7 +18,7 @@ class Playlists(object):
|
||||||
|
|
||||||
def delete_playlist(self, playlist):
|
def delete_playlist(self, playlist):
|
||||||
"""
|
"""
|
||||||
Removes the entry for playlist [Playqueue_Object] from the Plex
|
Removes the entry for playlist [Playqueue()] from the Plex
|
||||||
playlists table.
|
playlists table.
|
||||||
Be sure to either set playlist.id or playlist.kodi_path
|
Be sure to either set playlist.id or playlist.kodi_path
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -1 +0,0 @@
|
||||||
# Dummy file to make this directory a package.
|
|
|
@ -1,105 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from logging import getLogger
|
|
||||||
import http.client
|
|
||||||
import traceback
|
|
||||||
import string
|
|
||||||
import errno
|
|
||||||
from socket import error as socket_error
|
|
||||||
|
|
||||||
###############################################################################
|
|
||||||
|
|
||||||
LOG = getLogger('PLEX.httppersist')
|
|
||||||
|
|
||||||
###############################################################################
|
|
||||||
|
|
||||||
|
|
||||||
class RequestMgr(object):
|
|
||||||
def __init__(self):
|
|
||||||
self.conns = {}
|
|
||||||
|
|
||||||
def getConnection(self, protocol, host, port):
|
|
||||||
conn = self.conns.get(protocol + host + str(port), False)
|
|
||||||
if not conn:
|
|
||||||
if protocol == "https":
|
|
||||||
conn = http.client.HTTPSConnection(host, port)
|
|
||||||
else:
|
|
||||||
conn = http.client.HTTPConnection(host, port)
|
|
||||||
self.conns[protocol + host + str(port)] = conn
|
|
||||||
return conn
|
|
||||||
|
|
||||||
def closeConnection(self, protocol, host, port):
|
|
||||||
conn = self.conns.get(protocol + host + str(port), False)
|
|
||||||
if conn:
|
|
||||||
conn.close()
|
|
||||||
self.conns.pop(protocol + host + str(port), None)
|
|
||||||
|
|
||||||
def dumpConnections(self):
|
|
||||||
for conn in list(self.conns.values()):
|
|
||||||
conn.close()
|
|
||||||
self.conns = {}
|
|
||||||
|
|
||||||
def post(self, host, port, path, body, header={}, protocol="http"):
|
|
||||||
conn = None
|
|
||||||
try:
|
|
||||||
conn = self.getConnection(protocol, host, port)
|
|
||||||
header['Connection'] = "keep-alive"
|
|
||||||
conn.request("POST", path, body, header)
|
|
||||||
data = conn.getresponse()
|
|
||||||
if int(data.status) >= 400:
|
|
||||||
LOG.error("HTTP response error: %s" % str(data.status))
|
|
||||||
# this should return false, but I'm hacking it since iOS
|
|
||||||
# returns 404 no matter what
|
|
||||||
return data.read() or True
|
|
||||||
else:
|
|
||||||
return data.read() or True
|
|
||||||
except socket_error as serr:
|
|
||||||
# Ignore remote close and connection refused (e.g. shutdown PKC)
|
|
||||||
if serr.errno in (errno.WSAECONNABORTED, errno.WSAECONNREFUSED):
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
LOG.error("Unable to connect to %s\nReason:" % host)
|
|
||||||
LOG.error(traceback.print_exc())
|
|
||||||
self.conns.pop(protocol + host + str(port), None)
|
|
||||||
if conn:
|
|
||||||
conn.close()
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
LOG.error("Exception encountered: %s", e)
|
|
||||||
# Close connection just in case
|
|
||||||
try:
|
|
||||||
conn.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return False
|
|
||||||
|
|
||||||
def getwithparams(self, host, port, path, params, header={},
|
|
||||||
protocol="http"):
|
|
||||||
newpath = path + '?'
|
|
||||||
pairs = []
|
|
||||||
for key in params:
|
|
||||||
pairs.append(str(key) + '=' + str(params[key]))
|
|
||||||
newpath += string.join(pairs, '&')
|
|
||||||
return self.get(host, port, newpath, header, protocol)
|
|
||||||
|
|
||||||
def get(self, host, port, path, header={}, protocol="http"):
|
|
||||||
try:
|
|
||||||
conn = self.getConnection(protocol, host, port)
|
|
||||||
header['Connection'] = "keep-alive"
|
|
||||||
conn.request("GET", path, headers=header)
|
|
||||||
data = conn.getresponse()
|
|
||||||
if int(data.status) >= 400:
|
|
||||||
LOG.error("HTTP response error: %s", str(data.status))
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
return data.read() or True
|
|
||||||
except socket_error as serr:
|
|
||||||
# Ignore remote close and connection refused (e.g. shutdown PKC)
|
|
||||||
if serr.errno in (errno.WSAECONNABORTED, errno.WSAECONNREFUSED):
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
LOG.error("Unable to connect to %s\nReason:", host)
|
|
||||||
LOG.error(traceback.print_exc())
|
|
||||||
self.conns.pop(protocol + host + str(port), None)
|
|
||||||
conn.close()
|
|
||||||
return False
|
|
|
@ -1,238 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
Plex Companion listener
|
|
||||||
"""
|
|
||||||
from logging import getLogger
|
|
||||||
from re import sub
|
|
||||||
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
|
|
||||||
|
|
||||||
from .. import utils, companion, json_rpc as js, clientinfo, variables as v
|
|
||||||
from .. import app
|
|
||||||
|
|
||||||
###############################################################################
|
|
||||||
|
|
||||||
LOG = getLogger('PLEX.listener')
|
|
||||||
|
|
||||||
# Hack we need in order to keep track of the open connections from Plex Web
|
|
||||||
CLIENT_DICT = {}
|
|
||||||
|
|
||||||
###############################################################################
|
|
||||||
|
|
||||||
RESOURCES_XML = ('%s<MediaContainer>\n'
|
|
||||||
' <Player'
|
|
||||||
' title="{title}"'
|
|
||||||
' protocol="plex"'
|
|
||||||
' protocolVersion="1"'
|
|
||||||
' protocolCapabilities="timeline,playback,navigation,playqueues"'
|
|
||||||
' machineIdentifier="{machineIdentifier}"'
|
|
||||||
' product="%s"'
|
|
||||||
' platform="%s"'
|
|
||||||
' platformVersion="%s"'
|
|
||||||
' deviceClass="pc"/>\n'
|
|
||||||
'</MediaContainer>\n') % (v.XML_HEADER,
|
|
||||||
v.ADDON_NAME,
|
|
||||||
v.PLATFORM,
|
|
||||||
v.PLATFORM_VERSION)
|
|
||||||
|
|
||||||
|
|
||||||
class MyHandler(BaseHTTPRequestHandler):
|
|
||||||
"""
|
|
||||||
BaseHTTPRequestHandler implementation of Plex Companion listener
|
|
||||||
"""
|
|
||||||
protocol_version = 'HTTP/1.1'
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
self.serverlist = []
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
def log_message(self, format, *args):
|
|
||||||
'''
|
|
||||||
Mute all requests, don't log 'em
|
|
||||||
'''
|
|
||||||
pass
|
|
||||||
|
|
||||||
def do_HEAD(self):
|
|
||||||
LOG.debug("Serving HEAD request...")
|
|
||||||
self.answer_request(0)
|
|
||||||
|
|
||||||
def do_GET(self):
|
|
||||||
LOG.debug("Serving GET request...")
|
|
||||||
self.answer_request(1)
|
|
||||||
|
|
||||||
def do_OPTIONS(self):
|
|
||||||
LOG.debug("Serving OPTIONS request...")
|
|
||||||
self.send_response(200)
|
|
||||||
self.send_header('Content-Length', '0')
|
|
||||||
self.send_header('X-Plex-Client-Identifier', v.PKC_MACHINE_IDENTIFIER)
|
|
||||||
self.send_header('Content-Type', 'text/plain')
|
|
||||||
self.send_header('Connection', 'close')
|
|
||||||
self.send_header('Access-Control-Max-Age', '1209600')
|
|
||||||
self.send_header('Access-Control-Allow-Origin', '*')
|
|
||||||
self.send_header('Access-Control-Allow-Methods',
|
|
||||||
'POST, GET, OPTIONS, DELETE, PUT, HEAD')
|
|
||||||
self.send_header(
|
|
||||||
'Access-Control-Allow-Headers',
|
|
||||||
'x-plex-version, x-plex-platform-version, x-plex-username, '
|
|
||||||
'x-plex-client-identifier, x-plex-target-client-identifier, '
|
|
||||||
'x-plex-device-name, x-plex-platform, x-plex-product, accept, '
|
|
||||||
'x-plex-device, x-plex-device-screen-resolution')
|
|
||||||
self.end_headers()
|
|
||||||
|
|
||||||
def response(self, body, headers=None, code=200):
|
|
||||||
headers = {} if headers is None else headers
|
|
||||||
self.send_response(code)
|
|
||||||
for key in headers:
|
|
||||||
self.send_header(key, headers[key])
|
|
||||||
self.send_header('Content-Length', len(body))
|
|
||||||
self.end_headers()
|
|
||||||
if body:
|
|
||||||
self.wfile.write(body.encode('utf-8'))
|
|
||||||
|
|
||||||
def answer_request(self, send_data):
|
|
||||||
self.serverlist = self.server.client.getServerList()
|
|
||||||
sub_mgr = self.server.subscription_manager
|
|
||||||
|
|
||||||
request_path = self.path[1:]
|
|
||||||
request_path = sub(r"\?.*", "", request_path)
|
|
||||||
parseresult = utils.urlparse(self.path)
|
|
||||||
paramarrays = utils.parse_qs(parseresult.query)
|
|
||||||
params = {}
|
|
||||||
for key in paramarrays:
|
|
||||||
params[key] = paramarrays[key][0]
|
|
||||||
LOG.debug("remote request_path: %s, received from %s with headers: %s",
|
|
||||||
request_path, self.client_address, self.headers.items())
|
|
||||||
LOG.debug("params received from remote: %s", params)
|
|
||||||
sub_mgr.update_command_id(self.headers.get(
|
|
||||||
'X-Plex-Client-Identifier', self.client_address[0]),
|
|
||||||
params.get('commandID'))
|
|
||||||
|
|
||||||
conntype = self.headers.get('Connection', '')
|
|
||||||
if conntype.lower() == 'keep-alive':
|
|
||||||
headers = {
|
|
||||||
'Connection': 'Keep-Alive',
|
|
||||||
'Keep-Alive': 'timeout=20'
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
headers = {'Connection': 'Close'}
|
|
||||||
|
|
||||||
if request_path == "version":
|
|
||||||
self.response(
|
|
||||||
"PlexKodiConnect Plex Companion: Running\nVersion: %s"
|
|
||||||
% v.ADDON_VERSION,
|
|
||||||
headers)
|
|
||||||
elif request_path == "verify":
|
|
||||||
self.response("XBMC JSON connection test:\n" + js.ping(),
|
|
||||||
headers)
|
|
||||||
elif request_path == 'resources':
|
|
||||||
self.response(
|
|
||||||
RESOURCES_XML.format(
|
|
||||||
title=v.DEVICENAME,
|
|
||||||
machineIdentifier=v.PKC_MACHINE_IDENTIFIER),
|
|
||||||
clientinfo.getXArgsDeviceInfo(options=headers,
|
|
||||||
include_token=False))
|
|
||||||
elif request_path == 'player/timeline/poll':
|
|
||||||
# Plex web does polling if connected to PKC via Companion
|
|
||||||
# Only reply if there is indeed something playing
|
|
||||||
# Otherwise, all clients seem to keep connection open
|
|
||||||
if params.get('wait') == '1':
|
|
||||||
app.APP.monitor.waitForAbort(0.95)
|
|
||||||
if self.client_address[0] not in CLIENT_DICT:
|
|
||||||
CLIENT_DICT[self.client_address[0]] = []
|
|
||||||
tracker = CLIENT_DICT[self.client_address[0]]
|
|
||||||
tracker.append(self.client_address[1])
|
|
||||||
while (not app.APP.is_playing and
|
|
||||||
not app.APP.monitor.abortRequested() and
|
|
||||||
sub_mgr.stop_sent_to_web and not
|
|
||||||
(len(tracker) >= 4 and
|
|
||||||
tracker[0] == self.client_address[1])):
|
|
||||||
# Keep at most 3 connections open, then drop the first one
|
|
||||||
# Doesn't need to be thread-save
|
|
||||||
# Silly stuff really
|
|
||||||
app.APP.monitor.waitForAbort(1)
|
|
||||||
# Let PKC know that we're releasing this connection
|
|
||||||
tracker.pop(0)
|
|
||||||
msg = sub_mgr.msg(js.get_players()).format(
|
|
||||||
command_id=params.get('commandID', 0))
|
|
||||||
if sub_mgr.isplaying:
|
|
||||||
self.response(
|
|
||||||
msg,
|
|
||||||
{
|
|
||||||
'X-Plex-Client-Identifier': v.PKC_MACHINE_IDENTIFIER,
|
|
||||||
'X-Plex-Protocol': '1.0',
|
|
||||||
'Access-Control-Allow-Origin': '*',
|
|
||||||
'Access-Control-Max-Age': '1209600',
|
|
||||||
'Access-Control-Expose-Headers':
|
|
||||||
'X-Plex-Client-Identifier',
|
|
||||||
'Content-Type': 'text/xml;charset=utf-8'
|
|
||||||
}.update(headers))
|
|
||||||
elif not sub_mgr.stop_sent_to_web:
|
|
||||||
sub_mgr.stop_sent_to_web = True
|
|
||||||
LOG.debug('Signaling STOP to Plex Web')
|
|
||||||
self.response(
|
|
||||||
msg,
|
|
||||||
{
|
|
||||||
'X-Plex-Client-Identifier': v.PKC_MACHINE_IDENTIFIER,
|
|
||||||
'X-Plex-Protocol': '1.0',
|
|
||||||
'Access-Control-Allow-Origin': '*',
|
|
||||||
'Access-Control-Max-Age': '1209600',
|
|
||||||
'Access-Control-Expose-Headers':
|
|
||||||
'X-Plex-Client-Identifier',
|
|
||||||
'Content-Type': 'text/xml;charset=utf-8'
|
|
||||||
}.update(headers))
|
|
||||||
else:
|
|
||||||
# We're not playing anything yet, just reply with a 200
|
|
||||||
self.response(
|
|
||||||
msg,
|
|
||||||
{
|
|
||||||
'X-Plex-Client-Identifier': v.PKC_MACHINE_IDENTIFIER,
|
|
||||||
'X-Plex-Protocol': '1.0',
|
|
||||||
'Access-Control-Allow-Origin': '*',
|
|
||||||
'Access-Control-Max-Age': '1209600',
|
|
||||||
'Access-Control-Expose-Headers':
|
|
||||||
'X-Plex-Client-Identifier',
|
|
||||||
'Content-Type': 'text/xml;charset=utf-8'
|
|
||||||
}.update(headers))
|
|
||||||
elif "/subscribe" in request_path:
|
|
||||||
headers['Content-Type'] = 'text/xml;charset=utf-8'
|
|
||||||
headers = clientinfo.getXArgsDeviceInfo(options=headers,
|
|
||||||
include_token=False)
|
|
||||||
self.response(v.COMPANION_OK_MESSAGE, headers)
|
|
||||||
protocol = params.get('protocol')
|
|
||||||
host = self.client_address[0]
|
|
||||||
port = params.get('port')
|
|
||||||
uuid = self.headers.get('X-Plex-Client-Identifier')
|
|
||||||
command_id = params.get('commandID', 0)
|
|
||||||
sub_mgr.add_subscriber(protocol,
|
|
||||||
host,
|
|
||||||
port,
|
|
||||||
uuid,
|
|
||||||
command_id)
|
|
||||||
elif "/unsubscribe" in request_path:
|
|
||||||
headers['Content-Type'] = 'text/xml;charset=utf-8'
|
|
||||||
headers = clientinfo.getXArgsDeviceInfo(options=headers,
|
|
||||||
include_token=False)
|
|
||||||
self.response(v.COMPANION_OK_MESSAGE, headers)
|
|
||||||
uuid = self.headers.get('X-Plex-Client-Identifier') \
|
|
||||||
or self.client_address[0]
|
|
||||||
sub_mgr.remove_subscriber(uuid)
|
|
||||||
else:
|
|
||||||
# Throw it to companion.py
|
|
||||||
companion.process_command(request_path, params)
|
|
||||||
headers['Content-Type'] = 'text/xml;charset=utf-8'
|
|
||||||
headers = clientinfo.getXArgsDeviceInfo(options=headers,
|
|
||||||
include_token=False)
|
|
||||||
self.response(v.COMPANION_OK_MESSAGE, headers)
|
|
||||||
|
|
||||||
|
|
||||||
class PKCHTTPServer(ThreadingHTTPServer):
|
|
||||||
def __init__(self, client, subscription_manager, *args, **kwargs):
|
|
||||||
"""
|
|
||||||
client: Class handle to plexgdm.plexgdm. We can thus ask for an up-to-
|
|
||||||
date serverlist without instantiating anything
|
|
||||||
|
|
||||||
same for SubscriptionMgr
|
|
||||||
"""
|
|
||||||
self.client = client
|
|
||||||
self.subscription_manager = subscription_manager
|
|
||||||
super().__init__(*args, **kwargs)
|
|
|
@ -1,314 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
PlexGDM.py - Version 0.2
|
|
||||||
|
|
||||||
This class implements the Plex GDM (G'Day Mate) protocol to discover
|
|
||||||
local Plex Media Servers. Also allow client registration into all local
|
|
||||||
media servers.
|
|
||||||
|
|
||||||
|
|
||||||
This program is free software; you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU General Public License as published by
|
|
||||||
the Free Software Foundation; either version 2 of the License, or
|
|
||||||
(at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License
|
|
||||||
along with this program; if not, write to the Free Software
|
|
||||||
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
|
|
||||||
MA 02110-1301, USA.
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
import socket
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
|
|
||||||
from ..downloadutils import DownloadUtils as DU
|
|
||||||
from .. import utils, app, variables as v
|
|
||||||
|
|
||||||
###############################################################################
|
|
||||||
|
|
||||||
log = logging.getLogger('PLEX.plexgdm')
|
|
||||||
|
|
||||||
###############################################################################
|
|
||||||
|
|
||||||
|
|
||||||
class plexgdm(object):
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.discover_message = 'M-SEARCH * HTTP/1.0'
|
|
||||||
self.client_header = '* HTTP/1.0'
|
|
||||||
self.client_data = None
|
|
||||||
self.update_sock = None
|
|
||||||
self.discover_t = None
|
|
||||||
self.register_t = None
|
|
||||||
|
|
||||||
self._multicast_address = '239.0.0.250'
|
|
||||||
self.discover_group = (self._multicast_address, 32414)
|
|
||||||
self.client_register_group = (self._multicast_address, 32413)
|
|
||||||
self.client_update_port = int(utils.settings('companionUpdatePort'))
|
|
||||||
|
|
||||||
self.server_list = []
|
|
||||||
self.discovery_interval = 120
|
|
||||||
|
|
||||||
self._discovery_is_running = False
|
|
||||||
self._registration_is_running = False
|
|
||||||
|
|
||||||
self.client_registered = False
|
|
||||||
self.download = DU().downloadUrl
|
|
||||||
|
|
||||||
def clientDetails(self):
|
|
||||||
self.client_data = (
|
|
||||||
"Content-Type: plex/media-player\n"
|
|
||||||
"Resource-Identifier: %s\n"
|
|
||||||
"Name: %s\n"
|
|
||||||
"Port: %s\n"
|
|
||||||
"Product: %s\n"
|
|
||||||
"Version: %s\n"
|
|
||||||
"Protocol: plex\n"
|
|
||||||
"Protocol-Version: 1\n"
|
|
||||||
"Protocol-Capabilities: timeline,playback,navigation,"
|
|
||||||
"playqueues\n"
|
|
||||||
"Device-Class: HTPC\n"
|
|
||||||
) % (
|
|
||||||
v.PKC_MACHINE_IDENTIFIER,
|
|
||||||
v.DEVICENAME,
|
|
||||||
v.COMPANION_PORT,
|
|
||||||
v.ADDON_NAME,
|
|
||||||
v.ADDON_VERSION
|
|
||||||
)
|
|
||||||
|
|
||||||
def getClientDetails(self):
|
|
||||||
return self.client_data
|
|
||||||
|
|
||||||
def register_as_client(self):
|
|
||||||
"""
|
|
||||||
Registers PKC's Plex Companion to the PMS
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
log.debug("Sending registration data: HELLO %s\n%s"
|
|
||||||
% (self.client_header, self.client_data))
|
|
||||||
msg = 'HELLO {}\n{}'.format(self.client_header, self.client_data)
|
|
||||||
self.update_sock.sendto(msg.encode('utf-8'),
|
|
||||||
self.client_register_group)
|
|
||||||
log.debug('(Re-)registering PKC Plex Companion successful')
|
|
||||||
except Exception as exc:
|
|
||||||
log.error("Unable to send registration message. Error: %s", exc)
|
|
||||||
|
|
||||||
def client_update(self):
|
|
||||||
self.update_sock = socket.socket(socket.AF_INET,
|
|
||||||
socket.SOCK_DGRAM,
|
|
||||||
socket.IPPROTO_UDP)
|
|
||||||
update_sock = self.update_sock
|
|
||||||
|
|
||||||
# Set socket reuse, may not work on all OSs.
|
|
||||||
try:
|
|
||||||
update_sock.setsockopt(socket.SOL_SOCKET,
|
|
||||||
socket.SO_REUSEADDR,
|
|
||||||
1)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Attempt to bind to the socket to recieve and send data. If we cant
|
|
||||||
# do this, then we cannot send registration
|
|
||||||
try:
|
|
||||||
update_sock.bind(('0.0.0.0', self.client_update_port))
|
|
||||||
except Exception:
|
|
||||||
log.error("Unable to bind to port [%s] - Plex Companion will not "
|
|
||||||
"be registered. Change the Plex Companion update port!"
|
|
||||||
% self.client_update_port)
|
|
||||||
if utils.settings('companion_show_gdm_port_warning') == 'true':
|
|
||||||
from ..windows import optionsdialog
|
|
||||||
# Plex Companion could not open the GDM port. Please change it
|
|
||||||
# in the PKC settings.
|
|
||||||
if optionsdialog.show(utils.lang(29999),
|
|
||||||
'Port %s\n%s' % (self.client_update_port,
|
|
||||||
utils.lang(39079)),
|
|
||||||
utils.lang(30013), # Never show again
|
|
||||||
utils.lang(186)) == 0:
|
|
||||||
utils.settings('companion_show_gdm_port_warning',
|
|
||||||
value='false')
|
|
||||||
from xbmc import executebuiltin
|
|
||||||
executebuiltin(
|
|
||||||
'Addon.OpenSettings(plugin.video.plexkodiconnect)')
|
|
||||||
return
|
|
||||||
|
|
||||||
update_sock.setsockopt(socket.IPPROTO_IP,
|
|
||||||
socket.IP_MULTICAST_TTL,
|
|
||||||
255)
|
|
||||||
update_sock.setsockopt(socket.IPPROTO_IP,
|
|
||||||
socket.IP_ADD_MEMBERSHIP,
|
|
||||||
socket.inet_aton(
|
|
||||||
self._multicast_address) +
|
|
||||||
socket.inet_aton('0.0.0.0'))
|
|
||||||
update_sock.setblocking(0)
|
|
||||||
|
|
||||||
# Send initial client registration
|
|
||||||
self.register_as_client()
|
|
||||||
|
|
||||||
# Now, listen format client discovery reguests and respond.
|
|
||||||
while self._registration_is_running:
|
|
||||||
try:
|
|
||||||
data, addr = update_sock.recvfrom(1024)
|
|
||||||
data = data.decode()
|
|
||||||
log.debug("Recieved UDP packet from [%s] containing [%s]"
|
|
||||||
% (addr, data.strip()))
|
|
||||||
except socket.error:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
if "M-SEARCH * HTTP/1." in data:
|
|
||||||
log.debug('Detected client discovery request from %s. '
|
|
||||||
'Replying', addr)
|
|
||||||
message = f'HTTP/1.0 200 OK\n{self.client_data}'.encode()
|
|
||||||
try:
|
|
||||||
update_sock.sendto(message, addr)
|
|
||||||
except Exception:
|
|
||||||
log.error("Unable to send client update message")
|
|
||||||
else:
|
|
||||||
log.debug("Sent registration data HTTP/1.0 200 OK")
|
|
||||||
self.client_registered = True
|
|
||||||
app.APP.monitor.waitForAbort(0.5)
|
|
||||||
log.info("Client Update loop stopped")
|
|
||||||
# When we are finished, then send a final goodbye message to
|
|
||||||
# deregister cleanly.
|
|
||||||
log.debug("Sending registration data: BYE %s\n%s"
|
|
||||||
% (self.client_header, self.client_data))
|
|
||||||
try:
|
|
||||||
update_sock.sendto("BYE %s\n%s"
|
|
||||||
% (self.client_header, self.client_data),
|
|
||||||
self.client_register_group)
|
|
||||||
except Exception:
|
|
||||||
log.error("Unable to send client update message")
|
|
||||||
self.client_registered = False
|
|
||||||
|
|
||||||
def check_client_registration(self):
|
|
||||||
if not self.client_registered:
|
|
||||||
log.debug('Client has not been marked as registered')
|
|
||||||
return False
|
|
||||||
if not self.server_list:
|
|
||||||
log.info("Server list is empty. Unable to check")
|
|
||||||
return False
|
|
||||||
for server in self.server_list:
|
|
||||||
if server['uuid'] == app.CONN.machine_identifier:
|
|
||||||
media_server = server['server']
|
|
||||||
media_port = server['port']
|
|
||||||
scheme = server['protocol']
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
log.info("Did not find our server!")
|
|
||||||
return False
|
|
||||||
|
|
||||||
log.debug("Checking server [%s] on port [%s]"
|
|
||||||
% (media_server, media_port))
|
|
||||||
xml = self.download(
|
|
||||||
'%s://%s:%s/clients' % (scheme, media_server, media_port))
|
|
||||||
try:
|
|
||||||
xml[0].attrib
|
|
||||||
except (TypeError, IndexError, AttributeError):
|
|
||||||
log.error('Could not download clients for %s' % media_server)
|
|
||||||
return False
|
|
||||||
registered = False
|
|
||||||
for client in xml:
|
|
||||||
if (client.attrib.get('machineIdentifier') ==
|
|
||||||
v.PKC_MACHINE_IDENTIFIER):
|
|
||||||
registered = True
|
|
||||||
if registered:
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
log.info("Client registration not found. "
|
|
||||||
"Client data is: %s" % xml)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def getServerList(self):
|
|
||||||
return self.server_list
|
|
||||||
|
|
||||||
def discover(self):
|
|
||||||
currServer = app.CONN.server
|
|
||||||
if not currServer:
|
|
||||||
return
|
|
||||||
currServerProt, currServerIP, currServerPort = \
|
|
||||||
currServer.split(':')
|
|
||||||
currServerIP = currServerIP.replace('/', '')
|
|
||||||
# Currently active server was not discovered via GDM; ADD
|
|
||||||
self.server_list = [{
|
|
||||||
'port': currServerPort,
|
|
||||||
'protocol': currServerProt,
|
|
||||||
'class': None,
|
|
||||||
'content-type': 'plex/media-server',
|
|
||||||
'discovery': 'auto',
|
|
||||||
'master': 1,
|
|
||||||
'owned': '1',
|
|
||||||
'role': 'master',
|
|
||||||
'server': currServerIP,
|
|
||||||
'serverName': app.CONN.server_name,
|
|
||||||
'updated': int(time.time()),
|
|
||||||
'uuid': app.CONN.machine_identifier,
|
|
||||||
'version': 'irrelevant'
|
|
||||||
}]
|
|
||||||
|
|
||||||
def setInterval(self, interval):
|
|
||||||
self.discovery_interval = interval
|
|
||||||
|
|
||||||
def stop_all(self):
|
|
||||||
self.stop_discovery()
|
|
||||||
self.stop_registration()
|
|
||||||
|
|
||||||
def stop_discovery(self):
|
|
||||||
if self._discovery_is_running:
|
|
||||||
log.info("Discovery shutting down")
|
|
||||||
self._discovery_is_running = False
|
|
||||||
self.discover_t.join()
|
|
||||||
del self.discover_t
|
|
||||||
else:
|
|
||||||
log.info("Discovery not running")
|
|
||||||
|
|
||||||
def stop_registration(self):
|
|
||||||
if self._registration_is_running:
|
|
||||||
log.info("Registration shutting down")
|
|
||||||
self._registration_is_running = False
|
|
||||||
self.register_t.join()
|
|
||||||
del self.register_t
|
|
||||||
else:
|
|
||||||
log.info("Registration not running")
|
|
||||||
|
|
||||||
def run_discovery_loop(self):
|
|
||||||
# Run initial discovery
|
|
||||||
self.discover()
|
|
||||||
|
|
||||||
discovery_count = 0
|
|
||||||
while self._discovery_is_running:
|
|
||||||
discovery_count += 1
|
|
||||||
if discovery_count > self.discovery_interval:
|
|
||||||
self.discover()
|
|
||||||
discovery_count = 0
|
|
||||||
app.APP.monitor.waitForAbort(0.5)
|
|
||||||
|
|
||||||
def start_discovery(self, daemon=False):
|
|
||||||
if not self._discovery_is_running:
|
|
||||||
log.info("Discovery starting up")
|
|
||||||
self._discovery_is_running = True
|
|
||||||
self.discover_t = threading.Thread(target=self.run_discovery_loop)
|
|
||||||
self.discover_t.setDaemon(daemon)
|
|
||||||
self.discover_t.start()
|
|
||||||
else:
|
|
||||||
log.info("Discovery already running")
|
|
||||||
|
|
||||||
def start_registration(self, daemon=False):
|
|
||||||
if not self._registration_is_running:
|
|
||||||
log.info("Registration starting up")
|
|
||||||
self._registration_is_running = True
|
|
||||||
self.register_t = threading.Thread(target=self.client_update)
|
|
||||||
self.register_t.setDaemon(daemon)
|
|
||||||
self.register_t.start()
|
|
||||||
else:
|
|
||||||
log.info("Registration already running")
|
|
||||||
|
|
||||||
def start_all(self, daemon=False):
|
|
||||||
self.start_discovery(daemon)
|
|
||||||
if utils.settings('plexCompanion') == 'true':
|
|
||||||
self.start_registration(daemon)
|
|
|
@ -1,470 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
"""
|
|
||||||
Manages getting playstate from Kodi and sending it to the PMS as well as
|
|
||||||
subscribed Plex Companion clients.
|
|
||||||
"""
|
|
||||||
from logging import getLogger
|
|
||||||
from threading import Thread
|
|
||||||
|
|
||||||
from ..downloadutils import DownloadUtils as DU
|
|
||||||
from .. import timing
|
|
||||||
from .. import app
|
|
||||||
from .. import variables as v
|
|
||||||
from .. import json_rpc as js
|
|
||||||
from .. import playqueue as PQ
|
|
||||||
|
|
||||||
###############################################################################
|
|
||||||
LOG = getLogger('PLEX.subscribers')
|
|
||||||
###############################################################################
|
|
||||||
|
|
||||||
# What is Companion controllable?
|
|
||||||
CONTROLLABLE = {
|
|
||||||
v.PLEX_PLAYLIST_TYPE_VIDEO: 'playPause,stop,volume,shuffle,audioStream,'
|
|
||||||
'subtitleStream,seekTo,skipPrevious,skipNext,'
|
|
||||||
'stepBack,stepForward',
|
|
||||||
v.PLEX_PLAYLIST_TYPE_AUDIO: 'playPause,stop,volume,shuffle,repeat,seekTo,'
|
|
||||||
'skipPrevious,skipNext,stepBack,stepForward',
|
|
||||||
v.PLEX_PLAYLIST_TYPE_PHOTO: 'playPause,stop,skipPrevious,skipNext'
|
|
||||||
}
|
|
||||||
|
|
||||||
STREAM_DETAILS = {
|
|
||||||
'video': 'currentvideostream',
|
|
||||||
'audio': 'currentaudiostream',
|
|
||||||
'subtitle': 'currentsubtitle'
|
|
||||||
}
|
|
||||||
|
|
||||||
XML = ('%s<MediaContainer commandID="{command_id}" location="{location}">\n'
|
|
||||||
' <Timeline {%s}/>\n'
|
|
||||||
' <Timeline {%s}/>\n'
|
|
||||||
' <Timeline {%s}/>\n'
|
|
||||||
'</MediaContainer>\n') % (v.XML_HEADER,
|
|
||||||
v.PLEX_PLAYLIST_TYPE_VIDEO,
|
|
||||||
v.PLEX_PLAYLIST_TYPE_AUDIO,
|
|
||||||
v.PLEX_PLAYLIST_TYPE_PHOTO)
|
|
||||||
|
|
||||||
# Headers are different for Plex Companion - use these for PMS notifications
|
|
||||||
HEADERS_PMS = {
|
|
||||||
'Connection': 'keep-alive',
|
|
||||||
'Accept': 'text/plain, */*; q=0.01',
|
|
||||||
'Accept-Language': 'en',
|
|
||||||
'Accept-Encoding': 'gzip, deflate',
|
|
||||||
'User-Agent': '%s %s (%s)' % (v.ADDON_NAME, v.ADDON_VERSION, v.DEVICE)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def params_pms():
|
|
||||||
"""
|
|
||||||
Returns the url parameters for communicating with the PMS
|
|
||||||
"""
|
|
||||||
return {
|
|
||||||
'X-Plex-Client-Identifier': v.PKC_MACHINE_IDENTIFIER,
|
|
||||||
'X-Plex-Device': v.DEVICE,
|
|
||||||
'X-Plex-Device-Name': v.DEVICENAME,
|
|
||||||
'X-Plex-Model': v.MODEL,
|
|
||||||
'X-Plex-Platform': v.PLATFORM,
|
|
||||||
'X-Plex-Platform-Version': v.PLATFORM_VERSION,
|
|
||||||
'X-Plex-Product': v.ADDON_NAME,
|
|
||||||
'X-Plex-Version': v.ADDON_VERSION,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def headers_companion_client():
|
|
||||||
"""
|
|
||||||
Headers are different for Plex Companion - use these for a Plex Companion
|
|
||||||
client
|
|
||||||
"""
|
|
||||||
return {
|
|
||||||
'Content-Type': 'application/xml',
|
|
||||||
'Connection': 'Keep-Alive',
|
|
||||||
'X-Plex-Client-Identifier': v.PKC_MACHINE_IDENTIFIER,
|
|
||||||
'X-Plex-Device-Name': v.DEVICENAME,
|
|
||||||
'X-Plex-Platform': v.PLATFORM,
|
|
||||||
'X-Plex-Platform-Version': v.PLATFORM_VERSION,
|
|
||||||
'X-Plex-Product': v.ADDON_NAME,
|
|
||||||
'X-Plex-Version': v.ADDON_VERSION,
|
|
||||||
'Accept-Encoding': 'gzip, deflate',
|
|
||||||
'Accept-Language': 'en,*'
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def update_player_info(playerid):
|
|
||||||
"""
|
|
||||||
Updates all player info for playerid [int] in state.py.
|
|
||||||
"""
|
|
||||||
app.PLAYSTATE.player_states[playerid].update(js.get_player_props(playerid))
|
|
||||||
app.PLAYSTATE.player_states[playerid]['volume'] = js.get_volume()
|
|
||||||
app.PLAYSTATE.player_states[playerid]['muted'] = js.get_muted()
|
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionMgr(object):
|
|
||||||
"""
|
|
||||||
Manages Plex companion subscriptions
|
|
||||||
"""
|
|
||||||
def __init__(self, request_mgr, player):
|
|
||||||
self.serverlist = []
|
|
||||||
self.subscribers = {}
|
|
||||||
self.info = {}
|
|
||||||
self.server = ""
|
|
||||||
self.protocol = "http"
|
|
||||||
self.port = ""
|
|
||||||
self.isplaying = False
|
|
||||||
self.location = 'navigation'
|
|
||||||
# In order to be able to signal a stop at the end
|
|
||||||
self.last_params = {}
|
|
||||||
self.lastplayers = {}
|
|
||||||
# In order to signal a stop to Plex Web ONCE on playback stop
|
|
||||||
self.stop_sent_to_web = True
|
|
||||||
self.request_mgr = request_mgr
|
|
||||||
|
|
||||||
def _server_by_host(self, host):
|
|
||||||
if len(self.serverlist) == 1:
|
|
||||||
return self.serverlist[0]
|
|
||||||
for server in self.serverlist:
|
|
||||||
if (server.get('serverName') in host or
|
|
||||||
server.get('server') in host):
|
|
||||||
return server
|
|
||||||
return {}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _get_correct_position(info, playqueue):
|
|
||||||
"""
|
|
||||||
Kodi tells us the PLAYLIST position, not PLAYQUEUE position, if the
|
|
||||||
user initiated playback of a playlist
|
|
||||||
"""
|
|
||||||
if playqueue.kodi_playlist_playback:
|
|
||||||
position = 0
|
|
||||||
else:
|
|
||||||
position = info['position'] or 0
|
|
||||||
return position
|
|
||||||
|
|
||||||
def msg(self, players):
|
|
||||||
"""
|
|
||||||
Returns a timeline xml as str
|
|
||||||
(xml containing video, audio, photo player state)
|
|
||||||
"""
|
|
||||||
self.isplaying = False
|
|
||||||
self.location = 'navigation'
|
|
||||||
answ = str(XML)
|
|
||||||
timelines = {
|
|
||||||
v.PLEX_PLAYLIST_TYPE_VIDEO: None,
|
|
||||||
v.PLEX_PLAYLIST_TYPE_AUDIO: None,
|
|
||||||
v.PLEX_PLAYLIST_TYPE_PHOTO: None
|
|
||||||
}
|
|
||||||
for typus in timelines:
|
|
||||||
if players.get(v.KODI_PLAYLIST_TYPE_FROM_PLEX_PLAYLIST_TYPE[typus]) is None:
|
|
||||||
timeline = {
|
|
||||||
'controllable': CONTROLLABLE[typus],
|
|
||||||
'type': typus,
|
|
||||||
'state': 'stopped'
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
timeline = self._timeline_dict(players[
|
|
||||||
v.KODI_PLAYLIST_TYPE_FROM_PLEX_PLAYLIST_TYPE[typus]],
|
|
||||||
typus)
|
|
||||||
timelines[typus] = self._dict_to_xml(timeline)
|
|
||||||
timelines.update({'command_id': '{command_id}',
|
|
||||||
'location': self.location})
|
|
||||||
return answ.format(**timelines)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _dict_to_xml(dictionary):
|
|
||||||
"""
|
|
||||||
Returns the string 'key1="value1" key2="value2" ...' for dictionary
|
|
||||||
"""
|
|
||||||
answ = ''
|
|
||||||
for key, value in dictionary.items():
|
|
||||||
answ += '%s="%s" ' % (key, value)
|
|
||||||
return answ
|
|
||||||
|
|
||||||
def _timeline_dict(self, player, ptype):
|
|
||||||
with app.APP.lock_playqueues:
|
|
||||||
playerid = player['playerid']
|
|
||||||
info = app.PLAYSTATE.player_states[playerid]
|
|
||||||
playqueue = PQ.PLAYQUEUES[playerid]
|
|
||||||
position = self._get_correct_position(info, playqueue)
|
|
||||||
try:
|
|
||||||
item = playqueue.items[position]
|
|
||||||
except IndexError:
|
|
||||||
# E.g. for direct path playback for single item
|
|
||||||
return {
|
|
||||||
'controllable': CONTROLLABLE[ptype],
|
|
||||||
'type': ptype,
|
|
||||||
'state': 'stopped'
|
|
||||||
}
|
|
||||||
self.isplaying = True
|
|
||||||
self.stop_sent_to_web = False
|
|
||||||
if ptype in (v.PLEX_PLAYLIST_TYPE_VIDEO,
|
|
||||||
v.PLEX_PLAYLIST_TYPE_PHOTO):
|
|
||||||
self.location = 'fullScreenVideo'
|
|
||||||
pbmc_server = app.CONN.server
|
|
||||||
if pbmc_server:
|
|
||||||
(self.protocol, self.server, self.port) = pbmc_server.split(':')
|
|
||||||
self.server = self.server.replace('/', '')
|
|
||||||
status = 'paused' if int(info['speed']) == 0 else 'playing'
|
|
||||||
duration = timing.kodi_time_to_millis(info['totaltime'])
|
|
||||||
shuffle = '1' if info['shuffled'] else '0'
|
|
||||||
mute = '1' if info['muted'] is True else '0'
|
|
||||||
answ = {
|
|
||||||
'controllable': CONTROLLABLE[ptype],
|
|
||||||
'protocol': self.protocol,
|
|
||||||
'address': self.server,
|
|
||||||
'port': self.port,
|
|
||||||
'machineIdentifier': app.CONN.machine_identifier,
|
|
||||||
'state': status,
|
|
||||||
'type': ptype,
|
|
||||||
'itemType': ptype,
|
|
||||||
'time': timing.kodi_time_to_millis(info['time']),
|
|
||||||
'duration': duration,
|
|
||||||
'seekRange': '0-%s' % duration,
|
|
||||||
'shuffle': shuffle,
|
|
||||||
'repeat': v.PLEX_REPEAT_FROM_KODI_REPEAT[info['repeat']],
|
|
||||||
'volume': info['volume'],
|
|
||||||
'mute': mute,
|
|
||||||
'mediaIndex': 0, # Still to implement from here
|
|
||||||
'partIndex': 0,
|
|
||||||
'partCount': 1,
|
|
||||||
'providerIdentifier': 'com.plexapp.plugins.library',
|
|
||||||
}
|
|
||||||
# Get the plex id from the PKC playqueue not info, as Kodi jumps to
|
|
||||||
# next playqueue element way BEFORE kodi monitor onplayback is
|
|
||||||
# called
|
|
||||||
if item.plex_id:
|
|
||||||
answ['key'] = '/library/metadata/%s' % item.plex_id
|
|
||||||
answ['ratingKey'] = item.plex_id
|
|
||||||
# PlayQueue stuff
|
|
||||||
if info['container_key']:
|
|
||||||
answ['containerKey'] = info['container_key']
|
|
||||||
if (info['container_key'] is not None and
|
|
||||||
info['container_key'].startswith('/playQueues')):
|
|
||||||
answ['playQueueID'] = playqueue.id
|
|
||||||
answ['playQueueVersion'] = playqueue.version
|
|
||||||
answ['playQueueItemID'] = item.id
|
|
||||||
if playqueue.items[position].guid:
|
|
||||||
answ['guid'] = item.guid
|
|
||||||
# Temp. token set?
|
|
||||||
if app.CONN.plex_transient_token:
|
|
||||||
answ['token'] = app.CONN.plex_transient_token
|
|
||||||
elif playqueue.plex_transient_token:
|
|
||||||
answ['token'] = playqueue.plex_transient_token
|
|
||||||
# Process audio and subtitle streams
|
|
||||||
if ptype == v.PLEX_PLAYLIST_TYPE_VIDEO:
|
|
||||||
answ['videoStreamID'] = str(item.current_plex_video_stream)
|
|
||||||
answ['audioStreamID'] = str(item.current_plex_audio_stream)
|
|
||||||
# Mind the zero - meaning subs are deactivated
|
|
||||||
answ['subtitleStreamID'] = str(item.current_plex_sub_stream or 0)
|
|
||||||
return answ
|
|
||||||
|
|
||||||
def signal_stop(self):
|
|
||||||
"""
|
|
||||||
Externally called on PKC shutdown to ensure that PKC signals a stop to
|
|
||||||
the PMS. Otherwise, PKC might be stuck at "currently playing"
|
|
||||||
"""
|
|
||||||
LOG.info('Signaling a complete stop to PMS')
|
|
||||||
# To avoid RuntimeError, don't use self.lastplayers
|
|
||||||
for playerid in (0, 1, 2):
|
|
||||||
self.last_params['state'] = 'stopped'
|
|
||||||
self._send_pms_notification(playerid,
|
|
||||||
self.last_params,
|
|
||||||
timeout=0.0001)
|
|
||||||
|
|
||||||
def update_command_id(self, uuid, command_id):
|
|
||||||
"""
|
|
||||||
Updates the Plex Companien client with the machine identifier uuid with
|
|
||||||
command_id
|
|
||||||
"""
|
|
||||||
with app.APP.lock_subscriber:
|
|
||||||
if command_id and self.subscribers.get(uuid):
|
|
||||||
self.subscribers[uuid].command_id = int(command_id)
|
|
||||||
|
|
||||||
def _playqueue_init_done(self, players):
|
|
||||||
"""
|
|
||||||
update_player_info() can result in values BEFORE kodi monitor is called.
|
|
||||||
Hence we'd have a missmatch between the state.PLAYER_STATES and our
|
|
||||||
playqueues.
|
|
||||||
"""
|
|
||||||
for player in list(players.values()):
|
|
||||||
info = app.PLAYSTATE.player_states[player['playerid']]
|
|
||||||
playqueue = PQ.PLAYQUEUES[player['playerid']]
|
|
||||||
position = self._get_correct_position(info, playqueue)
|
|
||||||
try:
|
|
||||||
item = playqueue.items[position]
|
|
||||||
except IndexError:
|
|
||||||
# E.g. for direct path playback for single item
|
|
||||||
return False
|
|
||||||
if item.plex_id != info['plex_id']:
|
|
||||||
# Kodi playqueue already progressed; need to wait until
|
|
||||||
# everything is loaded
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
def notify(self):
|
|
||||||
"""
|
|
||||||
Causes PKC to tell the PMS and Plex Companion players to receive a
|
|
||||||
notification what's being played.
|
|
||||||
"""
|
|
||||||
with app.APP.lock_subscriber:
|
|
||||||
self._cleanup()
|
|
||||||
# Get all the active/playing Kodi players (video, audio, pictures)
|
|
||||||
players = js.get_players()
|
|
||||||
# Update the PKC info with what's playing on the Kodi side
|
|
||||||
for player in list(players.values()):
|
|
||||||
update_player_info(player['playerid'])
|
|
||||||
# Check whether we can use the CURRENT info or whether PKC is still
|
|
||||||
# initializing
|
|
||||||
if self._playqueue_init_done(players) is False:
|
|
||||||
LOG.debug('PKC playqueue is still initializing - skip update')
|
|
||||||
return
|
|
||||||
self._notify_server(players)
|
|
||||||
if self.subscribers:
|
|
||||||
msg = self.msg(players)
|
|
||||||
for subscriber in list(self.subscribers.values()):
|
|
||||||
subscriber.send_update(msg)
|
|
||||||
self.lastplayers = players
|
|
||||||
|
|
||||||
def _notify_server(self, players):
|
|
||||||
for typus, player in players.items():
|
|
||||||
self._send_pms_notification(
|
|
||||||
player['playerid'], self._get_pms_params(player['playerid']))
|
|
||||||
try:
|
|
||||||
del self.lastplayers[typus]
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
# Process the players we have left (to signal a stop)
|
|
||||||
for player in list(self.lastplayers.values()):
|
|
||||||
self.last_params['state'] = 'stopped'
|
|
||||||
self._send_pms_notification(player['playerid'], self.last_params)
|
|
||||||
|
|
||||||
def _get_pms_params(self, playerid):
|
|
||||||
info = app.PLAYSTATE.player_states[playerid]
|
|
||||||
playqueue = PQ.PLAYQUEUES[playerid]
|
|
||||||
position = self._get_correct_position(info, playqueue)
|
|
||||||
try:
|
|
||||||
item = playqueue.items[position]
|
|
||||||
except IndexError:
|
|
||||||
return self.last_params
|
|
||||||
status = 'paused' if int(info['speed']) == 0 else 'playing'
|
|
||||||
params = {
|
|
||||||
'state': status,
|
|
||||||
'ratingKey': item.plex_id,
|
|
||||||
'key': '/library/metadata/%s' % item.plex_id,
|
|
||||||
'time': timing.kodi_time_to_millis(info['time']),
|
|
||||||
'duration': timing.kodi_time_to_millis(info['totaltime'])
|
|
||||||
}
|
|
||||||
if info['container_key'] is not None:
|
|
||||||
# params['containerKey'] = info['container_key']
|
|
||||||
if info['container_key'].startswith('/playQueues/'):
|
|
||||||
# params['playQueueVersion'] = playqueue.version
|
|
||||||
# params['playQueueID'] = playqueue.id
|
|
||||||
params['playQueueItemID'] = item.id
|
|
||||||
self.last_params = params
|
|
||||||
return params
|
|
||||||
|
|
||||||
def _send_pms_notification(self, playerid, params, timeout=None):
|
|
||||||
"""
|
|
||||||
Pass a really low timeout in seconds if shutting down Kodi and we don't
|
|
||||||
need the PMS' response
|
|
||||||
"""
|
|
||||||
serv = self._server_by_host(self.server)
|
|
||||||
playqueue = PQ.PLAYQUEUES[playerid]
|
|
||||||
xargs = params_pms()
|
|
||||||
xargs.update(params)
|
|
||||||
if app.CONN.plex_transient_token:
|
|
||||||
xargs['X-Plex-Token'] = app.CONN.plex_transient_token
|
|
||||||
elif playqueue.plex_transient_token:
|
|
||||||
xargs['X-Plex-Token'] = playqueue.plex_transient_token
|
|
||||||
elif app.ACCOUNT.pms_token:
|
|
||||||
xargs['X-Plex-Token'] = app.ACCOUNT.pms_token
|
|
||||||
url = '%s://%s:%s/:/timeline' % (serv.get('protocol', 'http'),
|
|
||||||
serv.get('server', 'localhost'),
|
|
||||||
serv.get('port', '32400'))
|
|
||||||
DU().downloadUrl(url,
|
|
||||||
authenticate=False,
|
|
||||||
parameters=xargs,
|
|
||||||
headerOverride=HEADERS_PMS,
|
|
||||||
timeout=timeout)
|
|
||||||
LOG.debug("Sent server notification with parameters: %s to %s",
|
|
||||||
xargs, url)
|
|
||||||
|
|
||||||
def add_subscriber(self, protocol, host, port, uuid, command_id):
|
|
||||||
"""
|
|
||||||
Adds a new Plex Companion subscriber to PKC.
|
|
||||||
"""
|
|
||||||
subscriber = Subscriber(protocol,
|
|
||||||
host,
|
|
||||||
port,
|
|
||||||
uuid,
|
|
||||||
command_id,
|
|
||||||
self,
|
|
||||||
self.request_mgr)
|
|
||||||
with app.APP.lock_subscriber:
|
|
||||||
self.subscribers[subscriber.uuid] = subscriber
|
|
||||||
return subscriber
|
|
||||||
|
|
||||||
def remove_subscriber(self, uuid):
|
|
||||||
"""
|
|
||||||
Removes a connected Plex Companion subscriber with machine identifier
|
|
||||||
uuid from PKC notifications.
|
|
||||||
(Calls the cleanup() method of the subscriber)
|
|
||||||
"""
|
|
||||||
with app.APP.lock_subscriber:
|
|
||||||
for subscriber in list(self.subscribers.values()):
|
|
||||||
if subscriber.uuid == uuid or subscriber.host == uuid:
|
|
||||||
subscriber.cleanup()
|
|
||||||
del self.subscribers[subscriber.uuid]
|
|
||||||
|
|
||||||
def _cleanup(self):
|
|
||||||
for subscriber in list(self.subscribers.values()):
|
|
||||||
if subscriber.age > 30:
|
|
||||||
subscriber.cleanup()
|
|
||||||
del self.subscribers[subscriber.uuid]
|
|
||||||
|
|
||||||
|
|
||||||
class Subscriber(object):
|
|
||||||
"""
|
|
||||||
Plex Companion subscribing device
|
|
||||||
"""
|
|
||||||
def __init__(self, protocol, host, port, uuid, command_id, sub_mgr,
|
|
||||||
request_mgr):
|
|
||||||
self.protocol = protocol or "http"
|
|
||||||
self.host = host
|
|
||||||
self.port = port or 32400
|
|
||||||
self.uuid = uuid or host
|
|
||||||
self.command_id = int(command_id) or 0
|
|
||||||
self.age = 0
|
|
||||||
self.sub_mgr = sub_mgr
|
|
||||||
self.request_mgr = request_mgr
|
|
||||||
|
|
||||||
def __eq__(self, other):
|
|
||||||
return self.uuid == other.uuid
|
|
||||||
|
|
||||||
def cleanup(self):
|
|
||||||
"""
|
|
||||||
Closes the connection to the Plex Companion client
|
|
||||||
"""
|
|
||||||
self.request_mgr.closeConnection(self.protocol, self.host, self.port)
|
|
||||||
|
|
||||||
def send_update(self, msg):
|
|
||||||
"""
|
|
||||||
Sends msg to the Plex Companion client (via .../:/timeline)
|
|
||||||
"""
|
|
||||||
self.age += 1
|
|
||||||
msg = msg.format(command_id=self.command_id)
|
|
||||||
LOG.debug("sending xml to subscriber uuid=%s,commandID=%i:\n%s",
|
|
||||||
self.uuid, self.command_id, msg)
|
|
||||||
url = '%s://%s:%s/:/timeline' % (self.protocol, self.host, self.port)
|
|
||||||
thread = Thread(target=self._threaded_send, args=(url, msg))
|
|
||||||
thread.start()
|
|
||||||
|
|
||||||
def _threaded_send(self, url, msg):
|
|
||||||
"""
|
|
||||||
Threaded POST request, because they stall due to response missing
|
|
||||||
the Content-Length header :-(
|
|
||||||
"""
|
|
||||||
response = DU().downloadUrl(url,
|
|
||||||
action_type="POST",
|
|
||||||
postBody=msg,
|
|
||||||
authenticate=False,
|
|
||||||
headerOverride=headers_companion_client())
|
|
||||||
if response in (False, None, 401):
|
|
||||||
self.sub_mgr.remove_subscriber(self.uuid)
|
|
|
@ -2,6 +2,7 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
import xbmc
|
import xbmc
|
||||||
import xbmcvfs
|
import xbmcvfs
|
||||||
|
|
||||||
|
@ -11,9 +12,8 @@ from . import kodimonitor
|
||||||
from . import sync, library_sync
|
from . import sync, library_sync
|
||||||
from . import websocket_client
|
from . import websocket_client
|
||||||
from . import plex_companion
|
from . import plex_companion
|
||||||
from . import plex_functions as PF, playqueue as PQ
|
from . import plex_functions as PF
|
||||||
from . import playback_starter
|
from . import playback_starter
|
||||||
from . import playqueue
|
|
||||||
from . import variables as v
|
from . import variables as v
|
||||||
from . import app
|
from . import app
|
||||||
from . import loghandler
|
from . import loghandler
|
||||||
|
@ -34,7 +34,6 @@ WINDOW_PROPERTIES = (
|
||||||
class Service(object):
|
class Service(object):
|
||||||
ws = None
|
ws = None
|
||||||
sync = None
|
sync = None
|
||||||
plexcompanion = None
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._init_done = False
|
self._init_done = False
|
||||||
|
@ -100,7 +99,6 @@ class Service(object):
|
||||||
self.setup = None
|
self.setup = None
|
||||||
self.pms_ws = None
|
self.pms_ws = None
|
||||||
self.alexa_ws = None
|
self.alexa_ws = None
|
||||||
self.playqueue = None
|
|
||||||
# Flags for other threads
|
# Flags for other threads
|
||||||
self.connection_check_running = False
|
self.connection_check_running = False
|
||||||
self.auth_running = False
|
self.auth_running = False
|
||||||
|
@ -437,8 +435,6 @@ class Service(object):
|
||||||
app.init()
|
app.init()
|
||||||
app.APP.monitor = kodimonitor.KodiMonitor()
|
app.APP.monitor = kodimonitor.KodiMonitor()
|
||||||
app.APP.player = xbmc.Player()
|
app.APP.player = xbmc.Player()
|
||||||
# Initialize the PKC playqueues
|
|
||||||
PQ.init_playqueues()
|
|
||||||
|
|
||||||
# Server auto-detect
|
# Server auto-detect
|
||||||
self.setup = initialsetup.InitialSetup()
|
self.setup = initialsetup.InitialSetup()
|
||||||
|
@ -448,8 +444,12 @@ class Service(object):
|
||||||
self.pms_ws = websocket_client.get_pms_websocketapp()
|
self.pms_ws = websocket_client.get_pms_websocketapp()
|
||||||
self.alexa_ws = websocket_client.get_alexa_websocketapp()
|
self.alexa_ws = websocket_client.get_alexa_websocketapp()
|
||||||
self.sync = sync.Sync()
|
self.sync = sync.Sync()
|
||||||
self.plexcompanion = plex_companion.PlexCompanion()
|
self.companion_playstate_mgr = plex_companion.PlaystateMgr(
|
||||||
self.playqueue = playqueue.PlayqueueMonitor()
|
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
|
# Main PKC program loop
|
||||||
while not self.should_cancel():
|
while not self.should_cancel():
|
||||||
|
@ -498,6 +498,9 @@ class Service(object):
|
||||||
elif plex_command == 'EXIT-PKC':
|
elif plex_command == 'EXIT-PKC':
|
||||||
LOG.info('Received command from another instance to quit')
|
LOG.info('Received command from another instance to quit')
|
||||||
app.APP.stop_pkc = True
|
app.APP.stop_pkc = True
|
||||||
|
elif plex_command == 'generate_new_uuid':
|
||||||
|
LOG.info('Generating new UUID for PKC')
|
||||||
|
clientinfo.getDeviceId(reset=True)
|
||||||
else:
|
else:
|
||||||
raise RuntimeError('Unknown command: %s', plex_command)
|
raise RuntimeError('Unknown command: %s', plex_command)
|
||||||
if task:
|
if task:
|
||||||
|
@ -548,8 +551,9 @@ class Service(object):
|
||||||
self.startup_completed = True
|
self.startup_completed = True
|
||||||
self.pms_ws.start()
|
self.pms_ws.start()
|
||||||
self.sync.start()
|
self.sync.start()
|
||||||
self.plexcompanion.start()
|
self.companion_playstate_mgr.start()
|
||||||
self.playqueue.start()
|
if self.companion_polling is not None:
|
||||||
|
self.companion_polling.start()
|
||||||
self.alexa_ws.start()
|
self.alexa_ws.start()
|
||||||
|
|
||||||
elif app.APP.is_playing:
|
elif app.APP.is_playing:
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from .windows.skip_intro import SkipIntroDialog
|
from .windows.skip_intro import SkipIntroDialog
|
||||||
from . import app, variables as v
|
from . import app, utils, variables as v
|
||||||
|
|
||||||
|
|
||||||
def skip_intro(intros):
|
def skip_intro(intros):
|
||||||
|
@ -15,12 +15,18 @@ def skip_intro(intros):
|
||||||
if start <= progress < end:
|
if start <= progress < end:
|
||||||
in_intro = True
|
in_intro = True
|
||||||
if in_intro and app.APP.skip_intro_dialog is None:
|
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',
|
app.APP.skip_intro_dialog = SkipIntroDialog('script-plex-skip_intro.xml',
|
||||||
v.ADDON_PATH,
|
v.ADDON_PATH,
|
||||||
'default',
|
'default',
|
||||||
'1080i',
|
'1080i',
|
||||||
intro_end=end)
|
intro_end=end)
|
||||||
app.APP.skip_intro_dialog.show()
|
|
||||||
|
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:
|
elif not in_intro and app.APP.skip_intro_dialog is not None:
|
||||||
app.APP.skip_intro_dialog.close()
|
app.APP.skip_intro_dialog.close()
|
||||||
app.APP.skip_intro_dialog = None
|
app.APP.skip_intro_dialog = None
|
||||||
|
|
|
@ -214,7 +214,8 @@ LANGUAGE_ISO_CODES = (
|
||||||
|
|
||||||
|
|
||||||
def accessible_plex_subtitles(playmethod, playing_file, xml_streams):
|
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
|
# We can access all subtitles because we're downloading additional
|
||||||
# external ones into the Kodi PKC add-on directory
|
# external ones into the Kodi PKC add-on directory
|
||||||
streams = []
|
streams = []
|
||||||
|
|
|
@ -55,6 +55,7 @@ REGEX_PLEX_ID_FROM_URL = re.compile(r'''metadata%2F(\d+)''')
|
||||||
SAFE_URL_CHARACTERS = "%/:=&?~#+!$,;'@()*[]"
|
SAFE_URL_CHARACTERS = "%/:=&?~#+!$,;'@()*[]"
|
||||||
HTTP_DAV_FTP = re.compile(r'(http(s)?|dav(s)?|(s)?ftp)://((.+):(.+)@)?([\w\.]+)(:([\d]+))?/')
|
HTTP_DAV_FTP = re.compile(r'(http(s)?|dav(s)?|(s)?ftp)://((.+):(.+)@)?([\w\.]+)(:([\d]+))?/')
|
||||||
|
|
||||||
|
|
||||||
def garbageCollect():
|
def garbageCollect():
|
||||||
gc.collect(2)
|
gc.collect(2)
|
||||||
|
|
||||||
|
@ -114,7 +115,7 @@ def settings(setting, value=None):
|
||||||
"""
|
"""
|
||||||
# We need to instantiate every single time to read changed variables!
|
# We need to instantiate every single time to read changed variables!
|
||||||
with SETTINGS_LOCK:
|
with SETTINGS_LOCK:
|
||||||
addon = xbmcaddon.Addon()
|
addon = xbmcaddon.Addon('plugin.video.plexkodiconnect')
|
||||||
if value is not None:
|
if value is not None:
|
||||||
# Takes string or unicode by default!
|
# Takes string or unicode by default!
|
||||||
addon.setSetting(setting, value)
|
addon.setSetting(setting, value)
|
||||||
|
@ -556,6 +557,17 @@ def reset(ask_user=True):
|
||||||
reboot_kodi()
|
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):
|
def compare_version(current, minimum):
|
||||||
"""
|
"""
|
||||||
Returns True if current is >= then minimum. False otherwise. Returns True
|
Returns True if current is >= then minimum. False otherwise. Returns True
|
||||||
|
|
|
@ -21,7 +21,7 @@ MARK_PLAYED_AT = 0.9
|
||||||
# watched?
|
# watched?
|
||||||
IGNORE_SECONDS_AT_START = 60
|
IGNORE_SECONDS_AT_START = 60
|
||||||
|
|
||||||
_ADDON = Addon()
|
_ADDON = Addon('plugin.video.plexkodiconnect')
|
||||||
ADDON_NAME = 'PlexKodiConnect'
|
ADDON_NAME = 'PlexKodiConnect'
|
||||||
ADDON_ID = 'plugin.video.plexkodiconnect'
|
ADDON_ID = 'plugin.video.plexkodiconnect'
|
||||||
ADDON_VERSION = _ADDON.getAddonInfo('version')
|
ADDON_VERSION = _ADDON.getAddonInfo('version')
|
||||||
|
@ -30,8 +30,9 @@ ADDON_FOLDER = xbmcvfs.translatePath('special://home')
|
||||||
ADDON_PROFILE = xbmcvfs.translatePath(_ADDON.getAddonInfo('profile'))
|
ADDON_PROFILE = xbmcvfs.translatePath(_ADDON.getAddonInfo('profile'))
|
||||||
|
|
||||||
# Used e.g. for json_rpc
|
# Used e.g. for json_rpc
|
||||||
KODI_VIDEO_PLAYER_ID = 1
|
|
||||||
KODI_AUDIO_PLAYER_ID = 0
|
KODI_AUDIO_PLAYER_ID = 0
|
||||||
|
KODI_VIDEO_PLAYER_ID = 1
|
||||||
|
KODI_PHOTO_PLAYER_ID = 2
|
||||||
|
|
||||||
KODILANGUAGE = xbmc.getLanguage(xbmc.ISO_639_1)
|
KODILANGUAGE = xbmc.getLanguage(xbmc.ISO_639_1)
|
||||||
KODIVERSION = int(xbmc.getInfoLabel("System.BuildVersion")[:2])
|
KODIVERSION = int(xbmc.getInfoLabel("System.BuildVersion")[:2])
|
||||||
|
@ -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 = {
|
PLEX_REPEAT_FROM_KODI_REPEAT = {
|
||||||
'off': '0',
|
'off': '0',
|
||||||
'one': '1',
|
'one': '1',
|
||||||
|
|
|
@ -126,7 +126,6 @@ def on_error(ws, error):
|
||||||
ws.name, type(error), error)
|
ws.name, type(error), error)
|
||||||
# Status = Error
|
# Status = Error
|
||||||
utils.settings(status, value=utils.lang(257))
|
utils.settings(status, value=utils.lang(257))
|
||||||
raise RuntimeError
|
|
||||||
|
|
||||||
|
|
||||||
def on_close(ws):
|
def on_close(ws):
|
||||||
|
@ -198,14 +197,19 @@ class PlexWebSocketApp(websocket.WebSocketApp,
|
||||||
log.exception('Exception of type %s occured: %s', type(err), err)
|
log.exception('Exception of type %s occured: %s', type(err), err)
|
||||||
finally:
|
finally:
|
||||||
self.close()
|
self.close()
|
||||||
# Status = Not connected
|
if self._enabled:
|
||||||
|
# Status = Not connected
|
||||||
|
message = utils.lang(15208)
|
||||||
|
else:
|
||||||
|
# Status = Disabled
|
||||||
|
message = utils.lang(24023)
|
||||||
utils.settings(self.name + SETTINGS_STRING,
|
utils.settings(self.name + SETTINGS_STRING,
|
||||||
value=utils.lang(15208))
|
value=message)
|
||||||
app.APP.deregister_thread(self)
|
app.APP.deregister_thread(self)
|
||||||
log.info("----===## %s stopped ##===----", self.name)
|
log.info("----===## %s stopped ##===----", self.name)
|
||||||
|
|
||||||
def _run(self):
|
def _run(self):
|
||||||
while not self.should_cancel():
|
while not self.should_cancel() and self._enabled:
|
||||||
# In the event the server goes offline
|
# In the event the server goes offline
|
||||||
while self.should_suspend():
|
while self.should_suspend():
|
||||||
# We will be caught in this loop if either another thread
|
# We will be caught in this loop if either another thread
|
||||||
|
@ -231,16 +235,13 @@ class PlexWebSocketApp(websocket.WebSocketApp,
|
||||||
class PMSWebsocketApp(PlexWebSocketApp):
|
class PMSWebsocketApp(PlexWebSocketApp):
|
||||||
name = 'pms_websocket'
|
name = 'pms_websocket'
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self._enabled = utils.settings('enableBackgroundSync') == 'true'
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
def get_uri(self):
|
def get_uri(self):
|
||||||
return get_pms_uri()
|
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):
|
def set_suspension_settings_status(self):
|
||||||
if utils.settings('enableBackgroundSync') != 'true':
|
if utils.settings('enableBackgroundSync') != 'true':
|
||||||
# Status = Disabled
|
# Status = Disabled
|
||||||
|
@ -255,6 +256,10 @@ class PMSWebsocketApp(PlexWebSocketApp):
|
||||||
class AlexaWebsocketApp(PlexWebSocketApp):
|
class AlexaWebsocketApp(PlexWebSocketApp):
|
||||||
name = 'alexa_websocket'
|
name = 'alexa_websocket'
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self._enabled = utils.settings('enable_alexa') == 'true'
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
def get_uri(self):
|
def get_uri(self):
|
||||||
return get_alexa_uri()
|
return get_alexa_uri()
|
||||||
|
|
||||||
|
@ -263,7 +268,6 @@ class AlexaWebsocketApp(PlexWebSocketApp):
|
||||||
Returns True if the thread needs to suspend.
|
Returns True if the thread needs to suspend.
|
||||||
"""
|
"""
|
||||||
return self._suspended or \
|
return self._suspended or \
|
||||||
utils.settings('enable_alexa') != 'true' or \
|
|
||||||
app.ACCOUNT.restricted_user or \
|
app.ACCOUNT.restricted_user or \
|
||||||
not app.ACCOUNT.plex_token
|
not app.ACCOUNT.plex_token
|
||||||
|
|
||||||
|
|
|
@ -994,8 +994,7 @@ class WindowProperty(object):
|
||||||
|
|
||||||
class GlobalProperty(object):
|
class GlobalProperty(object):
|
||||||
def __init__(self, prop, val='1', end=None):
|
def __init__(self, prop, val='1', end=None):
|
||||||
import xbmcaddon
|
self._addonID = 'plugin.video.plexkodiconnect'
|
||||||
self._addonID = xbmcaddon.Addon().getAddonInfo('id')
|
|
||||||
self.prop = prop
|
self.prop = prop
|
||||||
self.val = val
|
self.val = val
|
||||||
self.end = end
|
self.end = end
|
||||||
|
|
|
@ -36,12 +36,15 @@ class SkipIntroDialog(WindowXMLDialog):
|
||||||
logger.debug('Closing dialog')
|
logger.debug('Closing dialog')
|
||||||
WindowXMLDialog.close(self)
|
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
|
def onClick(self, control_id): # pylint: disable=invalid-name
|
||||||
if self.intro_end and control_id == 3002: # 3002 = Skip Intro button
|
if self.intro_end and control_id == 3002: # 3002 = Skip Intro button
|
||||||
if app.APP.is_playing:
|
if app.APP.is_playing:
|
||||||
self.on_hold = True
|
self.on_hold = True
|
||||||
logger.info('Skipping intro, seeking to %s', self.intro_end)
|
self.seekTimeToIntroEnd()
|
||||||
app.APP.player.seekTime(self.intro_end)
|
|
||||||
self.close()
|
self.close()
|
||||||
|
|
||||||
def onAction(self, action): # pylint: disable=invalid-name
|
def onAction(self, action): # pylint: disable=invalid-name
|
||||||
|
|
|
@ -114,6 +114,7 @@
|
||||||
<setting id="askCinema" type="bool" label="30519" default="false" visible="eq(-1,true)" subsetting="true" />
|
<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="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="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="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="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? -->
|
<setting id="subtitleStreamPick" type="enum" label="30548" values="Plex|Kodi" default="0" /><!-- Who picks subtitles on playback start? -->
|
||||||
|
|
Loading…
Reference in a new issue