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