Skip to content

Commit

Permalink
QML UI: Download resuming (bug 1487)
Browse files Browse the repository at this point in the history
Move download resuming code from Gtk UI module to
gpodder.common (new module) and use it from the QML UI
for a simple "Resume downloads" dialog at startup.
  • Loading branch information
thp committed Aug 16, 2012
1 parent bf5536a commit 3a9eb69
Show file tree
Hide file tree
Showing 3 changed files with 202 additions and 84 deletions.
95 changes: 95 additions & 0 deletions src/gpodder/common.py
@@ -0,0 +1,95 @@
# -*- coding: utf-8 -*-
#
# gPodder - A media aggregator and podcast client
# Copyright (c) 2005-2012 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/>.
#

# gpodder.common - Common helper functions for all UIs
# Thomas Perl <thp@gpodder.org>; 2012-08-16


import gpodder

from gpodder import util

import glob
import os

import logging
logger = logging.getLogger(__name__)


def clean_up_downloads(delete_partial=False):
"""Clean up temporary files left behind by old gPodder versions
delete_partial - If True, also delete in-progress downloads
"""
temporary_files = glob.glob('%s/*/.tmp-*' % gpodder.downloads)

if delete_partial:
temporary_files += glob.glob('%s/*/*.partial' % gpodder.downloads)

for tempfile in temporary_files:
util.delete_file(tempfile)


def find_partial_downloads(channels, start_progress_callback, progress_callback, finish_progress_callback):
"""Find partial downloads and match them with episodes
channels - A list of all model.PodcastChannel objects
start_progress_callback - A callback(count) when partial files are searched
progress_callback - A callback(title, progress) when an episode was found
finish_progress_callback - A callback(resumable_episodes) when finished
"""
# Look for partial file downloads
partial_files = glob.glob(os.path.join(gpodder.downloads, '*', '*.partial'))
count = len(partial_files)
resumable_episodes = []
if count:
start_progress_callback(count)
candidates = [f[:-len('.partial')] for f in partial_files]
found = 0

for channel in channels:
for episode in channel.get_all_episodes():
filename = episode.local_filename(create=False, check_only=True)
if filename in candidates:
found += 1
progress_callback(episode.title, float(found)/count)
candidates.remove(filename)
partial_files.remove(filename+'.partial')

if os.path.exists(filename):
# The file has already been downloaded;
# remove the leftover partial file
util.delete_file(filename+'.partial')
else:
resumable_episodes.append(episode)

if not candidates:
break

if not candidates:
break

for f in partial_files:
logger.warn('Partial file without episode: %s', f)
util.delete_file(f)

finish_progress_callback(resumable_episodes)
else:
clean_up_downloads(True)

129 changes: 49 additions & 80 deletions src/gpodder/gtkui/main.py
Expand Up @@ -50,6 +50,7 @@
from gpodder import my
from gpodder import youtube
from gpodder import player
from gpodder import common

import logging
logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -227,74 +228,8 @@ def new(self):

self.message_area = None

def find_partial_downloads():
# Look for partial file downloads
partial_files = glob.glob(os.path.join(gpodder.downloads, '*', '*.partial'))
count = len(partial_files)
resumable_episodes = []
if count:
util.idle_add(self.wNotebook.set_current_page, 1)
indicator = ProgressIndicator(_('Loading incomplete downloads'),
_('Some episodes have not finished downloading in a previous session.'),
False, self.get_dialog_parent())
indicator.on_message(N_('%(count)d partial file', '%(count)d partial files', count) % {'count':count})

candidates = [f[:-len('.partial')] for f in partial_files]
found = 0

for c in self.channels:
for e in c.get_all_episodes():
filename = e.local_filename(create=False, check_only=True)
if filename in candidates:
found += 1
indicator.on_message(e.title)
indicator.on_progress(float(found)/count)
candidates.remove(filename)
partial_files.remove(filename+'.partial')

if os.path.exists(filename):
# The file has already been downloaded;
# remove the leftover partial file
util.delete_file(filename+'.partial')
else:
resumable_episodes.append(e)

