1919import abc
2020import inspect
2121import re
22- import traceback
22+ import sys
2323from collections import defaultdict
2424from functools import wraps
25+ from pathlib import Path
2526from types import GenericAlias
26- from typing import TYPE_CHECKING , Any , Callable , Sequence , TypeVar
27+ from typing import TYPE_CHECKING , Any , ClassVar , Literal , TypeVar
2728
2829import mediafile
2930from typing_extensions import ParamSpec
3031
3132import beets
3233from beets import logging
34+ from beets .util import unique_list
3335
3436if TYPE_CHECKING :
35- from beets .event_types import EventType
36-
37-
38- if TYPE_CHECKING :
39- from collections .abc import Iterable
37+ from collections .abc import Callable , Iterable , Sequence
4038
4139 from confuse import ConfigView
4240
5856
5957 P = ParamSpec ("P" )
6058 Ret = TypeVar ("Ret" , bound = Any )
61- Listener = Callable [..., None ]
59+ Listener = Callable [..., Any ]
6260 IterF = Callable [P , Iterable [Ret ]]
6361
6462
6765# Plugins using the Last.fm API can share the same API key.
6866LASTFM_KEY = "2dc3914abf35f0d9c92d97d8f8e42b43"
6967
68+ EventType = Literal [
69+ "after_write" ,
70+ "album_imported" ,
71+ "album_removed" ,
72+ "albuminfo_received" ,
73+ "before_choose_candidate" ,
74+ "before_item_moved" ,
75+ "cli_exit" ,
76+ "database_change" ,
77+ "import" ,
78+ "import_begin" ,
79+ "import_task_apply" ,
80+ "import_task_before_choice" ,
81+ "import_task_choice" ,
82+ "import_task_created" ,
83+ "import_task_files" ,
84+ "import_task_start" ,
85+ "item_copied" ,
86+ "item_hardlinked" ,
87+ "item_imported" ,
88+ "item_linked" ,
89+ "item_moved" ,
90+ "item_reflinked" ,
91+ "item_removed" ,
92+ "library_opened" ,
93+ "mb_album_extract" ,
94+ "mb_track_extract" ,
95+ "pluginload" ,
96+ "trackinfo_received" ,
97+ "write" ,
98+ ]
7099# Global logger.
71100log = logging .getLogger ("beets" )
72101
@@ -79,6 +108,17 @@ class PluginConflictError(Exception):
79108 """
80109
81110
111+ class PluginImportError (ImportError ):
112+ """Indicates that a plugin could not be imported.
113+
114+ This is a subclass of ImportError so that it can be caught separately
115+ from other errors.
116+ """
117+
118+ def __init__ (self , name : str ):
119+ super ().__init__ (f"Could not import plugin { name } " )
120+
121+
82122class PluginLogFilter (logging .Filter ):
83123 """A logging filter that identifies the plugin that emitted a log
84124 message.
@@ -105,6 +145,14 @@ class BeetsPlugin(metaclass=abc.ABCMeta):
105145 the abstract methods defined here.
106146 """
107147
148+ _raw_listeners : ClassVar [dict [EventType , list [Listener ]]] = defaultdict (
149+ list
150+ )
151+ listeners : ClassVar [dict [EventType , list [Listener ]]] = defaultdict (list )
152+ template_funcs : TFuncMap [str ] | None = None
153+ template_fields : TFuncMap [Item ] | None = None
154+ album_template_fields : TFuncMap [Album ] | None = None
155+
108156 name : str
109157 config : ConfigView
110158 early_import_stages : list [ImportStageFunc ]
@@ -218,25 +266,13 @@ def add_media_field(
218266 mediafile .MediaFile .add_field (name , descriptor )
219267 library .Item ._media_fields .add (name )
220268
221- _raw_listeners : dict [str , list [Listener ]] | None = None
222- listeners : dict [str , list [Listener ]] | None = None
223-
224- def register_listener (self , event : "EventType" , func : Listener ):
269+ def register_listener (self , event : EventType , func : Listener ) -> None :
225270 """Add a function as a listener for the specified event."""
226- wrapped_func = self ._set_log_level_and_params (logging .WARNING , func )
227-
228- cls = self .__class__
229-
230- if cls .listeners is None or cls ._raw_listeners is None :
231- cls ._raw_listeners = defaultdict (list )
232- cls .listeners = defaultdict (list )
233- if func not in cls ._raw_listeners [event ]:
234- cls ._raw_listeners [event ].append (func )
235- cls .listeners [event ].append (wrapped_func )
236-
237- template_funcs : TFuncMap [str ] | None = None
238- template_fields : TFuncMap [Item ] | None = None
239- album_template_fields : TFuncMap [Album ] | None = None
271+ if func not in self ._raw_listeners [event ]:
272+ self ._raw_listeners [event ].append (func )
273+ self .listeners [event ].append (
274+ self ._set_log_level_and_params (logging .WARNING , func )
275+ )
240276
241277 @classmethod
242278 def template_func (cls , name : str ) -> Callable [[TFunc [str ]], TFunc [str ]]:
@@ -270,69 +306,92 @@ def helper(func: TFunc[Item]) -> TFunc[Item]:
270306 return helper
271307
272308
273- _classes : set [type [BeetsPlugin ]] = set ()
309+ def get_plugin_names () -> list [str ]:
310+ """Discover and return the set of plugin names to be loaded.
274311
275-
276- def load_plugins (names : Sequence [str ] = ()) -> None :
277- """Imports the modules for a sequence of plugin names. Each name
278- must be the name of a Python module under the "beetsplug" namespace
279- package in sys.path; the module indicated should contain the
280- BeetsPlugin subclasses desired.
312+ Configures the plugin search paths and resolves the final set of plugins
313+ based on configuration settings, inclusion filters, and exclusion rules.
314+ Automatically includes the musicbrainz plugin when enabled in configuration.
315+ """
316+ paths = [
317+ str (Path (p ).expanduser ().absolute ())
318+ for p in beets .config ["pluginpath" ].as_str_seq (split = False )
319+ ]
320+ log .debug ("plugin paths: {}" , paths )
321+
322+ # Extend the `beetsplug` package to include the plugin paths.
323+ import beetsplug
324+
325+ beetsplug .__path__ = paths + list (beetsplug .__path__ )
326+
327+ # For backwards compatibility, also support plugin paths that
328+ # *contain* a `beetsplug` package.
329+ sys .path += paths
330+ plugins = unique_list (beets .config ["plugins" ].as_str_seq ())
331+ # TODO: Remove in v3.0.0
332+ if (
333+ "musicbrainz" not in plugins
334+ and "musicbrainz" in beets .config
335+ and beets .config ["musicbrainz" ].get ().get ("enabled" )
336+ ):
337+ plugins .append ("musicbrainz" )
338+
339+ beets .config .add ({"disabled_plugins" : []})
340+ disabled_plugins = set (beets .config ["disabled_plugins" ].as_str_seq ())
341+ return [p for p in plugins if p not in disabled_plugins ]
342+
343+
344+ def _get_plugin (name : str ) -> BeetsPlugin | None :
345+ """Dynamically load and instantiate a plugin class by name.
346+
347+ Attempts to import the plugin module, locate the appropriate plugin class
348+ within it, and return an instance. Handles import failures gracefully and
349+ logs warnings for missing plugins or loading errors.
281350 """
282- for name in names :
283- modname = f"{ PLUGIN_NAMESPACE } .{ name } "
351+ try :
284352 try :
285- try :
286- namespace = __import__ (modname , None , None )
287- except ImportError as exc :
288- # Again, this is hacky:
289- if exc .args [0 ].endswith (" " + name ):
290- log .warning ("** plugin {0} not found" , name )
291- else :
292- raise
293- else :
294- for obj in getattr (namespace , name ).__dict__ .values ():
295- if (
296- inspect .isclass (obj )
297- and not isinstance (
298- obj , GenericAlias
299- ) # seems to be needed for python <= 3.9 only
300- and issubclass (obj , BeetsPlugin )
301- and obj != BeetsPlugin
302- and not inspect .isabstract (obj )
303- and obj not in _classes
304- ):
305- _classes .add (obj )
306-
307- except Exception :
308- log .warning (
309- "** error loading plugin {}:\n {}" ,
310- name ,
311- traceback .format_exc (),
312- )
353+ namespace = __import__ (f"{ PLUGIN_NAMESPACE } .{ name } " , None , None )
354+ except Exception as exc :
355+ raise PluginImportError (name ) from exc
356+
357+ for obj in getattr (namespace , name ).__dict__ .values ():
358+ if (
359+ inspect .isclass (obj )
360+ and not isinstance (
361+ obj , GenericAlias
362+ ) # seems to be needed for python <= 3.9 only
363+ and issubclass (obj , BeetsPlugin )
364+ and obj != BeetsPlugin
365+ and not inspect .isabstract (obj )
366+ ):
367+ return obj ()
368+
369+ except Exception :
370+ log .warning ("** error loading plugin {}" , name , exc_info = True )
313371
372+ return None
314373
315- _instances : dict [type [BeetsPlugin ], BeetsPlugin ] = {}
316374
375+ _instances : list [BeetsPlugin ] = []
317376
318- def find_plugins () -> list [BeetsPlugin ]:
319- """Returns a list of BeetsPlugin subclass instances from all
320- currently loaded beets plugins. Loads the default plugin set
321- first.
377+
378+ def load_plugins () -> None :
379+ """Initialize the plugin system by loading all configured plugins.
380+
381+ Performs one-time plugin discovery and instantiation, storing loaded plugin
382+ instances globally. Emits a pluginload event after successful initialization
383+ to notify other components.
322384 """
323- if _instances :
324- # After the first call, use cached instances for performance reasons.
325- # See https://github.com/beetbox/beets/pull/3810
326- return list (_instances .values ())
385+ if not _instances :
386+ names = get_plugin_names ()
387+ log .info ("Loading plugins: {}" , ", " .join (sorted (names )))
388+ _instances .extend (filter (None , map (_get_plugin , names )))
389+
390+ send ("pluginload" )
391+
327392
328- load_plugins ()
329- plugins = []
330- for cls in _classes :
331- # Only instantiate each plugin class once.
332- if cls not in _instances :
333- _instances [cls ] = cls ()
334- plugins .append (_instances [cls ])
335- return plugins
393+ def find_plugins () -> Iterable [BeetsPlugin ]:
394+ return _instances
336395
337396
338397# Communication with plugins.
@@ -383,7 +442,9 @@ def named_queries(model_cls: type[AnyModel]) -> dict[str, FieldQueryType]:
383442 }
384443
385444
386- def notify_info_yielded (event : str ) -> Callable [[IterF [P , Ret ]], IterF [P , Ret ]]:
445+ def notify_info_yielded (
446+ event : EventType ,
447+ ) -> Callable [[IterF [P , Ret ]], IterF [P , Ret ]]:
387448 """Makes a generator send the event 'event' every time it yields.
388449 This decorator is supposed to decorate a generator, but any function
389450 returning an iterable should work.
@@ -474,19 +535,7 @@ def album_field_getters() -> TFuncMap[Album]:
474535# Event dispatch.
475536
476537
477- def event_handlers () -> dict [str , list [Listener ]]:
478- """Find all event handlers from plugins as a dictionary mapping
479- event names to sequences of callables.
480- """
481- all_handlers : dict [str , list [Listener ]] = defaultdict (list )
482- for plugin in find_plugins ():
483- if plugin .listeners :
484- for event , handlers in plugin .listeners .items ():
485- all_handlers [event ] += handlers
486- return all_handlers
487-
488-
489- def send (event : str , ** arguments : Any ) -> list [Any ]:
538+ def send (event : EventType , ** arguments : Any ) -> list [Any ]:
490539 """Send an event to all assigned event listeners.
491540
492541 `event` is the name of the event to send, all other named arguments
@@ -495,12 +544,11 @@ def send(event: str, **arguments: Any) -> list[Any]:
495544 Return a list of non-None values returned from the handlers.
496545 """
497546 log .debug ("Sending event: {0}" , event )
498- results : list [Any ] = []
499- for handler in event_handlers ()[event ]:
500- result = handler (** arguments )
501- if result is not None :
502- results .append (result )
503- return results
547+ return [
548+ r
549+ for handler in BeetsPlugin .listeners [event ]
550+ if (r := handler (** arguments )) is not None
551+ ]
504552
505553
506554def feat_tokens (for_artist : bool = True ) -> str :
0 commit comments