From 9b8ad7dcd1758973edaf1306d4f866319115c235 Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Wed, 16 Oct 2013 10:26:18 +0200 Subject: [PATCH 01/44] Experimental EchoNest plugin to fetch and map attributes from EchoNests audio_summary. --- beetsplug/echoplus.py | 217 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 beetsplug/echoplus.py diff --git a/beetsplug/echoplus.py b/beetsplug/echoplus.py new file mode 100644 index 000000000..9536eb3c6 --- /dev/null +++ b/beetsplug/echoplus.py @@ -0,0 +1,217 @@ +# This file is part of beets. +# Copyright 2013, Peter Schnebel +# +# Original 'echonest_tempo' plugin is copyright 2013, David Brenner +# +# +# 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. + +"""Gets additional information for imported music from the EchoNest API. Requires +the pyechonest library (https://github.com/echonest/pyechonest). +""" +import time +import logging +from beets.plugins import BeetsPlugin +from beets import ui +from beets import config +import pyechonest.config +import pyechonest.song +import socket + +# Global logger. +log = logging.getLogger('beets') + +RETRY_INTERVAL = 10 # Seconds. +RETRIES = 10 +ATTRIBUTES = ['energy', 'liveness', 'speechiness', 'acousticness', + 'danceability', 'valence', 'tempo' ] +ATTRIBUTES_WITH_STYLE = ['energy', 'liveness', 'speechiness', 'acousticness', + 'danceability', 'valence' ] + +def apply_style(style, custom, value): + if style == 'raw': + return value + mapping = None + if style == 'custom': + mapping = [ m.strip() for m in custom.split(',') ] + elif style == '1to5': + mapping = [1, 2, 3, 4, 5] + elif style == 'hr5': # human readable + mapping = ['very low', 'low', 'neutral', 'high', 'very high'] + elif style == 'hr3': # human readable + mapping = ['low', 'neutral', 'high'] + if mapping is None: + log.error(loglevel, u'Unsupported style setting: {}'.format(style)) + return value + inc = 1.0 / len(mapping) + cut = 0.0 + for i in range(len(mapping)): + cut += inc + if value < cut: + return mapping[i] + log.error(loglevel, u'Failed to apply style: {} [{}]'.format(style, + u', '.join(mapping))) + return value + +def fetch_item_attributes(lib, loglevel, item, write): + """Fetch and store tempo for a single item. If ``write``, then the + tempo will also be written to the file itself in the bpm field. The + ``loglevel`` parameter controls the visibility of the function's + status log messages. + """ + # Skip if the item already has the tempo field. + audio_summary = get_audio_summary(item.artist, item.title) + changed = False + global_style = config['echoplus']['style'].get() + global_custom_style = config['echoplus']['custom_style'].get() + force = config['echoplus']['force'].get(bool) + if audio_summary: + for attr in ATTRIBUTES: + if config['echoplus'][attr].get(str) == '': + continue + if item.get(attr, None) is not None and not force: + log.debug(loglevel, u'{} already present, use the force Luke: {} - {} = {}'.format( + attr, item.artist, item.title, item.get(attr))) + else: + if not attr in audio_summary or audio_summary[attr] is None: + log.debug(loglevel, u'{} not found: {} - {}'.format( + attr, item.artist, item.title)) + else: + log.debug(loglevel, u'fetched {}: {} - {} = {}'.format( + attr, item.artist, item.title, audio_summary[attr])) + value = float(audio_summary[attr]) + if attr in ATTRIBUTES_WITH_STYLE: + style = config['echoplus']['{}_style'.format(attr)].get() + custom_style = config['echoplus']['{}_custom_style'.format(attr)].get() + if style is None: + style = global_style + if custom_style is None: + custom_style = global_custom_style + value = apply_style(style, custom_style, value) + log.debug(loglevel, u'mapped {}: {} - {} = {}'.format( + attr, item.artist, item.title, audio_summary[attr])) + item[attr] = value + changed = True + if changed: + if write: + item.write() + item.store() + + +def get_audio_summary(artist, title): + """Get the attribute for a song.""" + # We must have sufficient metadata for the lookup. Otherwise the API + # will just complain. + artist = artist.replace(u'\n', u' ').strip() + title = title.replace(u'\n', u' ').strip() + if not artist or not title: + return None + + for i in range(RETRIES): + try: + # Unfortunately, all we can do is search by artist and title. + # EchoNest supports foreign ids from MusicBrainz, but currently + # only for artists, not individual tracks/recordings. + results = pyechonest.song.search( + artist=artist, title=title, results=1, + buckets=['audio_summary'] + ) + except pyechonest.util.EchoNestAPIError as e: + if e.code == 3: + # Wait and try again. + time.sleep(RETRY_INTERVAL) + else: + log.warn(u'echonest: {0}'.format(e.args[0][0])) + return None + except (pyechonest.util.EchoNestIOError, socket.error) as e: + log.debug(u'echonest: IO error: {0}'.format(e)) + time.sleep(RETRY_INTERVAL) + else: + break + else: + # If we exited the loop without breaking, then we used up all + # our allotted retries. + log.debug(u'echonest: exceeded retries') + return None + + # The Echo Nest API can return songs that are not perfect matches. + # So we look through the results for songs that have the right + # artist and title. The API also doesn't have MusicBrainz track IDs; + # otherwise we could use those for a more robust match. + for result in results: + if result.artist_name == artist and result.title == title: + return results[0].audio_summary + + +class EchoPlusPlugin(BeetsPlugin): + def __init__(self): + super(EchoPlusPlugin, self).__init__() + self.import_stages = [self.imported] + # for an explanation of 'valence' see: + # http://developer.echonest.com/forums/thread/1297 + self.config.add({ + 'apikey': u'NY2KTZHQ0QDSHBAP6', + 'auto': True, + 'style': 'hr5', + 'custom_style': None, + 'force': True, + 'printinfo': True, + }) + for attr in ATTRIBUTES: + if attr == 'tempo': + target = 'bpm' + self.config.add({attr:target}) + else: + target = attr + self.config.add({attr:target, + '{}_style'.format(attr):None, + '{}_custom_style'.format(attr):None, + }) + + pyechonest.config.ECHO_NEST_API_KEY = \ + self.config['apikey'].get(unicode) + + def commands(self): + cmd = ui.Subcommand('echoplus', + help='fetch additional song information from the echonest') + cmd.parser.add_option('-p', '--print', dest='printinfo', + action='store_true', default=False, + help='print fetched information to console') + cmd.parser.add_option('-f', '--force', dest='force', + action='store_true', default=False, + help='re-download information from the echonest') + def func(lib, opts, args): + # The "write to files" option corresponds to the + # import_write config value. + write = config['import']['write'].get(bool) + + for item in lib.items(ui.decargs(args)): + fetch_item_attributes(lib, logging.INFO, item, write) + if opts.printinfo: + d = [] + for attr in ATTRIBUTES: + if item.get(attr, None) is not None: + d.append(u'{}={}'.format(attr, item.get(attr))) + s = u', '.join(d) + if s == u'': + s = u'no information received' + ui.print_(u'{}-{}: {}'.format(item.artist, item.title, s)) + cmd.func = func + return [cmd] + + # Auto-fetch tempo on import. + def imported(self, config, task): + if self.config['auto']: + for item in task.imported_items(): + fetch_item_tempo(config.lib, logging.DEBUG, item, False) + +# eof From 846f4eb7ae5507e05e4a25e357455e040860057e Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Wed, 16 Oct 2013 10:33:51 +0200 Subject: [PATCH 02/44] fix possible bug --- beetsplug/echoplus.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/beetsplug/echoplus.py b/beetsplug/echoplus.py index 9536eb3c6..14f8946b3 100644 --- a/beetsplug/echoplus.py +++ b/beetsplug/echoplus.py @@ -58,9 +58,7 @@ def apply_style(style, custom, value): cut += inc if value < cut: return mapping[i] - log.error(loglevel, u'Failed to apply style: {} [{}]'.format(style, - u', '.join(mapping))) - return value + return mapping[i] def fetch_item_attributes(lib, loglevel, item, write): """Fetch and store tempo for a single item. If ``write``, then the From 7591957327eaf2164a779bfd0b16b81280f32fac Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Wed, 16 Oct 2013 11:28:02 +0200 Subject: [PATCH 03/44] added experimental guess mood feature (see http://developer.echonest.com/forums/thread/1297) --- beetsplug/echoplus.py | 79 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 68 insertions(+), 11 deletions(-) diff --git a/beetsplug/echoplus.py b/beetsplug/echoplus.py index 14f8946b3..1d2a3d61b 100644 --- a/beetsplug/echoplus.py +++ b/beetsplug/echoplus.py @@ -60,19 +60,74 @@ def apply_style(style, custom, value): return mapping[i] return mapping[i] +def guess_mood(valence, energy): + # for an explanation see: + # http://developer.echonest.com/forums/thread/1297 + # i picked a Valence-Arousal space from here: + # http://mat.ucsb.edu/~ivana/200a/background.htm + + # energy from 0.0 to 1.0. valence < 0.5 + low_valence = [ + 'fatigued', 'lethargic', 'depressed', 'sad', + 'upset', 'stressed', 'nervous', 'tense' ] + # energy from 0.0 to 1.0. valence > 0.5 + high_valence = [ + 'calm', 'relaxed', 'serene', 'contented', + 'happy', 'elated', 'excited', 'alert' ] + if valence < 0.5: + mapping = low_valence + else: + mapping = high_valence + inc = 1.0 / len(mapping) + cut = 0.0 + for i in range(len(mapping)): + cut += inc + if energy < cut: + return mapping[i] + return mapping[i] + + def fetch_item_attributes(lib, loglevel, item, write): """Fetch and store tempo for a single item. If ``write``, then the tempo will also be written to the file itself in the bpm field. The ``loglevel`` parameter controls the visibility of the function's status log messages. """ - # Skip if the item already has the tempo field. + # Check if we need to update + force = config['echoplus']['force'].get(bool) + guess_mood = config['echoplus']['guess_mood'].get(bool) + if force: + require_update = True + else: + require_update = False + for attr in ATTRIBUTES: + if config['echoplus'][attr].get(str) == '': + continue + if item.get(attr, None) is None: + require_update = True + break + if not require_update and guess_mood: + if item.get('mood', None) is None: + require_update = True + if not require_update: + log.debug(loglevel, u'no update required for: {} - {}'.format( + item.artist, item.title)) + return audio_summary = get_audio_summary(item.artist, item.title) - changed = False global_style = config['echoplus']['style'].get() global_custom_style = config['echoplus']['custom_style'].get() - force = config['echoplus']['force'].get(bool) if audio_summary: + changed = False + if guess_mood: + attr = 'mood' + if item.get(attr, None) is not None and not force: + log.debug(loglevel, u'{} already present, use the force Luke: {} - {} = {}'.format( + attr, item.artist, item.title, item.get(attr))) + else: + if 'valence' in audio_summary and 'energy' in audio_summary: + item[attr] = guess_mode(audio_summary['valence'], + audio_summary['energy']) + changed = True for attr in ATTRIBUTES: if config['echoplus'][attr].get(str) == '': continue @@ -99,10 +154,10 @@ def fetch_item_attributes(lib, loglevel, item, write): attr, item.artist, item.title, audio_summary[attr])) item[attr] = value changed = True - if changed: - if write: - item.write() - item.store() + if changed: + if write: + item.write() + item.store() def get_audio_summary(artist, title): @@ -154,15 +209,14 @@ class EchoPlusPlugin(BeetsPlugin): def __init__(self): super(EchoPlusPlugin, self).__init__() self.import_stages = [self.imported] - # for an explanation of 'valence' see: - # http://developer.echonest.com/forums/thread/1297 self.config.add({ 'apikey': u'NY2KTZHQ0QDSHBAP6', 'auto': True, 'style': 'hr5', 'custom_style': None, - 'force': True, + 'force': False, 'printinfo': True, + 'guess_mood': False, }) for attr in ATTRIBUTES: if attr == 'tempo': @@ -196,7 +250,10 @@ class EchoPlusPlugin(BeetsPlugin): fetch_item_attributes(lib, logging.INFO, item, write) if opts.printinfo: d = [] - for attr in ATTRIBUTES: + attrs = [ a for a in ATTRIBUTES ] + if config['echoplus']['guess_mood'].get(bool): + attrs.append('mood') + for attr in attrs: if item.get(attr, None) is not None: d.append(u'{}={}'.format(attr, item.get(attr))) s = u', '.join(d) From 2c2b7600c3e16d08fe3e27470c91c168a4e1b547 Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Wed, 16 Oct 2013 11:43:08 +0200 Subject: [PATCH 04/44] bugfix --- beetsplug/echoplus.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/echoplus.py b/beetsplug/echoplus.py index 1d2a3d61b..c8533e5ea 100644 --- a/beetsplug/echoplus.py +++ b/beetsplug/echoplus.py @@ -267,6 +267,6 @@ class EchoPlusPlugin(BeetsPlugin): def imported(self, config, task): if self.config['auto']: for item in task.imported_items(): - fetch_item_tempo(config.lib, logging.DEBUG, item, False) + fetch_item_attributes(config.lib, logging.DEBUG, item, False) # eof From c7a269d1c5282c5d353c1fe7c871d48bd130c5db Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Wed, 16 Oct 2013 11:53:48 +0200 Subject: [PATCH 05/44] fetch more results to increase the chance of finding the correct result --- beetsplug/echoplus.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/beetsplug/echoplus.py b/beetsplug/echoplus.py index c8533e5ea..b0418bfd0 100644 --- a/beetsplug/echoplus.py +++ b/beetsplug/echoplus.py @@ -175,7 +175,7 @@ def get_audio_summary(artist, title): # EchoNest supports foreign ids from MusicBrainz, but currently # only for artists, not individual tracks/recordings. results = pyechonest.song.search( - artist=artist, title=title, results=1, + artist=artist, title=title, results=100, buckets=['audio_summary'] ) except pyechonest.util.EchoNestAPIError as e: @@ -249,17 +249,17 @@ class EchoPlusPlugin(BeetsPlugin): for item in lib.items(ui.decargs(args)): fetch_item_attributes(lib, logging.INFO, item, write) if opts.printinfo: - d = [] attrs = [ a for a in ATTRIBUTES ] if config['echoplus']['guess_mood'].get(bool): attrs.append('mood') + d = [] for attr in attrs: if item.get(attr, None) is not None: d.append(u'{}={}'.format(attr, item.get(attr))) s = u', '.join(d) if s == u'': s = u'no information received' - ui.print_(u'{}-{}: {}'.format(item.artist, item.title, s)) + ui.print_(u'{} - {}: {}'.format(item.artist, item.title, s)) cmd.func = func return [cmd] From abf2a17f4276141e5366a8b54c720dc2f1ded0ec Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Wed, 16 Oct 2013 12:12:41 +0200 Subject: [PATCH 06/44] bugfixes --- beetsplug/echoplus.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/beetsplug/echoplus.py b/beetsplug/echoplus.py index b0418bfd0..8f6602903 100644 --- a/beetsplug/echoplus.py +++ b/beetsplug/echoplus.py @@ -37,7 +37,7 @@ ATTRIBUTES = ['energy', 'liveness', 'speechiness', 'acousticness', ATTRIBUTES_WITH_STYLE = ['energy', 'liveness', 'speechiness', 'acousticness', 'danceability', 'valence' ] -def apply_style(style, custom, value): +def _apply_style(style, custom, value): if style == 'raw': return value mapping = None @@ -60,7 +60,7 @@ def apply_style(style, custom, value): return mapping[i] return mapping[i] -def guess_mood(valence, energy): +def _guess_mood(valence, energy): # for an explanation see: # http://developer.echonest.com/forums/thread/1297 # i picked a Valence-Arousal space from here: @@ -125,7 +125,7 @@ def fetch_item_attributes(lib, loglevel, item, write): attr, item.artist, item.title, item.get(attr))) else: if 'valence' in audio_summary and 'energy' in audio_summary: - item[attr] = guess_mode(audio_summary['valence'], + item[attr] = _guess_mood(audio_summary['valence'], audio_summary['energy']) changed = True for attr in ATTRIBUTES: @@ -149,7 +149,7 @@ def fetch_item_attributes(lib, loglevel, item, write): style = global_style if custom_style is None: custom_style = global_custom_style - value = apply_style(style, custom_style, value) + value = _apply_style(style, custom_style, value) log.debug(loglevel, u'mapped {}: {} - {} = {}'.format( attr, item.artist, item.title, audio_summary[attr])) item[attr] = value @@ -164,8 +164,8 @@ def get_audio_summary(artist, title): """Get the attribute for a song.""" # We must have sufficient metadata for the lookup. Otherwise the API # will just complain. - artist = artist.replace(u'\n', u' ').strip() - title = title.replace(u'\n', u' ').strip() + artist = artist.replace(u'\n', u' ').strip().lower() + title = title.replace(u'\n', u' ').strip().lower() if not artist or not title: return None @@ -201,8 +201,9 @@ def get_audio_summary(artist, title): # artist and title. The API also doesn't have MusicBrainz track IDs; # otherwise we could use those for a more robust match. for result in results: - if result.artist_name == artist and result.title == title: - return results[0].audio_summary + log.debug(u'result: {} - {}'.format(result.artist_name, result.title)) + if result.artist_name.lower() == artist and result.title.lower() == title: + return result.audio_summary class EchoPlusPlugin(BeetsPlugin): From 6dd827964b55d3bb8102f2a95a29ddb24a2d2671 Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Wed, 16 Oct 2013 13:48:04 +0200 Subject: [PATCH 07/44] bugfix for opts --- beetsplug/echoplus.py | 51 ++++++++++++++++++++++++++----------------- 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/beetsplug/echoplus.py b/beetsplug/echoplus.py index 8f6602903..6a666c39b 100644 --- a/beetsplug/echoplus.py +++ b/beetsplug/echoplus.py @@ -50,7 +50,7 @@ def _apply_style(style, custom, value): elif style == 'hr3': # human readable mapping = ['low', 'neutral', 'high'] if mapping is None: - log.error(loglevel, u'Unsupported style setting: {}'.format(style)) + log.error(u'Unsupported style setting: {}'.format(style)) return value inc = 1.0 / len(mapping) cut = 0.0 @@ -87,14 +87,13 @@ def _guess_mood(valence, energy): return mapping[i] -def fetch_item_attributes(lib, loglevel, item, write): +def fetch_item_attributes(lib, loglevel, item, write, force): """Fetch and store tempo for a single item. If ``write``, then the tempo will also be written to the file itself in the bpm field. The ``loglevel`` parameter controls the visibility of the function's status log messages. """ # Check if we need to update - force = config['echoplus']['force'].get(bool) guess_mood = config['echoplus']['guess_mood'].get(bool) if force: require_update = True @@ -110,10 +109,10 @@ def fetch_item_attributes(lib, loglevel, item, write): if item.get('mood', None) is None: require_update = True if not require_update: - log.debug(loglevel, u'no update required for: {} - {}'.format( + log.log(loglevel, u'no update required for: {} - {}'.format( item.artist, item.title)) return - audio_summary = get_audio_summary(item.artist, item.title) + audio_summary = get_audio_summary(item.artist, item.title, item.length) global_style = config['echoplus']['style'].get() global_custom_style = config['echoplus']['custom_style'].get() if audio_summary: @@ -121,7 +120,7 @@ def fetch_item_attributes(lib, loglevel, item, write): if guess_mood: attr = 'mood' if item.get(attr, None) is not None and not force: - log.debug(loglevel, u'{} already present, use the force Luke: {} - {} = {}'.format( + log.log(loglevel, u'{} already present, use the force Luke: {} - {} = {}'.format( attr, item.artist, item.title, item.get(attr))) else: if 'valence' in audio_summary and 'energy' in audio_summary: @@ -132,14 +131,14 @@ def fetch_item_attributes(lib, loglevel, item, write): if config['echoplus'][attr].get(str) == '': continue if item.get(attr, None) is not None and not force: - log.debug(loglevel, u'{} already present, use the force Luke: {} - {} = {}'.format( + log.log(loglevel, u'{} already present, use the force Luke: {} - {} = {}'.format( attr, item.artist, item.title, item.get(attr))) else: if not attr in audio_summary or audio_summary[attr] is None: - log.debug(loglevel, u'{} not found: {} - {}'.format( + log.log(loglevel, u'{} not found: {} - {}'.format( attr, item.artist, item.title)) else: - log.debug(loglevel, u'fetched {}: {} - {} = {}'.format( + log.log(loglevel, u'fetched {}: {} - {} = {}'.format( attr, item.artist, item.title, audio_summary[attr])) value = float(audio_summary[attr]) if attr in ATTRIBUTES_WITH_STYLE: @@ -150,7 +149,7 @@ def fetch_item_attributes(lib, loglevel, item, write): if custom_style is None: custom_style = global_custom_style value = _apply_style(style, custom_style, value) - log.debug(loglevel, u'mapped {}: {} - {} = {}'.format( + log.log(loglevel, u'mapped {}: {} - {} = {}'.format( attr, item.artist, item.title, audio_summary[attr])) item[attr] = value changed = True @@ -160,7 +159,7 @@ def fetch_item_attributes(lib, loglevel, item, write): item.store() -def get_audio_summary(artist, title): +def get_audio_summary(artist, title, duration): """Get the attribute for a song.""" # We must have sufficient metadata for the lookup. Otherwise the API # will just complain. @@ -183,27 +182,36 @@ def get_audio_summary(artist, title): # Wait and try again. time.sleep(RETRY_INTERVAL) else: - log.warn(u'echonest: {0}'.format(e.args[0][0])) + log.warn(u'echoplus: {0}'.format(e.args[0][0])) return None except (pyechonest.util.EchoNestIOError, socket.error) as e: - log.debug(u'echonest: IO error: {0}'.format(e)) + log.debug(u'echoplus: IO error: {0}'.format(e)) time.sleep(RETRY_INTERVAL) else: break else: # If we exited the loop without breaking, then we used up all # our allotted retries. - log.debug(u'echonest: exceeded retries') + log.debug(u'echoplus: exceeded retries') return None # The Echo Nest API can return songs that are not perfect matches. # So we look through the results for songs that have the right # artist and title. The API also doesn't have MusicBrainz track IDs; # otherwise we could use those for a more robust match. + min_distance = duration + pick = None for result in results: - log.debug(u'result: {} - {}'.format(result.artist_name, result.title)) - if result.artist_name.lower() == artist and result.title.lower() == title: - return result.audio_summary + distance = abs(duration - result.audio_summary['duration']) + log.debug( + u'echoplus: candidate {} - {} [dist({:2.2f}-{:2.2f})={:2.2f}] = {}'.format( + result.artist_name, result.title, + result.audio_summary['duration'], duration, distance, + result.audio_summary)) + if distance < min_distance: + min_distance = distance + pick = result.audio_summary + return pick class EchoPlusPlugin(BeetsPlugin): @@ -241,14 +249,16 @@ class EchoPlusPlugin(BeetsPlugin): help='print fetched information to console') cmd.parser.add_option('-f', '--force', dest='force', action='store_true', default=False, - help='re-download information from the echonest') + help='re-download information from the EchoNest') def func(lib, opts, args): # The "write to files" option corresponds to the # import_write config value. write = config['import']['write'].get(bool) + self.config.set_args(opts) for item in lib.items(ui.decargs(args)): - fetch_item_attributes(lib, logging.INFO, item, write) + fetch_item_attributes(lib, logging.INFO, item, write, + self.config['force']) if opts.printinfo: attrs = [ a for a in ATTRIBUTES ] if config['echoplus']['guess_mood'].get(bool): @@ -268,6 +278,7 @@ class EchoPlusPlugin(BeetsPlugin): def imported(self, config, task): if self.config['auto']: for item in task.imported_items(): - fetch_item_attributes(config.lib, logging.DEBUG, item, False) + fetch_item_attributes(config.lib, logging.DEBUG, item, False, + self.config['force']) # eof From f200cb0825d0d7d599212ea543f04dc148cca82e Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Wed, 16 Oct 2013 16:08:00 +0200 Subject: [PATCH 08/44] upload for the win --- beetsplug/echoplus.py | 52 ++++++++++++++++++++++++++++++++----------- 1 file changed, 39 insertions(+), 13 deletions(-) diff --git a/beetsplug/echoplus.py b/beetsplug/echoplus.py index 6a666c39b..114ef62e6 100644 --- a/beetsplug/echoplus.py +++ b/beetsplug/echoplus.py @@ -16,7 +16,7 @@ # included in all copies or substantial portions of the Software. """Gets additional information for imported music from the EchoNest API. Requires -the pyechonest library (https://github.com/echonest/pyechonest). +version >= 8.0.1 of the pyechonest library (https://github.com/echonest/pyechonest). """ import time import logging @@ -25,6 +25,7 @@ from beets import ui from beets import config import pyechonest.config import pyechonest.song +import pyechonest.track import socket # Global logger. @@ -95,6 +96,7 @@ def fetch_item_attributes(lib, loglevel, item, write, force): """ # Check if we need to update guess_mood = config['echoplus']['guess_mood'].get(bool) + allow_upload = config['echoplus']['upload'].get(bool) if force: require_update = True else: @@ -112,7 +114,8 @@ def fetch_item_attributes(lib, loglevel, item, write, force): log.log(loglevel, u'no update required for: {} - {}'.format( item.artist, item.title)) return - audio_summary = get_audio_summary(item.artist, item.title, item.length) + audio_summary = get_audio_summary(item.artist, item.title, item.length, + allow_upload, item.path) global_style = config['echoplus']['style'].get() global_custom_style = config['echoplus']['custom_style'].get() if audio_summary: @@ -131,14 +134,14 @@ def fetch_item_attributes(lib, loglevel, item, write, force): if config['echoplus'][attr].get(str) == '': continue if item.get(attr, None) is not None and not force: - log.log(loglevel, u'{} already present, use the force Luke: {} - {} = {}'.format( + log.log(loglevel, u'{} already present: {} - {} = {}'.format( attr, item.artist, item.title, item.get(attr))) else: if not attr in audio_summary or audio_summary[attr] is None: log.log(loglevel, u'{} not found: {} - {}'.format( attr, item.artist, item.title)) else: - log.log(loglevel, u'fetched {}: {} - {} = {}'.format( + log.debug(u'fetched {}: {} - {} = {}'.format( attr, item.artist, item.title, audio_summary[attr])) value = float(audio_summary[attr]) if attr in ATTRIBUTES_WITH_STYLE: @@ -149,8 +152,8 @@ def fetch_item_attributes(lib, loglevel, item, write, force): if custom_style is None: custom_style = global_custom_style value = _apply_style(style, custom_style, value) - log.log(loglevel, u'mapped {}: {} - {} = {}'.format( - attr, item.artist, item.title, audio_summary[attr])) + log.debug(u'mapped {}: {} - {} = {}'.format( + attr, item.artist, item.title, value)) item[attr] = value changed = True if changed: @@ -159,7 +162,7 @@ def fetch_item_attributes(lib, loglevel, item, write, force): item.store() -def get_audio_summary(artist, title, duration): +def get_audio_summary(artist, title, duration, upload, path): """Get the attribute for a song.""" # We must have sufficient metadata for the lookup. Otherwise the API # will just complain. @@ -204,14 +207,36 @@ def get_audio_summary(artist, title, duration): for result in results: distance = abs(duration - result.audio_summary['duration']) log.debug( - u'echoplus: candidate {} - {} [dist({:2.2f}-{:2.2f})={:2.2f}] = {}'.format( + u'echoplus: candidate {} - {} [dist({:2.2f}-{:2.2f})={:2.2f}]'.format( result.artist_name, result.title, - result.audio_summary['duration'], duration, distance, - result.audio_summary)) + result.audio_summary['duration'], duration, distance)) if distance < min_distance: min_distance = distance - pick = result.audio_summary - return pick + pick = result + log.debug( + u'echoplus: picked {} - {} [dist({:2.2f}-{:2.2f})={:2.2f}] = {}'.format( + pick.artist_name, pick.title, + pick.audio_summary['duration'], duration, min_distance, + pick.audio_summary)) + if min_distance > 1.0 and upload: + log.debug(u'uploading file to EchoNest') + t = pyechonest.track.track_from_filename(path) + if t: + log.debug(u'{} - {} [{:2.2f}]'.format(t.artist, t.title, + t.duration)) + # FIXME: maybe make pyechonest "nicer"? + result = {} + result['energy'] = t.energy + result['liveness'] = t.liveness + result['speechiness'] = t.speechiness + result['acousticness'] = t.acousticness + result['danceability'] = t.danceability + result['valence'] = t.valence + result['tempo'] = t.tempo + return result + else: + log.debug(u'upload did not return a result') + return pick.audio_summary class EchoPlusPlugin(BeetsPlugin): @@ -226,6 +251,7 @@ class EchoPlusPlugin(BeetsPlugin): 'force': False, 'printinfo': True, 'guess_mood': False, + 'upload': False, }) for attr in ATTRIBUTES: if attr == 'tempo': @@ -270,7 +296,7 @@ class EchoPlusPlugin(BeetsPlugin): s = u', '.join(d) if s == u'': s = u'no information received' - ui.print_(u'{} - {}: {}'.format(item.artist, item.title, s)) + ui.print_(u'{}: {}'.format(item.path, s)) cmd.func = func return [cmd] From 78e75317a7a6d70a1c3682c9195f050122873e9b Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Wed, 16 Oct 2013 16:31:26 +0200 Subject: [PATCH 09/44] bugfixes for import --- beetsplug/echoplus.py | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/beetsplug/echoplus.py b/beetsplug/echoplus.py index 114ef62e6..73bbdbcda 100644 --- a/beetsplug/echoplus.py +++ b/beetsplug/echoplus.py @@ -114,11 +114,14 @@ def fetch_item_attributes(lib, loglevel, item, write, force): log.log(loglevel, u'no update required for: {} - {}'.format( item.artist, item.title)) return + else: + log.debug(u'echoplus for: {} - {}'.format( + item.artist, item.title)) audio_summary = get_audio_summary(item.artist, item.title, item.length, allow_upload, item.path) - global_style = config['echoplus']['style'].get() - global_custom_style = config['echoplus']['custom_style'].get() if audio_summary: + global_style = config['echoplus']['style'].get() + global_custom_style = config['echoplus']['custom_style'].get() changed = False if guess_mood: attr = 'mood' @@ -213,12 +216,13 @@ def get_audio_summary(artist, title, duration, upload, path): if distance < min_distance: min_distance = distance pick = result - log.debug( - u'echoplus: picked {} - {} [dist({:2.2f}-{:2.2f})={:2.2f}] = {}'.format( - pick.artist_name, pick.title, - pick.audio_summary['duration'], duration, min_distance, - pick.audio_summary)) - if min_distance > 1.0 and upload: + if pick: + log.debug( + u'echoplus: picked {} - {} [dist({:2.2f}-{:2.2f})={:2.2f}] = {}'.format( + pick.artist_name, pick.title, + pick.audio_summary['duration'], duration, min_distance, + pick.audio_summary)) + if (not pick or min_distance > 1.0) and upload: log.debug(u'uploading file to EchoNest') t = pyechonest.track.track_from_filename(path) if t: @@ -236,6 +240,8 @@ def get_audio_summary(artist, title, duration, upload, path): return result else: log.debug(u'upload did not return a result') + elif not pick: + return None return pick.audio_summary @@ -300,11 +306,17 @@ class EchoPlusPlugin(BeetsPlugin): cmd.func = func return [cmd] - # Auto-fetch tempo on import. - def imported(self, config, task): + # Auto-fetch info on import. + def imported(self, session, task): if self.config['auto']: - for item in task.imported_items(): - fetch_item_attributes(config.lib, logging.DEBUG, item, False, + if task.is_album: + album = session.lib.get_album(task.album_id) + for item in album.items(): + fetch_item_attributes(session.lib, logging.DEBUG, item, False, + self.config['force']) + else: + item = task.item + fetch_item_attributes(session.lib, logging.DEBUG, item, False, self.config['force']) # eof From c5a9a66ad8d656754dd50d9a06fff1d6619223d1 Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Wed, 16 Oct 2013 16:46:41 +0200 Subject: [PATCH 10/44] bugfixes for candidate detection --- beetsplug/echoplus.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/beetsplug/echoplus.py b/beetsplug/echoplus.py index 73bbdbcda..28e32f758 100644 --- a/beetsplug/echoplus.py +++ b/beetsplug/echoplus.py @@ -97,6 +97,9 @@ def fetch_item_attributes(lib, loglevel, item, write, force): # Check if we need to update guess_mood = config['echoplus']['guess_mood'].get(bool) allow_upload = config['echoplus']['upload'].get(bool) + if allow_upload and \ + item.format.lower() not in ['wav', 'mp3', 'au', 'ogg', 'mp4', 'm4a']: + allow_upload = False if force: require_update = True else: @@ -208,14 +211,16 @@ def get_audio_summary(artist, title, duration, upload, path): min_distance = duration pick = None for result in results: - distance = abs(duration - result.audio_summary['duration']) - log.debug( - u'echoplus: candidate {} - {} [dist({:2.2f}-{:2.2f})={:2.2f}]'.format( - result.artist_name, result.title, - result.audio_summary['duration'], duration, distance)) - if distance < min_distance: - min_distance = distance - pick = result + if result.artist_name.lower() == artist \ + and result.title.lower() == title: + distance = abs(duration - result.audio_summary['duration']) + log.debug( + u'echoplus: candidate {} - {} [dist({:2.2f}-{:2.2f})={:2.2f}]'.format( + result.artist_name, result.title, + result.audio_summary['duration'], duration, distance)) + if distance < min_distance: + min_distance = distance + pick = result if pick: log.debug( u'echoplus: picked {} - {} [dist({:2.2f}-{:2.2f})={:2.2f}] = {}'.format( From 8215e81ceace6a7336b188ecb53c091ac7098d0e Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Wed, 16 Oct 2013 17:12:24 +0200 Subject: [PATCH 11/44] nicer debug output --- beetsplug/echoplus.py | 66 +++++++++++++++++++++++++++++-------------- 1 file changed, 45 insertions(+), 21 deletions(-) diff --git a/beetsplug/echoplus.py b/beetsplug/echoplus.py index 28e32f758..61ac52ced 100644 --- a/beetsplug/echoplus.py +++ b/beetsplug/echoplus.py @@ -135,20 +135,22 @@ def fetch_item_attributes(lib, loglevel, item, write, force): if 'valence' in audio_summary and 'energy' in audio_summary: item[attr] = _guess_mood(audio_summary['valence'], audio_summary['energy']) + log.debug(u'mapped {}: {} - {} = {:2.2f} x {:2.2f} > {}'.format( + attr, item.artist, item.title, + audio_summary['valence'], audio_summary['energy'], + item[attr])) changed = True for attr in ATTRIBUTES: if config['echoplus'][attr].get(str) == '': continue if item.get(attr, None) is not None and not force: - log.log(loglevel, u'{} already present: {} - {} = {}'.format( + log.log(loglevel, u'{} already present: {} - {} = {:2.2f}'.format( attr, item.artist, item.title, item.get(attr))) else: if not attr in audio_summary or audio_summary[attr] is None: log.log(loglevel, u'{} not found: {} - {}'.format( attr, item.artist, item.title)) else: - log.debug(u'fetched {}: {} - {} = {}'.format( - attr, item.artist, item.title, audio_summary[attr])) value = float(audio_summary[attr]) if attr in ATTRIBUTES_WITH_STYLE: style = config['echoplus']['{}_style'.format(attr)].get() @@ -157,9 +159,13 @@ def fetch_item_attributes(lib, loglevel, item, write, force): style = global_style if custom_style is None: custom_style = global_custom_style - value = _apply_style(style, custom_style, value) - log.debug(u'mapped {}: {} - {} = {}'.format( - attr, item.artist, item.title, value)) + mapped_value = _apply_style(style, custom_style, value) + log.debug(u'mapped {}: {} - {} = {:2.2f} > {}'.format( + attr, item.artist, item.title, value, mapped_value)) + value = mapped_value + else: + log.debug(u'fetched {}: {} - {} = {:2.2f}'.format( + attr, item.artist, item.title, audio_summary[attr])) item[attr] = value changed = True if changed: @@ -229,22 +235,40 @@ def get_audio_summary(artist, title, duration, upload, path): pick.audio_summary)) if (not pick or min_distance > 1.0) and upload: log.debug(u'uploading file to EchoNest') - t = pyechonest.track.track_from_filename(path) - if t: - log.debug(u'{} - {} [{:2.2f}]'.format(t.artist, t.title, - t.duration)) - # FIXME: maybe make pyechonest "nicer"? - result = {} - result['energy'] = t.energy - result['liveness'] = t.liveness - result['speechiness'] = t.speechiness - result['acousticness'] = t.acousticness - result['danceability'] = t.danceability - result['valence'] = t.valence - result['tempo'] = t.tempo - return result + # FIXME: same loop as above... make this better + for i in range(RETRIES): + try: + t = pyechonest.track.track_from_filename(path) + if t: + log.debug(u'{} - {} [{:2.2f}]'.format(t.artist, t.title, + t.duration)) + # FIXME: maybe make pyechonest "nicer"? + result = {} + result['energy'] = t.energy + result['liveness'] = t.liveness + result['speechiness'] = t.speechiness + result['acousticness'] = t.acousticness + result['danceability'] = t.danceability + result['valence'] = t.valence + result['tempo'] = t.tempo + return result + except pyechonest.util.EchoNestAPIError as e: + if e.code == 3: + # Wait and try again. + time.sleep(RETRY_INTERVAL) + else: + log.warn(u'echoplus: {0}'.format(e.args[0][0])) + return None + except (pyechonest.util.EchoNestIOError, socket.error) as e: + log.debug(u'echoplus: IO error: {0}'.format(e)) + time.sleep(RETRY_INTERVAL) + else: + break else: - log.debug(u'upload did not return a result') + # If we exited the loop without breaking, then we used up all + # our allotted retries. + log.debug(u'echoplus: exceeded retries') + return None elif not pick: return None return pick.audio_summary From f3b64e87bcbf4ee9311bd4eb80c6eaa63faf90e3 Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Thu, 17 Oct 2013 09:29:18 +0200 Subject: [PATCH 12/44] sync (plugin is broken) --- beetsplug/echoplus.py | 117 ++++++++++++++++++++++-------------------- 1 file changed, 60 insertions(+), 57 deletions(-) diff --git a/beetsplug/echoplus.py b/beetsplug/echoplus.py index 61ac52ced..f90216101 100644 --- a/beetsplug/echoplus.py +++ b/beetsplug/echoplus.py @@ -27,6 +27,7 @@ import pyechonest.config import pyechonest.song import pyechonest.track import socket +import math # Global logger. log = logging.getLogger('beets') @@ -38,28 +39,19 @@ ATTRIBUTES = ['energy', 'liveness', 'speechiness', 'acousticness', ATTRIBUTES_WITH_STYLE = ['energy', 'liveness', 'speechiness', 'acousticness', 'danceability', 'valence' ] -def _apply_style(style, custom, value): - if style == 'raw': - return value - mapping = None - if style == 'custom': - mapping = [ m.strip() for m in custom.split(',') ] - elif style == '1to5': - mapping = [1, 2, 3, 4, 5] - elif style == 'hr5': # human readable - mapping = ['very low', 'low', 'neutral', 'high', 'very high'] - elif style == 'hr3': # human readable - mapping = ['low', 'neutral', 'high'] - if mapping is None: - log.error(u'Unsupported style setting: {}'.format(style)) - return value - inc = 1.0 / len(mapping) +MAX_LEN = math.sqrt(2.0 * 0.5 * 0.5) + +def _picker(value, rang, mapping): + inc = rang / len(mapping) cut = 0.0 - for i in range(len(mapping)): + for m in mapping: cut += inc if value < cut: - return mapping[i] - return mapping[i] + return m + return m + +def _mapping(mapstr): + return [ m.strip() for m in mapstr.split(',') ] def _guess_mood(valence, energy): # for an explanation see: @@ -67,53 +59,64 @@ def _guess_mood(valence, energy): # i picked a Valence-Arousal space from here: # http://mat.ucsb.edu/~ivana/200a/background.htm - # energy from 0.0 to 1.0. valence < 0.5 - low_valence = [ - 'fatigued', 'lethargic', 'depressed', 'sad', - 'upset', 'stressed', 'nervous', 'tense' ] - # energy from 0.0 to 1.0. valence > 0.5 + # move center to 0/0 + valence -= 0.5 + energy -= 0.5 + length = math.sqrt(valence * valence + energy * energy) + + strength = ['slightly', None, 'very' ] high_valence = [ 'calm', 'relaxed', 'serene', 'contented', 'happy', 'elated', 'excited', 'alert' ] - if valence < 0.5: - mapping = low_valence + low_valence = [ + 'fatigued', 'lethargic', 'depressed', 'sad', + 'upset', 'stressed', 'nervous', 'tense' ] + # energy from -0.5 to 0.5, valence > 0.0 + if length == 0.0: + # FIXME: what now? + return u'neutral' + angle = math.asin(energy / length) + PI_2 + if valence < 0.0: + moods = low_valence else: - mapping = high_valence - inc = 1.0 / len(mapping) - cut = 0.0 - for i in range(len(mapping)): - cut += inc - if energy < cut: - return mapping[i] - return mapping[i] + moods = high_valence + mood = _picker(angle, math.pi, moods) + strength = _picker(length, MAX_LEN, strength) + if strength is None: + return mood + return u'{} {}'.format(strength, mood) - -def fetch_item_attributes(lib, loglevel, item, write, force): - """Fetch and store tempo for a single item. If ``write``, then the - tempo will also be written to the file itself in the bpm field. The - ``loglevel`` parameter controls the visibility of the function's - status log messages. - """ +def fetch_item_attributes(lib, item, write, force, reapply): # Check if we need to update guess_mood = config['echoplus']['guess_mood'].get(bool) allow_upload = config['echoplus']['upload'].get(bool) + store_raw = config['echoplus']['store_raw'].get(bool) + # force implies reapply + if force: + reapply = True + # EchoNest only supports these file formats if allow_upload and \ item.format.lower() not in ['wav', 'mp3', 'au', 'ogg', 'mp4', 'm4a']: allow_upload = False + + do_update if force: - require_update = True + do_update = True else: - require_update = False + do_update = False for attr in ATTRIBUTES: + # do we want this attribute? if config['echoplus'][attr].get(str) == '': continue + if store_raw: # only check if the raw values are present + attr = '{}_raw'.format(attr) if item.get(attr, None) is None: - require_update = True + do_update = True break - if not require_update and guess_mood: + if not do_update and guess_mood: if item.get('mood', None) is None: - require_update = True - if not require_update: + do_update = True + if do_update: log.log(loglevel, u'no update required for: {} - {}'.format( item.artist, item.title)) return @@ -281,9 +284,8 @@ class EchoPlusPlugin(BeetsPlugin): self.config.add({ 'apikey': u'NY2KTZHQ0QDSHBAP6', 'auto': True, - 'style': 'hr5', - 'custom_style': None, - 'force': False, + 'mapping': 'very low,low,neutral,high,very high', + 'store_raw': True, 'printinfo': True, 'guess_mood': False, 'upload': False, @@ -295,8 +297,7 @@ class EchoPlusPlugin(BeetsPlugin): else: target = attr self.config.add({attr:target, - '{}_style'.format(attr):None, - '{}_custom_style'.format(attr):None, + '{}_mapping'.format(attr):None, }) pyechonest.config.ECHO_NEST_API_KEY = \ @@ -311,6 +312,9 @@ class EchoPlusPlugin(BeetsPlugin): cmd.parser.add_option('-f', '--force', dest='force', action='store_true', default=False, help='re-download information from the EchoNest') + cmd.parser.add_option('-r', '--reapply', dest='reapply', + action='store_true', default=False, + help='reapply mappings') def func(lib, opts, args): # The "write to files" option corresponds to the # import_write config value. @@ -319,7 +323,7 @@ class EchoPlusPlugin(BeetsPlugin): for item in lib.items(ui.decargs(args)): fetch_item_attributes(lib, logging.INFO, item, write, - self.config['force']) + self.config['force'], self.config['reapply']) if opts.printinfo: attrs = [ a for a in ATTRIBUTES ] if config['echoplus']['guess_mood'].get(bool): @@ -341,11 +345,10 @@ class EchoPlusPlugin(BeetsPlugin): if task.is_album: album = session.lib.get_album(task.album_id) for item in album.items(): - fetch_item_attributes(session.lib, logging.DEBUG, item, False, - self.config['force']) + fetch_item_attributes(session.lib, item, False, True, + True) else: item = task.item - fetch_item_attributes(session.lib, logging.DEBUG, item, False, - self.config['force']) + fetch_item_attributes(session.lib, item, False, True, True) # eof From b08e68a920283595a497d479d332444446739954 Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Thu, 17 Oct 2013 16:41:43 +0200 Subject: [PATCH 13/44] Save raw values + added reapply switch + better debug output --- beetsplug/echoplus.py | 380 ++++++++++++++++++++++-------------------- 1 file changed, 199 insertions(+), 181 deletions(-) diff --git a/beetsplug/echoplus.py b/beetsplug/echoplus.py index f90216101..ef07a3f25 100644 --- a/beetsplug/echoplus.py +++ b/beetsplug/echoplus.py @@ -35,46 +35,59 @@ log = logging.getLogger('beets') RETRY_INTERVAL = 10 # Seconds. RETRIES = 10 ATTRIBUTES = ['energy', 'liveness', 'speechiness', 'acousticness', - 'danceability', 'valence', 'tempo' ] -ATTRIBUTES_WITH_STYLE = ['energy', 'liveness', 'speechiness', 'acousticness', - 'danceability', 'valence' ] + 'danceability', 'valence', 'tempo', 'mood' ] +MAPPED_ATTRIBUTES = ['energy', 'liveness', 'speechiness', 'acousticness', + 'danceability', 'valence', 'mood' ] +PI_2 = math.pi / 2.0 MAX_LEN = math.sqrt(2.0 * 0.5 * 0.5) def _picker(value, rang, mapping): inc = rang / len(mapping) - cut = 0.0 + i = 0.0 for m in mapping: - cut += inc - if value < cut: + i += inc + if value < i: return m - return m + return m # in case of floating point precision problems def _mapping(mapstr): - return [ m.strip() for m in mapstr.split(',') ] + """Split mapstr at comma and returned the stripped values as array.""" + return [ m.strip() for m in mapstr.split(u',') ] def _guess_mood(valence, energy): - # for an explanation see: - # http://developer.echonest.com/forums/thread/1297 - # i picked a Valence-Arousal space from here: - # http://mat.ucsb.edu/~ivana/200a/background.htm + """Based on the valence [0.0 .. 1.0]and energy [0.0 .. 1.0] of a song, we + try to guess the mood. - # move center to 0/0 + For an explanation see: + http://developer.echonest.com/forums/thread/1297 + + We use the Valence-Arousal space from here: + http://mat.ucsb.edu/~ivana/200a/background.htm + """ + + # move center to 0.0/0.0 valence -= 0.5 energy -= 0.5 + + # we use the length of the valence / energy vector to determine the + # strength of the emotion length = math.sqrt(valence * valence + energy * energy) - strength = ['slightly', None, 'very' ] - high_valence = [ - 'calm', 'relaxed', 'serene', 'contented', - 'happy', 'elated', 'excited', 'alert' ] + # FIXME: do we want the next 3 as config options? + strength = [u'slightly', u'', u'very' ] + # energy from -0.5 to 0.5, valence < 0.0 low_valence = [ - 'fatigued', 'lethargic', 'depressed', 'sad', - 'upset', 'stressed', 'nervous', 'tense' ] - # energy from -0.5 to 0.5, valence > 0.0 + u'fatigued', u'lethargic', u'depressed', u'sad', + u'upset', u'stressed', u'nervous', u'tense' ] + # energy from -0.5 to 0.5, valence >= 0.0 + high_valence = [ + u'calm', u'relaxed', u'serene', u'contented', + u'happy', u'elated', u'excited', u'alert' ] if length == 0.0: - # FIXME: what now? + # FIXME: what now? return a fallback? config? return u'neutral' + angle = math.asin(energy / length) + PI_2 if valence < 0.0: moods = low_valence @@ -82,119 +95,140 @@ def _guess_mood(valence, energy): moods = high_valence mood = _picker(angle, math.pi, moods) strength = _picker(length, MAX_LEN, strength) - if strength is None: + if strength == u'': return mood return u'{} {}'.format(strength, mood) -def fetch_item_attributes(lib, item, write, force, reapply): - # Check if we need to update - guess_mood = config['echoplus']['guess_mood'].get(bool) - allow_upload = config['echoplus']['upload'].get(bool) +def fetch_item_attributes(lib, item, write, force, re_apply): + """Fetches audio_summary from the EchoNest and writes it to item. + """ + + log.debug(u'echoplus: {} - {} [{}] force:{} re_apply:{}'.format( + item.artist, item.title, item.length, force, re_apply)) + # permanently store the raw values? store_raw = config['echoplus']['store_raw'].get(bool) - # force implies reapply + + # if we want to set mood, we need to make sure, that valence and energy + # are imported + if config['echoplus']['mood'].get(str): + if config['echoplus']['valence'].get(str) == '': + log.warn(u'echoplus: "valence" is required to guess the mood') + config['echoplus']['mood'].set('') # disable mood + + if config['echoplus']['energy'].get(str) == '': + log.warn(u'echoplus: "energy" is required to guess the mood') + config['echoplus']['mood'].set('') # disable mood + + # force implies re_apply if force: - reapply = True - # EchoNest only supports these file formats + re_apply = True + + allow_upload = config['echoplus']['upload'].get(bool) + # the EchoNest only supports these file formats if allow_upload and \ item.format.lower() not in ['wav', 'mp3', 'au', 'ogg', 'mp4', 'm4a']: + log.warn(u'echoplus: format {} not supported'.format(item.format)) allow_upload = False - do_update + # Check if we need to update + need_update = False if force: - do_update = True + need_update = True else: - do_update = False + need_update = False for attr in ATTRIBUTES: # do we want this attribute? - if config['echoplus'][attr].get(str) == '': + target = config['echoplus'][attr].get(str) + if target == '': continue - if store_raw: # only check if the raw values are present - attr = '{}_raw'.format(attr) - if item.get(attr, None) is None: - do_update = True + + # check if the raw values are present. 'mood' has no direct raw + # representation and 'tempo' is stored raw anyway + if (store_raw or re_apply) and not attr in ['mood', 'tempo']: + target = '{}_raw'.format(target) + + if item.get(target, None) is None: + need_update = True break - if not do_update and guess_mood: - if item.get('mood', None) is None: - do_update = True - if do_update: - log.log(loglevel, u'no update required for: {} - {}'.format( - item.artist, item.title)) - return - else: - log.debug(u'echoplus for: {} - {}'.format( - item.artist, item.title)) - audio_summary = get_audio_summary(item.artist, item.title, item.length, - allow_upload, item.path) - if audio_summary: - global_style = config['echoplus']['style'].get() - global_custom_style = config['echoplus']['custom_style'].get() + + if need_update: + log.debug(u'echoplus: fetching data') + re_apply = True + + # (re-)fetch audio_summary and store it to the raw values. if we do + # not want to keep the raw values, we clean them up later + + audio_summary = get_audio_summary(item.artist, item.title, + item.length, allow_upload, item.path) changed = False - if guess_mood: - attr = 'mood' - if item.get(attr, None) is not None and not force: - log.log(loglevel, u'{} already present, use the force Luke: {} - {} = {}'.format( - attr, item.artist, item.title, item.get(attr))) - else: - if 'valence' in audio_summary and 'energy' in audio_summary: - item[attr] = _guess_mood(audio_summary['valence'], - audio_summary['energy']) - log.debug(u'mapped {}: {} - {} = {:2.2f} x {:2.2f} > {}'.format( - attr, item.artist, item.title, - audio_summary['valence'], audio_summary['energy'], - item[attr])) - changed = True - for attr in ATTRIBUTES: - if config['echoplus'][attr].get(str) == '': - continue - if item.get(attr, None) is not None and not force: - log.log(loglevel, u'{} already present: {} - {} = {:2.2f}'.format( - attr, item.artist, item.title, item.get(attr))) - else: - if not attr in audio_summary or audio_summary[attr] is None: - log.log(loglevel, u'{} not found: {} - {}'.format( - attr, item.artist, item.title)) + if not audio_summary: + return None + else: + for attr in ATTRIBUTES: + if attr == 'mood': # no raw representation + continue + + # do we want this attribute? + target = config['echoplus'][attr].get(str) + if target == '': + continue + if attr != 'tempo': + target = '{}_raw'.format(target) + + if item.get(target, None) is not None and not force: + log.info(u'{} already present: {} - {} = {:2.2f}'.format( + attr, item.artist, item.title, item.get(target))) else: - value = float(audio_summary[attr]) - if attr in ATTRIBUTES_WITH_STYLE: - style = config['echoplus']['{}_style'.format(attr)].get() - custom_style = config['echoplus']['{}_custom_style'.format(attr)].get() - if style is None: - style = global_style - if custom_style is None: - custom_style = global_custom_style - mapped_value = _apply_style(style, custom_style, value) - log.debug(u'mapped {}: {} - {} = {:2.2f} > {}'.format( - attr, item.artist, item.title, value, mapped_value)) - value = mapped_value + if not attr in audio_summary or audio_summary[attr] is None: + log.info(u'{} not found: {} - {}'.format( attr, + item.artist, item.title)) else: - log.debug(u'fetched {}: {} - {} = {:2.2f}'.format( - attr, item.artist, item.title, audio_summary[attr])) - item[attr] = value - changed = True + value = float(audio_summary[attr]) + item[target] = float(audio_summary[attr]) + changed = True + if re_apply: + log.debug(u'echoplus: reapplying data') + global_mapping = _mapping(config['echoplus']['mapping'].get()) + for attr in ATTRIBUTES: + # do we want this attribute? + target = config['echoplus'][attr].get(str) + if target == '': + continue + if attr == 'mood': + # we validated above, that valence and energy are + # included, so this should not fail + valence = \ + float(item.get('{}_raw'.format(config['echoplus']['valence'].get(str)))) + energy = \ + float(item.get('{}_raw'.format(config['echoplus']['energy'].get(str)))) + item[target] = _guess_mood(valence, energy) + log.debug(u'echoplus: mapped {}: {:2.2f}x{:2.2f} = {}'.format( + attr, valence, energy, item[target])) + changed = True + elif attr in MAPPED_ATTRIBUTES: + mapping = global_mapping + map_str = config['echoplus']['{}_mapping'.format(attr)].get() + if map_str is not None: + mapping = _mapping(map_str) + value = float(item.get('{}_raw'.format(target))) + mapped_value = _picker(value, 1.0, mapping) + log.debug(u'echoplus: mapped {}: {:2.2f} > {}'.format( + attr, value, mapped_value)) + item[attr] = mapped_value + changed = True + if changed: if write: item.write() item.store() - -def get_audio_summary(artist, title, duration, upload, path): - """Get the attribute for a song.""" - # We must have sufficient metadata for the lookup. Otherwise the API - # will just complain. - artist = artist.replace(u'\n', u' ').strip().lower() - title = title.replace(u'\n', u' ').strip().lower() - if not artist or not title: - return None - +def _echonest_fun(function, **kwargs): for i in range(RETRIES): try: # Unfortunately, all we can do is search by artist and title. # EchoNest supports foreign ids from MusicBrainz, but currently # only for artists, not individual tracks/recordings. - results = pyechonest.song.search( - artist=artist, title=title, results=100, - buckets=['audio_summary'] - ) + results = function(**kwargs) except pyechonest.util.EchoNestAPIError as e: if e.code == 3: # Wait and try again. @@ -212,66 +246,62 @@ def get_audio_summary(artist, title, duration, upload, path): # our allotted retries. log.debug(u'echoplus: exceeded retries') return None + return results - # The Echo Nest API can return songs that are not perfect matches. - # So we look through the results for songs that have the right - # artist and title. The API also doesn't have MusicBrainz track IDs; - # otherwise we could use those for a more robust match. - min_distance = duration +def get_audio_summary(artist, title, duration, upload, path): + """Get the attribute for a song.""" + # We must have sufficient metadata for the lookup. Otherwise the API + # will just complain. + artist = artist.replace(u'\n', u' ').strip().lower() + title = title.replace(u'\n', u' ').strip().lower() + if not artist or not title: + return None + + results = _echonest_fun(pyechonest.song.search, + artist=artist, title=title, results=100, + buckets=['audio_summary']) pick = None - for result in results: - if result.artist_name.lower() == artist \ - and result.title.lower() == title: - distance = abs(duration - result.audio_summary['duration']) + min_distance = duration + if results: + # The Echo Nest API can return songs that are not perfect matches. + # So we look through the results for songs that have the right + # artist and title. The API also doesn't have MusicBrainz track IDs; + # otherwise we could use those for a more robust match. + for result in results: + if result.artist_name.lower() == artist \ + and result.title.lower() == title: + distance = abs(duration - result.audio_summary['duration']) + log.debug( + u'echoplus: candidate {} - {} [dist({:2.2f})={:2.2f}]'.format( + result.artist_name, result.title, + result.audio_summary['duration'], distance)) + if distance < min_distance: + min_distance = distance + pick = result + if pick: log.debug( - u'echoplus: candidate {} - {} [dist({:2.2f}-{:2.2f})={:2.2f}]'.format( - result.artist_name, result.title, - result.audio_summary['duration'], duration, distance)) - if distance < min_distance: - min_distance = distance - pick = result - if pick: - log.debug( - u'echoplus: picked {} - {} [dist({:2.2f}-{:2.2f})={:2.2f}] = {}'.format( - pick.artist_name, pick.title, - pick.audio_summary['duration'], duration, min_distance, - pick.audio_summary)) + u'echoplus: picked {} - {} [dist({:2.2f}-{:2.2f})={:2.2f}]'.format( + pick.artist_name, pick.title, + pick.audio_summary['duration'], duration, min_distance)) + if (not pick or min_distance > 1.0) and upload: - log.debug(u'uploading file to EchoNest') + log.debug(u'echoplus: uploading file "{}" to EchoNest'.format(path)) # FIXME: same loop as above... make this better for i in range(RETRIES): - try: - t = pyechonest.track.track_from_filename(path) - if t: - log.debug(u'{} - {} [{:2.2f}]'.format(t.artist, t.title, - t.duration)) - # FIXME: maybe make pyechonest "nicer"? - result = {} - result['energy'] = t.energy - result['liveness'] = t.liveness - result['speechiness'] = t.speechiness - result['acousticness'] = t.acousticness - result['danceability'] = t.danceability - result['valence'] = t.valence - result['tempo'] = t.tempo - return result - except pyechonest.util.EchoNestAPIError as e: - if e.code == 3: - # Wait and try again. - time.sleep(RETRY_INTERVAL) - else: - log.warn(u'echoplus: {0}'.format(e.args[0][0])) - return None - except (pyechonest.util.EchoNestIOError, socket.error) as e: - log.debug(u'echoplus: IO error: {0}'.format(e)) - time.sleep(RETRY_INTERVAL) - else: - break - else: - # If we exited the loop without breaking, then we used up all - # our allotted retries. - log.debug(u'echoplus: exceeded retries') - return None + t = _echonest_fun(pyechonest.track.track_from_filename, filename=path) + if t: + log.debug(u'echoplus: track {} - {} [{:2.2f}]'.format(t.artist, t.title, + t.duration)) + # FIXME: maybe make pyechonest "nicer"? + result = {} + result['energy'] = t.energy + result['liveness'] = t.liveness + result['speechiness'] = t.speechiness + result['acousticness'] = t.acousticness + result['danceability'] = t.danceability + result['valence'] = t.valence + result['tempo'] = t.tempo + return result elif not pick: return None return pick.audio_summary @@ -286,7 +316,6 @@ class EchoPlusPlugin(BeetsPlugin): 'auto': True, 'mapping': 'very low,low,neutral,high,very high', 'store_raw': True, - 'printinfo': True, 'guess_mood': False, 'upload': False, }) @@ -297,7 +326,7 @@ class EchoPlusPlugin(BeetsPlugin): else: target = attr self.config.add({attr:target, - '{}_mapping'.format(attr):None, + '{}_mapping'.format(attr): None, }) pyechonest.config.ECHO_NEST_API_KEY = \ @@ -306,15 +335,12 @@ class EchoPlusPlugin(BeetsPlugin): def commands(self): cmd = ui.Subcommand('echoplus', help='fetch additional song information from the echonest') - cmd.parser.add_option('-p', '--print', dest='printinfo', - action='store_true', default=False, - help='print fetched information to console') cmd.parser.add_option('-f', '--force', dest='force', action='store_true', default=False, help='re-download information from the EchoNest') - cmd.parser.add_option('-r', '--reapply', dest='reapply', + cmd.parser.add_option('-r', '--re_apply', dest='re_apply', action='store_true', default=False, - help='reapply mappings') + help='re_apply mappings') def func(lib, opts, args): # The "write to files" option corresponds to the # import_write config value. @@ -322,20 +348,12 @@ class EchoPlusPlugin(BeetsPlugin): self.config.set_args(opts) for item in lib.items(ui.decargs(args)): - fetch_item_attributes(lib, logging.INFO, item, write, - self.config['force'], self.config['reapply']) - if opts.printinfo: - attrs = [ a for a in ATTRIBUTES ] - if config['echoplus']['guess_mood'].get(bool): - attrs.append('mood') - d = [] - for attr in attrs: - if item.get(attr, None) is not None: - d.append(u'{}={}'.format(attr, item.get(attr))) - s = u', '.join(d) - if s == u'': - s = u'no information received' - ui.print_(u'{}: {}'.format(item.path, s)) + log.debug(u'{} {}'.format( + self.config['force'], + self.config['re_apply'])) + fetch_item_attributes(lib, item, write, + self.config['force'], + self.config['re_apply']) cmd.func = func return [cmd] From b13e9caea712227ac905633a4a5ad3f13e210514 Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Thu, 17 Oct 2013 16:42:41 +0200 Subject: [PATCH 14/44] hint --- beetsplug/echoplus.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/echoplus.py b/beetsplug/echoplus.py index ef07a3f25..4dfa665ac 100644 --- a/beetsplug/echoplus.py +++ b/beetsplug/echoplus.py @@ -127,7 +127,7 @@ def fetch_item_attributes(lib, item, write, force, re_apply): # the EchoNest only supports these file formats if allow_upload and \ item.format.lower() not in ['wav', 'mp3', 'au', 'ogg', 'mp4', 'm4a']: - log.warn(u'echoplus: format {} not supported'.format(item.format)) + log.warn(u'echoplus: format {} not supported for upload'.format(item.format)) allow_upload = False # Check if we need to update From b34a94505167d5f40701a4e2be0b515a4381fbd5 Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Thu, 17 Oct 2013 17:12:15 +0200 Subject: [PATCH 15/44] better(?) defaults --- beetsplug/echoplus.py | 36 ++++++++++++++++++++---------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/beetsplug/echoplus.py b/beetsplug/echoplus.py index 4dfa665ac..285dde6a0 100644 --- a/beetsplug/echoplus.py +++ b/beetsplug/echoplus.py @@ -99,12 +99,12 @@ def _guess_mood(valence, energy): return mood return u'{} {}'.format(strength, mood) -def fetch_item_attributes(lib, item, write, force, re_apply): +def fetch_item_attributes(lib, item, write, force, reapply): """Fetches audio_summary from the EchoNest and writes it to item. """ - log.debug(u'echoplus: {} - {} [{}] force:{} re_apply:{}'.format( - item.artist, item.title, item.length, force, re_apply)) + log.debug(u'echoplus: {} - {} [{}] force:{} reapply:{}'.format( + item.artist, item.title, item.length, force, reapply)) # permanently store the raw values? store_raw = config['echoplus']['store_raw'].get(bool) @@ -119,9 +119,9 @@ def fetch_item_attributes(lib, item, write, force, re_apply): log.warn(u'echoplus: "energy" is required to guess the mood') config['echoplus']['mood'].set('') # disable mood - # force implies re_apply + # force implies reapply if force: - re_apply = True + reapply = True allow_upload = config['echoplus']['upload'].get(bool) # the EchoNest only supports these file formats @@ -144,7 +144,7 @@ def fetch_item_attributes(lib, item, write, force, re_apply): # check if the raw values are present. 'mood' has no direct raw # representation and 'tempo' is stored raw anyway - if (store_raw or re_apply) and not attr in ['mood', 'tempo']: + if (store_raw or reapply) and not attr in ['mood', 'tempo']: target = '{}_raw'.format(target) if item.get(target, None) is None: @@ -153,7 +153,7 @@ def fetch_item_attributes(lib, item, write, force, re_apply): if need_update: log.debug(u'echoplus: fetching data') - re_apply = True + reapply = True # (re-)fetch audio_summary and store it to the raw values. if we do # not want to keep the raw values, we clean them up later @@ -186,8 +186,8 @@ def fetch_item_attributes(lib, item, write, force, re_apply): value = float(audio_summary[attr]) item[target] = float(audio_summary[attr]) changed = True - if re_apply: - log.debug(u'echoplus: reapplying data') + if reapply: + log.debug(u'echoplus: (re-)applying data') global_mapping = _mapping(config['echoplus']['mapping'].get()) for attr in ATTRIBUTES: # do we want this attribute? @@ -315,6 +315,12 @@ class EchoPlusPlugin(BeetsPlugin): 'apikey': u'NY2KTZHQ0QDSHBAP6', 'auto': True, 'mapping': 'very low,low,neutral,high,very high', + 'energy_mapping': None, + 'liveness_mapping': 'studio,strange,stage', + 'speechiness_mapping': 'sing,rap,talk', + 'acousticness_mapping': 'electric,natural', + 'danceability_mapping': 'couch,party', + 'valence_mapping': None, 'store_raw': True, 'guess_mood': False, 'upload': False, @@ -325,9 +331,7 @@ class EchoPlusPlugin(BeetsPlugin): self.config.add({attr:target}) else: target = attr - self.config.add({attr:target, - '{}_mapping'.format(attr): None, - }) + self.config.add({attr:target}) pyechonest.config.ECHO_NEST_API_KEY = \ self.config['apikey'].get(unicode) @@ -338,9 +342,9 @@ class EchoPlusPlugin(BeetsPlugin): cmd.parser.add_option('-f', '--force', dest='force', action='store_true', default=False, help='re-download information from the EchoNest') - cmd.parser.add_option('-r', '--re_apply', dest='re_apply', + cmd.parser.add_option('-r', '--reapply', dest='reapply', action='store_true', default=False, - help='re_apply mappings') + help='reapply mappings') def func(lib, opts, args): # The "write to files" option corresponds to the # import_write config value. @@ -350,10 +354,10 @@ class EchoPlusPlugin(BeetsPlugin): for item in lib.items(ui.decargs(args)): log.debug(u'{} {}'.format( self.config['force'], - self.config['re_apply'])) + self.config['reapply'])) fetch_item_attributes(lib, item, write, self.config['force'], - self.config['re_apply']) + self.config['reapply']) cmd.func = func return [cmd] From f790b7cb1589fcc73dabf398ed36583e6bdc417f Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Thu, 17 Oct 2013 22:02:20 +0200 Subject: [PATCH 16/44] renamed mappings --- beetsplug/echoplus.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/beetsplug/echoplus.py b/beetsplug/echoplus.py index 285dde6a0..7706aa6a0 100644 --- a/beetsplug/echoplus.py +++ b/beetsplug/echoplus.py @@ -316,13 +316,12 @@ class EchoPlusPlugin(BeetsPlugin): 'auto': True, 'mapping': 'very low,low,neutral,high,very high', 'energy_mapping': None, - 'liveness_mapping': 'studio,strange,stage', - 'speechiness_mapping': 'sing,rap,talk', - 'acousticness_mapping': 'electric,natural', - 'danceability_mapping': 'couch,party', + 'liveness_mapping': 'studio,probably studio,probably live,live', + 'speechiness_mapping': 'sing,unsure,talk', + 'acousticness_mapping': 'artificial,probably artifical,probably natural,natural', + 'danceability_mapping': 'bed,couch,unsure,party,disco', 'valence_mapping': None, 'store_raw': True, - 'guess_mood': False, 'upload': False, }) for attr in ATTRIBUTES: From bd7a56bf07b017829346ca01ebcd487869d50b1d Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Thu, 17 Oct 2013 22:27:37 +0200 Subject: [PATCH 17/44] renamed mappings --- beetsplug/echoplus.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/echoplus.py b/beetsplug/echoplus.py index 7706aa6a0..0045afce3 100644 --- a/beetsplug/echoplus.py +++ b/beetsplug/echoplus.py @@ -317,7 +317,7 @@ class EchoPlusPlugin(BeetsPlugin): 'mapping': 'very low,low,neutral,high,very high', 'energy_mapping': None, 'liveness_mapping': 'studio,probably studio,probably live,live', - 'speechiness_mapping': 'sing,unsure,talk', + 'speechiness_mapping': 'singing,unsure,talking', 'acousticness_mapping': 'artificial,probably artifical,probably natural,natural', 'danceability_mapping': 'bed,couch,unsure,party,disco', 'valence_mapping': None, From 491dff1f09b5e03441eaf9a409e7d0b26796029b Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Thu, 17 Oct 2013 22:48:40 +0200 Subject: [PATCH 18/44] disabled tempo --- beetsplug/echoplus.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/echoplus.py b/beetsplug/echoplus.py index 0045afce3..b0d87a38a 100644 --- a/beetsplug/echoplus.py +++ b/beetsplug/echoplus.py @@ -326,7 +326,7 @@ class EchoPlusPlugin(BeetsPlugin): }) for attr in ATTRIBUTES: if attr == 'tempo': - target = 'bpm' + target = '' # disabled to not conflict with echonest_tempo self.config.add({attr:target}) else: target = attr From f3935e8aead98cc87e5ae3fb7146ad4309275758 Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Thu, 17 Oct 2013 22:49:27 +0200 Subject: [PATCH 19/44] disabled tempo --- beetsplug/echoplus.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/beetsplug/echoplus.py b/beetsplug/echoplus.py index b0d87a38a..2386a68f3 100644 --- a/beetsplug/echoplus.py +++ b/beetsplug/echoplus.py @@ -326,7 +326,8 @@ class EchoPlusPlugin(BeetsPlugin): }) for attr in ATTRIBUTES: if attr == 'tempo': - target = '' # disabled to not conflict with echonest_tempo + target = '' # disabled to not conflict with echonest_tempo, + # to enable, set it to 'bpm' self.config.add({attr:target}) else: target = attr From 8700736020706f0b69893235664d95b0457acbbc Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Fri, 18 Oct 2013 00:12:16 +0200 Subject: [PATCH 20/44] bugfix for too many upload retires + added comments --- beetsplug/echoplus.py | 37 ++++++++++++++++++------------------- docs/faq.rst | 6 +++--- 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/beetsplug/echoplus.py b/beetsplug/echoplus.py index 2386a68f3..83505f06c 100644 --- a/beetsplug/echoplus.py +++ b/beetsplug/echoplus.py @@ -52,7 +52,7 @@ def _picker(value, rang, mapping): return m # in case of floating point precision problems def _mapping(mapstr): - """Split mapstr at comma and returned the stripped values as array.""" + """Split mapstr at comma and return the stripped values as array.""" return [ m.strip() for m in mapstr.split(u',') ] def _guess_mood(valence, energy): @@ -105,7 +105,7 @@ def fetch_item_attributes(lib, item, write, force, reapply): log.debug(u'echoplus: {} - {} [{}] force:{} reapply:{}'.format( item.artist, item.title, item.length, force, reapply)) - # permanently store the raw values? + # permanently store the raw values? not supported yet store_raw = config['echoplus']['store_raw'].get(bool) # if we want to set mood, we need to make sure, that valence and energy @@ -156,7 +156,8 @@ def fetch_item_attributes(lib, item, write, force, reapply): reapply = True # (re-)fetch audio_summary and store it to the raw values. if we do - # not want to keep the raw values, we clean them up later + # not want to keep the raw values, we clean them up later (not + # implemented yet) audio_summary = get_audio_summary(item.artist, item.title, item.length, allow_upload, item.path) @@ -286,22 +287,20 @@ def get_audio_summary(artist, title, duration, upload, path): if (not pick or min_distance > 1.0) and upload: log.debug(u'echoplus: uploading file "{}" to EchoNest'.format(path)) - # FIXME: same loop as above... make this better - for i in range(RETRIES): - t = _echonest_fun(pyechonest.track.track_from_filename, filename=path) - if t: - log.debug(u'echoplus: track {} - {} [{:2.2f}]'.format(t.artist, t.title, - t.duration)) - # FIXME: maybe make pyechonest "nicer"? - result = {} - result['energy'] = t.energy - result['liveness'] = t.liveness - result['speechiness'] = t.speechiness - result['acousticness'] = t.acousticness - result['danceability'] = t.danceability - result['valence'] = t.valence - result['tempo'] = t.tempo - return result + t = _echonest_fun(pyechonest.track.track_from_filename, filename=path) + if t: + log.debug(u'echoplus: track {} - {} [{:2.2f}]'.format(t.artist, t.title, + t.duration)) + # FIXME: maybe make pyechonest "nicer"? + result = {} + result['energy'] = t.energy + result['liveness'] = t.liveness + result['speechiness'] = t.speechiness + result['acousticness'] = t.acousticness + result['danceability'] = t.danceability + result['valence'] = t.valence + result['tempo'] = t.tempo + return result elif not pick: return None return pick.audio_summary diff --git a/docs/faq.rst b/docs/faq.rst index 4408e0c50..f7d516bec 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -202,9 +202,9 @@ Why does beets… There are a number of possibilities: - First, make sure the album is in `the MusicBrainz - database `__ the MusicBrainz database. You - can search on their site to make sure it's cataloged there. (If not, - anyone can edit MusicBrainz---so consider adding the data yourself.) + database `__. You can search on their site to make + sure it's cataloged there. (If not, anyone can edit MusicBrainz---so + consider adding the data yourself.) - If the album in question is a multi-disc release, see the relevant FAQ answer above. - The music files' metadata might be insufficient. Try using the "enter From 332e8a2924b7c5c59e421dd6c0a1b5dacd0683ac Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Fri, 18 Oct 2013 00:17:40 +0200 Subject: [PATCH 21/44] Revert "bugfix for too many upload retires + added comments" This reverts commit 8700736020706f0b69893235664d95b0457acbbc. --- beetsplug/echoplus.py | 37 +++++++++++++++++++------------------ docs/faq.rst | 6 +++--- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/beetsplug/echoplus.py b/beetsplug/echoplus.py index 83505f06c..2386a68f3 100644 --- a/beetsplug/echoplus.py +++ b/beetsplug/echoplus.py @@ -52,7 +52,7 @@ def _picker(value, rang, mapping): return m # in case of floating point precision problems def _mapping(mapstr): - """Split mapstr at comma and return the stripped values as array.""" + """Split mapstr at comma and returned the stripped values as array.""" return [ m.strip() for m in mapstr.split(u',') ] def _guess_mood(valence, energy): @@ -105,7 +105,7 @@ def fetch_item_attributes(lib, item, write, force, reapply): log.debug(u'echoplus: {} - {} [{}] force:{} reapply:{}'.format( item.artist, item.title, item.length, force, reapply)) - # permanently store the raw values? not supported yet + # permanently store the raw values? store_raw = config['echoplus']['store_raw'].get(bool) # if we want to set mood, we need to make sure, that valence and energy @@ -156,8 +156,7 @@ def fetch_item_attributes(lib, item, write, force, reapply): reapply = True # (re-)fetch audio_summary and store it to the raw values. if we do - # not want to keep the raw values, we clean them up later (not - # implemented yet) + # not want to keep the raw values, we clean them up later audio_summary = get_audio_summary(item.artist, item.title, item.length, allow_upload, item.path) @@ -287,20 +286,22 @@ def get_audio_summary(artist, title, duration, upload, path): if (not pick or min_distance > 1.0) and upload: log.debug(u'echoplus: uploading file "{}" to EchoNest'.format(path)) - t = _echonest_fun(pyechonest.track.track_from_filename, filename=path) - if t: - log.debug(u'echoplus: track {} - {} [{:2.2f}]'.format(t.artist, t.title, - t.duration)) - # FIXME: maybe make pyechonest "nicer"? - result = {} - result['energy'] = t.energy - result['liveness'] = t.liveness - result['speechiness'] = t.speechiness - result['acousticness'] = t.acousticness - result['danceability'] = t.danceability - result['valence'] = t.valence - result['tempo'] = t.tempo - return result + # FIXME: same loop as above... make this better + for i in range(RETRIES): + t = _echonest_fun(pyechonest.track.track_from_filename, filename=path) + if t: + log.debug(u'echoplus: track {} - {} [{:2.2f}]'.format(t.artist, t.title, + t.duration)) + # FIXME: maybe make pyechonest "nicer"? + result = {} + result['energy'] = t.energy + result['liveness'] = t.liveness + result['speechiness'] = t.speechiness + result['acousticness'] = t.acousticness + result['danceability'] = t.danceability + result['valence'] = t.valence + result['tempo'] = t.tempo + return result elif not pick: return None return pick.audio_summary diff --git a/docs/faq.rst b/docs/faq.rst index f7d516bec..4408e0c50 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -202,9 +202,9 @@ Why does beets… There are a number of possibilities: - First, make sure the album is in `the MusicBrainz - database `__. You can search on their site to make - sure it's cataloged there. (If not, anyone can edit MusicBrainz---so - consider adding the data yourself.) + database `__ the MusicBrainz database. You + can search on their site to make sure it's cataloged there. (If not, + anyone can edit MusicBrainz---so consider adding the data yourself.) - If the album in question is a multi-disc release, see the relevant FAQ answer above. - The music files' metadata might be insufficient. Try using the "enter From 1c453e852b91cf3b9fcba9934d70386a6d881c7f Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Fri, 18 Oct 2013 00:22:52 +0200 Subject: [PATCH 22/44] bugfix for too many upload retires + added comments --- beetsplug/echoplus.py | 33 ++++++++++++++++----------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/beetsplug/echoplus.py b/beetsplug/echoplus.py index 2386a68f3..3e6e9b777 100644 --- a/beetsplug/echoplus.py +++ b/beetsplug/echoplus.py @@ -52,7 +52,7 @@ def _picker(value, rang, mapping): return m # in case of floating point precision problems def _mapping(mapstr): - """Split mapstr at comma and returned the stripped values as array.""" + """Split mapstr at comma and return the stripped values as array.""" return [ m.strip() for m in mapstr.split(u',') ] def _guess_mood(valence, energy): @@ -105,7 +105,7 @@ def fetch_item_attributes(lib, item, write, force, reapply): log.debug(u'echoplus: {} - {} [{}] force:{} reapply:{}'.format( item.artist, item.title, item.length, force, reapply)) - # permanently store the raw values? + # permanently store the raw values? not implemented yet store_raw = config['echoplus']['store_raw'].get(bool) # if we want to set mood, we need to make sure, that valence and energy @@ -287,21 +287,20 @@ def get_audio_summary(artist, title, duration, upload, path): if (not pick or min_distance > 1.0) and upload: log.debug(u'echoplus: uploading file "{}" to EchoNest'.format(path)) # FIXME: same loop as above... make this better - for i in range(RETRIES): - t = _echonest_fun(pyechonest.track.track_from_filename, filename=path) - if t: - log.debug(u'echoplus: track {} - {} [{:2.2f}]'.format(t.artist, t.title, - t.duration)) - # FIXME: maybe make pyechonest "nicer"? - result = {} - result['energy'] = t.energy - result['liveness'] = t.liveness - result['speechiness'] = t.speechiness - result['acousticness'] = t.acousticness - result['danceability'] = t.danceability - result['valence'] = t.valence - result['tempo'] = t.tempo - return result + t = _echonest_fun(pyechonest.track.track_from_filename, filename=path) + if t: + log.debug(u'echoplus: track {} - {} [{:2.2f}]'.format(t.artist, t.title, + t.duration)) + # FIXME: maybe make pyechonest "nicer"? + result = {} + result['energy'] = t.energy + result['liveness'] = t.liveness + result['speechiness'] = t.speechiness + result['acousticness'] = t.acousticness + result['danceability'] = t.danceability + result['valence'] = t.valence + result['tempo'] = t.tempo + return result elif not pick: return None return pick.audio_summary From 00ec247515dc29363cb5b3849a9ac62ed923514c Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Fri, 18 Oct 2013 00:32:28 +0200 Subject: [PATCH 23/44] bugfix for failed uploads --- beetsplug/echoplus.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/beetsplug/echoplus.py b/beetsplug/echoplus.py index 3e6e9b777..15d0ac24c 100644 --- a/beetsplug/echoplus.py +++ b/beetsplug/echoplus.py @@ -301,6 +301,8 @@ def get_audio_summary(artist, title, duration, upload, path): result['valence'] = t.valence result['tempo'] = t.tempo return result + else: + return None elif not pick: return None return pick.audio_summary From 6f5d4d1328df59e14e37ade41f503922598e125c Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Sat, 26 Oct 2013 08:56:18 +0200 Subject: [PATCH 24/44] new proposal --- beetsplug/echonest.py | 372 +++++++++++++++++++++++++++++++++++++++++ beetsplug/echoplus.py | 377 ------------------------------------------ 2 files changed, 372 insertions(+), 377 deletions(-) create mode 100644 beetsplug/echonest.py delete mode 100644 beetsplug/echoplus.py diff --git a/beetsplug/echonest.py b/beetsplug/echonest.py new file mode 100644 index 000000000..d1edd6524 --- /dev/null +++ b/beetsplug/echonest.py @@ -0,0 +1,372 @@ +import time +import logging +import socket + +from beets import util, config, plugins, ui, library +import pyechonest +import pyechonest.song +import pyechonest.track + +log = logging.getLogger('beets') + +# If a request at the EchoNest fails, we want to retry the request RETRIES +# times and wait between retries for RETRY_INTERVAL seconds. +RETRIES = 10 +RETRY_INTERVAL = 10 + +# for converting files +import os +import tempfile +from string import Template +from subprocess import Popen +DEVNULL = open(os.devnull, 'wb') + +# The attributes we can import and where to store them +# Note: We use echonest_id (song_id) and echonest_fingerprint to speed up +# lookups. They are not listed as attributes here. +ATTRIBUTES = { + 'energy' : 'echonest_energy', + 'liveness' : 'echonest_liveness', + 'speechiness' : 'echonest_speechiness', + 'acousticness' : 'echonest_acousticness', + 'danceability' : 'echonest_danceability', + 'valence' : 'echonest_valence', + 'tempo' : 'bpm', + } + +def _splitstrip(string): + """Split string at comma and return the stripped values as array.""" + return [ s.strip() for s in string.split(u',') ] + +class EchonestMetadataPlugin(plugins.BeetsPlugin): + _songs = {} + _attributes = [] + _no_mapping = [] + + def __init__(self): + super(EchonestMetadataPlugin, self).__init__() + self.config.add({ + 'auto' : True, + 'apikey' : u'NY2KTZHQ0QDSHBAP6', + 'codegen' : None, + 'upload' : True + 'convert' : True + }) + for k, v in ATTRIBUTES.iteritems(): + self.config.add({k:v}) + + pyechonest.config.ECHO_NEST_API_KEY = \ + config['echonest']['apikey'].get(unicode) + + if config['echonest']['codegen'].get() is not None: + pyechonest.config.CODEGEN_BINARY_OVERRIDE = \ + config['echonest']['codegen'].get(unicode) + + self.register_listener('import_task_start', self.fetch_song_task) + self.register_listener('import_task_apply', self.apply_metadata_task) + + def _echofun(self, func, **kwargs): + """Wrapper for requests to the EchoNest API. Will retry up to RETRIES + times and wait between retries for RETRY_INTERVAL seconds. + """ + for i in range(RETRIES): + try: + result = func(**kwargs) + except pyechonest.util.EchoNestAPIError as e: + if e.code == 3: + # reached access limit per minute + time.sleep(RETRY_INTERVAL) + elif e.code == 5: + # specified identifier does not exist + # no use in trying again. + log.debug(u'echonest: {}'.format(e)) + return None + else: + log.error(u'echonest: {0}'.format(e.args[0][0])) + return None + except (pyechonest.util.EchoNestIOError, socket.error) as e: + log.warn(u'echonest: IO error: {0}'.format(e)) + time.sleep(RETRY_INTERVAL) + else: + break + else: + # If we exited the loop without breaking, then we used up all + # our allotted retries. + raise Exception(u'exceeded retries') + return None + return result + + def fingerprint(self, item): + """Get the fingerprint for this item from the EchoNest. If we already + have a fingerprint, return it and don't calculate it again. + """ + if item.get('echonest_fingerprint', None) is not None: + try: + code = self._echofun(pyechonest.util.codegen, filename=item.path) + item['echonest_fingerprint'] = code[0]['code'] + item.write() + except Exception as exc: + log.error(u'echonest: fingerprinting failed: {0}' + .format(str(exc))) + return None + return item.get('echonest_fingerprint') + + def convert(self, item): + """Converts an item in an unsupported media format to ogg. Config + pending. + This is stolen from Jakob Schnitzers convert plugin. + """ + fd, dest = tempfile.mkstemp(u'.ogg') + os.close(fd) + source = item.path + # FIXME: use avconv? + command = u'ffmpeg -i $source -y -acodec libvorbis -vn -aq 2 $dest'.split(u' ') + log.info(u'echonest: encoding {0} to {1}' + .format(util.displayable_path(source), + util.displayable_path(dest))) + opts = [] + for arg in command: + arg = arg.encode('utf-8') + opts.append(Template(arg).substitute({ + 'source': source, + 'dest': dest + })) + + try: + encode = Popen(opts, close_fds=True, stderr=DEVNULL) + encode.wait() + except Exception as exc: + log.error(u'echonest: encode failed: {0}'.format(str(exc))) + util.remove(dest) + util.prune_dirs(os.path.dirname(dest)) + return None + + if encode.returncode != 0: + log.info(u'echonest: encoding {0} failed ({1}). Cleaning up...' + .format(util.displayable_path(source), encode.returncode)) + util.remove(dest) + util.prune_dirs(os.path.dirname(dest)) + return None + log.info(u'Finished encoding {0}'.format(util.displayable_path(source))) + return dest + + def analyze(self, item): + """Upload the item to the EchoNest for analysis. May require to + convert the item to a supported media format. + """ + try: + source = item.path + if item.format.lower() not in ['wav', 'mp3', 'au', 'ogg', 'mp4', 'm4a']: + if not config['echonest']['convert'].get(bool): + raise Exception(u'format {} not supported for upload' + .format(item.format)) + else: + source = self.convert(item) + if source is None: + raise Exception(u'failed to convert file' + .format(item.format)) + log.info(u'echonest: uploading file, be patient') + track = self._echofun(pyechonest.track.track_from_filename, + filename=source) + if track is None: + raise Exception(u'failed to upload file') + + # Sometimes we have a track but no song. I guess this happens for + # new / unverified songs. We need to 'extract' the audio_summary + # from the track object 'manually'. I don't know why the + # pyechonest API handles tracks (merge audio_summary to __dict__) + # and songs (keep audio_summary in an extra attribute) + # differently. + # Maybe a patch for pyechonest could help? + ids = [] + try: + ids = [track.song_id] + except Exception: + result = {} + result['energy'] = track.energy + result['liveness'] = track.liveness + result['speechiness'] = track.speechiness + result['acousticness'] = track.acousticness + result['danceability'] = track.danceability + result['valence'] = track.valence + result['tempo'] = track.tempo + return result + songs = self._echofun(pyechonest.song.profile, + ids=ids, track_ids=[track.id], + buckets=['audio_summary']) + if songs is None: + raise Exception(u'failed to retrieve info from upload') + return self._pick_song(songs, item) + except Exception as exc: + log.error(u'echonest: analysis failed: {0}'.format(str(exc))) + return None + + def identify(self, item): + """Try to identify the song at the EchoNest. + """ + try: + code = self.fingerprint(item) + if code is None: + raise Exception(u'can not identify without a fingerprint') + songs = self._echofun(pyechonest.song.identify, code=code) + if not songs: + raise Exception(u'no songs found') + return max(songs, key=lambda s: s.score) + except Exception as exc: + log.error(u'echonest: identification failed: {0}'.format(str(exc))) + return None + + def _pick_song(self, songs, item): + """Helper method to pick the best matching song from a list of songs + returned by the EchoNest. Compares artist, title and duration. If + the artist and title match and the duration difference is <= 1.0 + seconds, it's considered a match. + """ + pick = None + if songs: + min_dist = item.length + for song in songs: + if song.artist_name.lower() == item.artist.lower() \ + and song.title.lower() == item.title.lower(): + dist = abs(item.length - song.audio_summary['duration']) + if dist < min_dist: + min_dist = dist + pick = song + if min_dist > 1.0: + return None + return pick + + def search(self, item): + """Search the item at the EchoNest by artist and title. + """ + try: + songs = self._echofun(pyechonest.song.search, title=item.title, + results=100, artist=item.artist, + buckets=['id:musicbrainz', 'tracks']) + pick = self._pick_song(songs, item) + if pick is None: + raise Exception(u'no (matching) songs found') + return pick + except Exception as exc: + log.error(u'echonest: search failed: {0}'.format(str(exc))) + return None + + def profile(self, item): + """Do a lookup on the EchoNest by MusicBrainz ID. + """ + try: + if item.get('echonest_id', None) is None: + if not item.mb_trackid: + raise Exception(u'musicbrainz ID not available') + mbid = 'musicbrainz:track:{0}'.format(item.mb_trackid) + track = self._echofun(pyechonest.track.track_from_id, identifier=mbid) + if not track: + raise Exception(u'could not get track from ID') + ids = track.song_id + else: + ids = item.get('echonest_id') + songs = self._echofun(pyechonest.song.profile, ids=ids, + buckets=['id:musicbrainz', 'audio_summary']) + if not songs: + raise Exception(u'could not get songs from track ID') + return self._pick_song(songs, item) + except Exception as exc: + log.debug(u'echonest: profile failed: {0}'.format(str(exc))) + return None + + def fetch_song(self, item): + """Try all methods, to get a matching song object from the EchoNest. + """ + methods = [self.profile, self.search, self.identify] + if config['echonest']['codegen'].get() is not None: + methods.append(self.identify) + if config['echonest']['upload'].get(bool): + methods.append(self.analyze) + for method in methods: + try: + song = method(item) + if not song is None: + if isinstance(song, pyechonest.song.Song): + log.debug(u'echonest: got song through {0}: {1} - {2} [{3}]' + .format(method.im_func.func_name, + item.artist, item.title, + song.audio_summary['duration'])) + else: # it's our dict filled from a track object + log.debug(u'echonest: got song through {0}: {1} - {2} [{3}]' + .format(method.im_func.func_name, + item.artist, item.title, + song['duration'])) + return song + except Exception as exc: + log.debug(u'echonest: profile failed: {0}'.format(str(exc))) + return None + + def apply_metadata(self, item): + """Copy the metadata from the EchoNest to the item. + """ + if item.path in self._songs: + # song can be a dict + if isinstance(self._songs[item.path], pyechonest.song.Song): + item.echonest_id = self._songs[item.path].id + values = self._songs[item.path].audio_summary + else: + values = self._songs[item.path] + for k, v in values.iteritems(): + if ATTRIBUTES.has_key(k) and ATTRIBUTES[k] is not None: + log.debug(u'echonest: metadata: {0} = {1}' + .format(ATTRIBUTES[k], v)) + item[ATTRIBUTES[k]] = v + if config['import']['write'].get(bool): + log.info(u'echonest: writing metadata: {0}' + .format(util.displayable_path(item.path))) + item.write() + if item._lib: + item.store() + else: + log.warn(u'echonest: no metadata available') + + def requires_update(self, item): + """Check if this item requires an update from the EchoNest aka data is + missing. + """ + for k, v in ATTRIBUTES.iteritems(): + if v is None: + continue + if item.get(v, None) is None: + return True + log.info(u'echonest: no update required') + return False + + def fetch_song_task(self, task, session): + items = task.items if task.is_album else [task.item] + for item in items: + song = self.fetch_song(item) + if not song is None: + self._songs[item.path] = song + + def apply_metadata_task(self, task, session): + for item in task.imported_items(): + self.apply_metadata(item) + + def commands(self): + cmd = ui.Subcommand('echonest', + help='Fetch metadata from the EchoNest') + cmd.parser.add_option('-f', '--force', dest='force', + action='store_true', default=False, + help='(re-)download information from the EchoNest') + + def func(lib, opts, args): + self.config.set_args(opts) + for item in lib.items(ui.decargs(args)): + log.info(u'echonest: {0} - {1} [{2}]'.format(item.artist, + item.title, item.length)) + if self.config['force'] or self.requires_update(item): + song = self.fetch_song(item) + if not song is None: + self._songs[item.path] = song + self.apply_metadata(item) + + cmd.func = func + return [cmd] + +# eof diff --git a/beetsplug/echoplus.py b/beetsplug/echoplus.py deleted file mode 100644 index 15d0ac24c..000000000 --- a/beetsplug/echoplus.py +++ /dev/null @@ -1,377 +0,0 @@ -# This file is part of beets. -# Copyright 2013, Peter Schnebel -# -# Original 'echonest_tempo' plugin is copyright 2013, David Brenner -# -# -# 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. - -"""Gets additional information for imported music from the EchoNest API. Requires -version >= 8.0.1 of the pyechonest library (https://github.com/echonest/pyechonest). -""" -import time -import logging -from beets.plugins import BeetsPlugin -from beets import ui -from beets import config -import pyechonest.config -import pyechonest.song -import pyechonest.track -import socket -import math - -# Global logger. -log = logging.getLogger('beets') - -RETRY_INTERVAL = 10 # Seconds. -RETRIES = 10 -ATTRIBUTES = ['energy', 'liveness', 'speechiness', 'acousticness', - 'danceability', 'valence', 'tempo', 'mood' ] -MAPPED_ATTRIBUTES = ['energy', 'liveness', 'speechiness', 'acousticness', - 'danceability', 'valence', 'mood' ] - -PI_2 = math.pi / 2.0 -MAX_LEN = math.sqrt(2.0 * 0.5 * 0.5) - -def _picker(value, rang, mapping): - inc = rang / len(mapping) - i = 0.0 - for m in mapping: - i += inc - if value < i: - return m - return m # in case of floating point precision problems - -def _mapping(mapstr): - """Split mapstr at comma and return the stripped values as array.""" - return [ m.strip() for m in mapstr.split(u',') ] - -def _guess_mood(valence, energy): - """Based on the valence [0.0 .. 1.0]and energy [0.0 .. 1.0] of a song, we - try to guess the mood. - - For an explanation see: - http://developer.echonest.com/forums/thread/1297 - - We use the Valence-Arousal space from here: - http://mat.ucsb.edu/~ivana/200a/background.htm - """ - - # move center to 0.0/0.0 - valence -= 0.5 - energy -= 0.5 - - # we use the length of the valence / energy vector to determine the - # strength of the emotion - length = math.sqrt(valence * valence + energy * energy) - - # FIXME: do we want the next 3 as config options? - strength = [u'slightly', u'', u'very' ] - # energy from -0.5 to 0.5, valence < 0.0 - low_valence = [ - u'fatigued', u'lethargic', u'depressed', u'sad', - u'upset', u'stressed', u'nervous', u'tense' ] - # energy from -0.5 to 0.5, valence >= 0.0 - high_valence = [ - u'calm', u'relaxed', u'serene', u'contented', - u'happy', u'elated', u'excited', u'alert' ] - if length == 0.0: - # FIXME: what now? return a fallback? config? - return u'neutral' - - angle = math.asin(energy / length) + PI_2 - if valence < 0.0: - moods = low_valence - else: - moods = high_valence - mood = _picker(angle, math.pi, moods) - strength = _picker(length, MAX_LEN, strength) - if strength == u'': - return mood - return u'{} {}'.format(strength, mood) - -def fetch_item_attributes(lib, item, write, force, reapply): - """Fetches audio_summary from the EchoNest and writes it to item. - """ - - log.debug(u'echoplus: {} - {} [{}] force:{} reapply:{}'.format( - item.artist, item.title, item.length, force, reapply)) - # permanently store the raw values? not implemented yet - store_raw = config['echoplus']['store_raw'].get(bool) - - # if we want to set mood, we need to make sure, that valence and energy - # are imported - if config['echoplus']['mood'].get(str): - if config['echoplus']['valence'].get(str) == '': - log.warn(u'echoplus: "valence" is required to guess the mood') - config['echoplus']['mood'].set('') # disable mood - - if config['echoplus']['energy'].get(str) == '': - log.warn(u'echoplus: "energy" is required to guess the mood') - config['echoplus']['mood'].set('') # disable mood - - # force implies reapply - if force: - reapply = True - - allow_upload = config['echoplus']['upload'].get(bool) - # the EchoNest only supports these file formats - if allow_upload and \ - item.format.lower() not in ['wav', 'mp3', 'au', 'ogg', 'mp4', 'm4a']: - log.warn(u'echoplus: format {} not supported for upload'.format(item.format)) - allow_upload = False - - # Check if we need to update - need_update = False - if force: - need_update = True - else: - need_update = False - for attr in ATTRIBUTES: - # do we want this attribute? - target = config['echoplus'][attr].get(str) - if target == '': - continue - - # check if the raw values are present. 'mood' has no direct raw - # representation and 'tempo' is stored raw anyway - if (store_raw or reapply) and not attr in ['mood', 'tempo']: - target = '{}_raw'.format(target) - - if item.get(target, None) is None: - need_update = True - break - - if need_update: - log.debug(u'echoplus: fetching data') - reapply = True - - # (re-)fetch audio_summary and store it to the raw values. if we do - # not want to keep the raw values, we clean them up later - - audio_summary = get_audio_summary(item.artist, item.title, - item.length, allow_upload, item.path) - changed = False - if not audio_summary: - return None - else: - for attr in ATTRIBUTES: - if attr == 'mood': # no raw representation - continue - - # do we want this attribute? - target = config['echoplus'][attr].get(str) - if target == '': - continue - if attr != 'tempo': - target = '{}_raw'.format(target) - - if item.get(target, None) is not None and not force: - log.info(u'{} already present: {} - {} = {:2.2f}'.format( - attr, item.artist, item.title, item.get(target))) - else: - if not attr in audio_summary or audio_summary[attr] is None: - log.info(u'{} not found: {} - {}'.format( attr, - item.artist, item.title)) - else: - value = float(audio_summary[attr]) - item[target] = float(audio_summary[attr]) - changed = True - if reapply: - log.debug(u'echoplus: (re-)applying data') - global_mapping = _mapping(config['echoplus']['mapping'].get()) - for attr in ATTRIBUTES: - # do we want this attribute? - target = config['echoplus'][attr].get(str) - if target == '': - continue - if attr == 'mood': - # we validated above, that valence and energy are - # included, so this should not fail - valence = \ - float(item.get('{}_raw'.format(config['echoplus']['valence'].get(str)))) - energy = \ - float(item.get('{}_raw'.format(config['echoplus']['energy'].get(str)))) - item[target] = _guess_mood(valence, energy) - log.debug(u'echoplus: mapped {}: {:2.2f}x{:2.2f} = {}'.format( - attr, valence, energy, item[target])) - changed = True - elif attr in MAPPED_ATTRIBUTES: - mapping = global_mapping - map_str = config['echoplus']['{}_mapping'.format(attr)].get() - if map_str is not None: - mapping = _mapping(map_str) - value = float(item.get('{}_raw'.format(target))) - mapped_value = _picker(value, 1.0, mapping) - log.debug(u'echoplus: mapped {}: {:2.2f} > {}'.format( - attr, value, mapped_value)) - item[attr] = mapped_value - changed = True - - if changed: - if write: - item.write() - item.store() - -def _echonest_fun(function, **kwargs): - for i in range(RETRIES): - try: - # Unfortunately, all we can do is search by artist and title. - # EchoNest supports foreign ids from MusicBrainz, but currently - # only for artists, not individual tracks/recordings. - results = function(**kwargs) - except pyechonest.util.EchoNestAPIError as e: - if e.code == 3: - # Wait and try again. - time.sleep(RETRY_INTERVAL) - else: - log.warn(u'echoplus: {0}'.format(e.args[0][0])) - return None - except (pyechonest.util.EchoNestIOError, socket.error) as e: - log.debug(u'echoplus: IO error: {0}'.format(e)) - time.sleep(RETRY_INTERVAL) - else: - break - else: - # If we exited the loop without breaking, then we used up all - # our allotted retries. - log.debug(u'echoplus: exceeded retries') - return None - return results - -def get_audio_summary(artist, title, duration, upload, path): - """Get the attribute for a song.""" - # We must have sufficient metadata for the lookup. Otherwise the API - # will just complain. - artist = artist.replace(u'\n', u' ').strip().lower() - title = title.replace(u'\n', u' ').strip().lower() - if not artist or not title: - return None - - results = _echonest_fun(pyechonest.song.search, - artist=artist, title=title, results=100, - buckets=['audio_summary']) - pick = None - min_distance = duration - if results: - # The Echo Nest API can return songs that are not perfect matches. - # So we look through the results for songs that have the right - # artist and title. The API also doesn't have MusicBrainz track IDs; - # otherwise we could use those for a more robust match. - for result in results: - if result.artist_name.lower() == artist \ - and result.title.lower() == title: - distance = abs(duration - result.audio_summary['duration']) - log.debug( - u'echoplus: candidate {} - {} [dist({:2.2f})={:2.2f}]'.format( - result.artist_name, result.title, - result.audio_summary['duration'], distance)) - if distance < min_distance: - min_distance = distance - pick = result - if pick: - log.debug( - u'echoplus: picked {} - {} [dist({:2.2f}-{:2.2f})={:2.2f}]'.format( - pick.artist_name, pick.title, - pick.audio_summary['duration'], duration, min_distance)) - - if (not pick or min_distance > 1.0) and upload: - log.debug(u'echoplus: uploading file "{}" to EchoNest'.format(path)) - # FIXME: same loop as above... make this better - t = _echonest_fun(pyechonest.track.track_from_filename, filename=path) - if t: - log.debug(u'echoplus: track {} - {} [{:2.2f}]'.format(t.artist, t.title, - t.duration)) - # FIXME: maybe make pyechonest "nicer"? - result = {} - result['energy'] = t.energy - result['liveness'] = t.liveness - result['speechiness'] = t.speechiness - result['acousticness'] = t.acousticness - result['danceability'] = t.danceability - result['valence'] = t.valence - result['tempo'] = t.tempo - return result - else: - return None - elif not pick: - return None - return pick.audio_summary - - -class EchoPlusPlugin(BeetsPlugin): - def __init__(self): - super(EchoPlusPlugin, self).__init__() - self.import_stages = [self.imported] - self.config.add({ - 'apikey': u'NY2KTZHQ0QDSHBAP6', - 'auto': True, - 'mapping': 'very low,low,neutral,high,very high', - 'energy_mapping': None, - 'liveness_mapping': 'studio,probably studio,probably live,live', - 'speechiness_mapping': 'singing,unsure,talking', - 'acousticness_mapping': 'artificial,probably artifical,probably natural,natural', - 'danceability_mapping': 'bed,couch,unsure,party,disco', - 'valence_mapping': None, - 'store_raw': True, - 'upload': False, - }) - for attr in ATTRIBUTES: - if attr == 'tempo': - target = '' # disabled to not conflict with echonest_tempo, - # to enable, set it to 'bpm' - self.config.add({attr:target}) - else: - target = attr - self.config.add({attr:target}) - - pyechonest.config.ECHO_NEST_API_KEY = \ - self.config['apikey'].get(unicode) - - def commands(self): - cmd = ui.Subcommand('echoplus', - help='fetch additional song information from the echonest') - cmd.parser.add_option('-f', '--force', dest='force', - action='store_true', default=False, - help='re-download information from the EchoNest') - cmd.parser.add_option('-r', '--reapply', dest='reapply', - action='store_true', default=False, - help='reapply mappings') - def func(lib, opts, args): - # The "write to files" option corresponds to the - # import_write config value. - write = config['import']['write'].get(bool) - self.config.set_args(opts) - - for item in lib.items(ui.decargs(args)): - log.debug(u'{} {}'.format( - self.config['force'], - self.config['reapply'])) - fetch_item_attributes(lib, item, write, - self.config['force'], - self.config['reapply']) - cmd.func = func - return [cmd] - - # Auto-fetch info on import. - def imported(self, session, task): - if self.config['auto']: - if task.is_album: - album = session.lib.get_album(task.album_id) - for item in album.items(): - fetch_item_attributes(session.lib, item, False, True, - True) - else: - item = task.item - fetch_item_attributes(session.lib, item, False, True, True) - -# eof From a5be1648f9dabaf61f060789cee5985cc94caeee Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Sat, 26 Oct 2013 08:56:56 +0200 Subject: [PATCH 25/44] new proposal --- beetsplug/echonest.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/beetsplug/echonest.py b/beetsplug/echonest.py index d1edd6524..60e8aaf71 100644 --- a/beetsplug/echonest.py +++ b/beetsplug/echonest.py @@ -49,8 +49,8 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin): 'auto' : True, 'apikey' : u'NY2KTZHQ0QDSHBAP6', 'codegen' : None, - 'upload' : True - 'convert' : True + 'upload' : True, + 'convert' : True, }) for k, v in ATTRIBUTES.iteritems(): self.config.add({k:v}) From 2d1788e59598f3d98c1b1077aeadc0e6724847bc Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Sat, 26 Oct 2013 08:58:39 +0200 Subject: [PATCH 26/44] bugfix --- beetsplug/echonest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/echonest.py b/beetsplug/echonest.py index 60e8aaf71..e84776a56 100644 --- a/beetsplug/echonest.py +++ b/beetsplug/echonest.py @@ -277,7 +277,7 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin): def fetch_song(self, item): """Try all methods, to get a matching song object from the EchoNest. """ - methods = [self.profile, self.search, self.identify] + methods = [self.profile, self.search] if config['echonest']['codegen'].get() is not None: methods.append(self.identify) if config['echonest']['upload'].get(bool): From c4445df8af07f45bd1527c9f920669a60836c8db Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Sat, 26 Oct 2013 09:03:20 +0200 Subject: [PATCH 27/44] bugfix --- beetsplug/echonest.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/beetsplug/echonest.py b/beetsplug/echonest.py index e84776a56..c3843b752 100644 --- a/beetsplug/echonest.py +++ b/beetsplug/echonest.py @@ -196,7 +196,9 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin): buckets=['audio_summary']) if songs is None: raise Exception(u'failed to retrieve info from upload') - return self._pick_song(songs, item) + # No need to _pick_song, match is match + # return self._pick_song(songs, item) + return songs[0] except Exception as exc: log.error(u'echonest: analysis failed: {0}'.format(str(exc))) return None From 1324498f2910098d1293b4d9bccf165872c7ce1f Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Sat, 26 Oct 2013 09:06:47 +0200 Subject: [PATCH 28/44] bugfix --- beetsplug/echonest.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/beetsplug/echonest.py b/beetsplug/echonest.py index c3843b752..9bed08822 100644 --- a/beetsplug/echonest.py +++ b/beetsplug/echonest.py @@ -271,7 +271,10 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin): buckets=['id:musicbrainz', 'audio_summary']) if not songs: raise Exception(u'could not get songs from track ID') - return self._pick_song(songs, item) + # It seems like _pick_song fails even if we have a good match. So + # just return the first song. + # return self._pick_song(songs, item) + return songs[0] except Exception as exc: log.debug(u'echonest: profile failed: {0}'.format(str(exc))) return None From 03f5f71732a64c0e93e60f0abaf9bc9c2b2567d9 Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Sat, 26 Oct 2013 09:09:00 +0200 Subject: [PATCH 29/44] show the artist / title as received from EchoNest --- beetsplug/echonest.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/beetsplug/echonest.py b/beetsplug/echonest.py index 9bed08822..d8264365c 100644 --- a/beetsplug/echonest.py +++ b/beetsplug/echonest.py @@ -1,3 +1,4 @@ +# This file is part of beets. import time import logging import socket @@ -294,7 +295,7 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin): if isinstance(song, pyechonest.song.Song): log.debug(u'echonest: got song through {0}: {1} - {2} [{3}]' .format(method.im_func.func_name, - item.artist, item.title, + song.artist_name, song.title, song.audio_summary['duration'])) else: # it's our dict filled from a track object log.debug(u'echonest: got song through {0}: {1} - {2} [{3}]' From 9ac89b350c86a105ee7efe815b2bafebd080c2cc Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Sat, 26 Oct 2013 09:39:24 +0200 Subject: [PATCH 30/44] bugfix: extract duration from track --- beetsplug/echonest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/beetsplug/echonest.py b/beetsplug/echonest.py index d8264365c..9a5433d2f 100644 --- a/beetsplug/echonest.py +++ b/beetsplug/echonest.py @@ -191,6 +191,7 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin): result['danceability'] = track.danceability result['valence'] = track.valence result['tempo'] = track.tempo + result['duration'] = track.duration return result songs = self._echofun(pyechonest.song.profile, ids=ids, track_ids=[track.id], From c8c3c4ae6638ba69c59a6fd4e7b79fa45dc239ea Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Sat, 26 Oct 2013 10:26:55 +0200 Subject: [PATCH 31/44] workaround: onetime after analyze, the wrong song was returned. double check that. fall back to track data. --- beetsplug/echonest.py | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/beetsplug/echonest.py b/beetsplug/echonest.py index 9a5433d2f..7edaae937 100644 --- a/beetsplug/echonest.py +++ b/beetsplug/echonest.py @@ -179,28 +179,29 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin): # and songs (keep audio_summary in an extra attribute) # differently. # Maybe a patch for pyechonest could help? + from_track = {} + from_track['energy'] = track.energy + from_track['liveness'] = track.liveness + from_track['speechiness'] = track.speechiness + from_track['acousticness'] = track.acousticness + from_track['danceability'] = track.danceability + from_track['valence'] = track.valence + from_track['tempo'] = track.tempo + from_track['duration'] = track.duration ids = [] try: ids = [track.song_id] except Exception: - result = {} - result['energy'] = track.energy - result['liveness'] = track.liveness - result['speechiness'] = track.speechiness - result['acousticness'] = track.acousticness - result['danceability'] = track.danceability - result['valence'] = track.valence - result['tempo'] = track.tempo - result['duration'] = track.duration - return result + return from_track songs = self._echofun(pyechonest.song.profile, - ids=ids, track_ids=[track.id], + ids=ids, track_ids=[track.id], limit=100, buckets=['audio_summary']) if songs is None: raise Exception(u'failed to retrieve info from upload') - # No need to _pick_song, match is match - # return self._pick_song(songs, item) - return songs[0] + pick = self._pick_song(songs, item) + if pick is None: + return from_track + return pick except Exception as exc: log.error(u'echonest: analysis failed: {0}'.format(str(exc))) return None From a755fbcf537d097ac9e1d6ae125ff0defa6d0ed3 Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Sat, 26 Oct 2013 10:28:11 +0200 Subject: [PATCH 32/44] workaround: also double check after profile --- beetsplug/echonest.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/beetsplug/echonest.py b/beetsplug/echonest.py index 7edaae937..942e4cfb1 100644 --- a/beetsplug/echonest.py +++ b/beetsplug/echonest.py @@ -274,10 +274,7 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin): buckets=['id:musicbrainz', 'audio_summary']) if not songs: raise Exception(u'could not get songs from track ID') - # It seems like _pick_song fails even if we have a good match. So - # just return the first song. - # return self._pick_song(songs, item) - return songs[0] + return self._pick_song(songs, item) except Exception as exc: log.debug(u'echonest: profile failed: {0}'.format(str(exc))) return None From e4792df50fd47ec75ba416f596734edd201b1e53 Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Sat, 26 Oct 2013 10:29:21 +0200 Subject: [PATCH 33/44] bugfix: don't use limit here --- beetsplug/echonest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/echonest.py b/beetsplug/echonest.py index 942e4cfb1..e39dffd08 100644 --- a/beetsplug/echonest.py +++ b/beetsplug/echonest.py @@ -194,7 +194,7 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin): except Exception: return from_track songs = self._echofun(pyechonest.song.profile, - ids=ids, track_ids=[track.id], limit=100, + ids=ids, track_ids=[track.id], buckets=['audio_summary']) if songs is None: raise Exception(u'failed to retrieve info from upload') From 5e0bac8ab51612bd6e13acc1397511927b6d4fc4 Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Sat, 26 Oct 2013 10:30:42 +0200 Subject: [PATCH 34/44] be a little less picky when judging matches --- beetsplug/echonest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/beetsplug/echonest.py b/beetsplug/echonest.py index e39dffd08..93a1403e5 100644 --- a/beetsplug/echonest.py +++ b/beetsplug/echonest.py @@ -237,7 +237,7 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin): if dist < min_dist: min_dist = dist pick = song - if min_dist > 1.0: + if min_dist > 2.5: return None return pick From 428302b45c859987aa18673fc6a71f997ae9fc97 Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Sat, 26 Oct 2013 10:33:29 +0200 Subject: [PATCH 35/44] add echonest_id to debug output --- beetsplug/echonest.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/beetsplug/echonest.py b/beetsplug/echonest.py index 93a1403e5..8173080ef 100644 --- a/beetsplug/echonest.py +++ b/beetsplug/echonest.py @@ -312,6 +312,8 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin): if item.path in self._songs: # song can be a dict if isinstance(self._songs[item.path], pyechonest.song.Song): + log.debug(u'echonest: metadata: echonest_id = {0}' + .format(self._songs[item.path].id)) item.echonest_id = self._songs[item.path].id values = self._songs[item.path].audio_summary else: From f6613ee31c1ce5692a8e00f17f8eea71dff9a6b7 Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Sat, 26 Oct 2013 11:33:34 +0200 Subject: [PATCH 36/44] added similarity finder plugin --- beetsplug/echonest.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/beetsplug/echonest.py b/beetsplug/echonest.py index 8173080ef..f76a3406d 100644 --- a/beetsplug/echonest.py +++ b/beetsplug/echonest.py @@ -2,6 +2,7 @@ import time import logging import socket +import math from beets import util, config, plugins, ui, library import pyechonest @@ -376,4 +377,40 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin): cmd.func = func return [cmd] +def diff(item1, item2, attributes): + result = 0.0 + for attr in attributes: + try: + result += abs( + float(item1.get(attr, None)) - + float(item2.get(attr, None)) + ) + except TypeError: + result += 1.0 + return result + +def similar(lib, src_item, threshold=0.15): + attributes = [] + for attr in ['energy', 'danceability', 'valence', 'speechiness', + 'acousticness', 'liveness']: + if ATTRIBUTES[attr] is not None: + attributes.append(ATTRIBUTES[attr]) + for item in lib.items(): + if not item.path == src_item.path: + d = diff(item, src_item, attributes) + if d < threshold: + print(u'{1:2.2f}: {0}'.format(item.path, d)) + +class EchonestSimilarPlugin(plugins.BeetsPlugin): + def commands(self): + cmd = ui.Subcommand('echosim', help='show related files') + + def func(lib, opts, args): + self.config.set_args(opts) + for item in lib.items(ui.decargs(args)): + similar(lib, item) + + cmd.func = func + return [cmd] + # eof From 5dbb277b1a886fcf71c192a28b228f3f66909e85 Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Sat, 26 Oct 2013 12:49:52 +0200 Subject: [PATCH 37/44] added partial documentation --- docs/echonest.rst | 112 ++++++++++++++++++++++++++++++++++++++ docs/plugins/echonest.rst | 80 +++++++++++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 docs/echonest.rst create mode 100644 docs/plugins/echonest.rst diff --git a/docs/echonest.rst b/docs/echonest.rst new file mode 100644 index 000000000..4dda925c5 --- /dev/null +++ b/docs/echonest.rst @@ -0,0 +1,112 @@ +Echonest Plugin +=========================== + +Acoustic fingerprinting is a technique for identifying songs from the +way they "sound" rather from their existing metadata. That means that +beets' autotagger can theoretically use fingerprinting to tag files +that don't have any ID3 information at all (or have completely +incorrect data). This plugin uses a fingerprinting technology called +`ENMFP `_ +and its associated Web service, called Echonest `song/identify +`_. + +Turning on fingerprinting can increase the accuracy of the +autotagger---especially on files with very poor metadata---but it +comes at a cost. First, it can be trickier to set up than beets itself +(you need to set up the native fingerprinting library, whereas all of +the beets core is written in pure Python). Also, fingerprinting takes +significantly more CPU and memory than ordinary tagging---which means +that imports will go substantially slower. + +If you're willing to pay the performance cost for fingerprinting, read +on! + +Installing Dependencies +----------------------- + +To get fingerprinting working, you'll need to install two things: +the `ENMFP `_ codegen +command-line tool, and the `pyechonest +`_ Python library. + +First, you will need to install ``ENMFP``, as a command-line tool. +The ``ENMFP`` codegen binary distribution has executables for all +major OSs and architectures. + +Then, install pyechonest itself. You can do this using `pip +`_, like so:: + + $ pip install pyechonest + +Configuring +----------- + +Once you have all the dependencies sorted out, you can enable +fingerprinting by editing your :doc:`configuration file +`. Put ``echonest`` on your ``plugins:`` line. +You'll also need an `API key from Echonest `_. +Then, add the key to your ``config.yaml`` as the value ``apikey`` in a +section called ``echonest`` like so:: + + echonest: + apikey: YOURKEY + +If the ``ENMFP`` binary is not in your path, you'll need to add an +additional key called ``codegen`` under the ``echonest`` section like +so:: + + echonest: + apikey: YOURKEY + codegen: PATH/TO/YOUR/CODEGEN/BINARY + +With that, beets will use fingerprinting the next time you run ``beet +import``. + +If you'd prefer not to run the Echonest plugin importer automatically +when importing, you can shut it off:: + + echonest: + apikey: YOURKEY + codegen: PATH/TO/YOUR/CODEGEN/BINARY + auto: no + +Using +''''' + +The Echonest plugin will automatically fetch and store in the database +(but *not* in the audio file itself) the following audio descriptors: + +- danceability +- duration +- energy +- key +- liveness +- loudness +- mode +- speechiness +- tempo +- time_signature + + +Since most of these fields represent real numbers between 0 and 1, you +will also be able to query inequalities using the range operator +``..``, once it supports doing so in flexattrs, like so:: + + beet ls danceability:0.25...0.75 + beet ls liveness:...0.1 + beet ls speechiness:0.9... + +The above would return all tracks will danceability values in the +range [0.25, 0.75], liveness values less than 0.1, or speechiness +values greater than 0.9. For now, you can sort of get around this +limitation by using regexp queries:: + + beet ls energy::0\.[89] + + +Additionally, the plugin adds a new command, named ``fingerprint``, +which is analogous to the same command provided by ``chroma``. + +TODO +'''' +Provide a command for performing tagging outside of an import stage. diff --git a/docs/plugins/echonest.rst b/docs/plugins/echonest.rst new file mode 100644 index 000000000..799ee646a --- /dev/null +++ b/docs/plugins/echonest.rst @@ -0,0 +1,80 @@ +Echonest Plugin +=============== + +The ``echonest`` plugin will automatically fetch and store the following audio +descriptors from the `EchoNest API`_. All except ``tempo`` will be stored in +flexattrs and *not* in the audio file itself: + +- danceability > echonest_danceability +- energy > echonest_energy +- liveness > echonest_liveness +- loudness > echonest_loudness +- speechiness > echonest_speechiness +- tempo > bpm + +.. _EchoNest API: http://developer.echonest.com/ + +Installing Dependencies +----------------------- + +This plugin requires the pyechonest library in order to talk to the EchoNest +API. At least version 8.0.1 is required. + +There are packages for most major linux distributions, you can download the +library from the Echo Nest, or you can install the library from `pip`_, +like so:: + + $ pip install pyechonest + +To transcode music, this plugin requires the `ffmpeg`_ command-line tool. + +.. _pip: http://pip.openplans.org/ +.. _FFmpeg: http://ffmpeg.org + +To get fingerprinting working, you'll need to install +the `ENMFP `_ codegen +command-line tool. + +You will need to install ``ENMFP``, as a command-line tool. The ``ENMFP`` +codegen binary distribution has executables for all major OSs and +architectures. + +Configuring +----------- + +Beets includes its own Echo Nest API key, but you can `apply for your own`_ for +free from the EchoNest. To specify your own API key, add the key to your +:doc:`configuration file ` as the value for ``apikey`` under +the key ``echonest_tempo`` like so:: + + echonest: + apikey: YOUR_API_KEY + +In addition, the ``auto`` config option lets you disable automatic metadata +fetching during import. To do so, add this to your ``config.yaml``:: + + echonest: + auto: no + +The ``echonest`` plugin tries to upload files to the EchoNest server if it can +not be identified by other means. If you don't want that, disable the +``upload`` config option like so:: + + echonest: + upload: no + +The EchoNest server only supports a limited range of file formats. The +``plugin`` automatically converts unsupported files to ``ogg``. If you don't +want that, disable the ``convert`` config option like so:: + + echonest: + convert: no + +You can enable fingerprinting by editing your :doc:`configuration file +`. If the ``ENMFP`` binary is not in your path, you'll +need to add a key called ``codegen`` under the ``echonest`` section like so:: + + echonest: + codegen: PATH/TO/YOUR/CODEGEN/BINARY + +.. _apply for your own: http://developer.echonest.com/account/register From a7eb1f0493586ee756bc6781aac85ead0176b92e Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Sat, 26 Oct 2013 12:50:03 +0200 Subject: [PATCH 38/44] added partial documentation --- docs/echonest.rst | 112 ---------------------------------------------- 1 file changed, 112 deletions(-) delete mode 100644 docs/echonest.rst diff --git a/docs/echonest.rst b/docs/echonest.rst deleted file mode 100644 index 4dda925c5..000000000 --- a/docs/echonest.rst +++ /dev/null @@ -1,112 +0,0 @@ -Echonest Plugin -=========================== - -Acoustic fingerprinting is a technique for identifying songs from the -way they "sound" rather from their existing metadata. That means that -beets' autotagger can theoretically use fingerprinting to tag files -that don't have any ID3 information at all (or have completely -incorrect data). This plugin uses a fingerprinting technology called -`ENMFP `_ -and its associated Web service, called Echonest `song/identify -`_. - -Turning on fingerprinting can increase the accuracy of the -autotagger---especially on files with very poor metadata---but it -comes at a cost. First, it can be trickier to set up than beets itself -(you need to set up the native fingerprinting library, whereas all of -the beets core is written in pure Python). Also, fingerprinting takes -significantly more CPU and memory than ordinary tagging---which means -that imports will go substantially slower. - -If you're willing to pay the performance cost for fingerprinting, read -on! - -Installing Dependencies ------------------------ - -To get fingerprinting working, you'll need to install two things: -the `ENMFP `_ codegen -command-line tool, and the `pyechonest -`_ Python library. - -First, you will need to install ``ENMFP``, as a command-line tool. -The ``ENMFP`` codegen binary distribution has executables for all -major OSs and architectures. - -Then, install pyechonest itself. You can do this using `pip -`_, like so:: - - $ pip install pyechonest - -Configuring ------------ - -Once you have all the dependencies sorted out, you can enable -fingerprinting by editing your :doc:`configuration file -`. Put ``echonest`` on your ``plugins:`` line. -You'll also need an `API key from Echonest `_. -Then, add the key to your ``config.yaml`` as the value ``apikey`` in a -section called ``echonest`` like so:: - - echonest: - apikey: YOURKEY - -If the ``ENMFP`` binary is not in your path, you'll need to add an -additional key called ``codegen`` under the ``echonest`` section like -so:: - - echonest: - apikey: YOURKEY - codegen: PATH/TO/YOUR/CODEGEN/BINARY - -With that, beets will use fingerprinting the next time you run ``beet -import``. - -If you'd prefer not to run the Echonest plugin importer automatically -when importing, you can shut it off:: - - echonest: - apikey: YOURKEY - codegen: PATH/TO/YOUR/CODEGEN/BINARY - auto: no - -Using -''''' - -The Echonest plugin will automatically fetch and store in the database -(but *not* in the audio file itself) the following audio descriptors: - -- danceability -- duration -- energy -- key -- liveness -- loudness -- mode -- speechiness -- tempo -- time_signature - - -Since most of these fields represent real numbers between 0 and 1, you -will also be able to query inequalities using the range operator -``..``, once it supports doing so in flexattrs, like so:: - - beet ls danceability:0.25...0.75 - beet ls liveness:...0.1 - beet ls speechiness:0.9... - -The above would return all tracks will danceability values in the -range [0.25, 0.75], liveness values less than 0.1, or speechiness -values greater than 0.9. For now, you can sort of get around this -limitation by using regexp queries:: - - beet ls energy::0\.[89] - - -Additionally, the plugin adds a new command, named ``fingerprint``, -which is analogous to the same command provided by ``chroma``. - -TODO -'''' -Provide a command for performing tagging outside of an import stage. From d9a1b81bff2d7276dfffb654dee27b1d11563778 Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Sat, 26 Oct 2013 13:57:20 +0200 Subject: [PATCH 39/44] added echonest to the index --- docs/plugins/index.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index dd0c6ee43..2ee0fcd15 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -22,6 +22,7 @@ by typing ``beet version``. chroma lyrics echonest_tempo + echonest bpd mpdupdate fetchart @@ -68,6 +69,8 @@ Metadata * :doc:`lyrics`: Automatically fetch song lyrics. * :doc:`echonest_tempo`: Automatically fetch song tempos (bpm). +* :doc:`echonest`: Automatically fetch metadata from EchoNest (energy, + danceability, ...). * :doc:`lastgenre`: Fetch genres based on Last.fm tags. * :doc:`mbsync`: Fetch updated metadata from MusicBrainz * :doc:`fetchart`: Fetch album cover art from various sources. From 315522aafa361a32fc62d73d9c3c1d74484a2ad8 Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Sat, 26 Oct 2013 17:32:30 +0200 Subject: [PATCH 40/44] fix inverted conditional when fingerprinting (@pedros) --- beetsplug/echonest.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/beetsplug/echonest.py b/beetsplug/echonest.py index f76a3406d..5407fba2d 100644 --- a/beetsplug/echonest.py +++ b/beetsplug/echonest.py @@ -102,9 +102,9 @@ class EchonestMetadataPlugin(plugins.BeetsPlugin): """Get the fingerprint for this item from the EchoNest. If we already have a fingerprint, return it and don't calculate it again. """ - if item.get('echonest_fingerprint', None) is not None: + if item.get('echonest_fingerprint', None) is None: try: - code = self._echofun(pyechonest.util.codegen, filename=item.path) + code = self._echofun(pyechonest.util.codegen, filename=item.path.decode('utf-8')) item['echonest_fingerprint'] = code[0]['code'] item.write() except Exception as exc: From 538aa457e0711d3c58ccdbd91dbc545c016d4da7 Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Sun, 27 Oct 2013 08:52:54 +0100 Subject: [PATCH 41/44] removed echonest_ prefix --- beetsplug/echonest.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/beetsplug/echonest.py b/beetsplug/echonest.py index 5407fba2d..4cded8e0f 100644 --- a/beetsplug/echonest.py +++ b/beetsplug/echonest.py @@ -27,12 +27,12 @@ DEVNULL = open(os.devnull, 'wb') # Note: We use echonest_id (song_id) and echonest_fingerprint to speed up # lookups. They are not listed as attributes here. ATTRIBUTES = { - 'energy' : 'echonest_energy', - 'liveness' : 'echonest_liveness', - 'speechiness' : 'echonest_speechiness', - 'acousticness' : 'echonest_acousticness', - 'danceability' : 'echonest_danceability', - 'valence' : 'echonest_valence', + 'energy' : 'energy', + 'liveness' : 'liveness', + 'speechiness' : 'speechiness', + 'acousticness' : 'acousticness', + 'danceability' : 'danceability', + 'valence' : 'valence', 'tempo' : 'bpm', } From a45f8d6abfd1831df4d523b195febf37f55336fc Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Sun, 27 Oct 2013 08:53:08 +0100 Subject: [PATCH 42/44] removed echonest_ prefix --- docs/plugins/echonest.rst | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/plugins/echonest.rst b/docs/plugins/echonest.rst index 799ee646a..84458e128 100644 --- a/docs/plugins/echonest.rst +++ b/docs/plugins/echonest.rst @@ -2,15 +2,16 @@ Echonest Plugin =============== The ``echonest`` plugin will automatically fetch and store the following audio -descriptors from the `EchoNest API`_. All except ``tempo`` will be stored in -flexattrs and *not* in the audio file itself: +descriptors from the `EchoNest API`_. All except for ``tempo`` will be stored in +flexattrs and *not* in the audio file itself. ``tempo`` will be stored in +``bpm``: -- danceability > echonest_danceability -- energy > echonest_energy -- liveness > echonest_liveness -- loudness > echonest_loudness -- speechiness > echonest_speechiness -- tempo > bpm +- danceability +- energy +- liveness +- loudness +- speechiness +- tempo .. _EchoNest API: http://developer.echonest.com/ From 8fca70a0be1a452b32a2b489fabc54099dafc83d Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Sun, 27 Oct 2013 11:08:03 +0100 Subject: [PATCH 43/44] added the echonest plugin --- docs/changelog.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index b1819eeae..f42c83f23 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,6 +9,14 @@ Little fixes: * :doc:`/plugins/missing`: Avoid a possible error when an album's ``tracktotal`` field is missing. +New stuff: + +* :doc:`/plugins/echonest`: A drop in replacement of + :doc:`/plugins/echonest_tempo` that also fetches `Acoustic Attributes`_ from + `The Echo Nest`_. + +.. _Acoustic Attributes: http://developer.echonest.com/acoustic-attributes.html + 1.3.1 (October 12, 2013) ------------------------ From e2a14985c95237c290bf1e9dd8212c31353b4f73 Mon Sep 17 00:00:00 2001 From: Peter Schnebel Date: Sun, 27 Oct 2013 11:08:42 +0100 Subject: [PATCH 44/44] added the echonest plugin --- docs/plugins/echonest.rst | 31 ++++++++++++++++--------------- docs/plugins/echonest_tempo.rst | 4 ++++ 2 files changed, 20 insertions(+), 15 deletions(-) diff --git a/docs/plugins/echonest.rst b/docs/plugins/echonest.rst index 84458e128..dd3fb1689 100644 --- a/docs/plugins/echonest.rst +++ b/docs/plugins/echonest.rst @@ -13,8 +13,12 @@ flexattrs and *not* in the audio file itself. ``tempo`` will be stored in - speechiness - tempo +See `Acoustic Attributes`_ for a detailed description. + .. _EchoNest API: http://developer.echonest.com/ +.. _Acoustic Attributes: http://developer.echonest.com/acoustic-attributes.html + Installing Dependencies ----------------------- @@ -29,22 +33,20 @@ like so:: To transcode music, this plugin requires the `ffmpeg`_ command-line tool. +To get fingerprinting working, you'll need to install the `ENMFP`_ codegen +command-line tool. The ``ENMFP`` codegen binary distribution has executables +for all major OSs and architectures. Please note that fingerprinting is not +required if ``upload`` and ``convert`` is enabled, which is the default. + .. _pip: http://pip.openplans.org/ .. _FFmpeg: http://ffmpeg.org - -To get fingerprinting working, you'll need to install -the `ENMFP `_ codegen -command-line tool. - -You will need to install ``ENMFP``, as a command-line tool. The ``ENMFP`` -codegen binary distribution has executables for all major OSs and -architectures. +.. _ENMFP: http://static.echonest.com/ENMFP_codegen.zip Configuring ----------- Beets includes its own Echo Nest API key, but you can `apply for your own`_ for -free from the EchoNest. To specify your own API key, add the key to your +free from the Echo Nest. To specify your own API key, add the key to your :doc:`configuration file ` as the value for ``apikey`` under the key ``echonest_tempo`` like so:: @@ -57,23 +59,22 @@ fetching during import. To do so, add this to your ``config.yaml``:: echonest: auto: no -The ``echonest`` plugin tries to upload files to the EchoNest server if it can -not be identified by other means. If you don't want that, disable the +The ``echonest`` plugin tries to upload files to the Echo Nest server if it +can not be identified by other means. If you don't want that, disable the ``upload`` config option like so:: echonest: upload: no -The EchoNest server only supports a limited range of file formats. The +The Echo Nest server only supports a limited range of file formats. The ``plugin`` automatically converts unsupported files to ``ogg``. If you don't want that, disable the ``convert`` config option like so:: echonest: convert: no -You can enable fingerprinting by editing your :doc:`configuration file -`. If the ``ENMFP`` binary is not in your path, you'll -need to add a key called ``codegen`` under the ``echonest`` section like so:: +If the ``ENMFP`` binary is not in your path, you'll need to add a key called +``codegen`` under the ``echonest`` section like so:: echonest: codegen: PATH/TO/YOUR/CODEGEN/BINARY diff --git a/docs/plugins/echonest_tempo.rst b/docs/plugins/echonest_tempo.rst index 9fdafa02d..13fbe9339 100644 --- a/docs/plugins/echonest_tempo.rst +++ b/docs/plugins/echonest_tempo.rst @@ -1,10 +1,14 @@ EchoNest Tempo Plugin ===================== +*Note*: A new plugin :doc:`echonest` is available, that in addition to +``tempo`` also fetches `Acoustic Attributes`_ from the EchoNest. + The ``echonest_tempo`` plugin fetches and stores a track's tempo (the "bpm" field) from the `EchoNest API`_ .. _EchoNest API: http://developer.echonest.com/ +.. _Acoustic Attributes: http://developer.echonest.com/acoustic-attributes.html Installing Dependencies -----------------------