diff --git a/bin/gpo b/bin/gpo index f188f767..2f46b475 100755 --- a/bin/gpo +++ b/bin/gpo @@ -935,6 +935,13 @@ class gPodderCli(object): def _not_applicable(*args, **kwargs): pass + def _mount_volume_for_file(file): + result, message = util.mount_volume_for_file(file, None) + if not result: + self._error(_('mounting volume for file %(file)s failed with: %(error)s' + % dict(file=file.get_uri(), error=message))) + return result + class DownloadStatusModel(object): def register_task(self, ask): pass @@ -961,7 +968,8 @@ class gPodderCli(object): _not_applicable, self._db.commit, _delete_episode_list, - _episode_selector) + _episode_selector, + _mount_volume_for_file) 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 diff --git a/share/gpodder/extensions/ubuntu_unity.py b/share/gpodder/extensions/ubuntu_unity.py index fd9be769..0258f850 100644 --- a/share/gpodder/extensions/ubuntu_unity.py +++ b/share/gpodder/extensions/ubuntu_unity.py @@ -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)) diff --git a/share/gpodder/ui/gtk/gpodderpreferences.ui b/share/gpodder/ui/gtk/gpodderpreferences.ui index d4482d00..4cc595c4 100644 --- a/share/gpodder/ui/gtk/gpodderpreferences.ui +++ b/share/gpodder/ui/gtk/gpodderpreferences.ui @@ -801,6 +801,14 @@ True + + + Remove episodes deleted in gPodder from device + True + False + True + + Devices diff --git a/src/gpodder/config.py b/src/gpodder/config.py index f92189c8..ec88159d 100644 --- a/src/gpodder/config.py +++ b/src/gpodder/config.py @@ -187,6 +187,7 @@ defaults = { 'one_folder_per_podcast': True, 'skip_played_episodes': True, 'delete_played_episodes': False, + 'delete_deleted_episodes': False, 'max_filename_length': 120, diff --git a/src/gpodder/deviceplaylist.py b/src/gpodder/deviceplaylist.py index 5d321305..09f06855 100644 --- a/src/gpodder/deviceplaylist.py +++ b/src/gpodder/deviceplaylist.py @@ -24,6 +24,9 @@ import gpodder from gpodder import util from gpodder.sync import (episode_filename_on_device, episode_foldername_on_device) +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import GLib, Gio _ = gpodder.gettext @@ -36,12 +39,19 @@ class gPodderDevicePlaylist(object): self._config = config self.linebreak = '\r\n' self.playlist_file = util.sanitize_filename(playlist_name, self._config.device_sync.max_filename_length) + '.m3u' - self.playlist_folder = os.path.join(self._config.device_sync.device_folder, self._config.device_sync.playlists.folder) - self.mountpoint = util.find_mount_point(self.playlist_folder) - if self.mountpoint == '/': + device_folder = util.new_gio_file(self._config.device_sync.device_folder) + self.playlist_folder = device_folder.resolve_relative_path(self._config.device_sync.playlists.folder) + + self.mountpoint = None + try: + self.mountpoint = self.playlist_folder.find_enclosing_mount().get_root() + except GLib.Error as err: + logger.error('find_enclosing_mount folder %s failed: %s', self.playlist_folder.get_uri(), err.message) + + if not self.mountpoint: self.mountpoint = self.playlist_folder - logger.warning('MP3 player resides on / - using %s as MP3 player root', self.mountpoint) - self.playlist_absolute_filename = os.path.join(self.playlist_folder, self.playlist_file) + logger.warning('could not find mount point for MP3 player - using %s as MP3 player root', self.mountpoint.get_uri()) + self.playlist_absolute_filename = self.playlist_folder.resolve_relative_path(self.playlist_file) def build_extinf(self, filename): # TODO: Windows playlists @@ -64,11 +74,16 @@ class gPodderDevicePlaylist(object): read all files from the existing playlist """ tracks = [] - logger.info("Read data from the playlistfile %s" % self.playlist_absolute_filename) - if os.path.exists(self.playlist_absolute_filename): - for line in open(self.playlist_absolute_filename, 'r'): + logger.info("Read data from the playlistfile %s" % self.playlist_absolute_filename.get_uri()) + if self.playlist_absolute_filename.query_exists(): + stream = Gio.DataInputStream.new(self.playlist_absolute_filename.read()) + while True: + line = stream.read_line_utf8()[0] + if not line: + break if not line.startswith('#EXT'): tracks.append(line.rstrip('\r\n')) + stream.close() return tracks def get_filename_for_playlist(self, episode): @@ -86,7 +101,7 @@ class gPodderDevicePlaylist(object): if foldername: filename = os.path.join(foldername, filename) if self._config.device_sync.playlist.absolute_path: - filename = os.path.join(util.relpath(self.mountpoint, self._config.device_sync.device_folder), filename) + filename = os.path.join(util.relpath(self._config.device_sync.device_folder, self.mountpoint.get_uri()), filename) return filename def write_m3u(self, episodes): @@ -97,12 +112,29 @@ class gPodderDevicePlaylist(object): if not util.make_directory(self.playlist_folder): raise IOError(_('Folder %s could not be created.') % self.playlist_folder, _('Error writing playlist')) else: - fp = open(os.path.join(self.playlist_folder, self.playlist_file), 'w') - fp.write('#EXTM3U%s' % self.linebreak) + # work around libmtp devices potentially having limited capabilities for partial writes + is_mtp = self.playlist_folder.get_uri().startswith("mtp://") + tempfile = None + if is_mtp: + tempfile = Gio.File.new_tmp() + fs = tempfile[1].get_output_stream() + else: + fs = self.playlist_absolute_filename.replace(None, False, Gio.FileCreateFlags.NONE) + + os = Gio.DataOutputStream.new(fs) + os.put_string('#EXTM3U%s' % self.linebreak) for current_episode in episodes: filename = self.get_filename_for_playlist(current_episode) - fp.write(self.build_extinf(filename)) + os.put_string(self.build_extinf(filename)) filename = self.get_absolute_filename_for_playlist(current_episode) - fp.write(filename) - fp.write(self.linebreak) - fp.close() + os.put_string(filename) + os.put_string(self.linebreak) + os.close() + + if is_mtp: + try: + tempfile[0].copy(self.playlist_absolute_filename, Gio.FileCopyFlags.OVERWRITE) + except GLib.Error as err: + logger.error('copying playlist to mtp device file %s failed: %s', + self.playlist_absolute_filename.get_uri(), err.message) + tempfile[0].delete() diff --git a/src/gpodder/download.py b/src/gpodder/download.py index 6b4031f8..6eb045bb 100644 --- a/src/gpodder/download.py +++ b/src/gpodder/download.py @@ -383,14 +383,14 @@ class DownloadQueueWorker(object): if not self.continue_check_callback(self): return - try: - task = self.queue.get_next() - logger.info('%s is processing: %s', self, task) - task.run() - task.recycle() - except StopIteration as e: + task = self.queue.get_next() + if not task: logger.info('No more tasks for %s to carry out.', self) break + logger.info('%s is processing: %s', self, task) + task.run() + task.recycle() + self.exit_callback(self) @@ -439,8 +439,9 @@ class DownloadQueueManager(object): spawn_limit = max_downloads - len(self.worker_threads) else: spawn_limit = self._config.limit.downloads.concurrent_max - logger.info('%r tasks to do, can start at most %r threads', work_count, spawn_limit) - for i in range(0, min(work_count, spawn_limit)): + running = len(self.worker_threads) + logger.info('%r tasks to do, can start at most %r threads, %r threads currently running', work_count, spawn_limit, running) + for i in range(0, min(work_count, spawn_limit - running)): # We have to create a new thread here, there's work to do logger.info('Starting new worker thread.') @@ -460,7 +461,6 @@ class DownloadQueueManager(object): def queue_task(self, task): """Marks a task as queued """ - task.status = DownloadTask.QUEUED self.__spawn_threads() diff --git a/src/gpodder/gtkui/desktop/preferences.py b/src/gpodder/gtkui/desktop/preferences.py index a9993247..5301b402 100644 --- a/src/gpodder/gtkui/desktop/preferences.py +++ b/src/gpodder/gtkui/desktop/preferences.py @@ -26,7 +26,7 @@ from gi.repository import Gdk, Gtk, Pango import gpodder from gpodder import util, vimeo, youtube from gpodder.gtkui.desktopfile import PlayerListModel -from gpodder.gtkui.interface.common import BuilderWidget, TreeViewHelper +from gpodder.gtkui.interface.common import BuilderWidget, TreeViewHelper, show_message_dialog from gpodder.gtkui.interface.configeditor import gPodderConfigEditor logger = logging.getLogger(__name__) @@ -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 @@ -640,7 +642,7 @@ class gPodderPreferences(BuilderWidget): self.combobox_on_sync.set_sensitive(False) self.checkbutton_skip_played_episodes.set_sensitive(False) elif device_type == 'filesystem': - self.btn_filesystemMountpoint.set_label(self._config.device_sync.device_folder) + self.btn_filesystemMountpoint.set_label(self._config.device_sync.device_folder or "") self.btn_filesystemMountpoint.set_sensitive(True) self.checkbutton_create_playlists.set_sensitive(True) children = self.btn_filesystemMountpoint.get_children() @@ -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) @@ -664,22 +667,19 @@ class gPodderPreferences(BuilderWidget): label = children.pop() label.set_alignment(0., .5) - else: - # TODO: Add support for iPod and MTP devices - pass - 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() or "") 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) @@ -688,18 +688,28 @@ class gPodderPreferences(BuilderWidget): def on_btn_playlist_folder_clicked(self, widget): fs = Gtk.FileChooserDialog(title=_('Select folder for playlists'), 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_playlistfolder.get_label()) - if fs.run() == Gtk.ResponseType.OK: - filename = util.relpath(self._config.device_sync.device_folder, - fs.get_filename()) + + device_folder = util.new_gio_file(self._config.device_sync.device_folder) + playlists_folder = device_folder.resolve_relative_path(self._config.device_sync.playlists.folder) + fs.set_file(playlists_folder) + + while fs.run() == Gtk.ResponseType.OK: + filename = util.relpath(fs.get_uri(), + self._config.device_sync.device_folder) + if not filename: + show_message_dialog(fs, _('The playlists folder must be on the device')) + continue + if self._config.device_sync.device_type == 'filesystem': self._config.device_sync.playlists.folder = filename - self.btn_playlistfolder.set_label(filename) + self.btn_playlistfolder.set_label(filename or "") children = self.btn_playlistfolder.get_children() if children: label = children.pop() label.set_alignment(0., .5) + break fs.destroy() diff --git a/src/gpodder/gtkui/download.py b/src/gpodder/gtkui/download.py index aed9565a..2538bf31 100644 --- a/src/gpodder/gtkui/download.py +++ b/src/gpodder/gtkui/download.py @@ -34,17 +34,62 @@ from gpodder import download, util _ = gpodder.gettext +class TaskQueue: + def __init__(self): + self.lock = threading.Lock() + self.tasks = list() -class DownloadStatusModel(Gtk.ListStore): + def __len__(self): + with self.lock: + return len(self.tasks) + + def add_task(self, task): + with self.lock: + self.tasks.append(task) + + def remove_task(self, task): + with self.lock: + try: + self.tasks.remove(task) + return True + except ValueError: + # already dequeued + return False + + def pop(self): + with self.lock: + if len(self.tasks) == 0: + return None + task = self.tasks.pop(0) + return task + + def move_after(self, task, after): + with self.lock: + try: + index = self.tasks.index(after) + self.tasks.remove(task) + self.tasks.insert(index + 1, task) + except ValueError: + pass + + def move_before(self, task, before): + with self.lock: + try: + index = self.tasks.index(before) + self.tasks.remove(task) + self.tasks.insert(index, task) + except ValueError: + pass + +class DownloadStatusModel: # Symbolic names for our columns, so we know what we're up to C_TASK, C_NAME, C_URL, C_PROGRESS, C_PROGRESS_TEXT, C_ICON_NAME = list(range(6)) SEARCH_COLUMNS = (C_NAME, C_URL) def __init__(self): - Gtk.ListStore.__init__(self, object, str, str, int, str, str) - - self.set_downloading_access = threading.RLock() + self.list = Gtk.ListStore(object, str, str, int, str, str) + self.work_queue = TaskQueue() # Set up stock icon IDs for tasks self._status_ids = collections.defaultdict(lambda: None) @@ -54,6 +99,9 @@ class DownloadStatusModel(Gtk.ListStore): self._status_ids[download.DownloadTask.CANCELLED] = 'media-playback-stop' self._status_ids[download.DownloadTask.PAUSED] = 'media-playback-pause' + def get_model(self): + return self.list + def _format_message(self, episode, message, podcast): episode = html.escape(episode) podcast = html.escape(podcast) @@ -63,10 +111,10 @@ class DownloadStatusModel(Gtk.ListStore): def request_update(self, iter, task=None): if task is None: # Ongoing update request from UI - get task from model - task = self.get_value(iter, self.C_TASK) + task = self.list.get_value(iter, self.C_TASK) else: # Initial update request - update non-changing fields - self.set(iter, + self.list.set(iter, self.C_TASK, task, self.C_URL, task.url) @@ -100,7 +148,7 @@ class DownloadStatusModel(Gtk.ListStore): else: progress_message = ('unknown size') - self.set(iter, + self.list.set(iter, self.C_NAME, self._format_message(task.episode.title, status_message, task.episode.channel.title), self.C_PROGRESS, 100. * task.progress, @@ -108,14 +156,15 @@ class DownloadStatusModel(Gtk.ListStore): self.C_ICON_NAME, self._status_ids[task.status]) def __add_new_task(self, task): - iter = self.append() + iter = self.list.append() self.request_update(iter, task) def register_task(self, task): + self.work_queue.add_task(task) util.idle_add(self.__add_new_task, task) def tell_all_tasks_to_quit(self): - for row in self: + for row in self.list: task = row[DownloadStatusModel.C_TASK] if task is not None: # Pause currently-running (and queued) downloads @@ -131,7 +180,7 @@ class DownloadStatusModel(Gtk.ListStore): Returns True if there are any downloads in the QUEUED or DOWNLOADING status, False otherwise. """ - for row in self: + for row in self.list: task = row[DownloadStatusModel.C_TASK] if task is not None and \ task.status in (task.DOWNLOADING, @@ -140,31 +189,36 @@ class DownloadStatusModel(Gtk.ListStore): return False + def move_after(self, iter, position): + self.list.move_after(iter, position) + iter_task = self.list.get_value(iter, DownloadStatusModel.C_TASK) + pos_task = self.list.get_value(position, DownloadStatusModel.C_TASK) + self.work_queue.move_after(iter_task, pos_task) + + def move_before(self, iter, position): + self.list.move_before(iter, position) + iter_task = self.list.get_value(iter, DownloadStatusModel.C_TASK) + pos_task = self.list.get_value(position, DownloadStatusModel.C_TASK) + self.work_queue.move_before(iter_task, pos_task) + def has_work(self): - return any(self._work_gen()) + return len(self.work_queue) > 0 def available_work_count(self): - return len(list(self._work_gen())) + return len(self.work_queue) def get_next(self): - with self.set_downloading_access: - result = next(self._work_gen()) - self.set_downloading(result) - return result - - def _work_gen(self): - return (task for task in - (row[DownloadStatusModel.C_TASK] for row in self) - if task.status == task.QUEUED) + task = self.work_queue.pop() + if task: + task.status = task.DOWNLOADING + return task def set_downloading(self, task): - with self.set_downloading_access: - if task.status is task.DOWNLOADING: - # Task was already set as DOWNLOADING by get_next - return False - task.status = task.DOWNLOADING - return True - + if not self.work_queue.remove_task(task): + # Task was already dequeued get_next + return False + task.status = task.DOWNLOADING + return True class DownloadTaskMonitor(object): """A helper class that abstracts download events""" diff --git a/src/gpodder/gtkui/interface/common.py b/src/gpodder/gtkui/interface/common.py index 7e203211..81c200df 100644 --- a/src/gpodder/gtkui/interface/common.py +++ b/src/gpodder/gtkui/interface/common.py @@ -29,6 +29,21 @@ from gpodder.gtkui.base import GtkBuilderWidget _ = gpodder.gettext +def show_message_dialog(parent, message, title=None): + dlg = Gtk.MessageDialog(parent, Gtk.DialogFlags.MODAL, Gtk.MessageType.INFO, Gtk.ButtonsType.OK) + if title: + dlg.set_title(str(title)) + dlg.set_markup('%s\n\n%s' % (title, message)) + else: + dlg.set_markup('%s' % (message)) + # make message copy/pastable + for lbl in dlg.get_message_area(): + if isinstance(lbl, Gtk.Label): + lbl.set_selectable(True) + dlg.run() + dlg.destroy() + + class BuilderWidget(GtkBuilderWidget): def __init__(self, parent, **kwargs): self._window_iconified = False @@ -64,18 +79,7 @@ class BuilderWidget(GtkBuilderWidget): def show_message(self, message, title=None, important=False, widget=None): if important: - dlg = Gtk.MessageDialog(self.main_window, Gtk.DialogFlags.MODAL, Gtk.MessageType.INFO, Gtk.ButtonsType.OK) - if title: - dlg.set_title(str(title)) - dlg.set_markup('%s\n\n%s' % (title, message)) - else: - dlg.set_markup('%s' % (message)) - # make message copy/pastable - for lbl in dlg.get_message_area(): - if isinstance(lbl, Gtk.Label): - lbl.set_selectable(True) - dlg.run() - dlg.destroy() + show_message_dialog(self.main_window, message, title) else: gpodder.user_extensions.on_notification_show(title, message) diff --git a/src/gpodder/gtkui/main.py b/src/gpodder/gtkui/main.py index 62cb49a1..ea844f18 100644 --- a/src/gpodder/gtkui/main.py +++ b/src/gpodder/gtkui/main.py @@ -196,6 +196,7 @@ class gPodder(BuilderWidget, dbus.service.Object): self.download_tasks_seen = set() self.download_list_update_enabled = False + self.things_adding_tasks = 0 self.download_task_monitors = set() # Set up the first instance of MygPoClient @@ -1042,7 +1043,7 @@ class gPodder(BuilderWidget, dbus.service.Object): column.set_property('min-width', 150) column.set_property('max-width', 150) - self.treeDownloads.set_model(self.download_status_model) + self.treeDownloads.set_model(self.download_status_model.get_model()) TreeViewHelper.set(self.treeDownloads, TreeViewHelper.ROLE_DOWNLOADS) self.treeDownloads.connect('popup-menu', self.treeview_downloads_show_context_menu) @@ -1080,14 +1081,18 @@ class gPodder(BuilderWidget, dbus.service.Object): draw_text_box_centered(ctx, treeview, width, height, text, None, None) return True - def enable_download_list_update(self): + def set_download_list_state(self, state): + if state == gPodderSyncUI.DL_ADDING_TASKS: + self.things_adding_tasks += 1 + elif state == gPodderSyncUI.DL_ADDED_TASKS: + self.things_adding_tasks -= 1 if not self.download_list_update_enabled: self.update_downloads_list() GObject.timeout_add(1500, self.update_downloads_list) self.download_list_update_enabled = True def cleanup_downloads(self): - model = self.download_status_model + model = self.download_status_model.get_model() all_tasks = [(Gtk.TreeRowReference.new(model, row.path), row[0]) for row in model] changed_episode_urls = set() @@ -1122,7 +1127,7 @@ class gPodder(BuilderWidget, dbus.service.Object): model = self.download_status_model if model is None: model = () - for row in model: + for row in model.get_model(): task = row[self.download_status_model.C_TASK] monitor.task_updated(task) @@ -1134,7 +1139,7 @@ class gPodder(BuilderWidget, dbus.service.Object): def update_downloads_list(self, can_call_cleanup=True): try: - model = self.download_status_model + model = self.download_status_model.get_model() downloading, synchronizing, failed, finished, queued, paused, others = 0, 0, 0, 0, 0, 0, 0 total_speed, total_size, done_size = 0, 0, 0 @@ -1151,6 +1156,7 @@ class gPodder(BuilderWidget, dbus.service.Object): task = row[self.download_status_model.C_TASK] speed, size, status, progress, activity = task.speed, task.total_size, task.status, task.progress, task.activity + logger.info("%s: %f", task.episode.title, progress) # Let the download task monitors know of changes for monitor in self.download_task_monitors: @@ -1224,7 +1230,8 @@ class gPodder(BuilderWidget, dbus.service.Object): title.append(N_('%(queued)d task queued', '%(queued)d tasks queued', queued) % {'queued': queued}) - if (downloading + synchronizing + queued) == 0: + if ((downloading + synchronizing + queued) == 0 and + self.things_adding_tasks == 0): self.set_download_progress(1.) self.downloads_finished(self.download_tasks_seen) gpodder.user_extensions.on_all_episodes_downloaded() @@ -1539,7 +1546,7 @@ class gPodder(BuilderWidget, dbus.service.Object): self.download_queue_manager.force_start_task(task) else: self.download_queue_manager.queue_task(task) - self.enable_download_list_update() + self.set_download_list_state(gPodderSyncUI.DL_ADDED_ONE) elif status == download.DownloadTask.CANCELLED: # Cancelling a download allowed when downloading/queued if task.status in (task.QUEUED, task.DOWNLOADING): @@ -1605,10 +1612,7 @@ class gPodder(BuilderWidget, dbus.service.Object): index_above = path[0] - 1 if index_above < 0: return - task = model.get_value( - model.get_iter(path), - DownloadStatusModel.C_TASK) - model.move_before( + self.download_status_model.move_before( model.get_iter(path), model.get_iter((index_above,))) @@ -1619,10 +1623,7 @@ class gPodder(BuilderWidget, dbus.service.Object): index_below = path[0] + 1 if index_below >= len(model): return - task = model.get_value( - model.get_iter(path), - DownloadStatusModel.C_TASK) - model.move_after( + self.download_status_model.move_after( model.get_iter(path), model.get_iter((index_below,))) @@ -3052,7 +3053,7 @@ class gPodder(BuilderWidget, dbus.service.Object): else: self.download_queue_manager.queue_task(task) if tasks or queued_existing_task: - self.enable_download_list_update() + self.set_download_list_state(gPodderSyncUI.DL_ONEOFF) # Flush updated episode status if self.mygpo_client.can_access_webservice(): self.mygpo_client.flush() @@ -3583,7 +3584,7 @@ class gPodder(BuilderWidget, dbus.service.Object): task.status = task.PAUSED elif task.status in (task.CANCELLED, task.PAUSED, task.FAILED): self.download_queue_manager.queue_task(task) - self.enable_download_list_update() + self.set_download_list_state(gPodderSyncUI.DL_ONEOFF) elif task.status == task.DONE: model.remove(model.get_iter(tree_row_reference.get_path())) @@ -3695,6 +3696,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): + op = Gtk.MountOperation.new(self.main_window) + result, message = util.mount_volume_for_file(file, op) + if not result: + logger.error('mounting volume %s failed: %s' % (file.get_uri(), message)) + return 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, @@ -3703,13 +3724,14 @@ class gPodder(BuilderWidget, dbus.service.Object): self.channels, self.download_status_model, self.download_queue_manager, - self.enable_download_list_update, + self.set_download_list_state, 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) + self.sync_ui.on_synchronize_episodes(self.channels, episodes, force_played) + # self.set_download_list_state) def on_extension_enabled(self, extension): if getattr(extension, 'on_ui_object_available', None) is not None: diff --git a/src/gpodder/sync.py b/src/gpodder/sync.py index 74889d75..d2411089 100644 --- a/src/gpodder/sync.py +++ b/src/gpodder/sync.py @@ -28,33 +28,26 @@ import glob import logging import os.path import time +from urllib.parse import urlparse import gpodder from gpodder import download, services, util +import gi # isort:skip +gi.require_version('Gtk', '3.0') # isort:skip +from gi.repository import GLib, Gio, Gtk # isort:skip + logger = logging.getLogger(__name__) _ = gpodder.gettext -# -# TODO: Re-enable iPod and MTP sync support -# - -pymtp_available = False gpod_available = True try: import gpod except: gpod_available = False -# pymtp_available = True -# try: -# import gpodder.gpopymtp as pymtp -# except: -# pymtp_available = False -# logger.warning('Could not load gpopymtp (libmtp not installed?).') - mplayer_available = True if util.find_command('mplayer') is not None else False eyed3mp3_available = True @@ -64,52 +57,6 @@ except: eyed3mp3_available = False -if pymtp_available: - class MTP(pymtp.MTP): - sep = os.path.sep - - def __init__(self): - pymtp.MTP.__init__(self) - self.folders = {} - - def connect(self): - pymtp.MTP.connect(self) - self.folders = self.unfold(self.mtp.LIBMTP_Get_Folder_List(self.device)) - - def get_folder_list(self): - return self.folders - - def unfold(self, folder, path=''): - result = {} - while folder: - folder = folder.contents - name = self.sep.join([path, folder.name]).lstrip(self.sep) - result[name] = folder.folder_id - if folder.get_child(): - result.update(self.unfold(folder.get_child(), name)) - folder = folder.sibling - return result - - def mkdir(self, path): - folder_id = 0 - prefix = [] - parts = path.split(self.sep) - while parts: - prefix.append(parts[0]) - tmpath = self.sep.join(prefix) - if tmpath in self.folders: - folder_id = self.folders[tmpath] - else: - folder_id = self.create_folder(parts[0], parent=folder_id) - # logger.info('Creating subfolder %s in %s (id=%u)' % (parts[0], self.sep.join(prefix), folder_id)) - tmpath = self.sep.join(prefix + [parts[0]]) - self.folders[tmpath] = folder_id - # logger.info(">>> %s = %s" % (tmpath, folder_id)) - del parts[0] - # logger.info('MTP.mkdir: %s = %u' % (path, folder_id)) - return folder_id - - def open_device(gui): config = gui._config device_type = gui._config.device_sync.device_type @@ -120,7 +67,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 @@ -273,7 +221,7 @@ class Device(services.ObservableService): if tracklist: for track in sorted(tracklist, key=lambda e: e.pubdate_prop): if self.cancelled: - return False + break # XXX: need to check if track is added properly? sync_task = SyncTask(track) @@ -555,25 +503,47 @@ class iPodDevice(Device): except: logger.warning('Seems like your python-gpod is out-of-date.') - 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 + + folder = self._config.device_sync.device_folder + self.destination = util.new_gio_file(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 + + try: + info = self.destination.query_info( + Gio.FILE_ATTRIBUTE_ACCESS_CAN_WRITE + "," + + Gio.FILE_ATTRIBUTE_STANDARD_TYPE, + Gio.FileQueryInfoFlags.NONE, + None) + except GLib.Error as err: + logger.error('querying destination info for %s failed with %s', + self.destination.get_uri(), err.message) + return False + + # 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 +553,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 +587,60 @@ 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): - try: - os.makedirs(folder) - except: - logger.error('Cannot create folder on MP3 player: %s', folder) - return False + util.make_directory(folder) - if not os.path.exists(to_file): + if not to_file.query_exists(): 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) + to_file.get_uri()) + 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) - self.cancel() + 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: + logger.error('Error copying %s to %s: %s', from_file.get_uri(), to_file.get_uri(), err.message) + d = {'from_file': from_file.get_uri(), 'to_file': to_file.get_uri(), 'message': err.message} + self.errors.append(_('Error copying %(from_file)s to %(to_file)s: %(message)s') % d) 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,249 +652,34 @@ 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(): try: - os.rmdir(directory) - except: - logger.error('Cannot remove %s', directory) + file.delete() + except GLib.Error as err: + # if the file went away don't worry about it + if not err.matches(Gio.io_error_quark(), Gio.IOErrorEnum.NOT_FOUND): + logger.error('deleting file %s failed: %s', file.get_uri(), err.message) + return + + if self._config.one_folder_per_podcast: + try: + if self.directory_is_empty(folder): + folder.delete() + except GLib.Error as err: + # if the folder went away don't worry about it (multiple threads could + # make this happen if they both notice the folder is empty simultaneously) + if not err.matches(Gio.io_error_quark(), Gio.IOErrorEnum.NOT_FOUND): + 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 - - -class MTPDevice(Device): - def __init__(self, config): - Device.__init__(self, config) - self.__model_name = None - try: - self.__MTPDevice = MTP() - except NameError as e: - # pymtp not available / not installed (see bug 924) - logger.error('pymtp not found: %s', str(e)) - self.__MTPDevice = None - - def __callback(self, sent, total): - if self.cancelled: - return -1 - percentage = round(sent / total * 100) - text = ('%i%%' % percentage) - self.notify('progress', sent, total, text) - - def __date_to_mtp(self, date): - """ - this function format the given date and time to a string representation - according to MTP specifications: YYYYMMDDThhmmss.s - - return - the string representation od the given date - """ - if not date: - return "" - try: - d = time.gmtime(date) - return time.strftime("%Y%m%d-%H%M%S.0Z", d) - except Exception as exc: - logger.error('ERROR: An error has happend while trying to convert date to an mtp string') - return None - - def __mtp_to_date(self, mtp): - """ - this parse the mtp's string representation for date - according to specifications (YYYYMMDDThhmmss.s) to - a python time object - """ - if not mtp: - return None - - try: - mtp = mtp.replace(" ", "0") - # replace blank with 0 to fix some invalid string - d = time.strptime(mtp[:8] + mtp[9:13], "%Y%m%d%H%M%S") - _date = calendar.timegm(d) - if len(mtp) == 20: - # TIME ZONE SHIFTING: the string contains a hour/min shift relative to a time zone - try: - shift_direction = mtp[15] - hour_shift = int(mtp[16:18]) - minute_shift = int(mtp[18:20]) - shift_in_sec = hour_shift * 3600 + minute_shift * 60 - if shift_direction == "+": - _date += shift_in_sec - elif shift_direction == "-": - _date -= shift_in_sec - else: - raise ValueError("Expected + or -") - except Exception as exc: - logger.warning('WARNING: ignoring invalid time zone information for %s (%s)') - return max(0, _date) - except Exception as exc: - logger.warning('WARNING: the mtp date "%s" can not be parsed against mtp specification (%s)') - return None - - def get_name(self): - """ - this function try to find a nice name for the device. - First, it tries to find a friendly (user assigned) name - (this name can be set by other application and is stored on the device). - if no friendly name was assign, it tries to get the model name (given by the vendor). - If no name is found at all, a generic one is returned. - - Once found, the name is cached internaly to prevent reading again the device - - return - the name of the device - """ - - if self.__model_name: - return self.__model_name - - if self.__MTPDevice is None: - return _('MTP device') - - self.__model_name = self.__MTPDevice.get_devicename() - # actually libmtp.Get_Friendlyname - if not self.__model_name or self.__model_name == "?????": - self.__model_name = self.__MTPDevice.get_modelname() - if not self.__model_name: - self.__model_name = _('MTP device') - - return self.__model_name - - def open(self): - Device.open(self) - logger.info("opening the MTP device") - self.notify('status', _('Opening the MTP device'), ) - - try: - self.__MTPDevice.connect() - # build the initial tracks_list - self.tracks_list = self.get_all_tracks() - except Exception as exc: - logger.error('unable to find an MTP device (%s)') + for child in directory.enumerate_children(Gio.FILE_ATTRIBUTE_STANDARD_NAME, Gio.FileQueryInfoFlags.NONE, None): return False - - self.notify('status', _('%s opened') % self.get_name()) return True - def close(self): - logger.info("closing %s", self.get_name()) - self.notify('status', _('Closing %s') % self.get_name()) - - try: - self.__MTPDevice.disconnect() - except Exception as exc: - logger.error('unable to close %s (%s)', self.get_name()) - return False - - self.notify('status', _('%s closed') % self.get_name()) - Device.close(self) - return True - - def add_track(self, episode): - self.notify('status', _('Adding %s...') % episode.title) - filename = str(self.convert_track(episode)) - logger.info("sending %s (%s).", filename, episode.title) - - try: - # verify free space - needed = util.calculate_size(filename) - free = self.get_free_space() - if needed > free: - logger.error('Not enough space on device %s: %s available, but ' - 'need at least %s', - self.get_name(), - util.format_filesize(free), - util.format_filesize(needed)) - self.cancelled = True - return False - - # fill metadata - metadata = pymtp.LIBMTP_Track() - metadata.title = str(episode.title) - metadata.artist = str(episode.channel.title) - metadata.album = str(episode.channel.title) - metadata.genre = "podcast" - metadata.date = self.__date_to_mtp(episode.published) - metadata.duration = get_track_length(str(filename)) - - folder_name = '' - if episode.mimetype.startswith('audio/') and self._config.mtp_audio_folder: - folder_name = self._config.mtp_audio_folder - if episode.mimetype.startswith('video/') and self._config.mtp_video_folder: - folder_name = self._config.mtp_video_folder - if episode.mimetype.startswith('image/') and self._config.mtp_image_folder: - folder_name = self._config.mtp_image_folder - - if folder_name != '' and self._config.mtp_podcast_folders: - folder_name += os.path.sep + str(episode.channel.title) - - # log('Target MTP folder: %s' % folder_name) - - if folder_name == '': - folder_id = 0 - else: - folder_id = self.__MTPDevice.mkdir(folder_name) - - # send the file - to_file = util.sanitize_filename(metadata.title) + episode.extension() - self.__MTPDevice.send_track_from_file(filename, to_file, - metadata, folder_id, callback=self.__callback) - if gpodder.user_hooks is not None: - gpodder.user_hooks.on_file_copied_to_mtp(self, filename, to_file) - except: - logger.error('unable to add episode %s', episode.title) - return False - - return True - - def remove_track(self, sync_track): - self.notify('status', _('Removing %s') % sync_track.mtptrack.title) - logger.info("removing %s", sync_track.mtptrack.title) - - try: - self.__MTPDevice.delete_object(sync_track.mtptrack.item_id) - except Exception as exc: - logger.error('unable remove file %s (%s)', sync_track.mtptrack.filename) - - logger.info('%s removed', sync_track.mtptrack.title) - - def get_all_tracks(self): - try: - listing = self.__MTPDevice.get_tracklisting(callback=self.__callback) - except Exception as exc: - logger.error('unable to get file listing %s (%s)') - - tracks = [] - for track in listing: - title = track.title - if not title or title == "": title = track.filename - if len(title) > 50: title = title[0:49] + '...' - artist = track.artist - if artist and len(artist) > 50: artist = artist[0:49] + '...' - length = track.filesize - age_in_days = 0 - date = self.__mtp_to_date(track.date) - if not date: - modified = track.date # not a valid mtp date. Display what mtp gave anyway - modified_sort = -1 # no idea how to sort invalid date - else: - modified = util.format_date(date) - modified_sort = date - - t = SyncTrack(title, length, modified, modified_sort=modified_sort, mtptrack=track, podcast=artist) - tracks.append(t) - return tracks - - def get_free_space(self): - if self.__MTPDevice is not None: - return self.__MTPDevice.get_freespace() - else: - return 0 - class SyncCancelledException(Exception): pass diff --git a/src/gpodder/syncui.py b/src/gpodder/syncui.py index d64ae399..ebf8d3d8 100644 --- a/src/gpodder/syncui.py +++ b/src/gpodder/syncui.py @@ -35,16 +35,20 @@ logger = logging.getLogger(__name__) class gPodderSyncUI(object): + # download list states + (DL_ONEOFF, DL_ADDING_TASKS, DL_ADDED_TASKS) = list(range(3)) + def __init__(self, config, notification, parent_window, show_confirmation, show_preferences, channels, download_status_model, download_queue_manager, - enable_download_list_update, + set_download_list_state, 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 @@ -56,10 +60,11 @@ class gPodderSyncUI(object): self.channels = channels self.download_status_model = download_status_model self.download_queue_manager = download_queue_manager - self.enable_download_list_update = enable_download_list_update + self.set_download_list_state = set_download_list_state 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 @@ -95,10 +100,16 @@ class gPodderSyncUI(object): device = sync.open_device(self) if device is None: - return self._show_message_unconfigured() + self._show_message_unconfigured() + if done_callback: + done_callback() + return if not device.open(): - return self._show_message_cannot_open() + self._show_message_cannot_open() + if done_callback: + done_callback() + return else: # Only set if device is configured and opened successfully self.device = device @@ -144,7 +155,7 @@ class gPodderSyncUI(object): return # enable updating of UI - self.enable_download_list_update() + self.set_download_list_state(gPodderSyncUI.DL_ONEOFF) """Update device playlists General approach is as follows: @@ -194,20 +205,26 @@ class gPodderSyncUI(object): episodes_for_playlist = [ep for ep in episodes_for_playlist if ep.is_new] playlist.write_m3u(episodes_for_playlist) - # enable updating of UI - self.enable_download_list_update() + # 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) if (self._config.device_sync.device_type == 'filesystem' and self._config.device_sync.playlists.create): title = _('Update successful') message = _('The playlist on your MP3 player has been updated.') self.notification(message, title) + # called from the main thread to complete adding tasks_ + def add_downloads_complete(): + self.set_download_list_state(gPodderSyncUI.DL_ADDED_TASKS) + # Finally start the synchronization process @util.run_in_background def sync_thread_func(): device.add_sync_tasks(episodes, force_played=force_played, done_callback=done_callback) - + util.idle_add(add_downloads_complete) return if self._config.device_sync.playlists.create: @@ -232,9 +249,8 @@ class gPodderSyncUI(object): # if playlist doesn't exist (yet) episodes_in_playlist will be empty if episodes_in_playlists: for episode_filename in episodes_in_playlists: - - if not(os.path.exists(os.path.join(playlist.mountpoint, - episode_filename))): + if not playlist.mountpoint.resolve_relative_path( + episode_filename).query_exists(): # episode was synced but no longer on device # i.e. must have been deleted by user, so delete from gpodder try: @@ -294,8 +310,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: diff --git a/src/gpodder/util.py b/src/gpodder/util.py index edd4b382..5a109561 100644 --- a/src/gpodder/util.py +++ b/src/gpodder/util.py @@ -61,6 +61,10 @@ import xml.dom.minidom from html.entities import entitydefs, name2codepoint from html.parser import HTMLParser +import gi +gi.require_version('Gtk', '3.0') # isort:skip +from gi.repository import Gio, GLib, Gtk # isort:skip + import requests import requests.exceptions from requests.packages.urllib3.util.retry import Retry @@ -154,20 +158,46 @@ _MIME_TYPES = dict((k, v) for v, k in _MIME_TYPE_LIST) _MIME_TYPES_EXT = dict(_MIME_TYPE_LIST) +def is_absolute_url(url): + """ + Check if url is an absolute url (i.e. has a scheme) + """ + try: + parsed = urllib.parse.urlparse(url) + return not not parsed.scheme + except ValueError: + return False + + +def new_gio_file(path): + """ + Create a new Gio.File given a path or uri + """ + if is_absolute_url(path): + return Gio.File.new_for_uri(path) + else: + return Gio.File.new_for_path(path) + + def make_directory(path): """ Tries to create a directory if it does not exist already. Returns True if the directory exists after the function call, False otherwise. """ - if os.path.isdir(path): + if not isinstance(path, Gio.File): + path = new_gio_file(path) + + if path.query_exists(): return True try: - os.makedirs(path) - except: - logger.warn('Could not create directory: %s', path) - return False + path.make_directory_with_parents() + except GLib.Error as err: + # The sync might be multithreaded, so directories can be created by other threads + if not err.matches(Gio.io_error_quark(), Gio.IOErrorEnum.EXISTS): + logger.warn('Could not create directory %s: %s', path.get_uri(), err.message) + return False return True @@ -1622,34 +1652,17 @@ def isabs(string): return os.path.isabs(string) -def commonpath(l1, l2, common=[]): - """ - helper functions for relpath - Source: http://code.activestate.com/recipes/208993/ - """ - if len(l1) < 1: return (common, l1, l2) - if len(l2) < 1: return (common, l1, l2) - if l1[0] != l2[0]: return (common, l1, l2) - return commonpath(l1[1:], l2[1:], common + [l1[0]]) - - def relpath(p1, p2): """ - Finds relative path from p1 to p2 - Source: http://code.activestate.com/recipes/208993/ + Finds relative path from p2 to p1, like os.path.relpath but handles + uris. Returns None if no such path exists due to the paths being on + different devices. """ - def pathsplit(s): - return s.split(os.path.sep) - - (common, l1, l2) = commonpath(pathsplit(p1), pathsplit(p2)) - p = [] - if len(l1) > 0: - p = [('..' + os.sep) * len(l1)] - p = p + l2 - if len(p) == 0: - return "." - - return os.path.join(*p) + u1 = urllib.parse.urlparse(p1) + u2 = urllib.parse.urlparse(p2) + if u1.scheme and u2.scheme and (u1.scheme != u2.scheme or u1.netloc != u2.netloc): + return None + return os.path.relpath(u1.path, u2.path) def get_hostname(): @@ -2212,3 +2225,29 @@ def response_text(response, default_encoding='utf-8'): return response.text else: return response.content.decode(default_encoding) + + +def mount_volume_for_file(file, op = None): + """ + Utility method to mount the enclosing volume for the given file in a blocking + fashion + """ + result = True + message = None + + def callback(file, res): + nonlocal result, message + try: + file.mount_enclosing_volume_finish(res) + result = True + 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)): + message = err.message + result = False + finally: + Gtk.main_quit() + + file.mount_enclosing_volume(Gio.MountMountFlags.NONE, op, None, callback) + Gtk.main() + return result, message diff --git a/tools/win_installer/bootstrap.sh b/tools/win_installer/bootstrap.sh index b735267c..d09386a8 100644 --- a/tools/win_installer/bootstrap.sh +++ b/tools/win_installer/bootstrap.sh @@ -24,6 +24,9 @@ function main { mingw-w64-i686-python3-cairo \ mingw-w64-i686-python3-pip + pip3 install --user podcastparser mygpoclient \ + pywin32-ctypes \ + html5lib webencodings six } main;