Refactor CoverDownloader to gtkui.services

This makes the gpodder.services module GTK+-free, and
also removes the global state object cover_downloader.
This commit is contained in:
Thomas Perl 2009-08-24 16:47:59 +02:00
parent 26dda59ad9
commit 94513d4365
5 changed files with 160 additions and 168 deletions

View File

@ -28,7 +28,6 @@ import gpodder
_ = gpodder.gettext
from gpodder import util
from gpodder import services
from gpodder.gtkui import draw
@ -203,12 +202,13 @@ class PodcastListModel(gtk.ListStore):
C_URL, C_TITLE, C_DESCRIPTION, C_PILL, C_CHANNEL, \
C_COVER, C_ERROR, C_PILL_VISIBLE = range(8)
def __init__(self, max_image_side):
def __init__(self, max_image_side, cover_downloader):
gtk.ListStore.__init__(self, str, str, str, gtk.gdk.Pixbuf, \
object, gtk.gdk.Pixbuf, str, bool)
self._cover_cache = {}
self._max_image_side = max_image_side
self._cover_downloader = cover_downloader
def _resize_pixbuf_keep_ratio(self, url, pixbuf):
"""
@ -249,7 +249,10 @@ class PodcastListModel(gtk.ListStore):
return self._resize_pixbuf_keep_ratio(url, pixbuf) or pixbuf
def _get_cover_image(self, channel):
pixbuf = services.cover_downloader.get_cover(channel, avoid_downloading=True)
if self._cover_downloader is None:
return None
pixbuf = self._cover_downloader.get_cover(channel, avoid_downloading=True)
return self._resize_pixbuf(channel.url, pixbuf)
def _get_pill_image(self, channel):

View File

@ -22,11 +22,20 @@
# gpodder.gtkui.services - UI parts for the services module (2009-08-24)
#
import gpodder
import gpodder
_ = gpodder.gettext
from gpodder.services import ObservableService
from gpodder.liblogger import log
from gpodder import util
from gpodder import resolver
import gtk
import os
import threading
import urllib2
class DependencyModel(gtk.ListStore):
C_NAME, C_DESCRIPTION, C_AVAILABLE_TEXT, C_AVAILABLE, C_MISSING = range(5)
@ -55,3 +64,125 @@ class DependencyModel(gtk.ListStore):
self.append((feature_name, description, available_str, available, missing_str))
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.
"""
log('cover download request for %s', channel.url, sender=self)
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
new_url = resolver.get_real_cover(channel.url)
if new_url is not None:
url = new_url
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)
try:
fp = open(channel.cover_file, 'wb')
fp.write(image_data)
fp.close()
except IOError, ioe:
log('Cannot save image due to I/O error', sender=self, traceback=True)
if os.path.exists(channel.cover_file):
try:
loader.write(open(channel.cover_file, 'rb').read())
loader.close()
pixbuf = loader.get_pixbuf()
except:
log('Data error while loading %s', channel.cover_file, sender=self)
else:
try:
loader.close()
except:
pass
if async:
self.notify('cover-available', channel.url, pixbuf)
else:
return (channel.url, pixbuf)

View File

