diff --git a/beets/dbcore/db.py b/beets/dbcore/db.py index d54e3a0d3..202cf006f 100644 --- a/beets/dbcore/db.py +++ b/beets/dbcore/db.py @@ -467,6 +467,11 @@ class Model(object): return cls._type(key).parse(string) + def set_parse(self, key, string): + """Set the object's key to a value represented by a string. + """ + self[key] = self._parse(key, string) + # Database controller and supporting interfaces. diff --git a/beetsplug/edit.py b/beetsplug/edit.py new file mode 100644 index 000000000..8d6b168f5 --- /dev/null +++ b/beetsplug/edit.py @@ -0,0 +1,326 @@ +# This file is part of beets. +# Copyright 2015 +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +"""Open metadata information in a text editor to let the user edit it. +""" +from __future__ import (division, absolute_import, print_function, + unicode_literals) + +from beets import plugins +from beets import util +from beets import ui +from beets.dbcore import types +from beets.ui.commands import _do_query +import subprocess +import yaml +from tempfile import NamedTemporaryFile +import os + + +# These "safe" types can avoid the format/parse cycle that most fields go +# through: they are safe to edit with native YAML types. +SAFE_TYPES = (types.Float, types.Integer, types.Boolean) + + +class ParseError(Exception): + """The modified file is unreadable. The user should be offered a chance to + fix the error. + """ + + +def edit(filename): + """Open `filename` in a text editor. + """ + cmd = util.shlex_split(util.editor_command()) + cmd.append(filename) + subprocess.call(cmd) + + +def dump(arg): + """Dump a sequence of dictionaries as YAML for editing. + """ + return yaml.safe_dump_all( + arg, + allow_unicode=True, + default_flow_style=False, + ) + + +def load(s): + """Read a sequence of YAML documents back to a list of dictionaries + with string keys. + + Can raise a `ParseError`. + """ + try: + out = [] + for d in yaml.load_all(s): + if not isinstance(d, dict): + raise ParseError( + 'each entry must be a dictionary; found {}'.format( + type(d).__name__ + ) + ) + + # Convert all keys to strings. They started out as strings, + # but the user may have inadvertently messed this up. + out.append({unicode(k): v for k, v in d.items()}) + + except yaml.YAMLError as e: + raise ParseError('invalid YAML: {}'.format(e)) + return out + + +def _safe_value(obj, key, value): + """Check whether the `value` is safe to represent in YAML and trust as + returned from parsed YAML. + + This ensures that values do not change their type when the user edits their + YAML representation. + """ + typ = obj._type(key) + return isinstance(typ, SAFE_TYPES) and isinstance(value, typ.model_type) + + +def flatten(obj, fields): + """Represent `obj`, a `dbcore.Model` object, as a dictionary for + serialization. Only include the given `fields` if provided; + otherwise, include everything. + + The resulting dictionary's keys are strings and the values are + safely YAML-serializable types. + """ + # Format each value. + d = {} + for key in obj.keys(): + value = obj[key] + if _safe_value(obj, key, value): + # A safe value that is faithfully representable in YAML. + d[key] = value + else: + # A value that should be edited as a string. + d[key] = obj.formatted()[key] + + # Possibly filter field names. + if fields: + return {k: v for k, v in d.items() if k in fields} + else: + return d + + +def apply(obj, data): + """Set the fields of a `dbcore.Model` object according to a + dictionary. + + This is the opposite of `flatten`. The `data` dictionary should have + strings as values. + """ + for key, value in data.items(): + if _safe_value(obj, key, value): + # A safe value *stayed* represented as a safe type. Assign it + # directly. + obj[key] = value + else: + # Either the field was stringified originally or the user changed + # it from a safe type to an unsafe one. Parse it as a string. + obj.set_parse(key, unicode(value)) + + +class EditPlugin(plugins.BeetsPlugin): + + def __init__(self): + super(EditPlugin, self).__init__() + + self.config.add({ + # The default fields to edit. + 'albumfields': 'album albumartist', + 'itemfields': 'track title artist album', + + # Silently ignore any changes to these fields. + 'ignore_fields': 'id path', + }) + + def commands(self): + edit_command = ui.Subcommand( + 'edit', + help='interactively edit metadata' + ) + edit_command.parser.add_option( + '-f', '--field', + metavar='FIELD', + action='append', + help='edit this field also', + ) + edit_command.parser.add_option( + '--all', + action='store_true', dest='all', + help='edit all fields', + ) + edit_command.parser.add_album_option() + edit_command.func = self._edit_command + return [edit_command] + + def _edit_command(self, lib, opts, args): + """The CLI command function for the `beet edit` command. + """ + # Get the objects to edit. + query = ui.decargs(args) + items, albums = _do_query(lib, query, opts.album, False) + objs = albums if opts.album else items + if not objs: + ui.print_('Nothing to edit.') + return + + # Get the fields to edit. + if opts.all: + fields = None + else: + fields = self._get_fields(opts.album, opts.field) + self.edit(opts.album, objs, fields) + + def _get_fields(self, album, extra): + """Get the set of fields to edit. + """ + # Start with the configured base fields. + if album: + fields = self.config['albumfields'].as_str_seq() + else: + fields = self.config['itemfields'].as_str_seq() + + # Add the requested extra fields. + if extra: + fields += extra + + # Ensure we always have the `id` field for identification. + fields.append('id') + + return set(fields) + + def edit(self, album, objs, fields): + """The core editor function. + + - `album`: A flag indicating whether we're editing Items or Albums. + - `objs`: The `Item`s or `Album`s to edit. + - `fields`: The set of field names to edit (or None to edit + everything). + """ + # Present the YAML to the user and let her change it. + success = self.edit_objects(objs, fields) + + # Save the new data. + if success: + self.save_write(objs) + + def edit_objects(self, objs, fields): + """Dump a set of Model objects to a file as text, ask the user + to edit it, and apply any changes to the objects. + + Return a boolean indicating whether the edit succeeded. + """ + # Get the content to edit as raw data structures. + old_data = [flatten(o, fields) for o in objs] + + # Set up a temporary file with the initial data for editing. + new = NamedTemporaryFile(suffix='.yaml', delete=False) + old_str = dump(old_data) + new.write(old_str) + new.close() + + # Loop until we have parseable data and the user confirms. + try: + while True: + # Ask the user to edit the data. + edit(new.name) + + # Read the data back after editing and check whether anything + # changed. + with open(new.name) as f: + new_str = f.read() + if new_str == old_str: + ui.print_("No changes; aborting.") + return False + + # Parse the updated data. + try: + new_data = load(new_str) + except ParseError as e: + ui.print_("Could not read data: {}".format(e)) + if ui.input_yn("Edit again to fix? (Y/n)", True): + continue + else: + return False + + # Show the changes. + self.apply_data(objs, old_data, new_data) + changed = False + for obj in objs: + changed |= ui.show_model_changes(obj) + if not changed: + ui.print_('No changes to apply.') + return False + + # Confirm the changes. + choice = ui.input_options( + ('continue Editing', 'apply', 'cancel') + ) + if choice == 'a': # Apply. + return True + elif choice == 'c': # Cancel. + return False + elif choice == 'e': # Keep editing. + # Reset the temporary changes to the objects. + for obj in objs: + obj.read() + continue + + # Remove the temporary file before returning. + finally: + os.remove(new.name) + + def apply_data(self, objs, old_data, new_data): + """Take potentially-updated data and apply it to a set of Model + objects. + + The objects are not written back to the database, so the changes + are temporary. + """ + if len(old_data) != len(new_data): + self._log.warn('number of objects changed from {} to {}', + len(old_data), len(new_data)) + + obj_by_id = {o.id: o for o in objs} + ignore_fields = self.config['ignore_fields'].as_str_seq() + for old_dict, new_dict in zip(old_data, new_data): + # Prohibit any changes to forbidden fields to avoid + # clobbering `id` and such by mistake. + forbidden = False + for key in ignore_fields: + if old_dict.get(key) != new_dict.get(key): + self._log.warn('ignoring object whose {} changed', key) + forbidden = True + break + if forbidden: + continue + + id = int(old_dict['id']) + apply(obj_by_id[id], new_dict) + + def save_write(self, objs): + """Save a list of updated Model objects to the database. + """ + # Save to the database and possibly write tags. + for ob in objs: + if ob._dirty: + self._log.debug('saving changes to {}', ob) + ob.try_sync(ui.should_write()) diff --git a/docs/changelog.rst b/docs/changelog.rst index 2df44a999..305157bf0 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -3,6 +3,10 @@ Changelog 1.3.16 (in development) ----------------------- +* A new plugin edit helps you manually edit fields from items. + You search for items in the normal beets way.Then edit opens a texteditor + with the items and the fields of the items you want to edit. Afterwards you can + review your changes save them back into the items. New: diff --git a/docs/plugins/edit.rst b/docs/plugins/edit.rst new file mode 100644 index 000000000..507d56950 --- /dev/null +++ b/docs/plugins/edit.rst @@ -0,0 +1,36 @@ +Edit Plugin +=========== + +The ``edit`` plugin lets you modify music metadata using your favorite text +editor. + +Enable the ``edit`` plugin in your configuration (see :ref:`using-plugins`) and +then type:: + + beet edit QUERY + +Your text editor (i.e., the command in your ``$EDITOR`` environment variable) +will open with a list of tracks to edit. Make your changes and exit your text +editor to apply them to your music. + +Command-Line Options +-------------------- + +The ``edit`` command has these command-line options: + +- ``-a`` or ``--album``: Edit albums instead of individual items. +- ``-f FIELD`` or ``--field FIELD``: Specify an additional field to edit + (in addition to the defaults set in the configuration). +- ``--all``: Edit *all* available fields. + +Configuration +------------- + +To configure the plugin, make an ``edit:`` section in your configuration +file. The available options are: + +- **itemfields**: A space-separated list of item fields to include in the + editor by default. + Default: ``track title artist album`` +- **albumfields**: The same when editing albums (with the ``-a`` option). + Default: ``album albumartist`` diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index d09139837..5da5ee0be 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -40,6 +40,7 @@ Each plugin has its own set of options that can be defined in a section bearing discogs duplicates echonest + edit embedart embyupdate fetchart @@ -96,6 +97,7 @@ Metadata * :doc:`bpm`: Measure tempo using keystrokes. * :doc:`echonest`: Automatically fetch `acoustic attributes`_ from `the Echo Nest`_ (tempo, energy, danceability, ...). +* :doc:`edit`: Edit metadata from a texteditor. * :doc:`embedart`: Embed album art images into files' metadata. * :doc:`fetchart`: Fetch album cover art from various sources. * :doc:`ftintitle`: Move "featured" artists from the artist field to the title diff --git a/test/test_edit.py b/test/test_edit.py new file mode 100644 index 000000000..ae0500029 --- /dev/null +++ b/test/test_edit.py @@ -0,0 +1,260 @@ +# This file is part of beets. +# Copyright 2015, Adrian Sampson and Diego Moreda. +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +from __future__ import (division, absolute_import, print_function, + unicode_literals) +import codecs + +from mock import patch +from test._common import unittest +from test.helper import TestHelper, control_stdin + + +class ModifyFileMocker(object): + """Helper for modifying a file, replacing or editing its contents. Used for + mocking the calls to the external editor during testing.""" + + def __init__(self, contents=None, replacements=None): + """ `self.contents` and `self.replacements` are initalized here, in + order to keep the rest of the functions of this class with the same + signature as `EditPlugin.get_editor()`, making mocking easier. + - `contents`: string with the contents of the file to be used for + `overwrite_contents()` + - `replacement`: dict with the in-place replacements to be used for + `replace_contents()`, in the form {'previous string': 'new string'} + + TODO: check if it can be solved more elegantly with a decorator + """ + self.contents = contents + self.replacements = replacements + self.action = self.overwrite_contents + if replacements: + self.action = self.replace_contents + + def overwrite_contents(self, filename): + """Modify `filename`, replacing its contents with `self.contents`. If + `self.contents` is empty, the file remains unchanged. + """ + if self.contents: + with codecs.open(filename, 'w', encoding='utf8') as f: + f.write(self.contents) + + def replace_contents(self, filename): + """Modify `filename`, reading its contents and replacing the strings + specified in `self.replacements`. + """ + with codecs.open(filename, 'r', encoding='utf8') as f: + contents = f.read() + for old, new_ in self.replacements.iteritems(): + contents = contents.replace(old, new_) + with codecs.open(filename, 'w', encoding='utf8') as f: + f.write(contents) + + +class EditCommandTest(unittest.TestCase, TestHelper): + """ Black box tests for `beetsplug.edit`. Command line interaction is + simulated using `test.helper.control_stdin()`, and yaml editing via an + external editor is simulated using `ModifyFileMocker`. + """ + ALBUM_COUNT = 1 + TRACK_COUNT = 10 + + def setUp(self): + self.setup_beets() + self.load_plugins('edit') + # make sure that we avoid invoking the editor except for making changes + self.config['edit']['diff_method'] = '' + # add an album, storing the original fields for comparison + self.album = self.add_album_fixture(track_count=self.TRACK_COUNT) + self.album_orig = {f: self.album[f] for f in self.album._fields} + self.items_orig = [{f: item[f] for f in item._fields} for + item in self.album.items()] + + # keep track of write()s + self.write_patcher = patch('beets.library.Item.write') + self.mock_write = self.write_patcher.start() + + def tearDown(self): + self.write_patcher.stop() + self.teardown_beets() + self.unload_plugins() + + def run_mocked_command(self, modify_file_args={}, stdin=[], args=[]): + """Run the edit command, with mocked stdin and yaml writing, and + passing `args` to `run_command`.""" + m = ModifyFileMocker(**modify_file_args) + with patch('beetsplug.edit.edit', side_effect=m.action): + with control_stdin('\n'.join(stdin)): + self.run_command('edit', *args) + + def assertCounts(self, album_count=ALBUM_COUNT, track_count=TRACK_COUNT, + write_call_count=TRACK_COUNT, title_starts_with=''): + """Several common assertions on Album, Track and call counts.""" + self.assertEqual(len(self.lib.albums()), album_count) + self.assertEqual(len(self.lib.items()), track_count) + self.assertEqual(self.mock_write.call_count, write_call_count) + self.assertTrue(all(i.title.startswith(title_starts_with) + for i in self.lib.items())) + + def assertItemFieldsModified(self, library_items, items, fields=[]): + """Assert that items in the library (`lib_items`) have different values + on the specified `fields` (and *only* on those fields), compared to + `items`. + An empty `fields` list results in asserting that no modifications have + been performed. + """ + changed_fields = [] + for lib_item, item in zip(library_items, items): + changed_fields.append([field for field in lib_item._fields + if lib_item[field] != item[field]]) + self.assertTrue(all(diff_fields == fields for diff_fields in + changed_fields)) + + def test_title_edit_discard(self): + """Edit title for all items in the library, then discard changes-""" + # edit titles + self.run_mocked_command({'replacements': {u't\u00eftle': + u'modified t\u00eftle'}}, + # Cancel. + ['c']) + + self.assertCounts(write_call_count=0, + title_starts_with=u't\u00eftle') + self.assertItemFieldsModified(self.album.items(), self.items_orig, []) + + def test_title_edit_apply(self): + """Edit title for all items in the library, then apply changes.""" + # edit titles + self.run_mocked_command({'replacements': {u't\u00eftle': + u'modified t\u00eftle'}}, + # Apply changes. + ['a']) + + self.assertCounts(write_call_count=self.TRACK_COUNT, + title_starts_with=u'modified t\u00eftle') + self.assertItemFieldsModified(self.album.items(), self.items_orig, + ['title']) + + def test_single_title_edit_apply(self): + """Edit title for one item in the library, then apply changes.""" + # edit title + self.run_mocked_command({'replacements': {u't\u00eftle 9': + u'modified t\u00eftle 9'}}, + # Apply changes. + ['a']) + + self.assertCounts(write_call_count=1,) + # no changes except on last item + self.assertItemFieldsModified(list(self.album.items())[:-1], + self.items_orig[:-1], []) + self.assertEqual(list(self.album.items())[-1].title, + u'modified t\u00eftle 9') + + def test_noedit(self): + """Do not edit anything.""" + # do not edit anything + self.run_mocked_command({'contents': None}, + # no stdin + []) + + self.assertCounts(write_call_count=0, + title_starts_with=u't\u00eftle') + self.assertItemFieldsModified(self.album.items(), self.items_orig, []) + + def test_album_edit_apply(self): + """Edit the album field for all items in the library, apply changes. + By design, the album should not be updated."" + """ + # edit album + self.run_mocked_command({'replacements': {u'\u00e4lbum': + u'modified \u00e4lbum'}}, + # Apply changes. + ['a']) + + self.assertCounts(write_call_count=self.TRACK_COUNT) + self.assertItemFieldsModified(self.album.items(), self.items_orig, + ['album']) + # ensure album is *not* modified + self.album.load() + self.assertEqual(self.album.album, u'\u00e4lbum') + + def test_single_edit_add_field(self): + """Edit the yaml file appending an extra field to the first item, then + apply changes.""" + # append "foo: bar" to item with id == 1 + self.run_mocked_command({'replacements': {u"id: 1": + u"id: 1\nfoo: bar"}}, + # Apply changes. + ['a']) + + self.assertEqual(self.lib.items('id:1')[0].foo, 'bar') + self.assertCounts(write_call_count=1, + title_starts_with=u't\u00eftle') + + def test_a_album_edit_apply(self): + """Album query (-a), edit album field, apply changes.""" + self.run_mocked_command({'replacements': {u'\u00e4lbum': + u'modified \u00e4lbum'}}, + # Apply changes. + ['a'], + args=['-a']) + + self.album.load() + self.assertCounts(write_call_count=self.TRACK_COUNT) + self.assertEqual(self.album.album, u'modified \u00e4lbum') + self.assertItemFieldsModified(self.album.items(), self.items_orig, + ['album']) + + def test_a_albumartist_edit_apply(self): + """Album query (-a), edit albumartist field, apply changes.""" + self.run_mocked_command({'replacements': {u'album artist': + u'modified album artist'}}, + # Apply changes. + ['a'], + args=['-a']) + + self.album.load() + self.assertCounts(write_call_count=self.TRACK_COUNT) + self.assertEqual(self.album.albumartist, u'the modified album artist') + self.assertItemFieldsModified(self.album.items(), self.items_orig, + ['albumartist']) + + def test_malformed_yaml(self): + """Edit the yaml file incorrectly (resulting in a malformed yaml + document).""" + # edit the yaml file to an invalid file + self.run_mocked_command({'contents': '!MALFORMED'}, + # Edit again to fix? No. + ['n']) + + self.assertCounts(write_call_count=0, + title_starts_with=u't\u00eftle') + + def test_invalid_yaml(self): + """Edit the yaml file incorrectly (resulting in a well-formed but + invalid yaml document).""" + # edit the yaml file to an invalid file + self.run_mocked_command({'contents': 'wellformed: yes, but invalid'}, + # no stdin + []) + + self.assertCounts(write_call_count=0, + title_starts_with=u't\u00eftle') + + +def suite(): + return unittest.TestLoader().loadTestsFromName(__name__) + +if __name__ == b'__main__': + unittest.main(defaultTest='suite')