diff --git a/beets/config_default.yaml b/beets/config_default.yaml index b7e6b1e2b..439a93f55 100644 --- a/beets/config_default.yaml +++ b/beets/config_default.yaml @@ -25,7 +25,8 @@ import: pretend: false search_ids: [] duplicate_action: ask - bell: no + bell: no + set_fields: {} clutter: ["Thumbs.DB", ".DS_Store"] ignore: [".*", "*~", "System Volume Information", "lost+found"] diff --git a/beets/importer.py b/beets/importer.py index ce9ebc01c..d36a5a0a0 100644 --- a/beets/importer.py +++ b/beets/importer.py @@ -419,7 +419,7 @@ class ImportTask(BaseImportTask): from the `candidates` list. * `find_duplicates()` Returns a list of albums from `lib` with the - same artist and album name as the task. + same artist and album name as the task. * `apply_metadata()` Sets the attributes of the items from the task's `match` attribute. @@ -429,6 +429,9 @@ class ImportTask(BaseImportTask): * `manipulate_files()` Copy, move, and write files depending on the session configuration. + * `set_fields()` Sets the fields given at CLI or configuration to + the specified values. + * `finalize()` Update the import progress and cleanup the file system. """ @@ -530,6 +533,19 @@ class ImportTask(BaseImportTask): util.prune_dirs(os.path.dirname(item.path), lib.directory) + def set_fields(self): + """Sets the fields given at CLI or configuration to the specified + values. + """ + for field, view in config['import']['set_fields'].items(): + value = view.get() + log.debug(u'Set field {1}={2} for {0}', + displayable_path(self.paths), + field, + value) + self.album[field] = value + self.album.store() + def finalize(self, session): """Save progress, clean up files, and emit plugin event. """ @@ -877,6 +893,19 @@ class SingletonImportTask(ImportTask): def reload(self): self.item.load() + def set_fields(self): + """Sets the fields given at CLI or configuration to the specified + values. + """ + for field, view in config['import']['set_fields'].items(): + value = view.get() + log.debug(u'Set field {1}={2} for {0}', + displayable_path(self.paths), + field, + value) + self.item[field] = value + self.item.store() + # FIXME The inheritance relationships are inverted. This is why there # are so many methods which pass. More responsibility should be delegated to @@ -1385,6 +1414,14 @@ def apply_choice(session, task): task.add(session.lib) + # If ``set_fields`` is set, set those fields to the + # configured values. + # NOTE: This cannot be done before the ``task.add()`` call above, + # because then the ``ImportTask`` won't have an `album` for which + # it can set the fields. + if config['import']['set_fields']: + task.set_fields() + @pipeline.mutator_stage def plugin_stage(session, func, task): diff --git a/beets/ui/__init__.py b/beets/ui/__init__.py index 60652fa08..d014cc5d0 100644 --- a/beets/ui/__init__.py +++ b/beets/ui/__init__.py @@ -769,6 +769,34 @@ def show_path_changes(path_changes): pad = max_width - len(source) log.info(u'{0} {1} -> {2}', source, ' ' * pad, dest) +# Helper functions for option parsing. + + +def _store_dict(option, opt_str, value, parser): + """Custom action callback to parse options which have ``key=value`` + pairs as values. All such pairs passed for this option are + aggregated into a dictionary. + """ + dest = option.dest + option_values = getattr(parser.values, dest, None) + + if option_values is None: + # This is the first supplied ``key=value`` pair of option. + # Initialize empty dictionary and get a reference to it. + setattr(parser.values, dest, dict()) + option_values = getattr(parser.values, dest) + + try: + key, value = map(lambda s: util.text_string(s), value.split('=')) + if not (key and value): + raise ValueError + except ValueError: + raise UserError( + "supplied argument `{0}' is not of the form `key=value'" + .format(value)) + + option_values[key] = value + class CommonOptionsParser(optparse.OptionParser, object): """Offers a simple way to add common formatting options. diff --git a/beets/ui/commands.py b/beets/ui/commands.py index 96cd08b6b..4171ae3b1 100644 --- a/beets/ui/commands.py +++ b/beets/ui/commands.py @@ -40,6 +40,7 @@ from beets import config from beets import logging from beets.util.confit import _package_path import six +from . import _store_dict VARIOUS_ARTISTS = u'Various Artists' PromptChoice = namedtuple('PromptChoice', ['short', 'long', 'callback']) @@ -1017,6 +1018,12 @@ import_cmd.parser.add_option( metavar='ID', help=u'restrict matching to a specific metadata backend ID' ) +import_cmd.parser.add_option( + u'--set', dest='set_fields', action='callback', + callback=_store_dict, + metavar='FIELD=VALUE', + help=u'set the given fields to the supplied values' +) import_cmd.func = import_func default_commands.append(import_cmd) diff --git a/docs/changelog.rst b/docs/changelog.rst index d50c25800..ff5f7330f 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -88,6 +88,14 @@ Here's a full list of new features: Thanks to :user:`jansol`. :bug:`2488` :bug:`2524` +* A new field, ``composer_sort``, is now supported and fetched from + MusicBrainz. + Thanks to :user:`dosoe`. + :bug:`2519` :bug:`2529` +* It is now possible to set fields to certain values during import, using + either the `importer.set_fields` dictionary in the config file, or by + passing one or more `--set field=value` options on the command-line. + :bug: `1881` There are also quite a few fixes: diff --git a/docs/reference/cli.rst b/docs/reference/cli.rst index e830d1482..c5ea5b349 100644 --- a/docs/reference/cli.rst +++ b/docs/reference/cli.rst @@ -138,6 +138,16 @@ Optional command flags: searching for other candidates by using the ``--search-id SEARCH_ID`` option. Multiple IDs can be specified by simply repeating the option several times. +* You can supply ``--set`` options with ``field=value`` pairs to assign to + those fields the specified values on import, in addition to such field/value + pairs defined in the ``importer.set_fields`` dictionary in the configuration + file. Make sure to use an option per field/value pair, like so:: + + beet import --set genre="Alternative Rock" --set mood="emotional" + + Note that values for the fields specified on the command-line override the + ones defined for those fields in the configuration file. + .. _rarfile: https://pypi.python.org/pypi/rarfile/2.2 .. only:: html diff --git a/docs/reference/config.rst b/docs/reference/config.rst index 82c11238a..bcd30169b 100644 --- a/docs/reference/config.rst +++ b/docs/reference/config.rst @@ -586,6 +586,26 @@ Ring the terminal bell to get your attention when the importer needs your input. Default: ``no``. +.. _set_fields: + +set_fields +~~~~~~~~~~ + +A dictionary of field/value pairs, each one used to set a field to the +corresponding value during import. + +Example: :: + + set_fields: + genre: 'To Listen' + collection: 'Unordered' + +Note that field/value pairs supplied via ``--set`` options on the +command-line are processed in addition to those specified here. Those values +override the ones defined here in the case of fields with the same name. + +Default: ``{}`` (empty). + .. _musicbrainz-config: MusicBrainz Options diff --git a/test/test_importer.py b/test/test_importer.py index 26dec3de8..1e0dd1fb9 100644 --- a/test/test_importer.py +++ b/test/test_importer.py @@ -543,6 +543,38 @@ class ImportSingletonTest(_common.TestCase, ImportHelper): self.assertEqual(len(self.lib.items()), 2) self.assertEqual(len(self.lib.albums()), 2) + def test_set_fields(self): + genre = u"\U0001F3B7 Jazz" + collection = u"To Listen" + + config['import']['set_fields'] = { + u'collection': collection, + u'genre': genre + } + + # As-is item import. + self.assertEqual(self.lib.albums().get(), None) + self.importer.add_choice(importer.action.ASIS) + self.importer.run() + + for item in self.lib.items(): + item.load() # TODO: Not sure this is necessary. + self.assertEqual(item.genre, genre) + self.assertEqual(item.collection, collection) + # Remove item from library to test again with APPLY choice. + item.remove() + + # Autotagged. + self.assertEqual(self.lib.albums().get(), None) + self.importer.clear_choices() + self.importer.add_choice(importer.action.APPLY) + self.importer.run() + + for item in self.lib.items(): + item.load() + self.assertEqual(item.genre, genre) + self.assertEqual(item.collection, collection) + class ImportTest(_common.TestCase, ImportHelper): """Test APPLY, ASIS and SKIP choices. @@ -672,6 +704,38 @@ class ImportTest(_common.TestCase, ImportHelper): with self.assertRaises(AttributeError): self.lib.items().get().data_source + def test_set_fields(self): + genre = u"\U0001F3B7 Jazz" + collection = u"To Listen" + + config['import']['set_fields'] = { + u'collection': collection, + u'genre': genre + } + + # As-is album import. + self.assertEqual(self.lib.albums().get(), None) + self.importer.add_choice(importer.action.ASIS) + self.importer.run() + + for album in self.lib.albums(): + album.load() # TODO: Not sure this is necessary. + self.assertEqual(album.genre, genre) + self.assertEqual(album.collection, collection) + # Remove album from library to test again with APPLY choice. + album.remove() + + # Autotagged. + self.assertEqual(self.lib.albums().get(), None) + self.importer.clear_choices() + self.importer.add_choice(importer.action.APPLY) + self.importer.run() + + for album in self.lib.albums(): + album.load() + self.assertEqual(album.genre, genre) + self.assertEqual(album.collection, collection) + class ImportTracksTest(_common.TestCase, ImportHelper): """Test TRACKS and APPLY choice.