Merge pull request #1618 from tpikonen/view-prefs
[gpodder.git] / src / gpodder / syncui.py
blob7a355f2687e57f18596e296604be653ab3c6314a
1 # -*- coding: utf-8 -*-
3 # gPodder - A media aggregator and podcast client
4 # Copyright (c) 2005-2018 The gPodder Team
6 # gPodder is free software; you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation; either version 3 of the License, or
9 # (at your option) any later version.
11 # gPodder is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU General Public License for more details.
16 # You should have received a copy of the GNU General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
20 # gpodder.gtkui.desktop.sync - Glue code between GTK+ UI and sync module
21 # Thomas Perl <thp@gpodder.org>; 2009-09-05 (based on code from gui.py)
22 # Ported to gPodder 3 by Joseph Wickremasinghe in June 2012
24 import logging
26 import gpodder
27 from gpodder import sync, util
28 from gpodder.deviceplaylist import gPodderDevicePlaylist
30 _ = gpodder.gettext
33 logger = logging.getLogger(__name__)
36 class gPodderSyncUI(object):
37 # download list states
38 (DL_ONEOFF, DL_ADDING_TASKS, DL_ADDED_TASKS) = list(range(3))
40 def __init__(self, config, notification, parent_window,
41 show_confirmation,
42 show_preferences,
43 channels,
44 download_status_model,
45 download_queue_manager,
46 set_download_list_state,
47 commit_changes_to_database,
48 delete_episode_list,
49 select_episodes_to_delete,
50 mount_volume_for_file):
51 self.device = None
53 self._config = config
54 self.notification = notification
55 self.parent_window = parent_window
56 self.show_confirmation = show_confirmation
58 self.show_preferences = show_preferences
59 self.channels = channels
60 self.download_status_model = download_status_model
61 self.download_queue_manager = download_queue_manager
62 self.set_download_list_state = set_download_list_state
63 self.commit_changes_to_database = commit_changes_to_database
64 self.delete_episode_list = delete_episode_list
65 self.select_episodes_to_delete = select_episodes_to_delete
66 self.mount_volume_for_file = mount_volume_for_file
68 def _filter_sync_episodes(self, channels, only_downloaded=False):
69 """Return a list of episodes for device synchronization
71 If only_downloaded is True, this will skip episodes that
72 have not been downloaded yet and podcasts that are marked
73 as "Do not synchronize to my device".
74 """
75 episodes = []
76 for channel in channels:
77 if only_downloaded or not channel.sync_to_mp3_player:
78 logger.info('Skipping channel: %s', channel.title)
79 continue
81 for episode in channel.get_all_episodes():
82 if (episode.was_downloaded(and_exists=True)
83 or not only_downloaded):
84 episodes.append(episode)
85 return episodes
87 def _show_message_unconfigured(self):
88 title = _('No device configured')
89 message = _('Please set up your device in the preferences dialog.')
90 if self.show_confirmation(message, title):
91 self.show_preferences(self.parent_window, None)
93 def _show_message_cannot_open(self):
94 title = _('Cannot open device')
95 message = _('Please check logs and the settings in the preferences dialog.')
96 self.notification(message, title, important=True)
98 def on_synchronize_episodes(self, channels, episodes=None, force_played=True, done_callback=None):
99 device = sync.open_device(self)
101 if device is None:
102 self._show_message_unconfigured()
103 if done_callback:
104 done_callback()
105 return
107 try:
108 if not device.open():
109 self._show_message_cannot_open()
110 if done_callback:
111 done_callback()
112 return
113 else:
114 # Only set if device is configured and opened successfully
115 self.device = device
116 except Exception as err:
117 logger.error('opening destination %s failed with %s',
118 device.destination.get_uri(), err.message)
119 self._show_message_cannot_open()
120 if done_callback:
121 done_callback()
122 return
124 if episodes is None:
125 force_played = False
126 episodes = self._filter_sync_episodes(channels)
128 def check_free_space():
129 # "Will we add this episode to the device?"
130 def will_add(episode):
131 # If already on-device, it won't take up any space
132 if device.episode_on_device(episode):
133 return False
135 # Might not be synced if it's played already
136 if (not force_played
137 and self._config.device_sync.skip_played_episodes):
138 return False
140 # In all other cases, we expect the episode to be
141 # synchronized to the device, so "answer" positive
142 return True
144 # "What is the file size of this episode?"
145 def file_size(episode):
146 filename = episode.local_filename(create=False)
147 if filename is None:
148 return 0
149 return util.calculate_size(str(filename))
151 # Calculate total size of sync and free space on device
152 total_size = sum(file_size(e) for e in episodes if will_add(e))
153 free_space = max(device.get_free_space(), 0)
155 if total_size > free_space:
156 title = _('Not enough space left on device')
157 message = (_('Additional free space required: %(required_space)s\nDo you want to continue?') %
158 {'required_space': util.format_filesize(total_size - free_space)})
159 if not self.show_confirmation(message, title):
160 device.cancel()
161 device.close()
162 return
164 # enable updating of UI
165 self.set_download_list_state(gPodderSyncUI.DL_ONEOFF)
167 """Update device playlists
168 General approach is as follows:
170 When a episode is downloaded and synched, it is added to the
171 standard playlist for that podcast which is then written to
172 the device.
174 After the user has played that episode on their device, they
175 can delete that episode from their device.
177 At the next sync, gPodder will then compare the standard
178 podcast-specific playlists on the device (as written by
179 gPodder during the last sync), with the episodes on the
180 device.If there is an episode referenced in the playlist
181 that is no longer on the device, gPodder will assume that
182 the episode has already been synced and subsequently deleted
183 from the device, and will hence mark that episode as deleted
184 in gPodder. If there are no playlists, nothing is deleted.
186 At the next sync, the playlists will be refreshed based on
187 the downloaded, undeleted episodes in gPodder, and the
188 cycle begins again...
191 def resume_sync(episode_urls, channel_urls, progress):
192 if progress is not None:
193 progress.on_finished()
195 # rest of sync process should continue here
196 self.commit_changes_to_database()
197 for current_channel in self.channels:
198 # only sync those channels marked for syncing
199 if (self._config.device_sync.device_type == 'filesystem'
200 and current_channel.sync_to_mp3_player
201 and self._config.device_sync.playlists.create):
203 # get playlist object
204 playlist = gPodderDevicePlaylist(self._config,
205 current_channel.title)
206 # need to refresh episode list so that
207 # deleted episodes aren't included in playlists
208 episodes_for_playlist = sorted(current_channel.get_episodes(gpodder.STATE_DOWNLOADED),
209 key=lambda ep: ep.published)
210 # don't add played episodes to playlist if skip_played_episodes is True
211 if self._config.device_sync.skip_played_episodes:
212 episodes_for_playlist = [ep for ep in episodes_for_playlist if ep.is_new]
213 playlist.write_m3u(episodes_for_playlist)
215 # enable updating of UI, but mark it as tasks being added so that a
216 # adding a single task that completes immediately doesn't turn off the
217 # ui updates again
218 self.set_download_list_state(gPodderSyncUI.DL_ADDING_TASKS)
220 if (self._config.device_sync.device_type == 'filesystem' and self._config.device_sync.playlists.create):
221 title = _('Update successful')
222 message = _('The playlist on your MP3 player has been updated.')
223 self.notification(message, title)
225 # called from the main thread to complete adding tasks
226 def add_downloads_complete():
227 self.set_download_list_state(gPodderSyncUI.DL_ADDED_TASKS)
229 # Finally start the synchronization process
230 @util.run_in_background
231 def sync_thread_func():
232 device.add_sync_tasks(episodes, force_played=force_played,
233 done_callback=done_callback)
234 util.idle_add(add_downloads_complete)
235 return
237 if self._config.device_sync.playlists.create:
238 try:
239 episodes_to_delete = []
240 if self._config.device_sync.playlists.two_way_sync:
241 for current_channel in self.channels:
242 # only include channels that are included in the sync
243 if current_channel.sync_to_mp3_player:
244 # get playlist object
245 playlist = gPodderDevicePlaylist(self._config, current_channel.title)
246 # get episodes to be written to playlist
247 episodes_for_playlist = sorted(current_channel.get_episodes(gpodder.STATE_DOWNLOADED),
248 key=lambda ep: ep.published)
249 episode_keys = list(map(playlist.get_absolute_filename_for_playlist,
250 episodes_for_playlist))
252 episode_dict = dict(list(zip(episode_keys, episodes_for_playlist)))
254 # then get episodes in playlist (if it exists) already on device
255 episodes_in_playlists = playlist.read_m3u()
256 # if playlist doesn't exist (yet) episodes_in_playlist will be empty
257 if episodes_in_playlists:
258 for episode_filename in episodes_in_playlists:
259 if ((not self._config.device_sync.playlists.use_absolute_path
260 and not playlist.playlist_folder.resolve_relative_path(episode_filename).query_exists())
261 or (self._config.device_sync.playlists.use_absolute_path
262 and not playlist.mountpoint.resolve_relative_path(episode_filename).query_exists())):
263 # episode was synced but no longer on device
264 # i.e. must have been deleted by user, so delete from gpodder
265 try:
266 episodes_to_delete.append(episode_dict[episode_filename])
267 except KeyError:
268 logger.warning('Episode %s, removed from device has already been deleted from gpodder',
269 episode_filename)
270 # delete all episodes from gpodder (will prompt user)
272 # not using playlists to delete
273 def auto_delete_callback(episodes):
275 if not episodes:
276 # episodes were deleted on device
277 # but user decided not to delete them from gpodder
278 # so jump straight to sync
279 logger.info('Starting sync - no episodes selected for deletion')
280 resume_sync([], [], None)
281 else:
282 # episodes need to be deleted from gpodder
283 for episode_to_delete in episodes:
284 logger.info("Deleting episode %s",
285 episode_to_delete.title)
287 logger.info('Will start sync - after deleting episodes')
288 self.delete_episode_list(episodes, False, resume_sync)
290 return
292 if episodes_to_delete:
293 columns = (
294 ('markup_delete_episodes', None, None, _('Episode')),
297 self.select_episodes_to_delete(
298 self.parent_window,
299 title=_('Episodes have been deleted on device'),
300 instructions='Select the episodes you want to delete:',
301 episodes=episodes_to_delete,
302 selected=[True, ] * len(episodes_to_delete),
303 columns=columns,
304 callback=auto_delete_callback,
305 _config=self._config)
306 else:
307 logger.warning("Starting sync - no episodes to delete")
308 resume_sync([], [], None)
310 except IOError as ioe:
311 title = _('Error writing playlist files')
312 message = _(str(ioe))
313 self.notification(message, title)
314 else:
315 logger.info('Not creating playlists - starting sync')
316 resume_sync([], [], None)
318 # This function is used to remove files from the device
319 def cleanup_episodes():
320 # 'skip_played_episodes' must be used or else all the
321 # played tracks will be copied then immediately deleted
322 if (self._config.device_sync.delete_deleted_episodes
323 or (self._config.device_sync.delete_played_episodes
324 and self._config.device_sync.skip_played_episodes)):
325 all_episodes = self._filter_sync_episodes(
326 channels, only_downloaded=False)
327 for local_episode in all_episodes:
328 episode = device.episode_on_device(local_episode)
329 if episode is None:
330 continue
332 if local_episode.state == gpodder.STATE_DELETED:
333 logger.info('Removing episode from device: %s',
334 episode.title)
335 device.remove_track(episode)
337 # When this is done, start the callback in the UI code
338 util.idle_add(check_free_space)
340 # This will run the following chain of actions:
341 # 1. Remove old episodes (in worker thread)
342 # 2. Check for free space (in UI thread)
343 # 3. Sync the device (in UI thread)
344 util.run_in_background(cleanup_episodes)