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
+
+
+
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;