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
43 class ObservableService(object):
44 def __init__(self
, signal_names
=[]):
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
)
54 log('Observer already added to signal "%s".', signal_name
, sender
=self
)
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
)
63 log('Observer could not be removed from signal "%s".', signal_name
, sender
=self
)
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
)
72 log('Signal "%s" is not available for notification.', signal_name
, sender
=self
)
75 class DependencyManager(object):
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
):
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
94 for module
in modules
:
99 result
[module
] = False
100 all_available
= False
102 return (all_available
, result
)
104 def tools_available(self
, tools
):
106 See modules_available.
111 if util
.find_command(tool
):
115 all_available
= False
117 return (all_available
, result
)
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
128 available_str
= _('Available')
130 available_str
= _('Missing dependencies')
133 for module
in modules
:
134 if not module_info
[module
]:
135 missing_str
.append(_('Python module "%s" not installed') % module
)
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
])
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
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
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
)
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()
231 if not os
.path
.exists(channel
.cover_file
):
235 new_url
= resolver
.get_real_cover(channel
.url
)
236 if new_url
is not None:
242 log('Trying to download: %s', url
, sender
=self
)
244 image_data
= urllib2
.urlopen(url
).read()
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')
254 if os
.path
.exists(channel
.cover_file
):
256 loader
.write(open(channel
.cover_file
, 'rb').read())
258 pixbuf
= loader
.get_pixbuf()
260 log('Data error while loading %s', channel
.cover_file
, sender
=self
)
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')
276 self
.notify('cover-available', channel
.url
, pixbuf
)
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
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
:
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
:
380 # If we land here, we've acquired exactly the one we need
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):
394 self
.semaphore
.release()
396 def reserve_download_id( self
):
397 id = self
.next_status_id
398 self
.next_status_id
= id + 1
401 def remove_iter( self
, iter):
402 self
.tree_model
.remove( iter)
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
)
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
:
418 iter = self
.status_list
[id]['iter']
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
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
)
439 self
.notify('list-changed', [episode_url
], [channel_url
])
440 self
.notify_progress(force
=True)
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
):
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:
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
):
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
:
468 iter = self
.status_list
[id]['iter']
470 self
.tree_model_lock
.acquire()
471 for ( column
, key
) in self
.COLUMN_NAMES
.items():
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
:
497 thread
= self
.status_list
[element
]['thread']
501 if thread
is not None and thread
.url
== url
:
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
)
524 download_status_manager
= DownloadStatusManager()