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
-
-
+
- -
+
-
30600
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 0000000..a1f2f6f
Binary files /dev/null and b/tests/data/external_playlist.m3u.gz differ
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()