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): def _not_applicable(*args, **kwargs):
pass 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): class DownloadStatusModel(object):
def register_task(self, ask): def register_task(self, ask):
pass pass
@ -961,7 +968,8 @@ class gPodderCli(object):
_not_applicable, _not_applicable,
self._db.commit, self._db.commit,
_delete_episode_list, _delete_episode_list,
_episode_selector) _episode_selector,
_mount_volume_for_file)
done_lock.acquire() done_lock.acquire()
sync_ui.on_synchronize_episodes(self._model.get_podcasts(), episodes=None, force_played=True, done_callback=done_lock.release) 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 done_lock.acquire() # block until done

View File

@ -59,4 +59,4 @@ class gPodderExtension:
self.launcher_entry = None self.launcher_entry = None
def on_download_progress(self, progress): 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> <property name="draw-indicator">True</property>
</object> </object>
</child> </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> </object>
<packing> <packing>
<property name="tab-label" translatable="yes">Devices</property> <property name="tab-label" translatable="yes">Devices</property>

View File

@ -187,6 +187,7 @@ defaults = {
'one_folder_per_podcast': True, 'one_folder_per_podcast': True,
'skip_played_episodes': True, 'skip_played_episodes': True,
'delete_played_episodes': False, 'delete_played_episodes': False,
'delete_deleted_episodes': False,
'max_filename_length': 120, 'max_filename_length': 120,

View File

@ -24,6 +24,9 @@ import gpodder
from gpodder import util from gpodder import util
from gpodder.sync import (episode_filename_on_device, from gpodder.sync import (episode_filename_on_device,
episode_foldername_on_device) episode_foldername_on_device)
import gi
gi.require_version('Gtk', '3.0')
from gi.repository import GLib, Gio
_ = gpodder.gettext _ = gpodder.gettext
@ -36,12 +39,19 @@ class gPodderDevicePlaylist(object):
self._config = config self._config = config
self.linebreak = '\r\n' self.linebreak = '\r\n'
self.playlist_file = util.sanitize_filename(playlist_name, self._config.device_sync.max_filename_length) + '.m3u' 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) device_folder = util.new_gio_file(self._config.device_sync.device_folder)
self.mountpoint = util.find_mount_point(self.playlist_folder) self.playlist_folder = device_folder.resolve_relative_path(self._config.device_sync.playlists.folder)
if self.mountpoint == '/':
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 self.mountpoint = self.playlist_folder
logger.warning('MP3 player resides on / - using %s as MP3 player root', self.mountpoint) logger.warning('could not find mount point for MP3 player - using %s as MP3 player root', self.mountpoint.get_uri())
self.playlist_absolute_filename = os.path.join(self.playlist_folder, self.playlist_file) self.playlist_absolute_filename = self.playlist_folder.resolve_relative_path(self.playlist_file)
def build_extinf(self, filename): def build_extinf(self, filename):
# TODO: Windows playlists # TODO: Windows playlists
@ -64,11 +74,16 @@ class gPodderDevicePlaylist(object):
read all files from the existing playlist read all files from the existing playlist
""" """
tracks = [] tracks = []
logger.info("Read data from the playlistfile %s" % self.playlist_absolute_filename) logger.info("Read data from the playlistfile %s" % self.playlist_absolute_filename.get_uri())
if os.path.exists(self.playlist_absolute_filename): if self.playlist_absolute_filename.query_exists():
for line in open(self.playlist_absolute_filename, 'r'): 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'): if not line.startswith('#EXT'):
tracks.append(line.rstrip('\r\n')) tracks.append(line.rstrip('\r\n'))
stream.close()
return tracks return tracks
def get_filename_for_playlist(self, episode): def get_filename_for_playlist(self, episode):
@ -86,7 +101,7 @@ class gPodderDevicePlaylist(object):
if foldername: if foldername:
filename = os.path.join(foldername, filename) filename = os.path.join(foldername, filename)
if self._config.device_sync.playlist.absolute_path: 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 return filename
def write_m3u(self, episodes): def write_m3u(self, episodes):
@ -97,12 +112,29 @@ class gPodderDevicePlaylist(object):
if not util.make_directory(self.playlist_folder): if not util.make_directory(self.playlist_folder):
raise IOError(_('Folder %s could not be created.') % self.playlist_folder, _('Error writing playlist')) raise IOError(_('Folder %s could not be created.') % self.playlist_folder, _('Error writing playlist'))
else: else:
fp = open(os.path.join(self.playlist_folder, self.playlist_file), 'w') # work around libmtp devices potentially having limited capabilities for partial writes
fp.write('#EXTM3U%s' % self.linebreak) 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: for current_episode in episodes:
filename = self.get_filename_for_playlist(current_episode) 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) filename = self.get_absolute_filename_for_playlist(current_episode)
fp.write(filename) os.put_string(filename)
fp.write(self.linebreak) os.put_string(self.linebreak)
fp.close() 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): if not self.continue_check_callback(self):
return return
try: task = self.queue.get_next()
task = self.queue.get_next() if not task:
logger.info('%s is processing: %s', self, task)
task.run()
task.recycle()
except StopIteration as e:
logger.info('No more tasks for %s to carry out.', self) logger.info('No more tasks for %s to carry out.', self)
break break
logger.info('%s is processing: %s', self, task)
task.run()
task.recycle()
self.exit_callback(self) self.exit_callback(self)
@ -439,8 +439,9 @@ class DownloadQueueManager(object):
spawn_limit = max_downloads - len(self.worker_threads) spawn_limit = max_downloads - len(self.worker_threads)
else: else:
spawn_limit = self._config.limit.downloads.concurrent_max spawn_limit = self._config.limit.downloads.concurrent_max
logger.info('%r tasks to do, can start at most %r threads', work_count, spawn_limit) running = len(self.worker_threads)
for i in range(0, min(work_count, spawn_limit)): 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 # We have to create a new thread here, there's work to do
logger.info('Starting new worker thread.') logger.info('Starting new worker thread.')
@ -460,7 +461,6 @@ class DownloadQueueManager(object):
def queue_task(self, task): def queue_task(self, task):
"""Marks a task as queued """Marks a task as queued
""" """
task.status = DownloadTask.QUEUED
self.__spawn_threads() self.__spawn_threads()

