use Gio for file system based device sync (allows mtp:// urls)

This commit is contained in:
blushingpenguin 2021-05-31 07:12:13 +01:00
parent a2fc81ee82
commit 6d40da3cb5
8 changed files with 138 additions and 96 deletions

View File

@ -961,7 +961,8 @@ class gPodderCli(object):
_not_applicable,
self._db.commit,
_delete_episode_list,
_episode_selector)
_episode_selector,
_not_applicable)
done_lock.acquire()
sync_ui.on_synchronize_episodes(self._model.get_podcasts(), episodes=None, force_played=True, done_callback=done_lock.release)
done_lock.acquire() # block until done

View File

@ -59,4 +59,4 @@ class gPodderExtension:
self.launcher_entry = None
def on_download_progress(self, progress):
GObject.idle_add(self.launcher_entry.set_progress, float(value))
GObject.idle_add(self.launcher_entry.set_progress, float(progress))

View File

@ -801,6 +801,14 @@
<property name="draw-indicator">True</property>
</object>
</child>
<child>
<object class="GtkCheckButton" id="checkbutton_delete_deleted_episodes">
<property name="label" translatable="yes">Remove episodes deleted in gPodder from device</property>
<property name="visible">True</property>
<property name="receives-default">False</property>
<property name="draw-indicator">True</property>
</object>
</child>
</object>
<packing>
<property name="tab-label" translatable="yes">Devices</property>

View File

@ -182,6 +182,7 @@ defaults = {
'one_folder_per_podcast': True,
'skip_played_episodes': True,
'delete_played_episodes': False,
'delete_deleted_episodes': False,
'max_filename_length': 120,

View File

@ -293,6 +293,8 @@ class gPodderPreferences(BuilderWidget):
self.checkbutton_create_playlists)
self._config.connect_gtk_togglebutton('device_sync.playlists.two_way_sync',
self.checkbutton_delete_using_playlists)
self._config.connect_gtk_togglebutton('device_sync.delete_deleted_episodes',
self.checkbutton_delete_deleted_episodes)
# Have to do this before calling set_active on checkbutton_enable
self._enable_mygpo = self._config.mygpo.enabled
@ -650,6 +652,7 @@ class gPodderPreferences(BuilderWidget):
self.toggle_playlist_interface(self._config.device_sync.playlists.create)
self.combobox_on_sync.set_sensitive(True)
self.checkbutton_skip_played_episodes.set_sensitive(True)
self.checkbutton_delete_deleted_episodes.set_sensitive(True)
elif device_type == 'ipod':
self.btn_filesystemMountpoint.set_label(self._config.device_sync.device_folder)
self.btn_filesystemMountpoint.set_sensitive(True)
@ -671,15 +674,16 @@ class gPodderPreferences(BuilderWidget):
def on_btn_device_mountpoint_clicked(self, widget):
fs = Gtk.FileChooserDialog(title=_('Select folder for mount point'),
action=Gtk.FileChooserAction.SELECT_FOLDER)
fs.set_local_only(False)
fs.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL)
fs.add_button(Gtk.STOCK_OPEN, Gtk.ResponseType.OK)
fs.set_current_folder(self.btn_filesystemMountpoint.get_label())
fs.set_uri(self.btn_filesystemMountpoint.get_label())
if fs.run() == Gtk.ResponseType.OK:
filename = fs.get_filename()
if self._config.device_sync.device_type == 'filesystem':
self._config.device_sync.device_folder = filename
self._config.device_sync.device_folder = fs.get_uri()
elif self._config.device_sync.device_type == 'ipod':
self._config.device_sync.device_folder = filename
self._config.device_sync.device_folder = fs.get_filename()
# Request an update of the mountpoint button
self.on_combobox_device_type_changed(None)

View File

@ -3695,6 +3695,26 @@ class gPodder(BuilderWidget, dbus.service.Object):
logger.debug('extension_episode_download_cb(%s)', episode)
self.download_episode_list(episodes=[episode])
def mount_volume_cb(self, file, res, mount_result):
result = True
try:
file.mount_enclosing_volume_finish(res)
except GLib.Error as err:
if (not err.matches(Gio.io_error_quark(), Gio.IOErrorEnum.NOT_SUPPORTED) and
not err.matches(Gio.io_error_quark(), Gio.IOErrorEnum.ALREADY_MOUNTED)):
logger.error('mounting volume %s failed: %s' % (file.get_uri(), err.message));
result = False
finally:
mount_result["result"] = result
Gtk.main_quit()
def mount_volume_for_file(self, file):
mount_result = {}
op = Gtk.MountOperation.new(self.main_window)
file.mount_enclosing_volume(Gio.MountMountFlags.NONE, op, None, self.mount_volume_cb, mount_result)
Gtk.main()
return mount_result["result"]
def on_sync_to_device_activate(self, widget, episodes=None, force_played=True):
self.sync_ui = gPodderSyncUI(self.config, self.notification,
self.main_window,
@ -3706,7 +3726,8 @@ class gPodder(BuilderWidget, dbus.service.Object):
self.enable_download_list_update,
self.commit_changes_to_database,
self.delete_episode_list,
gPodderEpisodeSelector)
gPodderEpisodeSelector,
self.mount_volume_for_file)
self.sync_ui.on_synchronize_episodes(self.channels, episodes, force_played,
self.enable_download_list_update)

