Repository: beetbox/beets

Issue:
info: Select specific fields
Right now beet info spews all tags, but it should have an option for pulling a single key/value, like so:

> $ beet info -k acoustid_id "05 Le Freak.aiff"
> 616e1881-cb7e-4d9f-9988-fa2b546c7085

This is useful if you wanted to do a multi-step operation cleanly from the command line.

Here is the bash script I am using to get around this right now:

> # !/usr/bin/env bash
> # 
> 
> FILE=$1
> 
> if [ -z "$FILE" ]; then
>   echo "need filename"
>   exit 0
> fi
> 
> beet info "$FILE" | grep acoustid_id | sed -e 's/^ *acoustid_id: //g' 



Discussion:
How about:

```
$ beet ls -f '$acoustid_id' './05 Le Freak.aiff'
```

Unless, of course, you're interested in inspecting files that aren't in your library. In that case, sure, this seems like a fine addition.

OK, that works.  A bit unwieldy, but doable.  Seems like this should exist in the info plugin though, not in ls.  Although arguably those two should be collapsed.  beet info is more like a verbose ls.

and yes, using it on something not in the library is definitely useful.  I didn't know I could do that, but now that I know, I'm definitely going to use the hell out of that feature.  I think this is the first CLI tool I've seen that cleanly reads a bunch of different file formats for tags...

OK, sounds good. Let's make this a feature request.

Somehow merging `info`'s functionality with `list` is an interesting long-term goal (but is not part of this ticket).

Makes sense to me.


Resolving patch:
diff --git a/beetsplug/info.py b/beetsplug/info.py
--- a/beetsplug/info.py
+++ b/beetsplug/info.py
@@ -19,6 +19,7 @@
                         unicode_literals)
 
 import os
+import re
 
 from beets.plugins import BeetsPlugin
 from beets import ui
@@ -77,7 +78,7 @@ def update_summary(summary, tags):
 
 
 def print_data(data):
-    path = data.pop('path')
+    path = data.pop('path', None)
     formatted = {}
     for key, value in data.iteritems():
         if isinstance(value, list):
@@ -85,6 +86,9 @@ def print_data(data):
         if value is not None:
             formatted[key] = value
 
+    if len(formatted) == 0:
+        return
+
     maxwidth = max(len(key) for key in formatted)
     lineformat = u'{{0:>{0}}}: {{1}}'.format(maxwidth)
 
@@ -107,6 +111,9 @@ def commands(self):
                               help='show library fields instead of tags')
         cmd.parser.add_option('-s', '--summarize', action='store_true',
                               help='summarize the tags of all files')
+        cmd.parser.add_option('-i', '--include-keys', default=[],
+                              action='append', dest='included_keys',
+                              help='comma separated list of keys to show')
         return [cmd]
 
     def run(self, lib, opts, args):
@@ -128,6 +135,11 @@ def run(self, lib, opts, args):
         else:
             data_collector = tag_data
 
+        included_keys = []
+        for keys in opts.included_keys:
+            included_keys.extend(keys.split(','))
+        key_filter = make_key_filter(included_keys)
+
         first = True
         summary = {}
         for data_emitter in data_collector(lib, ui.decargs(args)):
@@ -137,6 +149,9 @@ def run(self, lib, opts, args):
                 self._log.error(u'cannot read file: {0}', ex)
                 continue
 
+            path = data.get('path')
+            data = key_filter(data)
+            data['path'] = path  # always show path
             if opts.summarize:
                 update_summary(summary, data)
             else:
@@ -147,3 +162,33 @@ def run(self, lib, opts, args):
 
         if opts.summarize:
             print_data(summary)
+
+
+def make_key_filter(include):
+    """Return a function that filters a dictionary.
+
+    The returned filter takes a dictionary and returns another
+    dictionary that only includes the key-value pairs where the key
+    glob-matches one of the keys in `include`.
+    """
+    if not include:
+        return identity
+
+    matchers = []
+    for key in include:
+        key = re.escape(key)
+        key = key.replace(r'\*', '.*')
+        matchers.append(re.compile(key + '$'))
+
+    def filter(data):
+        filtered = dict()
+        for key, value in data.items():
+            if any(map(lambda m: m.match(key), matchers)):
+                filtered[key] = value
+        return filtered
+
+    return filter
+
+
+def identity(val):
+    return val


Test patch:
diff --git a/test/helper.py b/test/helper.py
--- a/test/helper.py
+++ b/test/helper.py
@@ -407,7 +407,7 @@ def run_command(self, *args):
     def run_with_output(self, *args):
         with capture_stdout() as out:
             self.run_command(*args)
-        return out.getvalue()
+        return out.getvalue().decode('utf-8')
 
     # Safe file operations
 
diff --git a/test/test_info.py b/test/test_info.py
--- a/test/test_info.py
+++ b/test/test_info.py
@@ -19,6 +19,7 @@
 from test.helper import TestHelper
 
 from beets.mediafile import MediaFile
+from beets.util import displayable_path
 
 
 class InfoTest(unittest.TestCase, TestHelper):
@@ -52,17 +53,17 @@ def test_path(self):
         self.assertNotIn('composer:', out)
 
     def test_item_query(self):
-        items = self.add_item_fixtures(count=2)
-        items[0].album = 'xxxx'
-        items[0].write()
-        items[0].album = 'yyyy'
-        items[0].store()
+        item1, item2 = self.add_item_fixtures(count=2)
+        item1.album = 'xxxx'
+        item1.write()
+        item1.album = 'yyyy'
+        item1.store()
 
         out = self.run_with_output('album:yyyy')
-        self.assertIn(items[0].path, out)
-        self.assertIn(b'album: xxxx', out)
+        self.assertIn(displayable_path(item1.path), out)
+        self.assertIn(u'album: xxxx', out)
 
-        self.assertNotIn(items[1].path, out)
+        self.assertNotIn(displayable_path(item2.path), out)
 
     def test_item_library_query(self):
         item, = self.add_item_fixtures()
@@ -70,8 +71,8 @@ def test_item_library_query(self):
         item.store()
 
         out = self.run_with_output('--library', 'album:xxxx')
-        self.assertIn(item.path, out)
-        self.assertIn(b'album: xxxx', out)
+        self.assertIn(displayable_path(item.path), out)
+        self.assertIn(u'album: xxxx', out)
 
     def test_collect_item_and_path(self):
         path = self.create_mediafile_fixture()
@@ -88,9 +89,18 @@ def test_collect_item_and_path(self):
         mediafile.save()
 
         out = self.run_with_output('--summarize', 'album:AAA', path)
-        self.assertIn('album: AAA', out)
-        self.assertIn('tracktotal: 5', out)
-        self.assertIn('title: [various]', out)
+        self.assertIn(u'album: AAA', out)
+        self.assertIn(u'tracktotal: 5', out)
+        self.assertIn(u'title: [various]', out)
+
+    def test_include_pattern(self):
+        item = self.add_item(album='xxxx')
+
+        out = self.run_with_output('--library', 'album:xxxx',
+                                   '--include-keys', '*lbu*')
+        self.assertIn(displayable_path(item.path), out)
+        self.assertNotIn(u'title:', out)
+        self.assertIn(u'album: xxxx', out)
 
 
 def suite():