@ -98,6 +98,7 @@ from gpodder.gtkui.opml import OpmlListModel
from gpodder.gtkui.config import ConfigModel
from gpodder.gtkui.download import DownloadStatusModel
from gpodder.gtkui.services import DependencyModel
from gpodder.gtkui.services import CoverDownloader
from gpodder.libgpodder import db
from gpodder.libgpodder import gl
@ -565,8 +566,10 @@ class gPodder(BuilderWidget, dbus.service.Object):
namecolumn.add_attribute(iconcell, 'visible', PodcastListModel.C_PILL_VISIBLE)
self.treeChannels.append_column(namecolumn)
self.cover_downloader = CoverDownloader()
# Generate list models for podcasts and their episodes
self.podcast_list_model = PodcastListModel(gl.config.podcast_list_icon_size)
self.podcast_list_model = PodcastListModel(gl.config.podcast_list_icon_size, self.cover_downloader)
self.treeChannels.set_model(self.podcast_list_model)
self.episode_list_model = EpisodeListModel()
@ -715,8 +718,8 @@ class gPodder(BuilderWidget, dbus.service.Object):
if self.tray_icon and gl.config.minimize_to_tray:
self.tray_icon.set_visible(False)
services.cover_downloader.register('cover-available', self.cover_download_finished)
services.cover_downloader.register('cover-removed', self.cover_file_removed)
self.cover_downloader.register('cover-available', self.cover_download_finished)
self.cover_downloader.register('cover-removed', self.cover_file_removed)
self.treeDownloads.set_model(self.download_status_model)
self.download_tasks_seen = set()
@ -1864,7 +1867,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
result = sel.data
rl = result.strip().lower()
if (rl.endswith('.jpg') or rl.endswith('.png') or rl.endswith('.gif') or rl.endswith('.svg')) and dnd_channel is not None:
services.cover_downloader.replace_cover(dnd_channel, result)
self.cover_downloader.replace_cover(dnd_channel, result)
else:
self.add_new_channel(result)
@ -2076,6 +2079,10 @@ class gPodder(BuilderWidget, dbus.service.Object):
self.pbFeedUpdate.set_text(message)
def _update_cover(self, channel):
if not os.path.exists(channel.cover_file) and channel.image:
self.cover_downloader.request_cover(channel)
def update_feed_cache_proc(self, channels, select_url_afterwards):
total = len(channels)
@ -2083,6 +2090,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
if not self.feed_cache_update_cancelled:
try:
channel.update(max_episodes=gl.config.max_episodes_per_feed)
self._update_cover(channel)
# except feedcore.Offline:
# self.feed_cache_update_cancelled = True
# if not self.minimized:
@ -2796,7 +2804,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
self.show_message( message, title)
return
gPodderChannel(channel=self.active_channel, callback_closed=lambda: self.updateComboBox(only_selected_channel=True))
gPodderChannel(channel=self.active_channel, callback_closed=lambda: self.updateComboBox(only_selected_channel=True), cover_downloader=self.cover_downloader)
def on_itemRemoveChannel_activate(self, widget, *args):
try:
@ -3301,8 +3309,8 @@ class gPodderChannel(BuilderWidget):
if self.channel.password:
self.FeedPassword.set_text( self.channel.password)
services.cover_downloader.register('cover-available', self.cover_download_finished)
services.cover_downloader.request_cover(self.channel)
self.cover_downloader.register('cover-available', self.cover_download_finished)
self.cover_downloader.request_cover(self.channel)
# Hide the website button if we don't have a valid URL
if not self.channel.link:
@ -3332,12 +3340,12 @@ class gPodderChannel(BuilderWidget):
if dlg.run() == gtk.RESPONSE_OK:
url = dlg.get_uri()
services.cover_downloader.replace_cover(self.channel, url)
self.cover_downloader.replace_cover(self.channel, url)
dlg.destroy()
def on_btnClearCover_clicked(self, widget):
services.cover_downloader.replace_cover(self.channel)
self.cover_downloader.replace_cover(self.channel)
def cover_download_finished(self, channel_url, pixbuf):
if pixbuf is not None:
@ -3353,13 +3361,13 @@ class gPodderChannel(BuilderWidget):
file = files[0]
if file.startswith('file://') or file.startswith('http://'):
services.cover_downloader.replace_cover(self.channel, file)
self.cover_downloader.replace_cover(self.channel, file)
return
self.show_message( _('You can only drop local files and http:// URLs here.'), _('Drag and drop'))
def on_gPodderChannel_destroy(self, widget, *args):
services.cover_downloader.unregister('cover-available', self.cover_download_finished)
self.cover_downloader.unregister('cover-available', self.cover_download_finished)
def on_btnOK_clicked(self, widget, *args):
self.channel.sync_to_devices = not self.cbNoSync.get_active()

View File

@ -26,7 +26,6 @@
import gpodder
from gpodder import util
from gpodder import feedcore
from gpodder import services
from gpodder import resolver
from gpodder import corestats
@ -149,9 +148,6 @@ class PodcastChannel(PodcastModelObject):
return PodcastEpisode.create_from_dict(d, self)
def _consume_updated_feed(self, feed, max_episodes=0):
# update the cover if it's not there
self.update_cover()
self.parse_error = feed.get('bozo_exception', None)
self.title = feed.feed.get('title', self.url)
@ -172,8 +168,6 @@ class PodcastChannel(PodcastModelObject):
if hasattr(feed.feed.image, 'href') and feed.feed.image.href:
old = self.image
self.image = feed.feed.image.href
if old != self.image:
self.update_cover(force=True)
self.save()
@ -269,11 +263,6 @@ class PodcastChannel(PodcastModelObject):
self.db.commit()
def update_cover(self, force=False):
if self.cover_file is None or not os.path.exists(self.cover_file) or force:
if self.image is not None:
services.cover_downloader.request_cover(self)
def delete(self):
self.db.delete_channel(self)
@ -537,11 +526,9 @@ class PodcastChannel(PodcastModelObject):
def remove_downloaded( self):
shutil.rmtree( self.save_dir, True)
def get_cover_file( self):
# gets cover filename for cover download cache
return os.path.join( self.save_dir, 'cover')
cover_file = property(fget=get_cover_file)
@property
def cover_file(self):
return os.path.join(self.save_dir, 'cover')
def delete_episode_by_url(self, url):
episode = self.db.load_episode(url, factory=self.episode_factory)

View File

@ -24,8 +24,6 @@
#
#
from __future__ import with_statement
import gpodder
from gpodder.liblogger import log
@ -33,8 +31,6 @@ from gpodder import util
from gpodder import resolver
from gpodder import download
import gtk
import threading
import time
import urllib2
@ -126,136 +122,3 @@ dependency_manager = DependencyManager()
dependency_manager.depend_on(_('Bluetooth file transfer'), _('Send podcast episodes to Bluetooth devices. Needs Python Bluez bindings.'), ['bluetooth'], ['bluetooth-sendto'])
dependency_manager.depend_on(_('HTML episode shownotes'), _('Display episode shownotes in HTML format using GTKHTML2.'), ['gtkhtml2'], [])
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.
"""
log('cover download request for %s', channel.url, sender=self)
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
new_url = resolver.get_real_cover(channel.url)
if new_url is not None:
url = new_url
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)
try:
fp = open(channel.cover_file, 'wb')
fp.write(image_data)
fp.close()
except IOError, ioe:
log('Cannot save image due to I/O error', sender=self, traceback=True)
if os.path.exists(channel.cover_file):
try:
loader.write(open(channel.cover_file, 'rb').read())
loader.close()
pixbuf = loader.get_pixbuf()
except:
log('Data error while loading %s', channel.cover_file, sender=self)
else:
try:
loader.close()
except:
pass
# 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()