From a1523893f2f249ffcb95f04e3dd073e453edbaf6 Mon Sep 17 00:00:00 2001 From: sezanzeb Date: Sat, 5 Sep 2020 23:25:30 +0200 Subject: [PATCH 01/10] idk --- soundconverter/interface/ui.py | 15 +++- soundconverter/util/taskqueue.py | 135 ++++++++++++++++++++++--------- tests/testcases/taskqueue.py | 38 ++++++++- 3 files changed, 147 insertions(+), 41 deletions(-) diff --git a/soundconverter/interface/ui.py b/soundconverter/interface/ui.py index 6a95eb63..a2c42c27 100644 --- a/soundconverter/interface/ui.py +++ b/soundconverter/interface/ui.py @@ -55,6 +55,7 @@ GObject.TYPE_FLOAT, # progress GObject.TYPE_STRING, # status GObject.TYPE_STRING, # complete filename + GObject.TYPE_FLOAT # duration of the audio ] COLUMNS = ['filename'] @@ -139,9 +140,14 @@ def __init__(self, window, builder): self.widget = builder.get_object('filelist') self.widget.props.fixed_height_mode = True + + # sort the longest audio to the front, because converting those + # right at the start make sure that no process ends up converting + # a single large file while all other tasks are done and therefore + # all other cpu cores are idle self.sortedmodel = Gtk.TreeModelSort(model=self.model) self.widget.set_model(self.sortedmodel) - self.sortedmodel.set_sort_column_id(4, Gtk.SortType.ASCENDING) + self.sortedmodel.set_sort_column_id(4, Gtk.SortType.DESCENDING) self.widget.get_selection().set_mode(Gtk.SelectionMode.MULTIPLE) self.widget.drag_dest_set( @@ -462,7 +468,12 @@ def append_file(self, sound_file): This soundfile is expected to be readable by gstreamer """ self.model.append([ - self.format_cell(sound_file), sound_file, 0.0, '', sound_file.uri + self.format_cell(sound_file), + sound_file, + 0.0, + '', + sound_file.uri, + sound_file.duration ]) self.filelist.add(sound_file.uri) sound_file.filelist_row = len(self.model) - 1 diff --git a/soundconverter/util/taskqueue.py b/soundconverter/util/taskqueue.py index 366e6d6b..117f4582 100644 --- a/soundconverter/util/taskqueue.py +++ b/soundconverter/util/taskqueue.py @@ -38,7 +38,10 @@ def __init__(self): self.done = [] self.finished = False self.paused = False + self._timer = Timer() + self._remaining_history = History(size=11) + self._smooth_remaining_time = Smoothing(factor=20) def add(self, task): """Add a task to the queue that will be executed later. @@ -165,54 +168,110 @@ def get_remaining(self): # cannot be estimated yet return None - total_duration = 0 - total_remaining_weight = 0 + print() + + # replicate how the workload is distributed among the processes + # to figure out how much time is remaining + workloads = [0] * get_num_jobs() + total_processed_weight = 0 + total_duration = 0 - max_remaining_weight = -1 for task in self.all_tasks: - # duration is the time the timer has been running, not the - # audio duration. - duration = task.timer.get_duration() - # total_duration would be 12s if 12 tasks run for 1s - total_duration += duration - - # weight is actually the audio duration, but it's unit is going - # to be canceled in the remaining_duration calculation. It could - # be anything as long as all tasks have the same unit of weight. + if task.done: + continue progress, weight = task.get_progress() + smallest_index = 0 + smallest_workload = float('inf') + for i, workload in enumerate(workloads): + if workload < smallest_workload: + smallest_index = i + smallest_workload = workload remaining_weight = (1 - progress) * weight - max_remaining_weight = max(remaining_weight, max_remaining_weight) - total_remaining_weight += remaining_weight - processed_weight = progress * weight - total_processed_weight += processed_weight + workloads[smallest_index] += remaining_weight - if total_processed_weight == 0: - # cannot be calculated yet - return None - - # how many seconds per weight. This remains pretty stable, even when - # less processes are running in parallel, because total_duration - # is the sum of all task durations and not the queues duration. - speed = total_duration / total_processed_weight - - # how many weight left per process - remaining_weight_per_p = total_remaining_weight / len(self.running) - remaining_duration = speed * remaining_weight_per_p - - # if the max_remaining time exceeds the time of the - # remaining_duration which especially happens when the conversion - # comes to an end while one very large file is being converted, - # take that one. - if max_remaining_weight != -1: - max_remaining = speed * max_remaining_weight - remaining = max(max_remaining, remaining_duration) - else: - remaining = remaining_duration + progress, weight = task.get_progress() + total_duration += task.timer.get_duration() + total_processed_weight += progress * weight + + speed = total_processed_weight / total_duration + + remaining = max(workloads) / speed + + print('remaining', remaining) + + # due to possible inaccuracies like unaccounted overheads, correct + # the remaining time based on how long it has actually been running. + # this is only really a concern for large files, otherwise the above + # prediction is actually not bad. + self._remaining_history.push({ + 'time': time.time(), + 'remaining': remaining + }) + historic = self._remaining_history.get_oldest() + if historic is not None: + seconds_since = time.time() - historic['time'] + remaining_change = historic['remaining'] - remaining + max_factor = 1.5 + if remaining_change > 0: + factor = seconds_since / remaining_change + # avoid having ridiculous factors, put some trust into + # the above unfactored prediction + factor = max(1 / max_factor, min(max_factor, factor)) + else: + factor = max_factor + factor = self._smooth_remaining_time.smooth(factor) + print('####', factor, '####') + remaining = remaining * factor return remaining +class Smoothing: + """Exponential smoothing for a single value.""" + def __init__(self, factor): + self.factor = factor + self.value = None + + def smooth(self, value): + if self.value is not None: + value = (self.value * self.factor + value) / (self.factor + 1) + self.value = value + return value + + +class History: + """History of predictions.""" + def __init__(self, size): + """Create a new History object, with a memory of `size`.""" + self.index = 0 + self.values = [None] * size + + def push(self, value): + """Add a new value to the history, possibly overwriting old values.""" + self.values[self.index] = value + self.index = (self.index + 1) % len(self.values) + + def get(self, offset): + """Get a value offset steps in the past.""" + if offset >= len(self.values): + # Doesn't carry such old values + return None + + index = (self.index - offset) % len(self.values) - 1 + return self.values[index] + + def get_oldest(self): + """Get the oldest known value.""" + index = len(self.values) - 1 + while index > 0: + if self.get(index) is None: + index -= 1 + else: + break + return self.get(index) + + class Timer: """Time how long the TaskQueue took.""" # separate class because I would like to not pollute the TaskQueue diff --git a/tests/testcases/taskqueue.py b/tests/testcases/taskqueue.py index 815262ef..e34b6e60 100644 --- a/tests/testcases/taskqueue.py +++ b/tests/testcases/taskqueue.py @@ -25,7 +25,7 @@ from unittest.mock import Mock from gi.repository import GLib, Gst -from soundconverter.util.taskqueue import TaskQueue, Timer +from soundconverter.util.taskqueue import TaskQueue, Timer, History, Smoothing from soundconverter.util.task import Task from soundconverter.util.settings import get_gio_settings from util import reset_settings @@ -481,5 +481,41 @@ def test(self): self.assertLess(timer.get_duration(), 0.011) +class TestHistory(unittest.TestCase): + def test(self): + history = History(size=5) + history.push(10) + history.push(20) + history.push(30) + print(history.values) + self.assertEqual(history.get(0), 30) + self.assertEqual(history.get(1), 20) + self.assertEqual(history.get(2), 10) + self.assertEqual(history.get(3), None) + self.assertEqual(history.get(5), None) + self.assertEqual(history.get(6), None) + self.assertEqual(history.get_oldest(), 10) + history.push(40) + history.push(50) + history.push(60) + self.assertEqual(history.get(0), 60) + self.assertEqual(history.get(1), 50) + self.assertEqual(history.get(2), 40) + self.assertEqual(history.get(3), 30) + self.assertEqual(history.get(4), 20) + self.assertEqual(history.get(5), None) + self.assertEqual(history.get(8), None) + self.assertEqual(history.get_oldest(), 20) + + +class TestSmooth(unittest.TestCase): + def test(self): + smooth = Smoothing(factor=10) + self.assertEqual(smooth.smooth(5), 5) + value_2 = (5 * 10 + 10) / 11 + self.assertEqual(smooth.smooth(10), (5 * 10 + 10) / 11) + self.assertEqual(smooth.smooth(20), (value_2 * 10 + 20) / 11) + + if __name__ == "__main__": unittest.main() From cf5d6b2e9aa849f33d696151544bf0a7ff6b6189 Mon Sep 17 00:00:00 2001 From: sezanzeb Date: Sun, 6 Sep 2020 12:03:53 +0200 Subject: [PATCH 02/10] split ui.py, more accurate remaining time calculation, better progressbar --- soundconverter/gstreamer/converter.py | 4 +- soundconverter/interface/filelist.py | 420 ++++++++++ soundconverter/interface/gladewindow.py | 50 ++ soundconverter/interface/mainloop.py | 53 ++ soundconverter/interface/preferences.py | 514 ++++++++++++ soundconverter/interface/ui.py | 1009 ++--------------------- soundconverter/util/taskqueue.py | 51 +- tests/testcases/taskqueue.py | 1 - 8 files changed, 1149 insertions(+), 953 deletions(-) create mode 100644 soundconverter/interface/filelist.py create mode 100644 soundconverter/interface/gladewindow.py create mode 100644 soundconverter/interface/mainloop.py create mode 100644 soundconverter/interface/preferences.py diff --git a/soundconverter/gstreamer/converter.py b/soundconverter/gstreamer/converter.py index 47ca24b7..c4319e64 100644 --- a/soundconverter/gstreamer/converter.py +++ b/soundconverter/gstreamer/converter.py @@ -381,9 +381,9 @@ def _conversion_done(self): assert vfs_exists(newname) - logger.info("converted '{}' to '{}'".format( + """logger.info("converted '{}' to '{}'".format( beautify_uri(input_uri), beautify_uri(newname) - )) + ))""" # Copy file permissions source = Gio.file_parse_name(self.sound_file.uri) diff --git a/soundconverter/interface/filelist.py b/soundconverter/interface/filelist.py new file mode 100644 index 00000000..231b5c87 --- /dev/null +++ b/soundconverter/interface/filelist.py @@ -0,0 +1,420 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# +# SoundConverter - GNOME application for converting between audio formats. +# Copyright 2004 Lars Wirzenius +# Copyright 2005-2020 Gautier Portet +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; version 3 of the License. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 +# USA + +import os +import time +from gettext import gettext as _ +from gettext import ngettext + +from gi.repository import Gtk, Gio, Gdk, GLib, Pango, GObject + +from soundconverter.util.fileoperations import unquote_filename, vfs_walk +from soundconverter.util.soundfile import SoundFile +from soundconverter.util.taskqueue import TaskQueue +from soundconverter.util.logger import logger +from soundconverter.gstreamer.discoverer import add_discoverers +from soundconverter.util.error import show_error +from soundconverter.interface.notify import notification +from soundconverter.util.formatting import format_time +from soundconverter.interface.mainloop import gtk_iteration + + +def idle(func): + def callback(*args, **kwargs): + GLib.idle_add(func, *args, **kwargs) + return callback + + +# Names of columns in the file list +MODEL = [ + GObject.TYPE_STRING, # visible filename + GObject.TYPE_PYOBJECT, # soundfile + GObject.TYPE_FLOAT, # progress + GObject.TYPE_STRING, # status + GObject.TYPE_STRING, # complete filename + GObject.TYPE_FLOAT # duration of the audio +] + + +class FileList: + """List of files added by the user.""" + + # List of MIME types which we accept for drops. + drop_mime_types = ['text/uri-list', 'text/plain', 'STRING'] + + def __init__(self, window, builder): + self.window = window + self.discoverers = None + self.filelist = set() + + self.model = Gtk.ListStore(*MODEL) + + self.widget = builder.get_object('filelist') + self.widget.props.fixed_height_mode = True + + # sort the longest audio to the front, because converting those + # right at the start make sure that no process ends up converting + # a single large file while all other tasks are done and therefore + # all other cpu cores are idle + self.sortedmodel = Gtk.TreeModelSort(model=self.model) + self.widget.set_model(self.sortedmodel) + self.sortedmodel.set_sort_column_id(4, Gtk.SortType.DESCENDING) + self.widget.get_selection().set_mode(Gtk.SelectionMode.MULTIPLE) + + self.widget.drag_dest_set( + Gtk.DestDefaults.ALL, [], Gdk.DragAction.COPY + ) + targets = [ + (accepted, 0, i) for i, accepted + in enumerate(self.drop_mime_types) + ] + self.widget.drag_dest_set_target_list(targets) + + self.widget.connect('drag-data-received', self.drag_data_received) + + renderer = Gtk.CellRendererProgress() + column = Gtk.TreeViewColumn( + 'progress', + renderer, + value=2, + text=3, + ) + column.props.sizing = Gtk.TreeViewColumnSizing.FIXED + self.widget.append_column(column) + self.progress_column = column + self.progress_column.set_visible(False) + + renderer = Gtk.CellRendererText() + renderer.set_property('ellipsize', Pango.EllipsizeMode.MIDDLE) + column = Gtk.TreeViewColumn( + 'Filename', + renderer, + markup=0, + ) + column.props.sizing = Gtk.TreeViewColumnSizing.FIXED + column.set_expand(True) + self.widget.append_column(column) + + self.window.progressbarstatus.hide() + + self.invalid_files_list = [] + self.good_uris = [] + + def drag_data_received(self, widget, context, x, y, selection, mime_id, time): + widget.stop_emission('drag-data-received') + if 0 <= mime_id < len(self.drop_mime_types): + text = selection.get_data().decode('utf-8') + uris = [uri.strip() for uri in text.split('\n')] + self.add_uris(uris) + context.finish(True, False, time) + + def get_files(self): + """Return all valid SoundFile objects.""" + return [i[1] for i in self.sortedmodel] + + @idle + def add_uris(self, uris, base=None, extensions=None): + """Add URIs that should be converted to the list in the GTK interface. + + uris is a list of string URIs, which are absolute paths + starting with 'file://' + + extensions is a list of strings like ['.ogg', '.oga'], + in which case only files of this type are added to the + list. This can be useful when files of multiple types + are inside a directory and only some of them should be + converted. Default:None which accepts all types. + """ + if len(uris) == 0: + return + + start_t = time.time() + files = [] + self.window.set_status(_('Scanning files…')) + # for whichever reason, that set_status needs some more iterations + # to show up: + gtk_iteration(True) + self.window.progressbarstatus.show() + self.window.progressbarstatus.set_fraction(0) + + for uri in uris: + gtk_iteration() + if not uri: + continue + if uri.startswith('cdda:'): + show_error( + 'Cannot read from Audio CD.', + 'Use SoundJuicer Audio CD Extractor instead.' + ) + return + info = Gio.file_parse_name(uri).query_file_type( + Gio.FileMonitorFlags.NONE, None + ) + if info == Gio.FileType.DIRECTORY: + logger.info('walking: \'{}\''.format(uri)) + if len(uris) == 1: + # if only one folder is passed to the function, + # use its parent as base path. + base = os.path.dirname(uri) + + # get a list of all the files as URIs in + # that directory and its subdirectories + filelist = vfs_walk(uri) + + accepted = [] + if extensions: + for filename in filelist: + for extension in extensions: + if filename.lower().endswith(extension): + accepted.append(filename) + filelist = accepted + files.extend(filelist) + else: + files.append(uri) + + files = [f for f in files if not f.endswith('~SC~')] + + if len(files) == 0: + show_error('No files found!', '') + + if not base: + base = os.path.commonprefix(files) + if base and not base.endswith('/'): + # we want a common folder + base = base[0:base.rfind('/')] + base += '/' + else: + base += '/' + + scan_t = time.time() + logger.info('analysing file integrity') + + # self.good_uris will be populated + # by the discoverer. + # It is a list of uris and only contains those files + # that can be handled by gstreamer + self.good_uris = [] + + self.discoverers = TaskQueue() + sound_files = [] + for filename in files: + sound_file = SoundFile(filename, base) + sound_files.append(sound_file) + + add_discoverers(self.discoverers, sound_files) + + self.discoverers.set_on_queue_finished(self.discoverer_queue_ended) + self.discoverers.run() + + self.window.set_status('{}'.format(_('Adding Files…'))) + logger.info('adding: {} files'.format(len(files))) + + # show progress and enable GTK main loop iterations + # so that the ui stays responsive + self.window.progressbarstatus.set_text('0/{}'.format(len(files))) + self.window.progressbarstatus.set_show_text(True) + + while self.discoverers.running: + progress = self.discoverers.get_progress()[0] + if progress: + completed = int(progress * len(files)) + self.window.progressbarstatus.set_fraction(progress) + self.window.progressbarstatus.set_text( + '{}/{}'.format(completed, len(files)) + ) + gtk_iteration() + logger.info('Discovered {} audiofiles in {} s'.format( + len(files), round(self.discoverers.get_duration(), 1) + )) + + self.window.progressbarstatus.set_show_text(False) + + # see if one of the files with an audio extension + # was not readable. + known_audio_types = [ + '.flac', '.mp3', '.aac', + '.m4a', '.mpeg', '.opus', '.vorbis', '.ogg', '.wav' + ] + + # invalid_files is the number of files that are not + # added to the list in the current function call + invalid_files = 0 + # out of those files, that many have an audio file extension + broken_audiofiles = 0 + + sound_files = [] + for discoverer in self.discoverers.all_tasks: + sound_files += discoverer.sound_files + + for sound_file in sound_files: + # create a list of human readable file paths + # that were not added to the list + if not sound_file.readable: + filename = sound_file.filename + + extension = os.path.splitext(filename)[1].lower() + if extension in known_audio_types: + broken_audiofiles += 1 + + subfolders = sound_file.subfolders + relative_path = os.path.join(subfolders, filename) + + self.invalid_files_list.append(relative_path) + invalid_files += 1 + continue + if sound_file.uri in self.filelist: + logger.info('file already present: \'{}\''.format( + sound_file.uri + )) + continue + self.append_file(sound_file) + + if invalid_files > 0: + self.window.invalid_files_button.set_visible(True) + if len(files) == invalid_files == 1: + # case 1: the single file that should be added is not supported + show_error( + _('The specified file is not supported!'), + _('Either because it is broken or not an audio file.') + ) + + elif len(files) == invalid_files: + # case 2: all files that should be added cannot be added + show_error( + _('All {} specified files are not supported!').format( + len(files) + ), + _('Either because they are broken or not audio files.') + ) + + else: + # case 3: some files could not be added (that can already be + # because there is a single picture in a folder of hundreds + # of sound files). Show an error if this skipped file has a + # soundfile extension, otherwise don't bother the user. + logger.info( + '{} of {} files were not added to the list'.format( + invalid_files, len(files) + ) + ) + if broken_audiofiles > 0: + show_error( + ngettext( + 'One audio file could not be read by GStreamer!', + '{} audio files could not be read by GStreamer!', + broken_audiofiles + ).format(broken_audiofiles), + _( + 'Check "Invalid Files" in the menu for more' + 'information.' + ) + ) + else: + # case 4: all files were successfully added. No error message + pass + + self.window.set_status() + self.window.progressbarstatus.hide() + end_t = time.time() + logger.debug( + 'Added %d files in %.2fs (scan %.2fs, add %.2fs)' % ( + len(files), end_t - start_t, scan_t - start_t, end_t - scan_t + ) + ) + + def discoverer_queue_ended(self, queue): + # all tasks done + self.window.set_sensitive() + self.window.conversion_ended() + + total_time = queue.get_duration() + msg = _('Tasks done in %s') % format_time(total_time) + + errors = [ + task.error for task in queue.done + if task.error is not None + ] + if len(errors) > 0: + msg += ', {} error(s)'.format(len(errors)) + + self.window.set_status(msg) + if not self.window.is_active(): + notification(msg) + + readable = [] + for discoverer in self.discoverers.all_tasks: + for sound_file in discoverer.sound_files: + if sound_file.readable: + readable.append(sound_file) + + self.good_uris = [sound_file.uri for sound_file in readable] + self.window.set_status() + self.window.progressbarstatus.hide() + + def cancel(self): + if self.discoverers is not None: + self.discoverers.cancel() + + def format_cell(self, sound_file): + """Take a SoundFile and return a human readable path to it.""" + return GLib.markup_escape_text(unquote_filename(sound_file.filename)) + + def set_row_progress(self, number, progress): + """Update the progress bar of a single row/file.""" + self.progress_column.set_visible(True) + if self.model[number][2] == 1.0: + return + + self.model[number][2] = progress * 100.0 + + def hide_row_progress(self): + self.progress_column.set_visible(False) + + def append_file(self, sound_file): + """Add a valid SoundFile object to the list of files in the GUI. + + Parameters + ---------- + sound_file : SoundFile + This soundfile is expected to be readable by gstreamer + """ + self.model.append([ + self.format_cell(sound_file), + sound_file, + 0.0, + '', + sound_file.uri, + sound_file.duration + ]) + self.filelist.add(sound_file.uri) + sound_file.filelist_row = len(self.model) - 1 + + def remove(self, iterator): + uri = self.model.get(iterator, 1)[0].uri + self.filelist.remove(uri) + self.model.remove(iterator) + + def is_nonempty(self): + try: + self.model.get_iter((0,)) + except ValueError: + return False + return True diff --git a/soundconverter/interface/gladewindow.py b/soundconverter/interface/gladewindow.py new file mode 100644 index 00000000..3aa60c38 --- /dev/null +++ b/soundconverter/interface/gladewindow.py @@ -0,0 +1,50 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# +# SoundConverter - GNOME application for converting between audio formats. +# Copyright 2004 Lars Wirzenius +# Copyright 2005-2020 Gautier Portet +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; version 3 of the License. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 +# USA + + +class GladeWindow(object): + """Create a window from a glade builder.""" + + callbacks = {} + builder = None + + def __init__(self, builder): + """Init GladeWindow, store the objects's potential callbacks for later. + + You have to call connect_signals() when all descendants are ready. + """ + GladeWindow.builder = builder + GladeWindow.callbacks.update(dict( + [[x, getattr(self, x)] for x in dir(self) if x.startswith('on_')] + )) + + def __getattr__(self, attribute): + """Allow direct use of window widget.""" + widget = GladeWindow.builder.get_object(attribute) + if widget is None: + raise AttributeError('Widget \'{}\' not found'.format(attribute)) + self.__dict__[attribute] = widget # cache result + return widget + + @staticmethod + def connect_signals(): + """Connect all GladeWindow objects to theirs respective signals.""" + GladeWindow.builder.connect_signals(GladeWindow.callbacks) diff --git a/soundconverter/interface/mainloop.py b/soundconverter/interface/mainloop.py new file mode 100644 index 00000000..b1c919b3 --- /dev/null +++ b/soundconverter/interface/mainloop.py @@ -0,0 +1,53 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# +# SoundConverter - GNOME application for converting between audio formats. +# Copyright 2004 Lars Wirzenius +# Copyright 2005-2020 Gautier Portet +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; version 3 of the License. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 +# USA + +"""Utils for controlling the GTK mainloop.""" + +import time + +from gi.repository import Gtk + + +def gtk_iteration(blocking=False): + """Keeps the UI and event loops for gst going. + + Paramters + --------- + blocking : bool + If True, will call main_iteration even if no events are pending, + which will wait until an event is available. + """ + if blocking: + while True: + Gtk.main_iteration() + if not Gtk.events_pending(): + break + else: + while Gtk.events_pending(): + Gtk.main_iteration() + + +def gtk_sleep(duration): + """Sleep while keeping the GUI responsive.""" + start = time.time() + while time.time() < start + duration: + time.sleep(0.01) + gtk_iteration() diff --git a/soundconverter/interface/preferences.py b/soundconverter/interface/preferences.py new file mode 100644 index 00000000..54b4604b --- /dev/null +++ b/soundconverter/interface/preferences.py @@ -0,0 +1,514 @@ +#!/usr/bin/python3 +# -*- coding: utf-8 -*- +# +# SoundConverter - GNOME application for converting between audio formats. +# Copyright 2004 Lars Wirzenius +# Copyright 2005-2020 Gautier Portet +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; version 3 of the License. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 +# USA + +import urllib.request +import urllib.parse +import urllib.error +from gettext import gettext as _ + +from gi.repository import Gtk, GLib + +from soundconverter.util.fileoperations import filename_to_uri, \ + beautify_uri +from soundconverter.util.soundfile import SoundFile +from soundconverter.util.settings import get_gio_settings +from soundconverter.util.formats import get_quality, \ + get_bitrate_from_settings, get_file_extension +from soundconverter.util.namegenerator import TargetNameGenerator, \ + subfolder_patterns, basename_patterns, locale_patterns_dict +from soundconverter.util.logger import logger +from soundconverter.gstreamer.converter import available_elements +from soundconverter.interface.gladewindow import GladeWindow + + +encoders = ( + ('audio/x-vorbis', 'vorbisenc'), + ('audio/mpeg', 'lamemp3enc'), + ('audio/x-flac', 'flacenc'), + ('audio/x-wav', 'wavenc'), + ('audio/x-m4a', 'fdkaacenc,faac,avenc_aac'), + ('audio/ogg; codecs=opus', 'opusenc'), +) # must be in same order as the output_mime_type GtkComboBox + + +class PreferencesDialog(GladeWindow): + sensitive_names = [ + 'vorbis_quality', 'choose_folder', 'create_subfolders', + 'subfolder_pattern', 'jobs_spinbutton', 'resample_hbox', + 'force_mono' + ] + + def __init__(self, builder, parent): + self.settings = get_gio_settings() + GladeWindow.__init__(self, builder) + + # encoders should be in the same order as in the glade file + for i, encoder in enumerate(encoders): + extension = get_file_extension(encoder[0]) + ui_row_text = self.liststore8[i][0] + # the extension should be part of the row with the correct index + assert extension.lower() in ui_row_text.lower() + + self.dialog = builder.get_object('prefsdialog') + self.dialog.set_transient_for(parent) + self.example = builder.get_object('example_filename') + self.force_mono = builder.get_object('force_mono') + + self.target_bitrate = None + + self.sensitive_widgets = {} + for name in self.sensitive_names: + self.sensitive_widgets[name] = builder.get_object(name) + assert self.sensitive_widgets[name] is not None + + self.encoders = None + self.present_mime_types = [] + + self.set_widget_initial_values() + self.set_sensitive() + + tip = [_('Available patterns:')] + for k in sorted(locale_patterns_dict.values()): + tip.append(k) + self.custom_filename.set_tooltip_text('\n'.join(tip)) + + def set_widget_initial_values(self): + self.quality_tabs.set_show_tabs(False) + + if self.settings.get_boolean('same-folder-as-input'): + widget = self.same_folder_as_input + else: + widget = self.into_selected_folder + widget.set_active(True) + + self.target_folder_chooser = Gtk.FileChooserDialog( + title=_('Add Folder…'), + transient_for=self.dialog, + action=Gtk.FileChooserAction.SELECT_FOLDER + ) + + self.target_folder_chooser.add_button( + Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL + ) + self.target_folder_chooser.add_button( + Gtk.STOCK_OPEN, Gtk.ResponseType.OK + ) + + self.target_folder_chooser.set_select_multiple(False) + self.target_folder_chooser.set_local_only(False) + + uri = filename_to_uri(urllib.parse.quote( + self.settings.get_string('selected-folder'), safe='/:@' + )) + self.target_folder_chooser.set_uri(uri) + self.update_selected_folder() + + widget = self.create_subfolders + widget.set_active(self.settings.get_boolean('create-subfolders')) + + widget = self.subfolder_pattern + active = self.settings.get_int('subfolder-pattern-index') + model = widget.get_model() + model.clear() + for pattern, desc in subfolder_patterns: + i = model.append() + model.set(i, 0, desc) + widget.set_active(active) + + if self.settings.get_boolean('replace-messy-chars'): + widget = self.replace_messy_chars + widget.set_active(True) + + if self.settings.get_boolean('delete-original'): + self.delete_original.set_active(True) + + current_mime_type = self.settings.get_string('output-mime-type') + + # deactivate output if encoder plugin is not present + widget = self.output_mime_type + model = widget.get_model() + assert len(model) == len(encoders), 'model:{} widgets:{}'.format( + len(model), len(encoders) + ) + + i = 0 + model = self.output_mime_type.get_model() + for mime, encoder_name in encoders: + # valid default output? + encoder_present = any( + e in available_elements for e in encoder_name.split(',') + ) + if encoder_present: + # add to supported outputs + self.present_mime_types.append(mime) + i += 1 + else: + logger.error( + '{} {} is not supported, a gstreamer plugins package ' + 'is possibly missing.'.format(mime, encoder_name) + ) + del model[i] + + for i, mime in enumerate(self.present_mime_types): + if current_mime_type == mime: + widget.set_active(i) + self.change_mime_type(current_mime_type) + + # display information about mp3 encoding + if 'lamemp3enc' not in available_elements: + widget = self.lame_absent + widget.show() + + widget = self.vorbis_quality + quality = self.settings.get_double('vorbis-quality') + quality_setting = get_quality('audio/x-vorbis', quality, reverse=True) + widget.set_active(-1) + self.vorbis_quality.set_active(quality_setting) + if self.settings.get_boolean('vorbis-oga-extension'): + self.vorbis_oga_extension.set_active(True) + + widget = self.aac_quality + quality = self.settings.get_int('aac-quality') + quality_setting = get_quality('audio/x-m4a', quality, reverse=True) + widget.set_active(quality_setting) + + widget = self.opus_quality + quality = self.settings.get_int('opus-bitrate') + quality_setting = get_quality('audio/ogg; codecs=opus', quality, reverse=True) + widget.set_active(quality_setting) + + widget = self.flac_compression + quality = self.settings.get_int('flac-compression') + quality_setting = get_quality('audio/x-flac', quality, reverse=True) + widget.set_active(quality_setting) + + widget = self.wav_sample_width + quality = self.settings.get_int('wav-sample-width') + quality_setting = get_quality('audio/x-wav', quality, reverse=True) + widget.set_active(quality_setting) + + self.mp3_quality = self.mp3_quality + self.mp3_mode = self.mp3_mode + + mode = self.settings.get_string('mp3-mode') + self.change_mp3_mode(mode) + + widget = self.basename_pattern + active = self.settings.get_int('name-pattern-index') + model = widget.get_model() + model.clear() + for pattern, desc in basename_patterns: + iter = model.append() + model.set(iter, 0, desc) + widget.set_active(active) + + self.custom_filename.set_text( + self.settings.get_string('custom-filename-pattern') + ) + if self.basename_pattern.get_active() == len(basename_patterns)-1: + self.custom_filename_box.set_sensitive(True) + else: + self.custom_filename_box.set_sensitive(False) + + output_resample = self.settings.get_boolean('output-resample') + self.resample_toggle.set_active(output_resample) + + cell = Gtk.CellRendererText() + self.resample_rate.pack_start(cell, True) + self.resample_rate.add_attribute(cell, 'text', 0) + rates = [8000, 11025, 16000, 22050, 32000, 44100, 48000, 96000, 128000] + rate = self.settings.get_int('resample-rate') + try: + idx = rates.index(rate) + except ValueError: + idx = -1 + self.resample_rate.set_active(idx) + + self.force_mono.set_active(self.settings.get_boolean('force-mono')) + + self.jobs.set_active(self.settings.get_boolean('limit-jobs')) + self.jobs_spinbutton.set_value(self.settings.get_int('number-of-jobs')) + + self.update_example() + + def update_selected_folder(self): + self.into_selected_folder.set_use_underline(False) + self.into_selected_folder.set_label( + _('Into folder %s') % + beautify_uri(self.settings.get_string('selected-folder')) + ) + + def update_example(self): + """Refresh the example in the settings dialog.""" + sound_file = SoundFile('file:///foo/bar.flac') + sound_file.tags.update({ + 'track-number': 1, + 'track-count': 99, + 'album-disc-number': 2, + 'album-disc-count': 9 + }) + sound_file.tags.update(locale_patterns_dict) + + generator = TargetNameGenerator() + generator.replace_messy_chars = False + + example_path = GLib.markup_escape_text( + generator.generate_target_uri(sound_file, for_display=True) + ) + position = 0 + replaces = [] + + while True: + beginning = example_path.find('{', position) + if beginning == -1: + break + end = example_path.find('}', beginning) + + tag = example_path[beginning:end+1] + available_tags = [ + v.lower() for v in list(locale_patterns_dict.values()) + ] + if tag.lower() in available_tags: + bold_tag = tag.replace( + '{', '{' + ).replace( + '}', '}' + ) + replaces.append([tag, bold_tag]) + else: + red_tag = tag.replace( + '{', '{' + ).replace( + '}', '}' + ) + replaces.append([tag, red_tag]) + position = beginning + 1 + + for tag, formatted in replaces: + example_path = example_path.replace(tag, formatted) + + self.example.set_markup(example_path) + + markup = '{}'.format( + _('Target bitrate: %s') % get_bitrate_from_settings() + ) + self.approx_bitrate.set_markup(markup) + + def set_sensitive(self): + for widget in list(self.sensitive_widgets.values()): + widget.set_sensitive(False) + + same_folder = self.settings.get_boolean('same-folder-as-input') + for name in ['choose_folder', 'create_subfolders', + 'subfolder_pattern']: + self.sensitive_widgets[name].set_sensitive(not same_folder) + + self.sensitive_widgets['vorbis_quality'].set_sensitive( + self.settings.get_string('output-mime-type') == 'audio/x-vorbis') + + self.sensitive_widgets['jobs_spinbutton'].set_sensitive( + self.settings.get_boolean('limit-jobs')) + + self.sensitive_widgets['resample_hbox'].set_sensitive(True) + self.sensitive_widgets['force_mono'].set_sensitive(True) + + def run(self): + self.dialog.run() + self.dialog.hide() + + def on_delete_original_toggled(self, button): + self.settings.set_boolean('delete-original', button.get_active()) + + def on_same_folder_as_input_toggled(self, button): + self.settings.set_boolean('same-folder-as-input', True) + self.set_sensitive() + self.update_example() + + def on_into_selected_folder_toggled(self, button): + self.settings.set_boolean('same-folder-as-input', False) + self.set_sensitive() + self.update_example() + + def on_choose_folder_clicked(self, button): + ret = self.target_folder_chooser.run() + folder = self.target_folder_chooser.get_uri() + self.target_folder_chooser.hide() + if ret == Gtk.ResponseType.OK: + if folder: + folder = urllib.parse.unquote(folder) + self.settings.set_string('selected-folder', folder) + self.update_selected_folder() + self.update_example() + + def on_create_subfolders_toggled(self, button): + self.settings.set_boolean('create-subfolders', button.get_active()) + self.update_example() + + def on_subfolder_pattern_changed(self, combobox): + self.settings.set_int('subfolder-pattern-index', combobox.get_active()) + self.update_example() + + def on_basename_pattern_changed(self, combobox): + self.settings.set_int('name-pattern-index', combobox.get_active()) + if combobox.get_active() == len(basename_patterns)-1: + self.custom_filename_box.set_sensitive(True) + else: + self.custom_filename_box.set_sensitive(False) + self.update_example() + + def on_custom_filename_changed(self, entry): + self.settings.set_string('custom-filename-pattern', entry.get_text()) + self.update_example() + + def on_replace_messy_chars_toggled(self, button): + self.settings.set_boolean('replace-messy-chars', button.get_active()) + + def change_mime_type(self, mime_type): + """Show the correct quality tab based on the selected format.""" + self.settings.set_string('output-mime-type', mime_type) + self.set_sensitive() + self.update_example() + tabs = { + 'audio/x-vorbis': 0, + 'audio/mpeg': 1, + 'audio/x-flac': 2, + 'audio/x-wav': 3, + 'audio/x-m4a': 4, + 'audio/ogg; codecs=opus': 5, + } + self.quality_tabs.set_current_page(tabs[mime_type]) + + def on_output_mime_type_changed(self, combo): + """Called when the format is changed on the UI.""" + format_index = combo.get_active() + mime = encoders[format_index][0] + if mime in self.present_mime_types: + self.change_mime_type(mime) + + def on_output_mime_type_ogg_vorbis_toggled(self, button): + if button.get_active(): + self.change_mime_type('audio/x-vorbis') + + def on_output_mime_type_flac_toggled(self, button): + if button.get_active(): + self.change_mime_type('audio/x-flac') + + def on_output_mime_type_wav_toggled(self, button): + if button.get_active(): + self.change_mime_type('audio/x-wav') + + def on_output_mime_type_mp3_toggled(self, button): + if button.get_active(): + self.change_mime_type('audio/mpeg') + + def on_output_mime_type_aac_toggled(self, button): + if button.get_active(): + self.change_mime_type('audio/x-m4a') + + def on_output_mime_type_opus_toggled(self, button): + if button.get_active(): + self.change_mime_type('audio/ogg; codecs=opus') + + def on_vorbis_quality_changed(self, combobox): + if combobox.get_active() == -1: + return # just de-selectionning + fquality = get_quality('audio/x-vorbis', combobox.get_active()) + self.settings.set_double('vorbis-quality', fquality) + self.update_example() + + def on_vorbis_oga_extension_toggled(self, toggle): + self.settings.set_boolean('vorbis-oga-extension', toggle.get_active()) + self.update_example() + + def on_aac_quality_changed(self, combobox): + quality = get_quality('audio/x-m4a', combobox.get_active()) + self.settings.set_int('aac-quality', quality) + self.update_example() + + def on_opus_quality_changed(self, combobox): + quality = get_quality('audio/ogg; codecs=opus', combobox.get_active()) + self.settings.set_int('opus-bitrate', quality) + self.update_example() + + def on_wav_sample_width_changed(self, combobox): + quality = get_quality('audio/x-wav', combobox.get_active()) + self.settings.set_int('wav-sample-width', quality) + self.update_example() + + def on_flac_compression_changed(self, combobox): + quality = get_quality('audio/x-flac', combobox.get_active()) + self.settings.set_int('flac-compression', quality) + self.update_example() + + def on_force_mono_toggle(self, button): + self.settings.set_boolean('force-mono', button.get_active()) + self.update_example() + + def change_mp3_mode(self, mode): + keys = {'cbr': 0, 'abr': 1, 'vbr': 2} + self.mp3_mode.set_active(keys[mode]) + + keys = { + 'cbr': 'mp3-cbr-quality', + 'abr': 'mp3-abr-quality', + 'vbr': 'mp3-vbr-quality', + } + quality = self.settings.get_int(keys[mode]) + + index = get_quality('audio/mpeg', quality, mode, reverse=True) + self.mp3_quality.set_active(index) + self.update_example() + + def on_mp3_mode_changed(self, combobox): + mode = ('cbr', 'abr', 'vbr')[combobox.get_active()] + self.settings.set_string('mp3-mode', mode) + self.change_mp3_mode(mode) + + def on_mp3_quality_changed(self, combobox): + keys = { + 'cbr': 'mp3-cbr-quality', + 'abr': 'mp3-abr-quality', + 'vbr': 'mp3-vbr-quality' + } + mode = self.settings.get_string('mp3-mode') + + bitrate = get_quality('audio/mpeg', combobox.get_active(), mode) + self.settings.set_int(keys[mode], bitrate) + self.update_example() + + def on_resample_rate_changed(self, combobox): + selected = combobox.get_active() + rates = [8000, 11025, 16000, 22050, 32000, 44100, 48000, 96000, 128000] + self.settings.set_int('resample-rate', rates[selected]) + self.update_example() + + def on_resample_toggle(self, rstoggle): + self.settings.set_boolean('output-resample', rstoggle.get_active()) + self.resample_rate.set_sensitive(rstoggle.get_active()) + self.update_example() + + def on_jobs_toggled(self, jtoggle): + self.settings.set_boolean('limit-jobs', jtoggle.get_active()) + self.jobs_spinbutton.set_sensitive(jtoggle.get_active()) + + def on_jobs_spinbutton_value_changed(self, jspinbutton): + self.settings.set_int('number-of-jobs', int(jspinbutton.get_value())) + diff --git a/soundconverter/interface/ui.py b/soundconverter/interface/ui.py index a2c42c27..b1e7194d 100644 --- a/soundconverter/interface/ui.py +++ b/soundconverter/interface/ui.py @@ -20,91 +20,24 @@ # USA import os -import time import sys -import urllib.request -import urllib.parse -import urllib.error from gettext import gettext as _ -from gettext import ngettext -from gi.repository import GObject, Gtk, Gio, Gdk, GLib, Pango +from gi.repository import Gtk, GLib -from soundconverter.util.fileoperations import filename_to_uri, \ - beautify_uri, unquote_filename, vfs_walk -from soundconverter.util.soundfile import SoundFile -from soundconverter.util.settings import get_gio_settings, settings -from soundconverter.util.formats import get_quality, \ - get_bitrate_from_settings, get_file_extension +from soundconverter.util.fileoperations import filename_to_uri +from soundconverter.util.settings import settings from soundconverter.util.namegenerator import TargetNameGenerator, \ - subfolder_patterns, basename_patterns, locale_patterns_dict, \ filepattern from soundconverter.util.taskqueue import TaskQueue from soundconverter.util.logger import logger -from soundconverter.gstreamer.discoverer import add_discoverers -from soundconverter.gstreamer.converter import Converter, available_elements -from soundconverter.util.error import show_error, set_error_handler -from soundconverter.interface.notify import notification +from soundconverter.gstreamer.converter import Converter +from soundconverter.util.error import set_error_handler from soundconverter.util.formatting import format_time - - -# Names of columns in the file list -MODEL = [ - GObject.TYPE_STRING, # visible filename - GObject.TYPE_PYOBJECT, # soundfile - GObject.TYPE_FLOAT, # progress - GObject.TYPE_STRING, # status - GObject.TYPE_STRING, # complete filename - GObject.TYPE_FLOAT # duration of the audio -] - -COLUMNS = ['filename'] - -# VISIBLE_COLUMNS = ['filename'] -# ALL_COLUMNS = VISIBLE_COLUMNS + ['META'] - - -encoders = ( - ('audio/x-vorbis', 'vorbisenc'), - ('audio/mpeg', 'lamemp3enc'), - ('audio/x-flac', 'flacenc'), - ('audio/x-wav', 'wavenc'), - ('audio/x-m4a', 'fdkaacenc,faac,avenc_aac'), - ('audio/ogg; codecs=opus', 'opusenc'), -) # must be in same order as the output_mime_type GtkComboBox - - -def idle(func): - def callback(*args, **kwargs): - GLib.idle_add(func, *args, **kwargs) - return callback - - -def gtk_iteration(blocking=False): - """Keeps the UI and event loops for gst going. - - Paramters - --------- - blocking : bool - If True, will call main_iteration even if no events are pending, - which will wait until an event is available. - """ - if blocking: - while True: - Gtk.main_iteration() - if not Gtk.events_pending(): - break - else: - while Gtk.events_pending(): - Gtk.main_iteration() - - -def gtk_sleep(duration): - """Sleep while keeping the GUI responsive.""" - start = time.time() - while time.time() < start + duration: - time.sleep(0.01) - gtk_iteration() +from soundconverter.interface.filelist import FileList +from soundconverter.interface.gladewindow import GladeWindow +from soundconverter.interface.preferences import PreferencesDialog +from soundconverter.interface.mainloop import gtk_sleep, gtk_iteration class ErrorDialog: @@ -125,863 +58,79 @@ def show_error(self, primary, secondary): self.dialog.hide() -class FileList: - """List of files added by the user.""" - - # List of MIME types which we accept for drops. - drop_mime_types = ['text/uri-list', 'text/plain', 'STRING'] - - def __init__(self, window, builder): - self.window = window - self.discoverers = None - self.filelist = set() - - self.model = Gtk.ListStore(*MODEL) +class ProgressBar: + """Wrapper for the progressbar to enable smoothing. - self.widget = builder.get_object('filelist') - self.widget.props.fixed_height_mode = True + Because changes only arrive every second which makes the progress jump + for a small number of files. + """ + def __init__(self, progressbar, period=1): + """Initialize the fraction object without doing anything yet.""" + self.progressbar = progressbar + self.steps = 0 + self.period = period + self.current_fraction = 0 + self.fraction_target = 0 + self.step = 0 + self.timeout_id = None + self.progressbar.set_fraction(0) - # sort the longest audio to the front, because converting those - # right at the start make sure that no process ends up converting - # a single large file while all other tasks are done and therefore - # all other cpu cores are idle - self.sortedmodel = Gtk.TreeModelSort(model=self.model) - self.widget.set_model(self.sortedmodel) - self.sortedmodel.set_sort_column_id(4, Gtk.SortType.DESCENDING) - self.widget.get_selection().set_mode(Gtk.SelectionMode.MULTIPLE) + def __getattr__(self, attribute): + """For all other attributes, try to get them from the widget.""" + return getattr(self.progressbar, attribute) - self.widget.drag_dest_set( - Gtk.DestDefaults.ALL, [], Gdk.DragAction.COPY - ) - targets = [ - (accepted, 0, i) for i, accepted - in enumerate(self.drop_mime_types) - ] - self.widget.drag_dest_set_target_list(targets) - - self.widget.connect('drag-data-received', self.drag_data_received) - - renderer = Gtk.CellRendererProgress() - column = Gtk.TreeViewColumn( - 'progress', - renderer, - value=2, - text=3, - ) - column.props.sizing = Gtk.TreeViewColumnSizing.FIXED - self.widget.append_column(column) - self.progress_column = column - self.progress_column.set_visible(False) - - renderer = Gtk.CellRendererText() - renderer.set_property('ellipsize', Pango.EllipsizeMode.MIDDLE) - column = Gtk.TreeViewColumn( - 'Filename', - renderer, - markup=0, - ) - column.props.sizing = Gtk.TreeViewColumnSizing.FIXED - column.set_expand(True) - self.widget.append_column(column) - - self.window.progressbarstatus.hide() - - self.invalid_files_list = [] - self.good_uris = [] - - def drag_data_received(self, widget, context, x, y, selection, mime_id, time): - widget.stop_emission('drag-data-received') - if 0 <= mime_id < len(self.drop_mime_types): - text = selection.get_data().decode('utf-8') - uris = [uri.strip() for uri in text.split('\n')] - self.add_uris(uris) - context.finish(True, False, time) - - def get_files(self): - """Return all valid SoundFile objects.""" - return [i[1] for i in self.sortedmodel] - - @idle - def add_uris(self, uris, base=None, extensions=None): - """Add URIs that should be converted to the list in the GTK interface. - - uris is a list of string URIs, which are absolute paths - starting with 'file://' - - extensions is a list of strings like ['.ogg', '.oga'], - in which case only files of this type are added to the - list. This can be useful when files of multiple types - are inside a directory and only some of them should be - converted. Default:None which accepts all types. - """ - if len(uris) == 0: + def set_fraction(self, fraction): + """Set a fraction that will be shown after interpolating to it.""" + if fraction in [0, 1]: + self.set_current(fraction) return - start_t = time.time() - files = [] - self.window.set_status(_('Scanning files…')) - # for whichever reason, that set_status needs some more iterations - # to show up: - gtk_iteration(True) - self.window.progressbarstatus.show() - self.window.progressbarstatus.set_fraction(0) + self.fraction_target = fraction + difference = self.fraction_target - self.current_fraction - for uri in uris: - gtk_iteration() - if not uri: - continue - if uri.startswith('cdda:'): - show_error( - 'Cannot read from Audio CD.', - 'Use SoundJuicer Audio CD Extractor instead.' - ) - return - info = Gio.file_parse_name(uri).query_file_type( - Gio.FileMonitorFlags.NONE, None - ) - if info == Gio.FileType.DIRECTORY: - logger.info('walking: \'{}\''.format(uri)) - if len(uris) == 1: - # if only one folder is passed to the function, - # use its parent as base path. - base = os.path.dirname(uri) - - # get a list of all the files as URIs in - # that directory and its subdirectories - filelist = vfs_walk(uri) - - accepted = [] - if extensions: - for filename in filelist: - for extension in extensions: - if filename.lower().endswith(extension): - accepted.append(filename) - filelist = accepted - files.extend(filelist) - else: - files.append(uri) - - files = [f for f in files if not f.endswith('~SC~')] - - if len(files) == 0: - show_error('No files found!', '') - - if not base: - base = os.path.commonprefix(files) - if base and not base.endswith('/'): - # we want a common folder - base = base[0:base.rfind('/')] - base += '/' - else: - base += '/' - - scan_t = time.time() - logger.info('analysing file integrity') - - # self.good_uris will be populated - # by the discoverer. - # It is a list of uris and only contains those files - # that can be handled by gstreamer - self.good_uris = [] - - self.discoverers = TaskQueue() - sound_files = [] - for filename in files: - sound_file = SoundFile(filename, base) - sound_files.append(sound_file) - - add_discoverers(self.discoverers, sound_files) - - self.discoverers.set_on_queue_finished(self.discoverer_queue_ended) - self.discoverers.run() - - self.window.set_status('{}'.format(_('Adding Files…'))) - logger.info('adding: {} files'.format(len(files))) - - # show progress and enable GTK main loop iterations - # so that the ui stays responsive - self.window.progressbarstatus.set_text('0/{}'.format(len(files))) - self.window.progressbarstatus.set_show_text(True) - - while self.discoverers.running: - progress = self.discoverers.get_progress()[0] - if progress: - completed = int(progress * len(files)) - self.window.progressbarstatus.set_fraction(progress) - self.window.progressbarstatus.set_text( - '{}/{}'.format(completed, len(files)) - ) - gtk_iteration() - logger.info('Discovered {} audiofiles in {} s'.format( - len(files), round(self.discoverers.get_duration(), 1) - )) - - self.window.progressbarstatus.set_show_text(False) - - # see if one of the files with an audio extension - # was not readable. - known_audio_types = [ - '.flac', '.mp3', '.aac', - '.m4a', '.mpeg', '.opus', '.vorbis', '.ogg', '.wav' - ] - - # invalid_files is the number of files that are not - # added to the list in the current function call - invalid_files = 0 - # out of those files, that many have an audio file extension - broken_audiofiles = 0 - - sound_files = [] - for discoverer in self.discoverers.all_tasks: - sound_files += discoverer.sound_files - - for sound_file in sound_files: - # create a list of human readable file paths - # that were not added to the list - if not sound_file.readable: - filename = sound_file.filename - - extension = os.path.splitext(filename)[1].lower() - if extension in known_audio_types: - broken_audiofiles += 1 - - subfolders = sound_file.subfolders - relative_path = os.path.join(subfolders, filename) - - self.invalid_files_list.append(relative_path) - invalid_files += 1 - continue - if sound_file.uri in self.filelist: - logger.info('file already present: \'{}\''.format( - sound_file.uri - )) - continue - self.append_file(sound_file) - - if invalid_files > 0: - self.window.invalid_files_button.set_visible(True) - if len(files) == invalid_files == 1: - # case 1: the single file that should be added is not supported - show_error( - _('The specified file is not supported!'), - _('Either because it is broken or not an audio file.') - ) - - elif len(files) == invalid_files: - # case 2: all files that should be added cannot be added - show_error( - _('All {} specified files are not supported!').format( - len(files) - ), - _('Either because they are broken or not audio files.') - ) - - else: - # case 3: some files could not be added (that can already be - # because there is a single picture in a folder of hundreds - # of sound files). Show an error if this skipped file has a - # soundfile extension, otherwise don't bother the user. - logger.info( - '{} of {} files were not added to the list'.format( - invalid_files, len(files) - ) - ) - if broken_audiofiles > 0: - show_error( - ngettext( - 'One audio file could not be read by GStreamer!', - '{} audio files could not be read by GStreamer!', - broken_audiofiles - ).format(broken_audiofiles), - _( - 'Check "Invalid Files" in the menu for more' - 'information.' - ) - ) - else: - # case 4: all files were successfully added. No error message - pass + # not more steps than the progressbar can resolute + self.steps = round(self.progressbar.get_allocated_width() * difference) - self.window.set_status() - self.window.progressbarstatus.hide() - end_t = time.time() - logger.debug( - 'Added %d files in %.2fs (scan %.2fs, add %.2fs)' % ( - len(files), end_t - start_t, scan_t - start_t, end_t - scan_t - ) - ) - - def discoverer_queue_ended(self, queue): - # all tasks done - self.window.set_sensitive() - self.window.conversion_ended() - - total_time = queue.get_duration() - msg = _('Tasks done in %s') % format_time(total_time) - - errors = [ - task.error for task in queue.done - if task.error is not None - ] - if len(errors) > 0: - msg += ', {} error(s)'.format(len(errors)) - - self.window.set_status(msg) - if not self.window.is_active(): - notification(msg) - - readable = [] - for discoverer in self.discoverers.all_tasks: - for sound_file in discoverer.sound_files: - if sound_file.readable: - readable.append(sound_file) - - self.good_uris = [sound_file.uri for sound_file in readable] - self.window.set_status() - self.window.progressbarstatus.hide() - - def cancel(self): - if self.discoverers is not None: - self.discoverers.cancel() - - def format_cell(self, sound_file): - """Take a SoundFile and return a human readable path to it.""" - return GLib.markup_escape_text(unquote_filename(sound_file.filename)) - - def set_row_progress(self, number, progress): - """Update the progress bar of a single row/file.""" - self.progress_column.set_visible(True) - if self.model[number][2] == 1.0: + # don't make an interval if not needed + if self.steps <= 1: + self.set_current(fraction) return - self.model[number][2] = progress * 100.0 - - def hide_row_progress(self): - self.progress_column.set_visible(False) - - def append_file(self, sound_file): - """Add a valid SoundFile object to the list of files in the GUI. - - Parameters - ---------- - sound_file : SoundFile - This soundfile is expected to be readable by gstreamer - """ - self.model.append([ - self.format_cell(sound_file), - sound_file, - 0.0, - '', - sound_file.uri, - sound_file.duration - ]) - self.filelist.add(sound_file.uri) - sound_file.filelist_row = len(self.model) - 1 - - def remove(self, iterator): - uri = self.model.get(iterator, 1)[0].uri - self.filelist.remove(uri) - self.model.remove(iterator) - - def is_nonempty(self): - try: - self.model.get_iter((0,)) - except ValueError: + # restart the interpolation + self.step = 0 + interval = 1000 * self.period / self.steps + if self.timeout_id is not None: + GLib.Source.remove(self.timeout_id) + self.timeout_id = GLib.timeout_add(interval, self.interpolate) + + def get_fraction(self): + return self.current_fraction + + def set_current(self, new_fraction): + """Set the value immediatelly without interpolating it.""" + if new_fraction != self.current_fraction: + self.progressbar.set_fraction(new_fraction) + self.current_fraction = new_fraction + + def interpolate(self): + """Take a small step from the previous fraction to the target.""" + if self.fraction_target in [0, 1]: + self.set_current(self.fraction_target) + self.timeout_id = None return False - return True - - -class GladeWindow(object): - callbacks = {} - builder = None - - def __init__(self, builder): - """Init GladeWindow, store the objects's potential callbacks for later. - - You have to call connect_signals() when all descendants are ready. - """ - GladeWindow.builder = builder - GladeWindow.callbacks.update(dict( - [[x, getattr(self, x)] for x in dir(self) if x.startswith('on_')] - )) - - def __getattr__(self, attribute): - """Allow direct use of window widget.""" - widget = GladeWindow.builder.get_object(attribute) - if widget is None: - raise AttributeError('Widget \'{}\' not found'.format(attribute)) - self.__dict__[attribute] = widget # cache result - return widget - - @staticmethod - def connect_signals(): - """Connect all GladeWindow objects to theirs respective signals.""" - GladeWindow.builder.connect_signals(GladeWindow.callbacks) - - -class PreferencesDialog(GladeWindow): - sensitive_names = [ - 'vorbis_quality', 'choose_folder', 'create_subfolders', - 'subfolder_pattern', 'jobs_spinbutton', 'resample_hbox', - 'force_mono' - ] + difference = self.fraction_target - self.current_fraction + change = difference / max(1, self.steps - self.step) + self.step += 1 + new_fraction = self.current_fraction + change + self.set_current(new_fraction) - def __init__(self, builder, parent): - self.settings = get_gio_settings() - GladeWindow.__init__(self, builder) - - # encoders should be in the same order as in the glade file - for i, encoder in enumerate(encoders): - extension = get_file_extension(encoder[0]) - ui_row_text = self.liststore8[i][0] - # the extension should be part of the row with the correct index - assert extension.lower() in ui_row_text.lower() - - self.dialog = builder.get_object('prefsdialog') - self.dialog.set_transient_for(parent) - self.example = builder.get_object('example_filename') - self.force_mono = builder.get_object('force_mono') - - self.target_bitrate = None - - self.sensitive_widgets = {} - for name in self.sensitive_names: - self.sensitive_widgets[name] = builder.get_object(name) - assert self.sensitive_widgets[name] is not None - - self.encoders = None - self.present_mime_types = [] - - self.set_widget_initial_values() - self.set_sensitive() - - tip = [_('Available patterns:')] - for k in sorted(locale_patterns_dict.values()): - tip.append(k) - self.custom_filename.set_tooltip_text('\n'.join(tip)) - - def set_widget_initial_values(self): - self.quality_tabs.set_show_tabs(False) - - if self.settings.get_boolean('same-folder-as-input'): - widget = self.same_folder_as_input - else: - widget = self.into_selected_folder - widget.set_active(True) - - self.target_folder_chooser = Gtk.FileChooserDialog( - title=_('Add Folder…'), - transient_for=self.dialog, - action=Gtk.FileChooserAction.SELECT_FOLDER - ) - - self.target_folder_chooser.add_button( - Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL - ) - self.target_folder_chooser.add_button( - Gtk.STOCK_OPEN, Gtk.ResponseType.OK - ) - - self.target_folder_chooser.set_select_multiple(False) - self.target_folder_chooser.set_local_only(False) - - uri = filename_to_uri(urllib.parse.quote( - self.settings.get_string('selected-folder'), safe='/:@' - )) - self.target_folder_chooser.set_uri(uri) - self.update_selected_folder() - - widget = self.create_subfolders - widget.set_active(self.settings.get_boolean('create-subfolders')) - - widget = self.subfolder_pattern - active = self.settings.get_int('subfolder-pattern-index') - model = widget.get_model() - model.clear() - for pattern, desc in subfolder_patterns: - i = model.append() - model.set(i, 0, desc) - widget.set_active(active) - - if self.settings.get_boolean('replace-messy-chars'): - widget = self.replace_messy_chars - widget.set_active(True) - - if self.settings.get_boolean('delete-original'): - self.delete_original.set_active(True) - - current_mime_type = self.settings.get_string('output-mime-type') - - # deactivate output if encoder plugin is not present - widget = self.output_mime_type - model = widget.get_model() - assert len(model) == len(encoders), 'model:{} widgets:{}'.format( - len(model), len(encoders) - ) - - i = 0 - model = self.output_mime_type.get_model() - for mime, encoder_name in encoders: - # valid default output? - encoder_present = any( - e in available_elements for e in encoder_name.split(',') - ) - if encoder_present: - # add to supported outputs - self.present_mime_types.append(mime) - i += 1 - else: - logger.error( - '{} {} is not supported, a gstreamer plugins package ' - 'is possibly missing.'.format(mime, encoder_name) - ) - del model[i] - - for i, mime in enumerate(self.present_mime_types): - if current_mime_type == mime: - widget.set_active(i) - self.change_mime_type(current_mime_type) - - # display information about mp3 encoding - if 'lamemp3enc' not in available_elements: - widget = self.lame_absent - widget.show() - - widget = self.vorbis_quality - quality = self.settings.get_double('vorbis-quality') - quality_setting = get_quality('audio/x-vorbis', quality, reverse=True) - widget.set_active(-1) - self.vorbis_quality.set_active(quality_setting) - if self.settings.get_boolean('vorbis-oga-extension'): - self.vorbis_oga_extension.set_active(True) - - widget = self.aac_quality - quality = self.settings.get_int('aac-quality') - quality_setting = get_quality('audio/x-m4a', quality, reverse=True) - widget.set_active(quality_setting) - - widget = self.opus_quality - quality = self.settings.get_int('opus-bitrate') - quality_setting = get_quality('audio/ogg; codecs=opus', quality, reverse=True) - widget.set_active(quality_setting) - - widget = self.flac_compression - quality = self.settings.get_int('flac-compression') - quality_setting = get_quality('audio/x-flac', quality, reverse=True) - widget.set_active(quality_setting) - - widget = self.wav_sample_width - quality = self.settings.get_int('wav-sample-width') - quality_setting = get_quality('audio/x-wav', quality, reverse=True) - widget.set_active(quality_setting) - - self.mp3_quality = self.mp3_quality - self.mp3_mode = self.mp3_mode - - mode = self.settings.get_string('mp3-mode') - self.change_mp3_mode(mode) - - widget = self.basename_pattern - active = self.settings.get_int('name-pattern-index') - model = widget.get_model() - model.clear() - for pattern, desc in basename_patterns: - iter = model.append() - model.set(iter, 0, desc) - widget.set_active(active) - - self.custom_filename.set_text( - self.settings.get_string('custom-filename-pattern') - ) - if self.basename_pattern.get_active() == len(basename_patterns)-1: - self.custom_filename_box.set_sensitive(True) - else: - self.custom_filename_box.set_sensitive(False) - - output_resample = self.settings.get_boolean('output-resample') - self.resample_toggle.set_active(output_resample) - - cell = Gtk.CellRendererText() - self.resample_rate.pack_start(cell, True) - self.resample_rate.add_attribute(cell, 'text', 0) - rates = [8000, 11025, 16000, 22050, 32000, 44100, 48000, 96000, 128000] - rate = self.settings.get_int('resample-rate') - try: - idx = rates.index(rate) - except ValueError: - idx = -1 - self.resample_rate.set_active(idx) - - self.force_mono.set_active(self.settings.get_boolean('force-mono')) - - self.jobs.set_active(self.settings.get_boolean('limit-jobs')) - self.jobs_spinbutton.set_value(self.settings.get_int('number-of-jobs')) - - self.update_example() - - def update_selected_folder(self): - self.into_selected_folder.set_use_underline(False) - self.into_selected_folder.set_label( - _('Into folder %s') % - beautify_uri(self.settings.get_string('selected-folder')) - ) - - def update_example(self): - """Refresh the example in the settings dialog.""" - sound_file = SoundFile('file:///foo/bar.flac') - sound_file.tags.update({ - 'track-number': 1, - 'track-count': 99, - 'album-disc-number': 2, - 'album-disc-count': 9 - }) - sound_file.tags.update(locale_patterns_dict) - - generator = TargetNameGenerator() - generator.replace_messy_chars = False - - example_path = GLib.markup_escape_text( - generator.generate_target_uri(sound_file, for_display=True) - ) - position = 0 - replaces = [] - - while True: - beginning = example_path.find('{', position) - if beginning == -1: - break - end = example_path.find('}', beginning) - - tag = example_path[beginning:end+1] - available_tags = [ - v.lower() for v in list(locale_patterns_dict.values()) - ] - if tag.lower() in available_tags: - bold_tag = tag.replace( - '{', '{' - ).replace( - '}', '}' - ) - replaces.append([tag, bold_tag]) - else: - red_tag = tag.replace( - '{', '{' - ).replace( - '}', '}' - ) - replaces.append([tag, red_tag]) - position = beginning + 1 - - for tag, formatted in replaces: - example_path = example_path.replace(tag, formatted) - - self.example.set_markup(example_path) - - markup = '{}'.format( - _('Target bitrate: %s') % get_bitrate_from_settings() - ) - self.approx_bitrate.set_markup(markup) - - def set_sensitive(self): - for widget in list(self.sensitive_widgets.values()): - widget.set_sensitive(False) - - same_folder = self.settings.get_boolean('same-folder-as-input') - for name in ['choose_folder', 'create_subfolders', - 'subfolder_pattern']: - self.sensitive_widgets[name].set_sensitive(not same_folder) - - self.sensitive_widgets['vorbis_quality'].set_sensitive( - self.settings.get_string('output-mime-type') == 'audio/x-vorbis') - - self.sensitive_widgets['jobs_spinbutton'].set_sensitive( - self.settings.get_boolean('limit-jobs')) - - self.sensitive_widgets['resample_hbox'].set_sensitive(True) - self.sensitive_widgets['force_mono'].set_sensitive(True) - - def run(self): - self.dialog.run() - self.dialog.hide() - - def on_delete_original_toggled(self, button): - self.settings.set_boolean('delete-original', button.get_active()) - - def on_same_folder_as_input_toggled(self, button): - self.settings.set_boolean('same-folder-as-input', True) - self.set_sensitive() - self.update_example() - - def on_into_selected_folder_toggled(self, button): - self.settings.set_boolean('same-folder-as-input', False) - self.set_sensitive() - self.update_example() - - def on_choose_folder_clicked(self, button): - ret = self.target_folder_chooser.run() - folder = self.target_folder_chooser.get_uri() - self.target_folder_chooser.hide() - if ret == Gtk.ResponseType.OK: - if folder: - folder = urllib.parse.unquote(folder) - self.settings.set_string('selected-folder', folder) - self.update_selected_folder() - self.update_example() - - def on_create_subfolders_toggled(self, button): - self.settings.set_boolean('create-subfolders', button.get_active()) - self.update_example() - - def on_subfolder_pattern_changed(self, combobox): - self.settings.set_int('subfolder-pattern-index', combobox.get_active()) - self.update_example() - - def on_basename_pattern_changed(self, combobox): - self.settings.set_int('name-pattern-index', combobox.get_active()) - if combobox.get_active() == len(basename_patterns)-1: - self.custom_filename_box.set_sensitive(True) - else: - self.custom_filename_box.set_sensitive(False) - self.update_example() - - def on_custom_filename_changed(self, entry): - self.settings.set_string('custom-filename-pattern', entry.get_text()) - self.update_example() - - def on_replace_messy_chars_toggled(self, button): - self.settings.set_boolean('replace-messy-chars', button.get_active()) + if self.step >= self.steps: + # wait before starting the interpolation timeout again + self.timeout_id = None + return False - def change_mime_type(self, mime_type): - """Show the correct quality tab based on the selected format.""" - self.settings.set_string('output-mime-type', mime_type) - self.set_sensitive() - self.update_example() - tabs = { - 'audio/x-vorbis': 0, - 'audio/mpeg': 1, - 'audio/x-flac': 2, - 'audio/x-wav': 3, - 'audio/x-m4a': 4, - 'audio/ogg; codecs=opus': 5, - } - self.quality_tabs.set_current_page(tabs[mime_type]) - - def on_output_mime_type_changed(self, combo): - """Called when the format is changed on the UI.""" - format_index = combo.get_active() - mime = encoders[format_index][0] - if mime in self.present_mime_types: - self.change_mime_type(mime) - - def on_output_mime_type_ogg_vorbis_toggled(self, button): - if button.get_active(): - self.change_mime_type('audio/x-vorbis') - - def on_output_mime_type_flac_toggled(self, button): - if button.get_active(): - self.change_mime_type('audio/x-flac') - - def on_output_mime_type_wav_toggled(self, button): - if button.get_active(): - self.change_mime_type('audio/x-wav') - - def on_output_mime_type_mp3_toggled(self, button): - if button.get_active(): - self.change_mime_type('audio/mpeg') - - def on_output_mime_type_aac_toggled(self, button): - if button.get_active(): - self.change_mime_type('audio/x-m4a') - - def on_output_mime_type_opus_toggled(self, button): - if button.get_active(): - self.change_mime_type('audio/ogg; codecs=opus') - - def on_vorbis_quality_changed(self, combobox): - if combobox.get_active() == -1: - return # just de-selectionning - fquality = get_quality('audio/x-vorbis', combobox.get_active()) - self.settings.set_double('vorbis-quality', fquality) - self.update_example() - - def on_vorbis_oga_extension_toggled(self, toggle): - self.settings.set_boolean('vorbis-oga-extension', toggle.get_active()) - self.update_example() - - def on_aac_quality_changed(self, combobox): - quality = get_quality('audio/x-m4a', combobox.get_active()) - self.settings.set_int('aac-quality', quality) - self.update_example() - - def on_opus_quality_changed(self, combobox): - quality = get_quality('audio/ogg; codecs=opus', combobox.get_active()) - self.settings.set_int('opus-bitrate', quality) - self.update_example() - - def on_wav_sample_width_changed(self, combobox): - quality = get_quality('audio/x-wav', combobox.get_active()) - self.settings.set_int('wav-sample-width', quality) - self.update_example() - - def on_flac_compression_changed(self, combobox): - quality = get_quality('audio/x-flac', combobox.get_active()) - self.settings.set_int('flac-compression', quality) - self.update_example() - - def on_force_mono_toggle(self, button): - self.settings.set_boolean('force-mono', button.get_active()) - self.update_example() - - def change_mp3_mode(self, mode): - keys = {'cbr': 0, 'abr': 1, 'vbr': 2} - self.mp3_mode.set_active(keys[mode]) - - keys = { - 'cbr': 'mp3-cbr-quality', - 'abr': 'mp3-abr-quality', - 'vbr': 'mp3-vbr-quality', - } - quality = self.settings.get_int(keys[mode]) - - index = get_quality('audio/mpeg', quality, mode, reverse=True) - self.mp3_quality.set_active(index) - self.update_example() - - def on_mp3_mode_changed(self, combobox): - mode = ('cbr', 'abr', 'vbr')[combobox.get_active()] - self.settings.set_string('mp3-mode', mode) - self.change_mp3_mode(mode) - - def on_mp3_quality_changed(self, combobox): - keys = { - 'cbr': 'mp3-cbr-quality', - 'abr': 'mp3-abr-quality', - 'vbr': 'mp3-vbr-quality' - } - mode = self.settings.get_string('mp3-mode') - - bitrate = get_quality('audio/mpeg', combobox.get_active(), mode) - self.settings.set_int(keys[mode], bitrate) - self.update_example() - - def on_resample_rate_changed(self, combobox): - selected = combobox.get_active() - rates = [8000, 11025, 16000, 22050, 32000, 44100, 48000, 96000, 128000] - self.settings.set_int('resample-rate', rates[selected]) - self.update_example() - - def on_resample_toggle(self, rstoggle): - self.settings.set_boolean('output-resample', rstoggle.get_active()) - self.resample_rate.set_sensitive(rstoggle.get_active()) - self.update_example() - - def on_jobs_toggled(self, jtoggle): - self.settings.set_boolean('limit-jobs', jtoggle.get_active()) - self.jobs_spinbutton.set_sensitive(jtoggle.get_active()) - - def on_jobs_spinbutton_value_changed(self, jspinbutton): - self.settings.set_int('number-of-jobs', int(jspinbutton.get_value())) + return True class SoundConverterWindow(GladeWindow): @@ -1079,6 +228,9 @@ def __init__(self, builder): self.set_sensitive() self.set_status() + # wrap the widget + self.progressbar = ProgressBar(self.progressbar) + # This bit of code constructs a list of methods for binding to Gtk+ # signals. This way, we don't have to maintain a list manually, # saving editing effort. It's enough to add a method to the suitable @@ -1265,17 +417,21 @@ def update_remaining(self): if remaining is not None: seconds = max(remaining % 60, 1) minutes = remaining / 60 - remaining = _('%d:%02d left') % (minutes, seconds) - self.progressbar.set_text(remaining) + remaining_str = _('%d:%02d left') % (minutes, seconds) + self.progressbar.set_text(remaining_str) self.progressbar.set_show_text(True) - title = '{} - {}'.format(_('SoundConverter'), remaining) + title = '{} - {}'.format(_('SoundConverter'), remaining_str) self.widget.set_title(title) + # -1 because the progressbar should get a chance for + # displaying 100% and not get hidden too early + fraction = duration / (duration + remaining - 1) + self.progressbar.set_fraction(fraction) # return True to keep the GLib timeout running return True def update_progress(self): - """Refresh all progress bars, including the total progress. + """Refresh all progress bars. Can be used in GLib.timeout_add. """ @@ -1289,9 +445,7 @@ def update_progress(self): if not paused and running: # if paused, don't refresh the progress - total_progress, task_progress = self.converter_queue.get_progress() - self.progressbar.set_fraction(total_progress) - + task_progress = self.converter_queue.get_progress()[1] for task, progress in task_progress: self.set_file_progress(task.sound_file, progress) @@ -1300,6 +454,7 @@ def update_progress(self): def on_convert_button_clicked(self, *args): # reset and show progress bar + self.progressbar.set_fraction(0) self.progress_frame.show() self.status_frame.hide() self.set_status(_('Converting')) diff --git a/soundconverter/util/taskqueue.py b/soundconverter/util/taskqueue.py index 117f4582..f68d3956 100644 --- a/soundconverter/util/taskqueue.py +++ b/soundconverter/util/taskqueue.py @@ -40,8 +40,10 @@ def __init__(self): self.paused = False self._timer = Timer() - self._remaining_history = History(size=11) - self._smooth_remaining_time = Smoothing(factor=20) + self._remaining_history = History(size=31) + # In my experience the factor for the remaining time correction + # is around 1.3. Start with it and then correct it if needed. + self._smooth_remaining_time = Smoothing(factor=50, first_value=1.3) def add(self, task): """Add a task to the queue that will be executed later. @@ -67,18 +69,18 @@ def get_progress(self, only_running=False): if len(self.all_tasks) == 0: return None total_weight = 0 - total_progress = 0 + total_processed_weight = 0 tasks = self.running if only_running else self.all_tasks task_progress = [] for task in tasks: progress, weight = task.get_progress() - total_progress += progress * weight + total_processed_weight += progress * weight total_weight += weight task_progress.append((task, progress)) - return total_progress / total_weight, task_progress + return total_processed_weight / total_weight, task_progress def pause(self): """Pause all tasks.""" @@ -168,10 +170,10 @@ def get_remaining(self): # cannot be estimated yet return None - print() - # replicate how the workload is distributed among the processes - # to figure out how much time is remaining + # to figure out how much time is remaining. This is needed because + # at the end some processes will be idle while other are still + # converting. workloads = [0] * get_num_jobs() total_processed_weight = 0 @@ -190,20 +192,23 @@ def get_remaining(self): remaining_weight = (1 - progress) * weight workloads[smallest_index] += remaining_weight + for task in self.all_tasks: progress, weight = task.get_progress() total_duration += task.timer.get_duration() total_processed_weight += progress * weight - speed = total_processed_weight / total_duration + if len(self.running) == get_num_jobs(): + # if possible, use the the taskqueues duration instead of the + # sum of all tasks to account for overheads between running tasks + taskqueue_duration = self.get_duration() * get_num_jobs() + speed = total_processed_weight / taskqueue_duration + else: + speed = total_processed_weight / total_duration remaining = max(workloads) / speed - print('remaining', remaining) - - # due to possible inaccuracies like unaccounted overheads, correct - # the remaining time based on how long it has actually been running. - # this is only really a concern for large files, otherwise the above - # prediction is actually not bad. + # correct the remaining time based on how long it has actually + # been running self._remaining_history.push({ 'time': time.time(), 'remaining': remaining @@ -212,16 +217,16 @@ def get_remaining(self): if historic is not None: seconds_since = time.time() - historic['time'] remaining_change = historic['remaining'] - remaining - max_factor = 1.5 if remaining_change > 0: factor = seconds_since / remaining_change - # avoid having ridiculous factors, put some trust into - # the above unfactored prediction - factor = max(1 / max_factor, min(max_factor, factor)) + # put some trust into the unfactored prediction after all. + # sometimes for large files, the speed seems to decrease over + # time, in which case the remaining_change is close to 0. + # Don't make the factor super large then. + factor = max(0.8, min(1.6, factor)) else: - factor = max_factor + factor = 1.6 factor = self._smooth_remaining_time.smooth(factor) - print('####', factor, '####') remaining = remaining * factor return remaining @@ -229,9 +234,9 @@ def get_remaining(self): class Smoothing: """Exponential smoothing for a single value.""" - def __init__(self, factor): + def __init__(self, factor, first_value=None): self.factor = factor - self.value = None + self.value = first_value def smooth(self, value): if self.value is not None: diff --git a/tests/testcases/taskqueue.py b/tests/testcases/taskqueue.py index e34b6e60..d964e038 100644 --- a/tests/testcases/taskqueue.py +++ b/tests/testcases/taskqueue.py @@ -487,7 +487,6 @@ def test(self): history.push(10) history.push(20) history.push(30) - print(history.values) self.assertEqual(history.get(0), 30) self.assertEqual(history.get(1), 20) self.assertEqual(history.get(2), 10) From 085721bb73818ed5b8146b50d72ccd37a7136df6 Mon Sep 17 00:00:00 2001 From: sezanzeb Date: Sun, 6 Sep 2020 12:12:12 +0200 Subject: [PATCH 03/10] some improvements to the ProgressBar class --- soundconverter/interface/ui.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/soundconverter/interface/ui.py b/soundconverter/interface/ui.py index b1e7194d..3b960b47 100644 --- a/soundconverter/interface/ui.py +++ b/soundconverter/interface/ui.py @@ -65,7 +65,14 @@ class ProgressBar: for a small number of files. """ def __init__(self, progressbar, period=1): - """Initialize the fraction object without doing anything yet.""" + """Initialize the fraction object without doing anything yet. + + Parameters + ---------- + period : int + How fast new fraction values are expected to arrive in seconds. + Doesn't have to be accurate. + """ self.progressbar = progressbar self.steps = 0 self.period = period @@ -81,6 +88,7 @@ def __getattr__(self, attribute): def set_fraction(self, fraction): """Set a fraction that will be shown after interpolating to it.""" + fraction = min(1, max(0, fraction)) if fraction in [0, 1]: self.set_current(fraction) return @@ -88,8 +96,16 @@ def set_fraction(self, fraction): self.fraction_target = fraction difference = self.fraction_target - self.current_fraction + if difference <= 0: + self.set_current(fraction) + return + # not more steps than the progressbar can resolute - self.steps = round(self.progressbar.get_allocated_width() * difference) + # and not more than 30 fps + self.steps = min( + 30 * self.period, + round(self.progressbar.get_allocated_width() * difference) + ) # don't make an interval if not needed if self.steps <= 1: From c248e45bca2044c3a97bd5bdf3bf2d226702c231 Mon Sep 17 00:00:00 2001 From: sezanzeb Date: Sun, 6 Sep 2020 12:15:07 +0200 Subject: [PATCH 04/10] fixed tests --- tests/testcases/integration.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/testcases/integration.py b/tests/testcases/integration.py index 784bb241..3df387b0 100644 --- a/tests/testcases/integration.py +++ b/tests/testcases/integration.py @@ -37,7 +37,8 @@ from soundconverter.util.formats import get_quality, get_file_extension from soundconverter.util.fileoperations import filename_to_uri from soundconverter.util.soundfile import SoundFile -from soundconverter.interface.ui import win, gtk_iteration, encoders +from soundconverter.interface.ui import win, gtk_iteration +from soundconverter.interface.preferences import encoders from soundconverter.interface.batch import cli_convert from soundconverter.gstreamer.converter import available_elements from soundconverter.gstreamer.discoverer import Discoverer From 89f3ea765796ff595347bce4ae4f6fda494148dd Mon Sep 17 00:00:00 2001 From: sezanzeb Date: Sun, 6 Sep 2020 12:21:42 +0200 Subject: [PATCH 05/10] not sure if that docstring made sense --- soundconverter/interface/gladewindow.py | 1 - 1 file changed, 1 deletion(-) diff --git a/soundconverter/interface/gladewindow.py b/soundconverter/interface/gladewindow.py index 3aa60c38..189ed4e7 100644 --- a/soundconverter/interface/gladewindow.py +++ b/soundconverter/interface/gladewindow.py @@ -21,7 +21,6 @@ class GladeWindow(object): - """Create a window from a glade builder.""" callbacks = {} builder = None From 77b2b1e80638913c36e8ef9afb5541b128e94756 Mon Sep 17 00:00:00 2001 From: sezanzeb Date: Sun, 6 Sep 2020 16:09:05 +0200 Subject: [PATCH 06/10] some potential improvements to the calculation --- soundconverter/gstreamer/converter.py | 4 +-- soundconverter/util/taskqueue.py | 43 ++++++++++++++++----------- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/soundconverter/gstreamer/converter.py b/soundconverter/gstreamer/converter.py index c4319e64..47ca24b7 100644 --- a/soundconverter/gstreamer/converter.py +++ b/soundconverter/gstreamer/converter.py @@ -381,9 +381,9 @@ def _conversion_done(self): assert vfs_exists(newname) - """logger.info("converted '{}' to '{}'".format( + logger.info("converted '{}' to '{}'".format( beautify_uri(input_uri), beautify_uri(newname) - ))""" + )) # Copy file permissions source = Gio.file_parse_name(self.sound_file.uri) diff --git a/soundconverter/util/taskqueue.py b/soundconverter/util/taskqueue.py index f68d3956..9a9681a7 100644 --- a/soundconverter/util/taskqueue.py +++ b/soundconverter/util/taskqueue.py @@ -105,8 +105,8 @@ def cancel(self): # from the beginning. The proper way would be to call pause and # resume for such a functionality though. self.pending.put(task) - task.timer.stop() task.cancel() + task.timer.stop() self._timer.reset() self.running = [] @@ -120,26 +120,31 @@ def task_done(self, task): task : Task A completed task """ + if task.get_progress()[0] != 1: + raise ValueError( + f'Task with a progress of {task.get_progress()[0]} called ' + 'task_done' + ) self.done.append(task) - task.timer.stop() if task not in self.running: logger.warning('tried to remove task that was already removed') else: self.running.remove(task) + task.timer.stop() if self.pending.qsize() > 0: self.start_next() elif len(self.running) == 0: self.finished = True - self._timer.stop() if self._on_queue_finished is not None: self._on_queue_finished(self) + self._timer.stop() def start_next(self): """Start the next task if available.""" if self.pending.qsize() > 0: task = self.pending.get() - self.running.append(task) task.timer.start() + self.running.append(task) task.run() def run(self): @@ -148,6 +153,7 @@ def run(self): Finished tasks will trigger running the next task over the task_done callback. """ + self._remaining_history.reset() self._timer.start() num_jobs = get_num_jobs() while self.pending.qsize() > 0 and len(self.running) < num_jobs: @@ -176,9 +182,6 @@ def get_remaining(self): # converting. workloads = [0] * get_num_jobs() - total_processed_weight = 0 - total_duration = 0 - for task in self.all_tasks: if task.done: continue @@ -192,18 +195,20 @@ def get_remaining(self): remaining_weight = (1 - progress) * weight workloads[smallest_index] += remaining_weight - for task in self.all_tasks: + total_processed_weight = 0 + total_duration = 0 + if len(self.done) >= get_num_jobs(): + # prefer finished tasks for the speed calculation, because the + # conversion speed seems to change during conversion + speed_calculation_tasks = self.done + else: + # not enough tasks to calculate speed yet + speed_calculation_tasks = self.done + self.running + for task in speed_calculation_tasks: progress, weight = task.get_progress() total_duration += task.timer.get_duration() total_processed_weight += progress * weight - - if len(self.running) == get_num_jobs(): - # if possible, use the the taskqueues duration instead of the - # sum of all tasks to account for overheads between running tasks - taskqueue_duration = self.get_duration() * get_num_jobs() - speed = total_processed_weight / taskqueue_duration - else: - speed = total_processed_weight / total_duration + speed = total_processed_weight / total_duration remaining = max(workloads) / speed @@ -220,7 +225,7 @@ def get_remaining(self): if remaining_change > 0: factor = seconds_since / remaining_change # put some trust into the unfactored prediction after all. - # sometimes for large files, the speed seems to decrease over + # sometimes for large files the speed seems to decrease over # time, in which case the remaining_change is close to 0. # Don't make the factor super large then. factor = max(0.8, min(1.6, factor)) @@ -252,6 +257,10 @@ def __init__(self, size): self.index = 0 self.values = [None] * size + def reset(self): + self.index = 0 + self.values = [None] * len(self.values) + def push(self, value): """Add a new value to the history, possibly overwriting old values.""" self.values[self.index] = value From dc347204661668ed5bc80018757a8b027f492248 Mon Sep 17 00:00:00 2001 From: sezanzeb Date: Sun, 6 Sep 2020 17:31:50 +0200 Subject: [PATCH 07/10] discovering the type of audio --- soundconverter/gstreamer/discoverer.py | 9 +++++++++ soundconverter/util/soundfile.py | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/soundconverter/gstreamer/discoverer.py b/soundconverter/gstreamer/discoverer.py index c01295c5..177c992e 100644 --- a/soundconverter/gstreamer/discoverer.py +++ b/soundconverter/gstreamer/discoverer.py @@ -106,6 +106,12 @@ def run(self): msg = Gst.Message.new_custom(msg_type, None, None) self.bus.post(msg) + def _get_type(self, info): + """Figure out the type of the audio stream.""" + caps = info.get_audio_streams()[0].get_caps() + stream_type = caps.get_structure(0).get_name() + return stream_type + def _analyse_file(self, sound_file): """Figure out readable, tags and duration properties.""" denylisted_pattern = is_denylisted(sound_file) @@ -123,6 +129,9 @@ def _analyse_file(self, sound_file): # whatever anybody might ever need from it, here it is: sound_file.info = info + # type of the stream + sound_file.type = self._get_type(info) + taglist = info.get_tags() taglist.foreach(lambda *args: self._add_tag(*args, sound_file)) diff --git a/soundconverter/util/soundfile.py b/soundconverter/util/soundfile.py index 58d5366a..2957814a 100644 --- a/soundconverter/util/soundfile.py +++ b/soundconverter/util/soundfile.py @@ -30,7 +30,7 @@ class SoundFile: __slots__ = [ 'uri', 'base_path', 'filename', 'tags', 'filelist_row', 'subfolders', - 'readable', 'duration', 'info' + 'readable', 'duration', 'info', 'type' ] def __init__(self, uri, base_path=None): From 9a58bb23bef3422025a3be09210a3f36e52556a0 Mon Sep 17 00:00:00 2001 From: sezanzeb Date: Sun, 6 Sep 2020 17:32:10 +0200 Subject: [PATCH 08/10] conversion is marked done when skipping existing files --- soundconverter/gstreamer/converter.py | 1 + soundconverter/util/taskqueue.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/soundconverter/gstreamer/converter.py b/soundconverter/gstreamer/converter.py index 47ca24b7..af794529 100644 --- a/soundconverter/gstreamer/converter.py +++ b/soundconverter/gstreamer/converter.py @@ -426,6 +426,7 @@ def run(self): logger.info('output file already exists, skipping \'{}\''.format( beautify_uri(self.newname) )) + self.done = True self._conversion_done() return diff --git a/soundconverter/util/taskqueue.py b/soundconverter/util/taskqueue.py index 9a9681a7..9ee7a34b 100644 --- a/soundconverter/util/taskqueue.py +++ b/soundconverter/util/taskqueue.py @@ -122,8 +122,8 @@ def task_done(self, task): """ if task.get_progress()[0] != 1: raise ValueError( - f'Task with a progress of {task.get_progress()[0]} called ' - 'task_done' + f'{task.__class__.__name__} Task with a progress of ' + f'{task.get_progress()[0]} called task_done' ) self.done.append(task) if task not in self.running: From 3116154cbe219ec5a7edc3f16a54431d3dc4eecb Mon Sep 17 00:00:00 2001 From: sezanzeb Date: Sun, 6 Sep 2020 17:56:31 +0200 Subject: [PATCH 09/10] comment clarification --- soundconverter/util/taskqueue.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/soundconverter/util/taskqueue.py b/soundconverter/util/taskqueue.py index 9ee7a34b..0e2f333e 100644 --- a/soundconverter/util/taskqueue.py +++ b/soundconverter/util/taskqueue.py @@ -202,7 +202,7 @@ def get_remaining(self): # conversion speed seems to change during conversion speed_calculation_tasks = self.done else: - # not enough tasks to calculate speed yet + # not enough finished tasks to calculate speed yet speed_calculation_tasks = self.done + self.running for task in speed_calculation_tasks: progress, weight = task.get_progress() From 04eee8bfde707dd42201883718cf9a30742b42dd Mon Sep 17 00:00:00 2001 From: sezanzeb Date: Mon, 7 Sep 2020 22:54:12 +0200 Subject: [PATCH 10/10] merge py3k --- soundconverter/interface/filelist.py | 2 +- soundconverter/interface/ui.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/soundconverter/interface/filelist.py b/soundconverter/interface/filelist.py index 231b5c87..cf8d054e 100644 --- a/soundconverter/interface/filelist.py +++ b/soundconverter/interface/filelist.py @@ -380,7 +380,7 @@ def format_cell(self, sound_file): def set_row_progress(self, number, progress): """Update the progress bar of a single row/file.""" self.progress_column.set_visible(True) - if self.model[number][2] == 1.0: + if self.model[number][2] == progress * 100: return self.model[number][2] = progress * 100.0 diff --git a/soundconverter/interface/ui.py b/soundconverter/interface/ui.py index 3b960b47..10b88e3e 100644 --- a/soundconverter/interface/ui.py +++ b/soundconverter/interface/ui.py @@ -48,8 +48,8 @@ def __init__(self, builder): self.secondary = builder.get_object('secondary_error_label') def show_error(self, primary, secondary): - self.primary.set_markup(primary) - self.secondary.set_markup(secondary) + self.primary.set_markup(str(primary)) + self.secondary.set_markup(str(secondary)) try: sys.stderr.write(_('\nError: %s\n%s\n') % (primary, secondary)) except Exception: