Repository: beetbox/beets

Issue:
Formatted modify
There are a number of cool things we could do if the `modify` command supported templates in the value part. For example, you could put the Echo Nest liveness into the comments field like so:

```
$ beet mod comments='liveness: $liveness'
```

It might also be nice to expose these rewritings in an import hook also.


Discussion:
sampsyo: Awesome!! Thanks for putting this together! This looks really good at first glance; I will give it a thorough review soon.

Patch:
diff --git a/beets/importer.py b/beets/importer.py
index 14478b43d..2a7508c3c 100644
--- a/beets/importer.py
+++ b/beets/importer.py
@@ -584,9 +584,9 @@ class ImportTask(BaseImportTask):
                       displayable_path(self.paths),
                       field,
                       value)
-            self.album[field] = value
+            self.album.set_parse(field, format(self.album, value))
             for item in items:
-                item[field] = value
+                item.set_parse(field, format(item, value))
         with lib.transaction():
             for item in items:
                 item.store()
@@ -963,7 +963,7 @@ class SingletonImportTask(ImportTask):
                       displayable_path(self.paths),
                       field,
                       value)
-            self.item[field] = value
+            self.item.set_parse(field, format(self.item, value))
         self.item.store()
 
 
diff --git a/beets/ui/commands.py b/beets/ui/commands.py
index cbdd8e3bf..91cee4516 100755
--- a/beets/ui/commands.py
+++ b/beets/ui/commands.py
@@ -33,7 +33,7 @@ from beets import plugins
 from beets import importer
 from beets import util
 from beets.util import syspath, normpath, ancestry, displayable_path, \
-    MoveOperation
+    MoveOperation, functemplate
 from beets import library
 from beets import config
 from beets import logging
@@ -1477,9 +1477,6 @@ def modify_items(lib, mods, dels, query, write, move, album, confirm):
     # Parse key=value specifications into a dictionary.
     model_cls = library.Album if album else library.Item
 
-    for key, value in mods.items():
-        mods[key] = model_cls._parse(key, value)
-
     # Get the items to modify.
     items, albums = _do_query(lib, query, album, False)
     objs = albums if album else items
@@ -1489,8 +1486,14 @@ def modify_items(lib, mods, dels, query, write, move, album, confirm):
     print_('Modifying {} {}s.'
            .format(len(objs), 'album' if album else 'item'))
     changed = []
+    templates = {key: functemplate.template(value)
+                 for key, value in mods.items()}
     for obj in objs:
-        if print_and_modify(obj, mods, dels) and obj not in changed:
+        obj_mods = {
+            key: model_cls._parse(key, obj.evaluate_template(templates[key]))
+            for key in mods.keys()
+        }
+        if print_and_modify(obj, obj_mods, dels) and obj not in changed:
             changed.append(obj)
 
     # Still something to do?
diff --git a/docs/changelog.rst b/docs/changelog.rst
index 9363ee250..1b4669fae 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -135,6 +135,11 @@ Major new features:
 * An accompanying new :doc:`/plugins/albumtypes` includes some options for
   formatting this new ``albumtypes`` field.
   Thanks to :user:`edgars-supe`.
+* The :ref:`modify-cmd` and :ref:`import-cmd` can now use
+  :doc:`/reference/pathformat` formats when setting fields.
+  For example, you can now do ``beet modify title='$track $title'`` to put
+  track numbers into songs' titles.
+  :bug:`488`
 
 Other new things:
 
diff --git a/docs/reference/cli.rst b/docs/reference/cli.rst
index 3726fb373..da119d0f8 100644
--- a/docs/reference/cli.rst
+++ b/docs/reference/cli.rst
@@ -147,6 +147,8 @@ Optional command flags:
   Multiple IDs can be specified by simply repeating the option several times.
 
 * You can supply ``--set field=value`` to assign `field` to `value` on import.
+  Values support the same template syntax as beets'
+  :doc:`path formats <pathformat>`.
   These assignments will merge with (and possibly override) the
   :ref:`set_fields` configuration dictionary. You can use the option multiple
   times on the command line, like so::
@@ -265,6 +267,13 @@ artist="Tom Tom Club"`` will change the artist for the track "Genius of Love."
 To remove fields (which is only possible for flexible attributes), follow a
 field name with an exclamation point: ``field!``.
 
