1# -*- coding: utf-8 -*- 2# This file is part of beets. 3# Copyright 2015-2016, Ohm Patel. 4# 5# Permission is hereby granted, free of charge, to any person obtaining 6# a copy of this software and associated documentation files (the 7# "Software"), to deal in the Software without restriction, including 8# without limitation the rights to use, copy, modify, merge, publish, 9# distribute, sublicense, and/or sell copies of the Software, and to 10# permit persons to whom the Software is furnished to do so, subject to 11# the following conditions: 12# 13# The above copyright notice and this permission notice shall be 14# included in all copies or substantial portions of the Software. 15 16"""Fetch various AcousticBrainz metadata using MBID. 17""" 18from __future__ import division, absolute_import, print_function 19 20from collections import defaultdict 21 22import requests 23 24from beets import plugins, ui 25from beets.dbcore import types 26 27ACOUSTIC_BASE = "https://acousticbrainz.org/" 28LEVELS = ["/low-level", "/high-level"] 29ABSCHEME = { 30 'highlevel': { 31 'danceability': { 32 'all': { 33 'danceable': 'danceable' 34 } 35 }, 36 'gender': { 37 'value': 'gender' 38 }, 39 'genre_rosamerica': { 40 'value': 'genre_rosamerica' 41 }, 42 'mood_acoustic': { 43 'all': { 44 'acoustic': 'mood_acoustic' 45 } 46 }, 47 'mood_aggressive': { 48 'all': { 49 'aggressive': 'mood_aggressive' 50 } 51 }, 52 'mood_electronic': { 53 'all': { 54 'electronic': 'mood_electronic' 55 } 56 }, 57 'mood_happy': { 58 'all': { 59 'happy': 'mood_happy' 60 } 61 }, 62 'mood_party': { 63 'all': { 64 'party': 'mood_party' 65 } 66 }, 67 'mood_relaxed': { 68 'all': { 69 'relaxed': 'mood_relaxed' 70 } 71 }, 72 'mood_sad': { 73 'all': { 74 'sad': 'mood_sad' 75 } 76 }, 77 'ismir04_rhythm': { 78 'value': 'rhythm' 79 }, 80 'tonal_atonal': { 81 'all': { 82 'tonal': 'tonal' 83 } 84 }, 85 'voice_instrumental': { 86 'value': 'voice_instrumental' 87 }, 88 }, 89 'lowlevel': { 90 'average_loudness': 'average_loudness' 91 }, 92 'rhythm': { 93 'bpm': 'bpm' 94 }, 95 'tonal': { 96 'chords_changes_rate': 'chords_changes_rate', 97 'chords_key': 'chords_key', 98 'chords_number_rate': 'chords_number_rate', 99 'chords_scale': 'chords_scale', 100 'key_key': ('initial_key', 0), 101 'key_scale': ('initial_key', 1), 102 'key_strength': 'key_strength' 103 104 } 105} 106 107 108class AcousticPlugin(plugins.BeetsPlugin): 109 item_types = { 110 'average_loudness': types.Float(6), 111 'chords_changes_rate': types.Float(6), 112 'chords_key': types.STRING, 113 'chords_number_rate': types.Float(6), 114 'chords_scale': types.STRING, 115 'danceable': types.Float(6), 116 'gender': types.STRING, 117 'genre_rosamerica': types.STRING, 118 'initial_key': types.STRING, 119 'key_strength': types.Float(6), 120 'mood_acoustic': types.Float(6), 121 'mood_aggressive': types.Float(6), 122 'mood_electronic': types.Float(6), 123 'mood_happy': types.Float(6), 124 'mood_party': types.Float(6), 125 'mood_relaxed': types.Float(6), 126 'mood_sad': types.Float(6), 127 'rhythm': types.Float(6), 128 'tonal': types.Float(6), 129 'voice_instrumental': types.STRING, 130 } 131 132 def __init__(self): 133 super(AcousticPlugin, self).__init__() 134 135 self.config.add({ 136 'auto': True, 137 'force': False, 138 'tags': [] 139 }) 140 141 if self.config['auto']: 142 self.register_listener('import_task_files', 143 self.import_task_files) 144 145 def commands(self): 146 cmd = ui.Subcommand('acousticbrainz', 147 help=u"fetch metadata from AcousticBrainz") 148 cmd.parser.add_option( 149 u'-f', u'--force', dest='force_refetch', 150 action='store_true', default=False, 151 help=u're-download data when already present' 152 ) 153 154 def func(lib, opts, args): 155 items = lib.items(ui.decargs(args)) 156 self._fetch_info(items, ui.should_write(), 157 opts.force_refetch or self.config['force']) 158 159 cmd.func = func 160 return [cmd] 161 162 def import_task_files(self, session, task): 163 """Function is called upon beet import. 164 """ 165 self._fetch_info(task.imported_items(), False, True) 166 167 def _get_data(self, mbid): 168 data = {} 169 for url in _generate_urls(mbid): 170 self._log.debug(u'fetching URL: {}', url) 171 172 try: 173 res = requests.get(url) 174 except requests.RequestException as exc: 175 self._log.info(u'request error: {}', exc) 176 return {} 177 178 if res.status_code == 404: 179 self._log.info(u'recording ID {} not found', mbid) 180 return {} 181 182 try: 183 data.update(res.json()) 184 except ValueError: 185 self._log.debug(u'Invalid Response: {}', res.text) 186 return {} 187 188 return data 189 190 def _fetch_info(self, items, write, force): 191 """Fetch additional information from AcousticBrainz for the `item`s. 192 """ 193 tags = self.config['tags'].as_str_seq() 194 for item in items: 195 # If we're not forcing re-downloading for all tracks, check 196 # whether the data is already present. We use one 197 # representative field name to check for previously fetched 198 # data. 199 if not force: 200 mood_str = item.get('mood_acoustic', u'') 201 if mood_str: 202 self._log.info(u'data already present for: {}', item) 203 continue 204 205 # We can only fetch data for tracks with MBIDs. 206 if not item.mb_trackid: 207 continue 208 209 self._log.info(u'getting data for: {}', item) 210 data = self._get_data(item.mb_trackid) 211 if data: 212 for attr, val in self._map_data_to_scheme(data, ABSCHEME): 213 if not tags or attr in tags: 214 self._log.debug(u'attribute {} of {} set to {}', 215 attr, 216 item, 217 val) 218 setattr(item, attr, val) 219 else: 220 self._log.debug(u'skipping attribute {} of {}' 221 u' (value {}) due to config', 222 attr, 223 item, 224 val) 225 item.store() 226 if write: 227 item.try_write() 228 229 def _map_data_to_scheme(self, data, scheme): 230 """Given `data` as a structure of nested dictionaries, and `scheme` as a 231 structure of nested dictionaries , `yield` tuples `(attr, val)` where 232 `attr` and `val` are corresponding leaf nodes in `scheme` and `data`. 233 234 As its name indicates, `scheme` defines how the data is structured, 235 so this function tries to find leaf nodes in `data` that correspond 236 to the leafs nodes of `scheme`, and not the other way around. 237 Leaf nodes of `data` that do not exist in the `scheme` do not matter. 238 If a leaf node of `scheme` is not present in `data`, 239 no value is yielded for that attribute and a simple warning is issued. 240 241 Finally, to account for attributes of which the value is split between 242 several leaf nodes in `data`, leaf nodes of `scheme` can be tuples 243 `(attr, order)` where `attr` is the attribute to which the leaf node 244 belongs, and `order` is the place at which it should appear in the 245 value. The different `value`s belonging to the same `attr` are simply 246 joined with `' '`. This is hardcoded and not very flexible, but it gets 247 the job done. 248 249 For example: 250 251 >>> scheme = { 252 'key1': 'attribute', 253 'key group': { 254 'subkey1': 'subattribute', 255 'subkey2': ('composite attribute', 0) 256 }, 257 'key2': ('composite attribute', 1) 258 } 259 >>> data = { 260 'key1': 'value', 261 'key group': { 262 'subkey1': 'subvalue', 263 'subkey2': 'part 1 of composite attr' 264 }, 265 'key2': 'part 2' 266 } 267 >>> print(list(_map_data_to_scheme(data, scheme))) 268 [('subattribute', 'subvalue'), 269 ('attribute', 'value'), 270 ('composite attribute', 'part 1 of composite attr part 2')] 271 """ 272 # First, we traverse `scheme` and `data`, `yield`ing all the non 273 # composites attributes straight away and populating the dictionary 274 # `composites` with the composite attributes. 275 276 # When we are finished traversing `scheme`, `composites` should 277 # map each composite attribute to an ordered list of the values 278 # belonging to the attribute, for example: 279 # `composites = {'initial_key': ['B', 'minor']}`. 280 281 # The recursive traversal. 282 composites = defaultdict(list) 283 for attr, val in self._data_to_scheme_child(data, 284 scheme, 285 composites): 286 yield attr, val 287 288 # When composites has been populated, yield the composite attributes 289 # by joining their parts. 290 for composite_attr, value_parts in composites.items(): 291 yield composite_attr, ' '.join(value_parts) 292 293 def _data_to_scheme_child(self, subdata, subscheme, composites): 294 """The recursive business logic of :meth:`_map_data_to_scheme`: 295 Traverse two structures of nested dictionaries in parallel and `yield` 296 tuples of corresponding leaf nodes. 297 298 If a leaf node belongs to a composite attribute (is a `tuple`), 299 populate `composites` rather than yielding straight away. 300 All the child functions for a single traversal share the same 301 `composites` instance, which is passed along. 302 """ 303 for k, v in subscheme.items(): 304 if k in subdata: 305 if type(v) == dict: 306 for attr, val in self._data_to_scheme_child(subdata[k], 307 v, 308 composites): 309 yield attr, val 310 elif type(v) == tuple: 311 composite_attribute, part_number = v 312 attribute_parts = composites[composite_attribute] 313 # Parts are not guaranteed to be inserted in order 314 while len(attribute_parts) <= part_number: 315 attribute_parts.append('') 316 attribute_parts[part_number] = subdata[k] 317 else: 318 yield v, subdata[k] 319 else: 320 self._log.warning(u'Acousticbrainz did not provide info' 321 u'about {}', k) 322 self._log.debug(u'Data {} could not be mapped to scheme {} ' 323 u'because key {} was not found', subdata, v, k) 324 325 326def _generate_urls(mbid): 327 """Generates AcousticBrainz end point urls for given `mbid`. 328 """ 329 for level in LEVELS: 330 yield ACOUSTIC_BASE + mbid + level 331