use Gio for file system based device sync (allows mtp:// urls)
This commit is contained in:
parent
a2fc81ee82
commit
6d40da3cb5
3
bin/gpo
3
bin/gpo
|
@ -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
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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:
|
||||
|
|
Loading…
Reference in New Issue