View File

@ -31,6 +31,7 @@ import time
import gpodder
from gpodder import download, services, util
from gi.repository import GLib, Gio, Gst, Gtk
logger = logging.getLogger(__name__)
@ -120,7 +121,8 @@ def open_device(gui):
elif device_type == 'filesystem':
return MP3PlayerDevice(config,
gui.download_status_model,
gui.download_queue_manager)
gui.download_queue_manager,
gui.mount_volume_for_file)
return None
@ -559,21 +561,43 @@ class iPodDevice(Device):
class MP3PlayerDevice(Device):
def __init__(self, config,
download_status_model,
download_queue_manager):
download_queue_manager,
mount_volume_for_file):
Device.__init__(self, config)
self.destination = self._config.device_sync.device_folder
self.buffer_size = 1024 * 1024 # 1 MiB
# self.destination = self._config.device_sync.device_folder
folder = self._config.device_sync.device_folder
if Gst.Uri.is_valid(folder):
self.destination = Gio.File.new_for_uri(folder)
else:
self.destination = Gio.File.new_for_path(folder)
self.mount_volume_for_file = mount_volume_for_file
self.download_status_model = download_status_model
self.download_queue_manager = download_queue_manager
def get_free_space(self):
return util.get_free_disk_space(self.destination)
info = self.destination.query_filesystem_info(Gio.FILE_ATTRIBUTE_FILESYSTEM_FREE, None)
return info.get_attribute_uint64(Gio.FILE_ATTRIBUTE_FILESYSTEM_FREE)
def open(self):
Device.open(self)
self.notify('status', _('Opening MP3 player'))
if util.directory_is_writable(self.destination):
if not self.mount_volume_for_file(self.destination):
return False
info = self.destination.query_info(
Gio.FILE_ATTRIBUTE_ACCESS_CAN_WRITE + "," +
Gio.FILE_ATTRIBUTE_STANDARD_TYPE,
Gio.FileQueryInfoFlags.NONE,
None)
# open is ok if the target is a directory, and it can be written to
# for smb, query_info doesn't return FILE_ATTRIBUTE_ACCESS_CAN_WRITE,
# -- if that's the case, just assume that it's writable
if (info.get_file_type() == Gio.FileType.DIRECTORY and (
not info.has_attribute(Gio.FILE_ATTRIBUTE_ACCESS_CAN_WRITE) or
info.get_attribute_boolean(Gio.FILE_ATTRIBUTE_ACCESS_CAN_WRITE))):
self.notify('status', _('MP3 player opened'))
self.tracks_list = self.get_all_tracks()
return True
@ -583,7 +607,7 @@ class MP3PlayerDevice(Device):
def get_episode_folder_on_device(self, episode):
folder = episode_foldername_on_device(self._config, episode)
if folder:
folder = os.path.join(self.destination, folder)
folder = self.destination.get_child(folder)
else:
folder = self.destination
@ -617,95 +641,66 @@ class MP3PlayerDevice(Device):
# get the filename that will be used on the device
to_file = self.get_episode_file_on_device(episode)
to_file = os.path.join(folder, to_file)
to_file = folder.get_child(to_file)
if not os.path.exists(folder):
if not folder.query_exists(None):
try:
os.makedirs(folder)
except:
logger.error('Cannot create folder on MP3 player: %s', folder)
folder.make_directory_with_parents(None)
except GLib.Error as err:
logger.error('Cannot create folder %s on MP3 player: %s', (folder % err.message))
self.cancel()
return False
if not os.path.exists(to_file):
if not to_file.query_exists(None):
logger.info('Copying %s => %s',
os.path.basename(from_file),
to_file)
self.copy_file_progress(from_file, to_file, reporthook)
return True
def copy_file_progress(self, from_file, to_file, reporthook=None):
try:
out_file = open(to_file, 'wb')
except IOError as ioerror:
d = {'filename': ioerror.filename, 'message': ioerror.strerror}
self.errors.append(_('Error opening %(filename)s: %(message)s') % d)
self.cancel()
return False
try:
in_file = open(from_file, 'rb')
except IOError as ioerror:
d = {'filename': ioerror.filename, 'message': ioerror.strerror}
self.errors.append(_('Error opening %(filename)s: %(message)s') % d)
self.cancel()
return False
in_file.seek(0, os.SEEK_END)
total_bytes = in_file.tell()
in_file.seek(0)
bytes_read = 0
s = in_file.read(self.buffer_size)
while s:
bytes_read += len(s)
from_file = Gio.File.new_for_path(from_file)
try:
out_file.write(s)
except IOError as ioerror:
self.errors.append(ioerror.strerror)
try:
out_file.close()
except:
pass
try:
logger.info('Trying to remove partially copied file: %s' % to_file)
os.unlink(to_file)
logger.info('Yeah! Unlinked %s at least..' % to_file)
except:
logger.error('Error while trying to unlink %s. OH MY!' % to_file)
hookconvert = lambda current_bytes, total_bytes, user_data : reporthook(current_bytes, 1, total_bytes)
from_file.copy(to_file, Gio.FileCopyFlags.OVERWRITE, None, hookconvert, None)
except GLib.Error as err:
d = {'from_file': from_file, 'to_file': to_file, 'message': err.message}
self.errors.append(_('Error copying %(from_file)s to %(to_file)s: %(message)s') % d)
self.cancel()
return False
reporthook(bytes_read, 1, total_bytes)
s = in_file.read(self.buffer_size)
out_file.close()
in_file.close()
return True
def add_sync_track(self, tracks, file, info, podcast_name):
(title, extension) = os.path.splitext(info.get_name())
timestamp = info.get_modification_time()
modified = util.format_date(timestamp.tv_sec)
t = SyncTrack(title, info.get_size(), modified,
modified_sort=timestamp,
filename=file.get_uri(),
podcast=podcast_name)
tracks.append(t)
def get_all_tracks(self):
tracks = []
if self._config.one_folder_per_podcast:
files = glob.glob(os.path.join(self.destination, '*', '*'))
else:
files = glob.glob(os.path.join(self.destination, '*'))
attributes = (
Gio.FILE_ATTRIBUTE_STANDARD_NAME + "," +
Gio.FILE_ATTRIBUTE_STANDARD_TYPE + "," +
Gio.FILE_ATTRIBUTE_STANDARD_SIZE + "," +
Gio.FILE_ATTRIBUTE_TIME_MODIFIED)
for filename in files:
(title, extension) = os.path.splitext(os.path.basename(filename))
length = util.calculate_size(filename)
timestamp = util.file_modification_timestamp(filename)
modified = util.format_date(timestamp)
root_path = self.destination
for path_info in root_path.enumerate_children(attributes, Gio.FileQueryInfoFlags.NONE, None):
if self._config.one_folder_per_podcast:
podcast_name = os.path.basename(os.path.dirname(filename))
else:
podcast_name = None
if path_info.get_file_type() == Gio.FileType.DIRECTORY:
path_file = root_path.get_child(path_info.get_name())
for child_info in path_file.enumerate_children(attributes, Gio.FileQueryInfoFlags.NONE, None):
if child_info.get_file_type() == Gio.FileType.REGULAR:
child_file = path_file.get_child(child_info.get_name())
self.add_sync_track(tracks, child_file, child_info, path_info.get_name())
t = SyncTrack(title, length, modified,
modified_sort=timestamp,
filename=filename,
podcast=podcast_name)
tracks.append(t)
else:
if path_info.get_file_type() == Gio.FileTypeFlags.REGULAR:
path_file = root_path.get_child(path_info.get_name())
self.add_sync_track(tracks, path_file, path_info, None)
return tracks
def episode_on_device(self, episode):
@ -717,18 +712,27 @@ class MP3PlayerDevice(Device):
def remove_track(self, track):
self.notify('status', _('Removing %s') % track.title)
util.delete_file(track.filename)
directory = os.path.dirname(track.filename)
if self.directory_is_empty(directory) and self._config.one_folder_per_podcast:
# get the folder on the device
file = Gio.File.new_for_uri(track.filename)
folder = file.get_parent()
if file.query_exists(None):
try:
os.rmdir(directory)
except:
logger.error('Cannot remove %s', directory)
file.delete(None)
except GLib.Error as err:
logger.error('deleting file %s failed: %s', (file.get_uri() % err.message))
return
if self._config.one_folder_per_podcast and self.directory_is_empty(folder):
try:
folder.delete(None)
except GLib.Error as err:
logger.error('deleting folder %s failed: %s', (folder.get_uri() % err.message))
def directory_is_empty(self, directory):
files = glob.glob(os.path.join(directory, '*'))
dotfiles = glob.glob(os.path.join(directory, '.*'))
return len(files + dotfiles) == 0
for child in directory.enumerate_children(Gio.FILE_ATTRIBUTE_STANDARD_NAME, Gio.FileQueryInfoFlags.NONE, None):
return False
return True
class MTPDevice(Device):

View File

@ -44,7 +44,8 @@ class gPodderSyncUI(object):
enable_download_list_update,
commit_changes_to_database,
delete_episode_list,
select_episodes_to_delete):
select_episodes_to_delete,
mount_volume_for_file):
self.device = None
self._config = config
@ -60,6 +61,7 @@ class gPodderSyncUI(object):
self.commit_changes_to_database = commit_changes_to_database
self.delete_episode_list = delete_episode_list
self.select_episodes_to_delete = select_episodes_to_delete
self.mount_volume_for_file = mount_volume_for_file
def _filter_sync_episodes(self, channels, only_downloaded=False):
"""Return a list of episodes for device synchronization
@ -295,8 +297,9 @@ class gPodderSyncUI(object):
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):
if (self._config.device_sync.delete_deleted_episodes or
(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)
for local_episode in all_episodes: