Support for initial_key with EchoNest

This commit is contained in:
Thomas Scholtes 2014-04-11 21:03:11 +02:00
parent 1670cb4565
commit c01fc542ed
10 changed files with 180 additions and 23 deletions

View file

@ -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)

View file

@ -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'),

View file

@ -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)."""

View file

@ -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))

View file

@ -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:

View file

@ -86,6 +86,8 @@ setup(
tests_require=[
'responses',
'pyechonest',
'mock',
],
# Plugin (optional) dependencies:

79
test/test_echonest.py Normal file
View file

@ -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')

View file

@ -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):

View file

@ -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()

View file

@ -9,9 +9,11 @@ envlist = py26, py27, pypy, docs, flake8
[testenv]
deps =
nose
mock
pylast
flask
responses
pyechonest
commands =
nosetests {posargs}