mirror of
https://github.com/beetbox/beets.git
synced 2025-12-09 10:05:35 +01:00
Support for initial_key with EchoNest
This commit is contained in:
parent
1670cb4565
commit
c01fc542ed
10 changed files with 180 additions and 23 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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)."""
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
2
setup.py
2
setup.py
|
|
@ -86,6 +86,8 @@ setup(
|
|||
|
||||
tests_require=[
|
||||
'responses',
|
||||
'pyechonest',
|
||||
'mock',
|
||||
],
|
||||
|
||||
# Plugin (optional) dependencies:
|
||||
|
|
|
|||
79
test/test_echonest.py
Normal file
79
test/test_echonest.py
Normal 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')
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
2
tox.ini
2
tox.ini
|
|
@ -9,9 +9,11 @@ envlist = py26, py27, pypy, docs, flake8
|
|||
[testenv]
|
||||
deps =
|
||||
nose
|
||||
mock
|
||||
pylast
|
||||
flask
|
||||
responses
|
||||
pyechonest
|
||||
commands =
|
||||
nosetests {posargs}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue