Limit downloads has an off-by-one error, and progress icon is missing in episode list
PR #1054 has caused the number of downloads to be one less than what is set. Set download limit to 2, download a video, and then download a second video. The second download is listed as "Added" in progress and does not begin downloading until the first download has finished. Downloading them as a selection works fine, but adding them one at a time does not. Bump the download limit to 3 and the two downloads work fine, but a third download will wait for the first two to finish.

The download icon also does not appear in the episode list. Prior to #1054, all waiting downloads would have an icon, now there is no way to know if an episode has been added to the download list without opening the progress tab.
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 @@
                     <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>
diff --git a/src/gpodder/config.py b/src/gpodder/config.py
index 45381bc0..09abfa1b 100644
--- a/src/gpodder/config.py
+++ b/src/gpodder/config.py
@@ -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,
 
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()
+
+    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
 
-class DownloadStatusModel(Gtk.ListStore):
+    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)
-
-    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 = self.work_queue.pop()
+        if task:
             task.status = task.DOWNLOADING
-            return True
+        return task
 
+    def set_downloading(self, task):
+        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('<span weight="bold" size="larger">%s</span>\n\n%s' % (title, message))
+    else:
+        dlg.set_markup('<span weight="bold" size="larger">%s</span>' % (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('<span weight="bold" size="larger">%s</span>\n\n%s' % (title, message))
-            else:
-                dlg.set_markup('<span weight="bold" size="larger">%s</span>' % (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, '*'))
-
-        for filename in files:
-            (title, extension) = os.path.splitext(os.path.basename(filename))
-            length = util.calculate_size(filename)
+        attributes = (
+            Gio.FILE_ATTRIBUTE_STANDARD_NAME + "," +
+            Gio.FILE_ATTRIBUTE_STANDARD_TYPE + "," +
+            Gio.FILE_ATTRIBUTE_STANDARD_SIZE + "," +
+            Gio.FILE_ATTRIBUTE_TIME_MODIFIED)
 
-            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:
-            try:
-                os.rmdir(directory)
-            except:
-                logger.error('Cannot remove %s', directory)
 
-    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)')
-            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)
+        # get the folder on the device
+        file = Gio.File.new_for_uri(track.filename)
+        folder = file.get_parent()
+        if file.query_exists():
+            try:
+                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
 
-        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
+        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)
 
-            # 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)
+    def directory_is_empty(self, directory):
+        for child in directory.enumerate_children(Gio.FILE_ATTRIBUTE_STANDARD_NAME, Gio.FileQueryInfoFlags.NONE, None):
             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 5a3a7350..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:
@@ -260,8 +276,7 @@ class gPodderSyncUI(object):
                                             episode_to_delete.title)
 
                             logger.info('Will start sync - after deleting episodes')
-                            self.delete_episode_list(episodes, False,
-                                                     True, resume_sync)
+                            self.delete_episode_list(episodes, False, resume_sync)
 
                         return
 
@@ -295,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;
['src/gpodder/download.py', 'bin/gpo', 'share/gpodder/extensions/ubuntu_unity.py', 'src/gpodder/gtkui/interface/common.py', 'share/gpodder/ui/gtk/gpodderpreferences.ui', 'src/gpodder/syncui.py', 'src/gpodder/config.py', 'src/gpodder/util.py', 'tools/win_installer/bootstrap.sh', 'src/gpodder/gtkui/main.py', 'src/gpodder/gtkui/desktop/preferences.py', 'src/gpodder/gtkui/download.py', 'src/gpodder/deviceplaylist.py', 'src/gpodder/sync.py']