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