View File

@ -26,7 +26,7 @@ from gi.repository import Gdk, Gtk, Pango
import gpodder import gpodder
from gpodder import util, vimeo, youtube from gpodder import util, vimeo, youtube
from gpodder.gtkui.desktopfile import PlayerListModel 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 from gpodder.gtkui.interface.configeditor import gPodderConfigEditor
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -293,6 +293,8 @@ class gPodderPreferences(BuilderWidget):
self.checkbutton_create_playlists) self.checkbutton_create_playlists)
self._config.connect_gtk_togglebutton('device_sync.playlists.two_way_sync', self._config.connect_gtk_togglebutton('device_sync.playlists.two_way_sync',
self.checkbutton_delete_using_playlists) 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 # Have to do this before calling set_active on checkbutton_enable
self._enable_mygpo = self._config.mygpo.enabled self._enable_mygpo = self._config.mygpo.enabled
@ -640,7 +642,7 @@ class gPodderPreferences(BuilderWidget):
self.combobox_on_sync.set_sensitive(False) self.combobox_on_sync.set_sensitive(False)
self.checkbutton_skip_played_episodes.set_sensitive(False) self.checkbutton_skip_played_episodes.set_sensitive(False)
elif device_type == 'filesystem': 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.btn_filesystemMountpoint.set_sensitive(True)
self.checkbutton_create_playlists.set_sensitive(True) self.checkbutton_create_playlists.set_sensitive(True)
children = self.btn_filesystemMountpoint.get_children() children = self.btn_filesystemMountpoint.get_children()
@ -650,6 +652,7 @@ class gPodderPreferences(BuilderWidget):
self.toggle_playlist_interface(self._config.device_sync.playlists.create) self.toggle_playlist_interface(self._config.device_sync.playlists.create)
self.combobox_on_sync.set_sensitive(True) self.combobox_on_sync.set_sensitive(True)
self.checkbutton_skip_played_episodes.set_sensitive(True) self.checkbutton_skip_played_episodes.set_sensitive(True)
self.checkbutton_delete_deleted_episodes.set_sensitive(True)
elif device_type == 'ipod': elif device_type == 'ipod':
self.btn_filesystemMountpoint.set_label(self._config.device_sync.device_folder) self.btn_filesystemMountpoint.set_label(self._config.device_sync.device_folder)
self.btn_filesystemMountpoint.set_sensitive(True) self.btn_filesystemMountpoint.set_sensitive(True)
@ -664,22 +667,19 @@ class gPodderPreferences(BuilderWidget):
label = children.pop() label = children.pop()
label.set_alignment(0., .5) label.set_alignment(0., .5)
else:
# TODO: Add support for iPod and MTP devices
pass
def on_btn_device_mountpoint_clicked(self, widget): def on_btn_device_mountpoint_clicked(self, widget):
fs = Gtk.FileChooserDialog(title=_('Select folder for mount point'), fs = Gtk.FileChooserDialog(title=_('Select folder for mount point'),
action=Gtk.FileChooserAction.SELECT_FOLDER) action=Gtk.FileChooserAction.SELECT_FOLDER)
fs.set_local_only(False)
fs.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL) fs.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL)
fs.add_button(Gtk.STOCK_OPEN, Gtk.ResponseType.OK) 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: if fs.run() == Gtk.ResponseType.OK:
filename = fs.get_filename()
if self._config.device_sync.device_type == 'filesystem': 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': 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 # Request an update of the mountpoint button
self.on_combobox_device_type_changed(None) self.on_combobox_device_type_changed(None)
@ -688,18 +688,28 @@ class gPodderPreferences(BuilderWidget):
def on_btn_playlist_folder_clicked(self, widget): def on_btn_playlist_folder_clicked(self, widget):
fs = Gtk.FileChooserDialog(title=_('Select folder for playlists'), fs = Gtk.FileChooserDialog(title=_('Select folder for playlists'),
action=Gtk.FileChooserAction.SELECT_FOLDER) action=Gtk.FileChooserAction.SELECT_FOLDER)
fs.set_local_only(False)
fs.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL) fs.add_button(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL)
fs.add_button(Gtk.STOCK_OPEN, Gtk.ResponseType.OK) fs.add_button(Gtk.STOCK_OPEN, Gtk.ResponseType.OK)
fs.set_current_folder(self.btn_playlistfolder.get_label())
if fs.run() == Gtk.ResponseType.OK: device_folder = util.new_gio_file(self._config.device_sync.device_folder)
filename = util.relpath(self._config.device_sync.device_folder, playlists_folder = device_folder.resolve_relative_path(self._config.device_sync.playlists.folder)
fs.get_filename()) 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': if self._config.device_sync.device_type == 'filesystem':
self._config.device_sync.playlists.folder = filename 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() children = self.btn_playlistfolder.get_children()
if children: if children:
label = children.pop() label = children.pop()
label.set_alignment(0., .5) label.set_alignment(0., .5)
break
fs.destroy() fs.destroy()

