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"?>
|
||||
<addon id="plugin.video.plexkodiconnect" name="PlexKodiConnect" version="3.5.8" provider-name="croneter">
|
||||
<addon id="plugin.video.plexkodiconnect" name="PlexKodiConnect" version="3.6.0" provider-name="croneter">
|
||||
<requires>
|
||||
<import addon="xbmc.python" version="3.0.0"/>
|
||||
<import addon="script.module.requests" version="2.22.0+matrix.1" />
|
||||
<import addon="plugin.video.plexkodiconnect.movies" version="3.0.1" />
|
||||
<import addon="plugin.video.plexkodiconnect.tvshows" version="3.0.1" />
|
||||
<import addon="plugin.video.plexkodiconnect.movies" version="3.0.2" />
|
||||
<import addon="plugin.video.plexkodiconnect.tvshows" version="3.0.2" />
|
||||
<import addon="metadata.themoviedb.org.python" version="1.3.1+matrix.1" />
|
||||
</requires>
|
||||
<extension point="xbmc.python.pluginsource" library="default.py">
|
||||
|
@ -20,6 +20,8 @@
|
|||
</item>
|
||||
</extension>
|
||||
<extension point="xbmc.addon.metadata">
|
||||
<!-- see e.g. https://github.com/xbmc/xbmc/pull/14136 -->
|
||||
<reuselanguageinvoker>true</reuselanguageinvoker>
|
||||
<assets>
|
||||
<icon>icon.png</icon>
|
||||
<fanart>fanart.jpg</fanart>
|
||||
|
@ -91,7 +93,51 @@
|
|||
<summary lang="ko_KR">Plex를 Kodi에 기본 통합</summary>
|
||||
<description lang="ko_KR">Kodi를 Plex Media Server에 연결합니다. 이 플러그인은 Plex로 모든 비디오를 관리하고 Kodi로는 관리하지 않는다고 가정합니다. Kodi 비디오 및 음악 데이터베이스에 이미 저장된 데이터가 손실 될 수 있습니다 (이 플러그인이 직접 변경하므로). 자신의 책임하에 사용하십시오!</description>
|
||||
<disclaimer lang="ko_KR">자신의 책임하에 사용</disclaimer>
|
||||
<news>version 3.5.8:
|
||||
<news>version 3.6.0:
|
||||
- versions 3.5.9 - 3.5.17 for everyone
|
||||
- Fix media not showing up in the library if Kodi masterlock has been activated. Kodi bugfix is also necessary, hopefully coming with Kodi 19.4, otherwise switch to Kodi 19.1
|
||||
- Fix PKC sometimes selecting the wrong subtitle for direct paths
|
||||
- Fix additional artwork download crashing if there was no sensible reply
|
||||
- Fix PKC changing subtitles on playback start unnecessarily
|
||||
- Fix Plex Companion not able to switch streams
|
||||
|
||||
version 3.5.17 (beta only):
|
||||
- Use addon.xml `reuselanguageinvoker` to turn add-on snappier
|
||||
- Fix detection of playqueue order. Thus fix PKC reporting back the playing of an old episode when using UpNext
|
||||
- Fix logging for playlist items not working correctly
|
||||
|
||||
version 3.5.16 (beta only):
|
||||
- Fix playback report for widget not working if direct paths are used
|
||||
|
||||
version 3.5.15 (beta only):
|
||||
- Re-add old Plex Companion mechanism as the new one sucks
|
||||
- Fix KeyError on playback startup
|
||||
|
||||
version 3.5.14 (beta only):
|
||||
- Fix PKC not being able to connect to plex.tv after installation
|
||||
|
||||
version 3.5.13 (beta only):
|
||||
- Fix Kodi getting blocked and losing PMS access e.g. due to cloudflare
|
||||
|
||||
version 3.5.12 (beta only):
|
||||
- Fix skip intro not working
|
||||
- Fix playback report not working due to an IndexError
|
||||
- Fix rare IndexError when trying to delete a playlist item
|
||||
|
||||
version 3.5.11 (beta only):
|
||||
- Fix playback startup and AttributeError: 'bool' object has no attribute 'get'
|
||||
|
||||
version 3.5.10 (beta only):
|
||||
- Tell the PMS and Plex Companion about any stream changes on the Kodi side
|
||||
|
||||
version 3.5.9 (beta only):
|
||||
- Huge overhaul: completely new Plex Companion implementation. PKC is now available as a casting target for Plexamp. Includes refactoring of Skip Intro as well as Playqueues
|
||||
- Add auto skip intro functionality
|
||||
- Fix streams for videos not being set-up
|
||||
- Fix generating new unique device ID for PKC not working
|
||||
- Make PKC compatible with Python 3.6 again
|
||||
|
||||
version 3.5.8:
|
||||
- Fix UnboundLocalError: local variable 'identifier' referenced before assignment
|
||||
- versions 3.5.6-3.5.7 for everyone
|
||||
|
||||
|
|
|
@ -1,3 +1,47 @@
|
|||
version 3.6.0:
|
||||
- versions 3.5.9 - 3.5.17 for everyone
|
||||
- Fix media not showing up in the library if Kodi masterlock has been activated. Kodi bugfix is also necessary, hopefully coming with Kodi 19.4, otherwise switch to Kodi 19.1
|
||||
- Fix PKC sometimes selecting the wrong subtitle for direct paths
|
||||
- Fix additional artwork download crashing if there was no sensible reply
|
||||
- Fix PKC changing subtitles on playback start unnecessarily
|
||||
- Fix Plex Companion not able to switch streams
|
||||
|
||||
version 3.5.17 (beta only):
|
||||
- Use addon.xml `reuselanguageinvoker` to turn add-on snappier
|
||||
- Fix detection of playqueue order. Thus fix PKC reporting back the playing of an old episode when using UpNext
|
||||
- Fix logging for playlist items not working correctly
|
||||
|
||||
version 3.5.16 (beta only):
|
||||
- Fix playback report for widget not working if direct paths are used
|
||||
|
||||
version 3.5.15 (beta only):
|
||||
- Re-add old Plex Companion mechanism as the new one sucks
|
||||
- Fix KeyError on playback startup
|
||||
|
||||
version 3.5.14 (beta only):
|
||||
- Fix PKC not being able to connect to plex.tv after installation
|
||||
|
||||
version 3.5.13 (beta only):
|
||||
- Fix Kodi getting blocked and losing PMS access e.g. due to cloudflare
|
||||
|
||||
version 3.5.12 (beta only):
|
||||
- Fix skip intro not working
|
||||
- Fix playback report not working due to an IndexError
|
||||
- Fix rare IndexError when trying to delete a playlist item
|
||||
|
||||
version 3.5.11 (beta only):
|
||||
- Fix playback startup and AttributeError: 'bool' object has no attribute 'get'
|
||||
|
||||
version 3.5.10 (beta only):
|
||||
- Tell the PMS and Plex Companion about any stream changes on the Kodi side
|
||||
|
||||
version 3.5.9 (beta only):
|
||||
- Huge overhaul: completely new Plex Companion implementation. PKC is now available as a casting target for Plexamp. Includes refactoring of Skip Intro as well as Playqueues
|
||||
- Add auto skip intro functionality
|
||||
- Fix streams for videos not being set-up
|
||||
- Fix generating new unique device ID for PKC not working
|
||||
- Make PKC compatible with Python 3.6 again
|
||||
|
||||
version 3.5.8:
|
||||
- Fix UnboundLocalError: local variable 'identifier' referenced before assignment
|
||||
- versions 3.5.6-3.5.7 for everyone
|
||||
|
|
35
default.py
35
default.py
|
@ -1,6 +1,4 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
###############################################################################
|
||||
from builtins import object
|
||||
import logging
|
||||
from sys import argv
|
||||
|
@ -12,15 +10,10 @@ import xbmcplugin
|
|||
|
||||
from resources.lib import entrypoint, utils, transfer, variables as v, loghandler
|
||||
|
||||
###############################################################################
|
||||
|
||||
loghandler.config()
|
||||
LOG = logging.getLogger('PLEX.default')
|
||||
|
||||
###############################################################################
|
||||
|
||||
HANDLE = int(argv[1])
|
||||
|
||||
|
||||
class Main(object):
|
||||
# MAIN ENTRY POINT
|
||||
|
@ -117,7 +110,8 @@ class Main(object):
|
|||
transfer.plex_command('choose_pms_server')
|
||||
|
||||
elif mode == 'deviceid':
|
||||
self.deviceid()
|
||||
LOG.info('New PKC UUID / unique device id requested')
|
||||
transfer.plex_command('generate_new_uuid')
|
||||
|
||||
elif mode == 'fanart':
|
||||
LOG.info('User requested fanarttv refresh')
|
||||
|
@ -157,38 +151,21 @@ class Main(object):
|
|||
"""
|
||||
Start up playback_starter in main Python thread
|
||||
"""
|
||||
request = '%s&handle=%s' % (argv[2], HANDLE)
|
||||
request = '%s&handle=%s' % (argv[2], int(argv[1]))
|
||||
# Put the request into the 'queue'
|
||||
transfer.plex_command('PLAY-%s' % request)
|
||||
if HANDLE == -1:
|
||||
if int(argv[1]) == -1:
|
||||
# Handle -1 received, not waiting for main thread
|
||||
return
|
||||
# Wait for the result from the main PKC thread
|
||||
result = transfer.wait_for_transfer(source='main')
|
||||
if result is True:
|
||||
xbmcplugin.setResolvedUrl(HANDLE, False, xbmcgui.ListItem())
|
||||
xbmcplugin.setResolvedUrl(int(argv[1]), False, xbmcgui.ListItem())
|
||||
# Tell main thread that we're done
|
||||
transfer.send(True, target='main')
|
||||
else:
|
||||
# Received a xbmcgui.ListItem()
|
||||
xbmcplugin.setResolvedUrl(HANDLE, True, result)
|
||||
|
||||
@staticmethod
|
||||
def deviceid():
|
||||
window = xbmcgui.Window(10000)
|
||||
deviceId_old = window.getProperty('plex_client_Id')
|
||||
from resources.lib import clientinfo
|
||||
try:
|
||||
deviceId = clientinfo.getDeviceId(reset=True)
|
||||
except Exception as e:
|
||||
LOG.error('Failed to generate a new device Id: %s' % e)
|
||||
utils.messageDialog(utils.lang(29999), utils.lang(33032))
|
||||
else:
|
||||
LOG.info('Successfully removed old device ID: %s New deviceId:'
|
||||
'%s' % (deviceId_old, deviceId))
|
||||
# 'Kodi will now restart to apply the changes'
|
||||
utils.messageDialog(utils.lang(29999), utils.lang(33033))
|
||||
xbmc.executebuiltin('RestartApp')
|
||||
xbmcplugin.setResolvedUrl(int(argv[1]), True, result)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
# Translators:
|
||||
# Croneter None <croneter@gmail.com>, 2017
|
||||
# Michal Kuncl <michal.kuncl@gmail.com>, 2020
|
||||
#
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PlexKodiConnect\n"
|
||||
|
@ -1676,3 +1676,8 @@ msgstr ""
|
|||
msgctxt "#39719"
|
||||
msgid "Replace user ratings with number of media versions"
|
||||
msgstr "Nahradit uživatelské hodnocení počtem verzí média"
|
||||
|
||||
# PKC Settings - Playback
|
||||
msgctxt "#39720"
|
||||
msgid "Auto skip intro"
|
||||
msgstr ""
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
# Croneter None <croneter@gmail.com>, 2017
|
||||
# Thomas H. <dontspampls@gmail.com>, 2019
|
||||
# coz2001 <sallerup2001@gmail.com>, 2019
|
||||
#
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PlexKodiConnect\n"
|
||||
|
@ -1684,3 +1684,8 @@ msgstr ""
|
|||
msgctxt "#39719"
|
||||
msgid "Replace user ratings with number of media versions"
|
||||
msgstr "Erstat brugerbedømmelser med antal af medieversioner"
|
||||
|
||||
# PKC Settings - Playback
|
||||
msgctxt "#39720"
|
||||
msgid "Auto skip intro"
|
||||
msgstr ""
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
# XBMC Media Center language file
|
||||
# Translators:
|
||||
# Croneter None <croneter@gmail.com>, 2021
|
||||
#
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PlexKodiConnect\n"
|
||||
|
@ -1713,3 +1713,8 @@ msgstr ""
|
|||
msgctxt "#39719"
|
||||
msgid "Replace user ratings with number of media versions"
|
||||
msgstr "Benutzerbewertungen durch verfügbare Anzahl Versionen ersetzen"
|
||||
|
||||
# PKC Settings - Playback
|
||||
msgctxt "#39720"
|
||||
msgid "Auto skip intro"
|
||||
msgstr ""
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
# XBMC Media Center language file
|
||||
# Translators:
|
||||
# Croneter None <croneter@gmail.com>, 2017
|
||||
#
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PlexKodiConnect\n"
|
||||
|
@ -1601,3 +1601,8 @@ msgstr ""
|
|||
msgctxt "#39719"
|
||||
msgid "Replace user ratings with number of media versions"
|
||||
msgstr ""
|
||||
|
||||
# PKC Settings - Playback
|
||||
msgctxt "#39720"
|
||||
msgid "Auto skip intro"
|
||||
msgstr ""
|
||||
|
|
|
@ -1139,12 +1139,12 @@ msgctxt "#39082"
|
|||
msgid "Direct Paths"
|
||||
msgstr ""
|
||||
|
||||
# Dialog for manually entering PMS
|
||||
# Dialog for manually entering PMS
|
||||
msgctxt "#39083"
|
||||
msgid "Enter PMS IP or URL"
|
||||
msgstr ""
|
||||
|
||||
# Dialog for manually entering PMS
|
||||
# Dialog for manually entering PMS
|
||||
msgctxt "#39084"
|
||||
msgid "Enter PMS port"
|
||||
msgstr ""
|
||||
|
@ -1501,3 +1501,8 @@ msgstr ""
|
|||
msgctxt "#39719"
|
||||
msgid "Replace user ratings with number of media versions"
|
||||
msgstr ""
|
||||
|
||||
# PKC Settings - Playback
|
||||
msgctxt "#39720"
|
||||
msgid "Auto skip intro"
|
||||
msgstr ""
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
# XBMC Media Center language file
|
||||
# Translators:
|
||||
# Croneter None <croneter@gmail.com>, 2020
|
||||
#
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PlexKodiConnect\n"
|
||||
|
@ -1699,3 +1699,8 @@ msgctxt "#39719"
|
|||
msgid "Replace user ratings with number of media versions"
|
||||
msgstr ""
|
||||
"Reemplazar valoraciones de usuario con cantidad de versiones de medios"
|
||||
|
||||
# PKC Settings - Playback
|
||||
msgctxt "#39720"
|
||||
msgid "Auto skip intro"
|
||||
msgstr ""
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
# Dani <danichispa@gmail.com>, 2019
|
||||
# Bartolome Soriano <bsoriano@gmail.com>, 2019
|
||||
# Croneter None <croneter@gmail.com>, 2020
|
||||
#
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PlexKodiConnect\n"
|
||||
|
@ -1701,3 +1701,8 @@ msgctxt "#39719"
|
|||
msgid "Replace user ratings with number of media versions"
|
||||
msgstr ""
|
||||
"Reemplazar valoraciones de usuario con cantidad de versiones de medios"
|
||||
|
||||
# PKC Settings - Playback
|
||||
msgctxt "#39720"
|
||||
msgid "Auto skip intro"
|
||||
msgstr ""
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
# XBMC Media Center language file
|
||||
# Translators:
|
||||
# Croneter None <croneter@gmail.com>, 2020
|
||||
#
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PlexKodiConnect\n"
|
||||
|
@ -1699,3 +1699,8 @@ msgctxt "#39719"
|
|||
msgid "Replace user ratings with number of media versions"
|
||||
msgstr ""
|
||||
"Reemplazar valoraciones de usuario con cantidad de versiones de medios"
|
||||
|
||||
# PKC Settings - Playback
|
||||
msgctxt "#39720"
|
||||
msgid "Auto skip intro"
|
||||
msgstr ""
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
# Elixir59, 2019
|
||||
# Croneter None <croneter@gmail.com>, 2020
|
||||
# Raph Mell, 2020
|
||||
#
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PlexKodiConnect\n"
|
||||
|
@ -1719,3 +1719,8 @@ msgstr ""
|
|||
msgctxt "#39719"
|
||||
msgid "Replace user ratings with number of media versions"
|
||||
msgstr "Remplacer les notes d'utilisateurs par le nombre de versions du média"
|
||||
|
||||
# PKC Settings - Playback
|
||||
msgctxt "#39720"
|
||||
msgid "Auto skip intro"
|
||||
msgstr ""
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
# julien benoist <jbnitro@hotmail.fr>, 2019
|
||||
# Croneter None <croneter@gmail.com>, 2020
|
||||
# Raph Mell, 2020
|
||||
#
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PlexKodiConnect\n"
|
||||
|
@ -1723,3 +1723,8 @@ msgstr ""
|
|||
msgctxt "#39719"
|
||||
msgid "Replace user ratings with number of media versions"
|
||||
msgstr "Remplacer les notes d'utilisateurs par le nombre de versions du média"
|
||||
|
||||
# PKC Settings - Playback
|
||||
msgctxt "#39720"
|
||||
msgid "Auto skip intro"
|
||||
msgstr ""
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
# Translators:
|
||||
# Croneter None <croneter@gmail.com>, 2019
|
||||
# Savage93 <savageistheking@gmail.com>, 2021
|
||||
#
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PlexKodiConnect\n"
|
||||
|
@ -1712,3 +1712,8 @@ msgstr ""
|
|||
msgctxt "#39719"
|
||||
msgid "Replace user ratings with number of media versions"
|
||||
msgstr "Felhasználói osztályzatok lecserélése a médiaverziók számára"
|
||||
|
||||
# PKC Settings - Playback
|
||||
msgctxt "#39720"
|
||||
msgid "Auto skip intro"
|
||||
msgstr ""
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
# Angela Calò <angycalo@libero.it>, 2018
|
||||
# Cristiano Bozzi <c.bozzi@nextworks.it>, 2018
|
||||
# Luigi Mantellini <luigi.mantellini@gmail.com>, 2019
|
||||
#
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PlexKodiConnect\n"
|
||||
|
@ -1711,3 +1711,8 @@ msgid "Replace user ratings with number of media versions"
|
|||
msgstr ""
|
||||
"Sostituisci la valutazione contenuti con il numero delle versioni del "
|
||||
"contenuto disponibili"
|
||||
|
||||
# PKC Settings - Playback
|
||||
msgctxt "#39720"
|
||||
msgid "Auto skip intro"
|
||||
msgstr ""
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
# k irbymaker <hun010811@naver.com>, 2020
|
||||
# Croneter None <croneter@gmail.com>, 2021
|
||||
# so.o.bima <cjhamo@naver.com>, 2021
|
||||
#
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PlexKodiConnect\n"
|
||||
|
@ -1632,3 +1632,8 @@ msgstr "사용자 지정 사용자 등급을 보유하고있는 미디어 항목
|
|||
msgctxt "#39719"
|
||||
msgid "Replace user ratings with number of media versions"
|
||||
msgstr "사용자 등급을 미디어 버전 수로 대체"
|
||||
|
||||
# PKC Settings - Playback
|
||||
msgctxt "#39720"
|
||||
msgid "Auto skip intro"
|
||||
msgstr ""
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
# Translators:
|
||||
# tigriso1 <tigriso1@outlook.com>, 2019
|
||||
# Egidijus Mz <sixpilot80@yahoo.com>, 2019
|
||||
#
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PlexKodiConnect\n"
|
||||
|
@ -1697,3 +1697,8 @@ msgstr ""
|
|||
msgctxt "#39719"
|
||||
msgid "Replace user ratings with number of media versions"
|
||||
msgstr "Pakeiskite naudotojų reitingus medijos versijų skaičiumi"
|
||||
|
||||
# PKC Settings - Playback
|
||||
msgctxt "#39720"
|
||||
msgid "Auto skip intro"
|
||||
msgstr ""
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
# XBMC Media Center language file
|
||||
# Translators:
|
||||
# marcisbe <marcisbe@gmail.com>, 2020
|
||||
#
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PlexKodiConnect\n"
|
||||
|
@ -1632,3 +1632,8 @@ msgstr ""
|
|||
msgctxt "#39719"
|
||||
msgid "Replace user ratings with number of media versions"
|
||||
msgstr ""
|
||||
|
||||
# PKC Settings - Playback
|
||||
msgctxt "#39720"
|
||||
msgid "Auto skip intro"
|
||||
msgstr ""
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
# Panja0 <panja0@protonmail.com>, 2019
|
||||
# Nick Corthals <corthals.nick@gmail.com>, 2019
|
||||
# Rick van Soest <r.vansoest@gmail.com>, 2019
|
||||
#
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PlexKodiConnect\n"
|
||||
|
@ -1683,3 +1683,8 @@ msgstr ""
|
|||
msgctxt "#39719"
|
||||
msgid "Replace user ratings with number of media versions"
|
||||
msgstr "Vervang ratings met aantal media versies"
|
||||
|
||||
# PKC Settings - Playback
|
||||
msgctxt "#39720"
|
||||
msgid "Auto skip intro"
|
||||
msgstr ""
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
# Croneter None <croneter@gmail.com>, 2017
|
||||
# Jon Mjørud <jon.mjorud@gmail.com>, 2017
|
||||
# Kyb ntnu, 2019
|
||||
#
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PlexKodiConnect\n"
|
||||
|
@ -1677,3 +1677,8 @@ msgstr ""
|
|||
msgctxt "#39719"
|
||||
msgid "Replace user ratings with number of media versions"
|
||||
msgstr "Erstatt rating med antall versjoner av media"
|
||||
|
||||
# PKC Settings - Playback
|
||||
msgctxt "#39720"
|
||||
msgid "Auto skip intro"
|
||||
msgstr ""
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
# Wiktor Dackiewicz <wiktor.dackiewicz@gmail.com>, 2017
|
||||
# Kacpolz <kacperbrzozowski123@gmail.com>, 2019
|
||||
# Ziuta <spam210195@gmail.com>, 2020
|
||||
#
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PlexKodiConnect\n"
|
||||
|
@ -1606,3 +1606,8 @@ msgstr ""
|
|||
msgctxt "#39719"
|
||||
msgid "Replace user ratings with number of media versions"
|
||||
msgstr ""
|
||||
|
||||
# PKC Settings - Playback
|
||||
msgctxt "#39720"
|
||||
msgid "Auto skip intro"
|
||||
msgstr ""
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
# Translators:
|
||||
# Croneter None <croneter@gmail.com>, 2017
|
||||
# Daniel Leite <danieldefreitasleite@gmail.com>, 2019
|
||||
#
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PlexKodiConnect\n"
|
||||
|
@ -1674,3 +1674,8 @@ msgctxt "#39719"
|
|||
msgid "Replace user ratings with number of media versions"
|
||||
msgstr ""
|
||||
"Substituir classificações do utilizador com numero de versões de média"
|
||||
|
||||
# PKC Settings - Playback
|
||||
msgctxt "#39720"
|
||||
msgid "Auto skip intro"
|
||||
msgstr ""
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
# Croneter None <croneter@gmail.com>, 2017
|
||||
# Goncalo Campos <gda_campos@hotmail.com>, 2018
|
||||
# Bruno Guerreiro <american.jesus.pt@gmail.com>, 2019
|
||||
#
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PlexKodiConnect\n"
|
||||
|
@ -1677,3 +1677,8 @@ msgctxt "#39719"
|
|||
msgid "Replace user ratings with number of media versions"
|
||||
msgstr ""
|
||||
"Substituir classificações do utilizador com numero de versões de média"
|
||||
|
||||
# PKC Settings - Playback
|
||||
msgctxt "#39720"
|
||||
msgid "Auto skip intro"
|
||||
msgstr ""
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
# Alex Freit <alex.nx@mail.ru>, 2019
|
||||
# Vladimir Supranenok <stark_v@mail.ru>, 2019
|
||||
# Vlad Anisimov <uniss@ua.fm>, 2019
|
||||
#
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PlexKodiConnect\n"
|
||||
|
@ -1691,3 +1691,8 @@ msgstr ""
|
|||
msgctxt "#39719"
|
||||
msgid "Replace user ratings with number of media versions"
|
||||
msgstr "Заменить пользовательский рейтинг счетчиком версий элемента"
|
||||
|
||||
# PKC Settings - Playback
|
||||
msgctxt "#39720"
|
||||
msgid "Auto skip intro"
|
||||
msgstr ""
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
# Nisse Karlsson <transifex@xcorp.at>, 2019
|
||||
# Ludwig Johnson <public@ludwigjohnson.se>, 2019
|
||||
# namob <boman.d@gmail.com>, 2021
|
||||
#
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PlexKodiConnect\n"
|
||||
|
@ -1685,3 +1685,8 @@ msgstr ""
|
|||
msgctxt "#39719"
|
||||
msgid "Replace user ratings with number of media versions"
|
||||
msgstr "Ersätt användarbetyg med antalet mediaobjekt"
|
||||
|
||||
# PKC Settings - Playback
|
||||
msgctxt "#39720"
|
||||
msgid "Auto skip intro"
|
||||
msgstr ""
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
# XBMC Media Center language file
|
||||
# Translators:
|
||||
# Vlad Anisimov <uniss@ua.fm>, 2020
|
||||
#
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PlexKodiConnect\n"
|
||||
|
@ -1691,3 +1691,8 @@ msgstr ""
|
|||
msgctxt "#39719"
|
||||
msgid "Replace user ratings with number of media versions"
|
||||
msgstr "Замінити користувацький рейтинг лічильником версій елемента"
|
||||
|
||||
# PKC Settings - Playback
|
||||
msgctxt "#39720"
|
||||
msgid "Auto skip intro"
|
||||
msgstr ""
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
# Croneter None <croneter@gmail.com>, 2017
|
||||
# Tony Z <zj45499@gmail.com>, 2017
|
||||
# Jingen Chen <jingen.chen@gmail.com>, 2019
|
||||
#
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PlexKodiConnect\n"
|
||||
|
@ -1602,3 +1602,8 @@ msgstr ""
|
|||
msgctxt "#39719"
|
||||
msgid "Replace user ratings with number of media versions"
|
||||
msgstr ""
|
||||
|
||||
# PKC Settings - Playback
|
||||
msgctxt "#39720"
|
||||
msgid "Auto skip intro"
|
||||
msgstr ""
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
# XBMC Media Center language file
|
||||
# Translators:
|
||||
# Croneter None <croneter@gmail.com>, 2017
|
||||
#
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PlexKodiConnect\n"
|
||||
|
@ -1598,3 +1598,8 @@ msgstr ""
|
|||
msgctxt "#39719"
|
||||
msgid "Replace user ratings with number of media versions"
|
||||
msgstr ""
|
||||
|
||||
# PKC Settings - Playback
|
||||
msgctxt "#39720"
|
||||
msgid "Auto skip intro"
|
||||
msgstr ""
|
||||
|
|
|
@ -9,12 +9,14 @@ from .application import App
|
|||
from .connection import Connection
|
||||
from .libsync import Sync
|
||||
from .playstate import PlayState
|
||||
from .playqueues import Playqueues
|
||||
|
||||
ACCOUNT = None
|
||||
APP = None
|
||||
CONN = None
|
||||
SYNC = None
|
||||
PLAYSTATE = None
|
||||
PLAYQUEUES = None
|
||||
|
||||
|
||||
def init(entrypoint=False):
|
||||
|
@ -22,13 +24,15 @@ def init(entrypoint=False):
|
|||
entrypoint=True initiates only the bare minimum - for other PKC python
|
||||
instances
|
||||
"""
|
||||
global ACCOUNT, APP, CONN, SYNC, PLAYSTATE
|
||||
global ACCOUNT, APP, CONN, SYNC, PLAYSTATE, PLAYQUEUES
|
||||
APP = App(entrypoint)
|
||||
CONN = Connection(entrypoint)
|
||||
ACCOUNT = Account(entrypoint)
|
||||
SYNC = Sync(entrypoint)
|
||||
if not entrypoint:
|
||||
PLAYSTATE = PlayState()
|
||||
PLAYQUEUES = Playqueues()
|
||||
|
||||
|
||||
def reload():
|
||||
"""
|
||||
|
|
230
resources/lib/app/playqueues.py
Normal file
230
resources/lib/app/playqueues.py
Normal file
|
@ -0,0 +1,230 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
from logging import getLogger
|
||||
|
||||
import xbmc
|
||||
|
||||
from .. import variables as v
|
||||
|
||||
|
||||
LOG = getLogger('PLEX.playqueue')
|
||||
|
||||
|
||||
class Playqueue(object):
|
||||
"""
|
||||
PKC object to represent PMS playQueues and Kodi playlist for queueing
|
||||
|
||||
playlistid = None [int] Kodi playlist id (0, 1, 2)
|
||||
type = None [str] Kodi type: 'audio', 'video', 'picture'
|
||||
kodi_pl = None Kodi xbmc.PlayList object
|
||||
items = [] [list] of Playlist_Items
|
||||
id = None [str] Plex playQueueID, unique Plex identifier
|
||||
version = None [int] Plex version of the playQueue
|
||||
selectedItemID = None
|
||||
[str] Plex selectedItemID, playing element in queue
|
||||
selectedItemOffset = None
|
||||
[str] Offset of the playing element in queue
|
||||
shuffled = 0 [int] 0: not shuffled, 1: ??? 2: ???
|
||||
repeat = 0 [int] 0: not repeated, 1: ??? 2: ???
|
||||
|
||||
If Companion playback is initiated by another user:
|
||||
plex_transient_token = None
|
||||
"""
|
||||
kind = 'playQueue'
|
||||
|
||||
def __init__(self):
|
||||
self.id = None
|
||||
self.type = None
|
||||
self.playlistid = None
|
||||
self.kodi_pl = None
|
||||
self.items = []
|
||||
self.version = None
|
||||
self.selectedItemID = None
|
||||
self.selectedItemOffset = None
|
||||
self.shuffled = 0
|
||||
self.repeat = 0
|
||||
self.plex_transient_token = None
|
||||
# Need a hack for detecting swaps of elements
|
||||
self.old_kodi_pl = []
|
||||
# Did PKC itself just change the playqueue so the PKC playqueue monitor
|
||||
# should not pick up any changes?
|
||||
self.pkc_edit = False
|
||||
# Workaround to avoid endless loops of detecting PL clears
|
||||
self._clear_list = []
|
||||
# To keep track if Kodi playback was initiated from a Kodi playlist
|
||||
# There are a couple of pitfalls, unfortunately...
|
||||
self.kodi_playlist_playback = False
|
||||
|
||||
def __repr__(self):
|
||||
answ = ("{{"
|
||||
"'playlistid': {self.playlistid}, "
|
||||
"'id': {self.id}, "
|
||||
"'version': {self.version}, "
|
||||
"'type': '{self.type}', "
|
||||
"'selectedItemID': {self.selectedItemID}, "
|
||||
"'selectedItemOffset': {self.selectedItemOffset}, "
|
||||
"'shuffled': {self.shuffled}, "
|
||||
"'repeat': {self.repeat}, "
|
||||
"'kodi_playlist_playback': {self.kodi_playlist_playback}, "
|
||||
"'pkc_edit': {self.pkc_edit}, ".format(self=self))
|
||||
# Since list.__repr__ will return string, not unicode
|
||||
return answ + "'items': {self.items}}}".format(self=self)
|
||||
|
||||
def is_pkc_clear(self):
|
||||
"""
|
||||
Returns True if PKC has cleared the Kodi playqueue just recently.
|
||||
Then this clear will be ignored from now on
|
||||
"""
|
||||
try:
|
||||
self._clear_list.pop()
|
||||
except IndexError:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
def clear(self, kodi=True):
|
||||
"""
|
||||
Resets the playlist object to an empty playlist.
|
||||
|
||||
Pass kodi=False in order to NOT clear the Kodi playqueue
|
||||
"""
|
||||
# kodi monitor's on_clear method will only be called if there were some
|
||||
# items to begin with
|
||||
if kodi and self.kodi_pl.size() != 0:
|
||||
self._clear_list.append(None)
|
||||
self.kodi_pl.clear() # Clear Kodi playlist object
|
||||
self.items = []
|
||||
self.id = None
|
||||
self.version = None
|
||||
self.selectedItemID = None
|
||||
self.selectedItemOffset = None
|
||||
self.shuffled = 0
|
||||
self.repeat = 0
|
||||
self.plex_transient_token = None
|
||||
self.old_kodi_pl = []
|
||||
self.kodi_playlist_playback = False
|
||||
LOG.debug('Playlist cleared: %s', self)
|
||||
|
||||
def position_from_plex_id(self, plex_id):
|
||||
"""
|
||||
Returns the position [int] for the very first item with plex_id [int]
|
||||
(Plex seems uncapable of adding the same element multiple times to a
|
||||
playqueue or playlist)
|
||||
|
||||
Raises KeyError if not found
|
||||
"""
|
||||
for position, item in enumerate(self.items):
|
||||
if item.plex_id == plex_id:
|
||||
break
|
||||
else:
|
||||
raise KeyError('Did not find plex_id %s in %s', plex_id, self)
|
||||
return position
|
||||
|
||||
|
||||
class Playqueues(list):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
for i, typus in enumerate((v.KODI_PLAYLIST_TYPE_AUDIO,
|
||||
v.KODI_PLAYLIST_TYPE_VIDEO,
|
||||
v.KODI_PLAYLIST_TYPE_PHOTO)):
|
||||
playqueue = Playqueue()
|
||||
playqueue.playlistid = i
|
||||
playqueue.type = typus
|
||||
# Initialize each Kodi playlist
|
||||
if typus == v.KODI_PLAYLIST_TYPE_AUDIO:
|
||||
playqueue.kodi_pl = xbmc.PlayList(xbmc.PLAYLIST_MUSIC)
|
||||
elif typus == v.KODI_PLAYLIST_TYPE_VIDEO:
|
||||
playqueue.kodi_pl = xbmc.PlayList(xbmc.PLAYLIST_VIDEO)
|
||||
else:
|
||||
# Currently, only video or audio playqueues available
|
||||
playqueue.kodi_pl = xbmc.PlayList(xbmc.PLAYLIST_VIDEO)
|
||||
# Overwrite 'picture' with 'photo'
|
||||
playqueue.type = v.KODI_TYPE_PHOTO
|
||||
self.append(playqueue)
|
||||
|
||||
@property
|
||||
def audio(self):
|
||||
return self[0]
|
||||
|
||||
@property
|
||||
def video(self):
|
||||
return self[1]
|
||||
|
||||
@property
|
||||
def photo(self):
|
||||
return self[2]
|
||||
|
||||
def from_kodi_playlist_type(self, kodi_playlist_type):
|
||||
"""
|
||||
Returns the playqueue according to the kodi_playlist_type ('video',
|
||||
'audio', 'picture') passed in
|
||||
"""
|
||||
if kodi_playlist_type == v.KODI_PLAYLIST_TYPE_AUDIO:
|
||||
return self[0]
|
||||
elif kodi_playlist_type == v.KODI_PLAYLIST_TYPE_VIDEO:
|
||||
return self[1]
|
||||
elif kodi_playlist_type == v.KODI_PLAYLIST_TYPE_PHOTO:
|
||||
return self[2]
|
||||
else:
|
||||
raise ValueError('Unknown kodi_playlist_type: %s' % kodi_playlist_type)
|
||||
|
||||
def from_kodi_type(self, kodi_type):
|
||||
"""
|
||||
Pass in the kodi_type (e.g. the string 'movie') to get the correct
|
||||
playqueue (either video, audio or picture)
|
||||
"""
|
||||
if kodi_type == v.KODI_TYPE_VIDEO:
|
||||
return self[1]
|
||||
elif kodi_type == v.KODI_TYPE_MOVIE:
|
||||
return self[1]
|
||||
elif kodi_type == v.KODI_TYPE_EPISODE:
|
||||
return self[1]
|
||||
elif kodi_type == v.KODI_TYPE_SEASON:
|
||||
return self[1]
|
||||
elif kodi_type == v.KODI_TYPE_SHOW:
|
||||
return self[1]
|
||||
elif kodi_type == v.KODI_TYPE_CLIP:
|
||||
return self[1]
|
||||
elif kodi_type == v.KODI_TYPE_SONG:
|
||||
return self[0]
|
||||
elif kodi_type == v.KODI_TYPE_ALBUM:
|
||||
return self[0]
|
||||
elif kodi_type == v.KODI_TYPE_ARTIST:
|
||||
return self[0]
|
||||
elif kodi_type == v.KODI_TYPE_AUDIO:
|
||||
return self[0]
|
||||
elif kodi_type == v.KODI_TYPE_PHOTO:
|
||||
return self[2]
|
||||
else:
|
||||
raise ValueError('Unknown kodi_type: %s' % kodi_type)
|
||||
|
||||
def from_plex_type(self, plex_type):
|
||||
"""
|
||||
Pass in the plex_type (e.g. the string 'movie') to get the correct
|
||||
playqueue (either video, audio or picture)
|
||||
"""
|
||||
if plex_type == v.PLEX_TYPE_VIDEO:
|
||||
return self[1]
|
||||
elif plex_type == v.PLEX_TYPE_MOVIE:
|
||||
return self[1]
|
||||
elif plex_type == v.PLEX_TYPE_EPISODE:
|
||||
return self[1]
|
||||
elif plex_type == v.PLEX_TYPE_SEASON:
|
||||
return self[1]
|
||||
elif plex_type == v.PLEX_TYPE_SHOW:
|
||||
return self[1]
|
||||
elif plex_type == v.PLEX_TYPE_CLIP:
|
||||
return self[1]
|
||||
elif plex_type == v.PLEX_TYPE_SONG:
|
||||
return self[0]
|
||||
elif plex_type == v.PLEX_TYPE_ALBUM:
|
||||
return self[0]
|
||||
elif plex_type == v.PLEX_TYPE_ARTIST:
|
||||
return self[0]
|
||||
elif plex_type == v.PLEX_TYPE_AUDIO:
|
||||
return self[0]
|
||||
elif plex_type == v.PLEX_TYPE_PHOTO:
|
||||
return self[2]
|
||||
else:
|
||||
raise ValueError('Unknown plex_type: %s' % plex_type)
|
|
@ -44,12 +44,6 @@ class PlayState(object):
|
|||
1: {},
|
||||
2: {}
|
||||
}
|
||||
# The LAST playstate once playback is finished
|
||||
self.old_player_states = {
|
||||
0: {},
|
||||
1: {},
|
||||
2: {}
|
||||
}
|
||||
self.played_info = {}
|
||||
|
||||
# Currently playing PKC item, a PlaylistItem()
|
||||
|
|
|
@ -51,6 +51,22 @@ def getXArgsDeviceInfo(options=None, include_token=True):
|
|||
return xargs
|
||||
|
||||
|
||||
def generate_device_id():
|
||||
LOG.info("Generating a new deviceid.")
|
||||
from uuid import uuid4
|
||||
client_id = str(uuid4())
|
||||
utils.settings('plex_client_Id', value=client_id)
|
||||
v.PKC_MACHINE_IDENTIFIER = client_id
|
||||
utils.window('plex_client_Id', value=client_id)
|
||||
LOG.info("Unique device Id plex_client_Id generated: %s", client_id)
|
||||
# IF WE EXIT KODI NOW, THE SETTING WON'T STICK!
|
||||
# 'Kodi will now restart to apply the changes'
|
||||
# utils.messageDialog(utils.lang(29999), utils.lang(33033))
|
||||
# xbmc.executebuiltin('RestartApp')
|
||||
utils.messageDialog(utils.lang(29999), 'Please restart Kodi now!')
|
||||
return client_id
|
||||
|
||||
|
||||
def getDeviceId(reset=False):
|
||||
"""
|
||||
Returns a unique Plex client id "X-Plex-Client-Identifier" from Kodi
|
||||
|
@ -59,28 +75,18 @@ def getDeviceId(reset=False):
|
|||
|
||||
If id does not exist, create one and save in Kodi settings file.
|
||||
"""
|
||||
if reset is True:
|
||||
v.PKC_MACHINE_IDENTIFIER = None
|
||||
utils.window('plex_client_Id', clear=True)
|
||||
utils.settings('plex_client_Id', value="")
|
||||
if reset:
|
||||
return generate_device_id()
|
||||
|
||||
client_id = v.PKC_MACHINE_IDENTIFIER
|
||||
if client_id:
|
||||
return client_id
|
||||
|
||||
client_id = utils.settings('plex_client_Id')
|
||||
# Because Kodi appears to cache file settings!!
|
||||
if client_id != "" and reset is False:
|
||||
if client_id != "":
|
||||
v.PKC_MACHINE_IDENTIFIER = client_id
|
||||
utils.window('plex_client_Id', value=client_id)
|
||||
LOG.info("Unique device Id plex_client_Id loaded: %s", client_id)
|
||||
return client_id
|
||||
|
||||
LOG.info("Generating a new deviceid.")
|
||||
from uuid import uuid4
|
||||
client_id = str(uuid4())
|
||||
utils.settings('plex_client_Id', value=client_id)
|
||||
v.PKC_MACHINE_IDENTIFIER = client_id
|
||||
utils.window('plex_client_Id', value=client_id)
|
||||
LOG.info("Unique device Id plex_client_Id generated: %s", client_id)
|
||||
return client_id
|
||||
else:
|
||||
return generate_device_id()
|
||||
|
|
|
@ -6,8 +6,10 @@ Processes Plex companion inputs from the plexbmchelper to Kodi commands
|
|||
from logging import getLogger
|
||||
from xbmc import Player
|
||||
|
||||
from . import playqueue as PQ, plex_functions as PF
|
||||
from . import json_rpc as js, variables as v, app
|
||||
from . import plex_functions as PF
|
||||
from . import json_rpc as js
|
||||
from . import variables as v
|
||||
from . import app
|
||||
|
||||
###############################################################################
|
||||
|
||||
|
@ -28,7 +30,7 @@ def skip_to(params):
|
|||
playqueue_item_id, plex_id)
|
||||
found = True
|
||||
for player in list(js.get_players().values()):
|
||||
playqueue = PQ.PLAYQUEUES[player['playerid']]
|
||||
playqueue = app.PLAYQUEUES[player['playerid']]
|
||||
for i, item in enumerate(playqueue.items):
|
||||
if item.id == playqueue_item_id:
|
||||
found = True
|
||||
|
@ -78,6 +80,7 @@ def process_command(request_path, params):
|
|||
js.set_volume(int(params['volume']))
|
||||
else:
|
||||
LOG.error('Unknown parameters: %s', params)
|
||||
return False
|
||||
elif request_path == "player/playback/play":
|
||||
js.play()
|
||||
elif request_path == "player/playback/pause":
|
||||
|
@ -117,3 +120,5 @@ def process_command(request_path, params):
|
|||
})
|
||||
else:
|
||||
LOG.error('Unknown request path: %s', request_path)
|
||||
return False
|
||||
return True
|
||||
|
|
|
@ -6,8 +6,11 @@ import xbmcgui
|
|||
|
||||
from .plex_api import API
|
||||
from .plex_db import PlexDB
|
||||
from . import context, plex_functions as PF, playqueue as PQ
|
||||
from . import utils, variables as v, app
|
||||
from . import context
|
||||
from . import plex_functions as PF
|
||||
from . import utils
|
||||
from . import variables as v
|
||||
from . import app
|
||||
|
||||
###############################################################################
|
||||
|
||||
|
@ -137,8 +140,7 @@ class ContextMenu(object):
|
|||
"""
|
||||
For using direct paths: Initiates playback using the PMS
|
||||
"""
|
||||
playqueue = PQ.get_playqueue_from_type(
|
||||
v.KODI_PLAYLIST_TYPE_FROM_KODI_TYPE[self.kodi_type])
|
||||
playqueue = app.PLAYQUEUES.from_kodi_type(self.kodi_type)
|
||||
playqueue.clear()
|
||||
app.PLAYSTATE.context_menu_play = True
|
||||
handle = self.api.fullpath(force_addon=True)[0]
|
||||
|
|
|
@ -526,7 +526,7 @@ class InitialSetup(object):
|
|||
force_create=True,
|
||||
top_element='sources') as xml:
|
||||
changed = False
|
||||
for extension in ('smb://', 'nfs://'):
|
||||
for extension in ('smb://', 'nfs://', 'plugin://'):
|
||||
root = xml.set_setting(['video'])
|
||||
changed = self._add_sources(root, extension) or changed
|
||||
if changed:
|
||||
|
|
|
@ -442,8 +442,6 @@ def get_current_subtitle_stream_index(playerid):
|
|||
"""
|
||||
Returns the currently active subtitle stream index [int] or None if there
|
||||
are no subs
|
||||
PICKING UP CHANGES ON SUBTITLES IS CURRENTLY BROKEN ON THE KODI SIDE! The
|
||||
JSON reply won't change even though subtitles are changed :-(
|
||||
"""
|
||||
try:
|
||||
return JsonRPC('Player.GetProperties').execute({
|
||||
|
@ -456,8 +454,6 @@ def get_current_subtitle_stream_index(playerid):
|
|||
def get_subtitle_enabled(playerid):
|
||||
"""
|
||||
Returns True if a subtitle is currently enabled, False otherwise.
|
||||
PICKING UP CHANGES ON SUBTITLES IS CURRENTLY BROKEN ON THE KODI SIDE! The
|
||||
JSON reply won't change even though subtitles are changed :-(
|
||||
"""
|
||||
return JsonRPC('Player.GetProperties').execute({
|
||||
'playerid': playerid,
|
||||
|
|
|
@ -17,7 +17,7 @@ from .kodi_db import KodiVideoDB
|
|||
from . import kodi_db
|
||||
from .downloadutils import DownloadUtils as DU
|
||||
from . import utils, timing, plex_functions as PF
|
||||
from . import json_rpc as js, playqueue as PQ, playlist_func as PL
|
||||
from . import json_rpc as js, playlist_func as PL
|
||||
from . import backgroundthread, app, variables as v
|
||||
from . import exceptions
|
||||
|
||||
|
@ -31,11 +31,9 @@ class KodiMonitor(xbmc.Monitor):
|
|||
|
||||
def __init__(self):
|
||||
self._already_slept = False
|
||||
self._switched_to_plex_streams = True
|
||||
xbmc.Monitor.__init__(self)
|
||||
for playerid in app.PLAYSTATE.player_states:
|
||||
app.PLAYSTATE.player_states[playerid] = copy.deepcopy(app.PLAYSTATE.template)
|
||||
app.PLAYSTATE.old_player_states[playerid] = copy.deepcopy(app.PLAYSTATE.template)
|
||||
LOG.info("Kodi monitor started.")
|
||||
|
||||
def onScanStarted(self, library):
|
||||
|
@ -141,7 +139,7 @@ class KodiMonitor(xbmc.Monitor):
|
|||
u'playlistid': 1,
|
||||
}
|
||||
"""
|
||||
playqueue = PQ.PLAYQUEUES[data['playlistid']]
|
||||
playqueue = app.PLAYQUEUES[data['playlistid']]
|
||||
if not playqueue.is_pkc_clear():
|
||||
playqueue.pkc_edit = True
|
||||
playqueue.clear(kodi=False)
|
||||
|
@ -257,7 +255,7 @@ class KodiMonitor(xbmc.Monitor):
|
|||
if not playerid:
|
||||
LOG.error('Coud not get playerid for data %s', data)
|
||||
return
|
||||
playqueue = PQ.PLAYQUEUES[playerid]
|
||||
playqueue = app.PLAYQUEUES[playerid]
|
||||
info = js.get_player_props(playerid)
|
||||
if playqueue.kodi_playlist_playback:
|
||||
# Kodi will tell us the wrong position - of the playlist, not the
|
||||
|
@ -310,7 +308,7 @@ class KodiMonitor(xbmc.Monitor):
|
|||
initialize = False
|
||||
if initialize:
|
||||
LOG.debug('Need to initialize Plex and PKC playqueue')
|
||||
if not kodi_id or not kodi_type:
|
||||
if not kodi_id or not kodi_type or not path:
|
||||
kodi_id, kodi_type, path = self._json_item(playerid)
|
||||
plex_id, plex_type = self._get_ids(kodi_id, kodi_type, path)
|
||||
if not plex_id:
|
||||
|
@ -327,7 +325,7 @@ class KodiMonitor(xbmc.Monitor):
|
|||
container_key = None
|
||||
if info['playlistid'] != -1:
|
||||
# -1 is Kodi's answer if there is no playlist
|
||||
container_key = PQ.PLAYQUEUES[playerid].id
|
||||
container_key = app.PLAYQUEUES[playerid].id
|
||||
if container_key is not None:
|
||||
container_key = '/playQueues/%s' % container_key
|
||||
elif plex_id is not None:
|
||||
|
@ -345,6 +343,9 @@ class KodiMonitor(xbmc.Monitor):
|
|||
# Mechanik for Plex skip intro feature
|
||||
if utils.settings('enableSkipIntro') == 'true':
|
||||
status['intro_markers'] = item.api.intro_markers()
|
||||
if item.playmethod is None and not path.startswith('plugin://'):
|
||||
item.playmethod = v.PLAYBACK_METHOD_DIRECT_PATH
|
||||
item.playerid = playerid
|
||||
# Remember the currently playing item
|
||||
app.PLAYSTATE.item = item
|
||||
# Remember that this player has been active
|
||||
|
@ -365,7 +366,10 @@ class KodiMonitor(xbmc.Monitor):
|
|||
# Workaround for the Kodi add-on Up Next
|
||||
if not app.SYNC.direct_paths:
|
||||
_notify_upnext(item)
|
||||
self._switched_to_plex_streams = False
|
||||
|
||||
if playerid == v.KODI_VIDEO_PLAYER_ID:
|
||||
task = InitVideoStreams(item)
|
||||
backgroundthread.BGThreader.addTask(task)
|
||||
|
||||
def _on_av_change(self, data):
|
||||
"""
|
||||
|
@ -375,34 +379,8 @@ class KodiMonitor(xbmc.Monitor):
|
|||
Example data as returned by Kodi:
|
||||
{'item': {'id': 5, 'type': 'movie'},
|
||||
'player': {'playerid': 1, 'speed': 1}}
|
||||
|
||||
PICKING UP CHANGES ON SUBTITLES IS CURRENTLY BROKEN ON THE KODI SIDE!
|
||||
Kodi subs will never change. Also see json_rpc.py
|
||||
"""
|
||||
playerid = data['player']['playerid']
|
||||
if not playerid == v.KODI_VIDEO_PLAYER_ID:
|
||||
# We're just messing with Kodi's videoplayer
|
||||
return
|
||||
item = app.PLAYSTATE.item
|
||||
if item is None:
|
||||
# Player might've quit
|
||||
return
|
||||
if not self._switched_to_plex_streams:
|
||||
# We need to switch to the Plex streams ONCE upon playback start
|
||||
# after onavchange has been fired
|
||||
# Wait a bit because JSON responses won't be ready otherwise
|
||||
if app.APP.monitor.waitForAbort(2):
|
||||
# In case PKC needs to quit
|
||||
return
|
||||
item.init_kodi_streams()
|
||||
item.switch_to_plex_stream('video')
|
||||
if utils.settings('audioStreamPick') == '0':
|
||||
item.switch_to_plex_stream('audio')
|
||||
if utils.settings('subtitleStreamPick') == '0':
|
||||
item.switch_to_plex_stream('subtitle')
|
||||
self._switched_to_plex_streams = True
|
||||
else:
|
||||
item.on_av_change(playerid)
|
||||
pass
|
||||
|
||||
|
||||
def _playback_cleanup(ended=False):
|
||||
|
@ -421,8 +399,6 @@ def _playback_cleanup(ended=False):
|
|||
app.CONN.plex_transient_token = None
|
||||
for playerid in app.PLAYSTATE.active_players:
|
||||
status = app.PLAYSTATE.player_states[playerid]
|
||||
# Remember the last played item later
|
||||
app.PLAYSTATE.old_player_states[playerid] = copy.deepcopy(status)
|
||||
# Stop transcoding
|
||||
if status['playmethod'] == v.PLAYBACK_METHOD_TRANSCODE:
|
||||
LOG.debug('Tell the PMS to stop transcoding')
|
||||
|
@ -688,3 +664,19 @@ def _videolibrary_onupdate(data):
|
|||
PF.scrobble(db_item['plex_id'], 'watched')
|
||||
else:
|
||||
PF.scrobble(db_item['plex_id'], 'unwatched')
|
||||
|
||||
|
||||
class InitVideoStreams(backgroundthread.Task):
|
||||
"""
|
||||
The Kodi player takes forever to initialize all streams Especially
|
||||
subtitles, apparently. No way to tell when Kodi is done :-(
|
||||
"""
|
||||
|
||||
def __init__(self, item):
|
||||
self.item = item
|
||||
super().__init__()
|
||||
|
||||
def run(self):
|
||||
if app.APP.monitor.waitForAbort(5):
|
||||
return
|
||||
self.item.init_streams()
|
||||
|
|
|
@ -12,8 +12,12 @@ import xbmc
|
|||
from .plex_api import API
|
||||
from .plex_db import PlexDB
|
||||
from .kodi_db import KodiVideoDB
|
||||
from . import plex_functions as PF, playlist_func as PL, playqueue as PQ
|
||||
from . import json_rpc as js, variables as v, utils, transfer
|
||||
from . import plex_functions as PF
|
||||
from . import playlist_func as PL
|
||||
from . import json_rpc as js
|
||||
from . import variables as v
|
||||
from . import utils
|
||||
from . import transfer
|
||||
from . import playback_decision, app
|
||||
from . import exceptions
|
||||
|
||||
|
@ -74,20 +78,19 @@ def _playback_triage(plex_id, plex_type, path, resolve, resume):
|
|||
_ensure_resolve(abort=True)
|
||||
return
|
||||
with app.APP.lock_playqueues:
|
||||
playqueue = PQ.get_playqueue_from_type(
|
||||
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[plex_type])
|
||||
playqueue = app.PLAYQUEUES.from_plex_type(plex_type)
|
||||
try:
|
||||
pos = js.get_position(playqueue.playlistid)
|
||||
except KeyError:
|
||||
# Kodi bug - Playlist plays (not Playqueue) will ALWAYS be audio for
|
||||
# add-on paths
|
||||
LOG.debug('No position returned from player! Assuming playlist')
|
||||
playqueue = PQ.get_playqueue_from_type(v.KODI_PLAYLIST_TYPE_AUDIO)
|
||||
playqueue = app.PLAYQUEUES.audio
|
||||
try:
|
||||
pos = js.get_position(playqueue.playlistid)
|
||||
except KeyError:
|
||||
LOG.debug('Assuming video instead of audio playlist playback')
|
||||
playqueue = PQ.get_playqueue_from_type(v.KODI_PLAYLIST_TYPE_VIDEO)
|
||||
playqueue = app.PLAYQUEUES.video
|
||||
try:
|
||||
pos = js.get_position(playqueue.playlistid)
|
||||
except KeyError:
|
||||
|
@ -159,7 +162,7 @@ def _playlist_playback(plex_id):
|
|||
return
|
||||
# Kodi bug: playqueue will ALWAYS be audio playqueue UNTIL playback
|
||||
# has actually started. Need to tell Kodimonitor
|
||||
playqueue = PQ.get_playqueue_from_type(v.KODI_PLAYLIST_TYPE_AUDIO)
|
||||
playqueue = app.PLAYQUEUES.audio
|
||||
playqueue.clear(kodi=False)
|
||||
# Set the flag for the potentially WRONG audio playlist so Kodimonitor
|
||||
# can pick up on it
|
||||
|
@ -499,8 +502,7 @@ def process_indirect(key, offset, resolve=True):
|
|||
|
||||
api = API(xml[0])
|
||||
listitem = api.listitem(listitem=transfer.PKCListItem, resume=False)
|
||||
playqueue = PQ.get_playqueue_from_type(
|
||||
v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[api.plex_type])
|
||||
playqueue = app.PLAYQUEUES.from_plex_type(api.plex_type)
|
||||
playqueue.clear()
|
||||
item = PL.playlist_item_from_xml(xml[0])
|
||||
item.offset = offset
|
||||
|
|
|
@ -22,117 +22,6 @@ from .subtitles import accessible_plex_subtitles
|
|||
LOG = getLogger('PLEX.playlist_func')
|
||||
|
||||
|
||||
class Playqueue_Object(object):
|
||||
"""
|
||||
PKC object to represent PMS playQueues and Kodi playlist for queueing
|
||||
|
||||
playlistid = None [int] Kodi playlist id (0, 1, 2)
|
||||
type = None [str] Kodi type: 'audio', 'video', 'picture'
|
||||
kodi_pl = None Kodi xbmc.PlayList object
|
||||
items = [] [list] of Playlist_Items
|
||||
id = None [str] Plex playQueueID, unique Plex identifier
|
||||
version = None [int] Plex version of the playQueue
|
||||
selectedItemID = None
|
||||
[str] Plex selectedItemID, playing element in queue
|
||||
selectedItemOffset = None
|
||||
[str] Offset of the playing element in queue
|
||||
shuffled = 0 [int] 0: not shuffled, 1: ??? 2: ???
|
||||
repeat = 0 [int] 0: not repeated, 1: ??? 2: ???
|
||||
|
||||
If Companion playback is initiated by another user:
|
||||
plex_transient_token = None
|
||||
"""
|
||||
kind = 'playQueue'
|
||||
|
||||
def __init__(self):
|
||||
self.id = None
|
||||
self.type = None
|
||||
self.playlistid = None
|
||||
self.kodi_pl = None
|
||||
self.items = []
|
||||
self.version = None
|
||||
self.selectedItemID = None
|
||||
self.selectedItemOffset = None
|
||||
self.shuffled = 0
|
||||
self.repeat = 0
|
||||
self.plex_transient_token = None
|
||||
# Need a hack for detecting swaps of elements
|
||||
self.old_kodi_pl = []
|
||||
# Did PKC itself just change the playqueue so the PKC playqueue monitor
|
||||
# should not pick up any changes?
|
||||
self.pkc_edit = False
|
||||
# Workaround to avoid endless loops of detecting PL clears
|
||||
self._clear_list = []
|
||||
# To keep track if Kodi playback was initiated from a Kodi playlist
|
||||
# There are a couple of pitfalls, unfortunately...
|
||||
self.kodi_playlist_playback = False
|
||||
|
||||
def __repr__(self):
|
||||
answ = ("{{"
|
||||
"'playlistid': {self.playlistid}, "
|
||||
"'id': {self.id}, "
|
||||
"'version': {self.version}, "
|
||||
"'type': '{self.type}', "
|
||||
"'selectedItemID': {self.selectedItemID}, "
|
||||
"'selectedItemOffset': {self.selectedItemOffset}, "
|
||||
"'shuffled': {self.shuffled}, "
|
||||
"'repeat': {self.repeat}, "
|
||||
"'kodi_playlist_playback': {self.kodi_playlist_playback}, "
|
||||
"'pkc_edit': {self.pkc_edit}, ".format(self=self))
|
||||
# Since list.__repr__ will return string, not unicode
|
||||
return answ + "'items': {self.items}}}".format(self=self)
|
||||
|
||||
def is_pkc_clear(self):
|
||||
"""
|
||||
Returns True if PKC has cleared the Kodi playqueue just recently.
|
||||
Then this clear will be ignored from now on
|
||||
"""
|
||||
try:
|
||||
self._clear_list.pop()
|
||||
except IndexError:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
def clear(self, kodi=True):
|
||||
"""
|
||||
Resets the playlist object to an empty playlist.
|
||||
|
||||
Pass kodi=False in order to NOT clear the Kodi playqueue
|
||||
"""
|
||||
# kodi monitor's on_clear method will only be called if there were some
|
||||
# items to begin with
|
||||
if kodi and self.kodi_pl.size() != 0:
|
||||
self._clear_list.append(None)
|
||||
self.kodi_pl.clear() # Clear Kodi playlist object
|
||||
self.items = []
|
||||
self.id = None
|
||||
self.version = None
|
||||
self.selectedItemID = None
|
||||
self.selectedItemOffset = None
|
||||
self.shuffled = 0
|
||||
self.repeat = 0
|
||||
self.plex_transient_token = None
|
||||
self.old_kodi_pl = []
|
||||
self.kodi_playlist_playback = False
|
||||
LOG.debug('Playlist cleared: %s', self)
|
||||
|
||||
def position_from_plex_id(self, plex_id):
|
||||
"""
|
||||
Returns the position [int] for the very first item with plex_id [int]
|
||||
(Plex seems uncapable of adding the same element multiple times to a
|
||||
playqueue or playlist)
|
||||
|
||||
Raises KeyError if not found
|
||||
"""
|
||||
for position, item in enumerate(self.items):
|
||||
if item.plex_id == plex_id:
|
||||
break
|
||||
else:
|
||||
raise KeyError('Did not find plex_id %s in %s', plex_id, self)
|
||||
return position
|
||||
|
||||
|
||||
class PlaylistItem(object):
|
||||
"""
|
||||
Object to fill our playqueues and playlists with.
|
||||
|
@ -166,6 +55,7 @@ class PlaylistItem(object):
|
|||
self.playmethod = None
|
||||
self.playcount = None
|
||||
self.offset = None
|
||||
self.playerid = None
|
||||
# Transcoding quality, if needed
|
||||
self.quality = None
|
||||
# If Plex video consists of several parts; part number
|
||||
|
@ -183,11 +73,12 @@ class PlaylistItem(object):
|
|||
self._audio_streams = None
|
||||
self._subtitle_streams = None
|
||||
# Which Kodi streams are active?
|
||||
self.current_kodi_video_stream = None
|
||||
self.current_kodi_audio_stream = None
|
||||
# False means "deactivated", None means "we do not have a Kodi
|
||||
# equivalent for this Plex subtitle"
|
||||
self.current_kodi_sub_stream = None
|
||||
self._current_kodi_video_stream = None
|
||||
self._current_kodi_audio_stream = None
|
||||
# Kodi subs can be turned on/off additionally!
|
||||
self._current_kodi_sub_stream = None
|
||||
self._current_kodi_sub_stream_enabled = None
|
||||
self.streams_initialized = False
|
||||
|
||||
@property
|
||||
def plex_id(self):
|
||||
|
@ -221,6 +112,48 @@ class PlaylistItem(object):
|
|||
self._process_streams()
|
||||
return self._subtitle_streams
|
||||
|
||||
@property
|
||||
def current_kodi_video_stream(self):
|
||||
return self._current_kodi_video_stream
|
||||
|
||||
@current_kodi_video_stream.setter
|
||||
def current_kodi_video_stream(self, value):
|
||||
if value != self._current_kodi_video_stream:
|
||||
self.on_kodi_video_stream_change(value)
|
||||
self._current_kodi_video_stream = value
|
||||
|
||||
@property
|
||||
def current_kodi_audio_stream(self):
|
||||
return self._current_kodi_audio_stream
|
||||
|
||||
@current_kodi_audio_stream.setter
|
||||
def current_kodi_audio_stream(self, value):
|
||||
if value != self._current_kodi_audio_stream:
|
||||
self.on_kodi_audio_stream_change(value)
|
||||
self._current_kodi_audio_stream = value
|
||||
|
||||
@property
|
||||
def current_kodi_sub_stream_enabled(self):
|
||||
return self._current_kodi_sub_stream_enabled
|
||||
|
||||
@current_kodi_sub_stream_enabled.setter
|
||||
def current_kodi_sub_stream_enabled(self, value):
|
||||
if value != self._current_kodi_sub_stream_enabled:
|
||||
self.on_kodi_subtitle_stream_change(self.current_kodi_sub_stream,
|
||||
value)
|
||||
self._current_kodi_sub_stream_enabled = value
|
||||
|
||||
@property
|
||||
def current_kodi_sub_stream(self):
|
||||
return self._current_kodi_sub_stream
|
||||
|
||||
@current_kodi_sub_stream.setter
|
||||
def current_kodi_sub_stream(self, value):
|
||||
if value != self._current_kodi_sub_stream:
|
||||
self.on_kodi_subtitle_stream_change(value,
|
||||
self.current_kodi_sub_stream_enabled)
|
||||
self._current_kodi_sub_stream = value
|
||||
|
||||
@property
|
||||
def current_plex_video_stream(self):
|
||||
return self.plex_stream_index(self.current_kodi_video_stream, 'video')
|
||||
|
@ -247,7 +180,7 @@ class PlaylistItem(object):
|
|||
"'resume': {self.resume},"
|
||||
"'offset': {self.offset}, "
|
||||
"'force_transcode': {self.force_transcode}, "
|
||||
"'part': {self.part}".format(self=self))
|
||||
"'part': {self.part}}}".format(self=self))
|
||||
|
||||
def _process_streams(self):
|
||||
"""
|
||||
|
@ -281,44 +214,96 @@ class PlaylistItem(object):
|
|||
elif stream_type == 'video':
|
||||
return self.video_streams
|
||||
|
||||
def init_kodi_streams(self):
|
||||
def _current_index(self, stream_type):
|
||||
"""
|
||||
Kodi might tell us the wrong index for any stream after playback start
|
||||
Get the correct one!
|
||||
"""
|
||||
function = {
|
||||
'audio': js.get_current_audio_stream_index,
|
||||
'video': js.get_current_video_stream_index,
|
||||
'subtitle': js.get_current_subtitle_stream_index
|
||||
}[stream_type]
|
||||
i = 0
|
||||
while i < 30:
|
||||
# Really annoying: Kodi might return wrong results directly after
|
||||
# playback startup, e.g. a Kodi audio index of 1953718901 (!)
|
||||
try:
|
||||
index = function(self.playerid)
|
||||
except (TypeError, IndexError, KeyError):
|
||||
# No sensible reply yet
|
||||
pass
|
||||
else:
|
||||
if index != 1953718901:
|
||||
# Correct result!
|
||||
return index
|
||||
i += 1
|
||||
app.APP.monitor.waitForAbort(0.1)
|
||||
else:
|
||||
raise RuntimeError('Kodi did not tell us the correct index for %s'
|
||||
% stream_type)
|
||||
|
||||
def init_streams(self):
|
||||
"""
|
||||
Initializes all streams after Kodi has started playing this video
|
||||
WARNING: KODI TAKES FOREVER TO INITIALIZE STREAMS AFTER PLAYBACK
|
||||
STARTUP. YOU WONT GET THE CORRECT NUMBER OFAUDIO AND SUB STREAMS RIGHT
|
||||
AFTER STARTUP. Seems like you need to wait a couple of seconds
|
||||
"""
|
||||
self.current_kodi_video_stream = js.get_current_video_stream_index(v.KODI_VIDEO_PLAYER_ID)
|
||||
self.current_kodi_audio_stream = js.get_current_audio_stream_index(v.KODI_VIDEO_PLAYER_ID)
|
||||
self.current_kodi_sub_stream = False if not js.get_subtitle_enabled(v.KODI_VIDEO_PLAYER_ID) \
|
||||
else js.get_current_subtitle_stream_index(v.KODI_VIDEO_PLAYER_ID)
|
||||
if not app.PLAYSTATE.item == self:
|
||||
# Already stopped playback or skipped to the next one
|
||||
LOG.warn('Skipping init_streams!')
|
||||
return
|
||||
self.init_kodi_streams()
|
||||
self.switch_to_plex_stream('video')
|
||||
if utils.settings('audioStreamPick') == '0':
|
||||
self.switch_to_plex_stream('audio')
|
||||
if utils.settings('subtitleStreamPick') == '0':
|
||||
self.switch_to_plex_stream('subtitle')
|
||||
self.streams_initialized = True
|
||||
|
||||
def init_kodi_streams(self):
|
||||
self._current_kodi_video_stream = self._current_index('video')
|
||||
self._current_kodi_audio_stream = self._current_index('audio')
|
||||
self._current_kodi_sub_stream_enabled = js.get_subtitle_enabled(self.playerid)
|
||||
self._current_kodi_sub_stream = self._current_index('subtitle')
|
||||
|
||||
def plex_stream_index(self, kodi_stream_index, stream_type):
|
||||
"""
|
||||
Pass in the kodi_stream_index [int] in order to receive the Plex stream
|
||||
index [int].
|
||||
stream_type: 'video', 'audio', 'subtitle'
|
||||
Returns None if unsuccessful
|
||||
"""
|
||||
if stream_type == 'audio':
|
||||
return int(self.audio_streams[kodi_stream_index].get('id'))
|
||||
elif stream_type == 'video':
|
||||
return int(self.video_streams[kodi_stream_index].get('id'))
|
||||
elif stream_type == 'subtitle':
|
||||
try:
|
||||
return int(self.subtitle_streams[kodi_stream_index].get('id'))
|
||||
except (IndexError, TypeError):
|
||||
pass
|
||||
if self.current_kodi_sub_stream_enabled:
|
||||
try:
|
||||
return int(self.subtitle_streams[kodi_stream_index].get('id'))
|
||||
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):
|
||||
"""
|
||||
Pass in the plex_stream_index [int] in order to receive the Kodi stream
|
||||
index [int].
|
||||
stream_type: 'video', 'audio', 'subtitle'
|
||||
Returns None if unsuccessful
|
||||
Raises ValueError if unsuccessful
|
||||
"""
|
||||
if plex_stream_index is None:
|
||||
return
|
||||
if not isinstance(plex_stream_index, int):
|
||||
raise ValueError('%s plex_stream_index %s of type %s received' %
|
||||
(stream_type, plex_stream_index, type(plex_stream_index)))
|
||||
for i, stream in enumerate(self._get_iterator(stream_type)):
|
||||
if cast(int, stream.get('id')) == plex_stream_index:
|
||||
return i
|
||||
raise ValueError('No %s kodi_stream_index for plex_stream_index %s' %
|
||||
(stream_type, plex_stream_index))
|
||||
|
||||
def active_plex_stream_index(self, stream_type):
|
||||
"""
|
||||
|
@ -341,16 +326,14 @@ class PlaylistItem(object):
|
|||
except (IndexError, TypeError):
|
||||
LOG.debug('Kodi subtitle change detected to a sub %s that is '
|
||||
'NOT available on the Plex side', kodi_stream_index)
|
||||
self.current_kodi_sub_stream = None
|
||||
return
|
||||
LOG.debug('Kodi subtitle change detected: telling Plex about '
|
||||
'switch to index %s, Plex stream id %s',
|
||||
kodi_stream_index, plex_stream_index)
|
||||
self.current_kodi_sub_stream = kodi_stream_index
|
||||
plex_stream_index = 0
|
||||
else:
|
||||
LOG.debug('Kodi subtitle change detected: telling Plex about '
|
||||
'switch to index %s, Plex stream id %s',
|
||||
kodi_stream_index, plex_stream_index)
|
||||
else:
|
||||
plex_stream_index = 0
|
||||
LOG.debug('Kodi subtitle has been deactivated, telling Plex')
|
||||
self.current_kodi_sub_stream = False
|
||||
PF.change_subtitle(plex_stream_index, self.api.part_id())
|
||||
|
||||
def on_kodi_audio_stream_change(self, kodi_stream_index):
|
||||
|
@ -362,7 +345,6 @@ class PlaylistItem(object):
|
|||
LOG.debug('Changing Plex audio stream to %s, Kodi index %s',
|
||||
plex_stream_index, kodi_stream_index)
|
||||
PF.change_audio_stream(plex_stream_index, self.api.part_id())
|
||||
self.current_kodi_audio_stream = kodi_stream_index
|
||||
|
||||
def on_kodi_video_stream_change(self, kodi_stream_index):
|
||||
"""
|
||||
|
@ -373,43 +355,55 @@ class PlaylistItem(object):
|
|||
LOG.debug('Changing Plex video stream to %s, Kodi index %s',
|
||||
plex_stream_index, kodi_stream_index)
|
||||
PF.change_video_stream(plex_stream_index, self.api.part_id())
|
||||
self.current_kodi_video_stream = kodi_stream_index
|
||||
|
||||
def switch_to_plex_streams(self):
|
||||
self.switch_to_plex_stream('video')
|
||||
self.switch_to_plex_stream('audio')
|
||||
self.switch_to_plex_stream('subtitle')
|
||||
|
||||
@staticmethod
|
||||
def _set_kodi_stream_if_different(kodi_index, typus):
|
||||
def _set_kodi_stream_if_different(self, kodi_index, typus):
|
||||
"""Will always activate subtitles."""
|
||||
if typus == 'video':
|
||||
current = js.get_current_video_stream_index(v.KODI_VIDEO_PLAYER_ID)
|
||||
current = js.get_current_video_stream_index(self.playerid)
|
||||
if current != kodi_index:
|
||||
LOG.debug('Switching video stream')
|
||||
app.APP.player.setVideoStream(kodi_index)
|
||||
else:
|
||||
LOG.debug('Not switching video stream (no change)')
|
||||
elif typus == 'audio':
|
||||
current = js.get_current_audio_stream_index(v.KODI_VIDEO_PLAYER_ID)
|
||||
current = js.get_current_audio_stream_index(self.playerid)
|
||||
if current != kodi_index:
|
||||
LOG.debug('Switching audio stream')
|
||||
app.APP.player.setAudioStream(kodi_index)
|
||||
else:
|
||||
LOG.debug('Not switching audio stream (no change)')
|
||||
elif typus == 'subtitle':
|
||||
current = js.get_current_subtitle_stream_index(self.playerid)
|
||||
enabled = js.get_subtitle_enabled(self.playerid)
|
||||
if current != kodi_index:
|
||||
LOG.debug('Switching subtitle stream')
|
||||
app.APP.player.setAudioStream(kodi_index)
|
||||
else:
|
||||
LOG.debug('Not switching subtitle stream (no change)')
|
||||
if not enabled:
|
||||
LOG.debug('Enabling subtitles')
|
||||
app.APP.player.showSubtitles(True)
|
||||
else:
|
||||
raise RuntimeError('Unknown stream type %s' % typus)
|
||||
|
||||
def switch_to_plex_stream(self, typus):
|
||||
try:
|
||||
plex_index, language_tag = self.active_plex_stream_index(typus)
|
||||
except TypeError:
|
||||
# Only happens if Plex did not provide us with a suitable sub
|
||||
# Meaning Plex tells us to deactivate subs
|
||||
LOG.debug('Deactivating Kodi subtitles because the PMS '
|
||||
'told us to not show any subtitles')
|
||||
app.APP.player.showSubtitles(False)
|
||||
self.current_kodi_sub_stream = False
|
||||
self._current_kodi_sub_stream_enabled = False
|
||||
return
|
||||
# Rest: video, audio and activated subs
|
||||
LOG.debug('The PMS wants to display %s stream with Plex id %s and '
|
||||
'languageTag %s', typus, plex_index, language_tag)
|
||||
kodi_index = self.kodi_stream_index(plex_index, typus)
|
||||
if kodi_index is None:
|
||||
try:
|
||||
kodi_index = self.kodi_stream_index(plex_index, typus)
|
||||
except ValueError:
|
||||
kodi_index = None
|
||||
LOG.debug('Leaving Kodi %s stream settings untouched since we '
|
||||
'could not parse Plex %s stream with id %s to a Kodi'
|
||||
' index', typus, typus, plex_index)
|
||||
|
@ -419,69 +413,55 @@ class PlaylistItem(object):
|
|||
typus, kodi_index, plex_index)
|
||||
# If we're choosing an "illegal" index, this function does
|
||||
# need seem to fail nor log any errors
|
||||
if typus == 'audio':
|
||||
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')
|
||||
self._set_kodi_stream_if_different(kodi_index, typus)
|
||||
if typus == 'audio':
|
||||
self.current_kodi_audio_stream = kodi_index
|
||||
self._current_kodi_audio_stream = kodi_index
|
||||
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':
|
||||
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"
|
||||
(event "Player.OnAVChange")
|
||||
Call this method if Plex Companion wants to change streams [ints]
|
||||
"""
|
||||
kodi_video_stream = js.get_current_video_stream_index(playerid)
|
||||
kodi_audio_stream = js.get_current_audio_stream_index(playerid)
|
||||
sub_enabled = js.get_subtitle_enabled(playerid)
|
||||
kodi_sub_stream = js.get_current_subtitle_stream_index(playerid)
|
||||
# Audio
|
||||
if kodi_audio_stream != self.current_kodi_audio_stream:
|
||||
self.on_kodi_audio_stream_change(kodi_audio_stream)
|
||||
# Video
|
||||
if kodi_video_stream != self.current_kodi_video_stream:
|
||||
self.on_kodi_video_stream_change(kodi_audio_stream)
|
||||
# Subtitles - CURRENTLY BROKEN ON THE KODI SIDE!
|
||||
# current_kodi_sub_stream may also be zero
|
||||
subs_off = (None, False)
|
||||
if ((sub_enabled and self.current_kodi_sub_stream in subs_off)
|
||||
or (not sub_enabled and self.current_kodi_sub_stream not in subs_off)
|
||||
or (kodi_sub_stream is not None
|
||||
and kodi_sub_stream != self.current_kodi_sub_stream)):
|
||||
self.on_kodi_subtitle_stream_change(kodi_sub_stream, sub_enabled)
|
||||
|
||||
def on_plex_stream_change(self, plex_data):
|
||||
"""
|
||||
Call this method if Plex Companion wants to change streams
|
||||
"""
|
||||
if 'audioStreamID' in plex_data:
|
||||
plex_index = int(plex_data['audioStreamID'])
|
||||
kodi_index = self.kodi_stream_index(plex_index, 'audio')
|
||||
self._set_kodi_stream_if_different(kodi_index, 'audio')
|
||||
self.current_kodi_audio_stream = kodi_index
|
||||
if 'videoStreamID' in plex_data:
|
||||
plex_index = int(plex_data['videoStreamID'])
|
||||
kodi_index = self.kodi_stream_index(plex_index, 'video')
|
||||
if video_stream_id is not None:
|
||||
try:
|
||||
kodi_index = self.kodi_stream_index(video_stream_id, 'video')
|
||||
except ValueError:
|
||||
LOG.error('Unexpected Plex video_stream_id %s, not changing '
|
||||
'the video stream!', video_stream_id)
|
||||
return
|
||||
self._set_kodi_stream_if_different(kodi_index, 'video')
|
||||
self.current_kodi_video_stream = kodi_index
|
||||
if 'subtitleStreamID' in plex_data:
|
||||
plex_index = int(plex_data['subtitleStreamID'])
|
||||
if plex_index == 0:
|
||||
self._current_kodi_video_stream = kodi_index
|
||||
if audio_stream_id is not None:
|
||||
try:
|
||||
kodi_index = self.kodi_stream_index(audio_stream_id, 'audio')
|
||||
except ValueError:
|
||||
LOG.error('Unexpected Plex audio_stream_id %s, not changing '
|
||||
'the video stream!', audio_stream_id)
|
||||
return
|
||||
self._set_kodi_stream_if_different(kodi_index, 'audio')
|
||||
self._current_kodi_audio_stream = kodi_index
|
||||
if subtitle_stream_id is not None:
|
||||
if subtitle_stream_id == 0:
|
||||
app.APP.player.showSubtitles(False)
|
||||
kodi_index = False
|
||||
self._current_kodi_sub_stream_enabled = False
|
||||
else:
|
||||
kodi_index = self.kodi_stream_index(plex_index, 'subtitle')
|
||||
if kodi_index:
|
||||
try:
|
||||
kodi_index = self.kodi_stream_index(subtitle_stream_id,
|
||||
'subtitle')
|
||||
except ValueError:
|
||||
LOG.debug('The PMS wanted to change subs, but we could not'
|
||||
' match the sub with id %s to a Kodi sub',
|
||||
subtitle_stream_id)
|
||||
else:
|
||||
app.APP.player.setSubtitleStream(kodi_index)
|
||||
app.APP.player.showSubtitles(True)
|
||||
self.current_kodi_sub_stream = kodi_index
|
||||
self._current_kodi_sub_stream_enabled = True
|
||||
self._current_kodi_sub_stream = kodi_index
|
||||
|
||||
|
||||
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
|
||||
"""
|
||||
LOG.debug('Deleting position %s for %s on the Plex side', pos, playlist)
|
||||
xml = DU().downloadUrl("{server}/%ss/%s/items/%s?repeat=%s" %
|
||||
(playlist.kind,
|
||||
playlist.id,
|
||||
playlist.items[pos].id,
|
||||
playlist.repeat),
|
||||
action_type="DELETE")
|
||||
del playlist.items[pos]
|
||||
_update_playlist_version(playlist, xml)
|
||||
try:
|
||||
xml = DU().downloadUrl("{server}/%ss/%s/items/%s?repeat=%s" %
|
||||
(playlist.kind,
|
||||
playlist.id,
|
||||
playlist.items[pos].id,
|
||||
playlist.repeat),
|
||||
action_type="DELETE")
|
||||
except IndexError:
|
||||
raise PlaylistError('Position %s out of bound for %s' % (pos, playlist))
|
||||
else:
|
||||
del playlist.items[pos]
|
||||
_update_playlist_version(playlist, xml)
|
||||
|
||||
|
||||
# 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,
|
||||
timeout=15,
|
||||
return_response=True)
|
||||
if not data.ok:
|
||||
if data in (None, 401) or not data.ok:
|
||||
LOG.debug('Could not download data from FanartTV')
|
||||
return artworks
|
||||
data = data.json()
|
||||
|
|
|
@ -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):
|
||||
"""
|
||||
Removes the entry for playlist [Playqueue_Object] from the Plex
|
||||
Removes the entry for playlist [Playqueue()] from the Plex
|
||||
playlists table.
|
||||
Be sure to either set playlist.id or playlist.kodi_path
|
||||
"""
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
# Dummy file to make this directory a package.
|
|
@ -1,105 +0,0 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
from logging import getLogger
|
||||
import http.client
|
||||
import traceback
|
||||
import string
|
||||
import errno
|
||||
from socket import error as socket_error
|
||||
|
||||
###############################################################################
|
||||
|
||||
LOG = getLogger('PLEX.httppersist')
|
||||
|
||||
###############################################################################
|
||||
|
||||
|
||||
class RequestMgr(object):
|
||||
def __init__(self):
|
||||
self.conns = {}
|
||||
|
||||
def getConnection(self, protocol, host, port):
|
||||
conn = self.conns.get(protocol + host + str(port), False)
|
||||
if not conn:
|
||||
if protocol == "https":
|
||||
conn = http.client.HTTPSConnection(host, port)
|
||||
else:
|
||||
conn = http.client.HTTPConnection(host, port)
|
||||
self.conns[protocol + host + str(port)] = conn
|
||||
return conn
|
||||
|
||||
def closeConnection(self, protocol, host, port):
|
||||
conn = self.conns.get(protocol + host + str(port), False)
|
||||
if conn:
|
||||
conn.close()
|
||||
self.conns.pop(protocol + host + str(port), None)
|
||||
|
||||
def dumpConnections(self):
|
||||
for conn in list(self.conns.values()):
|
||||
conn.close()
|
||||
self.conns = {}
|
||||
|
||||
def post(self, host, port, path, body, header={}, protocol="http"):
|
||||
conn = None
|
||||
try:
|
||||
conn = self.getConnection(protocol, host, port)
|
||||
header['Connection'] = "keep-alive"
|
||||
conn.request("POST", path, body, header)
|
||||
data = conn.getresponse()
|
||||
if int(data.status) >= 400:
|
||||
LOG.error("HTTP response error: %s" % str(data.status))
|
||||
# this should return false, but I'm hacking it since iOS
|
||||
# returns 404 no matter what
|
||||
return data.read() or True
|
||||
else:
|
||||
return data.read() or True
|
||||
except socket_error as serr:
|
||||
# Ignore remote close and connection refused (e.g. shutdown PKC)
|
||||
if serr.errno in (errno.WSAECONNABORTED, errno.WSAECONNREFUSED):
|
||||
pass
|
||||
else:
|
||||
LOG.error("Unable to connect to %s\nReason:" % host)
|
||||
LOG.error(traceback.print_exc())
|
||||
self.conns.pop(protocol + host + str(port), None)
|
||||
if conn:
|
||||
conn.close()
|
||||
return False
|
||||
except Exception as e:
|
||||
LOG.error("Exception encountered: %s", e)
|
||||
# Close connection just in case
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
|
||||
def getwithparams(self, host, port, path, params, header={},
|
||||
protocol="http"):
|
||||
newpath = path + '?'
|
||||
pairs = []
|
||||
for key in params:
|
||||
pairs.append(str(key) + '=' + str(params[key]))
|
||||
newpath += string.join(pairs, '&')
|
||||
return self.get(host, port, newpath, header, protocol)
|
||||
|
||||
def get(self, host, port, path, header={}, protocol="http"):
|
||||
try:
|
||||
conn = self.getConnection(protocol, host, port)
|
||||
header['Connection'] = "keep-alive"
|
||||
conn.request("GET", path, headers=header)
|
||||
data = conn.getresponse()
|
||||
if int(data.status) >= 400:
|
||||
LOG.error("HTTP response error: %s", str(data.status))
|
||||
return False
|
||||
else:
|
||||
return data.read() or True
|
||||
except socket_error as serr:
|
||||
# Ignore remote close and connection refused (e.g. shutdown PKC)
|
||||
if serr.errno in (errno.WSAECONNABORTED, errno.WSAECONNREFUSED):
|
||||
pass
|
||||
else:
|
||||
LOG.error("Unable to connect to %s\nReason:", host)
|
||||
LOG.error(traceback.print_exc())
|
||||
self.conns.pop(protocol + host + str(port), None)
|
||||
conn.close()
|
||||
return False
|
|
@ -1,238 +0,0 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Plex Companion listener
|
||||
"""
|
||||
from logging import getLogger
|
||||
from re import sub
|
||||
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
|
||||
|
||||
from .. import utils, companion, json_rpc as js, clientinfo, variables as v
|
||||
from .. import app
|
||||
|
||||
###############################################################################
|
||||
|
||||
LOG = getLogger('PLEX.listener')
|
||||
|
||||
# Hack we need in order to keep track of the open connections from Plex Web
|
||||
CLIENT_DICT = {}
|
||||
|
||||
###############################################################################
|
||||
|
||||
RESOURCES_XML = ('%s<MediaContainer>\n'
|
||||
' <Player'
|
||||
' title="{title}"'
|
||||
' protocol="plex"'
|
||||
' protocolVersion="1"'
|
||||
' protocolCapabilities="timeline,playback,navigation,playqueues"'
|
||||
' machineIdentifier="{machineIdentifier}"'
|
||||
' product="%s"'
|
||||
' platform="%s"'
|
||||
' platformVersion="%s"'
|
||||
' deviceClass="pc"/>\n'
|
||||
'</MediaContainer>\n') % (v.XML_HEADER,
|
||||
v.ADDON_NAME,
|
||||
v.PLATFORM,
|
||||
v.PLATFORM_VERSION)
|
||||
|
||||
|
||||
class MyHandler(BaseHTTPRequestHandler):
|
||||
"""
|
||||
BaseHTTPRequestHandler implementation of Plex Companion listener
|
||||
"""
|
||||
protocol_version = 'HTTP/1.1'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.serverlist = []
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def log_message(self, format, *args):
|
||||
'''
|
||||
Mute all requests, don't log 'em
|
||||
'''
|
||||
pass
|
||||
|
||||
def do_HEAD(self):
|
||||
LOG.debug("Serving HEAD request...")
|
||||
self.answer_request(0)
|
||||
|
||||
def do_GET(self):
|
||||
LOG.debug("Serving GET request...")
|
||||
self.answer_request(1)
|
||||
|
||||
def do_OPTIONS(self):
|
||||
LOG.debug("Serving OPTIONS request...")
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Length', '0')
|
||||
self.send_header('X-Plex-Client-Identifier', v.PKC_MACHINE_IDENTIFIER)
|
||||
self.send_header('Content-Type', 'text/plain')
|
||||
self.send_header('Connection', 'close')
|
||||
self.send_header('Access-Control-Max-Age', '1209600')
|
||||
self.send_header('Access-Control-Allow-Origin', '*')
|
||||
self.send_header('Access-Control-Allow-Methods',
|
||||
'POST, GET, OPTIONS, DELETE, PUT, HEAD')
|
||||
self.send_header(
|
||||
'Access-Control-Allow-Headers',
|
||||
'x-plex-version, x-plex-platform-version, x-plex-username, '
|
||||
'x-plex-client-identifier, x-plex-target-client-identifier, '
|
||||
'x-plex-device-name, x-plex-platform, x-plex-product, accept, '
|
||||
'x-plex-device, x-plex-device-screen-resolution')
|
||||
self.end_headers()
|
||||
|
||||
def response(self, body, headers=None, code=200):
|
||||
headers = {} if headers is None else headers
|
||||
self.send_response(code)
|
||||
for key in headers:
|
||||
self.send_header(key, headers[key])
|
||||
self.send_header('Content-Length', len(body))
|
||||
self.end_headers()
|
||||
if body:
|
||||
self.wfile.write(body.encode('utf-8'))
|
||||
|
||||
def answer_request(self, send_data):
|
||||
self.serverlist = self.server.client.getServerList()
|
||||
sub_mgr = self.server.subscription_manager
|
||||
|
||||
request_path = self.path[1:]
|
||||
request_path = sub(r"\?.*", "", request_path)
|
||||
parseresult = utils.urlparse(self.path)
|
||||
paramarrays = utils.parse_qs(parseresult.query)
|
||||
params = {}
|
||||
for key in paramarrays:
|
||||
params[key] = paramarrays[key][0]
|
||||
LOG.debug("remote request_path: %s, received from %s with headers: %s",
|
||||
request_path, self.client_address, self.headers.items())
|
||||
LOG.debug("params received from remote: %s", params)
|
||||
sub_mgr.update_command_id(self.headers.get(
|
||||
'X-Plex-Client-Identifier', self.client_address[0]),
|
||||
params.get('commandID'))
|
||||
|
||||
conntype = self.headers.get('Connection', '')
|
||||
if conntype.lower() == 'keep-alive':
|
||||
headers = {
|
||||
'Connection': 'Keep-Alive',
|
||||
'Keep-Alive': 'timeout=20'
|
||||
}
|
||||
else:
|
||||
headers = {'Connection': 'Close'}
|
||||
|
||||
if request_path == "version":
|
||||
self.response(
|
||||
"PlexKodiConnect Plex Companion: Running\nVersion: %s"
|
||||
% v.ADDON_VERSION,
|
||||
headers)
|
||||
elif request_path == "verify":
|
||||
self.response("XBMC JSON connection test:\n" + js.ping(),
|
||||
headers)
|
||||
elif request_path == 'resources':
|
||||
self.response(
|
||||
RESOURCES_XML.format(
|
||||
title=v.DEVICENAME,
|
||||
machineIdentifier=v.PKC_MACHINE_IDENTIFIER),
|
||||
clientinfo.getXArgsDeviceInfo(options=headers,
|
||||
include_token=False))
|
||||
elif request_path == 'player/timeline/poll':
|
||||
# Plex web does polling if connected to PKC via Companion
|
||||
# Only reply if there is indeed something playing
|
||||
# Otherwise, all clients seem to keep connection open
|
||||
if params.get('wait') == '1':
|
||||
app.APP.monitor.waitForAbort(0.95)
|
||||
if self.client_address[0] not in CLIENT_DICT:
|
||||
CLIENT_DICT[self.client_address[0]] = []
|
||||
tracker = CLIENT_DICT[self.client_address[0]]
|
||||
tracker.append(self.client_address[1])
|
||||
while (not app.APP.is_playing and
|
||||
not app.APP.monitor.abortRequested() and
|
||||
sub_mgr.stop_sent_to_web and not
|
||||
(len(tracker) >= 4 and
|
||||
tracker[0] == self.client_address[1])):
|
||||
# Keep at most 3 connections open, then drop the first one
|
||||
# Doesn't need to be thread-save
|
||||
# Silly stuff really
|
||||
app.APP.monitor.waitForAbort(1)
|
||||
# Let PKC know that we're releasing this connection
|
||||
tracker.pop(0)
|
||||
msg = sub_mgr.msg(js.get_players()).format(
|
||||
command_id=params.get('commandID', 0))
|
||||
if sub_mgr.isplaying:
|
||||
self.response(
|
||||
msg,
|
||||
{
|
||||
'X-Plex-Client-Identifier': v.PKC_MACHINE_IDENTIFIER,
|
||||
'X-Plex-Protocol': '1.0',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Max-Age': '1209600',
|
||||
'Access-Control-Expose-Headers':
|
||||
'X-Plex-Client-Identifier',
|
||||
'Content-Type': 'text/xml;charset=utf-8'
|
||||
}.update(headers))
|
||||
elif not sub_mgr.stop_sent_to_web:
|
||||
sub_mgr.stop_sent_to_web = True
|
||||
LOG.debug('Signaling STOP to Plex Web')
|
||||
self.response(
|
||||
msg,
|
||||
{
|
||||
'X-Plex-Client-Identifier': v.PKC_MACHINE_IDENTIFIER,
|
||||
'X-Plex-Protocol': '1.0',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Max-Age': '1209600',
|
||||
'Access-Control-Expose-Headers':
|
||||
'X-Plex-Client-Identifier',
|
||||
'Content-Type': 'text/xml;charset=utf-8'
|
||||
}.update(headers))
|
||||
else:
|
||||
# We're not playing anything yet, just reply with a 200
|
||||
self.response(
|
||||
msg,
|
||||
{
|
||||
'X-Plex-Client-Identifier': v.PKC_MACHINE_IDENTIFIER,
|
||||
'X-Plex-Protocol': '1.0',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Max-Age': '1209600',
|
||||
'Access-Control-Expose-Headers':
|
||||
'X-Plex-Client-Identifier',
|
||||
'Content-Type': 'text/xml;charset=utf-8'
|
||||
}.update(headers))
|
||||
elif "/subscribe" in request_path:
|
||||
headers['Content-Type'] = 'text/xml;charset=utf-8'
|
||||
headers = clientinfo.getXArgsDeviceInfo(options=headers,
|
||||
include_token=False)
|
||||
self.response(v.COMPANION_OK_MESSAGE, headers)
|
||||
protocol = params.get('protocol')
|
||||
host = self.client_address[0]
|
||||
port = params.get('port')
|
||||
uuid = self.headers.get('X-Plex-Client-Identifier')
|
||||
command_id = params.get('commandID', 0)
|
||||
sub_mgr.add_subscriber(protocol,
|
||||
host,
|
||||
port,
|
||||
uuid,
|
||||
command_id)
|
||||
elif "/unsubscribe" in request_path:
|
||||
headers['Content-Type'] = 'text/xml;charset=utf-8'
|
||||
headers = clientinfo.getXArgsDeviceInfo(options=headers,
|
||||
include_token=False)
|
||||
self.response(v.COMPANION_OK_MESSAGE, headers)
|
||||
uuid = self.headers.get('X-Plex-Client-Identifier') \
|
||||
or self.client_address[0]
|
||||
sub_mgr.remove_subscriber(uuid)
|
||||
else:
|
||||
# Throw it to companion.py
|
||||
companion.process_command(request_path, params)
|
||||
headers['Content-Type'] = 'text/xml;charset=utf-8'
|
||||
headers = clientinfo.getXArgsDeviceInfo(options=headers,
|
||||
include_token=False)
|
||||
self.response(v.COMPANION_OK_MESSAGE, headers)
|
||||
|
||||
|
||||
class PKCHTTPServer(ThreadingHTTPServer):
|
||||
def __init__(self, client, subscription_manager, *args, **kwargs):
|
||||
"""
|
||||
client: Class handle to plexgdm.plexgdm. We can thus ask for an up-to-
|
||||
date serverlist without instantiating anything
|
||||
|
||||
same for SubscriptionMgr
|
||||
"""
|
||||
self.client = client
|
||||
self.subscription_manager = subscription_manager
|
||||
super().__init__(*args, **kwargs)
|
|
@ -1,314 +0,0 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
PlexGDM.py - Version 0.2
|
||||
|
||||
This class implements the Plex GDM (G'Day Mate) protocol to discover
|
||||
local Plex Media Servers. Also allow client registration into all local
|
||||
media servers.
|
||||
|
||||
|
||||
This program is free software; you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program; if not, write to the Free Software
|
||||
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
|
||||
MA 02110-1301, USA.
|
||||
"""
|
||||
import logging
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
|
||||
from ..downloadutils import DownloadUtils as DU
|
||||
from .. import utils, app, variables as v
|
||||
|
||||
###############################################################################
|
||||
|
||||
log = logging.getLogger('PLEX.plexgdm')
|
||||
|
||||
###############################################################################
|
||||
|
||||
|
||||
class plexgdm(object):
|
||||
|
||||
def __init__(self):
|
||||
self.discover_message = 'M-SEARCH * HTTP/1.0'
|
||||
self.client_header = '* HTTP/1.0'
|
||||
self.client_data = None
|
||||
self.update_sock = None
|
||||
self.discover_t = None
|
||||
self.register_t = None
|
||||
|
||||
self._multicast_address = '239.0.0.250'
|
||||
self.discover_group = (self._multicast_address, 32414)
|
||||
self.client_register_group = (self._multicast_address, 32413)
|
||||
self.client_update_port = int(utils.settings('companionUpdatePort'))
|
||||
|
||||
self.server_list = []
|
||||
self.discovery_interval = 120
|
||||
|
||||
self._discovery_is_running = False
|
||||
self._registration_is_running = False
|
||||
|
||||
self.client_registered = False
|
||||
self.download = DU().downloadUrl
|
||||
|
||||
def clientDetails(self):
|
||||
self.client_data = (
|
||||
"Content-Type: plex/media-player\n"
|
||||
"Resource-Identifier: %s\n"
|
||||
"Name: %s\n"
|
||||
"Port: %s\n"
|
||||
"Product: %s\n"
|
||||
"Version: %s\n"
|
||||
"Protocol: plex\n"
|
||||
"Protocol-Version: 1\n"
|
||||
"Protocol-Capabilities: timeline,playback,navigation,"
|
||||
"playqueues\n"
|
||||
"Device-Class: HTPC\n"
|
||||
) % (
|
||||
v.PKC_MACHINE_IDENTIFIER,
|
||||
v.DEVICENAME,
|
||||
v.COMPANION_PORT,
|
||||
v.ADDON_NAME,
|
||||
v.ADDON_VERSION
|
||||
)
|
||||
|
||||
def getClientDetails(self):
|
||||
return self.client_data
|
||||
|
||||
def register_as_client(self):
|
||||
"""
|
||||
Registers PKC's Plex Companion to the PMS
|
||||
"""
|
||||
try:
|
||||
log.debug("Sending registration data: HELLO %s\n%s"
|
||||
% (self.client_header, self.client_data))
|
||||
msg = 'HELLO {}\n{}'.format(self.client_header, self.client_data)
|
||||
self.update_sock.sendto(msg.encode('utf-8'),
|
||||
self.client_register_group)
|
||||
log.debug('(Re-)registering PKC Plex Companion successful')
|
||||
except Exception as exc:
|
||||
log.error("Unable to send registration message. Error: %s", exc)
|
||||
|
||||
def client_update(self):
|
||||
self.update_sock = socket.socket(socket.AF_INET,
|
||||
socket.SOCK_DGRAM,
|
||||
socket.IPPROTO_UDP)
|
||||
update_sock = self.update_sock
|
||||
|
||||
# Set socket reuse, may not work on all OSs.
|
||||
try:
|
||||
update_sock.setsockopt(socket.SOL_SOCKET,
|
||||
socket.SO_REUSEADDR,
|
||||
1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Attempt to bind to the socket to recieve and send data. If we cant
|
||||
# do this, then we cannot send registration
|
||||
try:
|
||||
update_sock.bind(('0.0.0.0', self.client_update_port))
|
||||
except Exception:
|
||||
log.error("Unable to bind to port [%s] - Plex Companion will not "
|
||||
"be registered. Change the Plex Companion update port!"
|
||||
% self.client_update_port)
|
||||
if utils.settings('companion_show_gdm_port_warning') == 'true':
|
||||
from ..windows import optionsdialog
|
||||
# Plex Companion could not open the GDM port. Please change it
|
||||
# in the PKC settings.
|
||||
if optionsdialog.show(utils.lang(29999),
|
||||
'Port %s\n%s' % (self.client_update_port,
|
||||
utils.lang(39079)),
|
||||
utils.lang(30013), # Never show again
|
||||
utils.lang(186)) == 0:
|
||||
utils.settings('companion_show_gdm_port_warning',
|
||||
value='false')
|
||||
from xbmc import executebuiltin
|
||||
executebuiltin(
|
||||
'Addon.OpenSettings(plugin.video.plexkodiconnect)')
|
||||
return
|
||||
|
||||
update_sock.setsockopt(socket.IPPROTO_IP,
|
||||
socket.IP_MULTICAST_TTL,
|
||||
255)
|
||||
update_sock.setsockopt(socket.IPPROTO_IP,
|
||||
socket.IP_ADD_MEMBERSHIP,
|
||||
socket.inet_aton(
|
||||
self._multicast_address) +
|
||||
socket.inet_aton('0.0.0.0'))
|
||||
update_sock.setblocking(0)
|
||||
|
||||
# Send initial client registration
|
||||
self.register_as_client()
|
||||
|
||||
# Now, listen format client discovery reguests and respond.
|
||||
while self._registration_is_running:
|
||||
try:
|
||||
data, addr = update_sock.recvfrom(1024)
|
||||
data = data.decode()
|
||||
log.debug("Recieved UDP packet from [%s] containing [%s]"
|
||||
% (addr, data.strip()))
|
||||
except socket.error:
|
||||
pass
|
||||
else:
|
||||
if "M-SEARCH * HTTP/1." in data:
|
||||
log.debug('Detected client discovery request from %s. '
|
||||
'Replying', addr)
|
||||
message = f'HTTP/1.0 200 OK\n{self.client_data}'.encode()
|
||||
try:
|
||||
update_sock.sendto(message, addr)
|
||||
except Exception:
|
||||
log.error("Unable to send client update message")
|
||||
else:
|
||||
log.debug("Sent registration data HTTP/1.0 200 OK")
|
||||
self.client_registered = True
|
||||
app.APP.monitor.waitForAbort(0.5)
|
||||
log.info("Client Update loop stopped")
|
||||
# When we are finished, then send a final goodbye message to
|
||||
# deregister cleanly.
|
||||
log.debug("Sending registration data: BYE %s\n%s"
|
||||
% (self.client_header, self.client_data))
|
||||
try:
|
||||
update_sock.sendto("BYE %s\n%s"
|
||||
% (self.client_header, self.client_data),
|
||||
self.client_register_group)
|
||||
except Exception:
|
||||
log.error("Unable to send client update message")
|
||||
self.client_registered = False
|
||||
|
||||
def check_client_registration(self):
|
||||
if not self.client_registered:
|
||||
log.debug('Client has not been marked as registered')
|
||||
return False
|
||||
if not self.server_list:
|
||||
log.info("Server list is empty. Unable to check")
|
||||
return False
|
||||
for server in self.server_list:
|
||||
if server['uuid'] == app.CONN.machine_identifier:
|
||||
media_server = server['server']
|
||||
media_port = server['port']
|
||||
scheme = server['protocol']
|
||||
break
|
||||
else:
|
||||
log.info("Did not find our server!")
|
||||
return False
|
||||
|
||||
log.debug("Checking server [%s] on port [%s]"
|
||||
% (media_server, media_port))
|
||||
xml = self.download(
|
||||
'%s://%s:%s/clients' % (scheme, media_server, media_port))
|
||||
try:
|
||||
xml[0].attrib
|
||||
except (TypeError, IndexError, AttributeError):
|
||||
log.error('Could not download clients for %s' % media_server)
|
||||
return False
|
||||
registered = False
|
||||
for client in xml:
|
||||
if (client.attrib.get('machineIdentifier') ==
|
||||
v.PKC_MACHINE_IDENTIFIER):
|
||||
registered = True
|
||||
if registered:
|
||||
return True
|
||||
else:
|
||||
log.info("Client registration not found. "
|
||||
"Client data is: %s" % xml)
|
||||
return False
|
||||
|
||||
def getServerList(self):
|
||||
return self.server_list
|
||||
|
||||
def discover(self):
|
||||
currServer = app.CONN.server
|
||||
if not currServer:
|
||||
return
|
||||
currServerProt, currServerIP, currServerPort = \
|
||||
currServer.split(':')
|
||||
currServerIP = currServerIP.replace('/', '')
|
||||
# Currently active server was not discovered via GDM; ADD
|
||||
self.server_list = [{
|
||||
'port': currServerPort,
|
||||
'protocol': currServerProt,
|
||||
'class': None,
|
||||
'content-type': 'plex/media-server',
|
||||
'discovery': 'auto',
|
||||
'master': 1,
|
||||
'owned': '1',
|
||||
'role': 'master',
|
||||
'server': currServerIP,
|
||||
'serverName': app.CONN.server_name,
|
||||
'updated': int(time.time()),
|
||||
'uuid': app.CONN.machine_identifier,
|
||||
'version': 'irrelevant'
|
||||
}]
|
||||
|
||||
def setInterval(self, interval):
|
||||
self.discovery_interval = interval
|
||||
|
||||
def stop_all(self):
|
||||
self.stop_discovery()
|
||||
self.stop_registration()
|
||||
|
||||
def stop_discovery(self):
|
||||
if self._discovery_is_running:
|
||||
log.info("Discovery shutting down")
|
||||
self._discovery_is_running = False
|
||||
self.discover_t.join()
|
||||
del self.discover_t
|
||||
else:
|
||||
log.info("Discovery not running")
|
||||
|
||||
def stop_registration(self):
|
||||
if self._registration_is_running:
|
||||
log.info("Registration shutting down")
|
||||
self._registration_is_running = False
|
||||
self.register_t.join()
|
||||
del self.register_t
|
||||
else:
|
||||
log.info("Registration not running")
|
||||
|
||||
def run_discovery_loop(self):
|
||||
# Run initial discovery
|
||||
self.discover()
|
||||
|
||||
discovery_count = 0
|
||||
while self._discovery_is_running:
|
||||
discovery_count += 1
|
||||
if discovery_count > self.discovery_interval:
|
||||
self.discover()
|
||||
discovery_count = 0
|
||||
app.APP.monitor.waitForAbort(0.5)
|
||||
|
||||
def start_discovery(self, daemon=False):
|
||||
if not self._discovery_is_running:
|
||||
log.info("Discovery starting up")
|
||||
self._discovery_is_running = True
|
||||
self.discover_t = threading.Thread(target=self.run_discovery_loop)
|
||||
self.discover_t.setDaemon(daemon)
|
||||
self.discover_t.start()
|
||||
else:
|
||||
log.info("Discovery already running")
|
||||
|
||||
def start_registration(self, daemon=False):
|
||||
if not self._registration_is_running:
|
||||
log.info("Registration starting up")
|
||||
self._registration_is_running = True
|
||||
self.register_t = threading.Thread(target=self.client_update)
|
||||
self.register_t.setDaemon(daemon)
|
||||
self.register_t.start()
|
||||
else:
|
||||
log.info("Registration already running")
|
||||
|
||||
def start_all(self, daemon=False):
|
||||
self.start_discovery(daemon)
|
||||
if utils.settings('plexCompanion') == 'true':
|
||||
self.start_registration(daemon)
|
|
@ -1,470 +0,0 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Manages getting playstate from Kodi and sending it to the PMS as well as
|
||||
subscribed Plex Companion clients.
|
||||
"""
|
||||
from logging import getLogger
|
||||
from threading import Thread
|
||||
|
||||
from ..downloadutils import DownloadUtils as DU
|
||||
from .. import timing
|
||||
from .. import app
|
||||
from .. import variables as v
|
||||
from .. import json_rpc as js
|
||||
from .. import playqueue as PQ
|
||||
|
||||
###############################################################################
|
||||
LOG = getLogger('PLEX.subscribers')
|
||||
###############################################################################
|
||||
|
||||
# What is Companion controllable?
|
||||
CONTROLLABLE = {
|
||||
v.PLEX_PLAYLIST_TYPE_VIDEO: 'playPause,stop,volume,shuffle,audioStream,'
|
||||
'subtitleStream,seekTo,skipPrevious,skipNext,'
|
||||
'stepBack,stepForward',
|
||||
v.PLEX_PLAYLIST_TYPE_AUDIO: 'playPause,stop,volume,shuffle,repeat,seekTo,'
|
||||
'skipPrevious,skipNext,stepBack,stepForward',
|
||||
v.PLEX_PLAYLIST_TYPE_PHOTO: 'playPause,stop,skipPrevious,skipNext'
|
||||
}
|
||||
|
||||
STREAM_DETAILS = {
|
||||
'video': 'currentvideostream',
|
||||
'audio': 'currentaudiostream',
|
||||
'subtitle': 'currentsubtitle'
|
||||
}
|
||||
|
||||
XML = ('%s<MediaContainer commandID="{command_id}" location="{location}">\n'
|
||||
' <Timeline {%s}/>\n'
|
||||
' <Timeline {%s}/>\n'
|
||||
' <Timeline {%s}/>\n'
|
||||
'</MediaContainer>\n') % (v.XML_HEADER,
|
||||
v.PLEX_PLAYLIST_TYPE_VIDEO,
|
||||
v.PLEX_PLAYLIST_TYPE_AUDIO,
|
||||
v.PLEX_PLAYLIST_TYPE_PHOTO)
|
||||
|
||||
# Headers are different for Plex Companion - use these for PMS notifications
|
||||
HEADERS_PMS = {
|
||||
'Connection': 'keep-alive',
|
||||
'Accept': 'text/plain, */*; q=0.01',
|
||||
'Accept-Language': 'en',
|
||||
'Accept-Encoding': 'gzip, deflate',
|
||||
'User-Agent': '%s %s (%s)' % (v.ADDON_NAME, v.ADDON_VERSION, v.DEVICE)
|
||||
}
|
||||
|
||||
|
||||
def params_pms():
|
||||
"""
|
||||
Returns the url parameters for communicating with the PMS
|
||||
"""
|
||||
return {
|
||||
'X-Plex-Client-Identifier': v.PKC_MACHINE_IDENTIFIER,
|
||||
'X-Plex-Device': v.DEVICE,
|
||||
'X-Plex-Device-Name': v.DEVICENAME,
|
||||
'X-Plex-Model': v.MODEL,
|
||||
'X-Plex-Platform': v.PLATFORM,
|
||||
'X-Plex-Platform-Version': v.PLATFORM_VERSION,
|
||||
'X-Plex-Product': v.ADDON_NAME,
|
||||
'X-Plex-Version': v.ADDON_VERSION,
|
||||
}
|
||||
|
||||
|
||||
def headers_companion_client():
|
||||
"""
|
||||
Headers are different for Plex Companion - use these for a Plex Companion
|
||||
client
|
||||
"""
|
||||
return {
|
||||
'Content-Type': 'application/xml',
|
||||
'Connection': 'Keep-Alive',
|
||||
'X-Plex-Client-Identifier': v.PKC_MACHINE_IDENTIFIER,
|
||||
'X-Plex-Device-Name': v.DEVICENAME,
|
||||
'X-Plex-Platform': v.PLATFORM,
|
||||
'X-Plex-Platform-Version': v.PLATFORM_VERSION,
|
||||
'X-Plex-Product': v.ADDON_NAME,
|
||||
'X-Plex-Version': v.ADDON_VERSION,
|
||||
'Accept-Encoding': 'gzip, deflate',
|
||||
'Accept-Language': 'en,*'
|
||||
}
|
||||
|
||||
|
||||
def update_player_info(playerid):
|
||||
"""
|
||||
Updates all player info for playerid [int] in state.py.
|
||||
"""
|
||||
app.PLAYSTATE.player_states[playerid].update(js.get_player_props(playerid))
|
||||
app.PLAYSTATE.player_states[playerid]['volume'] = js.get_volume()
|
||||
app.PLAYSTATE.player_states[playerid]['muted'] = js.get_muted()
|
||||
|
||||
|
||||
class SubscriptionMgr(object):
|
||||
"""
|
||||
Manages Plex companion subscriptions
|
||||
"""
|
||||
def __init__(self, request_mgr, player):
|
||||
self.serverlist = []
|
||||
self.subscribers = {}
|
||||
self.info = {}
|
||||
self.server = ""
|
||||
self.protocol = "http"
|
||||
self.port = ""
|
||||
self.isplaying = False
|
||||
self.location = 'navigation'
|
||||
# In order to be able to signal a stop at the end
|
||||
self.last_params = {}
|
||||
self.lastplayers = {}
|
||||
# In order to signal a stop to Plex Web ONCE on playback stop
|
||||
self.stop_sent_to_web = True
|
||||
self.request_mgr = request_mgr
|
||||
|
||||
def _server_by_host(self, host):
|
||||
if len(self.serverlist) == 1:
|
||||
return self.serverlist[0]
|
||||
for server in self.serverlist:
|
||||
if (server.get('serverName') in host or
|
||||
server.get('server') in host):
|
||||
return server
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _get_correct_position(info, playqueue):
|
||||
"""
|
||||
Kodi tells us the PLAYLIST position, not PLAYQUEUE position, if the
|
||||
user initiated playback of a playlist
|
||||
"""
|
||||
if playqueue.kodi_playlist_playback:
|
||||
position = 0
|
||||
else:
|
||||
position = info['position'] or 0
|
||||
return position
|
||||
|
||||
def msg(self, players):
|
||||
"""
|
||||
Returns a timeline xml as str
|
||||
(xml containing video, audio, photo player state)
|
||||
"""
|
||||
self.isplaying = False
|
||||
self.location = 'navigation'
|
||||
answ = str(XML)
|
||||
timelines = {
|
||||
v.PLEX_PLAYLIST_TYPE_VIDEO: None,
|
||||
v.PLEX_PLAYLIST_TYPE_AUDIO: None,
|
||||
v.PLEX_PLAYLIST_TYPE_PHOTO: None
|
||||
}
|
||||
for typus in timelines:
|
||||
if players.get(v.KODI_PLAYLIST_TYPE_FROM_PLEX_PLAYLIST_TYPE[typus]) is None:
|
||||
timeline = {
|
||||
'controllable': CONTROLLABLE[typus],
|
||||
'type': typus,
|
||||
'state': 'stopped'
|
||||
}
|
||||
else:
|
||||
timeline = self._timeline_dict(players[
|
||||
v.KODI_PLAYLIST_TYPE_FROM_PLEX_PLAYLIST_TYPE[typus]],
|
||||
typus)
|
||||
timelines[typus] = self._dict_to_xml(timeline)
|
||||
timelines.update({'command_id': '{command_id}',
|
||||
'location': self.location})
|
||||
return answ.format(**timelines)
|
||||
|
||||
@staticmethod
|
||||
def _dict_to_xml(dictionary):
|
||||
"""
|
||||
Returns the string 'key1="value1" key2="value2" ...' for dictionary
|
||||
"""
|
||||
answ = ''
|
||||
for key, value in dictionary.items():
|
||||
answ += '%s="%s" ' % (key, value)
|
||||
return answ
|
||||
|
||||
def _timeline_dict(self, player, ptype):
|
||||
with app.APP.lock_playqueues:
|
||||
playerid = player['playerid']
|
||||
info = app.PLAYSTATE.player_states[playerid]
|
||||
playqueue = PQ.PLAYQUEUES[playerid]
|
||||
position = self._get_correct_position(info, playqueue)
|
||||
try:
|
||||
item = playqueue.items[position]
|
||||
except IndexError:
|
||||
# E.g. for direct path playback for single item
|
||||
return {
|
||||
'controllable': CONTROLLABLE[ptype],
|
||||
'type': ptype,
|
||||
'state': 'stopped'
|
||||
}
|
||||
self.isplaying = True
|
||||
self.stop_sent_to_web = False
|
||||
if ptype in (v.PLEX_PLAYLIST_TYPE_VIDEO,
|
||||
v.PLEX_PLAYLIST_TYPE_PHOTO):
|
||||
self.location = 'fullScreenVideo'
|
||||
pbmc_server = app.CONN.server
|
||||
if pbmc_server:
|
||||
(self.protocol, self.server, self.port) = pbmc_server.split(':')
|
||||
self.server = self.server.replace('/', '')
|
||||
status = 'paused' if int(info['speed']) == 0 else 'playing'
|
||||
duration = timing.kodi_time_to_millis(info['totaltime'])
|
||||
shuffle = '1' if info['shuffled'] else '0'
|
||||
mute = '1' if info['muted'] is True else '0'
|
||||
answ = {
|
||||
'controllable': CONTROLLABLE[ptype],
|
||||
'protocol': self.protocol,
|
||||
'address': self.server,
|
||||
'port': self.port,
|
||||
'machineIdentifier': app.CONN.machine_identifier,
|
||||
'state': status,
|
||||
'type': ptype,
|
||||
'itemType': ptype,
|
||||
'time': timing.kodi_time_to_millis(info['time']),
|
||||
'duration': duration,
|
||||
'seekRange': '0-%s' % duration,
|
||||
'shuffle': shuffle,
|
||||
'repeat': v.PLEX_REPEAT_FROM_KODI_REPEAT[info['repeat']],
|
||||
'volume': info['volume'],
|
||||
'mute': mute,
|
||||
'mediaIndex': 0, # Still to implement from here
|
||||
'partIndex': 0,
|
||||
'partCount': 1,
|
||||
'providerIdentifier': 'com.plexapp.plugins.library',
|
||||
}
|
||||
# Get the plex id from the PKC playqueue not info, as Kodi jumps to
|
||||
# next playqueue element way BEFORE kodi monitor onplayback is
|
||||
# called
|
||||
if item.plex_id:
|
||||
answ['key'] = '/library/metadata/%s' % item.plex_id
|
||||
answ['ratingKey'] = item.plex_id
|
||||
# PlayQueue stuff
|
||||
if info['container_key']:
|
||||
answ['containerKey'] = info['container_key']
|
||||
if (info['container_key'] is not None and
|
||||
info['container_key'].startswith('/playQueues')):
|
||||
answ['playQueueID'] = playqueue.id
|
||||
answ['playQueueVersion'] = playqueue.version
|
||||
answ['playQueueItemID'] = item.id
|
||||
if playqueue.items[position].guid:
|
||||
answ['guid'] = item.guid
|
||||
# Temp. token set?
|
||||
if app.CONN.plex_transient_token:
|
||||
answ['token'] = app.CONN.plex_transient_token
|
||||
elif playqueue.plex_transient_token:
|
||||
answ['token'] = playqueue.plex_transient_token
|
||||
# Process audio and subtitle streams
|
||||
if ptype == v.PLEX_PLAYLIST_TYPE_VIDEO:
|
||||
answ['videoStreamID'] = str(item.current_plex_video_stream)
|
||||
answ['audioStreamID'] = str(item.current_plex_audio_stream)
|
||||
# Mind the zero - meaning subs are deactivated
|
||||
answ['subtitleStreamID'] = str(item.current_plex_sub_stream or 0)
|
||||
return answ
|
||||
|
||||
def signal_stop(self):
|
||||
"""
|
||||
Externally called on PKC shutdown to ensure that PKC signals a stop to
|
||||
the PMS. Otherwise, PKC might be stuck at "currently playing"
|
||||
"""
|
||||
LOG.info('Signaling a complete stop to PMS')
|
||||
# To avoid RuntimeError, don't use self.lastplayers
|
||||
for playerid in (0, 1, 2):
|
||||
self.last_params['state'] = 'stopped'
|
||||
self._send_pms_notification(playerid,
|
||||
self.last_params,
|
||||
timeout=0.0001)
|
||||
|
||||
def update_command_id(self, uuid, command_id):
|
||||
"""
|
||||
Updates the Plex Companien client with the machine identifier uuid with
|
||||
command_id
|
||||
"""
|
||||
with app.APP.lock_subscriber:
|
||||
if command_id and self.subscribers.get(uuid):
|
||||
self.subscribers[uuid].command_id = int(command_id)
|
||||
|
||||
def _playqueue_init_done(self, players):
|
||||
"""
|
||||
update_player_info() can result in values BEFORE kodi monitor is called.
|
||||
Hence we'd have a missmatch between the state.PLAYER_STATES and our
|
||||
playqueues.
|
||||
"""
|
||||
for player in list(players.values()):
|
||||
info = app.PLAYSTATE.player_states[player['playerid']]
|
||||
playqueue = PQ.PLAYQUEUES[player['playerid']]
|
||||
position = self._get_correct_position(info, playqueue)
|
||||
try:
|
||||
item = playqueue.items[position]
|
||||
except IndexError:
|
||||
# E.g. for direct path playback for single item
|
||||
return False
|
||||
if item.plex_id != info['plex_id']:
|
||||
# Kodi playqueue already progressed; need to wait until
|
||||
# everything is loaded
|
||||
return False
|
||||
return True
|
||||
|
||||
def notify(self):
|
||||
"""
|
||||
Causes PKC to tell the PMS and Plex Companion players to receive a
|
||||
notification what's being played.
|
||||
"""
|
||||
with app.APP.lock_subscriber:
|
||||
self._cleanup()
|
||||
# Get all the active/playing Kodi players (video, audio, pictures)
|
||||
players = js.get_players()
|
||||
# Update the PKC info with what's playing on the Kodi side
|
||||
for player in list(players.values()):
|
||||
update_player_info(player['playerid'])
|
||||
# Check whether we can use the CURRENT info or whether PKC is still
|
||||
# initializing
|
||||
if self._playqueue_init_done(players) is False:
|
||||
LOG.debug('PKC playqueue is still initializing - skip update')
|
||||
return
|
||||
self._notify_server(players)
|
||||
if self.subscribers:
|
||||
msg = self.msg(players)
|
||||
for subscriber in list(self.subscribers.values()):
|
||||
subscriber.send_update(msg)
|
||||
self.lastplayers = players
|
||||
|
||||
def _notify_server(self, players):
|
||||
for typus, player in players.items():
|
||||
self._send_pms_notification(
|
||||
player['playerid'], self._get_pms_params(player['playerid']))
|
||||
try:
|
||||
del self.lastplayers[typus]
|
||||
except KeyError:
|
||||
pass
|
||||
# Process the players we have left (to signal a stop)
|
||||
for player in list(self.lastplayers.values()):
|
||||
self.last_params['state'] = 'stopped'
|
||||
self._send_pms_notification(player['playerid'], self.last_params)
|
||||
|
||||
def _get_pms_params(self, playerid):
|
||||
info = app.PLAYSTATE.player_states[playerid]
|
||||
playqueue = PQ.PLAYQUEUES[playerid]
|
||||
position = self._get_correct_position(info, playqueue)
|
||||
try:
|
||||
item = playqueue.items[position]
|
||||
except IndexError:
|
||||
return self.last_params
|
||||
status = 'paused' if int(info['speed']) == 0 else 'playing'
|
||||
params = {
|
||||
'state': status,
|
||||
'ratingKey': item.plex_id,
|
||||
'key': '/library/metadata/%s' % item.plex_id,
|
||||
'time': timing.kodi_time_to_millis(info['time']),
|
||||
'duration': timing.kodi_time_to_millis(info['totaltime'])
|
||||
}
|
||||
if info['container_key'] is not None:
|
||||
# params['containerKey'] = info['container_key']
|
||||
if info['container_key'].startswith('/playQueues/'):
|
||||
# params['playQueueVersion'] = playqueue.version
|
||||
# params['playQueueID'] = playqueue.id
|
||||
params['playQueueItemID'] = item.id
|
||||
self.last_params = params
|
||||
return params
|
||||
|
||||
def _send_pms_notification(self, playerid, params, timeout=None):
|
||||
"""
|
||||
Pass a really low timeout in seconds if shutting down Kodi and we don't
|
||||
need the PMS' response
|
||||
"""
|
||||
serv = self._server_by_host(self.server)
|
||||
playqueue = PQ.PLAYQUEUES[playerid]
|
||||
xargs = params_pms()
|
||||
xargs.update(params)
|
||||
if app.CONN.plex_transient_token:
|
||||
xargs['X-Plex-Token'] = app.CONN.plex_transient_token
|
||||
elif playqueue.plex_transient_token:
|
||||
xargs['X-Plex-Token'] = playqueue.plex_transient_token
|
||||
elif app.ACCOUNT.pms_token:
|
||||
xargs['X-Plex-Token'] = app.ACCOUNT.pms_token
|
||||
url = '%s://%s:%s/:/timeline' % (serv.get('protocol', 'http'),
|
||||
serv.get('server', 'localhost'),
|
||||
serv.get('port', '32400'))
|
||||
DU().downloadUrl(url,
|
||||
authenticate=False,
|
||||
parameters=xargs,
|
||||
headerOverride=HEADERS_PMS,
|
||||
timeout=timeout)
|
||||
LOG.debug("Sent server notification with parameters: %s to %s",
|
||||
xargs, url)
|
||||
|
||||
def add_subscriber(self, protocol, host, port, uuid, command_id):
|
||||
"""
|
||||
Adds a new Plex Companion subscriber to PKC.
|
||||
"""
|
||||
subscriber = Subscriber(protocol,
|
||||
host,
|
||||
port,
|
||||
uuid,
|
||||
command_id,
|
||||
self,
|
||||
self.request_mgr)
|
||||
with app.APP.lock_subscriber:
|
||||
self.subscribers[subscriber.uuid] = subscriber
|
||||
return subscriber
|
||||
|
||||
def remove_subscriber(self, uuid):
|
||||
"""
|
||||
Removes a connected Plex Companion subscriber with machine identifier
|
||||
uuid from PKC notifications.
|
||||
(Calls the cleanup() method of the subscriber)
|
||||
"""
|
||||
with app.APP.lock_subscriber:
|
||||
for subscriber in list(self.subscribers.values()):
|
||||
if subscriber.uuid == uuid or subscriber.host == uuid:
|
||||
subscriber.cleanup()
|
||||
del self.subscribers[subscriber.uuid]
|
||||
|
||||
def _cleanup(self):
|
||||
for subscriber in list(self.subscribers.values()):
|
||||
if subscriber.age > 30:
|
||||
subscriber.cleanup()
|
||||
del self.subscribers[subscriber.uuid]
|
||||
|
||||
|
||||
class Subscriber(object):
|
||||
"""
|
||||
Plex Companion subscribing device
|
||||
"""
|
||||
def __init__(self, protocol, host, port, uuid, command_id, sub_mgr,
|
||||
request_mgr):
|
||||
self.protocol = protocol or "http"
|
||||
self.host = host
|
||||
self.port = port or 32400
|
||||
self.uuid = uuid or host
|
||||
self.command_id = int(command_id) or 0
|
||||
self.age = 0
|
||||
self.sub_mgr = sub_mgr
|
||||
self.request_mgr = request_mgr
|
||||
|
||||
def __eq__(self, other):
|
||||
return self.uuid == other.uuid
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Closes the connection to the Plex Companion client
|
||||
"""
|
||||
self.request_mgr.closeConnection(self.protocol, self.host, self.port)
|
||||
|
||||
def send_update(self, msg):
|
||||
"""
|
||||
Sends msg to the Plex Companion client (via .../:/timeline)
|
||||
"""
|
||||
self.age += 1
|
||||
msg = msg.format(command_id=self.command_id)
|
||||
LOG.debug("sending xml to subscriber uuid=%s,commandID=%i:\n%s",
|
||||
self.uuid, self.command_id, msg)
|
||||
url = '%s://%s:%s/:/timeline' % (self.protocol, self.host, self.port)
|
||||
thread = Thread(target=self._threaded_send, args=(url, msg))
|
||||
thread.start()
|
||||
|
||||
def _threaded_send(self, url, msg):
|
||||
"""
|
||||
Threaded POST request, because they stall due to response missing
|
||||
the Content-Length header :-(
|
||||
"""
|
||||
response = DU().downloadUrl(url,
|
||||
action_type="POST",
|
||||
postBody=msg,
|
||||
authenticate=False,
|
||||
headerOverride=headers_companion_client())
|
||||
if response in (False, None, 401):
|
||||
self.sub_mgr.remove_subscriber(self.uuid)
|
|
@ -2,6 +2,7 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
import sys
|
||||
|
||||
import xbmc
|
||||
import xbmcvfs
|
||||
|
||||
|
@ -11,9 +12,8 @@ from . import kodimonitor
|
|||
from . import sync, library_sync
|
||||
from . import websocket_client
|
||||
from . import plex_companion
|
||||
from . import plex_functions as PF, playqueue as PQ
|
||||
from . import plex_functions as PF
|
||||
from . import playback_starter
|
||||
from . import playqueue
|
||||
from . import variables as v
|
||||
from . import app
|
||||
from . import loghandler
|
||||
|
@ -34,7 +34,6 @@ WINDOW_PROPERTIES = (
|
|||
class Service(object):
|
||||
ws = None
|
||||
sync = None
|
||||
plexcompanion = None
|
||||
|
||||
def __init__(self):
|
||||
self._init_done = False
|
||||
|
@ -100,7 +99,6 @@ class Service(object):
|
|||
self.setup = None
|
||||
self.pms_ws = None
|
||||
self.alexa_ws = None
|
||||
self.playqueue = None
|
||||
# Flags for other threads
|
||||
self.connection_check_running = False
|
||||
self.auth_running = False
|
||||
|
@ -437,8 +435,6 @@ class Service(object):
|
|||
app.init()
|
||||
app.APP.monitor = kodimonitor.KodiMonitor()
|
||||
app.APP.player = xbmc.Player()
|
||||
# Initialize the PKC playqueues
|
||||
PQ.init_playqueues()
|
||||
|
||||
# Server auto-detect
|
||||
self.setup = initialsetup.InitialSetup()
|
||||
|
@ -448,8 +444,12 @@ class Service(object):
|
|||
self.pms_ws = websocket_client.get_pms_websocketapp()
|
||||
self.alexa_ws = websocket_client.get_alexa_websocketapp()
|
||||
self.sync = sync.Sync()
|
||||
self.plexcompanion = plex_companion.PlexCompanion()
|
||||
self.playqueue = playqueue.PlayqueueMonitor()
|
||||
self.companion_playstate_mgr = plex_companion.PlaystateMgr(
|
||||
companion_enabled=utils.settings('plexCompanion') == 'true')
|
||||
if utils.settings('plexCompanion') == 'true':
|
||||
self.companion_polling = plex_companion.Polling(self.companion_playstate_mgr)
|
||||
else:
|
||||
self.companion_polling = None
|
||||
|
||||
# Main PKC program loop
|
||||
while not self.should_cancel():
|
||||
|
@ -498,6 +498,9 @@ class Service(object):
|
|||
elif plex_command == 'EXIT-PKC':
|
||||
LOG.info('Received command from another instance to quit')
|
||||
app.APP.stop_pkc = True
|
||||
elif plex_command == 'generate_new_uuid':
|
||||
LOG.info('Generating new UUID for PKC')
|
||||
clientinfo.getDeviceId(reset=True)
|
||||
else:
|
||||
raise RuntimeError('Unknown command: %s', plex_command)
|
||||
if task:
|
||||
|
@ -548,8 +551,9 @@ class Service(object):
|
|||
self.startup_completed = True
|
||||
self.pms_ws.start()
|
||||
self.sync.start()
|
||||
self.plexcompanion.start()
|
||||
self.playqueue.start()
|
||||
self.companion_playstate_mgr.start()
|
||||
if self.companion_polling is not None:
|
||||
self.companion_polling.start()
|
||||
self.alexa_ws.start()
|
||||
|
||||
elif app.APP.is_playing:
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
from .windows.skip_intro import SkipIntroDialog
|
||||
from . import app, variables as v
|
||||
from . import app, utils, variables as v
|
||||
|
||||
|
||||
def skip_intro(intros):
|
||||
|
@ -15,12 +15,18 @@ def skip_intro(intros):
|
|||
if start <= progress < end:
|
||||
in_intro = True
|
||||
if in_intro and app.APP.skip_intro_dialog is None:
|
||||
# WARNING: This Dialog only seems to work if called from the main
|
||||
# thread. Otherwise, onClick and onAction won't work
|
||||
app.APP.skip_intro_dialog = SkipIntroDialog('script-plex-skip_intro.xml',
|
||||
v.ADDON_PATH,
|
||||
'default',
|
||||
'1080i',
|
||||
intro_end=end)
|
||||
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:
|
||||
app.APP.skip_intro_dialog.close()
|
||||
app.APP.skip_intro_dialog = None
|
||||
|
|
|
@ -214,7 +214,8 @@ LANGUAGE_ISO_CODES = (
|
|||
|
||||
|
||||
def accessible_plex_subtitles(playmethod, playing_file, xml_streams):
|
||||
if not playmethod == v.PLAYBACK_METHOD_DIRECT_PATH:
|
||||
if playmethod not in (v.PLAYBACK_METHOD_DIRECT_PATH,
|
||||
v.PLAYBACK_METHOD_DIRECT_PLAY):
|
||||
# We can access all subtitles because we're downloading additional
|
||||
# external ones into the Kodi PKC add-on directory
|
||||
streams = []
|
||||
|
|
|
@ -55,6 +55,7 @@ REGEX_PLEX_ID_FROM_URL = re.compile(r'''metadata%2F(\d+)''')
|
|||
SAFE_URL_CHARACTERS = "%/:=&?~#+!$,;'@()*[]"
|
||||
HTTP_DAV_FTP = re.compile(r'(http(s)?|dav(s)?|(s)?ftp)://((.+):(.+)@)?([\w\.]+)(:([\d]+))?/')
|
||||
|
||||
|
||||
def garbageCollect():
|
||||
gc.collect(2)
|
||||
|
||||
|
@ -114,7 +115,7 @@ def settings(setting, value=None):
|
|||
"""
|
||||
# We need to instantiate every single time to read changed variables!
|
||||
with SETTINGS_LOCK:
|
||||
addon = xbmcaddon.Addon()
|
||||
addon = xbmcaddon.Addon('plugin.video.plexkodiconnect')
|
||||
if value is not None:
|
||||
# Takes string or unicode by default!
|
||||
addon.setSetting(setting, value)
|
||||
|
@ -556,6 +557,17 @@ def reset(ask_user=True):
|
|||
reboot_kodi()
|
||||
|
||||
|
||||
def log_xml(xml, logger, loglevel):
|
||||
"""
|
||||
Logs an etree xml. Pass the loglevel for which logging will happen, e.g.
|
||||
loglevel=logging.DEBUG
|
||||
"""
|
||||
if LOG.isEnabledFor(loglevel):
|
||||
string = undefused_etree.tostring(xml, encoding='utf8')
|
||||
string = string.decode('utf-8')
|
||||
logger('\n' + string)
|
||||
|
||||
|
||||
def compare_version(current, minimum):
|
||||
"""
|
||||
Returns True if current is >= then minimum. False otherwise. Returns True
|
||||
|
|
|
@ -21,7 +21,7 @@ MARK_PLAYED_AT = 0.9
|
|||
# watched?
|
||||
IGNORE_SECONDS_AT_START = 60
|
||||
|
||||
_ADDON = Addon()
|
||||
_ADDON = Addon('plugin.video.plexkodiconnect')
|
||||
ADDON_NAME = 'PlexKodiConnect'
|
||||
ADDON_ID = 'plugin.video.plexkodiconnect'
|
||||
ADDON_VERSION = _ADDON.getAddonInfo('version')
|
||||
|
@ -30,8 +30,9 @@ ADDON_FOLDER = xbmcvfs.translatePath('special://home')
|
|||
ADDON_PROFILE = xbmcvfs.translatePath(_ADDON.getAddonInfo('profile'))
|
||||
|
||||
# Used e.g. for json_rpc
|
||||
KODI_VIDEO_PLAYER_ID = 1
|
||||
KODI_AUDIO_PLAYER_ID = 0
|
||||
KODI_VIDEO_PLAYER_ID = 1
|
||||
KODI_PHOTO_PLAYER_ID = 2
|
||||
|
||||
KODILANGUAGE = xbmc.getLanguage(xbmc.ISO_639_1)
|
||||
KODIVERSION = int(xbmc.getInfoLabel("System.BuildVersion")[:2])
|
||||
|
@ -655,10 +656,6 @@ SORT_METHODS_ALBUMS = (
|
|||
)
|
||||
|
||||
|
||||
XML_HEADER = '<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
|
||||
COMPANION_OK_MESSAGE = XML_HEADER + '<Response code="200" status="OK" />'
|
||||
|
||||
PLEX_REPEAT_FROM_KODI_REPEAT = {
|
||||
'off': '0',
|
||||
'one': '1',
|
||||
|
|
|
@ -126,7 +126,6 @@ def on_error(ws, error):
|
|||
ws.name, type(error), error)
|
||||
# Status = Error
|
||||
utils.settings(status, value=utils.lang(257))
|
||||
raise RuntimeError
|
||||
|
||||
|
||||
def on_close(ws):
|
||||
|
@ -198,14 +197,19 @@ class PlexWebSocketApp(websocket.WebSocketApp,
|
|||
log.exception('Exception of type %s occured: %s', type(err), err)
|
||||
finally:
|
||||
self.close()
|
||||
# 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,
|
||||
value=utils.lang(15208))
|
||||
value=message)
|
||||
app.APP.deregister_thread(self)
|
||||
log.info("----===## %s stopped ##===----", self.name)
|
||||
|
||||
def _run(self):
|
||||
while not self.should_cancel():
|
||||
while not self.should_cancel() and self._enabled:
|
||||
# In the event the server goes offline
|
||||
while self.should_suspend():
|
||||
# We will be caught in this loop if either another thread
|
||||
|
@ -231,16 +235,13 @@ class PlexWebSocketApp(websocket.WebSocketApp,
|
|||
class PMSWebsocketApp(PlexWebSocketApp):
|
||||
name = 'pms_websocket'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self._enabled = utils.settings('enableBackgroundSync') == 'true'
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def get_uri(self):
|
||||
return get_pms_uri()
|
||||
|
||||
def should_suspend(self):
|
||||
"""
|
||||
Returns True if the thread needs to suspend.
|
||||
"""
|
||||
return (self._suspended or
|
||||
utils.settings('enableBackgroundSync') != 'true')
|
||||
|
||||
def set_suspension_settings_status(self):
|
||||
if utils.settings('enableBackgroundSync') != 'true':
|
||||
# Status = Disabled
|
||||
|
@ -255,6 +256,10 @@ class PMSWebsocketApp(PlexWebSocketApp):
|
|||
class AlexaWebsocketApp(PlexWebSocketApp):
|
||||
name = 'alexa_websocket'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self._enabled = utils.settings('enable_alexa') == 'true'
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def get_uri(self):
|
||||
return get_alexa_uri()
|
||||
|
||||
|
@ -263,7 +268,6 @@ class AlexaWebsocketApp(PlexWebSocketApp):
|
|||
Returns True if the thread needs to suspend.
|
||||
"""
|
||||
return self._suspended or \
|
||||
utils.settings('enable_alexa') != 'true' or \
|
||||
app.ACCOUNT.restricted_user or \
|
||||
not app.ACCOUNT.plex_token
|
||||
|
||||
|
|
|
@ -994,8 +994,7 @@ class WindowProperty(object):
|
|||
|
||||
class GlobalProperty(object):
|
||||
def __init__(self, prop, val='1', end=None):
|
||||
import xbmcaddon
|
||||
self._addonID = xbmcaddon.Addon().getAddonInfo('id')
|
||||
self._addonID = 'plugin.video.plexkodiconnect'
|
||||
self.prop = prop
|
||||
self.val = val
|
||||
self.end = end
|
||||
|
|
|
@ -36,12 +36,15 @@ class SkipIntroDialog(WindowXMLDialog):
|
|||
logger.debug('Closing dialog')
|
||||
WindowXMLDialog.close(self)
|
||||
|
||||
def seekTimeToIntroEnd(self):
|
||||
logger.info('Skipping intro, seeking to %s', self.intro_end)
|
||||
app.APP.player.seekTime(self.intro_end)
|
||||
|
||||
def onClick(self, control_id): # pylint: disable=invalid-name
|
||||
if self.intro_end and control_id == 3002: # 3002 = Skip Intro button
|
||||
if app.APP.is_playing:
|
||||
self.on_hold = True
|
||||
logger.info('Skipping intro, seeking to %s', self.intro_end)
|
||||
app.APP.player.seekTime(self.intro_end)
|
||||
self.seekTimeToIntroEnd()
|
||||
self.close()
|
||||
|
||||
def onAction(self, action): # pylint: disable=invalid-name
|
||||
|
|
|
@ -114,6 +114,7 @@
|
|||
<setting id="askCinema" type="bool" label="30519" default="false" visible="eq(-1,true)" subsetting="true" />
|
||||
<setting id="trailerNumber" type="slider" label="39000" default="3" visible="eq(-2,true)" range="1,1,15" option="int" />
|
||||
<setting id="enableSkipIntro" type="bool" label="30525" default="true" /><!-- Enable skipping of intros -->
|
||||
<setting id="enableAutoSkipIntro" type="bool" label="39720" default="false" visible="eq(-1,true)" subsetting="true" /><!-- Enable auto skipping of intros -->
|
||||
<setting id="firstVideoStream" type="bool" label="30546" default="false" /><!-- Pick the first video if several versions are present -->
|
||||
<setting id="audioStreamPick" type="enum" label="30547" values="Plex|Kodi" default="0" /><!-- Who picks the audio stream on playback start? -->
|
||||
<setting id="subtitleStreamPick" type="enum" label="30548" values="Plex|Kodi" default="0" /><!-- Who picks subtitles on playback start? -->
|
||||
|
@ -142,7 +143,7 @@
|
|||
</category>
|
||||
|
||||
<category label="30544"><!-- artwork -->
|
||||
<setting id="usePlexArtwork" label="30502" type="bool" default="true" /> <!-- Sync Plex artwork from the PMS -->
|
||||
<setting id="usePlexArtwork" label="30502" type="bool" default="true" /> <!-- Sync Plex artwork from the PMS -->
|
||||
<setting id="enableTextureCache" label="30512" type="bool" default="true" visible="eq(-1,true)"/> <!-- Cache all artwork for a smooth Kodi experience -->
|
||||
<setting id="FanartTV" label="30539" type="bool" default="false" visible="eq(-2,true)"/><!-- Download additional art from FanArtTV -->
|
||||
<setting id="PreferKodiCollectionArt" label="30543" type="bool" default="true" visible="eq(-1,true) + eq(-3,true)" subsetting="true" /><!-- Prefer Kodi artwork for collections -->
|
||||
|
|
Loading…
Reference in a new issue