Added device sync using 'Downloads' tab to show progress

This commit is contained in:
Joseph Wickremasinghe 2012-07-01 20:53:33 -07:00 committed by Thomas Perl
parent f9c6a74fb6
commit 2774142f8f
9 changed files with 1579 additions and 16 deletions

View File

@ -218,6 +218,21 @@
<signal handler="on_shownotes_selected_episodes" name="activate"/>
</object>
</child>
<child>
<object class="GtkAction" id="menuExtras">
<property name="name">menuExtras</property>
<property name="label" translatable="yes">E_xtras</property>
</object>
</child>
<child>
<object class="GtkAction" id="item_sync">
<property name="stock_id">gtk-refresh</property>
<property name="name">item_sync</property>
<property name="label" translatable="yes">Sync to Device</property>
<signal handler="on_sync_to_device_activate" name="activate"/>
</object>
<accelerator key="S" modifiers="GDK_CONTROL_MASK"/>
</child>
<child>
<object class="GtkAction" id="menuView">
<property name="name">menuView</property>
@ -363,6 +378,9 @@
<separator/>
<menuitem action="item_episode_details"/>
</menu>
<menu action="menuExtras">
<menuitem action="item_sync"/>
</menu>
<menu action="menuView">
<menuitem action="itemShowAllEpisodes"/>
<menuitem action="item_podcast_sections"/>
@ -928,7 +946,7 @@
<child type="tab">
<object class="GtkLabel" id="labelDownloads">
<property name="visible">True</property>
<property name="label" translatable="yes">Downloads</property>
<property name="label" translatable="yes">Tasks</property>
<property name="use_underline">False</property>
<property name="use_markup">False</property>
<property name="wrap">False</property>

View File

@ -477,6 +477,122 @@
<property name="position">3</property>
</packing>
</child>
<child>
<object class="GtkVBox" id="vbox_devices">
<property name="border_width">12</property>
<property name="visible">True</property>
<property name="spacing">6</property>
<child>
<object class="GtkTable" id="table_devices">
<property name="visible">True</property>
<property name="n_rows">3</property>
<property name="n_columns">2</property>
<property name="column_spacing">6</property>
<property name="row_spacing">6</property>
<child>
<object class="GtkLabel" id="label_device_type">
<property name="visible">True</property>
<property name="xalign">1</property>
<property name="label" translatable="yes">Device type:</property>
<property name="justify">right</property>
</object>
<packing>
<property name="x_options">GTK_FILL</property>
<property name="y_options"></property>
</packing>
</child>
<child>
<object class="GtkComboBox" id="combobox_device_type">
<property name="visible">True</property>
<signal name="changed" handler="on_combobox_device_type_changed"/>
</object>
<packing>
<property name="left_attach">1</property>
<property name="right_attach">2</property>
<property name="y_options"></property>
</packing>
</child>
<child>
<object class="GtkLabel" id="label_device_mount">
<property name="visible">True</property>
<property name="xalign">1</property>
<property name="label" translatable="yes">Mountpoint:</property>
</object>
<packing>
<property name="top_attach">1</property>
<property name="bottom_attach">2</property>
<property name="x_options">GTK_FILL</property>
<property name="y_options"></property>
</packing>
</child>
<child>
<object class="GtkButton" id="btn_filesystemMountpoint">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<property name="label" translatable="no"></property>
<signal name="clicked" handler="on_btn_device_mountpoint_clicked"/>
</object>
<packing>
<property name="left_attach">1</property>
<property name="right_attach">2</property>
<property name="top_attach">1</property>
<property name="bottom_attach">2</property>
<property name="y_options"></property>
</packing>
</child>
<child>
<object class="GtkLabel" id="label_on_sync">
<property name="visible">True</property>
<property name="xalign">1</property>
<property name="label" translatable="yes">After syncing an episode:</property>
<property name="justify">right</property>
</object>
<packing>
<property name="x_options">GTK_FILL</property>
<property name="y_options"></property>
<property name="top_attach">2</property>
<property name="bottom_attach">3</property>
</packing>
</child>
<child>
<object class="GtkComboBox" id="combobox_on_sync">
<property name="visible">True</property>
<signal name="changed" handler="on_combobox_on_sync_changed"/>
</object>
<packing>
<property name="left_attach">1</property>
<property name="right_attach">2</property>
<property name="y_options"></property>
<property name="top_attach">2</property>
<property name="bottom_attach">3</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkCheckButton" id="checkbutton_skip_played_episodes">
<property name="label" translatable="yes">Only sync unplayed episodes</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<property name="draw_indicator">True</property>
</object>
<packing>
<property name="expand">False</property>
<property name="position">3</property>
</packing>
</child>
</object>
<packing>
<property name="tab-label" translatable="yes">Devices</property>
<property name="position">4</property>
</packing>
</child>
</object>
<packing>
<property name="position">2</property>