View File

@ -34,17 +34,62 @@ from gpodder import download, util
_ = gpodder.gettext _ = 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 # 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)) C_TASK, C_NAME, C_URL, C_PROGRESS, C_PROGRESS_TEXT, C_ICON_NAME = list(range(6))
SEARCH_COLUMNS = (C_NAME, C_URL) SEARCH_COLUMNS = (C_NAME, C_URL)
def __init__(self): def __init__(self):
Gtk.ListStore.__init__(self, object, str, str, int, str, str) self.list = Gtk.ListStore(object, str, str, int, str, str)
self.work_queue = TaskQueue()
self.set_downloading_access = threading.RLock()
# Set up stock icon IDs for tasks # Set up stock icon IDs for tasks
self._status_ids = collections.defaultdict(lambda: None) 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.CANCELLED] = 'media-playback-stop'
self._status_ids[download.DownloadTask.PAUSED] = 'media-playback-pause' self._status_ids[download.DownloadTask.PAUSED] = 'media-playback-pause'
def get_model(self):
return self.list
def _format_message(self, episode, message, podcast): def _format_message(self, episode, message, podcast):
episode = html.escape(episode) episode = html.escape(episode)
podcast = html.escape(podcast) podcast = html.escape(podcast)
@ -63,10 +111,10 @@ class DownloadStatusModel(Gtk.ListStore):
def request_update(self, iter, task=None): def request_update(self, iter, task=None):
if task is None: if task is None:
# Ongoing update request from UI - get task from model # 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: else:
# Initial update request - update non-changing fields # Initial update request - update non-changing fields
self.set(iter, self.list.set(iter,
self.C_TASK, task, self.C_TASK, task,
self.C_URL, task.url) self.C_URL, task.url)
@ -100,7 +148,7 @@ class DownloadStatusModel(Gtk.ListStore):
else: else:
progress_message = ('unknown size') progress_message = ('unknown size')
self.set(iter, self.list.set(iter,
self.C_NAME, self._format_message(task.episode.title, self.C_NAME, self._format_message(task.episode.title,
status_message, task.episode.channel.title), status_message, task.episode.channel.title),
self.C_PROGRESS, 100. * task.progress, self.C_PROGRESS, 100. * task.progress,
@ -108,14 +156,15 @@ class DownloadStatusModel(Gtk.ListStore):
self.C_ICON_NAME, self._status_ids[task.status]) self.C_ICON_NAME, self._status_ids[task.status])
def __add_new_task(self, task): def __add_new_task(self, task):
iter = self.append() iter = self.list.append()
self.request_update(iter, task) self.request_update(iter, task)
def register_task(self, task): def register_task(self, task):
self.work_queue.add_task(task)
util.idle_add(self.__add_new_task, task) util.idle_add(self.__add_new_task, task)
def tell_all_tasks_to_quit(self): def tell_all_tasks_to_quit(self):
for row in self: for row in self.list:
task = row[DownloadStatusModel.C_TASK] task = row[DownloadStatusModel.C_TASK]
if task is not None: if task is not None:
# Pause currently-running (and queued) downloads # Pause currently-running (and queued) downloads
@ -131,7 +180,7 @@ class DownloadStatusModel(Gtk.ListStore):
Returns True if there are any downloads in the Returns True if there are any downloads in the
QUEUED or DOWNLOADING status, False otherwise. QUEUED or DOWNLOADING status, False otherwise.
""" """
for row in self: for row in self.list:
task = row[DownloadStatusModel.C_TASK] task = row[DownloadStatusModel.C_TASK]
if task is not None and \ if task is not None and \
task.status in (task.DOWNLOADING, task.status in (task.DOWNLOADING,
@ -140,31 +189,36 @@ class DownloadStatusModel(Gtk.ListStore):
return False 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): def has_work(self):
return any(self._work_gen()) return len(self.work_queue) > 0
def available_work_count(self): def available_work_count(self):
return len(list(self._work_gen())) return len(self.work_queue)
def get_next(self): def get_next(self):
with self.set_downloading_access: task = self.work_queue.pop()
result = next(self._work_gen()) if task:
self.set_downloading(result) task.status = task.DOWNLOADING
return result return task
def _work_gen(self):
return (task for task in
(row[DownloadStatusModel.C_TASK] for row in self)
if task.status == task.QUEUED)
def set_downloading(self, task): def set_downloading(self, task):
with self.set_downloading_access: if not self.work_queue.remove_task(task):
if task.status is task.DOWNLOADING: # Task was already dequeued get_next
# Task was already set as DOWNLOADING by get_next return False
return False task.status = task.DOWNLOADING
task.status = task.DOWNLOADING return True
return True
class DownloadTaskMonitor(object): class DownloadTaskMonitor(object):
"""A helper class that abstracts download events""" """A helper class that abstracts download events"""

View File

@ -29,6 +29,21 @@ from gpodder.gtkui.base import GtkBuilderWidget
_ = gpodder.gettext _ = 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): class BuilderWidget(GtkBuilderWidget):
def __init__(self, parent, **kwargs): def __init__(self, parent, **kwargs):
self._window_iconified = False self._window_iconified = False
@ -64,18 +79,7 @@ class BuilderWidget(GtkBuilderWidget):
def show_message(self, message, title=None, important=False, widget=None): def show_message(self, message, title=None, important=False, widget=None):
if important: if important:
dlg = Gtk.MessageDialog(self.main_window, Gtk.DialogFlags.MODAL, Gtk.MessageType.INFO, Gtk.ButtonsType.OK) show_message_dialog(self.main_window, message, title)
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()
else: else:
gpodder.user_extensions.on_notification_show(title, message) 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_tasks_seen = set()
self.download_list_update_enabled = False self.download_list_update_enabled = False
self.things_adding_tasks = 0
self.download_task_monitors = set() self.download_task_monitors = set()
# Set up the first instance of MygPoClient # 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('min-width', 150)
column.set_property('max-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) TreeViewHelper.set(self.treeDownloads, TreeViewHelper.ROLE_DOWNLOADS)
self.treeDownloads.connect('popup-menu', self.treeview_downloads_show_context_menu) 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) draw_text_box_centered(ctx, treeview, width, height, text, None, None)
return True 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: if not self.download_list_update_enabled:
self.update_downloads_list() self.update_downloads_list()
GObject.timeout_add(1500, self.update_downloads_list) GObject.timeout_add(1500, self.update_downloads_list)
self.download_list_update_enabled = True self.download_list_update_enabled = True
def cleanup_downloads(self): 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] all_tasks = [(Gtk.TreeRowReference.new(model, row.path), row[0]) for row in model]
changed_episode_urls = set() changed_episode_urls = set()
@ -1122,7 +1127,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
model = self.download_status_model model = self.download_status_model
if model is None: if model is None:
model = () model = ()
for row in model: for row in model.get_model():
task = row[self.download_status_model.C_TASK] task = row[self.download_status_model.C_TASK]
monitor.task_updated(task) monitor.task_updated(task)
@ -1134,7 +1139,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
def update_downloads_list(self, can_call_cleanup=True): def update_downloads_list(self, can_call_cleanup=True):
try: 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 downloading, synchronizing, failed, finished, queued, paused, others = 0, 0, 0, 0, 0, 0, 0
total_speed, total_size, done_size = 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] task = row[self.download_status_model.C_TASK]
speed, size, status, progress, activity = task.speed, task.total_size, task.status, task.progress, task.activity 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 # Let the download task monitors know of changes
for monitor in self.download_task_monitors: for monitor in self.download_task_monitors:
@ -1224,7 +1230,8 @@ class gPodder(BuilderWidget, dbus.service.Object):
title.append(N_('%(queued)d task queued', title.append(N_('%(queued)d task queued',
'%(queued)d tasks queued', '%(queued)d tasks queued',
queued) % {'queued': 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.set_download_progress(1.)
self.downloads_finished(self.download_tasks_seen) self.downloads_finished(self.download_tasks_seen)
gpodder.user_extensions.on_all_episodes_downloaded() 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) self.download_queue_manager.force_start_task(task)
else: else:
self.download_queue_manager.queue_task(task) 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: elif status == download.DownloadTask.CANCELLED:
# Cancelling a download allowed when downloading/queued # Cancelling a download allowed when downloading/queued
if task.status in (task.QUEUED, task.DOWNLOADING): if task.status in (task.QUEUED, task.DOWNLOADING):
@ -1605,10 +1612,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
index_above = path[0] - 1 index_above = path[0] - 1
if index_above < 0: if index_above < 0:
return return
task = model.get_value( self.download_status_model.move_before(
model.get_iter(path),
DownloadStatusModel.C_TASK)
model.move_before(
model.get_iter(path), model.get_iter(path),
model.get_iter((index_above,))) model.get_iter((index_above,)))
@ -1619,10 +1623,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
index_below = path[0] + 1 index_below = path[0] + 1
if index_below >= len(model): if index_below >= len(model):
return return
task = model.get_value( self.download_status_model.move_after(
model.get_iter(path),
DownloadStatusModel.C_TASK)
model.move_after(
model.get_iter(path), model.get_iter(path),
model.get_iter((index_below,))) model.get_iter((index_below,)))
@ -3052,7 +3053,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
else: else:
self.download_queue_manager.queue_task(task) self.download_queue_manager.queue_task(task)
if tasks or queued_existing_task: if tasks or queued_existing_task:
self.enable_download_list_update() self.set_download_list_state(gPodderSyncUI.DL_ONEOFF)
# Flush updated episode status # Flush updated episode status
if self.mygpo_client.can_access_webservice(): if self.mygpo_client.can_access_webservice():
self.mygpo_client.flush() self.mygpo_client.flush()
@ -3583,7 +3584,7 @@ class gPodder(BuilderWidget, dbus.service.Object):
task.status = task.PAUSED task.status = task.PAUSED
elif task.status in (task.CANCELLED, task.PAUSED, task.FAILED): elif task.status in (task.CANCELLED, task.PAUSED, task.FAILED):
self.download_queue_manager.queue_task(task) 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: elif task.status == task.DONE:
model.remove(model.get_iter(tree_row_reference.get_path())) 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) logger.debug('extension_episode_download_cb(%s)', episode)
self.download_episode_list(episodes=[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): def on_sync_to_device_activate(self, widget, episodes=None, force_played=True):
self.sync_ui = gPodderSyncUI(self.config, self.notification, self.sync_ui = gPodderSyncUI(self.config, self.notification,
self.main_window, self.main_window,
@ -3703,13 +3724,14 @@ class gPodder(BuilderWidget, dbus.service.Object):
self.channels, self.channels,
self.download_status_model, self.download_status_model,
self.download_queue_manager, self.download_queue_manager,
self.enable_download_list_update, self.set_download_list_state,
self.commit_changes_to_database, self.commit_changes_to_database,
self.delete_episode_list, self.delete_episode_list,
gPodderEpisodeSelector) gPodderEpisodeSelector,
self.mount_volume_for_file)
self.sync_ui.on_synchronize_episodes(self.channels, episodes, force_played, self.sync_ui.on_synchronize_episodes(self.channels, episodes, force_played)
self.enable_download_list_update) # self.set_download_list_state)
def on_extension_enabled(self, extension): def on_extension_enabled(self, extension):
if getattr(extension, 'on_ui_object_available', None) is not None: if getattr(extension, 'on_ui_object_available', None) is not None:

View File

@ -28,33 +28,26 @@ import glob
import logging import logging
import os.path import os.path
import time import time
from urllib.parse import urlparse
import gpodder import gpodder
from gpodder import download, services, util 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__) logger = logging.getLogger(__name__)
_ = gpodder.gettext _ = gpodder.gettext
#
# TODO: Re-enable iPod and MTP sync support
#
pymtp_available = False
gpod_available = True gpod_available = True
try: try:
import gpod import gpod
except: except:
gpod_available = False 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 mplayer_available = True if util.find_command('mplayer') is not None else False
eyed3mp3_available = True eyed3mp3_available = True
@ -64,52 +57,6 @@ except:
eyed3mp3_available = False 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): def open_device(gui):
config = gui._config config = gui._config
device_type = gui._config.device_sync.device_type device_type = gui._config.device_sync.device_type
@ -120,7 +67,8 @@ def open_device(gui):
elif device_type == 'filesystem': elif device_type == 'filesystem':
return MP3PlayerDevice(config, return MP3PlayerDevice(config,
gui.download_status_model, gui.download_status_model,
gui.download_queue_manager) gui.download_queue_manager,
gui.mount_volume_for_file)
return None return None
@ -273,7 +221,7 @@ class Device(services.ObservableService):
if tracklist: if tracklist:
for track in sorted(tracklist, key=lambda e: e.pubdate_prop): for track in sorted(tracklist, key=lambda e: e.pubdate_prop):
if self.cancelled: if self.cancelled:
return False break
# XXX: need to check if track is added properly? # XXX: need to check if track is added properly?
sync_task = SyncTask(track) sync_task = SyncTask(track)
@ -555,25 +503,47 @@ class iPodDevice(Device):
except: except:
logger.warning('Seems like your python-gpod is out-of-date.') logger.warning('Seems like your python-gpod is out-of-date.')
class MP3PlayerDevice(Device): class MP3PlayerDevice(Device):
def __init__(self, config, def __init__(self, config,
download_status_model, download_status_model,
download_queue_manager): download_queue_manager,
mount_volume_for_file):
Device.__init__(self, config) 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_status_model = download_status_model
self.download_queue_manager = download_queue_manager self.download_queue_manager = download_queue_manager
def get_free_space(self): 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): def open(self):
Device.open(self) Device.open(self)
self.notify('status', _('Opening MP3 player')) 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.notify('status', _('MP3 player opened'))
self.tracks_list = self.get_all_tracks() self.tracks_list = self.get_all_tracks()
return True return True
@ -583,7 +553,7 @@ class MP3PlayerDevice(Device):
def get_episode_folder_on_device(self, episode): def get_episode_folder_on_device(self, episode):
folder = episode_foldername_on_device(self._config, episode) folder = episode_foldername_on_device(self._config, episode)
if folder: if folder:
folder = os.path.join(self.destination, folder) folder = self.destination.get_child(folder)
else: else:
folder = self.destination folder = self.destination
@ -617,95 +587,60 @@ class MP3PlayerDevice(Device):
# get the filename that will be used on the device # get the filename that will be used on the device
to_file = self.get_episode_file_on_device(episode) 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): util.make_directory(folder)
try:
os.makedirs(folder)
except:
logger.error('Cannot create folder on MP3 player: %s', folder)
return False
if not os.path.exists(to_file): if not to_file.query_exists():
logger.info('Copying %s => %s', logger.info('Copying %s => %s',
os.path.basename(from_file), os.path.basename(from_file),
to_file) to_file.get_uri())
self.copy_file_progress(from_file, to_file, reporthook) from_file = Gio.File.new_for_path(from_file)
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)
try: try:
out_file.write(s) hookconvert = lambda current_bytes, total_bytes, user_data : reporthook(current_bytes, 1, total_bytes)
except IOError as ioerror: from_file.copy(to_file, Gio.FileCopyFlags.OVERWRITE, None, hookconvert, None)
self.errors.append(ioerror.strerror) except GLib.Error as err:
try: logger.error('Error copying %s to %s: %s', from_file.get_uri(), to_file.get_uri(), err.message)
out_file.close() d = {'from_file': from_file.get_uri(), 'to_file': to_file.get_uri(), 'message': err.message}
except: self.errors.append(_('Error copying %(from_file)s to %(to_file)s: %(message)s') % d)
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()
return False return False
reporthook(bytes_read, 1, total_bytes)
s = in_file.read(self.buffer_size)
out_file.close()
in_file.close()
return True 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): def get_all_tracks(self):
tracks = [] tracks = []
if self._config.one_folder_per_podcast: attributes = (
files = glob.glob(os.path.join(self.destination, '*', '*')) Gio.FILE_ATTRIBUTE_STANDARD_NAME + "," +
else: Gio.FILE_ATTRIBUTE_STANDARD_TYPE + "," +
files = glob.glob(os.path.join(self.destination, '*')) Gio.FILE_ATTRIBUTE_STANDARD_SIZE + "," +
Gio.FILE_ATTRIBUTE_TIME_MODIFIED)
for filename in files: root_path = self.destination
(title, extension) = os.path.splitext(os.path.basename(filename)) for path_info in root_path.enumerate_children(attributes, Gio.FileQueryInfoFlags.NONE, None):
length = util.calculate_size(filename)
timestamp = util.file_modification_timestamp(filename)
modified = util.format_date(timestamp)
if self._config.one_folder_per_podcast: if self._config.one_folder_per_podcast:
podcast_name = os.path.basename(os.path.dirname(filename)) if path_info.get_file_type() == Gio.FileType.DIRECTORY:
else: path_file = root_path.get_child(path_info.get_name())
podcast_name = None 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, else:
modified_sort=timestamp, if path_info.get_file_type() == Gio.FileTypeFlags.REGULAR:
filename=filename, path_file = root_path.get_child(path_info.get_name())
podcast=podcast_name) self.add_sync_track(tracks, path_file, path_info, None)
tracks.append(t)
return tracks return tracks
def episode_on_device(self, episode): def episode_on_device(self, episode):
@ -717,249 +652,34 @@ class MP3PlayerDevice(Device):
def remove_track(self, track): def remove_track(self, track):
self.notify('status', _('Removing %s') % track.title) self.notify('status', _('Removing %s') % track.title)
util.delete_file(track.filename)
directory = os.path.dirname(track.filename) # get the folder on the device
if self.directory_is_empty(directory) and self._config.one_folder_per_podcast: file = Gio.File.new_for_uri(track.filename)
folder = file.get_parent()
if file.query_exists():
try: try:
os.rmdir(directory) file.delete()
except: except GLib.Error as err:
logger.error('Cannot remove %s', directory) # 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): def directory_is_empty(self, directory):
files = glob.glob(os.path.join(directory, '*')) for child in directory.enumerate_children(Gio.FILE_ATTRIBUTE_STANDARD_NAME, Gio.FileQueryInfoFlags.NONE, None):
dotfiles = glob.glob(os.path.join(directory, '.*'))
return len(files + dotfiles) == 0
class MTPDevice(Device):
def __init__(self, config):
Device.__init__(self, config)
self.__model_name = None
try:
self.__MTPDevice = MTP()
except NameError as e:
# pymtp not available / not installed (see bug 924)
logger.error('pymtp not found: %s', str(e))
self.__MTPDevice = None
def __callback(self, sent, total):
if self.cancelled:
return -1
percentage = round(sent / total * 100)
text = ('%i%%' % percentage)
self.notify('progress', sent, total, text)
def __date_to_mtp(self, date):
"""
this function format the given date and time to a string representation
according to MTP specifications: YYYYMMDDThhmmss.s
return
the string representation od the given date
"""
if not date:
return ""
try:
d = time.gmtime(date)
return time.strftime("%Y%m%d-%H%M%S.0Z", d)
except Exception as exc:
logger.error('ERROR: An error has happend while trying to convert date to an mtp string')
return None
def __mtp_to_date(self, mtp):
"""
this parse the mtp's string representation for date
according to specifications (YYYYMMDDThhmmss.s) to
a python time object
"""
if not mtp:
return None
try:
mtp = mtp.replace(" ", "0")
# replace blank with 0 to fix some invalid string
d = time.strptime(mtp[:8] + mtp[9:13], "%Y%m%d%H%M%S")
_date = calendar.timegm(d)
if len(mtp) == 20:
# TIME ZONE SHIFTING: the string contains a hour/min shift relative to a time zone
try:
shift_direction = mtp[15]
hour_shift = int(mtp[16:18])
minute_shift = int(mtp[18:20])
shift_in_sec = hour_shift * 3600 + minute_shift * 60
if shift_direction == "+":
_date += shift_in_sec
elif shift_direction == "-":
_date -= shift_in_sec
else:
raise ValueError("Expected + or -")
except Exception as exc:
logger.warning('WARNING: ignoring invalid time zone information for %s (%s)')
return max(0, _date)
except Exception as exc:
logger.warning('WARNING: the mtp date "%s" can not be parsed against mtp specification (%s)')
return None
def get_name(self):
"""
this function try to find a nice name for the device.
First, it tries to find a friendly (user assigned) name
(this name can be set by other application and is stored on the device).
if no friendly name was assign, it tries to get the model name (given by the vendor).
If no name is found at all, a generic one is returned.
Once found, the name is cached internaly to prevent reading again the device
return
the name of the device
"""
if self.__model_name:
return self.__model_name
if self.__MTPDevice is None:
return _('MTP device')
self.__model_name = self.__MTPDevice.get_devicename()
# actually libmtp.Get_Friendlyname
if not self.__model_name or self.__model_name == "?????":
self.__model_name = self.__MTPDevice.get_modelname()
if not self.__model_name:
self.__model_name = _('MTP device')
return self.__model_name
def open(self):
Device.open(self)
logger.info("opening the MTP device")
self.notify('status', _('Opening the MTP device'), )
try:
self.__MTPDevice.connect()
# build the initial tracks_list
self.tracks_list = self.get_all_tracks()
except Exception as exc:
logger.error('unable to find an MTP device (%s)')
return False return False
self.notify('status', _('%s opened') % self.get_name())
return True 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 class SyncCancelledException(Exception): pass