if not candidates:
break

if not candidates:
break

for f in partial_files:
logger.warn('Partial file without episode: %s', f)
util.delete_file(f)

util.idle_add(indicator.on_finished)

if len(resumable_episodes):
def offer_resuming():
self.download_episode_list_paused(resumable_episodes)
resume_all = gtk.Button(_('Resume all'))
def on_resume_all(button):
selection = self.treeDownloads.get_selection()
selection.select_all()
selected_tasks, can_queue, can_cancel, can_pause, can_remove, can_force = self.downloads_list_get_selection()
selection.unselect_all()
self._for_each_task_set_status(selected_tasks, download.DownloadTask.QUEUED)
self.message_area.hide()
resume_all.connect('clicked', on_resume_all)

self.message_area = SimpleMessageArea(_('Incomplete downloads from a previous session were found.'), (resume_all,))
self.vboxDownloadStatusWidgets.pack_start(self.message_area, expand=False)
self.vboxDownloadStatusWidgets.reorder_child(self.message_area, 0)
self.message_area.show_all()
self.clean_up_downloads(delete_partial=False)
util.idle_add(offer_resuming)
else:
util.idle_add(self.wNotebook.set_current_page, 0)
else:
util.idle_add(self.clean_up_downloads, True)
util.run_in_background(find_partial_downloads)
self.partial_downloads_indicator = None
util.run_in_background(self.find_partial_downloads)

# Start the auto-update procedure
self._auto_update_timer_source_id = None
Expand All @@ -321,6 +256,51 @@ def on_resume_all(button):
self.config.software_update.last_check = int(time.time())
self.check_for_updates(silent=True)

def find_partial_downloads(self):
def start_progress_callback(count):
self.partial_downloads_indicator = ProgressIndicator(
_('Loading incomplete downloads'),
_('Some episodes have not finished downloading in a previous session.'),
False, self.get_dialog_parent())
self.partial_downloads_indicator.on_message(N_('%(count)d partial file', '%(count)d partial files', count) % {'count':count})

util.idle_add(self.wNotebook.set_current_page, 1)

def progress_callback(title, progress):
self.partial_downloads_indicator.on_message(title)
self.partial_downloads_indicator.on_progress(progress)

def finish_progress_callback(resumable_episodes):
util.idle_add(self.partial_downloads_indicator.on_finished)
self.partial_downloads_indicator = None

if resumable_episodes:
def offer_resuming():
self.download_episode_list_paused(resumable_episodes)
resume_all = gtk.Button(_('Resume all'))
def on_resume_all(button):
selection = self.treeDownloads.get_selection()
selection.select_all()
selected_tasks, _, _, _, _, _ = self.downloads_list_get_selection()
selection.unselect_all()
self._for_each_task_set_status(selected_tasks, download.DownloadTask.QUEUED)
self.message_area.hide()
resume_all.connect('clicked', on_resume_all)

self.message_area = SimpleMessageArea(_('Incomplete downloads from a previous session were found.'), (resume_all,))
self.vboxDownloadStatusWidgets.pack_start(self.message_area, expand=False)
self.vboxDownloadStatusWidgets.reorder_child(self.message_area, 0)
self.message_area.show_all()
common.clean_up_downloads(delete_partial=False)
util.idle_add(offer_resuming)
else:
util.idle_add(self.wNotebook.set_current_page, 0)

common.find_partial_downloads(self.channels,
start_progress_callback,
progress_callback,
finish_progress_callback)