View File

@ -155,6 +155,23 @@ defaults = {
},
},
'device_sync': {
# Settings for Device sync
'device_type': 'None',
'device_folder': '/media',
'one_folder_per_podcasts': True,
'skip_played_episodes': True,
'delete_played_episodes': False,
'max_filename_length': 999,
'custom_sync_name': '{episode.pubdate_prop}_{episode.title}',
'custom_sync_name_enabled': False,
'after_sync': {
'mark_episodes_played': False,
'delete_episodes': False,
'sync_disks': False,
},
},
'youtube': {
'preferred_fmt_id': 18,
},

View File

@ -57,6 +57,52 @@ class NewEpisodeActionList(gtk.ListStore):
def set_index(self, index):
self._config.auto_download = self[index][self.C_AUTO_DOWNLOAD]
class DeviceTypeActionList(gtk.ListStore):
C_CAPTION, C_DEVICE_TYPE = range(2)
def __init__(self, config):
gtk.ListStore.__init__(self, str, str)
self._config = config
self.append((_('None'), 'none'))
self.append((_('Filesystem-based'), 'filesystem'))
def get_index(self):
for index, row in enumerate(self):
if self._config.device_sync.device_type == row[self.C_DEVICE_TYPE]:
return index
return 0 # Some sane default
def set_index(self, index):
self._config.device_sync.device_type = self[index][self.C_DEVICE_TYPE]
class OnSyncActionList(gtk.ListStore):
C_CAPTION, C_ON_SYNC_DELETE, C_ON_SYNC_MARK_PLAYED = range(3)
ACTION_NONE, ACTION_ASK, ACTION_MINIMIZED, ACTION_ALWAYS = range(4)
def __init__(self, config):
gtk.ListStore.__init__(self, str, bool, bool)
self._config = config
self.append((_('Do nothing'), False, False))
self.append((_('Mark it as played'), False, True))
self.append((_('Delete it from gPodder'), True, False))
def get_index(self):
for index, row in enumerate(self):
if (self._config.device_sync.after_sync.delete_episodes and
row[self.C_ON_SYNC_DELETE]):
return index
if (self._config.device_sync.after_sync.mark_episodes_played and
row[self.C_ON_SYNC_MARK_PLAYED] and not
self._config.device_sync.after_sync.delete_episodes):
return index
return 0 # Some sane default
def set_index(self, index):
self._config.device_sync.after_sync.delete_episodes = self[index][self.C_ON_SYNC_DELETE]
self._config.device_sync.after_sync.mark_episodes_played = self[index][self.C_ON_SYNC_MARK_PLAYED]
class gPodderPreferences(BuilderWidget):
C_TOGGLE, C_LABEL, C_EXTENSION = range(3)
@ -119,6 +165,23 @@ class gPodderPreferences(BuilderWidget):
self._config.connect_gtk_togglebutton('auto_remove_unplayed_episodes', self.checkbutton_expiration_unplayed)
self._config.connect_gtk_togglebutton('auto_remove_unfinished_episodes', self.checkbutton_expiration_unfinished)
self.device_type_model = DeviceTypeActionList(self._config)
self.combobox_device_type.set_model(self.device_type_model)
cellrenderer = gtk.CellRendererText()
self.combobox_device_type.pack_start(cellrenderer, True)
self.combobox_device_type.add_attribute(cellrenderer, 'text', DeviceTypeActionList.C_CAPTION)
self.combobox_device_type.set_active(self.device_type_model.get_index())
self.on_sync_model = OnSyncActionList(self._config)
self.combobox_on_sync.set_model(self.on_sync_model)
cellrenderer = gtk.CellRendererText()
self.combobox_on_sync.pack_start(cellrenderer, True)
self.combobox_on_sync.add_attribute(cellrenderer, 'text', OnSyncActionList.C_CAPTION)
self.combobox_on_sync.set_active(self.on_sync_model.get_index())
self._config.connect_gtk_togglebutton('device_sync.skip_played_episodes', self.checkbutton_skip_played_episodes)
# Have to do this before calling set_active on checkbutton_enable
self._enable_mygpo = self._config.mygpo.enabled
@ -301,3 +364,37 @@ class gPodderPreferences(BuilderWidget):
self._config.mygpo.enabled = False
threading.Thread(target=thread_proc).start()
def on_combobox_on_sync_changed(self, widget):
index = self.combobox_on_sync.get_active()
self.on_sync_model.set_index(index)
def on_combobox_device_type_changed(self, widget):
index = self.combobox_device_type.get_active()
self.device_type_model.set_index(index)
if index == 0:
self.btn_filesystemMountpoint.set_label('')
self.btn_filesystemMountpoint.set_sensitive(False)
if index == 1: #JOSEPH: add back in ipod & mtp support
self.btn_filesystemMountpoint.set_label(self._config.device_sync.device_folder)
self.btn_filesystemMountpoint.set_sensitive(True)
children = self.btn_filesystemMountpoint.get_children()
if children:
label = children.pop()
label.set_alignment(0., .5)
def on_btn_device_mountpoint_clicked(self, widget):
fs = gtk.FileChooserDialog( title = _('Select folder for mount point'), action = gtk.FILE_CHOOSER_ACTION_SELECT_FOLDER)
fs.add_button( gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL)
fs.add_button( gtk.STOCK_OPEN, gtk.RESPONSE_OK)
fs.set_current_folder(self.btn_filesystemMountpoint.get_label())
if fs.run() == gtk.RESPONSE_OK:
filename = fs.get_filename()
if self._config.device_sync.device_type == 'filesystem':
self._config.device_sync.device_folder = filename
# Request an update of the mountpoint button
self.on_combobox_device_type_changed(None)
fs.destroy()

