1# -*- coding: utf-8 -*-
2# This file is part of beets.
3# Copyright 2016, Adrian Sampson.
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"""Support for beets plugins."""
17
18from __future__ import division, absolute_import, print_function
19
20import traceback
21import re
22import inspect
23from collections import defaultdict
24from functools import wraps
25
26
27import beets
28from beets import logging
29from beets import mediafile
30import six
31
32PLUGIN_NAMESPACE = 'beetsplug'
33
34# Plugins using the Last.fm API can share the same API key.
35LASTFM_KEY = '2dc3914abf35f0d9c92d97d8f8e42b43'
36
37# Global logger.
38log = logging.getLogger('beets')
39
40
41class PluginConflictException(Exception):
42    """Indicates that the services provided by one plugin conflict with
43    those of another.
44
45    For example two plugins may define different types for flexible fields.
46    """
47
48
49class PluginLogFilter(logging.Filter):
50    """A logging filter that identifies the plugin that emitted a log
51    message.
52    """
53    def __init__(self, plugin):
54        self.prefix = u'{0}: '.format(plugin.name)
55
56    def filter(self, record):
57        if hasattr(record.msg, 'msg') and isinstance(record.msg.msg,
58                                                     six.string_types):
59            # A _LogMessage from our hacked-up Logging replacement.
60            record.msg.msg = self.prefix + record.msg.msg
61        elif isinstance(record.msg, six.string_types):
62            record.msg = self.prefix + record.msg
63        return True
64
65
66# Managing the plugins themselves.
67
68class BeetsPlugin(object):
69    """The base class for all beets plugins. Plugins provide
70    functionality by defining a subclass of BeetsPlugin and overriding
71    the abstract methods defined here.
72    """
73    def __init__(self, name=None):
74        """Perform one-time plugin setup.
75        """
76        self.name = name or self.__module__.split('.')[-1]
77        self.config = beets.config[self.name]
78        if not self.template_funcs:
79            self.template_funcs = {}
80        if not self.template_fields:
81            self.template_fields = {}
82        if not self.album_template_fields:
83            self.album_template_fields = {}
84        self.early_import_stages = []
85        self.import_stages = []
86
87        self._log = log.getChild(self.name)
88        self._log.setLevel(logging.NOTSET)  # Use `beets` logger level.
89        if not any(isinstance(f, PluginLogFilter) for f in self._log.filters):
90            self._log.addFilter(PluginLogFilter(self))
91
92    def commands(self):
93        """Should return a list of beets.ui.Subcommand objects for
94        commands that should be added to beets' CLI.
95        """
96        return ()
97
98    def _set_stage_log_level(self, stages):
99        """Adjust all the stages in `stages` to WARNING logging level.
100        """
101        return [self._set_log_level_and_params(logging.WARNING, stage)
102                for stage in stages]
103
104    def get_early_import_stages(self):
105        """Return a list of functions that should be called as importer
106        pipelines stages early in the pipeline.
107
108        The callables are wrapped versions of the functions in
109        `self.early_import_stages`. Wrapping provides some bookkeeping for the
110        plugin: specifically, the logging level is adjusted to WARNING.
111        """
112        return self._set_stage_log_level(self.early_import_stages)
113
114    def get_import_stages(self):
115        """Return a list of functions that should be called as importer
116        pipelines stages.
117
118        The callables are wrapped versions of the functions in
119        `self.import_stages`. Wrapping provides some bookkeeping for the
120        plugin: specifically, the logging level is adjusted to WARNING.
121        """
122        return self._set_stage_log_level(self.import_stages)
123
124    def _set_log_level_and_params(self, base_log_level, func):
125        """Wrap `func` to temporarily set this plugin's logger level to
126        `base_log_level` + config options (and restore it to its previous
127        value after the function returns). Also determines which params may not
128        be sent for backwards-compatibility.
129        """
130        if six.PY2:
131            func_args = inspect.getargspec(func).args
132        else:
133            func_args = inspect.getfullargspec(func).args
134
135        @wraps(func)
136        def wrapper(*args, **kwargs):
137            assert self._log.level == logging.NOTSET
138            verbosity = beets.config['verbose'].get(int)
139            log_level = max(logging.DEBUG, base_log_level - 10 * verbosity)
140            self._log.setLevel(log_level)
141            try:
142                try:
143                    return func(*args, **kwargs)
144                except TypeError as exc:
145                    if exc.args[0].startswith(func.__name__):
146                        # caused by 'func' and not stuff internal to 'func'
147                        kwargs = dict((arg, val) for arg, val in kwargs.items()
148                                      if arg in func_args)
149                        return func(*args, **kwargs)
150                    else:
151                        raise
152            finally:
153                self._log.setLevel(logging.NOTSET)
154        return wrapper
155
156    def queries(self):
157        """Should return a dict mapping prefixes to Query subclasses.
158        """
159        return {}
160
161    def track_distance(self, item, info):
162        """Should return a Distance object to be added to the
163        distance for every track comparison.
164        """
165        return beets.autotag.hooks.Distance()
166
167    def album_distance(self, items, album_info, mapping):
168        """Should return a Distance object to be added to the
169        distance for every album-level comparison.
170        """
171        return beets.autotag.hooks.Distance()
172
173    def candidates(self, items, artist, album, va_likely):
174        """Should return a sequence of AlbumInfo objects that match the
175        album whose items are provided.
176        """
177        return ()
178
179    def item_candidates(self, item, artist, title):
180        """Should return a sequence of TrackInfo objects that match the
181        item provided.
182        """
183        return ()
184
185    def album_for_id(self, album_id):
186        """Return an AlbumInfo object or None if no matching release was
187        found.
188        """
189        return None
190
191    def track_for_id(self, track_id):
192        """Return a TrackInfo object or None if no matching release was
193        found.
194        """
195        return None
196
197    def add_media_field(self, name, descriptor):
198        """Add a field that is synchronized between media files and items.
199
200        When a media field is added ``item.write()`` will set the name
201        property of the item's MediaFile to ``item[name]`` and save the
202        changes. Similarly ``item.read()`` will set ``item[name]`` to
203        the value of the name property of the media file.
204
205        ``descriptor`` must be an instance of ``mediafile.MediaField``.
206        """
207        # Defer impor to prevent circular dependency
208        from beets import library
209        mediafile.MediaFile.add_field(name, descriptor)
210        library.Item._media_fields.add(name)
211
212    _raw_listeners = None
213    listeners = None
214
215    def register_listener(self, event, func):
216        """Add a function as a listener for the specified event.
217        """
218        wrapped_func = self._set_log_level_and_params(logging.WARNING, func)
219
220        cls = self.__class__
221        if cls.listeners is None or cls._raw_listeners is None:
222            cls._raw_listeners = defaultdict(list)
223            cls.listeners = defaultdict(list)
224        if func not in cls._raw_listeners[event]:
225            cls._raw_listeners[event].append(func)
226            cls.listeners[event].append(wrapped_func)
227
228    template_funcs = None
229    template_fields = None
230    album_template_fields = None
231
232    @classmethod
233    def template_func(cls, name):
234        """Decorator that registers a path template function. The
235        function will be invoked as ``%name{}`` from path format
236        strings.
237        """
238        def helper(func):
239            if cls.template_funcs is None:
240                cls.template_funcs = {}
241            cls.template_funcs[name] = func
242            return func
243        return helper
244
245    @classmethod
246    def template_field(cls, name):
247        """Decorator that registers a path template field computation.
248        The value will be referenced as ``$name`` from path format
249        strings. The function must accept a single parameter, the Item
250        being formatted.
251        """
252        def helper(func):
253            if cls.template_fields is None:
254                cls.template_fields = {}
255            cls.template_fields[name] = func
256            return func
257        return helper
258
259
260_classes = set()
261
262
263def load_plugins(names=()):
264    """Imports the modules for a sequence of plugin names. Each name
265    must be the name of a Python module under the "beetsplug" namespace
266    package in sys.path; the module indicated should contain the
267    BeetsPlugin subclasses desired.
268    """
269    for name in names:
270        modname = '{0}.{1}'.format(PLUGIN_NAMESPACE, name)
271        try:
272            try:
273                namespace = __import__(modname, None, None)
274            except ImportError as exc:
275                # Again, this is hacky:
276                if exc.args[0].endswith(' ' + name):
277                    log.warning(u'** plugin {0} not found', name)
278                else:
279                    raise
280            else:
281                for obj in getattr(namespace, name).__dict__.values():
282                    if isinstance(obj, type) and issubclass(obj, BeetsPlugin) \
283                            and obj != BeetsPlugin and obj not in _classes:
284                        _classes.add(obj)
285
286        except Exception:
287            log.warning(
288                u'** error loading plugin {}:\n{}',
289                name,
290                traceback.format_exc(),
291            )
292
293
294_instances = {}
295
296
297def find_plugins():
298    """Returns a list of BeetsPlugin subclass instances from all
299    currently loaded beets plugins. Loads the default plugin set
300    first.
301    """
302    load_plugins()
303    plugins = []
304    for cls in _classes:
305        # Only instantiate each plugin class once.
306        if cls not in _instances:
307            _instances[cls] = cls()
308        plugins.append(_instances[cls])
309    return plugins
310
311
312# Communication with plugins.
313
314def commands():
315    """Returns a list of Subcommand objects from all loaded plugins.
316    """
317    out = []
318    for plugin in find_plugins():
319        out += plugin.commands()
320    return out
321
322
323def queries():
324    """Returns a dict mapping prefix strings to Query subclasses all loaded
325    plugins.
326    """
327    out = {}
328    for plugin in find_plugins():
329        out.update(plugin.queries())
330    return out
331
332
333def types(model_cls):
334    # Gives us `item_types` and `album_types`
335    attr_name = '{0}_types'.format(model_cls.__name__.lower())
336    types = {}
337    for plugin in find_plugins():
338        plugin_types = getattr(plugin, attr_name, {})
339        for field in plugin_types:
340            if field in types and plugin_types[field] != types[field]:
341                raise PluginConflictException(
342                    u'Plugin {0} defines flexible field {1} '
343                    u'which has already been defined with '
344                    u'another type.'.format(plugin.name, field)
345                )
346        types.update(plugin_types)
347    return types
348
349
350def named_queries(model_cls):
351    # Gather `item_queries` and `album_queries` from the plugins.
352    attr_name = '{0}_queries'.format(model_cls.__name__.lower())
353    queries = {}
354    for plugin in find_plugins():
355        plugin_queries = getattr(plugin, attr_name, {})
356        queries.update(plugin_queries)
357    return queries
358
359
360def track_distance(item, info):
361    """Gets the track distance calculated by all loaded plugins.
362    Returns a Distance object.
363    """
364    from beets.autotag.hooks import Distance
365    dist = Distance()
366    for plugin in find_plugins():
367        dist.update(plugin.track_distance(item, info))
368    return dist
369
370
371def album_distance(items, album_info, mapping):
372    """Returns the album distance calculated by plugins."""
373    from beets.autotag.hooks import Distance
374    dist = Distance()
375    for plugin in find_plugins():
376        dist.update(plugin.album_distance(items, album_info, mapping))
377    return dist
378
379
380def candidates(items, artist, album, va_likely):
381    """Gets MusicBrainz candidates for an album from each plugin.
382    """
383    for plugin in find_plugins():
384        for candidate in plugin.candidates(items, artist, album, va_likely):
385            yield candidate
386
387
388def item_candidates(item, artist, title):
389    """Gets MusicBrainz candidates for an item from the plugins.
390    """
391    for plugin in find_plugins():
392        for item_candidate in plugin.item_candidates(item, artist, title):
393            yield item_candidate
394
395
396def album_for_id(album_id):
397    """Get AlbumInfo objects for a given ID string.
398    """
399    for plugin in find_plugins():
400        album = plugin.album_for_id(album_id)
401        if album:
402            yield album
403
404
405def track_for_id(track_id):
406    """Get TrackInfo objects for a given ID string.
407    """
408    for plugin in find_plugins():
409        track = plugin.track_for_id(track_id)
410        if track:
411            yield track
412
413
414def template_funcs():
415    """Get all the template functions declared by plugins as a
416    dictionary.
417    """
418    funcs = {}
419    for plugin in find_plugins():
420        if plugin.template_funcs:
421            funcs.update(plugin.template_funcs)
422    return funcs
423
424
425def early_import_stages():
426    """Get a list of early import stage functions defined by plugins."""
427    stages = []
428    for plugin in find_plugins():
429        stages += plugin.get_early_import_stages()
430    return stages
431
432
433def import_stages():
434    """Get a list of import stage functions defined by plugins."""
435    stages = []
436    for plugin in find_plugins():
437        stages += plugin.get_import_stages()
438    return stages
439
440
441# New-style (lazy) plugin-provided fields.
442
443def item_field_getters():
444    """Get a dictionary mapping field names to unary functions that
445    compute the field's value.
446    """
447    funcs = {}
448    for plugin in find_plugins():
449        if plugin.template_fields:
450            funcs.update(plugin.template_fields)
451    return funcs
452
453
454def album_field_getters():
455    """As above, for album fields.
456    """
457    funcs = {}
458    for plugin in find_plugins():
459        if plugin.album_template_fields:
460            funcs.update(plugin.album_template_fields)
461    return funcs
462
463
464# Event dispatch.
465
466def event_handlers():
467    """Find all event handlers from plugins as a dictionary mapping
468    event names to sequences of callables.
469    """
470    all_handlers = defaultdict(list)
471    for plugin in find_plugins():
472        if plugin.listeners:
473            for event, handlers in plugin.listeners.items():
474                all_handlers[event] += handlers
475    return all_handlers
476
477
478def send(event, **arguments):
479    """Send an event to all assigned event listeners.
480
481    `event` is the name of  the event to send, all other named arguments
482    are passed along to the handlers.
483
484    Return a list of non-None values returned from the handlers.
485    """
486    log.debug(u'Sending event: {0}', event)
487    results = []
488    for handler in event_handlers()[event]:
489        result = handler(**arguments)
490        if result is not None:
491            results.append(result)
492    return results
493
494
495def feat_tokens(for_artist=True):
496    """Return a regular expression that matches phrases like "featuring"
497    that separate a main artist or a song title from secondary artists.
498    The `for_artist` option determines whether the regex should be
499    suitable for matching artist fields (the default) or title fields.
500    """
501    feat_words = ['ft', 'featuring', 'feat', 'feat.', 'ft.']
502    if for_artist:
503        feat_words += ['with', 'vs', 'and', 'con', '&']
504    return r'(?<=\s)(?:{0})(?=\s)'.format(
505        '|'.join(re.escape(x) for x in feat_words)
506    )
507
508
509def sanitize_choices(choices, choices_all):
510    """Clean up a stringlist configuration attribute: keep only choices
511    elements present in choices_all, remove duplicate elements, expand '*'
512    wildcard while keeping original stringlist order.
513    """
514    seen = set()
515    others = [x for x in choices_all if x not in choices]
516    res = []
517    for s in choices:
518        if s not in seen:
519            if s in list(choices_all):
520                res.append(s)
521            elif s == '*':
522                res.extend(others)
523        seen.add(s)
524    return res
525
526
527def sanitize_pairs(pairs, pairs_all):
528    """Clean up a single-element mapping configuration attribute as returned
529    by `confit`'s `Pairs` template: keep only two-element tuples present in
530    pairs_all, remove duplicate elements, expand ('str', '*') and ('*', '*')
531    wildcards while keeping the original order. Note that ('*', '*') and
532    ('*', 'whatever') have the same effect.
533
534    For example,
535
536    >>> sanitize_pairs(
537    ...     [('foo', 'baz bar'), ('key', '*'), ('*', '*')],
538    ...     [('foo', 'bar'), ('foo', 'baz'), ('foo', 'foobar'),
539    ...      ('key', 'value')]
540    ...     )
541    [('foo', 'baz'), ('foo', 'bar'), ('key', 'value'), ('foo', 'foobar')]
542    """
543    pairs_all = list(pairs_all)
544    seen = set()
545    others = [x for x in pairs_all if x not in pairs]
546    res = []
547    for k, values in pairs:
548        for v in values.split():
549            x = (k, v)
550            if x in pairs_all:
551                if x not in seen:
552                    seen.add(x)
553                    res.append(x)
554            elif k == '*':
555                new = [o for o in others if o not in seen]
556                seen.update(new)
557                res.extend(new)
558            elif v == '*':
559                new = [o for o in others if o not in seen and o[0] == k]
560                seen.update(new)
561                res.extend(new)
562    return res
563
564
565def notify_info_yielded(event):
566    """Makes a generator send the event 'event' every time it yields.
567    This decorator is supposed to decorate a generator, but any function
568    returning an iterable should work.
569    Each yielded value is passed to plugins using the 'info' parameter of
570    'send'.
571    """
572    def decorator(generator):
573        def decorated(*args, **kwargs):
574            for v in generator(*args, **kwargs):
575                send(event, info=v)
576                yield v
577        return decorated
578    return decorator
579