+Values can also be *templates*, using the same syntax as
+:doc:`path formats <pathformat>`.
+For example, ``beet modify artist='$artist_sort'`` will copy the artist sort
+name into the artist field for all your tracks,
+and ``beet modify title='$track $title'`` will add track numbers to their
+title metadata.
+
 The ``-a`` switch also operates on albums in addition to the individual tracks.
 Without this flag, the command will only change *track-level* data, even if all
 the tracks belong to the same album. If you want to change an *album-level*
diff --git a/docs/reference/config.rst b/docs/reference/config.rst
index 26a07e643..2afb6f0bc 100644
--- a/docs/reference/config.rst
+++ b/docs/reference/config.rst
@@ -725,6 +725,9 @@ Here's an example::
 Other field/value pairs supplied via the ``--set`` option on the command-line
 override any settings here for fields with the same name.
 
+Values support the same template syntax as beets'
+:doc:`path formats <pathformat>`.
+
 Fields are set on both the album and each individual track of the album.
 Fields are persisted to the media files of each track.
 


Test patch:
diff --git a/test/test_importer.py b/test/test_importer.py
index 2bd95315f..784c14c07 100644
--- a/test/test_importer.py
+++ b/test/test_importer.py
@@ -554,7 +554,8 @@ class ImportSingletonTest(_common.TestCase, ImportHelper):
 
         config['import']['set_fields'] = {
             'collection': collection,
-            'genre': genre
+            'genre': genre,
+            'title': "$title - formatted",
         }
 
         # As-is item import.
@@ -566,6 +567,7 @@ class ImportSingletonTest(_common.TestCase, ImportHelper):
             item.load()  # TODO: Not sure this is necessary.
             self.assertEqual(item.genre, genre)
             self.assertEqual(item.collection, collection)
+            self.assertEqual(item.title, "Tag Title 1 - formatted")
             # Remove item from library to test again with APPLY choice.
             item.remove()
 
@@ -579,6 +581,7 @@ class ImportSingletonTest(_common.TestCase, ImportHelper):
             item.load()
             self.assertEqual(item.genre, genre)
             self.assertEqual(item.collection, collection)
+            self.assertEqual(item.title, "Applied Title 1 - formatted")
 
 
 class ImportTest(_common.TestCase, ImportHelper):
@@ -743,7 +746,8 @@ class ImportTest(_common.TestCase, ImportHelper):
         config['import']['set_fields'] = {
             'genre': genre,
             'collection': collection,
-            'comments': comments
+            'comments': comments,
+            'album': "$album - formatted",
         }
 
         # As-is album import.
@@ -765,6 +769,9 @@ class ImportTest(_common.TestCase, ImportHelper):
                 self.assertEqual(
                         item.get("comments", with_album=False),
                         comments)
+                self.assertEqual(
+                        item.get("album", with_album=False),
+                        "Tag Album - formatted")
             # Remove album from library to test again with APPLY choice.
             album.remove()
 
@@ -788,6 +795,9 @@ class ImportTest(_common.TestCase, ImportHelper):
                 self.assertEqual(
                         item.get("comments", with_album=False),
                         comments)
+                self.assertEqual(
+                        item.get("album", with_album=False),
+                        "Applied Album - formatted")
 
 
 class ImportTracksTest(_common.TestCase, ImportHelper):
diff --git a/test/test_ui.py b/test/test_ui.py
index 0c7478d45..ad4387013 100644
--- a/test/test_ui.py
+++ b/test/test_ui.py
@@ -287,6 +287,18 @@ class ModifyTest(unittest.TestCase, TestHelper):
         self.assertEqual(len(list(original_items)), 3)
         self.assertEqual(len(list(new_items)), 7)
 
+    def test_modify_formatted(self):
+        for i in range(0, 3):
+            self.add_item_fixture(title=f"title{i}",
+                                  artist="artist",
+                                  album="album")
+        items = list(self.lib.items())
+        self.modify("title=${title} - append")
+        for item in items:
+            orig_title = item.title
+            item.load()
+            self.assertEqual(item.title, f"{orig_title} - append")
+
     # Album Tests
 
     def test_modify_album(self):
@@ -318,6 +330,13 @@ class ModifyTest(unittest.TestCase, TestHelper):
         item.read()
         self.assertNotIn(b'newAlbum', item.path)
 
+    def test_modify_album_formatted(self):
+        item = self.lib.items().get()
+        orig_album = item.album
+        self.modify("--album", "album=${album} - append")
+        item.load()
+        self.assertEqual(item.album, f"{orig_album} - append")
+
     # Misc
 
     def test_write_initial_key_tag(self):