def test_label_capitals(self): """ Check that various input labels (strings ending with a ':') are written with proper capitalization. Examples: Dough amount: - ok Salt: - ok Channel eggs through the Internet: - ok All Caps: - title case can't be used for labels Caveats: The test doesn't yet know which words are usually capitalized, so: Send to Kitchen: - will erroneously pass the test """ fails = [] ok_labels = L('Local _IP:', 'Songs with MBIDs:') for entry in self.pot: if not entry.msgid.endswith(':'): continue if ' ' not in entry.msgid.strip(): continue if entry.msgid == human_title(entry.msgid): if entry.msgid not in ok_labels: fails.append(entry) ok_labels.check_unused() self.conclude(fails, "title case used for a label")
def test_label_capitals(self): """ Check that various input labels (strings ending with a ':') are written with proper capitalization. Examples: Dough amount: - ok Salt: - ok Channel eggs through the Internet: - ok All Caps: - title case can't be used for labels Caveats: The test doesn't yet know which words are usually capitalized, so: Send to Kitchen: - will erroneously pass the test """ fails = [] ok_labels = L("Local _IP:", "Songs with MBIDs:") for entry in self.pot: if not entry.msgid.endswith(":"): continue if " " not in entry.msgid.strip(): continue if entry.msgid == human_title(entry.msgid): if entry.msgid not in ok_labels: fails.append(entry) ok_labels.check_unused() self.conclude(fails, "title case used for a label")
def test_plugin_name(self): REASON_ABSENT = "plugin should have PLUGIN_NAME" REASON_CASE = "PLUGIN_NAME should be in Title Case" ok_names = L('Last.fm Cover Source', 'Last.fm Sync', 'Send to iFP', 'This is a test') fails = [] for pid, plugin in self.plugins.items(): if not hasattr(plugin.cls, 'PLUGIN_NAME'): fails.append((plugin, None, REASON_ABSENT)) continue name = plugin.cls.PLUGIN_NAME if name != human_title(name): if name not in ok_names: fails.append((plugin, name, REASON_CASE)) ok_names.check_unused() self.conclude(fails)
def test_plugin_name(self): REASON_ABSENT = "plugin should have PLUGIN_NAME" REASON_CASE = "PLUGIN_NAME should be in Title Case" ok_names = L( 'Last.fm Cover Source', 'Last.fm Sync', 'Send to iFP', 'This is a test') fails = [] for pid, plugin in self.plugins.iteritems(): if not hasattr(plugin.cls, 'PLUGIN_NAME'): fails.append((plugin, None, REASON_ABSENT)) continue name = plugin.cls.PLUGIN_NAME if name != human_title(name): if name not in ok_names: fails.append((plugin, name, REASON_CASE)) ok_names.check_unused() self.conclude(fails)
class SyncToDevice(EventPlugin, PluginConfigMixin): PLUGIN_ICON = Icons.NETWORK_TRANSMIT PLUGIN_ID = PLUGIN_CONFIG_SECTION PLUGIN_NAME = human_title(PLUGIN_CONFIG_SECTION.replace('_', ' ')) PLUGIN_DESC = _('Synchronizes all songs from the selected saved searches ' 'with the specified folder.') CONFIG_SECTION = PLUGIN_CONFIG_SECTION CONFIG_QUERY_PREFIX = 'query_' CONFIG_PATH_KEY = '{}_{}'.format(PLUGIN_CONFIG_SECTION, 'path') CONFIG_PATTERN_KEY = '{}_{}'.format(PLUGIN_CONFIG_SECTION, 'pattern') path_query = os.path.join(get_user_dir(), 'lists', 'queries.saved') path_pattern = os.path.join(get_user_dir(), 'lists', 'renamepatterns') spacing_main = 20 spacing_large = 6 spacing_small = 3 summary_sep = ' ' * 2 summary_sep_list = ',' + summary_sep default_export_pattern = os.path.join(_('<artist>'), _('<album>'), _('<title>')) model_cols = { 'entry': (0, object), 'tag': (1, str), 'filename': (2, str), 'export': (3, str) } def PluginPreferences(self, parent): # Check if the queries file exists if not os.path.exists(self.path_query): return self._no_queries_frame() # Read saved searches from file self.queries = {} with open(self.path_query, 'r', encoding='utf-8') as query_file: for query_string in query_file: name = next(query_file).strip() self.queries[name] = Query(query_string.strip()) if not self.queries: # query_file is empty return self._no_queries_frame() main_vbox = Gtk.VBox(spacing=self.spacing_main) self.main_vbox = main_vbox # Saved search selection frame saved_search_vbox = Gtk.VBox(spacing=self.spacing_large) self.saved_search_vbox = saved_search_vbox for query_name, query in self.queries.items(): query_config = self.CONFIG_QUERY_PREFIX + query_name check_button = ConfigCheckButton(query_name, PM.CONFIG_SECTION, self._config_key(query_config)) check_button.set_active(self.config_get_bool(query_config)) saved_search_vbox.pack_start(check_button, False, False, 0) saved_search_scroll = self._expandable_scroll(min_h=0, max_h=300) saved_search_scroll.add(saved_search_vbox) frame = qltk.Frame( label=_('Synchronize the following saved searches:'), child=saved_search_scroll) main_vbox.pack_start(frame, False, False, 0) # Destination path entry field destination_entry = Gtk.Entry( placeholder_text=_('The absolute path to your export location'), text=config.get(PM.CONFIG_SECTION, self.CONFIG_PATH_KEY, '')) destination_entry.connect('changed', self._destination_path_changed) self.destination_entry = destination_entry # Destination path selection button destination_button = qltk.Button(label='', icon_name=Icons.FOLDER_OPEN) destination_button.connect('clicked', self._select_destination_path) # Destination path hbox destination_path_hbox = Gtk.HBox(spacing=self.spacing_small) destination_path_hbox.pack_start(destination_entry, True, True, 0) destination_path_hbox.pack_start(destination_button, False, False, 0) # Destination path information destination_warn_label = self._label_with_icon( _("All pre-existing files in the destination folder that aren't in " "the saved searches will be deleted."), Icons.DIALOG_WARNING) destination_info_label = self._label_with_icon( _('For devices mounted with MTP, export to a local destination ' 'folder, then transfer it to your device with rsync. ' 'Or, when syncing many files to an Android Device, use adb-sync, ' 'which is much faster.'), Icons.DIALOG_INFORMATION) # Destination path frame destination_vbox = Gtk.VBox(spacing=self.spacing_large) destination_vbox.pack_start(destination_path_hbox, False, False, 0) destination_vbox.pack_start(destination_warn_label, False, False, 0) destination_vbox.pack_start(destination_info_label, False, False, 0) frame = qltk.Frame(label=_('Destination path:'), child=destination_vbox) main_vbox.pack_start(frame, False, False, 0) # Export pattern frame export_pattern_combo = ComboBoxEntrySave( self.path_pattern, [self.default_export_pattern], title=_('Path Patterns'), edit_title=_(u'Edit saved patterns…')) export_pattern_combo.enable_clear_button() export_pattern_combo.show_all() export_pattern_entry = export_pattern_combo.get_child() export_pattern_entry.set_placeholder_text( _('The structure of the exported filenames, based on their tags')) export_pattern_entry.set_text( config.get(PM.CONFIG_SECTION, self.CONFIG_PATTERN_KEY, self.default_export_pattern)) export_pattern_entry.connect('changed', self._export_pattern_changed) self.export_pattern_entry = export_pattern_entry frame = qltk.Frame(label=_('Export pattern:'), child=export_pattern_combo) main_vbox.pack_start(frame, False, False, 0) # Start preview button preview_start_button = qltk.Button(label=_('Preview'), icon_name=Icons.VIEW_REFRESH) preview_start_button.set_visible(True) preview_start_button.connect('clicked', self._start_preview) self.preview_start_button = preview_start_button # Stop preview button preview_stop_button = qltk.Button(label=_('Stop preview'), icon_name=Icons.PROCESS_STOP) preview_stop_button.set_visible(False) preview_stop_button.set_no_show_all(True) preview_stop_button.connect('clicked', self._stop_preview) self.preview_stop_button = preview_stop_button # Details view column_types = [column[1] for column in self.model_cols.values()] self.model = Gtk.ListStore(*column_types) self.details_tree = details_tree = HintedTreeView(model=self.model) details_scroll = self._expandable_scroll() details_scroll.set_shadow_type(Gtk.ShadowType.IN) details_scroll.add(details_tree) self.renders = {} # Preview column: status render = Gtk.CellRendererText() column = self._tree_view_column(render, self._cdf_status, title=_('Status'), expand=False, sort=self._model_col_id('tag')) details_tree.append_column(column) # Preview column: file render = Gtk.CellRendererText() column = self._tree_view_column(render, self._cdf_source_path, title=_('Source File'), sort=self._model_col_id('filename')) details_tree.append_column(column) # Preview column: export path render = Gtk.CellRendererText() render.set_property('editable', True) render.connect('edited', self._row_edited) column = self._tree_view_column(render, self._cdf_export_path, title=_('Export Path'), sort=self._model_col_id('export')) details_tree.append_column(column) # Status labels self.status_operation = Gtk.Label(xalign=0.0, yalign=0.5, wrap=True, visible=False, no_show_all=True) self.status_progress = Gtk.Label(xalign=0.0, yalign=0.5, wrap=True, visible=False, no_show_all=True) self.status_duplicates = self._label_with_icon(_( 'Duplicate export paths detected! The export paths above can be ' 'edited before starting the synchronization.'), Icons.DIALOG_WARNING, visible=False) self.status_deletions = self._label_with_icon( _('Existing files in the destination path will be deleted!'), Icons.DIALOG_WARNING, visible=False) # Section for previewing exported files preview_vbox = Gtk.VBox(spacing=self.spacing_large) preview_vbox.pack_start(preview_start_button, False, False, 0) preview_vbox.pack_start(preview_stop_button, False, False, 0) preview_vbox.pack_start(details_scroll, True, True, 0) preview_vbox.pack_start(self.status_operation, False, False, 0) preview_vbox.pack_start(self.status_progress, False, False, 0) preview_vbox.pack_start(self.status_duplicates, False, False, 0) preview_vbox.pack_start(self.status_deletions, False, False, 0) main_vbox.pack_start(preview_vbox, True, True, 0) # Start sync button sync_start_button = qltk.Button(label=_('Start synchronization'), icon_name=Icons.DOCUMENT_SAVE) sync_start_button.set_sensitive(False) sync_start_button.set_visible(True) sync_start_button.connect('clicked', self._start_sync) self.sync_start_button = sync_start_button # Stop sync button sync_stop_button = qltk.Button(label=_('Stop synchronization'), icon_name=Icons.PROCESS_STOP) sync_stop_button.set_visible(False) sync_stop_button.set_no_show_all(True) sync_stop_button.connect('clicked', self._stop_sync) self.sync_stop_button = sync_stop_button # Section for the sync buttons sync_vbox = Gtk.VBox(spacing=self.spacing_large) sync_vbox.pack_start(sync_start_button, False, False, 0) sync_vbox.pack_start(sync_stop_button, False, False, 0) main_vbox.pack_start(sync_vbox, False, False, 0) return main_vbox @staticmethod def _no_queries_frame(): """ Create a frame to use when there are no saved searches. :return: A new Frame. """ return qltk.Frame( _('No saved searches yet, create some and come back!')) def _expandable_scroll(self, min_h=50, max_h=-1, expand=True): """ Create a ScrolledWindow that expands as content is added. :param min_h: The minimum height of the window, in pixels. :param max_h: The maximum height of the window, in pixels. It will grow up to this height before it starts scrolling the content. :param expand: Whether the window should expand. :return: A new ScrolledWindow. """ return Gtk.ScrolledWindow(min_content_height=min_h, max_content_height=max_h, propagate_natural_height=expand) def _label_with_icon(self, text, icon_name, visible=True): """ Create a new label with an icon to the left of the text. :param text: The new text to set for the label. :param icon_name: An icon name or None. :return: A HBox containing an icon followed by a label. """ image = Gtk.Image.new_from_icon_name(icon_name, Gtk.IconSize.BUTTON) label = Gtk.Label(label=text, xalign=0.0, yalign=0.5, wrap=True) hbox = Gtk.HBox(spacing=self.spacing_large) if not visible: hbox.set_visible(False) hbox.set_no_show_all(True) hbox.pack_start(image, False, False, 0) hbox.pack_start(label, True, True, 0) return hbox def _tree_view_column(self, render, cdf, title=None, sort=None, expand=True, resize=True, reorder=True): """ Create a new TreeViewColumn with the given properties. :param render: The A Gtk.CellRenderer of this cell. :param cdf: The Gtk.TreeCellDataFunc to use for updating content. :param title: The column's title. :param sort: The model column to use when sorting this column. :param expand: Whether the column width should automatically expand. :param resize: Whether the column can be resized. :param reorder: Whether the column can be reordered. :return: The new TreeViewColumn. """ tvc = Gtk.TreeViewColumn() tvc.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE) tvc.set_expand(expand) tvc.set_resizable(resize) tvc.set_reorderable(reorder) if title: tvc.set_title(title) if resize: render.set_property('ellipsize', Pango.EllipsizeMode.END) if sort: tvc.set_sort_column_id(sort) tvc.set_cell_data_func(render, cdf) tvc.pack_start(render, True) self.renders[tvc] = render return tvc def _destination_path_changed(self, entry): """ Save the destination path to the global config when the path changes. :param entry: The destination path entry field. """ config.set(PM.CONFIG_SECTION, self.CONFIG_PATH_KEY, entry.get_text()) def _select_destination_path(self, button): """ Show a folder selection dialog to select the destination path from the file system. :param button: The destination path selection button. """ dialog = Gtk.FileChooserDialog( title=_('Choose destination path'), action=Gtk.FileChooserAction.SELECT_FOLDER, select_multiple=False, create_folders=True, local_only=False, show_hidden=True) dialog.add_buttons(_('_Cancel'), Gtk.ResponseType.CANCEL, _('_Save'), Gtk.ResponseType.OK) dialog.set_default_response(Gtk.ResponseType.OK) # If there is an existing path in the entry field, # make that path the default destination_entry_text = self.destination_entry.get_text() if destination_entry_text != '': dialog.set_current_folder(destination_entry_text) # Show the dialog and get the selected path response = dialog.run() response_path = dialog.get_filename() # Close the dialog and save the selected path dialog.destroy() if response == Gtk.ResponseType.OK \ and response_path != destination_entry_text: self.destination_entry.set_text(response_path) def _export_pattern_changed(self, entry): """ Save the export pattern to the global config when the pattern changes. :param entry: The export pattern entry field. """ config.set(PM.CONFIG_SECTION, self.CONFIG_PATTERN_KEY, entry.get_text()) def _cdf_status(self, column, cell, model, iter_, data): """ Handle entering data into the "Status" column of the sync previews. """ cell.set_property('text', model[iter_][self._model_col_id('tag')]) def _cdf_source_path(self, column, cell, model, iter_, data): """ Handle entering data into the "File" column of the sync previews. """ cell.set_property('text', model[iter_][self._model_col_id('filename')]) def _cdf_export_path(self, column, cell, model, iter_, data): """ Handle entering data into the "Export" column of the sync previews. """ cell.set_property('text', model[iter_][self._model_col_id('export')]) def _row_edited(self, renderer, path, entered_path): """ Handle a manual edit of a previewed export path. :param renderer: The object which received the signal. :param path: The path identifying the edited cell. :param entered_path: The new path entered by the user. """ def _update_warnings(): """ Toggle the visibility of the status warning labels based on the song counts. """ if self.c_song_dupes == 0: self.status_duplicates.set_visible(False) else: self.status_duplicates.set_visible(True) if self.c_songs_delete == 0: self.status_deletions.set_visible(False) else: self.status_deletions.set_visible(True) def _make_duplicate(entry, old_unique): """ Mark the given entry as a duplicate. """ print_d(entry.filename) entry.tag = Entry.Tags.SKIP_DUPLICATE self.c_song_dupes += 1 if old_unique: self.c_songs_copy -= 1 _update_warnings() def _make_unique(entry, old_duplicate): """ Mark the given entry as a unique file. """ print_d(entry.filename) entry.tag = Entry.Tags.PENDING_COPY self.c_songs_copy += 1 if old_duplicate: self.c_song_dupes -= 1 _update_warnings() def _make_skip(entry, counter): """ Skip the given entry during synchronization. """ print_d(entry.filename) entry.tag = Entry.Tags.SKIP entry.export_path = '' return counter - 1 def _update_other_song(model, path, iter_, *data): """ Update a previewed path based on the current change. This is a callback function passed to Gtk.TreeModel.foreach() to iterate over the rows in a tree model. :return: True to stop iterating, False to continue. """ model_entry = model[path][self._model_col_id('entry')] if model_entry is entry \ or model_entry.tag == Entry.Tags.DELETE \ or model_entry.export_path == '': pass elif model_entry.export_path == entered_path \ and model_entry.tag == Entry.Tags.PENDING_COPY: _make_duplicate(model_entry, True) self._update_model_value(iter_, 'tag', model_entry.tag) elif model_entry.tag == Entry.Tags.SKIP_DUPLICATE \ and model_entry.export_path != entered_path \ and self._get_paths()[model_entry.export_path] == 1: _make_unique(model_entry, True) self._update_model_value(iter_, 'tag', model_entry.tag) return False path = Gtk.TreePath.new_from_string(path) entry = self.model[path][self._model_col_id('entry')] if entry.export_path != entered_path: old_path, new_path = {}, {} old_path['duplicate'] = entry.tag == Entry.Tags.SKIP_DUPLICATE old_path['delete'] = entry.tag == Entry.Tags.PENDING_DELETE old_path[ 'empty'] = not entry.export_path and not old_path['delete'] old_path['unique'] = not (old_path['duplicate'] or old_path['delete'] or old_path['empty']) old_path_inv = {} for key, value in old_path.items(): old_path_inv.setdefault(value, []).append(key) previewed_paths = self._get_paths().keys() new_path['duplicate'] = entered_path in previewed_paths new_path['delete'] = entered_path.lower() == Entry.Tags.DELETE new_path['empty'] = not entered_path and not new_path['delete'] new_path['unique'] = not (new_path['duplicate'] or new_path['delete'] or new_path['empty']) new_path_inv = {} for key, value in new_path.items(): new_path_inv.setdefault(value, []).append(key) print_d( _('Export path changed from [{old_path}] to [{new_path}] ' 'for file [{filename}]').format( filename=entry.filename, old_path=' '.join(old_path_inv[True]), new_path=' '.join(new_path_inv[True]))) # If the old path was empty... if old_path['empty'] and new_path['empty']: pass elif old_path['empty'] and new_path['delete']: try: Path(entry.filename).relative_to(self.expanded_destination) entry.tag = Entry.Tags.PENDING_DELETE self.c_songs_delete += 1 _update_warnings() except ValueError: pass elif old_path['empty'] and new_path['duplicate']: _make_duplicate(entry, False) entry.export_path = entered_path elif old_path['empty'] and new_path['unique']: _make_unique(entry, False) entry.export_path = entered_path # If the old path was a deletion... elif old_path['delete'] and new_path['empty']: pass elif old_path['delete'] and new_path['delete']: self.c_songs_delete = _make_skip(entry, self.c_songs_delete) _update_warnings() elif old_path['delete'] and new_path['duplicate']: pass elif old_path['delete'] and new_path['unique']: pass # If the old path was a duplicate... elif old_path['duplicate'] and new_path['empty']: self.c_song_dupes = _make_skip(entry, self.c_song_dupes) self.model.foreach(_update_other_song) _update_warnings() elif old_path['duplicate'] and new_path['delete']: self.c_song_dupes = _make_skip(entry, self.c_song_dupes) self.model.foreach(_update_other_song) _update_warnings() elif old_path['duplicate'] and new_path['duplicate']: entry.export_path = entered_path elif old_path['duplicate'] and new_path['unique']: _make_unique(entry, True) entry.export_path = entered_path self.model.foreach(_update_other_song) # If the old path was unique... elif old_path['unique'] and new_path['empty']: self.c_songs_copy = _make_skip(entry, self.c_songs_copy) self.model.foreach(_update_other_song) _update_warnings() elif old_path['unique'] and new_path['delete']: self.c_songs_copy = _make_skip(entry, self.c_songs_copy) self.model.foreach(_update_other_song) _update_warnings() elif old_path['unique'] and new_path['duplicate']: _make_duplicate(entry, True) entry.export_path = entered_path elif old_path['unique'] and new_path['unique']: entry.export_path = entered_path self.model.foreach(_update_other_song) # Update the model and the summary field self.model.set_row(self.model.get_iter(path), self._make_model_row(entry)) self._update_preview_summary() def _update_model_value(self, iter_, column, value): """ Set the data in a since cell of the ListStore model. :param iter_: A Gtk.TreeIter for the row being modified. :param column: The name of the column to modify. :param value: The new value for the cell. """ self.model.set_value(iter_, self._model_col_id(column), value) def _model_col_id(self, name): """ Get the column ID from the given name. :param name: The column name to search for. :raises: KeyError if a column with the given name does not exist. """ return self.model_cols[name][0] @staticmethod def _make_model_row(entry): """ Create a new row to insert into the ListStore model. :param entry: The Entry to insert. """ return [entry, entry.tag, entry.filename, entry.export_path] @staticmethod def _run_pending_events(): """ Prevent the application from becoming unresponsive. """ while Gtk.events_pending(): Gtk.main_iteration() def _start_preview(self, button): """ Start the generation of export paths for all songs. :param button: The start preview button. """ print_d(_('Starting synchronization preview')) self.running = True # Summary labels self.status_operation.set_label( _('Synchronization preview in progress.')) self.status_operation.set_visible(True) self.status_progress.set_visible(False) self.status_duplicates.set_visible(False) self.status_deletions.set_visible(False) # Change button visibility self.preview_start_button.set_visible(False) self.preview_stop_button.set_visible(True) self.c_songs_copy = self.c_song_dupes = self.c_songs_delete = 0 if self._run_preview() is None: return self._stop_preview() self.sync_start_button.set_sensitive(True) print_d(_('Finished synchronization preview')) def _stop_preview(self, button=None): """ Stop the generation of export paths for all songs. :param button: The stop preview button. """ if button: print_d(_('Stopping synchronization preview')) self.status_operation.set_label( _('Synchronization preview was stopped.')) else: self.status_operation.set_label( _('Synchronization preview has finished.')) self.status_operation.set_visible(True) self.running = False # Change button visibility self.preview_start_button.set_visible(True) self.preview_stop_button.set_visible(False) self._update_preview_summary() def _run_preview(self): """ Show the export paths for all songs to be synchronized. :return: Whether the generation of preview paths was successful. """ destination_path, pattern = self._get_valid_inputs() if None in {destination_path, pattern}: return False self.expanded_destination = os.path.expanduser(destination_path) # Get a list containing all songs to export songs = self._get_songs_from_queries() if not songs: return False self.model.clear() export_paths = [] for song in songs: if not self.running: print_d(_('Stopped synchronization preview')) return None self._run_pending_events() if not self.destination_entry.get_text(): print_d(_('A different plugin was selected - stop preview')) return False export_path = self._get_export_path(song, destination_path, pattern) if not export_path: return False entry = Entry(song, export_path) expanded_path = os.path.expanduser(export_path) if expanded_path in export_paths: entry.tag = Entry.Tags.SKIP_DUPLICATE self.c_song_dupes += 1 else: entry.tag = Entry.Tags.PENDING_COPY self.c_songs_copy += 1 export_paths.append(expanded_path) self.model.append(row=self._make_model_row(entry)) # List files to delete for root, __, files in os.walk(self.expanded_destination): for name in files: file_path = os.path.join(root, name) if file_path not in export_paths: entry = Entry(None) entry.filename = file_path entry.tag = Entry.Tags.PENDING_DELETE self.model.append(row=self._make_model_row(entry)) self.c_songs_delete += 1 return True def _update_preview_summary(self): """ Update the preview summary text field. """ prefix = _('Synchronization will:') + self.summary_sep preview_progress = [] if self.c_songs_copy > 0: counter = self.c_songs_copy preview_progress.append( _('attempt to write {count} {file_str}').format( count=counter, file_str=ngt('file', 'files', counter))) if self.c_song_dupes > 0: counter = self.c_song_dupes preview_progress.append( _('skip {count} duplicate {file_str}').format( count=counter, file_str=ngt('file', 'files', counter))) for child in self.status_duplicates.get_children(): child.set_visible(True) self.status_duplicates.set_visible(True) if self.c_songs_delete > 0: counter = self.c_songs_delete preview_progress.append( _('delete {count} {file_str}').format(count=counter, file_str=ngt( 'file', 'files', counter))) for child in self.status_deletions.get_children(): child.set_visible(True) self.status_deletions.set_visible(True) preview_progress_text = self.summary_sep_list.join(preview_progress) if preview_progress_text: preview_progress_text = prefix + preview_progress_text self.status_progress.set_label(preview_progress_text) self.status_progress.set_visible(True) print_d(preview_progress_text) def _get_paths(self): """ Build a list of all current export paths for the songs to be synchronized. """ paths = {} for row in self.model: entry = row[self._model_col_id('entry')] if entry.tag != Entry.Tags.PENDING_DELETE and entry.export_path: if entry.export_path not in paths.keys(): paths[entry.export_path] = 1 else: paths[entry.export_path] += 1 return paths def _show_sync_error(self, title, message): """ Show an error message whenever a synchronization error occurs. :param title: The title of the message popup. :param message: The error message. """ qltk.ErrorMessage(self.main_vbox, title, message).run() print_e(title) def _get_valid_inputs(self): """ Ensure that all user inputs have been given. Shows a popup error message if values are not as expected. :return: The entered destination path and an fsnative pattern, or None if an error occurred. """ # Get text from the destination path entry destination_path = self.destination_entry.get_text() if not destination_path: self._show_sync_error( _('No destination path provided'), _('Please specify the directory where songs ' 'should be exported.')) return None, None # Get text from the export pattern entry export_pattern = self.export_pattern_entry.get_text() if not export_pattern: self._show_sync_error( _('No export pattern provided'), _('Please specify an export pattern for the ' 'names of the exported songs.')) return None, None # Combine destination path and export pattern to form the full pattern full_export_path = os.path.join(destination_path, export_pattern) try: pattern = FileFromPattern(full_export_path) except ValueError: self._show_sync_error( _('Export path is not absolute'), _('The pattern\n\n<b>{}</b>\n\ncontains "/" but does not start ' 'from root. Please provide an absolute destination path by ' 'making sure it starts with / or ~/.').format( util.escape(full_export_path))) return None, None return destination_path, pattern def _get_songs_from_queries(self): """ Build a list of songs to be synchronized, filtered using the selected saved searches. :return: A list of the selected songs. """ enabled_queries = [] for query_name, query in self.queries.items(): query_config = self.CONFIG_QUERY_PREFIX + query_name if self.config_get_bool(query_config): enabled_queries.append(query) if not enabled_queries: self._show_sync_error( _('No saved searches selected'), _('Please select at least one saved search.')) return [] selected_songs = [] for song in app.library.itervalues(): if any(query.search(song) for query in enabled_queries): selected_songs.append(song) if not selected_songs: self._show_sync_error(_('No songs in the selected saved searches'), _('All selected saved searches are empty.')) return [] print_d(_('Found {} songs to synchronize').format(len(selected_songs))) return selected_songs def _get_export_path(self, song, destination_path, export_pattern): """ Use the given pattern of song tags to build the destination path for a song. :param song: The song for which to build the export path. :param destination_path: The user-entered destination path. :param export_pattern: An fsnative file path pattern. :return: A safe full destination path for the song. """ new_name = Path(export_pattern.format(song)) try: relative_name = new_name.relative_to(self.expanded_destination) except ValueError as ex: self._show_sync_error( _('Mismatch between destination path and export ' 'pattern'), _('The export pattern starts with a path that ' 'differs from the destination path. Please ' 'correct the pattern.\n\nError:\n{}').format(ex)) return None return os.path.join(destination_path, self._make_safe_name(relative_name)) def _make_safe_name(self, input_path): """ Make a file path safe by replacing unsafe characters. :param input_path: A relative Path. :return: The given path, with any unsafe characters replaced. Returned as a string. """ # Remove diacritics (accents) safe_filename = unicodedata.normalize('NFKD', str(input_path)) safe_filename = u''.join( [c for c in safe_filename if not unicodedata.combining(c)]) if os.name != "nt": # Ensure that Win32-incompatible chars are always removed. # On Windows, this is called during `FileFromPattern`. safe_filename = strip_win32_incompat_from_path(safe_filename) return safe_filename def _start_sync(self, button): """ Start the song synchronization. :param button: The start sync button. """ # Check sort column sort_columns = [ c.get_title() for c in self.details_tree.get_columns() if c.get_sort_indicator() ] if 'Status' in sort_columns: self._show_sync_error( _('Unable to sync'), _('Cannot start synchronization while ' 'sorting by <b>Status</b>.')) return print_d(_('Starting song synchronization')) self.running = True # Summary labels self.status_operation.set_label(_('Synchronization in progress.')) self.status_duplicates.set_visible(False) self.status_deletions.set_visible(False) # Change button visibility self.sync_start_button.set_visible(False) self.sync_stop_button.set_visible(True) if not self._run_sync(): return self._stop_sync() print_d(_('Finished song synchronization')) def _stop_sync(self, button=None): """ Stop the song synchronization. :param button: The stop sync button. """ if button: print_d(_('Stopping song synchronization')) self.status_operation.set_label(_('Synchronization was stopped.')) else: self.status_operation.set_label(_('Synchronization has finished.')) self.running = False # Change button visibility self.sync_start_button.set_visible(True) self.sync_stop_button.set_visible(False) def _run_sync(self): """ Synchronize the songs from the selected saved searches with the specified folder. :return: Whether the synchronization was successful. """ self.c_files_copy = self.c_files_skip = self.c_files_skip_previous \ = self.c_files_dupes = self.c_files_delete = self.c_files_failed = 0 self.model.foreach(self._sync_entry) if not self.running: return False self._remove_empty_dirs() return True def _sync_entry(self, model, path, iter_, *data): """ Synchronize a single song. This is a callback function passed to Gtk.TreeModel.foreach() to iterate over the rows in a tree model. :return: True to stop iterating, False to continue. """ entry = model[path][self._model_col_id('entry')] if not self.running: print_d(_('Stopped song synchronization')) return True self._run_pending_events() if not self.destination_entry.get_text(): print_d( _('A different plugin was selected - stop synchronization')) return True print_d( _('{tag} - "{filename}"').format(tag=entry.tag, filename=entry.filename)) if not entry.export_path and not entry.tag: return False if entry.tag == Entry.Tags.PENDING_COPY: # Export, skipping existing files expanded_path = os.path.expanduser(entry.export_path) if os.path.exists(expanded_path): entry.tag = Entry.Tags.RESULT_SKIP_EXISTING self._update_model_value(iter_, 'tag', entry.tag) self.c_files_skip += 1 else: entry.tag = Entry.Tags.IN_PROGRESS_SYNC self._update_model_value(iter_, 'tag', entry.tag) song_folders = os.path.dirname(expanded_path) os.makedirs(song_folders, exist_ok=True) try: shutil.copyfile(entry.filename, expanded_path) except Exception as ex: entry.tag = Entry.Tags.RESULT_FAILURE + ': ' + str(ex) self._update_model_value(iter_, 'tag', entry.tag) print_exc() self.c_files_failed += 1 else: entry.tag = Entry.Tags.RESULT_SUCCESS self._update_model_value(iter_, 'tag', entry.tag) self.c_files_copy += 1 elif entry.tag == Entry.Tags.SKIP_DUPLICATE: self.c_files_dupes += 1 elif entry.tag == Entry.Tags.PENDING_DELETE: # Delete file try: entry.tag = Entry.Tags.IN_PROGRESS_DELETE self._update_model_value(iter_, 'tag', entry.tag) os.remove(entry.filename) except Exception as ex: entry.tag = Entry.Tags.RESULT_FAILURE + ': ' + str(ex) self._update_model_value(iter_, 'tag', entry.tag) print_exc() self.c_files_failed += 1 else: entry.tag = Entry.Tags.RESULT_SUCCESS self._update_model_value(iter_, 'tag', entry.tag) self.c_files_delete += 1 else: self.c_files_skip_previous += 1 self._update_sync_summary() return False def _remove_empty_dirs(self): """ Delete all empty sub-directories from the given path. """ for root, dirs, __ in os.walk(self.expanded_destination, topdown=False): for dirname in dirs: dir_path = os.path.realpath(os.path.join(root, dirname)) if not os.listdir(dir_path): entry = Entry(None) entry.filename = dir_path entry.tag = Entry.Tags.IN_PROGRESS_DELETE iter_ = self.model.append(row=self._make_model_row(entry)) print_d(_('Removing "{}"').format(entry.filename)) self.c_songs_delete += 1 try: os.rmdir(dir_path) except Exception as ex: entry.tag = Entry.Tags.RESULT_FAILURE + ': ' + str(ex) self._update_model_value(iter_, 'tag', entry.tag) print_exc() self.c_files_failed += 1 else: entry.tag = Entry.Tags.RESULT_SUCCESS self._update_model_value(iter_, 'tag', entry.tag) self.c_files_delete += 1 self._update_sync_summary() def _update_sync_summary(self): """ Update the synchronization summary text field. """ sync_summary_prefix = _('Synchronization has:') + self.summary_sep sync_summary = [] if self.c_files_copy > 0 or self.c_files_skip > 0: text = [] counter = self.c_files_copy text.append( _('written {count}/{total} {file_str}').format( count=counter, total=self.c_songs_copy, file_str=ngt('file', 'files', counter))) if self.c_files_skip > 0: counter = self.c_files_skip text.append( _('(skipped {count} existing {file_str})').format( count=counter, file_str=ngt('file', 'files', counter))) sync_summary.append(self.summary_sep.join(text)) if self.c_files_dupes > 0: counter = self.c_files_dupes sync_summary.append( _('skipped {count}/{total} duplicate {file_str}').format( count=counter, total=self.c_song_dupes, file_str=ngt('file', 'files', counter))) if self.c_files_delete > 0: counter = self.c_files_delete sync_summary.append( _('deleted {count}/{total} {file_str}').format( count=counter, total=self.c_songs_delete, file_str=ngt('file', 'files', counter))) if self.c_files_failed > 0: counter = self.c_files_failed sync_summary.append( _('failed to sync {count} {file_str}').format( count=counter, file_str=ngt('file', 'files', counter))) if self.c_files_skip_previous > 0: counter = self.c_files_skip_previous sync_summary.append( _('skipped {count} {file_str} synchronized previously').format( count=counter, file_str=ngt('file', 'files', counter))) sync_summary_text = self.summary_sep_list.join(sync_summary) sync_summary_text = sync_summary_prefix + sync_summary_text self.status_progress.set_label(sync_summary_text) print_d(sync_summary_text)