2012-02-04 21:40:47 +01:00
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
#
|
|
|
|
# gPodder - A media aggregator and podcast client
|
|
|
|
# Copyright (c) 2005-2009 Thomas Perl and the gPodder Team
|
|
|
|
#
|
|
|
|
# gPodder is free software; you can redistribute it and/or modify
|
|
|
|
# it under the terms of the GNU General Public License as published by
|
|
|
|
# the Free Software Foundation; either version 3 of the License, or
|
|
|
|
# (at your option) any later version.
|
|
|
|
#
|
|
|
|
# gPodder is distributed in the hope that it will be useful,
|
|
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
|
|
# GNU General Public License for more details.
|
|
|
|
#
|
|
|
|
# You should have received a copy of the GNU General Public License
|
|
|
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
|
|
|
|
"""
|
2012-02-05 15:46:35 +01:00
|
|
|
Loads and executes user extensions
|
2012-02-04 21:40:47 +01:00
|
|
|
|
2012-02-05 15:46:35 +01:00
|
|
|
Extensions are Python scripts in "$GPODDER_HOME/Extensions". Each script must
|
2012-02-20 23:55:36 +01:00
|
|
|
define a class named "gPodderExtension", otherwise it will be ignored.
|
2012-02-04 21:40:47 +01:00
|
|
|
|
2012-02-05 15:46:35 +01:00
|
|
|
The extensions class defines several callbacks that will be called by gPodder
|
|
|
|
at certain points. See the methods defined below for a list of callbacks and
|
|
|
|
their parameters.
|
2012-02-04 21:40:47 +01:00
|
|
|
|
|
|
|
For an example extension see examples/extensions.py
|
|
|
|
"""
|
|
|
|
|
|
|
|
import glob
|
|
|
|
import imp
|
|
|
|
import inspect
|
|
|
|
import json
|
|
|
|
import os
|
|
|
|
import functools
|
2012-02-05 17:16:20 +01:00
|
|
|
import shlex
|
|
|
|
import subprocess
|
2012-02-04 21:40:47 +01:00
|
|
|
import sys
|
2012-02-08 22:03:51 +01:00
|
|
|
import re
|
2012-02-04 21:40:47 +01:00
|
|
|
from datetime import datetime
|
|
|
|
|
|
|
|
import gpodder
|
2012-02-20 23:55:36 +01:00
|
|
|
|
2012-02-21 11:28:18 +01:00
|
|
|
_ = gpodder.gettext
|
|
|
|
|
2012-02-04 21:40:47 +01:00
|
|
|
from gpodder import util
|
|
|
|
|
|
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
def call_extensions(func):
|
|
|
|
"""Decorator to create handler functions in ExtensionManager
|
|
|
|
|
|
|
|
Calls the specified function in all user extensions that define it.
|
|
|
|
"""
|
|
|
|
method_name = func.__name__
|
|
|
|
|
|
|
|
@functools.wraps(func)
|
|
|
|
def handler(self, *args, **kwargs):
|
|
|
|
result = None
|
2012-02-21 13:50:33 +01:00
|
|
|
for container in self.containers:
|
|
|
|
if not container.enabled or container.module is None:
|
2012-02-20 23:55:36 +01:00
|
|
|
continue
|
|
|
|
|
|
|
|
try:
|
|
|
|
callback = getattr(container.module, method_name, None)
|
|
|
|
if callback is None:
|
|
|
|
continue
|
|
|
|
|
|
|
|
# If the results are lists, concatenate them to show all
|
|
|
|
# possible items that are generated by all extension together
|
|
|
|
cb_res = callback(*args, **kwargs)
|
|
|
|
if isinstance(result, list) and isinstance(cb_res, list):
|
|
|
|
result.extend(cb_res)
|
|
|
|
elif cb_res is not None:
|
|
|
|
result = cb_res
|
|
|
|
except Exception, exception:
|
|
|
|
logger.error('Error in %s in %s: %s', container.filename,
|
|
|
|
method_name, exception, exc_info=True)
|
2012-02-04 21:40:47 +01:00
|
|
|
func(self, *args, **kwargs)
|
|
|
|
return result
|
|
|
|
|
|
|
|
return handler
|
|
|
|
|
2012-02-20 23:55:36 +01:00
|
|
|
class ExtensionMetadata(object):
|
2012-02-21 11:28:18 +01:00
|
|
|
# Default fallback metadata in case metadata fields are missing
|
|
|
|
DEFAULTS = {
|
|
|
|
'description': _('No description for this extension.'),
|
|
|
|
}
|
|
|
|
|
|
|
|
def __init__(self, container, metadata):
|
|
|
|
if 'title' not in metadata:
|
|
|
|
metadata['title'] = container.name
|
|
|
|
|
2012-02-20 23:55:36 +01:00
|
|
|
self.__dict__.update(metadata)
|
2012-02-04 21:40:47 +01:00
|
|
|
|
2012-02-21 11:28:18 +01:00
|
|
|
def __getattr__(self, name):
|
|
|
|
try:
|
|
|
|
return self.DEFAULTS[name]
|
|
|
|
except KeyError:
|
|
|
|
raise AttributeError(name)
|
|
|
|
|
2012-02-21 10:14:23 +01:00
|
|
|
@property
|
|
|
|
def for_current_ui(self):
|
|
|
|
"""Check if this extension makes sense for the current UI
|
|
|
|
|
|
|
|
The __only_for__ metadata field in an extension can be a string with
|
|
|
|
comma-separated values for UIs. This will be checked against boolean
|
|
|
|
variables in the "gpodder.ui" object.
|
|
|
|
|
|
|
|
Example metadata field in an extension:
|
|
|
|
|
|
|
|
__only_for__ = 'gtk,qml'
|
|
|
|
|
|
|
|
In this case, this function will return True if any of the following
|
|
|
|
expressions will evaluate to True:
|
|
|
|
|
|
|
|
gpodder.ui.gtk
|
|
|
|
gpodder.ui.qml
|
|
|
|
|
|
|
|
New, unknown UIs are silently ignored and will evaluate to False.
|
|
|
|
"""
|
|
|
|
if not hasattr(self, 'only_for'):
|
|
|
|
return True
|
|
|
|
|
2012-02-21 14:12:46 +01:00
|
|
|
uis = filter(None, [x.strip() for x in self.only_for.split(',')])
|
|
|
|
return any(getattr(gpodder.ui, ui.lower(), False) for ui in uis)
|
2012-02-21 10:14:23 +01:00
|
|
|
|
2012-02-14 22:58:32 +01:00
|
|
|
class ExtensionContainer(object):
|
2012-02-20 23:55:36 +01:00
|
|
|
"""An extension container wraps one extension module"""
|
2012-02-04 21:40:47 +01:00
|
|
|
|
2012-02-20 23:55:36 +01:00
|
|
|
def __init__(self, manager, name, config, filename=None, module=None):
|
|
|
|
self.manager = manager
|
|
|
|
|
|
|
|
self.name = name
|
|
|
|
self.config = config
|
|
|
|
self.filename = filename
|
2012-02-04 21:40:47 +01:00
|
|
|
self.module = module
|
2012-02-21 13:50:33 +01:00
|
|
|
self.enabled = False
|
2012-02-20 23:55:36 +01:00
|
|
|
|
|
|
|
self.default_config = None
|
|
|
|
self.parameters = None
|
2012-02-21 11:28:18 +01:00
|
|
|
self.metadata = ExtensionMetadata(self, self._load_metadata(filename))
|
2012-02-04 21:40:47 +01:00
|
|
|
|
2012-02-08 22:03:51 +01:00
|
|
|
def _load_metadata(self, filename):
|
2012-02-20 23:55:36 +01:00
|
|
|
if not filename or not os.path.exists(filename):
|
|
|
|
return {}
|
|
|
|
|
2012-02-08 22:03:51 +01:00
|
|
|
extension_py = open(filename).read()
|
2012-02-21 10:14:23 +01:00
|
|
|
return dict(re.findall("__([a-z_]+)__ = '([^']+)'", extension_py))
|
2012-02-04 21:40:47 +01:00
|
|
|
|
2012-02-20 23:55:36 +01:00
|
|
|
def _load_module(self):
|
|
|
|
basename, extension = os.path.splitext(os.path.basename(self.filename))
|
2012-02-21 11:28:18 +01:00
|
|
|
fp = open(self.filename, 'r')
|
|
|
|
module = imp.load_module(basename, fp, self.filename,
|
|
|
|
(extension, 'r', imp.PY_SOURCE))
|
|
|
|
fp.close()
|
|
|
|
|
|
|
|
# Remove the .pyc file if it was created during import
|
|
|
|
util.delete_file(self.filename + 'c')
|
|
|
|
|
|
|
|
return module
|
2012-02-04 21:40:47 +01:00
|
|
|
|
2012-02-21 13:50:33 +01:00
|
|
|
def set_enabled(self, enabled):
|
|
|
|
if enabled:
|
|
|
|
try:
|
|
|
|
self.load_extension()
|
|
|
|
self.enabled = True
|
|
|
|
except Exception, exception:
|
|
|
|
logger.error('Cannot load %s from %s: %s', self.name,
|
|
|
|
self.filename, exception, exc_info=True)
|
|
|
|
self.enabled = False
|
|
|
|
else:
|
|
|
|
self.enabled = False
|
|
|
|
|
2012-02-04 21:40:47 +01:00
|
|
|
def load_extension(self):
|
2012-02-20 23:55:36 +01:00
|
|
|
"""Load and initialize the gPodder extension module"""
|
|
|
|
if self.module is not None:
|
|
|
|
logger.info('Module already loaded.')
|
|
|
|
return
|
2012-02-04 21:40:47 +01:00
|
|
|
|
2012-02-21 10:14:23 +01:00
|
|
|
if not self.metadata.for_current_ui:
|
|
|
|
logger.info('Not loading "%s" (only_for = "%s")',
|
|
|
|
self.name, self.metadata.only_for)
|
|
|
|
return
|
|
|
|
|
2012-02-21 13:50:33 +01:00
|
|
|
module_file = self._load_module()
|
|
|
|
self.default_config = getattr(module_file, 'DefaultConfig', {})
|
|
|
|
self.parameters = getattr(module_file, 'Parameters', {})
|
2012-02-04 21:40:47 +01:00
|
|
|
|
2012-02-21 13:50:33 +01:00
|
|
|
self.module = module_file.gPodderExtension(self)
|
2012-02-04 21:40:47 +01:00
|
|
|
|
2012-02-21 13:50:33 +01:00
|
|
|
logger.info('Module loaded: %s', self.filename)
|
2012-02-04 21:40:47 +01:00
|
|
|
|
|
|
|
|
|
|
|
class ExtensionManager(object):
|
2012-02-20 23:55:36 +01:00
|
|
|
"""Loads extensions and manages self-registering plugins"""
|
2012-02-04 21:40:47 +01:00
|
|
|
|
2012-02-21 14:12:46 +01:00
|
|
|
def __init__(self, core):
|
|
|
|
self.core = core
|
2012-02-20 23:55:36 +01:00
|
|
|
self.containers = []
|
2012-02-21 14:12:46 +01:00
|
|
|
|
|
|
|
core.config.add_observer(self._config_value_changed)
|
|
|
|
enabled_extensions = core.config.extensions.enabled
|
2012-02-04 21:40:47 +01:00
|
|
|
|
2012-02-20 23:55:36 +01:00
|
|
|
for name, filename in self._find_extensions():
|
2012-02-21 11:28:18 +01:00
|
|
|
logger.debug('Found extension "%s" in %s', name, filename)
|
2012-02-21 14:12:46 +01:00
|
|
|
config = getattr(core.config.extensions, name)
|
2012-02-20 23:55:36 +01:00
|
|
|
container = ExtensionContainer(self, name, config, filename)
|
|
|
|
if name in enabled_extensions:
|
2012-02-21 13:50:33 +01:00
|
|
|
container.set_enabled(True)
|
|
|
|
self.containers.append(container)
|
|
|
|
|
|
|
|
def _config_value_changed(self, name, old_value, new_value):
|
|
|
|
if name == 'extensions.enabled':
|
|
|
|
for container in self.containers:
|
|
|
|
new_enabled = (container.name in new_value)
|
|
|
|
if new_enabled != container.enabled:
|
|
|
|
logger.info('Extension "%s" is now %s', container.name,
|
|
|
|
'enabled' if new_enabled else 'disabled')
|
|
|
|
container.set_enabled(new_enabled)
|
2012-02-04 21:40:47 +01:00
|
|
|
|
2012-02-20 23:55:36 +01:00
|
|
|
def _find_extensions(self):
|
2012-02-21 11:28:18 +01:00
|
|
|
extensions = {}
|
|
|
|
|
|
|
|
root = os.path.abspath(gpodder.__path__[0]) # XXX: Works always?
|
|
|
|
builtins = os.path.join(root, 'builtins', '*.py')
|
|
|
|
user_extensions = os.path.join(gpodder.home, 'Extensions', '*.py')
|
|
|
|
|
|
|
|
# Let user extensions override built-in extensions of the same name
|
|
|
|
for filename in glob.glob(builtins) + glob.glob(user_extensions):
|
2012-02-20 23:55:36 +01:00
|
|
|
name, _ = os.path.splitext(os.path.basename(filename))
|
2012-02-21 11:28:18 +01:00
|
|
|
extensions[name] = filename
|
|
|
|
|
|
|
|
return sorted(extensions.items())
|
2012-02-04 21:40:47 +01:00
|
|
|
|
|
|
|
def get_extensions(self):
|
2012-02-20 21:51:06 +01:00
|
|
|
"""Get a list of all loaded extensions and their enabled flag"""
|
2012-02-20 23:55:36 +01:00
|
|
|
return self.containers
|
2012-02-04 21:40:47 +01:00
|
|
|
|
|
|
|
# Define all known handler functions here, decorate them with the
|
|
|
|
# "call_extension" decorator to forward all calls to extension scripts that have
|
|
|
|
# the same function defined in them. If the handler functions here contain
|
|
|
|
# any code, it will be called after all the extensions have been called.
|
|
|
|
|
|
|
|
@call_extensions
|
|
|
|
def on_ui_initialized(self, model, update_podcast_callback,
|
|
|
|
download_episode_callback):
|
|
|
|
"""Called when the user interface is initialized.
|
|
|
|
|
|
|
|
@param model: A gpodder.model.Model instance
|
|
|
|
@param update_podcast_callback: Function to update a podcast feed
|
|
|
|
@param download_episode_callback: Function to download an episode
|
|
|
|
"""
|
|
|
|
pass
|
|
|
|
|
|
|
|
@call_extensions
|
|
|
|
def on_podcast_subscribe(self, podcast):
|
|
|
|
"""Called when the user subscribes to a new podcast feed.
|
|
|
|
|
|
|
|
@param podcast: A gpodder.model.PodcastChannel instance
|
|
|
|
"""
|
|
|
|
pass
|
|
|
|
|
|
|
|
@call_extensions
|
|
|
|
def on_podcast_updated(self, podcast):
|
|
|
|
"""Called when a podcast feed was updated
|
|
|
|
|
|
|
|
This extension will be called even if there were no new episodes.
|
|
|
|
|
|
|
|
@param podcast: A gpodder.model.PodcastChannel instance
|
|
|
|
"""
|
|
|
|
pass
|
|
|
|
|
|
|
|
@call_extensions
|
|
|
|
def on_podcast_update_failed(self, podcast, exception):
|
|
|
|
"""Called when a podcast update failed.
|
|
|
|
|
|
|
|
@param podcast: A gpodder.model.PodcastChannel instance
|
|
|
|
|
|
|
|
@param exception: The reason.
|
|
|
|
"""
|
|
|
|
pass
|
|
|
|
|
|
|
|
@call_extensions
|
|
|
|
def on_podcast_save(self, podcast):
|
|
|
|
"""Called when a podcast is saved to the database
|
|
|
|
|
|
|
|
This extensions will be called when the user edits the metadata of
|
|
|
|
the podcast or when the feed was updated.
|
|
|
|
|
|
|
|
@param podcast: A gpodder.model.PodcastChannel instance
|
|
|
|
"""
|
|
|
|
pass
|
|
|
|
|
|
|
|
@call_extensions
|
|
|
|
def on_podcast_delete(self, podcast):
|
|
|
|
"""Called when a podcast is deleted from the database
|
|
|
|
|
|
|
|
@param podcast: A gpodder.model.PodcastChannel instance
|
|
|
|
"""
|
|
|
|
pass
|
|
|
|
|
|
|
|
@call_extensions
|
|
|
|
def on_episode_save(self, episode):
|
|
|
|
"""Called when an episode is saved to the database
|
|
|
|
|
|
|
|
This extension will be called when a new episode is added to the
|
|
|
|
database or when the state of an existing episode is changed.
|
|
|
|
|
|
|
|
@param episode: A gpodder.model.PodcastEpisode instance
|
|
|
|
"""
|
|
|
|
pass
|
|
|
|
|
|
|
|
@call_extensions
|
|
|
|
def on_episode_downloaded(self, episode):
|
|
|
|
"""Called when an episode has been downloaded
|
|
|
|
|
|
|
|
You can retrieve the filename via episode.local_filename(False)
|
|
|
|
|
|
|
|
@param episode: A gpodder.model.PodcastEpisode instance
|
|
|
|
"""
|
|
|
|
pass
|
|
|
|
|
|
|
|
@call_extensions
|
|
|
|
def on_all_episodes_downloaded(self):
|
|
|
|
"""Called when all episodes has been downloaded
|
|
|
|
"""
|
|
|
|
pass
|
|
|
|
|
|
|
|
@call_extensions
|
|
|
|
def on_episodes_context_menu(self, episodes):
|
|
|
|
"""Called when the episode list context menu is opened
|
|
|
|
|
|
|
|
You can add additional context menu entries here. You have to
|
|
|
|
return a list of tuples, where the first item is a label and
|
|
|
|
the second item is a callable that will get the episode as its
|
|
|
|
first and only parameter.
|
|
|
|
|
|
|
|
Example return value:
|
|
|
|
|
|
|
|
[('Mark as new', lambda episodes: ...)]
|
|
|
|
|
|
|
|
@param episodes: A list of gpodder.model.PodcastEpisode instances
|
|
|
|
"""
|
|
|
|
pass
|
|
|
|
|
|
|
|
@call_extensions
|
|
|
|
def on_episode_delete(self, episode, filename):
|
|
|
|
"""Called just before the episode's disk file is about to be
|
|
|
|
deleted."""
|
|
|
|
pass
|
|
|
|
|
|
|
|
@call_extensions
|
|
|
|
def on_episode_removed_from_podcast(self, episode):
|
|
|
|
"""Called just before the episode is about to be removed from
|
|
|
|
the podcast channel, e.g., when the episode has not been
|
|
|
|
downloaded and it disappears from the feed.
|
|
|
|
|
|
|
|
@param podcast: A gpodder.model.PodcastChannel instance
|
|
|
|
"""
|
|
|
|
pass
|
2012-02-14 22:58:32 +01:00
|
|
|
|
|
|
|
@call_extensions
|
|
|
|
def on_notification_show(self, title, message):
|
|
|
|
"""Called when a notification should be shown
|
|
|
|
|
|
|
|
@param title: title of the notification
|
|
|
|
@param message: message of the notification
|
|
|
|
"""
|
|
|
|
pass
|
2012-02-20 23:55:36 +01:00
|
|
|
|