Update the UI more efficiently, make it much faster
[gpodder.git] / src / gpodder / services.py
blob7d4e5f5f5e8f2e0fc6e3499d77b13c2ef623b604
1 # -*- coding: utf-8 -*-
3 # gPodder - A media aggregator and podcast client
4 # Copyright (c) 2005-2008 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/>.
22 # services.py -- Core Services for gPodder
23 # Thomas Perl <thp@perli.net> 2007-08-24
27 from gpodder.liblogger import log
28 from gpodder.libgpodder import gl
30 from gpodder import util
31 from gpodder import resolver
33 import gtk
34 import gobject
36 import threading
37 import time
38 import urllib2
39 import os
40 import os.path
43 class ObservableService(object):
44 def __init__(self, signal_names=[]):
45 self.observers = {}
46 for signal in signal_names:
47 self.observers[signal] = []
49 def register(self, signal_name, observer):
50 if signal_name in self.observers:
51 if not observer in self.observers[signal_name]:
52 self.observers[signal_name].append(observer)
53 else:
54 log('Observer already added to signal "%s".', signal_name, sender=self)
55 else:
56 log('Signal "%s" is not available for registration.', signal_name, sender=self)
58 def unregister(self, signal_name, observer):
59 if signal_name in self.observers:
60 if observer in self.observers[signal_name]:
61 self.observers[signal_name].remove(observer)
62 else:
63 log('Observer could not be removed from signal "%s".', signal_name, sender=self)
64 else:
65 log('Signal "%s" is not available for un-registration.', signal_name, sender=self)
67 def notify(self, signal_name, *args):
68 if signal_name in self.observers:
69 for observer in self.observers[signal_name]:
70 util.idle_add(observer, *args)
71 else:
72 log('Signal "%s" is not available for notification.', signal_name, sender=self)
75 class DependencyManager(object):
76 def __init__(self):
77 self.dependencies = []
79 def depend_on(self, feature_name, description, modules, tools):
80 self.dependencies.append([feature_name, description, modules, tools])
82 def modules_available(self, modules):
83 """
84 Receives a list of modules and checks if each
85 of them is available. Returns a tuple with the
86 first item being a boolean variable that is True
87 when all required modules are available and False
88 otherwise. The second item is a dictionary that
89 lists every module as key with the available as
90 boolean value.
91 """
92 result = {}
93 all_available = True
94 for module in modules:
95 try:
96 __import__(module)
97 result[module] = True
98 except:
99 result[module] = False
100 all_available = False
102 return (all_available, result)
104 def tools_available(self, tools):
106 See modules_available.
108 result = {}
109 all_available = True
110 for tool in tools:
111 if util.find_command(tool):
112 result[tool] = True
113 else:
114 result[tool] = False
115 all_available = False
117 return (all_available, result)
119 def get_model(self):
120 # Name, Description, Available (str), Available (bool), Missing (str)
121 model = gtk.ListStore(str, str, str, bool, str)
122 for feature_name, description, modules, tools in self.dependencies:
123 modules_available, module_info = self.modules_available(modules)
124 tools_available, tool_info = self.tools_available(tools)
126 available = modules_available and tools_available
127 if available:
128 available_str = _('Available')
129 else:
130 available_str = _('Missing dependencies')
132 missing_str = []
133 for module in modules:
134 if not module_info[module]:
135 missing_str.append(_('Python module "%s" not installed') % module)
136 for tool in tools:
137 if not tool_info[tool]:
138 missing_str.append(_('Command "%s" not installed') % tool)
139 missing_str = '\n'.join(missing_str)
141 model.append([feature_name, description, available_str, available, missing_str])
142 return model
145 dependency_manager = DependencyManager()
148 # Register non-module-specific dependencies here
149 dependency_manager.depend_on(_('Bluetooth file transfer'), _('Send podcast episodes to Bluetooth devices. Needs Python Bluez bindings.'), ['bluetooth'], ['bluetooth-sendto'])
150 dependency_manager.depend_on(_('Update tags on MP3 files'), _('Support the "Update tags after download" option for MP3 files.'), ['eyeD3'], [])
151 dependency_manager.depend_on(_('Update tags on OGG files'), _('Support the "Update tags after download" option for OGG files.'), [], ['vorbiscomment'])
154 class CoverDownloader(ObservableService):
156 This class manages downloading cover art and notification
157 of other parts of the system. Downloading cover art can
158 happen either synchronously via get_cover() or in
159 asynchronous mode via request_cover(). When in async mode,
160 the cover downloader will send the cover via the
161 'cover-available' message (via the ObservableService).
164 # Maximum width/height of the cover in pixels
165 MAX_SIZE = 400
167 def __init__(self):
168 signal_names = ['cover-available', 'cover-removed']
169 ObservableService.__init__(self, signal_names)
171 def request_cover(self, channel, custom_url=None):
173 Sends an asynchronous request to download a
174 cover for the specific channel.
176 After the cover has been downloaded, the
177 "cover-available" signal will be sent with
178 the channel url and new cover as pixbuf.
180 If you specify a custom_url, the cover will
181 be downloaded from the specified URL and not
182 taken from the channel metadata.
184 log('cover download request for %s', channel.url, sender=self)
185 args = [channel, custom_url, True]
186 threading.Thread(target=self.__get_cover, args=args).start()
188 def get_cover(self, channel, custom_url=None, avoid_downloading=False):
190 Sends a synchronous request to download a
191 cover for the specified channel.
193 The cover will be returned to the caller.
195 The custom_url has the same semantics as
196 in request_cover().
198 The optional parameter "avoid_downloading",
199 when true, will make sure we return only
200 already-downloaded covers and return None
201 when we have no cover on the local disk.
203 (url, pixbuf) = self.__get_cover(channel, custom_url, False, avoid_downloading)
204 return pixbuf
206 def remove_cover(self, channel):
208 Removes the current cover for the channel
209 so that a new one is downloaded the next
210 time we request the channel cover.
212 util.delete_file(channel.cover_file)
213 self.notify('cover-removed', channel.url)
215 def replace_cover(self, channel, custom_url=None):
217 This is a convenience function that deletes
218 the current cover file and requests a new
219 cover from the URL specified.
221 self.remove_cover(channel)
222 self.request_cover(channel, custom_url)
224 def __get_cover(self, channel, url, async=False, avoid_downloading=False):
225 if not async and avoid_downloading and not os.path.exists(channel.cover_file):
226 return (channel.url, None)
228 loader = gtk.gdk.PixbufLoader()
229 pixbuf = None
231 if not os.path.exists(channel.cover_file):
232 if url is None:
233 url = channel.image
235 new_url = resolver.get_real_cover(channel.url)
236 if new_url is not None:
237 url = new_url
239 if url is not None:
240 image_data = None
241 try:
242 log('Trying to download: %s', url, sender=self)
244 image_data = urllib2.urlopen(url).read()
245 except:
246 log('Cannot get image from %s', url, sender=self)
248 if image_data is not None:
249 log('Saving image data to %s', channel.cover_file, sender=self)
250 fp = open(channel.cover_file, 'wb')
251 fp.write(image_data)
252 fp.close()
254 if os.path.exists(channel.cover_file):
255 try:
256 loader.write(open(channel.cover_file, 'rb').read())
257 loader.close()
258 pixbuf = loader.get_pixbuf()
259 except:
260 log('Data error while loading %s', channel.cover_file, sender=self)
261 else:
262 try:
263 loader.close()
264 except:
265 pass
267 if pixbuf is not None:
268 new_pixbuf = util.resize_pixbuf_keep_ratio(pixbuf, self.MAX_SIZE, self.MAX_SIZE)
269 if new_pixbuf is not None:
270 # Save the resized cover so we do not have to
271 # resize it next time we load it
272 new_pixbuf.save(channel.cover_file, 'png')
273 pixbuf = new_pixbuf
275 if async:
276 self.notify('cover-available', channel.url, pixbuf)
277 else:
278 return (channel.url, pixbuf)
280 cover_downloader = CoverDownloader()
283 class DownloadStatusManager(ObservableService):
284 COLUMN_NAMES = { 0: 'episode', 1: 'speed', 2: 'progress', 3: 'url' }
285 COLUMN_TYPES = ( gobject.TYPE_STRING, gobject.TYPE_STRING, gobject.TYPE_FLOAT, gobject.TYPE_STRING )
286 PROGRESS_HOLDDOWN_TIMEOUT = 5
288 def __init__( self):
289 self.status_list = {}
290 self.next_status_id = 0
292 self.last_progress_status = (0, 0)
293 self.last_progress_update = 0
295 # use to correctly calculate percentage done
296 self.downloads_done_bytes = 0
298 self.max_downloads = gl.config.max_downloads
299 self.semaphore = threading.Semaphore( self.max_downloads)
301 self.tree_model = gtk.ListStore( *self.COLUMN_TYPES)
302 self.tree_model_lock = threading.Lock()
304 # batch add in progress?
305 self.batch_mode_enabled = False
306 # remember which episodes and channels changed during batch mode
307 self.batch_mode_changed_episode_urls = set()
308 self.batch_mode_changed_channel_urls = set()
310 # Used to notify all threads that they should
311 # re-check if they can acquire the lock
312 self.notification_event = threading.Event()
313 self.notification_event_waiters = 0
315 signal_names = ['list-changed', 'progress-changed', 'progress-detail', 'download-complete']
316 ObservableService.__init__(self, signal_names)
318 def start_batch_mode(self):
320 This is called when we are going to add multiple
321 episodes to our download list, and do not want to
322 notify the GUI for every single episode.
324 After all episodes have been added, you MUST call
325 the end_batch_mode() method to trigger a notification.
327 self.batch_mode_enabled = True
329 def end_batch_mode(self):
331 This is called after multiple episodes have been
332 added when start_batch_mode() has been called before.
334 This sends out a notification that the list has changed.
336 self.batch_mode_enabled = False
337 if len(self.batch_mode_changed_episode_urls) + len(self.batch_mode_changed_channel_urls) > 0:
338 self.notify('list-changed', self.batch_mode_changed_episode_urls, self.batch_mode_changed_channel_urls)
339 self.batch_mode_changed_episode_urls = set()
340 self.batch_mode_changed_channel_urls = set()
342 def notify_progress(self, force=False):
343 now = (self.count(), self.average_progress())
345 next_progress_update = self.last_progress_update + self.PROGRESS_HOLDDOWN_TIMEOUT
347 if force or (now != self.last_progress_status and \
348 time.time() > next_progress_update):
349 self.notify( 'progress-changed', *now)
350 self.last_progress_status = now
351 self.last_progress_update = time.time()
353 def s_acquire( self):
354 if not gl.config.max_downloads_enabled:
355 return False
357 # Acquire queue slots if user has decreased the slots
358 while self.max_downloads > gl.config.max_downloads:
359 self.semaphore.acquire()
360 self.max_downloads -= 1
362 # Make sure we update the maximum number of downloads
363 self.update_max_downloads()
365 while self.semaphore.acquire(False) == False:
366 self.notification_event_waiters += 1
367 self.notification_event.wait(2.)
368 self.notification_event_waiters -= 1
370 # If we are the last thread that woke up from
371 # the notification_event, clear the flag here
372 if self.notification_event_waiters == 0:
373 self.notification_event.clear()
375 # If the user has change the config option since the
376 # last time we checked, return false and start download
377 if not gl.config.max_downloads_enabled:
378 return False
380 # If we land here, we've acquired exactly the one we need
381 return True
383 def update_max_downloads(self):
384 # Release queue slots if user has enabled more slots
385 while self.max_downloads < gl.config.max_downloads:
386 self.semaphore.release()
387 self.max_downloads += 1
389 # Notify all threads that the limit might have been changed
390 self.notification_event.set()
392 def s_release( self, acquired = True):
393 if acquired:
394 self.semaphore.release()
396 def reserve_download_id( self):
397 id = self.next_status_id
398 self.next_status_id = id + 1
399 return id
401 def remove_iter( self, iter):
402 self.tree_model.remove( iter)
403 return False
405 def register_download_id( self, id, thread):
406 self.tree_model_lock.acquire()
407 self.status_list[id] = { 'iter': self.tree_model.append(), 'thread': thread, 'progress': 0.0, 'speed': _('Queued'), }
408 if self.batch_mode_enabled:
409 self.batch_mode_changed_episode_urls.add(thread.episode.url)
410 self.batch_mode_changed_channel_urls.add(thread.channel.url)
411 else:
412 self.notify('list-changed', [thread.episode.url], [thread.channel.url])
413 self.tree_model_lock.release()
415 def remove_download_id( self, id):
416 if not id in self.status_list:
417 return
418 iter = self.status_list[id]['iter']
419 if iter is not None:
420 self.tree_model_lock.acquire()
421 util.idle_add(self.remove_iter, iter)
422 self.tree_model_lock.release()
423 self.status_list[id]['iter'] = None
424 episode_url = self.status_list[id]['thread'].episode.url
425 channel_url = self.status_list[id]['thread'].channel.url
426 self.status_list[id]['thread'].cancel()
427 del self.status_list[id]
428 if not self.has_items():
429 # Reset the counter now
430 self.downloads_done_bytes = 0
431 else:
432 episode_url = None
433 channel_url = None
435 if self.batch_mode_enabled:
436 self.batch_mode_changed_episode_urls.add(episode_url)
437 self.batch_mode_changed_channel_urls.add(channel_url)
438 else:
439 self.notify('list-changed', [episode_url], [channel_url])
440 self.notify_progress(force=True)
442 def count( self):
443 return len(self.status_list)
445 def has_items( self):
446 return self.count() > 0
448 def average_progress( self):
449 if not len(self.status_list):
450 return 0
452 done = sum(status['progress']/100. * status['thread'].total_size for status in self.status_list.values())
453 total = sum(status['thread'].total_size for status in self.status_list.values())
454 if total + self.downloads_done_bytes == 0:
455 return 0
456 return float(done + self.downloads_done_bytes) / float(total + self.downloads_done_bytes) * 100
458 def total_speed(self):
459 if not len(self.status_list):
460 return 0
462 return sum(status['thread'].speed_value for status in self.status_list.values())
464 def update_status( self, id, **kwargs):
465 if not id in self.status_list:
466 return
468 iter = self.status_list[id]['iter']
469 if iter:
470 self.tree_model_lock.acquire()
471 for ( column, key ) in self.COLUMN_NAMES.items():
472 if key in kwargs:
473 util.idle_add(self.tree_model.set, iter, column, kwargs[key])
474 self.status_list[id][key] = kwargs[key]
475 self.tree_model_lock.release()
477 if 'progress' in kwargs and 'speed' in kwargs and 'url' in self.status_list[id]:
478 self.notify( 'progress-detail', self.status_list[id]['url'], kwargs['progress'], kwargs['speed'])
480 self.notify_progress()
482 def download_completed(self, id):
483 if id in self.status_list:
484 self.notify('download-complete', self.status_list[id]['episode'])
485 self.downloads_done_bytes += self.status_list[id]['thread'].total_size
487 def request_progress_detail( self, url):
488 for status in self.status_list.values():
489 if 'url' in status and status['url'] == url and 'progress' in status and 'speed' in status:
490 self.notify( 'progress-detail', url, status['progress'], status['speed'])
492 def is_download_in_progress( self, url):
493 for element in self.status_list.keys():
494 # We need this, because status_list is modified from other threads
495 if element in self.status_list:
496 try:
497 thread = self.status_list[element]['thread']
498 except:
499 thread = None
501 if thread is not None and thread.url == url:
502 return True
504 return False
506 def cancel_all( self):
507 for element in self.status_list:
508 self.status_list[element]['iter'] = None
509 self.status_list[element]['thread'].cancel()
510 # clear the tree model after cancelling
511 util.idle_add(self.tree_model.clear)
512 self.downloads_done_bytes = 0
514 def cancel_by_url( self, url):
515 for element in self.status_list:
516 thread = self.status_list[element]['thread']
517 if thread is not None and thread.url == url:
518 self.remove_download_id( element)
519 return True
521 return False
524 download_status_manager = DownloadStatusManager()