View File

@ -0,0 +1,175 @@
# -*- coding: utf-8 -*-
#
# gPodder - A media aggregator and podcast client
# Copyright (c) 2005-2011 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.gtkui.desktop.sync - Glue code between GTK+ UI and sync module
# Thomas Perl <thp@gpodder.org>; 2009-09-05 (based on code from gui.py)
import gtk
import threading
import gpodder
_ = gpodder.gettext
from gpodder import util
from gpodder import sync
import logging
logger = logging.getLogger(__name__)
class gPodderSyncUI(object):
def __init__(self, config, notification,
parent_window, show_confirmation,
update_episode_list_icons,
update_podcast_list_model,
preferences_widget,
episode_selector_class, download_status_model,
download_queue_manager,
enable_download_list_update,
commit_changes_to_database):
self._config = config
self.notification = notification
self.parent_window = parent_window
self.show_confirmation = show_confirmation
self.update_episode_list_icons = update_episode_list_icons
self.update_podcast_list_model = update_podcast_list_model
self.preferences_widget = preferences_widget
self.episode_selector_class = episode_selector_class
self.commit_changes_to_database = commit_changes_to_database
self.download_status_model=download_status_model
self.download_queue_manager=download_queue_manager
self.enable_download_list_update=enable_download_list_update
self.device=None
def _filter_sync_episodes(self, channels, only_downloaded=False):
"""Return a list of episodes for device synchronization
If only_downloaded is True, this will skip episodes that
have not been downloaded yet and podcasts that are marked
as "Do not synchronize to my device".
"""
episodes = []
for channel in channels:
if only_downloaded:
logger.info('Skipping channel: %s', channel.title)
continue
for episode in channel.get_all_episodes():
if (episode.was_downloaded(and_exists=True) or
not only_downloaded):
episodes.append(episode)
return episodes
def _show_message_unconfigured(self):
title = _('No device configured')
message = _('Please set up your device in the preferences dialog.')
self.notification(message, title, widget=self.preferences_widget)
def _show_message_cannot_open(self):
title = _('Cannot open device')
message = _('Please check the settings in the preferences dialog.')
self.notification(message, title, widget=self.preferences_widget)
def on_synchronize_episodes(self, channels, episodes=None, force_played=True):
device = sync.open_device(self)
if device is None:
return self._show_message_unconfigured()
if not device.open():
return self._show_message_cannot_open()
else:
#only set if device is configured
#and opened successfully
self.device=device
if episodes is None:
force_played = False
episodes = self._filter_sync_episodes(channels)
def check_free_space():
# "Will we add this episode to the device?"
def will_add(episode):
# If already on-device, it won't take up any space
if device.episode_on_device(episode):
return False
# Might not be synced if it's played already
if (not force_played and
self._config.device_sync.skip_played_episodes):
return False
# In all other cases, we expect the episode to be
# synchronized to the device, so "answer" positive
return True
# "What is the file size of this episode?"
def file_size(episode):
filename = episode.local_filename(create=False)
if filename is None:
return 0
return util.calculate_size(str(filename))
# Calculate total size of sync and free space on device
total_size = sum(file_size(e) for e in episodes if will_add(e))
free_space = max(device.get_free_space(), 0)
if total_size > free_space:
title = _('Not enough space left on device')
message = _('You need to free up %s.\nDo you want to continue?') \
% (util.format_filesize(total_size-free_space),)
if not self.show_confirmation(message, title):
device.cancel()
device.close()
return
# Finally start the synchronization process
def sync_thread_func():
self.enable_download_list_update()
device.add_sync_tasks(episodes, force_played=force_played)
threading.Thread(target=sync_thread_func).start()
# This function is used to remove files from the device
def cleanup_episodes():
# 'skip_played_episodes' must be used or else all the
# played tracks will be copied then immediately deleted
if (self._config.device_sync.delete_played_episodes and
self._config.device_sync.skip_played_episodes):
all_episodes = self._filter_sync_episodes(channels,
only_downloaded=False)
episodes_on_device = device.get_all_tracks()
for local_episode in all_episodes:
episode = device.episode_on_device(local_episode)
if episode is None:
continue
if local_episode.state == gpodder.STATE_DELETED:
logger.info('Removing episode from device: %s',
episode.title)
device.remove_track(episode)
# When this is done, start the callback in the UI code
util.idle_add(check_free_space)
# This will run the following chain of actions:
# 1. Remove old episodes (in worker thread)
# 2. Check for free space (in UI thread)
# 3. Sync the device (in UI thread)
threading.Thread(target=cleanup_episodes).start()

