diff --git a/Readme.md b/Readme.md
new file mode 100644
index 0000000..3ba92ec
--- /dev/null
+++ b/Readme.md
@@ -0,0 +1,8 @@
+Simple example plugin for Kodi mediacenter
+===
+
+This is a simple yet fully functional example of a video plugin for [Kodi](www.kodi.tv) mediacenter.
+
+**tests/** folder includes a simple example for partial unit testing of the plugin code.
+
+The plugin uses free sample videos are provided by [www.vidsplay.com](www.vidsplay.com).
diff --git a/plugin.video.example/addon.xml b/plugin.video.example/addon.xml
index 84c3456..f52a4c7 100644
--- a/plugin.video.example/addon.xml
+++ b/plugin.video.example/addon.xml
@@ -12,5 +12,6 @@ provider-name="Roman_V_M">
Example Kodi Video Plugin
An example video plugin for Kodi mediacenter.
+ Free sample videos are provided by www.vidsplay.com.
diff --git a/plugin.video.example/default.py b/plugin.video.example/default.py
index 28a11ea..c2ceebc 100644
--- a/plugin.video.example/default.py
+++ b/plugin.video.example/default.py
@@ -5,7 +5,7 @@
# License: GPL v.3 https://www.gnu.org/copyleft/gpl.html
import sys
-import urlparse
+from urlparse import parse_qs
import xbmcgui
import xbmcplugin
@@ -18,40 +18,36 @@ __handle__ = int(sys.argv[1])
# Here we use a fixed set of properties simply for demonstrating purposes
# In a "real life" plugin you will need to get info and links to video files/streams
# from some web-site or online service.
-VIDEOS = {'Animals': [('Crab', 'http://www.vidsplay.com/vids/crab.jpg',
- 'http://www.vidsplay.com/vids/crab.mp4'),
- ('Alligator', 'http://www.vidsplay.com/vids/alligator.jpg',
- 'http://www.vidsplay.com/vids/alligator.mp4'),
- ('Turtle', 'http://www.vidsplay.com/vids/turtle.jpg',
- 'http://www.vidsplay.com/vids/turtle.mp4')],
- 'Cars': [('Postal Truck', 'http://www.vidsplay.com/vids/us_postal.jpg',
- 'http://www.vidsplay.com/vids/us_postal.mp4'),
- ('Traffic', 'http://www.vidsplay.com/vids/traffic1.jpg',
- 'http://www.vidsplay.com/vids/traffic1.avi'),
- ('Traffic Arrows', 'http://www.vidsplay.com/vids/traffic_arrows.jpg',
- 'http://www.vidsplay.com/vids/traffic_arrows.mp4')],
- 'Food': [('Chicken', 'http://www.vidsplay.com/vids/chicken.jpg',
- 'http://www.vidsplay.com/vids/bbqchicken.mp4'),
- ('Hamburger', 'http://www.vidsplay.com/vids/hamburger.jpg',
- 'http://www.vidsplay.com/vids/hamburger.mp4'),
- ('Pizza', 'http://www.vidsplay.com/vids/pizza.jpg',
- 'http://www.vidsplay.com/vids/pizza.mp4')]}
-
-
-def get_params():
- """
- Parse parameters string received as sys.argv[2] list item.
- :return: list
- """
- # Remove the starting '?' character from the paramstring.
- paramstring = sys.argv[2].replace('?', '')
- if paramstring:
- # if a paramstring present, parse it to the list of tuples (parameter, value)
- params = urlparse.parse_qsl(paramstring)
- else:
- # Return an empty stub list if there's no paramstring passed to the plugin.
- params = [('',)]
- return params
+VIDEOS = {'Animals': [{'name': 'Crab',
+ 'thumb': 'http://www.vidsplay.com/vids/crab.jpg',
+ 'video': 'http://www.vidsplay.com/vids/crab.mp4'},
+ {'name': 'Alligator',
+ 'thumb': 'http://www.vidsplay.com/vids/alligator.jpg',
+ 'video': 'http://www.vidsplay.com/vids/alligator.mp4'},
+ {'name': 'Turtle',
+ 'thumb': 'http://www.vidsplay.com/vids/turtle.jpg',
+ 'video': 'http://www.vidsplay.com/vids/turtle.mp4'}
+ ],
+ 'Cars': [{'name': 'Postal Truck',
+ 'thumb': 'http://www.vidsplay.com/vids/us_postal.jpg',
+ 'video': 'http://www.vidsplay.com/vids/us_postal.mp4'},
+ {'name': 'Traffic',
+ 'thumb': 'http://www.vidsplay.com/vids/traffic1.jpg',
+ 'video': 'http://www.vidsplay.com/vids/traffic1.avi'},
+ {'name': 'Traffic Arrows',
+ 'thumb': 'http://www.vidsplay.com/vids/traffic_arrows.jpg',
+ 'video': 'http://www.vidsplay.com/vids/traffic_arrows.mp4'}
+ ],
+ 'Food': [{'name': 'Chicken',
+ 'thumb': 'http://www.vidsplay.com/vids/chicken.jpg',
+ 'video': 'http://www.vidsplay.com/vids/bbqchicken.mp4'},
+ {'name': 'Hamburger',
+ 'thumb': 'http://www.vidsplay.com/vids/hamburger.jpg',
+ 'video': 'http://www.vidsplay.com/vids/hamburger.mp4'},
+ {'name': 'Pizza',
+ 'thumb': 'http://www.vidsplay.com/vids/pizza.jpg',
+ 'video': 'http://www.vidsplay.com/vids/pizza.mp4'}
+ ]}
def get_categories():
@@ -86,13 +82,13 @@ def list_categories():
# Iterate through categories
for category in categories:
# Create a list item with a text label and a thumbnail image.
- list_item = xbmcgui.ListItem(label=category, thumbnailImage=VIDEOS[category][0][1])
+ list_item = xbmcgui.ListItem(label=category, thumbnailImage=VIDEOS[category][0]['thumb'])
# Set a fanart image for the list item.
# Here we use the same image as the thumbnail for simplicity's sake.
- list_item.setProperty('fanart_image', VIDEOS[category][0][1])
+ list_item.setProperty('fanart_image', VIDEOS[category][0]['thumb'])
# Create a URL for the plugin recursive callback.
- # Example: plugin://plugin.video.example/?category=Animals
- url = '{0}?category={1}'.format(__url__, category)
+ # Example: plugin://plugin.video.example/?action=listing&category=Animals
+ url = '{0}?action=listing&category={1}'.format(__url__, category)
# Add the list item to a virtual Kodi folder.
# isFolder=True means that this item opens a sub-list of lower level items.
xbmcplugin.addDirectoryItem(__handle__, url, list_item, isFolder=True)
@@ -109,20 +105,20 @@ def list_videos(category):
:return: None
"""
# Get the list of videos in the category.
- videos = VIDEOS[category]
+ videos = get_videos(category)
# Iterate through videos.
for video in videos:
# Create a list item with a text label and a thumbnail image.
- list_item = xbmcgui.ListItem(label=video[0], thumbnailImage=video[1])
+ list_item = xbmcgui.ListItem(label=video['name'], thumbnailImage=video['thumb'])
# Set a fanart image for the list item.
# Here we use the same image as the thumbnail for simplicity's sake.
- list_item.setProperty('fanart_image', video[1])
+ list_item.setProperty('fanart_image', video['thumb'])
# Set 'IsPlayable' property to 'true'.
# This is mandatory for playable items!
list_item.setProperty('IsPlayable', 'true')
# Create a URL for the plugin recursive callback.
- # Example: plugin://plugin.video.example/?play=http://www.vidsplay.com/vids/crab.mp4
- url = '{0}?play={1}'.format(__url__, video[2])
+ # Example: plugin://plugin.video.example/?action=play&video=http://www.vidsplay.com/vids/crab.mp4
+ url = '{0}?action=play&video={1}'.format(__url__, video['video'])
# Add the list item to a virtual Kodi folder.
# isFolder=False means that this item won't open any sub-list.
xbmcplugin.addDirectoryItem(__handle__, url, list_item, isFolder=False)
@@ -142,17 +138,31 @@ def play_video(path):
xbmcplugin.setResolvedUrl(__handle__, True, listitem=play_item)
-if __name__ == '__main__':
- # Get parameters
- params = get_params()
+def router(paramstring):
+ """
+ Router function that calls other functions
+ depending on the provided paramstring
+ :param paramstring:
+ :return:
+ """
+ # Parse a URL-encoded paramstring to the dictionary of
+ # {: []} elements
+ params = parse_qs(paramstring)
# Check the parameters passed to the plugin
- if params[0][0] == 'category':
- # Display the list of videos in a given category.
- list_videos(params[0][1])
- elif params[0][0] == 'play':
- # Play a video from a given URL.
- play_video(params[0][1])
+ if params:
+ if params['action'][0] == 'listing':
+ # Display the list of videos in a provided category.
+ list_videos(params['category'][0])
+ elif params['action'][0] == 'play':
+ # Play a video from a provided URL.
+ play_video(params['video'][0])
else:
- # Display the list of video categories
- # if the plugin is called from Kodi UI without parameters.
+ # If the plugin is called from Kodi UI without any parameters,
+ # display the list of video categories
list_categories()
+
+
+if __name__ == '__main__':
+ # Call the router function and pass the plugin call parameters to it.
+ # We use string slicing to remove starting "?" from the paramstring
+ router(sys.argv[2][1:])
diff --git a/tests/tests.py b/tests/tests.py
new file mode 100644
index 0000000..ec7dc7d
--- /dev/null
+++ b/tests/tests.py
@@ -0,0 +1,63 @@
+# License: GPL v3: http://www.gnu.org/copyleft/gpl.html
+# This is a very minimalistic example of unit testing for Kodi addons.
+# Perquisites:
+# xbmcstubs: https://github.com/romanvm/xbmcstubs
+# mock: https://pypi.python.org/pypi/mock
+__author__ = 'Roman_V_M'
+
+import unittest
+import os
+import sys
+import mock
+# Add our plugin to sys.path
+sys.path.append(os.path.join(os.path.dirname(os.path.dirname(__file__)), 'plugin.video.expample'))
+# Mock plugin call parameters so that our plugin can be imported correctly
+with mock.patch('sys.argv', ['plugin://plugin.video.example', '5', '']):
+ # Import our plugin for testing
+ import default
+
+
+class RouterTestCase(unittest.TestCase):
+ """
+ Test router function
+
+ router() function includes custom logic for calling other
+ functions depending on a provided paramstring.
+ So it's a good candidate for unit testing.
+ We don't test other functions here
+ so they are replaced with mocks.
+ """
+ @mock.patch('default.list_categories')
+ def test_router_with_no_params(self, mock_list_categories):
+ """
+ Test router with an empty paramstring
+ """
+ default.router('')
+ mock_list_categories.assert_called_with()
+
+ @mock.patch('default.list_videos')
+ def test_router_list_videos(self, mock_list_videos):
+ """
+ Test router for open a category request
+ """
+ default.router('action=listing&category=Animals')
+ mock_list_videos.assert_called_with('Animals')
+
+ @mock.patch('default.play_video')
+ def test_router_play_video(self, mock_play_video):
+ """
+ Test router for a play video request
+ """
+ default.router('action=play&video=http://test')
+ mock_play_video.assert_called_with('http://test')
+
+# list_categories(), list_videos() and play_video() functions include
+# mostly calls to Kody Python API with little or no custom logic.
+# So they are bad candidates for unit testing outside Kodi.
+# They are better tested on a running Kodi instance.
+# It is always a good idea to separate Kodi and non-Kodi logic
+# into separate units of code (functions, classes, modules).
+
+if __name__ == '__main__':
+ # Run our tests
+ unittest.main()