2007-08-29 20:30:26 +02:00
|
|
|
# -*- coding: utf-8 -*-
|
2007-08-24 16:49:41 +02:00
|
|
|
#
|
2007-08-29 20:30:26 +02:00
|
|
|
# gPodder - A media aggregator and podcast client
|
2009-02-01 21:22:21 +01:00
|
|
|
# Copyright (c) 2005-2009 Thomas Perl and the gPodder Team
|
2007-08-24 16:49:41 +02:00
|
|
|
#
|
2007-08-29 20:30:26 +02:00
|
|
|
# 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.
|
2007-08-24 16:49:41 +02:00
|
|
|
#
|
2007-08-29 20:30:26 +02:00
|
|
|
# gPodder is distributed in the hope that it will be useful,
|
2007-08-24 16:49:41 +02:00
|
|
|
# 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
|
2007-08-29 20:30:26 +02:00
|
|
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
2007-08-24 16:49:41 +02:00
|
|
|
#
|
|
|
|
|
|
|
|
|
|
|
|
#
|
|
|
|
# services.py -- Core Services for gPodder
|
2007-08-24 17:30:06 +02:00
|
|
|
# Thomas Perl <thp@perli.net> 2007-08-24
|
2007-08-24 16:49:41 +02:00
|
|
|
#
|
|
|
|
#
|
|
|
|
|
2009-04-01 01:12:17 +02:00
|
|
|
from __future__ import with_statement
|
|
|
|
|
2007-08-24 16:49:41 +02:00
|
|
|
from gpodder.liblogger import log
|
2008-03-02 14:22:29 +01:00
|
|
|
from gpodder.libgpodder import gl
|
2007-08-24 16:49:41 +02:00
|
|
|
|
2008-01-15 14:54:22 +01:00
|
|
|
from gpodder import util
|
2008-10-13 17:07:01 +02:00
|
|
|
from gpodder import resolver
|
2009-04-01 13:05:53 +02:00
|
|
|
from gpodder import download
|
2007-08-24 16:49:41 +02:00
|
|
|
|
|
|
|
import gtk
|
|
|
|
import gobject
|
|
|
|
|
2009-04-01 01:12:17 +02:00
|
|
|
import collections
|
2007-08-24 16:49:41 +02:00
|
|
|
import threading
|
2008-08-04 14:17:01 +02:00
|
|
|
import time
|
2008-06-14 13:43:53 +02:00
|
|
|
import urllib2
|
|
|
|
import os
|
|
|
|
import os.path
|
2007-08-24 16:49:41 +02:00
|
|
|
|
|
|
|
|
2007-12-10 09:44:03 +01:00
|
|
|
class ObservableService(object):
|
|
|
|
def __init__(self, signal_names=[]):
|
|
|
|
self.observers = {}
|
|
|
|
for signal in signal_names:
|
|
|
|
self.observers[signal] = []
|
2007-08-24 16:49:41 +02:00
|
|
|
|
2007-12-10 09:44:03 +01:00
|
|
|
def register(self, signal_name, observer):
|
2007-08-24 16:49:41 +02:00
|
|
|
if signal_name in self.observers:
|
|
|
|
if not observer in self.observers[signal_name]:
|
2007-12-10 09:44:03 +01:00
|
|
|
self.observers[signal_name].append(observer)
|
2007-08-24 16:49:41 +02:00
|
|
|
else:
|
2007-12-10 09:44:03 +01:00
|
|
|
log('Observer already added to signal "%s".', signal_name, sender=self)
|
2007-08-24 16:49:41 +02:00
|
|
|
else:
|
2007-12-10 09:44:03 +01:00
|
|
|
log('Signal "%s" is not available for registration.', signal_name, sender=self)
|
2007-08-24 16:49:41 +02:00
|
|
|
|
2007-12-10 09:44:03 +01:00
|
|
|
def unregister(self, signal_name, observer):
|
2007-08-27 22:28:00 +02:00
|
|
|
if signal_name in self.observers:
|
|
|
|
if observer in self.observers[signal_name]:
|
2007-12-10 09:44:03 +01:00
|
|
|
self.observers[signal_name].remove(observer)
|
2007-08-27 22:28:00 +02:00
|
|
|
else:
|
2007-12-10 09:44:03 +01:00
|
|
|
log('Observer could not be removed from signal "%s".', signal_name, sender=self)
|
2007-08-27 22:28:00 +02:00
|
|
|
else:
|
2007-12-10 09:44:03 +01:00
|
|
|
log('Signal "%s" is not available for un-registration.', signal_name, sender=self)
|
2007-08-27 22:28:00 +02:00
|
|
|
|
2007-12-10 09:44:03 +01:00
|
|
|
def notify(self, signal_name, *args):
|
2007-08-24 16:49:41 +02:00
|
|
|
if signal_name in self.observers:
|
|
|
|
for observer in self.observers[signal_name]:
|
2008-01-15 14:54:22 +01:00
|
|
|
util.idle_add(observer, *args)
|
2007-08-24 16:49:41 +02:00
|
|
|
else:
|
2007-12-10 09:44:03 +01:00
|
|
|
log('Signal "%s" is not available for notification.', signal_name, sender=self)
|
2007-08-24 16:49:41 +02:00
|
|
|
|
|
|
|
|
2008-10-14 19:27:10 +02:00
|
|
|
class DependencyManager(object):
|
|
|
|
def __init__(self):
|
|
|
|
self.dependencies = []
|
|
|
|
|
|
|
|
def depend_on(self, feature_name, description, modules, tools):
|
|
|
|
self.dependencies.append([feature_name, description, modules, tools])
|
|
|
|
|
|
|
|
def modules_available(self, modules):
|
|
|
|
"""
|
|
|
|
Receives a list of modules and checks if each
|
|
|
|
of them is available. Returns a tuple with the
|
|
|
|
first item being a boolean variable that is True
|
|
|
|
when all required modules are available and False
|
|
|
|
otherwise. The second item is a dictionary that
|
|
|
|
lists every module as key with the available as
|
|
|
|
boolean value.
|
|
|
|
"""
|
|
|
|
result = {}
|
|
|
|
all_available = True
|
|
|
|
for module in modules:
|
|
|
|
try:
|
|
|
|
__import__(module)
|
|
|
|
result[module] = True
|
|
|
|
except:
|
|
|
|
result[module] = False
|
|
|
|
all_available = False
|
|
|
|
|
|
|
|
return (all_available, result)
|
|
|
|
|
|
|
|
def tools_available(self, tools):
|
|
|
|
"""
|
|
|
|
See modules_available.
|
|
|
|
"""
|
|
|
|
result = {}
|
|
|
|
all_available = True
|
|
|
|
for tool in tools:
|
|
|
|
if util.find_command(tool):
|
|
|
|
result[tool] = True
|
|
|
|
else:
|
|
|
|
result[tool] = False
|
|
|
|
all_available = False
|
|
|
|
|
|
|
|
return (all_available, result)
|
|
|
|
|
|
|
|
def get_model(self):
|
|
|
|
# Name, Description, Available (str), Available (bool), Missing (str)
|
|
|
|
model = gtk.ListStore(str, str, str, bool, str)
|
|
|
|
for feature_name, description, modules, tools in self.dependencies:
|
|
|
|
modules_available, module_info = self.modules_available(modules)
|
|
|
|
tools_available, tool_info = self.tools_available(tools)
|
|
|
|
|
|
|
|
available = modules_available and tools_available
|
|
|
|
if available:
|
|
|
|
available_str = _('Available')
|
|
|
|
else:
|
|
|
|
available_str = _('Missing dependencies')
|
|
|
|
|
|
|
|
missing_str = []
|
|
|
|
for module in modules:
|
|
|
|
if not module_info[module]:
|
|
|
|
missing_str.append(_('Python module "%s" not installed') % module)
|
|
|
|
for tool in tools:
|
|
|
|
if not tool_info[tool]:
|
|
|
|
missing_str.append(_('Command "%s" not installed') % tool)
|
|
|
|
missing_str = '\n'.join(missing_str)
|
|
|
|
|
|
|
|
model.append([feature_name, description, available_str, available, missing_str])
|
|
|
|
return model
|
|
|
|
|
|
|
|
|
|
|
|
dependency_manager = DependencyManager()
|
|
|
|
|
|
|
|
|
|
|
|
# Register non-module-specific dependencies here
|
|
|
|
dependency_manager.depend_on(_('Bluetooth file transfer'), _('Send podcast episodes to Bluetooth devices. Needs Python Bluez bindings.'), ['bluetooth'], ['bluetooth-sendto'])
|
|
|
|
dependency_manager.depend_on(_('Update tags on MP3 files'), _('Support the "Update tags after download" option for MP3 files.'), ['eyeD3'], [])
|
|
|
|
dependency_manager.depend_on(_('Update tags on OGG files'), _('Support the "Update tags after download" option for OGG files.'), [], ['vorbiscomment'])
|
2009-02-01 21:35:07 +01:00
|
|
|
dependency_manager.depend_on(_('HTML episode shownotes'), _('Display episode shownotes in HTML format using GTKHTML2.'), ['gtkhtml2'], [])
|
2008-10-14 19:27:10 +02:00
|
|
|
|
|
|
|
|
2008-06-14 13:43:53 +02:00
|
|
|
class CoverDownloader(ObservableService):
|
|
|
|
"""
|
|
|
|
This class manages downloading cover art and notification
|
|
|
|
of other parts of the system. Downloading cover art can
|
|
|
|
happen either synchronously via get_cover() or in
|
|
|
|
asynchronous mode via request_cover(). When in async mode,
|
|
|
|
the cover downloader will send the cover via the
|
|
|
|
'cover-available' message (via the ObservableService).
|
|
|
|
"""
|
|
|
|
|
|
|
|
# Maximum width/height of the cover in pixels
|
|
|
|
MAX_SIZE = 400
|
|
|
|
|
|
|
|
def __init__(self):
|
|
|
|
signal_names = ['cover-available', 'cover-removed']
|
|
|
|
ObservableService.__init__(self, signal_names)
|
|
|
|
|
|
|
|
def request_cover(self, channel, custom_url=None):
|
|
|
|
"""
|
|
|
|
Sends an asynchronous request to download a
|
|
|
|
cover for the specific channel.
|
|
|
|
|
|
|
|
After the cover has been downloaded, the
|
|
|
|
"cover-available" signal will be sent with
|
|
|
|
the channel url and new cover as pixbuf.
|
|
|
|
|
|
|
|
If you specify a custom_url, the cover will
|
|
|
|
be downloaded from the specified URL and not
|
|
|
|
taken from the channel metadata.
|
|
|
|
"""
|
2008-07-20 02:46:49 +02:00
|
|
|
log('cover download request for %s', channel.url, sender=self)
|
2008-06-14 13:43:53 +02:00
|
|
|
args = [channel, custom_url, True]
|
|
|
|
threading.Thread(target=self.__get_cover, args=args).start()
|
|
|
|
|
|
|
|
def get_cover(self, channel, custom_url=None, avoid_downloading=False):
|
|
|
|
"""
|
|
|
|
Sends a synchronous request to download a
|
|
|
|
cover for the specified channel.
|
|
|
|
|
|
|
|
The cover will be returned to the caller.
|
|
|
|
|
|
|
|
The custom_url has the same semantics as
|
|
|
|
in request_cover().
|
|
|
|
|
|
|
|
The optional parameter "avoid_downloading",
|
|
|
|
when true, will make sure we return only
|
|
|
|
already-downloaded covers and return None
|
|
|
|
when we have no cover on the local disk.
|
|
|
|
"""
|
|
|
|
(url, pixbuf) = self.__get_cover(channel, custom_url, False, avoid_downloading)
|
|
|
|
return pixbuf
|
|
|
|
|
|
|
|
def remove_cover(self, channel):
|
|
|
|
"""
|
|
|
|
Removes the current cover for the channel
|
|
|
|
so that a new one is downloaded the next
|
|
|
|
time we request the channel cover.
|
|
|
|
"""
|
|
|
|
util.delete_file(channel.cover_file)
|
|
|
|
self.notify('cover-removed', channel.url)
|
|
|
|
|
|
|
|
def replace_cover(self, channel, custom_url=None):
|
|
|
|
"""
|
|
|
|
This is a convenience function that deletes
|
|
|
|
the current cover file and requests a new
|
|
|
|
cover from the URL specified.
|
|
|
|
"""
|
|
|
|
self.remove_cover(channel)
|
|
|
|
self.request_cover(channel, custom_url)
|
|
|
|
|
|
|
|
def __get_cover(self, channel, url, async=False, avoid_downloading=False):
|
|
|
|
if not async and avoid_downloading and not os.path.exists(channel.cover_file):
|
|
|
|
return (channel.url, None)
|
|
|
|
|
|
|
|
loader = gtk.gdk.PixbufLoader()
|
|
|
|
pixbuf = None
|
|
|
|
|
|
|
|
if not os.path.exists(channel.cover_file):
|
|
|
|
if url is None:
|
|
|
|
url = channel.image
|
|
|
|
|
2008-10-13 17:07:01 +02:00
|
|
|
new_url = resolver.get_real_cover(channel.url)
|
|
|
|
if new_url is not None:
|
|
|
|
url = new_url
|
|
|
|
|
2008-06-14 13:43:53 +02:00
|
|
|
if url is not None:
|
|
|
|
image_data = None
|
|
|
|
try:
|
|
|
|
log('Trying to download: %s', url, sender=self)
|
|
|
|
|
|
|
|
image_data = urllib2.urlopen(url).read()
|
|
|
|
except:
|
|
|
|
log('Cannot get image from %s', url, sender=self)
|
|
|
|
|
|
|
|
if image_data is not None:
|
|
|
|
log('Saving image data to %s', channel.cover_file, sender=self)
|
|
|
|
fp = open(channel.cover_file, 'wb')
|
|
|
|
fp.write(image_data)
|
|
|
|
fp.close()
|
|
|
|
|
|
|
|
if os.path.exists(channel.cover_file):
|
|
|
|
try:
|
2008-07-28 10:53:01 +02:00
|
|
|
loader.write(open(channel.cover_file, 'rb').read())
|
2008-06-14 13:43:53 +02:00
|
|
|
loader.close()
|
|
|
|
pixbuf = loader.get_pixbuf()
|
|
|
|
except:
|
|
|
|
log('Data error while loading %s', channel.cover_file, sender=self)
|
|
|
|
else:
|
|
|
|
try:
|
|
|
|
loader.close()
|
|
|
|
except:
|
2008-06-14 15:57:34 +02:00
|
|
|
pass
|
2008-06-14 13:43:53 +02:00
|
|
|
|
|
|
|
if pixbuf is not None:
|
|
|
|
new_pixbuf = util.resize_pixbuf_keep_ratio(pixbuf, self.MAX_SIZE, self.MAX_SIZE)
|
|
|
|
if new_pixbuf is not None:
|
|
|
|
# Save the resized cover so we do not have to
|
|
|
|
# resize it next time we load it
|
|
|
|
new_pixbuf.save(channel.cover_file, 'png')
|
|
|
|
pixbuf = new_pixbuf
|
|
|
|
|
|
|
|
if async:
|
|
|
|
self.notify('cover-available', channel.url, pixbuf)
|
|
|
|
else:
|
|
|
|
return (channel.url, pixbuf)
|
|
|
|
|
|
|
|
cover_downloader = CoverDownloader()
|
|
|
|
|
|
|
|
|
2009-04-01 01:12:17 +02:00
|
|
|
class DownloadStatusManager(object):
|
|
|
|
# Types of columns, needed for creation of gtk.ListStore
|
|
|
|
COLUMNS = (object, str, str, int, str, str, str, str, str)
|
2008-05-14 15:38:06 +02:00
|
|
|
|
2009-04-01 01:12:17 +02:00
|
|
|
# Symbolic names for our columns, so we know what we're up to
|
|
|
|
C_TASK, C_NAME, C_URL, C_PROGRESS, C_PROGRESS_TEXT, C_SIZE_TEXT, \
|
|
|
|
C_ICON_NAME, C_SPEED_TEXT, C_STATUS_TEXT = range(len(COLUMNS))
|
2007-08-24 16:49:41 +02:00
|
|
|
|
2009-04-01 01:12:17 +02:00
|
|
|
def __init__(self):
|
|
|
|
self.__model = gtk.ListStore(*DownloadStatusManager.COLUMNS)
|
|
|
|
|
2009-04-01 13:05:53 +02:00
|
|
|
# Shorten the name (for below)
|
|
|
|
DownloadTask = download.DownloadTask
|
|
|
|
|
|
|
|
# Set up stock icon IDs for tasks
|
2009-04-01 01:12:17 +02:00
|
|
|
self.status_stock_ids = collections.defaultdict(lambda: None)
|
2009-04-01 13:05:53 +02:00
|
|
|
self.status_stock_ids[DownloadTask.DOWNLOADING] = gtk.STOCK_GO_DOWN
|
|
|
|
self.status_stock_ids[DownloadTask.DONE] = gtk.STOCK_APPLY
|
|
|
|
self.status_stock_ids[DownloadTask.FAILED] = gtk.STOCK_STOP
|
|
|
|
self.status_stock_ids[DownloadTask.CANCELLED] = gtk.STOCK_CANCEL
|
|
|
|
self.status_stock_ids[DownloadTask.PAUSED] = gtk.STOCK_MEDIA_PAUSE
|
2009-04-01 01:12:17 +02:00
|
|
|
|
|
|
|
def get_tree_model(self):
|
|
|
|
return self.__model
|
|
|
|
|
|
|
|
def request_update(self, iter, task=None):
|
|
|
|
if task is None:
|
|
|
|
# Ongoing update request from UI - get task from model
|
|
|
|
task = self.__model.get_value(iter, self.C_TASK)
|
2008-05-14 15:38:06 +02:00
|
|
|
else:
|
2009-04-01 01:12:17 +02:00
|
|
|
# Initial update request - update non-changing fields
|
|
|
|
self.__model.set(iter,
|
|
|
|
self.C_TASK, task,
|
|
|
|
self.C_NAME, str(task),
|
|
|
|
self.C_URL, task.url)
|
|
|
|
|
|
|
|
if task.status == task.FAILED:
|
|
|
|
status_message = _('Failed: %s') % (task.error_message,)
|
2008-12-13 13:29:45 +01:00
|
|
|
else:
|
2009-04-01 01:12:17 +02:00
|
|
|
status_message = task.STATUS_MESSAGE[task.status]
|
2008-12-13 13:29:45 +01:00
|
|
|
|
2009-04-01 01:12:17 +02:00
|
|
|
if task.status == task.DOWNLOADING:
|
|
|
|
speed_message = '%s/s' % util.format_filesize(task.speed)
|
2008-05-14 15:38:06 +02:00
|
|
|
else:
|
2009-04-01 01:12:17 +02:00
|
|
|
speed_message = ''
|
|
|
|
|
|
|
|
self.__model.set(iter,
|
|
|
|
self.C_PROGRESS, 100.*task.progress,
|
|
|
|
self.C_PROGRESS_TEXT, '%.0f%%' % (task.progress*100.,),
|
|
|
|
self.C_SIZE_TEXT, util.format_filesize(task.total_size),
|
|
|
|
self.C_ICON_NAME, self.status_stock_ids[task.status],
|
|
|
|
self.C_SPEED_TEXT, speed_message,
|
|
|
|
self.C_STATUS_TEXT, status_message)
|
|
|
|
|
|
|
|
def __add_new_task(self, task):
|
|
|
|
iter = self.__model.append()
|
|
|
|
self.request_update(iter, task)
|
|
|
|
|
|
|
|
def register_task(self, task):
|
|
|
|
util.idle_add(self.__add_new_task, task)
|
|
|
|
|
2009-04-02 00:33:49 +02:00
|
|
|
def tell_all_tasks_to_quit(self):
|
2009-04-01 01:12:17 +02:00
|
|
|
for row in self.__model:
|
|
|
|
task = row[DownloadStatusManager.C_TASK]
|
|
|
|
if task is not None:
|
2009-04-02 00:33:49 +02:00
|
|
|
# Pause currently-running (and queued) downloads
|
|
|
|
if task.status in (task.QUEUED, task.DOWNLOADING):
|
|
|
|
task.status = task.PAUSED
|
|
|
|
|
|
|
|
# Delete cancelled and failed downloads
|
|
|
|
if task.status in (task.CANCELLED, task.FAILED):
|
|
|
|
task.removed_from_list()
|
2009-04-01 01:12:17 +02:00
|
|
|
|
2009-04-01 13:34:19 +02:00
|
|
|
def are_downloads_in_progress(self):
|
|
|
|
"""
|
|
|
|
Returns True if there are any downloads in the
|
|
|
|
QUEUED or DOWNLOADING status, False otherwise.
|
|
|
|
"""
|
2009-04-01 01:12:17 +02:00
|
|
|
for row in self.__model:
|
|
|
|
task = row[DownloadStatusManager.C_TASK]
|
2009-04-01 13:34:19 +02:00
|
|
|
if task is not None and \
|
|
|
|
task.status in (task.DOWNLOADING, \
|
|
|
|
task.QUEUED):
|
2008-07-22 21:09:48 +02:00
|
|
|
return True
|
2008-07-22 21:09:48 +02:00
|
|
|
|
2007-08-24 16:49:41 +02:00
|
|
|
return False
|
|
|
|
|
2009-04-01 13:34:19 +02:00
|
|
|
def cancel_by_url(self, url):
|
|
|
|
for row in self.__model:
|
|
|
|
task = row[DownloadStatusManager.C_TASK]
|
|
|
|
if task is not None and task.url == url and \
|
|
|
|
task.status in (task.DOWNLOADING, \
|
|
|
|
task.QUEUED):
|
|
|
|
task.status = task.CANCELLED
|
|
|
|
return True
|
2007-08-24 16:49:41 +02:00
|
|
|
|
2009-04-01 13:34:19 +02:00
|
|
|
return False
|
2007-08-24 16:49:41 +02:00
|
|
|
|