View File

@ -83,6 +83,8 @@ from gpodder.gtkui.desktop.episodeselector import gPodderEpisodeSelector
from gpodder.gtkui.desktop.podcastdirectory import gPodderPodcastDirectory
from gpodder.gtkui.interface.progress import ProgressIndicator
from gpodder.gtkui.desktop.sync import gPodderSyncUI
from gpodder.dbusproxy import DBusPodcastsProxy
from gpodder import extensions
@ -985,7 +987,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
else:
text = _('No subscriptions')
elif role == TreeViewHelper.ROLE_DOWNLOADS:
text = _('No active downloads')
text = _('No active tasks')
else:
raise Exception('on_treeview_expose_event: unknown role')
@ -1056,7 +1058,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
try:
model = self.download_status_model
downloading, failed, finished, queued, paused, others = 0, 0, 0, 0, 0, 0
downloading, synchronizing, failed, finished, queued, paused, others = 0, 0, 0, 0, 0, 0, 0
total_speed, total_size, done_size = 0, 0, 0
# Keep a list of all download tasks that we've seen
@ -1079,7 +1081,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
self.download_status_model.request_update(row.iter)
task = row[self.download_status_model.C_TASK]
speed, size, status, progress = task.speed, task.total_size, task.status, task.progress
speed, size, status, progress, activity = task.speed, task.total_size, task.status, task.progress, type(task).__name__
# Let the download task monitors know of changes
for monitor in self.download_task_monitors:
@ -1094,9 +1096,11 @@ class gPodder(BuilderWidget, dbus.service.Object):
download_tasks_seen.add(task)
if status == download.DownloadTask.DOWNLOADING:
if (status == download.DownloadTask.DOWNLOADING and activity=='DownloadTask'):
downloading += 1
total_speed += speed
elif (status == download.DownloadTask.DOWNLOADING and activity=='SyncTask'):
synchronizing+=1
elif status == download.DownloadTask.FAILED:
failed += 1
elif status == download.DownloadTask.DONE:
@ -1111,11 +1115,13 @@ class gPodder(BuilderWidget, dbus.service.Object):
# Remember which tasks we have seen after this run
self.download_tasks_seen = download_tasks_seen
text = [_('Downloads')]
if downloading + failed + queued > 0:
text = [_('Tasks')]
if downloading + failed + queued + synchronizing > 0:
s = []
if downloading > 0:
s.append(N_('%(count)d active', '%(count)d active', downloading) % {'count':downloading})
if synchronizing > 0:
s.append(N_('%(count)d active', '%(count)d active', synchronizing) % {'count':synchronizing})
if failed > 0:
s.append(N_('%(count)d failed', '%(count)d failed', failed) % {'count':failed})
if queued > 0:
@ -1131,9 +1137,9 @@ class gPodder(BuilderWidget, dbus.service.Object):
self.download_tasks_seen if task.status_changed]
episode_urls = [task.url for task in self.download_tasks_seen]
count = downloading + queued
if count > 0:
title.append(N_('downloading %(count)d file', 'downloading %(count)d files', count) % {'count':count})
if downloading > 0:
title.append(N_('downloading %(count)d file', 'downloading %(count)d files', downloading) % {'count':downloading})
if total_size > 0:
percentage = 100.0*done_size/total_size
@ -1142,11 +1148,15 @@ class gPodder(BuilderWidget, dbus.service.Object):
self.set_download_progress(percentage/100.)
total_speed = util.format_filesize(total_speed)
title[1] += ' (%d%%, %s/s)' % (percentage, total_speed)
else:
if synchronizing > 0:
title.append(N_('synchronizing %(count)d file', 'synchronizing %(count)d files', synchronizing) % {'count':synchronizing})
if queued > 0:
title.append(N_('%(queued)d task queued', '%(queued)d tasks queued', queued) % {'queued':queued})
if (downloading + synchronizing + queued)==0:
self.set_download_progress(1.)
self.downloads_finished(self.download_tasks_seen)
gpodder.user_extensions.on_all_episodes_downloaded()
logger.info('All downloads have finished.')
logger.info('All tasks have finished.')
# Remove finished episodes
if self.config.auto_cleanup_downloads and can_call_cleanup:
@ -1346,8 +1356,27 @@ class gPodder(BuilderWidget, dbus.service.Object):
return selected_tasks, can_queue, can_cancel, can_pause, can_remove, can_force
def downloads_finished(self, download_tasks_seen):
finished_downloads = [str(task) for task in download_tasks_seen if task.notify_as_finished()]
failed_downloads = [str(task)+' ('+task.error_message+')' for task in download_tasks_seen if task.notify_as_failed()]
#separate tasks into downloads & syncs
#since calling notify_as_finished or notify_as_failed clears the flag,
#need to iterate through downloads & syncs separately, else all sync
#tasks will have their flags cleared if we do downloads first
download_tasks=filter((lambda task: type(task).__name__=='DownloadTask'),download_tasks_seen)
sync_tasks=filter((lambda task: type(task).__name__=='SyncTask'),download_tasks_seen)
finished_downloads = [str(task) for task in download_tasks if
task.notify_as_finished()]
failed_downloads = [str(task)+' ('+task.error_message+')'
for task in download_tasks if
task.notify_as_failed()]
#Note that 'finished_ / failed_downloads' is a list of strings
#Whereas 'finished_ / failed_syncs' is a list of SyncTask objects
finished_syncs=filter((lambda task: task.notify_as_finished()),sync_tasks)
failed_syncs=filter((lambda task: task.notify_as_failed()),sync_tasks)
if finished_downloads and failed_downloads:
message = self.format_episode_list(finished_downloads, 5)
@ -1361,6 +1390,35 @@ class gPodder(BuilderWidget, dbus.service.Object):
message = self.format_episode_list(failed_downloads)
self.show_message(message, _('Downloads failed'), True, widget=self.labelDownloads)
if finished_syncs and failed_syncs:
message = self.format_episode_list(map((lambda task: str(task)),finished_syncs), 5)
message += '\n\n<i>%s</i>\n' % _('These syncs failed:')
message += self.format_episode_list(map((lambda task: str(task)),failed_syncs), 5)
self.show_message(message, _('Device synchronization finished'), True, widget=self.labelDownloads)
elif finished_syncs:
message = self.format_episode_list(map((lambda task: str(task)),finished_syncs))
self.show_message(message, _('Device synchronization finished'), widget=self.labelDownloads)
elif failed_syncs:
message = self.format_episode_list(map((lambda task: str(task)),failed_syncs))
self.show_message(message, _('Device synchronization failed'), True, widget=self.labelDownloads)
#do post sync processing if appropriate
for task in finished_syncs+failed_syncs:
if self.config.device_sync.after_sync.mark_episodes_played:
logger.info('Marking as played on transfer: %s', task.episode.url)
task.episode.mark(is_played=True)
if self.config.device_sync.after_sync.delete_episodes:
logger.info('Removing episode after transfer: %s', task.episode.url)
task.episode.delete_from_disk()
self.sync_ui.device.close()
#update icon list to show changes, if any
self.update_episode_list_icons(all=True)
def format_episode_list(self, episode_list, max_episodes=10):
"""
@ -3410,6 +3468,19 @@ class gPodder(BuilderWidget, dbus.service.Object):
logger.debug('extension_episode_download_cb(%s)', episode)
self.download_episode_list(episodes=[episode])
def on_sync_to_device_activate(self, widget, episodes=None, force_played=True):
self.sync_ui = gPodderSyncUI(self.config, self.notification,
self.main_window, self.show_confirmation,
self.update_episode_list_icons,
self.update_podcast_list_model, self.toolPreferences,
gPodderEpisodeSelector,
self.download_status_model,self.download_queue_manager,
self.enable_download_list_update,
self.commit_changes_to_database
)
self.sync_ui.on_synchronize_episodes(self.channels, episodes, force_played)
def main(options=None):
gobject.threads_init()
gobject.set_application_name('gPodder')

View File

@ -667,8 +667,12 @@ class PodcastEpisode(PodcastModelObject):
return False
return True
def sync_filename(self):
return self.title
def sync_filename(self, use_custom=False, custom_format=None):
if use_custom:
return util.object_string_formatter(custom_format,
episode=self, podcast=self.channel)
else:
return self.title
def file_type(self):
# Assume all YouTube/Vimeo links are video files

1044
src/gpodder/sync.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -305,6 +305,13 @@ def username_password_from_url(url):
return (username, password)
def directory_is_writable(path):
"""
Returns True if the specified directory exists and is writable
by the current user.
"""
return os.path.isdir(path) and os.access(path, os.W_OK)
def calculate_size( path):
"""
@ -371,6 +378,20 @@ def file_age_in_days(filename):
else:
return (datetime.datetime.now()-dt).days
def file_modification_timestamp(filename):
"""
Returns the modification date of the specified file as a number
or -1 if the modification date cannot be determined.
"""
if filename is None:
return -1
try:
s = os.stat(filename)
return s[stat.ST_MTIME]
except:
logger.warn('Cannot get modification timestamp for %s', filename)
return -1
def file_age_to_string(days):
"""