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
27 from gpodder
import sync
, util
28 from gpodder
.deviceplaylist
import gPodderDevicePlaylist
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
,
44 download_status_model
,
45 download_queue_manager
,
46 set_download_list_state
,
47 commit_changes_to_database
,
49 select_episodes_to_delete
,
50 mount_volume_for_file
):
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".
76 for channel
in channels
:
77 if only_downloaded
or not channel
.sync_to_mp3_player
:
78 logger
.info('Skipping channel: %s', channel
.title
)
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
)
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
)
102 self
._show
_message
_unconfigured
()
108 if not device
.open():
109 self
._show
_message
_cannot
_open
()
114 # Only set if device is configured and opened successfully
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
()
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
):
135 # Might not be synced if it's played already
137 and self
._config
.device_sync
.skip_played_episodes
):
140 # In all other cases, we expect the episode to be
141 # synchronized to the device, so "answer" positive
144 # "What is the file size of this episode?"
145 def file_size(episode
):
146 filename
= episode
.local_filename(create
=False)
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
):
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
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
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
)
237 if self
._config
.device_sync
.playlists
.create
:
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
266 episodes_to_delete
.append(episode_dict
[episode_filename
])
268 logger
.warning('Episode %s, removed from device has already been deleted from gpodder',
270 # delete all episodes from gpodder (will prompt user)
272 # not using playlists to delete
273 def auto_delete_callback(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)
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
)
292 if episodes_to_delete
:
294 ('markup_delete_episodes', None, None, _('Episode')),
297 self
.select_episodes_to_delete(
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
),
304 callback
=auto_delete_callback
,
305 _config
=self
._config
)
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
)
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
)
332 if local_episode
.state
== gpodder
.STATE_DELETED
:
333 logger
.info('Removing episode from device: %s',
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
)