diff --git a/beets/dbcore/types.py b/beets/dbcore/types.py index 4862d39b4..06bd397fe 100644 --- a/beets/dbcore/types.py +++ b/beets/dbcore/types.py @@ -14,6 +14,8 @@ """Representation of type information for DBCore model fields. """ +import re + from . import query from beets.util import str2bool @@ -164,3 +166,31 @@ class Boolean(Type): def parse(self, string): return str2bool(string) + + +class MusicalKey(String): + """String representing the musical key of a song. + + The standard format is C, Cm, C#, C#m, etc. + """ + + ENHARMONIC = { + r'db': 'c#', + r'eb': 'd#', + r'gb': 'f#', + r'ab': 'g#', + r'bb': 'a#', + } + + def parse(self, key): + key = key.lower() + for flat, sharp in self.ENHARMONIC: + re.sub(flat, sharp, key) + re.sub(r'[\W\s]+minor', 'm', key) + return key.capitalize() + + def normalize(self, key): + if key is None: + return None + else: + return self.parse(key) diff --git a/beets/library.py b/beets/library.py index ca5ece778..574ce85e7 100644 --- a/beets/library.py +++ b/beets/library.py @@ -252,6 +252,7 @@ class Item(LibModel): 'original_year': types.PaddedInt(4), 'original_month': types.PaddedInt(2), 'original_day': types.PaddedInt(2), + 'initial_key': types.MusicalKey(), 'length': types.Float(), 'bitrate': types.ScaledInt(1000, u'kbps'), diff --git a/beets/mediafile.py b/beets/mediafile.py index b4414f244..a05fb5414 100644 --- a/beets/mediafile.py +++ b/beets/mediafile.py @@ -1752,6 +1752,13 @@ class MediaFile(object): out_type=float, ) + initial_key = MediaField( + MP3StorageStyle('TKEY'), + MP4StorageStyle('----:com.apple.iTunes:initialkey'), + StorageStyle('INITIALKEY'), + ASFStorageStyle('INITIALKEY'), + ) + @property def length(self): """The duration of the audio in seconds (a float).""" diff --git a/beetsplug/echonest.py b/beetsplug/echonest.py index 8b6509a55..8c92a7b8e 100644 --- a/beetsplug/echonest.py +++ b/beetsplug/echonest.py @@ -47,6 +47,11 @@ ATTRIBUTES = { 'valence': 'valence', 'tempo': 'bpm', } + +MUSICAL_SCALE = ['C', 'C#', 'D', 'D#', 'E' 'F', + 'F#', 'G', 'G#', 'A', 'A#', 'B'] + + # We also use echonest_id (song_id) and echonest_fingerprint to speed up # lookups. ID_KEY = 'echonest_id' @@ -407,6 +412,11 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin): item[field] = int(v) else: item[field] = v + if 'key' in values and 'mode' in values: + key = MUSICAL_SCALE[values['key'] - 1] + if values['mode'] == 0: # Minor key + key += 'm' + item['initial_key'] = key if 'id' in values: enid = values['id'] log.debug(u'echonest: metadata: {0} = {1}'.format(ID_KEY, enid)) diff --git a/docs/changelog.rst b/docs/changelog.rst index 9366bbf9a..bd134609e 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -13,6 +13,11 @@ New stuff: * :doc:`/plugins/replaygain`: Added support for calculating ReplayGain values with GStreamer as well the mp3gain programs. This enables ReplayGain calculation for any audio format. +* Add support for `initial_key` as field in the library and tag for + media files. When the user sets this field with ``beet modify + 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. Fixes: diff --git a/setup.py b/setup.py index 04f3959f7..e2c917121 100755 --- a/setup.py +++ b/setup.py @@ -86,6 +86,8 @@ setup( tests_require=[ 'responses', + 'pyechonest', + 'mock', ], # Plugin (optional) dependencies: diff --git a/test/test_echonest.py b/test/test_echonest.py new file mode 100644 index 000000000..e0f044a71 --- /dev/null +++ b/test/test_echonest.py @@ -0,0 +1,79 @@ +# 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. + + +import os.path +from mock import Mock, patch + +import _common +from _common import unittest +from helper import TestHelper + +from beets.library import Item, Album + + +class EchonestCliTest(unittest.TestCase, TestHelper): + + def setUp(self): + self.setup_beets() + self.load_plugins('echonest') + + def tearDown(self): + self.teardown_beets() + self.unload_plugins() + + @patch.object(Item, 'write') + @patch('pyechonest.song.profile') + @patch('pyechonest.track.track_from_id') + def test_store_data(self, echonest_track, echonest_profile, item_write): + profile = Mock( + artist_name='artist', + title='title', + id='echonestid', + audio_summary={ + 'duration': 10, + 'energy': 0.5, + 'liveness': 0.5, + 'loudness': 0.5, + 'speechiness': 0.5, + 'danceability': 0.5, + 'tempo': 120, + 'key': 2, + 'mode': 0 + }, + ) + echonest_profile.return_value = [profile] + echonest_track.return_value = Mock(song_id='echonestid') + + item = Item( + mb_trackid='01234', + artist='artist', + title='title', + length=10, + ) + item.add(self.lib) + self.assertNotIn('danceability', item) + self.assertNotIn('initialkey', item) + + self.run_command('echonest') + item.load() + self.assertEqual(item['danceability'], '0.5') + self.assertEqual(item['initial_key'], 'C#m') + + +def suite(): + return unittest.TestLoader().loadTestsFromName(__name__) + +if __name__ == '__main__': + unittest.main(defaultTest='suite') diff --git a/test/test_mediafile.py b/test/test_mediafile.py index 060f7cf2c..10f22f09b 100644 --- a/test/test_mediafile.py +++ b/test/test_mediafile.py @@ -24,7 +24,7 @@ import time import _common from _common import unittest from beets.mediafile import MediaFile, MediaField, Image, \ - MP3StorageStyle, StorageStyle, \ + MP3DescStorageStyle, StorageStyle, \ MP4StorageStyle, ASFStorageStyle, \ ImageType from beets.library import Item @@ -259,54 +259,54 @@ class GenreListTestMixin(object): field_extension = MediaField( - MP3StorageStyle('TKEY'), - MP4StorageStyle('----:com.apple.iTunes:initialkey'), - StorageStyle('INITIALKEY'), - ASFStorageStyle('INITIALKEY'), + MP3DescStorageStyle('customtag'), + MP4StorageStyle('----:com.apple.iTunes:customtag'), + StorageStyle('customtag'), + ASFStorageStyle('customtag'), ) class ExtendedFieldTestMixin(object): def test_extended_field_write(self): plugin = BeetsPlugin() - plugin.add_media_field('initialkey', field_extension) + plugin.add_media_field('customtag', field_extension) mediafile = self._mediafile_fixture('empty') - mediafile.initialkey = 'F#' + mediafile.customtag = 'F#' mediafile.save() mediafile = MediaFile(mediafile.path) - self.assertEqual(mediafile.initialkey, 'F#') - delattr(MediaFile, 'initialkey') - Item._media_fields.remove('initialkey') + self.assertEqual(mediafile.customtag, 'F#') + delattr(MediaFile, 'customtag') + Item._media_fields.remove('customtag') def test_write_extended_tag_from_item(self): plugin = BeetsPlugin() - plugin.add_media_field('initialkey', field_extension) + plugin.add_media_field('customtag', field_extension) mediafile = self._mediafile_fixture('empty') - self.assertIsNone(mediafile.initialkey) + self.assertIsNone(mediafile.customtag) - item = Item(path=mediafile.path, initialkey='Gb') + item = Item(path=mediafile.path, customtag='Gb') item.write() mediafile = MediaFile(mediafile.path) - self.assertEqual(mediafile.initialkey, 'Gb') + self.assertEqual(mediafile.customtag, 'Gb') - delattr(MediaFile, 'initialkey') - Item._media_fields.remove('initialkey') + delattr(MediaFile, 'customtag') + Item._media_fields.remove('customtag') def test_read_flexible_attribute_from_file(self): plugin = BeetsPlugin() - plugin.add_media_field('initialkey', field_extension) + plugin.add_media_field('customtag', field_extension) mediafile = self._mediafile_fixture('empty') - mediafile.update({'initialkey': 'F#'}) + mediafile.update({'customtag': 'F#'}) mediafile.save() item = Item.from_path(mediafile.path) - self.assertEqual(item['initialkey'], 'F#') + self.assertEqual(item['customtag'], 'F#') - delattr(MediaFile, 'initialkey') - Item._media_fields.remove('initialkey') + delattr(MediaFile, 'customtag') + Item._media_fields.remove('customtag') def test_invalid_descriptor(self): with self.assertRaises(ValueError) as cm: @@ -401,6 +401,7 @@ class ReadWriteTestBase(ArtTestMixin, GenreListTestMixin, 'original_month', 'original_day', 'original_date', + 'initial_key', ] def setUp(self): diff --git a/test/test_ui.py b/test/test_ui.py index 2f8edf3ec..ce72debd0 100644 --- a/test/test_ui.py +++ b/test/test_ui.py @@ -154,10 +154,10 @@ class ModifyTest(_common.TestCase): self.lib = library.Library(':memory:', self.libdir) self.i = library.Item.from_path(os.path.join(_common.RSRC, 'full.mp3')) self.lib.add(self.i) - self.i.move(True) + self.i.move(copy=True) self.album = self.lib.add_album([self.i]) - def _modify(self, mods, dels=(), query=(), write=False, move=False, + def _modify(self, mods=(), dels=(), query=(), write=False, move=False, album=False): self.io.addinput('y') commands.modify_items(self.lib, mods, dels, query, @@ -219,6 +219,26 @@ class ModifyTest(_common.TestCase): item.read() self.assertFalse('newAlbum' in item.path) + def test_write_initial_key_tag(self): + self._modify(["initial_key=C#m"], write=True) + item = self.lib.items().get() + mediafile = MediaFile(item.path) + self.assertEqual(mediafile.initial_key, 'C#m') + + @unittest.skip('not yet implemented') + def test_delete_initial_key_tag(self): + item = self.i + item.initial_key = 'C#m' + item.write() + item.store() + + mediafile = MediaFile(item.path) + self.assertEqual(mediafile.initial_key, 'C#m') + + self._modify(dels=["initial_key!"], write=True) + mediafile = MediaFile(item.path) + self.assertIsNone(mediafile.initial_key) + class MoveTest(_common.TestCase): def setUp(self): super(MoveTest, self).setUp() diff --git a/tox.ini b/tox.ini index d4d056f94..5e50071c6 100644 --- a/tox.ini +++ b/tox.ini @@ -9,9 +9,11 @@ envlist = py26, py27, pypy, docs, flake8 [testenv] deps = nose + mock pylast flask responses + pyechonest commands = nosetests {posargs}