From 3dca92c9e7c2a02ab73f28d2e7c9cd20c3b39216 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Arnauts?= Date: Mon, 1 Feb 2021 16:31:42 +0100 Subject: [PATCH] Add configuration menu, allow merging external sources --- README.md | 1 + addon.xml | 10 +- addon_entry.py | 18 + context.py => context_entry.py | 5 +- default.py | 11 - requirements.txt | 3 + .../resource.language.el_gr/strings.po | 89 +++++ .../resource.language.en_gb/strings.po | 94 +++++- .../resource.language.hu_hu/strings.po | 89 +++++ .../resource.language.nl_nl/strings.po | 89 +++++ .../resource.language.ro_ro/strings.po | 95 +++++- .../resource.language.ru_ru/strings.po | 89 +++++ resources/lib/addon.py | 98 ++++++ resources/lib/functions.py | 70 ---- resources/lib/kodiutils.py | 163 +++++++++ resources/lib/modules/contextmenu.py | 18 +- resources/lib/modules/iptvsimple.py | 67 ++-- resources/lib/modules/menu.py | 314 ++++++++++++++++++ resources/lib/modules/sources/__init__.py | 173 ++++++++++ resources/lib/modules/{ => sources}/addon.py | 135 +++----- resources/lib/modules/sources/external.py | 140 ++++++++ resources/lib/service.py | 4 +- resources/settings.xml | 4 +- service.py => service_entry.py | 0 tests/data/external_epg.xml | 18 + tests/data/external_playlist.m3u | 8 + tests/data/external_playlist.m3u.gz | Bin 0 -> 156 bytes .../__init__.py | 0 .../addon.xml | 2 +- .../plugin.py | 21 +- .../resources/raw_epg.xml | 12 + .../resources/raw_playlist.m3u | 3 + .../resources/settings.xml | 4 +- .../plugin.video.example.three/addon.xml | 6 - .../plugin.video.example.three/plugin.py | 98 ------ .../plugin.video.example.two/__init__.py | 0 .../resources/settings.xml | 5 - .../plugin.video.example.three/settings.xml | 4 +- .../plugin.video.example.two/settings.xml | 4 - tests/test_integration.py | 47 ++- tests/test_iptvsimple.py | 4 +- tests/test_sources.py | 165 +++++++++ 42 files changed, 1832 insertions(+), 348 deletions(-) create mode 100644 addon_entry.py rename context.py => context_entry.py (59%) delete mode 100644 default.py create mode 100644 resources/lib/addon.py delete mode 100644 resources/lib/functions.py create mode 100644 resources/lib/modules/menu.py create mode 100644 resources/lib/modules/sources/__init__.py rename resources/lib/modules/{ => sources}/addon.py (67%) create mode 100644 resources/lib/modules/sources/external.py rename service.py => service_entry.py (100%) create mode 100644 tests/data/external_epg.xml create mode 100644 tests/data/external_playlist.m3u create mode 100644 tests/data/external_playlist.m3u.gz rename tests/home/addons/{plugin.video.example.three => plugin.video.example.raw}/__init__.py (100%) rename tests/home/addons/{plugin.video.example.two => plugin.video.example.raw}/addon.xml (62%) rename tests/home/addons/{plugin.video.example.two => plugin.video.example.raw}/plugin.py (82%) create mode 100644 tests/home/addons/plugin.video.example.raw/resources/raw_epg.xml create mode 100644 tests/home/addons/plugin.video.example.raw/resources/raw_playlist.m3u rename tests/home/addons/{plugin.video.example.three => plugin.video.example.raw}/resources/settings.xml (75%) delete mode 100644 tests/home/addons/plugin.video.example.three/addon.xml delete mode 100644 tests/home/addons/plugin.video.example.three/plugin.py delete mode 100644 tests/home/addons/plugin.video.example.two/__init__.py delete mode 100644 tests/home/addons/plugin.video.example.two/resources/settings.xml delete mode 100644 tests/home/userdata/addon_data/plugin.video.example.two/settings.xml create mode 100644 tests/test_sources.py diff --git a/README.md b/README.md index 0c7858d..b258aa8 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ More information and documentation for developers can be found on our [Wiki page ## Features * Integrates Live TV Channels with EPG data in Kodi from supported IPTV Add-ons +* Supports external sources so you can specify your own `M3U` and `XMLTV` files to merge from a file or a http(s)://-url * Allows playback of past and future programs directly from the EPG ## Screenshots diff --git a/addon.xml b/addon.xml index f51c89a..f573293 100644 --- a/addon.xml +++ b/addon.xml @@ -3,17 +3,17 @@ + + - - + executable - - + - + String.StartsWith(System.BuildVersion,18) + Window.IsVisible(tvguide) | Window.IsVisible(tvsearch) diff --git a/addon_entry.py b/addon_entry.py new file mode 100644 index 0000000..f2ba730 --- /dev/null +++ b/addon_entry.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +"""Addon entry point""" + +from __future__ import absolute_import, division, unicode_literals + +from xbmcaddon import Addon + +from resources.lib import kodiutils, kodilogging + +# Reinitialise ADDON every invocation to fix an issue that settings are not fresh. +kodiutils.ADDON = Addon() +kodilogging.ADDON = Addon() + +if __name__ == '__main__': + from sys import argv + from resources.lib.addon import run + + run(argv) diff --git a/context.py b/context_entry.py similarity index 59% rename from context.py rename to context_entry.py index 1b4a35a..bead060 100644 --- a/context.py +++ b/context_entry.py @@ -3,6 +3,7 @@ from __future__ import absolute_import, division, unicode_literals -from resources.lib.functions import run +if __name__ == '__main__': + from resources.lib.addon import run -run([-1, 'play_from_contextmenu']) + run(['/play']) diff --git a/default.py b/default.py deleted file mode 100644 index 3fc7da6..0000000 --- a/default.py +++ /dev/null @@ -1,11 +0,0 @@ -# -*- coding: utf-8 -*- -"""Script entry point""" - -from __future__ import absolute_import, division, unicode_literals -import sys -from resources.lib.functions import run - -if len(sys.argv) > 1: - run(sys.argv) -else: - run([-1, 'open_settings']) diff --git a/requirements.txt b/requirements.txt index 5531b02..a5ec91b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,4 +6,7 @@ pytest-cov pytest-timeout python-dateutil mock +responses +requests +git+git://github.com/dagwieers/kodi-plugin-routing.git@setup#egg=routing sakee \ No newline at end of file diff --git a/resources/language/resource.language.el_gr/strings.po b/resources/language/resource.language.el_gr/strings.po index 149231d..b107853 100644 --- a/resources/language/resource.language.el_gr/strings.po +++ b/resources/language/resource.language.el_gr/strings.po @@ -8,6 +8,95 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" +# MENU +msgctxt "#30001" +msgid "Configure IPTV Simple automatically" +msgstr "Ρύθμιση του IPTV Simple αυτομάτως" + +msgctxt "#30002" +msgid "Refresh channels and guide now" +msgstr "Ανανέωση καναλιών και οδηγού τώρα" + +msgctxt "#30003" +msgid "IPTV Manager Settings" +msgstr "" + +msgctxt "#30004" +msgid "Manage Sources" +msgstr "" + +msgctxt "#30010" +msgid "Sources" +msgstr "" + +msgctxt "#30011" +msgid "Supported Add-ons" +msgstr "" + +msgctxt "#30012" +msgid "External Sources" +msgstr "" + +msgctxt "#30013" +msgid "Add external source…" +msgstr "" + +msgctxt "#30014" +msgid "Delete Source" +msgstr "" + +msgctxt "#30020" +msgid "Name" +msgstr "" + +msgctxt "#30021" +msgid "Modify the name of this source." +msgstr "" + +msgctxt "#30022" +msgid "Enabled" +msgstr "" + +msgctxt "#30023" +msgid "Enable or disable this source." +msgstr "" + +msgctxt "#30024" +msgid "Yes" +msgstr "" + +msgctxt "#30025" +msgid "No" +msgstr "" + +msgctxt "#30026" +msgid "Playlist" +msgstr "" + +msgctxt "#30027" +msgid "Select the channel data in M3U format for this source." +msgstr "" + +msgctxt "#30028" +msgid "EPG" +msgstr "" + +msgctxt "#30029" +msgid "Select the EPG data in XMLTV format for this source." +msgstr "" + +msgctxt "#30030" +msgid "Enter URL…" +msgstr "" + +msgctxt "#30031" +msgid "Browse for file…" +msgstr "" + +msgctxt "#30032" +msgid "None" +msgstr "" + # CONTEXT MENU msgctxt "#30600" msgid "Play with IPTV Manager" diff --git a/resources/language/resource.language.en_gb/strings.po b/resources/language/resource.language.en_gb/strings.po index e4ff5d0..17f2cb7 100644 --- a/resources/language/resource.language.en_gb/strings.po +++ b/resources/language/resource.language.en_gb/strings.po @@ -8,11 +8,102 @@ msgstr "" "Language: en\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" +# MENU +msgctxt "#30001" +msgid "Configure IPTV Simple automatically" +msgstr "" + +msgctxt "#30002" +msgid "Refresh channels and guide now" +msgstr "" + +msgctxt "#30003" +msgid "IPTV Manager Settings" +msgstr "" + +msgctxt "#30004" +msgid "Manage Sources" +msgstr "" + +msgctxt "#30010" +msgid "Sources" +msgstr "" + +msgctxt "#30011" +msgid "Supported Add-ons" +msgstr "" + +msgctxt "#30012" +msgid "External Sources" +msgstr "" + +msgctxt "#30013" +msgid "Add external source…" +msgstr "" + +msgctxt "#30014" +msgid "Delete Source" +msgstr "" + +msgctxt "#30020" +msgid "Name" +msgstr "" + +msgctxt "#30021" +msgid "Modify the name of this source." +msgstr "" + +msgctxt "#30022" +msgid "Enabled" +msgstr "" + +msgctxt "#30023" +msgid "Enable or disable this source." +msgstr "" + +msgctxt "#30024" +msgid "Yes" +msgstr "" + +msgctxt "#30025" +msgid "No" +msgstr "" + +msgctxt "#30026" +msgid "Playlist" +msgstr "" + +msgctxt "#30027" +msgid "Select the channel data in M3U format for this source." +msgstr "" + +msgctxt "#30028" +msgid "EPG" +msgstr "" + +msgctxt "#30029" +msgid "Select the EPG data in XMLTV format for this source." +msgstr "" + +msgctxt "#30030" +msgid "Enter URL…" +msgstr "" + +msgctxt "#30031" +msgid "Browse for file…" +msgstr "" + +msgctxt "#30032" +msgid "None" +msgstr "" + + # CONTEXT MENU msgctxt "#30600" msgid "Play with IPTV Manager" msgstr "" + # MESSAGES msgctxt "#30700" msgid "Are you sure you want to setup IPTV Simple for the use with IPTV Manager? Any existing configuration of IPTV Simple will be overwritten." @@ -42,6 +133,7 @@ msgctxt "#30706" msgid "This program isn't available to play." msgstr "" + # SETTINGS msgctxt "#30800" msgid "Channels" @@ -97,4 +189,4 @@ msgstr "" msgctxt "#30884" msgid "Open Kodi Logfile Uploader…" -msgstr "" \ No newline at end of file +msgstr "" diff --git a/resources/language/resource.language.hu_hu/strings.po b/resources/language/resource.language.hu_hu/strings.po index 16a9935..d44a3e8 100644 --- a/resources/language/resource.language.hu_hu/strings.po +++ b/resources/language/resource.language.hu_hu/strings.po @@ -8,6 +8,95 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" +# MENU +msgctxt "#30001" +msgid "Configure IPTV Simple automatically" +msgstr "IPTV Simple automatikus konfigurációja" + +msgctxt "#30002" +msgid "Refresh channels and guide now" +msgstr "Csatornák és műsorújság frissítése most" + +msgctxt "#30003" +msgid "IPTV Manager Settings" +msgstr "" + +msgctxt "#30004" +msgid "Manage Sources" +msgstr "" + +msgctxt "#30010" +msgid "Sources" +msgstr "" + +msgctxt "#30011" +msgid "Supported Add-ons" +msgstr "" + +msgctxt "#30012" +msgid "External Sources" +msgstr "" + +msgctxt "#30013" +msgid "Add external source…" +msgstr "" + +msgctxt "#30014" +msgid "Delete Source" +msgstr "" + +msgctxt "#30020" +msgid "Name" +msgstr "" + +msgctxt "#30021" +msgid "Modify the name of this source." +msgstr "" + +msgctxt "#30022" +msgid "Enabled" +msgstr "" + +msgctxt "#30023" +msgid "Enable or disable this source." +msgstr "" + +msgctxt "#30024" +msgid "Yes" +msgstr "" + +msgctxt "#30025" +msgid "No" +msgstr "" + +msgctxt "#30026" +msgid "Playlist" +msgstr "" + +msgctxt "#30027" +msgid "Select the channel data in M3U format for this source." +msgstr "" + +msgctxt "#30028" +msgid "EPG" +msgstr "" + +msgctxt "#30029" +msgid "Select the EPG data in XMLTV format for this source." +msgstr "" + +msgctxt "#30030" +msgid "Enter URL…" +msgstr "" + +msgctxt "#30031" +msgid "Browse for file…" +msgstr "" + +msgctxt "#30032" +msgid "None" +msgstr "" + # CONTEXT MENU msgctxt "#30600" msgid "Play with IPTV Manager" diff --git a/resources/language/resource.language.nl_nl/strings.po b/resources/language/resource.language.nl_nl/strings.po index d221c08..6ca5a12 100644 --- a/resources/language/resource.language.nl_nl/strings.po +++ b/resources/language/resource.language.nl_nl/strings.po @@ -9,6 +9,95 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" +# MENU +msgctxt "#30001" +msgid "Configure IPTV Simple automatically" +msgstr "Configureer IPTV Simple automatisch" + +msgctxt "#30002" +msgid "Refresh channels and guide now" +msgstr "Kanalenlijst en gids nu verniewen" + +msgctxt "#30003" +msgid "IPTV Manager Settings" +msgstr "IPTV Manager instellingen" + +msgctxt "#30004" +msgid "Manage Sources" +msgstr "Beheer bronnen" + +msgctxt "#30010" +msgid "Sources" +msgstr "Bronnen" + +msgctxt "#30011" +msgid "Supported Add-ons" +msgstr "Ondersteunde Add-ons" + +msgctxt "#30012" +msgid "External Sources" +msgstr "Externe bronnen" + +msgctxt "#30013" +msgid "Add external source…" +msgstr "Externe bron toevoegen…" + +msgctxt "#30014" +msgid "Delete Source" +msgstr "Bron verwijderen" + +msgctxt "#30020" +msgid "Name" +msgstr "Naam" + +msgctxt "#30021" +msgid "Modify the name of this source." +msgstr "De naam van deze bron aanpassen." + +msgctxt "#30022" +msgid "Enabled" +msgstr "Geactiveerd" + +msgctxt "#30023" +msgid "Enable or disable this source." +msgstr "Activeer of deactiveer deze bron." + +msgctxt "#30024" +msgid "Yes" +msgstr "Ja" + +msgctxt "#30025" +msgid "No" +msgstr "Nee" + +msgctxt "#30026" +msgid "Playlist" +msgstr "Afspeellijst" + +msgctxt "#30027" +msgid "Select the channel data in M3U format for this source." +msgstr "Selecteer de kanaalgegevens in M3U formaat voor deze bron." + +msgctxt "#30028" +msgid "EPG" +msgstr "EPG" + +msgctxt "#30029" +msgid "Select the EPG data in XMLTV format for this source." +msgstr "Selecteer de EGP-gegevens in XMLTV formaat voor deze bron." + +msgctxt "#30030" +msgid "Enter URL…" +msgstr "Geef URL in…" + +msgctxt "#30031" +msgid "Browse for file…" +msgstr "Bestand selecteren…" + +msgctxt "#30032" +msgid "None" +msgstr "Geen" + # CONTEXT MENU msgctxt "#30600" msgid "Play with IPTV Manager" diff --git a/resources/language/resource.language.ro_ro/strings.po b/resources/language/resource.language.ro_ro/strings.po index 1199600..de26ff2 100644 --- a/resources/language/resource.language.ro_ro/strings.po +++ b/resources/language/resource.language.ro_ro/strings.po @@ -8,6 +8,95 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" +# MENU +msgctxt "#30001" +msgid "Configure IPTV Simple automatically" +msgstr "Configurează IPTV Simple în mod automat" + +msgctxt "#30002" +msgid "Refresh channels and guide now" +msgstr "Actualizează canalele și ghidul acum" + +msgctxt "#30003" +msgid "IPTV Manager Settings" +msgstr "" + +msgctxt "#30004" +msgid "Manage Sources" +msgstr "" + +msgctxt "#30010" +msgid "Sources" +msgstr "" + +msgctxt "#30011" +msgid "Supported Add-ons" +msgstr "" + +msgctxt "#30012" +msgid "External Sources" +msgstr "" + +msgctxt "#30013" +msgid "Add external source…" +msgstr "" + +msgctxt "#30014" +msgid "Delete Source" +msgstr "" + +msgctxt "#30020" +msgid "Name" +msgstr "" + +msgctxt "#30021" +msgid "Modify the name of this source." +msgstr "" + +msgctxt "#30022" +msgid "Enabled" +msgstr "" + +msgctxt "#30023" +msgid "Enable or disable this source." +msgstr "" + +msgctxt "#30024" +msgid "Yes" +msgstr "" + +msgctxt "#30025" +msgid "No" +msgstr "" + +msgctxt "#30026" +msgid "Playlist" +msgstr "" + +msgctxt "#30027" +msgid "Select the channel data in M3U format for this source." +msgstr "" + +msgctxt "#30028" +msgid "EPG" +msgstr "" + +msgctxt "#30029" +msgid "Select the EPG data in XMLTV format for this source." +msgstr "" + +msgctxt "#30030" +msgid "Enter URL…" +msgstr "" + +msgctxt "#30031" +msgid "Browse for file…" +msgstr "" + +msgctxt "#30032" +msgid "None" +msgstr "" + # CONTEXT MENU msgctxt "#30600" msgid "Play with IPTV Manager" @@ -57,7 +146,7 @@ msgstr "Interval de actualizare [I](în ore)[/I]" msgctxt "#30803" msgid "Refresh channels and guide now…" -msgstr "Actualizează canalele și ghidul acum..." +msgstr "Actualizează canalele și ghidul acum…" msgctxt "#30820" msgid "IPTV Simple" @@ -69,11 +158,11 @@ msgstr "Configurare" msgctxt "#30822" msgid "Configure IPTV Simple automatically…" -msgstr "Configurează IPTV Simple în mod automat..." +msgstr "Configurează IPTV Simple în mod automat…" msgctxt "#30823" msgid "Open IPTV Simple settings…" -msgstr "Deschide setările IPTV Simple..." +msgstr "Deschide setările IPTV Simple…" msgctxt "#30824" msgid "Automatically restart IPTV Simple to refresh the data" diff --git a/resources/language/resource.language.ru_ru/strings.po b/resources/language/resource.language.ru_ru/strings.po index 0608592..559075d 100644 --- a/resources/language/resource.language.ru_ru/strings.po +++ b/resources/language/resource.language.ru_ru/strings.po @@ -8,6 +8,95 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" +# MENU +msgctxt "#30001" +msgid "Configure IPTV Simple automatically" +msgstr "Настроить IPTV Simple автоматически" + +msgctxt "#30002" +msgid "Refresh channels and guide now" +msgstr "Обновить каналы и программу сейчас" + +msgctxt "#30003" +msgid "IPTV Manager Settings" +msgstr "" + +msgctxt "#30004" +msgid "Manage Sources" +msgstr "" + +msgctxt "#30010" +msgid "Sources" +msgstr "" + +msgctxt "#30011" +msgid "Supported Add-ons" +msgstr "" + +msgctxt "#30012" +msgid "External Sources" +msgstr "" + +msgctxt "#30013" +msgid "Add external source…" +msgstr "" + +msgctxt "#30014" +msgid "Delete Source" +msgstr "" + +msgctxt "#30020" +msgid "Name" +msgstr "" + +msgctxt "#30021" +msgid "Modify the name of this source." +msgstr "" + +msgctxt "#30022" +msgid "Enabled" +msgstr "" + +msgctxt "#30023" +msgid "Enable or disable this source." +msgstr "" + +msgctxt "#30024" +msgid "Yes" +msgstr "" + +msgctxt "#30025" +msgid "No" +msgstr "" + +msgctxt "#30026" +msgid "Playlist" +msgstr "" + +msgctxt "#30027" +msgid "Select the channel data in M3U format for this source." +msgstr "" + +msgctxt "#30028" +msgid "EPG" +msgstr "" + +msgctxt "#30029" +msgid "Select the EPG data in XMLTV format for this source." +msgstr "" + +msgctxt "#30030" +msgid "Enter URL…" +msgstr "" + +msgctxt "#30031" +msgid "Browse for file…" +msgstr "" + +msgctxt "#30032" +msgid "None" +msgstr "" + # CONTEXT MENU msgctxt "#30600" msgid "Play with IPTV Manager" diff --git a/resources/lib/addon.py b/resources/lib/addon.py new file mode 100644 index 0000000..5c9fa08 --- /dev/null +++ b/resources/lib/addon.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- +""" Addon code """ + +from __future__ import absolute_import, division, unicode_literals + +import logging + +import routing + +from resources.lib import kodilogging + +routing = routing.Plugin() # pylint: disable=invalid-name + +_LOGGER = logging.getLogger(__name__) + + +@routing.route('/') +def show_main_menu(): + """ Show the main menu """ + from resources.lib.modules.menu import Menu + Menu().show_mainmenu() + + +@routing.route('/settings') +def show_settings(): + """ Show the sources menu """ + from resources.lib.modules.menu import Menu + Menu().show_settings() + + +@routing.route('/sources') +def show_sources(): + """ Show the sources menu """ + from resources.lib.modules.menu import Menu + Menu().show_sources() + + +@routing.route('/sources/add') +def add_source(): + """ Add a source """ + from resources.lib.modules.menu import Menu + Menu().add_source() + + +@routing.route('/sources/edit/') +@routing.route('/sources/edit//') +def edit_source(uuid, edit=None): + """ Edit a source """ + from resources.lib.modules.menu import Menu + Menu().edit_source(uuid, edit) + + +@routing.route('/sources/delete/') +def delete_source(uuid): + """ Delete a source """ + from resources.lib.modules.menu import Menu + Menu().delete_source(uuid) + + +@routing.route('/sources/refresh') +def refresh(): + """ Show the sources menu """ + from resources.lib.modules.menu import Menu + Menu().refresh() + + +@routing.route('/sources/enable/') +def enable_source(addon_id): + """ Show the sources menu """ + from resources.lib.modules.menu import Menu + Menu().enable_addon_source(addon_id) + + +@routing.route('/sources/disable/') +def disable_source(addon_id): + """ Show the sources menu """ + from resources.lib.modules.menu import Menu + Menu().disable_addon_source(addon_id) + + +@routing.route('/install') +def install(): + """ Setup IPTV Simple """ + from resources.lib.modules.menu import Menu + Menu().show_install() + + +@routing.route('/play') +def play(): + """ Play from Context Menu (used in Kodi 18) """ + from resources.lib.modules.contextmenu import ContextMenu + ContextMenu().play() + + +def run(params): + """ Run the routing plugin """ + kodilogging.config() + routing.run(params) diff --git a/resources/lib/functions.py b/resources/lib/functions.py deleted file mode 100644 index 560107a..0000000 --- a/resources/lib/functions.py +++ /dev/null @@ -1,70 +0,0 @@ -# -*- coding: utf-8 -*- -"""Functions code""" - -from __future__ import absolute_import, division, unicode_literals - -import logging - -from resources.lib import kodilogging, kodiutils -from resources.lib.modules.addon import Addon -from resources.lib.modules.contextmenu import ContextMenu -from resources.lib.modules.iptvsimple import IptvSimple - -_LOGGER = logging.getLogger(__name__) - - -def setup_iptv_simple(): - """Setup IPTV Simple""" - reply = kodiutils.yesno_dialog(message=kodiutils.localize(30700)) # Are you sure... - if reply: - if IptvSimple.setup(): - kodiutils.ok_dialog(message=kodiutils.localize(30701)) # The configuration of IPTV Simple is completed! - else: - kodiutils.ok_dialog(message=kodiutils.localize(30702)) # The configuration of IPTV Simple has failed! - - # Open settings again - kodiutils.open_settings() - - -def refresh(): - """Refresh the channels and EPG""" - Addon.refresh(True) - - # Open settings again - kodiutils.open_settings() - - -def play_from_contextmenu(): - """Play an item from the Context Menu in Kodi 18""" - stream = ContextMenu.get_direct_uri() - if stream is None: - kodiutils.ok_dialog(message=kodiutils.localize(30706)) - return - - _LOGGER.debug('Playing using direct URI: %s', stream) - kodiutils.execute_builtin('PlayMedia', stream) - - -def open_settings(): - """Open the settings for IPTV Manager""" - kodiutils.open_settings() - - -def run(args): - """Run the function""" - kodilogging.config() - - function = args[1] - function_map = { - 'setup-iptv-simple': setup_iptv_simple, - 'refresh': refresh, - 'play_from_contextmenu': play_from_contextmenu, - 'open_settings': open_settings, - } - try: - # TODO: allow to pass *args to the function so we can also pass arguments - _LOGGER.debug('Routing to function: %s', function) - function_map.get(function)() - except (TypeError, IndexError): - _LOGGER.error('Could not route to %s', function) - raise diff --git a/resources/lib/kodiutils.py b/resources/lib/kodiutils.py index a1aa913..29064a9 100644 --- a/resources/lib/kodiutils.py +++ b/resources/lib/kodiutils.py @@ -8,13 +8,58 @@ import xbmc import xbmcaddon import xbmcgui +import xbmcplugin import xbmcvfs ADDON = xbmcaddon.Addon() +SORT_METHODS = dict( + unsorted=xbmcplugin.SORT_METHOD_UNSORTED, + label=xbmcplugin.SORT_METHOD_LABEL_IGNORE_FOLDERS, + title=xbmcplugin.SORT_METHOD_TITLE, + episode=xbmcplugin.SORT_METHOD_EPISODE, + duration=xbmcplugin.SORT_METHOD_DURATION, + year=xbmcplugin.SORT_METHOD_VIDEO_YEAR, + date=xbmcplugin.SORT_METHOD_DATE, +) +DEFAULT_SORT_METHODS = [ + 'unsorted', 'title' +] + _LOGGER = logging.getLogger(__name__) +class TitleItem: + """ This helper object holds all information to be used with Kodi xbmc's ListItem object """ + + def __init__(self, title, path=None, art_dict=None, info_dict=None, prop_dict=None, stream_dict=None, context_menu=None, subtitles_path=None, + is_playable=False): + """ The constructor for the TitleItem class. + + :param str title: + :param str path: + :param dict art_dict: + :param dict info_dict: + :param dict prop_dict: + :param dict stream_dict: + :param list[tuple[str, str]] context_menu: + :param list[str] subtitles_path: + :param bool is_playable: + """ + self.title = title + self.path = path + self.art_dict = art_dict + self.info_dict = info_dict + self.stream_dict = stream_dict + self.prop_dict = prop_dict + self.context_menu = context_menu + self.subtitles_path = subtitles_path + self.is_playable = is_playable + + def __repr__(self): + return "%r" % self.__dict__ + + class SafeDict(dict): """A safe dictionary implementation that does not break down on missing keys""" @@ -68,6 +113,88 @@ def addon_profile(addon=None): return to_unicode(xbmc.translatePath(addon.getAddonInfo('profile'))) +def url_for(name, *args, **kwargs): + """Wrapper for routing.url_for() to lookup by name""" + import resources.lib.addon as addon + return addon.routing.url_for(getattr(addon, name), *args, **kwargs) + + +def show_listing(title_items, category=None, sort=None, content=None, cache=True, update_listing=False): + """Show a virtual directory in Kodi""" + from resources.lib.addon import routing + + if content: + # content is one of: files, songs, artists, albums, movies, tvshows, episodes, musicvideos, videos, images, games + xbmcplugin.setContent(routing.handle, content=content) + + # Jump through hoops to get a stable breadcrumbs implementation + category_label = '' + if category: + if not content: + category_label = '' + if isinstance(category, int): + category_label += localize(category) + else: + category_label += category + elif not content: + category_label = '' + + xbmcplugin.setPluginCategory(handle=routing.handle, category=category_label) + + # Add all sort methods to GUI (start with preferred) + if sort is None: + sort = DEFAULT_SORT_METHODS + elif not isinstance(sort, list): + sort = [sort] + DEFAULT_SORT_METHODS + + for key in sort: + xbmcplugin.addSortMethod(handle=routing.handle, sortMethod=SORT_METHODS[key]) + + # Add the listings + listing = [] + for title_item in title_items: + # Three options: + # - item is a virtual directory/folder (not playable, path) + # - item is a playable file (playable, path) + # - item is non-actionable item (not playable, no path) + is_folder = bool(not title_item.is_playable and title_item.path) + is_playable = bool(title_item.is_playable and title_item.path) + + list_item = xbmcgui.ListItem(label=title_item.title, path=title_item.path) + + if title_item.prop_dict: + list_item.setProperties(title_item.prop_dict) + list_item.setProperty(key='IsPlayable', value='true' if is_playable else 'false') + + list_item.setIsFolder(is_folder) + + if title_item.art_dict: + list_item.setArt(title_item.art_dict) + + if title_item.info_dict: + # type is one of: video, music, pictures, game + list_item.setInfo(type='video', infoLabels=title_item.info_dict) + + if title_item.stream_dict: + # type is one of: video, audio, subtitle + list_item.addStreamInfo('video', title_item.stream_dict) + + if title_item.context_menu: + list_item.addContextMenuItems(title_item.context_menu, ) + + is_folder = bool(not title_item.is_playable and title_item.path) + url = title_item.path if title_item.path else None + listing.append((url, list_item, is_folder)) + + succeeded = xbmcplugin.addDirectoryItems(routing.handle, listing, len(listing)) + xbmcplugin.endOfDirectory(routing.handle, succeeded, cacheToDisc=cache, updateListing=update_listing) + + +def input_dialog(heading='', message=''): + """Ask the user for a search string""" + return xbmcgui.Dialog().input(heading, defaultt=message) + + def ok_dialog(heading='', message=''): """Show Kodi's OK dialog""" if not heading: @@ -78,6 +205,16 @@ def ok_dialog(heading='', message=''): return xbmcgui.Dialog().ok(heading=heading, message=message) +def file_dialog(heading='', browse_type=1, default='', mask=''): + """Show Kodi's OK dialog""" + return xbmcgui.Dialog().browse(browse_type, heading, shares='', mask=mask, defaultt=default) + + +def show_context_menu(items): + """Show Kodi's OK dialog""" + return xbmcgui.Dialog().contextmenu(items) + + def yesno_dialog(heading='', message='', nolabel=None, yeslabel=None, autoclose=0): """Show Kodi's Yes/No dialog""" if not heading: @@ -260,6 +397,32 @@ def get_addon_info(key, addon=None): return to_unicode(addon.getAddonInfo(key)) +def container_refresh(url=None): + """Refresh the current container or (re)load a container by URL""" + if url: + _LOGGER.debug('Execute: Container.Refresh(%s)', url) + xbmc.executebuiltin('Container.Refresh({url})'.format(url=url)) + else: + _LOGGER.debug('Execute: Container.Refresh') + xbmc.executebuiltin('Container.Refresh') + + +def container_update(url): + """Update the current container while respecting the path history.""" + if url: + _LOGGER.debug('Execute: Container.Update(%s)', url) + xbmc.executebuiltin('Container.Update({url})'.format(url=url)) + else: + # URL is a mandatory argument for Container.Update, use Container.Refresh instead + container_refresh() + + +def end_of_directory(success=False): + """Close a virtual directory, required to avoid a waiting Kodi""" + from resources.lib.addon import routing + xbmcplugin.endOfDirectory(handle=routing.handle, succeeded=success, updateListing=False, cacheToDisc=False) + + def jsonrpc(*args, **kwargs): """Perform JSONRPC calls""" from json import dumps, loads diff --git a/resources/lib/modules/contextmenu.py b/resources/lib/modules/contextmenu.py index 8cf57a8..fdea31b 100644 --- a/resources/lib/modules/contextmenu.py +++ b/resources/lib/modules/contextmenu.py @@ -7,18 +7,30 @@ import re import sys +from resources.lib import kodiutils + _LOGGER = logging.getLogger(__name__) class ContextMenu: - """Helper class for PVR Context Menu handling""" + """ Helper class for PVR Context Menu handling (used in Kodi 18) """ def __init__(self): - """Initialise the Context Menu Module""" + """ Initialise object """ + + def play(self): + """ Play from Context Menu """ + stream = self.get_direct_uri() + if stream is None: + kodiutils.ok_dialog(message=kodiutils.localize(30706)) + return + + _LOGGER.debug('Playing using direct URI: %s', stream) + kodiutils.execute_builtin('PlayMedia', stream) @staticmethod def get_direct_uri(): - """Retrieve a direct URI from the selected ListItem.""" + """ Retrieve a direct URI from the selected ListItem. """ # We use a clever way / ugly hack (pick your choice) to hide the direct stream in Kodi 18. # Title [COLOR green]•[/COLOR][COLOR vod="plugin://plugin.video.example/play/whatever"][/COLOR] label = sys.listitem.getLabel() # pylint: disable=no-member diff --git a/resources/lib/modules/iptvsimple.py b/resources/lib/modules/iptvsimple.py index 83af90a..e88d256 100644 --- a/resources/lib/modules/iptvsimple.py +++ b/resources/lib/modules/iptvsimple.py @@ -25,7 +25,42 @@ class IptvSimple: restart_required = False def __init__(self): - """Init""" + """Initialise object""" + + @classmethod + def _get_settings(cls): + """Return a dictionary with the required settings.""" + output_dir = kodiutils.addon_profile() + return { + 'm3uPathType': '0', # Local path + 'm3uPath': os.path.join(output_dir, IPTV_SIMPLE_PLAYLIST), + 'epgPathType': '0', # Local path + 'epgPath': os.path.join(output_dir, IPTV_SIMPLE_EPG), + 'epgCache': 'true', + 'epgTimeShift': '0', + 'logoPathType': '0', # Local path + 'logoPath': '/', + 'catchupEnabled': 'true', # Allow playback from the guide in Matrix + 'allChannelsCatchupMode': '1', # Allow to specify the vod mode per channel + 'catchupOnlyOnFinishedProgrammes': 'false', # Allow vod also on future programs + } + + @classmethod + def check(cls): + """Check if IPTV Simple is configured correctly.""" + try: + addon = kodiutils.get_addon(IPTV_SIMPLE_ID) + except Exception as exc: # pylint: disable=broad-except + _LOGGER.exception(exc) + return True # It might be restarting + + # Validate IPTV Simple configuration + settings = cls._get_settings() + for key, value in settings.items(): + if value != addon.getSetting(key): + return False + + return True @classmethod def setup(cls): @@ -45,22 +80,9 @@ def setup(cls): cls._deactivate() # Configure IPTV Simple - output_dir = kodiutils.addon_profile() - - addon.setSetting('m3uPathType', '0') # Local path - addon.setSetting('m3uPath', os.path.join(output_dir, IPTV_SIMPLE_PLAYLIST)) - - addon.setSetting('epgPathType', '0') # Local path - addon.setSetting('epgPath', os.path.join(output_dir, IPTV_SIMPLE_EPG)) - addon.setSetting('epgCache', 'true') - addon.setSetting('epgTimeShift', '0') - - addon.setSetting('logoPathType', '0') # Local path - addon.setSetting('logoPath', '/') - - addon.setSetting('catchupEnabled', 'true') - addon.setSetting('allChannelsCatchupMode', '1') - addon.setSetting('catchupOnlyOnFinishedProgrammes', 'false') + settings = cls._get_settings() + for key, value in settings.items(): + addon.setSetting(key, value) # Activate IPTV Simple cls._activate() @@ -107,16 +129,17 @@ def write_playlist(channels): with open(playlist_path + '.tmp', 'wb') as fdesc: m3u8_data = '#EXTM3U\n' - for addon in channels: - m3u8_data += '## {addon_name}\n'.format(**addon) + for source in channels: + m3u8_data += '## {name}\n'.format(**source) # RAW M3U8 data - if not isinstance(addon['channels'], list): - m3u8_data += addon['channels'] + if not isinstance(source['channels'], list): + m3u8_data += source['channels'] + m3u8_data += "\n" continue # JSON-STREAMS format - for channel in addon['channels']: + for channel in source['channels']: m3u8_data += '#EXTINF:-1 tvg-name="{name}"'.format(**channel) if channel.get('id'): m3u8_data += ' tvg-id="{id}"'.format(**channel) diff --git a/resources/lib/modules/menu.py b/resources/lib/modules/menu.py new file mode 100644 index 0000000..3789d35 --- /dev/null +++ b/resources/lib/modules/menu.py @@ -0,0 +1,314 @@ +# -*- coding: utf-8 -*- +""" Menu module """ + +from __future__ import absolute_import, division, unicode_literals + +import logging +import os +from uuid import uuid4 + +from resources.lib import kodiutils +from resources.lib.kodiutils import TitleItem +from resources.lib.modules.iptvsimple import IptvSimple, IPTV_SIMPLE_PLAYLIST, IPTV_SIMPLE_EPG +from resources.lib.modules.sources import Sources +from resources.lib.modules.sources.addon import AddonSource +from resources.lib.modules.sources.external import ExternalSource + +_LOGGER = logging.getLogger(__name__) + + +class Menu: + """ Menu code """ + + def __init__(self): + """ Initialise object """ + + @staticmethod + def show_mainmenu(): + """ Show the main menu. """ + listing = [] + + if not IptvSimple.check(): + listing.append(TitleItem( + title='[B]%s[/B]' % kodiutils.localize(30001), # Configure IPTV Simple automatically… + path=kodiutils.url_for('install'), + art_dict=dict( + icon='DefaultAddonService.png', + ), + )) + + listing.append(TitleItem( + title=kodiutils.localize(30002), + path=kodiutils.url_for('refresh'), # Refresh channels and guide now… + art_dict=dict( + icon='DefaultAddonsUpdates.png', + ), + )) + + listing.append(TitleItem( + title=kodiutils.localize(30003), # IPTV Manager Settings… + path=kodiutils.url_for('show_settings'), + art_dict=dict( + icon='DefaultAddonService.png', + ), + info_dict=dict( + plot=kodiutils.localize(30003), # IPTV Manager Settings… + ), + )) + + listing.append(TitleItem( + title=kodiutils.localize(30004), # Manage sources… + path=kodiutils.url_for('show_sources'), + art_dict=dict( + icon='DefaultPlaylist.png', + ), + info_dict=dict( + plot=kodiutils.localize(30004), # Manage sources… + ), + )) + + kodiutils.show_listing(listing, sort=['unsorted']) + + @staticmethod + def show_install(): + """ Setup IPTV Simple """ + reply = kodiutils.yesno_dialog(message=kodiutils.localize(30700)) # Are you sure... + if reply: + if IptvSimple.setup(): + kodiutils.ok_dialog(message=kodiutils.localize(30701)) # The configuration of IPTV Simple is completed! + else: + kodiutils.ok_dialog(message=kodiutils.localize(30702)) # The configuration of IPTV Simple has failed! + kodiutils.end_of_directory() + + @staticmethod + def show_settings(): + """ Show the sources menu. """ + kodiutils.open_settings() + + @staticmethod + def refresh(): + """ Manually refresh to channels and epg. """ + kodiutils.end_of_directory() + Sources.refresh(True) + + @staticmethod + def show_sources(): + """ Show the sources menu. """ + listing = [] + + addon_sources = AddonSource.detect_sources() + external_sources = ExternalSource.detect_sources() + + if addon_sources: + listing.append(TitleItem( + title='[B]%s[/B]' % kodiutils.localize(30011), # Supported Add-ons + path=None, + art_dict=dict( + icon='empty.png', + ), + )) + for addon in addon_sources: + if addon.enabled: + path = kodiutils.url_for('disable_source', addon_id=addon.addon_id) + else: + path = kodiutils.url_for('enable_source', addon_id=addon.addon_id) + + listing.append(TitleItem( + title=kodiutils.addon_name(addon.addon_obj), + path=path, + art_dict=dict( + icon='icons/infodialogs/enabled.png' if addon.enabled else 'icons/infodialogs/disable.png', + poster=kodiutils.addon_icon(addon.addon_obj), + ), + )) + + listing.append(TitleItem( + title='[B]%s[/B]' % kodiutils.localize(30012), # External Sources + path=None, + art_dict=dict( + icon='empty.png', + ), + )) + + for source in external_sources: + context_menu = [( + kodiutils.localize(30014), # Delete this Source + 'Container.Update(%s)' % + kodiutils.url_for('delete_source', uuid=source.uuid) + )] + + listing.append(TitleItem( + title=source.name, + path=kodiutils.url_for('edit_source', uuid=source.uuid), + art_dict=dict( + icon='icons/infodialogs/enabled.png' if source.enabled else 'icons/infodialogs/disable.png', + poster='DefaultAddonService.png', + ), + context_menu=context_menu, + )) + + listing.append(TitleItem( + title=kodiutils.localize(30013), # Add Source + path=kodiutils.url_for('add_source'), + art_dict=dict( + icon='DefaultAddSource.png', + ), + )) + + kodiutils.show_listing(listing, category='Sources', sort=['unsorted']) + + @staticmethod + def enable_addon_source(addon_id): + """ Enable the specified source. """ + source = AddonSource(addon_id=addon_id) + source.enable() + kodiutils.end_of_directory() + + @staticmethod + def disable_addon_source(addon_id): + """ Disable the specified source. """ + source = AddonSource(addon_id=addon_id) + source.disable() + kodiutils.end_of_directory() + + @staticmethod + def add_source(): + """ Add a new source. """ + source = ExternalSource(uuid=str(uuid4()), + name='External Source', # Default name + enabled=False) + source.save() + + # Go to edit page + Menu.edit_source(source.uuid) + + @staticmethod + def delete_source(uuid): + """ Add a new source. """ + sources = ExternalSource.detect_sources() + source = next(source for source in sources if source.uuid == uuid) + source.delete() + + kodiutils.end_of_directory() + + @staticmethod + def edit_source(uuid, edit=None): + """ Edit a custom source. """ + sources = ExternalSource.detect_sources() + source = next(source for source in sources if source.uuid == uuid) + + if source is None: + kodiutils.container_refresh(kodiutils.url_for('show_sources')) + return + + if edit == 'name': + name = kodiutils.input_dialog(heading='Enter name', message=source.name) + if name: + source.name = name + source.save() + + elif edit == 'enabled': + source.enabled = not source.enabled + source.save() + + elif edit == 'playlist': + new_type, new_source = Menu._select_source(source.playlist_type, source.playlist_uri, '.m3u|.m3u8') + if new_type is not None: + source.playlist_type = new_type + source.playlist_uri = new_source + source.save() + + elif edit == 'guide': + new_type, new_source = Menu._select_source(source.epg_type, source.epg_uri, mask='.xml|.xmltv') + if new_type is not None: + source.epg_type = new_type + source.epg_uri = new_source + source.save() + + listing = [ + TitleItem( + title='[B]%s:[/B] %s' % (kodiutils.localize(30020), source.name), + path=kodiutils.url_for('edit_source', uuid=source.uuid, edit='name'), + info_dict=dict( + plot=kodiutils.localize(30021), + ), + art_dict=dict( + icon='empty.png', + ), + ), + TitleItem( + title='[B]%s:[/B] %s' % (kodiutils.localize(30022), # Enabled + kodiutils.localize(30024) if source.enabled else kodiutils.localize(30025)), # Yes, No + path=kodiutils.url_for('edit_source', uuid=source.uuid, edit='enabled'), + info_dict=dict( + plot=kodiutils.localize(30023), + ), + art_dict=dict( + icon='icons/infodialogs/enabled.png' if source.enabled else 'icons/infodialogs/disable.png', + poster='empty.png', + ), + ), + TitleItem( + title='[B]%s:[/B] %s' % (kodiutils.localize(30026), source.playlist_uri), + path=kodiutils.url_for('edit_source', uuid=source.uuid, edit='playlist'), + info_dict=dict( + plot=kodiutils.localize(30027), + ), + art_dict=dict( + icon='empty.png', + ), + ), + TitleItem( + title='[B]%s:[/B] %s' % (kodiutils.localize(30028), source.epg_uri), + path=kodiutils.url_for('edit_source', uuid=source.uuid, edit='guide'), + info_dict=dict( + plot=kodiutils.localize(30029), + ), + art_dict=dict( + icon='empty.png', + ), + ) + ] + + kodiutils.show_listing(listing, category=30010, sort=['unsorted'], update_listing=edit is not None) + + @staticmethod + def _select_source(current_type, current_source, mask): + """ Select a source """ + new_source = current_source + new_type = current_type + + res = kodiutils.show_context_menu([kodiutils.localize(30032), # None + kodiutils.localize(30030), # Enter URL + kodiutils.localize(30031)]) # Browse for file + if res == -1: + # User has cancelled + return None, None + + if res == 0: + # None + new_source = None + new_type = ExternalSource.TYPE_NONE + + elif res == 1: + # Enter URL + url = kodiutils.input_dialog(heading=kodiutils.localize(30030), # Enter URL + message=current_source if current_type == ExternalSource.TYPE_URL else '') + if url: + new_source = url + new_type = ExternalSource.TYPE_URL + + elif res == 2: + # Browse for file... + filename = kodiutils.file_dialog(kodiutils.localize(30031), mask=mask, # Browse for file + default=current_source if current_type == ExternalSource.TYPE_FILE else '') + if filename: + # Simple loop prevention + if filename in [os.path.join(kodiutils.addon_profile(), IPTV_SIMPLE_PLAYLIST), + os.path.join(kodiutils.addon_profile(), IPTV_SIMPLE_EPG)]: + return None, None + + new_source = filename + new_type = ExternalSource.TYPE_FILE + + return new_type, new_source diff --git a/resources/lib/modules/sources/__init__.py b/resources/lib/modules/sources/__init__.py new file mode 100644 index 0000000..5e3e323 --- /dev/null +++ b/resources/lib/modules/sources/__init__.py @@ -0,0 +1,173 @@ +# -*- coding: utf-8 -*- +"""Sources Module""" + +from __future__ import absolute_import, division, unicode_literals + +import logging +import re +import time + +import requests + +from resources.lib import kodiutils +from resources.lib.modules.iptvsimple import IptvSimple + +_LOGGER = logging.getLogger(__name__) + + +class Sources: + """Helper class for Source updating""" + + def __init__(self): + """ Initialise object """ + + @classmethod + def refresh(cls, show_progress=False): + """Update channels and EPG data""" + channels = [] + epg = [] + + if show_progress: + progress = kodiutils.progress(message=kodiutils.localize(30703)) # Detecting IPTV add-ons... + else: + progress = None + + from resources.lib.modules.sources.addon import AddonSource + addon_sources = AddonSource.detect_sources() + + from resources.lib.modules.sources.external import ExternalSource + external_sources = ExternalSource.detect_sources() + + sources = [source for source in addon_sources + external_sources if source.enabled] + + for index, source in enumerate(sources): + # Skip Add-ons that have IPTV Manager support disabled + if not source.enabled: + continue + + _LOGGER.info('Updating IPTV data for %s...', source) + + if progress: + # Fetching channels and guide of {addon}... + progress.update(int(100 * index / len(sources)), + kodiutils.localize(30704).format(addon=str(source))) + + # Fetch channels + channels.append(dict( + name=str(source), + channels=source.get_channels(), + )) + + if progress and progress.iscanceled(): + progress.close() + return + + # Fetch EPG + epg.append(source.get_epg()) + + if progress and progress.iscanceled(): + progress.close() + return + + # Write files + if show_progress: + progress.update(100, kodiutils.localize(30705)) # Updating channels and guide... + + IptvSimple.write_playlist(channels) + IptvSimple.write_epg(epg, channels) + + if kodiutils.get_setting_bool('iptv_simple_restart'): + if show_progress: + # Restart now. + IptvSimple.restart(True) + else: + # Try to restart now. We will schedule it if the user is watching TV. + IptvSimple.restart(False) + + # Update last_refreshed + kodiutils.set_setting_int('last_refreshed', int(time.time())) + + if show_progress: + progress.close() + + +class Source(object): # pylint: disable=useless-object-inheritance + """ Base class for a Source """ + + def __init__(self): + """ Initialise object """ + + @staticmethod + def detect_sources(): + """ Detect available sources. """ + raise NotImplementedError + + def enable(self): + """ Enable this source. """ + raise NotImplementedError + + def disable(self): + """ Disable this source. """ + raise NotImplementedError + + def get_channels(self): + """ Get channel data from this source. """ + raise NotImplementedError + + @staticmethod + def get_epg(): + """ Get EPG data from this source. """ + raise NotImplementedError + + def _load_url(self, url): + """ Load the specified URL. """ + response = requests.get(url) + response.raise_for_status() + + if url.lower().endswith('.gz'): + return self._decompress_gz(response.content) + if url.lower().endswith('.bz2'): + return self._decompress_bz2(response.content) + + return response.text + + def _load_file(self, filename): + """ Load the specified file. """ + with open(filename, 'rb') as fdesc: + data = fdesc.read() + + if filename.lower().endswith('.gz'): + return self._decompress_gz(data) + if filename.lower().endswith('.bz2'): + return self._decompress_bz2(data) + + return data.decode(encoding='utf-8') + + @staticmethod + def _extract_m3u(data): + """ Extract the m3u content """ + return data.replace('#EXTM3U', '').strip() + + @staticmethod + def _extract_xmltv(data): + """ Extract the xmltv content """ + return re.search(r']*>(.*)', data, flags=re.DOTALL).group(1).strip() + + @staticmethod + def _decompress_gz(data): + """ Decompress gzip data. """ + try: # Python 3 + from gzip import decompress + return decompress(data).decode() + except ImportError: # Python 2 + from gzip import GzipFile + + from StringIO import StringIO + with GzipFile(fileobj=StringIO(data)) as fdesc: + return fdesc.read().decode() + + @staticmethod + def _decompress_bz2(data): + """ Decompress bzip2 data. """ + from bz2 import decompress + return decompress(data).decode() diff --git a/resources/lib/modules/addon.py b/resources/lib/modules/sources/addon.py similarity index 67% rename from resources/lib/modules/addon.py rename to resources/lib/modules/sources/addon.py index 3d8e188..203c6c7 100644 --- a/resources/lib/modules/addon.py +++ b/resources/lib/modules/sources/addon.py @@ -1,23 +1,18 @@ # -*- coding: utf-8 -*- -"""Addon Module""" +"""Sources Module""" from __future__ import absolute_import, division, unicode_literals import json import logging import os -import re import socket -import time from resources.lib import kodiutils -from resources.lib.modules.iptvsimple import IptvSimple +from resources.lib.modules.sources import Source _LOGGER = logging.getLogger(__name__) -CHANNELS_VERSION = 1 -EPG_VERSION = 1 - def update_qs(url, **params): """Add or update a URL query string""" @@ -34,102 +29,68 @@ def update_qs(url, **params): return urlunparse(url_parts) -class Addon: - """Helper class for Addon communication""" +class AddonSource(Source): + """ Defines an Add-on source """ + + CHANNELS_VERSION = 1 + EPG_VERSION = 1 - def __init__(self, addon_id, addon_obj, channels_uri, epg_uri): + def __init__(self, addon_id, enabled=False, channels_uri=None, epg_uri=None): + """ Initialise object """ + super(AddonSource, self).__init__() self.addon_id = addon_id - self.addon_obj = addon_obj + self.enabled = enabled self.channels_uri = channels_uri self.epg_uri = epg_uri addon = kodiutils.get_addon(addon_id) + self.addon_obj = addon self.addon_path = kodiutils.addon_path(addon) - @classmethod - def refresh(cls, show_progress=False): - """Update channels and EPG data""" - channels = [] - epg = [] - - if show_progress: - progress = kodiutils.progress(message=kodiutils.localize(30703)) # Detecting IPTV add-ons... - else: - progress = None - - addons = cls.detect_iptv_addons() - for index, addon in enumerate(addons): - _LOGGER.info('Updating IPTV data for %s...', addon.addon_id) - - if progress: - # Fetching channels and guide of {addon}... - progress.update(int(100 * index / len(addons)), - kodiutils.localize(30704).format(addon=kodiutils.addon_name(addon.addon_obj))) - - # Fetch channels - channels.append(dict( - addon_id=addon.addon_id, - addon_name=kodiutils.addon_name(addon.addon_obj), - channels=addon.get_channels(), - )) - - if progress and progress.iscanceled(): - progress.close() - return - - # Fetch EPG - epg.append(addon.get_epg()) - - if progress and progress.iscanceled(): - progress.close() - return - - # Write files - if show_progress: - progress.update(100, kodiutils.localize(30705)) # Updating channels and guide... - - IptvSimple.write_playlist(channels) - IptvSimple.write_epg(epg, channels) - - if kodiutils.get_setting_bool('iptv_simple_restart'): - if show_progress: - # Restart now. - IptvSimple.restart(True) - else: - # Try to restart now. We will schedule it if the user is watching TV. - IptvSimple.restart(False) - - # Update last_refreshed - kodiutils.set_setting_int('last_refreshed', int(time.time())) - - if show_progress: - progress.close() + def __str__(self): + return kodiutils.addon_name(self.addon_obj) @staticmethod - def detect_iptv_addons(): - """Find add-ons that provide IPTV channel data""" + def detect_sources(): + """ Find add-ons that provide IPTV channel data. + + :rtype: list[AddonSource] + """ result = kodiutils.jsonrpc(method="Addons.GetAddons", params={'installed': True, 'enabled': True, 'type': 'xbmc.python.pluginsource'}) - addons = [] + sources = [] for row in result['result'].get('addons', []): addon = kodiutils.get_addon(row['addonid']) # Check if add-on supports IPTV Manager - if addon.getSetting('iptv.enabled') != 'true': + if not addon.getSetting('iptv.enabled'): continue - addons.append(Addon( + sources.append(AddonSource( addon_id=row['addonid'], - addon_obj=addon, + enabled=addon.getSetting('iptv.enabled') == 'true', channels_uri=addon.getSetting('iptv.channels_uri'), epg_uri=addon.getSetting('iptv.epg_uri'), )) - return addons + return sources + + def enable(self): + """ Enable this source. """ + addon = kodiutils.get_addon(self.addon_id) + addon.setSetting('iptv.enabled', 'true') + + def disable(self): + """ Disable this source. """ + addon = kodiutils.get_addon(self.addon_id) + addon.setSetting('iptv.enabled', 'false') def get_channels(self): - """Get channel data from this add-on""" + """ Get channel data from this source. + + :rtype: dict|str + """ _LOGGER.info('Requesting channels from %s...', self.channels_uri) if not self.channels_uri: return [] @@ -143,10 +104,10 @@ def get_channels(self): # Return M3U8-format as-is without headers if not isinstance(data, dict): - return data.replace('#EXTM3U\n', '') + return self._extract_m3u(data) # JSON-STREAMS format - if data.get('version', 1) > CHANNELS_VERSION: + if data.get('version', 1) > self.CHANNELS_VERSION: _LOGGER.warning('Skipping %s since it uses an unsupported version: %d', self.channels_uri, data.get('version')) return [] @@ -173,24 +134,26 @@ def get_channels(self): return channels def get_epg(self): - """Get epg data from this add-on""" + """ Get EPG data from this source. + + :rtype: dict|str + """ if not self.epg_uri: return {} _LOGGER.info('Requesting epg from %s...', self.epg_uri) try: data = self._get_data_from_addon(self.epg_uri) - _LOGGER.debug(data) except Exception as exc: # pylint: disable=broad-except _LOGGER.error('Something went wrong while calling %s: %s', self.addon_id, exc) return {} # Return XMLTV-format as-is without headers and footers if not isinstance(data, dict): - return re.search(r']*>(.*)', data, flags=re.DOTALL).group(1).strip() + return self._extract_xmltv(data) # JSON-EPG format - if data.get('version', 1) > EPG_VERSION: + if data.get('version', 1) > self.EPG_VERSION: _LOGGER.warning('Skipping EPG from %s since it uses an unsupported version: %d', self.epg_uri, data.get('version')) return {} @@ -203,7 +166,7 @@ def get_epg(self): return data['epg'] def _get_data_from_addon(self, uri): - """Request data from the specified URI""" + """ Request data from the specified URI. """ # Plugin path if uri.startswith('plugin://'): # Prepare data @@ -226,7 +189,7 @@ def _get_data_from_addon(self, uri): @staticmethod def _prepare_for_data(): - """Prepare ourselves so we can receive data""" + """ Prepare ourselves so we can receive data. """ # Bind on localhost on a free port above 1024 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.bind(('localhost', 0)) @@ -238,7 +201,7 @@ def _prepare_for_data(): return sock def _wait_for_data(self, sock, timeout=10): - """Wait for data to arrive on the socket""" + """ Wait for data to arrive on the socket. """ # Set a connection timeout # The remote and should connect back as soon as possible so we know that the request is being processed sock.settimeout(timeout) diff --git a/resources/lib/modules/sources/external.py b/resources/lib/modules/sources/external.py new file mode 100644 index 0000000..27a3e19 --- /dev/null +++ b/resources/lib/modules/sources/external.py @@ -0,0 +1,140 @@ +# -*- coding: utf-8 -*- +"""Sources Module""" + +from __future__ import absolute_import, division, unicode_literals + +import json +import logging +import os + +from resources.lib import kodiutils +from resources.lib.modules.sources import Source + +_LOGGER = logging.getLogger(__name__) + + +class ExternalSource(Source): + """ Defines an External source """ + + SOURCES_FILE = 'sources.json' + + TYPE_NONE = 0 + TYPE_URL = 1 + TYPE_FILE = 2 + + def __init__(self, uuid, name, enabled, playlist_uri=None, playlist_type=TYPE_NONE, epg_uri=None, epg_type=TYPE_NONE): + """ Initialise object """ + super(ExternalSource, self).__init__() + self.uuid = uuid + self.name = name + self.enabled = enabled + self.playlist_uri = playlist_uri + self.playlist_type = playlist_type + self.epg_uri = epg_uri + self.epg_type = epg_type + + def __str__(self): + return self.name + + @staticmethod + def detect_sources(): + """ Load our sources that provide external channel data. + + :rtype: list[ExternalSource] + """ + try: + with open(os.path.join(kodiutils.addon_profile(), ExternalSource.SOURCES_FILE), 'r') as fdesc: + result = json.loads(fdesc.read()) + except (IOError, TypeError, ValueError): + result = {} + + sources = [] + for source in result.values(): + sources.append(ExternalSource( + uuid=source.get('uuid'), + name=source.get('name'), + enabled=source.get('enabled'), + playlist_uri=source.get('playlist_uri'), + playlist_type=source.get('playlist_type', ExternalSource.TYPE_NONE), + epg_uri=source.get('epg_uri'), + epg_type=source.get('epg_type', ExternalSource.TYPE_NONE), + )) + + return sources + + def enable(self): + """ Enable this source. """ + self.enabled = True + self.save() + + def disable(self): + """ Disable this source. """ + self.enabled = False + self.save() + + def get_channels(self): + """ Get channel data from this source. + + :rtype: str + """ + if self.playlist_type == self.TYPE_NONE: + return '' + + if self.playlist_type == self.TYPE_FILE: + data = self._load_file(self.playlist_uri) + elif self.playlist_type == self.TYPE_URL: + data = self._load_url(self.playlist_uri) + else: + raise ValueError('Unknown source type: %s' % self.playlist_type) + + return self._extract_m3u(data) # Remove the headers + + def get_epg(self): + """ Get EPG data from this source. + + :rtype: str + """ + if self.epg_type == self.TYPE_NONE: + return '' + + if self.epg_type == self.TYPE_FILE: + data = self._load_file(self.epg_uri) + elif self.epg_type == self.TYPE_URL: + data = self._load_url(self.epg_uri) + else: + raise ValueError('Unknown source type: %s' % self.epg_type) + + return self._extract_xmltv(data) # Remove the headers + + def save(self): + """ Save this source. """ + output_path = kodiutils.addon_profile() + try: + if not os.path.exists(output_path): + os.mkdir(output_path) + + with open(os.path.join(output_path, ExternalSource.SOURCES_FILE), 'r') as fdesc: + sources = json.loads(fdesc.read()) + except (IOError, TypeError, ValueError): + sources = {} + + # Update the element with my uuid + sources[self.uuid] = self.__dict__ + + with open(os.path.join(output_path, ExternalSource.SOURCES_FILE), 'w') as fdesc: + json.dump(sources, fdesc) + + def delete(self): + """ Delete this source. """ + output_path = kodiutils.addon_profile() + try: + with open(os.path.join(output_path, ExternalSource.SOURCES_FILE), 'r') as fdesc: + sources = json.loads(fdesc.read()) + except (IOError, TypeError, ValueError): + sources = {} + + # Remove the element with my uuid + sources.pop(self.uuid) + + with open(os.path.join(output_path, ExternalSource.SOURCES_FILE), 'w') as fdesc: + json.dump(sources, fdesc) diff --git a/resources/lib/service.py b/resources/lib/service.py index 004cfb0..47ee834 100644 --- a/resources/lib/service.py +++ b/resources/lib/service.py @@ -9,8 +9,8 @@ from xbmc import Monitor from resources.lib import kodilogging, kodiutils -from resources.lib.modules.addon import Addon from resources.lib.modules.iptvsimple import IptvSimple +from resources.lib.modules.sources import Sources _LOGGER = logging.getLogger(__name__) @@ -30,7 +30,7 @@ def run(self): while not self.abortRequested(): # Check if we need to do an update if self._is_refresh_required(): - Addon.refresh() + Sources.refresh() # Check if IPTV Simple needs to be restarted if IptvSimple.restart_required: diff --git a/resources/settings.xml b/resources/settings.xml index a061487..52cd550 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -4,11 +4,11 @@ - + - + diff --git a/service.py b/service_entry.py similarity index 100% rename from service.py rename to service_entry.py diff --git a/tests/data/external_epg.xml b/tests/data/external_epg.xml new file mode 100644 index 0000000..3fc7a4b --- /dev/null +++ b/tests/data/external_epg.xml @@ -0,0 +1,18 @@ + + + + + Custom 1 + + + Custom 2 + + + Custom 1 program + Custom 1 description + + + Custom 1 program + Custom 1 description + + \ No newline at end of file diff --git a/tests/data/external_playlist.m3u b/tests/data/external_playlist.m3u new file mode 100644 index 0000000..d4ab5cd --- /dev/null +++ b/tests/data/external_playlist.m3u @@ -0,0 +1,8 @@ +# Some comments + +#EXTM3U +#EXTINF:-1 tvg-name="Custom 1" tvg-id="custom1.com",Custom 1 +https://www.example.com/custom_1.m3u + +#EXTINF:-1 tvg-name="Custom 2" tvg-id="custom2.com",Custom 2 +https://www.example.com/custom_2.m3u \ No newline at end of file diff --git a/tests/data/external_playlist.m3u.gz b/tests/data/external_playlist.m3u.gz new file mode 100644 index 0000000000000000000000000000000000000000..a1f2f6f3442c3ecc5cde54fc602cbc53bb935632 GIT binary patch literal 156 zcmV;N0Av3jiwFoAbQE9!17me_bZ>26aBN|DY-w|JGA?a1bpTUV2+q$#9?$?K z9hfv%MoCFQv6a4ld3m{BYDHphK~5@2g+4@eyrEvMaVZyGD~!;sFha4yh>#UVAS(d< Kvd;?L0002B3PD-` literal 0 HcmV?d00001 diff --git a/tests/home/addons/plugin.video.example.three/__init__.py b/tests/home/addons/plugin.video.example.raw/__init__.py similarity index 100% rename from tests/home/addons/plugin.video.example.three/__init__.py rename to tests/home/addons/plugin.video.example.raw/__init__.py diff --git a/tests/home/addons/plugin.video.example.two/addon.xml b/tests/home/addons/plugin.video.example.raw/addon.xml similarity index 62% rename from tests/home/addons/plugin.video.example.two/addon.xml rename to tests/home/addons/plugin.video.example.raw/addon.xml index da7e6ae..29c9338 100644 --- a/tests/home/addons/plugin.video.example.two/addon.xml +++ b/tests/home/addons/plugin.video.example.raw/addon.xml @@ -1,5 +1,5 @@ - + video diff --git a/tests/home/addons/plugin.video.example.two/plugin.py b/tests/home/addons/plugin.video.example.raw/plugin.py similarity index 82% rename from tests/home/addons/plugin.video.example.two/plugin.py rename to tests/home/addons/plugin.video.example.raw/plugin.py index 4797823..4c220a9 100644 --- a/tests/home/addons/plugin.video.example.two/plugin.py +++ b/tests/home/addons/plugin.video.example.raw/plugin.py @@ -3,6 +3,7 @@ from __future__ import absolute_import, division, print_function, unicode_literals import logging +import os import sys import xbmc @@ -43,22 +44,16 @@ def send(self): @via_socket def send_channels(): # pylint: disable=no-method-argument """Return JSON-STREAMS formatted information to IPTV Manager""" - streams = [ - dict( - id='channel1.com', - name='Channel 1', - preset=1, - stream='plugin://plugin.video.example.two/play/1', - logo='https://example.com/channel1.png' - ), - ] - return dict(version=1, streams=streams) + with open(os.path.dirname(__file__) + '/resources/raw_playlist.m3u', 'rb') as fdesc: + channels = fdesc.read() + return channels.decode() @via_socket def send_epg(): # pylint: disable=no-method-argument """Return JSON-EPG formatted information to IPTV Manager""" - epg = {} - return dict(version=1, epg=epg) + with open(os.path.dirname(__file__) + '/resources/raw_epg.xml', 'rb') as fdesc: + epg = fdesc.read() + return epg.decode() if __name__ == "__main__": @@ -74,7 +69,7 @@ def send_epg(): # pylint: disable=no-method-argument query = dict(parse_qsl(sys.argv[2].lstrip('?'))) else: query = {} - print('Invoked plugin.video.example.two with route %s and query %s' % (route, query)) + print('Invoked plugin.video.example.raw with route %s and query %s' % (route, query)) if route == '/iptv/channels': IPTVManager(int(query['port'])).send_channels() diff --git a/tests/home/addons/plugin.video.example.raw/resources/raw_epg.xml b/tests/home/addons/plugin.video.example.raw/resources/raw_epg.xml new file mode 100644 index 0000000..89afe68 --- /dev/null +++ b/tests/home/addons/plugin.video.example.raw/resources/raw_epg.xml @@ -0,0 +1,12 @@ + + + + + RAW 1 + + + RAW 1 + RAW 1 description + + \ No newline at end of file diff --git a/tests/home/addons/plugin.video.example.raw/resources/raw_playlist.m3u b/tests/home/addons/plugin.video.example.raw/resources/raw_playlist.m3u new file mode 100644 index 0000000..2b346a9 --- /dev/null +++ b/tests/home/addons/plugin.video.example.raw/resources/raw_playlist.m3u @@ -0,0 +1,3 @@ +#EXTM3U +#EXTINF:-1 tvg-name="Raw 1" tvg-id="raw1.com" tvg-logo="https://example.com/raw1.png",RAW 1 +https://www.example.com/raw1.m3u \ No newline at end of file diff --git a/tests/home/addons/plugin.video.example.three/resources/settings.xml b/tests/home/addons/plugin.video.example.raw/resources/settings.xml similarity index 75% rename from tests/home/addons/plugin.video.example.three/resources/settings.xml rename to tests/home/addons/plugin.video.example.raw/resources/settings.xml index e8cf59b..c20a424 100644 --- a/tests/home/addons/plugin.video.example.three/resources/settings.xml +++ b/tests/home/addons/plugin.video.example.raw/resources/settings.xml @@ -1,6 +1,6 @@ - - + + diff --git a/tests/home/addons/plugin.video.example.three/addon.xml b/tests/home/addons/plugin.video.example.three/addon.xml deleted file mode 100644 index c87ce70..0000000 --- a/tests/home/addons/plugin.video.example.three/addon.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - video - - diff --git a/tests/home/addons/plugin.video.example.three/plugin.py b/tests/home/addons/plugin.video.example.three/plugin.py deleted file mode 100644 index 86c531f..0000000 --- a/tests/home/addons/plugin.video.example.three/plugin.py +++ /dev/null @@ -1,98 +0,0 @@ -# -*- coding: utf-8 -*- -"""This is a fake addon""" -from __future__ import absolute_import, division, print_function, unicode_literals - -import logging -import sys - -import xbmc -import xbmcplugin - -try: # Python 3 - from urllib.parse import parse_qsl, urlparse -except ImportError: # Python 2 - from urlparse import parse_qsl, urlparse - -logging.basicConfig(level=logging.DEBUG) -_LOGGER = logging.getLogger() - - -class IPTVManager: - """Interface to IPTV Manager""" - - def __init__(self, port): - """Initialize IPTV Manager object""" - self.port = port - - def via_socket(func): # pylint: disable=no-self-argument - """Send the output of the wrapped function to socket""" - - def send(self): - """Decorator to send over a socket""" - import json - import socket - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.connect(('127.0.0.1', self.port)) - try: - sock.send(json.dumps(func()).encode()) - finally: - sock.close() - - return send - - @via_socket - def send_channels(): # pylint: disable=no-method-argument - """Return JSON-STREAMS formatted information to IPTV Manager""" - channels = """#EXTM3U -#EXTINF:-1 tvg-name="Test 1" tvg-id="raw1.com" tvg-logo="https://example.com/raw1.png" tvg-chno="1" group-title="Test Addon" catchup="vod",RAW 1 -plugin://plugin.video.test/play/raw""" - return channels - - @via_socket - def send_epg(): # pylint: disable=no-method-argument - """Return JSON-EPG formatted information to IPTV Manager""" - epg = """ - - - - RAW 1 - - - RAW 1 - RAW 1 description - -""" - return epg - - -if __name__ == "__main__": - - if len(sys.argv) <= 1: - print('ERROR: Missing URL as first parameter') - exit(1) - - # Parse routing - url_parts = urlparse(sys.argv[0]) - route = url_parts.path - if len(sys.argv) > 2: - query = dict(parse_qsl(sys.argv[2].lstrip('?'))) - else: - query = {} - print('Invoked plugin.video.example.three with route %s and query %s' % (route, query)) - - if route == '/iptv/channels': - IPTVManager(int(query['port'])).send_channels() - exit() - - elif route == '/iptv/epg': - IPTVManager(int(query['port'])).send_epg() - exit() - - elif route.startswith('/play'): - listitem = xbmc.ListItem(label='Something', path='something.mp4') - xbmcplugin.setResolvedUrl(-1, True, listitem) - exit() - - # Unknown route - print('Unknown route %s' % route) - exit(1) diff --git a/tests/home/addons/plugin.video.example.two/__init__.py b/tests/home/addons/plugin.video.example.two/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/home/addons/plugin.video.example.two/resources/settings.xml b/tests/home/addons/plugin.video.example.two/resources/settings.xml deleted file mode 100644 index 83ce99d..0000000 --- a/tests/home/addons/plugin.video.example.two/resources/settings.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/tests/home/userdata/addon_data/plugin.video.example.three/settings.xml b/tests/home/userdata/addon_data/plugin.video.example.three/settings.xml index 38e5e4c..f9c5b3b 100644 --- a/tests/home/userdata/addon_data/plugin.video.example.three/settings.xml +++ b/tests/home/userdata/addon_data/plugin.video.example.three/settings.xml @@ -1,5 +1,5 @@ true - plugin://plugin.video.example.three/iptv/channels - plugin://plugin.video.example.three/iptv/epg + plugin://plugin.video.example.raw/iptv/channels + plugin://plugin.video.example.raw/iptv/epg \ No newline at end of file diff --git a/tests/home/userdata/addon_data/plugin.video.example.two/settings.xml b/tests/home/userdata/addon_data/plugin.video.example.two/settings.xml deleted file mode 100644 index f34d9a8..0000000 --- a/tests/home/userdata/addon_data/plugin.video.example.two/settings.xml +++ /dev/null @@ -1,4 +0,0 @@ - - true - plugin://plugin.video.example.two/iptv/channels - \ No newline at end of file diff --git a/tests/test_integration.py b/tests/test_integration.py index 85660ec..fdd6957 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -6,8 +6,11 @@ from __future__ import absolute_import, division, print_function, unicode_literals import os +import sys import time import unittest +from uuid import uuid4 + import lxml.etree import xbmc @@ -15,25 +18,38 @@ from xbmcgui import ListItem from resources.lib import kodiutils -from resources.lib.modules.addon import Addon +from resources.lib.modules.contextmenu import ContextMenu +from resources.lib.modules.sources import Sources +from resources.lib.modules.sources.external import ExternalSource class IntegrationTest(unittest.TestCase): """Integration Tests""" def test_refresh(self): - """Test the refreshing of data""" + """Test the refreshing of data.""" m3u_path = 'tests/home/userdata/addon_data/service.iptv.manager/playlist.m3u8' epg_path = 'tests/home/userdata/addon_data/service.iptv.manager/epg.xml' + sources_path = 'tests/home/userdata/addon_data/service.iptv.manager/sources.json' # Remove existing files - for path in [m3u_path, epg_path]: + for path in [m3u_path, epg_path, sources_path]: if os.path.exists(path): os.unlink(path) + # Add an external source + source = ExternalSource(uuid=str(uuid4()), + name='External Source', + enabled=True, + playlist_type=ExternalSource.TYPE_FILE, + playlist_uri=os.path.realpath('tests/data/external_playlist.m3u'), + epg_type=ExternalSource.TYPE_FILE, + epg_uri=os.path.realpath('tests/data/external_epg.xml')) + source.save() + # Do the refresh with patch('xbmcgui.DialogProgress.iscanceled', return_value=False): - Addon.refresh(True) + Sources.refresh(True) # Check that the files now exist for path in [m3u_path, epg_path]: @@ -44,9 +60,12 @@ def test_refresh(self): data = kodiutils.to_unicode(fdesc.read()) self.assertTrue('#EXTM3U' in data) self.assertTrue('channel1.com' in data) + self.assertTrue('channel2.com' in data) self.assertTrue('radio1.com' in data) self.assertTrue('één.be' in data) self.assertTrue('raw1.com' in data) + self.assertTrue('custom1.com' in data) + self.assertTrue('custom2.com' in data) self.assertTrue('#KODIPROP:inputstream=inputstream.ffmpegdirect' in data) # Validate EPG @@ -56,17 +75,27 @@ def test_refresh(self): # Verify if it contains the info we expect. self.assertIsNotNone(xml.find('./channel[@id="channel1.com"]')) + self.assertIsNotNone(xml.find('./channel[@id="channel2.com"]')) + self.assertIsNotNone(xml.find('./channel[@id="radio1.com"]')) self.assertIsNotNone(xml.find('./channel[@id="één.be"]')) self.assertIsNotNone(xml.find('./channel[@id="raw1.com"]')) - - # Now, try playing something from the Guide - import sys + self.assertIsNotNone(xml.find('./channel[@id="custom1.com"]')) + self.assertIsNotNone(xml.find('./channel[@id="custom2.com"]')) + self.assertIsNotNone(xml.find('./programme[@channel="channel1.com"]')) + self.assertIsNotNone(xml.find('./programme[@channel="channel2.com"]')) + self.assertIsNone(xml.find('./programme[@channel="radio1.com"]')) # No epg for this channel + self.assertIsNotNone(xml.find('./programme[@channel="één.be"]')) + self.assertIsNotNone(xml.find('./programme[@channel="raw1.com"]')) + self.assertIsNotNone(xml.find('./programme[@channel="custom1.com"]')) + self.assertIsNotNone(xml.find('./programme[@channel="custom2.com"]')) + + def test_play_from_guide(self): + """Play something from the guide.""" sys.listitem = ListItem(label='Example Show [COLOR green]•[/COLOR][COLOR vod="plugin://plugin.video.example/play/something"][/COLOR]', path='pvr://guide/0006/2020-05-23 11:35:00.epg') # Try to play it - from resources.lib.functions import play_from_contextmenu - play_from_contextmenu() + ContextMenu().play() # Check that something is playing player = xbmc.Player() diff --git a/tests/test_iptvsimple.py b/tests/test_iptvsimple.py index 72fcc2f..6fde622 100644 --- a/tests/test_iptvsimple.py +++ b/tests/test_iptvsimple.py @@ -15,7 +15,9 @@ class IptvSimpleTest(unittest.TestCase): def test_setup(self): """Test the setup of IPTV Simple (this will be mocked)""" - self.assertTrue(IptvSimple.setup()) + self.assertFalse(IptvSimple.check()) # Configuration will be incorrect + self.assertTrue(IptvSimple.setup()) # Setup configuration + self.assertTrue(IptvSimple.check()) # Configuration will be correct def test_restart(self): """Test the restart of IPTV Simple (this will be mocked)""" diff --git a/tests/test_sources.py b/tests/test_sources.py new file mode 100644 index 0000000..7d192dd --- /dev/null +++ b/tests/test_sources.py @@ -0,0 +1,165 @@ +# -*- coding: utf-8 -*- + +# pylint: disable=invalid-name,missing-docstring,no-self-use,protected-access + +from __future__ import absolute_import, division, print_function, unicode_literals + +import os +import re +import unittest +from uuid import uuid4 + +import responses + +from resources.lib import kodiutils +from resources.lib.modules.sources import Source +from resources.lib.modules.sources.external import ExternalSource + + +class SourcesTest(unittest.TestCase): + + def test_create(self): + # Clean sources + filename = os.path.join(kodiutils.addon_profile(), ExternalSource.SOURCES_FILE) + if os.path.exists(filename): + os.unlink(filename) + + key = str(uuid4()) + + # Create new source + source = ExternalSource(uuid=key, + name='External Source', + enabled=False) + source.save() + + # Check that we can find this source + sources = ExternalSource.detect_sources() + self.assertIn(key, [source.uuid for source in sources]) + self.assertEqual(next(source for source in sources if source.uuid == key).enabled, False) + + # Update source + source.enabled = True + source.save() + + # Check that we can find this source + sources = ExternalSource.detect_sources() + self.assertIn(key, [source.uuid for source in sources]) + self.assertEqual(next(source for source in sources if source.uuid == key).enabled, True) + + # Remove source + source.delete() + + # Check that we can't find this source anymore + sources = ExternalSource.detect_sources() + self.assertNotIn(key, [source.uuid for source in sources]) + + def test_fetch_none(self): + source = ExternalSource( + uuid=str(uuid4()), + name='Test Source', + enabled=True, + playlist_uri=None, + playlist_type=ExternalSource.TYPE_NONE, + epg_uri=None, + epg_type=ExternalSource.TYPE_NONE, + ) + + channels = source.get_channels() + self.assertEqual(channels, '') + + epg = source.get_epg() + self.assertEqual(epg, '') + + def test_fetch_file(self): + source = ExternalSource( + uuid=str(uuid4()), + name='Test Source', + enabled=True, + playlist_uri=os.path.realpath('tests/data/external_playlist.m3u'), + playlist_type=ExternalSource.TYPE_FILE, + epg_uri=os.path.realpath('tests/data/external_epg.xml'), + epg_type=ExternalSource.TYPE_FILE, + ) + expected_channels = Source._extract_m3u(open('tests/data/external_playlist.m3u', 'r').read()) + expected_epg = Source._extract_xmltv(open('tests/data/external_epg.xml', 'r').read()) + + # Test channels + channels = source.get_channels() + self.assertEqual(channels.replace('\r\n', '\n'), expected_channels) + + # Test channels (gzip) + source.playlist_uri = os.path.realpath('tests/data/external_playlist.m3u.gz') + channels = source.get_channels() + self.assertEqual(channels.replace('\r\n', '\n'), expected_channels) + + # Test EPG + epg = source.get_epg() + self.assertEqual(epg.replace('\r\n', '\n'), expected_epg) + + @responses.activate + def test_fetch_url(self): + + def request_callback(request): + if request.url.endswith('m3u'): + data = open('tests/data/external_playlist.m3u', 'rb').read() + return 200, {}, data + + if request.url.endswith('m3u.gz'): + data = open('tests/data/external_playlist.m3u', 'rb').read() + try: # Python 3 + from gzip import compress + return 200, {}, compress(data) + except ImportError: # Python 2 + from gzip import GzipFile + from StringIO import StringIO + buf = StringIO() + with GzipFile(fileobj=buf, mode='wb') as f: + f.write(data) + return 200, {}, buf.getvalue() + + if request.url.endswith('m3u.bz2'): + from bz2 import compress + data = open('tests/data/external_playlist.m3u', 'rb').read() + return 200, {}, compress(data) + + if request.url.endswith('xml'): + data = open('tests/data/external_epg.xml', 'rb').read() + return 200, {}, data + + return 404, {}, None + + responses.add_callback(responses.GET, re.compile('https://example.com/.*'), callback=request_callback) + + source = ExternalSource( + uuid=str(uuid4()), + name='Test Source', + enabled=True, + playlist_uri='https://example.com/playlist.m3u', + playlist_type=ExternalSource.TYPE_URL, + epg_uri='https://example.com/xmltv.xml', + epg_type=ExternalSource.TYPE_URL, + ) + expected_channels = Source._extract_m3u(open('tests/data/external_playlist.m3u', 'r').read()) + expected_epg = Source._extract_xmltv(open('tests/data/external_epg.xml', 'r').read()) + + # Test channels + channels = source.get_channels() + self.assertEqual(channels.replace('\r\n', '\n'), expected_channels) + + # Test channels (gzip) + source.playlist_uri = 'https://example.com/playlist.m3u.gz' + channels = source.get_channels() + self.assertEqual(channels.replace('\r\n', '\n'), expected_channels) + + # Test channels (bzip2) + source.playlist_uri = 'https://example.com/playlist.m3u.bz2' + channels = source.get_channels() + self.assertEqual(channels.replace('\r\n', '\n'), expected_channels) + + # Test EPG + epg = source.get_epg() + self.assertEqual(epg.replace('\r\n', '\n'), expected_epg) + + +if __name__ == '__main__': + unittest.main()