Device playlists & two-way sync

This commit is contained in:
Joseph Wickremasinghe 2013-01-31 19:36:21 -08:00 committed by Thomas Perl
parent 0434bb529b
commit b9b752df40
6 changed files with 403 additions and 32 deletions

View File

@ -567,13 +567,13 @@
</child>
<child>
<object class="GtkVBox" id="vbox_devices">
<property name="border_width">12</property>
<property name="visible">True</property>
<property name="border_width">12</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_rows">6</property>
<property name="n_columns">2</property>
<property name="column_spacing">6</property>
<property name="row_spacing">6</property>
@ -618,7 +618,6 @@
<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>
@ -637,10 +636,10 @@
<property name="justify">right</property>
</object>
<packing>
<property name="top_attach">5</property>
<property name="bottom_attach">6</property>
<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>
@ -651,18 +650,78 @@
<packing>
<property name="left_attach">1</property>
<property name="right_attach">2</property>
<property name="top_attach">5</property>
<property name="bottom_attach">6</property>
<property name="y_options"></property>
</packing>
</child>
<child>
<object class="GtkCheckButton" id="checkbutton_create_playlists">
<property name="label" translatable="yes">Create playlists on device</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<property name="draw_indicator">True</property>
<signal name="toggled" handler="on_checkbutton_create_playlists_toggled"/>
</object>
<packing>
<property name="right_attach">2</property>
<property name="top_attach">2</property>
<property name="bottom_attach">3</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="label_device_playlists">
<property name="visible">True</property>
<property name="xalign">1</property>
<property name="label" translatable="yes">Playlists Folder:</property>
</object>
<packing>
<property name="top_attach">3</property>
<property name="bottom_attach">4</property>
<property name="x_options">GTK_FILL</property>
<property name="y_options"></property>
</packing>
</child>
<child>
<object class="GtkButton" id="btn_playlistfolder">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">False</property>
<signal name="clicked" handler="on_btn_playlist_folder_clicked"/>
</object>
<packing>
<property name="left_attach">1</property>
<property name="right_attach">2</property>
<property name="top_attach">3</property>
<property name="bottom_attach">4</property>
<property name="y_options"></property>
</packing>
</child>
<child>
<object class="GtkCheckButton" id="checkbutton_delete_using_playlists">
<property name="label" translatable="yes">Remove episodes deleted on device from gPodder</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="left_attach">0</property>
<property name="right_attach">2</property>
<property name="top_attach">4</property>
<property name="bottom_attach">5</property>
<property name="y_options"></property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="position">0</property>
</packing>
</child>
<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>
@ -672,7 +731,7 @@
</object>
<packing>
<property name="expand">False</property>
<property name="position">3</property>
<property name="position">1</property>
</packing>
</child>
</object>

View File

