From 20e6a3d24cb785a94d9047de546096a58269acf9 Mon Sep 17 00:00:00 2001 From: Christopher Kaliszewski Date: Sun, 24 Oct 2021 01:50:15 -0400 Subject: [PATCH 01/46] Add auto skip intro functionality --- resources/language/resource.language.cs_CZ/strings.po | 7 ++++++- resources/language/resource.language.da_DK/strings.po | 7 ++++++- resources/language/resource.language.de_DE/strings.po | 7 ++++++- resources/language/resource.language.el_GR/strings.po | 7 ++++++- resources/language/resource.language.en_gb/strings.po | 9 +++++++-- resources/language/resource.language.es_AR/strings.po | 7 ++++++- resources/language/resource.language.es_ES/strings.po | 7 ++++++- resources/language/resource.language.es_MX/strings.po | 7 ++++++- resources/language/resource.language.fr_CA/strings.po | 7 ++++++- resources/language/resource.language.fr_FR/strings.po | 7 ++++++- resources/language/resource.language.hu_HU/strings.po | 7 ++++++- resources/language/resource.language.it_IT/strings.po | 7 ++++++- resources/language/resource.language.ko_KR/strings.po | 7 ++++++- resources/language/resource.language.lt_LT/strings.po | 7 ++++++- resources/language/resource.language.lv_LV/strings.po | 7 ++++++- resources/language/resource.language.nl_NL/strings.po | 7 ++++++- resources/language/resource.language.no_NO/strings.po | 7 ++++++- resources/language/resource.language.pl_PL/strings.po | 7 ++++++- resources/language/resource.language.pt_BR/strings.po | 7 ++++++- resources/language/resource.language.pt_PT/strings.po | 7 ++++++- resources/language/resource.language.ru_RU/strings.po | 7 ++++++- resources/language/resource.language.sv_SE/strings.po | 7 ++++++- resources/language/resource.language.uk_UA/strings.po | 7 ++++++- resources/language/resource.language.zh_CN/strings.po | 7 ++++++- resources/language/resource.language.zh_TW/strings.po | 7 ++++++- resources/lib/skip_plex_intro.py | 8 ++++++-- resources/lib/windows/skip_intro.py | 7 +++++-- resources/settings.xml | 3 ++- 28 files changed, 164 insertions(+), 31 deletions(-) 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/skip_plex_intro.py b/resources/lib/skip_plex_intro.py index b3590cab..3ab4cddc 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): @@ -20,7 +20,11 @@ def skip_intro(intros): '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/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 @@ - + From 622c468e3d0c60c35606869b087d093ecf340197 Mon Sep 17 00:00:00 2001 From: croneter Date: Sat, 30 Oct 2021 18:10:33 +0200 Subject: [PATCH 02/46] Fix streams not being set-up --- resources/lib/kodimonitor.py | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/resources/lib/kodimonitor.py b/resources/lib/kodimonitor.py index 95765a59..29669750 100644 --- a/resources/lib/kodimonitor.py +++ b/resources/lib/kodimonitor.py @@ -31,7 +31,6 @@ 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) @@ -365,7 +364,15 @@ 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 + + # We need to switch to the Plex streams ONCE upon playback start + if playerid == v.KODI_VIDEO_PLAYER_ID: + 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') def _on_av_change(self, data): """ @@ -387,22 +394,7 @@ class KodiMonitor(xbmc.Monitor): 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) + item.on_av_change(playerid) def _playback_cleanup(ended=False): From 2c74ddde8815570bad3dcca180960e8d85ae853a Mon Sep 17 00:00:00 2001 From: croneter Date: Fri, 22 Oct 2021 08:40:25 +0200 Subject: [PATCH 03/46] Completely new implementation for Plex Companion --- resources/lib/playlist_func.py | 36 +- resources/lib/plex_companion.py | 362 ---------------- resources/lib/plex_companion/__init__.py | 5 + resources/lib/plex_companion/common.py | 33 ++ resources/lib/plex_companion/playstate.py | 390 +++++++++++++++++ resources/lib/plex_companion/polling.py | 172 ++++++++ resources/lib/plex_companion/processing.py | 241 +++++++++++ resources/lib/plexbmchelper/__init__.py | 1 - resources/lib/plexbmchelper/httppersist.py | 105 ----- resources/lib/plexbmchelper/listener.py | 238 ----------- resources/lib/plexbmchelper/plexgdm.py | 314 -------------- resources/lib/plexbmchelper/subscribers.py | 470 --------------------- resources/lib/service_entry.py | 12 +- resources/lib/utils.py | 9 + resources/lib/variables.py | 3 +- 15 files changed, 880 insertions(+), 1511 deletions(-) delete mode 100644 resources/lib/plex_companion.py create mode 100644 resources/lib/plex_companion/__init__.py create mode 100644 resources/lib/plex_companion/common.py create mode 100644 resources/lib/plex_companion/playstate.py create mode 100644 resources/lib/plex_companion/polling.py create mode 100644 resources/lib/plex_companion/processing.py delete mode 100644 resources/lib/plexbmchelper/__init__.py delete mode 100644 resources/lib/plexbmchelper/httppersist.py delete mode 100644 resources/lib/plexbmchelper/listener.py delete mode 100644 resources/lib/plexbmchelper/plexgdm.py delete mode 100644 resources/lib/plexbmchelper/subscribers.py diff --git a/resources/lib/playlist_func.py b/resources/lib/playlist_func.py index 697823b5..f5e4a145 100644 --- a/resources/lib/playlist_func.py +++ b/resources/lib/playlist_func.py @@ -312,13 +312,16 @@ class PlaylistItem(object): 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): """ @@ -457,27 +460,26 @@ class PlaylistItem(object): 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): + def on_plex_stream_change(self, video_stream_id=None, audio_stream_id=None, + subtitle_stream_id=None): """ - Call this method if Plex Companion wants to change streams + Call this method if Plex Companion wants to change streams [ints] """ - if 'audioStreamID' in plex_data: - 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: + kodi_index = self.kodi_stream_index(video_stream_id, 'video') self._set_kodi_stream_if_different(kodi_index, 'video') self.current_kodi_video_stream = kodi_index - if 'subtitleStreamID' in plex_data: - plex_index = int(plex_data['subtitleStreamID']) - if plex_index == 0: + if audio_stream_id is not None: + kodi_index = self.kodi_stream_index(audio_stream_id, 'audio') + 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 else: - kodi_index = self.kodi_stream_index(plex_index, 'subtitle') + kodi_index = self.kodi_stream_index(subtitle_stream_id, + 'subtitle') if kodi_index: app.APP.player.setSubtitleStream(kodi_index) app.APP.player.showSubtitles(True) 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..ba2478a9 --- /dev/null +++ b/resources/lib/plex_companion/__init__.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +from .polling import Listener +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..14f6e4c1 --- /dev/null +++ b/resources/lib/plex_companion/common.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from .. import variables as v +from .. import app + + +def log_error(logger, error_message, response): + logger('%s: %s: %s', error_message, response.status_code, response.reason) + logger('headers received from the PMS: %s', response.headers) + logger('Message received from the PMS: %s', response.text) + + +def proxy_headers(): + return { + 'X-Plex-Client-Identifier': v.PKC_MACHINE_IDENTIFIER, + 'X-Plex-Product': v.ADDON_NAME, + 'X-Plex-Version': v.ADDON_VERSION, + 'X-Plex-Platform': v.PLATFORM, + 'X-Plex-Platform-Version': v.PLATFORM_VERSION, + 'X-Plex-Device-Name': v.DEVICENAME, + 'Content-Type': 'text/xml;charset=utf-8' + } + + +def proxy_params(): + params = { + 'deviceClass': 'pc', + 'protocolCapabilities': 'timeline,playback,navigation,playqueues', + 'protocolVersion': 3 + } + if app.ACCOUNT.pms_token: + params['X-Plex-Token'] = app.ACCOUNT.pms_token + return params diff --git a/resources/lib/plex_companion/playstate.py b/resources/lib/plex_companion/playstate.py new file mode 100644 index 00000000..853e58af --- /dev/null +++ b/resources/lib/plex_companion/playstate.py @@ -0,0 +1,390 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from logging import getLogger +import requests +import xml.etree.ElementTree as etree + +from .common import proxy_headers, proxy_params, log_error + +from .. import json_rpc as js +from .. import variables as v +from .. import backgroundthread +from .. import app +from .. import timing +from .. import playqueue as PQ + + +# Disable annoying requests warnings +import requests.packages.urllib3 +requests.packages.urllib3.disable_warnings() + +log = getLogger('PLEX.companion.playstate') + +TIMEOUT = (5, 5) + +# What is Companion controllable? +CONTROLLABLE = { + v.PLEX_PLAYLIST_TYPE_VIDEO: 'playPause,stop,volume,shuffle,audioStream,' + 'subtitleStream,seekTo,skipPrevious,skipNext,' + 'stepBack,stepForward', + v.PLEX_PLAYLIST_TYPE_AUDIO: 'playPause,stop,volume,shuffle,repeat,seekTo,' + 'skipPrevious,skipNext,stepBack,stepForward', + v.PLEX_PLAYLIST_TYPE_PHOTO: 'playPause,stop,skipPrevious,skipNext' +} + + +def split_server_uri(server): + (protocol, url, port) = server.split(':') + url = url.replace('/', '') + return (protocol, url, port) + + +def get_correct_position(info, playqueue): + """ + Kodi tells us the PLAYLIST position, not PLAYQUEUE position, if the + user initiated playback of a playlist + """ + if playqueue.kodi_playlist_playback: + position = 0 + else: + position = info['position'] or 0 + return position + + +def timeline_dict(playerid, typus): + with app.APP.lock_playqueues: + info = app.PLAYSTATE.player_states[playerid] + playqueue = PQ.PLAYQUEUES[playerid] + position = get_correct_position(info, playqueue) + try: + item = playqueue.items[position] + except IndexError: + # E.g. for direct path playback for single item + return { + 'controllable': CONTROLLABLE[typus], + 'type': typus, + 'state': 'stopped' + } + protocol, url, port = split_server_uri(app.CONN.server) + status = 'paused' if int(info['speed']) == 0 else 'playing' + duration = timing.kodi_time_to_millis(info['totaltime']) + shuffle = '1' if info['shuffled'] else '0' + mute = '1' if info['muted'] is True else '0' + answ = { + 'controllable': CONTROLLABLE[typus], + 'protocol': protocol, + 'address': url, + 'port': port, + 'machineIdentifier': app.CONN.machine_identifier, + 'state': status, + 'type': typus, + 'itemType': typus, + 'time': str(timing.kodi_time_to_millis(info['time'])), + 'duration': str(duration), + 'seekRange': '0-%s' % duration, + 'shuffle': shuffle, + 'repeat': v.PLEX_REPEAT_FROM_KODI_REPEAT[info['repeat']], + 'volume': str(info['volume']), + 'mute': mute, + 'mediaIndex': '0', # Still to implement + 'partIndex': '0', + 'partCount': '1', + 'providerIdentifier': 'com.plexapp.plugins.library', + } + # Get the plex id from the PKC playqueue not info, as Kodi jumps to + # next playqueue element way BEFORE kodi monitor onplayback is + # called + if item.plex_id: + answ['key'] = '/library/metadata/%s' % item.plex_id + answ['ratingKey'] = str(item.plex_id) + # PlayQueue stuff + if info['container_key']: + answ['containerKey'] = info['container_key'] + if (info['container_key'] is not None and + info['container_key'].startswith('/playQueues')): + answ['playQueueID'] = str(playqueue.id) + answ['playQueueVersion'] = str(playqueue.version) + answ['playQueueItemID'] = str(item.id) + if playqueue.items[position].guid: + answ['guid'] = item.guid + # Temp. token set? + if app.CONN.plex_transient_token: + answ['token'] = app.CONN.plex_transient_token + elif playqueue.plex_transient_token: + answ['token'] = playqueue.plex_transient_token + # Process audio and subtitle streams + if typus == v.PLEX_PLAYLIST_TYPE_VIDEO: + answ['videoStreamID'] = str(item.current_plex_video_stream) + answ['audioStreamID'] = str(item.current_plex_audio_stream) + # Mind the zero - meaning subs are deactivated + answ['subtitleStreamID'] = str(item.current_plex_sub_stream or 0) + return answ + + +def timeline(players): + """ + Returns a timeline xml as str + (xml containing video, audio, photo player state) + """ + xml = etree.Element('MediaContainer') + location = 'navigation' + for typus in (v.PLEX_PLAYLIST_TYPE_AUDIO, + v.PLEX_PLAYLIST_TYPE_VIDEO, + v.PLEX_PLAYLIST_TYPE_PHOTO): + player = players.get(v.KODI_PLAYLIST_TYPE_FROM_PLEX_PLAYLIST_TYPE[typus]) + if player is None: + # Kodi player currently not actively playing, but stopped + timeline = { + 'controllable': CONTROLLABLE[typus], + 'type': typus, + 'state': 'stopped' + } + else: + # Active Kodi player, i.e. video, audio or picture player + timeline = timeline_dict(player['playerid'], typus) + if typus in (v.PLEX_PLAYLIST_TYPE_VIDEO, v.PLEX_PLAYLIST_TYPE_PHOTO): + location = 'fullScreenVideo' + etree.SubElement(xml, 'Timeline', attrib=timeline) + xml.set('location', location) + return xml + + +def stopped_timeline(): + """ + Returns an XML stating that all players have stopped playback + """ + xml = etree.Element('MediaContainer', attrib={'location': 'navigation'}) + for typus in (v.PLEX_PLAYLIST_TYPE_AUDIO, + v.PLEX_PLAYLIST_TYPE_VIDEO, + v.PLEX_PLAYLIST_TYPE_PHOTO): + # Kodi player currently not actively playing, but stopped + timeline = { + 'controllable': CONTROLLABLE[typus], + 'type': typus, + 'state': 'stopped' + } + etree.SubElement(xml, 'Timeline', attrib=timeline) + return xml + + +def update_player_info(players): + """ + Update the playstate info for other PKC "consumers" + """ + for player in players.values(): + playerid = player['playerid'] + app.PLAYSTATE.player_states[playerid].update(js.get_player_props(playerid)) + app.PLAYSTATE.player_states[playerid]['volume'] = js.get_volume() + app.PLAYSTATE.player_states[playerid]['muted'] = js.get_muted() + + +class PlaystateMgr(backgroundthread.KillableThread): + """ + If Kodi plays something, tell the PMS about it and - if a Companion client + is connected - tell the PMS Plex Companion piece of the PMS about it. + Also checks whether an intro is currently playing, enabling the user to + skip it. + """ + daemon = True + + def __init__(self): + self._subscribed = False + self._command_id = None + self.s = None + self.t = None + self.stopped_timeline = stopped_timeline() + super().__init__() + + def _get_requests_session(self): + if self.s is None: + log.debug('Creating new requests session') + self.s = requests.Session() + self.s.headers = proxy_headers() + self.s.verify = app.CONN.verify_ssl_cert + if app.CONN.ssl_cert_path: + self.s.cert = app.CONN.ssl_cert_path + self.s.params = proxy_params() + return self.s + + def _get_requests_session_companion(self): + if self.t is None: + log.debug('Creating new companion requests session') + self.t = requests.Session() + self.t.headers = proxy_headers() + self.t.verify = app.CONN.verify_ssl_cert + if app.CONN.ssl_cert_path: + self.t.cert = app.CONN.ssl_cert_path + self.t.params = proxy_params() + return self.t + + def close_requests_session(self): + for session in (self.s, self.t): + if session is not None: + try: + session.close() + except AttributeError: + # "thread-safety" - Just in case s was set to None in the + # meantime + pass + session = None + + @staticmethod + def communicate(method, url, **kwargs): + try: + # This will usually block until timeout is reached! + req = method(url, **kwargs) + except requests.ConnectTimeout: + # The request timed out while trying to connect to the PMS + log.error('Requests ConnectionTimeout!') + raise + except requests.ReadTimeout: + # The PMS did not send any data in the allotted amount of time + log.error('Requests ReadTimeout!') + raise + except requests.TooManyRedirects: + log.error('TooManyRedirects error!') + raise + except requests.HTTPError as error: + log.error('HTTPError: %s', error) + raise + except requests.ConnectionError as error: + log.error('ConnectionError: %s', error) + raise + req.encoding = 'utf-8' + # To make sure that we release the socket, need to access content once + req.content + return req + + def _subscribe(self, cmd): + self._command_id = int(cmd.get('commandID')) + self._subscribed = True + + def _unsubscribe(self): + self._subscribed = False + self._command_id = None + + def send_stop(self): + """ + If we're still connected to a PMS, tells the PMS that playback stopped + """ + if app.CONN.online and app.ACCOUNT.authenticated: + # Only try to send something if we're connected + self.pms_timeline(dict(), self.stopped_timeline) + self.companion_timeline(self.stopped_timeline) + + def check_subscriber(self, cmd): + if cmd.get('path') == '/player/timeline/unsubscribe': + log.info('Stop Plex Companion subscription') + self._unsubscribe() + elif not self._subscribed: + log.info('Start Plex Companion subscription') + self._subscribe(cmd) + else: + try: + self._command_id = int(cmd.get('commandID')) + except TypeError: + pass + + def companion_timeline(self, message): + if not self._subscribed: + return + url = f'{app.CONN.server}/player/proxy/timeline' + self._get_requests_session_companion() + self.t.params['commandID'] = self._command_id + message.set('commandID', str(self._command_id)) + # Get the correct playstate + state = 'stopped' + for timeline in message: + if timeline.get('state') != 'stopped': + state = timeline.get('state') + self.t.params['state'] = state + # Send update + try: + req = self.communicate(self.t.post, + url, + data=etree.tostring(message, + encoding='utf-8'), + timeout=TIMEOUT) + except (requests.RequestException, SystemExit): + return + if not req.ok: + log_error(log.error, 'Unexpected Companion timeline', req) + + def pms_timeline_per_player(self, playerid, message): + """ + Pass a really low timeout in seconds if shutting down Kodi and we don't + need the PMS' response + """ + url = f'{app.CONN.server}/:/timeline' + self._get_requests_session() + self.s.params.update(message[playerid].attrib) + # Tell the PMS about our playstate progress + try: + req = self.communicate(self.s.get, url, timeout=TIMEOUT) + except (requests.RequestException, SystemExit): + return + if not req.ok: + log_error(log.error, 'Failed reporting playback progress', req) + + def pms_timeline(self, players, message): + players = players if players else \ + {0: {'playerid': 0}, 1: {'playerid': 1}, 2: {'playerid': 2}} + for player in players.values(): + self.pms_timeline_per_player(player['playerid'], message) + + def run(self): + app.APP.register_thread(self) + log.info("----===## Starting PlaystateMgr ##===----") + try: + self._run() + finally: + # Make sure we're telling the PMS that playback will stop + self.send_stop() + # Cleanup + self.close_requests_session() + app.APP.deregister_thread(self) + log.info("----===## PlaystateMgr stopped ##===----") + + def _run(self): + signaled_playback_stop = True + while not self.should_cancel(): + if self.should_suspend(): + self._unsubscribe() + self.close_requests_session() + if self.wait_while_suspended(): + break + # We will only become active if there's Kodi playback going on + players = js.get_players() + if not players and signaled_playback_stop: + self.sleep(1) + continue + elif not players: + # Playback has just stopped, need to tell Plex + signaled_playback_stop = True + self.send_stop() + self.sleep(1) + continue + else: + # Update the playstate info, such as playback progress + update_player_info(players) + try: + message = timeline(players) + except TypeError: + # We haven't had a chance to set the kodi_stream_index for + # the currently playing item. Just skip for now + self.sleep(1) + continue + else: + # Kodi will started with 'stopped' - make sure we're + # waiting here until we got something playing or on pause. + for entry in message: + if entry.get('state') != 'stopped': + break + else: + continue + signaled_playback_stop = False + # 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/polling.py b/resources/lib/plex_companion/polling.py new file mode 100644 index 00000000..b9c29e38 --- /dev/null +++ b/resources/lib/plex_companion/polling.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from logging import getLogger +import requests + +from .processing import process_proxy_xml +from .common import proxy_headers, proxy_params, log_error + +from .. import utils +from .. import backgroundthread +from .. import app +from .. import variables as v + +# Disable annoying requests warnings +import requests.packages.urllib3 +requests.packages.urllib3.disable_warnings() + +# Timeout (connection timeout, read timeout) +# The later is up to 20 seconds, if the PMS has nothing to tell us +# THIS WILL PREVENT PKC FROM SHUTTING DOWN CORRECTLY +TIMEOUT = (5.0, 3.0) + +log = getLogger('PLEX.companion.listener') + + +class Listener(backgroundthread.KillableThread): + """ + Opens a GET HTTP connection to the current PMS (that will time-out PMS-wise + after ~20 seconds) and listens for any commands by the PMS. Listening + will cause this PKC client to be registered as a Plex Companien client. + """ + daemon = True + + def __init__(self, playstate_mgr): + self.s = None + self.playstate_mgr = playstate_mgr + super().__init__() + + def _get_requests_session(self): + if self.s is None: + log.debug('Creating new requests session') + self.s = requests.Session() + self.s.headers = proxy_headers() + self.s.verify = app.CONN.verify_ssl_cert + if app.CONN.ssl_cert_path: + self.s.cert = app.CONN.ssl_cert_path + self.s.params = proxy_params() + return self.s + + def close_requests_session(self): + try: + self.s.close() + except AttributeError: + # "thread-safety" - Just in case s was set to None in the + # meantime + pass + self.s = None + + def ok_message(self, command_id): + url = f'{app.CONN.server}/player/proxy/response?commandID={command_id}' + try: + req = self.communicate(self.s.post, + url, + data=v.COMPANION_OK_MESSAGE.encode('utf-8')) + except (requests.RequestException, SystemExit): + return + if not req.ok: + log_error(log.error, 'Error replying OK', req) + + @staticmethod + def communicate(method, url, **kwargs): + try: + req = method(url, **kwargs) + except requests.ConnectTimeout: + # The request timed out while trying to connect to the PMS + log.error('Requests ConnectionTimeout!') + raise + except requests.ReadTimeout: + # The PMS did not send any data in the allotted amount of time + log.error('Requests ReadTimeout!') + raise + except requests.TooManyRedirects: + log.error('TooManyRedirects error!') + raise + except requests.HTTPError as error: + log.error('HTTPError: %s', error) + raise + except requests.ConnectionError: + # Caused by PKC terminating the connection prematurely + # log.error('ConnectionError: %s', error) + raise + else: + req.encoding = 'utf-8' + # Access response content once in order to make sure to release the + # underlying sockets + req.content + return req + + def run(self): + """ + Ensure that sockets will be closed no matter what + """ + app.APP.register_thread(self) + log.info("----===## Starting PollCompanion ##===----") + try: + self._run() + finally: + self.close_requests_session() + app.APP.deregister_thread(self) + log.info("----===## PollCompanion stopped ##===----") + + def _run(self): + while not self.should_cancel(): + if self.should_suspend(): + self.close_requests_session() + if self.wait_while_suspended(): + break + # See if there's anything we need to process + # timeout=1 will cause the PMS to "hold" the connection for approx + # 20 seconds. This will BLOCK requests - not something we can + # circumvent. + url = app.CONN.server + '/player/proxy/poll?timeout=1' + self._get_requests_session() + try: + req = self.communicate(self.s.get, + url, + timeout=TIMEOUT) + except requests.ConnectionError: + # No command received from the PMS - try again immediately + continue + except requests.RequestException: + self.sleep(0.5) + continue + except SystemExit: + # We need to quit PKC entirely + break + + # Sanity checks + if not req.ok: + log_error(log.error, 'Error while contacting the PMS', req) + self.sleep(0.5) + continue + if not req.text: + # Means the connection timed-out (usually after 20 seconds), + # because there was no command from the PMS or a client to + # remote-control anything no the PKC-side + # Received an empty body, but still header Content-Type: xml + continue + if not ('content-type' in req.headers + and 'xml' in req.headers['content-type']): + log_error(log.error, 'Unexpected answer from the PMS', req) + self.sleep(0.5) + continue + + # Parsing + try: + xml = utils.etree.fromstring(req.content) + cmd = xml[0] + if len(xml) > 1: + # We should always just get ONE command per message + raise IndexError() + except (utils.ParseError, IndexError): + log_error(log.error, 'Could not parse the PMS xml:', req) + self.sleep(0.5) + continue + + # Do the work + log.debug('Received a Plex Companion command from the PMS:') + utils.log_xml(xml, log.debug) + self.playstate_mgr.check_subscriber(cmd) + if process_proxy_xml(cmd): + self.ok_message(cmd.get('commandID')) diff --git a/resources/lib/plex_companion/processing.py b/resources/lib/plex_companion/processing.py new file mode 100644 index 00000000..b984f7f3 --- /dev/null +++ b/resources/lib/plex_companion/processing.py @@ -0,0 +1,241 @@ +#!/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 playqueue as PQ +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 = PQ.get_playqueue_from_type( + v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[typus]) + 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(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]) + 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(typus, video_stream_id, audio_stream_id, subtitle_stream_id): + """ + Plex Companion client adjusted audio or subtitle stream + """ + playqueue = PQ.get_playqueue_from_type( + v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[typus]) + 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 = 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, 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 = PQ.PLAYQUEUES[player['playerid']] + for i, item in enumerate(playqueue.items): + if item.id == playqueue_item_id: + found = True + break + else: + for i, item in enumerate(playqueue.items): + if item.plex_id == plex_id: + found = True + break + if found is True: + app.APP.player.play(playqueue.kodi_pl, None, False, i) + else: + log.error('Item not found to skip to') + + +def process_proxy_xml(cmd): + """cmd: a "Command" etree xml""" + path = cmd.get('path') + if (path == '/player/playback/playMedia' + and cmd.get('queryAddress') == 'node.plexapp.com'): + process_node(cmd.get('queryKey'), + cmd.get('queryToken'), + cmd.get('queryOffset') or 0) + elif path == '/player/playback/playMedia': + with app.APP.lock_playqueues: + process_playlist(cmd.get('queryContainerKey'), + cmd.get('queryType'), + cmd.get('queryKey'), + cmd.get('queryOffset'), + cmd.get('queryToken')) + elif path == '/player/playback/refreshPlayQueue': + with app.APP.lock_playqueues: + process_refresh(cmd.get('queryPlayQueueID')) + elif path == '/player/playback/setParameters': + if 'queryVolume' in cmd.attrib: + js.set_volume(int(cmd.get('queryVolume'))) + else: + log.error('Unknown command: %s: %s', cmd.tag, cmd.attrib) + elif path == '/player/playback/play': + js.play() + elif path == '/player/playback/pause': + js.pause() + elif path == '/player/playback/stop': + js.stop() + elif path == '/player/playback/seekTo': + js.seek_to(float(cmd.get('queryOffset', 0.0)) / 1000.0) + elif path == '/player/playback/stepForward': + js.smallforward() + elif path == '/player/playback/stepBack': + js.smallbackward() + elif path == '/player/playback/skipNext': + js.skipnext() + elif path == '/player/playback/skipPrevious': + js.skipprevious() + elif path == '/player/playback/skipTo': + skip_to(cmd.get('queryPlayQueueItemID'), cmd.get('queryKey')) + elif path == '/player/navigation/moveUp': + js.input_up() + elif path == '/player/navigation/moveDown': + js.input_down() + elif path == '/player/navigation/moveLeft': + js.input_left() + elif path == '/player/navigation/moveRight': + js.input_right() + elif path == '/player/navigation/select': + js.input_select() + elif path == '/player/navigation/home': + js.input_home() + elif path == '/player/navigation/back': + js.input_back() + elif path == '/player/playback/setStreams': + process_streams(cmd.get('queryType'), + cast(int, cmd.get('queryVideoStreamID')), + cast(int, cmd.get('queryAudioStreamID')), + cast(int, cmd.get('querySubtitleStreamID'))) + elif path == '/player/timeline/subscribe': + pass + elif path == '/player/timeline/unsubscribe': + pass + else: + log.error('Unknown Plex companion path/command: %s: %s', + cmd.tag, cmd.attrib) + return True 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..4cf8ac34 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 @@ -34,7 +35,6 @@ WINDOW_PROPERTIES = ( class Service(object): ws = None sync = None - plexcompanion = None def __init__(self): self._init_done = False @@ -448,7 +448,11 @@ 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.companion_playstate_mgr = plex_companion.PlaystateMgr() + if utils.settings('plexCompanion') == 'true': + self.companion_listener = plex_companion.Listener(self.companion_playstate_mgr) + else: + self.companion_listener = None self.playqueue = playqueue.PlayqueueMonitor() # Main PKC program loop @@ -548,7 +552,9 @@ class Service(object): self.startup_completed = True self.pms_ws.start() self.sync.start() - self.plexcompanion.start() + self.companion_playstate_mgr.start() + if self.companion_listener is not None: + self.companion_listener.start() self.playqueue.start() self.alexa_ws.start() diff --git a/resources/lib/utils.py b/resources/lib/utils.py index 11db93c0..9355c5b8 100644 --- a/resources/lib/utils.py +++ b/resources/lib/utils.py @@ -556,6 +556,15 @@ def reset(ask_user=True): reboot_kodi() +def log_xml(xml, logger): + """ + Logs an etree xml + """ + string = undefused_etree.tostring(xml, encoding='utf-8') + string = string.decode('utf-8') + logger('\n' + string) + + def compare_version(current, minimum): """ 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..ada62b23 100644 --- a/resources/lib/variables.py +++ b/resources/lib/variables.py @@ -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]) From eec4e14513904fdbe15b3e7311897365170c891e Mon Sep 17 00:00:00 2001 From: croneter Date: Sun, 31 Oct 2021 11:04:31 +0100 Subject: [PATCH 04/46] Fix IndexError due to Kodi returning wrong stream indices --- resources/lib/playlist_func.py | 92 +++++++++++++++++++++++++++++----- 1 file changed, 79 insertions(+), 13 deletions(-) diff --git a/resources/lib/playlist_func.py b/resources/lib/playlist_func.py index f5e4a145..515bf91e 100644 --- a/resources/lib/playlist_func.py +++ b/resources/lib/playlist_func.py @@ -281,14 +281,44 @@ class PlaylistItem(object): elif stream_type == 'video': return self.video_streams + @staticmethod + def _current_index(stream_type): + """ + Kodi might tell us the wrong index for any stream after playback start + Get the correct one! + """ + function = { + 'audio': js.get_current_audio_stream_index, + 'video': js.get_current_video_stream_index, + 'subtitle': js.get_current_subtitle_stream_index + }[stream_type] + i = 0 + while i < 30: + # Really annoying: Kodi might return wrong results directly after + # playback startup, e.g. a Kodi audio index of 1953718901 (!) + try: + index = function(v.KODI_VIDEO_PLAYER_ID) + except TypeError: + # No sensible reply yet + pass + else: + if index != 1953718901: + # Correct result! + return index + i += 1 + app.APP.monitor.waitForAbort(0.1) + else: + raise RuntimeError('Kodi did not tell us the correct index for %s' + % stream_type) + def init_kodi_streams(self): """ Initializes all streams after Kodi has started playing this video """ - self.current_kodi_video_stream = js.get_current_video_stream_index(v.KODI_VIDEO_PLAYER_ID) - self.current_kodi_audio_stream = js.get_current_audio_stream_index(v.KODI_VIDEO_PLAYER_ID) + self.current_kodi_video_stream = self._current_index('video') + self.current_kodi_audio_stream = self._current_index('audio') self.current_kodi_sub_stream = False if not js.get_subtitle_enabled(v.KODI_VIDEO_PLAYER_ID) \ - else js.get_current_subtitle_stream_index(v.KODI_VIDEO_PLAYER_ID) + else self._current_index('subtitle') def plex_stream_index(self, kodi_stream_index, stream_type): """ @@ -411,8 +441,10 @@ class PlaylistItem(object): return 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) @@ -441,10 +473,28 @@ class PlaylistItem(object): Call this method if Kodi reports an "AV-Change" (event "Player.OnAVChange") """ - kodi_video_stream = js.get_current_video_stream_index(playerid) - kodi_audio_stream = js.get_current_audio_stream_index(playerid) + i = 0 + while i < 20: + # Really annoying: Kodi might return wrong results directly after + # playback startup, e.g. a Kodi audio index of 1953718901 (!) + kodi_video_stream = js.get_current_video_stream_index(playerid) + kodi_audio_stream = js.get_current_audio_stream_index(playerid) + if kodi_video_stream < len(self.video_streams) and kodi_audio_stream < len(self.audio_streams): + # Correct result! + break + i += 1 + if app.APP.monitor.waitForAbort(0.1): + # Need to quit PKC + return + else: + LOG.error('Could not get sensible Kodi indices! kodi_video_stream ' + '%s, kodi_audio_stream %s', + kodi_video_stream, kodi_audio_stream) + return + kodi_video_stream = self._current_index('video') + kodi_audio_stream = self._current_index('audio') sub_enabled = js.get_subtitle_enabled(playerid) - kodi_sub_stream = js.get_current_subtitle_stream_index(playerid) + kodi_sub_stream = self._current_index('subtitle') # Audio if kodi_audio_stream != self.current_kodi_audio_stream: self.on_kodi_audio_stream_change(kodi_audio_stream) @@ -466,11 +516,21 @@ class PlaylistItem(object): Call this method if Plex Companion wants to change streams [ints] """ if video_stream_id is not None: - kodi_index = self.kodi_stream_index(video_stream_id, 'video') + 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 audio_stream_id is not None: - kodi_index = self.kodi_stream_index(audio_stream_id, 'audio') + 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: @@ -478,9 +538,15 @@ class PlaylistItem(object): app.APP.player.showSubtitles(False) kodi_index = False else: - kodi_index = self.kodi_stream_index(subtitle_stream_id, - 'subtitle') - if kodi_index: + try: + kodi_index = self.kodi_stream_index(subtitle_stream_id, + 'subtitle') + except ValueError: + kodi_index = None + LOG.debug('The PMS wanted to change subs, but we could not' + ' match the sub with id %s to a Kodi sub', + subtitle_stream_id) + else: app.APP.player.setSubtitleStream(kodi_index) app.APP.player.showSubtitles(True) self.current_kodi_sub_stream = kodi_index From edf0cd9a54688d3a0505cfb9e04f6c4f99c85986 Mon Sep 17 00:00:00 2001 From: croneter Date: Fri, 29 Oct 2021 20:01:25 +0200 Subject: [PATCH 05/46] Refactor Plex intro detection --- resources/lib/plex_companion/playstate.py | 7 +++++++ resources/lib/service_entry.py | 4 ---- resources/lib/skip_plex_intro.py | 2 -- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/resources/lib/plex_companion/playstate.py b/resources/lib/plex_companion/playstate.py index 853e58af..1cf70a70 100644 --- a/resources/lib/plex_companion/playstate.py +++ b/resources/lib/plex_companion/playstate.py @@ -12,6 +12,7 @@ from .. import backgroundthread from .. import app from .. import timing from .. import playqueue as PQ +from .. import skip_plex_intro # Disable annoying requests warnings @@ -383,6 +384,12 @@ class PlaystateMgr(backgroundthread.KillableThread): else: continue signaled_playback_stop = False + try: + # Check whether an intro is currently running + skip_plex_intro.check() + except IndexError: + # Playback might have already stopped + pass # Send the playback progress info to the PMS self.pms_timeline(players, message) # Send the info to all Companion devices via the PMS diff --git a/resources/lib/service_entry.py b/resources/lib/service_entry.py index 4cf8ac34..d33a288e 100644 --- a/resources/lib/service_entry.py +++ b/resources/lib/service_entry.py @@ -19,7 +19,6 @@ from . import variables as v from . import app from . import loghandler from . import backgroundthread -from . import skip_plex_intro from .windows import userselect ############################################################################### @@ -558,9 +557,6 @@ class Service(object): self.playqueue.start() self.alexa_ws.start() - elif app.APP.is_playing: - skip_plex_intro.check() - xbmc.sleep(200) # EXITING PKC diff --git a/resources/lib/skip_plex_intro.py b/resources/lib/skip_plex_intro.py index 3ab4cddc..ec6d76ad 100644 --- a/resources/lib/skip_plex_intro.py +++ b/resources/lib/skip_plex_intro.py @@ -32,8 +32,6 @@ def skip_intro(intros): def check(): with app.APP.lock_playqueues: - if len(app.PLAYSTATE.active_players) != 1: - return playerid = list(app.PLAYSTATE.active_players)[0] intros = app.PLAYSTATE.player_states[playerid]['intro_markers'] if not intros: From f2cd4d68eae73282b21f65d9da0872459efb581c Mon Sep 17 00:00:00 2001 From: croneter Date: Sun, 31 Oct 2021 10:44:27 +0100 Subject: [PATCH 06/46] Refactor playqueues --- resources/lib/app/__init__.py | 6 +- resources/lib/app/playqueues.py | 230 ++++++++++++++++++++ resources/lib/companion.py | 8 +- resources/lib/context_entry.py | 10 +- resources/lib/kodimonitor.py | 8 +- resources/lib/playback.py | 20 +- resources/lib/playlist_func.py | 111 ---------- resources/lib/playqueue.py | 232 --------------------- resources/lib/plex_companion/playqueue.py | 144 +++++++++++++ resources/lib/plex_companion/playstate.py | 20 +- resources/lib/plex_companion/processing.py | 23 +- resources/lib/plex_db/playlists.py | 2 +- resources/lib/service_entry.py | 8 +- 13 files changed, 433 insertions(+), 389 deletions(-) create mode 100644 resources/lib/app/playqueues.py delete mode 100644 resources/lib/playqueue.py create mode 100644 resources/lib/plex_companion/playqueue.py 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/companion.py b/resources/lib/companion.py index 077474fd..cb630222 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 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/kodimonitor.py b/resources/lib/kodimonitor.py index 29669750..3830e8d8 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 @@ -140,7 +140,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) @@ -256,7 +256,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 @@ -326,7 +326,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: 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 515bf91e..b472bb7b 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. 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_companion/playqueue.py b/resources/lib/plex_companion/playqueue.py new file mode 100644 index 00000000..064b7065 --- /dev/null +++ b/resources/lib/plex_companion/playqueue.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +from logging import getLogger +import copy + +from ..plex_api import API +from .. import variables as v +from .. import app +from .. import utils +from .. import plex_functions as PF +from .. import playlist_func as PL +from .. import exceptions + +log = getLogger('PLEX.companion.playqueue') + +PLUGIN = 'plugin://%s' % v.ADDON_ID + + +def init_playqueue_from_plex_children(plex_id, transient_token=None): + """ + Init a new playqueue e.g. from an album. Alexa does this + + Returns the playqueue + """ + xml = PF.GetAllPlexChildren(plex_id) + try: + xml[0].attrib + except (TypeError, IndexError, AttributeError): + log.error('Could not download the PMS xml for %s', plex_id) + return + playqueue = app.PLAYQUEUES.from_plex_type(xml[0].attrib['type']) + playqueue.clear() + for i, child in enumerate(xml): + api = API(child) + try: + PL.add_item_to_playlist(playqueue, i, plex_id=api.plex_id) + except exceptions.PlaylistError: + log.error('Could not add Plex item to our playlist: %s, %s', + child.tag, child.attrib) + playqueue.plex_transient_token = transient_token + log.debug('Firing up Kodi player') + app.APP.player.play(playqueue.kodi_pl, None, False, 0) + return playqueue + + +def compare_playqueues(playqueue, new_kodi_playqueue): + """ + Used to poll the Kodi playqueue and update the Plex playqueue if needed + """ + old = list(playqueue.items) + # We might append to new_kodi_playqueue but will need the original + # still back in the main loop + new = copy.deepcopy(new_kodi_playqueue) + index = list(range(0, len(old))) + log.debug('Comparing new Kodi playqueue %s with our play queue %s', + new, old) + for i, new_item in enumerate(new): + if (new_item['file'].startswith('plugin://') and + not new_item['file'].startswith(PLUGIN)): + # Ignore new media added by other addons + continue + for j, old_item in enumerate(old): + + if app.APP.stop_pkc: + # Chances are that we got an empty Kodi playlist due to + # Kodi exit + return + try: + if (old_item.file.startswith('plugin://') and + not old_item.file.startswith(PLUGIN)): + # Ignore media by other addons + continue + except AttributeError: + # were not passed a filename; ignore + pass + if 'id' in new_item: + identical = (old_item.kodi_id == new_item['id'] and + old_item.kodi_type == new_item['type']) + else: + try: + plex_id = int(utils.REGEX_PLEX_ID.findall(new_item['file'])[0]) + except IndexError: + log.debug('Comparing paths directly as a fallback') + identical = old_item.file == new_item['file'] + else: + identical = plex_id == old_item.plex_id + if j == 0 and identical: + del old[j], index[j] + break + elif identical: + log.debug('Playqueue item %s moved to position %s', + i + j, i) + try: + PL.move_playlist_item(playqueue, i + j, i) + except exceptions.PlaylistError: + log.error('Could not modify playqueue positions') + log.error('This is likely caused by mixing audio and ' + 'video tracks in the Kodi playqueue') + del old[j], index[j] + break + else: + log.debug('Detected new Kodi element at position %s: %s ', + i, new_item) + try: + if playqueue.id is None: + PL.init_plex_playqueue(playqueue, kodi_item=new_item) + else: + PL.add_item_to_plex_playqueue(playqueue, + i, + kodi_item=new_item) + except exceptions.PlaylistError: + # Could not add the element + pass + except KeyError: + # Catches KeyError from PL.verify_kodi_item() + # Hack: Kodi already started playback of a new item and we + # started playback already using kodimonitors + # PlayBackStart(), but the Kodi playlist STILL only shows + # the old element. Hence ignore playlist difference here + log.debug('Detected an outdated Kodi playlist - ignoring') + return + except IndexError: + # This is really a hack - happens when using Addon Paths + # and repeatedly starting the same element. Kodi will then + # not pass kodi id nor file path AND will also not + # start-up playback. Hence kodimonitor kicks off playback. + # Also see kodimonitor.py - _playlist_onadd() + pass + else: + for j in range(i, len(index)): + index[j] += 1 + for i in reversed(index): + if app.APP.stop_pkc: + # Chances are that we got an empty Kodi playlist due to + # Kodi exit + return + log.debug('Detected deletion of playqueue element at pos %s', i) + try: + PL.delete_playlist_item_from_PMS(playqueue, i) + except exceptions.PlaylistError: + log.error('Could not delete PMS element from position %s', i) + log.error('This is likely caused by mixing audio and ' + 'video tracks in the Kodi playqueue') + log.debug('Done comparing playqueues') diff --git a/resources/lib/plex_companion/playstate.py b/resources/lib/plex_companion/playstate.py index 1cf70a70..16ba8417 100644 --- a/resources/lib/plex_companion/playstate.py +++ b/resources/lib/plex_companion/playstate.py @@ -5,13 +5,13 @@ import requests import xml.etree.ElementTree as etree from .common import proxy_headers, proxy_params, log_error +from .playqueue import compare_playqueues from .. import json_rpc as js from .. import variables as v from .. import backgroundthread from .. import app from .. import timing -from .. import playqueue as PQ from .. import skip_plex_intro @@ -55,7 +55,7 @@ def get_correct_position(info, playqueue): def timeline_dict(playerid, typus): with app.APP.lock_playqueues: info = app.PLAYSTATE.player_states[playerid] - playqueue = PQ.PLAYQUEUES[playerid] + playqueue = app.PLAYQUEUES[playerid] position = get_correct_position(info, playqueue) try: item = playqueue.items[position] @@ -354,7 +354,21 @@ class PlaystateMgr(backgroundthread.KillableThread): self.close_requests_session() if self.wait_while_suspended(): break - # We will only become active if there's Kodi playback going on + # Check for Kodi playlist changes first + with app.APP.lock_playqueues: + for playqueue in app.PLAYQUEUES: + kodi_pl = js.playlist_get_items(playqueue.playlistid) + if playqueue.old_kodi_pl != kodi_pl: + if playqueue.id is None and (not app.SYNC.direct_paths or + app.PLAYSTATE.context_menu_play): + # Only initialize if directly fired up using direct + # paths. Otherwise let default.py do its magic + log.debug('Not yet initiating playback') + else: + # compare old and new playqueue + compare_playqueues(playqueue, kodi_pl) + playqueue.old_kodi_pl = list(kodi_pl) + # Then check for Kodi playback players = js.get_players() if not players and signaled_playback_stop: self.sleep(1) diff --git a/resources/lib/plex_companion/processing.py b/resources/lib/plex_companion/processing.py index b984f7f3..0f3de1f1 100644 --- a/resources/lib/plex_companion/processing.py +++ b/resources/lib/plex_companion/processing.py @@ -14,7 +14,6 @@ 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 app from .. import exceptions @@ -87,9 +86,8 @@ def process_playlist(containerKey, typus, key, offset, token): # Get the playqueue ID _, container_key, query = PF.ParseContainerKey(containerKey) try: - playqueue = PQ.get_playqueue_from_type( - v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[typus]) - except KeyError: + 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) @@ -99,8 +97,7 @@ def process_playlist(containerKey, typus, key, offset, token): 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]) + playqueue = app.PLAYQUEUES.from_plex_type(api.plex_type) if key: _, key, _ = PF.ParseContainerKey(key) update_playqueue_from_PMS(playqueue, @@ -111,12 +108,12 @@ def process_playlist(containerKey, typus, key, offset, token): start_plex_id=key) -def process_streams(typus, video_stream_id, audio_stream_id, subtitle_stream_id): +def process_streams(plex_type, video_stream_id, audio_stream_id, + subtitle_stream_id): """ Plex Companion client adjusted audio or subtitle stream """ - playqueue = PQ.get_playqueue_from_type( - v.KODI_PLAYLIST_TYPE_FROM_PLEX_TYPE[typus]) + 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, @@ -135,12 +132,10 @@ def process_refresh(playqueue_id): 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 = app.PLAYQUEUES.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']]) + playqueue = app.PLAYQUEUES.from_plex_type(xml[0].attrib['type']) update_playqueue_from_PMS(playqueue, playqueue_id) @@ -155,7 +150,7 @@ def skip_to(playqueue_item_id, key): 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 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/service_entry.py b/resources/lib/service_entry.py index d33a288e..033613e5 100644 --- a/resources/lib/service_entry.py +++ b/resources/lib/service_entry.py @@ -12,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 @@ -99,7 +98,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 @@ -436,8 +434,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() @@ -452,7 +448,6 @@ class Service(object): self.companion_listener = plex_companion.Listener(self.companion_playstate_mgr) else: self.companion_listener = None - self.playqueue = playqueue.PlayqueueMonitor() # Main PKC program loop while not self.should_cancel(): @@ -554,7 +549,6 @@ class Service(object): self.companion_playstate_mgr.start() if self.companion_listener is not None: self.companion_listener.start() - self.playqueue.start() self.alexa_ws.start() xbmc.sleep(200) From dfa1b4beaad4c67c238dbf63aa61569e9a607f3a Mon Sep 17 00:00:00 2001 From: croneter Date: Tue, 2 Nov 2021 19:48:08 +0100 Subject: [PATCH 07/46] Fix subtitle streams missmatch between Kodi and Plex --- resources/lib/kodimonitor.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/resources/lib/kodimonitor.py b/resources/lib/kodimonitor.py index 3830e8d8..0d3bbe5f 100644 --- a/resources/lib/kodimonitor.py +++ b/resources/lib/kodimonitor.py @@ -367,6 +367,11 @@ class KodiMonitor(xbmc.Monitor): # We need to switch to the Plex streams ONCE upon playback start if playerid == v.KODI_VIDEO_PLAYER_ID: + # The Kodi player takes forever to initialize all streams + # Especially subtitles, apparently. No way to tell when Kodi + # is done :-( + if app.APP.monitor.waitForAbort(5): + return item.init_kodi_streams() item.switch_to_plex_stream('video') if utils.settings('audioStreamPick') == '0': From 71f4fa0f765ae60983229f8a4f123633906e8326 Mon Sep 17 00:00:00 2001 From: croneter Date: Wed, 3 Nov 2021 07:01:17 +0100 Subject: [PATCH 08/46] Reduce the number of instantiations for Addon() --- resources/lib/websocket_client.py | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/resources/lib/websocket_client.py b/resources/lib/websocket_client.py index 56901c15..0ac1e5e6 100644 --- a/resources/lib/websocket_client.py +++ b/resources/lib/websocket_client.py @@ -198,14 +198,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 +236,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 +257,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 +269,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 From 4baee9afc169ea58e04169426843b5859486a8ee Mon Sep 17 00:00:00 2001 From: croneter Date: Wed, 3 Nov 2021 07:03:50 +0100 Subject: [PATCH 09/46] Explicitly instantiate Addon() for PKC --- resources/lib/utils.py | 2 +- resources/lib/variables.py | 2 +- resources/lib/windows/kodigui.py | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/resources/lib/utils.py b/resources/lib/utils.py index 11db93c0..4a88e768 100644 --- a/resources/lib/utils.py +++ b/resources/lib/utils.py @@ -114,7 +114,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) diff --git a/resources/lib/variables.py b/resources/lib/variables.py index 8174c5ea..4da35055 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') 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 From b6d09eb59b77c688422a721f2154b49177ed1234 Mon Sep 17 00:00:00 2001 From: croneter Date: Wed, 3 Nov 2021 07:43:44 +0100 Subject: [PATCH 10/46] Fix generating new PKC unique device ID not working --- default.py | 20 ++------------------ resources/lib/clientinfo.py | 27 ++++++++++++++------------- resources/lib/service_entry.py | 3 +++ 3 files changed, 19 insertions(+), 31 deletions(-) diff --git a/default.py b/default.py index 8eab9599..e2d8c187 100644 --- a/default.py +++ b/default.py @@ -117,7 +117,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') @@ -173,23 +174,6 @@ class Main(object): # 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') - if __name__ == '__main__': LOG.info('%s started' % v.ADDON_ID) diff --git a/resources/lib/clientinfo.py b/resources/lib/clientinfo.py index 92b7ebf5..5c5e51d8 100644 --- a/resources/lib/clientinfo.py +++ b/resources/lib/clientinfo.py @@ -59,10 +59,20 @@ 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: + 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 client_id = v.PKC_MACHINE_IDENTIFIER if client_id: @@ -75,12 +85,3 @@ def getDeviceId(reset=False): 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 diff --git a/resources/lib/service_entry.py b/resources/lib/service_entry.py index ce3e9678..4c9ec757 100644 --- a/resources/lib/service_entry.py +++ b/resources/lib/service_entry.py @@ -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: From 595474d1be5d06f96f1579ae28b45b3a4425456a Mon Sep 17 00:00:00 2001 From: croneter Date: Sat, 30 Oct 2021 18:51:14 +0200 Subject: [PATCH 11/46] Beta version bump 2.5.9 --- addon.xml | 11 +++++++++-- changelog.txt | 7 +++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/addon.xml b/addon.xml index 02ae8290..3c64cfde 100644 --- a/addon.xml +++ b/addon.xml @@ -1,5 +1,5 @@ - + @@ -91,7 +91,14 @@ Plex를 Kodi에 기본 통합 Kodi를 Plex Media Server에 연결합니다. 이 플러그인은 Plex로 모든 비디오를 관리하고 Kodi로는 관리하지 않는다고 가정합니다. Kodi 비디오 및 음악 데이터베이스에 이미 저장된 데이터가 손실 될 수 있습니다 (이 플러그인이 직접 변경하므로). 자신의 책임하에 사용하십시오! 자신의 책임하에 사용 - version 3.5.8: + 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..921ffbfa 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,10 @@ +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 From 581b3d25367a6b4b245a817cd55556fdd73ada96 Mon Sep 17 00:00:00 2001 From: croneter Date: Fri, 5 Nov 2021 07:17:19 +0100 Subject: [PATCH 12/46] Don't track the old playstate anymore --- resources/lib/app/playstate.py | 6 ------ resources/lib/kodimonitor.py | 3 --- 2 files changed, 9 deletions(-) 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/kodimonitor.py b/resources/lib/kodimonitor.py index 0d3bbe5f..89296c33 100644 --- a/resources/lib/kodimonitor.py +++ b/resources/lib/kodimonitor.py @@ -34,7 +34,6 @@ class KodiMonitor(xbmc.Monitor): 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): @@ -418,8 +417,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') From 1d82fbc0fb430e38dea90fea6033f560df6c8aa2 Mon Sep 17 00:00:00 2001 From: croneter Date: Fri, 5 Nov 2021 17:38:37 +0100 Subject: [PATCH 13/46] Tell the PMS and Plex Companion about stream changes --- resources/lib/kodimonitor.py | 44 +++--- resources/lib/playlist_func.py | 179 +++++++++++----------- resources/lib/plex_companion/playstate.py | 10 ++ 3 files changed, 122 insertions(+), 111 deletions(-) diff --git a/resources/lib/kodimonitor.py b/resources/lib/kodimonitor.py index 89296c33..c7107453 100644 --- a/resources/lib/kodimonitor.py +++ b/resources/lib/kodimonitor.py @@ -343,6 +343,7 @@ class KodiMonitor(xbmc.Monitor): # Mechanik for Plex skip intro feature if utils.settings('enableSkipIntro') == 'true': status['intro_markers'] = item.api.intro_markers() + item.playerid = playerid # Remember the currently playing item app.PLAYSTATE.item = item # Remember that this player has been active @@ -364,19 +365,9 @@ class KodiMonitor(xbmc.Monitor): if not app.SYNC.direct_paths: _notify_upnext(item) - # We need to switch to the Plex streams ONCE upon playback start if playerid == v.KODI_VIDEO_PLAYER_ID: - # The Kodi player takes forever to initialize all streams - # Especially subtitles, apparently. No way to tell when Kodi - # is done :-( - if app.APP.monitor.waitForAbort(5): - return - item.init_kodi_streams() - item.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') + task = InitVideoStreams(item) + backgroundthread.BGThreader.addTask(task) def _on_av_change(self, data): """ @@ -386,19 +377,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 - item.on_av_change(playerid) + pass def _playback_cleanup(ended=False): @@ -682,3 +662,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/playlist_func.py b/resources/lib/playlist_func.py index b472bb7b..41a92ff5 100644 --- a/resources/lib/playlist_func.py +++ b/resources/lib/playlist_func.py @@ -55,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 @@ -72,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): @@ -110,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') @@ -170,8 +214,7 @@ class PlaylistItem(object): elif stream_type == 'video': return self.video_streams - @staticmethod - def _current_index(stream_type): + def _current_index(self, stream_type): """ Kodi might tell us the wrong index for any stream after playback start Get the correct one! @@ -186,7 +229,7 @@ class PlaylistItem(object): # Really annoying: Kodi might return wrong results directly after # playback startup, e.g. a Kodi audio index of 1953718901 (!) try: - index = function(v.KODI_VIDEO_PLAYER_ID) + index = function(self.playerid) except TypeError: # No sensible reply yet pass @@ -200,31 +243,44 @@ class PlaylistItem(object): raise RuntimeError('Kodi did not tell us the correct index for %s' % stream_type) - def init_kodi_streams(self): + def init_streams(self): """ Initializes all streams after Kodi has started playing this video """ - self.current_kodi_video_stream = self._current_index('video') - self.current_kodi_audio_stream = self._current_index('audio') - self.current_kodi_sub_stream = False if not js.get_subtitle_enabled(v.KODI_VIDEO_PLAYER_ID) \ - else self._current_index('subtitle') + 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): """ @@ -263,16 +319,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): @@ -284,7 +338,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): """ @@ -295,24 +348,17 @@ 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): 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) @@ -326,7 +372,7 @@ class PlaylistItem(object): 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 LOG.debug('The PMS wants to display %s stream with Plex id %s and ' 'languageTag %s', typus, plex_index, language_tag) @@ -351,53 +397,12 @@ class PlaylistItem(object): elif typus == 'video': self._set_kodi_stream_if_different(kodi_index, 'video') 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 - - def on_av_change(self, playerid): - """ - Call this method if Kodi reports an "AV-Change" - (event "Player.OnAVChange") - """ - i = 0 - while i < 20: - # Really annoying: Kodi might return wrong results directly after - # playback startup, e.g. a Kodi audio index of 1953718901 (!) - kodi_video_stream = js.get_current_video_stream_index(playerid) - kodi_audio_stream = js.get_current_audio_stream_index(playerid) - if kodi_video_stream < len(self.video_streams) and kodi_audio_stream < len(self.audio_streams): - # Correct result! - break - i += 1 - if app.APP.monitor.waitForAbort(0.1): - # Need to quit PKC - return - else: - LOG.error('Could not get sensible Kodi indices! kodi_video_stream ' - '%s, kodi_audio_stream %s', - kodi_video_stream, kodi_audio_stream) - return - kodi_video_stream = self._current_index('video') - kodi_audio_stream = self._current_index('audio') - sub_enabled = js.get_subtitle_enabled(playerid) - kodi_sub_stream = self._current_index('subtitle') - # 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) + self._current_kodi_video_stream = kodi_index def on_plex_stream_change(self, video_stream_id=None, audio_stream_id=None, subtitle_stream_id=None): @@ -412,7 +417,7 @@ class PlaylistItem(object): 'the video stream!', video_stream_id) return self._set_kodi_stream_if_different(kodi_index, 'video') - self.current_kodi_video_stream = kodi_index + 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') @@ -421,24 +426,24 @@ class PlaylistItem(object): 'the video stream!', audio_stream_id) return self._set_kodi_stream_if_different(kodi_index, 'audio') - self.current_kodi_audio_stream = kodi_index + self._current_kodi_audio_stream = kodi_index if subtitle_stream_id is not None: if subtitle_stream_id == 0: app.APP.player.showSubtitles(False) - kodi_index = False + self._current_kodi_sub_stream_enabled = False else: try: kodi_index = self.kodi_stream_index(subtitle_stream_id, 'subtitle') except ValueError: - kodi_index = None LOG.debug('The PMS wanted to change subs, but we could not' ' match the sub with id %s to a Kodi sub', subtitle_stream_id) else: app.APP.player.setSubtitleStream(kodi_index) app.APP.player.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): diff --git a/resources/lib/plex_companion/playstate.py b/resources/lib/plex_companion/playstate.py index 16ba8417..b1872bff 100644 --- a/resources/lib/plex_companion/playstate.py +++ b/resources/lib/plex_companion/playstate.py @@ -66,6 +66,9 @@ def timeline_dict(playerid, 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() protocol, url, port = split_server_uri(app.CONN.server) status = 'paused' if int(info['speed']) == 0 else 'playing' duration = timing.kodi_time_to_millis(info['totaltime']) @@ -115,6 +118,13 @@ def timeline_dict(playerid, typus): 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 From f2e2be6da7a21d7e89c0bad1d9922c1e2f4e2aaa Mon Sep 17 00:00:00 2001 From: croneter Date: Fri, 5 Nov 2021 17:58:16 +0100 Subject: [PATCH 14/46] Adjust some Companion headers --- resources/lib/plex_companion/common.py | 2 +- resources/lib/variables.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/resources/lib/plex_companion/common.py b/resources/lib/plex_companion/common.py index 14f6e4c1..a957a785 100644 --- a/resources/lib/plex_companion/common.py +++ b/resources/lib/plex_companion/common.py @@ -25,7 +25,7 @@ def proxy_headers(): def proxy_params(): params = { 'deviceClass': 'pc', - 'protocolCapabilities': 'timeline,playback,navigation,playqueues', + 'protocolCapabilities': 'timeline,playback,navigation,mirror,playqueues', 'protocolVersion': 3 } if app.ACCOUNT.pms_token: diff --git a/resources/lib/variables.py b/resources/lib/variables.py index e457a5bd..5219af5a 100644 --- a/resources/lib/variables.py +++ b/resources/lib/variables.py @@ -445,9 +445,9 @@ CONTENT_FROM_PLEX_TYPE = { # Plex profile for transcoding and direct streaming # Uses the empty Generic.xml at Plex Media Server/Resources/Profiles for any # Playback decisions -PLATFORM = 'Generic' +PLATFORM = 'Kodi' # Version seems to be irrelevant for the generic platform -PLATFORM_VERSION = '1.0.0' +PLATFORM_VERSION = KODILONGVERSION # Overrides (replace=true) any existing entries in generic.xml STREAMING_HEADERS = { 'X-Plex-Client-Profile-Extra': From 62218b03222c0eee84bc13e06eaaad09f2248e84 Mon Sep 17 00:00:00 2001 From: croneter Date: Fri, 5 Nov 2021 19:02:59 +0100 Subject: [PATCH 15/46] Beta version bump 3.5.10 --- addon.xml | 7 +++++-- changelog.txt | 3 +++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/addon.xml b/addon.xml index 3c64cfde..83f02ba9 100644 --- a/addon.xml +++ b/addon.xml @@ -1,5 +1,5 @@ - + @@ -91,7 +91,10 @@ Plex를 Kodi에 기본 통합 Kodi를 Plex Media Server에 연결합니다. 이 플러그인은 Plex로 모든 비디오를 관리하고 Kodi로는 관리하지 않는다고 가정합니다. Kodi 비디오 및 음악 데이터베이스에 이미 저장된 데이터가 손실 될 수 있습니다 (이 플러그인이 직접 변경하므로). 자신의 책임하에 사용하십시오! 자신의 책임하에 사용 - version 3.5.9 (beta only): + 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 diff --git a/changelog.txt b/changelog.txt index 921ffbfa..771e1663 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,6 @@ +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 From 5714d4fb0a7424e0dc6af66ece1951fd862127fd Mon Sep 17 00:00:00 2001 From: croneter Date: Fri, 5 Nov 2021 19:12:41 +0100 Subject: [PATCH 16/46] Fix stream init if quickly changing to the next video --- resources/lib/playlist_func.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/resources/lib/playlist_func.py b/resources/lib/playlist_func.py index 41a92ff5..e23e81ab 100644 --- a/resources/lib/playlist_func.py +++ b/resources/lib/playlist_func.py @@ -247,6 +247,9 @@ class PlaylistItem(object): """ Initializes all streams after Kodi has started playing this video """ + if not app.PLAYSTATE.item == self: + # Already stopped playback or skipped to the next one + return self.init_kodi_streams() self.switch_to_plex_stream('video') if utils.settings('audioStreamPick') == '0': From 020f050068bd6d974a063f4512da81514a77cec4 Mon Sep 17 00:00:00 2001 From: croneter Date: Sun, 7 Nov 2021 11:48:58 +0100 Subject: [PATCH 17/46] Revert "Adjust some Companion headers" This reverts commit f2e2be6da7a21d7e89c0bad1d9922c1e2f4e2aaa. --- resources/lib/plex_companion/common.py | 2 +- resources/lib/variables.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/resources/lib/plex_companion/common.py b/resources/lib/plex_companion/common.py index a957a785..14f6e4c1 100644 --- a/resources/lib/plex_companion/common.py +++ b/resources/lib/plex_companion/common.py @@ -25,7 +25,7 @@ def proxy_headers(): def proxy_params(): params = { 'deviceClass': 'pc', - 'protocolCapabilities': 'timeline,playback,navigation,mirror,playqueues', + 'protocolCapabilities': 'timeline,playback,navigation,playqueues', 'protocolVersion': 3 } if app.ACCOUNT.pms_token: diff --git a/resources/lib/variables.py b/resources/lib/variables.py index 5219af5a..e457a5bd 100644 --- a/resources/lib/variables.py +++ b/resources/lib/variables.py @@ -445,9 +445,9 @@ CONTENT_FROM_PLEX_TYPE = { # Plex profile for transcoding and direct streaming # Uses the empty Generic.xml at Plex Media Server/Resources/Profiles for any # Playback decisions -PLATFORM = 'Kodi' +PLATFORM = 'Generic' # Version seems to be irrelevant for the generic platform -PLATFORM_VERSION = KODILONGVERSION +PLATFORM_VERSION = '1.0.0' # Overrides (replace=true) any existing entries in generic.xml STREAMING_HEADERS = { 'X-Plex-Client-Profile-Extra': From 9708f8194c1c649449d4e701033a190a6bac9d34 Mon Sep 17 00:00:00 2001 From: croneter Date: Sun, 7 Nov 2021 11:52:50 +0100 Subject: [PATCH 18/46] Beta version bump 3.5.11 --- addon.xml | 7 +++++-- changelog.txt | 3 +++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/addon.xml b/addon.xml index 83f02ba9..be6e61c7 100644 --- a/addon.xml +++ b/addon.xml @@ -1,5 +1,5 @@ - + @@ -91,7 +91,10 @@ Plex를 Kodi에 기본 통합 Kodi를 Plex Media Server에 연결합니다. 이 플러그인은 Plex로 모든 비디오를 관리하고 Kodi로는 관리하지 않는다고 가정합니다. Kodi 비디오 및 음악 데이터베이스에 이미 저장된 데이터가 손실 될 수 있습니다 (이 플러그인이 직접 변경하므로). 자신의 책임하에 사용하십시오! 자신의 책임하에 사용 - version 3.5.10 (beta only): + 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): diff --git a/changelog.txt b/changelog.txt index 771e1663..04b07cc7 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,6 @@ +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 From d7f3a797eac21f3d326d70e1396f8d47cc82d7db Mon Sep 17 00:00:00 2001 From: croneter Date: Sat, 13 Nov 2021 13:25:36 +0100 Subject: [PATCH 19/46] Fix playback report not working due to an IndexError --- resources/lib/playlist_func.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/lib/playlist_func.py b/resources/lib/playlist_func.py index e23e81ab..2f967ad2 100644 --- a/resources/lib/playlist_func.py +++ b/resources/lib/playlist_func.py @@ -230,7 +230,7 @@ class PlaylistItem(object): # playback startup, e.g. a Kodi audio index of 1953718901 (!) try: index = function(self.playerid) - except TypeError: + except (TypeError, IndexError): # No sensible reply yet pass else: From 5ac785ce6a61e72d1a9829ab75c2bc3ceaec9120 Mon Sep 17 00:00:00 2001 From: croneter Date: Sat, 13 Nov 2021 13:49:41 +0100 Subject: [PATCH 20/46] Fix rare IndexError when trying to delete a playlist item --- resources/lib/playlist_func.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/resources/lib/playlist_func.py b/resources/lib/playlist_func.py index e23e81ab..6feb0df3 100644 --- a/resources/lib/playlist_func.py +++ b/resources/lib/playlist_func.py @@ -876,14 +876,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 ########## From 96fde4db32fe8e8dd31d6f4aa0769a4532b68439 Mon Sep 17 00:00:00 2001 From: croneter Date: Sat, 13 Nov 2021 14:05:51 +0100 Subject: [PATCH 21/46] Fix skip intro not working --- resources/lib/plex_companion/playstate.py | 7 ------- resources/lib/service_entry.py | 4 ++++ resources/lib/skip_plex_intro.py | 4 ++++ 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/resources/lib/plex_companion/playstate.py b/resources/lib/plex_companion/playstate.py index b1872bff..79e16454 100644 --- a/resources/lib/plex_companion/playstate.py +++ b/resources/lib/plex_companion/playstate.py @@ -12,7 +12,6 @@ from .. import variables as v from .. import backgroundthread from .. import app from .. import timing -from .. import skip_plex_intro # Disable annoying requests warnings @@ -408,12 +407,6 @@ class PlaystateMgr(backgroundthread.KillableThread): else: continue signaled_playback_stop = False - try: - # Check whether an intro is currently running - skip_plex_intro.check() - except IndexError: - # Playback might have already stopped - pass # Send the playback progress info to the PMS self.pms_timeline(players, message) # Send the info to all Companion devices via the PMS diff --git a/resources/lib/service_entry.py b/resources/lib/service_entry.py index a966576c..c37b26b8 100644 --- a/resources/lib/service_entry.py +++ b/resources/lib/service_entry.py @@ -18,6 +18,7 @@ from . import variables as v from . import app from . import loghandler from . import backgroundthread +from . import skip_plex_intro from .windows import userselect ############################################################################### @@ -554,6 +555,9 @@ class Service(object): self.companion_listener.start() self.alexa_ws.start() + elif app.APP.is_playing: + skip_plex_intro.check() + xbmc.sleep(200) # EXITING PKC diff --git a/resources/lib/skip_plex_intro.py b/resources/lib/skip_plex_intro.py index ec6d76ad..f7b22867 100644 --- a/resources/lib/skip_plex_intro.py +++ b/resources/lib/skip_plex_intro.py @@ -15,6 +15,8 @@ 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', @@ -32,6 +34,8 @@ def skip_intro(intros): def check(): with app.APP.lock_playqueues: + if len(app.PLAYSTATE.active_players) != 1: + return playerid = list(app.PLAYSTATE.active_players)[0] intros = app.PLAYSTATE.player_states[playerid]['intro_markers'] if not intros: From 1a9defdeea9a3e45d51a3c8c578cef0fda7703c5 Mon Sep 17 00:00:00 2001 From: croneter Date: Sat, 13 Nov 2021 14:08:00 +0100 Subject: [PATCH 22/46] Beta version bump 3.5.12 --- addon.xml | 9 +++++++-- changelog.txt | 5 +++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/addon.xml b/addon.xml index be6e61c7..ad41f71e 100644 --- a/addon.xml +++ b/addon.xml @@ -1,5 +1,5 @@ - + @@ -91,7 +91,12 @@ Plex를 Kodi에 기본 통합 Kodi를 Plex Media Server에 연결합니다. 이 플러그인은 Plex로 모든 비디오를 관리하고 Kodi로는 관리하지 않는다고 가정합니다. Kodi 비디오 및 음악 데이터베이스에 이미 저장된 데이터가 손실 될 수 있습니다 (이 플러그인이 직접 변경하므로). 자신의 책임하에 사용하십시오! 자신의 책임하에 사용 - version 3.5.11 (beta only): + 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): diff --git a/changelog.txt b/changelog.txt index 04b07cc7..4c117b0f 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,8 @@ +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' From 5042d94f7b71d42b0cf5a488c13e89fcbb185f5f Mon Sep 17 00:00:00 2001 From: croneter Date: Tue, 16 Nov 2021 18:46:21 +0100 Subject: [PATCH 23/46] Beta version bump 3.5.13 --- addon.xml | 7 +++++-- changelog.txt | 3 +++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/addon.xml b/addon.xml index ad41f71e..53702a7b 100644 --- a/addon.xml +++ b/addon.xml @@ -1,5 +1,5 @@ - + @@ -91,7 +91,10 @@ Plex를 Kodi에 기본 통합 Kodi를 Plex Media Server에 연결합니다. 이 플러그인은 Plex로 모든 비디오를 관리하고 Kodi로는 관리하지 않는다고 가정합니다. Kodi 비디오 및 음악 데이터베이스에 이미 저장된 데이터가 손실 될 수 있습니다 (이 플러그인이 직접 변경하므로). 자신의 책임하에 사용하십시오! 자신의 책임하에 사용 - version 3.5.12 (beta only): + 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 diff --git a/changelog.txt b/changelog.txt index 4c117b0f..253d41e1 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,6 @@ +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 From 58a2a970b501c4e6ffde97d0bcfb455095c3dd17 Mon Sep 17 00:00:00 2001 From: croneter Date: Tue, 16 Nov 2021 17:50:32 +0100 Subject: [PATCH 24/46] Fix Kodi getting blocked by e.g. cloudflare --- resources/lib/plex_companion/polling.py | 46 +++++++++++++++++++------ 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/resources/lib/plex_companion/polling.py b/resources/lib/plex_companion/polling.py index b9c29e38..f254bad3 100644 --- a/resources/lib/plex_companion/polling.py +++ b/resources/lib/plex_companion/polling.py @@ -20,6 +20,10 @@ requests.packages.urllib3.disable_warnings() # THIS WILL PREVENT PKC FROM SHUTTING DOWN CORRECTLY TIMEOUT = (5.0, 3.0) +# Max. timeout for the Listener: 2 ^ MAX_TIMEOUT +# Corresponds to 2 ^ 7 = 128 seconds +MAX_TIMEOUT = 7 + log = getLogger('PLEX.companion.listener') @@ -34,6 +38,7 @@ class Listener(backgroundthread.KillableThread): def __init__(self, playstate_mgr): self.s = None self.playstate_mgr = playstate_mgr + self._sleep_timer = 0 super().__init__() def _get_requests_session(self): @@ -56,6 +61,19 @@ class Listener(backgroundthread.KillableThread): 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): + if req: + log_error(log.error, 'Error while contacting the PMS', req) + 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: @@ -129,28 +147,33 @@ class Listener(backgroundthread.KillableThread): # No command received from the PMS - try again immediately continue except requests.RequestException: - self.sleep(0.5) + self._on_connection_error() continue except SystemExit: # We need to quit PKC entirely break # Sanity checks - if not req.ok: - log_error(log.error, 'Error while contacting the PMS', req) - self.sleep(0.5) + 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) + continue + elif not ('content-type' in req.headers + and 'xml' in req.headers['content-type']): + self._on_connection_error(req) + continue + if not req.text: # Means the connection timed-out (usually after 20 seconds), # because there was no command from the PMS or a client to # remote-control anything no the PKC-side # Received an empty body, but still header Content-Type: xml continue - if not ('content-type' in req.headers - and 'xml' in req.headers['content-type']): - log_error(log.error, 'Unexpected answer from the PMS', req) - self.sleep(0.5) - continue # Parsing try: @@ -160,8 +183,8 @@ class Listener(backgroundthread.KillableThread): # We should always just get ONE command per message raise IndexError() except (utils.ParseError, IndexError): - log_error(log.error, 'Could not parse the PMS xml:', req) - self.sleep(0.5) + log.error('Could not parse the PMS xml:') + self._on_connection_error() continue # Do the work @@ -170,3 +193,4 @@ class Listener(backgroundthread.KillableThread): self.playstate_mgr.check_subscriber(cmd) if process_proxy_xml(cmd): self.ok_message(cmd.get('commandID')) + self._sleep_timer = 0 From 51699bfdbbe2627759fd3bda11f132f3393dbae1 Mon Sep 17 00:00:00 2001 From: croneter Date: Tue, 16 Nov 2021 17:56:59 +0100 Subject: [PATCH 25/46] Plex Companion: Increase read timeout from 3 to 4 seconds to fix ReadTimeout --- resources/lib/plex_companion/polling.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/lib/plex_companion/polling.py b/resources/lib/plex_companion/polling.py index f254bad3..8b875603 100644 --- a/resources/lib/plex_companion/polling.py +++ b/resources/lib/plex_companion/polling.py @@ -18,7 +18,7 @@ requests.packages.urllib3.disable_warnings() # Timeout (connection timeout, read timeout) # The later is up to 20 seconds, if the PMS has nothing to tell us # THIS WILL PREVENT PKC FROM SHUTTING DOWN CORRECTLY -TIMEOUT = (5.0, 3.0) +TIMEOUT = (5.0, 4.0) # Max. timeout for the Listener: 2 ^ MAX_TIMEOUT # Corresponds to 2 ^ 7 = 128 seconds From c6aa4d3021ef132f5f41aa8a74e63188e1d8d50e Mon Sep 17 00:00:00 2001 From: croneter Date: Thu, 18 Nov 2021 07:14:21 +0100 Subject: [PATCH 26/46] Fix PKC not being able to connect to plex.tv after installation --- resources/lib/clientinfo.py | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/resources/lib/clientinfo.py b/resources/lib/clientinfo.py index 5c5e51d8..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 @@ -60,28 +76,17 @@ def getDeviceId(reset=False): If id does not exist, create one and save in Kodi settings file. """ if reset: - 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 + 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 + else: + return generate_device_id() From ec8de5caa6052e9355a1e51b0a93814075788cd9 Mon Sep 17 00:00:00 2001 From: croneter Date: Thu, 18 Nov 2021 07:15:45 +0100 Subject: [PATCH 27/46] Beta version bump 3.5.14 --- addon.xml | 7 +++++-- changelog.txt | 3 +++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/addon.xml b/addon.xml index 53702a7b..21b39258 100644 --- a/addon.xml +++ b/addon.xml @@ -1,5 +1,5 @@ - + @@ -91,7 +91,10 @@ Plex를 Kodi에 기본 통합 Kodi를 Plex Media Server에 연결합니다. 이 플러그인은 Plex로 모든 비디오를 관리하고 Kodi로는 관리하지 않는다고 가정합니다. Kodi 비디오 및 음악 데이터베이스에 이미 저장된 데이터가 손실 될 수 있습니다 (이 플러그인이 직접 변경하므로). 자신의 책임하에 사용하십시오! 자신의 책임하에 사용 - version 3.5.13 (beta only): + 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): diff --git a/changelog.txt b/changelog.txt index 253d41e1..5892b5c2 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,6 @@ +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 From 67e46e706ca3809acf11048a1e8a9a489fdbb591 Mon Sep 17 00:00:00 2001 From: croneter Date: Fri, 19 Nov 2021 08:47:32 +0100 Subject: [PATCH 28/46] Fix KeyError on playback startup --- resources/lib/playlist_func.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/lib/playlist_func.py b/resources/lib/playlist_func.py index 83abf97e..ab4ac20b 100644 --- a/resources/lib/playlist_func.py +++ b/resources/lib/playlist_func.py @@ -230,7 +230,7 @@ class PlaylistItem(object): # playback startup, e.g. a Kodi audio index of 1953718901 (!) try: index = function(self.playerid) - except (TypeError, IndexError): + except (TypeError, IndexError, KeyError): # No sensible reply yet pass else: From af793c335eb37c8ff3e2937aed2f9e1b93957734 Mon Sep 17 00:00:00 2001 From: croneter Date: Sun, 21 Nov 2021 14:40:56 +0100 Subject: [PATCH 29/46] Re-add old Plex Companion mechanism as the new one sucks --- resources/lib/companion.py | 3 + resources/lib/plex_companion/__init__.py | 2 +- resources/lib/plex_companion/common.py | 284 ++++++++++++++++ resources/lib/plex_companion/playstate.py | 373 +++++++-------------- resources/lib/plex_companion/plexgdm.py | 210 ++++++++++++ resources/lib/plex_companion/polling.py | 97 ++---- resources/lib/plex_companion/processing.py | 58 ++-- resources/lib/plex_companion/webserver.py | 201 +++++++++++ resources/lib/service_entry.py | 11 +- resources/lib/utils.py | 13 +- resources/lib/variables.py | 4 - resources/lib/websocket_client.py | 1 - 12 files changed, 898 insertions(+), 359 deletions(-) create mode 100644 resources/lib/plex_companion/plexgdm.py create mode 100644 resources/lib/plex_companion/webserver.py diff --git a/resources/lib/companion.py b/resources/lib/companion.py index cb630222..5dfad3e0 100644 --- a/resources/lib/companion.py +++ b/resources/lib/companion.py @@ -80,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": @@ -119,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/plex_companion/__init__.py b/resources/lib/plex_companion/__init__.py index ba2478a9..f167e7d1 100644 --- a/resources/lib/plex_companion/__init__.py +++ b/resources/lib/plex_companion/__init__.py @@ -1,5 +1,5 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -from .polling import Listener +from .polling import Polling from .playstate import PlaystateMgr diff --git a/resources/lib/plex_companion/common.py b/resources/lib/plex_companion/common.py index 14f6e4c1..1cbe03ac 100644 --- a/resources/lib/plex_companion/common.py +++ b/resources/lib/plex_companion/common.py @@ -1,7 +1,31 @@ #!/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): @@ -31,3 +55,263 @@ def proxy_params(): 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/playstate.py b/resources/lib/plex_companion/playstate.py index 79e16454..1fe41d20 100644 --- a/resources/lib/plex_companion/playstate.py +++ b/resources/lib/plex_companion/playstate.py @@ -2,10 +2,13 @@ # -*- coding: utf-8 -*- from logging import getLogger import requests -import xml.etree.ElementTree as etree +from threading import Thread -from .common import proxy_headers, proxy_params, log_error +from .common import communicate, proxy_headers, proxy_params, log_error, \ + UUIDStr, Subscriber, timeline, stopped_timeline 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 @@ -22,159 +25,9 @@ log = getLogger('PLEX.companion.playstate') TIMEOUT = (5, 5) -# What is Companion controllable? -CONTROLLABLE = { - v.PLEX_PLAYLIST_TYPE_VIDEO: 'playPause,stop,volume,shuffle,audioStream,' - 'subtitleStream,seekTo,skipPrevious,skipNext,' - 'stepBack,stepForward', - v.PLEX_PLAYLIST_TYPE_AUDIO: 'playPause,stop,volume,shuffle,repeat,seekTo,' - 'skipPrevious,skipNext,stepBack,stepForward', - v.PLEX_PLAYLIST_TYPE_PHOTO: 'playPause,stop,skipPrevious,skipNext' -} - - -def split_server_uri(server): - (protocol, url, port) = server.split(':') - url = url.replace('/', '') - return (protocol, url, port) - - -def get_correct_position(info, playqueue): - """ - Kodi tells us the PLAYLIST position, not PLAYQUEUE position, if the - user initiated playback of a playlist - """ - if playqueue.kodi_playlist_playback: - position = 0 - else: - position = info['position'] or 0 - return position - - -def timeline_dict(playerid, typus): - with app.APP.lock_playqueues: - info = app.PLAYSTATE.player_states[playerid] - playqueue = app.PLAYQUEUES[playerid] - position = get_correct_position(info, playqueue) - try: - item = playqueue.items[position] - except IndexError: - # E.g. for direct path playback for single item - return { - 'controllable': CONTROLLABLE[typus], - 'type': typus, - 'state': 'stopped' - } - if typus == v.PLEX_PLAYLIST_TYPE_VIDEO and not item.streams_initialized: - # Not ready yet to send updates - raise TypeError() - protocol, url, port = split_server_uri(app.CONN.server) - status = 'paused' if int(info['speed']) == 0 else 'playing' - duration = timing.kodi_time_to_millis(info['totaltime']) - shuffle = '1' if info['shuffled'] else '0' - mute = '1' if info['muted'] is True else '0' - answ = { - 'controllable': CONTROLLABLE[typus], - 'protocol': protocol, - 'address': url, - 'port': port, - 'machineIdentifier': app.CONN.machine_identifier, - 'state': status, - 'type': typus, - 'itemType': typus, - 'time': str(timing.kodi_time_to_millis(info['time'])), - 'duration': str(duration), - 'seekRange': '0-%s' % duration, - 'shuffle': shuffle, - 'repeat': v.PLEX_REPEAT_FROM_KODI_REPEAT[info['repeat']], - 'volume': str(info['volume']), - 'mute': mute, - 'mediaIndex': '0', # Still to implement - 'partIndex': '0', - 'partCount': '1', - 'providerIdentifier': 'com.plexapp.plugins.library', - } - # Get the plex id from the PKC playqueue not info, as Kodi jumps to - # next playqueue element way BEFORE kodi monitor onplayback is - # called - if item.plex_id: - answ['key'] = '/library/metadata/%s' % item.plex_id - answ['ratingKey'] = str(item.plex_id) - # PlayQueue stuff - if info['container_key']: - answ['containerKey'] = info['container_key'] - if (info['container_key'] is not None and - info['container_key'].startswith('/playQueues')): - answ['playQueueID'] = str(playqueue.id) - answ['playQueueVersion'] = str(playqueue.version) - answ['playQueueItemID'] = str(item.id) - if playqueue.items[position].guid: - answ['guid'] = item.guid - # Temp. token set? - if app.CONN.plex_transient_token: - answ['token'] = app.CONN.plex_transient_token - elif playqueue.plex_transient_token: - answ['token'] = playqueue.plex_transient_token - # Process audio and subtitle streams - if typus == v.PLEX_PLAYLIST_TYPE_VIDEO: - 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 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 +# 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): @@ -197,14 +50,35 @@ class PlaystateMgr(backgroundthread.KillableThread): """ daemon = True - def __init__(self): - self._subscribed = False - self._command_id = None + def __init__(self, companion_enabled): + self.companion_enabled = companion_enabled + self.subscribers = dict() self.s = None - self.t = 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: log.debug('Creating new requests session') @@ -216,122 +90,94 @@ class PlaystateMgr(backgroundthread.KillableThread): self.s.params = proxy_params() return self.s - def _get_requests_session_companion(self): - if self.t is None: - log.debug('Creating new companion requests session') - self.t = requests.Session() - self.t.headers = proxy_headers() - self.t.verify = app.CONN.verify_ssl_cert - if app.CONN.ssl_cert_path: - self.t.cert = app.CONN.ssl_cert_path - self.t.params = proxy_params() - return self.t + def _close_requests_session(self): + 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_requests_session(self): - for session in (self.s, self.t): - if session is not None: - try: - session.close() - except AttributeError: - # "thread-safety" - Just in case s was set to None in the - # meantime - pass - session = None - - @staticmethod - def communicate(method, url, **kwargs): - try: - # This will usually block until timeout is reached! - req = method(url, **kwargs) - except requests.ConnectTimeout: - # The request timed out while trying to connect to the PMS - log.error('Requests ConnectionTimeout!') - raise - except requests.ReadTimeout: - # The PMS did not send any data in the allotted amount of time - log.error('Requests ReadTimeout!') - raise - except requests.TooManyRedirects: - log.error('TooManyRedirects error!') - raise - except requests.HTTPError as error: - log.error('HTTPError: %s', error) - raise - except requests.ConnectionError as error: - log.error('ConnectionError: %s', error) - raise - req.encoding = 'utf-8' - # To make sure that we release the socket, need to access content once - req.content - return req - - def _subscribe(self, cmd): - self._command_id = int(cmd.get('commandID')) - self._subscribed = True - - def _unsubscribe(self): - self._subscribed = False - self._command_id = None + def 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 """ - if app.CONN.online and app.ACCOUNT.authenticated: - # Only try to send something if we're connected - self.pms_timeline(dict(), self.stopped_timeline) - self.companion_timeline(self.stopped_timeline) + self.pms_timeline(None, self.stopped_timeline) + self.companion_timeline(self.stopped_timeline) def check_subscriber(self, cmd): - if cmd.get('path') == '/player/timeline/unsubscribe': - log.info('Stop Plex Companion subscription') - self._unsubscribe() - elif not self._subscribed: - log.info('Start Plex Companion subscription') - self._subscribe(cmd) - else: + 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: - self._command_id = int(cmd.get('commandID')) - except TypeError: + 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): - if not self._subscribed: - return - url = f'{app.CONN.server}/player/proxy/timeline' - self._get_requests_session_companion() - self.t.params['commandID'] = self._command_id - message.set('commandID', str(self._command_id)) - # Get the correct playstate state = 'stopped' - for timeline in message: - if timeline.get('state') != 'stopped': - state = timeline.get('state') - self.t.params['state'] = state - # Send update - try: - req = self.communicate(self.t.post, - url, - data=etree.tostring(message, - encoding='utf-8'), - timeout=TIMEOUT) - except (requests.RequestException, SystemExit): - return - if not req.ok: - log_error(log.error, 'Unexpected Companion timeline', req) + 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): """ - Pass a really low timeout in seconds if shutting down Kodi and we don't - need the PMS' response + 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 = self.communicate(self.s.get, url, timeout=TIMEOUT) - except (requests.RequestException, SystemExit): + 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) @@ -342,6 +188,12 @@ class PlaystateMgr(backgroundthread.KillableThread): 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 ##===----") @@ -351,16 +203,18 @@ class PlaystateMgr(backgroundthread.KillableThread): # Make sure we're telling the PMS that playback will stop self.send_stop() # Cleanup - self.close_requests_session() + 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._unsubscribe() - self.close_requests_session() + self.close_connections() if self.wait_while_suspended(): break # Check for Kodi playlist changes first @@ -377,6 +231,11 @@ class PlaystateMgr(backgroundthread.KillableThread): # 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: @@ -384,8 +243,8 @@ class PlaystateMgr(backgroundthread.KillableThread): continue elif not players: # Playback has just stopped, need to tell Plex - signaled_playback_stop = True self.send_stop() + signaled_playback_stop = True self.sleep(1) continue else: 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 index 8b875603..9699a09e 100644 --- a/resources/lib/plex_companion/polling.py +++ b/resources/lib/plex_companion/polling.py @@ -1,33 +1,29 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -from logging import getLogger +import logging import requests -from .processing import process_proxy_xml -from .common import proxy_headers, proxy_params, log_error +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 -from .. import variables as v - -# Disable annoying requests warnings -import requests.packages.urllib3 -requests.packages.urllib3.disable_warnings() # Timeout (connection timeout, read timeout) # The later is up to 20 seconds, if the PMS has nothing to tell us # THIS WILL PREVENT PKC FROM SHUTTING DOWN CORRECTLY TIMEOUT = (5.0, 4.0) -# Max. timeout for the Listener: 2 ^ MAX_TIMEOUT +# Max. timeout for the Polling: 2 ^ MAX_TIMEOUT # Corresponds to 2 ^ 7 = 128 seconds MAX_TIMEOUT = 7 -log = getLogger('PLEX.companion.listener') +log = logging.getLogger('PLEX.companion.polling') -class Listener(backgroundthread.KillableThread): +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 @@ -39,17 +35,13 @@ class Listener(backgroundthread.KillableThread): 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 = requests.Session() - self.s.headers = proxy_headers() - self.s.verify = app.CONN.verify_ssl_cert - if app.CONN.ssl_cert_path: - self.s.cert = app.CONN.ssl_cert_path - self.s.params = proxy_params() + self.s = create_requests_session() return self.s def close_requests_session(self): @@ -67,52 +59,24 @@ class Listener(backgroundthread.KillableThread): 'Plex Companion will not work.') self.suspend() - def _on_connection_error(self, req=None): + def _on_connection_error(self, req=None, error=None): if req: log_error(log.error, 'Error while contacting the PMS', req) - self.sleep(2 ^ self._sleep_timer) + 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 = self.communicate(self.s.post, - url, - data=v.COMPANION_OK_MESSAGE.encode('utf-8')) - except (requests.RequestException, SystemExit): + 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 OK', req) - - @staticmethod - def communicate(method, url, **kwargs): - try: - req = method(url, **kwargs) - except requests.ConnectTimeout: - # The request timed out while trying to connect to the PMS - log.error('Requests ConnectionTimeout!') - raise - except requests.ReadTimeout: - # The PMS did not send any data in the allotted amount of time - log.error('Requests ReadTimeout!') - raise - except requests.TooManyRedirects: - log.error('TooManyRedirects error!') - raise - except requests.HTTPError as error: - log.error('HTTPError: %s', error) - raise - except requests.ConnectionError: - # Caused by PKC terminating the connection prematurely - # log.error('ConnectionError: %s', error) - raise - else: - req.encoding = 'utf-8' - # Access response content once in order to make sure to release the - # underlying sockets - req.content - return req + log_error(log.error, 'Error replying with OK message', req) def run(self): """ @@ -140,14 +104,19 @@ class Listener(backgroundthread.KillableThread): url = app.CONN.server + '/player/proxy/poll?timeout=1' self._get_requests_session() try: - req = self.communicate(self.s.get, - url, - timeout=TIMEOUT) - except requests.ConnectionError: + 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: - self._on_connection_error() + except requests.RequestException as error: + self._on_connection_error(req=None, error=error) continue except SystemExit: # We need to quit PKC entirely @@ -161,11 +130,11 @@ class Listener(backgroundthread.KillableThread): self._unauthorized() continue elif not req.ok: - self._on_connection_error(req) + 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) + self._on_connection_error(req=req, error=None) continue if not req.text: @@ -184,13 +153,13 @@ class Listener(backgroundthread.KillableThread): raise IndexError() except (utils.ParseError, IndexError): log.error('Could not parse the PMS xml:') - self._on_connection_error() + 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) + utils.log_xml(xml, log.debug, logging.DEBUG) self.playstate_mgr.check_subscriber(cmd) - if process_proxy_xml(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 index 0f3de1f1..a2007060 100644 --- a/resources/lib/plex_companion/processing.py +++ b/resources/lib/plex_companion/processing.py @@ -166,29 +166,39 @@ def skip_to(playqueue_item_id, key): log.error('Item not found to skip to') -def process_proxy_xml(cmd): +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 (path == '/player/playback/playMedia' - and cmd.get('queryAddress') == 'node.plexapp.com'): + 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(cmd.get('queryContainerKey'), - cmd.get('queryType'), - cmd.get('queryKey'), - cmd.get('queryOffset'), - cmd.get('queryToken')) + 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(cmd.get('queryPlayQueueID')) + process_refresh(params.get('playQueueID')) elif path == '/player/playback/setParameters': - if 'queryVolume' in cmd.attrib: - js.set_volume(int(cmd.get('queryVolume'))) - else: - log.error('Unknown command: %s: %s', cmd.tag, cmd.attrib) + js.set_volume(int(params.get('volume'))) elif path == '/player/playback/play': js.play() elif path == '/player/playback/pause': @@ -196,7 +206,7 @@ def process_proxy_xml(cmd): elif path == '/player/playback/stop': js.stop() elif path == '/player/playback/seekTo': - js.seek_to(float(cmd.get('queryOffset', 0.0)) / 1000.0) + js.seek_to(float(params.get('offset', 0.0)) / 1000.0) elif path == '/player/playback/stepForward': js.smallforward() elif path == '/player/playback/stepBack': @@ -206,7 +216,7 @@ def process_proxy_xml(cmd): elif path == '/player/playback/skipPrevious': js.skipprevious() elif path == '/player/playback/skipTo': - skip_to(cmd.get('queryPlayQueueItemID'), cmd.get('queryKey')) + skip_to(params.get('playQueueItemID'), params.get('key')) elif path == '/player/navigation/moveUp': js.input_up() elif path == '/player/navigation/moveDown': @@ -222,15 +232,19 @@ def process_proxy_xml(cmd): elif path == '/player/navigation/back': js.input_back() elif path == '/player/playback/setStreams': - process_streams(cmd.get('queryType'), - cast(int, cmd.get('queryVideoStreamID')), - cast(int, cmd.get('queryAudioStreamID')), - cast(int, cmd.get('querySubtitleStreamID'))) + process_streams(params.get('queryType'), + 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: - log.error('Unknown Plex companion path/command: %s: %s', - cmd.tag, cmd.attrib) + 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/service_entry.py b/resources/lib/service_entry.py index c37b26b8..67827d97 100644 --- a/resources/lib/service_entry.py +++ b/resources/lib/service_entry.py @@ -444,11 +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.companion_playstate_mgr = plex_companion.PlaystateMgr() + self.companion_playstate_mgr = plex_companion.PlaystateMgr( + companion_enabled=utils.settings('plexCompanion') == 'true') if utils.settings('plexCompanion') == 'true': - self.companion_listener = plex_companion.Listener(self.companion_playstate_mgr) + self.companion_polling = plex_companion.Polling(self.companion_playstate_mgr) else: - self.companion_listener = None + self.companion_polling = None # Main PKC program loop while not self.should_cancel(): @@ -551,8 +552,8 @@ class Service(object): self.pms_ws.start() self.sync.start() self.companion_playstate_mgr.start() - if self.companion_listener is not None: - self.companion_listener.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/utils.py b/resources/lib/utils.py index 2e104aed..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) @@ -556,13 +557,15 @@ def reset(ask_user=True): reboot_kodi() -def log_xml(xml, logger): +def log_xml(xml, logger, loglevel): """ - Logs an etree xml + Logs an etree xml. Pass the loglevel for which logging will happen, e.g. + loglevel=logging.DEBUG """ - string = undefused_etree.tostring(xml, encoding='utf-8') - string = string.decode('utf-8') - logger('\n' + string) + if LOG.isEnabledFor(loglevel): + string = undefused_etree.tostring(xml, encoding='utf8') + string = string.decode('utf-8') + logger('\n' + string) def compare_version(current, minimum): diff --git a/resources/lib/variables.py b/resources/lib/variables.py index e457a5bd..6c084460 100644 --- a/resources/lib/variables.py +++ b/resources/lib/variables.py @@ -656,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 0ac1e5e6..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): From ca7112b43052675e95cf7cd2b16c0001ee5712b8 Mon Sep 17 00:00:00 2001 From: croneter Date: Sun, 21 Nov 2021 14:42:24 +0100 Subject: [PATCH 30/46] Beta version bump 3.5.15 --- addon.xml | 8 ++++++-- changelog.txt | 4 ++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/addon.xml b/addon.xml index 21b39258..f391d9dc 100644 --- a/addon.xml +++ b/addon.xml @@ -1,5 +1,5 @@ - + @@ -91,7 +91,11 @@ Plex를 Kodi에 기본 통합 Kodi를 Plex Media Server에 연결합니다. 이 플러그인은 Plex로 모든 비디오를 관리하고 Kodi로는 관리하지 않는다고 가정합니다. Kodi 비디오 및 음악 데이터베이스에 이미 저장된 데이터가 손실 될 수 있습니다 (이 플러그인이 직접 변경하므로). 자신의 책임하에 사용하십시오! 자신의 책임하에 사용 - version 3.5.14 (beta only): + 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): diff --git a/changelog.txt b/changelog.txt index 5892b5c2..5a17ddc1 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,7 @@ +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 From 9ed7128ea225b4e739e6434665f58e6fb4c96a7f Mon Sep 17 00:00:00 2001 From: croneter Date: Thu, 25 Nov 2021 08:34:23 +0100 Subject: [PATCH 31/46] Add warning --- resources/lib/playlist_func.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/resources/lib/playlist_func.py b/resources/lib/playlist_func.py index ab4ac20b..3566ffdf 100644 --- a/resources/lib/playlist_func.py +++ b/resources/lib/playlist_func.py @@ -246,9 +246,13 @@ class PlaylistItem(object): 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 """ 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') From 0383d560c5441cc7057a19c289c88be14e95b68d Mon Sep 17 00:00:00 2001 From: croneter Date: Thu, 25 Nov 2021 08:34:48 +0100 Subject: [PATCH 32/46] Fix PKC not setting the currently playing item's path --- resources/lib/kodimonitor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/lib/kodimonitor.py b/resources/lib/kodimonitor.py index c7107453..9067c170 100644 --- a/resources/lib/kodimonitor.py +++ b/resources/lib/kodimonitor.py @@ -308,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: From 253ed05dfe1035f83a8c038ca586a441a3018047 Mon Sep 17 00:00:00 2001 From: croneter Date: Thu, 25 Nov 2021 09:27:21 +0100 Subject: [PATCH 33/46] Beta version bump 3.5.16 --- addon.xml | 7 +++++-- changelog.txt | 3 +++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/addon.xml b/addon.xml index f391d9dc..327c8773 100644 --- a/addon.xml +++ b/addon.xml @@ -1,5 +1,5 @@ - + @@ -91,7 +91,10 @@ Plex를 Kodi에 기본 통합 Kodi를 Plex Media Server에 연결합니다. 이 플러그인은 Plex로 모든 비디오를 관리하고 Kodi로는 관리하지 않는다고 가정합니다. Kodi 비디오 및 음악 데이터베이스에 이미 저장된 데이터가 손실 될 수 있습니다 (이 플러그인이 직접 변경하므로). 자신의 책임하에 사용하십시오! 자신의 책임하에 사용 - version 3.5.15 (beta only): + 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 diff --git a/changelog.txt b/changelog.txt index 5a17ddc1..fe653e4d 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,6 @@ +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 From 56906db3c7b327ef4b45cd67eb44b60fb77c6486 Mon Sep 17 00:00:00 2001 From: croneter Date: Sun, 5 Dec 2021 11:54:40 +0100 Subject: [PATCH 34/46] Fix logging for playlist items not working correctly --- resources/lib/playlist_func.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/lib/playlist_func.py b/resources/lib/playlist_func.py index 3566ffdf..c0ebfd2f 100644 --- a/resources/lib/playlist_func.py +++ b/resources/lib/playlist_func.py @@ -180,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): """ From 2ad3d3c902a5f66caca1bb3581bf703b7f406c44 Mon Sep 17 00:00:00 2001 From: croneter Date: Sun, 5 Dec 2021 13:37:57 +0100 Subject: [PATCH 35/46] Fix detection of playqueue order. Thus fix PKC reporting back old episode with UpNext --- resources/lib/plex_companion/playqueue.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/resources/lib/plex_companion/playqueue.py b/resources/lib/plex_companion/playqueue.py index 064b7065..e10742a4 100644 --- a/resources/lib/plex_companion/playqueue.py +++ b/resources/lib/plex_companion/playqueue.py @@ -66,8 +66,7 @@ def compare_playqueues(playqueue, new_kodi_playqueue): # Kodi exit return try: - if (old_item.file.startswith('plugin://') and - not old_item.file.startswith(PLUGIN)): + if (old_item.file.startswith('plugin://') and not old_item.file.startswith(PLUGIN)): # Ignore media by other addons continue except AttributeError: @@ -85,18 +84,18 @@ def compare_playqueues(playqueue, new_kodi_playqueue): else: identical = plex_id == old_item.plex_id if j == 0 and identical: - del old[j], index[j] + del old[0], index[0] break elif identical: log.debug('Playqueue item %s moved to position %s', - i + j, i) + index[j], i) try: - PL.move_playlist_item(playqueue, i + j, i) + 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[j] + del old[j], index[i] break else: log.debug('Detected new Kodi element at position %s: %s ', From 889384a661b934bccae068037352c97d35d12f05 Mon Sep 17 00:00:00 2001 From: croneter Date: Sun, 5 Dec 2021 14:27:52 +0100 Subject: [PATCH 36/46] Use addon.xml reuselanguageinvoker to turn add-on snappier --- addon.xml | 2 ++ default.py | 15 ++++----------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/addon.xml b/addon.xml index 327c8773..b8d04d03 100644 --- a/addon.xml +++ b/addon.xml @@ -20,6 +20,8 @@ + + true icon.png fanart.jpg diff --git a/default.py b/default.py index e2d8c187..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 @@ -158,21 +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) + xbmcplugin.setResolvedUrl(int(argv[1]), True, result) if __name__ == '__main__': From 69ab2ee03144720fc09c41a4dc625313c51539b6 Mon Sep 17 00:00:00 2001 From: croneter Date: Sun, 5 Dec 2021 14:28:26 +0100 Subject: [PATCH 37/46] Enforce reuselangeinvoker also with helper add-ons --- addon.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/addon.xml b/addon.xml index b8d04d03..700c0d84 100644 --- a/addon.xml +++ b/addon.xml @@ -3,8 +3,8 @@ - - + + From b77749ee01f1137502e73a46750c7a303b962cbc Mon Sep 17 00:00:00 2001 From: croneter Date: Sun, 5 Dec 2021 14:42:02 +0100 Subject: [PATCH 38/46] Beta version bump 3.5.17 --- addon.xml | 9 +++++++-- changelog.txt | 5 +++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/addon.xml b/addon.xml index 327c8773..cdcb01a6 100644 --- a/addon.xml +++ b/addon.xml @@ -1,5 +1,5 @@ - + @@ -91,7 +91,12 @@ Plex를 Kodi에 기본 통합 Kodi를 Plex Media Server에 연결합니다. 이 플러그인은 Plex로 모든 비디오를 관리하고 Kodi로는 관리하지 않는다고 가정합니다. Kodi 비디오 및 음악 데이터베이스에 이미 저장된 데이터가 손실 될 수 있습니다 (이 플러그인이 직접 변경하므로). 자신의 책임하에 사용하십시오! 자신의 책임하에 사용 - version 3.5.16 (beta only): + 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): diff --git a/changelog.txt b/changelog.txt index fe653e4d..b3340b2b 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,8 @@ +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 From a36f32990ffaa6b618b84552004bd395d9db757f Mon Sep 17 00:00:00 2001 From: croneter Date: Wed, 8 Dec 2021 17:17:32 +0100 Subject: [PATCH 39/46] Refactor requests Sessions code --- resources/lib/plex_companion/playstate.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/resources/lib/plex_companion/playstate.py b/resources/lib/plex_companion/playstate.py index 1fe41d20..682730d9 100644 --- a/resources/lib/plex_companion/playstate.py +++ b/resources/lib/plex_companion/playstate.py @@ -4,8 +4,8 @@ from logging import getLogger import requests from threading import Thread -from .common import communicate, proxy_headers, proxy_params, log_error, \ - UUIDStr, Subscriber, timeline, stopped_timeline +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 @@ -81,13 +81,7 @@ class PlaystateMgr(backgroundthread.KillableThread): def _get_requests_session(self): if self.s is None: - log.debug('Creating new requests session') - self.s = requests.Session() - self.s.headers = proxy_headers() - self.s.verify = app.CONN.verify_ssl_cert - if app.CONN.ssl_cert_path: - self.s.cert = app.CONN.ssl_cert_path - self.s.params = proxy_params() + self.s = create_requests_session() return self.s def _close_requests_session(self): From f3ef6bdfe6f9fc3053e488820eb5a06c536b0024 Mon Sep 17 00:00:00 2001 From: croneter Date: Tue, 21 Dec 2021 15:15:29 +0100 Subject: [PATCH 40/46] Remove obsolete comments --- resources/lib/json_rpc.py | 4 ---- 1 file changed, 4 deletions(-) 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, From e275d1bbe9089816cd3d1042e408c2453fff56e1 Mon Sep 17 00:00:00 2001 From: croneter Date: Tue, 21 Dec 2021 16:01:45 +0100 Subject: [PATCH 41/46] Fix Plex Companion not able to switch streams --- resources/lib/plex_companion/processing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/lib/plex_companion/processing.py b/resources/lib/plex_companion/processing.py index a2007060..1026ae16 100644 --- a/resources/lib/plex_companion/processing.py +++ b/resources/lib/plex_companion/processing.py @@ -232,7 +232,7 @@ def process_command(cmd=None, path=None, params=None): elif path == '/player/navigation/back': js.input_back() elif path == '/player/playback/setStreams': - process_streams(params.get('queryType'), + process_streams(params.get('type'), cast(int, params.get('videoStreamID')), cast(int, params.get('audioStreamID')), cast(int, params.get('subtitleStreamID'))) From d8c96fa699bed220a3e58f7de0b61b47fd71bf93 Mon Sep 17 00:00:00 2001 From: croneter Date: Wed, 22 Dec 2021 13:38:50 +0100 Subject: [PATCH 42/46] Fix PKC changing subtitles on playback start unnecessarily --- resources/lib/playlist_func.py | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/resources/lib/playlist_func.py b/resources/lib/playlist_func.py index c0ebfd2f..e65277df 100644 --- a/resources/lib/playlist_func.py +++ b/resources/lib/playlist_func.py @@ -357,6 +357,7 @@ class PlaylistItem(object): PF.change_video_stream(plex_stream_index, self.api.part_id()) def _set_kodi_stream_if_different(self, kodi_index, typus): + """Will always activate subtitles.""" if typus == 'video': current = js.get_current_video_stream_index(self.playerid) if current != kodi_index: @@ -371,16 +372,32 @@ class PlaylistItem(object): 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_enabled = False return + # Rest: video, audio and activated subs LOG.debug('The PMS wants to display %s stream with Plex id %s and ' 'languageTag %s', typus, plex_index, language_tag) try: @@ -396,13 +413,7 @@ 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 elif typus == 'subtitle': From 15ede9a38a5f4045c2aa0e74d2bef361f904b245 Mon Sep 17 00:00:00 2001 From: croneter Date: Wed, 22 Dec 2021 14:01:53 +0100 Subject: [PATCH 43/46] Fix PKC sometimes selecting the wrong subtitle for direct paths --- resources/lib/kodimonitor.py | 2 ++ resources/lib/subtitles.py | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/resources/lib/kodimonitor.py b/resources/lib/kodimonitor.py index 9067c170..76c02424 100644 --- a/resources/lib/kodimonitor.py +++ b/resources/lib/kodimonitor.py @@ -343,6 +343,8 @@ 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 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 = [] From 14b59ff31f6b3ba3db5f716ee1a74202086b6d8a Mon Sep 17 00:00:00 2001 From: croneter Date: Wed, 22 Dec 2021 14:09:52 +0100 Subject: [PATCH 44/46] Fix additional artwork download crashing if there was no sensible reply --- resources/lib/plex_api/artwork.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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() From 740271a8bb19501a8f35c95e4de7fc2367bee53b Mon Sep 17 00:00:00 2001 From: croneter Date: Wed, 22 Dec 2021 14:21:01 +0100 Subject: [PATCH 45/46] Fix Media not showing up if Kodi masterlock has been activated. Kodi bugfix is also necessary, hopefully coming with Kodi 19.4, otherwise switch to Kodi 19.1 --- resources/lib/initialsetup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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: From 670add80f284e7b44ca60e6cad7e39d1c07383f5 Mon Sep 17 00:00:00 2001 From: croneter Date: Wed, 22 Dec 2021 14:47:01 +0100 Subject: [PATCH 46/46] Stable and beta version bump 3.6.0 --- addon.xml | 12 ++++++++++-- changelog.txt | 8 ++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/addon.xml b/addon.xml index 44e27816..e0ccd605 100644 --- a/addon.xml +++ b/addon.xml @@ -1,5 +1,5 @@ - + @@ -93,7 +93,15 @@ Plex를 Kodi에 기본 통합 Kodi를 Plex Media Server에 연결합니다. 이 플러그인은 Plex로 모든 비디오를 관리하고 Kodi로는 관리하지 않는다고 가정합니다. Kodi 비디오 및 음악 데이터베이스에 이미 저장된 데이터가 손실 될 수 있습니다 (이 플러그인이 직접 변경하므로). 자신의 책임하에 사용하십시오! 자신의 책임하에 사용 - version 3.5.17 (beta only): + 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 diff --git a/changelog.txt b/changelog.txt index b3340b2b..1fb7f5bb 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,3 +1,11 @@ +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