1 # -*- coding: utf-8 -*-
3 # gPodder - A media aggregator and podcast client
4 # Copyright (c) 2005-2009 Thomas Perl and the gPodder Team
6 # gPodder is free software; you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation; either version 3 of the License, or
9 # (at your option) any later version.
11 # gPodder is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU General Public License for more details.
16 # You should have received a copy of the GNU General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
20 Loads and executes user extensions
22 Extensions are Python scripts in "$GPODDER_HOME/Extensions". Each script must
23 define a class named "gPodderExtensions", otherwise it will be ignored.
25 The extensions class defines several callbacks that will be called by gPodder
26 at certain points. See the methods defined below for a list of callbacks and
29 For an example extension see examples/extensions.py
42 from datetime
import datetime
45 from gpodder
import util
46 from gpodder
.jsonconfig
import JsonConfig
49 logger
= logging
.getLogger(__name__
)
51 # The class name that has to appear in a extension module
52 EXTENSION_CLASS
= 'gPodderExtension'
54 # The variable name that stores the extensions parameters
55 EXTENSION_PARAMS
= 'PARAMS'
57 # The variable name that stores the extensions parameters
58 EXTENSION_CONFIG
= 'DEFAULT_CONFIG'
60 # The variable name the directory where the extensions are stored
61 EXTENSION_FOLDER
= 'gpodder_extensions'
64 def call_extensions(func
):
65 """Decorator to create handler functions in ExtensionManager
67 Calls the specified function in all user extensions that define it.
69 method_name
= func
.__name
__
71 @functools.wraps(func
)
72 def handler(self
, *args
, **kwargs
):
74 for extension_container
, state
in self
.modules
:
77 callback
= getattr(extension_container
.module
, method_name
, None)
78 if callback
is not None:
79 # If the results are lists, concatenate them to show all
80 # possible items that are generated by all extension together
81 cb_res
= callback(*args
, **kwargs
)
82 if isinstance(result
, list) and isinstance(cb_res
, list):
84 elif cb_res
is not None:
87 logger
.error('Error in %s, function %s: %s', extension_container
.extension_file
,
88 method_name
, e
, exc_info
=True)
89 func(self
, *args
, **kwargs
)
95 class ExtensionParent(object):
96 """Super class for every extension"""
98 def __init__(self
, **kwargs
):
99 self
.metadata
= kwargs
.get('metadata', None)
100 self
.config
= kwargs
.get('config', None)
101 self
.context_menu_callback
= None
104 # this code is needed when running the extensions unittests
105 if isinstance(self
.config
, dict) and self
.metadata
:
106 self
.config
= JsonConfig(data
=json
.dumps(self
.config
))
107 self
.config
= getattr(self
.config
.extensions
, self
.metadata
['id'])
109 def read_metadata(self
):
114 if self
.metadata
is not None:
115 self
.id = self
.metadata
.get('id', None)
116 self
.name
= self
.metadata
.get('name', None)
117 self
.desc
= self
.metadata
.get('desc', None)
119 def check_command(self
, cmd
):
120 """Check if a command line command/program exists"""
122 # Prior to Python 2.7.3, this module (shlex) did not support Unicode input.
123 cmd
= util
.sanitize_encoding(cmd
)
124 program
= shlex
.split(cmd
)[0]
125 if util
.find_command(program
) is None:
126 raise ImportError("Couldn't find program '%s'" % program
)
128 def notify_action(self
, action
, episode
):
129 """method to simple use the notification system"""
131 if self
.config
is None or not self
.config
.enable_notifications
:
134 name
= 'gPodder-Extension'
135 if self
.name
is not None:
136 name
= '%s: %s' % (name
, self
.name
)
139 msg
= "%s(%s): '%s/%s'" % (action
, now
.strftime('%x %X'),
140 episode
.channel
.title
, episode
.title
)
142 gpodder
.user_extensions
.on_notification_show(name
, msg
)
144 def _show_context_menu(self
, episodes
):
145 """return if a context menu entry should be displayed"""
148 def on_episodes_context_menu(self
, episodes
):
149 """add context menu entry for a specific extension"""
151 if self
.name
is None:
154 if not self
._show
_context
_menu
(episodes
) or self
.context_menu_callback
is None:
157 return [(self
.name
, self
.context_menu_callback
)]
159 def rename_episode_file(self
, episode
, filename
):
160 """method for simple update an episode with filename information"""
162 if not os
.path
.exists(filename
):
165 basename
, extension
= os
.path
.splitext(filename
)
167 episode
.download_filename
= os
.path
.basename(filename
)
168 episode
.file_size
= os
.path
.getsize(filename
)
169 episode
.mime_type
= util
.mimetype_from_extension(extension
)
173 def get_filename(self
, episode
):
174 filename
= episode
.local_filename(create
=False, check_only
=True)
175 if filename
is not None and os
.path
.exists(filename
):
181 class ExtensionContainer(object):
182 """A class which manage one extension"""
184 def __init__(self
, config
=None, filename
=None, module
=None):
185 self
.extension_file
= filename
187 self
._gpo
_config
= config
192 if filename
is not None and module
is None:
193 self
.metadata
= self
._load
_metadata
(filename
)
194 elif filename
is None and module
is not None:
197 logger
.error("ExtensionContainer couldn't initialize successfully")
199 def _load_module(self
, filename
):
200 basename
, extension
= os
.path
.splitext(os
.path
.basename(filename
))
201 return imp
.load_module(basename
, file(filename
, 'r'),
202 filename
, (extension
, 'r', imp
.PY_SOURCE
))
204 def _load_metadata(self
, filename
):
205 extension_py
= open(filename
).read()
206 return dict(re
.findall("__([a-z]+)__ = '([^']+)'", extension_py
))
208 def _load_user_prefs(self
, module_file
):
209 if not self
.metadata
['id'] in self
._gpo
_config
.extensions
.keys():
210 config
= getattr(module_file
, EXTENSION_CONFIG
, None)
211 if config
is not None:
212 self
._gpo
_config
.register_defaults(config
)
214 return getattr(self
._gpo
_config
.extensions
, self
.metadata
['id'])
216 def load_extension(self
):
217 """Load a Python module by filename
219 Returns an instance of the EXTENSION_CLASS class defined
220 in the module, or None if the module does not contain
224 module_file
= self
._load
_module
(self
.extension_file
)
225 self
.config
= self
._load
_user
_prefs
(module_file
)
226 self
.params
= getattr(module_file
, EXTENSION_PARAMS
, None)
228 extension_class
= getattr(module_file
, EXTENSION_CLASS
, None)
229 self
.module
= extension_class(
230 metadata
=self
.metadata
,
234 logger
.info('Module loaded: %s', self
.extension_file
)
236 logger
.error('Cannot load %s: %s', self
.extension_file
, e
, exc_info
=True)
239 def revert_settings(self
):
240 """revert stored extension settings to the default values"""
242 if self
.metadata
['id'] in self
._gpo
_config
.extensions
.keys():
243 del self
._gpo
_config
.extensions
[self
.metadata
['id']]
245 module_file
= self
._load
_module
(self
.extension_file
)
247 config
= getattr(module_file
, EXTENSION_CONFIG
, None)
248 if config
is not None:
249 self
._gpo
_config
.register_defaults(config
)
250 self
._gpo
_config
.save()
253 class ExtensionManager(object):
254 """Manager class for the extensions
256 This class loads all available extensions from the filesystem
257 it also holds all "extension"-methods which can be used in an
261 DISABLED
, ENABLED
= range(2)
262 EXTENSIONCONTAINER
, STATE
= range(2)
264 def __init__(self
, config
):
266 self
._config
= config
267 enabled_extensions
= self
._config
.extensions
.enabled
269 pathname
= os
.path
.join(os
.path
.abspath(gpodder
.__path
__[0]),
270 EXTENSION_FOLDER
, '*.py')
271 for extension_file
in glob
.glob(pathname
):
272 extension_container
= ExtensionContainer(
273 config
=self
._config
, filename
=extension_file
)
275 state
= self
.DISABLED
276 if extension_container
.metadata
:
277 extension_id
= extension_container
.metadata
['id']
278 if extension_id
in enabled_extensions
:
279 error
= extension_container
.load_extension()
283 state
= self
.DISABLED
284 enabled_extensions
.remove(extension_id
)
286 self
.modules
.append((extension_container
, state
, ))
288 def register_extension(self
, obj
):
289 """Register an object that implements some extensions."""
291 self
.modules
.append((ExtensionContainer(module
=obj
), self
.ENABLED
))
293 def unregister_extension(self
, obj
):
294 """Unregister a previously registered object."""
296 extension_module
= (ExtensionContainer(module
=obj
), self
.ENABLED
)
297 if extension_module
in self
.modules
:
298 self
.modules
.remove(extension_module
)
300 logger
.warn('Unregistered extension which was not registered.')
302 def get_extensions(self
):
303 """returns a list of all loaded extensions with the enabled/disable state"""
305 enabled_extensions
= self
._config
.extensions
.enabled
306 for index
, (extension_container
, enabled
) in enumerate(self
.modules
):
307 if extension_container
.metadata
:
308 if extension_container
.metadata
['id'] in enabled_extensions
:
309 enabled
= self
.ENABLED
311 enabled
= self
.DISABLED
312 self
.modules
[index
] = (extension_container
, enabled
, )
316 # Define all known handler functions here, decorate them with the
317 # "call_extension" decorator to forward all calls to extension scripts that have
318 # the same function defined in them. If the handler functions here contain
319 # any code, it will be called after all the extensions have been called.
322 def on_ui_initialized(self
, model
, update_podcast_callback
,
323 download_episode_callback
):
324 """Called when the user interface is initialized.
326 @param model: A gpodder.model.Model instance
327 @param update_podcast_callback: Function to update a podcast feed
328 @param download_episode_callback: Function to download an episode
333 def on_podcast_subscribe(self
, podcast
):
334 """Called when the user subscribes to a new podcast feed.
336 @param podcast: A gpodder.model.PodcastChannel instance
341 def on_podcast_updated(self
, podcast
):
342 """Called when a podcast feed was updated
344 This extension will be called even if there were no new episodes.
346 @param podcast: A gpodder.model.PodcastChannel instance
351 def on_podcast_update_failed(self
, podcast
, exception
):
352 """Called when a podcast update failed.
354 @param podcast: A gpodder.model.PodcastChannel instance
356 @param exception: The reason.
361 def on_podcast_save(self
, podcast
):
362 """Called when a podcast is saved to the database
364 This extensions will be called when the user edits the metadata of
365 the podcast or when the feed was updated.
367 @param podcast: A gpodder.model.PodcastChannel instance
372 def on_podcast_delete(self
, podcast
):
373 """Called when a podcast is deleted from the database
375 @param podcast: A gpodder.model.PodcastChannel instance
380 def on_episode_save(self
, episode
):
381 """Called when an episode is saved to the database
383 This extension will be called when a new episode is added to the
384 database or when the state of an existing episode is changed.
386 @param episode: A gpodder.model.PodcastEpisode instance
391 def on_episode_downloaded(self
, episode
):
392 """Called when an episode has been downloaded
394 You can retrieve the filename via episode.local_filename(False)
396 @param episode: A gpodder.model.PodcastEpisode instance
401 def on_all_episodes_downloaded(self
):
402 """Called when all episodes has been downloaded
407 def on_episodes_context_menu(self
, episodes
):
408 """Called when the episode list context menu is opened
410 You can add additional context menu entries here. You have to
411 return a list of tuples, where the first item is a label and
412 the second item is a callable that will get the episode as its
413 first and only parameter.
415 Example return value:
417 [('Mark as new', lambda episodes: ...)]
419 @param episodes: A list of gpodder.model.PodcastEpisode instances
424 def on_episode_delete(self
, episode
, filename
):
425 """Called just before the episode's disk file is about to be
430 def on_episode_removed_from_podcast(self
, episode
):
431 """Called just before the episode is about to be removed from
432 the podcast channel, e.g., when the episode has not been
433 downloaded and it disappears from the feed.
435 @param podcast: A gpodder.model.PodcastChannel instance
440 def on_notification_show(self
, title
, message
):
441 """Called when a notification should be shown
443 @param title: title of the notification
444 @param message: message of the notification