Merge pull request #1054 from blushingpenguin/master

use Gio for file system based device sync (allows mtp:// urls)
This commit is contained in:
Eric Le Lay 2021-07-23 15:04:59 +02:00 committed by GitHub
commit 99c46e89e3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 442 additions and 524 deletions

10
bin/gpo
View File

@ -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

View File

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

View File

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

View File

@ -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,

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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"""

View File

@ -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)

View File

@ -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:

View File

@ -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

View File

@ -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:

View File

@ -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

View File

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