Initial work on the new download manager code

This is still a work-in-progress, and many things
have been broken by introducing it, but the new
code is easier to understand and maintain, and
should also prove performance-enhancing on Maemo.

Last but not least, when it's done, it will fix
these bugs: 242, 361 (http://bugs.gpodder.org)
This commit is contained in:
Thomas Perl 2009-04-01 01:12:17 +02:00
parent 9a3c45f082
commit e3a8795a3e
9 changed files with 748 additions and 536 deletions

View File

@ -199,7 +199,7 @@
</child>
<child>
<widget class="GtkMenuItem" id="advanced1">
<widget class="GtkMenuItem" id="menuSubscriptions">
<property name="visible">True</property>
<property name="label" translatable="yes">_Subscriptions</property>
<property name="use_underline">True</property>

View File

@ -99,8 +99,13 @@ def run():
for channel in channels:
for episode in channel.get_new_episodes():
msg( 'downloading', urllib.unquote( episode.url))
# Calling run() calls the code in the current thread
download.DownloadThread( channel, episode).run()
task = download.DownloadTask(episode)
task.status = download.DownloadTask.QUEUED
task.run()
if task.status == task.DONE:
msg('done', 'Finished.')
elif task.status == task.FAILED:
msg('failed', 'Download error: %s' % task.error_message)
new_episodes += 1
if new_episodes == 0:

View File

@ -25,6 +25,8 @@
# Based on libwget.py (2005-10-29)
#
from __future__ import with_statement
from gpodder.liblogger import log
from gpodder.libgpodder import gl
from gpodder.dbsqlite import db
@ -39,6 +41,7 @@ import shutil
import os.path
import os
import time
import collections
from xml.sax import saxutils
@ -158,212 +161,321 @@ class DownloadURLOpener(urllib.FancyURLopener):
return ( None, None )
class DownloadThread(threading.Thread):
MAX_UPDATES_PER_SEC = 1
class DownloadQueueWorker(threading.Thread):
def __init__(self, queue, exit_callback):
threading.Thread.__init__(self)
self.queue = queue
self.exit_callback = exit_callback
self.cancelled = False
def __init__( self, channel, episode, notification = None):
threading.Thread.__init__( self)
self.setDaemon( True)
def stop_accepting_tasks(self):
"""
When this is called, the worker will not accept new tasks,
but quit when the current task has been finished.
"""
if not self.cancelled:
self.cancelled = True
log('%s stopped accepting tasks.', self.getName(), sender=self)
if gpodder.interface == gpodder.MAEMO:
# Only update status every 3 seconds on Maemo
self.MAX_UPDATES_PER_SEC = 1./3.
def run(self):
log('Running new thread: %s', self.getName(), sender=self)
while not self.cancelled:
try:
task = self.queue.pop()
log('%s is processing: %s', self.getName(), task, sender=self)
task.run()
except IndexError, e:
log('No more tasks for %s to carry out.', self.getName(), sender=self)
break
self.exit_callback(self)
self.channel = channel
self.episode = episode
self.notification = notification
class DownloadQueueManager(object):
def __init__(self, download_status_manager):
self.download_status_manager = download_status_manager
self.tasks = collections.deque()
self.url = self.episode.url
self.filename = self.episode.local_filename(create=True)
# Commit the database, so we won't lose the (possibly created) filename
self.worker_threads_access = threading.RLock()
self.worker_threads = []
def __exit_callback(self, worker_thread):
with self.worker_threads_access:
self.worker_threads.remove(worker_thread)
def spawn_and_retire_threads(self, request_new_thread=False):
with self.worker_threads_access:
if len(self.worker_threads) > gl.config.max_downloads and \
gl.config.max_downloads_enabled:
# Tell the excessive amount of oldest worker threads to quit, but keep at least one
count = min(len(self.worker_threads)-1, len(self.worker_threads)-gl.config.max_downloads)
for worker in self.worker_threads[:count]:
worker.stop_accepting_tasks()
if request_new_thread and (len(self.worker_threads) == 0 or \
len(self.worker_threads) < gl.config.max_downloads or \
not gl.config.max_downloads_enabled):
# We have to create a new thread here, there's work to do
log('I am going to spawn a new worker thread.', sender=self)
worker = DownloadQueueWorker(self.tasks, self.__exit_callback)
self.worker_threads.append(worker)
worker.start()
def add_resumed_task(self, task):
"""Simply add the task without starting the download"""
self.download_status_manager.register_task(task)
def add_task(self, task):
if task.status == DownloadTask.INIT:
# This task is fresh, so add it to our status manager
self.download_status_manager.register_task(task)
task.status = DownloadTask.QUEUED
self.tasks.appendleft(task)
self.spawn_and_retire_threads(request_new_thread=True)
class DownloadTask(object):
"""An object representing the download task of an episode
You can create a new download task like this:
task = DownloadTask(episode)
task.status = DownloadTask.QUEUED
task.run()
While the download is in progress, you can access its properties:
task.total_size # in bytes
task.progress # from 0.0 to 1.0
task.speed # in bytes per second
str(task) # name of the episode
task.status # current status
You can cancel a running download task by setting its status:
task.status = DownloadTask.CANCELLED
The task will then abort as soon as possible (due to the nature
of downloading data, this can take a while when the Internet is
busy).
While the download is taking place and after the .run() method
has finished, you can get the final status to check if the download
was successful:
if task.status == DownloadTask.DONE:
# .. everything ok ..
elif task.status == DownloadTask.FAILED:
# .. an error happened, and the
# error_message attribute is set ..
print task.error_message
elif task.status == DownloadTask.PAUSED:
# .. user paused the download ..
elif task.status == DownloadTask.CANCELLED:
# .. user cancelled the download ..
The difference between cancelling and pausing a DownloadTask is
that the temporary file gets deleted when cancelling, but does
not get deleted when pausing.
Be sure to call .removed_from_list() on this task when removing
it from the UI, so that it can carry out any pending clean-up
actions (e.g. removing the temporary file when the task has not
finished successfully; i.e. task.status != DownloadTask.DONE).
"""
# Possible states this download task can be in
STATUS_MESSAGE = (_('Added'), _('Queued'), _('Downloading'),
_('Finished'), _('Failed'), _('Cancelled'), _('Paused'))
(INIT, QUEUED, DOWNLOADING, DONE, FAILED, CANCELLED, PAUSED) = range(7)
def __str__(self):
return self.__episode.title
def __get_status(self):
return self.__status
def __set_status(self, status):
self.__status = status
status = property(fget=__get_status, fset=__set_status)
def __get_url(self):
return self.__episode.url
url = property(fget=__get_url)
def removed_from_list(self):
if self.status != self.DONE:
util.delete_file(self.tempname)
def __init__(self, episode):
self.__status = DownloadTask.INIT
self.__episode = episode
# Create the target filename and save it in the database
self.filename = self.__episode.local_filename(create=True)
self.tempname = self.filename + '.partial'
db.commit()
self.tempname = self.filename + '.partial'
# Make an educated guess about the total file size
self.total_size = self.episode.length
self.cancelled = False
self.keep_files = False
self.start_time = 0.0
self.speed = _('Queued')
self.speed_value = 0
self.total_size = self.__episode.length
self.speed = 0.0
self.progress = 0.0
self.downloader = DownloadURLOpener( self.channel)
self.last_update = 0.0
self.error_message = None
# Keep a copy of these global variables for comparison later
self.limit_rate_value = gl.config.limit_rate_value
self.limit_rate = gl.config.limit_rate
self.start_blocks = 0
def cancel(self, keep_files=False):
self.cancelled = True
self.keep_files = keep_files
def status_updated( self, count, blockSize, totalSize):
if totalSize:
# We see a different "total size" while downloading,
# so correct the total size variable in the thread
if totalSize != self.total_size and totalSize > 0:
log('Correcting file size for %s from %d to %d while downloading.', self.url, self.total_size, totalSize, sender=self)
self.total_size = totalSize
elif totalSize < 0:
# The current download has a negative value, so assume
# the total size given from the feed is correct
totalSize = self.total_size
try:
self.progress = 100.0*float(count*blockSize)/float(totalSize)
except ZeroDivisionError, zde:
log('Totalsize unknown, cannot determine progress.', sender=self)
self.progress = 100.0
else:
self.progress = 100.0
# Sanity checks for "progress" in valid range (0..100)
if self.progress < 0.0:
log('Warning: Progress is lower than 0 (count=%d, blockSize=%d, totalSize=%d)', count, blockSize, totalSize, sender=self)
self.progress = 0.0
elif self.progress > 100.0:
log('Warning: Progress is more than 100 (count=%d, blockSize=%d, totalSize=%d)', count, blockSize, totalSize, sender=self)
self.progress = 100.0
self.calculate_speed( count, blockSize)
if self.last_update < time.time() - (1.0 / self.MAX_UPDATES_PER_SEC):
services.download_status_manager.update_status( self.download_id, speed = self.speed, progress = self.progress)
self.last_update = time.time()
if self.cancelled:
if not self.keep_files:
util.delete_file(self.tempname)
raise DownloadCancelledException()
def calculate_speed( self, count, blockSize):
if count % 5 == 0:
now = time.time()
if self.start_time > 0:
# Has rate limiting been enabled or disabled?
if self.limit_rate != gl.config.limit_rate:
# If it has been enabled then reset base time and block count
if gl.config.limit_rate:
self.start_time = now
self.start_blocks = count
self.limit_rate = gl.config.limit_rate
# Has the rate been changed and are we currently limiting?
if self.limit_rate_value != gl.config.limit_rate_value and self.limit_rate:
self.start_time = now
self.start_blocks = count
self.limit_rate_value = gl.config.limit_rate_value
passed = now - self.start_time
if passed > 0:
speed = ((count-self.start_blocks)*blockSize)/passed
else:
speed = 0
else:
self.start_time = now
self.start_blocks = count
passed = now - self.start_time
speed = count*blockSize
self.speed = '%s/s' % gl.format_filesize(speed)
self.speed_value = speed
if gl.config.limit_rate and speed > gl.config.limit_rate_value:
# calculate the time that should have passed to reach
# the desired download rate and wait if necessary
should_have_passed = float((count-self.start_blocks)*blockSize)/(gl.config.limit_rate_value*1024.0)
if should_have_passed > passed:
# sleep a maximum of 10 seconds to not cause time-outs
delay = min( 10.0, float(should_have_passed-passed))
time.sleep( delay)
def run( self):
self.download_id = services.download_status_manager.reserve_download_id()
services.download_status_manager.register_download_id( self.download_id, self)
# Variables for speed limit and speed calculation
self.__start_time = 0
self.__start_blocks = 0
self.__limit_rate_value = gl.config.limit_rate_value
self.__limit_rate = gl.config.limit_rate
# If the tempname already exists, set progress accordingly
if os.path.exists(self.tempname):
try:
already_downloaded = os.path.getsize(self.tempname)
if self.total_size > 0:
self.progress = already_downloaded/self.total_size
if already_downloaded > 0:
self.speed = _('Queued (partial)')
except:
pass
self.progress = max(0.0, min(1.0, float(already_downloaded)/self.total_size))
except OSError, os_error:
log('Error while getting size for existing file: %s', os_error, sender=self)
else:
# "touch self.tempname", so we also get partial
# files for resuming when the file is queued
open(self.tempname, 'w').close()
# Initial status update
services.download_status_manager.update_status( self.download_id, episode = self.episode.title, url = self.episode.url, speed = self.speed, progress = self.progress)
def status_updated(self, count, blockSize, totalSize):
# We see a different "total size" while downloading,
# so correct the total size variable in the thread
if totalSize != self.total_size and totalSize > 0:
self.total_size = float(totalSize)
if self.total_size > 0:
self.progress = max(0.0, min(1.0, float(count*blockSize)/self.total_size))
self.calculate_speed(count, blockSize)
if self.status == DownloadTask.CANCELLED:
raise DownloadCancelledException()
if self.status == DownloadTask.PAUSED:
raise DownloadCancelledException()
def calculate_speed(self, count, blockSize):
if count % 5 == 0:
now = time.time()
if self.__start_time > 0:
# Has rate limiting been enabled or disabled?
if self.__limit_rate != gl.config.limit_rate:
# If it has been enabled then reset base time and block count
if gl.config.limit_rate:
self.__start_time = now
self.__start_blocks = count
self.__limit_rate = gl.config.limit_rate
# Has the rate been changed and are we currently limiting?
if self.__limit_rate_value != gl.config.limit_rate_value and self.__limit_rate:
self.__start_time = now
self.__start_blocks = count
self.__limit_rate_value = gl.config.limit_rate_value
passed = now - self.__start_time
if passed > 0:
speed = ((count-self.__start_blocks)*blockSize)/passed
else:
speed = 0
else:
self.__start_time = now
self.__start_blocks = count
passed = now - self.__start_time
speed = count*blockSize
self.speed = float(speed)
if gl.config.limit_rate and speed > gl.config.limit_rate_value:
# calculate the time that should have passed to reach
# the desired download rate and wait if necessary
should_have_passed = float((count-self.__start_blocks)*blockSize)/(gl.config.limit_rate_value*1024.0)
if should_have_passed > passed:
# sleep a maximum of 10 seconds to not cause time-outs
delay = min(10.0, float(should_have_passed-passed))
time.sleep(delay)
def run(self):
# Speed calculation (re-)starts here
self.__start_time = 0
self.__start_blocks = 0
# If the download has already been cancelled, skip it
if self.status == DownloadTask.CANCELLED:
util.delete_file(self.tempname)
return False
# We only start this download if its status is "queued"
if self.status != DownloadTask.QUEUED:
return False
# We are downloading this file right now
self.status = DownloadTask.DOWNLOADING
acquired = services.download_status_manager.s_acquire()
try:
try:
if self.cancelled:
# Remove the partial file in case we do
# not want to keep it (e.g. user cancelled)
if not self.keep_files:
util.delete_file(self.tempname)
return
(unused, headers) = self.downloader.retrieve_resume(resolver.get_real_download_url(self.url), self.tempname, reporthook=self.status_updated)
# Resolve URL and start downloading the episode
url = resolver.get_real_download_url(self.__episode.url)
downloader = DownloadURLOpener(self.__episode.channel)
(unused, headers) = downloader.retrieve_resume(url,
self.tempname, reporthook=self.status_updated)
new_mimetype = headers.get('content-type', self.episode.mimetype)
old_mimetype = self.episode.mimetype
if new_mimetype != old_mimetype:
log('Correcting mime type: %s => %s', old_mimetype, new_mimetype, sender=self)
old_extension = self.episode.extension()
self.episode.mimetype = new_mimetype
new_extension = self.episode.extension()
new_mimetype = headers.get('content-type', self.__episode.mimetype)
old_mimetype = self.__episode.mimetype
if new_mimetype != old_mimetype:
log('Correcting mime type: %s => %s', old_mimetype, new_mimetype, sender=self)
old_extension = self.__episode.extension()
self.__episode.mimetype = new_mimetype
new_extension = self.__episode.extension()
# If the desired filename extension changed due to the new mimetype,
# we force an update of the local filename to fix the extension
if old_extension != new_extension:
self.filename = self.episode.local_filename(create=True, force_update=True)
# If the desired filename extension changed due to the new mimetype,
# we force an update of the local filename to fix the extension
if old_extension != new_extension:
self.filename = self.__episode.local_filename(create=True, force_update=True)
shutil.move( self.tempname, self.filename)
# Get the _real_ filesize once we actually have the file
self.episode.length = os.path.getsize(self.filename)
self.channel.addDownloadedItem( self.episode)
services.download_status_manager.download_completed(self.download_id)
# If a user command has been defined, execute the command setting some environment variables
if len(gl.config.cmd_download_complete) > 0:
os.environ["GPODDER_EPISODE_URL"]=self.episode.url or ''
os.environ["GPODDER_EPISODE_TITLE"]=self.episode.title or ''
os.environ["GPODDER_EPISODE_FILENAME"]=self.filename or ''
os.environ["GPODDER_EPISODE_PUBDATE"]=str(int(self.episode.pubDate))
os.environ["GPODDER_EPISODE_LINK"]=self.episode.link or ''
os.environ["GPODDER_EPISODE_DESC"]=self.episode.description or ''
threading.Thread(target=gl.ext_command_thread, args=(self.notification,gl.config.cmd_download_complete)).start()
shutil.move(self.tempname, self.filename)
finally:
services.download_status_manager.remove_download_id( self.download_id)
services.download_status_manager.s_release( acquired)
# Get the _real_ filesize once we actually have the file
self.__episode.length = os.path.getsize(self.filename)
self.__episode.channel.addDownloadedItem(self.__episode)
# If a user command has been defined, execute the command setting some environment variables
if len(gl.config.cmd_download_complete) > 0:
os.environ["GPODDER_EPISODE_URL"]=self.__episode.url or ''
os.environ["GPODDER_EPISODE_TITLE"]=self.__episode.title or ''
os.environ["GPODDER_EPISODE_FILENAME"]=self.filename or ''
os.environ["GPODDER_EPISODE_PUBDATE"]=str(int(self.__episode.pubDate))
os.environ["GPODDER_EPISODE_LINK"]=self.__episode.link or ''
os.environ["GPODDER_EPISODE_DESC"]=self.__episode.description or ''
threading.Thread(target=gl.ext_command_thread, args=(gl.config.cmd_download_complete,)).start()
except DownloadCancelledException:
log('Download has been cancelled: %s', self.episode.title, traceback=None, sender=self)
if not self.keep_files:
log('Download has been cancelled/paused: %s', self, sender=self)
if self.status == DownloadTask.CANCELLED:
util.delete_file(self.tempname)
self.progress = 0.0
self.speed = 0.0
except IOError, ioe:
if self.notification is not None:
title = ioe.strerror
message = _('An error happened while trying to download <b>%s</b>. Please try again later.') % ( saxutils.escape( self.episode.title), )
self.notification( message, title)
log( 'Error "%s" while downloading "%s": %s', ioe.strerror, self.episode.title, ioe.filename, sender = self)
log( 'Error "%s" while downloading "%s": %s', ioe.strerror, self.__episode.title, ioe.filename, sender=self)
self.status = DownloadTask.FAILED
self.error_message = _('I/O Error: %s: %s') % (ioe.strerror, ioe.filename)
except gPodderDownloadHTTPError, gdhe:
if self.notification is not None:
title = gdhe.error_message
message = _('An error (HTTP %d) happened while trying to download <b>%s</b>. You can try to resume the download later.') % ( gdhe.error_code, saxutils.escape( self.episode.title), )
self.notification( message, title)
log( 'HTTP error %s while downloading "%s": %s', gdhe.error_code, self.episode.title, gdhe.error_message, sender=self)
except:
log( 'Error while downloading "%s".', self.episode.title, sender = self, traceback = True)
log( 'HTTP error %s while downloading "%s": %s', gdhe.error_code, self.__episode.title, gdhe.error_message, sender=self)
self.status = DownloadTask.FAILED
self.error_message = _('HTTP Error %s: %s') % (gdhe.error_code, gdhe.error_message)
except Exception, e:
self.status = DownloadTask.FAILED
self.error_message = _('Error: %s') % (e.message,)
if self.status == DownloadTask.DOWNLOADING:
# Everything went well - we're done
self.status = DownloadTask.DONE
self.progress = 1.0
return True
self.speed = 0.0
# We finished, but not successfully (at least not really)
return False

View File

@ -51,6 +51,7 @@ from gpodder import sync
from gpodder import download
from gpodder import SimpleGladeApp
from gpodder import my
from gpodder import widgets
from gpodder.liblogger import log
from gpodder.dbsqlite import db
from gpodder import resolver
@ -463,6 +464,7 @@ class gPodder(GladeWidget, dbus.service.Object):
self.uar = None
self.tray_icon = None
self.gpodder_episode_window = None
self.download_queue_manager = download.DownloadQueueManager(services.download_status_manager)
self.fullscreen = False
self.minimized = False
@ -483,9 +485,8 @@ class gPodder(GladeWidget, dbus.service.Object):
gl.config.connect_gtk_spinbutton('limit_rate_value', self.spinLimitDownloads)
gl.config.connect_gtk_togglebutton('limit_rate', self.cbLimitDownloads)
# Make sure we free/close the download queue when we
# update the "max downloads" spin button
changed_cb = lambda spinbutton: services.download_status_manager.update_max_downloads()
# Then the amount of maximum downloads changes, notify the queue manager
changed_cb = lambda spinbutton: self.download_queue_manager.spawn_and_retire_threads()
self.spinMaxDownloads.connect('value-changed', changed_cb)
self.default_title = None
@ -576,6 +577,8 @@ class gPodder(GladeWidget, dbus.service.Object):
self.treeAvailable.connect('button-press-event', self.treeview_button_pressed)
self.treeChannels.connect('button-press-event', self.treeview_channels_button_pressed)
self.treeDownloads.connect('button-press-event', self.treeview_downloads_button_pressed)
iconcell = gtk.CellRendererPixbuf()
if gpodder.interface == gpodder.MAEMO:
iconcell.set_fixed_size(-1, 52)
@ -617,25 +620,53 @@ class gPodder(GladeWidget, dbus.service.Object):
self.treeAvailable.get_selection().set_mode(gtk.SELECTION_SINGLE)
else:
self.treeAvailable.get_selection().set_mode(gtk.SELECTION_MULTIPLE)
self.treeDownloads.get_selection().set_mode( gtk.SELECTION_MULTIPLE)
self.treeDownloads.get_selection().set_mode(gtk.SELECTION_MULTIPLE)
if hasattr(self.treeDownloads, 'set_rubber_banding'):
# Available in PyGTK 2.10 and above
self.treeDownloads.set_rubber_banding(True)
# columns and renderers for "download progress" tab
episodecell = gtk.CellRendererText()
episodecell.set_property('ellipsize', pango.ELLIPSIZE_END)
episodecolumn = gtk.TreeViewColumn( _("Episode"), episodecell, text=0)
episodecolumn.set_sizing(gtk.TREE_VIEW_COLUMN_AUTOSIZE)
episodecolumn.set_resizable(True)
episodecolumn.set_expand(True)
speedcell = gtk.CellRendererText()
speedcolumn = gtk.TreeViewColumn( _("Speed"), speedcell, text=1)
progresscell = gtk.CellRendererProgress()
progresscolumn = gtk.TreeViewColumn( _("Progress"), progresscell, value=2)
progresscolumn.set_expand(True)
for itemcolumn in ( episodecolumn, speedcolumn, progresscolumn ):
self.treeDownloads.append_column( itemcolumn)
dsm = services.download_status_manager
# First column: [ICON] Episodename
column = gtk.TreeViewColumn(_('Episode'))
cell = gtk.CellRendererPixbuf()
cell.set_property('stock-size', gtk.ICON_SIZE_MENU)
column.pack_start(cell, expand=False)
column.add_attribute(cell, 'stock-id', dsm.C_ICON_NAME)
cell = gtk.CellRendererText()
cell.set_property('ellipsize', pango.ELLIPSIZE_END)
column.pack_start(cell, expand=True)
column.add_attribute(cell, 'text', dsm.C_NAME)
column.set_sizing(gtk.TREE_VIEW_COLUMN_AUTOSIZE)
column.set_resizable(True)
column.set_expand(True)
self.treeDownloads.append_column(column)
# Second column: Progress
column = gtk.TreeViewColumn(_('Progress'), gtk.CellRendererProgress(),
value=dsm.C_PROGRESS, text=dsm.C_PROGRESS_TEXT)
column.set_expand(True)
self.treeDownloads.append_column(column)
# Third column: Size
column = gtk.TreeViewColumn(_('Size'), gtk.CellRendererText(),
text=dsm.C_SIZE_TEXT)
self.treeDownloads.append_column(column)
# Fourth column: Speed
column = gtk.TreeViewColumn(_('Speed'), gtk.CellRendererText(),
text=dsm.C_SPEED_TEXT)
self.treeDownloads.append_column(column)
# Fifth column: Status
column = gtk.TreeViewColumn(_('Status'), gtk.CellRendererText(),
text=dsm.C_STATUS_TEXT)
self.treeDownloads.append_column(column)
# After we've set up most of the window, show it :)
if not gpodder.interface == gpodder.MAEMO:
@ -655,13 +686,13 @@ class gPodder(GladeWidget, dbus.service.Object):
# treeChannels row numbers to generate tree paths
self.channel_url_path_mapping = {}
services.download_status_manager.register( 'list-changed', self.download_status_updated)
services.download_status_manager.register( 'progress-changed', self.download_progress_updated)
services.cover_downloader.register('cover-available', self.cover_download_finished)
services.cover_downloader.register('cover-removed', self.cover_file_removed)
self.cover_cache = {}
self.treeDownloads.set_model( services.download_status_manager.tree_model)
self.treeDownloads.set_model(services.download_status_manager.get_tree_model())
gobject.timeout_add(1500, self.update_downloads_list)
self.download_tasks_seen = set()
#Add Drag and Drop Support
flags = gtk.DEST_DEFAULT_ALL
@ -703,6 +734,9 @@ class gPodder(GladeWidget, dbus.service.Object):
# Clean up old, orphaned download files
partial_files = gl.find_partial_files()
# Message area
self.message_area = None
resumable_episodes = []
if len(partial_files) > 0:
for f in partial_files:
@ -720,28 +754,13 @@ class gPodder(GladeWidget, dbus.service.Object):
if found_episode:
break
def remove_partial_file(episode):
fn = episode.local_filename(create=False)
if fn is not None:
util.delete_file(fn+'.partial')
if len(resumable_episodes):
if gl.config.resume_ask_every_episode:
gPodderEpisodeSelector(title = _('Resume downloads'), instructions = _('There are unfinished downloads from your last session. Pick the ones you want to resume.'), \
episodes = resumable_episodes, \
stock_ok_button = 'gpodder-download', callback = self.download_episode_list, remove_callback=remove_partial_file)
else:
if len(resumable_episodes) == 0:
question = _('There is one partially downloaded episode. Do you want to continue downloading it?')
else:
question = _('There are %d partially downloaded episodes. Do you want to continue downloading them?') % (len(resumable_episodes))
if self.show_confirmation(question, _('Resume downloads from last session')):
self.download_episode_list(resumable_episodes)
else:
for episode in resumable_episodes:
remove_partial_file(episode)
self.download_episode_list_paused(resumable_episodes)
self.message_area = widgets.SimpleMessageArea(_('There are unfinished downloads from your last session.\nPick the ones you want to continue downloading.'))
self.vboxDownloadStatusWidgets.pack_start(self.message_area, expand=False)
self.vboxDownloadStatusWidgets.reorder_child(self.message_area, 0)
self.message_area.show_all()
self.wNotebook.set_current_page(1)
gl.clean_up_downloads(delete_partial=False)
else:
@ -761,6 +780,83 @@ class gPodder(GladeWidget, dbus.service.Object):
if len(self.channels) == 0:
util.idle_add(self.on_itemUpdate_activate, None)
def update_downloads_list(self):
model = self.treeDownloads.get_model()
downloading, failed, finished, queued = 0, 0, 0, 0
total_speed, total_size, done_size = 0, 0, 0
# Keep a list of all download tasks that we've seen
old_download_tasks_seen = self.download_tasks_seen
self.download_tasks_seen = set()
for row in model:
services.download_status_manager.request_update(row.iter)
task = row[services.download_status_manager.C_TASK]
speed, size, status, progress = task.speed, task.total_size, task.status, task.progress
total_size += size
done_size += size*progress
self.download_tasks_seen.add(task)
if status == download.DownloadTask.DOWNLOADING:
downloading += 1
total_speed += speed
elif status == download.DownloadTask.FAILED:
failed += 1
elif status == download.DownloadTask.DONE:
finished += 1
elif status == download.DownloadTask.QUEUED:
queued += 1
text = [_('Downloads')]
if downloading + failed + finished + queued > 0:
s = []
if downloading > 0:
s.append(_('%d downloading') % downloading)
if failed > 0:
s.append(_('%d failed') % failed)
if finished > 0:
s.append(_('%d done') % finished)
if queued > 0:
s.append(_('%d queued') % queued)
text.append(' (' + ', '.join(s)+')')
self.labelDownloads.set_text(''.join(text))
title = [self.default_title]
# Calculate the difference based on what we've seen now and before
episode_urls = [task.url for task in self.download_tasks_seen.symmetric_difference(old_download_tasks_seen)]
last_download_count = sum(1 for task in old_download_tasks_seen if task.status in (task.DOWNLOADING, task.QUEUED))
count = downloading + queued
if count > 0:
if count == 1:
title.append( _('downloading one file'))
elif count > 1:
title.append( _('downloading %d files') % count)
if total_size > 0:
percentage = 100.0*done_size/total_size
else:
percentage = 0.0
total_speed = gl.format_filesize(total_speed)
title[1] += ' (%d%%, %s/s)' % (percentage, total_speed)
elif last_download_count > 0:
log('All downloads have finished.', sender=self)
if gl.config.cmd_all_downloads_complete:
Thread(target=gl.ext_command_thread, args=( \
gl.config.cmd_all_downloads_complete)).start()
self.gPodder.set_title(' - '.join(title))
self.update_episode_list_icons(episode_urls)
self.play_or_download()
#self.updateComboBox(only_these_urls=channel_urls)
return True
def on_tree_channels_resize(self, widget, allocation):
if not gl.config.podcast_sidebar_save_space:
return
@ -909,6 +1005,91 @@ class gPodder(GladeWidget, dbus.service.Object):
self.active_channel.update_m3u_playlist()
self.show_message(_('Updated M3U playlist in download folder.'), _('Updated playlist'))
def treeview_downloads_button_pressed(self, treeview, event):
if event.button == 1:
# Catch left mouse button presses, and if we there is no
# path at the given position, deselect all items
(x, y) = (int(event.x), int(event.y))
(path, column, rx, ry) = treeview.get_path_at_pos(x, y) or (None,)*4
if path is None:
treeview.get_selection().unselect_all()
if event.button == 3:
(x, y) = (int(event.x), int(event.y))
(path, column, rx, ry) = treeview.get_path_at_pos(x, y) or (None,)*4
paths = []
# Did the user right-click into a selection?
selection = treeview.get_selection()
if selection.count_selected_rows() and path:
(model, paths) = selection.get_selected_rows()
if path not in paths:
# We have right-clicked, but not into the
# selection, assume we don't want to operate
# on the selection
paths = []
# No selection or right click not in selection:
# Select the single item where we clicked
if not paths and path:
treeview.grab_focus()
treeview.set_cursor( path, column, 0)
(model, paths) = (treeview.get_model(), [path])
# We did not find a selection, and the user didn't
# click on an item to select -- don't show the menu
if not paths:
return True
selected_tasks = [(gtk.TreeRowReference(model, path), model.get_value(model.get_iter(path), 0)) for path in paths]
def make_menu_item(label, stock_id, tasks, status):
# This creates a menu item for selection-wide actions
def for_each_task_set_status(tasks, status):
for row_reference, task in tasks:
if status is not None:
if status == download.DownloadTask.QUEUED:
# Only queue task when its paused/failed/cancelled
if task.status in (task.PAUSED, task.FAILED, task.CANCELLED):
self.download_queue_manager.add_task(task)
elif status == download.DownloadTask.CANCELLED:
# Cancelling a download only allows when paused/downloading/queued
if task.status in (task.QUEUED, task.DOWNLOADING, task.PAUSED):
task.status = status
elif status == download.DownloadTask.PAUSED:
# Pausing a download only when queued/downloading
if task.status in (task.DOWNLOADING, task.QUEUED):
task.status = status
else:
# We (hopefully) can simply set the task status here
task.status = status
else:
# Remove the selected task - cancel downloading/queued tasks
if task.status in (task.QUEUED, task.DOWNLOADING):
task.status = task.CANCELLED
model.remove(model.get_iter(row_reference.get_path()))
# Tell the task that it has been removed (so it can clean up)
task.removed_from_list()
return True
item = gtk.ImageMenuItem(label)
item.set_image(gtk.image_new_from_stock(stock_id, gtk.ICON_SIZE_MENU))
item.connect('activate', lambda item: for_each_task_set_status(tasks, status))
return item
menu = gtk.Menu()
menu.append(make_menu_item(_('Download'), gtk.STOCK_GO_DOWN, selected_tasks, download.DownloadTask.QUEUED))
menu.append(gtk.SeparatorMenuItem())
menu.append(make_menu_item(_('Cancel'), gtk.STOCK_CANCEL, selected_tasks, download.DownloadTask.CANCELLED))
menu.append(make_menu_item(_('Pause'), gtk.STOCK_MEDIA_PAUSE, selected_tasks, download.DownloadTask.PAUSED))
menu.append(gtk.SeparatorMenuItem())
menu.append(make_menu_item(_('Remove from list'), gtk.STOCK_REMOVE, selected_tasks, None))
menu.show_all()
menu.popup(None, None, None, event.button, event.time)
return True
def treeview_channels_button_pressed( self, treeview, event):
global WEB_BROWSER_ICON
@ -1311,27 +1492,6 @@ class gPodder(GladeWidget, dbus.service.Object):
self.default_title = new_title
self.gPodder.set_title(new_title)
def download_progress_updated( self, count, percentage):
title = [ self.default_title ]
total_speed = gl.format_filesize(services.download_status_manager.total_speed())
if count == 1:
title.append( _('downloading one file'))
elif count > 1:
title.append( _('downloading %d files') % count)
if len(title) == 2:
title[1] = ''.join( [ title[1], ' (%d%%, %s/s)' % (percentage, total_speed) ])
self.gPodder.set_title( ' - '.join( title))
# Have all the downloads completed?
# If so execute user command if defined, else do nothing
if count == 0:
if len(gl.config.cmd_all_downloads_complete) > 0:
Thread(target=gl.ext_command_thread, args=(self.notification,gl.config.cmd_all_downloads_complete)).start()
def update_selected_episode_list_icons(self):
"""
Updates the status icons in the episode list
@ -1340,7 +1500,7 @@ class gPodder(GladeWidget, dbus.service.Object):
(model, paths) = selection.get_selected_rows()
for path in paths:
iter = model.get_iter(path)
self.active_channel.iter_set_downloading_columns(model, iter)
self.active_channel.iter_set_downloading_columns(model, iter, downloading=self.episode_is_downloading)
def update_episode_list_icons(self, urls):
"""
@ -1358,7 +1518,7 @@ class gPodder(GladeWidget, dbus.service.Object):
for url in urls:
if url in self.url_path_mapping:
path = (self.url_path_mapping[url],)
self.active_channel.iter_set_downloading_columns(model, model.get_iter(path))
self.active_channel.iter_set_downloading_columns(model, model.get_iter(path), downloading=self.episode_is_downloading)
def playback_episode(self, episode, stream=False):
if gpodder.interface == gpodder.MAEMO:
@ -1426,7 +1586,7 @@ class gPodder(GladeWidget, dbus.service.Object):
if not can_play:
can_download = True
else:
if services.download_status_manager.is_download_in_progress(url):
if self.episode_is_downloading(episode):
can_cancel = True
else:
can_download = True
@ -1494,16 +1654,6 @@ class gPodder(GladeWidget, dbus.service.Object):
return (can_play, can_download, can_transfer, can_cancel, can_delete, open_instead_of_play)
def download_status_updated(self, episode_urls, channel_urls):
count = services.download_status_manager.count()
if count:
self.labelDownloads.set_text( _('Downloads (%d)') % count)
else:
self.labelDownloads.set_text( _('Downloads'))
self.update_episode_list_icons(episode_urls)
self.updateComboBox(only_these_urls=channel_urls)
def on_cbMaxDownloads_toggled(self, widget, *args):
self.spinMaxDownloads.set_sensitive(self.cbMaxDownloads.get_active())
@ -1582,6 +1732,10 @@ class gPodder(GladeWidget, dbus.service.Object):
log( 'Cannot set selection on treeChannels', sender = self)
self.on_treeChannels_cursor_changed( self.treeChannels)
self.channel_list_changed = False
def episode_is_downloading(self, episode):
"""Returns True if the given episode is being downloaded at the moment"""
return episode.url in (task.url for task in self.download_tasks_seen if task.status in (task.DOWNLOADING, task.QUEUED, task.PAUSED))
def updateTreeView(self):
if self.channels and self.active_channel is not None:
@ -1590,7 +1744,7 @@ class gPodder(GladeWidget, dbus.service.Object):
else:
banner = None
def thread_func(self, banner, active_channel):
(model, urls) = self.active_channel.get_tree_model()
(model, urls) = self.active_channel.get_tree_model(self.episode_is_downloading)
mapping = dict(zip(urls, range(len(urls))))
def update_gui_with_new_model(self, channel, model, urls, mapping, banner):
if self.active_channel is not None and channel is not None:
@ -1931,7 +2085,7 @@ class gPodder(GladeWidget, dbus.service.Object):
Displays a confirmation dialog (and closes/hides gPodder)
"""
downloading = services.download_status_manager.has_items()
downloading = False #services.download_status_manager.has_items()
# Only iconify if we are using the window's "X" button,
# but not when we are using "Quit" in the menu or toolbar
@ -1984,7 +2138,7 @@ class gPodder(GladeWidget, dbus.service.Object):
else:
self.show_message(_('Please check your permissions and free disk space.'), _('Error saving podcast list'))
services.download_status_manager.cancel_all(keep_files=True)
services.download_status_manager.pause_all_downloads()
self.gPodder.hide()
while gtk.events_pending():
gtk.main_iteration(False)
@ -2035,7 +2189,7 @@ class gPodder(GladeWidget, dbus.service.Object):
episode_urls.add(episode.url)
channel_urls.add(episode.channel.url)
self.download_status_updated(episode_urls, channel_urls)
#self.download_status_updated(episode_urls, channel_urls)
def on_itemRemoveOldEpisodes_activate( self, widget):
columns = (
@ -2167,13 +2321,29 @@ class gPodder(GladeWidget, dbus.service.Object):
else:
gPodderWelcome(center_on_widget=self.gPodder, show_example_podcasts_callback=self.on_itemImportChannels_activate, setup_my_gpodder_callback=self.on_download_from_mygpo)
def download_episode_list(self, episodes):
services.download_status_manager.start_batch_mode()
def download_episode_list_paused(self, episodes):
self.download_episode_list(episodes, True)
def download_episode_list(self, episodes, add_paused=False):
for episode in episodes:
log('Downloading episode: %s', episode.title, sender = self)
if not episode.was_downloaded(and_exists=True) and not services.download_status_manager.is_download_in_progress( episode.url):
download.DownloadThread(episode.channel, episode, self.notification).start()
services.download_status_manager.end_batch_mode()
if not episode.was_downloaded(and_exists=True):
task_exists = False
for task in self.download_tasks_seen:
if episode.url == task.url and task.status not in (task.DOWNLOADING, task.QUEUED):
self.download_queue_manager.add_task(task)
task_exists = True
continue
if task_exists:
continue
task = download.DownloadTask(episode)
if add_paused:
task.status = task.PAUSED
self.download_queue_manager.add_resumed_task(task)
else:
self.download_queue_manager.add_task(task)
def new_episodes_show(self, episodes):
columns = (
@ -2612,7 +2782,7 @@ class gPodder(GladeWidget, dbus.service.Object):
log('Not removing downloaded episodes', sender=self)
# only delete partial files if we do not have any downloads in progress
delete_partial = not services.download_status_manager.has_items()
delete_partial = False #not services.download_status_manager.has_items()
gl.clean_up_downloads(delete_partial)
# cancel any active downloads from this channel
@ -2741,11 +2911,20 @@ class gPodder(GladeWidget, dbus.service.Object):
self.set_title(tab_label)
if page_num == 0:
self.play_or_download()
self.menuChannels.set_sensitive(True)
self.menuSubscriptions.set_sensitive(True)
# The message area in the downloads tab should be hidden
# when the user switches away from the downloads tab
if self.message_area is not None:
self.message_area.hide()
self.message_area = None
else:
self.menuChannels.set_sensitive(False)
self.menuSubscriptions.set_sensitive(False)
self.toolDownload.set_sensitive( False)
self.toolPlay.set_sensitive( False)
self.toolTransfer.set_sensitive( False)
self.toolCancel.set_sensitive( services.download_status_manager.has_items())
self.toolCancel.set_sensitive( False)#services.download_status_manager.has_items())
def on_treeChannels_row_activated(self, widget, path, *args):
# double-click action of the podcast list or enter
@ -2845,6 +3024,7 @@ class gPodder(GladeWidget, dbus.service.Object):
self.gpodder_episode_window.show(episode=episode, download_callback=download_callback, play_callback=play_callback)
else:
self.download_episode_list(episodes)
self.update_selected_episode_list_icons()
self.play_or_download()
except:
log('Error in on_treeAvailable_row_activated', traceback=True, sender=self)
@ -2861,38 +3041,32 @@ class gPodder(GladeWidget, dbus.service.Object):
gobject.timeout_add(next_update, self.auto_update_procedure)
def on_treeDownloads_row_activated(self, widget, *args):
cancel_urls = []
if self.wNotebook.get_current_page() > 0:
# Use the download list treeview + model
( tree, column ) = ( self.treeDownloads, 3 )
else:
if self.wNotebook.get_current_page() == 0:
# Use the available podcasts treeview + model
( tree, column ) = ( self.treeAvailable, 0 )
selection = tree.get_selection()
(model, paths) = selection.get_selected_rows()
for path in paths:
url = model.get_value( model.get_iter( path), column)
cancel_urls.append( url)
if len( cancel_urls) == 0:
log('Nothing selected.', sender = self)
selection = self.treeAvailable.get_selection()
(model, paths) = selection.get_selected_rows()
urls = [model.get_value(model.get_iter(path), 0) for path in paths]
selected_tasks = [task for task in self.download_tasks_seen if task.url in urls]
for task in selected_tasks:
task.status = task.CANCELLED
self.update_selected_episode_list_icons()
self.play_or_download()
return
if len( cancel_urls) == 1:
title = _('Cancel download?')
message = _("Cancelling this download will remove the partially downloaded file and stop the download.")
else:
title = _('Cancel downloads?')
message = _("Cancelling the download will stop the %d selected downloads and remove partially downloaded files.") % selection.count_selected_rows()
# Use the standard way of working on the treeview
selection = self.treeDownloads.get_selection()
(model, paths) = selection.get_selected_rows()
selected_tasks = [(gtk.TreeRowReference(model, path), model.get_value(model.get_iter(path), 0)) for path in paths]
if self.show_confirmation( message, title):
services.download_status_manager.start_batch_mode()
for url in cancel_urls:
services.download_status_manager.cancel_by_url( url)
services.download_status_manager.end_batch_mode()
self.play_or_download()
for tree_row_reference, task in selected_tasks:
if task.status in (task.DOWNLOADING, task.QUEUED):
task.status = task.PAUSED
elif task.status in (task.CANCELLED, task.PAUSED, task.FAILED):
self.download_queue_manager.add_task(task)
elif task.status == task.DONE:
model.remove(model.get_iter(tree_row_reference.get_path()))
self.play_or_download()
def on_btnCancelDownloadStatus_clicked(self, widget, *args):
self.on_treeDownloads_row_activated( widget, None)
@ -2959,7 +3133,7 @@ class gPodder(GladeWidget, dbus.service.Object):
log( 'Error while deleting (some) downloads.', traceback=True, sender=self)
# only delete partial files if we do not have any downloads in progress
delete_partial = not services.download_status_manager.has_items()
delete_partial = False #not services.download_status_manager.has_items()
gl.clean_up_downloads(delete_partial)
self.update_selected_episode_list_icons()
self.play_or_download()
@ -3569,8 +3743,8 @@ class gPodderEpisode(GladeWidget):
setattr(self, 'play_callback', None)
self.gPodderEpisode.connect('delete-event', self.on_delete_event)
gl.config.connect_gtk_window(self.gPodderEpisode, 'episode_window', True)
services.download_status_manager.register('list-changed', self.on_download_status_changed)
services.download_status_manager.register('progress-detail', self.on_download_status_progress)
#services.download_status_manager.register('list-changed', self.on_download_status_changed)
#services.download_status_manager.register('progress-detail', self.on_download_status_progress)
self.textview.modify_bg(gtk.STATE_NORMAL, gtk.gdk.color_parse('#ffffff'))
if gl.config.enable_html_shownotes:
try:
@ -3668,8 +3842,6 @@ class gPodderEpisode(GladeWidget):
b.insert_with_tags_by_name(b.get_end_iter(), '\n'.join(footer), 'footer')
b.place_cursor(b.get_start_iter())
services.download_status_manager.request_progress_detail(self.episode.url)
def on_cancel(self, widget):
services.download_status_manager.cancel_by_url(self.episode.url)
@ -3706,7 +3878,7 @@ class gPodderEpisode(GladeWidget):
self.download_progress.set_text('Downloading: %d%% (%s)' % (progress, speed))
def hide_show_widgets(self):
is_downloading = services.download_status_manager.is_download_in_progress(self.episode.url)
is_downloading = False #services.download_status_manager.is_download_in_progress(self.episode.url)
if is_downloading:
self.download_progress.show_all()
self.btnCancel.show_all()

View File

@ -389,7 +389,7 @@ class gPodderLib(object):
return ( False, command_line[0] )
return ( True, command_line[0] )
def ext_command_thread(self, notification, command_line):
def ext_command_thread(self, command_line):
"""
This is the function that will be called in a separate
thread that will call an external command (specified by
@ -404,6 +404,10 @@ class gPodderLib(object):
p = subprocess.Popen(command_line, shell=True, stdout=sys.stdout, stderr=sys.stderr)
result = p.wait()
# FIXME: NOTIFICATION
def notification(message, title):
log('Message: %s (%s)', title, message, sender=self)
if result == 127:
title = _('User command not found')

View File

@ -356,7 +356,7 @@ class PodcastChannel(PodcastModelObject):
return db.load_episodes(self, factory=self.episode_factory, state=db.STATE_DOWNLOADED)
def get_new_episodes( self):
return [episode for episode in db.load_episodes(self, factory=self.episode_factory) if episode.state == db.STATE_NORMAL and not episode.is_played and not services.download_status_manager.is_download_in_progress(episode.url)]
return [episode for episode in db.load_episodes(self, factory=self.episode_factory) if episode.state == db.STATE_NORMAL and not episode.is_played] # and not services.download_status_manager.is_download_in_progress(episode.url)]
def update_m3u_playlist(self):
if gl.config.create_m3u_playlists:
@ -407,7 +407,7 @@ class PodcastChannel(PodcastModelObject):
def get_all_episodes(self):
return db.load_episodes(self, factory=self.episode_factory)
def iter_set_downloading_columns( self, model, iter, episode=None):
def iter_set_downloading_columns(self, model, iter, episode=None, downloading=None):
global ICON_AUDIO_FILE, ICON_VIDEO_FILE
global ICON_DOWNLOADING, ICON_DELETED, ICON_NEW
@ -422,7 +422,7 @@ class PodcastChannel(PodcastModelObject):
else:
icon_size = 16
if services.download_status_manager.is_download_in_progress(url):
if downloading is not None and downloading(episode):
status_icon = util.get_tree_icon(ICON_DOWNLOADING, icon_cache=self.icon_cache, icon_size=icon_size)
else:
if episode.state == db.STATE_NORMAL:
@ -451,7 +451,7 @@ class PodcastChannel(PodcastModelObject):
model.set( iter, 4, status_icon)
def get_tree_model(self):
def get_tree_model(self, downloading=None):
"""
Return a gtk.ListStore containing episodes for this channel
"""
@ -472,7 +472,7 @@ class PodcastChannel(PodcastModelObject):
new_iter = new_model.append((item.url, item.title, filelength,
True, None, item.cute_pubdate(), description, util.remove_html_tags(item.description),
'XXXXXXXXXXXXXUNUSEDXXXXXXXXXXXXXXXXXXX', item.extension()))
self.iter_set_downloading_columns( new_model, new_iter, episode=item)
self.iter_set_downloading_columns( new_model, new_iter, episode=item, downloading=downloading)
urls.append(item.url)
self.update_save_dir_size()

View File

@ -24,6 +24,8 @@
#
#
from __future__ import with_statement
from gpodder.liblogger import log
from gpodder.libgpodder import gl
@ -33,6 +35,7 @@ from gpodder import resolver
import gtk
import gobject
import collections
import threading
import time
import urllib2
@ -281,242 +284,76 @@ class CoverDownloader(ObservableService):
cover_downloader = CoverDownloader()
class DownloadStatusManager(ObservableService):
COLUMN_NAMES = { 0: 'episode', 1: 'speed', 2: 'progress', 3: 'url' }
COLUMN_TYPES = ( gobject.TYPE_STRING, gobject.TYPE_STRING, gobject.TYPE_FLOAT, gobject.TYPE_STRING )
PROGRESS_HOLDDOWN_TIMEOUT = 5
class DownloadStatusManager(object):
# Types of columns, needed for creation of gtk.ListStore
COLUMNS = (object, str, str, int, str, str, str, str, str)
def __init__( self):
self.status_list = {}
self.next_status_id = 0
# 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))
self.last_progress_status = (0, 0)
self.last_progress_update = 0
# use to correctly calculate percentage done
self.downloads_done_bytes = 0
self.max_downloads = gl.config.max_downloads
self.semaphore = threading.Semaphore( self.max_downloads)
def __init__(self):
self.__model = gtk.ListStore(*DownloadStatusManager.COLUMNS)
# FIXME: do not duplicate the names here (from DownloadTask!)
(INIT, QUEUED, DOWNLOADING, DONE, FAILED, CANCELLED, PAUSED) = range(7)
self.tree_model = gtk.ListStore( *self.COLUMN_TYPES)
self.tree_model_lock = threading.Lock()
self.status_stock_ids = collections.defaultdict(lambda: None)
self.status_stock_ids[DOWNLOADING] = gtk.STOCK_GO_DOWN
self.status_stock_ids[DONE] = gtk.STOCK_APPLY
self.status_stock_ids[FAILED] = gtk.STOCK_STOP
self.status_stock_ids[CANCELLED] = gtk.STOCK_CANCEL
self.status_stock_ids[PAUSED] = gtk.STOCK_MEDIA_PAUSE
# batch add in progress?
self.batch_mode_enabled = False
# remember which episodes and channels changed during batch mode
self.batch_mode_changed_episode_urls = set()
self.batch_mode_changed_channel_urls = set()
def get_tree_model(self):
return self.__model
# Used to notify all threads that they should
# re-check if they can acquire the lock
self.notification_event = threading.Event()
self.notification_event_waiters = 0
signal_names = ['list-changed', 'progress-changed', 'progress-detail', 'download-complete']
ObservableService.__init__(self, signal_names)
def start_batch_mode(self):
"""
This is called when we are going to add multiple
episodes to our download list, and do not want to
notify the GUI for every single episode.
After all episodes have been added, you MUST call
the end_batch_mode() method to trigger a notification.
"""
self.batch_mode_enabled = True
def end_batch_mode(self):
"""
This is called after multiple episodes have been
added when start_batch_mode() has been called before.
This sends out a notification that the list has changed.
"""
self.batch_mode_enabled = False
if len(self.batch_mode_changed_episode_urls) + len(self.batch_mode_changed_channel_urls) > 0:
self.notify('list-changed', self.batch_mode_changed_episode_urls, self.batch_mode_changed_channel_urls)
self.batch_mode_changed_episode_urls = set()
self.batch_mode_changed_channel_urls = set()
def notify_progress(self, force=False):
now = (self.count(), self.average_progress())
next_progress_update = self.last_progress_update + self.PROGRESS_HOLDDOWN_TIMEOUT
if force or (now != self.last_progress_status and \
time.time() > next_progress_update):
self.notify( 'progress-changed', *now)
self.last_progress_status = now
self.last_progress_update = time.time()
def s_acquire( self):
if not gl.config.max_downloads_enabled:
return False
# Acquire queue slots if user has decreased the slots
while self.max_downloads > gl.config.max_downloads:
self.semaphore.acquire()
self.max_downloads -= 1
# Make sure we update the maximum number of downloads
self.update_max_downloads()
while self.semaphore.acquire(False) == False:
self.notification_event_waiters += 1
self.notification_event.wait(2.)
self.notification_event_waiters -= 1
# If we are the last thread that woke up from
# the notification_event, clear the flag here
if self.notification_event_waiters == 0:
self.notification_event.clear()
# If the user has change the config option since the
# last time we checked, return false and start download
if not gl.config.max_downloads_enabled:
return False
# If we land here, we've acquired exactly the one we need
return True
def update_max_downloads(self):
# Release queue slots if user has enabled more slots
while self.max_downloads < gl.config.max_downloads:
self.semaphore.release()
self.max_downloads += 1
# Notify all threads that the limit might have been changed
self.notification_event.set()
def s_release( self, acquired = True):
if acquired:
self.semaphore.release()
def reserve_download_id( self):
id = self.next_status_id
self.next_status_id = id + 1
return id
def remove_iter( self, iter):
self.tree_model.remove( iter)
return False
def register_download_id( self, id, thread):
self.tree_model_lock.acquire()
self.status_list[id] = { 'iter': self.tree_model.append(), 'thread': thread, 'progress': 0.0, 'speed': _('Queued'), }
if self.batch_mode_enabled:
self.batch_mode_changed_episode_urls.add(thread.episode.url)
self.batch_mode_changed_channel_urls.add(thread.channel.url)
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)
else:
self.notify('list-changed', [thread.episode.url], [thread.channel.url])
self.tree_model_lock.release()
# 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)
def remove_download_id( self, id):
if not id in self.status_list:
return
iter = self.status_list[id]['iter']
if iter is not None:
self.tree_model_lock.acquire()
util.idle_add(self.remove_iter, iter)
self.tree_model_lock.release()
self.status_list[id]['iter'] = None
episode_url = self.status_list[id]['thread'].episode.url
channel_url = self.status_list[id]['thread'].channel.url
self.status_list[id]['thread'].cancel()
del self.status_list[id]
if not self.has_items():
# Reset the counter now
self.downloads_done_bytes = 0
if task.status == task.FAILED:
status_message = _('Failed: %s') % (task.error_message,)
else:
episode_url = None
channel_url = None
status_message = task.STATUS_MESSAGE[task.status]
if self.batch_mode_enabled:
self.batch_mode_changed_episode_urls.add(episode_url)
self.batch_mode_changed_channel_urls.add(channel_url)
if task.status == task.DOWNLOADING:
speed_message = '%s/s' % util.format_filesize(task.speed)
else:
self.notify('list-changed', [episode_url], [channel_url])
self.notify_progress(force=True)
speed_message = ''
def count( self):
return len(self.status_list)
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 has_items( self):
return self.count() > 0
def average_progress( self):
if not len(self.status_list):
return 0
def __add_new_task(self, task):
iter = self.__model.append()
self.request_update(iter, task)
done = sum(status['progress']/100. * status['thread'].total_size for status in self.status_list.values())
total = sum(status['thread'].total_size for status in self.status_list.values())
if total + self.downloads_done_bytes == 0:
return 0
return float(done + self.downloads_done_bytes) / float(total + self.downloads_done_bytes) * 100
def register_task(self, task):
util.idle_add(self.__add_new_task, task)
def total_speed(self):
if not len(self.status_list):
return 0
def pause_all_downloads(self):
for row in self.__model:
task = row[DownloadStatusManager.C_TASK]
if task is not None:
task.status = task.PAUSED
return sum(status['thread'].speed_value for status in self.status_list.values())
def update_status( self, id, **kwargs):
if not id in self.status_list:
return
iter = self.status_list[id]['iter']
if iter:
self.tree_model_lock.acquire()
for ( column, key ) in self.COLUMN_NAMES.items():
if key in kwargs:
util.idle_add(self.tree_model.set, iter, column, kwargs[key])
self.status_list[id][key] = kwargs[key]
self.tree_model_lock.release()
if 'progress' in kwargs and 'speed' in kwargs and 'url' in self.status_list[id]:
self.notify( 'progress-detail', self.status_list[id]['url'], kwargs['progress'], kwargs['speed'])
self.notify_progress()
def download_completed(self, id):
if id in self.status_list:
self.notify('download-complete', self.status_list[id]['episode'])
self.downloads_done_bytes += self.status_list[id]['thread'].total_size
def request_progress_detail( self, url):
for status in self.status_list.values():
if 'url' in status and status['url'] == url and 'progress' in status and 'speed' in status:
self.notify( 'progress-detail', url, status['progress'], status['speed'])
def is_download_in_progress( self, url):
for element in self.status_list.keys():
# We need this, because status_list is modified from other threads
if element in self.status_list:
try:
thread = self.status_list[element]['thread']
except:
thread = None
if thread is not None and thread.url == url:
return True
return False
def cancel_all(self, keep_files=False):
for element in self.status_list:
self.status_list[element]['iter'] = None
self.status_list[element]['thread'].cancel(keep_files)
# clear the tree model after cancelling
util.idle_add(self.tree_model.clear)
self.downloads_done_bytes = 0
def cancel_by_url( self, url):
for element in self.status_list:
thread = self.status_list[element]['thread']
if thread is not None and thread.url == url:
self.remove_download_id( element)
def cancel_by_url(self, url):
for row in self.__model:
task = row[DownloadStatusManager.C_TASK]
if task.url == url and task.status == task.DOWNLOADING:
task.status = task.CANCELLED
return True
return False

View File

@ -116,10 +116,7 @@ class GPodderStatusIcon(gtk.StatusIcon):
if not pynotify.init('gPodder'):
log('Error: unable to initialise pynotify', sender=self)
# Register with the download status manager
dl_man = services.download_status_manager
dl_man.register('progress-changed', self.__on_download_progress_changed)
dl_man.register('download-complete', self.__on_download_complete)
# Register with the download status manager FIXME FIXME
def __create_context_menu(self):
# build and connect the popup menu

85
src/gpodder/widgets.py Normal file
View File

@ -0,0 +1,85 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
#
# gPodder - A media aggregator and podcast client
# Copyright (c) 2005-2009 Thomas Perl and the gPodder Team
#
# 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.
#
# gPodder is distributed in the hope that it will be useful,
# 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
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
#
# widgets.py -- Additional widgets for gPodder
# Thomas Perl <thp@gpodder.org> 2009-03-31
#
import gtk
from xml.sax import saxutils
class SimpleMessageArea(gtk.HBox):
"""A simple, yellow message area. Inspired by gedit.
Original C source code:
http://svn.gnome.org/viewvc/gedit/trunk/gedit/gedit-message-area.c
"""
def __init__(self, message):
gtk.HBox.__init__(self, spacing=6)
self.set_border_width(6)
self.__in_style_set = False
self.connect('style-set', self.__style_set)
self.connect('expose-event', self.__expose_event)
self.__label = gtk.Label()
self.__label.set_alignment(0.0, 0.5)
self.__label.set_line_wrap(False)
self.__label.set_markup('<b>%s</b>' % saxutils.escape(message))
self.pack_start(self.__label, expand=True, fill=True)
self.__image = gtk.image_new_from_stock(gtk.STOCK_CLOSE, gtk.ICON_SIZE_BUTTON)
self.__button = gtk.ToolButton(self.__image)
self.__button.set_border_width(0)
self.__button.connect('clicked', self.__button_clicked)
vbox = gtk.VBox()
vbox.pack_start(self.__button, expand=True, fill=False)
self.pack_start(vbox, expand=False, fill=False)
def __style_set(self, widget, previous_style):
if self.__in_style_set:
return
w = gtk.Window(gtk.WINDOW_POPUP)
w.set_name('gtk-tooltip')
w.ensure_style()
style = w.get_style()
self.__in_style_set = True
self.set_style(style)
self.__in_style_set = False
w.destroy()
self.queue_draw()
def __expose_event(self, widget, event):
style = widget.get_style()
rect = widget.get_allocation()
style.paint_flat_box(widget.window, gtk.STATE_NORMAL,
gtk.SHADOW_OUT, None, widget, "tooltip",
rect.x, rect.y, rect.width, rect.height)
return False
def __button_clicked(self, toolbutton):
self.hide_all()