diff --git a/beetsplug/keyfinder.py b/beetsplug/keyfinder.py new file mode 100644 index 000000000..ecb0fe87c --- /dev/null +++ b/beetsplug/keyfinder.py @@ -0,0 +1,65 @@ +# This file is part of beets. +# Copyright 2014, Thomas Scholtes. +# +# 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. + +"""Uses the `KeyFinder` program to add the `initial_key` field. +""" + +import logging + +from beets import ui +from beets import util +from beets.plugins import BeetsPlugin + + +log = logging.getLogger('beets') + + +class KeyFinderPlugin(BeetsPlugin): + + def __init__(self): + super(KeyFinderPlugin, self).__init__() + self.config.add({ + u'bin': u'KeyFinder', + u'auto': True, + u'overwrite': False, + }) + self.config['auto'].get(bool) + self.import_stages = [self.imported] + + def commands(self): + cmd = ui.Subcommand('keyfinder', + help='detect and add initial key from audio') + cmd.func = self.command + return [cmd] + + def command(self, lib, opts, args): + self.find_key(lib.items(ui.decargs(args))) + + def imported(self, session, task): + if self.config['auto'].get(bool): + self.find_key(task.items) + + def find_key(self, items): + overwrite = self.config['overwrite'].get(bool) + bin = util.bytestring_path(self.config['bin'].get(unicode)) + + for item in items: + if item['initial_key'] and not overwrite: + continue + key = util.command_output([bin, '-f', item.path]) + item['initial_key'] = key + log.debug('added computed initial key {0} for {1}' + .format(key, util.displayable_path(item.path))) + item.try_write() + item.store() diff --git a/docs/changelog.rst b/docs/changelog.rst index 900f52ea1..d0a74ca85 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -18,6 +18,9 @@ New stuff: initial_key=Am`` the media files will reflect this in their tags. The :doc:`/plugins/echonest` plugin also sets this field if the data is available. +* There is also a new :doc:`/plugins/keyfinder` that runs a command line + program to get the key from audio data and store it in the + `initial_key` field. Fixes: diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 1077994f2..210c2d6b2 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -56,6 +56,7 @@ by typing ``beet version``. beatport fromfilename ftintitle + keyfinder Autotagger Extensions --------------------- @@ -86,9 +87,12 @@ Metadata field. * :doc:`mpdstats`: Connect to `MPD`_ and update the beets library with play statistics (last_played, play_count, skip_count, rating). +* :doc:`keyfinder`: Use the `KeyFinder`_ program to detect the musical + key from the audio. .. _Acoustic Attributes: http://developer.echonest.com/acoustic-attributes.html .. _the Echo Nest: http://www.echonest.com +.. _KeyFinder: http://www.ibrahimshaath.co.uk/keyfinder/ Path Formats ------------ diff --git a/docs/plugins/keyfinder.rst b/docs/plugins/keyfinder.rst new file mode 100644 index 000000000..76261709c --- /dev/null +++ b/docs/plugins/keyfinder.rst @@ -0,0 +1,25 @@ +Key Finder Plugin +================= + +The `keyfinder` plugin uses the `KeyFinder`_ program to detect the +musical key of track from its audio data and store it in the +`initial_key` field of you database. If enabled, it does so +automatically when importing music or through the ``beet keyfinder +[QUERY]`` command. + +There are a couple of configuration options to customize the behavior of +the plugin. By default they are:: + + keyfinder: + bin: KeyFinder + auto: yes + overwrite: no + +* ``bin`` This is the name of the `KeyFinder` program on your system or + a path to the binary. +* ``auto`` If set to `yes`, the plugin will analyze every file on + import. +* ``overwrite`` If set to `no` the import hook and the command will skip + any file that already has an `initial_key` in the database. + +.. _KeyFinder: http://www.ibrahimshaath.co.uk/keyfinder/ diff --git a/test/helper.py b/test/helper.py index e3e16199f..bf6865bd3 100644 --- a/test/helper.py +++ b/test/helper.py @@ -154,8 +154,12 @@ class TestHelper(object): import_dir = os.path.join(self.temp_dir, 'import') if not os.path.isdir(import_dir): os.mkdir(import_dir) - for fixture in glob(os.path.join(_common.RSRC, '*.mp3'))[0:file_count]: - shutil.copy(fixture, import_dir) + + for i in range(file_count): + title = 'track {0}'.format(i) + src = os.path.join(_common.RSRC, 'full.mp3') + dest = os.path.join(import_dir, '{0}.mp3'.format(title)) + shutil.copy(src, dest) config['import']['quiet'] = True config['import']['autotag'] = False diff --git a/test/test_keyfinder.py b/test/test_keyfinder.py new file mode 100644 index 000000000..ff65a014b --- /dev/null +++ b/test/test_keyfinder.py @@ -0,0 +1,81 @@ +# This file is part of beets. +# Copyright 2014, Thomas Scholtes. +# +# 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 mock import patch +from _common import unittest +from helper import TestHelper + +from beets.library import Item + +class KeyFinderTest(unittest.TestCase, TestHelper): + + def setUp(self): + self.setup_beets() + self.load_plugins('keyfinder') + self.patcher = patch('beets.util.command_output') + self.command_output = self.patcher.start() + + def tearDown(self): + self.teardown_beets() + self.unload_plugins() + self.patcher.stop() + + def test_add_key(self): + item = Item(path='/file') + item.add(self.lib) + + self.command_output.return_value = 'dbm' + self.run_command('keyfinder') + + item.load() + self.assertEqual(item['initial_key'], 'C#m') + self.command_output.assert_called_with( + ['KeyFinder', '-f', item.path]) + + def test_add_key_on_import(self): + self.command_output.return_value = 'dbm' + importer = self.create_importer() + importer.run() + + item = self.lib.items().get() + self.assertEqual(item['initial_key'], 'C#m') + + def test_force_overwrite(self): + self.config['keyfinder']['overwrite'] = True + + item = Item(path='/file', initial_key='F') + item.add(self.lib) + + self.command_output.return_value = 'C#m' + self.run_command('keyfinder') + + item.load() + self.assertEqual(item['initial_key'], 'C#m') + + def test_do_not_overwrite(self): + item = Item(path='/file', initial_key='F') + item.add(self.lib) + + self.command_output.return_value = 'dbm' + self.run_command('keyfinder') + + item.load() + self.assertEqual(item['initial_key'], 'F') + + +def suite(): + return unittest.TestLoader().loadTestsFromName(__name__) + +if __name__ == '__main__': + unittest.main(defaultTest='suite')