def episode_object_by_uri(self, uri):
"""Get an episode object given a local or remote URI
Expand Down Expand Up @@ -1833,17 +1813,6 @@ def episode_list_status_changed(self, episodes):
self.update_podcast_list_model(set(e.channel.url for e in episodes))
self.db.commit()

def clean_up_downloads(self, delete_partial=False):
# Clean up temporary files left behind by old gPodder versions
temporary_files = glob.glob('%s/*/.tmp-*' % gpodder.downloads)

if delete_partial:
temporary_files += glob.glob('%s/*/*.partial' % gpodder.downloads)

for tempfile in temporary_files:
util.delete_file(tempfile)


def streaming_possible(self):
# User has to have a media player set on the Desktop, or else we
# would probably open the browser when giving a URL to xdg-open..
Expand Down Expand Up @@ -3064,7 +3033,7 @@ def thread_proc():
channel.delete()

# Clean up downloads and download directories
self.clean_up_downloads()
common.clean_up_downloads()

# The remaining stuff is to be done in the GTK main thread
util.idle_add(finish_deletion, select_url)
Expand Down
62 changes: 58 additions & 4 deletions src/gpodder/qmlui/__init__.py
Expand Up @@ -46,6 +46,7 @@
from gpodder import util
from gpodder import my
from gpodder import query
from gpodder import common

from gpodder.model import Model

Expand Down Expand Up @@ -551,13 +552,17 @@ def title_changer(podcast):

self.start_input_dialog(title_changer(action.target))

def confirm_action(self, message, affirmative, callback):
def confirm(message, affirmative, callback):
def confirm_action(self, message, affirmative, callback,
negative_callback=None):
def confirm(message, affirmative, callback, negative_callback):
args = (message, '', affirmative, _('Cancel'), False)
if (yield args):
callback()
elif negative_callback is not None:
negative_callback()

self.start_input_dialog(confirm(message, affirmative, callback))
self.start_input_dialog(confirm(message, affirmative, callback,
negative_callback))

def start_input_dialog(self, generator):
"""Carry out an input dialog with the UI
Expand Down Expand Up @@ -948,7 +953,48 @@ def __init__(self, args, gpodder_core, dbus_bus_name):
self.do_end_progress.connect(self.on_end_progress)
self.do_show_message.connect(self.on_show_message)

self.load_podcasts()
podcasts = self.load_podcasts()

self.resumable_episodes = None
self.do_offer_download_resume.connect(self.on_offer_download_resume)
util.run_in_background(self.find_partial_downloads(podcasts))

def find_partial_downloads(self, podcasts):
def start_progress_callback(count):
self.start_progress(_('Loading incomplete downloads'))

def progress_callback(title, progress):
self.start_progress('%s (%d%%)' % (
_('Loading incomplete downloads'),
progress*100))

def finish_progress_callback(resumable_episodes):
self.end_progress()
self.resumable_episodes = resumable_episodes
self.do_offer_download_resume.emit()

common.find_partial_downloads(podcasts,
start_progress_callback,
progress_callback,
finish_progress_callback)

do_offer_download_resume = Signal()

def on_offer_download_resume(self):
if self.resumable_episodes:
def download_episodes():
for episode in self.resumable_episodes:
qepisode = self.wrap_simple_episode(episode)
self.controller.downloadEpisode(qepisode)

def delete_episodes():
logger.debug('Deleting incomplete downloads.')
common.clean_up_downloads(delete_partial=True)

message = _('Incomplete downloads from a previous session were found.')
title = _('Resume')

self.controller.confirm_action(message, title, download_episodes, delete_episodes)

def add_active_episode(self, episode):
self.active_episode_wrappers[episode.id] = episode
Expand Down Expand Up @@ -1059,13 +1105,21 @@ def remove_podcast(self, podcast):
def load_podcasts(self):
podcasts = map(model.QPodcast, self.model.get_podcasts())
self.podcast_model.set_podcasts(self.db, podcasts)
return podcasts

def wrap_episode(self, podcast, episode):
try:
return self.active_episode_wrappers[episode.id]
except KeyError:
return model.QEpisode(self, podcast, episode)

def wrap_simple_episode(self, episode):
for podcast in self.podcast_model.get_podcasts():
if podcast.id == episode.podcast_id:
return self.wrap_episode(podcast, episode)

return None

def select_podcast(self, podcast):
if isinstance(podcast, model.QPodcast):
# Normal QPodcast instance
Expand Down

0 comments on commit 3a9eb69

Please sign in to comment.