diff --git a/addon.xml b/addon.xml index 02ae8290..e0ccd605 100644 --- a/addon.xml +++ b/addon.xml @@ -1,10 +1,10 @@ - + - - + + @@ -20,6 +20,8 @@ + + true icon.png fanart.jpg @@ -91,7 +93,51 @@ Plex를 Kodi에 기본 통합 Kodi를 Plex Media Server에 연결합니다. 이 플러그인은 Plex로 모든 비디오를 관리하고 Kodi로는 관리하지 않는다고 가정합니다. Kodi 비디오 및 음악 데이터베이스에 이미 저장된 데이터가 손실 될 수 있습니다 (이 플러그인이 직접 변경하므로). 자신의 책임하에 사용하십시오! 자신의 책임하에 사용 - version 3.5.8: + 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 diff --git a/changelog.txt b/changelog.txt index 66b4595f..1fb7f5bb 100644 --- a/changelog.txt +++ b/changelog.txt @@ -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 diff --git a/default.py b/default.py index 8eab9599..e81a1479 100644 --- a/default.py +++ b/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__': diff --git a/resources/language/resource.language.cs_CZ/strings.po b/resources/language/resource.language.cs_CZ/strings.po index 13f7d9c6..8380a357 100644 --- a/resources/language/resource.language.cs_CZ/strings.po +++ b/resources/language/resource.language.cs_CZ/strings.po @@ -2,7 +2,7 @@ # Translators: # Croneter None , 2017 # Michal Kuncl , 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 "" diff --git a/resources/language/resource.language.da_DK/strings.po b/resources/language/resource.language.da_DK/strings.po index c6162e93..1c94ece0 100644 --- a/resources/language/resource.language.da_DK/strings.po +++ b/resources/language/resource.language.da_DK/strings.po @@ -3,7 +3,7 @@ # Croneter None , 2017 # Thomas H. , 2019 # coz2001 , 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 "" diff --git a/resources/language/resource.language.de_DE/strings.po b/resources/language/resource.language.de_DE/strings.po index 7f18ec32..7a3d33a3 100644 --- a/resources/language/resource.language.de_DE/strings.po +++ b/resources/language/resource.language.de_DE/strings.po @@ -1,7 +1,7 @@ # XBMC Media Center language file # Translators: # Croneter None , 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 "" diff --git a/resources/language/resource.language.el_GR/strings.po b/resources/language/resource.language.el_GR/strings.po index ac4eb2a2..e223c336 100644 --- a/resources/language/resource.language.el_GR/strings.po +++ b/resources/language/resource.language.el_GR/strings.po @@ -1,7 +1,7 @@ # XBMC Media Center language file # Translators: # Croneter None , 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 "" diff --git a/resources/language/resource.language.en_gb/strings.po b/resources/language/resource.language.en_gb/strings.po index c930a0a7..220a4ab1 100644 --- a/resources/language/resource.language.en_gb/strings.po +++ b/resources/language/resource.language.en_gb/strings.po @@ -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 "" diff --git a/resources/language/resource.language.es_AR/strings.po b/resources/language/resource.language.es_AR/strings.po index 299dc9e8..addfa449 100644 --- a/resources/language/resource.language.es_AR/strings.po +++ b/resources/language/resource.language.es_AR/strings.po @@ -1,7 +1,7 @@ # XBMC Media Center language file # Translators: # Croneter None , 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 "" diff --git a/resources/language/resource.language.es_ES/strings.po b/resources/language/resource.language.es_ES/strings.po index ba8f2c17..53528953 100644 --- a/resources/language/resource.language.es_ES/strings.po +++ b/resources/language/resource.language.es_ES/strings.po @@ -3,7 +3,7 @@ # Dani , 2019 # Bartolome Soriano , 2019 # Croneter None , 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 "" diff --git a/resources/language/resource.language.es_MX/strings.po b/resources/language/resource.language.es_MX/strings.po index 29c14f97..a11da862 100644 --- a/resources/language/resource.language.es_MX/strings.po +++ b/resources/language/resource.language.es_MX/strings.po @@ -1,7 +1,7 @@ # XBMC Media Center language file # Translators: # Croneter None , 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 "" diff --git a/resources/language/resource.language.fr_CA/strings.po b/resources/language/resource.language.fr_CA/strings.po index 9714c428..d9afe371 100644 --- a/resources/language/resource.language.fr_CA/strings.po +++ b/resources/language/resource.language.fr_CA/strings.po @@ -3,7 +3,7 @@ # Elixir59, 2019 # Croneter None , 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 "" diff --git a/resources/language/resource.language.fr_FR/strings.po b/resources/language/resource.language.fr_FR/strings.po index dd1ddb09..3d0e8f14 100644 --- a/resources/language/resource.language.fr_FR/strings.po +++ b/resources/language/resource.language.fr_FR/strings.po @@ -7,7 +7,7 @@ # julien benoist , 2019 # Croneter None , 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 "" diff --git a/resources/language/resource.language.hu_HU/strings.po b/resources/language/resource.language.hu_HU/strings.po index 04cd89f3..b37c17ab 100644 --- a/resources/language/resource.language.hu_HU/strings.po +++ b/resources/language/resource.language.hu_HU/strings.po @@ -2,7 +2,7 @@ # Translators: # Croneter None , 2019 # Savage93 , 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 "" diff --git a/resources/language/resource.language.it_IT/strings.po b/resources/language/resource.language.it_IT/strings.po index a08dee26..a75735e4 100644 --- a/resources/language/resource.language.it_IT/strings.po +++ b/resources/language/resource.language.it_IT/strings.po @@ -4,7 +4,7 @@ # Angela Calò , 2018 # Cristiano Bozzi , 2018 # Luigi Mantellini , 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 "" diff --git a/resources/language/resource.language.ko_KR/strings.po b/resources/language/resource.language.ko_KR/strings.po index 7752a1f8..f101f690 100644 --- a/resources/language/resource.language.ko_KR/strings.po +++ b/resources/language/resource.language.ko_KR/strings.po @@ -7,7 +7,7 @@ # k irbymaker , 2020 # Croneter None , 2021 # so.o.bima , 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 "" diff --git a/resources/language/resource.language.lt_LT/strings.po b/resources/language/resource.language.lt_LT/strings.po index c618ae10..587ed2a4 100644 --- a/resources/language/resource.language.lt_LT/strings.po +++ b/resources/language/resource.language.lt_LT/strings.po @@ -2,7 +2,7 @@ # Translators: # tigriso1 , 2019 # Egidijus Mz , 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 "" diff --git a/resources/language/resource.language.lv_LV/strings.po b/resources/language/resource.language.lv_LV/strings.po index fd869e8b..ad8b7be1 100644 --- a/resources/language/resource.language.lv_LV/strings.po +++ b/resources/language/resource.language.lv_LV/strings.po @@ -1,7 +1,7 @@ # XBMC Media Center language file # Translators: # marcisbe , 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 "" diff --git a/resources/language/resource.language.nl_NL/strings.po b/resources/language/resource.language.nl_NL/strings.po index 0aacdd81..62068bb3 100644 --- a/resources/language/resource.language.nl_NL/strings.po +++ b/resources/language/resource.language.nl_NL/strings.po @@ -5,7 +5,7 @@ # Panja0 , 2019 # Nick Corthals , 2019 # Rick van Soest , 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 "" diff --git a/resources/language/resource.language.no_NO/strings.po b/resources/language/resource.language.no_NO/strings.po index c0e44000..6978927c 100644 --- a/resources/language/resource.language.no_NO/strings.po +++ b/resources/language/resource.language.no_NO/strings.po @@ -3,7 +3,7 @@ # Croneter None , 2017 # Jon Mjørud , 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 "" diff --git a/resources/language/resource.language.pl_PL/strings.po b/resources/language/resource.language.pl_PL/strings.po index a0660851..808c38f5 100644 --- a/resources/language/resource.language.pl_PL/strings.po +++ b/resources/language/resource.language.pl_PL/strings.po @@ -4,7 +4,7 @@ # Wiktor Dackiewicz , 2017 # Kacpolz , 2019 # Ziuta , 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 "" diff --git a/resources/language/resource.language.pt_BR/strings.po b/resources/language/resource.language.pt_BR/strings.po index 71396a79..d85197ab 100644 --- a/resources/language/resource.language.pt_BR/strings.po +++ b/resources/language/resource.language.pt_BR/strings.po @@ -2,7 +2,7 @@ # Translators: # Croneter None , 2017 # Daniel Leite , 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 "" diff --git a/resources/language/resource.language.pt_PT/strings.po b/resources/language/resource.language.pt_PT/strings.po index 41484f77..d3558288 100644 --- a/resources/language/resource.language.pt_PT/strings.po +++ b/resources/language/resource.language.pt_PT/strings.po @@ -3,7 +3,7 @@ # Croneter None , 2017 # Goncalo Campos , 2018 # Bruno Guerreiro , 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 "" diff --git a/resources/language/resource.language.ru_RU/strings.po b/resources/language/resource.language.ru_RU/strings.po index 047e8fab..73f64f9f 100644 --- a/resources/language/resource.language.ru_RU/strings.po +++ b/resources/language/resource.language.ru_RU/strings.po @@ -6,7 +6,7 @@ # Alex Freit , 2019 # Vladimir Supranenok , 2019 # Vlad Anisimov , 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 "" diff --git a/resources/language/resource.language.sv_SE/strings.po b/resources/language/resource.language.sv_SE/strings.po index 8d87d11c..dd33a53a 100644 --- a/resources/language/resource.language.sv_SE/strings.po +++ b/resources/language/resource.language.sv_SE/strings.po @@ -7,7 +7,7 @@ # Nisse Karlsson , 2019 # Ludwig Johnson , 2019 # namob , 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 "" diff --git a/resources/language/resource.language.uk_UA/strings.po b/resources/language/resource.language.uk_UA/strings.po index 95be95d7..385c5f58 100644 --- a/resources/language/resource.language.uk_UA/strings.po +++ b/resources/language/resource.language.uk_UA/strings.po @@ -1,7 +1,7 @@ # XBMC Media Center language file # Translators: # Vlad Anisimov , 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 "" diff --git a/resources/language/resource.language.zh_CN/strings.po b/resources/language/resource.language.zh_CN/strings.po index d9eda772..2671bb36 100644 --- a/resources/language/resource.language.zh_CN/strings.po +++ b/resources/language/resource.language.zh_CN/strings.po @@ -3,7 +3,7 @@ # Croneter None , 2017 # Tony Z , 2017 # Jingen Chen , 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 "" diff --git a/resources/language/resource.language.zh_TW/strings.po b/resources/language/resource.language.zh_TW/strings.po index dfdcb423..401c689b 100644 --- a/resources/language/resource.language.zh_TW/strings.po +++ b/resources/language/resource.language.zh_TW/strings.po @@ -1,7 +1,7 @@ # XBMC Media Center language file # Translators: # Croneter None , 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 "" diff --git a/resources/lib/app/__init__.py b/resources/lib/app/__init__.py index 8243a64a..634fe004 100644 --- a/resources/lib/app/__init__.py +++ b/resources/lib/app/__init__.py @@ -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(): """ diff --git a/resources/lib/app/playqueues.py b/resources/lib/app/playqueues.py new file mode 100644 index 00000000..0f4b9c3f --- /dev/null +++ b/resources/lib/app/playqueues.py @@ -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) diff --git a/resources/lib/app/playstate.py b/resources/lib/app/playstate.py index 1f2c7ff4..21909b71 100644 --- a/resources/lib/app/playstate.py +++ b/resources/lib/app/playstate.py @@ -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() diff --git a/resources/lib/clientinfo.py b/resources/lib/clientinfo.py index 92b7ebf5..6b65413d 100644 --- a/resources/lib/clientinfo.py +++ b/resources/lib/clientinfo.py @@ -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() diff --git a/resources/lib/companion.py b/resources/lib/companion.py index 077474fd..5dfad3e0 100644 --- a/resources/lib/companion.py +++ b/resources/lib/companion.py @@ -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 diff --git a/resources/lib/context_entry.py b/resources/lib/context_entry.py index 8195016b..6a6553ad 100644 --- a/resources/lib/context_entry.py +++ b/resources/lib/context_entry.py @@ -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] diff --git a/resources/lib/initialsetup.py b/resources/lib/initialsetup.py index 9d480928..726ede99 100644 --- a/resources/lib/initialsetup.py +++ b/resources/lib/initialsetup.py @@ -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: diff --git a/resources/lib/json_rpc.py b/resources/lib/json_rpc.py index 8a6f00a6..3c060e91 100644 --- a/resources/lib/json_rpc.py +++ b/resources/lib/json_rpc.py @@ -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, diff --git a/resources/lib/kodimonitor.py b/resources/lib/kodimonitor.py index 95765a59..76c02424 100644 --- a/resources/lib/kodimonitor.py +++ b/resources/lib/kodimonitor.py @@ -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() diff --git a/resources/lib/playback.py b/resources/lib/playback.py index a40d4685..3cd493ef 100644 --- a/resources/lib/playback.py +++ b/resources/lib/playback.py @@ -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 diff --git a/resources/lib/playlist_func.py b/resources/lib/playlist_func.py index 697823b5..e65277df 100644 --- a/resources/lib/playlist_func.py +++ b/resources/lib/playlist_func.py @@ -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 ########## diff --git a/resources/lib/playqueue.py b/resources/lib/playqueue.py deleted file mode 100644 index 1f06cb32..00000000 --- a/resources/lib/playqueue.py +++ /dev/null @@ -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) diff --git a/resources/lib/plex_api/artwork.py b/resources/lib/plex_api/artwork.py index f8b351f8..a5f92842 100644 --- a/resources/lib/plex_api/artwork.py +++ b/resources/lib/plex_api/artwork.py @@ -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() diff --git a/resources/lib/plex_companion.py b/resources/lib/plex_companion.py deleted file mode 100644 index e6400dd8..00000000 --- a/resources/lib/plex_companion.py +++ /dev/null @@ -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() diff --git a/resources/lib/plex_companion/__init__.py b/resources/lib/plex_companion/__init__.py new file mode 100644 index 00000000..f167e7d1 --- /dev/null +++ b/resources/lib/plex_companion/__init__.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +from .polling import Polling +from .playstate import PlaystateMgr diff --git a/resources/lib/plex_companion/common.py b/resources/lib/plex_companion/common.py new file mode 100644 index 00000000..1cbe03ac --- /dev/null +++ b/resources/lib/plex_companion/common.py @@ -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__() diff --git a/resources/lib/plex_companion/playqueue.py b/resources/lib/plex_companion/playqueue.py new file mode 100644 index 00000000..e10742a4 --- /dev/null +++ b/resources/lib/plex_companion/playqueue.py @@ -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') diff --git a/resources/lib/plex_companion/playstate.py b/resources/lib/plex_companion/playstate.py new file mode 100644 index 00000000..682730d9 --- /dev/null +++ b/resources/lib/plex_companion/playstate.py @@ -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) diff --git a/resources/lib/plex_companion/plexgdm.py b/resources/lib/plex_companion/plexgdm.py new file mode 100644 index 00000000..e2a1aea0 --- /dev/null +++ b/resources/lib/plex_companion/plexgdm.py @@ -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) diff --git a/resources/lib/plex_companion/polling.py b/resources/lib/plex_companion/polling.py new file mode 100644 index 00000000..9699a09e --- /dev/null +++ b/resources/lib/plex_companion/polling.py @@ -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 diff --git a/resources/lib/plex_companion/processing.py b/resources/lib/plex_companion/processing.py new file mode 100644 index 00000000..1026ae16 --- /dev/null +++ b/resources/lib/plex_companion/processing.py @@ -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 diff --git a/resources/lib/plex_companion/webserver.py b/resources/lib/plex_companion/webserver.py new file mode 100644 index 00000000..0a3f8586 --- /dev/null +++ b/resources/lib/plex_companion/webserver.py @@ -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 diff --git a/resources/lib/plex_db/playlists.py b/resources/lib/plex_db/playlists.py index 57d3b085..5c318cf6 100644 --- a/resources/lib/plex_db/playlists.py +++ b/resources/lib/plex_db/playlists.py @@ -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 """ diff --git a/resources/lib/plexbmchelper/__init__.py b/resources/lib/plexbmchelper/__init__.py deleted file mode 100644 index b93054b3..00000000 --- a/resources/lib/plexbmchelper/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Dummy file to make this directory a package. diff --git a/resources/lib/plexbmchelper/httppersist.py b/resources/lib/plexbmchelper/httppersist.py deleted file mode 100644 index 2c4735df..00000000 --- a/resources/lib/plexbmchelper/httppersist.py +++ /dev/null @@ -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 diff --git a/resources/lib/plexbmchelper/listener.py b/resources/lib/plexbmchelper/listener.py deleted file mode 100644 index 6473bfed..00000000 --- a/resources/lib/plexbmchelper/listener.py +++ /dev/null @@ -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\n' - ' \n' - '\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) diff --git a/resources/lib/plexbmchelper/plexgdm.py b/resources/lib/plexbmchelper/plexgdm.py deleted file mode 100644 index 652d1bbc..00000000 --- a/resources/lib/plexbmchelper/plexgdm.py +++ /dev/null @@ -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) diff --git a/resources/lib/plexbmchelper/subscribers.py b/resources/lib/plexbmchelper/subscribers.py deleted file mode 100644 index 4e9767f2..00000000 --- a/resources/lib/plexbmchelper/subscribers.py +++ /dev/null @@ -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\n' - ' \n' - ' \n' - ' \n' - '\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) diff --git a/resources/lib/service_entry.py b/resources/lib/service_entry.py index ce3e9678..67827d97 100644 --- a/resources/lib/service_entry.py +++ b/resources/lib/service_entry.py @@ -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: diff --git a/resources/lib/skip_plex_intro.py b/resources/lib/skip_plex_intro.py index b3590cab..f7b22867 100644 --- a/resources/lib/skip_plex_intro.py +++ b/resources/lib/skip_plex_intro.py @@ -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 diff --git a/resources/lib/subtitles.py b/resources/lib/subtitles.py index 78fc9c83..bc4b04da 100644 --- a/resources/lib/subtitles.py +++ b/resources/lib/subtitles.py @@ -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 = [] diff --git a/resources/lib/utils.py b/resources/lib/utils.py index 11db93c0..4978e44e 100644 --- a/resources/lib/utils.py +++ b/resources/lib/utils.py @@ -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 diff --git a/resources/lib/variables.py b/resources/lib/variables.py index 8174c5ea..6c084460 100644 --- a/resources/lib/variables.py +++ b/resources/lib/variables.py @@ -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 = '\n' - -COMPANION_OK_MESSAGE = XML_HEADER + '' - PLEX_REPEAT_FROM_KODI_REPEAT = { 'off': '0', 'one': '1', diff --git a/resources/lib/websocket_client.py b/resources/lib/websocket_client.py index 56901c15..20c2fe4f 100644 --- a/resources/lib/websocket_client.py +++ b/resources/lib/websocket_client.py @@ -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 diff --git a/resources/lib/windows/kodigui.py b/resources/lib/windows/kodigui.py index 358ce98a..e860dd2e 100644 --- a/resources/lib/windows/kodigui.py +++ b/resources/lib/windows/kodigui.py @@ -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 diff --git a/resources/lib/windows/skip_intro.py b/resources/lib/windows/skip_intro.py index fa66582b..75e9b9be 100644 --- a/resources/lib/windows/skip_intro.py +++ b/resources/lib/windows/skip_intro.py @@ -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 diff --git a/resources/settings.xml b/resources/settings.xml index 46066caf..231b62a5 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -114,6 +114,7 @@ + @@ -142,7 +143,7 @@ - +