2012-07-02 05:53:33 +02:00
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
#
|
|
|
|
# gPodder - A media aggregator and podcast client
|
2018-01-28 19:39:53 +01:00
|
|
|
# Copyright (c) 2005-2018 The gPodder Team
|
2012-07-02 05:53:33 +02:00
|
|
|
#
|
|
|
|
# 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)
|
2012-07-09 21:08:40 +02:00
|
|
|
# Ported to gPodder 3 by Joseph Wickremasinghe in June 2012
|
2012-07-02 05:53:33 +02:00
|
|
|
|
2018-07-24 11:08:10 +02:00
|
|
|
import logging
|
2013-02-01 04:36:21 +01:00
|
|
|
import os
|
2018-07-24 11:08:10 +02:00
|
|
|
|
2012-07-02 05:53:33 +02:00
|
|
|
import gpodder
|
2018-07-24 11:08:10 +02:00
|
|
|
from gpodder import sync, util
|
2018-10-14 16:10:45 +02:00
|
|
|
from gpodder.deviceplaylist import gPodderDevicePlaylist
|
2012-07-02 05:53:33 +02:00
|
|
|
|
|
|
|
_ = gpodder.gettext
|
|
|
|
|
2012-07-09 21:08:40 +02:00
|
|
|
|
2012-07-02 05:53:33 +02:00
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
2018-02-11 00:22:00 +01:00
|
|
|
|
2012-07-02 05:53:33 +02:00
|
|
|
class gPodderSyncUI(object):
|
2021-06-05 23:24:04 +02:00
|
|
|
# download list states
|
|
|
|
(DL_ONEOFF, DL_ADDING_TASKS, DL_ADDED_TASKS) = list(range(3))
|
|
|
|
|
2012-07-09 21:08:40 +02:00
|
|
|
def __init__(self, config, notification, parent_window,
|
2018-08-18 12:27:24 +02:00
|
|
|
show_confirmation,
|
|
|
|
show_preferences,
|
|
|
|
channels,
|
|
|
|
download_status_model,
|
|
|
|
download_queue_manager,
|
2021-06-05 23:24:04 +02:00
|
|
|
set_download_list_state,
|
2018-08-18 12:27:24 +02:00
|
|
|
commit_changes_to_database,
|
2018-10-14 16:10:45 +02:00
|
|
|
delete_episode_list,
|
2021-05-31 08:12:13 +02:00
|
|
|
select_episodes_to_delete,
|
|
|
|
mount_volume_for_file):
|
2012-07-09 21:08:40 +02:00
|
|
|
self.device = None
|
|
|
|
|
2012-07-02 05:53:33 +02:00
|
|
|
self._config = config
|
|
|
|
self.notification = notification
|
|
|
|
self.parent_window = parent_window
|
|
|
|
self.show_confirmation = show_confirmation
|
2012-07-09 21:08:40 +02:00
|
|
|
|
2016-09-28 12:04:21 +02:00
|
|
|
self.show_preferences = show_preferences
|
2018-04-17 14:39:05 +02:00
|
|
|
self.channels = channels
|
2012-07-09 21:08:40 +02:00
|
|
|
self.download_status_model = download_status_model
|
|
|
|
self.download_queue_manager = download_queue_manager
|
2021-06-05 23:24:04 +02:00
|
|
|
self.set_download_list_state = set_download_list_state
|
2012-07-02 05:53:33 +02:00
|
|
|
self.commit_changes_to_database = commit_changes_to_database
|
2018-04-17 14:39:05 +02:00
|
|
|
self.delete_episode_list = delete_episode_list
|
2018-10-14 16:10:45 +02:00
|
|
|
self.select_episodes_to_delete = select_episodes_to_delete
|
2021-05-31 08:12:13 +02:00
|
|
|
self.mount_volume_for_file = mount_volume_for_file
|
2012-07-02 05:53:33 +02:00
|
|
|
|
|
|
|
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:
|
2012-08-30 04:35:43 +02:00
|
|
|
if only_downloaded or not channel.sync_to_mp3_player:
|
2012-07-02 05:53:33 +02:00
|
|
|
logger.info('Skipping channel: %s', channel.title)
|
|
|
|
continue
|
|
|
|
|
|
|
|
for episode in channel.get_all_episodes():
|
2012-07-09 21:08:40 +02:00
|
|
|
if (episode.was_downloaded(and_exists=True) or
|
2012-07-02 05:53:33 +02:00
|
|
|
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.')
|
2016-09-28 12:04:21 +02:00
|
|
|
if self.show_confirmation(message, title):
|
|
|
|
self.show_preferences(self.parent_window, None)
|
2012-07-02 05:53:33 +02:00
|
|
|
|
|
|
|
def _show_message_cannot_open(self):
|
|
|
|
title = _('Cannot open device')
|
2020-05-13 09:30:48 +02:00
|
|
|
message = _('Please check logs and the settings in the preferences dialog.')
|
2016-09-28 11:38:16 +02:00
|
|
|
self.notification(message, title, important=True)
|
2012-07-02 05:53:33 +02:00
|
|
|
|
2018-10-14 16:10:45 +02:00
|
|
|
def on_synchronize_episodes(self, channels, episodes=None, force_played=True, done_callback=None):
|
2012-07-02 05:53:33 +02:00
|
|
|
device = sync.open_device(self)
|
|
|
|
|
|
|
|
if device is None:
|
2021-06-12 16:45:53 +02:00
|
|
|
self._show_message_unconfigured()
|
|
|
|
if done_callback:
|
|
|
|
done_callback()
|
|
|
|
return
|
2012-07-02 05:53:33 +02:00
|
|
|
|
|
|
|
if not device.open():
|
2021-06-12 16:45:53 +02:00
|
|
|
self._show_message_cannot_open()
|
|
|
|
if done_callback:
|
|
|
|
done_callback()
|
|
|
|
return
|
2012-07-02 05:53:33 +02:00
|
|
|
else:
|
2012-07-09 21:08:40 +02:00
|
|
|
# Only set if device is configured and opened successfully
|
|
|
|
self.device = device
|
2012-07-02 05:53:33 +02:00
|
|
|
|
|
|
|
if episodes is None:
|
|
|
|
force_played = False
|
|
|
|
episodes = self._filter_sync_episodes(channels)
|
2012-07-09 21:08:40 +02:00
|
|
|
|
2012-07-02 05:53:33 +02:00
|
|
|
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
|
2012-07-09 21:08:40 +02:00
|
|
|
if (not force_played and
|
2012-07-02 05:53:33 +02:00
|
|
|
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')
|
2012-07-07 23:41:56 +02:00
|
|
|
message = (_('Additional free space required: %(required_space)s\nDo you want to continue?') %
|
|
|
|
{'required_space': util.format_filesize(total_size - free_space)})
|
2012-07-02 05:53:33 +02:00
|
|
|
if not self.show_confirmation(message, title):
|
|
|
|
device.cancel()
|
|
|
|
device.close()
|
|
|
|
return
|
|
|
|
|
2018-05-16 18:17:52 +02:00
|
|
|
# enable updating of UI
|
2021-06-05 23:24:04 +02:00
|
|
|
self.set_download_list_state(gPodderSyncUI.DL_ONEOFF)
|
2013-02-01 04:36:21 +01:00
|
|
|
|
2018-05-16 18:17:52 +02:00
|
|
|
"""Update device playlists
|
|
|
|
General approach is as follows:
|
2013-02-01 04:36:21 +01:00
|
|
|
|
2018-05-16 18:17:52 +02:00
|
|
|
When a episode is downloaded and synched, it is added to the
|
|
|
|
standard playlist for that podcast which is then written to
|
|
|
|
the device.
|
2013-02-01 04:36:21 +01:00
|
|
|
|
2018-05-16 18:17:52 +02:00
|
|
|
After the user has played that episode on their device, they
|
|
|
|
can delete that episode from their device.
|
2013-02-01 04:36:21 +01:00
|
|
|
|
2018-05-16 18:17:52 +02:00
|
|
|
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.
|
2013-02-01 04:36:21 +01:00
|
|
|
|
2018-05-16 18:17:52 +02:00
|
|
|
At the next sync, the playlists will be refreshed based on
|
|
|
|
the downloaded, undeleted episodes in gPodder, and the
|
|
|
|
cycle begins again...
|
|
|
|
"""
|
2013-02-01 04:36:21 +01:00
|
|
|
|
2018-05-29 22:54:05 +02:00
|
|
|
def resume_sync(episode_urls, channel_urls, progress):
|
2013-02-01 04:36:21 +01:00
|
|
|
if progress is not None:
|
|
|
|
progress.on_finished()
|
|
|
|
|
2018-05-16 18:17:52 +02:00
|
|
|
# rest of sync process should continue here
|
2013-02-01 04:36:21 +01:00
|
|
|
self.commit_changes_to_database()
|
|
|
|
for current_channel in self.channels:
|
2018-05-16 18:17:52 +02:00
|
|
|
# only sync those channels marked for syncing
|
2018-09-07 06:52:32 +02:00
|
|
|
if (self._config.device_sync.device_type == 'filesystem'
|
|
|
|
and current_channel.sync_to_mp3_player
|
|
|
|
and self._config.device_sync.playlists.create):
|
2013-02-01 04:36:21 +01:00
|
|
|
|
2018-05-16 18:17:52 +02:00
|
|
|
# get playlist object
|
2018-04-17 14:39:05 +02:00
|
|
|
playlist = gPodderDevicePlaylist(self._config,
|
2018-08-18 12:27:24 +02:00
|
|
|
current_channel.title)
|
2018-05-16 18:17:52 +02:00
|
|
|
# need to refresh episode list so that
|
|
|
|
# deleted episodes aren't included in playlists
|
2018-04-17 14:39:05 +02:00
|
|
|
episodes_for_playlist = sorted(current_channel.get_episodes(gpodder.STATE_DOWNLOADED),
|
2018-08-18 12:27:24 +02:00
|
|
|
key=lambda ep: ep.published)
|
2018-05-16 18:17:52 +02:00
|
|
|
# don't add played episodes to playlist if skip_played_episodes is True
|
2013-04-10 17:17:28 +02:00
|
|
|
if self._config.device_sync.skip_played_episodes:
|
2018-04-17 14:39:05 +02:00
|
|
|
episodes_for_playlist = [ep for ep in episodes_for_playlist if ep.is_new]
|
2013-02-01 04:36:21 +01:00
|
|
|
playlist.write_m3u(episodes_for_playlist)
|
|
|
|
|
2021-06-05 23:24:04 +02:00
|
|
|
# enable updating of UI, but mark it as tasks being added so that a
|
|
|
|
# adding a single task that completes immediately doesn't turn off the
|
|
|
|
# ui updates again
|
|
|
|
self.set_download_list_state(gPodderSyncUI.DL_ADDING_TASKS)
|
2018-01-30 14:04:28 +01:00
|
|
|
|
2018-04-17 14:39:05 +02:00
|
|
|
if (self._config.device_sync.device_type == 'filesystem' and self._config.device_sync.playlists.create):
|
2013-02-19 07:54:42 +01:00
|
|
|
title = _('Update successful')
|
|
|
|
message = _('The playlist on your MP3 player has been updated.')
|
2016-09-28 11:38:16 +02:00
|
|
|
self.notification(message, title)
|
2013-02-01 04:36:21 +01:00
|
|
|
|
2021-07-31 09:23:23 +02:00
|
|
|
# called from the main thread to complete adding tasks
|
2021-06-05 23:24:04 +02:00
|
|
|
def add_downloads_complete():
|
|
|
|
self.set_download_list_state(gPodderSyncUI.DL_ADDED_TASKS)
|
|
|
|
|
2013-02-01 04:36:21 +01:00
|
|
|
# Finally start the synchronization process
|
|
|
|
@util.run_in_background
|
|
|
|
def sync_thread_func():
|
2013-06-14 22:45:55 +02:00
|
|
|
device.add_sync_tasks(episodes, force_played=force_played,
|
2018-10-14 16:10:45 +02:00
|
|
|
done_callback=done_callback)
|
2021-06-05 23:24:04 +02:00
|
|
|
util.idle_add(add_downloads_complete)
|
2013-02-01 04:36:21 +01:00
|
|
|
return
|
|
|
|
|
|
|
|
if self._config.device_sync.playlists.create:
|
|
|
|
try:
|
2018-04-17 14:39:05 +02:00
|
|
|
episodes_to_delete = []
|
2013-02-01 04:36:21 +01:00
|
|
|
if self._config.device_sync.playlists.two_way_sync:
|
|
|
|
for current_channel in self.channels:
|
2018-05-16 18:17:52 +02:00
|
|
|
# only include channels that are included in the sync
|
2013-02-08 11:08:28 +01:00
|
|
|
if current_channel.sync_to_mp3_player:
|
2018-05-16 18:17:52 +02:00
|
|
|
# get playlist object
|
2018-04-17 14:39:05 +02:00
|
|
|
playlist = gPodderDevicePlaylist(self._config, current_channel.title)
|
2018-05-16 18:17:52 +02:00
|
|
|
# get episodes to be written to playlist
|
2018-04-17 14:39:05 +02:00
|
|
|
episodes_for_playlist = sorted(current_channel.get_episodes(gpodder.STATE_DOWNLOADED),
|
|
|
|
key=lambda ep: ep.published)
|
|
|
|
episode_keys = list(map(playlist.get_absolute_filename_for_playlist,
|
|
|
|
episodes_for_playlist))
|
2013-02-01 04:36:21 +01:00
|
|
|
|
2018-04-17 14:39:05 +02:00
|
|
|
episode_dict = dict(list(zip(episode_keys, episodes_for_playlist)))
|
2013-02-01 04:36:21 +01:00
|
|
|
|
2018-05-16 18:17:52 +02:00
|
|
|
# then get episodes in playlist (if it exists) already on device
|
2013-02-01 04:36:21 +01:00
|
|
|
episodes_in_playlists = playlist.read_m3u()
|
2018-05-16 18:17:52 +02:00
|
|
|
# if playlist doesn't exist (yet) episodes_in_playlist will be empty
|
2013-02-01 04:36:21 +01:00
|
|
|
if episodes_in_playlists:
|
|
|
|
for episode_filename in episodes_in_playlists:
|
2021-07-28 05:44:04 +02:00
|
|
|
if not playlist.mountpoint.resolve_relative_path(episode_filename).query_exists():
|
2018-05-16 18:17:52 +02:00
|
|
|
# episode was synced but no longer on device
|
|
|
|
# i.e. must have been deleted by user, so delete from gpodder
|
2013-02-01 04:36:21 +01:00
|
|
|
try:
|
|
|
|
episodes_to_delete.append(episode_dict[episode_filename])
|
2016-11-21 23:13:46 +01:00
|
|
|
except KeyError as ioe:
|
2022-04-17 11:07:51 +02:00
|
|
|
logger.warning('Episode %s, removed from device has already been deleted from gpodder',
|
2013-02-01 04:36:21 +01:00
|
|
|
episode_filename)
|
2018-05-16 18:17:52 +02:00
|
|
|
# delete all episodes from gpodder (will prompt user)
|
2013-02-01 04:36:21 +01:00
|
|
|
|
2018-05-16 18:17:52 +02:00
|
|
|
# not using playlists to delete
|
2013-02-01 04:36:21 +01:00
|
|
|
def auto_delete_callback(episodes):
|
|
|
|
|
|
|
|
if not episodes:
|
2018-05-16 18:17:52 +02:00
|
|
|
# episodes were deleted on device
|
|
|
|
# but user decided not to delete them from gpodder
|
|
|
|
# so jump straight to sync
|
2018-02-06 15:19:08 +01:00
|
|
|
logger.info('Starting sync - no episodes selected for deletion')
|
2018-05-29 22:54:05 +02:00
|
|
|
resume_sync([], [], None)
|
2013-02-01 04:36:21 +01:00
|
|
|
else:
|
2018-05-16 18:17:52 +02:00
|
|
|
# episodes need to be deleted from gpodder
|
2013-02-01 04:36:21 +01:00
|
|
|
for episode_to_delete in episodes:
|
|
|
|
logger.info("Deleting episode %s",
|
2018-08-18 12:27:24 +02:00
|
|
|
episode_to_delete.title)
|
2013-02-01 04:36:21 +01:00
|
|
|
|
2018-02-06 15:19:08 +01:00
|
|
|
logger.info('Will start sync - after deleting episodes')
|
2021-07-12 12:32:49 +02:00
|
|
|
self.delete_episode_list(episodes, False, resume_sync)
|
2013-02-01 04:36:21 +01:00
|
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
if episodes_to_delete:
|
|
|
|
columns = (
|
|
|
|
('markup_delete_episodes', None, None, _('Episode')),
|
|
|
|
)
|
|
|
|
|
2018-10-14 16:10:45 +02:00
|
|
|
self.select_episodes_to_delete(
|
2018-05-21 19:26:01 +02:00
|
|
|
self.parent_window,
|
|
|
|
title=_('Episodes have been deleted on device'),
|
|
|
|
instructions='Select the episodes you want to delete:',
|
|
|
|
episodes=episodes_to_delete,
|
2018-05-29 22:54:05 +02:00
|
|
|
selected=[True, ] * len(episodes_to_delete),
|
2018-05-21 19:26:01 +02:00
|
|
|
columns=columns,
|
|
|
|
callback=auto_delete_callback,
|
2013-02-01 04:36:21 +01:00
|
|
|
_config=self._config)
|
|
|
|
else:
|
|
|
|
logger.warning("Starting sync - no episodes to delete")
|
2018-05-29 22:54:05 +02:00
|
|
|
resume_sync([], [], None)
|
2013-02-01 04:36:21 +01:00
|
|
|
|
2016-11-21 23:13:46 +01:00
|
|
|
except IOError as ioe:
|
2018-02-06 15:19:08 +01:00
|
|
|
title = _('Error writing playlist files')
|
2013-02-01 04:36:21 +01:00
|
|
|
message = _(str(ioe))
|
2016-09-28 11:38:16 +02:00
|
|
|
self.notification(message, title)
|
2013-02-19 07:54:42 +01:00
|
|
|
else:
|
2018-02-06 15:19:08 +01:00
|
|
|
logger.info('Not creating playlists - starting sync')
|
2018-05-29 22:54:05 +02:00
|
|
|
resume_sync([], [], None)
|
2012-07-02 05:53:33 +02:00
|
|
|
|
|
|
|
# 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
|
2021-05-31 08:12:13 +02:00
|
|
|
if (self._config.device_sync.delete_deleted_episodes or
|
|
|
|
(self._config.device_sync.delete_played_episodes and
|
|
|
|
self._config.device_sync.skip_played_episodes)):
|
2018-08-18 12:27:24 +02:00
|
|
|
all_episodes = self._filter_sync_episodes(
|
|
|
|
channels, only_downloaded=False)
|
2012-07-02 05:53:33 +02:00
|
|
|
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',
|
2018-08-18 12:27:24 +02:00
|
|
|
episode.title)
|
2012-07-02 05:53:33 +02:00
|
|
|
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)
|
2012-07-10 13:52:34 +02:00
|
|
|
util.run_in_background(cleanup_episodes)
|