352 lines
13 KiB
Python
352 lines
13 KiB
Python
|
'''
|
||
|
xbmcswift2.plugin
|
||
|
-----------------
|
||
|
|
||
|
This module contains the Plugin class. This class handles all of the url
|
||
|
routing and interaction with KODI for a plugin.
|
||
|
|
||
|
:copyright: (c) 2012 by Jonathan Beluch
|
||
|
:license: GPLv3, see LICENSE for more details.
|
||
|
'''
|
||
|
import os
|
||
|
import sys
|
||
|
import pickle
|
||
|
import xbmcswift2
|
||
|
|
||
|
from functools import wraps
|
||
|
from optparse import OptionParser
|
||
|
|
||
|
from xbmcswift2 import xbmc, xbmcgui, xbmcplugin, xbmcaddon, Request
|
||
|
from xbmcswift2.listitem import ListItem
|
||
|
from xbmcswift2.logger import log, setup_log
|
||
|
from xbmcswift2.common import enum, clean_dict, Modes, DEBUG_MODES
|
||
|
from xbmcswift2.urls import UrlRule, NotFoundException, AmbiguousUrlException
|
||
|
from xbmcswift2.xbmcmixin import XBMCMixin
|
||
|
|
||
|
from urllib import urlencode
|
||
|
try:
|
||
|
from urlparse import parse_qs
|
||
|
except ImportError:
|
||
|
from cgi import parse_qs
|
||
|
|
||
|
|
||
|
class Plugin(XBMCMixin):
|
||
|
'''The Plugin objects encapsulates all the properties and methods necessary
|
||
|
for running an KODI plugin. The plugin instance is a central place for
|
||
|
registering view functions and keeping track of plugin state.
|
||
|
|
||
|
Usually the plugin instance is created in the main addon.py file for the
|
||
|
plugin. Typical creation looks like this::
|
||
|
|
||
|
from xbmcswift2 import Plugin
|
||
|
plugin = Plugin('Hello KODI')
|
||
|
|
||
|
|
||
|
.. versionchanged:: 0.2
|
||
|
The *addon_id* and *filepath* parameters are now optional. They will
|
||
|
now default to the correct values.
|
||
|
|
||
|
:param name: The name of the plugin, e.g. 'Academic Earth'.
|
||
|
|
||
|
:param addon_id: The KODI addon ID for the plugin, e.g.
|
||
|
'plugin.video.academicearth'. This parameter is now
|
||
|
optional and is really only useful for testing purposes.
|
||
|
If it is not provided, the correct value will be parsed
|
||
|
from the addon.xml file.
|
||
|
|
||
|
:param filepath: Optional parameter. If provided, it should be the path to
|
||
|
the addon.py file in the root of the addon directoy. This
|
||
|
only has an effect when xbmcswift2 is running on the
|
||
|
command line. Will default to the current working
|
||
|
directory since xbmcswift2 requires execution in the root
|
||
|
addon directoy anyway. The parameter still exists to ease
|
||
|
testing.
|
||
|
'''
|
||
|
|
||
|
def __init__(self, name=None, addon_id=None, filepath=None, info_type=None):
|
||
|
self._name = name
|
||
|
self._routes = []
|
||
|
self._view_functions = {}
|
||
|
|
||
|
# addon_id is no longer required as it can be parsed from addon.xml
|
||
|
if addon_id:
|
||
|
self._addon = xbmcaddon.Addon(id=addon_id)
|
||
|
else:
|
||
|
self._addon = xbmcaddon.Addon()
|
||
|
|
||
|
self._addon_id = addon_id or self._addon.getAddonInfo('id')
|
||
|
self._name = name or self._addon.getAddonInfo('name')
|
||
|
|
||
|
# Default plugin icon and fanart
|
||
|
self.icon = self._addon.getAddonInfo('icon')
|
||
|
self.fanart = self._addon.getAddonInfo('fanart')
|
||
|
|
||
|
# Profile and addon folder
|
||
|
self.addon_data = self._addon.getAddonInfo('profile')
|
||
|
self.addon_folder = self._addon.getAddonInfo('path')
|
||
|
|
||
|
self._info_type = info_type
|
||
|
if not self._info_type:
|
||
|
types = {
|
||
|
'video': 'video',
|
||
|
'audio': 'music',
|
||
|
'image': 'pictures',
|
||
|
}
|
||
|
self._info_type = types.get(self._addon_id.split('.')[1], 'video')
|
||
|
|
||
|
# Keeps track of the added list items
|
||
|
self._current_items = []
|
||
|
|
||
|
# Gets initialized when self.run() is called
|
||
|
self._request = None
|
||
|
|
||
|
# A flag to keep track of a call to xbmcplugin.endOfDirectory()
|
||
|
self._end_of_directory = False
|
||
|
|
||
|
# Keep track of the update_listing flag passed to
|
||
|
# xbmcplugin.endOfDirectory()
|
||
|
self._update_listing = False
|
||
|
|
||
|
# The plugin's named logger
|
||
|
self._log = setup_log(self._addon_id)
|
||
|
|
||
|
# The path to the storage directory for the addon
|
||
|
self._storage_path = xbmc.translatePath(
|
||
|
'special://profile/addon_data/%s/.storage/' % self._addon_id)
|
||
|
if not os.path.isdir(self._storage_path):
|
||
|
os.makedirs(self._storage_path)
|
||
|
|
||
|
# If we are runing in CLI, we need to load the strings.po manually
|
||
|
# Since xbmcswift2 currently relies on execution from an addon's root
|
||
|
# directly, we can rely on cwd for now...
|
||
|
if xbmcswift2.CLI_MODE:
|
||
|
from xbmcswift2.mockxbmc import utils
|
||
|
if filepath:
|
||
|
addon_dir = os.path.dirname(filepath)
|
||
|
else:
|
||
|
addon_dir = os.getcwd()
|
||
|
strings_fn = os.path.join(addon_dir, 'resources', 'language',
|
||
|
'resource.language.en_gb', 'strings.po')
|
||
|
if not os.path.exists(strings_fn):
|
||
|
strings_fn = os.path.join(addon_dir, 'resources', 'language',
|
||
|
'English', 'strings.po')
|
||
|
utils.load_addon_strings(self._addon, strings_fn)
|
||
|
|
||
|
@property
|
||
|
def info_type(self):
|
||
|
return self._info_type
|
||
|
|
||
|
@property
|
||
|
def log(self):
|
||
|
'''The log instance for the plugin. Returns an instance of the
|
||
|
stdlib's ``logging.Logger``. This log will print to STDOUT when running
|
||
|
in CLI mode and will forward messages to KODI's log when running in
|
||
|
KODI. Some examples::
|
||
|
|
||
|
plugin.log.debug('Debug message')
|
||
|
plugin.log.warning('Warning message')
|
||
|
plugin.log.error('Error message')
|
||
|
'''
|
||
|
return self._log
|
||
|
|
||
|
@property
|
||
|
def id(self):
|
||
|
'''The id for the addon instance.'''
|
||
|
return self._addon_id
|
||
|
|
||
|
@property
|
||
|
def storage_path(self):
|
||
|
'''A full path to the storage folder for this plugin's addon data.'''
|
||
|
return self._storage_path
|
||
|
|
||
|
@property
|
||
|
def addon(self):
|
||
|
'''This plugin's wrapped instance of xbmcaddon.Addon.'''
|
||
|
return self._addon
|
||
|
|
||
|
@property
|
||
|
def added_items(self):
|
||
|
'''The list of currently added items.
|
||
|
|
||
|
Even after repeated calls to :meth:`~xbmcswift2.Plugin.add_items`, this
|
||
|
property will contain the complete list of added items.
|
||
|
'''
|
||
|
return self._current_items
|
||
|
|
||
|
def clear_added_items(self):
|
||
|
# TODO: This shouldn't be exposed probably...
|
||
|
self._current_items = []
|
||
|
|
||
|
@property
|
||
|
def handle(self):
|
||
|
'''The current plugin's handle. Equal to ``plugin.request.handle``.'''
|
||
|
return self.request.handle
|
||
|
|
||
|
@property
|
||
|
def request(self):
|
||
|
'''The current :class:`~xbmcswift2.Request`.
|
||
|
|
||
|
Raises an Exception if the request hasn't been initialized yet via
|
||
|
:meth:`~xbmcswift2.Plugin.run()`.
|
||
|
'''
|
||
|
if self._request is None:
|
||
|
raise Exception('It seems the current request has not been '
|
||
|
'initialized yet. Please ensure that '
|
||
|
'`plugin.run()` has been called before attempting '
|
||
|
'to access the current request.')
|
||
|
return self._request
|
||
|
|
||
|
@property
|
||
|
def name(self):
|
||
|
'''The addon's name'''
|
||
|
return self._name
|
||
|
|
||
|
def _parse_request(self, url=None, handle=None):
|
||
|
'''Handles setup of the plugin state, including request
|
||
|
arguments, handle, mode.
|
||
|
|
||
|
This method never needs to be called directly. For testing, see
|
||
|
plugin.test()
|
||
|
'''
|
||
|
# To accomdate self.redirect, we need to be able to parse a full url as
|
||
|
# well
|
||
|
if url is None:
|
||
|
url = sys.argv[0]
|
||
|
if len(sys.argv) >= 3:
|
||
|
url += sys.argv[2]
|
||
|
if handle is None:
|
||
|
handle = sys.argv[1]
|
||
|
return Request(url, handle)
|
||
|
|
||
|
def register_module(self, module, url_prefix):
|
||
|
'''Registers a module with a plugin. Requires a url_prefix that
|
||
|
will then enable calls to url_for.
|
||
|
|
||
|
:param module: Should be an instance `xbmcswift2.Module`.
|
||
|
:param url_prefix: A url prefix to use for all module urls,
|
||
|
e.g. '/mymodule'
|
||
|
'''
|
||
|
module._plugin = self
|
||
|
module._url_prefix = url_prefix
|
||
|
for func in module._register_funcs:
|
||
|
func(self, url_prefix)
|
||
|
|
||
|
def cached_route(self, url_rule, name=None, options=None, TTL=None):
|
||
|
'''A decorator to add a route to a view and also apply caching. The
|
||
|
url_rule, name and options arguments are the same arguments for the
|
||
|
route function. The TTL argument if given will passed along to the
|
||
|
caching decorator.
|
||
|
'''
|
||
|
route_decorator = self.route(url_rule, name=name, options=options)
|
||
|
if TTL:
|
||
|
cache_decorator = self.cached(TTL)
|
||
|
else:
|
||
|
cache_decorator = self.cached()
|
||
|
|
||
|
def new_decorator(func):
|
||
|
return route_decorator(cache_decorator(func))
|
||
|
return new_decorator
|
||
|
|
||
|
def route(self, url_rule, name=None, options=None):
|
||
|
'''A decorator to add a route to a view. name is used to
|
||
|
differentiate when there are multiple routes for a given view.'''
|
||
|
# TODO: change options kwarg to defaults
|
||
|
def decorator(f):
|
||
|
view_name = name or f.__name__
|
||
|
self.add_url_rule(url_rule, f, name=view_name, options=options)
|
||
|
return f
|
||
|
return decorator
|
||
|
|
||
|
def add_url_rule(self, url_rule, view_func, name, options=None):
|
||
|
'''This method adds a URL rule for routing purposes. The
|
||
|
provided name can be different from the view function name if
|
||
|
desired. The provided name is what is used in url_for to build
|
||
|
a URL.
|
||
|
|
||
|
The route decorator provides the same functionality.
|
||
|
'''
|
||
|
rule = UrlRule(url_rule, view_func, name, options)
|
||
|
if name in self._view_functions.keys():
|
||
|
# TODO: Raise exception for ambiguous views during registration
|
||
|
log.warning('Cannot add url rule "%s" with name "%s". There is '
|
||
|
'already a view with that name', url_rule, name)
|
||
|
self._view_functions[name] = None
|
||
|
else:
|
||
|
log.debug('Adding url rule "%s" named "%s" pointing to function '
|
||
|
'"%s"', url_rule, name, view_func.__name__)
|
||
|
self._view_functions[name] = rule
|
||
|
self._routes.append(rule)
|
||
|
|
||
|
def url_for(self, endpoint, **items):
|
||
|
'''Returns a valid KODI plugin URL for the given endpoint name.
|
||
|
endpoint can be the literal name of a function, or it can
|
||
|
correspond to the name keyword arguments passed to the route
|
||
|
decorator.
|
||
|
|
||
|
Raises AmbiguousUrlException if there is more than one possible
|
||
|
view for the given endpoint name.
|
||
|
'''
|
||
|
try:
|
||
|
rule = self._view_functions[endpoint]
|
||
|
except KeyError:
|
||
|
try:
|
||
|
rule = (rule for rule in self._view_functions.values() if rule.view_func == endpoint).next()
|
||
|
except StopIteration:
|
||
|
raise NotFoundException(
|
||
|
'%s doesn\'t match any known patterns.' % endpoint)
|
||
|
|
||
|
# rule can be None since values of None are allowed in the
|
||
|
# _view_functions dict. This signifies more than one view function is
|
||
|
# tied to the same name.
|
||
|
if not rule:
|
||
|
# TODO: Make this a regular exception
|
||
|
raise AmbiguousUrlException
|
||
|
|
||
|
pathqs = rule.make_path_qs(items)
|
||
|
return 'plugin://%s%s' % (self._addon_id, pathqs)
|
||
|
|
||
|
def _dispatch(self, path):
|
||
|
for rule in self._routes:
|
||
|
try:
|
||
|
view_func, items = rule.match(path)
|
||
|
except NotFoundException:
|
||
|
continue
|
||
|
log.info('Request for "%s" matches rule for function "%s"',
|
||
|
path, view_func.__name__)
|
||
|
listitems = view_func(**items)
|
||
|
|
||
|
# Only call self.finish() for UI container listing calls to plugin
|
||
|
# (handle will be >= 0). Do not call self.finish() when called via
|
||
|
# RunPlugin() (handle will be -1).
|
||
|
if not self._end_of_directory and self.handle >= 0:
|
||
|
if listitems is None:
|
||
|
self.finish(succeeded=False)
|
||
|
else:
|
||
|
listitems = self.finish(listitems)
|
||
|
|
||
|
return listitems
|
||
|
raise NotFoundException('No matching view found for %s' % path)
|
||
|
|
||
|
def redirect(self, url):
|
||
|
'''Used when you need to redirect to another view, and you only
|
||
|
have the final plugin:// url.'''
|
||
|
# TODO: Should we be overriding self.request with the new request?
|
||
|
new_request = self._parse_request(url=url, handle=self.request.handle)
|
||
|
log.debug('Redirecting %s to %s', self.request.path, new_request.path)
|
||
|
return self._dispatch(new_request.path)
|
||
|
|
||
|
def run(self, test=False):
|
||
|
'''The main entry point for a plugin.'''
|
||
|
self._request = self._parse_request()
|
||
|
log.debug('Handling incoming request for %s', self.request.path)
|
||
|
items = self._dispatch(self.request.path)
|
||
|
|
||
|
# Close any open storages which will persist them to disk
|
||
|
if hasattr(self, '_unsynced_storages'):
|
||
|
for storage in self._unsynced_storages.values():
|
||
|
log.debug('Saving a %s storage to disk at "%s"',
|
||
|
storage.file_format, storage.filename)
|
||
|
storage.close()
|
||
|
|
||
|
return items
|