Merge pull request #1054 from blushingpenguin/master
use Gio for file system based device sync (allows mtp:// urls)
This commit is contained in:
commit
99c46e89e3
10
bin/gpo
10
bin/gpo
|
@ -935,6 +935,13 @@ class gPodderCli(object):
|
|||
def _not_applicable(*args, **kwargs):
|
||||
pass
|
||||
|
||||
def _mount_volume_for_file(file):
|
||||
result, message = util.mount_volume_for_file(file, None)
|
||||
if not result:
|
||||
self._error(_('mounting volume for file %(file)s failed with: %(error)s'
|
||||
% dict(file=file.get_uri(), error=message)))
|
||||
return result
|
||||
|
||||
class DownloadStatusModel(object):
|
||||
def register_task(self, ask):
|
||||
pass
|
||||
|
@ -961,7 +968,8 @@ class gPodderCli(object):
|
|||
_not_applicable,
|
||||
self._db.commit,
|
||||
_delete_episode_list,
|
||||
_episode_selector)
|
||||
_episode_selector,
|
||||
_mount_volume_for_file)
|
||||
done_lock.acquire()
|
||||
sync_ui.on_synchronize_episodes(self._model.get_podcasts(), episodes=None, force_played=True, done_callback=done_lock.release)
|
||||
done_lock.acquire() # block until done
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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"""
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in New Issue