View File

@ -35,16 +35,20 @@ logger = logging.getLogger(__name__)
class gPodderSyncUI(object): class gPodderSyncUI(object):
# download list states
(DL_ONEOFF, DL_ADDING_TASKS, DL_ADDED_TASKS) = list(range(3))
def __init__(self, config, notification, parent_window, def __init__(self, config, notification, parent_window,
show_confirmation, show_confirmation,
show_preferences, show_preferences,
channels, channels,
download_status_model, download_status_model,
download_queue_manager, download_queue_manager,
enable_download_list_update, set_download_list_state,
commit_changes_to_database, commit_changes_to_database,
delete_episode_list, delete_episode_list,
select_episodes_to_delete): select_episodes_to_delete,
mount_volume_for_file):
self.device = None self.device = None
self._config = config self._config = config
@ -56,10 +60,11 @@ class gPodderSyncUI(object):
self.channels = channels self.channels = channels
self.download_status_model = download_status_model self.download_status_model = download_status_model
self.download_queue_manager = download_queue_manager 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.commit_changes_to_database = commit_changes_to_database
self.delete_episode_list = delete_episode_list self.delete_episode_list = delete_episode_list
self.select_episodes_to_delete = select_episodes_to_delete 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): def _filter_sync_episodes(self, channels, only_downloaded=False):
"""Return a list of episodes for device synchronization """Return a list of episodes for device synchronization
@ -95,10 +100,16 @@ class gPodderSyncUI(object):
device = sync.open_device(self) device = sync.open_device(self)
if device is None: if device is None:
return self._show_message_unconfigured() self._show_message_unconfigured()
if done_callback:
done_callback()
return
if not device.open(): if not device.open():
return self._show_message_cannot_open() self._show_message_cannot_open()
if done_callback:
done_callback()
return
else: else:
# Only set if device is configured and opened successfully # Only set if device is configured and opened successfully
self.device = device self.device = device
@ -144,7 +155,7 @@ class gPodderSyncUI(object):
return return
# enable updating of UI # enable updating of UI
self.enable_download_list_update() self.set_download_list_state(gPodderSyncUI.DL_ONEOFF)
"""Update device playlists """Update device playlists
General approach is as follows: 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] episodes_for_playlist = [ep for ep in episodes_for_playlist if ep.is_new]
playlist.write_m3u(episodes_for_playlist) playlist.write_m3u(episodes_for_playlist)
# enable updating of UI # enable updating of UI, but mark it as tasks being added so that a
self.enable_download_list_update() # 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): if (self._config.device_sync.device_type == 'filesystem' and self._config.device_sync.playlists.create):
title = _('Update successful') title = _('Update successful')
message = _('The playlist on your MP3 player has been updated.') message = _('The playlist on your MP3 player has been updated.')
self.notification(message, title) 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 # Finally start the synchronization process
@util.run_in_background @util.run_in_background
def sync_thread_func(): def sync_thread_func():
device.add_sync_tasks(episodes, force_played=force_played, device.add_sync_tasks(episodes, force_played=force_played,
done_callback=done_callback) done_callback=done_callback)
util.idle_add(add_downloads_complete)
return return
if self._config.device_sync.playlists.create: 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 playlist doesn't exist (yet) episodes_in_playlist will be empty
if episodes_in_playlists: if episodes_in_playlists:
for episode_filename in episodes_in_playlists: for episode_filename in episodes_in_playlists:
if not playlist.mountpoint.resolve_relative_path(
if not(os.path.exists(os.path.join(playlist.mountpoint, episode_filename).query_exists():
episode_filename))):
# episode was synced but no longer on device # episode was synced but no longer on device
# i.e. must have been deleted by user, so delete from gpodder # i.e. must have been deleted by user, so delete from gpodder
try: try:
@ -294,8 +310,9 @@ class gPodderSyncUI(object):
def cleanup_episodes(): def cleanup_episodes():
# 'skip_played_episodes' must be used or else all the # 'skip_played_episodes' must be used or else all the
# played tracks will be copied then immediately deleted # played tracks will be copied then immediately deleted
if (self._config.device_sync.delete_played_episodes and if (self._config.device_sync.delete_deleted_episodes or
self._config.device_sync.skip_played_episodes): (self._config.device_sync.delete_played_episodes and
self._config.device_sync.skip_played_episodes)):
all_episodes = self._filter_sync_episodes( all_episodes = self._filter_sync_episodes(
channels, only_downloaded=False) channels, only_downloaded=False)
for local_episode in all_episodes: for local_episode in all_episodes:

View File

@ -61,6 +61,10 @@ import xml.dom.minidom
from html.entities import entitydefs, name2codepoint from html.entities import entitydefs, name2codepoint
from html.parser import HTMLParser 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
import requests.exceptions import requests.exceptions
from requests.packages.urllib3.util.retry import Retry 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) _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): def make_directory(path):
""" """
Tries to create a directory if it does not exist already. Tries to create a directory if it does not exist already.
Returns True if the directory exists after the function Returns True if the directory exists after the function
call, False otherwise. 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 return True
try: try:
os.makedirs(path) path.make_directory_with_parents()
except: except GLib.Error as err:
logger.warn('Could not create directory: %s', path) # The sync might be multithreaded, so directories can be created by other threads
return False 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 return True
@ -1622,34 +1652,17 @@ def isabs(string):
return os.path.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): def relpath(p1, p2):
""" """
Finds relative path from p1 to p2 Finds relative path from p2 to p1, like os.path.relpath but handles
Source: http://code.activestate.com/recipes/208993/ uris. Returns None if no such path exists due to the paths being on
different devices.
""" """
def pathsplit(s): u1 = urllib.parse.urlparse(p1)
return s.split(os.path.sep) u2 = urllib.parse.urlparse(p2)
if u1.scheme and u2.scheme and (u1.scheme != u2.scheme or u1.netloc != u2.netloc):
(common, l1, l2) = commonpath(pathsplit(p1), pathsplit(p2)) return None
p = [] return os.path.relpath(u1.path, u2.path)
if len(l1) > 0:
p = [('..' + os.sep) * len(l1)]
p = p + l2
if len(p) == 0:
return "."
return os.path.join(*p)
def get_hostname(): def get_hostname():
@ -2212,3 +2225,29 @@ def response_text(response, default_encoding='utf-8'):
return response.text return response.text
else: else:
return response.content.decode(default_encoding) 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-cairo \
mingw-w64-i686-python3-pip mingw-w64-i686-python3-pip
pip3 install --user podcastparser mygpoclient \
pywin32-ctypes \
html5lib webencodings six
} }
main; main;