@ -173,6 +173,13 @@ defaults = {
'delete_episodes': False,
'sync_disks': False,
},
'playlists': {
'create': True,
'two_way_sync': False,
'use_absolute_path': True,
'folder': 'Playlists',
}
},
'youtube': {

View File

@ -0,0 +1,114 @@
# -*- 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/>.
#
import os
import gpodder
_ = gpodder.gettext
from gpodder import util
import logging
logger = logging.getLogger(__name__)
class gPodderDevicePlaylist(object):
def __init__(self, config, playlist_name):
self._config=config
self.linebreak = '\r\n'
self.playlist_file=playlist_name + '.m3u'
self.playlist_folder = os.path.join(self._config.device_sync.device_folder, self._config.device_sync.playlists.folder)
self.mountpoint = util.find_mount_point(self.playlist_folder)
if self.mountpoint == '/':
self.mountpoint = self.playlist_folder
logger.warning('MP3 player resides on / - using %s as MP3 player root', self.mountpoint)
self.playlist_absolute_filename=os.path.join(self.playlist_folder, self.playlist_file)
def build_extinf(self, filename):
#TO DO: Windows playlists
# if self._config.mp3_player_playlist_win_path:
# filename = filename.replace('\\', os.sep)
# # rebuild the whole filename including the mountpoint
# if self._config.device_sync.playlist_absolute_path:
# absfile = os.path.join(self.mountpoint,filename)
# else: #TODO: Test rel filenames
# absfile = util.rel2abs(filename, os.path.dirname(self.playlist_file))
# fallback: use the basename of the file
(title, extension) = os.path.splitext(os.path.basename(filename))
return "#EXTINF:0,%s%s" % (title.strip(), self.linebreak)
def read_m3u(self):
"""
read all files from the existing playlist
"""
tracks = []
logger.info("Read data from the playlistfile %s" % self.playlist_absolute_filename)
if os.path.exists(self.playlist_absolute_filename):
for line in open(self.playlist_absolute_filename, 'r'):
if not line.startswith('#EXT'):
tracks.append(line.rstrip('\r\n'))
return tracks
def get_filename_for_playlist(self, episode):
"""
get the filename for the given episode for the playlist
"""
filename_base = util.sanitize_filename(episode.sync_filename(
self._config.device_sync.custom_sync_name_enabled,
self._config.device_sync.custom_sync_name),
self._config.device_sync.max_filename_length)
filename = filename_base + os.path.splitext(episode.local_filename(create=False))[1].lower()
return filename
def get_absolute_filename_for_playlist(self, episode):
"""
get the filename including full path for the given episode for the playlist
"""
filename = self.get_filename_for_playlist(episode)
if self._config.device_sync.one_folder_per_podcast:
filename = os.path.join(episode.channel.title, filename)
if self._config.device_sync.playlist.absolute_path:
filename = os.path.join(util.relpath(self.mountpoint, self._config.device_sync.device_folder), filename)
return filename
def write_m3u(self, episodes):
"""
write the list into the playlist on the device
"""
logger.info('Writing playlist file: %s', self.playlist_file)
if not util.make_directory(self.playlist_folder):
raise IOError(_('Folder %s could not be created.') % self.playlist_folder, _('Error writing playlist'))
else:
fp = open(os.path.join(self.playlist_folder, self.playlist_file), 'w')
fp.write('#EXTM3U%s' % self.linebreak)
for current_episode in episodes:
filename_base = util.sanitize_filename(current_episode.sync_filename(
self._config.device_sync.custom_sync_name_enabled,
self._config.device_sync.custom_sync_name),
self._config.device_sync.max_filename_length)
filename = filename_base + os.path.splitext(current_episode.local_filename(create=False))[1].lower()
filename = self.get_filename_for_playlist(current_episode)
fp.write(self.build_extinf(filename))
filename = self.get_absolute_filename_for_playlist(current_episode)
fp.write(filename)
fp.write(self.linebreak)
fp.close()

View File

@ -229,14 +229,17 @@ class gPodderPreferences(BuilderWidget):
else:
self.hscale_expiration.set_value(0)
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._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.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)
@ -246,7 +249,12 @@ class gPodderPreferences(BuilderWidget):
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)
self._config.connect_gtk_togglebutton('device_sync.skip_played_episodes',
self.checkbutton_skip_played_episodes)
self._config.connect_gtk_togglebutton('device_sync.playlists.create',
self.checkbutton_create_playlists)
self._config.connect_gtk_togglebutton('device_sync.playlists.two_way_sync',
self.checkbutton_delete_using_playlists)
# Have to do this before calling set_active on checkbutton_enable
self._enable_mygpo = self._config.mygpo.enabled
@ -501,6 +509,31 @@ class gPodderPreferences(BuilderWidget):
index = self.combobox_on_sync.get_active()
self.on_sync_model.set_index(index)
def on_checkbutton_create_playlists_toggled(self, widget,device_type_changed=False):
if not widget.get_active():
self._config.device_sync.playlists.create=False
self.toggle_playlist_interface(False)
#need to read value of checkbutton from interface,
#rather than value of parameter
else:
self._config.device_sync.playlists.create=True
self.toggle_playlist_interface(True)
def toggle_playlist_interface(self,enabled):
if (enabled==True and not(self._config.device_sync.device_type == 'none')):
self.btn_playlistfolder.set_sensitive(True)
self.btn_playlistfolder.set_label(self._config.device_sync.playlists.folder)
self.checkbutton_delete_using_playlists.set_sensitive(True)
children = self.btn_playlistfolder.get_children()
if children:
label = children.pop()
label.set_alignment(0., .5)
else:
self.btn_playlistfolder.set_sensitive(False)
self.btn_playlistfolder.set_label('')
self.checkbutton_delete_using_playlists.set_sensitive(False)
def on_combobox_device_type_changed(self, widget):
index = self.combobox_device_type.get_active()
self.device_type_model.set_index(index)
@ -508,17 +541,20 @@ class gPodderPreferences(BuilderWidget):
if device_type == 'none':
self.btn_filesystemMountpoint.set_label('')
self.btn_filesystemMountpoint.set_sensitive(False)
self.checkbutton_create_playlists.set_sensitive(False)
self.toggle_playlist_interface(False)
elif device_type == 'filesystem':
self.btn_filesystemMountpoint.set_label(self._config.device_sync.device_folder)
self.btn_filesystemMountpoint.set_sensitive(True)
self.checkbutton_create_playlists.set_sensitive(True)
children = self.btn_filesystemMountpoint.get_children()
if children:
label = children.pop()
label.set_alignment(0., .5)
self.toggle_playlist_interface(self._config.device_sync.playlists.create)
else:
# TODO: Add support for iPod and MTP devices
pass
children = self.btn_filesystemMountpoint.get_children()
if children:
label = children.pop()
label.set_alignment(0., .5)
pass
def on_btn_device_mountpoint_clicked(self, widget):
fs = gtk.FileChooserDialog(title=_('Select folder for mount point'),
@ -530,9 +566,26 @@ class gPodderPreferences(BuilderWidget):
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()
def on_btn_playlist_folder_clicked(self, widget):
fs = gtk.FileChooserDialog(title=_('Select folder for playlists'),
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_playlistfolder.get_label())
if fs.run() == gtk.RESPONSE_OK:
filename = util.relpath(self._config.device_sync.device_folder,
fs.get_filename())
if self._config.device_sync.device_type == 'filesystem':
self._config.device_sync.playlists.folder = filename
self.btn_playlistfolder.set_label(filename)
children = self.btn_playlistfolder.get_children()
if children:
label = children.pop()
label.set_alignment(0., .5)
fs.destroy()

View File

@ -21,7 +21,7 @@
# Thomas Perl <thp@gpodder.org>; 2009-09-05 (based on code from gui.py)
# Ported to gPodder 3 by Joseph Wickremasinghe in June 2012
import gtk
import os
import gpodder
_ = gpodder.gettext
@ -29,6 +29,8 @@ _ = gpodder.gettext
from gpodder import util
from gpodder import sync
from gpodder.gtkui.desktop.episodeselector import gPodderEpisodeSelector
from gpodder.gtkui.desktop.deviceplaylist import gPodderDevicePlaylist
import logging
logger = logging.getLogger(__name__)
@ -38,11 +40,12 @@ class gPodderSyncUI(object):
update_episode_list_icons,
update_podcast_list_model,
preferences_widget,
episode_selector_class,
channels,
download_status_model,
download_queue_manager,
enable_download_list_update,
commit_changes_to_database):
commit_changes_to_database,
delete_episode_list):
self.device = None
self._config = config
@ -53,12 +56,12 @@ class gPodderSyncUI(object):
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.channels=channels
self.download_status_model = download_status_model
self.download_queue_manager = download_queue_manager
self.enable_download_list_update = enable_download_list_update
self.commit_changes_to_database = commit_changes_to_database
self.delete_episode_list=delete_episode_list
def _filter_sync_episodes(self, channels, only_downloaded=False):
"""Return a list of episodes for device synchronization
@ -141,11 +144,143 @@ class gPodderSyncUI(object):
device.close()
return
# Finally start the synchronization process
@util.run_in_background
def sync_thread_func():
#enable updating of UI
self.enable_download_list_update()
#Update device playlists
#General approach is as follows:
#When a episode is downloaded and synched, it is added to the
#standard playlist for that podcast which is then written to
#the device.
#After the user has played that episode on their device, they
#can delete that episode from their device.
#At the next sync, gPodder will then compare the standard
#podcast-specific playlists on the device (as written by
#gPodder during the last sync), with the episodes on the
#device.If there is an episode referenced in the playlist
#that is no longer on the device, gPodder will assume that
#the episode has already been synced and subsequently deleted
#from the device, and will hence mark that episode as deleted
#in gPodder. If there are no playlists, nothing is deleted.
#At the next sync, the playlists will be refreshed based on
#the downloaded, undeleted episodes in gPodder, and the
#cycle begins again...
def resume_sync(episode_urls, channel_urls,progress):
if progress is not None:
progress.on_finished()
#rest of sync process should continue here
self.commit_changes_to_database()
for current_channel in self.channels:
#only sync those channels marked for syncing
if current_channel.sync_to_mp3_player:
#get playlist object
playlist=gPodderDevicePlaylist(self._config,
current_channel.title)
#need to refresh episode list so that
#deleted episodes aren't included in playlists
episodes_for_playlist=sorted(current_channel.get_episodes(gpodder.STATE_DOWNLOADED),
key=lambda ep: ep.published)
playlist.write_m3u(episodes_for_playlist)
#enable updating of UI
self.enable_download_list_update()
device.add_sync_tasks(episodes, force_played=force_played)
title = _('Update successful')
message = _('The playlist on your MP3 player has been updated.')
self.notification(message, title, widget=self.preferences_widget)
# Finally start the synchronization process
@util.run_in_background
def sync_thread_func():
device.add_sync_tasks(episodes, force_played=force_played)
return
if self._config.device_sync.playlists.create:
try:
episodes_to_delete=[]
if self._config.device_sync.playlists.two_way_sync:
for current_channel in self.channels:
#only include channels that are included in the sync
if current_channel.sync_to_mp3_player:
#get playlist object
playlist=gPodderDevicePlaylist(self._config, current_channel.title)
#get episodes to be written to playlist
episodes_for_playlist=sorted(current_channel.get_episodes(gpodder.STATE_DOWNLOADED),
key=lambda ep: ep.published)
episode_keys=map(playlist.get_absolute_filename_for_playlist,
episodes_for_playlist)
episode_dict=dict(zip(episode_keys, episodes_for_playlist))
#then get episodes in playlist (if it exists) already on device
episodes_in_playlists = playlist.read_m3u()
#if playlist doesn't exist (yet) episodes_in_playlist will be empty
if episodes_in_playlists:
for episode_filename in episodes_in_playlists:
if not(os.path.exists(os.path.join(playlist.mountpoint,
episode_filename))):
#episode was synced but no longer on device
#i.e. must have been deleted by user, so delete from gpodder
try:
episodes_to_delete.append(episode_dict[episode_filename])
except KeyError, ioe:
logger.warn('Episode %s, removed from device has already been deleted from gpodder',
episode_filename)
#delete all episodes from gpodder (will prompt user)
#not using playlists to delete
def auto_delete_callback(episodes):
if not episodes:
#episodes were deleted on device
#but user decided not to delete them from gpodder
#so jump straight to sync
logger.info ('Starting sync - no episodes selected for deletion')
resume_sync([],[],None)
else:
#episodes need to be deleted from gpodder
for episode_to_delete in episodes:
logger.info("Deleting episode %s",
episode_to_delete.title)
logger.info ('Will start sync - after deleting episodes')
self.delete_episode_list(episodes,False,
True,resume_sync)
return
if episodes_to_delete:
columns = (
('markup_delete_episodes', None, None, _('Episode')),
)
gPodderEpisodeSelector(self.parent_window,
title = _('Episodes have been deleted on device'),
instructions = 'Select the episodes you want to delete:',
episodes = episodes_to_delete,
selected = [True,]*len(episodes_to_delete), columns = columns,
callback = auto_delete_callback,
_config=self._config)
else:
logger.warning("Starting sync - no episodes to delete")
resume_sync([],[],None)
except IOError, ioe:
title = _('Error writing playlist files')
message = _(str(ioe))
self.notification(message, title, widget=self.preferences_widget)
# This function is used to remove files from the device
def cleanup_episodes():
@ -155,7 +290,6 @@ class gPodderSyncUI(object):
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:

View File

@ -2536,7 +2536,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
if macapp is None:
sys.exit(0)
def delete_episode_list(self, episodes, confirm=True, skip_locked=True):
def delete_episode_list(self, episodes, confirm=True, skip_locked=True,callback=None):
if not episodes:
return False
@ -2589,7 +2589,10 @@ class gPodder(BuilderWidget, dbus.service.Object):
self.mygpo_client.on_delete(episodes_status_update)
self.mygpo_client.flush()
util.idle_add(finish_deletion, episode_urls, channel_urls)
if callback==None:
util.idle_add(finish_deletion, episode_urls, channel_urls)
else:
util.idle_add(callback, episode_urls, channel_urls,progress)
return True
@ -3437,11 +3440,12 @@ class gPodder(BuilderWidget, dbus.service.Object):
self.update_episode_list_icons,
self.update_podcast_list_model,
self.toolPreferences,
gPodderEpisodeSelector,
self.channels,
self.download_status_model,
self.download_queue_manager,
self.enable_download_list_update,
self.commit_changes_to_database)
self.commit_changes_to_database,
self.delete_episode_list)
self.sync_ui.on_synchronize_episodes(self.channels, episodes, force_played)