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:
It's becoming increasingly clear that people would like to apply format-like changes _automatically_ to tags as well as manually, so this ticket should definitely include that.

As proposed in #1310, it would even be useful to apply transformations globally to all tags. That is, rather than configuring every tag separately to lower-case its value with `title=%lower{$title}`, `artist=%lower{$artist}`, etc., a global pattern would apply to every tag. Some people clearly also want to use `%asciify{}` the same way.

Another nice use case (especially if implemented as an import hook) would be to default a field to something else in case of an Unknown value, e.g.:`beet modify composer::^$ composer = $albumartist`

Hi,

following my recent mail, I've been thinking about this feature and I'll try to sum up how I see it : 

Formatted tag modification would be usable with the import and the modify command

```
beets import  PATH tagName1='<expr1>' tagName2='<expr2>'
beets mod QUERY tagName1='<expr1>' tagName2='<expr2>'
```

where `<expr>` can contains 
- references to other tags value : `beet mod comments='liveness: $liveness'`
- a template function : `beet import /path/to/album title='%lower{$title}'`

When importing, it would set the tag to the specified templatized value. If a variable is used in the expression, it's value would be the value produced by the import process (i.e. either from the file or form auto-tagging ).

It would also be possible to specify such transformation in the configuration file, with a plugin, to be applied to every import. A simple evolution of the (renamed) zero plugin is probably enough, 

```
zero:
fields: genre 
genre: 
    match : [rnb, 'power metal' , 'Default Genre']
    value : 'Default Genre'
```

The only thing that does not fit (yet?) in this approach is the global pattern suggested by @sampsyo  : it could be implemented with a `*` tag in the plugin configuration, but I'm not sure how that would fit on the commandline interface, may something like `beet import /path/to/album *='%lower{$*}'`

Additionnal note on implementation : it could probably be done in Model.**setitem** (db.py) : this path is used in all the aforementioned use-cases and template evaluation is already available.

Ok, I just made a first pull request (only for the modify use case) , but it seems so trivial that I'm wondering if I'm not missing something obvious here...

Now I working on the import use case and I'm considering where the tag manipulation should happen (that is, when should we execute  `tagName1='<expr1>'` ). It seems to me it requires a new stage in the pipeline that would be placed just after the plugins stages and before the manipulate_files stage. Of course, as it's the first time I really have a look at the import implementation, it might be completely wrong ...  

Any idea / comment on this ? 

+1, This would be really cool!

About the commandline interface, I think it would be better if we don't have to write `%__{$*}`. Something like:

``` sh
beet import /path/to/album  *='lower,asciify' or --apply='lower,asciify'
```

glad to see I'm not the only one maintaining lowercase tags here. indeed that would be 👍  for me.
alternatively, case-insensitive data comparison during auto-tagging would be a way out (so that I my tags aren't overwritten by discogs / musicbrainz data). 
so either a parameter like mentioned above or a config entry to globally influence tags.

thanks!
I'd also like modify to offer this capability to other plugins. For instance to allow the alternatives plugin (https://github.com/geigerzaehler/beets-alternatives) to put replace the artist field by the albumartist one for the mp3 players who sort the music tracks by the artist field.
I would appreciate having a feature for assembling tags from content of existing tags.

Use case for illustration:

Album with some tracks where Musicbrainz returns several different artists for one album.

1) I want $artist to be replaced with $albumartist (because I listen on devices which group by $artist)  
2) I want $artist to be appended to $title (to keep/show the information)

Template for this could be something like:
```
    artist: $albumartist
    title: $title%if{%compare{$artist,$albumartist},'', ($albumartist)}

```
Tricky thing here is that this is not idempotent, applying the second transformation rule twice on an imported element would possibly yield different results (the rule adds the albumartist again). This transformation would be safe only during imports, on tag data coming from Musicbrainz or another external source.

It may be most sane to have two distinct modes:
1) transformation rules in config file are applied during import or update only
2) transformation rules on command line are applied during modify or as part of a new command
Hi, I came here reading https://github.com/beetbox/beets/issues/1310.

It would be useful for beets to set the case for both filenames and tags; kid3 has this feature and in beets a global option for the user like:

tag_format: lowercase/uppercase/capitalize
file_format: lowercase/uppercase/capitalize

with:
lowercase -> all letters lowercase
uppercase -> all letters uppercase
capitalize -> capitalize words following title capitalization rules (default)

would be perfect.
Would certainly help with my issue....

https://discourse.beets.io/t/multiple-artist-i-just-want-the-first-one/416


Please add this feature to help with my issue:

- https://discourse.beets.io/t/help-with-update-artist-and-album-fields-by-parsing-title/1625

 Wife has over 2000 tracks that we got out of google music before they shutdown and the artist tags are set to the name of the playlist in google music which is not correct. The title of each track has the artist name and I would like to extract the first portion of the title to serve as the artist name. Each title is formatted as follows: 
- [Artist] - [Track]





Resolving patch:
diff --git a/beets/importer.py b/beets/importer.py
--- a/beets/importer.py
+++ b/beets/importer.py
@@ -584,9 +584,9 @@ def set_fields(self, lib):
                       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 @@ def set_fields(self, lib):
                       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
--- a/beets/ui/commands.py
+++ b/beets/ui/commands.py
@@ -33,7 +33,7 @@
 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?


Test patch:
diff --git a/test/test_importer.py b/test/test_importer.py
--- a/test/test_importer.py
+++ b/test/test_importer.py
@@ -554,7 +554,8 @@ def test_set_fields(self):
 
         config['import']['set_fields'] = {
             'collection': collection,
-            'genre': genre
+            'genre': genre,
+            'title': "$title - formatted",
         }
 
         # As-is item import.
@@ -566,6 +567,7 @@ def test_set_fields(self):
             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 @@ def test_set_fields(self):
             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 @@ def test_set_fields(self):
         config['import']['set_fields'] = {
             'genre': genre,
             'collection': collection,
-            'comments': comments
+            'comments': comments,
+            'album': "$album - formatted",
         }
 
         # As-is album import.
@@ -765,6 +769,9 @@ def test_set_fields(self):
                 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 @@ def test_set_fields(self):
                 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
--- a/test/test_ui.py
+++ b/test/test_ui.py
@@ -287,6 +287,18 @@ def test_selective_modify(self):
         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 @@ def test_album_not_move(self):
         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):